diff --git a/pyproject.toml b/pyproject.toml
index b39ced4..00df85a 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -17,7 +17,7 @@ fastapi = "^0.68.0"
aiofiles = "^23.1.0"
uiautomator2 = "^3.0.0"
facebook-wda = "^1.0.5"
-tidevice = "^0.12.10"
+#tidevice = "^0.12.10"
hmdriver2 = "^1.4.0"
[tool.poetry.extras]
diff --git a/uiviewer/_device.py b/uiviewer/_device.py
index ef4737b..d02d269 100644
--- a/uiviewer/_device.py
+++ b/uiviewer/_device.py
@@ -9,13 +9,13 @@
from PIL import Image
from requests import request
-import tidevice
import adbutils
import wda
import uiautomator2 as u2
from hmdriver2 import hdc
from fastapi import HTTPException
+from uiviewer.go_ios_cli import GoIOS
from uiviewer._logger import logger
from uiviewer._utils import file2base64, image2base64
from uiviewer._models import Platform, BaseHierarchy
@@ -28,8 +28,9 @@ def list_serials(platform: str) -> List[str]:
raws = adbutils.AdbClient().device_list()
devices = [item.serial for item in raws]
elif platform == Platform.IOS:
- raw = tidevice.Usbmux().device_list()
- devices = [d.udid for d in raw]
+ raw = GoIOS.list_devices()
+
+ devices = raw.get("deviceList", [])
else:
devices = hdc.list_devices()
diff --git a/uiviewer/go_ios_cli.py b/uiviewer/go_ios_cli.py
new file mode 100644
index 0000000..c9e29c2
--- /dev/null
+++ b/uiviewer/go_ios_cli.py
@@ -0,0 +1,371 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+"""
+go-ios命令封装模块
+提供对go-ios命令的封装,方便在Python中使用
+"""
+
+import os
+import json
+import time
+import subprocess
+from typing import Dict, List, Optional, Union, Any
+
+# 配置日志
+
+
+class GoIOSError(Exception):
+ """go-ios命令执行错误"""
+ def __init__(self, message: str, cmd: str = "", stderr: str = ""):
+ self.message = message
+ self.cmd = cmd
+ self.stderr = stderr
+ super().__init__(f"{message}, cmd: {cmd}, stderr: {stderr}")
+
+
+class GoIOS:
+ """go-ios命令封装类"""
+
+ @staticmethod
+ def _run_command(cmd: List[str], check: bool = True, json_output: bool = True) -> Union[Dict, str]:
+ """
+ 运行go-ios命令
+
+ Args:
+ cmd: 命令列表
+ check: 是否检查命令执行状态
+ json_output: 是否将输出解析为JSON
+
+ Returns:
+ Dict或str: 命令执行结果
+
+ Raises:
+ GoIOSError: 命令执行错误
+ """
+ try:
+ # logger.debug(f"执行命令: {' '.join(cmd)}")
+ result = subprocess.run(
+ cmd,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ text=True,
+ check=check
+ )
+
+ if result.returncode != 0:
+ raise GoIOSError(
+ f"命令执行失败,返回码: {result.returncode}",
+ cmd=' '.join(cmd),
+ stderr=result.stderr
+ )
+
+ if json_output:
+ try:
+ return json.loads(result.stdout)
+ except json.JSONDecodeError:
+ # logger.warning(f"无法解析JSON输出: {result.stdout}")
+ return result.stdout
+ else:
+ return result.stdout
+ except subprocess.CalledProcessError as e:
+ raise GoIOSError(
+ f"命令执行异常: {e}",
+ cmd=' '.join(cmd),
+ stderr=e.stderr if hasattr(e, 'stderr') else ""
+ )
+ except Exception as e:
+ raise GoIOSError(f"执行命令时发生错误: {e}", cmd=' '.join(cmd))
+
+ @classmethod
+ def get_version(cls) -> Dict:
+ """
+ 获取go-ios版本信息
+
+ Returns:
+ Dict: 版本信息
+ """
+ return cls._run_command(["ios", "version"])
+
+ @classmethod
+ def list_devices(cls, details: bool = False) -> Dict:
+ """
+ 获取设备列表
+
+ Args:
+ details: 是否获取详细信息
+
+ Returns:
+ Dict: 设备列表
+ """
+ cmd = ["ios", "list"]
+ if details:
+ cmd.append("--details")
+ return cls._run_command(cmd)
+
+ @classmethod
+ def get_device_info(cls, udid: str) -> Dict:
+ """
+ 获取设备信息
+
+ Args:
+ udid: 设备UDID
+
+ Returns:
+ Dict: 设备信息
+ """
+ return cls._run_command(["ios", "info", f"--udid={udid}"])
+
+ @classmethod
+ def ios_tunnel_status(cls,udid:str=None) -> Dict:
+ """
+ 启动隧道
+
+ Returns:
+ Dict: 隧道信息
+ """
+ cmd = ["ios", "tunnel", "ls"]
+ if udid:
+ cmd.append(f"--udid={udid}")
+ return cls._run_command(cmd)
+
+ @classmethod
+ def forward_port(cls, host_port: int, device_port: int, udid: Optional[str] = None) -> Dict:
+ """
+ 端口转发
+
+ Args:
+ host_port: 主机端口
+ device_port: 设备端口
+ udid: 设备UDID,如果为None则使用第一个可用设备
+
+ Returns:
+ Dict: 转发结果
+ """
+ cmd = ["ios", "forward", f"{host_port}", f"{device_port}"]
+ if udid:
+ cmd.append(f"--udid={udid}")
+ return cls._run_command(cmd)
+
+ @classmethod
+ def remove_forward(cls, host_port: int, udid: Optional[str] = None) -> Dict:
+ """
+ 移除端口转发
+
+ Args:
+ host_port: 主机端口
+ udid: 设备UDID,如果为None则使用第一个可用设备
+
+ Returns:
+ Dict: 移除结果
+ """
+ cmd = ["ios", "forward", "--remove", f"{host_port}"]
+ if udid:
+ cmd.append(f"--udid={udid}")
+ return cls._run_command(cmd)
+
+ @classmethod
+ def list_forward(cls, udid: Optional[str] = None) -> Dict:
+ """
+ 列出端口转发
+
+ Args:
+ udid: 设备UDID,如果为None则使用第一个可用设备
+
+ Returns:
+ Dict: 转发列表
+ """
+ cmd = ["ios", "forward", "--list"]
+ if udid:
+ cmd.append(f"--udid={udid}")
+ return cls._run_command(cmd)
+
+ @classmethod
+ def run_wda(cls, bundle_id: str, test_runner_bundle_id: str,
+ xctestconfig: str = "WebDriverAgentRunner.xctest",
+ udid: Optional[str] = None) -> subprocess.Popen:
+ """
+ 运行WDA(非阻塞方式)
+
+ Args:
+ bundle_id: WDA Bundle ID
+ test_runner_bundle_id: Test Runner Bundle ID
+ xctestconfig: XCTest配置
+ udid: 设备UDID,如果为None则使用第一个可用设备
+
+ Returns:
+ subprocess.Popen: 进程对象,可用于后续管理
+ """
+ cmd = [
+ "ios", "runwda",
+ f"--bundleid={bundle_id}",
+ f"--testrunnerbundleid={test_runner_bundle_id}",
+ f"--xctestconfig={xctestconfig}"
+ ]
+ if udid:
+ cmd.append(f"--udid={udid}")
+
+ # logger.debug(f"执行命令: {' '.join(cmd)}")
+ try:
+ # 使用Popen非阻塞方式启动WDA
+ process = subprocess.Popen(
+ cmd,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ text=True
+ )
+ # 等待一小段时间,确保进程启动
+ time.sleep(1)
+
+ # 检查进程是否已经终止(表示启动失败)
+ if process.poll() is not None:
+ stderr = process.stderr.read() if process.stderr else ""
+ raise GoIOSError(
+ f"WDA启动失败,进程已终止,返回码: {process.returncode}",
+ cmd=' '.join(cmd),
+ stderr=stderr
+ )
+
+ # logger.info("WDA启动成功(非阻塞)")
+ return process
+ except Exception as e:
+ if not isinstance(e, GoIOSError):
+ e = GoIOSError(f"启动WDA时发生错误: {e}", cmd=' '.join(cmd))
+ raise e
+
+ @classmethod
+ def list_apps(cls, udid:str, is_system:bool=False, is_all:bool=False) -> Dict:
+ """
+ 获取应用列表
+
+ Args:
+ udid: 设备UDID,如果为None则使用第一个可用设备
+
+ Returns:
+ Dict: 应用列表
+ """
+ cmd = ["ios", "apps", '--list']
+ if is_system:
+ cmd.append("--system")
+ if is_all:
+ cmd.append("--all")
+ cmd.append(f"--udid={udid}")
+
+ return cls._run_command(cmd)
+
+ @classmethod
+ def install_app(cls, ipa_path: str, udid: Optional[str] = None) -> Dict:
+ """
+ 安装应用
+
+ Args:
+ ipa_path: IPA文件路径
+ udid: 设备UDID,如果为None则使用第一个可用设备
+
+ Returns:
+ Dict: 安装结果
+ """
+ cmd = ["ios", "install", ipa_path]
+ if udid:
+ cmd.append(f"--udid={udid}")
+ return cls._run_command(cmd)
+
+ @classmethod
+ def uninstall_app(cls, bundle_id: str, udid: Optional[str] = None) -> Dict:
+ """
+ 卸载应用
+
+ Args:
+ bundle_id: 应用Bundle ID
+ udid: 设备UDID,如果为None则使用第一个可用设备
+
+ Returns:
+ Dict: 卸载结果
+ """
+ cmd = ["ios", "uninstall", bundle_id]
+ if udid:
+ cmd.append(f"--udid={udid}")
+ return cls._run_command(cmd)
+
+ @classmethod
+ def launch_app(cls, bundle_id: str, udid: Optional[str] = None) -> Dict:
+ """
+ 启动应用
+
+ Args:
+ bundle_id: 应用Bundle ID
+ udid: 设备UDID,如果为None则使用第一个可用设备
+
+ Returns:
+ Dict: 启动结果
+ """
+ cmd = ["ios", "launch", bundle_id]
+ if udid:
+ cmd.append(f"--udid={udid}")
+ return cls._run_command(cmd)
+
+ @classmethod
+ def terminate_app(cls, bundle_id: str, udid: Optional[str] = None) -> Dict:
+ """
+ 终止应用
+
+ Args:
+ bundle_id: 应用Bundle ID
+ udid: 设备UDID,如果为None则使用第一个可用设备
+
+ Returns:
+ Dict: 终止结果
+ """
+ cmd = ["ios", "kill", bundle_id]
+ if udid:
+ cmd.append(f"--udid={udid}")
+ return cls._run_command(cmd)
+
+ @classmethod
+ def get_app_state(cls, bundle_id: str, udid: Optional[str] = None) -> Dict:
+ """
+ 获取应用状态
+
+ Args:
+ bundle_id: 应用Bundle ID
+ udid: 设备UDID,如果为None则使用第一个可用设备
+
+ Returns:
+ Dict: 应用状态
+ """
+ cmd = ["ios", "appstate", bundle_id]
+ if udid:
+ cmd.append(f"--udid={udid}")
+ return cls._run_command(cmd)
+
+ @classmethod
+ def take_screenshot(cls, output_path: Optional[str], udid: Optional[str] = None) -> str:
+ """
+ 截图
+
+ Args:
+ output_path: 输出路径,如果为None则使用临时文件
+ udid: 设备UDID,如果为None则使用第一个可用设备
+
+ Returns:
+ str: 截图路径
+ """
+ if not output_path:
+ raise ValueError("输出路径不能为空")
+
+ cmd = ["ios", "screenshot", f"--output={output_path}"]
+ if udid:
+ cmd.append(f"--udid={udid}")
+
+ try:
+ cls._run_command(cmd, json_output=False)
+ return output_path
+ except Exception as e:
+ # logger.error(f"截图失败: {e}")
+ return ""
+
+
+if __name__ == "__main__":
+ # # 测试代码
+ devices = GoIOS.list_devices()
+ print(devices)
\ No newline at end of file
diff --git a/uiviewer/routers/api.py b/uiviewer/routers/api.py
index 8b40318..8d74bb7 100644
--- a/uiviewer/routers/api.py
+++ b/uiviewer/routers/api.py
@@ -1,9 +1,10 @@
# -*- coding: utf-8 -*-
-from typing import Union, Dict, Any
+from typing import Union, Dict, Any, Optional
-from fastapi import APIRouter, Query
+from fastapi import APIRouter, Query, HTTPException
from fastapi.responses import RedirectResponse
+from pydantic import BaseModel
from uiviewer._device import (
list_serials,
@@ -16,11 +17,19 @@
from uiviewer._version import __version__
from uiviewer._models import ApiResponse, XPathLiteRequest
from uiviewer.parser.xpath_lite import XPathLiteGenerator
+from uiviewer.go_ios_cli import GoIOS, GoIOSError
router = APIRouter()
+class RunWdaRequest(BaseModel):
+ bundle_id: str
+ test_runner_bundle_id: str
+ xctestconfig: str = "WebDriverAgentRunner.xctest"
+ udid: Optional[str] = None
+
+
@router.get("/")
def root():
return RedirectResponse(url="/static/index.html")
@@ -73,4 +82,20 @@ async def fetch_xpathLite(platform: str, request: XPathLiteRequest):
node_id = request.node_id
generator = XPathLiteGenerator(platform, tree_data)
xpath = generator.get_xpathLite(node_id)
- return ApiResponse.doSuccess(xpath)
\ No newline at end of file
+ return ApiResponse.doSuccess(xpath)
+
+
+@router.post("/ios/run_wda", response_model=ApiResponse)
+async def run_wda(request: RunWdaRequest):
+ try:
+ GoIOS.run_wda(
+ bundle_id=request.bundle_id,
+ test_runner_bundle_id=request.test_runner_bundle_id,
+ xctestconfig=request.xctestconfig,
+ udid=request.udid
+ )
+ return ApiResponse.doSuccess("WDA start command issued successfully.")
+ except GoIOSError as e:
+ return ApiResponse.doError(f"Failed to start WDA: {e.message}. Details: {e.stderr}")
+ except Exception as e:
+ return ApiResponse.doError(f"An unexpected error occurred: {str(e)}")
\ No newline at end of file
diff --git a/uiviewer/static/index.html b/uiviewer/static/index.html
index d250d86..6739037 100644
--- a/uiviewer/static/index.html
+++ b/uiviewer/static/index.html
@@ -62,6 +62,11 @@
+ 启动 WDA
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/uiviewer/static/js/api.js b/uiviewer/static/js/api.js
index 62b2193..50ac57a 100644
--- a/uiviewer/static/js/api.js
+++ b/uiviewer/static/js/api.js
@@ -63,5 +63,17 @@ export async function fetchXpathLite(platform, treeData, nodeId) {
})
});
+ return checkResponse(response);
+}
+
+// Added function to call the backend endpoint for starting WDA
+export async function startWdaProcess(payload) {
+ const response = await fetch(`${API_HOST}ios/run_wda`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify(payload) // Send bundle_id, test_runner_bundle_id, xctestconfig, udid
+ });
return checkResponse(response);
}
\ No newline at end of file
diff --git a/uiviewer/static/js/index.js b/uiviewer/static/js/index.js
index 216307a..5e34730 100644
--- a/uiviewer/static/js/index.js
+++ b/uiviewer/static/js/index.js
@@ -1,5 +1,13 @@
import { saveToLocalStorage, getFromLocalStorage, copyToClipboard } from './utils.js';
-import { getVersion, listDevices, connectDevice, fetchScreenshot, fetchHierarchy, fetchXpathLite } from './api.js';
+import {
+ getVersion,
+ listDevices,
+ connectDevice,
+ fetchScreenshot,
+ fetchHierarchy,
+ fetchXpathLite,
+ startWdaProcess
+} from './api.js';
new Vue({
@@ -35,7 +43,16 @@ new Vue({
nodeFilterText: '',
centerWidth: 500,
isDividerHovered: false,
- isDragging: false
+ isDragging: false,
+
+ wdaDialogVisible: false,
+ wdaStarting: false,
+ wdaForm: {
+ bundle_id: getFromLocalStorage('wda_bundle_id', ''),
+ test_runner_bundle_id: getFromLocalStorage('wda_test_runner_bundle_id', ''),
+ xctestconfig: getFromLocalStorage('wda_xctestconfig', 'WebDriverAgentRunner.xctest'),
+ udid: ''
+ }
};
},
computed: {
@@ -69,6 +86,14 @@ new Vue({
},
nodeFilterText(val) {
this.$refs.treeRef.filter(val);
+ },
+ wdaForm: {
+ deep: true,
+ handler(newVal) {
+ saveToLocalStorage('wda_bundle_id', newVal.bundle_id);
+ saveToLocalStorage('wda_test_runner_bundle_id', newVal.test_runner_bundle_id);
+ saveToLocalStorage('wda_xctestconfig', newVal.xctestconfig);
+ }
}
},
created() {
@@ -81,7 +106,6 @@ new Vue({
canvas.addEventListener('click', this.onMouseClick);
canvas.addEventListener('mouseleave', this.onMouseLeave);
- // 设置Canvas的尺寸和分辨率
this.setupCanvasResolution('#screenshotCanvas');
this.setupCanvasResolution('#hierarchyCanvas');
},
@@ -242,7 +266,6 @@ new Vue({
}
},
- // 解决在高分辨率屏幕上,Canvas绘制的内容可能会显得模糊。这是因为Canvas的默认分辨率与屏幕的物理像素密度不匹配
setupCanvasResolution(selector) {
const canvas = this.$el.querySelector(selector);
const dpr = window.devicePixelRatio || 1;
@@ -367,7 +390,6 @@ new Vue({
this.renderHierarchy();
} else {
- // 保证每次点击重新计算`selectedNodeDetails`,更新点击坐标
this.selectedNode = { ...this.selectedNode };
}
},
@@ -430,7 +452,66 @@ new Vue({
this.isDividerHovered = true;
},
leaveDivider() {
- this.isDividerHovered = false;
+ if (!this.isDragging) {
+ this.isDividerHovered = false;
+ }
+ },
+ showWdaDialog() {
+ this.wdaForm.udid = this.serial || '';
+ this.wdaDialogVisible = true;
+ this.$nextTick(() => {
+ if (this.$refs.wdaFormRef) {
+ this.$refs.wdaFormRef.clearValidate();
+ }
+ });
+ },
+
+ async startWda() {
+ if (this.platform !== 'ios') {
+ this.$message.error('WDA can only be started for iOS platform.');
+ return;
+ }
+
+ this.$refs.wdaFormRef.validate(async (valid) => {
+ if (valid) {
+ this.wdaStarting = true;
+ try {
+ const udidToUse = this.wdaForm.udid || this.serial;
+ if (!udidToUse && !this.wdaForm.udid) {
+ console.warn("No UDID selected or entered for WDA.");
+ }
+
+ const payload = {
+ bundle_id: this.wdaForm.bundle_id,
+ test_runner_bundle_id: this.wdaForm.test_runner_bundle_id,
+ xctestconfig: this.wdaForm.xctestconfig || 'WebDriverAgentRunner.xctest',
+ udid: udidToUse || null
+ };
+
+ const response = await startWdaProcess(payload);
+ if (response.success) {
+ this.$message.success('WDA start command issued successfully.');
+ this.wdaDialogVisible = false;
+ this.wdaUrl= "http://localhost:8100";
+ } else {
+ throw new Error(response.message || 'Failed to issue WDA start command.');
+ }
+ } catch (err) {
+ console.error('Error starting WDA:', err);
+ this.$message({
+ showClose: true,
+ message: `启动 WDA 失败: ${err.message}`,
+ type: 'error',
+ duration: 5000
+ });
+ } finally {
+ this.wdaStarting = false;
+ }
+ } else {
+ console.log('WDA form validation failed');
+ return false;
+ }
+ });
}
}
});
\ No newline at end of file