├── .coveragerc ├── .github └── workflows │ └── publish_to_pypi.yml ├── .gitignore ├── .idea ├── .gitignore ├── inspectionProfiles │ └── profiles_settings.xml ├── misc.xml ├── modules.xml ├── vcs.xml └── wdapy.iml ├── DEVELOP.md ├── LICENSE ├── README.md ├── README_CN.md ├── examples ├── draw_robot.py └── swipe.py ├── requirements.txt ├── setup.cfg ├── setup.py ├── tests ├── test_actions.py ├── test_common_client.py └── test_utils.py └── wdapy ├── __init__.py ├── _alert.py ├── _base.py ├── _proto.py ├── _types.py ├── _utils.py ├── _wdapy.py ├── _wrap.py ├── actions.py ├── exceptions.py └── usbmux ├── __init__.py ├── exceptions.py └── pyusbmux.py /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = True 3 | 4 | omit = 5 | **/wdapy/usbmux/** 6 | 7 | [report] 8 | ignore_errors = True 9 | 10 | exclude_lines = 11 | pragma: no cover 12 | if self.debug: 13 | if settings.DEBUG 14 | if 0: 15 | raise NotImplementedError 16 | ^(from|import) .* 17 | ^""" 18 | @abc.abstractmethod 19 | def __repr__ 20 | class .*(enum\.Enum|StrEnum).*:$ 21 | -------------------------------------------------------------------------------- /.github/workflows/publish_to_pypi.yml: -------------------------------------------------------------------------------- 1 | name: Publish to PyPI 2 | on: push 3 | jobs: 4 | build-n-publish: 5 | name: Build, test and publish Python 🐍 distributions 📦 to PyPI 6 | runs-on: ubuntu-latest 7 | steps: 8 | - uses: actions/checkout@master 9 | with: 10 | fetch-depth: 5 11 | - name: Set up Python 12 | uses: actions/setup-python@v1 13 | with: 14 | python-version: 3.9 15 | cache: 'pip' 16 | - name: Install requirements 17 | run: | 18 | pip3 install -e . 19 | pip3 install pytest coverage 20 | - name: Run UnitTest 21 | run: | 22 | python3 -m coverage run -m pytest -vv tests/ 23 | python3 -m coverage xml --include "wdapy/**" 24 | - name: Install pypa/build and Build targz and wheel 25 | run: | 26 | python3 -m pip install wheel 27 | python3 setup.py sdist bdist_wheel 28 | - name: Publish distribution 📦 to PyPI 29 | if: startsWith(github.ref, 'refs/tags') 30 | uses: pypa/gh-action-pypi-publish@master 31 | with: 32 | skip_existing: true 33 | password: ${{ secrets.PYPI_API_TOKEN }} 34 | # coverage: 35 | # runs-on: ubuntu-latest 36 | # steps: 37 | # - name: Get Cover 38 | # uses: orgoro/coverage@v3 39 | # with: 40 | # coverageFile: coverage.xml 41 | # token: ${{ secrets.GORELEASER_GITHUB_TOKEN }} 42 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 98 | __pypackages__/ 99 | 100 | # Celery stuff 101 | celerybeat-schedule 102 | celerybeat.pid 103 | 104 | # SageMath parsed files 105 | *.sage.py 106 | 107 | # Environments 108 | .env 109 | .venv 110 | env/ 111 | venv/ 112 | ENV/ 113 | env.bak/ 114 | venv.bak/ 115 | 116 | # Spyder project settings 117 | .spyderproject 118 | .spyproject 119 | 120 | # Rope project settings 121 | .ropeproject 122 | 123 | # mkdocs documentation 124 | /site 125 | 126 | # mypy 127 | .mypy_cache/ 128 | .dmypy.json 129 | dmypy.json 130 | 131 | # Pyre type checker 132 | .pyre/ 133 | 134 | # pytype static type analyzer 135 | .pytype/ 136 | 137 | # Cython debug symbols 138 | cython_debug/ 139 | 140 | 141 | .DS_Store 142 | */.DS_Store -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | # Datasource local storage ignored files 5 | /dataSources/ 6 | /dataSources.local.xml 7 | # Editor-based HTTP Client requests 8 | /httpRequests/ 9 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/profiles_settings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.idea/wdapy.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 14 | -------------------------------------------------------------------------------- /DEVELOP.md: -------------------------------------------------------------------------------- 1 | # 开发文档 2 | https://github.com/appium/WebDriverAgent/tree/master/WebDriverAgentLib/Commands 3 | 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | Copyright (c) 2021 codeskyblue 3 | 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 16 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 17 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 18 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, 19 | DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 20 | OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE 21 | OR OTHER DEALINGS IN THE SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # wdapy 2 | [![PyPI](https://img.shields.io/pypi/v/wdapy?color=blue)](https://pypi.org/project/wdapy/) 3 | 4 | [中文](README_CN.md) 5 | 6 | Follow WDA API written in 7 | 8 | Current WDA: 9.3.3 9 | 10 | ## Requires 11 | Recommended Python **3.9+** 12 | 13 | > Might be work with Python 3.7+ 14 | > Run unittest require py 3.8+ 15 | 16 | ## Installation 17 | ```bash 18 | pip3 install wdapy 19 | 20 | # Optional 21 | # Support launch WDA with tidevice when WDA is dead 22 | pip3 install tidevice[openssl] 23 | ``` 24 | 25 | ## Usage 26 | 27 | Create Client instance 28 | ```python 29 | import wdapy 30 | 31 | # Based on project: https://github.com/appium/WebDriverAgent 32 | c = wdapy.AppiumClient() 33 | # or 34 | c = wdapy.AppiumClient("http://localhost:8100") 35 | # or 36 | c = wdapy.AppiumUSBClient("00008101-001234567890ABCDEF") 37 | # or (only works when only one device) 38 | c = wdapy.AppiumUSBClient() 39 | 40 | # Based on project: https://github.com/codeskyblue/WebDriverAgent 41 | # with fast touch and swipe 42 | c = wdapy.NanoClient("http://localhost:8100") 43 | c = wdapy.NanoUSBClient() 44 | ``` 45 | 46 | Call WDA method 47 | 48 | ```python 49 | print(c.request_timeout) # show request timeout (default 120s) 50 | c.request_timeout = 60 # change to 60 51 | 52 | print(c.scale) # 2 or 3 53 | print(c.window_size()) # (width, height) 54 | print(c.debug) # output True or False (default False) 55 | c.debug = True 56 | 57 | c.app_start("com.apple.Preferences") 58 | c.app_terminate("com.apple.stocks") 59 | c.app_state("com.apple.mobilesafari") 60 | c.app_list() # like app_current 61 | 62 | c.app_current() 63 | # Output example 64 | # 65 | 66 | # put current app to background 2 seconds and put back to foreground 67 | c.deactivate(2.0) 68 | 69 | c.alert.exists # bool 70 | c.alert.buttons() 71 | c.alert.accept() 72 | c.alert.dismiss() 73 | c.alert.click("Accept") 74 | 75 | c.open_url("https://www.baidu.com") 76 | 77 | # clipboard only works when WebDriverAgent app in foreground 78 | c.app_start("com.facebook.WebDriverAgentRunner.xctrunner") 79 | c.set_clipboard("foo") 80 | c.get_clipboard() # output: foo 81 | 82 | c.is_locked() # bool 83 | c.unlock() 84 | c.lock() 85 | c.homescreen() 86 | c.shutdown() # shutdown WebDriverAgent 87 | 88 | c.send_keys("foo") 89 | c.send_keys("\n") # simulator enter 90 | 91 | # seems missing c.get_clipboard() 92 | c.screenshot() # PIL.Image.Image 93 | c.screenshot().save("screenshot.jpg") 94 | 95 | c.get_orientation() 96 | # PORTRAIT | LANDSCAPE 97 | 98 | c.window_size() # width, height 99 | print(c.status_barsize) # (width, height) 100 | print(c.device_info()) # (timeZone, currentLocation, model and so on) 101 | print(c.battery_info()) # (level, state) 102 | 103 | print(c.sourcetree()) 104 | 105 | c.press_duration(name="power_plus_home", duration=1) #take a screenshot 106 | # todo, need to add more method 107 | 108 | c.volume_up() 109 | c.volume_down() 110 | 111 | # tap x:100, y:200 112 | c.tap(100, 200) 113 | 114 | # dismiss keyboard 115 | # by tap keyboard button to dismiss, default keyNames are ["前往", "发送", "Send", "Done", "Return"] 116 | c.keyboard_dismiss(["Done", "Return"]) 117 | ``` 118 | 119 | Touch Actions 120 | 121 | ```python 122 | # simulate swipe right in 1000ms 123 | from wdapy.actions import TouchActions, PointerAction 124 | finger1 = TouchActions.pointer("finger1", actions=[ 125 | PointerAction.move(200, 300), 126 | PointerAction.down(), 127 | PointerAction.move(50, 0, duration=1000, origin=Origin.POINTER), 128 | # same as 129 | # PointerAction.move(250, 300, duration=1000, origin=Origin.VIEWPORT), 130 | PointerAction.up(), 131 | ]) 132 | c.touch_perform([finger1]) 133 | 134 | # simulate pinchOut 135 | finger2 = TouchActions.pointer("finger2", actions=[ 136 | PointerAction.move(150, 300), 137 | PointerAction.down(), 138 | PointerAction.move(-50, 0, duration=1000, origin=Origin.POINTER), 139 | PointerAction.up(), 140 | ]) 141 | c.touch_perform([finger1, finger2]) 142 | 143 | # even through touch actions can simulate key events 144 | # but it is not recommended, it's better to use send_keys instead 145 | ``` 146 | 147 | ## Breaking change 148 | 149 | Removed in WDA 7.0 and wdapy 1.0 150 | 151 | ``` 152 | from wdapy import Gesture, GestureOption as Option 153 | c.touch_perform([ 154 | Gesture("press", Option(x=100, y=200)), 155 | Gesture("wait", Option(ms=100)), # ms should > 17 156 | Gesture("moveTo", Option(x=100, y = 100)), 157 | Gesture("release") 158 | ]) 159 | ``` 160 | 161 | 162 | ## How to contribute 163 | Assume that you want to add a new method 164 | 165 | - First step, add method usage to README.md, README_CN.md 166 | - Add unit test in under direction tests/ 167 | - Add your name in the section `## Contributors` 168 | 169 | The repo is distributed by github actions. 170 | The code master just need to create a version through `git tag $VERSION` and `git push --tags` 171 | Github actions will build targz and wheel and publish to https://pypi.org 172 | 173 | ## Contributors 174 | 175 | - [codeskyblue](https://github.com/codeskyblue) 176 | - [justinxiang](https://github.com/Justin-Xiang) 177 | 178 | ## Alternative 179 | - https://github.com/openatx/facebook-wda 180 | 181 | ## LICENSE 182 | [MIT](LICENSE) 183 | -------------------------------------------------------------------------------- /README_CN.md: -------------------------------------------------------------------------------- 1 | # wdapy 2 | [![PyPI](https://img.shields.io/pypi/v/wdapy?color=blue)](https://pypi.org/project/wdapy/) 3 | 4 | [English](README.md) 5 | 6 | 遵循 中的 WDA API 7 | 8 | ## 环境要求 9 | Python 3.7+ 10 | 11 | > 运行单元测试需要 Python 3.8+ 12 | 13 | ## 安装 14 | ```bash 15 | pip3 install wdapy 16 | 17 | # 可选 18 | # 当 WDA 无响应时,支持使用 tidevice 启动 WDA 19 | pip3 install tidevice[openssl] 20 | ``` 21 | 22 | ## 使用方法 23 | 24 | 创建客户端实例 25 | ```python 26 | import wdapy 27 | 28 | # 基于项目: https://github.com/appium/WebDriverAgent 29 | c = wdapy.AppiumClient() 30 | # 或者 31 | c = wdapy.AppiumClient("http://localhost:8100") 32 | # 或者 33 | c = wdapy.AppiumUSBClient("00008101-001234567890ABCDEF") 34 | # 或者 (仅当只有一台设备连接时有效) 35 | c = wdapy.AppiumUSBClient() 36 | 37 | # 基于项目: https://github.com/codeskyblue/WebDriverAgent 38 | # 支持快速点击和滑动 39 | c = wdapy.NanoClient("http://localhost:8100") 40 | c = wdapy.NanoUSBClient() 41 | ``` 42 | 43 | 调用 WDA 方法 44 | 45 | ```python 46 | print(c.request_timeout) # 显示请求超时时间 (默认 120 秒) 47 | c.request_timeout = 60 # 修改为 60 秒 48 | 49 | print(c.scale) # 2 或 3 50 | print(c.window_size()) # (宽度, 高度) 51 | print(c.debug) # 输出 True 或 False (默认 False) 52 | c.debug = True 53 | 54 | c.app_start("com.apple.Preferences") 55 | c.app_terminate("com.apple.stocks") 56 | c.app_state("com.apple.mobilesafari") 57 | c.app_list() # 类似 app_current 58 | 59 | c.app_current() 60 | # 输出示例 61 | # 62 | 63 | # 将当前应用置于后台 2 秒,然后返回前台 64 | c.deactivate(2.0) 65 | 66 | c.alert.exists # 布尔值 67 | c.alert.buttons() 68 | c.alert.accept() 69 | c.alert.dismiss() 70 | c.alert.click("接受") 71 | 72 | c.open_url("https://www.baidu.com") 73 | 74 | # 剪贴板功能仅在 WebDriverAgent 应用在前台时有效 75 | c.app_start("com.facebook.WebDriverAgentRunner.xctrunner") 76 | c.set_clipboard("foo") 77 | c.get_clipboard() # 输出: foo 78 | 79 | c.is_locked() # 布尔值 80 | c.unlock() 81 | c.lock() 82 | c.homescreen() 83 | c.shutdown() # 关闭 WebDriverAgent 84 | 85 | c.send_keys("foo") 86 | c.send_keys("\n") # 模拟回车键 87 | 88 | # 似乎缺少 c.get_clipboard() 89 | c.screenshot() # PIL.Image.Image 90 | c.screenshot().save("screenshot.jpg") 91 | 92 | c.get_orientation() 93 | # PORTRAIT | LANDSCAPE (竖屏 | 横屏) 94 | 95 | c.window_size() # 宽度, 高度 96 | print(c.status_barsize) # (宽度, 高度) 97 | print(c.device_info()) # (时区, 当前位置, 型号等) 98 | print(c.battery_info()) # (电量, 状态) 99 | 100 | print(c.sourcetree()) 101 | 102 | c.press_duration(name="power_plus_home", duration=1) # 截屏 103 | # 待添加更多方法 104 | 105 | c.volume_up() 106 | c.volume_down() 107 | 108 | # 点击坐标 x:100, y:200 109 | c.tap(100, 200) 110 | 111 | # 关闭键盘 112 | # 通过点击键盘按钮关闭,默认按钮名称为 ["前往", "发送", "Send", "Done", "Return"] 113 | c.keyboard_dismiss(["Done", "Return"]) 114 | ``` 115 | 116 | 触摸操作 117 | 118 | ```python 119 | # 模拟向右滑动,持续 1000 毫秒 120 | from wdapy.actions import TouchActions, PointerAction 121 | finger1 = TouchActions.pointer("finger1", actions=[ 122 | PointerAction.move(200, 300), 123 | PointerAction.down(), 124 | PointerAction.move(50, 0, duration=1000, origin=Origin.POINTER), 125 | # 等同于 126 | # PointerAction.move(250, 300, duration=1000, origin=Origin.VIEWPORT), 127 | PointerAction.up(), 128 | ]) 129 | c.touch_perform([finger1]) 130 | 131 | # 模拟两指外扩 132 | finger2 = TouchActions.pointer("finger2", actions=[ 133 | PointerAction.move(150, 300), 134 | PointerAction.down(), 135 | PointerAction.move(-50, 0, duration=1000, origin=Origin.POINTER), 136 | PointerAction.up(), 137 | ]) 138 | c.touch_perform([finger1, finger2]) 139 | ``` 140 | 141 | ## 重大变更 142 | 143 | 在 WDA 7.0 和 wdapy 1.0 中已移除 144 | 145 | ``` 146 | from wdapy import Gesture, GestureOption as Option 147 | c.touch_perform([ 148 | Gesture("press", Option(x=100, y=200)), 149 | Gesture("wait", Option(ms=100)), # ms 应大于 17 150 | Gesture("moveTo", Option(x=100, y = 100)), 151 | Gesture("release") 152 | ]) 153 | ``` 154 | 155 | 156 | ## 如何贡献 157 | 假设你想添加一个新方法 158 | 159 | - 第一步,在 README.md 和 README_CN.md 中添加方法用法 160 | - 在 tests/ 目录下添加单元测试 161 | - 在 `## 贡献者` 部分添加你的名字 162 | 163 | 本仓库通过 GitHub Actions 发布。 164 | 代码管理员只需要通过 `git tag $VERSION` 和 `git push --tags` 创建版本 165 | GitHub Actions 将构建 targz 和 wheel 并发布到 https://pypi.org 166 | 167 | ## 贡献者 168 | 169 | - [codeskyblue](https://github.com/codeskyblue) 170 | - [justinxiang](https://github.com/Justin-Xiang) 171 | 172 | ## 替代方案 173 | - https://github.com/openatx/facebook-wda 174 | 175 | ## 许可证 176 | [MIT](LICENSE) -------------------------------------------------------------------------------- /examples/draw_robot.py: -------------------------------------------------------------------------------- 1 | from wdapy import AppiumClient, AppiumUSBClient 2 | from wdapy.actions import TouchActions, PointerAction, Origin 3 | 4 | 5 | def draw_robot(client: AppiumClient): 6 | # Calculate usable area with 20% padding 7 | width, height = client.window_size() 8 | padding_x = width * 0.2 9 | padding_y = height * 0.2 10 | usable_width = width - 2 * padding_x 11 | usable_height = height - 2 * padding_y 12 | 13 | # Starting position (center of usable area) 14 | center_x = width / 2 15 | center_y = height / 2 16 | 17 | # Draw the robot head (square) 18 | head_size = usable_width * 0.3 19 | head_top = center_y - head_size * 0.8 20 | head_left = center_x - head_size / 2 21 | 22 | # Draw the head (square) 23 | head = TouchActions.pointer("robot_finger", actions=[ 24 | PointerAction.move(int(head_left), int(head_top), origin=Origin.VIEWPORT), 25 | PointerAction.down(), 26 | PointerAction.move(int(head_left + head_size), int(head_top), origin=Origin.VIEWPORT), 27 | PointerAction.move(int(head_left + head_size), int(head_top + head_size), origin=Origin.VIEWPORT), 28 | PointerAction.move(int(head_left), int(head_top + head_size), origin=Origin.VIEWPORT), 29 | PointerAction.move(int(head_left), int(head_top), origin=Origin.VIEWPORT), 30 | PointerAction.up() 31 | ]) 32 | 33 | # Draw the eyes (two small circles) 34 | eye_left = TouchActions.pointer("robot_finger", actions=[ 35 | # Left eye 36 | PointerAction.move(int(head_left + head_size * 0.25), int(head_top + head_size * 0.3), origin=Origin.VIEWPORT), 37 | PointerAction.down(), 38 | PointerAction.pause(500), 39 | PointerAction.up() 40 | ]) 41 | eye_right = TouchActions.pointer("robot_finger", actions=[ 42 | # Right eye 43 | PointerAction.move(int(head_left + head_size * 0.75), int(head_top + head_size * 0.3), origin=Origin.VIEWPORT), 44 | PointerAction.down(), 45 | PointerAction.pause(500), 46 | PointerAction.up() 47 | ]) 48 | 49 | # Draw the mouth (horizontal line) 50 | mouth = TouchActions.pointer("robot_finger", actions=[ 51 | PointerAction.move(int(head_left + head_size * 0.25), int(head_top + head_size * 0.7), origin=Origin.VIEWPORT), 52 | PointerAction.down(), 53 | PointerAction.move(int(head_left + head_size * 0.75), int(head_top + head_size * 0.7), origin=Origin.VIEWPORT), 54 | PointerAction.up() 55 | ]) 56 | 57 | # Draw the body (rectangle) 58 | body = TouchActions.pointer("robot_finger", actions=[ 59 | PointerAction.move(int(center_x - head_size * 0.4), int(head_top + head_size), origin=Origin.VIEWPORT), 60 | PointerAction.down(), 61 | PointerAction.move(int(center_x + head_size * 0.4), int(head_top + head_size), origin=Origin.VIEWPORT), 62 | PointerAction.move(int(center_x + head_size * 0.4), int(head_top + head_size * 2), origin=Origin.VIEWPORT), 63 | PointerAction.move(int(center_x - head_size * 0.4), int(head_top + head_size * 2), origin=Origin.VIEWPORT), 64 | PointerAction.move(int(center_x - head_size * 0.4), int(head_top + head_size), origin=Origin.VIEWPORT), 65 | PointerAction.up() 66 | ]) 67 | 68 | # Draw the arms (two horizontal lines) 69 | arm_left = TouchActions.pointer("robot_finger", actions=[ 70 | # Left arm 71 | PointerAction.move(int(center_x - head_size * 0.4), int(head_top + head_size * 1.3), origin=Origin.VIEWPORT), 72 | PointerAction.down(), 73 | PointerAction.move(int(center_x - head_size * 0.9), int(head_top + head_size * 1.3), origin=Origin.VIEWPORT), 74 | PointerAction.up() 75 | ]) 76 | arm_right = TouchActions.pointer("robot_finger", actions=[ 77 | PointerAction.move(int(center_x + head_size * 0.4), int(head_top + head_size * 1.3), origin=Origin.VIEWPORT), 78 | PointerAction.down(), 79 | PointerAction.move(int(center_x + head_size * 0.9), int(head_top + head_size * 1.3), origin=Origin.VIEWPORT), 80 | PointerAction.up() 81 | ]) 82 | 83 | # Draw the legs (two vertical lines) 84 | leg_left = TouchActions.pointer("robot_finger", actions=[ 85 | PointerAction.move(int(center_x - head_size * 0.2), int(head_top + head_size * 2), origin=Origin.VIEWPORT), 86 | PointerAction.down(), 87 | PointerAction.move(int(center_x - head_size * 0.2), int(head_top + head_size * 2.5), origin=Origin.VIEWPORT), 88 | PointerAction.up() 89 | ]) 90 | leg_right = TouchActions.pointer("robot_finger", actions=[ 91 | PointerAction.move(int(center_x + head_size * 0.2), int(head_top + head_size * 2), origin=Origin.VIEWPORT), 92 | PointerAction.down(), 93 | PointerAction.move(int(center_x + head_size * 0.2), int(head_top + head_size * 2.5), origin=Origin.VIEWPORT), 94 | PointerAction.up() 95 | ]) 96 | 97 | for action in [head, eye_left, eye_right, mouth, body, arm_left, arm_right, leg_left, leg_right]: 98 | client.perform_actions([action]) 99 | 100 | 101 | if __name__ == '__main__': 102 | client = AppiumUSBClient() 103 | draw_robot(client) 104 | 105 | -------------------------------------------------------------------------------- /examples/swipe.py: -------------------------------------------------------------------------------- 1 | import random 2 | import wdapy 3 | 4 | c = wdapy.AppiumUSBClient() 5 | w, h = c.window_size() 6 | # print(w, h) 7 | x = w//10 * random.randint(1, 9) 8 | print(x) 9 | sy = h//10*7 10 | ey = h//10*2 11 | 12 | steps = 10 13 | step_size = (ey - sy) // steps 14 | ys = list(range(sy+step_size, ey, step_size)) + [ey] 15 | 16 | gestures = [] 17 | gestures.append(wdapy.Gesture("press", options={"x": x, "y": sy})) 18 | for y in ys: 19 | gestures.append(wdapy.Gesture("wait", options={"ms": 100})) 20 | gestures.append(wdapy.Gesture("moveTo", options={"x": x, "y": y})) 21 | gestures.append(wdapy.Gesture("wait", options={"ms": 100})) 22 | gestures.append(wdapy.Gesture("release")) 23 | 24 | 25 | for g in gestures: 26 | print(g.action, g.options) 27 | 28 | # c.debug = True 29 | c.appium_settings({"snapshotMaxDepth": 3}) 30 | c.touch_perform(gestures) -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Deprecated>=1.2.6 2 | Pillow 3 | retry 4 | construct>=2 5 | pydantic>=2.5.1 -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = wdapy 3 | author = "codeskyblue" 4 | summary = Python Client for Facebook WebDriverAgent 5 | license = MIT 6 | home-page = https://github.com/openatx/wdapy 7 | classifier = 8 | Development Status :: 3 - Alpha 9 | Environment :: Console 10 | Intended Audience :: Developers 11 | Operating System :: POSIX :: Linux 12 | Programming Language :: Python :: 3.6 13 | Programming Language :: Python :: 3.7 14 | Programming Language :: Python :: 3.8 15 | Programming Language :: Python :: 3.9 16 | Topic :: Software Development :: Libraries :: Python Modules 17 | Topic :: Software Development :: Testing 18 | 19 | [files] 20 | packages = 21 | wdapy 22 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # coding: utf-8 3 | # 4 | # Licensed under MIT 5 | # 6 | 7 | import setuptools 8 | setuptools.setup(setup_requires=['pbr'], pbr=True, python_requires='>=3.8') 9 | -------------------------------------------------------------------------------- /tests/test_actions.py: -------------------------------------------------------------------------------- 1 | from wdapy.actions import TouchActions, PointerAction, Origin 2 | from unittest.mock import MagicMock 3 | from wdapy import AppiumClient 4 | from wdapy._proto import POST 5 | 6 | 7 | def test_perform_actions(): 8 | # Create a mock client 9 | client = AppiumClient("http://localhost:8100") 10 | client.session_request = MagicMock(return_value={"value": None}) 11 | 12 | # Create test actions 13 | finger1 = TouchActions.pointer("finger1", actions=[ 14 | PointerAction.move(100, 200), 15 | PointerAction.down(), 16 | PointerAction.move(150, 250, duration=500, origin=Origin.POINTER), 17 | PointerAction.up() 18 | ]) 19 | 20 | # Call the method being tested 21 | client.perform_actions([finger1]) 22 | 23 | # Verify the session_request was called with correct parameters 24 | client.session_request.assert_called_once() 25 | args = client.session_request.call_args.args 26 | 27 | # Check that the method, endpoint and payload structure are correct 28 | assert args[0] == POST 29 | assert args[1] == "/actions" 30 | 31 | # Verify the payload contains the expected actions 32 | payload = args[2] 33 | assert "actions" in payload 34 | actions = payload["actions"] 35 | assert len(actions) == 1 36 | 37 | # Verify the first action's properties 38 | action = actions[0] 39 | assert action["type"] == "pointer" 40 | assert action["id"] == "finger1" 41 | assert "parameters" in action 42 | assert action["parameters"]["pointerType"] == "touch" 43 | 44 | # Verify the action sequence 45 | action_sequence = action["actions"] 46 | assert len(action_sequence) == 4 47 | 48 | # Check first action (move) 49 | assert action_sequence[0]["type"] == "pointerMove" 50 | assert action_sequence[0]["x"] == 100 51 | assert action_sequence[0]["y"] == 200 52 | 53 | # Check second action (down) 54 | assert action_sequence[1]["type"] == "pointerDown" 55 | 56 | # Check third action (move with duration and origin) 57 | assert action_sequence[2]["type"] == "pointerMove" 58 | assert action_sequence[2]["x"] == 150 59 | assert action_sequence[2]["y"] == 250 60 | assert action_sequence[2]["duration"] == 500 61 | assert action_sequence[2]["origin"] == "pointer" 62 | 63 | # Check fourth action (up) 64 | assert action_sequence[3]["type"] == "pointerUp" -------------------------------------------------------------------------------- /tests/test_common_client.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | # 3 | 4 | import unittest 5 | from unittest.mock import MagicMock 6 | import wdapy 7 | from wdapy import AppiumClient 8 | from wdapy._types import BatteryState 9 | 10 | 11 | class SimpleTest(unittest.TestCase): 12 | def setUp(self): 13 | self._client = AppiumClient("http://localhost:8100") 14 | 15 | def test_status(self): 16 | self._client.request = MagicMock(return_value={ 17 | "sessionId": "yyds", 18 | "value": { 19 | "message": "WebDriverAgent is ready to accept commands", 20 | "ios": { 21 | "ip": "1.2.3.4", 22 | } 23 | } 24 | }) 25 | st = self._client.status() 26 | self.assertEqual("1.2.3.4", st.ip) 27 | self.assertEqual("yyds", st.session_id) 28 | self.assertEqual( 29 | "WebDriverAgent is ready to accept commands", st.message) 30 | 31 | def test_sourcetree(self): 32 | self._client.request = MagicMock(return_value={ 33 | "value": "\n", 34 | "sessionId": "test" 35 | }) 36 | sourcetree = self._client.sourcetree() 37 | self.assertEqual("\n", sourcetree.value) 38 | 39 | 40 | def test_is_locked(self): 41 | m = MagicMock(return_value={ 42 | "value": False, 43 | }) 44 | self._client.request = m 45 | self.assertEqual(False, self._client.is_locked()) 46 | 47 | def test_app_start(self): 48 | pass 49 | 50 | def test_app_terminate(self): 51 | pass 52 | 53 | def test_unlock(self): 54 | pass 55 | 56 | def test_deviceinfo(self): 57 | self._client.session_request = MagicMock(return_value={ 58 | "value": { 59 | "timeZone": "GMT+0800", 60 | "currentLocale": "zh_CN", 61 | "model": "iPhone", 62 | "uuid": "12345678-ABCD-1234-ABCD-123456789ABC", 63 | "userInterfaceIdiom": 0, 64 | "userInterfaceStyle": "light", 65 | "name": "iPhone X", 66 | "isSimulator": False 67 | } 68 | }) 69 | di = self._client.device_info() 70 | self.assertEqual("GMT+0800", di.time_zone) 71 | self.assertEqual("zh_CN", di.current_locale) 72 | self.assertEqual("iPhone", di.model) 73 | self.assertEqual("12345678-ABCD-1234-ABCD-123456789ABC", di.uuid) 74 | self.assertEqual(0, di.user_interface_idiom) 75 | self.assertEqual("light", di.user_interface_style) 76 | self.assertEqual("iPhone X", di.name) 77 | self.assertEqual(False, di.is_simulator) 78 | 79 | def test_orientation(self): 80 | self._client.session_request = MagicMock(return_value={ 81 | "value": "PORTRAIT" 82 | }) 83 | ot = self._client.get_orientation() 84 | self.assertEqual("PORTRAIT", ot) 85 | 86 | def test_batteryinfo(self): 87 | self._client.session_request = MagicMock(return_value={ 88 | "value": { 89 | "level": 0.9999999999999999, 90 | "state": 0 91 | } 92 | }) 93 | bi = self._client.battery_info() 94 | self.assertEqual(0.9999999999999999, bi.level) 95 | self.assertEqual(0, bi.state) 96 | self.assertIn(bi.state, BatteryState) 97 | 98 | def test_statusbarsize(self): 99 | self._client.session_request = MagicMock(return_value={ 100 | "value": { 101 | "statusBarSize": { 102 | "width": 320, 103 | "height": 20 104 | } 105 | } 106 | }) 107 | sts = self._client.status_barsize() 108 | self.assertEqual(320, sts.width) 109 | self.assertEqual(20, sts.height) 110 | 111 | def test_applist(self): 112 | self._client.session_request = MagicMock(return_value={ 113 | "value": { 114 | "AppList": { 115 | "pid": 4453, 116 | "bundle_id": "com.apple.springboard" 117 | } 118 | } 119 | }) 120 | with self.assertRaises(KeyError): 121 | al = self._client.app_list() 122 | self.assertEqual(4453, al.pid) 123 | self.assertEqual("com.apple.springboard", al.bundle_id) 124 | 125 | def test_press(self): 126 | self._client.session_request = MagicMock(return_value={"value": None}) 127 | self._client.press(wdapy.Keycode.HOME) 128 | payload = self._client.session_request.call_args.args[-1] 129 | self.assertEqual(wdapy.Keycode.HOME, payload['name']) 130 | 131 | def test_keyboard_dismiss(self): 132 | self._client.session_request = MagicMock(return_value={"value": None}) 133 | self._client.keyboard_dismiss(["Done"]) 134 | payload = self._client.session_request.call_args.args[-1] 135 | self.assertEqual(["Done"], payload['keyNames']) 136 | 137 | 138 | if __name__ == "__main__": 139 | unittest.main() 140 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | # 3 | 4 | from wdapy._utils import camel_to_snake, json_dumps_omit_empty 5 | from wdapy._proto import * 6 | from wdapy._types import * 7 | 8 | def test_camel_to_snake(): 9 | assert "this_is_my_string" == camel_to_snake("ThisIsMyString") 10 | 11 | 12 | def test_json_dumps_omit_empty(): 13 | # Test with a mix of None and non-None values 14 | data = { 15 | "a": 1, 16 | "b": None, 17 | "c": "test", 18 | "d": [1, 2, 3], 19 | "e": [{ 20 | "f": 1, 21 | "g": None 22 | }] 23 | } 24 | expected_json = '{"a": 1, "c": "test", "d": [1, 2, 3], "e": [{"f": 1}]}' 25 | assert json_dumps_omit_empty(data) == expected_json 26 | 27 | # Test with all values as None 28 | data_all_none = { 29 | "a": None, 30 | "b": None 31 | } 32 | expected_json_all_none = '{}' 33 | assert json_dumps_omit_empty(data_all_none) == expected_json_all_none 34 | 35 | # Test with no None values 36 | data_no_none = { 37 | "a": 1, 38 | "b": "test" 39 | } 40 | expected_json_no_none = '{"a": 1, "b": "test"}' 41 | assert json_dumps_omit_empty(data_no_none) == expected_json_no_none 42 | 43 | # Test with empty dictionary 44 | data_empty = {} 45 | expected_json_empty = '{}' 46 | assert json_dumps_omit_empty(data_empty) == expected_json_empty 47 | -------------------------------------------------------------------------------- /wdapy/__init__.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | # 3 | 4 | from ._wdapy import (AppiumClient, AppiumUSBClient, NanoClient, NanoUSBClient) 5 | 6 | from wdapy import exceptions 7 | from wdapy import _types as types 8 | from wdapy._proto import * 9 | from wdapy._types import * -------------------------------------------------------------------------------- /wdapy/_alert.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | """Created on Tue Sep 14 2021 15:24:46 by codeskyblue 5 | """ 6 | 7 | import logging 8 | import typing 9 | from wdapy.exceptions import RequestError 10 | from wdapy._proto import * 11 | from wdapy._base import BaseClient 12 | 13 | logger = logging.getLogger(__name__) 14 | 15 | class Alert: 16 | def __init__(self, client: BaseClient): 17 | self._client = client 18 | 19 | @property 20 | def exists(self) -> bool: 21 | try: 22 | self.get_text() 23 | return True 24 | except RequestError: 25 | return False 26 | 27 | def buttons(self) -> typing.List[str]: 28 | return self._client.session_request(GET, "/wda/alert/buttons")["value"] 29 | 30 | def get_text(self) -> str: 31 | return self._client.session_request(GET, "/alert/text")["value"] 32 | 33 | def accept(self): 34 | return self._client.session_request(POST, "/alert/accept") 35 | 36 | def dismiss(self): 37 | return self._client.session_request(POST, "/alert/dismiss") 38 | 39 | def click(self, button_name: typing.Union[str, list]): 40 | if isinstance(button_name, str): 41 | self._client.session_request(POST, "/alert/accept", {"name": button_name}) 42 | return 43 | elif isinstance(button_name, list): 44 | expect_buttons = button_name 45 | buttons = self.buttons() 46 | for name in expect_buttons: 47 | if name in buttons: 48 | return self.click(name) 49 | logger.debug("alert not clicked, buttons: %s, expect buttons: %s", buttons, expect_buttons) -------------------------------------------------------------------------------- /wdapy/_base.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | """Created on Tue Sep 14 2021 15:26:27 by codeskyblue 5 | """ 6 | 7 | from http.client import HTTPConnection, HTTPSConnection 8 | import logging 9 | import typing 10 | from typing import Optional, Union 11 | 12 | import json 13 | from retry import retry 14 | from urllib.parse import urlparse 15 | 16 | from wdapy._proto import * 17 | from wdapy._types import Recover, StatusInfo 18 | from wdapy.exceptions import * 19 | from wdapy.usbmux.exceptions import MuxConnectError 20 | from wdapy.usbmux.pyusbmux import select_device 21 | 22 | 23 | logger = logging.getLogger(__name__) 24 | 25 | class HTTPResponseWrapper: 26 | def __init__(self, content: Union[bytes, bytearray], status_code: int): 27 | self.content = content 28 | self.status_code = status_code 29 | 30 | def json(self): 31 | return json.loads(self.content) 32 | 33 | @property 34 | def text(self) -> str: 35 | return self.content.decode("utf-8") 36 | 37 | def getcode(self) -> int: 38 | return self.status_code 39 | 40 | 41 | def http_create(url: str) -> typing.Union[HTTPConnection, HTTPSConnection]: 42 | u = urlparse(url) 43 | if u.scheme == "http+usbmux": 44 | udid, device_wda_port = u.netloc.split(":") 45 | device = select_device(udid) 46 | return device.make_http_connection(int(device_wda_port)) 47 | elif u.scheme == "http": 48 | return HTTPConnection(u.netloc) 49 | elif u.scheme == "https": 50 | return HTTPSConnection(u.netloc) 51 | else: 52 | raise ValueError(f"unknown scheme: {u.scheme}") 53 | 54 | 55 | class BaseClient: 56 | def __init__(self, wda_url: str): 57 | self._wda_url = wda_url.rstrip("/") + "/" 58 | 59 | self._session_id: Optional[str] = None 60 | self._recover: Optional[Recover] = None 61 | 62 | self.__request_timeout = DEFAULT_HTTP_TIMEOUT 63 | self.__debug = False 64 | 65 | @property 66 | def debug(self) -> bool: 67 | return self.__debug 68 | 69 | @debug.setter 70 | def debug(self, v: bool): 71 | self.__debug = v 72 | 73 | @property 74 | def request_timeout(self) -> float: 75 | return self.__request_timeout 76 | 77 | @request_timeout.setter 78 | def request_timeout(self, timeout: float): 79 | self.__request_timeout = timeout 80 | 81 | def status(self) -> StatusInfo: 82 | data = self.request(GET, "/status") 83 | return StatusInfo.value_of(data) 84 | 85 | def session(self, 86 | bundle_id: Optional[str] = None, 87 | arguments: Optional[list] = None, 88 | environment: Optional[dict] = None) -> Optional[str]: 89 | """ create session and return session id """ 90 | capabilities = {} 91 | if bundle_id: 92 | always_match = { 93 | "bundleId": bundle_id, 94 | "arguments": arguments or [], 95 | "environment": environment or {}, 96 | "shouldWaitForQuiescence": False, 97 | } 98 | capabilities['alwaysMatch'] = always_match 99 | payload = { 100 | "capabilities": capabilities, 101 | "desiredCapabilities": capabilities.get('alwaysMatch', 102 | {}), # 兼容旧版的wda 103 | } 104 | data = self.request(POST, "/session", payload) 105 | 106 | # update cached session_id 107 | self._session_id = data['sessionId'] 108 | return self._session_id 109 | 110 | def set_recover_handler(self, recover: Recover): 111 | self._recover = recover 112 | 113 | def _get_valid_session_id(self) -> Optional[str]: 114 | if self._session_id: 115 | return self._session_id 116 | old_session_id = self.status().session_id 117 | if old_session_id: 118 | self._session_id = old_session_id 119 | else: 120 | self._session_id = self.session() 121 | return self._session_id 122 | 123 | def session_request(self, method: RequestMethod, urlpath: str, payload: Optional[dict] = None) -> dict: 124 | """ request with session_id """ 125 | session_id = self._get_valid_session_id() 126 | session_urlpath = f"/session/{session_id}/" + urlpath.lstrip("/") 127 | try: 128 | return self.request(method, session_urlpath, payload) 129 | except WDASessionDoesNotExist: 130 | # In some condition, session_id exist in /status, but not working 131 | # The bellow code fix that case 132 | logger.info("session %r does not exist, generate new one", session_id) 133 | session_id = self._session_id = self.session() 134 | session_urlpath = f"/session/{session_id}/" + urlpath.lstrip("/") 135 | return self.request(method, session_urlpath, payload) 136 | 137 | def request(self, method: RequestMethod, urlpath: str, payload: Optional[dict] = None) -> dict: 138 | """ 139 | Raises: 140 | RequestError, WDASessionDoesNotExist 141 | """ 142 | full_url = self._wda_url.rstrip("/") + "/" + urlpath.lstrip("/") 143 | payload_debug = payload or "" 144 | payload_debug = json.dumps(payload_debug, ensure_ascii=False) 145 | if self.debug: 146 | print(f"$ curl -X{method} --max-time {self.request_timeout:d} {full_url} -d {payload_debug!r}") 147 | resp = self._request_http(method, full_url, payload) 148 | try: 149 | short_json = resp.json().copy() 150 | except json.JSONDecodeError: 151 | raise RequestError("response is not json format", resp.text) 152 | 153 | for k, v in short_json.items(): 154 | if isinstance(v, str) and len(v) > 40: 155 | v = v[:20] + "... skip ..." + v[-10:] 156 | short_json[k] = v 157 | if self.debug: 158 | print(f"==> Response <==\n{json.dumps(short_json, indent=4, ensure_ascii=False)}") 159 | 160 | value = resp.json().get("value") 161 | if value and isinstance(value, dict) and value.get("error"): 162 | raise ApiError(resp.status_code, value["error"], value.get("message")) 163 | 164 | return resp.json() 165 | 166 | @retry(RequestError, tries=2, delay=0.2, jitter=0.1, logger=logging) 167 | def _request_http(self, method: RequestMethod, url: str, payload: Optional[dict] = None, **kwargs) -> HTTPResponseWrapper: 168 | """ 169 | Raises: 170 | RequestError, WDAFatalError 171 | WDASessionDoesNotExist 172 | """ 173 | logger.info("request: %s %s %s", method, url, payload) 174 | try: 175 | conn = http_create(url) 176 | conn.timeout = kwargs.get("timeout", self.request_timeout) 177 | u = urlparse(url) 178 | urlpath = url[len(u.scheme) + len(u.netloc) + 3:] 179 | 180 | if not payload: 181 | conn.request(method.value, urlpath) 182 | else: 183 | conn.request(method.value, urlpath, json.dumps(payload), headers={"Content-Type": "application/json"}) 184 | response = conn.getresponse() 185 | content = bytearray() 186 | while chunk := response.read(4096): 187 | content.extend(chunk) 188 | resp = HTTPResponseWrapper(content, response.status) 189 | 190 | if response.getcode() == 200: 191 | return resp 192 | else: 193 | # handle unexpected response 194 | if "Session does not exist" in resp.text: 195 | raise WDASessionDoesNotExist(resp.text) 196 | else: 197 | raise RequestError(f"response code: {response.getcode()}", resp.text) 198 | except MuxConnectError as err: 199 | if self._recover: 200 | if not self._recover.recover(): 201 | raise WDAFatalError("recover failed") 202 | raise RequestError("ConnectionBroken", err) 203 | 204 | -------------------------------------------------------------------------------- /wdapy/_proto.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | # 3 | 4 | import enum 5 | 6 | NAME = "wdapy" 7 | DEFAULT_WDA_URL = "http://localhost:8100" 8 | DEFAULT_HTTP_TIMEOUT = 90 9 | 10 | 11 | class RequestMethod(str, enum.Enum): 12 | GET = "GET" 13 | POST = "POST" 14 | 15 | 16 | GET = RequestMethod.GET 17 | POST = RequestMethod.POST 18 | 19 | 20 | class AppState(enum.IntEnum): 21 | STOPPED = 1 22 | BACKGROUND = 2 23 | RUNNING = 4 24 | 25 | 26 | class Orientation(str, enum.Enum): 27 | LANDSCAPE = 'LANDSCAPE' 28 | PORTRAIT = 'PORTRAIT' 29 | LANDSCAPE_RIGHT = 'UIA_DEVICE_ORIENTATION_LANDSCAPERIGHT' 30 | PORTRAIT_UPSIDEDOWN = 'UIA_DEVICE_ORIENTATION_PORTRAIT_UPSIDEDOWN' 31 | 32 | 33 | class Keycode(str, enum.Enum): 34 | HOME = "home" 35 | VOLUME_UP = "volumeUp" 36 | VOLUME_DOWN = "volumeDown" 37 | 38 | # allow only for press_duration 39 | POWER = "power" 40 | SNAPSHOT = "snapshot" 41 | POWER_PLUS_HOME = "power_plus_home" 42 | 43 | 44 | class GestureAction(str, enum.Enum): 45 | TAP = "tap" 46 | PRESS = "press" 47 | MOVE_TO = "moveTo" 48 | WAIT = "wait" 49 | RELEASE = "release" 50 | 51 | 52 | class BatteryState(enum.IntEnum): 53 | Unknown = 0 54 | Unplugged = 1 55 | Charging = 2 56 | Full = 3 57 | -------------------------------------------------------------------------------- /wdapy/_types.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | # 3 | __all__ = ["Recover", "StatusInfo", "AppInfo", "DeviceInfo", "BatteryInfo", "SourceTree", 4 | "StatusBarSize", "AppList"] 5 | 6 | import abc 7 | import enum 8 | import typing 9 | from dataclasses import dataclass 10 | from typing import Optional, Union 11 | 12 | from wdapy._proto import * 13 | from wdapy._utils import camel_to_snake 14 | 15 | 16 | def smart_value_of(obj, data: dict): 17 | for key, val in data.items(): 18 | setattr(obj, key, val) 19 | 20 | 21 | class Recover(abc.ABC): 22 | @abc.abstractmethod 23 | def recover(self) -> bool: 24 | pass 25 | 26 | 27 | class _Base: 28 | def __init__(self): 29 | # set default value 30 | for k, _ in typing.get_type_hints(self).items(): 31 | if not hasattr(self, k): 32 | setattr(self, k, None) 33 | 34 | def __repr__(self): 35 | attrs = [] 36 | for k, v in self.__dict__.items(): 37 | attrs.append(f"{k}={v!r}") 38 | return f"<{self.__class__.__name__} " + ", ".join(attrs) + ">" 39 | 40 | @classmethod 41 | def value_of(cls, data: dict): 42 | instance = cls() 43 | for k, v in data.items(): 44 | key = camel_to_snake(k) 45 | if hasattr(instance, key): 46 | setattr(instance, key, v) 47 | 48 | return instance 49 | 50 | 51 | class AppInfo(_Base): 52 | name: str 53 | process_arguments: dict 54 | pid: int 55 | bundle_id: str 56 | 57 | 58 | class StatusInfo(_Base): 59 | ip: str 60 | session_id: str 61 | message: str 62 | 63 | @staticmethod 64 | def value_of(data: dict) -> "StatusInfo": 65 | info = StatusInfo() 66 | value = data['value'] 67 | info.session_id = data['sessionId'] 68 | info.ip = value['ios']['ip'] 69 | info.message = value['message'] 70 | return info 71 | 72 | class DeviceInfo(_Base): 73 | time_zone: str 74 | current_locale: str 75 | model: str 76 | uuid: str 77 | user_interface_idiom: int 78 | user_interface_style: str 79 | name: str 80 | is_simulator: bool 81 | 82 | 83 | class BatteryInfo(_Base): 84 | level: float 85 | state: BatteryState 86 | 87 | @staticmethod 88 | def value_of(data: dict) -> "BatteryInfo": 89 | info = BatteryInfo() 90 | info.level = data['level'] 91 | info.state = BatteryState(data['state']) 92 | return info 93 | 94 | 95 | class SourceTree(_Base): 96 | value: str 97 | sessionId: str 98 | 99 | class StatusBarSize(_Base): 100 | width: int 101 | height: int 102 | 103 | 104 | class AppList(_Base): 105 | pid: int 106 | bundle_id: str 107 | -------------------------------------------------------------------------------- /wdapy/_utils.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | # 3 | 4 | from __future__ import annotations 5 | 6 | import dataclasses 7 | import json 8 | 9 | 10 | def camel_to_snake(s: str) -> str: 11 | return ''.join(['_'+c.lower() if c.isupper() else c for c in s]).lstrip("_") 12 | 13 | 14 | def omit_empty(d: list | dict | dataclasses.dataclass | None): 15 | if isinstance(d, list): 16 | return [omit_empty(v) for v in d] 17 | elif isinstance(d, dict): 18 | return {k: omit_empty(v) for k, v in d.items() if v is not None} 19 | elif dataclasses.is_dataclass(d): 20 | return omit_empty(dataclasses.asdict(d)) 21 | else: 22 | return d 23 | 24 | 25 | def json_dumps_omit_empty(data: dict) -> str: 26 | """ 27 | Convert a dictionary to a JSON string, omitting any items with a value of None. 28 | 29 | Parameters: 30 | data (dict): The dictionary to convert to a JSON string. 31 | 32 | Returns: 33 | str: A JSON string representation of the dictionary with None values omitted. 34 | """ 35 | return json.dumps(omit_empty(data)) -------------------------------------------------------------------------------- /wdapy/_wdapy.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | from __future__ import annotations 4 | 5 | import atexit 6 | import base64 7 | import io 8 | import logging 9 | import queue 10 | import subprocess 11 | import sys 12 | import threading 13 | import time 14 | import typing 15 | 16 | from typing import Optional 17 | from functools import cached_property 18 | from PIL import Image 19 | 20 | from wdapy._alert import Alert 21 | from wdapy._base import BaseClient 22 | from wdapy._proto import * 23 | from wdapy._types import * 24 | from wdapy._utils import omit_empty 25 | 26 | from wdapy.exceptions import * 27 | from wdapy.actions import TouchActionsClient 28 | from wdapy.usbmux.pyusbmux import list_devices 29 | 30 | 31 | logger = logging.getLogger(__name__) 32 | 33 | class CommonClient(BaseClient): 34 | def __init__(self, wda_url: str): 35 | super().__init__(wda_url) 36 | self.__ui_size = None 37 | 38 | def app_start(self, bundle_id: str, arguments: typing.List[str] = [], environment: typing.Dict[str, str] = {}): 39 | self.session_request(POST, "/wda/apps/launch", { 40 | "bundleId": bundle_id, 41 | "arguments": arguments, 42 | "environment": environment, 43 | }) 44 | 45 | def app_terminate(self, bundle_id: str): 46 | self.session_request(POST, "/wda/apps/terminate", { 47 | "bundleId": bundle_id 48 | }) 49 | 50 | def app_state(self, bundle_id: str) -> AppState: 51 | value = self.session_request(POST, "/wda/apps/state", { 52 | "bundleId": bundle_id 53 | })["value"] 54 | return AppState(value) 55 | 56 | def app_current(self) -> AppInfo: 57 | self.unlock() 58 | st = self.status() 59 | if st.session_id is None: 60 | self.session() 61 | data = self.request(GET, "/wda/activeAppInfo") 62 | value = data['value'] 63 | return AppInfo.value_of(value) 64 | 65 | def app_list(self) -> AppList: 66 | value = self.session_request(GET, "/wda/apps/list")["value"][0] 67 | return AppList.value_of(value) 68 | 69 | def deactivate(self, duration: float): 70 | self.session_request(POST, "/wda/deactivateApp", { 71 | "duration": duration 72 | }) 73 | 74 | @cached_property 75 | def alert(self) -> Alert: 76 | return Alert(self) 77 | 78 | def sourcetree(self) -> SourceTree: 79 | data = self.request(GET, "/source") 80 | return SourceTree.value_of(data) 81 | 82 | def open_url(self, url: str): 83 | self.session_request(POST, "/url", { 84 | "url": url 85 | }) 86 | 87 | def set_clipboard(self, content: str, content_type="plaintext"): 88 | """ only works when WDA app is foreground """ 89 | self.session_request(POST, "/wda/setPasteboard",{ 90 | "content": base64.b64encode(content.encode()).decode(), 91 | "contentType": content_type 92 | }) 93 | 94 | def get_clipboard(self, content_type="plaintext") -> str: 95 | data = self.session_request(POST, "/wda/getPasteboard",{ 96 | "contentType": content_type 97 | }) 98 | return base64.b64decode(data['value']).decode('utf-8') 99 | 100 | def appium_settings(self, kwargs: dict = None) -> dict: 101 | if kwargs is None: 102 | return self.session_request(GET, "/appium/settings")["value"] 103 | payload = {"settings": kwargs} 104 | return self.session_request(POST, "/appium/settings", payload)["value"] 105 | 106 | def is_locked(self) -> bool: 107 | return self.request(GET, "/wda/locked")["value"] 108 | 109 | def unlock(self): 110 | self.request(POST, "/wda/unlock") 111 | 112 | def lock(self): 113 | self.request(POST, "/wda/lock") 114 | 115 | def homescreen(self): 116 | self.request(POST, "/wda/homescreen") 117 | 118 | def shutdown(self): 119 | self.request(GET, "/wda/shutdown") 120 | 121 | def get_orientation(self) -> Orientation: 122 | value = self.session_request(GET, '/orientation')['value'] 123 | return Orientation(value) 124 | 125 | def window_size(self) -> typing.Tuple[int, int]: 126 | """ 127 | Returns: 128 | UISize 129 | 130 | Ref: 131 | FBElementCommands.m 132 | """ 133 | data = self.session_request(GET, "/window/size") 134 | return data['value']['width'], data['value']['height'] 135 | 136 | # 代码暂时保留,下面的方法为通过截图获取屏幕大小 137 | # # 这里做了一点速度优化,跟进图像大小获取屏幕尺寸 138 | # orientation = self.get_orientation() 139 | # if self.__ui_size is None: 140 | # # 这里认为screenshot返回的屏幕转向时正确的 141 | # pixel_width, pixel_height = self.screenshot().size 142 | # w, h = pixel_width//self.scale, pixel_height//self.scale 143 | # if self.get_orientation() == Orientation.PORTRAIT: 144 | # self.__ui_size = (w, h) 145 | # else: 146 | # self.__ui_size = (h, w) 147 | 148 | # if orientation == Orientation.LANDSCAPE: 149 | # return self.__ui_size[::-1] 150 | # else: 151 | # return self.__ui_size 152 | 153 | def send_keys(self, value: str): 154 | """ input with some text """ 155 | self.session_request(POST, "/wda/keys", {"value": list(value)}) 156 | 157 | def tap(self, x: int, y: int): 158 | try: 159 | self.session_request(POST, "/wda/tap", {"x": x, "y": y}) 160 | except RequestError: 161 | self.session_request(POST, "/wda/tap/0", {"x": x, "y": y}) 162 | 163 | def touch_and_hold(self, x: int, y: int, duration: float): 164 | """ touch and hold 165 | 166 | Ref: 167 | FBElementCommands.m 168 | """ 169 | self.session_request(POST, "/wda/touchAndHold", {"x": x, "y": y, "duration": duration}) 170 | 171 | def swipe(self, 172 | from_x: int, 173 | from_y: int, 174 | to_x: int, 175 | to_y: int, 176 | duration: float = 0.5): 177 | payload = { 178 | "fromX": from_x, 179 | "fromY": from_y, 180 | "toX": to_x, 181 | "toY": to_y, 182 | "duration": duration} 183 | self.session_request(POST, "/wda/dragfromtoforduration", payload) 184 | 185 | def press(self, name: Keycode): 186 | payload = { 187 | "name": name 188 | } 189 | self.session_request(POST, "/wda/pressButton", payload) 190 | 191 | def press_duration(self, name: Keycode, duration: float): 192 | hid_usages = { 193 | "home": 0x40, 194 | "volumeup": 0xE9, 195 | "volumedown": 0xEA, 196 | "power": 0x30, 197 | "snapshot": 0x65, 198 | "power_plus_home": 0x65 199 | } 200 | name = name.lower() 201 | if name not in hid_usages: 202 | raise ValueError("Invalid name:", name) 203 | hid_usages = hid_usages[name] 204 | payload = { 205 | "page": 0x0C, 206 | "usage": hid_usages, 207 | "duration": duration 208 | } 209 | return self.session_request(POST, "/wda/performIoHidEvent", payload) 210 | 211 | def volume_up(self): 212 | self.press(Keycode.VOLUME_UP) 213 | 214 | def volume_down(self): 215 | self.press(Keycode.VOLUME_DOWN) 216 | 217 | @cached_property 218 | def scale(self) -> int: 219 | # Response example 220 | # {"statusBarSize": {'width': 320, 'height': 20}, 'scale': 2} 221 | value = self.session_request(GET, "/wda/screen")['value'] 222 | return value['scale'] 223 | 224 | def status_barsize(self) -> StatusBarSize: 225 | # Response example 226 | # {"statusBarSize": {'width': 320, 'height': 20}, 'scale': 2} 227 | value = self.session_request(GET, "/wda/screen")['value'] 228 | return StatusBarSize.value_of(value['statusBarSize']) 229 | 230 | def screenshot(self) -> Image.Image: 231 | """ take screenshot """ 232 | value = self.request(GET, "/screenshot")["value"] 233 | raw_value = base64.b64decode(value) 234 | buf = io.BytesIO(raw_value) 235 | im = Image.open(buf) 236 | return im.convert("RGB") 237 | 238 | def battery_info(self) -> BatteryInfo: 239 | data = self.session_request(GET, "/wda/batteryInfo")["value"] 240 | return BatteryInfo.value_of(data) 241 | 242 | @property 243 | def info(self) -> DeviceInfo: 244 | return self.device_info() 245 | 246 | def device_info(self) -> DeviceInfo: 247 | data = self.session_request(GET, "/wda/device/info")["value"] 248 | return DeviceInfo.value_of(data) 249 | 250 | def keyboard_dismiss(self, key_names: typing.List[str] = ["前往", "发送", "Send", "Done", "Return"]): 251 | """ dismiss keyboard 252 | 相当于通过点击键盘上的按钮来关闭键盘 253 | 254 | Args: 255 | key_names: list of keys to tap to dismiss keyboard 256 | """ 257 | self.session_request(POST, "/wda/keyboard/dismiss", {"keyNames": key_names}) 258 | 259 | 260 | class XCUITestRecover(Recover): 261 | def __init__(self, udid: str): 262 | self._udid = udid 263 | 264 | def recover(self) -> bool: 265 | """ launch by tidevice 266 | 267 | https://github.com/alibaba/tidevice 268 | """ 269 | logger.info("WDA is starting using tidevice ...") 270 | args = [sys.executable, '-m', 'tidevice', '-u', self._udid, 'xctest'] 271 | p = subprocess.Popen(args, 272 | stdin=subprocess.DEVNULL, 273 | stdout=subprocess.PIPE, 274 | stderr=subprocess.STDOUT, 275 | start_new_session=True, 276 | close_fds=True, encoding="utf-8") 277 | 278 | que = queue.Queue() 279 | threading.Thread(target=self.drain_process_output, args=(p, que), daemon=True).start() 280 | try: 281 | success = que.get(timeout=20) 282 | return success 283 | except queue.Empty: 284 | logger.warning("WDA launch timeout 20s") 285 | p.kill() 286 | return False 287 | 288 | def drain_process_output(self, p: subprocess.Popen, msg_queue: queue.Queue): 289 | deadline = time.time() + 10 290 | lines = [] 291 | while time.time() < deadline: 292 | if p.poll() is not None: 293 | logger.warning("xctest exited, output --.\n %s", "\n".join(lines)) # p.stdout.read()) 294 | msg_queue.put(False) 295 | return 296 | line = p.stdout.readline().strip() 297 | lines.append(line) 298 | # logger.info("%s", line) 299 | if "WebDriverAgent start successfully" in line: 300 | logger.info("WDA started") 301 | msg_queue.put(True) 302 | break 303 | 304 | atexit.register(p.terminate) 305 | while p.stdout.read() != "": 306 | pass 307 | 308 | 309 | class AppiumClient(CommonClient, TouchActionsClient): 310 | """ 311 | client for https://github.com/appium/WebDriverAgent 312 | """ 313 | 314 | def __init__(self, wda_url: str = DEFAULT_WDA_URL): 315 | super().__init__(wda_url) 316 | 317 | 318 | def get_single_device_udid() -> str: 319 | devices = list_devices() 320 | if len(devices) == 0: 321 | raise WDAException("No device connected") 322 | if len(devices) > 1: 323 | raise WDAException("More than one device connected") 324 | return devices[0].serial 325 | 326 | 327 | class AppiumUSBClient(AppiumClient): 328 | def __init__(self, udid: Optional[str] = None, port: int = 8100): 329 | if udid is None: 330 | udid = get_single_device_udid() 331 | super().__init__(f"http+usbmux://{udid}:{port}") 332 | self.set_recover_handler(XCUITestRecover(udid)) 333 | 334 | 335 | class NanoClient(AppiumClient): 336 | """ 337 | Repo: https://github.com/nanoscopic/WebDriverAgent 338 | 339 | This repo changes a lot recently and the new version code drop the HTTP API to NNG 340 | So here use the old commit version 341 | https://github.com/nanoscopic/WebDriverAgent/tree/d07372d73a4cc4dc0b0d7807271e6d7958e57302 342 | """ 343 | 344 | def tap(self, x: int, y: int): 345 | """ fast tap """ 346 | self.request(POST, "/wda/tap", { 347 | "x": x, 348 | "y": y, 349 | }) 350 | 351 | def fast_swipe(self, 352 | from_x: int, 353 | from_y: int, 354 | to_x: int, 355 | to_y: int, 356 | duration: float = .5): 357 | """ fast swipe, this method can not simulate back action by swipe left to right """ 358 | self.request(POST, "/wda/swipe", { 359 | "x1": from_x, 360 | "y1": from_y, 361 | "x2": to_x, 362 | "y2": to_y, 363 | "delay": duration}) 364 | 365 | 366 | class NanoUSBClient(NanoClient): 367 | def __init__(self, udid: Optional[str] = None, port: int = 8100): 368 | if udid is None: 369 | udid = get_single_device_udid() 370 | super().__init__(f"http+usbmux://{udid}:{port}") 371 | self.set_recover_handler(XCUITestRecover(udid)) 372 | -------------------------------------------------------------------------------- /wdapy/_wrap.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | # 3 | 4 | import typing 5 | 6 | 7 | class Wrapper: 8 | """ 9 | MetaClass ref: 10 | https://www.jianshu.com/p/224ffcb8e73e 11 | """ 12 | 13 | def __init__(self): 14 | self._request_timeout = None 15 | 16 | 17 | def timeout(seconds: typing.Union[float, int]): 18 | def _timeout_wrapper(fn): 19 | def _inner(self, *args, **kwargs): 20 | old_timeout = self._request_timeout 21 | try: 22 | return fn(self, *args, **kwargs) 23 | finally: 24 | self._request_timeout = old_timeout 25 | return _inner 26 | return _timeout_wrapper 27 | -------------------------------------------------------------------------------- /wdapy/actions.py: -------------------------------------------------------------------------------- 1 | """ 2 | See 3 | - https://www.w3.org/TR/webdriver/#actions 4 | - https://appium.github.io/appium.io/docs/en/commands/interactions/actions/#http-api-specifications 5 | 6 | Usage example: 7 | # 起点142, 240 画一个长度为100的正方形 8 | finger1 = TouchActions.pointer("finger1", actions=[ 9 | PointerAction.move(142, 240, origin=Origin.VIEWPORT), 10 | PointerAction.down(), 11 | PointerAction.pause(1000), 12 | PointerAction.move(0, 100, origin=Origin.POINTER), 13 | PointerAction.pause(1000), 14 | PointerAction.move(100, 0, origin=Origin.POINTER), 15 | PointerAction.pause(1000), 16 | PointerAction.move(0, -100, origin=Origin.POINTER), 17 | PointerAction.pause(1000), 18 | PointerAction.move(-100, 0, origin=Origin.POINTER), 19 | PointerAction.up(), 20 | ]) 21 | client.perform_actions([finger1]) 22 | """ 23 | __all__ = [ 24 | "TouchActionType", 25 | "PointerActionType", 26 | "KeyActionType", 27 | "PointerType", 28 | "Origin", 29 | "PointerAction", 30 | "KeyAction", 31 | "Parameters", 32 | "TouchActions", 33 | ] 34 | 35 | from enum import Enum 36 | 37 | from typing import List, Optional, Union 38 | from pydantic import BaseModel 39 | 40 | from ._proto import POST 41 | from ._base import BaseClient 42 | 43 | class TouchActionType(str, Enum): 44 | POINTER = "pointer" 45 | KEY = "key" 46 | NULL = "null" 47 | 48 | 49 | class PointerActionType(str, Enum): 50 | POINTER_MOVE = "pointerMove" 51 | POINTER_DOWN = "pointerDown" 52 | POINTER_UP = "pointerUp" 53 | PAUSE = "pause" 54 | 55 | class KeyActionType(str, Enum): 56 | KEY_DOWN = "keyDown" 57 | KEY_UP = "keyUp" 58 | 59 | class PointerType(str, Enum): 60 | TOUCH = "touch" 61 | MOUSE = "mouse" 62 | PEN = "pen" 63 | 64 | class Origin(str, Enum): 65 | VIEWPORT = "viewport" 66 | POINTER = "pointer" 67 | # or: {'element-6066-11e4-a52e-4f735466cecf': ''} # not tested 68 | 69 | 70 | class PointerAction(BaseModel): 71 | type: PointerActionType 72 | duration: Optional[int] = None 73 | x: Optional[int] = None 74 | y: Optional[int] = None 75 | origin: Optional[Origin] = None 76 | button: Optional[int] = None 77 | 78 | @staticmethod 79 | def move(x: int, y: int, *, duration: int=100, origin: Origin = Origin.VIEWPORT) -> "PointerAction": 80 | """ 81 | Move pointer to (x, y) with duration(ms) 82 | 83 | Args: 84 | x: x coordinate 85 | y: y coordinate 86 | duration: duration(ms) default 100 87 | origin: pointer(relative to last position) or viewport(relative to (0, 0)) 88 | """ 89 | # pointerMove $x, $y, $duration 90 | # same as 91 | # pause $duration 92 | # pointerMove $x, $y, duration=0 93 | return PointerAction(type=PointerActionType.POINTER_MOVE, x=x, y=y, duration=duration, origin=origin) 94 | 95 | @staticmethod 96 | def down() -> "PointerAction": 97 | # button=0 # left 98 | return PointerAction(type=PointerActionType.POINTER_DOWN, button=0) 99 | 100 | @staticmethod 101 | def up() -> "PointerAction": 102 | return PointerAction(type=PointerActionType.POINTER_UP, button=0) 103 | 104 | @staticmethod 105 | def pause(duration: int) -> "PointerAction": 106 | return PointerAction(type=PointerActionType.PAUSE, duration=duration) 107 | 108 | 109 | class KeyAction(BaseModel): 110 | type: KeyActionType 111 | value: str 112 | 113 | @staticmethod 114 | def down(value: str) -> "KeyAction": 115 | return KeyAction(type=KeyActionType.KEY_DOWN, value=value) 116 | 117 | @staticmethod 118 | def up(value: str) -> "KeyAction": 119 | return KeyAction(type=KeyActionType.KEY_UP, value=value) 120 | 121 | 122 | class Parameters(BaseModel): 123 | pointerType: PointerType 124 | 125 | 126 | class TouchActions(BaseModel): 127 | type: TouchActionType 128 | id: str 129 | parameters: Optional[Parameters] = None 130 | actions: Union[List[PointerAction], List[KeyAction]] 131 | 132 | @staticmethod 133 | def pointer(id: str, actions: List[PointerAction], pointer_type: PointerType = PointerType.TOUCH) -> "TouchActions": 134 | return TouchActions( 135 | type=TouchActionType.POINTER, 136 | id=id, 137 | parameters=Parameters(pointerType=pointer_type), 138 | actions=actions 139 | ) 140 | 141 | @staticmethod 142 | def key(id: str, actions: List[KeyAction]) -> "TouchActions": 143 | return TouchActions(type=TouchActionType.KEY, id=id, actions=actions) 144 | 145 | @staticmethod 146 | def null(id: str) -> "TouchActions": 147 | return TouchActions(type=TouchActionType.NULL, id=id, actions=[]) 148 | 149 | 150 | class TouchActionsClient(BaseClient): 151 | def perform_actions(self, actions: List[TouchActions]): 152 | self.session_request(POST, "/actions", {"actions": [a.model_dump(exclude_none=True) for a in actions]}) -------------------------------------------------------------------------------- /wdapy/exceptions.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | # 3 | 4 | class WDAException(Exception): 5 | pass 6 | 7 | 8 | class RequestError(WDAException): 9 | pass 10 | 11 | 12 | class WDASessionDoesNotExist(WDAException): 13 | """ Session does not exist """ 14 | 15 | 16 | class ApiError(RequestError): 17 | """ request error, but with formated data """ 18 | 19 | 20 | class WDAFatalError(RequestError): 21 | """ unrecoverable error """ -------------------------------------------------------------------------------- /wdapy/usbmux/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | """Created on Thu Dec 09 2021 09:56:30 by codeskyblue 5 | """ 6 | 7 | from wdapy.usbmux.exceptions import MuxError, MuxConnectError -------------------------------------------------------------------------------- /wdapy/usbmux/exceptions.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | """Created on Tue Mar 05 2024 10:18:09 by codeskyblue 5 | 6 | Copy from https://github.com/doronz88/pymobiledevice3 7 | """ 8 | 9 | class NotPairedError(Exception): 10 | pass 11 | 12 | 13 | class MuxError(Exception): 14 | pass 15 | 16 | 17 | class MuxVersionError(MuxError): 18 | pass 19 | 20 | 21 | class BadCommandError(MuxError): 22 | pass 23 | 24 | 25 | class BadDevError(MuxError): 26 | pass 27 | 28 | 29 | class MuxConnectError(MuxError): 30 | pass 31 | 32 | 33 | class MuxConnectToUsbmuxdError(MuxConnectError): 34 | pass 35 | 36 | 37 | class ArgumentError(Exception): 38 | pass 39 | -------------------------------------------------------------------------------- /wdapy/usbmux/pyusbmux.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copy from https://github.com/doronz88/pymobiledevice3 3 | 4 | Add http.client.HTTPConnection 5 | """ 6 | import abc 7 | import plistlib 8 | import socket 9 | import sys 10 | import time 11 | from dataclasses import dataclass 12 | from http.client import HTTPConnection 13 | from typing import List, Mapping, Optional 14 | 15 | from construct import Const, CString, Enum, FixedSized, GreedyBytes, Int16ul, Int32ul, Padding, Prefixed, StreamError, \ 16 | Struct, Switch, this 17 | 18 | from wdapy.usbmux.exceptions import BadCommandError, BadDevError, MuxConnectError, \ 19 | MuxConnectToUsbmuxdError, MuxError, MuxVersionError, NotPairedError 20 | 21 | usbmuxd_version = Enum(Int32ul, 22 | BINARY=0, 23 | PLIST=1, 24 | ) 25 | 26 | usbmuxd_result = Enum(Int32ul, 27 | OK=0, 28 | BADCOMMAND=1, 29 | BADDEV=2, 30 | CONNREFUSED=3, 31 | BADVERSION=6, 32 | ) 33 | 34 | usbmuxd_msgtype = Enum(Int32ul, 35 | RESULT=1, 36 | CONNECT=2, 37 | LISTEN=3, 38 | ADD=4, 39 | REMOVE=5, 40 | PAIRED=6, 41 | PLIST=8, 42 | ) 43 | 44 | usbmuxd_header = Struct( 45 | 'version' / usbmuxd_version, # protocol version 46 | 'message' / usbmuxd_msgtype, # message type 47 | 'tag' / Int32ul, # responses to this query will echo back this tag 48 | ) 49 | 50 | usbmuxd_request = Prefixed(Int32ul, Struct( 51 | 'header' / usbmuxd_header, 52 | 'data' / Switch(this.header.message, { 53 | usbmuxd_msgtype.CONNECT: Struct( 54 | 'device_id' / Int32ul, 55 | 'port' / Int16ul, # TCP port number 56 | 'reserved' / Const(0, Int16ul), 57 | ), 58 | usbmuxd_msgtype.PLIST: GreedyBytes, 59 | }), 60 | ), includelength=True) 61 | 62 | usbmuxd_device_record = Struct( 63 | 'device_id' / Int32ul, 64 | 'product_id' / Int16ul, 65 | 'serial_number' / FixedSized(256, CString('ascii')), 66 | Padding(2), 67 | 'location' / Int32ul 68 | ) 69 | 70 | usbmuxd_response = Prefixed(Int32ul, Struct( 71 | 'header' / usbmuxd_header, 72 | 'data' / Switch(this.header.message, { 73 | usbmuxd_msgtype.RESULT: Struct( 74 | 'result' / usbmuxd_result, 75 | ), 76 | usbmuxd_msgtype.ADD: usbmuxd_device_record, 77 | usbmuxd_msgtype.REMOVE: Struct( 78 | 'device_id' / Int32ul, 79 | ), 80 | usbmuxd_msgtype.PLIST: GreedyBytes, 81 | }), 82 | ), includelength=True) 83 | 84 | 85 | 86 | 87 | @dataclass 88 | class MuxDevice: 89 | devid: int 90 | serial: str 91 | connection_type: str 92 | 93 | def connect(self, port: int, usbmux_address: Optional[str] = None) -> socket.socket: 94 | mux = create_mux(usbmux_address=usbmux_address) 95 | try: 96 | return mux.connect(self, port) 97 | except: # noqa: E722 98 | mux.close() 99 | raise 100 | 101 | @property 102 | def is_usb(self) -> bool: 103 | return self.connection_type == 'USB' 104 | 105 | @property 106 | def is_network(self) -> bool: 107 | return self.connection_type == 'Network' 108 | 109 | def matches_udid(self, udid: str) -> bool: 110 | return self.serial.replace('-', '') == udid.replace('-', '') 111 | 112 | def make_http_connection(self, port: int) -> HTTPConnection: 113 | return USBMuxHTTPConnection(self, port) 114 | 115 | 116 | class SafeStreamSocket: 117 | """ wrapper to native python socket object to be used with construct as a stream """ 118 | 119 | def __init__(self, address, family): 120 | self._offset = 0 121 | self.sock = socket.socket(family, socket.SOCK_STREAM) 122 | self.sock.connect(address) 123 | 124 | def send(self, msg: bytes) -> int: 125 | self._offset += len(msg) 126 | self.sock.sendall(msg) 127 | return len(msg) 128 | 129 | def recv(self, size: int) -> bytes: 130 | msg = b'' 131 | while len(msg) < size: 132 | chunk = self.sock.recv(size - len(msg)) 133 | self._offset += len(chunk) 134 | if not chunk: 135 | raise MuxError('socket connection broken') 136 | msg += chunk 137 | return msg 138 | 139 | def close(self) -> None: 140 | self.sock.close() 141 | 142 | def settimeout(self, interval: float) -> None: 143 | self.sock.settimeout(interval) 144 | 145 | def setblocking(self, blocking: bool) -> None: 146 | self.sock.setblocking(blocking) 147 | 148 | def tell(self) -> int: 149 | return self._offset 150 | 151 | read = recv 152 | write = send 153 | 154 | 155 | class MuxConnection: 156 | # used on Windows 157 | ITUNES_HOST = ('127.0.0.1', 27015) 158 | 159 | # used for macOS and Linux 160 | USBMUXD_PIPE = '/var/run/usbmuxd' 161 | 162 | @staticmethod 163 | def create_usbmux_socket(usbmux_address: Optional[str] = None) -> SafeStreamSocket: 164 | try: 165 | if usbmux_address is not None: 166 | if ':' in usbmux_address: 167 | # assume tcp address 168 | hostname, port = usbmux_address.split(':') 169 | port = int(port) 170 | address = (hostname, port) 171 | family = socket.AF_INET 172 | else: 173 | # assume unix domain address 174 | address = usbmux_address 175 | family = socket.AF_UNIX 176 | else: 177 | if sys.platform in ['win32', 'cygwin']: 178 | address = MuxConnection.ITUNES_HOST 179 | family = socket.AF_INET 180 | else: 181 | address = MuxConnection.USBMUXD_PIPE 182 | family = socket.AF_UNIX 183 | return SafeStreamSocket(address, family) 184 | except ConnectionRefusedError: 185 | raise MuxConnectToUsbmuxdError() 186 | 187 | @staticmethod 188 | def create(usbmux_address: Optional[str] = None): 189 | # first attempt to connect with possibly the wrong version header (plist protocol) 190 | sock = MuxConnection.create_usbmux_socket(usbmux_address=usbmux_address) 191 | 192 | message = usbmuxd_request.build({ 193 | 'header': {'version': usbmuxd_version.PLIST, 'message': usbmuxd_msgtype.PLIST, 'tag': 1}, 194 | 'data': plistlib.dumps({'MessageType': 'ReadBUID'}) 195 | }) 196 | sock.send(message) 197 | response = usbmuxd_response.parse_stream(sock) 198 | 199 | # if we sent a bad request, we should re-create the socket in the correct version this time 200 | sock.close() 201 | sock = MuxConnection.create_usbmux_socket(usbmux_address=usbmux_address) 202 | 203 | if response.header.version == usbmuxd_version.BINARY: 204 | return BinaryMuxConnection(sock) 205 | elif response.header.version == usbmuxd_version.PLIST: 206 | return PlistMuxConnection(sock) 207 | 208 | raise MuxVersionError(f'usbmuxd returned unsupported version: {response.version}') 209 | 210 | def __init__(self, sock: SafeStreamSocket): 211 | self._sock = sock 212 | 213 | # after initiating the "Connect" packet, this same socket will be used to transfer data into the service 214 | # residing inside the target device. when this happens, we can no longer send/receive control commands to 215 | # usbmux on same socket 216 | self._connected = False 217 | 218 | # message sequence number. used when verifying the response matched the request 219 | self._tag = 1 220 | 221 | self.devices = [] 222 | 223 | @abc.abstractmethod 224 | def _connect(self, device_id: int, port: int): 225 | """ initiate a "Connect" request to target port """ 226 | pass 227 | 228 | @abc.abstractmethod 229 | def get_device_list(self, timeout: float = None): 230 | """ 231 | request an update to current device list 232 | """ 233 | pass 234 | 235 | def connect(self, device: MuxDevice, port: int) -> socket.socket: 236 | """ connect to a relay port on target machine and get a raw python socket object for the connection """ 237 | self._connect(device.devid, socket.htons(port)) 238 | self._connected = True 239 | return self._sock.sock 240 | 241 | def close(self): 242 | """ close current socket """ 243 | self._sock.close() 244 | 245 | def _assert_not_connected(self): 246 | """ verify active state is in state for control messages """ 247 | if self._connected: 248 | raise MuxError('Mux is connected, cannot issue control packets') 249 | 250 | def _raise_mux_exception(self, result: int, message: str = None): 251 | exceptions = { 252 | int(usbmuxd_result.BADCOMMAND): BadCommandError, 253 | int(usbmuxd_result.BADDEV): BadDevError, 254 | int(usbmuxd_result.CONNREFUSED): MuxConnectError, 255 | int(usbmuxd_result.BADVERSION): MuxVersionError, 256 | } 257 | exception = exceptions.get(result, MuxError) 258 | raise exception(message) 259 | 260 | def __enter__(self): 261 | return self 262 | 263 | def __exit__(self, exc_type, exc_val, exc_tb): 264 | self.close() 265 | 266 | 267 | class BinaryMuxConnection(MuxConnection): 268 | """ old binary protocol """ 269 | 270 | def __init__(self, sock: SafeStreamSocket): 271 | super().__init__(sock) 272 | self._version = usbmuxd_version.BINARY 273 | 274 | def get_device_list(self, timeout: float = None): 275 | """ use timeout to wait for the device list to be fully populated """ 276 | self._assert_not_connected() 277 | end = time.time() + timeout 278 | self.listen() 279 | while time.time() < end: 280 | self._sock.settimeout(end - time.time()) 281 | try: 282 | self._receive_device_state_update() 283 | except (BlockingIOError, StreamError): 284 | continue 285 | except IOError: 286 | try: 287 | self._sock.setblocking(True) 288 | self.close() 289 | except OSError: 290 | pass 291 | raise MuxError('Exception in listener socket') 292 | 293 | def listen(self): 294 | """ start listening for events of attached and detached devices """ 295 | self._send_receive(usbmuxd_msgtype.LISTEN) 296 | 297 | def _connect(self, device_id: int, port: int): 298 | self._send({'header': {'version': self._version, 299 | 'message': usbmuxd_msgtype.CONNECT, 300 | 'tag': self._tag}, 301 | 'data': {'device_id': device_id, 'port': port}, 302 | }) 303 | response = self._receive() 304 | if response.header.message != usbmuxd_msgtype.RESULT: 305 | raise MuxError(f'unexpected message type received: {response}') 306 | 307 | if response.data.result != usbmuxd_result.OK: 308 | raise self._raise_mux_exception(int(response.data.result), 309 | f'failed to connect to device: {device_id} at port: {port}. reason: ' 310 | f'{response.data.result}') 311 | 312 | def _send(self, data: Mapping): 313 | self._assert_not_connected() 314 | self._sock.send(usbmuxd_request.build(data)) 315 | self._tag += 1 316 | 317 | def _receive(self, expected_tag: Optional[int] = None): 318 | self._assert_not_connected() 319 | response = usbmuxd_response.parse_stream(self._sock) 320 | if expected_tag and response.header.tag != expected_tag: 321 | raise MuxError(f'Reply tag mismatch: expected {expected_tag}, got {response.header.tag}') 322 | return response 323 | 324 | def _send_receive(self, message_type: int): 325 | self._send({'header': {'version': self._version, 'message': message_type, 'tag': self._tag}, 326 | 'data': b''}) 327 | response = self._receive(self._tag - 1) 328 | if response.header.message != usbmuxd_msgtype.RESULT: 329 | raise MuxError(f'unexpected message type received: {response}') 330 | 331 | result = response.data.result 332 | if result != usbmuxd_result.OK: 333 | raise self._raise_mux_exception(int(result), f'{message_type} failed: error {result}') 334 | 335 | def _add_device(self, device: MuxDevice): 336 | self.devices.append(device) 337 | 338 | def _remove_device(self, device_id: int): 339 | self.devices = [device for device in self.devices if device.devid != device_id] 340 | 341 | def _receive_device_state_update(self): 342 | response = self._receive() 343 | if response.header.message == usbmuxd_msgtype.ADD: 344 | # old protocol only supported USB devices 345 | self._add_device(MuxDevice(response.data.device_id, response.data.serial_number, 'USB')) 346 | elif response.header.message == usbmuxd_msgtype.REMOVE: 347 | self._remove_device(response.data.device_id) 348 | else: 349 | raise MuxError(f'Invalid packet type received: {response}') 350 | 351 | 352 | class PlistMuxConnection(BinaryMuxConnection): 353 | def __init__(self, sock: SafeStreamSocket): 354 | super().__init__(sock) 355 | self._version = usbmuxd_version.PLIST 356 | 357 | def listen(self) -> None: 358 | self._send_receive({'MessageType': 'Listen'}) 359 | 360 | def get_pair_record(self, serial: str) -> Mapping: 361 | # serials are saved inside usbmuxd without '-' 362 | self._send({'MessageType': 'ReadPairRecord', 'PairRecordID': serial}) 363 | response = self._receive(self._tag - 1) 364 | pair_record = response.get('PairRecordData') 365 | if pair_record is None: 366 | raise NotPairedError('device should be paired first') 367 | return plistlib.loads(pair_record) 368 | 369 | def get_device_list(self, timeout: float = None) -> None: 370 | """ get device list synchronously without waiting the timeout """ 371 | self.devices = [] 372 | self._send_receive({'MessageType': 'Listen'}) 373 | end = time.time() + timeout 374 | self._sock.settimeout(timeout) 375 | while time.time() < end: 376 | try: 377 | response = self._receive() 378 | if response['MessageType'] == 'Attached': 379 | super()._add_device(MuxDevice(response['DeviceID'], response['Properties']['SerialNumber'], 380 | response['Properties']['ConnectionType'])) 381 | elif response['MessageType'] == 'Detached': 382 | super()._remove_device(response['DeviceID']) 383 | else: 384 | raise MuxError(f'Invalid packet type received: {response}') 385 | except (BlockingIOError, StreamError): 386 | continue 387 | except IOError: 388 | try: 389 | self._sock.setblocking(True) 390 | self.close() 391 | except OSError: 392 | pass 393 | raise MuxError('Exception in listener socket') 394 | 395 | 396 | def get_buid(self) -> str: 397 | """ get SystemBUID """ 398 | self._send({'MessageType': 'ReadBUID'}) 399 | return self._receive(self._tag - 1)['BUID'] 400 | 401 | def save_pair_record(self, serial: str, device_id: int, record_data: bytes): 402 | # serials are saved inside usbmuxd without '-' 403 | self._send_receive({'MessageType': 'SavePairRecord', 404 | 'PairRecordID': serial, 405 | 'PairRecordData': record_data, 406 | 'DeviceID': device_id}) 407 | 408 | def _connect(self, device_id: int, port: int): 409 | self._send_receive({'MessageType': 'Connect', 'DeviceID': device_id, 'PortNumber': port}) 410 | 411 | def _send(self, data: Mapping): 412 | request = {'ClientVersionString': 'qt4i-usbmuxd', 'ProgName': 'pymobiledevice3', 'kLibUSBMuxVersion': 3} 413 | request.update(data) 414 | super()._send({'header': {'version': self._version, 415 | 'message': usbmuxd_msgtype.PLIST, 416 | 'tag': self._tag}, 417 | 'data': plistlib.dumps(request), 418 | }) 419 | 420 | def _receive(self, expected_tag: Optional[int] = None) -> Mapping: 421 | response = super()._receive(expected_tag=expected_tag) 422 | if response.header.message != usbmuxd_msgtype.PLIST: 423 | raise MuxError(f'Received non-plist type {response}') 424 | return plistlib.loads(response.data) 425 | 426 | def _send_receive(self, data: Mapping): 427 | self._send(data) 428 | response = self._receive(self._tag - 1) 429 | if response['MessageType'] != 'Result': 430 | raise MuxError(f'got an invalid message: {response}') 431 | if response['Number'] != 0: 432 | raise self._raise_mux_exception(response['Number'], f'got an error message: {response}') 433 | 434 | 435 | def create_mux(usbmux_address: Optional[str] = None) -> MuxConnection: 436 | return MuxConnection.create(usbmux_address=usbmux_address) 437 | 438 | 439 | def list_devices(usbmux_address: Optional[str] = None) -> List[MuxDevice]: 440 | mux = create_mux(usbmux_address=usbmux_address) 441 | mux.get_device_list(0.1) 442 | devices = mux.devices 443 | mux.close() 444 | return devices 445 | 446 | 447 | def select_device(udid: Optional[str] = None, connection_type: Optional[str] = None, usbmux_address: Optional[str] = None) \ 448 | -> Optional[MuxDevice]: 449 | """ 450 | select a UsbMux device according to given arguments. 451 | if more than one device could be selected, always prefer the usb one. 452 | """ 453 | tmp = None 454 | for device in list_devices(usbmux_address=usbmux_address): 455 | if connection_type is not None and device.connection_type != connection_type: 456 | # if a specific connection_type was desired and not of this one then skip 457 | continue 458 | 459 | if udid is not None and not device.matches_udid(udid): 460 | # if a specific udid was desired and not of this one then skip 461 | continue 462 | 463 | # save best result as a temporary 464 | tmp = device 465 | 466 | if device.is_usb: 467 | # always prefer usb connection 468 | return device 469 | 470 | return tmp 471 | 472 | 473 | def select_devices_by_connection_type(connection_type: str, usbmux_address: Optional[str] = None) -> List[MuxDevice]: 474 | """ 475 | select all UsbMux devices by connection type 476 | """ 477 | tmp = [] 478 | for device in list_devices(usbmux_address=usbmux_address): 479 | if device.connection_type == connection_type: 480 | tmp.append(device) 481 | 482 | return tmp 483 | 484 | 485 | 486 | class USBMuxHTTPConnection(HTTPConnection): 487 | def __init__(self, device: MuxDevice, port=8100): 488 | super().__init__("localhost", port) 489 | self.__device = device 490 | self.__port = port 491 | 492 | def connect(self): 493 | self.sock = self.__device.connect(self.__port) 494 | 495 | def __enter__(self) -> HTTPConnection: 496 | return self 497 | 498 | def __exit__(self, exc_type, exc_value, traceback): 499 | self.close() 500 | --------------------------------------------------------------------------------