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