diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index cf3ec82..ed1ed0d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -17,9 +17,45 @@ on: jobs: + build_agents: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v5 + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'temurin' + + - name: Install Android NDK and build tools + run: | + sdkmanager --install \ + "ndk;29.0.13113456" \ + "build-tools;36.0.0" \ + "platforms;android-35" + env: + ANDROID_HOME: /usr/local/lib/android/sdk + + - name: Build agents + run: make -C agents/android all + env: + ANDROID_HOME: /usr/local/lib/android/sdk + + - name: Upload agent binaries + uses: actions/upload-artifact@v5 + with: + name: android-agents + path: | + agents/android/devicekit.so + agents/android/devicekit.dex + retention-days: 1 + test: runs-on: ubuntu-latest timeout-minutes: 10 + needs: [build_agents] steps: - uses: actions/checkout@v5 @@ -28,9 +64,14 @@ jobs: with: go-version-file: go.mod + - name: Download agent binaries + uses: actions/download-artifact@v5 + with: + name: android-agents + path: agents/android/ + - name: Run Unit Tests - run: | - make build test + run: make build test lint: runs-on: ubuntu-latest @@ -80,7 +121,7 @@ jobs: timeout-minutes: 10 permissions: contents: read - needs: [test, lint, security] + needs: [build_agents, test, lint, security] steps: - uses: actions/checkout@v5 @@ -89,6 +130,12 @@ jobs: with: go-version-file: go.mod + - name: Download agent binaries + uses: actions/download-artifact@v5 + with: + name: android-agents + path: agents/android/ + - name: Install dependencies run: | go install golang.org/x/tools/cmd/goimports@latest @@ -120,7 +167,7 @@ jobs: timeout-minutes: 10 permissions: contents: read - needs: [test, lint, security] + needs: [build_agents, test, lint, security] steps: - uses: actions/checkout@v5 @@ -129,6 +176,12 @@ jobs: with: go-version-file: go.mod + - name: Download agent binaries + uses: actions/download-artifact@v5 + with: + name: android-agents + path: agents/android/ + - name: Install dependencies run: | go install golang.org/x/tools/cmd/goimports@latest @@ -161,9 +214,16 @@ jobs: timeout-minutes: 10 permissions: contents: read - needs: [test, lint, security] + needs: [build_agents, test, lint, security] steps: - uses: actions/checkout@v5 + + - name: Download agent binaries + uses: actions/download-artifact@v5 + with: + name: android-agents + path: agents/android/ + - name: Set up Go uses: actions/setup-go@v5 with: diff --git a/.gitignore b/.gitignore index 521b68b..ecac11f 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,7 @@ screenshot.png **/coverage*.out **/coverage*.html test/coverage +agents/android/devicekit.so +agents/android/devicekit.dex +agents/ios/agent-sim.dylib +agents/ios/agent-dev.dylib diff --git a/Makefile b/Makefile index 1297207..0842135 100644 --- a/Makefile +++ b/Makefile @@ -1,12 +1,16 @@ -.PHONY: all build test test-cover lint fmt clean +.PHONY: all build agents test test-cover lint fmt clean all: build -build: +agents: + $(MAKE) -C agents/android all + $(MAKE) -C agents/ios all + +build: agents go mod tidy CGO_ENABLED=0 go build -ldflags="-s -w" -build-cover: +build-cover: agents go mod tidy CGO_ENABLED=0 go build -ldflags="-s -w" -cover @@ -31,4 +35,5 @@ fmt: $(shell go env GOPATH)/bin/goimports -w . clean: + $(MAKE) -C agents/android clean rm -f mobilecli coverage.out coverage.html diff --git a/README.md b/README.md index 89307e3..9235ad2 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ A universal command-line tool for managing iOS and Android devices, simulators, - **Device Control**: Reboot devices, tap screen coordinates, press hardware buttons - **App Management**: Launch, terminate, install, uninstall, list, and get foreground apps - **Crash Reports**: List and fetch crash reports from iOS and Android devices +- **Webview Inspection**: List, navigate, query DOM, and evaluate JavaScript in embedded webviews ### 🎯 Platform Support @@ -255,6 +256,66 @@ Example output for `agent status`: } ``` +### Webview Inspection 🌐 + +Inspect and interact with embedded webviews (`WKWebView` on iOS, `android.webkit.WebView` on Android) running inside native apps. + +```bash +# List embedded webviews in the foreground app +mobilecli webview list --device + +# Navigate a webview to a URL +mobilecli webview goto https://example.com --device + +# Reload, go back or forward +mobilecli webview reload --device +mobilecli webview back --device +mobilecli webview forward --device + +# Get current URL and page title +mobilecli webview url --device +mobilecli webview title --device + +# Dump the full HTML content of the page +mobilecli webview content --device + +# Query DOM elements by CSS selector +mobilecli webview query "button" --device +mobilecli webview query "[data-testid='submit']" --device + +# Evaluate arbitrary JavaScript +mobilecli webview eval "document.querySelectorAll('a').length" --device + +# Wait for the page to finish loading +mobilecli webview wait --state load --device +mobilecli webview wait --state domcontentloaded --timeout 5000 --device +``` + +Example output for `webview list`: +```json +{ + "status": "ok", + "data": [ + { + "id": "1", + "url": "https://example.com", + "title": "Example Domain" + } + ] +} +``` + +Example output for `webview query "button"`: +```json +{ + "status": "ok", + "data": [ + { "tag": "button", "text": "Sign In", "id": "login-btn", "class": "btn-primary", "value": null, "href": null }, + { "tag": "button", "text": "Cancel", "id": null, "class": "btn-secondary", "value": null, "href": null } + ] +} +``` + ### Crash Reports 💥 ```bash diff --git a/agents/agents.go b/agents/agents.go new file mode 100644 index 0000000..18dcc1f --- /dev/null +++ b/agents/agents.go @@ -0,0 +1,13 @@ +package agents + +import _ "embed" + +//go:embed android/devicekit.so +var AndroidDevicekitSO []byte + +//go:embed android/devicekit.dex +var AndroidDevicekitDEX []byte + +//go:embed ios/agent-sim.dylib +var IOSAgentSimDylib []byte + diff --git a/agents/android/Makefile b/agents/android/Makefile new file mode 100644 index 0000000..fca6956 --- /dev/null +++ b/agents/android/Makefile @@ -0,0 +1,57 @@ +NDK_VERSION ?= 29.0.13113456 +BUILD_TOOLS_VERSION ?= 36.0.0 + +# macOS default; override with ANDROID_HOME env var (e.g. on Linux CI) +ANDROID_HOME ?= $(HOME)/Library/Android/sdk + +# Detect host OS for NDK toolchain path and JNI header subdir +UNAME_S := $(shell uname -s) +ifeq ($(UNAME_S),Linux) + PREBUILT := linux-x86_64 + JDK_PLATFORM := linux +else + PREBUILT := darwin-x86_64 + JDK_PLATFORM := darwin +endif + +NDK := $(ANDROID_HOME)/ndk/$(NDK_VERSION) +TOOLCHAIN := $(NDK)/toolchains/llvm/prebuilt/$(PREBUILT)/bin +SYSROOT := $(NDK)/toolchains/llvm/prebuilt/$(PREBUILT)/sysroot/usr/include + +CC_ANDROID := $(TOOLCHAIN)/aarch64-linux-android26-clang +D8 := $(ANDROID_HOME)/build-tools/$(BUILD_TOOLS_VERSION)/d8 +ANDROID_JAR := $(ANDROID_HOME)/platforms/android-35/android.jar + +# Java: prefer JAVA_HOME, then macOS java_home helper, then system javac +ifndef JAVA_HOME + JAVA_HOME := $(shell /usr/libexec/java_home 2>/dev/null) +endif +ifeq ($(JAVA_HOME),) + JAVAC := $(shell which javac) + JDK_INC := $(shell dirname $$(dirname $$(which javac)))/include +else + JAVAC := $(JAVA_HOME)/bin/javac + JDK_INC := $(JAVA_HOME)/include +endif + +JAVA_SRCS := $(wildcard java/*.java) + +.PHONY: all clean + +all: devicekit.so devicekit.dex + +devicekit.so: jvmti_agent.c + $(CC_ANDROID) -shared -fPIC -O2 \ + -I$(SYSROOT) -I$(JDK_INC) -I$(JDK_INC)/$(JDK_PLATFORM) \ + -o $@ $< -llog + +devicekit.dex: $(JAVA_SRCS) + mkdir -p .dex_build + $(JAVAC) --release 8 -cp $(ANDROID_JAR) -d .dex_build $(JAVA_SRCS) + $(D8) --min-api 26 --output .dex_build $$(find .dex_build -name "*.class") + mv .dex_build/classes.dex devicekit.dex + rm -rf .dex_build + +clean: + rm -f devicekit.so devicekit.dex + rm -rf .dex_build diff --git a/agents/android/java/AndroidBridge.java b/agents/android/java/AndroidBridge.java new file mode 100644 index 0000000..8318564 --- /dev/null +++ b/agents/android/java/AndroidBridge.java @@ -0,0 +1,107 @@ +package com.mobilenext.mobilecli; + +import android.os.Handler; +import android.os.Looper; +import android.view.View; +import android.view.ViewGroup; +import android.webkit.WebView; + +import java.lang.reflect.Field; +import java.util.List; +import java.util.concurrent.Callable; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.stream.IntStream; +import java.util.stream.Stream; + +class AndroidBridge { + + static Handler sMainHandler; + + private static String sPackageName; + private static Field sMViewsField; + private static Object sWmgInstance; + + static void init() { + sMainHandler = new Handler(Looper.getMainLooper()); + } + + @SuppressWarnings("unchecked") + static List getRootViews() throws Exception { + if (sMViewsField == null) { + Class wmgClass = Class.forName("android.view.WindowManagerGlobal"); + sMViewsField = wmgClass.getDeclaredField("mViews"); + sMViewsField.setAccessible(true); + sWmgInstance = wmgClass.getMethod("getInstance").invoke(null); + } + return (List) sMViewsField.get(sWmgInstance); + } + + static String getPackageName() { + if (sPackageName != null) { + return sPackageName; + } + try { + byte[] buf = new byte[256]; + java.io.FileInputStream fis = new java.io.FileInputStream("/proc/self/cmdline"); + int len = fis.read(buf); + fis.close(); + int end = 0; + while (end < len && buf[end] != 0 && buf[end] != ':') { + end++; + } + sPackageName = new String(buf, 0, end).trim(); + return sPackageName; + } catch (Exception e) { + return "unknown"; + } + } + + static Stream streamWebViews(View view) { + if (view instanceof WebView) { + return Stream.of((WebView) view); + } else if (view instanceof ViewGroup) { + ViewGroup vg = (ViewGroup) view; + return IntStream.range(0, vg.getChildCount()) + .mapToObj(vg::getChildAt) + .flatMap(AndroidBridge::streamWebViews); + } + return Stream.empty(); + } + + @SuppressWarnings("unchecked") + static T runOnMainThread(Callable task) throws Exception { + Object[] result = {null}; + Exception[] err = {null}; + CountDownLatch latch = new CountDownLatch(1); + sMainHandler.post(() -> { + try { + result[0] = task.call(); + } catch (Exception e) { + err[0] = e; + } finally { + latch.countDown(); + } + }); + if (!latch.await(5, TimeUnit.SECONDS)) { + throw new Exception("timed out"); + } + if (err[0] != null) { + throw err[0]; + } + return (T) result[0]; + } + + static String evalJs(WebView wv, String script) throws Exception { + String[] result = {null}; + CountDownLatch latch = new CountDownLatch(1); + sMainHandler.post(() -> wv.evaluateJavascript(script, value -> { + result[0] = value; + latch.countDown(); + })); + if (!latch.await(10, TimeUnit.SECONDS)) { + throw new Exception("evaluateJavascript timed out"); + } + return result[0]; + } +} diff --git a/agents/android/java/HttpRpcServer.java b/agents/android/java/HttpRpcServer.java new file mode 100644 index 0000000..483b008 --- /dev/null +++ b/agents/android/java/HttpRpcServer.java @@ -0,0 +1,67 @@ +package com.mobilenext.mobilecli; + +import android.net.LocalServerSocket; +import android.net.LocalSocket; + +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.io.PrintWriter; + +class HttpRpcServer { + + static void start() { + String name = "mobilecli." + AndroidBridge.getPackageName(); + new Thread(() -> { + try { + LocalServerSocket server = new LocalServerSocket(name); + android.util.Log.d("MobileCliAgent", "listening on localabstract:" + name); + while (true) { + handleClient(server.accept()); + } + } catch (Exception e) { + android.util.Log.e("MobileCliAgent", "server error: " + e.getMessage()); + } + }, "vta-server").start(); + } + + private static void handleClient(LocalSocket client) { + new Thread(() -> { + try ( + BufferedReader in = new BufferedReader(new InputStreamReader(client.getInputStream())); + PrintWriter out = new PrintWriter(client.getOutputStream(), false) + ) { + String requestLine = in.readLine(); + if (requestLine == null) { + return; + } + + int contentLength = 0; + String header; + while ((header = in.readLine()) != null && !header.isEmpty()) { + if (header.toLowerCase().startsWith("content-length:")) { + contentLength = Integer.parseInt(header.substring(15).trim()); + } + } + + char[] body = new char[contentLength]; + if (contentLength > 0) { + in.read(body, 0, contentLength); + } + String bodyStr = new String(body).trim(); + + String response = JsonRpcDispatcher.dispatch(bodyStr.isEmpty() ? "{}" : bodyStr); + + byte[] bytes = response.getBytes("UTF-8"); + out.print("HTTP/1.1 200 OK\r\n"); + out.print("Content-Type: application/json\r\n"); + out.print("Content-Length: " + bytes.length + "\r\n"); + out.print("Connection: close\r\n"); + out.print("\r\n"); + out.print(response); + out.flush(); + } catch (Exception e) { + android.util.Log.e("MobileCliAgent", "client error: " + e.getMessage()); + } + }, "vta-client").start(); + } +} diff --git a/agents/android/java/JsonRpcDispatcher.java b/agents/android/java/JsonRpcDispatcher.java new file mode 100644 index 0000000..7d8ba55 --- /dev/null +++ b/agents/android/java/JsonRpcDispatcher.java @@ -0,0 +1,104 @@ +package com.mobilenext.mobilecli; + +import android.webkit.WebView; + +import org.json.JSONArray; +import org.json.JSONObject; + +class JsonRpcDispatcher { + + static String requireParam(JSONObject params, String key) throws RpcException { + if (params == null) { + throw new RpcException(-32602, "missing params"); + } + String v = params.optString(key, null); + if (v == null || v.isEmpty()) { + throw new RpcException(-32602, "missing params." + key); + } + return v; + } + + static String dispatch(String json) { + String id = null; + try { + JSONObject req = new JSONObject(json); + id = req.optString("id", null); + String method = req.optString("method", ""); + JSONObject params = req.optJSONObject("params"); + + switch (method) { + + case "device.dump.ui": + return result(id, WebViewAgent.dumpUi()); + + case "device.webview.list": + return result(id, WebViewAgent.listWebViews()); + + case "device.webview.goto": { + String wvId = requireParam(params, "id"); + String url = requireParam(params, "url"); + AndroidBridge.runOnMainThread(() -> { + WebViewAgent.lookupWebView(wvId).loadUrl(url); + return null; + }); + return result(id, new JSONObject().put("status", "ok")); + } + + case "device.webview.reload": + case "device.webview.goBack": + case "device.webview.goForward": { + WebViewAgent.webViewAction(requireParam(params, "id"), WebViewAgent.navAction(method)); + return result(id, new JSONObject().put("status", "ok")); + } + + case "device.webview.waitForLoadState": { + String wvId = requireParam(params, "id"); + String state = params != null ? params.optString("state", "load") : "load"; + int timeout = params != null ? params.optInt("timeout", 30_000) : 30_000; + WebView wv = WebViewAgent.findWebViewById(wvId); + WebViewAgent.waitForLoadState(wv, state, timeout); + return result(id, new JSONObject().put("status", "ok")); + } + + case "device.webview.evaluate": { + String wvId = requireParam(params, "id"); + String expression = requireParam(params, "expression"); + JSONArray args = params.optJSONArray("args"); + return result(id, WebViewAgent.evaluateExpression(WebViewAgent.findWebViewById(wvId), expression, args)); + } + + default: + return error(id, -32601, "method not found: " + method); + } + } catch (RpcException e) { + return error(id, e.code, e.getMessage()); + } catch (Exception e) { + return error(id, -32000, e.getMessage()); + } + } + + private static String result(String id, Object value) { + try { + return new JSONObject() + .put("jsonrpc", "2.0") + .put("id", id) + .put("result", value) + .toString(); + } catch (Exception e) { + return "{\"jsonrpc\":\"2.0\",\"error\":{\"code\":-32603,\"message\":\"internal error\"}}"; + } + } + + private static String error(String id, int code, String message) { + try { + JSONObject err = new JSONObject().put("code", code).put("message", message); + JSONObject r = new JSONObject().put("jsonrpc", "2.0").put("error", err); + if (id != null) { + r.put("id", id); + } + return r.toString(); + } catch (Exception e) { + return "{\"jsonrpc\":\"2.0\",\"error\":{\"code\":-32603,\"message\":\"internal error\"}}"; + } + } +} diff --git a/agents/android/java/MobileCliAgent.java b/agents/android/java/MobileCliAgent.java new file mode 100644 index 0000000..fe95789 --- /dev/null +++ b/agents/android/java/MobileCliAgent.java @@ -0,0 +1,8 @@ +package com.mobilenext.mobilecli; + +public class MobileCliAgent { + public static void start() { + AndroidBridge.init(); + AndroidBridge.sMainHandler.postDelayed(HttpRpcServer::start, 500); + } +} diff --git a/agents/android/java/RpcException.java b/agents/android/java/RpcException.java new file mode 100644 index 0000000..4e5d0c7 --- /dev/null +++ b/agents/android/java/RpcException.java @@ -0,0 +1,10 @@ +package com.mobilenext.mobilecli; + +class RpcException extends Exception { + final int code; + + RpcException(int code, String message) { + super(message); + this.code = code; + } +} diff --git a/agents/android/java/WebViewAgent.java b/agents/android/java/WebViewAgent.java new file mode 100644 index 0000000..3fa1552 --- /dev/null +++ b/agents/android/java/WebViewAgent.java @@ -0,0 +1,205 @@ +package com.mobilenext.mobilecli; + +import android.content.res.Resources; +import android.view.View; +import android.view.ViewGroup; +import android.webkit.WebView; +import android.widget.TextView; + +import org.json.JSONArray; +import org.json.JSONObject; +import org.json.JSONTokener; + +import java.util.List; +import java.util.function.Consumer; +import java.util.stream.Collectors; + +class WebViewAgent { + + private static final int MAX_DEPTH = 50; + + /* ── WebView lookup ──────────────────────────────────────────────────── */ + + static WebView lookupWebView(String id) throws Exception { + return AndroidBridge.getRootViews().stream() + .flatMap(AndroidBridge::streamWebViews) + .filter(v -> Integer.toHexString(System.identityHashCode(v)).equals(id)) + .findFirst() + .orElseThrow(() -> new RpcException(-32100, "webview not found: " + id)); + } + + static WebView findWebViewById(String id) throws Exception { + return AndroidBridge.runOnMainThread(() -> lookupWebView(id)); + } + + /* ── Navigation ──────────────────────────────────────────────────────── */ + + static void webViewAction(String id, Consumer action) throws Exception { + AndroidBridge.runOnMainThread(() -> { + action.accept(lookupWebView(id)); + return null; + }); + } + + static Consumer navAction(String method) { + switch (method) { + case "device.webview.reload": + return WebView::reload; + case "device.webview.goBack": + return WebView::goBack; + default: + return WebView::goForward; + } + } + + /* ── device.webview.waitForLoadState ─────────────────────────────────── */ + + static void waitForLoadState(WebView wv, String state, int timeoutMs) throws Exception { + String js = "domcontentloaded".equals(state) + ? "document.readyState === 'interactive' || document.readyState === 'complete'" + : "document.readyState === 'complete'"; + long deadline = System.currentTimeMillis() + timeoutMs; + while (true) { + String raw = AndroidBridge.evalJs(wv, "String(" + js + ")"); + if ("true".equals(raw) || "\"true\"".equals(raw)) { + return; + } + if (System.currentTimeMillis() >= deadline) { + throw new Exception("waitForLoadState timed out waiting for '" + state + "'"); + } + Thread.sleep(200); + } + } + + /* ── device.webview.evaluate ─────────────────────────────────────────── */ + + static JSONObject evaluateExpression(WebView wv, String expression, JSONArray args) throws Exception { + String argsJson = (args != null && args.length() > 0) ? args.toString() : "[]"; + String raw = AndroidBridge.evalJs(wv, buildEvalScript(expression, argsJson)); + if (raw == null || "null".equals(raw)) { + throw new Exception("script execution failed — check for syntax errors or missing DOM elements"); + } + Object tokenized = new JSONTokener(raw).nextValue(); + if (!(tokenized instanceof String)) { + throw new Exception("unexpected script result: " + raw); + } + JSONObject outcome = new JSONObject((String) tokenized); + if (!outcome.optBoolean("ok", false)) { + throw new Exception(outcome.optString("error", "script error")); + } + Object value = outcome.isNull("value") ? JSONObject.NULL : outcome.get("value"); + return new JSONObject().put("result", value); + } + + private static String buildEvalScript(String expression, String argsJson) { + // Wrap bare expressions (no return / no statement separator / no block) so + // callers can pass "document.title" instead of "return document.title". + String trimmed = expression.trim(); + boolean looksLikeStatement = trimmed.startsWith("return ") + || trimmed.contains(";") + || trimmed.contains("\n") + || trimmed.startsWith("{"); + String body = looksLikeStatement ? trimmed : "return (" + trimmed + ")"; + return "(function() { try {" + + " var __args = " + argsJson + ";" + + " var __r = (function() { " + body + " }).apply(null, __args);" + + " return JSON.stringify({ ok: true, value: __r });" + + "} catch(e) { return JSON.stringify({ ok: false, error: e.message || String(e) }); } })()"; + } + + /* ── device.dump.ui ──────────────────────────────────────────────────── */ + + static JSONArray dumpUi() throws Exception { + return AndroidBridge.runOnMainThread(() -> { + List roots = AndroidBridge.getRootViews(); + JSONArray arr = new JSONArray(); + for (View root : roots) { + arr.put(viewToJson(root, 0)); + } + return arr; + }); + } + + private static JSONObject viewToJson(View view, int depth) throws Exception { + JSONObject obj = new JSONObject(); + obj.put("type", view.getClass().getName()); + + int id = view.getId(); + if (id != View.NO_ID) { + try { + Resources res = view.getResources(); + obj.put("label", res.getResourceEntryName(id)); + obj.put("identifier", res.getResourceName(id)); + } catch (Exception ignored) { + } + } + + if (view instanceof TextView) { + CharSequence cs = ((TextView) view).getText(); + if (cs != null && cs.length() > 0) { + obj.put("text", cs.toString()); + } + } + + int[] loc = new int[2]; + view.getLocationOnScreen(loc); + JSONObject rect = new JSONObject(); + rect.put("x", loc[0]); + rect.put("y", loc[1]); + rect.put("width", view.getWidth()); + rect.put("height", view.getHeight()); + obj.put("rect", rect); + + if (depth < MAX_DEPTH && view instanceof ViewGroup) { + ViewGroup vg = (ViewGroup) view; + int n = vg.getChildCount(); + if (n > 0) { + JSONArray children = new JSONArray(); + for (int i = 0; i < n; i++) { + children.put(viewToJson(vg.getChildAt(i), depth + 1)); + } + obj.put("children", children); + } + } + + return obj; + } + + /* ── device.webview.list ─────────────────────────────────────────────── */ + + static JSONArray listWebViews() throws Exception { + return AndroidBridge.runOnMainThread(() -> { + List found = AndroidBridge.getRootViews().stream() + .flatMap(AndroidBridge::streamWebViews) + .collect(Collectors.toList()); + JSONArray arr = new JSONArray(); + for (WebView wv : found) { + arr.put(webViewToJson(wv)); + } + return arr; + }); + } + + private static JSONObject webViewToJson(WebView wv) throws Exception { + String pkg = AndroidBridge.getPackageName(); + String id = Integer.toHexString(System.identityHashCode(wv)); + int[] loc = new int[2]; + wv.getLocationOnScreen(loc); + int w = wv.getWidth(), h = wv.getHeight(); + + JSONObject bounds = new JSONObject(); + bounds.put("x", loc[0]); + bounds.put("y", loc[1]); + bounds.put("width", w); + bounds.put("height", h); + + return new JSONObject() + .put("id", id) + .put("url", wv.getUrl() != null ? wv.getUrl() : "") + .put("title", wv.getTitle() != null ? wv.getTitle() : "") + .put("bundleId", pkg) + .put("processName", pkg) + .put("bounds", bounds) + .put("isVisible", w > 0 && h > 0); + } +} diff --git a/agents/android/jvmti_agent.c b/agents/android/jvmti_agent.c new file mode 100644 index 0000000..5a86ec7 --- /dev/null +++ b/agents/android/jvmti_agent.c @@ -0,0 +1,151 @@ +#include +#include +#include +#include +#include + +#define TAG "devicekit" +#define LOG(...) __android_log_print(ANDROID_LOG_DEBUG, TAG, __VA_ARGS__) + +/* ── load devicekit.dex into the target process ─────────────────────────── */ + +/* dex_path — full path to devicekit.dex on the device + opt_dir — directory used by DexClassLoader for optimised odex output; + typically the same directory that contains the dex */ +static void bootstrap(JNIEnv *env, const char *dex_path, const char *opt_dir) { + jclass cls_CL = (*env)->FindClass(env, "java/lang/ClassLoader"); + jclass cls_DCL = (*env)->FindClass(env, "dalvik/system/DexClassLoader"); + if (!cls_CL || !cls_DCL || (*env)->ExceptionCheck(env)) { + (*env)->ExceptionClear(env); + return; + } + + jmethodID getSys = (*env)->GetStaticMethodID(env, cls_CL, "getSystemClassLoader", "()Ljava/lang/ClassLoader;"); + jmethodID init = (*env)->GetMethodID(env, cls_DCL, "", "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/ClassLoader;)V"); + jmethodID load = (*env)->GetMethodID(env, cls_CL, "loadClass", "(Ljava/lang/String;)Ljava/lang/Class;"); + if (!getSys || !init || !load) { + (*env)->ExceptionClear(env); + return; + } + + jobject parent = (*env)->CallStaticObjectMethod(env, cls_CL, getSys); + jstring j_dex = (*env)->NewStringUTF(env, dex_path); + jstring j_opt = (*env)->NewStringUTF(env, opt_dir); + jobject loader = (*env)->NewObject(env, cls_DCL, init, j_dex, j_opt, NULL, parent); + (*env)->DeleteLocalRef(env, j_dex); + (*env)->DeleteLocalRef(env, j_opt); + + if (!loader || (*env)->ExceptionCheck(env)) { + jthrowable exc = (*env)->ExceptionOccurred(env); + (*env)->ExceptionClear(env); + if (exc) { + jclass ecls = (*env)->GetObjectClass(env, exc); + jmethodID toStr = (*env)->GetMethodID(env, ecls, "toString", "()Ljava/lang/String;"); + if (toStr) { + jstring msg = (jstring)(*env)->CallObjectMethod(env, exc, toStr); + if (msg && !(*env)->ExceptionCheck(env)) { + const char *s = (*env)->GetStringUTFChars(env, msg, NULL); + LOG("DexClassLoader exception: %s", s ? s : "(null)"); + if (s) (*env)->ReleaseStringUTFChars(env, msg, s); + } else (*env)->ExceptionClear(env); + } + } + LOG("DexClassLoader failed — push devicekit.dex to %s", dex_path); + return; + } + + jstring j_cls = (*env)->NewStringUTF(env, "com.mobilenext.mobilecli.MobileCliAgent"); + jclass agentCls = (jclass)(*env)->CallObjectMethod(env, loader, load, j_cls); + (*env)->DeleteLocalRef(env, j_cls); + if (!agentCls || (*env)->ExceptionCheck(env)) { + jthrowable exc = (*env)->ExceptionOccurred(env); + (*env)->ExceptionClear(env); + if (exc) { + jclass ecls = (*env)->GetObjectClass(env, exc); + jmethodID toStr = (*env)->GetMethodID(env, ecls, "toString", "()Ljava/lang/String;"); + if (toStr) { + jstring msg = (jstring)(*env)->CallObjectMethod(env, exc, toStr); + if (msg && !(*env)->ExceptionCheck(env)) { + const char *s = (*env)->GetStringUTFChars(env, msg, NULL); + LOG("loadClass exception: %s", s ? s : "(null)"); + if (s) (*env)->ReleaseStringUTFChars(env, msg, s); + } else { + (*env)->ExceptionClear(env); + } + } + } + + LOG("loadClass(MobileCliAgent) failed"); + return; + } + + jmethodID start = (*env)->GetStaticMethodID(env, agentCls, "start", "()V"); + if (!start || (*env)->ExceptionCheck(env)) { + (*env)->ExceptionClear(env); + return; + } + + (*env)->CallStaticVoidMethod(env, agentCls, start); + if ((*env)->ExceptionCheck(env)) { + jthrowable exc = (*env)->ExceptionOccurred(env); + (*env)->ExceptionClear(env); + if (exc) { + jclass ecls = (*env)->GetObjectClass(env, exc); + jmethodID toStr = (*env)->GetMethodID(env, ecls, "toString", "()Ljava/lang/String;"); + if (toStr) { + jstring msg = (jstring)(*env)->CallObjectMethod(env, exc, toStr); + if (msg && !(*env)->ExceptionCheck(env)) { + const char *s = (*env)->GetStringUTFChars(env, msg, NULL); + LOG("MobileCliAgent.start() threw: %s", s ? s : "(null)"); + if (s) (*env)->ReleaseStringUTFChars(env, msg, s); + } else { + (*env)->ExceptionClear(env); + } + } + } + } + + LOG("MobileCliAgent started"); +} + +/* ── agent entry points ──────────────────────────────────────────────────── */ + +/* opts is the dex path passed via: am attach-agent agent.so= + opt_dir is derived as the directory containing the dex file. */ +static jint setup(JavaVM *vm, const char *opts) { + if (!opts || opts[0] == '\0') { + LOG("no dex path provided — pass it as agent.so=/path/to/devicekit.dex"); + return JNI_ERR; + } + + const char *dex_path = opts; + + /* derive opt_dir as dirname(dex_path) */ + char opt_dir[512]; + const char *slash = strrchr(dex_path, '/'); + if (slash && slash != dex_path) { + size_t len = (size_t)(slash - dex_path); + if (len >= sizeof(opt_dir)) { len = sizeof(opt_dir) - 1; } + memcpy(opt_dir, dex_path, len); + opt_dir[len] = '\0'; + } else { + opt_dir[0] = '.'; + opt_dir[1] = '\0'; + } + + JNIEnv *env = NULL; + (*vm)->AttachCurrentThread(vm, (void **) &env, NULL); + bootstrap(env, dex_path, opt_dir); + LOG("agent ready (dex=%s)", dex_path); + return JNI_OK; +} + +JNIEXPORT jint +JNICALL Agent_OnLoad(JavaVM *vm, char *opts, void *reserved) { + return setup(vm, opts); +} + +JNIEXPORT jint +JNICALL Agent_OnAttach(JavaVM *vm, char *opts, void *reserved) { + return setup(vm, opts); +} diff --git a/agents/ios/Makefile b/agents/ios/Makefile new file mode 100644 index 0000000..44cb51b --- /dev/null +++ b/agents/ios/Makefile @@ -0,0 +1,31 @@ +ARCH := arm64 +MIN := 15.0 +SRCS := agent.m server.m bridge.m dispatcher.m + +FLAGS = -arch $(ARCH) \ + -dynamiclib -fPIC -fobjc-arc \ + -framework Foundation -framework UIKit -framework WebKit + +all: agent-sim.dylib agent-dev.dylib + +agent-sim.dylib: $(SRCS) server.h bridge.h dispatcher.h + xcrun -sdk iphonesimulator clang $(FLAGS) \ + -mios-simulator-version-min=$(MIN) \ + -o $@ $(SRCS) + +agent-dev.dylib: $(SRCS) server.h bridge.h dispatcher.h + xcrun -sdk iphoneos clang $(FLAGS) \ + -miphoneos-version-min=$(MIN) \ + -o $@ $(SRCS) + codesign -s - --force $@ + +# legacy test dylib +devicekit.dylib: devicekit.m + xcrun -sdk iphonesimulator clang -arch $(ARCH) \ + -mios-simulator-version-min=$(MIN) \ + -dynamiclib -fPIC -fobjc-arc \ + -framework Foundation \ + -o $@ $< + +clean: + rm -f agent-sim.dylib agent-dev.dylib devicekit.dylib diff --git a/agents/ios/agent.m b/agents/ios/agent.m new file mode 100644 index 0000000..e7a57d1 --- /dev/null +++ b/agents/ios/agent.m @@ -0,0 +1,27 @@ +#import +#import "server.h" +#import "dispatcher.h" + +static int mobilecli_port = 0; + +// callable from lldb after dlopen: expr (int)mobilecli_get_port() +__attribute__((visibility("default"))) +int mobilecli_get_port(void) { return mobilecli_port; } + +__attribute__((constructor)) +static void on_load(void) { + NSLog(@"[mobilecli] agent loaded"); + + MobileServer *server = [[MobileServer alloc] initWithHandler:^NSData *(NSData *body) { + return dispatch_rpc(body); + }]; + + if (![server bind]) return; + + mobilecli_port = server.port; + NSLog(@"[mobilecli] port %d (read with: expr (int)mobilecli_get_port())", mobilecli_port); + + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + [server run]; + }); +} diff --git a/agents/ios/bridge.h b/agents/ios/bridge.h new file mode 100644 index 0000000..08195c6 --- /dev/null +++ b/agents/ios/bridge.h @@ -0,0 +1,17 @@ +#import +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface IosBridge : NSObject ++ (void)runOnMainThread:(dispatch_block_t)block; ++ (NSArray *)listWebViews; ++ (nullable UIView *)webViewWithID:(NSString *)wvId; ++ (NSDictionary *)evaluateJS:(NSString *)expression inWebView:(UIView *)webView; ++ (void)gotoURL:(NSString *)urlStr inWebView:(UIView *)webView; ++ (void)reloadWebView:(UIView *)webView; ++ (void)goBackWebView:(UIView *)webView; ++ (void)goForwardWebView:(UIView *)webView; +@end + +NS_ASSUME_NONNULL_END diff --git a/agents/ios/bridge.m b/agents/ios/bridge.m new file mode 100644 index 0000000..4bafeb2 --- /dev/null +++ b/agents/ios/bridge.m @@ -0,0 +1,141 @@ +#import "bridge.h" +#import +#import + +@implementation IosBridge + ++ (void)runOnMainThread:(dispatch_block_t)block { + if ([NSThread isMainThread]) { + block(); + return; + } + dispatch_semaphore_t sem = dispatch_semaphore_create(0); + dispatch_async(dispatch_get_main_queue(), ^{ + block(); + dispatch_semaphore_signal(sem); + }); + dispatch_semaphore_wait(sem, dispatch_time(DISPATCH_TIME_NOW, 5 * NSEC_PER_SEC)); +} + ++ (NSArray *)allWindows { + NSMutableArray *windows = [NSMutableArray array]; + for (UIScene *scene in [UIApplication sharedApplication].connectedScenes) { + if ([scene isKindOfClass:[UIWindowScene class]]) { + [windows addObjectsFromArray:((UIWindowScene *)scene).windows]; + } + } + return windows; +} + ++ (void)collectWebViews:(UIView *)view into:(NSMutableArray *)result depth:(int)depth { + if (depth > 50) return; + + Class wkClass = NSClassFromString(@"WKWebView"); + if (wkClass && [view isKindOfClass:wkClass]) { + NSURL *url = [view valueForKey:@"URL"]; + NSString *title = [view valueForKey:@"title"]; + CGRect frame = [view convertRect:view.bounds toView:nil]; + BOOL visible = !view.isHidden && view.alpha > 0.01 && view.window != nil; + + [result addObject:@{ + @"id": [NSString stringWithFormat:@"%p", view], + @"url": url.absoluteString ?: @"", + @"title": title ?: @"", + @"bounds": @{ + @"x": @(frame.origin.x), + @"y": @(frame.origin.y), + @"width": @(frame.size.width), + @"height": @(frame.size.height), + }, + @"visible": @(visible), + }]; + } + + for (UIView *child in view.subviews) { + [self collectWebViews:child into:result depth:depth + 1]; + } +} + ++ (NSArray *)listWebViews { + __block NSMutableArray *result = [NSMutableArray array]; + [self runOnMainThread:^{ + for (UIWindow *window in [self allWindows]) { + [self collectWebViews:window into:result depth:0]; + } + }]; + return result; +} + ++ (UIView *)findView:(UIView *)view withID:(NSString *)wvId wkClass:(Class)wkClass depth:(int)depth { + if (depth > 50) return nil; + if ([view isKindOfClass:wkClass] && [[NSString stringWithFormat:@"%p", view] isEqualToString:wvId]) { + return view; + } + for (UIView *child in view.subviews) { + UIView *found = [self findView:child withID:wvId wkClass:wkClass depth:depth + 1]; + if (found) return found; + } + return nil; +} + ++ (UIView *)webViewWithID:(NSString *)wvId { + Class wkClass = NSClassFromString(@"WKWebView"); + if (!wkClass) return nil; + __block UIView *found = nil; + [self runOnMainThread:^{ + for (UIWindow *window in [self allWindows]) { + found = [self findView:window withID:wvId wkClass:wkClass depth:0]; + if (found) break; + } + }]; + return found; +} + +// returns {"result": } on success, {"__error": } on failure ++ (NSDictionary *)evaluateJS:(NSString *)expression inWebView:(UIView *)webView { + NSString *wrapped = [NSString stringWithFormat: + @"(function(){try{%@}catch(e){return {__mce:e.toString()}}})()", + expression]; + + __block id jsResult = nil; + __block NSError *jsError = nil; + dispatch_semaphore_t sem = dispatch_semaphore_create(0); + + dispatch_async(dispatch_get_main_queue(), ^{ + [(WKWebView *)webView evaluateJavaScript:wrapped completionHandler:^(id result, NSError *error) { + jsResult = result; + jsError = error; + dispatch_semaphore_signal(sem); + }]; + }); + dispatch_semaphore_wait(sem, dispatch_time(DISPATCH_TIME_NOW, 10 * NSEC_PER_SEC)); + + if (jsError) { + return @{@"__error": jsError.localizedDescription}; + } + if ([jsResult isKindOfClass:[NSDictionary class]] && jsResult[@"__mce"]) { + return @{@"__error": jsResult[@"__mce"] ?: @"unknown JS error"}; + } + return @{@"result": jsResult ?: [NSNull null]}; +} + ++ (void)gotoURL:(NSString *)urlStr inWebView:(UIView *)webView { + [self runOnMainThread:^{ + NSURL *url = [NSURL URLWithString:urlStr]; + [(WKWebView *)webView loadRequest:[NSURLRequest requestWithURL:url]]; + }]; +} + ++ (void)reloadWebView:(UIView *)webView { + [self runOnMainThread:^{ [(WKWebView *)webView reload]; }]; +} + ++ (void)goBackWebView:(UIView *)webView { + [self runOnMainThread:^{ [(WKWebView *)webView goBack]; }]; +} + ++ (void)goForwardWebView:(UIView *)webView { + [self runOnMainThread:^{ [(WKWebView *)webView goForward]; }]; +} + +@end diff --git a/agents/ios/devicekit.m b/agents/ios/devicekit.m new file mode 100644 index 0000000..e234c5d --- /dev/null +++ b/agents/ios/devicekit.m @@ -0,0 +1,42 @@ +#import +#import + +typedef NSURLSessionDataTask *(*DataTaskIMP)(id, SEL, NSURL *, void (^)(NSData *, NSURLResponse *, NSError *)); + +static DataTaskIMP originalDataTaskWithURL = NULL; + +static NSURLSessionDataTask *swizzledDataTaskWithURL(id self, SEL _cmd, NSURL *url, + void (^handler)(NSData *, NSURLResponse *, NSError *)) { + void (^wrapped)(NSData *, NSURLResponse *, NSError *) = ^(NSData *data, NSURLResponse *resp, NSError *err) { + if (data != nil) { + NSString *body = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding] + ?: @""; + NSHTTPURLResponse *http = (NSHTTPURLResponse *)resp; + NSString *out = [NSString stringWithFormat:@"url: %@\nstatus: %ld\nbody:\n%@\n", + url.absoluteString, (long)http.statusCode, body]; + [out writeToFile:@"/tmp/gilm.txt" + atomically:YES + encoding:NSUTF8StringEncoding + error:nil]; + NSLog(@"[gadget] swizzle fired for %@ -> %ld, wrote to /tmp/gilm.txt", url, (long)http.statusCode); + } + if (handler) handler(data, resp, err); + }; + return originalDataTaskWithURL(self, _cmd, url, wrapped); +} + +__attribute__((constructor)) +static void on_load(void) { + // write a marker so we know the dylib loaded even before the request fires + [@"gadget loaded\n" writeToFile:@"/tmp/gilm.txt" + atomically:YES + encoding:NSUTF8StringEncoding + error:nil]; + + Class cls = [NSURLSession class]; + SEL sel = @selector(dataTaskWithURL:completionHandler:); + Method m = class_getInstanceMethod(cls, sel); + originalDataTaskWithURL = (DataTaskIMP)method_setImplementation(m, (IMP)swizzledDataTaskWithURL); + + NSLog(@"[gadget] swizzled NSURLSession -dataTaskWithURL:completionHandler:"); +} diff --git a/agents/ios/dispatcher.h b/agents/ios/dispatcher.h new file mode 100644 index 0000000..db970bf --- /dev/null +++ b/agents/ios/dispatcher.h @@ -0,0 +1,3 @@ +#import + +NSData * _Nonnull dispatch_rpc(NSData * _Nonnull body); diff --git a/agents/ios/dispatcher.m b/agents/ios/dispatcher.m new file mode 100644 index 0000000..290d17b --- /dev/null +++ b/agents/ios/dispatcher.m @@ -0,0 +1,119 @@ +#import "dispatcher.h" +#import "bridge.h" + +static NSData *rpc_result(id reqId, id value) { + NSDictionary *resp = @{@"jsonrpc": @"2.0", @"id": reqId, @"result": value}; + NSData *data = [NSJSONSerialization dataWithJSONObject:resp options:0 error:nil]; + return data ?: [@"{\"jsonrpc\":\"2.0\",\"error\":{\"code\":-32603,\"message\":\"serialization error\"}}" + dataUsingEncoding:NSUTF8StringEncoding]; +} + +static NSData *rpc_error(id reqId, int code, NSString *message) { + NSDictionary *resp = @{ + @"jsonrpc": @"2.0", + @"id": reqId, + @"error": @{@"code": @(code), @"message": message}, + }; + NSData *data = [NSJSONSerialization dataWithJSONObject:resp options:0 error:nil]; + return data ?: [@"{\"jsonrpc\":\"2.0\",\"error\":{\"code\":-32603,\"message\":\"internal error\"}}" + dataUsingEncoding:NSUTF8StringEncoding]; +} + +static NSString *requireParam(NSDictionary *params, NSString *key, NSData * _Nullable * _Nonnull outError) { + NSString *v = params[key]; + if (!v || [v isEqual:[NSNull null]] || ((NSString *)v).length == 0) { + *outError = rpc_error(nil, -32602, [NSString stringWithFormat:@"missing params.%@", key]); + return nil; + } + return v; +} + +NSData *dispatch_rpc(NSData *body) { + NSError *jsonErr = nil; + NSDictionary *req = [NSJSONSerialization JSONObjectWithData:body options:0 error:&jsonErr]; + if (!req || jsonErr) { + return rpc_error([NSNull null], -32700, @"parse error"); + } + + id reqId = req[@"id"] ?: [NSNull null]; + NSString *method = req[@"method"]; + NSDictionary *params = req[@"params"]; + + if ([method isEqualToString:@"device.webview.list"]) { + return rpc_result(reqId, [IosBridge listWebViews]); + } + + if ([method isEqualToString:@"device.webview.goto"]) { + NSData *err = nil; + NSString *wvId = requireParam(params, @"id", &err); if (!wvId) return err; + NSString *url = requireParam(params, @"url", &err); if (!url) return err; + UIView *wv = [IosBridge webViewWithID:wvId]; + if (!wv) return rpc_error(reqId, -32000, [NSString stringWithFormat:@"webview not found: %@", wvId]); + [IosBridge gotoURL:url inWebView:wv]; + return rpc_result(reqId, @{@"status": @"ok"}); + } + + if ([method isEqualToString:@"device.webview.evaluate"]) { + NSData *err = nil; + NSString *wvId = requireParam(params, @"id", &err); if (!wvId) return err; + NSString *expression = requireParam(params, @"expression", &err); if (!expression) return err; + UIView *wv = [IosBridge webViewWithID:wvId]; + if (!wv) return rpc_error(reqId, -32000, [NSString stringWithFormat:@"webview not found: %@", wvId]); + NSDictionary *eval = [IosBridge evaluateJS:expression inWebView:wv]; + if (eval[@"__error"]) return rpc_error(reqId, -32000, eval[@"__error"]); + return rpc_result(reqId, eval); // {"result": } + } + + if ([@[@"device.webview.reload", @"device.webview.goBack", @"device.webview.goForward"] containsObject:method]) { + NSData *err = nil; + NSString *wvId = requireParam(params, @"id", &err); + if (!wvId) return err; + UIView *wv = [IosBridge webViewWithID:wvId]; + if (!wv) return rpc_error(reqId, -32000, [NSString stringWithFormat:@"webview not found: %@", wvId]); + if ([method isEqualToString:@"device.webview.reload"]) [IosBridge reloadWebView:wv]; + else if ([method isEqualToString:@"device.webview.goBack"]) [IosBridge goBackWebView:wv]; + else if ([method isEqualToString:@"device.webview.goForward"]) [IosBridge goForwardWebView:wv]; + return rpc_result(reqId, @{@"status": @"ok"}); + } + + if ([method isEqualToString:@"device.webview.waitForLoadState"]) { + NSData *err = nil; + NSString *wvId = requireParam(params, @"id", &err); + if (!wvId) return err; + UIView *wv = [IosBridge webViewWithID:wvId]; + if (!wv) return rpc_error(reqId, -32000, [NSString stringWithFormat:@"webview not found: %@", wvId]); + + NSString *state = params[@"state"] ?: @"load"; + NSInteger timeoutMs = params[@"timeout"] ? [params[@"timeout"] integerValue] : 30000; + + NSString *js = [@"domcontentloaded" isEqualToString:state] + ? @"return String(document.readyState === 'interactive' || document.readyState === 'complete')" + : @"return String(document.readyState === 'complete')"; + + NSDate *deadline = [NSDate dateWithTimeIntervalSinceNow:timeoutMs / 1000.0]; + while (YES) { + NSDictionary *result = [IosBridge evaluateJS:js inWebView:wv]; + if ([@"true" isEqualToString:result[@"result"]]) { + return rpc_result(reqId, @{@"status": @"ok"}); + } + if ([[NSDate date] compare:deadline] != NSOrderedAscending) { + return rpc_error(reqId, -32000, + [NSString stringWithFormat:@"waitForLoadState timed out waiting for '%@'", state]); + } + [NSThread sleepForTimeInterval:0.2]; + } + } + + static NSArray *stubMethods; + static dispatch_once_t once; + dispatch_once(&once, ^{ + stubMethods = @[ + @"device.dump.ui", + ]; + }); + if ([stubMethods containsObject:method]) { + return rpc_error(reqId, -32000, [NSString stringWithFormat:@"not yet implemented: %@", method]); + } + + return rpc_error(reqId, -32601, [NSString stringWithFormat:@"method not found: %@", method]); +} diff --git a/agents/ios/server.h b/agents/ios/server.h new file mode 100644 index 0000000..cd1378b --- /dev/null +++ b/agents/ios/server.h @@ -0,0 +1,10 @@ +#import + +typedef NSData * _Nullable (^RpcHandler)(NSData * _Nonnull body); + +@interface MobileServer : NSObject +- (instancetype _Nonnull)initWithHandler:(RpcHandler _Nonnull)handler; +@property (readonly) int port; +- (BOOL)bind; // binds and listens; call once, returns immediately +- (void)run; // accept loop — blocks the calling thread +@end diff --git a/agents/ios/server.m b/agents/ios/server.m new file mode 100644 index 0000000..bdaad1c --- /dev/null +++ b/agents/ios/server.m @@ -0,0 +1,122 @@ +#import "server.h" +#import +#import +#import + +#define BASE_PORT 27042 +#define PORT_RANGE 10 + +@implementation MobileServer { + RpcHandler _handler; + int _serverFd; + int _port; +} + +- (instancetype)initWithHandler:(RpcHandler)handler { + self = [super init]; + _handler = [handler copy]; + _serverFd = -1; + _port = 0; + return self; +} + +- (int)port { return _port; } + +- (BOOL)bindPort { + _serverFd = socket(AF_INET, SOCK_STREAM, 0); + if (_serverFd < 0) return NO; + + int yes = 1; + setsockopt(_serverFd, SOL_SOCKET, SO_REUSEADDR, &yes, sizeof(yes)); + + struct sockaddr_in addr = {0}; + addr.sin_family = AF_INET; + addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + + for (int p = BASE_PORT; p < BASE_PORT + PORT_RANGE; p++) { + addr.sin_port = htons((uint16_t)p); + if (bind(_serverFd, (struct sockaddr *)&addr, sizeof(addr)) == 0) { + _port = p; + return YES; + } + } + close(_serverFd); + _serverFd = -1; + return NO; +} + +- (BOOL)bind { + if (![self bindPort]) { + NSLog(@"[mobilecli] failed to bind on ports %d-%d", BASE_PORT, BASE_PORT + PORT_RANGE - 1); + return NO; + } + if (listen(_serverFd, 8) < 0) { + NSLog(@"[mobilecli] listen failed"); + return NO; + } + NSLog(@"[mobilecli] bound to 127.0.0.1:%d", _port); + return YES; +} + +- (void)run { + while (1) { + int clientFd = accept(_serverFd, NULL, NULL); + if (clientFd < 0) continue; + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + [self handleConnection:clientFd]; + }); + } +} + +- (void)handleConnection:(int)fd { + NSMutableData *buf = [NSMutableData data]; + char tmp[4096]; + NSData *separator = [@"\r\n\r\n" dataUsingEncoding:NSASCIIStringEncoding]; + NSRange headerEnd = {NSNotFound, 0}; + + // read until the header/body separator is found + while (headerEnd.location == NSNotFound) { + ssize_t n = recv(fd, tmp, sizeof(tmp), 0); + if (n <= 0) { close(fd); return; } + [buf appendBytes:tmp length:(NSUInteger)n]; + headerEnd = [buf rangeOfData:separator options:0 range:NSMakeRange(0, buf.length)]; + } + + NSString *headerStr = [[NSString alloc] initWithData:[buf subdataWithRange:NSMakeRange(0, headerEnd.location)] + encoding:NSASCIIStringEncoding]; + NSInteger contentLength = 0; + for (NSString *line in [headerStr componentsSeparatedByString:@"\r\n"]) { + if ([line.lowercaseString hasPrefix:@"content-length:"]) { + contentLength = [[line substringFromIndex:15] integerValue]; + } + } + if (contentLength <= 0) { close(fd); return; } + + NSUInteger bodyStart = headerEnd.location + 4; + NSMutableData *body = [NSMutableData dataWithData:[buf subdataWithRange:NSMakeRange(bodyStart, buf.length - bodyStart)]]; + + while ((NSInteger)body.length < contentLength) { + NSInteger remaining = contentLength - (NSInteger)body.length; + ssize_t n = recv(fd, tmp, (size_t)MIN((NSInteger)sizeof(tmp), remaining), 0); + if (n <= 0) break; + [body appendBytes:tmp length:(NSUInteger)n]; + } + + NSData *response = _handler(body); + if (!response) response = [@"{}" dataUsingEncoding:NSUTF8StringEncoding]; + + NSString *headers = [NSString stringWithFormat: + @"HTTP/1.1 200 OK\r\n" + @"Content-Type: application/json\r\n" + @"Content-Length: %lu\r\n" + @"Connection: close\r\n" + @"\r\n", + (unsigned long)response.length]; + + NSData *headerData = [headers dataUsingEncoding:NSASCIIStringEncoding]; + send(fd, headerData.bytes, headerData.length, 0); + send(fd, response.bytes, response.length, 0); + close(fd); +} + +@end diff --git a/cli/flags.go b/cli/flags.go index fc527dd..2d8ac3c 100644 --- a/cli/flags.go +++ b/cli/flags.go @@ -31,4 +31,8 @@ var ( fleetNames []string fleetWait bool fleetTimeout int + + // for webview wait command + webviewWaitState string + webviewWaitTimeout int ) diff --git a/cli/root.go b/cli/root.go index 7e93d21..364e157 100644 --- a/cli/root.go +++ b/cli/root.go @@ -91,6 +91,35 @@ INPUT/OUTPUT: # Send text input mobilecli io text --device "Hello World" +WEBVIEW: + # List embedded webviews in the foreground app + mobilecli webview list --device + + # Navigate a webview to a URL + mobilecli webview goto https://example.com --device + + # Reload, go back or forward + mobilecli webview reload --device + mobilecli webview back --device + mobilecli webview forward --device + + # Get current URL and page title + mobilecli webview url --device + mobilecli webview title --device + + # Dump full HTML content + mobilecli webview content --device + + # Query DOM elements by CSS selector + mobilecli webview query "button" --device + mobilecli webview query "[data-testid='submit']" --device + + # Evaluate arbitrary JavaScript + mobilecli webview eval "document.querySelectorAll('a').length" --device + + # Wait for page load + mobilecli webview wait --state load --device + CRASH REPORTS: # List crash reports from a device mobilecli device crashes list --device diff --git a/cli/webview.go b/cli/webview.go new file mode 100644 index 0000000..6648467 --- /dev/null +++ b/cli/webview.go @@ -0,0 +1,261 @@ +package cli + +import ( + "fmt" + + "github.com/mobile-next/mobilecli/commands" + "github.com/spf13/cobra" +) + +var webviewCmd = &cobra.Command{ + Use: "webview", + Short: "Inspect and interact with embedded webviews", + Long: `List, navigate, evaluate JavaScript, and inspect DOM content inside embedded webviews on a device.`, +} + +var webviewListCmd = &cobra.Command{ + Use: "list", + Short: "List embedded webviews on a device", + Long: `Returns all embedded webviews currently visible in the foreground app. Browser apps (Safari, Chrome) are not included.`, + RunE: func(cmd *cobra.Command, args []string) error { + response := commands.WebViewListCommand(commands.WebViewListRequest{ + DeviceID: deviceId, + }) + printJson(response) + if response.Status == "error" { + return fmt.Errorf("%s", response.Error) + } + return nil + }, +} + +var webviewGotoCmd = &cobra.Command{ + Use: "goto ", + Short: "Navigate a webview to a URL", + Long: `Navigates the specified webview to the given URL. The webview id comes from 'webview list'.`, + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + response := commands.WebViewGotoCommand(commands.WebViewGotoRequest{ + DeviceID: deviceId, + WebViewID: args[0], + URL: args[1], + }) + printJson(response) + if response.Status == "error" { + return fmt.Errorf("%s", response.Error) + } + return nil + }, +} + +var webviewReloadCmd = &cobra.Command{ + Use: "reload ", + Short: "Reload a webview", + Long: `Reloads the page currently loaded in the specified webview.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + response := commands.WebViewReloadCommand(commands.WebViewReloadRequest{ + DeviceID: deviceId, + WebViewID: args[0], + }) + printJson(response) + if response.Status == "error" { + return fmt.Errorf("%s", response.Error) + } + return nil + }, +} + +var webviewBackCmd = &cobra.Command{ + Use: "back ", + Short: "Navigate a webview back", + Long: `Navigates the webview back in its history, equivalent to pressing the browser back button.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + response := commands.WebViewGoBackCommand(commands.WebViewRequest{ + DeviceID: deviceId, + WebViewID: args[0], + }) + printJson(response) + if response.Status == "error" { + return fmt.Errorf("%s", response.Error) + } + return nil + }, +} + +var webviewForwardCmd = &cobra.Command{ + Use: "forward ", + Short: "Navigate a webview forward", + Long: `Navigates the webview forward in its history, equivalent to pressing the browser forward button.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + response := commands.WebViewGoForwardCommand(commands.WebViewRequest{ + DeviceID: deviceId, + WebViewID: args[0], + }) + printJson(response) + if response.Status == "error" { + return fmt.Errorf("%s", response.Error) + } + return nil + }, +} + +var webviewEvalCmd = &cobra.Command{ + Use: "eval ", + Short: "Evaluate JavaScript in a webview", + Long: `Evaluates a JavaScript expression in the context of the specified webview and returns the result.`, + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + response := commands.WebViewEvaluateCommand(commands.WebViewEvaluateRequest{ + DeviceID: deviceId, + WebViewID: args[0], + Expression: args[1], + }) + printJson(response) + if response.Status == "error" { + return fmt.Errorf("%s", response.Error) + } + return nil + }, +} + +var webviewWaitCmd = &cobra.Command{ + Use: "wait ", + Short: "Wait for a webview to finish loading", + Long: `Waits for the webview to reach the specified load state before returning.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + response := commands.WebViewWaitForLoadStateCommand(commands.WebViewWaitForLoadStateRequest{ + DeviceID: deviceId, + WebViewID: args[0], + State: webviewWaitState, + Timeout: webviewWaitTimeout, + }) + printJson(response) + if response.Status == "error" { + return fmt.Errorf("%s", response.Error) + } + return nil + }, +} + +// ─── Convenience commands built on evaluate ─────────────────── + +var webviewURLCmd = &cobra.Command{ + Use: "url ", + Short: "Print the current URL of a webview", + Long: `Prints the current URL loaded in the specified webview.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + response := commands.WebViewEvaluateCommand(commands.WebViewEvaluateRequest{ + DeviceID: deviceId, + WebViewID: args[0], + Expression: "return location.href", + }) + printJson(response) + if response.Status == "error" { + return fmt.Errorf("%s", response.Error) + } + return nil + }, +} + +var webviewTitleCmd = &cobra.Command{ + Use: "title ", + Short: "Print the title of a webview", + Long: `Prints the document title of the page currently loaded in the specified webview.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + response := commands.WebViewEvaluateCommand(commands.WebViewEvaluateRequest{ + DeviceID: deviceId, + WebViewID: args[0], + Expression: "return document.title", + }) + printJson(response) + if response.Status == "error" { + return fmt.Errorf("%s", response.Error) + } + return nil + }, +} + +var webviewContentCmd = &cobra.Command{ + Use: "content ", + Short: "Dump the HTML content of a webview", + Long: `Returns the full outer HTML of the page currently loaded in the specified webview.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + response := commands.WebViewContentCommand(commands.WebViewRequest{ + DeviceID: deviceId, + WebViewID: args[0], + }) + printJson(response) + if response.Status == "error" { + return fmt.Errorf("%s", response.Error) + } + return nil + }, +} + +var webviewQueryCmd = &cobra.Command{ + Use: "query ", + Short: "Query DOM elements in a webview", + Long: `Finds elements matching a CSS selector and returns their tag, text, id, and value. Useful for inspecting webview content.`, + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + selector := args[1] + expression := fmt.Sprintf( + `Array.from(document.querySelectorAll(%q)).map(el => ({`+ + `tag: el.tagName.toLowerCase(),`+ + `text: (el.textContent || "").trim().slice(0, 200),`+ + `id: el.id || null,`+ + `class: el.className || null,`+ + `value: el.value || null,`+ + `href: el.href || null`+ + `}))`, + selector, + ) + response := commands.WebViewEvaluateCommand(commands.WebViewEvaluateRequest{ + DeviceID: deviceId, + WebViewID: args[0], + Expression: expression, + }) + printJson(response) + if response.Status == "error" { + return fmt.Errorf("%s", response.Error) + } + return nil + }, +} + +func init() { + rootCmd.AddCommand(webviewCmd) + + webviewCmd.AddCommand(webviewListCmd) + webviewCmd.AddCommand(webviewGotoCmd) + webviewCmd.AddCommand(webviewReloadCmd) + webviewCmd.AddCommand(webviewBackCmd) + webviewCmd.AddCommand(webviewForwardCmd) + webviewCmd.AddCommand(webviewEvalCmd) + webviewCmd.AddCommand(webviewWaitCmd) + webviewCmd.AddCommand(webviewURLCmd) + webviewCmd.AddCommand(webviewTitleCmd) + webviewCmd.AddCommand(webviewContentCmd) + webviewCmd.AddCommand(webviewQueryCmd) + + webviewListCmd.Flags().StringVar(&deviceId, "device", "", "ID of the device") + webviewGotoCmd.Flags().StringVar(&deviceId, "device", "", "ID of the device") + webviewReloadCmd.Flags().StringVar(&deviceId, "device", "", "ID of the device") + webviewBackCmd.Flags().StringVar(&deviceId, "device", "", "ID of the device") + webviewForwardCmd.Flags().StringVar(&deviceId, "device", "", "ID of the device") + webviewEvalCmd.Flags().StringVar(&deviceId, "device", "", "ID of the device") + webviewWaitCmd.Flags().StringVar(&deviceId, "device", "", "ID of the device") + webviewWaitCmd.Flags().StringVar(&webviewWaitState, "state", "load", `load state to wait for: "load" or "domcontentloaded"`) + webviewWaitCmd.Flags().IntVar(&webviewWaitTimeout, "timeout", 0, "maximum time to wait in milliseconds (0 = default)") + webviewURLCmd.Flags().StringVar(&deviceId, "device", "", "ID of the device") + webviewTitleCmd.Flags().StringVar(&deviceId, "device", "", "ID of the device") + webviewContentCmd.Flags().StringVar(&deviceId, "device", "", "ID of the device") + webviewQueryCmd.Flags().StringVar(&deviceId, "device", "", "ID of the device") +} diff --git a/commands/apps.go b/commands/apps.go index 2d0203c..98705d2 100644 --- a/commands/apps.go +++ b/commands/apps.go @@ -32,8 +32,8 @@ func LaunchAppCommand(req AppRequest) *CommandResponse { return NewErrorResponse(fmt.Errorf("failed to launch app on device %s: %v", targetDevice.ID(), err)) } - return NewSuccessResponse(map[string]any{ - "message": fmt.Sprintf("Launched app '%s' on device %s", req.BundleID, targetDevice.ID()), + return NewSuccessResponse(MessageResult{ + Message: fmt.Sprintf("Launched app '%s' on device %s", req.BundleID, targetDevice.ID()), }) } @@ -53,8 +53,8 @@ func TerminateAppCommand(req AppRequest) *CommandResponse { return NewErrorResponse(fmt.Errorf("failed to terminate app on device %s: %v", targetDevice.ID(), err)) } - return NewSuccessResponse(map[string]any{ - "message": fmt.Sprintf("Terminated app '%s' on device %s", req.BundleID, targetDevice.ID()), + return NewSuccessResponse(MessageResult{ + Message: fmt.Sprintf("Terminated app '%s' on device %s", req.BundleID, targetDevice.ID()), }) } @@ -150,8 +150,8 @@ func InstallAppCommand(req InstallAppRequest) *CommandResponse { return NewErrorResponse(fmt.Errorf("failed to install app on device %s: %w", targetDevice.ID(), err)) } - return NewSuccessResponse(map[string]any{ - "message": fmt.Sprintf("Installed app from '%s' on device %s", req.Path, targetDevice.ID()), + return NewSuccessResponse(MessageResult{ + Message: fmt.Sprintf("Installed app from '%s' on device %s", req.Path, targetDevice.ID()), }) } diff --git a/commands/boot.go b/commands/boot.go index 59000d3..93e2fee 100644 --- a/commands/boot.go +++ b/commands/boot.go @@ -21,11 +21,11 @@ func BootCommand(req BootRequest) *CommandResponse { return NewErrorResponse(fmt.Errorf("failed to boot device %s: %v", targetDevice.ID(), err)) } - return NewSuccessResponse(map[string]any{ - "message": fmt.Sprintf("Device %s booted successfully", targetDevice.ID()), - "platform": targetDevice.Platform(), - "type": targetDevice.DeviceType(), - "version": targetDevice.Version(), + return NewSuccessResponse(DeviceActionResult{ + Message: fmt.Sprintf("Device %s booted successfully", targetDevice.ID()), + Platform: targetDevice.Platform(), + Type: targetDevice.DeviceType(), + Version: targetDevice.Version(), }) } @@ -46,10 +46,10 @@ func ShutdownCommand(req ShutdownRequest) *CommandResponse { return NewErrorResponse(fmt.Errorf("failed to shutdown device %s: %v", targetDevice.ID(), err)) } - return NewSuccessResponse(map[string]any{ - "message": fmt.Sprintf("Device %s shut down successfully", targetDevice.ID()), - "platform": targetDevice.Platform(), - "type": targetDevice.DeviceType(), - "version": targetDevice.Version(), + return NewSuccessResponse(DeviceActionResult{ + Message: fmt.Sprintf("Device %s shut down successfully", targetDevice.ID()), + Platform: targetDevice.Platform(), + Type: targetDevice.DeviceType(), + Version: targetDevice.Version(), }) } diff --git a/commands/commands.go b/commands/commands.go index da6bfb9..9e1a59a 100644 --- a/commands/commands.go +++ b/commands/commands.go @@ -16,6 +16,28 @@ type CommandResponse struct { Error string `json:"error,omitempty"` } +// OKResult is returned by commands that produce no meaningful output data. +type OKResult struct { + Status string `json:"status"` +} + +// MessageResult is returned by commands that produce a human-readable message. +type MessageResult struct { + Message string `json:"message"` +} + +// DeviceActionResult is returned by device lifecycle commands (boot, shutdown) +// that include device metadata alongside a message. +type DeviceActionResult struct { + Message string `json:"message"` + Platform string `json:"platform"` + Type string `json:"type"` + Version string `json:"version"` +} + +// OK is the canonical success response for void commands. +var OK = OKResult{Status: "ok"} + // NewSuccessResponse creates a success response func NewSuccessResponse(data any) *CommandResponse { return &CommandResponse{ diff --git a/commands/input.go b/commands/input.go index 6f64039..b4d5ffd 100644 --- a/commands/input.go +++ b/commands/input.go @@ -73,8 +73,8 @@ func TapCommand(req TapRequest) *CommandResponse { return NewErrorResponse(fmt.Errorf("failed to tap on device %s: %v", targetDevice.ID(), err)) } - return NewSuccessResponse(map[string]any{ - "message": fmt.Sprintf("Tapped on device %s at (%d,%d)", targetDevice.ID(), req.X, req.Y), + return NewSuccessResponse(MessageResult{ + Message: fmt.Sprintf("Tapped on device %s at (%d,%d)", targetDevice.ID(), req.X, req.Y), }) } @@ -101,8 +101,8 @@ func LongPressCommand(req LongPressRequest) *CommandResponse { return NewErrorResponse(fmt.Errorf("failed to long press on device %s: %v", targetDevice.ID(), err)) } - return NewSuccessResponse(map[string]any{ - "message": fmt.Sprintf("Long pressed on device %s at (%d,%d) for %dms", targetDevice.ID(), req.X, req.Y, req.Duration), + return NewSuccessResponse(MessageResult{ + Message: fmt.Sprintf("Long pressed on device %s at (%d,%d) for %dms", targetDevice.ID(), req.X, req.Y, req.Duration), }) } @@ -129,8 +129,8 @@ func TextCommand(req TextRequest) *CommandResponse { return NewErrorResponse(fmt.Errorf("failed to send text to device %s: %v", targetDevice.ID(), err)) } - return NewSuccessResponse(map[string]any{ - "message": fmt.Sprintf("Sent text to device %s", targetDevice.ID()), + return NewSuccessResponse(MessageResult{ + Message: fmt.Sprintf("Sent text to device %s", targetDevice.ID()), }) } @@ -157,8 +157,8 @@ func ButtonCommand(req ButtonRequest) *CommandResponse { return NewErrorResponse(fmt.Errorf("failed to press button on device %s: %v", targetDevice.ID(), err)) } - return NewSuccessResponse(map[string]any{ - "message": fmt.Sprintf("Pressed button '%s' on device %s", req.Button, targetDevice.ID()), + return NewSuccessResponse(MessageResult{ + Message: fmt.Sprintf("Pressed button '%s' on device %s", req.Button, targetDevice.ID()), }) } @@ -200,8 +200,8 @@ func GestureCommand(req GestureRequest) *CommandResponse { return NewErrorResponse(fmt.Errorf("failed to perform gesture on device %s: %v", targetDevice.ID(), err)) } - return NewSuccessResponse(map[string]any{ - "message": fmt.Sprintf("Performed gesture on device %s with %d actions", targetDevice.ID(), len(req.Actions)), + return NewSuccessResponse(MessageResult{ + Message: fmt.Sprintf("Performed gesture on device %s with %d actions", targetDevice.ID(), len(req.Actions)), }) } @@ -224,7 +224,7 @@ func SwipeCommand(req SwipeRequest) *CommandResponse { return NewErrorResponse(fmt.Errorf("failed to swipe on device %s: %v", targetDevice.ID(), err)) } - return NewSuccessResponse(map[string]any{ - "message": fmt.Sprintf("Swiped on device %s from (%d,%d) to (%d,%d)", targetDevice.ID(), req.X1, req.Y1, req.X2, req.Y2), + return NewSuccessResponse(MessageResult{ + Message: fmt.Sprintf("Swiped on device %s from (%d,%d) to (%d,%d)", targetDevice.ID(), req.X1, req.Y1, req.X2, req.Y2), }) } diff --git a/commands/reboot.go b/commands/reboot.go index a2663ae..d35dd9d 100644 --- a/commands/reboot.go +++ b/commands/reboot.go @@ -21,7 +21,7 @@ func RebootCommand(req RebootRequest) *CommandResponse { return NewErrorResponse(fmt.Errorf("failed to reboot device %s: %v", targetDevice.ID(), err)) } - return NewSuccessResponse(map[string]any{ - "message": fmt.Sprintf("Reboot command processed for device %s", targetDevice.ID()), + return NewSuccessResponse(MessageResult{ + Message: fmt.Sprintf("Reboot command processed for device %s", targetDevice.ID()), }) } diff --git a/commands/url.go b/commands/url.go index 3d75d6b..8e2c49f 100644 --- a/commands/url.go +++ b/commands/url.go @@ -35,7 +35,7 @@ func URLCommand(req URLRequest) *CommandResponse { return NewErrorResponse(fmt.Errorf("failed to open URL on device %s: %v", targetDevice.ID(), err)) } - return NewSuccessResponse(map[string]any{ - "message": fmt.Sprintf("Opened URL '%s' on device %s", req.URL, targetDevice.ID()), + return NewSuccessResponse(MessageResult{ + Message: fmt.Sprintf("Opened URL '%s' on device %s", req.URL, targetDevice.ID()), }) } diff --git a/commands/webview.go b/commands/webview.go new file mode 100644 index 0000000..9b1c476 --- /dev/null +++ b/commands/webview.go @@ -0,0 +1,153 @@ +package commands + +import ( + "fmt" + + "github.com/mobile-next/mobilecli/devices" +) + +// ─── Request types ──────────────────────────────────────────── + +type WebViewListRequest struct { + DeviceID string +} + +// WebViewRequest is the base for all webview operations that target a specific webview. +type WebViewRequest struct { + DeviceID string + WebViewID string +} + +type WebViewGotoRequest struct { + DeviceID string + WebViewID string + URL string + WaitUntil string +} + +type WebViewReloadRequest struct { + DeviceID string + WebViewID string + WaitUntil string +} + +type WebViewEvaluateRequest struct { + DeviceID string + WebViewID string + Expression string + Args []any +} + +type WebViewWaitForLoadStateRequest struct { + DeviceID string + WebViewID string + State string + Timeout int +} + +// ─── Shared helper ──────────────────────────────────────────── + +func webViewableDevice(deviceID string) (devices.WebViewable, error) { + device, err := FindDeviceOrAutoSelect(deviceID) + if err != nil { + return nil, fmt.Errorf("error finding device: %w", err) + } + wv, ok := device.(devices.WebViewable) + if !ok { + return nil, fmt.Errorf("webview commands are not supported on %s (%s)", device.ID(), device.Platform()) + } + return wv, nil +} + +// ─── Commands ───────────────────────────────────────────────── + +func WebViewListCommand(req WebViewListRequest) *CommandResponse { + wv, err := webViewableDevice(req.DeviceID) + if err != nil { + return NewErrorResponse(err) + } + webviews, err := wv.ListWebViews() + if err != nil { + return NewErrorResponse(fmt.Errorf("webview list failed: %w", err)) + } + return NewSuccessResponse(webviews) +} + +func WebViewGotoCommand(req WebViewGotoRequest) *CommandResponse { + wv, err := webViewableDevice(req.DeviceID) + if err != nil { + return NewErrorResponse(err) + } + if err := wv.WebViewGoto(req.WebViewID, req.URL); err != nil { + return NewErrorResponse(fmt.Errorf("webview goto failed: %w", err)) + } + return NewSuccessResponse(OK) +} + +func WebViewReloadCommand(req WebViewReloadRequest) *CommandResponse { + wv, err := webViewableDevice(req.DeviceID) + if err != nil { + return NewErrorResponse(err) + } + if err := wv.WebViewReload(req.WebViewID); err != nil { + return NewErrorResponse(fmt.Errorf("webview reload failed: %w", err)) + } + return NewSuccessResponse(OK) +} + +func WebViewGoBackCommand(req WebViewRequest) *CommandResponse { + wv, err := webViewableDevice(req.DeviceID) + if err != nil { + return NewErrorResponse(err) + } + if err := wv.WebViewGoBack(req.WebViewID); err != nil { + return NewErrorResponse(fmt.Errorf("webview back failed: %w", err)) + } + return NewSuccessResponse(OK) +} + +func WebViewGoForwardCommand(req WebViewRequest) *CommandResponse { + wv, err := webViewableDevice(req.DeviceID) + if err != nil { + return NewErrorResponse(err) + } + if err := wv.WebViewGoForward(req.WebViewID); err != nil { + return NewErrorResponse(fmt.Errorf("webview forward failed: %w", err)) + } + return NewSuccessResponse(OK) +} + +func WebViewContentCommand(req WebViewRequest) *CommandResponse { + wv, err := webViewableDevice(req.DeviceID) + if err != nil { + return NewErrorResponse(err) + } + content, err := wv.WebViewContent(req.WebViewID) + if err != nil { + return NewErrorResponse(fmt.Errorf("webview content failed: %w", err)) + } + return NewSuccessResponse(content) +} + +func WebViewEvaluateCommand(req WebViewEvaluateRequest) *CommandResponse { + wv, err := webViewableDevice(req.DeviceID) + if err != nil { + return NewErrorResponse(err) + } + result, err := wv.WebViewEvaluate(req.WebViewID, req.Expression, req.Args) + if err != nil { + return NewErrorResponse(fmt.Errorf("webview evaluate failed: %w", err)) + } + return NewSuccessResponse(result) +} + +func WebViewWaitForLoadStateCommand(req WebViewWaitForLoadStateRequest) *CommandResponse { + wv, err := webViewableDevice(req.DeviceID) + if err != nil { + return NewErrorResponse(err) + } + if err := wv.WebViewWaitForLoadState(req.WebViewID, req.State, req.Timeout); err != nil { + return NewErrorResponse(fmt.Errorf("webview wait failed: %w", err)) + } + return NewSuccessResponse(OK) +} diff --git a/devices/adb.go b/devices/adb.go new file mode 100644 index 0000000..d15f201 --- /dev/null +++ b/devices/adb.go @@ -0,0 +1,309 @@ +package devices + +import ( + "context" + "fmt" + "io" + "net" + "strconv" + "sync" + "time" +) + +const ( + // default dial/read/write timeout for non-streaming adb operations + defaultADBTimeout = 10 * time.Second + // maximum payload we will buffer in memory from adb + defaultADBMaxResponseBytes = 2 << 20 // 2MiB +) + +// ADB is a small protocol-level client for talking to an adb server over TCP. +// +// note: many adb services are connection-scoped (e.g. host:transport), so callers +// must keep operations that depend on each other on the same connection. +type ADB struct { + host string + port int + timeout time.Duration + maxResponseBytes int64 +} + +// creates a new adb client with default timeout/limits. +func NewADB(host string, port int) *ADB { + return &ADB{ + host: host, + port: port, + timeout: defaultADBTimeout, + maxResponseBytes: defaultADBMaxResponseBytes, + } +} + +// WithTimeout sets the dial/read/write timeout used for non-streaming operations. +// +// note: TrackDevices uses this timeout for its initial handshake only. +func (a *ADB) WithTimeout(timeout time.Duration) *ADB { + a.timeout = timeout + return a +} + +// WithMaxResponseBytes sets the maximum number of bytes the client will buffer in memory +// for any single response. +func (a *ADB) WithMaxResponseBytes(max int64) *ADB { + a.maxResponseBytes = max + return a +} + +type adbConn struct { + conn net.Conn + timeout time.Duration + maxResponseBytes int64 +} + +func (c *adbConn) close() error { + return c.conn.Close() +} + +func (c *adbConn) setDeadlineFromNow() { + _ = c.conn.SetDeadline(time.Now().Add(c.timeout)) +} + +func (c *adbConn) clearDeadline() { + _ = c.conn.SetDeadline(time.Time{}) +} + +func (c *adbConn) writeService(service string) error { + if len(service) > 0xFFFF { + return fmt.Errorf("adb service string too long: %d bytes (max 65535)", len(service)) + } + length := fmt.Sprintf("%04x", len(service)) + message := length + service + + if _, err := c.conn.Write([]byte(message)); err != nil { + return fmt.Errorf("failed to write adb service %q: %w", service, err) + } + + return nil +} + +func (c *adbConn) readStatus() error { + status := make([]byte, 4) + if _, err := io.ReadFull(c.conn, status); err != nil { + return fmt.Errorf("failed to read adb status: %w", err) + } + + s := string(status) + switch s { + case "OKAY": + return nil + case "FAIL": + errorData, err := c.readLengthPrefixedData() + if err != nil { + return fmt.Errorf("failed to read adb error message: %w", err) + } + return fmt.Errorf("adb server error: %s", string(errorData)) + default: + return fmt.Errorf("unexpected adb status: %q", s) + } +} + +func (c *adbConn) service(service string) error { + if err := c.writeService(service); err != nil { + return err + } + if err := c.readStatus(); err != nil { + return err + } + return nil +} + +func (c *adbConn) readLengthPrefixedData() ([]byte, error) { + lengthBytes := make([]byte, 4) + if _, err := io.ReadFull(c.conn, lengthBytes); err != nil { + return nil, fmt.Errorf("failed to read adb length: %w", err) + } + + length, err := strconv.ParseInt(string(lengthBytes), 16, 64) + if err != nil { + return nil, fmt.Errorf("invalid adb length %q: %w", string(lengthBytes), err) + } + if length < 0 { + return nil, fmt.Errorf("invalid adb length: %d", length) + } + if length > c.maxResponseBytes { + return nil, fmt.Errorf("adb response too large: %d bytes (limit %d)", length, c.maxResponseBytes) + } + + data := make([]byte, int(length)) + if _, err := io.ReadFull(c.conn, data); err != nil { + return nil, fmt.Errorf("failed to read adb payload: %w", err) + } + return data, nil +} + +func (c *adbConn) readAllLimited() ([]byte, error) { + // read up to max+1 so we can detect truncation. + maxPlusOne := c.maxResponseBytes + 1 + data, err := io.ReadAll(io.LimitReader(c.conn, maxPlusOne)) + if err != nil { + return nil, fmt.Errorf("failed to read adb stream: %w", err) + } + if int64(len(data)) > c.maxResponseBytes { + return nil, fmt.Errorf("adb response too large: >%d bytes", c.maxResponseBytes) + } + return data, nil +} + +func (a *ADB) dial(ctx context.Context) (*adbConn, error) { + addr := net.JoinHostPort(a.host, strconv.Itoa(a.port)) + d := net.Dialer{Timeout: a.timeout} + conn, err := d.DialContext(ctx, "tcp", addr) + if err != nil { + return nil, fmt.Errorf("failed to connect to adb server at %s: %w", addr, err) + } + + return &adbConn{ + conn: conn, + timeout: a.timeout, + maxResponseBytes: a.maxResponseBytes, + }, nil +} + +// Shell runs a shell command on a specific device serial and returns stdout/stderr. +func (a *ADB) Shell(ctx context.Context, serial, command string) (string, error) { + if serial == "" { + return "", fmt.Errorf("serial is required") + } + if command == "" { + return "", fmt.Errorf("command is required") + } + + c, err := a.dial(ctx) + if err != nil { + return "", err + } + defer c.close() + c.setDeadlineFromNow() + + if err := c.service("host:transport:" + serial); err != nil { + return "", err + } + if err := c.service("shell:" + command); err != nil { + return "", err + } + + data, err := c.readAllLimited() + if err != nil { + return "", err + } + return string(data), nil +} + +// ExecOut runs a command using adb's exec service (similar to `adb exec-out`). +// +// exec is a variant of shell that uses a raw PTY to avoid output mangling. +func (a *ADB) ExecOut(ctx context.Context, serial, command string) (string, error) { + c, err := a.dial(ctx) + if err != nil { + return "", err + } + defer c.close() + c.setDeadlineFromNow() + + if serial == "" { + return "", fmt.Errorf("serial is required") + } + if command == "" { + return "", fmt.Errorf("command is required") + } + + if err := c.service("host:transport:" + serial); err != nil { + return "", err + } + if err := c.service("exec:" + command); err != nil { + return "", err + } + + data, err := c.readAllLimited() + if err != nil { + return "", err + } + return string(data), nil +} + +// TrackDevices subscribes to host:track-devices and streams each update frame. +// +// the returned channels are closed when the context is canceled or an error occurs. +func (a *ADB) TrackDevices(ctx context.Context) (<-chan string, <-chan error, error) { + c, err := a.dial(ctx) + if err != nil { + return nil, nil, err + } + + updates := make(chan string, 1) + errs := make(chan error, 1) + + // do the handshake with a deadline, then switch to ctx-driven cancellation for streaming. + c.setDeadlineFromNow() + if err := c.service("host:track-devices"); err != nil { + _ = c.close() + close(updates) + close(errs) + return nil, nil, err + } + c.clearDeadline() + + go func() { + var closeOnce sync.Once + closeConn := func() { + closeOnce.Do(func() { + _ = c.close() + }) + } + + // ensure ctx cancellation interrupts any blocking reads. + go func() { + <-ctx.Done() + closeConn() + }() + + defer func() { + closeConn() + close(updates) + close(errs) + }() + + for { + select { + case <-ctx.Done(): + return + default: + } + + // each frame is length-prefixed. + frame, err := c.readLengthPrefixedData() + if err != nil { + // if the context was canceled, the close-on-cancel goroutine will + // typically make the read fail; don't surface that as an error. + select { + case <-ctx.Done(): + return + default: + } + + select { + case errs <- err: + default: + } + return + } + + select { + case updates <- string(frame): + case <-ctx.Done(): + return + } + } + }() + + return updates, errs, nil +} diff --git a/devices/adb_test.go b/devices/adb_test.go new file mode 100644 index 0000000..b359e83 --- /dev/null +++ b/devices/adb_test.go @@ -0,0 +1,241 @@ +package devices + +import ( + "context" + "fmt" + "io" + "net" + "strconv" + "testing" + "time" +) + +func startFakeADBServer(t *testing.T, handler func(conn net.Conn)) (host string, port int, closeFn func()) { + t.Helper() + + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + + addr := ln.Addr().(*net.TCPAddr) + + done := make(chan struct{}) + go func() { + defer close(done) + conn, err := ln.Accept() + if err != nil { + return + } + handler(conn) + }() + + return "127.0.0.1", addr.Port, func() { + _ = ln.Close() + <-done + } +} + +func readADBService(t *testing.T, conn net.Conn) string { + t.Helper() + + hexLen := make([]byte, 4) + if _, err := io.ReadFull(conn, hexLen); err != nil { + t.Fatalf("read len: %v", err) + } + + l, err := strconv.ParseInt(string(hexLen), 16, 64) + if err != nil { + t.Fatalf("parse len %q: %v", string(hexLen), err) + } + + buf := make([]byte, l) + if _, err := io.ReadFull(conn, buf); err != nil { + t.Fatalf("read service bytes: %v", err) + } + + return string(buf) +} + +func writeOKAY(t *testing.T, conn net.Conn) { + t.Helper() + if _, err := conn.Write([]byte("OKAY")); err != nil { + t.Fatalf("write OKAY: %v", err) + } +} + +func writeLenPrefixed(t *testing.T, conn net.Conn, payload string) { + t.Helper() + prefix := fmt.Sprintf("%04x", len(payload)) + if _, err := conn.Write([]byte(prefix + payload)); err != nil { + t.Fatalf("write frame: %v", err) + } +} + +func TestADB_Shell_UsesTransportOnSameConnection(t *testing.T) { + host, port, closeFn := startFakeADBServer(t, func(conn net.Conn) { + defer conn.Close() + + got := readADBService(t, conn) + if got != "host:transport:serial-123" { + t.Fatalf("expected transport select, got %q", got) + } + writeOKAY(t, conn) + + got = readADBService(t, conn) + if got != "shell:echo hi" { + t.Fatalf("expected shell, got %q", got) + } + writeOKAY(t, conn) + + _, _ = conn.Write([]byte("hi\n")) + }) + defer closeFn() + + adb := NewADB(host, port) + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + out, err := adb.Shell(ctx, "serial-123", "echo hi") + if err != nil { + t.Fatalf("Shell error: %v", err) + } + if out != "hi\n" { + t.Fatalf("unexpected output %q", out) + } +} + +func TestADB_TrackDevices_StreamsFramesAndStopsOnCancel(t *testing.T) { + host, port, closeFn := startFakeADBServer(t, func(conn net.Conn) { + defer conn.Close() + + got := readADBService(t, conn) + if got != "host:track-devices" { + t.Fatalf("expected host:track-devices, got %q", got) + } + writeOKAY(t, conn) + + writeLenPrefixed(t, conn, "a\tdevice\n") + writeLenPrefixed(t, conn, "b\toffline\n") + + // keep the connection open until the client cancels (which closes the conn). + _, _ = io.Copy(io.Discard, conn) + }) + defer closeFn() + + adb := NewADB(host, port) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + updates, errs, err := adb.TrackDevices(ctx) + if err != nil { + t.Fatalf("TrackDevices error: %v", err) + } + + got1 := <-updates + got2 := <-updates + if got1 != "a\tdevice\n" { + t.Fatalf("unexpected frame 1: %q", got1) + } + if got2 != "b\toffline\n" { + t.Fatalf("unexpected frame 2: %q", got2) + } + + cancel() + + select { + case e := <-errs: + if e != nil { + // cancellation is not an error we surface on the error channel. + t.Fatalf("unexpected error: %v", e) + } + case <-time.After(250 * time.Millisecond): + // ok + } +} + +func TestADB_ResponseLimit(t *testing.T) { + host, port, closeFn := startFakeADBServer(t, func(conn net.Conn) { + defer conn.Close() + + _ = readADBService(t, conn) // host:transport:... + writeOKAY(t, conn) + + _ = readADBService(t, conn) // shell:... + writeOKAY(t, conn) + + // write slightly over 2MiB + payload := make([]byte, defaultADBMaxResponseBytes+1) + for i := range payload { + payload[i] = 'x' + } + _, _ = conn.Write(payload) + }) + defer closeFn() + + adb := NewADB(host, port) + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + _, err := adb.Shell(ctx, "serial-123", "echo big") + if err == nil { + t.Fatalf("expected error due to response limit") + } +} + +func TestADB_ExecOut_UsesTransportOnSameConnection(t *testing.T) { + host, port, closeFn := startFakeADBServer(t, func(conn net.Conn) { + defer conn.Close() + + got := readADBService(t, conn) + if got != "host:transport:serial-123" { + t.Fatalf("expected transport select, got %q", got) + } + writeOKAY(t, conn) + + got = readADBService(t, conn) + if got != "exec:cmd" { + t.Fatalf("expected exec, got %q", got) + } + writeOKAY(t, conn) + + _, _ = conn.Write([]byte("out\n")) + }) + defer closeFn() + + adb := NewADB(host, port) + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + out, err := adb.ExecOut(ctx, "serial-123", "cmd") + if err != nil { + t.Fatalf("ExecOut error: %v", err) + } + if out != "out\n" { + t.Fatalf("unexpected output %q", out) + } +} + +func TestADB_TrackDevices_IntegrationLocalhost5037(t *testing.T) { + adb := NewADB("localhost", 5037) + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + updates, errs, err := adb.TrackDevices(ctx) + if err != nil { + // if adb isn't running locally, skip. + t.Skipf("adb server not reachable on localhost:5037: %v", err) + } + + select { + case <-updates: + // any first update is acceptable (it can be empty if there are no devices). + case e := <-errs: + if e != nil { + t.Fatalf("TrackDevices error: %v", e) + } + case <-ctx.Done(): + t.Fatalf("timed out waiting for track-devices update") + } +} diff --git a/devices/android.go b/devices/android.go index 59b1653..dd81771 100644 --- a/devices/android.go +++ b/devices/android.go @@ -1192,7 +1192,7 @@ func (d *AndroidDevice) collectElements(node uiAutomatorXmlNode) []types.ScreenE } // process current node if it has text, content-desc, or hint - if node.Text != "" || node.ContentDesc != "" || node.Hint != "" { + if node.Text != "" || node.ContentDesc != "" || node.Hint != "" || node.ResourceID != "" { rect := d.getScreenElementRect(node.Bounds) // only include elements with positive width and height diff --git a/devices/android_webview.go b/devices/android_webview.go new file mode 100644 index 0000000..b469c86 --- /dev/null +++ b/devices/android_webview.go @@ -0,0 +1,381 @@ +package devices + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "strconv" + "strings" + "time" + + "github.com/mobile-next/mobilecli/agents" +) + +// WebViewInfo describes an embedded WebView found inside a running app. +type WebViewInfo struct { + ID string `json:"id"` + URL string `json:"url"` + Title string `json:"title"` + BundleID string `json:"bundleId"` + ProcessName string `json:"processName"` + Bounds map[string]any `json:"bounds,omitempty"` + IsVisible bool `json:"isVisible"` +} + +const agentSubDir = "mobilecli" + +// pushTempFile writes data to a host temp file then pushes it to the device +// at remotePath using adb push. +func (d *AndroidDevice) pushTempFile(data []byte, remotePath string) error { + tmp, err := os.CreateTemp("", "mobilecli-agent-*") + if err != nil { + return fmt.Errorf("create temp file: %w", err) + } + defer os.Remove(tmp.Name()) + + if _, err := tmp.Write(data); err != nil { + tmp.Close() + return fmt.Errorf("write temp file: %w", err) + } + tmp.Close() + + out, err := d.runAdbCommand("push", tmp.Name(), remotePath) + if err != nil { + return fmt.Errorf("adb push to %s: %s: %w", remotePath, strings.TrimSpace(string(out)), err) + } + return nil +} + +// getAppDataDir returns the data directory of the given package by running +// pwd as the app user. +func (d *AndroidDevice) getAppDataDir(pkg string) (string, error) { + out, err := d.runAdbCommand("shell", "run-as", pkg, "pwd") + if err != nil { + // fall back to the conventional path + return "/data/data/" + pkg, nil + } + dir := strings.TrimSpace(string(out)) + if dir == "" { + return "/data/data/" + pkg, nil + } + return dir, nil +} + +// copyToAppDir copies a file from /data/local/tmp into the app's data directory +// using run-as, then sets the given chmod mode. +func (d *AndroidDevice) copyToAppDir(pkg, tmpPath, destPath, mode string) error { + if out, err := d.runAdbCommand("shell", "run-as", pkg, "mkdir", "-p", destPath[:strings.LastIndex(destPath, "/")]); err != nil { + return fmt.Errorf("mkdir in app dir: %s: %w", strings.TrimSpace(string(out)), err) + } + if out, err := d.runAdbCommand("shell", "run-as", pkg, "cp", tmpPath, destPath); err != nil { + return fmt.Errorf("cp to app dir: %s: %w", strings.TrimSpace(string(out)), err) + } + if out, err := d.runAdbCommand("shell", "run-as", pkg, "chmod", mode, destPath); err != nil { + return fmt.Errorf("chmod %s %s: %s: %w", mode, destPath, strings.TrimSpace(string(out)), err) + } + return nil +} + +// installWebViewKit pushes devicekit.so and devicekit.dex to the app's data +// directory and returns the agent directory path. +func (d *AndroidDevice) installWebViewKit(pkg string) (string, error) { + dataDir, err := d.getAppDataDir(pkg) + if err != nil { + return "", err + } + agentDir := dataDir + "/" + agentSubDir + + const tmpSO = "/data/local/tmp/mobilecli-devicekit.so" + const tmpDEX = "/data/local/tmp/mobilecli-devicekit.dex" + + if err := d.pushTempFile(agents.AndroidDevicekitSO, tmpSO); err != nil { + return "", fmt.Errorf("push .so: %w", err) + } + if err := d.copyToAppDir(pkg, tmpSO, agentDir+"/devicekit.so", "755"); err != nil { + return "", fmt.Errorf("install .so: %w", err) + } + + if err := d.pushTempFile(agents.AndroidDevicekitDEX, tmpDEX); err != nil { + return "", fmt.Errorf("push .dex: %w", err) + } + // remove stale dex before copying (dex is immutable once loaded) + d.runAdbCommand("shell", "run-as", pkg, "rm", "-f", agentDir+"/devicekit.dex") + if err := d.copyToAppDir(pkg, tmpDEX, agentDir+"/devicekit.dex", "444"); err != nil { + return "", fmt.Errorf("install .dex: %w", err) + } + + return agentDir, nil +} + +// forwardWebViewSocket creates an adb forward from a random local TCP port to +// the agent's local abstract socket and returns the assigned port. +func (d *AndroidDevice) forwardWebViewSocket(pkg string) (int, error) { + out, err := d.runAdbCommand("forward", "tcp:0", "localabstract:mobilecli."+pkg) + if err != nil { + return 0, fmt.Errorf("adb forward: %s: %w", strings.TrimSpace(string(out)), err) + } + port, err := strconv.Atoi(strings.TrimSpace(string(out))) + if err != nil { + return 0, fmt.Errorf("unexpected adb forward output %q: %w", strings.TrimSpace(string(out)), err) + } + return port, nil +} + +// getProcessPID returns the PID of the running process for the given package. +func (d *AndroidDevice) getProcessPID(pkg string) (string, error) { + out, err := d.runAdbCommand("shell", "pidof", "-s", pkg) + if err != nil { + return "", fmt.Errorf("pidof %s: %s: %w", pkg, strings.TrimSpace(string(out)), err) + } + pid := strings.TrimSpace(string(out)) + if pid == "" { + return "", fmt.Errorf("no running process found for %s — is the app open?", pkg) + } + return pid, nil +} + +// attachJVMTIAgent attaches the .so to the running process via am attach-agent, +// passing the dex path as the agent option (agent.so=). +func (d *AndroidDevice) attachJVMTIAgent(pid, soPath, dexPath string) error { + agentArg := soPath + "=" + dexPath + out, err := d.runAdbCommand("shell", "am", "attach-agent", pid, agentArg) + if err != nil { + return fmt.Errorf("am attach-agent: %s: %w", strings.TrimSpace(string(out)), err) + } + return nil +} + +const defaultAgentTimeout = 10 * time.Second + +// agentRequest sends a JSON-RPC 2.0 request to the agent over HTTP and returns +// the result field from the response. +func agentRequest(port int, method string, params map[string]any) (json.RawMessage, error) { + return agentRequestWithTimeout(port, method, params, defaultAgentTimeout) +} + +func agentRequestWithTimeout(port int, method string, params map[string]any, timeout time.Duration) (json.RawMessage, error) { + body := map[string]any{ + "jsonrpc": "2.0", + "id": "1", + "method": method, + } + if len(params) > 0 { + body["params"] = params + } + + payload, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("marshal request: %w", err) + } + + client := &http.Client{Timeout: timeout} + resp, err := client.Post( + fmt.Sprintf("http://localhost:%d/", port), + "application/json", + bytes.NewReader(payload), + ) + if err != nil { + return nil, fmt.Errorf("connect to agent on port %d: %w", port, err) + } + defer resp.Body.Close() + + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read agent response: %w", err) + } + + var rpc struct { + Result json.RawMessage `json:"result"` + Error *struct { + Code int `json:"code"` + Message string `json:"message"` + } `json:"error"` + } + if err := json.Unmarshal(data, &rpc); err != nil { + return nil, fmt.Errorf("parse agent response: %w", err) + } + if rpc.Error != nil { + return nil, fmt.Errorf("agent error %d: %s", rpc.Error.Code, rpc.Error.Message) + } + return rpc.Result, nil +} + +// isAgentReady checks whether the agent socket is already accepting connections. +func isAgentReady(port int) bool { + client := &http.Client{Timeout: 300 * time.Millisecond} + resp, err := client.Post(fmt.Sprintf("http://localhost:%d/", port), "application/json", bytes.NewReader([]byte("{}"))) + if err != nil { + return false + } + resp.Body.Close() + return true +} + +// ensureAgentReady installs the kit, sets up the port forward, and attaches +// the agent if it is not already responding. Returns the local TCP port. +func (d *AndroidDevice) ensureAgentReady(pkg string) (int, error) { + agentDir, err := d.installWebViewKit(pkg) + if err != nil { + return 0, fmt.Errorf("install webview kit: %w", err) + } + + port, err := d.forwardWebViewSocket(pkg) + if err != nil { + return 0, fmt.Errorf("forward socket: %w", err) + } + + if !isAgentReady(port) { + pid, err := d.getProcessPID(pkg) + if err != nil { + return 0, err + } + if err := d.attachJVMTIAgent(pid, agentDir+"/devicekit.so", agentDir+"/devicekit.dex"); err != nil { + return 0, fmt.Errorf("attach agent: %w", err) + } + deadline := time.Now().Add(5 * time.Second) + for !isAgentReady(port) { + if time.Now().After(deadline) { + return 0, fmt.Errorf("agent did not start within 5s on port %d", port) + } + time.Sleep(200 * time.Millisecond) + } + } + + return port, nil +} + +// getWebViewPort resolves the foreground app and ensures the agent is ready, +// returning the local TCP port to use for RPC calls. +func (d *AndroidDevice) getWebViewPort() (int, error) { + foreground, err := d.GetForegroundApp() + if err != nil { + return 0, fmt.Errorf("could not determine foreground app: %w", err) + } + return d.ensureAgentReady(foreground.PackageName) +} + +func (d *AndroidDevice) ListWebViews() ([]WebViewInfo, error) { + port, err := d.getWebViewPort() + if err != nil { + return nil, err + } + result, err := agentRequest(port, "device.webview.list", nil) + if err != nil { + return nil, err + } + var webviews []WebViewInfo + if err := json.Unmarshal(result, &webviews); err != nil { + return nil, fmt.Errorf("parse webview list: %w", err) + } + return webviews, nil +} + +func (d *AndroidDevice) WebViewGoto(webviewID, url string) error { + port, err := d.getWebViewPort() + if err != nil { + return err + } + _, err = agentRequest(port, "device.webview.goto", map[string]any{"id": webviewID, "url": url}) + return err +} + +func (d *AndroidDevice) WebViewReload(webviewID string) error { + port, err := d.getWebViewPort() + if err != nil { + return err + } + _, err = agentRequest(port, "device.webview.reload", map[string]any{"id": webviewID}) + return err +} + +func (d *AndroidDevice) WebViewGoBack(webviewID string) error { + port, err := d.getWebViewPort() + if err != nil { + return err + } + _, err = agentRequest(port, "device.webview.goBack", map[string]any{"id": webviewID}) + return err +} + +func (d *AndroidDevice) WebViewGoForward(webviewID string) error { + port, err := d.getWebViewPort() + if err != nil { + return err + } + _, err = agentRequest(port, "device.webview.goForward", map[string]any{"id": webviewID}) + return err +} + +func (d *AndroidDevice) WebViewContent(webviewID string) (string, error) { + result, err := d.WebViewEvaluate(webviewID, "return document.documentElement.outerHTML", nil) + if err != nil { + return "", err + } + content, ok := result.(string) + if !ok { + return "", fmt.Errorf("unexpected content type %T", result) + } + return content, nil +} + +// ensureReturnExpression prepends "return" to bare expressions so the agent's +// eval wrapper can capture their value. Expressions that already start with +// "return", contain a statement separator, or look like a block are left as-is. +func ensureReturnExpression(expression string) string { + trimmed := strings.TrimSpace(expression) + if strings.HasPrefix(trimmed, "return ") || + strings.Contains(trimmed, ";") || + strings.Contains(trimmed, "\n") || + strings.HasPrefix(trimmed, "{") { + return expression + } + return "return (" + trimmed + ")" +} + +func (d *AndroidDevice) WebViewEvaluate(webviewID, expression string, args []any) (any, error) { + port, err := d.getWebViewPort() + if err != nil { + return nil, err + } + params := map[string]any{ + "id": webviewID, + "expression": ensureReturnExpression(expression), + } + if len(args) > 0 { + params["args"] = args + } + raw, err := agentRequest(port, "device.webview.evaluate", params) + if err != nil { + return nil, err + } + // agent returns {"result": } — unwrap one level + var wrapper struct { + Result any `json:"result"` + } + if err := json.Unmarshal(raw, &wrapper); err != nil { + return nil, fmt.Errorf("parse evaluate result: %w", err) + } + return wrapper.Result, nil +} + +func (d *AndroidDevice) WebViewWaitForLoadState(webviewID, state string, timeoutMs int) error { + port, err := d.getWebViewPort() + if err != nil { + return err + } + const agentDefaultMs = 30_000 + waitMs := agentDefaultMs + if timeoutMs > 0 { + waitMs = timeoutMs + } + params := map[string]any{"id": webviewID, "timeout": waitMs} + if state != "" { + params["state"] = state + } + httpTimeout := time.Duration(waitMs)*time.Millisecond + 5*time.Second + _, err = agentRequestWithTimeout(port, "device.webview.waitForLoadState", params, httpTimeout) + return err +} diff --git a/devices/common.go b/devices/common.go index 75f0565..5ba5aff 100644 --- a/devices/common.go +++ b/devices/common.go @@ -130,6 +130,18 @@ type ControllableDevice interface { GetCrashReport(id string) ([]byte, error) } +// WebViewable is implemented by devices that support webview inspection and control. +type WebViewable interface { + ListWebViews() ([]WebViewInfo, error) + WebViewGoto(webviewID, url string) error + WebViewReload(webviewID string) error + WebViewGoBack(webviewID string) error + WebViewGoForward(webviewID string) error + WebViewContent(webviewID string) (string, error) + WebViewEvaluate(webviewID, expression string, args []any) (any, error) + WebViewWaitForLoadState(webviewID, state string, timeoutMs int) error +} + // GetAllControllableDevices aggregates all known devices with options func GetAllControllableDevices(includeOffline bool) ([]ControllableDevice, error) { diff --git a/devices/ios_device_webview.go b/devices/ios_device_webview.go new file mode 100644 index 0000000..6a6ba03 --- /dev/null +++ b/devices/ios_device_webview.go @@ -0,0 +1,136 @@ +package devices + +import ( + "encoding/json" + "fmt" + "time" + + goios "github.com/danielpaulus/go-ios/ios" +) + +func iosDeviceDebugProxyPort(device goios.DeviceEntry) (int, error) { + if !device.SupportsRsd() { + return 0, fmt.Errorf("device does not support RSD — enable developer mode") + } + p := device.Rsd.GetPort("com.apple.internal.dt.remote.debugproxy") + if p == 0 { + return 0, fmt.Errorf("com.apple.internal.dt.remote.debugproxy not in RSD") + } + return p, nil +} + +func (d *IOSDevice) ListWebViews() ([]WebViewInfo, error) { + port, err := d.ensureIOSDeviceAgentReady() + if err != nil { + return nil, err + } + result, err := agentRequest(port, "device.webview.list", nil) + if err != nil { + setCachedAgentPort(d.Udid, 0) + return nil, err + } + var raw []struct { + ID string `json:"id"` + URL string `json:"url"` + Title string `json:"title"` + Bounds map[string]any `json:"bounds"` + Visible bool `json:"visible"` + } + if err := json.Unmarshal(result, &raw); err != nil { + return nil, fmt.Errorf("parse webview list: %w", err) + } + webviews := make([]WebViewInfo, len(raw)) + for i, wv := range raw { + webviews[i] = WebViewInfo{ID: wv.ID, URL: wv.URL, Title: wv.Title, Bounds: wv.Bounds, IsVisible: wv.Visible} + } + return webviews, nil +} + +func (d *IOSDevice) WebViewGoto(webviewID, url string) error { + port, err := d.ensureIOSDeviceAgentReady() + if err != nil { + return err + } + _, err = agentRequest(port, "device.webview.goto", map[string]any{"id": webviewID, "url": url}) + return err +} + +func (d *IOSDevice) WebViewReload(webviewID string) error { + port, err := d.ensureIOSDeviceAgentReady() + if err != nil { + return err + } + _, err = agentRequest(port, "device.webview.reload", map[string]any{"id": webviewID}) + return err +} + +func (d *IOSDevice) WebViewGoBack(webviewID string) error { + port, err := d.ensureIOSDeviceAgentReady() + if err != nil { + return err + } + _, err = agentRequest(port, "device.webview.goBack", map[string]any{"id": webviewID}) + return err +} + +func (d *IOSDevice) WebViewGoForward(webviewID string) error { + port, err := d.ensureIOSDeviceAgentReady() + if err != nil { + return err + } + _, err = agentRequest(port, "device.webview.goForward", map[string]any{"id": webviewID}) + return err +} + +func (d *IOSDevice) WebViewContent(webviewID string) (string, error) { + result, err := d.WebViewEvaluate(webviewID, "return document.documentElement.outerHTML", nil) + if err != nil { + return "", err + } + s, ok := result.(string) + if !ok { + return "", fmt.Errorf("unexpected content type %T", result) + } + return s, nil +} + +func (d *IOSDevice) WebViewEvaluate(webviewID, expression string, args []any) (any, error) { + port, err := d.ensureIOSDeviceAgentReady() + if err != nil { + return nil, err + } + params := map[string]any{ + "id": webviewID, + "expression": ensureReturnExpression(expression), + } + if len(args) > 0 { + params["args"] = args + } + raw, err := agentRequest(port, "device.webview.evaluate", params) + if err != nil { + return nil, err + } + var wrapper struct { + Result any `json:"result"` + } + if err := json.Unmarshal(raw, &wrapper); err != nil { + return nil, fmt.Errorf("parse evaluate result: %w", err) + } + return wrapper.Result, nil +} + +func (d *IOSDevice) WebViewWaitForLoadState(webviewID, state string, timeoutMs int) error { + port, err := d.ensureIOSDeviceAgentReady() + if err != nil { + return err + } + if timeoutMs <= 0 { + timeoutMs = 30_000 + } + _, err = agentRequestWithTimeout(port, "device.webview.waitForLoadState", map[string]any{ + "id": webviewID, + "state": state, + "timeout": timeoutMs, + }, time.Duration(timeoutMs+5000)*time.Millisecond) + return err +} diff --git a/devices/ios_webview.go b/devices/ios_webview.go new file mode 100644 index 0000000..912f78c --- /dev/null +++ b/devices/ios_webview.go @@ -0,0 +1,957 @@ +package devices + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net" + "os" + "os/exec" + "path/filepath" + "regexp" + "strconv" + "strings" + "sync" + "time" + + goios "github.com/danielpaulus/go-ios/ios" + "github.com/danielpaulus/go-ios/ios/debuggertools" + "github.com/danielpaulus/go-ios/ios/debugserver" + "github.com/danielpaulus/go-ios/ios/installationproxy" + "github.com/danielpaulus/go-ios/ios/instruments" + "github.com/mobile-next/mobilecli/agents" + iosutil "github.com/mobile-next/mobilecli/devices/ios" + "github.com/mobile-next/mobilecli/utils" +) + +// agentPortCache maps device UDID → local TCP port of its injected agent. +var ( + agentPortCache = map[string]int{} + agentPortCacheMu sync.Mutex +) + +func cachedAgentPort(udid string) (int, bool) { + agentPortCacheMu.Lock() + defer agentPortCacheMu.Unlock() + port, ok := agentPortCache[udid] + return port, ok +} + +func setCachedAgentPort(udid string, port int) { + agentPortCacheMu.Lock() + defer agentPortCacheMu.Unlock() + agentPortCache[udid] = port +} + +// findSimulatorForegroundApp searches the Mac process list for an app process +// running inside the given simulator and returns its PID and bundle ID. +func findSimulatorForegroundApp(udid string) (pid int, bundleID string, err error) { + out, err := exec.Command("ps", "aux").Output() + if err != nil { + return 0, "", fmt.Errorf("ps aux: %w", err) + } + + // match lines for app binaries inside this simulator's Bundle directory + pattern := fmt.Sprintf(`CoreSimulator/Devices/%s/data/Containers/Bundle`, udid) + var candidates []struct { + pid int + path string + } + + for _, line := range strings.Split(string(out), "\n") { + if !strings.Contains(line, pattern) { + continue + } + fields := strings.Fields(line) + if len(fields) < 11 { + continue + } + p, parseErr := strconv.Atoi(fields[1]) + if parseErr != nil { + continue + } + // fields[10] is the executable path + candidates = append(candidates, struct { + pid int + path string + }{p, fields[10]}) + } + + if len(candidates) == 0 { + return 0, "", fmt.Errorf("no app process found in simulator %s — is an app running?", udid) + } + if len(candidates) > 1 { + // pick the most recently listed (last in ps output) as a heuristic for foreground + // in practice most simulator sessions have a single app + } + candidate := candidates[len(candidates)-1] + + // extract the .app bundle directory from the executable path + appBundlePath := appBundleFromExecPath(candidate.path) + if appBundlePath == "" { + return 0, "", fmt.Errorf("could not locate .app bundle from path %q", candidate.path) + } + + bid, err := bundleIDFromInfoPlist(filepath.Join(appBundlePath, "Info.plist")) + if err != nil { + return 0, "", fmt.Errorf("read bundle ID: %w", err) + } + + return candidate.pid, bid, nil +} + +// appBundleFromExecPath returns the .app directory containing the executable. +// e.g. ".../playground.app/playground" → ".../playground.app" +func appBundleFromExecPath(execPath string) string { + re := regexp.MustCompile(`(.*\.app)/`) + m := re.FindStringSubmatch(execPath) + if len(m) < 2 { + return "" + } + return m[1] +} + +// bundleIDFromInfoPlist reads CFBundleIdentifier from an Info.plist using +// the macOS `defaults read` command, which handles both XML and binary plists. +func bundleIDFromInfoPlist(plistPath string) (string, error) { + out, err := exec.Command("defaults", "read", plistPath, "CFBundleIdentifier").Output() + if err != nil { + return "", fmt.Errorf("defaults read %s: %w", plistPath, err) + } + return strings.TrimSpace(string(out)), nil +} + +// writeIOSAgentDylib writes the embedded simulator dylib to a temp file and +// returns the path. The caller is responsible for removing the file. +func writeIOSAgentDylib() (string, error) { + f, err := os.CreateTemp("", "mobilecli-agent-*.dylib") + if err != nil { + return "", fmt.Errorf("create temp dylib: %w", err) + } + if _, err := f.Write(agents.IOSAgentSimDylib); err != nil { + f.Close() + os.Remove(f.Name()) + return "", fmt.Errorf("write dylib: %w", err) + } + f.Close() + return f.Name(), nil +} + +var portFromLLDB = regexp.MustCompile(`\$\d+\s*=\s*(\d+)`) + +// injectIOSAgent attaches lldb to the given PID, loads the dylib, reads the +// bound port via mobilecli_get_port(), then detaches. Returns the port. +func injectIOSAgent(pid int, dylibPath string) (int, error) { + cmd := exec.Command("lldb", + "-p", strconv.Itoa(pid), + "-o", fmt.Sprintf("expr (void*)dlopen(%q, 2)", dylibPath), + "-o", "expr (int)mobilecli_get_port()", + "-o", "detach", + "-o", "quit", + ) + out, err := cmd.CombinedOutput() + if err != nil { + return 0, fmt.Errorf("lldb: %w\noutput:\n%s", err, out) + } + + // find the port in a line like: (int) $1 = 27042 + for _, line := range strings.Split(string(out), "\n") { + if strings.Contains(line, "mobilecli_get_port") { + continue + } + m := portFromLLDB.FindStringSubmatch(line) + if m == nil { + continue + } + port, err := strconv.Atoi(m[1]) + if err != nil || port == 0 { + continue + } + return port, nil + } + return 0, fmt.Errorf("could not parse port from lldb output:\n%s", out) +} + +// ensureIOSAgentReady ensures the iOS agent is running inside the simulator +// and returns the local TCP port to connect to. +func (s *SimulatorDevice) ensureIOSAgentReady() (int, error) { + // fast path: reuse the port we injected into this simulator previously + if port, ok := cachedAgentPort(s.UDID); ok && isAgentReady(port) { + return port, nil + } + + pid, _, err := findSimulatorForegroundApp(s.UDID) + if err != nil { + return 0, err + } + + dylibPath, err := writeIOSAgentDylib() + if err != nil { + return 0, err + } + defer os.Remove(dylibPath) + + port, err := injectIOSAgent(pid, dylibPath) + if err != nil { + return 0, fmt.Errorf("inject agent: %w", err) + } + + // agent binds synchronously before dlopen returns, but give it a moment + deadline := time.Now().Add(3 * time.Second) + for !isAgentReady(port) { + if time.Now().After(deadline) { + return 0, fmt.Errorf("iOS agent did not respond on port %d within 3s", port) + } + time.Sleep(100 * time.Millisecond) + } + setCachedAgentPort(s.UDID, port) + return port, nil +} + +func (s *SimulatorDevice) webViewAction(wvID, method string) error { + port, err := s.ensureIOSAgentReady() + if err != nil { + return err + } + _, err = agentRequest(port, method, map[string]any{"id": wvID}) + return err +} + +// WebViewReload reloads the page in the given webview. +func (s *SimulatorDevice) WebViewReload(wvID string) error { + return s.webViewAction(wvID, "device.webview.reload") +} + +// WebViewGoBack navigates the given webview back in history. +func (s *SimulatorDevice) WebViewGoBack(wvID string) error { + return s.webViewAction(wvID, "device.webview.goBack") +} + +// WebViewGoForward navigates the given webview forward in history. +func (s *SimulatorDevice) WebViewGoForward(wvID string) error { + return s.webViewAction(wvID, "device.webview.goForward") +} + +// WebViewContent returns the full outer HTML of the page in the given webview. +func (s *SimulatorDevice) WebViewContent(wvID string) (string, error) { + result, err := s.WebViewEvaluate(wvID, "return document.documentElement.outerHTML", nil) + if err != nil { + return "", err + } + content, ok := result.(string) + if !ok { + return "", fmt.Errorf("unexpected content type %T", result) + } + return content, nil +} + +// WebViewWaitForLoadState blocks until the webview reaches the given load state. +// timeoutMs of 0 uses the agent's default (30s). +func (s *SimulatorDevice) WebViewWaitForLoadState(wvID, state string, timeoutMs int) error { + port, err := s.ensureIOSAgentReady() + if err != nil { + return err + } + const agentDefaultMs = 30_000 + waitMs := agentDefaultMs + if timeoutMs > 0 { + waitMs = timeoutMs + } + params := map[string]any{"id": wvID, "timeout": waitMs} + if state != "" { + params["state"] = state + } + httpTimeout := time.Duration(waitMs)*time.Millisecond + 5*time.Second + _, err = agentRequestWithTimeout(port, "device.webview.waitForLoadState", params, httpTimeout) + return err +} + +// WebViewGoto navigates the webview identified by wvID to url. +func (s *SimulatorDevice) WebViewGoto(wvID, url string) error { + port, err := s.ensureIOSAgentReady() + if err != nil { + return err + } + _, err = agentRequest(port, "device.webview.goto", map[string]any{"id": wvID, "url": url}) + return err +} + +// WebViewEvaluate runs expression in the webview and returns the JS result value. +func (s *SimulatorDevice) WebViewEvaluate(wvID, expression string, args []any) (any, error) { + port, err := s.ensureIOSAgentReady() + if err != nil { + return nil, err + } + params := map[string]any{ + "id": wvID, + "expression": ensureReturnExpression(expression), + } + if len(args) > 0 { + params["args"] = args + } + raw, err := agentRequest(port, "device.webview.evaluate", params) + if err != nil { + return nil, err + } + var wrapper struct { + Result any `json:"result"` + } + if err := json.Unmarshal(raw, &wrapper); err != nil { + return nil, fmt.Errorf("parse evaluate result: %w", err) + } + return wrapper.Result, nil +} + +// ListWebViews returns all embedded WKWebViews found in the foreground simulator app. +func (s *SimulatorDevice) ListWebViews() ([]WebViewInfo, error) { + port, err := s.ensureIOSAgentReady() + if err != nil { + return nil, err + } + + result, err := agentRequest(port, "device.webview.list", nil) + if err != nil { + return nil, err + } + + var raw []struct { + ID string `json:"id"` + URL string `json:"url"` + Title string `json:"title"` + Bounds map[string]any `json:"bounds"` + Visible bool `json:"visible"` + } + if err := json.Unmarshal(result, &raw); err != nil { + return nil, fmt.Errorf("parse webview list: %w", err) + } + + webviews := make([]WebViewInfo, len(raw)) + for i, wv := range raw { + webviews[i] = WebViewInfo{ + ID: wv.ID, + URL: wv.URL, + Title: wv.Title, + Bounds: wv.Bounds, + IsVisible: wv.Visible, + } + } + return webviews, nil +} + +// ── IOSDevice (real device) ─────────────────────────────────────────────────── + + +type userApp struct { + pid int + bundleID string + teamID string +} + +// userApps returns all currently running user-installed apps (PID + bundle ID), +// using installationproxy + instruments, with no WDA dependency. +func (d *IOSDevice) userApps(device goios.DeviceEntry) ([]userApp, error) { + utils.Verbose("connecting to installationproxy") + svc, err := installationproxy.New(device) + if err != nil { + return nil, fmt.Errorf("installationproxy: %w", err) + } + defer svc.Close() + + utils.Verbose("browsing user apps") + apps, err := svc.BrowseUserApps() + if err != nil { + return nil, fmt.Errorf("browse user apps: %w", err) + } + utils.Verbose("found %d installed user apps", len(apps)) + execToBundleID := map[string]string{} + for _, app := range apps { + execToBundleID[app.CFBundleExecutable()] = app.CFBundleIdentifier() + } + + utils.Verbose("connecting to instruments device info service") + infoSvc, err := instruments.NewDeviceInfoService(device) + if err != nil { + return nil, fmt.Errorf("device info service: %w", err) + } + defer infoSvc.Close() + + utils.Verbose("fetching process list") + processes, err := infoSvc.ProcessList() + if err != nil { + return nil, fmt.Errorf("process list: %w", err) + } + utils.Verbose("got %d processes", len(processes)) + + // also build a map from bundleID to teamIdentifier + bundleToTeam := map[string]string{} + for _, app := range apps { + if tid, ok := app["TeamIdentifier"].(string); ok { + bundleToTeam[app.CFBundleIdentifier()] = tid + } + } + + var result []userApp + for _, p := range processes { + if bid, ok := execToBundleID[p.Name]; ok { + result = append(result, userApp{pid: int(p.Pid), bundleID: bid, teamID: bundleToTeam[bid]}) + } + } + utils.Verbose("found %d running user apps", len(result)) + return result, nil +} + +// findForegroundApp finds the foreground user app by attaching to each candidate +// via the CoreDevice debug proxy and checking UIApplicationState via ObjC runtime. +func (d *IOSDevice) findForegroundApp(device goios.DeviceEntry, apps []userApp) (*userApp, error) { + if !device.SupportsRsd() { + return nil, fmt.Errorf("device does not support RSD") + } + proxyPort := device.Rsd.GetPort("com.apple.internal.dt.remote.debugproxy") + if proxyPort == 0 { + return nil, fmt.Errorf("com.apple.internal.dt.remote.debugproxy not in RSD") + } + utils.Verbose("debug proxy port: %d", proxyPort) + + for i := range apps { + app := &apps[i] + utils.Verbose("checking app %s (pid %d)", app.bundleID, app.pid) + conn, err := goios.ConnectTUNDevice(device.Address, proxyPort, device) + if err != nil { + utils.Verbose("connect to debug proxy for %s: %v", app.bundleID, err) + continue + } + gdb := debugserver.NewGDBServer(conn) + utils.Verbose("attaching to pid %d", app.pid) + resp, err := gdb.Request(fmt.Sprintf("vAttach;%x", app.pid)) + if err != nil || !strings.HasPrefix(resp, "T") { + utils.Verbose("attach to pid %d failed: err=%v resp=%q", app.pid, err, resp) + conn.Close() + continue + } + utils.Verbose("attached to pid %d, checking UIApplicationState", app.pid) + rt, err := debuggertools.NewObjCRuntime(gdb) + if err != nil { + utils.Verbose("ObjCRuntime for pid %d: %v", app.pid, err) + gdb.Request(fmt.Sprintf("D;%x", app.pid)) //nolint:errcheck + conn.Close() + continue + } + appInst, err := rt.ClassCall("UIApplication", "sharedApplication") + var state uint64 + if err == nil { + state, _ = rt.Call(appInst, "applicationState") + } + rt.Cleanup() + gdb.Request(fmt.Sprintf("D;%x", app.pid)) //nolint:errcheck + conn.Close() + utils.Verbose("pid %d (%s) applicationState=%d", app.pid, app.bundleID, state) + if err == nil && state == 0 { + utils.Verbose("foreground app: %s (pid %d)", app.bundleID, app.pid) + return app, nil + } + } + return nil, fmt.Errorf("no foreground user app found — is an app open?") +} + +// iosDeviceAgentExpr is an ObjC expression evaluated via LLDB that binds a TCP +// server socket inside the target app process and starts an HTTP/JSON-RPC +// accept loop on a GCD background queue. The accept loop persists after LLDB +// detaches. The expression evaluates to the bound port number. +const iosDeviceAgentExpr = ` +@import Foundation; +// UIKit/WebKit class metadata avoided — calls cast result to silence LLDB strict mode +extern Class objc_getClass(const char *); +// inline C declarations — LLDB remote-ios doesn't have SDK headers on include path +typedef unsigned int __socklen_t; +typedef unsigned short __in_port_t; +typedef unsigned int __in_addr_t; +struct __in_addr { __in_addr_t s_addr; }; +struct __sockaddr_in { + unsigned char sin_len; unsigned char sin_family; + __in_port_t sin_port; struct __in_addr sin_addr; char sin_zero[8]; +}; +struct __sockaddr { unsigned char sa_len; unsigned char sa_family; char sa_data[14]; }; +extern int socket(int, int, int); +extern int setsockopt(int, int, int, const void *, __socklen_t); +extern int bind(int, const struct __sockaddr *, __socklen_t); +extern int listen(int, int); +extern int accept(int, struct __sockaddr *, __socklen_t *); +extern long recv(int, void *, unsigned long, int); +extern long send(int, const void *, unsigned long, int); +extern int close(int); +extern __in_port_t htons(__in_port_t); +extern __in_addr_t htonl(__in_addr_t); +extern void *memset(void *, int, unsigned long); +#define __AF_INET 2 +#define __SOCK_STREAM 1 +#define __SOL_SOCKET 0xffff +#define __SO_REUSEADDR 0x0004 +#define __INADDR_LOOPBACK 0x7f000001UL +int __sfd = socket(__AF_INET, __SOCK_STREAM, 0); +int __opt = 1; +setsockopt(__sfd, __SOL_SOCKET, __SO_REUSEADDR, &__opt, sizeof(__opt)); +struct __sockaddr_in __sa; memset(&__sa, 0, sizeof(__sa)); +__sa.sin_family = __AF_INET; +__sa.sin_addr.s_addr = htonl(__INADDR_LOOPBACK); +int __port = 0; +for (int __p = 27042; __p < 27052; __p++) { + __sa.sin_port = htons((unsigned short)__p); + if (bind(__sfd, (struct __sockaddr *)&__sa, sizeof(__sa)) == 0) { __port = __p; break; } +} +if (__port > 0) { + listen(__sfd, 8); + int __srv = __sfd; + dispatch_async(dispatch_get_global_queue(0, 0), ^{ + id (^__findWV)(NSString *) = ^id(NSString *wvId) { + __block id found = nil; + id sem = dispatch_semaphore_create(0); + [[NSOperationQueue mainQueue] addOperationWithBlock:^{ + Class wk = NSClassFromString(@"WKWebView"); + Class wsCls = (Class)objc_getClass("UIWindowScene"); + id app = (id)[(Class)objc_getClass("UIApplication") sharedApplication]; + for (id sc in (NSArray *)[app connectedScenes]) + if ([(NSObject *)sc isKindOfClass:wsCls]) + for (id w in (NSArray *)[sc windows]) { + NSMutableArray *stk = [NSMutableArray arrayWithObject:w]; + while ([stk count]) { + id v = stk[0]; [stk removeObjectAtIndex:0]; + if (wk && [(NSObject *)v isKindOfClass:wk] && + [[NSString stringWithFormat:@"%p", v] isEqualToString:wvId]) + { found = v; break; } + [stk addObjectsFromArray:(NSArray *)[v subviews]]; + } + if (found) break; + } + dispatch_semaphore_signal(sem); + }]; + dispatch_semaphore_wait(sem, dispatch_time(0, 5000000000LL)); + return found; + }; + while (1) { + int cfd = accept(__srv, NULL, NULL); + if (cfd < 0) continue; + dispatch_async(dispatch_get_global_queue(0, 0), ^{ + NSMutableData *buf = [NSMutableData data]; + char tmp[4096]; long n; + NSData *crlf = [@"\r\n\r\n" dataUsingEncoding:NSASCIIStringEncoding]; + while ((n = recv(cfd, tmp, sizeof(tmp), 0)) > 0) { + [buf appendBytes:tmp length:(NSUInteger)n]; + NSRange sep = [buf rangeOfData:crlf options:0 range:NSMakeRange(0, buf.length)]; + if (sep.location != NSNotFound) { + NSString *hdr = [[NSString alloc] initWithData:[buf subdataWithRange:NSMakeRange(0, sep.location)] encoding:NSASCIIStringEncoding]; + NSInteger cl = 0; + for (NSString *__hdrLine in [hdr componentsSeparatedByString:@"\r\n"]) + if ([__hdrLine.lowercaseString hasPrefix:@"content-length:"]) + cl = [[__hdrLine substringFromIndex:15] integerValue]; + NSUInteger bs = sep.location + 4; + while ((NSInteger)(buf.length - bs) < cl && (n = recv(cfd, tmp, sizeof(tmp), 0)) > 0) + [buf appendBytes:tmp length:(NSUInteger)n]; + break; + } + } + NSRange hr = [buf rangeOfData:crlf options:0 range:NSMakeRange(0, buf.length)]; + NSData *body = (hr.location == NSNotFound) ? [NSData data] : + [buf subdataWithRange:NSMakeRange(hr.location + 4, buf.length - hr.location - 4)]; + NSDictionary *req = [NSJSONSerialization JSONObjectWithData:body options:0 error:nil]; + id rqId = req[@"id"] ?: [NSNull null]; + NSString *method = req[@"method"] ?: @""; + NSDictionary *params = req[@"params"]; + NSData *resp = nil; + if ([method isEqualToString:@"device.webview.list"]) { + __block NSMutableArray *wvs = [NSMutableArray array]; + id sem = dispatch_semaphore_create(0); + [[NSOperationQueue mainQueue] addOperationWithBlock:^{ + Class wk = NSClassFromString(@"WKWebView"); + Class wsCls = (Class)objc_getClass("UIWindowScene"); + id app = (id)[(Class)objc_getClass("UIApplication") sharedApplication]; + for (id sc in (NSArray *)[app connectedScenes]) + if ([(NSObject *)sc isKindOfClass:wsCls]) + for (id win in (NSArray *)[sc windows]) { + NSMutableArray *stk = [NSMutableArray arrayWithObject:win]; + while ([stk count]) { + id v = stk[0]; [stk removeObjectAtIndex:0]; + if (wk && [(NSObject *)v isKindOfClass:wk]) { + [wvs addObject:@{ + @"id": [NSString stringWithFormat:@"%p", v], + @"url": [(NSURL *)[v URL] absoluteString] ?: @"", + @"title": (NSString *)[v title] ?: @"", + @"bounds": @{@"x":@0,@"y":@0,@"width":@0,@"height":@0}, + @"visible": @(!(BOOL)[v isHidden] && (id)[v window] != nil) + }]; + } + [stk addObjectsFromArray:(NSArray *)[v subviews]]; + } + } + dispatch_semaphore_signal(sem); + }]; + dispatch_semaphore_wait(sem, dispatch_time(0, 5000000000LL)); + resp = [NSJSONSerialization dataWithJSONObject:@{@"jsonrpc":@"2.0",@"id":rqId,@"result":wvs} options:0 error:nil]; + } else if ([method isEqualToString:@"device.webview.goto"]) { + NSString *wvId = params[@"id"], *url = params[@"url"]; + id wv = (wvId && url) ? __findWV(wvId) : nil; + if (!wvId || !url) resp = [NSJSONSerialization dataWithJSONObject:@{@"jsonrpc":@"2.0",@"id":rqId,@"error":@{@"code":@(-32602),@"message":@"missing id or url"}} options:0 error:nil]; + else if (!wv) resp = [NSJSONSerialization dataWithJSONObject:@{@"jsonrpc":@"2.0",@"id":rqId,@"error":@{@"code":@(-32000),@"message":@"webview not found"}} options:0 error:nil]; + else { + id sem = dispatch_semaphore_create(0); + [[NSOperationQueue mainQueue] addOperationWithBlock:^{ (void)[wv loadRequest:[NSURLRequest requestWithURL:[NSURL URLWithString:url]]]; dispatch_semaphore_signal(sem); }]; + dispatch_semaphore_wait(sem, dispatch_time(0, 5000000000LL)); + resp = [NSJSONSerialization dataWithJSONObject:@{@"jsonrpc":@"2.0",@"id":rqId,@"result":@{@"status":@"ok"}} options:0 error:nil]; + } + } else if ([@[@"device.webview.reload",@"device.webview.goBack",@"device.webview.goForward"] containsObject:method]) { + NSString *wvId = params[@"id"]; + id wv = wvId ? __findWV(wvId) : nil; + if (!wvId) resp = [NSJSONSerialization dataWithJSONObject:@{@"jsonrpc":@"2.0",@"id":rqId,@"error":@{@"code":@(-32602),@"message":@"missing id"}} options:0 error:nil]; + else if (!wv) resp = [NSJSONSerialization dataWithJSONObject:@{@"jsonrpc":@"2.0",@"id":rqId,@"error":@{@"code":@(-32000),@"message":@"webview not found"}} options:0 error:nil]; + else { + id sem = dispatch_semaphore_create(0); + [[NSOperationQueue mainQueue] addOperationWithBlock:^{ + if ([method isEqualToString:@"device.webview.reload"]) (void)[wv reload]; + else if ([method isEqualToString:@"device.webview.goBack"]) (void)[wv goBack]; + else (void)[wv goForward]; + dispatch_semaphore_signal(sem); + }]; + dispatch_semaphore_wait(sem, dispatch_time(0, 5000000000LL)); + resp = [NSJSONSerialization dataWithJSONObject:@{@"jsonrpc":@"2.0",@"id":rqId,@"result":@{@"status":@"ok"}} options:0 error:nil]; + } + } else if ([method isEqualToString:@"device.webview.evaluate"]) { + NSString *wvId = params[@"id"], *expr = params[@"expression"]; + id wv = (wvId && expr) ? __findWV(wvId) : nil; + if (!wvId || !expr) resp = [NSJSONSerialization dataWithJSONObject:@{@"jsonrpc":@"2.0",@"id":rqId,@"error":@{@"code":@(-32602),@"message":@"missing id or expression"}} options:0 error:nil]; + else if (!wv) resp = [NSJSONSerialization dataWithJSONObject:@{@"jsonrpc":@"2.0",@"id":rqId,@"error":@{@"code":@(-32000),@"message":@"webview not found"}} options:0 error:nil]; + else { + NSString *wrapped = [NSString stringWithFormat:@"(function(){try{%@}catch(e){return{__mce:e.toString()}}})()", expr]; + __block id jsResult = nil; __block NSError *jsError = nil; + id sem = dispatch_semaphore_create(0); + [[NSOperationQueue mainQueue] addOperationWithBlock:^{ + (void)[wv evaluateJavaScript:wrapped completionHandler:^(id r, NSError *e) { jsResult = r; jsError = e; dispatch_semaphore_signal(sem); }]; + }]; + dispatch_semaphore_wait(sem, dispatch_time(0, 10000000000LL)); + if (jsError) resp = [NSJSONSerialization dataWithJSONObject:@{@"jsonrpc":@"2.0",@"id":rqId,@"error":@{@"code":@(-32000),@"message":jsError.localizedDescription}} options:0 error:nil]; + else if ([(NSObject *)jsResult isKindOfClass:[NSDictionary class]] && jsResult[@"__mce"]) resp = [NSJSONSerialization dataWithJSONObject:@{@"jsonrpc":@"2.0",@"id":rqId,@"error":@{@"code":@(-32000),@"message":jsResult[@"__mce"]}} options:0 error:nil]; + else resp = [NSJSONSerialization dataWithJSONObject:@{@"jsonrpc":@"2.0",@"id":rqId,@"result":@{@"result":jsResult?:[NSNull null]}} options:0 error:nil]; + } + } else if ([method isEqualToString:@"device.webview.waitForLoadState"]) { + NSString *wvId = params[@"id"]; + id wv = wvId ? __findWV(wvId) : nil; + if (!wvId) resp = [NSJSONSerialization dataWithJSONObject:@{@"jsonrpc":@"2.0",@"id":rqId,@"error":@{@"code":@(-32602),@"message":@"missing id"}} options:0 error:nil]; + else if (!wv) resp = [NSJSONSerialization dataWithJSONObject:@{@"jsonrpc":@"2.0",@"id":rqId,@"error":@{@"code":@(-32000),@"message":@"webview not found"}} options:0 error:nil]; + else { + NSString *state = params[@"state"] ?: @"load"; + NSInteger toMs = params[@"timeout"] ? [(NSNumber *)params[@"timeout"] integerValue] : 30000; + NSString *checkJS = [@"domcontentloaded" isEqualToString:state] ? + @"return String(document.readyState==='interactive'||document.readyState==='complete')" : + @"return String(document.readyState==='complete')"; + NSDate *deadline = [NSDate dateWithTimeIntervalSinceNow:toMs / 1000.0]; + BOOL done = NO; + while (!done && [[NSDate date] compare:deadline] == NSOrderedAscending) { + __block id jsR = nil; + id sem2 = dispatch_semaphore_create(0); + NSString *wrapped = [NSString stringWithFormat:@"(function(){try{%@}catch(e){return null}})()", checkJS]; + [[NSOperationQueue mainQueue] addOperationWithBlock:^{ + (void)[wv evaluateJavaScript:wrapped completionHandler:^(id r, NSError *e) { jsR = r; dispatch_semaphore_signal(sem2); }]; + }]; + dispatch_semaphore_wait(sem2, dispatch_time(0, 5000000000LL)); + if ([@"true" isEqualToString:jsR]) done = YES; + else [NSThread sleepForTimeInterval:0.2]; + } + resp = done ? + [NSJSONSerialization dataWithJSONObject:@{@"jsonrpc":@"2.0",@"id":rqId,@"result":@{@"status":@"ok"}} options:0 error:nil] : + [NSJSONSerialization dataWithJSONObject:@{@"jsonrpc":@"2.0",@"id":rqId,@"error":@{@"code":@(-32000),@"message":@"timed out"}} options:0 error:nil]; + } + } + if (!resp) resp = [NSJSONSerialization dataWithJSONObject:@{@"jsonrpc":@"2.0",@"id":rqId,@"error":@{@"code":@(-32601),@"message":[NSString stringWithFormat:@"method not found: %@", method]}} options:0 error:nil]; + if (!resp) resp = [@"{\"jsonrpc\":\"2.0\",\"error\":{\"code\":-32603,\"message\":\"internal error\"}}" dataUsingEncoding:NSUTF8StringEncoding]; + NSString *hdrs = [NSString stringWithFormat:@"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: %lu\r\nConnection: close\r\n\r\n", (unsigned long)resp.length]; + NSData *hdrData = [hdrs dataUsingEncoding:NSASCIIStringEncoding]; + send(cfd, hdrData.bytes, hdrData.length, 0); + send(cfd, resp.bytes, resp.length, 0); + close(cfd); + }); + } + }); +} +(int)__port +` + +// startLLDBProxy pre-attaches to pid on the device via the debug proxy, then +// starts a local TCP listener for LLDB. Pre-attaching before listening ensures +// the proxy can respond to LLDB's handshake immediately upon connection. +// Returns the local port and a stop function. +func startLLDBProxy(device goios.DeviceEntry, proxyPort, pid int) (int, func(), error) { + utils.Verbose("lldb-proxy: connecting to device debug proxy port %d", proxyPort) + devConn, err := goios.ConnectTUNDevice(device.Address, proxyPort, device) + if err != nil { + return 0, nil, fmt.Errorf("lldb-proxy: connect to device: %w", err) + } + + devGDB := debugserver.NewGDBServer(devConn) + utils.Verbose("lldb-proxy: pre-attaching to pid %d", pid) + stopReply, err := devGDB.Request(fmt.Sprintf("vAttach;%x", pid)) + if err != nil || !strings.HasPrefix(stopReply, "T") { + devConn.Close() + return 0, nil, fmt.Errorf("lldb-proxy: vAttach pid %d: err=%v resp=%q", pid, err, stopReply) + } + utils.Verbose("lldb-proxy: pre-attached, stop=%q", stopReply) + + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + devConn.Close() + return 0, nil, fmt.Errorf("listen for lldb proxy: %w", err) + } + localPort := ln.Addr().(*net.TCPAddr).Port + go func() { + defer ln.Close() + defer devConn.Close() + conn, err := ln.Accept() + if err != nil { + return + } + lldbProxyConn(conn, devGDB, pid) + }() + return localPort, func() { ln.Close() }, nil +} + +// lldbProxyConn is a GDB RSP bridge between LLDB and an already-attached +// device debugserver. Handles negotiation packets locally, forwards all others +// packet-by-packet with ack-mode translation (LLDB: no-ack; device: ack). +func lldbProxyConn(c net.Conn, devGDB *debugserver.GDBServer, pid int) { + defer c.Close() + + // debugserver sends '+' immediately upon accepting a connection; + // LLDB waits for this before sending the first packet. + c.Write([]byte("+")) //nolint:errcheck + + noAck := false + + gdbChecksum := func(pkt string) byte { + var sum byte + for i := 0; i < len(pkt); i++ { + sum += pkt[i] + } + return sum + } + + sendToLLDB := func(pkt string) { + ck := gdbChecksum(pkt) + var s string + if !noAck { + s = "+" + } + s += fmt.Sprintf("$%s#%02x", pkt, ck) + c.Write([]byte(s)) //nolint:errcheck + } + + recvFromLLDB := func() (string, error) { + buf := make([]byte, 1) + for { + if _, err := io.ReadFull(c, buf); err != nil { + return "", err + } + if buf[0] == '$' { + break + } + } + var pkt strings.Builder + for { + if _, err := io.ReadFull(c, buf); err != nil { + return "", err + } + if buf[0] == '#' { + break + } + pkt.WriteByte(buf[0]) + } + cksumBuf := make([]byte, 2) + if _, err := io.ReadFull(c, cksumBuf); err != nil { + return "", err + } + return pkt.String(), nil + } + + for { + pkt, err := recvFromLLDB() + if err != nil { + return + } + utils.Verbose("lldb-proxy ← LLDB: %.120s", pkt) + + // switchToNoAck is set by QStartNoAckMode and applied AFTER sendToLLDB + // so the OK response goes out in ack mode (with '+') as LLDB expects. + switchToNoAck := false + var reply string + switch { + case pkt == "QStartNoAckMode": + reply = "OK" + switchToNoAck = true + + case strings.HasPrefix(pkt, "qSupported"): + reply = "PacketSize=65536;vContSupported+" + + case pkt == "QThreadSuffixSupported", + pkt == "QListThreadsInStopReply", + pkt == "qVAttachOrWaitSupported", + pkt == "QEnableErrorStrings": + reply = "OK" + + case strings.HasPrefix(pkt, "vCont?"): + reply = "vCont;c;C;s;S" + + case pkt == "k": + // LLDB wants to kill — detach instead so the app keeps running + devGDB.Request(fmt.Sprintf("D;%x", pid)) //nolint:errcheck + return + + case strings.HasPrefix(pkt, "D"): + devReply, _ := devGDB.Request(pkt) + sendToLLDB(devReply) + return + + default: + // forward to device (devGDB uses ack mode: sends "+$pkt#XX") + devReply, err := devGDB.Request(pkt) + if err != nil { + utils.Verbose("lldb-proxy: device error for %q: %v", pkt[:min(len(pkt), 40)], err) + return + } + reply = devReply + } + + sendToLLDB(reply) + if switchToNoAck { + noAck = true + } + } +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} + +// injectServerViaLLDB connects LLDB to the proxy (which has already attached to +// the target process), evaluates iosDeviceAgentExpr to start a persistent HTTP +// server inside the app, and returns the device-side TCP port. +func injectServerViaLLDB(localProxyPort int) (int, error) { + const lldbTimeout = 120 * time.Second + ctx, cancel := context.WithTimeout(context.Background(), lldbTimeout) + defer cancel() + + utils.Verbose("running LLDB (timeout %s)", lldbTimeout) + cmd := exec.CommandContext(ctx, "lldb", + "-o", "settings set target.process.memory-cache-line-size 16384", + "-o", "platform select remote-ios", + "-o", fmt.Sprintf("process connect connect://localhost:%d", localProxyPort), + "-o", "expr -l objc -- "+iosDeviceAgentExpr, + "-o", "detach", + "-o", "quit", + ) + out, err := cmd.CombinedOutput() + utils.Verbose("LLDB finished (err=%v), output:\n%s", err, out) + if err != nil { + return 0, fmt.Errorf("lldb: %w\noutput:\n%s", err, out) + } + + for _, line := range strings.Split(string(out), "\n") { + m := portFromLLDB.FindStringSubmatch(line) + if m == nil { + continue + } + port, err := strconv.Atoi(m[1]) + if err == nil && port >= 27042 && port <= 27051 { + return port, nil + } + } + return 0, fmt.Errorf("could not parse port from lldb output:\n%s", out) +} + +// findFreeLocalPort returns the first available local TCP port in the given range. +func findFreeLocalPort(start, end int) (int, error) { + for p := start; p <= end; p++ { + ln, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", p)) + if err == nil { + ln.Close() + return p, nil + } + } + return 0, fmt.Errorf("no free port in range %d-%d", start, end) +} + +func (d *IOSDevice) ensureIOSDeviceAgentReady() (int, error) { + // fast path: reuse the forwarded port we set up for this device previously + if port, ok := cachedAgentPort(d.Udid); ok && isAgentReady(port) { + utils.Verbose("reusing cached agent port %d", port) + return port, nil + } + + if err := d.startTunnel(); err != nil { + return 0, fmt.Errorf("start tunnel: %w", err) + } + utils.Verbose("getting enhanced device info") + device, err := d.getEnhancedDevice() + if err != nil { + return 0, fmt.Errorf("get enhanced device: %w", err) + } + proxyPort, err := iosDeviceDebugProxyPort(device) + if err != nil { + return 0, err + } + utils.Verbose("debug proxy port from RSD: %d", proxyPort) + + utils.Verbose("listing running user apps") + apps, err := d.userApps(device) + if err != nil { + return 0, err + } + if len(apps) == 0 { + return 0, fmt.Errorf("no user app running — open an app first") + } + + utils.Verbose("finding foreground app among %d candidates", len(apps)) + foreground, err := d.findForegroundApp(device, apps) + if err != nil { + return 0, err + } + + utils.Verbose("injecting agent into %s (pid %d) via LLDB", foreground.bundleID, foreground.pid) + // start a local TCP proxy so LLDB can reach the device debug proxy + lldbProxyPort, cancelProxy, err := startLLDBProxy(device, proxyPort, foreground.pid) + if err != nil { + return 0, fmt.Errorf("start lldb proxy: %w", err) + } + defer cancelProxy() + utils.Verbose("LLDB proxy listening on localhost:%d", lldbProxyPort) + + devicePort, err := injectServerViaLLDB(lldbProxyPort) + if err != nil { + return 0, fmt.Errorf("inject server via lldb: %w", err) + } + utils.Verbose("agent started on device port %d", devicePort) + + localPort, err := findFreeLocalPort(27042, 27051) + if err != nil { + return 0, err + } + + utils.Verbose("forwarding localhost:%d -> device:%d", localPort, devicePort) + pf := iosutil.NewPortForwarder(d.Udid) + if err := pf.Forward(localPort, devicePort); err != nil { + return 0, fmt.Errorf("port forward %d->%d: %w", localPort, devicePort, err) + } + + utils.Verbose("waiting for agent to respond on port %d", localPort) + deadline := time.Now().Add(3 * time.Second) + for !isAgentReady(localPort) { + if time.Now().After(deadline) { + return 0, fmt.Errorf("iOS device agent did not respond within 3s") + } + time.Sleep(100 * time.Millisecond) + } + utils.Verbose("agent ready on port %d", localPort) + setCachedAgentPort(d.Udid, localPort) + return localPort, nil +} + + diff --git a/go.mod b/go.mod index bb292bd..959e3a9 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/mobile-next/mobilecli go 1.26.2 require ( - github.com/danielpaulus/go-ios v1.0.211 + github.com/danielpaulus/go-ios v1.0.207-0.20260326100139-5d5f0d1129b8 github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.3 github.com/hashicorp/golang-lru/v2 v2.0.7 @@ -42,8 +42,6 @@ require ( github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8 // indirect github.com/spf13/pflag v1.0.6 // indirect github.com/tadglines/go-pkgs v0.0.0-20210623144937-b983b20f54f9 // indirect - github.com/vishvananda/netlink v1.3.1 // indirect - github.com/vishvananda/netns v0.0.5 // indirect go.mozilla.org/pkcs7 v0.0.0-20210826202110-33d05740a352 // indirect go.uber.org/mock v0.5.0 // indirect golang.org/x/crypto v0.45.0 // indirect diff --git a/go.sum b/go.sum index ff3a28c..a2e8f1e 100644 --- a/go.sum +++ b/go.sum @@ -11,8 +11,8 @@ github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6N github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/danieljoos/wincred v1.2.2 h1:774zMFJrqaeYCK2W57BgAem/MLi6mtSE47MB6BOJ0i0= github.com/danieljoos/wincred v1.2.2/go.mod h1:w7w4Utbrz8lqeMbDAK0lkNJUv5sAOkFi7nd/ogr0Uh8= -github.com/danielpaulus/go-ios v1.0.211 h1:REv11Hc+kt3LEAEwYkps0r7KUew0kYWs1rgw2UJeEug= -github.com/danielpaulus/go-ios v1.0.211/go.mod h1:f5q5S4XJT53AA8cdgp3rLA41YaIpyaDg+w8aURzLNhM= +github.com/danielpaulus/go-ios v1.0.207-0.20260326100139-5d5f0d1129b8 h1:NNFHipsp8/cSRKKODH87wNBz/Gf1+fNQbBfF71IFFKM= +github.com/danielpaulus/go-ios v1.0.207-0.20260326100139-5d5f0d1129b8/go.mod h1:ZkUcaC59yNba47j/+ULKsCi3dYPFwY9r39PxdmVmLHE= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -90,10 +90,6 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/tadglines/go-pkgs v0.0.0-20210623144937-b983b20f54f9 h1:aeN+ghOV0b2VCmKKO3gqnDQ8mLbpABZgRR2FVYx4ouI= github.com/tadglines/go-pkgs v0.0.0-20210623144937-b983b20f54f9/go.mod h1:roo6cZ/uqpwKMuvPG0YmzI5+AmUiMWfjCBZpGXqbTxE= -github.com/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW6bV0= -github.com/vishvananda/netlink v1.3.1/go.mod h1:ARtKouGSTGchR8aMwmkzC0qiNPrrWO5JS/XMVl45+b4= -github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY= -github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM= github.com/yapingcat/gomedia v0.0.0-20240906162731-17feea57090c h1:xA2TJS9Hu/ivzaZIrDcwvpJ3Fnpsk5fDOJ4iSnL6J0w= github.com/yapingcat/gomedia v0.0.0-20240906162731-17feea57090c/go.mod h1:WSZ59bidJOO40JSJmLqlkBJrjZCtjbKKkygEMfzY/kc= github.com/zalando/go-keyring v0.2.6 h1:r7Yc3+H+Ux0+M72zacZoItR3UDxeWfKTcabvkI8ua9s= @@ -130,8 +126,6 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= diff --git a/server/dispatch.go b/server/dispatch.go index 8404435..953aeb5 100644 --- a/server/dispatch.go +++ b/server/dispatch.go @@ -12,35 +12,42 @@ type HandlerFunc func(params json.RawMessage) (any, error) // This is used by both the HTTP server and embedded clients func GetMethodRegistry() map[string]HandlerFunc { return map[string]HandlerFunc{ - "devices.list": handleDevicesList, - "device.screenshot": handleScreenshot, - "device.screencapture": handleScreenCaptureSession, - "device.io.tap": handleIoTap, - "device.io.longpress": handleIoLongPress, - "device.io.text": handleIoText, - "device.io.button": handleIoButton, - "device.io.swipe": handleIoSwipe, - "device.io.gesture": handleIoGesture, - "device.url": handleURL, - "device.info": handleDeviceInfo, - "device.io.orientation.get": handleIoOrientationGet, - "device.io.orientation.set": handleIoOrientationSet, - "device.boot": handleDeviceBoot, - "device.shutdown": handleDeviceShutdown, - "device.reboot": handleDeviceReboot, - "device.dump.ui": handleDumpUI, - "device.apps.launch": handleAppsLaunch, - "device.apps.terminate": handleAppsTerminate, - "device.apps.list": handleAppsList, - "device.apps.foreground": handleAppsForeground, - "device.apps.install": handleAppsInstall, - "device.apps.uninstall": handleAppsUninstall, - "device.screenrecord": handleScreenRecord, - "device.screenrecord.stop": handleScreenRecordStop, - "device.crashes.list": handleCrashesList, - "device.crashes.get": handleCrashesGet, - "server.info": handleServerInfo, - "server.shutdown": handleServerShutdown, + "devices.list": handleDevicesList, + "device.screenshot": handleScreenshot, + "device.screencapture": handleScreenCaptureSession, + "device.io.tap": handleIoTap, + "device.io.longpress": handleIoLongPress, + "device.io.text": handleIoText, + "device.io.button": handleIoButton, + "device.io.swipe": handleIoSwipe, + "device.io.gesture": handleIoGesture, + "device.url": handleURL, + "device.info": handleDeviceInfo, + "device.io.orientation.get": handleIoOrientationGet, + "device.io.orientation.set": handleIoOrientationSet, + "device.boot": handleDeviceBoot, + "device.shutdown": handleDeviceShutdown, + "device.reboot": handleDeviceReboot, + "device.dump.ui": handleDumpUI, + "device.apps.launch": handleAppsLaunch, + "device.apps.terminate": handleAppsTerminate, + "device.apps.list": handleAppsList, + "device.apps.foreground": handleAppsForeground, + "device.apps.install": handleAppsInstall, + "device.apps.uninstall": handleAppsUninstall, + "device.screenrecord": handleScreenRecord, + "device.screenrecord.stop": handleScreenRecordStop, + "device.crashes.list": handleCrashesList, + "device.crashes.get": handleCrashesGet, + "device.webview.list": handleWebViewList, + "device.webview.goto": handleWebViewGoto, + "device.webview.reload": handleWebViewReload, + "device.webview.goBack": handleWebViewGoBack, + "device.webview.goForward": handleWebViewGoForward, + "device.webview.evaluate": handleWebViewEvaluate, + "device.webview.waitForLoadState": handleWebViewWaitForLoadState, + "server.info": handleServerInfo, + "server.shutdown": handleServerShutdown, } } diff --git a/server/webview_handlers.go b/server/webview_handlers.go new file mode 100644 index 0000000..8679a94 --- /dev/null +++ b/server/webview_handlers.go @@ -0,0 +1,192 @@ +package server + +import ( + "encoding/json" + "fmt" + + "github.com/mobile-next/mobilecli/commands" +) + +// ─── Params structs ─────────────────────────────────────────── + +type WebViewListParams struct { + DeviceID string `json:"deviceId"` +} + +type WebViewParams struct { + DeviceID string `json:"deviceId"` + WebViewID string `json:"id"` +} + +type WebViewGotoParams struct { + DeviceID string `json:"deviceId"` + WebViewID string `json:"id"` + URL string `json:"url"` + WaitUntil string `json:"waitUntil,omitempty"` +} + +type WebViewReloadParams struct { + DeviceID string `json:"deviceId"` + WebViewID string `json:"id"` + WaitUntil string `json:"waitUntil,omitempty"` +} + +type WebViewEvaluateParams struct { + DeviceID string `json:"deviceId"` + WebViewID string `json:"id"` + Expression string `json:"expression"` + Args []any `json:"args,omitempty"` +} + +type WebViewWaitForLoadStateParams struct { + DeviceID string `json:"deviceId"` + WebViewID string `json:"id"` + State string `json:"state,omitempty"` + Timeout int `json:"timeout,omitempty"` +} + +// ─── Shared helpers ─────────────────────────────────────────── + +func unmarshal[T any](params json.RawMessage) (T, error) { + var p T + if err := json.Unmarshal(params, &p); err != nil { + return p, fmt.Errorf("invalid parameters: %w", err) + } + return p, nil +} + +func resultOf(resp *commands.CommandResponse) (any, error) { + if resp.Status == "error" { + return nil, fmt.Errorf("%s", resp.Error) + } + return resp.Data, nil +} + +func voidOf(resp *commands.CommandResponse) (any, error) { + if resp.Status == "error" { + return nil, fmt.Errorf("%s", resp.Error) + } + return okResponse, nil +} + +func requireWebViewParams(deviceID, webViewID string) error { + if deviceID == "" { + return fmt.Errorf("deviceId is required") + } + if webViewID == "" { + return fmt.Errorf("id is required") + } + return nil +} + +// ─── Handlers ───────────────────────────────────────────────── + +func handleWebViewList(params json.RawMessage) (any, error) { + p, err := unmarshal[WebViewListParams](params) + if err != nil { + return nil, err + } + if p.DeviceID == "" { + return nil, fmt.Errorf("deviceId is required") + } + return resultOf(commands.WebViewListCommand(commands.WebViewListRequest{ + DeviceID: p.DeviceID, + })) +} + +func handleWebViewGoto(params json.RawMessage) (any, error) { + p, err := unmarshal[WebViewGotoParams](params) + if err != nil { + return nil, err + } + if err := requireWebViewParams(p.DeviceID, p.WebViewID); err != nil { + return nil, err + } + if p.URL == "" { + return nil, fmt.Errorf("url is required") + } + return voidOf(commands.WebViewGotoCommand(commands.WebViewGotoRequest{ + DeviceID: p.DeviceID, + WebViewID: p.WebViewID, + URL: p.URL, + WaitUntil: p.WaitUntil, + })) +} + +func handleWebViewReload(params json.RawMessage) (any, error) { + p, err := unmarshal[WebViewReloadParams](params) + if err != nil { + return nil, err + } + if err := requireWebViewParams(p.DeviceID, p.WebViewID); err != nil { + return nil, err + } + return voidOf(commands.WebViewReloadCommand(commands.WebViewReloadRequest{ + DeviceID: p.DeviceID, + WebViewID: p.WebViewID, + WaitUntil: p.WaitUntil, + })) +} + +func handleWebViewGoBack(params json.RawMessage) (any, error) { + p, err := unmarshal[WebViewParams](params) + if err != nil { + return nil, err + } + if err := requireWebViewParams(p.DeviceID, p.WebViewID); err != nil { + return nil, err + } + return voidOf(commands.WebViewGoBackCommand(commands.WebViewRequest{ + DeviceID: p.DeviceID, + WebViewID: p.WebViewID, + })) +} + +func handleWebViewGoForward(params json.RawMessage) (any, error) { + p, err := unmarshal[WebViewParams](params) + if err != nil { + return nil, err + } + if err := requireWebViewParams(p.DeviceID, p.WebViewID); err != nil { + return nil, err + } + return voidOf(commands.WebViewGoForwardCommand(commands.WebViewRequest{ + DeviceID: p.DeviceID, + WebViewID: p.WebViewID, + })) +} + +func handleWebViewEvaluate(params json.RawMessage) (any, error) { + p, err := unmarshal[WebViewEvaluateParams](params) + if err != nil { + return nil, err + } + if err := requireWebViewParams(p.DeviceID, p.WebViewID); err != nil { + return nil, err + } + if p.Expression == "" { + return nil, fmt.Errorf("expression is required") + } + return resultOf(commands.WebViewEvaluateCommand(commands.WebViewEvaluateRequest{ + DeviceID: p.DeviceID, + WebViewID: p.WebViewID, + Expression: p.Expression, + Args: p.Args, + })) +} + +func handleWebViewWaitForLoadState(params json.RawMessage) (any, error) { + p, err := unmarshal[WebViewWaitForLoadStateParams](params) + if err != nil { + return nil, err + } + if err := requireWebViewParams(p.DeviceID, p.WebViewID); err != nil { + return nil, err + } + return voidOf(commands.WebViewWaitForLoadStateCommand(commands.WebViewWaitForLoadStateRequest{ + DeviceID: p.DeviceID, + WebViewID: p.WebViewID, + State: p.State, + Timeout: p.Timeout, + })) +}