├── .editorconfig ├── .github └── workflows │ └── release.yml ├── .gitignore ├── CONTRIBUTING.md ├── LICENSE.TXT ├── README.md ├── build.py ├── manager ├── __init__.py ├── activitymanager.py ├── controlmanager.py ├── devicemanager.py └── windowmanager.py ├── requirements.txt ├── res ├── ChromePortable49.7z ├── androiduispy.icns ├── androiduispy.ico ├── file_version_info.txt ├── inspect_dom_tree.gif └── inspect_native_control_tree.gif ├── ui ├── __init__.py ├── app.py └── mainframe.py ├── usage.md ├── utils ├── __init__.py ├── chrome.py ├── exceptions.py ├── logger.py ├── qpath.py └── workthread.py └── webinspect ├── __init__.py └── debugging_tool.py /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: http://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | # Unix-style newlines with a newline ending every file 7 | [*] 8 | end_of_line = lf 9 | insert_final_newline = true 10 | 11 | # Matches multiple files with brace expansion notation 12 | # Set default charset 13 | [*.{js,py}] 14 | charset = utf-8 15 | 16 | # 4 space indentation 17 | [*.py] 18 | indent_style = space 19 | indent_size = 4 20 | 21 | # Tab indentation (no size specified) 22 | [*.js] 23 | indent_style = tab 24 | 25 | # Indentation override for all JS under lib directory 26 | [lib/**.js] 27 | indent_style = space 28 | indent_size = 2 29 | 30 | # Matches the exact files either package.json or .travis.yml 31 | [{package.json,.travis.yml}] 32 | indent_style = space 33 | indent_size = 2 -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | 2 | name: Create Release and Upload Asset 3 | 4 | on: 5 | push: 6 | # Sequence of patterns matched against refs/tags 7 | tags: 8 | - '*' # Push events to matching v*, i.e. v1.0, v20.15.10 9 | 10 | jobs: 11 | release: 12 | name: Create Release ${{ github.ref }} 13 | runs-on: ubuntu-latest 14 | outputs: 15 | upload_url: ${{ steps.create_release.outputs.upload_url }} 16 | steps: 17 | - name: Create Release 18 | id: create_release 19 | uses: actions/create-release@v1 20 | env: 21 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 22 | with: 23 | tag_name: ${{ github.ref }} 24 | release_name: Release ${{ github.ref }} 25 | draft: false 26 | prerelease: false 27 | build: 28 | name: Build on ${{ matrix.os }} 29 | needs: release 30 | runs-on: ${{ matrix.os }} 31 | strategy: 32 | max-parallel: 3 33 | matrix: 34 | python-version: [3.11] 35 | os: [windows-latest, macos-13, macos-14] 36 | steps: 37 | - name: Checkout code 38 | uses: actions/checkout@v2 39 | - name: Set up Python ${{ matrix.python-version }} 40 | uses: actions/setup-python@v5 41 | with: 42 | python-version: ${{ matrix.python-version }} 43 | - name: Install Dependencies 44 | run: | 45 | python -V 46 | python -m pip install --upgrade pip 47 | pip install -r requirements.txt 48 | - name: Build binary file 49 | id: build_binary 50 | run: | 51 | python build.py ${{ github.ref }} 52 | cd dist 53 | python -c "import os,shutil;shutil.rmtree('AndroidUISpy.app') if os.path.isdir('AndroidUISpy.app') else None" 54 | python -m zipfile -c "../AndroidUISpy.zip" . 55 | python -c "print('::set-output name=arch::%s-%s' % (__import__('sys').platform, __import__('platform').machine().lower()))" 56 | - name: Upload Release Asset 57 | uses: actions/upload-release-asset@v1 58 | env: 59 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 60 | with: 61 | upload_url: ${{ needs.release.outputs.upload_url }} # This pulls from the CREATE RELEASE step above, referencing it's ID to get its outputs object, which include a `upload_url`. See this blog post for more info: https://jasonet.co/posts/new-features-of-github-actions/#passing-data-to-future-steps 62 | asset_path: AndroidUISpy.zip 63 | asset_name: AndroidUISpy-${{ steps.build_binary.outputs.arch }}.zip 64 | asset_content_type: application/zip 65 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env/* 2 | build/* 3 | dist/* 4 | qt4a/* 5 | version.py 6 | version_file.txt 7 | *.spec 8 | *.pyc 9 | *.log 10 | *.png 11 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qtacore/AndroidUISpy/dce970451028b63015b92be64e1ac60d8c184ce1/CONTRIBUTING.md -------------------------------------------------------------------------------- /LICENSE.TXT: -------------------------------------------------------------------------------- 1 | Tencent is pleased to support the open source community by making QTA available. 2 | Copyright (C) 2016THL A29 Limited, a Tencent company. All rights reserved. 3 | If you have downloaded a copy of the QTA binary from Tencent, please note that the QTA binary is licensed under the BSD 3-Clause License. 4 | If you have downloaded a copy of the QTA source code from Tencent, please note that QTAsource code is licensed under the BSD 3-Clause License, except for the third-party components listed below which are subject to different license terms. Your integration ofQTA into your own projects may require compliance with the BSD 3-ClauseLicense, as well as the other licenses applicable to the third-party components included within QTA. 5 | A copy of theBSD 3-Clause License is included in this file. 6 | Other dependencies and licenses: 7 | 8 | Open Source Software Licensed under the MITLicense: 9 | -------------------------------------------------------------------- 10 | 1. JavaScript-XPath0.1.12 11 | (c) 2007 Cybozu Labs, Inc. 12 | 13 | 14 | Terms of the MITLicense: 15 | -------------------------------------------------------------------- 16 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 17 | 18 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 19 | 20 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | 22 | Open Source Software Licensed Under the Apache License, Version 2.0: 23 | ---------------------------------------------------------------------------------------- 24 | 1. androguard 1.9 25 | Copyright (C) 2012 - 2015, Anthony Desnos (desnos at t0t0.fr) 26 | All rights reserved. 27 | 28 | 29 | Terms of the Apache License, Version 2.0: 30 | --------------------------------------------------- 31 | 32 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 33 | 34 | 1. Definitions. 35 | 36 | “License” shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. 37 | 38 | “Licensor” shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. 39 | 40 | “Legal Entity” shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, “control” means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. 41 | 42 | “You” (or “Your”) shall mean an individual or Legal Entity exercising permissions granted by this License. 43 | 44 | “Source” form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. 45 | 46 | “Object” form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. 47 | 48 | “Work” shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). 49 | 50 | “Derivative Works” shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. 51 | 52 | “Contribution” shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, “submitted” means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as “Not a Contribution.” 53 | 54 | “Contributor” shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 55 | 56 | 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 57 | 58 | 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 59 | 60 | 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: 61 | 62 | a) You must give any other recipients of the Work or Derivative Works a copy of this License; and 63 | 64 | b) You must cause any modified files to carry prominent notices stating that You changed the files; and 65 | 66 | c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and 67 | 68 | d) If the Work includes a “NOTICE” text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. 69 | 70 | You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 71 | 72 | 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 73 | 74 | 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 75 | 76 | 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an “AS IS” BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 77 | 78 | 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 79 | 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. 80 | 81 | 82 | Terms of the BSD 3-Clause License: 83 | -------------------------------------------------------------------- 84 | 85 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 86 | 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 87 | 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 88 | 3. Neither the name of [copyright holder] nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. 89 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 90 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Android控件探测器 2 | 3 | 本工具可用于探测Android设备中的应用控件,可代替`UIAutomator`等工具 4 | 5 | 使用限制: 6 | 7 | * Root设备中可以探测任意应用的控件 8 | * 非Root设备中只能探测debug应用的控件 9 | 10 | ## PREPARE ENVIRONMENT 11 | 12 | 1. `cd $project_root` 13 | 2. `python3 -m virtualenv .env3` 14 | 3. Windows: 15 | `.env3\Scripts\activate.bat` 16 | Others: 17 | `source .env3/bin/activate` 18 | 4. `pip install -r requirements.txt` 19 | 20 | ## HOW TO DEBUG 21 | 22 | ```bash 23 | $ export PYTHONPATH=. 24 | $ python ui/app.py 25 | ``` 26 | 27 | ## HOW TO BUILD 28 | 29 | ```bash 30 | $ python build.py ${versions}(3.0.0) 31 | ``` 32 | 33 | ## HOW TO RELEASE 34 | 35 | ```bash 36 | $ git tag ${version} 37 | $ git push origin ${version} 38 | ``` 39 | 40 | ## QUESTIONS 41 | 42 | * 如果macos上执行python脚本遇到以下报错: 43 | 44 | ``` 45 | This program needs access to the screen. Please run with a 46 | Framework build of python, and only when you are logged in 47 | on the main display of your Mac. 48 | ``` 49 | 50 | 可以将以下内容写入文件:`.env3/bin/python` 51 | 52 | ```bash 53 | #!/bin/bash 54 | 55 | # what real Python executable to use 56 | PYVER=3.11 57 | PYTHON=/System/Library/Frameworks/Python.framework/Versions/$PYVER/bin/python$PYVER 58 | 59 | # find the root of the virtualenv, it should be the parent of the dir this script is in 60 | ENV=`$PYTHON -c "import os; print os.path.abspath(os.path.join(os.path.dirname(\"$0\"), '..'))"` 61 | 62 | # now run Python with the virtualenv set as Python's HOME 63 | export PYTHONHOME=$ENV 64 | exec $PYTHON "$@" 65 | ``` 66 | 67 | 并添加可以执行权限:`chmod 775 .env3/bin/python` 68 | -------------------------------------------------------------------------------- /build.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | # 3 | # Tencent is pleased to support the open source community by making QTA available. 4 | # Copyright (C) 2016THL A29 Limited, a Tencent company. All rights reserved. 5 | # Licensed under the BSD 3-Clause License (the "License"); you may not use this 6 | # file except in compliance with the License. You may obtain a copy of the License at 7 | # 8 | # https://opensource.org/licenses/BSD-3-Clause 9 | # 10 | # Unless required by applicable law or agreed to in writing, software distributed 11 | # under the License is distributed on an "AS IS" basis, WITHOUT WARRANTIES OR CONDITIONS 12 | # OF ANY KIND, either express or implied. See the License for the specific language 13 | # governing permissions and limitations under the License. 14 | # 15 | 16 | """打包成可执行文件 17 | """ 18 | 19 | import os 20 | import sys 21 | 22 | import qt4a 23 | 24 | 25 | def main(version): 26 | qt4a_tools_path = qt4a.__path__[0] + "/androiddriver/tools" 27 | if "/" in version: 28 | # handle refs/tags/x.x.x.x 29 | version = version.split("/")[-1] 30 | version_items = version.split(".") 31 | for i in range(len(version_items)): 32 | version_items[i] = int(version_items[i]) 33 | 34 | with open("version.py", "w") as fp: 35 | fp.write('version_info=u"%s"' % version) 36 | 37 | if sys.platform == "win32": 38 | version_file_path = "version_file.txt" 39 | with open(os.path.join("res", "file_version_info.txt"), "r") as fp: 40 | text = fp.read() 41 | text = text % { 42 | "main_ver": version_items[0], 43 | "sub_ver": version_items[1], 44 | "min_ver": version_items[2], 45 | "build_num": version_items[3] if len(version_items) > 3 else 0, 46 | } 47 | with open(version_file_path, "w") as fp: 48 | fp.write(text) 49 | cmdline = ( 50 | "pyinstaller -F -w ui/app.py -n AndroidUISpy_v%s -i res/androiduispy.ico --add-data=%s;qt4a/androiddriver/tools --version-file %s" 51 | % (version, qt4a_tools_path, version_file_path) 52 | ) 53 | else: 54 | cmdline = ( 55 | "pyinstaller -F -w ui/app.py -n AndroidUISpy -i res/androiduispy.icns --add-data=%s:qt4a/androiddriver/tools" 56 | % qt4a_tools_path 57 | ) 58 | 59 | os.system(cmdline) 60 | 61 | if sys.platform == "darwin": 62 | os.system( 63 | 'hdiutil create /tmp/tmp.dmg -ov -volname "AndroidUISpy" -fs HFS+ -srcfolder "dist/AndroidUISpy.app"' 64 | ) 65 | os.system("hdiutil convert /tmp/tmp.dmg -format UDZO -o dist/AndroidUISpy.dmg") 66 | 67 | 68 | if __name__ == "__main__": 69 | if len(sys.argv) < 2: 70 | print >>sys.stderr, "usage: python build.py versions" 71 | exit() 72 | main(sys.argv[1]) 73 | -------------------------------------------------------------------------------- /manager/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | # 3 | # Tencent is pleased to support the open source community by making QTA available. 4 | # Copyright (C) 2016THL A29 Limited, a Tencent company. All rights reserved. 5 | # Licensed under the BSD 3-Clause License (the "License"); you may not use this 6 | # file except in compliance with the License. You may obtain a copy of the License at 7 | # 8 | # https://opensource.org/licenses/BSD-3-Clause 9 | # 10 | # Unless required by applicable law or agreed to in writing, software distributed 11 | # under the License is distributed on an "AS IS" basis, WITHOUT WARRANTIES OR CONDITIONS 12 | # OF ANY KIND, either express or implied. See the License for the specific language 13 | # governing permissions and limitations under the License. 14 | # 15 | 16 | """管理界面需要的各种数据 17 | """ 18 | 19 | 20 | class BaseManager(object): 21 | """Manager基类""" 22 | 23 | instance_dict = {} 24 | 25 | def __init__(self, device): 26 | self._device = device 27 | 28 | @classmethod 29 | def get_instance(cls, device): 30 | """获取实例""" 31 | key = "%s:%s" % (device._device_id, cls.__name__) 32 | if not key in cls.instance_dict: 33 | cls.instance_dict[key] = cls(device) 34 | return cls.instance_dict[key] 35 | 36 | def update(self): 37 | """刷新数据""" 38 | pass 39 | -------------------------------------------------------------------------------- /manager/activitymanager.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | # 3 | # Tencent is pleased to support the open source community by making QTA available. 4 | # Copyright (C) 2016THL A29 Limited, a Tencent company. All rights reserved. 5 | # Licensed under the BSD 3-Clause License (the "License"); you may not use this 6 | # file except in compliance with the License. You may obtain a copy of the License at 7 | # 8 | # https://opensource.org/licenses/BSD-3-Clause 9 | # 10 | # Unless required by applicable law or agreed to in writing, software distributed 11 | # under the License is distributed on an "AS IS" basis, WITHOUT WARRANTIES OR CONDITIONS 12 | # OF ANY KIND, either express or implied. See the License for the specific language 13 | # governing permissions and limitations under the License. 14 | # 15 | 16 | """Activity管理 17 | """ 18 | 19 | import re 20 | from manager import BaseManager 21 | 22 | 23 | class TaskStack(object): 24 | """任务栈""" 25 | 26 | def __init__(self, _id): 27 | self._id = _id 28 | self._tasks = [] 29 | 30 | def __str__(self): 31 | result = "TaskStack #%d\n" % self._id 32 | for task in self._tasks: 33 | result += "%s\n" % task 34 | return result 35 | 36 | @property 37 | def task_list(self): 38 | return self._tasks 39 | 40 | def add_task(self, task): 41 | self._tasks.append(task) 42 | 43 | 44 | class Task(object): 45 | """任务""" 46 | 47 | def __init__(self, _id): 48 | self._id = _id 49 | self._task_record = None 50 | self._activities = [] 51 | 52 | def __str__(self): 53 | result = "\n" % (id(self), self._task_record) 54 | for activity in self._activities: 55 | result += "%s\n" % activity 56 | return result 57 | 58 | @property 59 | def task_record(self): 60 | return self._task_record 61 | 62 | @task_record.setter 63 | def task_record(self, record): 64 | self._task_record = record 65 | 66 | @property 67 | def activity_list(self): 68 | return self._activities 69 | 70 | def add_activity(self, activity): 71 | """ """ 72 | self._activities.append(activity) 73 | 74 | 75 | class TaskRecord(object): 76 | """ """ 77 | 78 | def __init__(self, hashcode, task_id, package_name): 79 | self._hashcode = hashcode 80 | self._task_id = task_id 81 | self._package_name = package_name 82 | 83 | def __str__(self): 84 | return "" % ( 85 | id(self), 86 | self._hashcode, 87 | self._task_id, 88 | self._package_name, 89 | ) 90 | 91 | 92 | class Activity(object): 93 | """ """ 94 | 95 | def __init__(self, _id, activity_record): 96 | self._id = _id 97 | self._activity_record = activity_record 98 | self._attrs = {} 99 | 100 | @property 101 | def name(self): 102 | """Activity名称""" 103 | try: 104 | activity = self._attrs["realActivity"] 105 | except KeyError: 106 | activity = self._attrs["mActivityComponent"] 107 | pkg, activity = activity.split("/") 108 | if activity[0] == ".": 109 | activity = pkg + activity 110 | return activity 111 | 112 | @property 113 | def package_name(self): 114 | """所在包名""" 115 | return self._attrs["packageName"] 116 | 117 | @property 118 | def process_name(self): 119 | """所在进程名""" 120 | return self._attrs["processName"] 121 | 122 | def __setitem__(self, key, val): 123 | self._attrs[key] = val 124 | 125 | def __str__(self): 126 | result = "= 19 317 | ): 318 | # 支持Chrome远程调试 319 | debugging_tool = WebViewDebuggingTool(self._device) 320 | if not debugging_tool.is_webview_debugging_opened(process_name): 321 | self.enable_webview_debugging(process_name, hashcode) 322 | # webview.eval_script([], (ChromeInspectWebSocket.base_script % 'false') + ';qt4a_web_inspect._inspect_mode=true;') 323 | 324 | debugging_url = debugging_tool.get_debugging_url( 325 | process_name, multi_page_callback, None 326 | ) 327 | service_name = "webview_devtools_remote_%d" % pid 328 | 329 | if debugging_url == None: 330 | return None 331 | pos = debugging_url.find("?ws=") 332 | if pos <= 0: 333 | raise RuntimeError("Invalid debugging url: %s" % debugging_url) 334 | port = get_process_name_hash(process_name, self._device._device_id) 335 | port = self._device.adb.forward(port, service_name, "localabstract") 336 | debugging_url = ( 337 | debugging_url[: pos + 4] 338 | + ("127.0.0.1:%d" % port) 339 | + debugging_url[pos + 4 :] 340 | ) 341 | return Chrome.open_url(debugging_url) 342 | 343 | def get_webview(self, process_name, hashcode): 344 | """获取WebView实例""" 345 | # process_name = self._get_window_process(window_title) 346 | driver = self._get_driver(process_name) 347 | return WebView(driver, hashcode) 348 | 349 | 350 | class WebView(object): 351 | """WebView功能封装""" 352 | 353 | def __init__(self, driver, hashcode): 354 | self._driver = driver 355 | self._hashcode = hashcode 356 | self._type = self.get_webview_type() 357 | 358 | @staticmethod 359 | def is_webview(control_manager, window_title, hashcode): 360 | """是否是WebView控件""" 361 | webview = WebView(control_manager.get_driver(window_title), hashcode) 362 | return webview._type != EnumWebViewType.NotWebView 363 | 364 | def get_webview_type(self): 365 | """获取控件WebView类型""" 366 | result = self._driver.get_control_type(self._hashcode, True) 367 | if not isinstance(result, list): 368 | result = [result] 369 | 370 | for tp in result: 371 | if tp.startswith("org.xwalk.core.internal.XWalkContent$"): 372 | return EnumWebViewType.XWalkWebView 373 | elif tp in ["org.xwalk.core.internal.XWalkViewBridge"]: 374 | return EnumWebViewType.XWalkWebView 375 | elif tp in [ 376 | "com.tencent.smtt.webkit.WebView", 377 | "com.tencent.tbs.core.webkit.WebView", 378 | ]: 379 | return EnumWebViewType.X5WebView 380 | elif tp == "android.webkit.WebView": 381 | return EnumWebViewType.SystemWebView 382 | return EnumWebViewType.NotWebView 383 | 384 | def eval_script(self, frame_xpaths, script): 385 | """执行JavaScript代码""" 386 | return self._driver.eval_script(self._hashcode, frame_xpaths, script) 387 | 388 | 389 | if __name__ == "__main__": 390 | pass 391 | -------------------------------------------------------------------------------- /manager/devicemanager.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | # 3 | # Tencent is pleased to support the open source community by making QTA available. 4 | # Copyright (C) 2016THL A29 Limited, a Tencent company. All rights reserved. 5 | # Licensed under the BSD 3-Clause License (the "License"); you may not use this 6 | # file except in compliance with the License. You may obtain a copy of the License at 7 | # 8 | # https://opensource.org/licenses/BSD-3-Clause 9 | # 10 | # Unless required by applicable law or agreed to in writing, software distributed 11 | # under the License is distributed on an "AS IS" basis, WITHOUT WARRANTIES OR CONDITIONS 12 | # OF ANY KIND, either express or implied. See the License for the specific language 13 | # governing permissions and limitations under the License. 14 | # 15 | 16 | """设备管理 17 | """ 18 | 19 | import os 20 | import sys 21 | import threading 22 | import time 23 | 24 | from qt4a.androiddriver import adb 25 | from qt4a.androiddriver.androiddriver import copy_android_driver 26 | 27 | 28 | class DeviceManager(object): 29 | """ """ 30 | 31 | def __init__(self, hostname=None): 32 | if hostname == None: 33 | hostname = "127.0.0.1" 34 | self._hostname = hostname # 设备主机名称,为空表示是本机 35 | self._port = 5037 36 | self._running = True 37 | self._callbacks = [] 38 | t = threading.Thread(target=self.monitor_thread) 39 | t.setDaemon(True) 40 | t.start() 41 | 42 | def register_callback(self, on_device_inserted, on_device_removed): 43 | """注册回调 44 | 45 | :param on_device_inserted: 新设备插入回调 46 | :type on_device_inserted: function 47 | :param on_device_removed: 设备移除回调 48 | :type on_device_removed: function 49 | """ 50 | self._callbacks.append((on_device_inserted, on_device_removed)) 51 | 52 | @property 53 | def hostname(self): 54 | """ """ 55 | return self._hostname 56 | 57 | def get_device_list(self): 58 | """获取设备列表""" 59 | return adb.LocalADBBackend.list_device(self._hostname) 60 | 61 | def monitor_thread(self): 62 | """监控线程""" 63 | device_list = [] 64 | while self._running: 65 | new_device_list = self.get_device_list() 66 | 67 | for it in new_device_list: 68 | if not it in device_list: 69 | # 新设备插入 70 | copy_android_driver(it) 71 | for cb in self._callbacks: 72 | cb[0](it) 73 | 74 | for it in device_list: 75 | if not it in new_device_list: 76 | # 设备已移除 77 | for cb in self._callbacks: 78 | cb[1](it) 79 | 80 | device_list = new_device_list 81 | time.sleep(1) 82 | 83 | 84 | if __name__ == "__main__": 85 | pass 86 | -------------------------------------------------------------------------------- /manager/windowmanager.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | # 3 | # Tencent is pleased to support the open source community by making QTA available. 4 | # Copyright (C) 2016THL A29 Limited, a Tencent company. All rights reserved. 5 | # Licensed under the BSD 3-Clause License (the "License"); you may not use this 6 | # file except in compliance with the License. You may obtain a copy of the License at 7 | # 8 | # https://opensource.org/licenses/BSD-3-Clause 9 | # 10 | # Unless required by applicable law or agreed to in writing, software distributed 11 | # under the License is distributed on an "AS IS" basis, WITHOUT WARRANTIES OR CONDITIONS 12 | # OF ANY KIND, either express or implied. See the License for the specific language 13 | # governing permissions and limitations under the License. 14 | # 15 | 16 | """窗口管理 17 | """ 18 | 19 | import re 20 | from manager import BaseManager 21 | from utils.logger import Log 22 | 23 | 24 | class Window(object): 25 | """窗口类""" 26 | 27 | def __init__(self, win_manager, _id, hashcode, title): 28 | self._win_manager = win_manager 29 | self._id = _id 30 | self._hashcode = hashcode 31 | if not isinstance(title, str): 32 | title = title.decode("utf8") 33 | self._title = title 34 | self._attrs = {} 35 | 36 | def __str__(self): 37 | result = " 0 or self._attrs["y"] > 0: 128 | return True 129 | screen_width, screen_height = self._win_manager.get_screen_size() 130 | if ( 131 | w >= screen_width 132 | and h >= screen_height - 100 133 | or w >= screen_height 134 | and h >= screen_width 135 | ): 136 | return False 137 | # TODO: 排除掉虚拟按键的高度 138 | return True 139 | 140 | 141 | class WindowManager(BaseManager): 142 | """窗口管理""" 143 | 144 | def __init__(self, device): 145 | self._device = device 146 | self._current_window = None 147 | self._current_input_target = None 148 | self._window_list = [] 149 | 150 | def _update_window_info(self, window): 151 | """更新窗口信息""" 152 | if window is not None: 153 | for win in self._window_list: 154 | if window == win: 155 | for attr in win._attrs: 156 | window[attr] = win._attrs[attr] 157 | break 158 | 159 | def update(self): 160 | """刷新数据""" 161 | self._window_list = self._get_windows_data() 162 | for window in self._window_list: 163 | # 修复attached_window的部分信息 164 | self._update_window_info(window.attached_window) 165 | if self._current_window is not None: 166 | self._update_window_info(self._current_window) 167 | if self._current_input_target is not None: 168 | self._update_window_info(self._current_input_target) 169 | 170 | def get_screen_size(self): 171 | """获取屏幕大小""" 172 | w = h = 0 173 | for win in self.get_window_list(): 174 | if win.title.endswith(".Launcher"): 175 | continue # 桌面的高可能会包含虚拟机按键 176 | x, y = win.position 177 | if x != 0 or y != 0: 178 | continue 179 | w1, h1 = win.size 180 | if w1 > h1: 181 | w1, h1 = h1, w1 182 | if w1 > w: 183 | w = w1 184 | if h1 > h: 185 | h = h1 186 | return w, h 187 | 188 | def get_current_window(self): 189 | """当前拥有焦点的窗口""" 190 | if not self._window_list: 191 | self.update() 192 | return self._current_window 193 | 194 | def get_window_list(self, update=False): 195 | """ """ 196 | if not self._window_list or update: 197 | self.update() 198 | return self._window_list 199 | 200 | def _get_windows_data(self): 201 | """获取windows数据并解析""" 202 | result = self._device.adb.run_shell_cmd("dumpsys window") 203 | result = result.replace("\r", "") 204 | # print result 205 | windows = [] 206 | window = {} 207 | p1 = re.compile(r"^ Window #(\d+) Window{(\w{6,9}) (.*)}:$") 208 | p2 = re.compile(r"Window{(\w{6,9}) (u0 ){0,1}(\S+).*}") 209 | # p2 =re.compile(r'Window{(\w+) (u0 ){0,1}(\S+).*}') 210 | p3 = re.compile( 211 | r"mShownFrame=\[([-\d\.]+),([-\d\.]+)\]\[([-\d\.]+),([-\d\.]+)\]" 212 | ) 213 | for line in result.split("\n")[1:]: 214 | # print repr(line) 215 | ret = p1.match(line) 216 | if ret: 217 | title = ret.group(3) 218 | if " " in title: 219 | items = title.split(" ") 220 | if "u0" == items[0]: 221 | title = items[1] 222 | else: 223 | title = items[0] 224 | window = Window( 225 | self, int(ret.group(1)), ret.group(2), title 226 | ) # 此逻辑可能有bug 227 | windows.append(window) 228 | elif "mHoldScreenWindow" in line: 229 | ret = p2.search(line) 230 | if ret: 231 | title = ret.group(3) 232 | self._current_window = Window(self, 0, ret.group(1), title) 233 | else: 234 | Log.w("WindowManager", line) 235 | elif "mObscuringWindow" in line: 236 | ret = p2.search(line) 237 | if ret: 238 | title = ret.group(3) 239 | self._current_window = Window(self, 0, ret.group(1), title) 240 | else: 241 | Log.w("WindowManager", line) 242 | elif "mCurrentFocus" in line: 243 | ret = p2.search(line) 244 | if ret: 245 | title = ret.group(3) 246 | self._current_window = Window(self, 0, ret.group(1), title) 247 | else: 248 | Log.w("WindowManager", line) 249 | self._current_window = None 250 | elif "mInputMethodTarget" in line or "imeInputTarget" in line: 251 | ret = p2.search(line) 252 | self._current_input_target = Window( 253 | self, 254 | 0, 255 | ret.group(1), 256 | ret.group(2) 257 | if ret.group(2) and len(ret.group(2)) > 5 258 | else ret.group(3), 259 | ) 260 | elif line.startswith(" "): 261 | if "mShownFrame" in line: 262 | ret = p3.search(line) 263 | window["x"] = int(float(ret.group(1))) 264 | window["y"] = int(float(ret.group(2))) 265 | elif "mAttachedWindow" in line: 266 | ret = p2.search(line) 267 | window["mAttachedWindow"] = Window( 268 | self, 269 | 0, 270 | ret.group(1), 271 | ret.group(2) 272 | if ret.group(2) and len(ret.group(2)) > 5 273 | else ret.group(3), 274 | ) 275 | else: 276 | items = line.split(" ") 277 | for item in items: 278 | if "=" not in item: 279 | continue 280 | pos = item.find("=") 281 | key = item[:pos] 282 | val = item[pos + 1 :] 283 | if key in ["package", "w", "h"]: 284 | window[key] = val 285 | else: 286 | pass 287 | 288 | return windows 289 | 290 | 291 | if __name__ == "__main__": 292 | pass 293 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | wxPython 2 | PyInstaller 3 | qt4a 4 | pywin32==306; sys_platform == 'win32' 5 | -------------------------------------------------------------------------------- /res/ChromePortable49.7z: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qtacore/AndroidUISpy/dce970451028b63015b92be64e1ac60d8c184ce1/res/ChromePortable49.7z -------------------------------------------------------------------------------- /res/androiduispy.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qtacore/AndroidUISpy/dce970451028b63015b92be64e1ac60d8c184ce1/res/androiduispy.icns -------------------------------------------------------------------------------- /res/androiduispy.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qtacore/AndroidUISpy/dce970451028b63015b92be64e1ac60d8c184ce1/res/androiduispy.ico -------------------------------------------------------------------------------- /res/file_version_info.txt: -------------------------------------------------------------------------------- 1 | # UTF-8 2 | # 3 | # For more details about fixed file info 'ffi' see: 4 | # http://msdn.microsoft.com/en-us/library/ms646997.aspx 5 | VSVersionInfo( 6 | ffi=FixedFileInfo( 7 | # filevers and prodvers should be always a tuple with four items: (1, 2, 3, 4) 8 | # Set not needed items to zero 0. 9 | filevers=(%(main_ver)d, %(sub_ver)d, %(min_ver)d, %(build_num)d), 10 | prodvers=(%(main_ver)d, %(sub_ver)d, %(min_ver)d, %(build_num)d), 11 | # Contains a bitmask that specifies the valid bits 'flags'r 12 | mask=0x17, 13 | # Contains a bitmask that specifies the Boolean attributes of the file. 14 | flags=0x0, 15 | # The operating system for which this file was designed. 16 | # 0x4 - NT and there is no need to change it. 17 | OS=0x4, 18 | # The general type of file. 19 | # 0x1 - the file is an application. 20 | fileType=0x1, 21 | # The function of the file. 22 | # 0x0 - the function is not defined for this fileType 23 | subtype=0x0, 24 | # Creation date and time stamp. 25 | date=(0, 0) 26 | ), 27 | kids=[ 28 | StringFileInfo( 29 | [ 30 | StringTable( 31 | u'080404b0', 32 | [StringStruct(u'CompanyName', u'Tencent'), 33 | StringStruct(u'FileDescription', u'Android UI Spy'), 34 | StringStruct(u'FileVersion', u'%(main_ver)d.%(sub_ver)d.%(min_ver)d'), 35 | StringStruct(u'InternalName', u'AndroidUISpy.exe'), 36 | StringStruct(u'LegalCopyright', u'Copyright (C) 1998-2018 Tencent. All Rights Reserved'), 37 | StringStruct(u'OriginalFilename', u'AndroidUISpy.exe'), 38 | StringStruct(u'ProductName', u'AndroidUISpy'), 39 | StringStruct(u'ProductVersion', u'%(main_ver)d.%(sub_ver)d.%(min_ver)d')]) 40 | ]), 41 | VarFileInfo([VarStruct(u'Translation', [2052, 1200])]) 42 | ] 43 | ) 44 | -------------------------------------------------------------------------------- /res/inspect_dom_tree.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qtacore/AndroidUISpy/dce970451028b63015b92be64e1ac60d8c184ce1/res/inspect_dom_tree.gif -------------------------------------------------------------------------------- /res/inspect_native_control_tree.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qtacore/AndroidUISpy/dce970451028b63015b92be64e1ac60d8c184ce1/res/inspect_native_control_tree.gif -------------------------------------------------------------------------------- /ui/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | # 3 | # Tencent is pleased to support the open source community by making QTA available. 4 | # Copyright (C) 2016THL A29 Limited, a Tencent company. All rights reserved. 5 | # Licensed under the BSD 3-Clause License (the "License"); you may not use this 6 | # file except in compliance with the License. You may obtain a copy of the License at 7 | # 8 | # https://opensource.org/licenses/BSD-3-Clause 9 | # 10 | # Unless required by applicable law or agreed to in writing, software distributed 11 | # under the License is distributed on an "AS IS" basis, WITHOUT WARRANTIES OR CONDITIONS 12 | # OF ANY KIND, either express or implied. See the License for the specific language 13 | # governing permissions and limitations under the License. 14 | # 15 | 16 | """界面展示 17 | """ 18 | -------------------------------------------------------------------------------- /ui/app.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | # 3 | # Tencent is pleased to support the open source community by making QTA available. 4 | # Copyright (C) 2016THL A29 Limited, a Tencent company. All rights reserved. 5 | # Licensed under the BSD 3-Clause License (the "License"); you may not use this 6 | # file except in compliance with the License. You may obtain a copy of the License at 7 | # 8 | # https://opensource.org/licenses/BSD-3-Clause 9 | # 10 | # Unless required by applicable law or agreed to in writing, software distributed 11 | # under the License is distributed on an "AS IS" basis, WITHOUT WARRANTIES OR CONDITIONS 12 | # OF ANY KIND, either express or implied. See the License for the specific language 13 | # governing permissions and limitations under the License. 14 | # 15 | 16 | """APP类 17 | """ 18 | 19 | import wx 20 | 21 | from ui import mainframe 22 | 23 | 24 | class AndroidUISpyApp(wx.App): 25 | """ """ 26 | 27 | def OnInit(self): 28 | self.main = mainframe.create(None) 29 | self.main.Center() 30 | self.main.Show() 31 | 32 | self.SetTopWindow(self.main) 33 | return True 34 | 35 | 36 | def main(): 37 | application = AndroidUISpyApp() 38 | application.MainLoop() 39 | 40 | 41 | if __name__ == "__main__": 42 | main() 43 | -------------------------------------------------------------------------------- /ui/mainframe.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | # 3 | # Tencent is pleased to support the open source community by making QTA available. 4 | # Copyright (C) 2016THL A29 Limited, a Tencent company. All rights reserved. 5 | # Licensed under the BSD 3-Clause License (the "License"); you may not use this 6 | # file except in compliance with the License. You may obtain a copy of the License at 7 | # 8 | # https://opensource.org/licenses/BSD-3-Clause 9 | # 10 | # Unless required by applicable law or agreed to in writing, software distributed 11 | # under the License is distributed on an "AS IS" basis, WITHOUT WARRANTIES OR CONDITIONS 12 | # OF ANY KIND, either express or implied. See the License for the specific language 13 | # governing permissions and limitations under the License. 14 | # 15 | 16 | import io 17 | import os 18 | import sys 19 | import threading 20 | import time 21 | 22 | import wx 23 | 24 | from PIL import Image 25 | from qt4a.androiddriver.adb import ADB 26 | from qt4a.androiddriver.devicedriver import DeviceDriver 27 | from qt4a.androiddriver.util import ControlExpiredError 28 | 29 | from manager.controlmanager import EnumWebViewType, ControlManager, WebView 30 | from manager.devicemanager import DeviceManager 31 | from manager.windowmanager import WindowManager 32 | from utils import run_in_thread 33 | from utils.logger import Log 34 | from utils.workthread import WorkThread 35 | 36 | default_size = [1360, 800] 37 | 38 | try: 39 | from version import version_info 40 | except ImportError: 41 | version_info = "v3.0.0" 42 | 43 | 44 | def create(parent): 45 | return MainFrame(parent) 46 | 47 | 48 | def run_in_main_thread(func): 49 | """主线程运行""" 50 | 51 | def wrap_func(*args, **kwargs): 52 | wx.CallAfter(func, *args, **kwargs) 53 | 54 | return wrap_func 55 | 56 | 57 | class MainFrame(wx.Frame): 58 | """ """ 59 | 60 | def __init__(self, parent): 61 | self._window_size = self._get_window_size() 62 | self._init_ctrls(parent) 63 | self._enable_inspect = False 64 | self._tree_list = [] 65 | self._select_device = None 66 | self._device_host = None 67 | self._scale_rate = 1 # 截图缩放比例 68 | self._mouse_move_enabled = False 69 | self._image_path = None 70 | self._device_manager = DeviceManager() 71 | self._device_manager.register_callback( 72 | self.on_device_inserted, self.on_device_removed 73 | ) 74 | self._work_thread = WorkThread() 75 | self.Bind(wx.EVT_SIZE, self.on_resize) 76 | 77 | def _get_window_size(self): 78 | width, height = default_size 79 | screen_width, screen_height = wx.DisplaySize() 80 | Log.i( 81 | self.__class__.__name__, 82 | "Screen size: %d x %d" % (screen_width, screen_height), 83 | ) 84 | if screen_height > 1000: 85 | height = screen_height - 100 86 | width = min(screen_width - 100, default_size[0] * height // default_size[1]) 87 | Log.i(self.__class__.__name__, "Window size: %d x %d" % (width, height)) 88 | return width, height 89 | 90 | def _init_ctrls(self, prnt): 91 | # generated method, don't edit 92 | 93 | wx.Frame.__init__( 94 | self, 95 | id=wx.ID_ANY, 96 | name="", 97 | parent=prnt, 98 | pos=wx.Point(0, 0), 99 | size=wx.Size(*self._window_size), 100 | style=wx.DEFAULT_FRAME_STYLE, 101 | title="AndroidUISpy " + version_info, 102 | ) 103 | 104 | self.Bind(wx.EVT_CLOSE, self.on_close) 105 | self.statusbar = self.CreateStatusBar() 106 | # 将状态栏分割为3个区域,比例为1:2:3 107 | self.statusbar.SetFieldsCount(3) 108 | self.statusbar.SetStatusWidths([-3, -2, -1]) 109 | 110 | self.panel = wx.Panel( 111 | self, size=(self._window_size[0] - 20, self._window_size[1] - 70) 112 | ) 113 | self.font = wx.SystemSettings.GetFont(wx.SYS_SYSTEM_FONT) 114 | self.font.SetPointSize(9) 115 | 116 | main_panel_width = 900 117 | main_panel_height = self.panel.Size[1] 118 | self.main_panel = wx.Panel( 119 | self.panel, size=(main_panel_width, main_panel_height) 120 | ) 121 | 122 | device_panel_height = 90 123 | property_panel_height = 130 124 | device_panel = wx.Panel( 125 | self.main_panel, size=(main_panel_width, device_panel_height) 126 | ) 127 | self._init_device_panel(device_panel) 128 | 129 | self._tree_panel_width = main_panel_width 130 | self._tree_panel_height = ( 131 | main_panel_height - device_panel_height - property_panel_height 132 | ) 133 | self.tree_panel = wx.Panel( 134 | self.main_panel, 135 | pos=(0, device_panel_height), 136 | size=(self._tree_panel_width, self._tree_panel_height), 137 | ) 138 | 139 | self.property_panel = wx.Panel( 140 | self.main_panel, 141 | pos=(0, main_panel_height - property_panel_height), 142 | size=(main_panel_width, property_panel_height), 143 | ) 144 | self._init_property_panel(self.property_panel) 145 | 146 | self.screen_panel = wx.Panel( 147 | self.panel, 148 | pos=(main_panel_width, 5), 149 | size=(self.panel.Size[0] - main_panel_width, self.main_panel.Size[1]), 150 | ) 151 | # self.screen_panel.SetBackgroundColour(wx.BLUE) 152 | self._init_screen_panel(self.screen_panel) 153 | 154 | def _init_device_panel(self, panel): 155 | self.btn_inspect = wx.Button( 156 | panel, label="+", name="btn_inspect", pos=(5, 5), size=wx.Size(20, 20) 157 | ) 158 | self.btn_inspect.SetFont(self.font) 159 | self.btn_inspect.Bind(wx.EVT_BUTTON, self.on_inspect_btn_click) 160 | self.btn_inspect.Enable(False) 161 | 162 | wx.StaticText(panel, label="设备ID:", pos=(40, 7)) 163 | self.cb_device = wx.ComboBox(panel, wx.ID_ANY, pos=(100, 5), size=(200, 20)) 164 | self.cb_device.Bind(wx.EVT_COMBOBOX, self.on_select_device) 165 | 166 | self.btn_refresh = wx.Button( 167 | panel, label="刷新", name="btn_refresh", pos=(310, 5), size=(50, 20) 168 | ) 169 | self.btn_refresh.Enable(False) 170 | self.btn_refresh.Bind(wx.EVT_BUTTON, self.on_refresh_btn_click) 171 | 172 | wx.StaticText(self.panel, label="选择Activity: ", pos=(400, 7)) 173 | self.cb_activity = wx.ComboBox( 174 | panel, id=wx.ID_ANY, pos=(500, 5), size=(300, 20) 175 | ) 176 | self.cb_activity.Bind(wx.EVT_COMBOBOX, self.on_select_window) 177 | self.cb_activity.Bind(wx.EVT_COMBOBOX_DROPDOWN, self.on_window_list_dropdown) 178 | 179 | self.btn_getcontrol = wx.Button( 180 | panel, label="获取控件", name="btn_getcontrol", pos=(810, 5), size=(75, 20) 181 | ) 182 | self.btn_getcontrol.Bind(wx.EVT_BUTTON, self.on_getcontrol_btn_click) 183 | self.btn_getcontrol.Enable(False) 184 | 185 | wx.StaticBox(panel, label="高级选项", pos=(5, 30), size=(890, 50)) 186 | self.rb_local_device = wx.RadioButton( 187 | panel, label="本地设备", pos=(10, 52), size=wx.DefaultSize 188 | ) 189 | self.rb_local_device.SetValue(True) 190 | self.rb_local_device.Bind(wx.EVT_RADIOBUTTON, self.on_local_device_selected) 191 | 192 | self.rb_remote_device = wx.RadioButton( 193 | panel, label="远程设备", pos=(90, 52), size=wx.DefaultSize 194 | ) 195 | self.rb_remote_device.Bind(wx.EVT_RADIOBUTTON, self.on_remote_device_selected) 196 | 197 | wx.StaticText( 198 | panel, label="远程设备主机名: ", pos=(160, 52), size=wx.DefaultSize 199 | ) 200 | self.tc_dev_host = wx.TextCtrl(panel, pos=(255, 50), size=(150, 20)) 201 | self.tc_dev_host.Enable(False) 202 | self.tc_dev_host.SetToolTip(wx.ToolTip("输入要调试设备所在的主机名")) 203 | self.btn_set_device_host = wx.Button( 204 | panel, label="确定", pos=(410, 50), size=wx.Size(60, 20) 205 | ) 206 | self.btn_set_device_host.Enable(False) 207 | self.btn_set_device_host.Bind(wx.EVT_BUTTON, self.on_set_device_host_btn_click) 208 | 209 | self.cb_auto_refresh = wx.CheckBox( 210 | panel, label="自动刷新屏幕", pos=(500, 52), size=wx.DefaultSize 211 | ) 212 | self.cb_auto_refresh.Bind(wx.EVT_CHECKBOX, self.on_auto_fresh_checked) 213 | wx.StaticText(panel, label="刷新频率: ", pos=(610, 52), size=wx.DefaultSize) 214 | self.tc_refresh_interval = wx.TextCtrl( 215 | panel, pos=(670, 50), size=wx.Size(30, 20) 216 | ) 217 | self.tc_refresh_interval.SetValue("1") 218 | wx.StaticText(panel, label="秒", pos=(710, 52), size=wx.DefaultSize) 219 | 220 | self.refresh_timer = wx.Timer(self) 221 | self.Bind( 222 | wx.EVT_TIMER, self.on_refresh_timer, self.refresh_timer 223 | ) # 绑定一个计时器 224 | 225 | def _init_property_panel(self, panel): 226 | wx.StaticBox( 227 | panel, 228 | label="控件属性", 229 | pos=(5, 5), 230 | size=(panel.Size[0] - 10, panel.Size[1] - 4), 231 | ) 232 | wx.StaticText(panel, label="ID", pos=(15, 27), size=wx.DefaultSize) 233 | wx.StaticText(panel, label="Type", pos=(15, 52), size=wx.DefaultSize) 234 | wx.StaticText(panel, label="Visible", pos=(15, 77), size=wx.DefaultSize) 235 | wx.StaticText(panel, label="Text", pos=(15, 102), size=wx.DefaultSize) 236 | 237 | self.tc_id = wx.TextCtrl(panel, pos=(65, 25), size=(120, 20)) 238 | self.tc_type = wx.TextCtrl(panel, pos=(65, 50), size=(120, 20)) 239 | self.tc_visible = wx.TextCtrl(panel, pos=(65, 75), size=(120, 20)) 240 | self.tc_text = wx.TextCtrl(panel, pos=(65, 100), size=(120, 20)) 241 | self.tc_text.Enable(False) 242 | self.tc_text.Bind(wx.EVT_TEXT, self.on_node_text_changed) 243 | 244 | wx.StaticText(panel, label="HashCode", pos=(210, 27), size=wx.DefaultSize) 245 | wx.StaticText(panel, label="Rect", pos=(210, 52), size=wx.DefaultSize) 246 | wx.StaticText(panel, label="Enabled", pos=(210, 77), size=wx.DefaultSize) 247 | self.btn_set_text = wx.Button( 248 | panel, label="修改文本", pos=(190, 100), size=(70, 20) 249 | ) 250 | self.btn_set_text.Enable(False) 251 | self.btn_set_text.Bind(wx.EVT_BUTTON, self.on_set_text_btn_click) 252 | self.cb_show_hex = wx.CheckBox(panel, label="显示16进制", pos=(280, 102)) 253 | 254 | self.tc_hashcode = wx.TextCtrl(panel, pos=(280, 25), size=(120, 20)) 255 | self.tc_rect = wx.TextCtrl(panel, pos=(280, 50), size=(120, 20)) 256 | self.tc_enable = wx.TextCtrl(panel, pos=(280, 75), size=(120, 20)) 257 | 258 | wx.StaticText(panel, label="Clickable", pos=(420, 27), size=wx.DefaultSize) 259 | wx.StaticText(panel, label="Checkable", pos=(420, 52), size=wx.DefaultSize) 260 | wx.StaticText(panel, label="Checked", pos=(420, 77), size=wx.DefaultSize) 261 | self.tc_clickable = wx.TextCtrl(panel, pos=(490, 25), size=(120, 20)) 262 | self.tc_checkable = wx.TextCtrl(panel, pos=(490, 50), size=(120, 20)) 263 | self.tc_checked = wx.TextCtrl(panel, pos=(490, 75), size=(120, 20)) 264 | 265 | wx.StaticText(panel, label="ProcessName", pos=(640, 27), size=wx.DefaultSize) 266 | wx.StaticText(panel, label="Descriptions", pos=(640, 52), size=wx.DefaultSize) 267 | self.tc_process_name = wx.TextCtrl(panel, pos=(730, 25), size=(150, 20)) 268 | self.tc_desc = wx.TextCtrl(panel, pos=(730, 50), size=(150, 20)) 269 | 270 | def _init_screen_panel(self, panel): 271 | self.image = wx.StaticBitmap(panel) 272 | self.image.Bind(wx.EVT_MOUSE_EVENTS, self.on_mouse_move) 273 | 274 | self.mask_panel = CanvasPanel(parent=panel) 275 | self.mask_panel.Bind(wx.EVT_MOUSE_EVENTS, self.on_mouse_move) 276 | 277 | def on_close(self, event): 278 | """ """ 279 | import atexit 280 | 281 | atexit._exithandlers = [] # 禁止退出时弹出错误框 282 | event.Skip() 283 | 284 | def on_resize(self, event): 285 | prev_window_size = self._window_size 286 | window_size = self.GetSize() 287 | if ( 288 | abs(window_size[0] - prev_window_size[0]) <= 2 289 | and abs(window_size[1] - prev_window_size[1]) <= 2 290 | ): 291 | event.Skip() 292 | return 293 | 294 | if window_size[0] < default_size[0] or window_size[1] < default_size[1]: 295 | self.SetSize( 296 | ( 297 | max(window_size[0], default_size[0]), 298 | max(window_size[1], default_size[1]), 299 | ) 300 | ) 301 | event.Skip() 302 | return 303 | 304 | self._window_size = window_size 305 | width_delta = self._window_size[0] - prev_window_size[0] 306 | height_delta = self._window_size[1] - prev_window_size[1] 307 | self.panel.SetSize( 308 | (self.panel.Size[0] + width_delta, self.panel.Size[1] + height_delta) 309 | ) 310 | if height_delta: 311 | self.main_panel.SetSize( 312 | (self.main_panel.Size[0], self.main_panel.Size[1] + height_delta) 313 | ) 314 | self.tree_panel.SetSize( 315 | (self.tree_panel.Size[0], self.tree_panel.Size[1] + height_delta) 316 | ) 317 | self.tree.SetSize((self.tree.Size[0], self.tree.Size[1] + height_delta)) 318 | self.property_panel.SetPosition( 319 | (0, self.property_panel.Position[1] + height_delta) 320 | ) 321 | self.screen_panel.SetSize( 322 | (self.panel.Size[0] - self.main_panel.Size[0], self.main_panel.Size[1]) 323 | ) 324 | 325 | if self._image_path and os.path.isfile(self._image_path): 326 | image = Image.open(self._image_path) 327 | self._show_image(image) 328 | 329 | event.Skip() 330 | 331 | def on_select_window(self, event): 332 | """选择了一个窗口""" 333 | window_title = self.cb_activity.GetValue() 334 | if window_title.endswith(" "): 335 | window_title = window_title.strip() 336 | run_in_main_thread(self.cb_activity.SetLabelText)(window_title) 337 | 338 | def on_window_list_dropdown(self, event): 339 | """ """ 340 | cur_sel = self.cb_activity.GetSelection() 341 | self.cb_activity.Select(cur_sel) 342 | 343 | def on_device_inserted(self, device_name): 344 | """新设备插入回调""" 345 | self.statusbar.SetStatusText("设备:%s 已插入" % device_name, 0) 346 | self.cb_device.Append(device_name) 347 | if self.cb_device.GetSelection() < 0: 348 | self.cb_device.SetSelection(0) 349 | self.on_select_device(None) 350 | 351 | def on_device_removed(self, device_name): 352 | """设备移除回调""" 353 | self.statusbar.SetStatusText("设备:%s 已断开" % device_name, 0) 354 | for index, it in enumerate(self.cb_device.Items): 355 | if it == device_name: 356 | self.cb_device.Delete(index) 357 | break 358 | 359 | @run_in_main_thread 360 | def on_select_device(self, event): 361 | """选中的某个设备""" 362 | new_dev = self.cb_device.GetValue() 363 | if new_dev != self._select_device: 364 | self._select_device = new_dev 365 | device_id = self._select_device 366 | if self._device_host: 367 | device_id = self._device_host + ":" + device_id 368 | self._device = DeviceDriver(ADB.open_device(device_id)) 369 | self.statusbar.SetStatusText("当前设备:%s" % self._select_device, 0) 370 | for tree in self._tree_list: 371 | # 先删除之前创建的控件树 372 | tree["root"] = None 373 | tree["tree"].DeleteAllItems() 374 | tree["tree"].Destroy() 375 | self._tree_list = [] 376 | self._tree_idx = 0 377 | self.image.Hide() 378 | self.cb_activity.SetValue("") 379 | self._window_manager = WindowManager.get_instance(self._device) 380 | self._control_manager = ControlManager.get_instance(self._device) 381 | wx.CallLater( 382 | 1000, lambda: self.on_getcontrol_btn_click(None) 383 | ) # 自动获取控件树 384 | 385 | self.btn_refresh.Enable(True) 386 | self.btn_getcontrol.Enable(True) 387 | 388 | def on_getcontrol_btn_click(self, event): 389 | """点击获取控件按钮""" 390 | self.btn_getcontrol.Enable(False) 391 | if not self.cb_activity.GetValue(): 392 | # 先刷新窗口列表 393 | self.on_refresh_btn_click(None) 394 | 395 | if self.cb_activity.GetValue() == "Keyguard": 396 | # 锁屏状态 397 | dlg = wx.MessageDialog( 398 | self, 399 | "设备:%s 处于锁屏状态,请手动解锁后点击OK按钮" 400 | % self.cb_device.GetValue(), 401 | "提示", 402 | style=wx.YES_NO | wx.YES_DEFAULT | wx.ICON_QUESTION, 403 | ) 404 | result = dlg.ShowModal() 405 | if result == wx.ID_YES: 406 | self.on_refresh_btn_click(None) 407 | dlg.Destroy() 408 | 409 | self.statusbar.SetStatusText("正在获取控件树……", 0) 410 | 411 | def _update_control_tree(): 412 | time0 = time.time() 413 | try: 414 | controls_dict = ( 415 | self._control_manager.get_control_tree() 416 | ) # self.cb_activity.GetValue().strip(), index 417 | if not controls_dict: 418 | return 419 | except RuntimeError as e: 420 | msg = e.args[0] 421 | 422 | # if not isinstance(msg, str): 423 | # msg = msg.decode("utf8") 424 | def _show_dialog(): 425 | dlg = wx.MessageDialog( 426 | self, msg, "查找控件失败", style=wx.OK | wx.ICON_ERROR 427 | ) 428 | dlg.ShowModal() 429 | dlg.Destroy() 430 | 431 | run_in_main_thread(_show_dialog)() 432 | return 433 | 434 | used_time = time.time() - time0 435 | run_in_main_thread( 436 | lambda: self.statusbar.SetStatusText( 437 | "获取控件树完成,耗时:%s S" % used_time, 0 438 | ) 439 | )() 440 | msg = "" 441 | for key in controls_dict: 442 | msg += "\n%s: %d" % (key, len(controls_dict[key]) - 1) 443 | Log.i("MainFrame", "get control tree cost %s S%s" % (used_time, msg)) 444 | self._show_control_tree(controls_dict) 445 | 446 | run_in_thread(_update_control_tree)() 447 | 448 | t = threading.Thread(target=self._refresh_device_screenshot) 449 | t.setDaemon(True) 450 | t.start() 451 | 452 | @run_in_main_thread 453 | def _show_control_tree(self, controls_dict): 454 | """显示控件树""" 455 | self.show_controls(controls_dict) 456 | 457 | self._mouse_move_enabled = True 458 | self.btn_inspect.Enable(True) 459 | self.tree.SelectItem(self.root) 460 | self.tree.SetFocus() 461 | self.btn_getcontrol.Enable(True) 462 | 463 | def on_refresh_btn_click(self, event): 464 | """刷新按钮点击回调""" 465 | self.statusbar.SetStatusText("正在获取窗口列表……", 0) 466 | time0 = time.time() 467 | self.show_windows() 468 | used_time = time.time() - time0 469 | self.statusbar.SetStatusText("获取窗口列表完成,耗时:%s S" % used_time, 0) 470 | 471 | def show_windows(self): 472 | """显示Window列表""" 473 | self.cb_activity.Clear() 474 | self._control_manager.update() 475 | current_window = self._window_manager.get_current_window() 476 | 477 | if current_window is None: 478 | dlg = wx.MessageDialog( 479 | self, 480 | "请确认手机是否出现黑屏或ANR", 481 | "无法获取当前窗口", 482 | style=wx.OK | wx.ICON_ERROR, 483 | ) 484 | dlg.ShowModal() 485 | dlg.Destroy() 486 | return 487 | 488 | index = 0 489 | last_title = "" 490 | for window in self._window_manager.get_window_list(): 491 | if last_title == window.title: 492 | index += 1 493 | else: 494 | index = 0 495 | last_title = window.title 496 | idx = self.cb_activity.Append( 497 | window.title + " " * index 498 | ) # 避免始终选择到第一项 499 | data = window.title + (("::%d" % index) if index > 0 else "") 500 | 501 | self.cb_activity.SetClientData(idx, data) 502 | if window.hashcode == current_window.hashcode: 503 | self.cb_activity.SetSelection(idx) 504 | run_in_main_thread(lambda: self.cb_activity.SetLabelText(window.title)) 505 | 506 | @property 507 | def tree(self): 508 | """当前操作的控件树""" 509 | return self._tree_list[self._tree_idx]["tree"] 510 | 511 | @property 512 | def root(self): 513 | """当前操作的控件树的根""" 514 | return self._tree_list[self._tree_idx]["root"] 515 | 516 | def show_controls(self, controls_dict): 517 | """显示控件树""" 518 | self._build_control_trees(controls_dict) 519 | 520 | def switch_control_tree(self, index): 521 | """切换控件树""" 522 | if index < 0 or index >= len(self._tree_list): 523 | return 524 | self._tree_idx = index 525 | for i in range(len(self._tree_list)): 526 | if i == self._tree_idx: 527 | self._tree_list[i]["tree"].Show() 528 | self.cb_activity.SetValue(self._tree_list[i]["window_title"]) 529 | self.tc_process_name.SetValue(self._tree_list[i]["process_name"]) 530 | else: 531 | self._tree_list[i]["tree"].Hide() 532 | 533 | def on_tree_node_right_click(self, event): 534 | """ """ 535 | if hasattr(event, "Point"): 536 | point = event.Point 537 | else: 538 | point = event.GetPosition() 539 | item, _ = self.tree.HitTest(point) 540 | self.tree.PopupMenu(TreeNodePopupMenu(self, item), point) 541 | event.Skip() 542 | 543 | def _draw_mask(self, control): 544 | """绘制高亮区域""" 545 | if not self._scale_rate: 546 | return 547 | item_data = self.tree.GetItemData(control) 548 | rect = item_data["Rect"] 549 | p1 = rect["Left"] * self._scale_rate, rect["Top"] * self._scale_rate 550 | p2 = (rect["Left"] + rect["Width"]) * self._scale_rate, ( 551 | rect["Top"] + rect["Height"] 552 | ) * self._scale_rate 553 | self.mask_panel.draw_rectangle(p1, p2) 554 | 555 | def on_tree_node_click(self, event): 556 | """点击控件树节点""" 557 | self.cb_show_hex.SetValue(False) 558 | item_id = event.GetItem() 559 | self._draw_mask(item_id) 560 | item_data = self.tree.GetItemData(item_id) 561 | self.tc_id.SetValue(self._handle_control_id(item_data["Id"])) 562 | if "ConfusedId" in item_data: 563 | self.tc_id.SetHint(self._handle_control_id(item_data["ConfusedId"])) 564 | else: 565 | self.tc_id.SetHint("") 566 | self.tc_type.SetValue(item_data["Type"]) 567 | self.tc_visible.SetValue("True" if item_data["Visible"] else "False") 568 | self.tc_text.SetValue("") 569 | if "Text" in item_data: 570 | self.tc_text.SetValue(item_data["Text"]) 571 | self.tc_text.Enable(True) 572 | self.cb_show_hex.Enable(True) 573 | else: 574 | self.tc_text.Enable(False) 575 | self.cb_show_hex.Enable(False) 576 | self.btn_set_text.Enable(False) 577 | self.tc_hashcode.SetValue("0x%.8X" % item_data["Hashcode"]) 578 | rect = "(%s, %s, %s, %s)" % ( 579 | item_data["Rect"]["Left"], 580 | item_data["Rect"]["Top"], 581 | item_data["Rect"]["Width"], 582 | item_data["Rect"]["Height"], 583 | ) 584 | self.tc_rect.SetValue(rect) 585 | self.tc_enable.SetValue("True" if item_data["Enabled"] else "False") 586 | if "Clickable" in item_data: 587 | self.tc_clickable.SetValue("True" if item_data["Clickable"] else "False") 588 | else: 589 | self.tc_clickable.SetValue("") 590 | if "Checkable" in item_data: 591 | self.tc_checkable.SetValue("True" if item_data["Checkable"] else "False") 592 | else: 593 | self.tc_checkable.SetValue("") 594 | if "Checked" in item_data: 595 | self.tc_checked.SetValue("True" if item_data["Checked"] else "False") 596 | else: 597 | self.tc_checked.SetValue("") 598 | self.tc_desc.SetValue(item_data["Desc"]) 599 | 600 | def _handle_control_id(self, _id): 601 | """处理控件ID""" 602 | if _id == "NO_ID": 603 | _id = "None" 604 | elif _id.startswith("id/"): 605 | _id = _id[3:] 606 | return _id 607 | 608 | def _add_child(self, process_name, tree, parent, child, is_weex_node=False): 609 | """添加树形控件节点""" 610 | node_name = self._handle_control_id(child["Id"]) 611 | if is_weex_node: 612 | if not child["Type"].startswith("android.") and not child[ 613 | "Type" 614 | ].startswith("com.android."): 615 | driver = self._control_manager._get_driver(process_name) 616 | try: 617 | node_name = driver.get_object_field_value( 618 | child["Hashcode"], "mTest" 619 | ) 620 | except Exception as e: 621 | Log.ex("MainFrame", "get field mTest failed") 622 | else: 623 | if not node_name: 624 | node_name = "None" 625 | elif child["Type"].endswith(".WeexView"): 626 | is_weex_node = True 627 | node = tree.AppendItem(parent, node_name, data=child) 628 | for subchild in child["Children"]: 629 | self._add_child(process_name, tree, node, subchild, is_weex_node) 630 | 631 | def _build_control_trees(self, controls_dict): 632 | """构建控件树""" 633 | for tree in self._tree_list: 634 | # 先删除之前创建的控件树 635 | tree["root"] = None 636 | tree["tree"].DeleteAllItems() 637 | tree["tree"].Destroy() 638 | 639 | self._tree_list = [] 640 | index = -1 641 | for idx, key in enumerate(controls_dict.keys()): 642 | if index < 0: 643 | index = idx 644 | process_name = controls_dict[key][0] 645 | for i in range(1, len(controls_dict[key])): 646 | tree = wx.TreeCtrl( 647 | self.tree_panel, 648 | id=wx.ID_ANY, 649 | pos=(5, 0), 650 | size=(self._tree_panel_width - 10, self._tree_panel_height), 651 | ) 652 | tree_root = controls_dict[key][i] 653 | root = tree.AddRoot( 654 | self._handle_control_id(tree_root["Id"]), data=tree_root 655 | ) 656 | for child in tree_root["Children"]: 657 | self._add_child(process_name, tree, root, child) 658 | tree.Bind(wx.EVT_TREE_SEL_CHANGED, self.on_tree_node_click) 659 | # tree.Bind(wx.EVT_MOUSE_EVENTS, self.on_tree_mouse_event) 660 | tree.Bind(wx.EVT_RIGHT_DOWN, self.on_tree_node_right_click) 661 | 662 | # tree.Bind(wx.EVT_TREE_ITEM_RIGHT_CLICK, self.on_tree_node_right_click) 663 | 664 | item = { 665 | "process_name": process_name, 666 | "window_title": key, 667 | "tree": tree, 668 | "root": root, 669 | } 670 | self._tree_list.append(item) 671 | self.switch_control_tree(index) 672 | 673 | def _take_screen_shot(self, tmp_path, path, use_cmd=True): 674 | """屏幕截图""" 675 | if use_cmd: 676 | self._device.adb.run_shell_cmd("rm -f %s" % tmp_path) 677 | self._device.adb.run_shell_cmd("screencap %s" % tmp_path) 678 | self._device.adb.run_shell_cmd("chmod 444 %s" % tmp_path) 679 | self._device.adb.pull_file(tmp_path, path) 680 | else: 681 | self._device.take_screen_shot(path, 10) 682 | 683 | def _refresh_device_screenshot(self, use_cmd=False): 684 | """设置手机屏幕截图""" 685 | path = os.path.join(os.path.abspath(os.curdir), "screen.png") 686 | # Log.d('Screenshot', path) 687 | tmp_path = "/data/local/tmp/screen.png" 688 | 689 | if self._device.adb.get_sdk_version() >= 29: 690 | use_cmd = True 691 | try: 692 | self._take_screen_shot(tmp_path, path, use_cmd) 693 | except: 694 | Log.ex("take_screen_shot error") 695 | return 696 | 697 | if not os.path.exists(path): 698 | Log.w("Screenshot", "file not exist") 699 | 700 | # image = Image.open(path) 701 | # image = image.rotate(90, expand=True) 702 | # image.save(path) 703 | run_in_main_thread(self._set_image)(path) 704 | 705 | def _set_image(self, image_path): 706 | try: 707 | return self.__set_image(image_path) 708 | except: 709 | Log.ex("Set image failed") 710 | 711 | def _show_image(self, image): 712 | img_width, img_height = image.size 713 | panel_width, panel_height = self.screen_panel.Size 714 | print(panel_width, panel_height, img_width, img_height) 715 | if panel_width < img_width or panel_height < img_height: 716 | x_radio = panel_width / img_width 717 | y_radio = panel_height / img_height 718 | self._scale_rate = min(x_radio, y_radio) 719 | img_width = int(self._scale_rate * img_width) 720 | img_height = int(self._scale_rate * img_height) 721 | self.image.SetSize((img_width, img_height)) 722 | self.mask_panel.SetSize((img_width, img_height)) 723 | image = image.resize((img_width, img_height), Image.LANCZOS) 724 | 725 | x = (panel_width - img_width) // 2 726 | y = (panel_height - img_height) // 2 727 | 728 | buffer = io.BytesIO() 729 | image.save(buffer, format="PNG") 730 | image = wx.Bitmap.FromPNGData(buffer.getvalue()) 731 | self.image.SetBitmap(image) 732 | self.image.Refresh() 733 | self.image.SetPosition((x, y)) 734 | self.mask_panel.SetPosition((x, y)) 735 | 736 | def __set_image(self, image_path): 737 | """设置图片""" 738 | print("set image %s" % image_path) 739 | if not os.path.exists(image_path): 740 | Log.w(self.__class__.__name__, "Image file %s not exist" % image_path) 741 | return 742 | if self.cb_auto_refresh.IsChecked(): 743 | tmp_path = "%d.png" % int(time.time()) 744 | if os.path.exists(tmp_path): 745 | os.remove(tmp_path) 746 | os.rename(image_path, tmp_path) 747 | image_path = tmp_path 748 | try: 749 | image = Image.open(image_path) 750 | image.verify() # 验证完之后需要重新打开 751 | image = Image.open(image_path) 752 | except Exception as e: 753 | Log.ex("ImageError", image_path, e) 754 | return 755 | else: 756 | self._image_path = image_path 757 | self._show_image(image) 758 | self.image.Show() 759 | self.mask_panel.Show() 760 | 761 | # if self.cb_auto_refresh.IsChecked(): 762 | # os.remove(temp_path) 763 | # os.remove(image_path) 764 | 765 | def on_inspect_btn_click(self, event): 766 | """探测按钮点击回调""" 767 | self.btn_inspect.Enable(False) 768 | self._enable_inspect = True 769 | 770 | def on_mouse_move(self, event): 771 | """mouse move in screen area""" 772 | if not self._scale_rate: 773 | return 774 | 775 | x = event.x 776 | y = event.y 777 | if x < 0: 778 | x = 0 779 | if y < 0: 780 | y = 0 781 | if x >= self.image.Size[0]: 782 | x = self.image.Size[0] - 1 783 | if y >= self.image.Size[1]: 784 | y = self.image.Size[1] - 1 785 | 786 | x = int(x / self._scale_rate) 787 | y = int(y / self._scale_rate) 788 | self.statusbar.SetStatusText("(%d, %d)" % (x, y), 2) 789 | 790 | if not self._mouse_move_enabled: 791 | return 792 | 793 | if not hasattr(self, "_last_mouse_pos"): 794 | self._last_mouse_pos = x, y 795 | else: 796 | if ( 797 | abs(x - self._last_mouse_pos[0]) <= 5 798 | and abs(y - self._last_mouse_pos[1]) <= 5 799 | ): 800 | return 801 | 802 | web_inspect_enabled = ( 803 | hasattr(self, "_current_webview") and self._current_webview is not None 804 | ) 805 | web_inspect_enabled &= ( 806 | hasattr(self, "_chrome") 807 | and self._chrome is not None 808 | and not self._chrome.is_closed() 809 | ) 810 | if web_inspect_enabled: 811 | item_data = self.tree.GetItemData(self._current_webview) 812 | rect = item_data["Rect"] 813 | if ( 814 | x > rect["Left"] 815 | and x < rect["Left"] + rect["Width"] 816 | and y > rect["Top"] 817 | and y < rect["Top"] + rect["Height"] 818 | ): 819 | try: 820 | webview = WebView( 821 | self._control_manager.get_driver(self.cb_activity.GetValue()), 822 | item_data["Hashcode"], 823 | ) 824 | except ControlExpiredError: 825 | # 页面已关闭 826 | self._close_remote_web_debug() 827 | return 828 | except RuntimeError as e: 829 | Log.ex("onmouseover error") 830 | self._close_remote_web_debug() 831 | return 832 | 833 | if event.EventType == wx.EVT_LEFT_UP.typeId: 834 | # 点击事件 835 | if self._chrome: 836 | # 在Native层点击 837 | Log.i("WebViewDebugging", "click %d %d" % (x, y)) 838 | webview._driver.click(item_data["Hashcode"], x, y) 839 | else: 840 | script = ( 841 | r"""if(qt4a_web_inspect._inspect_mode){qt4a_web_inspect.fire_mouse_event('click', (%s)/window.devicePixelRatio, (%s)/window.devicePixelRatio)};""" 842 | % (x - rect["Left"], y - rect["Top"]) 843 | ) 844 | webview.eval_script([], script) 845 | self._chrome.bring_to_front() 846 | else: 847 | if not self._chrome: 848 | script = ( 849 | r"""if(qt4a_web_inspect._inspect_mode){qt4a_web_inspect.fire_mouse_event('mouseover', (%s)/window.devicePixelRatio, (%s)/window.devicePixelRatio)};""" 850 | % (x - rect["Left"], y - rect["Top"]) 851 | ) # 852 | webview.eval_script([], script) 853 | return 854 | 855 | if not self._enable_inspect: 856 | return 857 | 858 | result = [] 859 | for i in range(len(self._tree_list)): 860 | tree = self._tree_list[i]["tree"] 861 | root = self._tree_list[i]["root"] 862 | controls = self._get_current_control(tree, root, x, y) 863 | if len(controls) > 0: 864 | min_area = 0xFFFFFFFF 865 | min_item = None 866 | for item in controls: 867 | item_data = self.tree.GetItemData(item) 868 | area = item_data["Rect"]["Width"] * item_data["Rect"]["Height"] 869 | if area < min_area: 870 | min_area = area 871 | min_item = item 872 | if min_item: 873 | result.append({"index": i, "item": min_item, "area": min_area}) 874 | 875 | if len(result) == 0: 876 | print("find control failed") 877 | return 878 | 879 | # 控件树之间比较 880 | min_area = 0xFFFFFFFF 881 | min_item = None 882 | index = None 883 | for item in result: 884 | if item["area"] < min_area: 885 | min_area = item["area"] 886 | min_item = item["item"] 887 | index = item["index"] 888 | 889 | if index != self._tree_idx: 890 | # 需要切换控件树 891 | print("switch control tree from %s to %s" % (self._tree_idx, index)) 892 | self._tree_list[index]["tree"].Show() 893 | self._tree_list[self._tree_idx]["tree"].Hide() 894 | self.cb_activity.SetValue(self._tree_list[index]["window_title"]) 895 | self.tc_process_name.SetValue(self._tree_list[index]["process_name"]) 896 | self._tree_idx = index 897 | 898 | self._draw_mask(min_item) 899 | 900 | if event.EventType == wx.EVT_LEFT_UP.typeId: 901 | # 点击事件 902 | self._expand_tree(min_item) 903 | self.tree.SelectItem(min_item) 904 | self.tree.SetFocus() 905 | self._enable_inspect = False 906 | self.btn_inspect.Enable(True) 907 | # self.cb_show_hex.SetValue(False) 908 | 909 | def _get_current_control(self, tree, parent, x, y): 910 | """获取坐标(x,y)所在的控件""" 911 | item_data = tree.GetItemData(parent) 912 | if item_data is None: 913 | return [] 914 | 915 | rect = item_data["Rect"] 916 | if x < rect["Left"] or x >= rect["Left"] + rect["Width"]: 917 | return [] 918 | if y < rect["Top"] or y >= rect["Top"] + rect["Height"]: 919 | return [] 920 | if not item_data["Visible"]: 921 | return [] 922 | 923 | if tree.GetChildrenCount(parent) == 0: 924 | return [parent] 925 | 926 | result = [] 927 | item, cookie = tree.GetFirstChild(parent) 928 | while item: 929 | result.extend(self._get_current_control(tree, item, x, y)) 930 | if sys.platform == "win32": 931 | item, cookie = tree.GetNextChild(item, cookie) 932 | else: 933 | item = tree.GetNextSibling(item) 934 | 935 | if len(result) == 0: 936 | return [parent] 937 | else: 938 | return result 939 | 940 | def _expand_tree(self, item): 941 | """展开树形控件节点""" 942 | if item != self.root: 943 | parent = self.tree.GetItemParent(item) 944 | self._expand_tree(parent) 945 | self.tree.Expand(item) 946 | else: 947 | self.tree.Expand(self.root) 948 | 949 | def on_local_device_selected(self, event): 950 | """选择本地设备""" 951 | self._device_host = "127.0.0.1" 952 | self.tc_dev_host.Enable(False) 953 | self.btn_set_device_host.Enable(False) 954 | 955 | def on_remote_device_selected(self, event): 956 | """选择远程设备""" 957 | self.tc_dev_host.Enable(True) 958 | self.btn_set_device_host.Enable(True) 959 | 960 | def on_set_device_host_btn_click(self, event): 961 | """设置设备主机按钮点击回调""" 962 | hostname = self.tc_dev_host.GetValue() 963 | if hostname != self._device_host: 964 | self.statusbar.SetStatusText("正在检查设备主机: %s……" % hostname, 0) 965 | if not self._check_device_host(hostname): 966 | dlg = wx.MessageDialog( 967 | self, 968 | "设备主机无法访问!\n请确认设备主机名是否正确,以及网络是否连通", 969 | "设备主机名错误", 970 | style=wx.OK | wx.ICON_ERROR, 971 | ) 972 | result = dlg.ShowModal() 973 | dlg.Destroy() 974 | else: 975 | self._device_host = hostname 976 | self.statusbar.SetStatusText("检查设备主机: %s 完成" % hostname, 0) 977 | 978 | def on_auto_fresh_checked(self, event): 979 | """选择了自动刷新""" 980 | if self.cb_auto_refresh.IsChecked(): 981 | if not self._device: 982 | dlg = wx.MessageDialog( 983 | self, "尚未选择设备", "错误", style=wx.OK | wx.ICON_ERROR 984 | ) 985 | result = dlg.ShowModal() 986 | dlg.Destroy() 987 | self.cb_auto_refresh.SetValue(False) 988 | return 989 | sec = self.tc_refresh_interval.GetValue() 990 | self.refresh_timer.Start(int(float(sec) * 1000)) 991 | self.btn_getcontrol.Enable(False) 992 | self.rb_local_device.Enable(False) 993 | self.rb_remote_device.Enable(False) 994 | self.tc_dev_host.Enable(False) 995 | self.btn_set_device_host.Enable(False) 996 | self.tc_refresh_interval.Enable(False) 997 | else: 998 | self.refresh_timer.Stop() 999 | self.btn_getcontrol.Enable(True) 1000 | self.rb_local_device.Enable(True) 1001 | self.rb_remote_device.Enable(True) 1002 | self.tc_refresh_interval.Enable(True) 1003 | if self._device_host: 1004 | self.tc_dev_host.Enable(True) 1005 | self.btn_set_device_host.Enable(True) 1006 | 1007 | def on_refresh_timer(self, event): 1008 | """ """ 1009 | self._work_thread.post_task(self._refresh_device_screenshot, False) 1010 | 1011 | def on_node_text_changed(self, event): 1012 | """ """ 1013 | self.btn_set_text.Enable(True) 1014 | 1015 | def on_set_text_btn_click(self, event): 1016 | """ """ 1017 | window_title = self.cb_activity.GetValue() 1018 | hashcode = int(self.tc_hashcode.GetValue(), 16) 1019 | text = self.tc_text.GetValue() 1020 | self._control_manager.set_control_text(window_title, hashcode, text) 1021 | self.statusbar.SetStatusText("设置控件文本成功", 0) 1022 | time.sleep(0.5) 1023 | t = threading.Thread(target=self._refresh_device_screenshot, args=(True,)) 1024 | t.setDaemon(True) 1025 | t.start() 1026 | 1027 | def find_webview_control(self, parent): 1028 | """查找WebView节点""" 1029 | item_data = self.tree.GetItemData(parent) 1030 | result = [] 1031 | if not item_data["Visible"]: 1032 | return [] 1033 | if item_data["Rect"]["Width"] == 0 or item_data["Rect"]["Height"] == 0: 1034 | return [] 1035 | if ( 1036 | not item_data["Type"].startswith("android.widget.") 1037 | and item_data["Type"] 1038 | != "com.android.internal.policy.impl.PhoneWindow$DecorView" 1039 | and item_data["Type"] != "android.view.View" 1040 | ): 1041 | if WebView.is_webview( 1042 | self._control_manager, 1043 | self.cb_activity.GetValue(), 1044 | item_data["Hashcode"], 1045 | ): 1046 | self._current_webview = parent 1047 | result.append(parent) 1048 | return result 1049 | 1050 | item, cookie = self.tree.GetFirstChild(parent) 1051 | while item: 1052 | result.extend(self.find_webview_control(item)) 1053 | if sys.platform == "win32": 1054 | item, cookie = self.tree.GetNextChild(item, cookie) 1055 | else: 1056 | item = self.tree.GetNextSibling(item) 1057 | return result 1058 | 1059 | def _get_control_by_hashcode(self, parent, hashcode): 1060 | """根据hashcode找到控件""" 1061 | item_data = self.tree.GetItemData(parent) 1062 | if item_data["Hashcode"] == hashcode: 1063 | return parent 1064 | item, cookie = self.tree.GetFirstChild(parent) 1065 | while item: 1066 | ret = self._get_control_by_hashcode(item, hashcode) 1067 | if ret: 1068 | return ret 1069 | if sys.platform == "win32": 1070 | item, cookie = self.tree.GetNextChild(item, cookie) 1071 | else: 1072 | item = self.tree.GetNextSibling(item) 1073 | return None 1074 | 1075 | def _focus_control_by_hashcode(self, hashcode): 1076 | """将焦点放到hashcode指定的控件上""" 1077 | control = self._get_control_by_hashcode(self.root, hashcode) 1078 | if not control: 1079 | raise RuntimeError("查找控件失败:%s" % hashcode) 1080 | self._draw_mask(control) 1081 | self._expand_tree(control) 1082 | self.tree.SelectItem(control) 1083 | self.tree.SetFocus() 1084 | 1085 | 1086 | class CanvasPanel(wx.Panel): 1087 | """绘图面板""" 1088 | 1089 | def __init__(self, *args, **kwargs): 1090 | wx.Panel.__init__(self, *args, **kwargs) 1091 | self.Bind(wx.EVT_PAINT, self.on_paint) 1092 | self._draw_points = None 1093 | self._last_draw_points = None 1094 | # self.SetBackgroundColour(wx.RED) 1095 | 1096 | def draw_rectangle(self, p1, p2): 1097 | """画长方形""" 1098 | p1 = (int(p1[0]), int(p1[1])) 1099 | p2 = (int(p2[0]), int(p2[1])) 1100 | self._draw_points = (p1, p2) 1101 | if ( 1102 | self._last_draw_points 1103 | and self._last_draw_points[0] == p1 1104 | and self._last_draw_points[1] == p2 1105 | ): 1106 | pass 1107 | else: 1108 | # self.Refresh() 1109 | self.Hide() 1110 | self.Show() 1111 | 1112 | def on_paint(self, evt): 1113 | if self._draw_points: 1114 | left, top = self._draw_points[0] 1115 | right, bottom = self._draw_points[1] 1116 | dc = wx.PaintDC(self) 1117 | dc.SetPen(wx.Pen("red", 2)) 1118 | dc.DrawLine(left, top, right, top) 1119 | dc.DrawLine(right, top, right, bottom) 1120 | dc.DrawLine(right, bottom, left, bottom) 1121 | dc.DrawLine(left, bottom, left, top) 1122 | self._last_draw_points = self._draw_points 1123 | self._draw_points = None 1124 | 1125 | 1126 | class EnumControlType(object): 1127 | """控件类型""" 1128 | 1129 | ScrollView = 1 1130 | ListView = 2 1131 | GridView = 3 1132 | WebView = 4 1133 | PossiableListView = 5 1134 | 1135 | 1136 | class TreeNodePopupMenu(wx.Menu): 1137 | """树形控件节点弹出菜单""" 1138 | 1139 | def __init__(self, parent, select_node, *args, **kwargs): 1140 | super(TreeNodePopupMenu, self).__init__(*args, **kwargs) 1141 | self._parent = parent 1142 | self._select_node = select_node 1143 | 1144 | item1 = wx.MenuItem(self, wx.NewId(), "生成控件QPath") 1145 | self.Append(item1) 1146 | self.Bind(wx.EVT_MENU, self.on_gen_qpath_menu_click, item1) 1147 | if not select_node: 1148 | item1.Enable(False) 1149 | 1150 | item2 = wx.MenuItem(self, wx.NewId(), "输入QPath定位") 1151 | self.Append(item2) 1152 | self.Bind(wx.EVT_MENU, self.on_locate_by_qpath_menu_click, item2) 1153 | 1154 | # item3 = wx.MenuItem(self, wx.NewId(), u'查找控件') 1155 | # self.AppendItem(item3) 1156 | 1157 | item4 = wx.MenuItem(self, wx.NewId(), "查找WebView控件") 1158 | self.Append(item4) 1159 | self.Bind(wx.EVT_MENU, self.on_find_webview_control_menu_click, item4) 1160 | 1161 | item5 = wx.MenuItem(self, wx.NewId(), "启动WebView调试") 1162 | self.Append(item5) 1163 | self.Bind(wx.EVT_MENU, self.on_open_webview_debug_menu_click, item5) 1164 | 1165 | item6 = wx.MenuItem(self, wx.NewId(), "打开WebView命令行") 1166 | self.Append(item6) 1167 | self.Bind(wx.EVT_MENU, self.on_open_webview_console_menu_click, item6) 1168 | 1169 | menu_title = "切换控件树[%d/%d]" % ( 1170 | self._parent._tree_idx + 1, 1171 | len(self._parent._tree_list), 1172 | ) 1173 | item7 = wx.MenuItem(self, wx.NewId(), menu_title) 1174 | self.Append(item7) 1175 | self.Bind(wx.EVT_MENU, self.on_switch_control_tree_menu_click, item7) 1176 | 1177 | if not select_node: 1178 | item5.Enable(False) 1179 | item6.Enable(False) 1180 | else: 1181 | item_data = self._parent.tree.GetItemData(select_node) 1182 | 1183 | process_name = self._parent._tree_list[self._parent._tree_idx][ 1184 | "process_name" 1185 | ] 1186 | try: 1187 | webview = self._parent._control_manager.get_webview( 1188 | process_name, item_data["Hashcode"] 1189 | ) # self._parent.cb_activity.GetValue() 1190 | self._webview_type = webview.get_webview_type() 1191 | except ControlExpiredError: 1192 | dlg = wx.MessageDialog( 1193 | self._parent, 1194 | "请重新刷新控件树", 1195 | "WebView控件已失效", 1196 | style=wx.OK | wx.ICON_ERROR, 1197 | ) 1198 | result = dlg.ShowModal() 1199 | dlg.Destroy() 1200 | self.Destroy() 1201 | return 1202 | if self._webview_type == EnumWebViewType.NotWebView: 1203 | item5.Enable(False) 1204 | item6.Enable(False) 1205 | else: 1206 | item5.Enable(True) 1207 | item6.Enable(True) 1208 | 1209 | def _locate_qpath(self, window_title, root_hashcode, qpath, target_hashcode=None): 1210 | """使用QPath定位""" 1211 | hashcode = self._parent._control_manager.get_control( 1212 | window_title, root_hashcode, qpath 1213 | ) 1214 | if hashcode == 0: 1215 | return None 1216 | # raise RuntimeError('It\'s impossible!') 1217 | elif isinstance(hashcode, int): 1218 | # 能够唯一确定控件 1219 | return qpath 1220 | else: 1221 | # 使用Instance定位 1222 | if not target_hashcode: 1223 | return None 1224 | for idx in range(len(hashcode)): 1225 | _qpath = qpath + " && Instance=%d" % idx 1226 | hashcode = self._parent._control_manager.get_control( 1227 | window_title, root_hashcode, _qpath 1228 | ) 1229 | if hashcode == target_hashcode: 1230 | return _qpath 1231 | return None 1232 | 1233 | def _gen_qpath_by_attrs(self, control, window_title, root): 1234 | """根据属性生成QPath""" 1235 | item_data = self._parent.tree.GetItemData(control) 1236 | root_hashcode = None 1237 | qpath = "" 1238 | if root: 1239 | if not isinstance(root, str): 1240 | root_item_data = self._parent.tree.GetItemData(root) 1241 | root_hashcode = root_item_data["Hashcode"] 1242 | else: 1243 | qpath = root + " " 1244 | min_qpath_len = 0 1245 | 1246 | _id = self._parent._handle_control_id(item_data["Id"]) 1247 | if _id != "None": 1248 | # 存在ID 1249 | qpath += '/Id="%s"' % _id 1250 | if self._locate_qpath(window_title, root_hashcode, qpath): 1251 | return True, qpath 1252 | else: 1253 | qpath += "/" 1254 | min_qpath_len = len(qpath) # 用于判断是否需要添加&& 1255 | 1256 | text = item_data.get("Text") 1257 | if text: 1258 | # 使用文本定位 1259 | if len(qpath) > min_qpath_len: 1260 | qpath += " && " 1261 | qpath += 'Text="%s"' % text 1262 | if self._locate_qpath(window_title, root_hashcode, qpath): 1263 | return True, qpath 1264 | 1265 | type = item_data["Type"] 1266 | if "." in type: 1267 | type = type.split(".")[-1] 1268 | if len(type) > 3: 1269 | # 不处理混淆情况 1270 | if len(qpath) > min_qpath_len: 1271 | qpath += " && " 1272 | qpath += 'Type="%s"' % type 1273 | if self._locate_qpath(window_title, root_hashcode, qpath): 1274 | return True, qpath 1275 | 1276 | # if len(qpath) > min_qpath_len: qpath += ' && ' 1277 | # qpath += 'Visible="True"' 1278 | # if self._locate_qpath(window_title, root_hashcode, qpath): return True, qpath 1279 | 1280 | if len(qpath) == min_qpath_len: 1281 | qpath = None 1282 | return False, qpath 1283 | 1284 | def _get_special_control(self, control, window_title): 1285 | """获取ListView等特殊控件""" 1286 | parent = control 1287 | while True: 1288 | if parent == self._parent.root: 1289 | return None 1290 | parent = self._parent.tree.GetItemParent(parent) 1291 | parent_data = self._parent.tree.GetItemData(parent) 1292 | parent_type = self._parent._control_manager.get_control_type( 1293 | window_title, parent_data["Hashcode"] 1294 | ) 1295 | for type in parent_type: 1296 | control_type = None 1297 | if ( 1298 | type == "android.widget.ListView" 1299 | or type == "android.widget.AbsListView" 1300 | ): 1301 | control_type = EnumControlType.ListView 1302 | elif type == "android.widget.GridView": 1303 | control_type = EnumControlType.GridView 1304 | elif ( 1305 | type.endswith(".ListView") 1306 | or type.endswith(".AbsListView") 1307 | or type == "com.tencent.widget.AdapterView" 1308 | ): 1309 | control_type = EnumControlType.PossiableListView 1310 | 1311 | if control_type: 1312 | return parent, control_type 1313 | 1314 | def _gen_long_qpath(self, control, root, window_title): 1315 | """生成长QPath""" 1316 | 1317 | qpath = "" 1318 | 1319 | root_hash = None 1320 | if root: 1321 | root_data = self._parent.tree.GetItemData(root) 1322 | root_hash = root_data["Hashcode"] 1323 | 1324 | last_ctrl = None # qpath定位到的控件 1325 | depth = 0 1326 | parent = control 1327 | prev_pos = -1 # 上一次插入QPath的位置,用于加入MaxDepth字段 1328 | while True: 1329 | if parent == self._parent.root or parent == root: 1330 | break 1331 | ctrl_data = self._parent.tree.GetItemData(parent) 1332 | _id = self._parent._handle_control_id(ctrl_data["Id"]) 1333 | if _id != "None": 1334 | # 存在ID 1335 | if not last_ctrl: 1336 | last_ctrl = parent # 第一个有ID的控件 1337 | _qpath = '/Id="%s"' % _id 1338 | if len(qpath) > 0 and prev_pos > 0: 1339 | # 不是最底层控件 1340 | if depth > 1: 1341 | qpath = ( 1342 | qpath[:prev_pos] 1343 | + " && MaxDepth=%d" % depth 1344 | + qpath[prev_pos:] 1345 | ) 1346 | qpath = _qpath + " " + qpath 1347 | depth = 0 1348 | if self._locate_qpath(window_title, root_hash, qpath): 1349 | return last_ctrl, qpath 1350 | else: 1351 | qpath = _qpath 1352 | prev_pos = len(_qpath) 1353 | parent = self._parent.tree.GetItemParent(parent) 1354 | depth += 1 1355 | return None, qpath 1356 | 1357 | def _get_nearest_co_ancestor(self, controls): 1358 | """获取多个控件的最近共同祖先""" 1359 | ancestor_list = [[] for _ in range(len(controls))] 1360 | for i in range(len(controls)): 1361 | control = controls[i] 1362 | while True: 1363 | if control == self._parent.root: 1364 | break 1365 | ancestor_list[i].insert(0, control) 1366 | control = self._parent.tree.GetItemParent(control) 1367 | 1368 | idx = 0 1369 | while True: 1370 | for i in range(len(controls) - 1): 1371 | if ancestor_list[i][idx] != ancestor_list[i + 1][idx]: 1372 | return ancestor_list[i][idx - 1] 1373 | idx += 1 1374 | 1375 | def _get_control_depth(self, parent, control, depth=0): 1376 | """获取控件深度""" 1377 | if parent == control: 1378 | return depth 1379 | depth += 1 1380 | item, cookie = self._parent.tree.GetFirstChild(parent) 1381 | while item: 1382 | result = self._get_control_depth(item, control, depth) 1383 | if result: 1384 | return result 1385 | item, cookie = self._parent.tree.GetNextChild(item, cookie) 1386 | 1387 | def _gen_qpath(self, control): 1388 | """生成QPath 1389 | 1、如果控件可以使用ID|Text|Type唯一定位,则使用ID|Text|Type生成QPath 1390 | 2、从该控件向根节点判断是否存在ListView等特殊节点,如果是,则先计算ListView节点的QPath, 再计算该节点与ListView节点的关系 1391 | 3、判断父控件是否可唯一定位,如果是则计算该节点与父节点间的QPath 1392 | 4、从该控件向根节点不断使用ID生成链式QPath,直到能唯一定位或到达根节点 1393 | 5、如果链式ID无法唯一定位,则获取重复的控件的最近共同祖先,使用Instance进行区分 1394 | """ 1395 | window_title = self._parent.cb_activity.GetValue() 1396 | item_data = self._parent.tree.GetItemData(control) 1397 | 1398 | # --------- 1 -------------- 1399 | Log.i("GetQPath", "使用属性定位") 1400 | result, qpath = self._gen_qpath_by_attrs(control, window_title, None) 1401 | if result: 1402 | return qpath 1403 | 1404 | # --------- 2 -------------- 1405 | Log.i("GetQPath", "判断是否有特殊控件") 1406 | result = self._get_special_control(control, window_title) 1407 | if result: 1408 | # 存在特殊类型控件 1409 | ctrl, ctrl_type = result 1410 | Log.i("GetQPath", "存在特殊控件:%s" % ctrl_type) 1411 | ctrl_path = self._gen_qpath(ctrl) 1412 | if not ctrl_path: 1413 | Log.e("GetQPath", "获取控件%sQPath失败" % ctrl_type) 1414 | return None 1415 | ret, qpath = self._gen_qpath_by_attrs(control, window_title, ctrl) 1416 | if ret: 1417 | return ctrl_type, ctrl_path, qpath 1418 | item, cookie = self._parent.tree.GetFirstChild(ctrl) 1419 | while item: 1420 | ret, qpath = self._gen_qpath_by_attrs(control, window_title, item) 1421 | if ret: 1422 | return ctrl_type, ctrl_path, qpath 1423 | item = self._parent.tree.GetNextSibling(item) 1424 | Log.e("GetQPath", "获取控件QPath失败") 1425 | return None 1426 | 1427 | # --------- 3 -------------- 1428 | Log.i("GetQPath", "判断父控件是否可以定位") 1429 | parent = self._parent.tree.GetItemParent(control) 1430 | result, qpath = self._gen_qpath_by_attrs(parent, window_title, None) 1431 | if result: 1432 | ret, child_qpath = self._gen_qpath_by_attrs(control, window_title, qpath) 1433 | if ret: 1434 | return child_qpath 1435 | # 只能使用Instance定位了 1436 | return self._locate_qpath( 1437 | window_title, None, child_qpath, item_data["Hashcode"] 1438 | ) 1439 | # raise NotImplementedError(qpath) 1440 | 1441 | # 没有特殊容器节点,再遍历一次 1442 | # --------- 4 -------------- 1443 | Log.i("GetQPath", "使用长ID定位") 1444 | ret, qpath = self._gen_long_qpath(control, None, window_title) 1445 | if ret: 1446 | return qpath 1447 | else: 1448 | # 寻找最近公共祖先 1449 | return None 1450 | # Log.i('GetQPath', '寻找最近公共祖先') 1451 | # hashcode_list = self._parent._control_manager.get_control(window_title, None, qpath) 1452 | # controls = [self._parent._get_control_by_hashcode(self._parent.root, hashcode) for hashcode in hashcode_list] 1453 | # ancestor = self._get_nearest_co_ancestor(controls) 1454 | # ancestor_qpath = self._gen_qpath(ancestor) # 获取祖先节点的QPath 1455 | # print ancestor_qpath 1456 | # depth = self._get_control_depth(ancestor, control) 1457 | # ret, child_qpath = self._gen_qpath_by_attrs(control, window_title, ancestor_qpath) 1458 | # if ret: return child_qpath 1459 | # if depth != None and depth > 1: child_qpath += ' && MaxDepth=%d' % depth 1460 | # return self._locate_qpath(window_title, None, child_qpath, item_data['Hashcode']) 1461 | 1462 | def _copy_to_clipboard(self, text): 1463 | """拷贝到剪切板""" 1464 | if not isinstance(text, str): 1465 | text = text.decode("utf8") 1466 | if sys.platform == "win32": 1467 | import win32con, win32clipboard 1468 | 1469 | win32clipboard.OpenClipboard() 1470 | win32clipboard.EmptyClipboard() 1471 | win32clipboard.SetClipboardData(win32con.CF_UNICODETEXT, text) 1472 | win32clipboard.CloseClipboard() 1473 | elif sys.platform == "darwin": 1474 | import subprocess 1475 | 1476 | process = subprocess.Popen( 1477 | "pbcopy", env={"LANG": "en_US.UTF-8"}, stdin=subprocess.PIPE 1478 | ) 1479 | process.communicate(text.encode("utf-8")) 1480 | else: 1481 | raise NotImplementedError 1482 | 1483 | def on_gen_qpath_menu_click(self, event): 1484 | """生成QPath""" 1485 | control = self._parent.tree.GetSelection() 1486 | result = self._gen_qpath(control) 1487 | if result == None: 1488 | dlg = wx.MessageDialog( 1489 | self._parent, 1490 | "此控件过于复杂,请人工处理", 1491 | "QPath生成失败", 1492 | style=wx.OK | wx.ICON_ERROR, 1493 | ) 1494 | result = dlg.ShowModal() 1495 | dlg.Destroy() 1496 | elif not isinstance(result, tuple): 1497 | dlg = wx.MessageDialog( 1498 | self._parent, 1499 | "%s\n\n警告:自动生成的QPath仅供参考,不保证一定正确或最优!\n点击“OK”将QPath拷贝到剪切板中" 1500 | % result, 1501 | "QPath生成成功", 1502 | style=wx.OK | wx.ICON_INFORMATION, 1503 | ) 1504 | dlg.ShowModal() 1505 | self._copy_to_clipboard(result) 1506 | dlg.Destroy() 1507 | else: 1508 | control_type, root_qpath, child_qpath = result 1509 | msg = "" 1510 | if control_type == EnumControlType.ListView: 1511 | msg = "发现该控件在ListView中,需要先定义ListView控件,然后将该控件设置为ListView控件的子控件\n\nListView控件QPath: %s" 1512 | elif control_type == EnumControlType.GridView: 1513 | msg = "发现该控件在GridView中,需要先定义GridView控件,然后将该控件设置为GridView控件的子控件\n\nGridView控件QPath: %s" 1514 | elif control_type == EnumControlType.PossiableListView: 1515 | msg = "该控件可能在自定义ListView中,需要先定义ListView控件,然后将该控件设置为ListView控件的子控件\n\nListView控件QPath: %s" 1516 | 1517 | msg = msg % root_qpath 1518 | msg += "\n当前节点QPath: %s" % child_qpath 1519 | dlg = wx.MessageDialog( 1520 | self._parent, 1521 | "%s\n\n警告:自动生成的QPath仅供参考,不保证一定正确或最优!\n点击“OK”将QPath拷贝到剪切板中" 1522 | % msg, 1523 | "QPath生成成功", 1524 | style=wx.OK | wx.ICON_INFORMATION, 1525 | ) 1526 | result = dlg.ShowModal() 1527 | text = root_qpath 1528 | if text: 1529 | text += "\r\n" 1530 | text += child_qpath 1531 | self._copy_to_clipboard(text) 1532 | dlg.Destroy() 1533 | 1534 | def on_locate_by_qpath_menu_click(self, event): 1535 | """QPath定位""" 1536 | dlg = wx.TextEntryDialog( 1537 | self._parent, 1538 | "输入要定位的QPath,将返回该QPath能否正确定位到您期望的控件", 1539 | "输入要定位的QPath", 1540 | '/Id="title"', 1541 | ) 1542 | if dlg.ShowModal() == wx.ID_OK: 1543 | response = dlg.GetValue() 1544 | try: 1545 | self._parent.locate_by_qpath(response) 1546 | except ControlNotFoundError as e: 1547 | err_msg = str(e) 1548 | if not isinstance(err_msg, str): 1549 | err_msg = err_msg.decode("utf8") 1550 | dlg = wx.MessageDialog( 1551 | self._parent, err_msg, "查找控件失败", style=wx.OK | wx.ICON_ERROR 1552 | ) 1553 | dlg.ShowModal() 1554 | dlg.Destroy() 1555 | except: 1556 | Log.ex("Mainframe", "QPath error") 1557 | dlg = wx.MessageDialog( 1558 | self._parent, response, "QPath语法错误", style=wx.OK | wx.ICON_ERROR 1559 | ) 1560 | dlg.ShowModal() 1561 | dlg.Destroy() 1562 | 1563 | def on_find_webview_control_menu_click(self, event): 1564 | """查找并定位到WebView控件""" 1565 | webview_list = self._parent.find_webview_control(self._parent.root) 1566 | 1567 | if len(webview_list) == 0: 1568 | dlg = wx.MessageDialog( 1569 | self._parent, 1570 | "当前界面未找到WebView控件", 1571 | "查找WebView控件失败", 1572 | style=wx.OK | wx.ICON_ERROR, 1573 | ) 1574 | result = dlg.ShowModal() 1575 | dlg.Destroy() 1576 | return 1577 | elif len(webview_list) == 1: 1578 | webview = webview_list[0] 1579 | item_data = self._parent.tree.GetItemData(webview) 1580 | self._parent._focus_control_by_hashcode(item_data["Hashcode"]) 1581 | else: 1582 | for webview in webview_list: 1583 | item_data = self._parent.tree.GetItemData(webview) 1584 | self._parent._focus_control_by_hashcode(item_data["Hashcode"]) 1585 | 1586 | def on_open_webview_debug_menu_click(self, event): 1587 | """点击开启WebView调试菜单""" 1588 | from utils.chrome import Chrome 1589 | 1590 | chrome_path = Chrome._get_browser_path() 1591 | if not os.path.exists(chrome_path): 1592 | dlg = wx.MessageDialog( 1593 | self._parent, 1594 | "使用WebView调试必须要安装Chrome浏览器", 1595 | "无法使用WebView调试", 1596 | style=wx.OK | wx.ICON_ERROR, 1597 | ) 1598 | result = dlg.ShowModal() 1599 | dlg.Destroy() 1600 | return 1601 | 1602 | item_data = self._parent.tree.GetItemData(self._select_node) 1603 | process_name = self._parent._tree_list[self._parent._tree_idx]["process_name"] 1604 | 1605 | def on_multi_pages(page_list): 1606 | if not page_list: 1607 | self._parent._control_manager.enable_webview_debugging( 1608 | process_name, item_data["Hashcode"] 1609 | ) 1610 | dlg = wx.MessageDialog( 1611 | self._parent, 1612 | "可能是Web内核状态导致,请重启应用后再次尝试!", 1613 | "未找到可调试页面", 1614 | style=wx.OK | wx.ICON_INFORMATION, 1615 | ) 1616 | result = dlg.ShowModal() 1617 | dlg.Destroy() 1618 | return None 1619 | 1620 | title_black_patterns = [r"^wx.+:INVISIBLE$"] 1621 | url_black_patterns = [] # r'https://servicewechat.com/.+page-frame.html' 1622 | for i in range(len(page_list) - 1, -1, -1): 1623 | page = page_list[i] 1624 | title = page["title"] 1625 | url = page["url"] 1626 | if title: 1627 | for pattern in title_black_patterns: 1628 | if re.match(pattern, title): 1629 | del page_list[i] 1630 | break 1631 | elif url: 1632 | for pattern in url_black_patterns: 1633 | if re.match(pattern, url): 1634 | del page_list[i] 1635 | break 1636 | 1637 | if len(page_list) == 1: 1638 | return page_list[0]["devtoolsFrontendUrl"] 1639 | 1640 | dlg = SelectPageDialog(self._parent, page_list) 1641 | result = dlg.ShowModal() 1642 | if result < 0 or result >= len(page_list): 1643 | return None 1644 | page = page_list[result] 1645 | return page["devtoolsFrontendUrl"] 1646 | 1647 | self._parent._chrome = self._parent._control_manager.open_webview_debug( 1648 | process_name, item_data["Hashcode"], self._webview_type, on_multi_pages 1649 | ) # self._parent.cb_activity.GetValue() 1650 | if self._parent._chrome: 1651 | self._parent.refresh_timer.Start(int(1000)) 1652 | 1653 | def on_open_webview_console_menu_click(self, event): 1654 | """点击打开WebView命令行菜单""" 1655 | dlg = WebViewConsoleDialog(self._parent, self._select_node, self._webview_type) 1656 | dlg.Show() 1657 | 1658 | def on_switch_control_tree_menu_click(self, event): 1659 | """点击切换控件树菜单""" 1660 | index = self._parent._tree_idx 1661 | index += 1 1662 | if index >= len(self._parent._tree_list): 1663 | index = 0 1664 | print("switch from %d to %d" % (self._parent._tree_idx, index)) 1665 | self._parent.switch_control_tree(index) 1666 | 1667 | 1668 | class CustomMessageDialog(wx.Dialog): 1669 | """自定义消息对话框""" 1670 | 1671 | def __init__( 1672 | self, 1673 | parent, 1674 | title, 1675 | message, 1676 | btn1_title, 1677 | btn2_title, 1678 | size=(300, 150), 1679 | pos=wx.DefaultPosition, 1680 | style=wx.DEFAULT_DIALOG_STYLE, 1681 | useMetal=False, 1682 | ): 1683 | super(CustomMessageDialog, self).__init__(parent, -1, title, pos, size, style) 1684 | self._parent = parent 1685 | wx.StaticText(self, -1, message, pos=(20, 20), size=(260, 30)) 1686 | self.left_button = wx.Button( 1687 | id=wx.ID_ANY, 1688 | label=btn1_title, 1689 | parent=self, 1690 | pos=wx.Point(50, 60), 1691 | size=wx.Size(70, 30), 1692 | style=0, 1693 | ) 1694 | self.left_button.Bind(wx.EVT_BUTTON, self.on_left_button_click) 1695 | self.right_button = wx.Button( 1696 | id=wx.ID_ANY, 1697 | label=btn2_title, 1698 | parent=self, 1699 | pos=wx.Point(150, 60), 1700 | size=wx.Size(70, 30), 1701 | style=0, 1702 | ) 1703 | self.right_button.Bind(wx.EVT_BUTTON, self.on_right_button_click) 1704 | self.Center() 1705 | 1706 | def on_left_button_click(self, event): 1707 | """ """ 1708 | pass 1709 | 1710 | def on_right_button_click(self, event): 1711 | """ """ 1712 | pass 1713 | 1714 | 1715 | class SwitchNodeDialog(CustomMessageDialog): 1716 | """ """ 1717 | 1718 | def __init__(self, repeat_list, *args, **kwargs): 1719 | super(SwitchNodeDialog, self).__init__(*args, **kwargs) 1720 | self._repeat_list = repeat_list 1721 | self._cur_idx = 0 1722 | self._parent._focus_control_by_hashcode(self._repeat_list[self._cur_idx]) 1723 | 1724 | def on_left_button_click(self, event): 1725 | """ """ 1726 | self._cur_idx -= 1 1727 | if self._cur_idx < 0: 1728 | self._cur_idx += len(self._repeat_list) 1729 | self._parent._focus_control_by_hashcode(self._repeat_list[self._cur_idx]) 1730 | 1731 | def on_right_button_click(self, event): 1732 | """ """ 1733 | self._cur_idx += 1 1734 | if self._cur_idx >= len(self._repeat_list): 1735 | self._cur_idx -= len(self._repeat_list) 1736 | self._parent._focus_control_by_hashcode(self._repeat_list[self._cur_idx]) 1737 | 1738 | 1739 | class WebViewConsoleDialog(wx.Dialog): 1740 | """WebView命令行""" 1741 | 1742 | def __init__( 1743 | self, 1744 | parent, 1745 | select_node, 1746 | webview_type, 1747 | size=(800, 500), 1748 | pos=wx.DefaultPosition, 1749 | style=wx.DEFAULT_DIALOG_STYLE, 1750 | useMetal=False, 1751 | ): 1752 | super(WebViewConsoleDialog, self).__init__( 1753 | parent, -1, "WebView Console - 初始化中……", pos, size, style 1754 | ) 1755 | self._parent = parent 1756 | self._select_node = select_node 1757 | self._webview_type = webview_type 1758 | self.tc_console = wx.TextCtrl( 1759 | self, 1760 | wx.ID_ANY, 1761 | pos=(10, 5), 1762 | size=(780, 490), 1763 | style=wx.TE_MULTILINE | wx.TE_RICH | wx.TE_LEFT, 1764 | ) # 1765 | self.tc_console.Bind( 1766 | wx.EVT_KEY_DOWN, self.on_key_press 1767 | ) # wx.EVT_CHAR 会导致无法禁止删除字符 1768 | font = wx.Font(11, wx.MODERN, wx.NORMAL, wx.NORMAL, False, "Consolas") 1769 | self.tc_console.SetFont(font) 1770 | self._last_pos = 0 1771 | self._input_mode = False 1772 | self._cmd_list = [] 1773 | self._cmd_index = 0 1774 | self._last_input_pos = -1 1775 | item_data = self._parent.tree.GetItemData(self._select_node) 1776 | process_name = self._parent._tree_list[self._parent._tree_idx]["process_name"] 1777 | self._webview = self._parent._control_manager.get_webview( 1778 | process_name, item_data["Hashcode"] 1779 | ) 1780 | self._parent._work_thread.post_task(self.on_load) 1781 | 1782 | def eval_script(self, script): 1783 | """执行JavaScript代码""" 1784 | script = ( 1785 | r""" 1786 | var tmp_result = %s; 1787 | (function formatOutput(input){ 1788 | if(input == undefined) return 'undefined'; 1789 | if(input instanceof HTMLElement){ 1790 | return input.outerHTML + '\n'; 1791 | }else if(input instanceof NodeList){ 1792 | var result = ''; 1793 | for(var i=0;i") 1833 | # self.tc_console.SetStyle(pos, pos + 1, wx.TextAttr("red")) 1834 | self._last_pos = pos + 1 1835 | self.tc_console.SetInsertionPoint(self._last_pos) 1836 | self._input_mode = True 1837 | 1838 | def _set_console_style(self, start, end, style): 1839 | """设置控制台字体""" 1840 | if sys.platform == "win32": 1841 | # mac上会有crash 1842 | self.tc_console.SetStyle(start, end, style) 1843 | 1844 | def on_load(self): 1845 | result = self.eval_script('"[" + document.title + "] - " + location.href') 1846 | self._set_title(result) 1847 | self._show_enter_tip_char() 1848 | 1849 | def on_key_press(self, event): 1850 | key = event.GetKeyCode() 1851 | if not self._input_mode: 1852 | return 1853 | self.tc_console.SetEditable(True) 1854 | current_pos = self.tc_console.GetInsertionPoint() 1855 | if current_pos < self._last_pos or (current_pos == self._last_pos and key == 8): 1856 | self.tc_console.SetEditable(False) 1857 | return 1858 | 1859 | if key == 13: 1860 | # 回车 1861 | self._cmd_index = 0 1862 | value = self.tc_console.GetValue() 1863 | pos = len(value) 1864 | input = value[self._last_pos :].strip() 1865 | if input in self._cmd_list: 1866 | self._cmd_list.remove(input) 1867 | self._cmd_list.insert(0, input) 1868 | 1869 | try: 1870 | result = self.eval_script(input) 1871 | self._set_console_style(pos, -1, wx.TextAttr((0xBB, 0x00, 0x22))) 1872 | self.tc_console.WriteText("\n" + result) 1873 | except RuntimeError as e: 1874 | err_msg = str(e) 1875 | self._set_console_style(pos, -1, wx.TextAttr("red")) 1876 | self.tc_console.WriteText("\n" + err_msg) 1877 | wx.CallAfter(self._show_enter_tip_char) # 防止最后出现一个换行 1878 | elif key == 315: 1879 | # 上箭头 1880 | if self._cmd_index >= len(self._cmd_list): 1881 | return 1882 | self.tc_console.Remove(self._last_pos, -1) 1883 | self.tc_console.WriteText(self._cmd_list[self._cmd_index]) 1884 | self._cmd_index += 1 1885 | return 1886 | elif key == 317: 1887 | # 下箭头 1888 | if self._cmd_index <= 0: 1889 | return 1890 | self._cmd_index -= 1 1891 | self.tc_console.Remove(self._last_pos, -1) 1892 | self.tc_console.WriteText(self._cmd_list[self._cmd_index]) 1893 | return 1894 | event.Skip() 1895 | 1896 | 1897 | class SelectPageDialog(wx.Dialog): 1898 | """选择调试页面对话框""" 1899 | 1900 | def __init__( 1901 | self, 1902 | parent, 1903 | page_list, 1904 | size=(550, 160), 1905 | style=wx.DEFAULT_DIALOG_STYLE, 1906 | useMetal=False, 1907 | ): 1908 | super(SelectPageDialog, self).__init__( 1909 | parent, -1, "选择调试页面", wx.DefaultPosition, size, style 1910 | ) 1911 | self._parent = parent 1912 | wx.StaticText( 1913 | self, 1914 | -1, 1915 | "检测到%d个页面,请选择希望调试的页面" % len(page_list), 1916 | pos=(20, 10), 1917 | size=(400, 20), 1918 | ) 1919 | self._cb_pages = wx.ComboBox(self, wx.ID_ANY, pos=(20, 40), size=(500, 24)) 1920 | self._items = [] 1921 | for i, page in enumerate(page_list): 1922 | value = "%d. %s" % ( 1923 | (i + 1), 1924 | page["title"] if page["title"] else page["url"], 1925 | ) 1926 | self._items.append(value) 1927 | self._cb_pages.Append(value) 1928 | if i == 0: 1929 | self._cb_pages.SetValue(value) 1930 | self._btn_inspect = wx.Button( 1931 | self, 1932 | wx.ID_ANY, 1933 | label="开始探测", 1934 | pos=wx.Point(360, 80), 1935 | size=wx.Size(100, 30), 1936 | style=0, 1937 | ) 1938 | # self._btn_inspect.Enable(False) 1939 | self._btn_inspect.Bind(wx.EVT_BUTTON, self.on_click_inspect_btn) 1940 | self.Center() 1941 | 1942 | def on_click_inspect_btn(self, event): 1943 | """点击探测按钮""" 1944 | value = self._cb_pages.GetValue() 1945 | index = self._items.index(value) 1946 | self.EndModal(index) 1947 | -------------------------------------------------------------------------------- /usage.md: -------------------------------------------------------------------------------- 1 | # AndroidUISpy使用文档 2 | 3 | ## 概述 4 | 5 | AndroidUISpy可以辅助探测Android端原生控件树和Web Dom树,帮助使用 [QT4A](https://github.com/Tencent/QT4A) 进行控件QPath和XPath的定位与封装。请从github下载 [AndroidUISpy工具](https://github.com/qtacore/AndroidUISpy/releases)。 6 | 7 | 接下来以Windows版本(AndroidUISpy.exe)为例,说明如何探测Android原生控件树和Web Dom树。 8 | 9 | ## 探测Android控件树 10 | 11 | 先上图,节点(左侧树型控件)与控件(右侧屏幕截图)可以双向定位,显示控件控件ID、类型、坐标等信息: 12 | 13 | ![inspect_native_control_tree](https://raw.githubusercontent.com/qtacore/AndroidUISpy/master/res/inspect_native_control_tree.gif) 14 | 15 | 如上,使用左上角+号在屏幕内探测目标控件,左侧会显示对应控件树节点。当然,在左侧控件树点击节点,也会自动探测到右侧的目标区域。如果打开AndroidUiSpy时没有自动抓取控件树并显示正确的Activity名,请先手动用左上角+号在屏幕内探测任何一个控件,此时会获取Activity等信息。 16 | 17 | ## 探测Web Dom树 18 | 19 | 请确保你PC上已安装chrome浏览器。然后来到目标webview网页视图,在AndroidUiSpy左侧区域-鼠标右键-查找WebView控件,此时会找到控件树中对应WebView控件(命名为节点A),如下: 20 | 21 | ![inspect_dom_tree](https://raw.githubusercontent.com/qtacore/AndroidUISpy/master/res/inspect_dom_tree.gif) 22 | 23 | 接着对着节点A-右键-启动WebView调试,过会会自动调起chrome浏览器显示Dom树,点击Dom树各节点,可以看到AndroidUISpy内app屏幕对应区域被选中。接下来你就可以开始Web控件的XPath封装了。 24 | 25 | 对于低于`Chrome 50`版本的WebView,在调试时可能出现显示异常问题,建议使用低于Chrome 50的浏览器进行调试。推荐使用[Chrome 49随身版](https://raw.githubusercontent.com/qtacore/AndroidUISpy/master/res/ChromePortable49.7z),该版本解决了在HTTPS页面上访问WebSocket协议报错问题。 26 | 27 | -------------------------------------------------------------------------------- /utils/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | # 3 | # Tencent is pleased to support the open source community by making QTA available. 4 | # Copyright (C) 2016THL A29 Limited, a Tencent company. All rights reserved. 5 | # Licensed under the BSD 3-Clause License (the "License"); you may not use this 6 | # file except in compliance with the License. You may obtain a copy of the License at 7 | # 8 | # https://opensource.org/licenses/BSD-3-Clause 9 | # 10 | # Unless required by applicable law or agreed to in writing, software distributed 11 | # under the License is distributed on an "AS IS" basis, WITHOUT WARRANTIES OR CONDITIONS 12 | # OF ANY KIND, either express or implied. See the License for the specific language 13 | # governing permissions and limitations under the License. 14 | # 15 | 16 | """公共模块 17 | """ 18 | 19 | import os, sys 20 | import threading 21 | from .logger import Log 22 | 23 | 24 | def get_driver_root_path(): 25 | """获取测试桩根目录""" 26 | if hasattr(sys, "_MEIPASS"): 27 | print(sys._MEIPASS) 28 | elif not hasattr(sys, "frozen"): 29 | return os.path.join( 30 | os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 31 | "qt4a", 32 | "androiddriver", 33 | "tools", 34 | ) 35 | else: 36 | return os.path.join(os.environ["temp"], "tools_%d" % os.getpid()) 37 | 38 | 39 | def run_in_thread(func): 40 | """在线程中执行函数""" 41 | def safe_func(*args): 42 | try: 43 | Log.i(func.__name__, "Invoke method in thread") 44 | return func(*args) 45 | except Exception: 46 | Log.ex(func.__name__, "Invoke method failed") 47 | 48 | def wrap_func(*args): 49 | t = threading.Thread(target=safe_func, args=args) 50 | t.setDaemon(True) 51 | t.start() 52 | 53 | return wrap_func 54 | 55 | 56 | if __name__ == "__main__": 57 | pass 58 | -------------------------------------------------------------------------------- /utils/chrome.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | # 3 | # Tencent is pleased to support the open source community by making QTA available. 4 | # Copyright (C) 2016THL A29 Limited, a Tencent company. All rights reserved. 5 | # Licensed under the BSD 3-Clause License (the "License"); you may not use this 6 | # file except in compliance with the License. You may obtain a copy of the License at 7 | # 8 | # https://opensource.org/licenses/BSD-3-Clause 9 | # 10 | # Unless required by applicable law or agreed to in writing, software distributed 11 | # under the License is distributed on an "AS IS" basis, WITHOUT WARRANTIES OR CONDITIONS 12 | # OF ANY KIND, either express or implied. See the License for the specific language 13 | # governing permissions and limitations under the License. 14 | # 15 | 16 | """Chrome相关操作 17 | """ 18 | 19 | import sys 20 | 21 | from .logger import Log 22 | 23 | 24 | class Chrome(object): 25 | """Chrome浏览器封装""" 26 | 27 | def __init__(self, handle): 28 | self._handle = handle 29 | 30 | @staticmethod 31 | def _get_browser_path(): 32 | import sys, os 33 | 34 | if sys.platform == "win32": 35 | if sys.getwindowsversion()[0] >= 6: 36 | # Vista/Win7支持这个环境变量 37 | path = os.getenv("LOCALAPPDATA") 38 | else: 39 | # XP下则是位于这个路径 40 | path = os.getenv("USERPROFILE") 41 | path = os.path.join(path, r"Local Settings\Application Data") 42 | path = os.path.join(path, r"Google\Chrome\Application\chrome.exe") 43 | if not os.path.exists(path): 44 | import ctypes 45 | 46 | buff = ctypes.create_string_buffer(256) 47 | ctypes.memset(buff, 0, 256) 48 | if ctypes.windll.kernel32.GetWindowsDirectoryA(buff, 256): 49 | buff[3] = int(0) 50 | buff = buff.value 51 | else: 52 | buff = "C:\\" 53 | buff = buff.decode("utf-8") 54 | path = buff + r"Program Files\Google\Chrome\Application\chrome.exe" 55 | if not os.path.exists(path): 56 | path = ( 57 | buff 58 | + r"Program Files (x86)\Google\Chrome\Application\chrome.exe" 59 | ) 60 | return path 61 | elif sys.platform == "darwin": 62 | chrome_path = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" 63 | if os.path.exists(chrome_path): 64 | return chrome_path 65 | return None 66 | else: 67 | raise NotImplementedError 68 | 69 | @staticmethod 70 | def open_url(url): 71 | """打开URL""" 72 | import time 73 | 74 | chrome_path = Chrome._get_browser_path() 75 | if sys.platform == "win32": 76 | import win32process, win32event, win32gui 77 | 78 | Log.i("Chrome", chrome_path) 79 | cmdline = [ 80 | '"%s"' % chrome_path, 81 | url, 82 | "--user-data-dir=remote-profile_1113", 83 | ] # , '--disk-cache-dir="%sqt4a_cache"' % chrome_path[:-10] 84 | processinfo = win32process.CreateProcess( 85 | None, 86 | " ".join(cmdline), 87 | None, 88 | None, 89 | 0, 90 | 0, 91 | None, 92 | None, 93 | win32process.STARTUPINFO(), 94 | ) 95 | win32event.WaitForInputIdle(processinfo[0], 10000) 96 | time.sleep(2) 97 | return Chrome(win32gui.GetForegroundWindow()) 98 | elif sys.platform == "darwin": 99 | import subprocess 100 | 101 | subprocess.Popen([chrome_path, url]) 102 | 103 | def bring_to_front(self): 104 | """将窗口置前""" 105 | if sys.platform == "win32": 106 | import win32gui 107 | 108 | win32gui.SetForegroundWindow(self._handle) 109 | else: 110 | raise NotImplementedError 111 | 112 | def is_closed(self): 113 | """是否已关闭""" 114 | if sys.platform == "win32": 115 | import win32gui 116 | 117 | return not win32gui.IsWindow(self._handle) 118 | else: 119 | raise NotImplementedError 120 | 121 | def close(self): 122 | """关闭Chrome""" 123 | if sys.platform == "win32": 124 | import ctypes 125 | 126 | ctypes.windll.user32.PostMessageA( 127 | self._chrome_hwnd, win32con.WM_CLOSE, 0, 0 128 | ) # 使用win32api py2exe会报错 129 | else: 130 | raise NotImplementedError 131 | 132 | 133 | if __name__ == "__main__": 134 | pass 135 | -------------------------------------------------------------------------------- /utils/exceptions.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | # 3 | # Tencent is pleased to support the open source community by making QTA available. 4 | # Copyright (C) 2016THL A29 Limited, a Tencent company. All rights reserved. 5 | # Licensed under the BSD 3-Clause License (the "License"); you may not use this 6 | # file except in compliance with the License. You may obtain a copy of the License at 7 | # 8 | # https://opensource.org/licenses/BSD-3-Clause 9 | # 10 | # Unless required by applicable law or agreed to in writing, software distributed 11 | # under the License is distributed on an "AS IS" basis, WITHOUT WARRANTIES OR CONDITIONS 12 | # OF ANY KIND, either express or implied. See the License for the specific language 13 | # governing permissions and limitations under the License. 14 | # 15 | 16 | """异常定义 17 | """ 18 | 19 | 20 | class ControlNotFoundError(RuntimeError): 21 | """控件未定义错误""" 22 | 23 | pass 24 | 25 | 26 | class WebViewDebuggingNotEnabledError(RuntimeError): 27 | """WebView调试未开启""" 28 | 29 | pass 30 | -------------------------------------------------------------------------------- /utils/logger.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | # 3 | # Tencent is pleased to support the open source community by making QTA available. 4 | # Copyright (C) 2016THL A29 Limited, a Tencent company. All rights reserved. 5 | # Licensed under the BSD 3-Clause License (the "License"); you may not use this 6 | # file except in compliance with the License. You may obtain a copy of the License at 7 | # 8 | # https://opensource.org/licenses/BSD-3-Clause 9 | # 10 | # Unless required by applicable law or agreed to in writing, software distributed 11 | # under the License is distributed on an "AS IS" basis, WITHOUT WARRANTIES OR CONDITIONS 12 | # OF ANY KIND, either express or implied. See the License for the specific language 13 | # governing permissions and limitations under the License. 14 | # 15 | 16 | """日志模块 17 | """ 18 | 19 | import os, sys 20 | import logging 21 | 22 | 23 | class Log(object): 24 | """ """ 25 | 26 | logger = None 27 | log_name = "AndroidUISpy" 28 | 29 | @staticmethod 30 | def gen_log_path(): 31 | """生成log存放路径""" 32 | if hasattr(sys, "frozen"): 33 | dir_root = os.path.dirname(os.path.join(os.sys.executable)) 34 | else: 35 | dir_root = os.path.dirname(os.path.abspath(__file__)) 36 | log_name = "%s.log" % (Log.log_name) 37 | return os.path.join(dir_root, log_name) 38 | 39 | @staticmethod 40 | def get_logger(): 41 | if Log.logger == None: 42 | Log.logger = logging.getLogger(Log.log_name) 43 | if len(Log.logger.handlers) == 0: 44 | Log.logger.setLevel(logging.DEBUG) 45 | # logger.setLevel(logging.INFO) 46 | # logger.setLevel(logging.WARNING) 47 | Log.logger.addHandler(logging.StreamHandler(sys.stdout)) 48 | fmt = logging.Formatter( 49 | "%(asctime)s %(message)s" 50 | ) # %(filename)s %(funcName)s 51 | Log.logger.handlers[0].setFormatter(fmt) 52 | 53 | logger_path = Log.gen_log_path() 54 | file_handler = logging.FileHandler(logger_path) 55 | fmt = logging.Formatter( 56 | "%(asctime)s %(levelname)s %(thread)d %(message)s" 57 | ) # %(filename)s %(funcName)s 58 | file_handler.setFormatter(fmt) 59 | Log.logger.addHandler(file_handler) 60 | return Log.logger 61 | 62 | @staticmethod 63 | def call(func, tag, *args): 64 | """ """ 65 | msg = "[%s] %s" % ( 66 | tag, 67 | " ".join([arg if isinstance(arg, str) else str(arg) for arg in args]), 68 | ) 69 | func = getattr(Log.get_logger(), func) 70 | return func(msg) 71 | 72 | @staticmethod 73 | def d(tag, *args): 74 | return Log.call("debug", tag, *args) 75 | 76 | @staticmethod 77 | def i(tag, *args): 78 | return Log.call("info", tag, *args) 79 | 80 | @staticmethod 81 | def w(tag, *args): 82 | return Log.call("warn", tag, *args) 83 | 84 | @staticmethod 85 | def e(tag, *args): 86 | return Log.call("error", tag, *args) 87 | 88 | @staticmethod 89 | def ex(tag, *args): 90 | return Log.call("exception", tag, *args) 91 | -------------------------------------------------------------------------------- /utils/qpath.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Tencent is pleased to support the open source community by making QTA available. 4 | # Copyright (C) 2016THL A29 Limited, a Tencent company. All rights reserved. 5 | # Licensed under the BSD 3-Clause License (the "License"); you may not use this 6 | # file except in compliance with the License. You may obtain a copy of the License at 7 | # 8 | # https://opensource.org/licenses/BSD-3-Clause 9 | # 10 | # Unless required by applicable law or agreed to in writing, software distributed 11 | # under the License is distributed on an "AS IS" basis, WITHOUT WARRANTIES OR CONDITIONS 12 | # OF ANY KIND, either express or implied. See the License for the specific language 13 | # governing permissions and limitations under the License. 14 | # 15 | 16 | """ 17 | qpath模块 18 | 19 | 详见QPath类说明 20 | """ 21 | 22 | import re 23 | 24 | 25 | class EnumQPathKey(object): 26 | MAX_DEPTH = "MAXDEPTH" 27 | INSTANCE = "INSTANCE" 28 | UI_TYPE = "UITYPE" 29 | 30 | 31 | class EnumUIType(object): 32 | WIN = "Win" 33 | GF = "GF" 34 | QPCtrl = "QPCtrl" 35 | 36 | 37 | class QPathError(Exception): 38 | """QPath异常类定义""" 39 | 40 | pass 41 | 42 | 43 | class QPath(object): 44 | """Query Path类,使用QPath字符串定位UI控件""" 45 | 46 | PROPERTY_SEP = "&&" 47 | OPERATORS = ["=", "~="] 48 | MATCH_FUNCS = {} 49 | MATCH_FUNCS["="] = lambda x, y: x == y 50 | MATCH_FUNCS["~="] = lambda string, pattern: re.search(pattern, string) != None 51 | CONTROL_TYPES = {} 52 | 53 | def __init__(self, qpath_string): 54 | """Contructor 55 | 56 | :type qpath_string: string 57 | :param qpath_string: QPath字符串 58 | """ 59 | if not isinstance(qpath_string, str): 60 | raise QPathError("输入的QPath(%s)不是字符串!" % (qpath_string)) 61 | self._strqpath = qpath_string 62 | self._path_sep, self._parsed_qpath = self._parse(qpath_string) 63 | self._error_qpath = None 64 | 65 | def _parse_property(self, prop_str): 66 | """解析property字符串,返回解析后结构 67 | 68 | 例如将 "ClassName='Dialog' " 解析返回 {ClassName: ['=', 'Dialog']} 69 | """ 70 | 71 | parsed_pattern = "(\w+)\s*([=~!<>]+)\s*(.+)" 72 | match_object = re.match(parsed_pattern, prop_str) 73 | if match_object is None: 74 | raise QPathError("属性(%s)不符合QPath语法" % prop_str) 75 | prop_name, operator, prop_value = match_object.groups() 76 | prop_value = eval(prop_value) 77 | if not operator in self.OPERATORS: 78 | raise QPathError("QPath不支持操作符:%s" % operator) 79 | return {prop_name: [operator, prop_value]} 80 | 81 | def _parse(self, qpath_string): 82 | """解析qpath,并返回QPath的路径分隔符和解析后的结构 83 | 84 | 将例如"| ClassName='Dialog' && Caption~='SaveAs' | UIType='GF' && ControlID='123' && Instanc='-1'" 85 | 的QPath解析为下面结构:[{'ClassName': ['=', 'Dialog'], 'Caption': ['~=', 'SaveAs']}, {'UIType': ['=', 'GF'], 'ControlID': ['=', '123'], 'Instance': ['=', '-1']}] 86 | 87 | :param qpath_string: qpath 字符串 88 | :return: (seperator, parsed_qpath) 89 | """ 90 | qpath_string = qpath_string.strip() 91 | seperator = qpath_string[0] 92 | locators = qpath_string[1:].split(seperator) 93 | 94 | parsed_qpath = [] 95 | for locator in locators: 96 | props = locator.split(self.PROPERTY_SEP) 97 | parsed_locators = {} 98 | for prop_str in props: 99 | prop_str = prop_str.strip() 100 | if len(prop_str) == 0: 101 | raise QPathError("%s 中含有空的属性。" % locator) 102 | parsed_props = self._parse_property(prop_str) 103 | parsed_locators.update(parsed_props) 104 | parsed_qpath.append(parsed_locators) 105 | return seperator, parsed_qpath 106 | 107 | def __str__(self): 108 | """返回格式化后的QPath字符串""" 109 | qpath_str = "" 110 | for locator in self._parsed_qpath: 111 | qpath_str += self._path_sep + " " 112 | delimit_str = " " + self.PROPERTY_SEP + " " 113 | locator_str = delimit_str.join( 114 | [ 115 | "%s %s '%s'" % (key, locator[key][0], locator[key][1]) 116 | for key in locator 117 | ] 118 | ) 119 | qpath_str += locator_str 120 | return qpath_str 121 | 122 | def getErrorPath(self): 123 | """返回最后一次QPath.search搜索未能匹配的路径 124 | 125 | :rtype: string 126 | """ 127 | if self._error_qpath: 128 | props = self._error_qpath[0] 129 | delimit_str = " " + self.PROPERTY_SEP + " " 130 | return delimit_str.join( 131 | ["%s %s '%s'" % (key, props[key][0], props[key][1]) for key in props] 132 | ) 133 | 134 | 135 | if __name__ == "__main__": 136 | pass 137 | -------------------------------------------------------------------------------- /utils/workthread.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | # 3 | # Tencent is pleased to support the open source community by making QTA available. 4 | # Copyright (C) 2016THL A29 Limited, a Tencent company. All rights reserved. 5 | # Licensed under the BSD 3-Clause License (the "License"); you may not use this 6 | # file except in compliance with the License. You may obtain a copy of the License at 7 | # 8 | # https://opensource.org/licenses/BSD-3-Clause 9 | # 10 | # Unless required by applicable law or agreed to in writing, software distributed 11 | # under the License is distributed on an "AS IS" basis, WITHOUT WARRANTIES OR CONDITIONS 12 | # OF ANY KIND, either express or implied. See the License for the specific language 13 | # governing permissions and limitations under the License. 14 | # 15 | 16 | """工作线程 17 | """ 18 | 19 | import time 20 | import threading 21 | 22 | try: 23 | from Queue import Queue 24 | except ImportError: 25 | from queue import Queue 26 | 27 | 28 | class Task(object): 29 | """任务""" 30 | 31 | def __init__(self, func, *args, **kwargs): 32 | self._func = func 33 | self._args = args 34 | self._kwargs = kwargs 35 | 36 | def run(self): 37 | return self._func(*self._args, **self._kwargs) 38 | 39 | 40 | class WorkThread(object): 41 | """ """ 42 | 43 | def __init__(self): 44 | self._thread = threading.Thread(target=self._work_thread) 45 | self._thread.setDaemon(True) 46 | self._run = True 47 | self._task_queue = Queue() 48 | self._thread.start() 49 | 50 | def _work_thread(self): 51 | """ """ 52 | while self._run: 53 | if self._task_queue.empty(): 54 | time.sleep(0.1) 55 | continue 56 | task = self._task_queue.get() 57 | try: 58 | task.run() 59 | except: 60 | import traceback 61 | 62 | traceback.print_exc() 63 | 64 | def post_task(self, func, *args, **kwargs): 65 | """发送任务""" 66 | task = Task(func, *args, **kwargs) 67 | self._task_queue.put(task) 68 | -------------------------------------------------------------------------------- /webinspect/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | # 3 | # Tencent is pleased to support the open source community by making QTA available. 4 | # Copyright (C) 2016THL A29 Limited, a Tencent company. All rights reserved. 5 | # Licensed under the BSD 3-Clause License (the "License"); you may not use this 6 | # file except in compliance with the License. You may obtain a copy of the License at 7 | # 8 | # https://opensource.org/licenses/BSD-3-Clause 9 | # 10 | # Unless required by applicable law or agreed to in writing, software distributed 11 | # under the License is distributed on an "AS IS" basis, WITHOUT WARRANTIES OR CONDITIONS 12 | # OF ANY KIND, either express or implied. See the License for the specific language 13 | # governing permissions and limitations under the License. 14 | # 15 | 16 | """用于探测WEB元素 17 | """ 18 | -------------------------------------------------------------------------------- /webinspect/debugging_tool.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | # 3 | # Tencent is pleased to support the open source community by making QTA available. 4 | # Copyright (C) 2016THL A29 Limited, a Tencent company. All rights reserved. 5 | # Licensed under the BSD 3-Clause License (the "License"); you may not use this 6 | # file except in compliance with the License. You may obtain a copy of the License at 7 | # 8 | # https://opensource.org/licenses/BSD-3-Clause 9 | # 10 | # Unless required by applicable law or agreed to in writing, software distributed 11 | # under the License is distributed on an "AS IS" basis, WITHOUT WARRANTIES OR CONDITIONS 12 | # OF ANY KIND, either express or implied. See the License for the specific language 13 | # governing permissions and limitations under the License. 14 | # 15 | 16 | """WebView调试工具 17 | """ 18 | 19 | import json 20 | import time 21 | from utils.exceptions import WebViewDebuggingNotEnabledError 22 | 23 | 24 | def replace_url_func_wrap(func): 25 | """替换调试url""" 26 | 27 | def _func(*args, **kwargs): 28 | url = func(*args, **kwargs) 29 | if not url: 30 | return url 31 | url = url.replace("?ws=127.0.0.1/", "?ws=/") # 避免最后生成的url错误 32 | return url 33 | 34 | return _func 35 | 36 | 37 | class WebViewDebuggingTool(object): 38 | """WebView调试工具""" 39 | 40 | def __init__(self, device): 41 | self._device = device 42 | 43 | def get_webview_debugging_server_list(self): 44 | """获取开启WebView调试服务列表""" 45 | server_list = [] 46 | result = self._device.adb.run_shell_cmd("cat /proc/net/unix") 47 | for line in result.split("\n"): 48 | items = line.strip().split() 49 | name = items[-1] 50 | if name.startswith("@webview_devtools_remote_") or name.startswith( 51 | "@xweb_devtools_remote_" 52 | ): 53 | if not name[1:] in server_list: 54 | server_list.append(name[1:]) 55 | return server_list 56 | 57 | def get_service_name(self, process_name): 58 | pid = self._device.adb.get_pid(process_name) 59 | service_list = self.get_webview_debugging_server_list() 60 | for service_name in [ 61 | "xweb_devtools_remote_%d" % pid, 62 | "webview_devtools_remote_%d" % pid, 63 | ]: 64 | if service_name in service_list: 65 | return service_name 66 | return None 67 | 68 | def create_tunnel(self, process_name): 69 | service_name = self.get_service_name(process_name) 70 | if not service_name: 71 | raise RuntimeError("Get webview debug service name failed") 72 | return self._device.adb.create_tunnel(service_name, "localabstract") 73 | 74 | def is_webview_debugging_opened(self, process_name): 75 | """是否进程开启了WebView调试""" 76 | service_name = self.get_service_name(process_name) 77 | return service_name != None 78 | 79 | def get_page_info(self, process_name, debugging_url): 80 | """通过执行js获取页面标题和url""" 81 | try: 82 | import chrome_master 83 | except ImportError: 84 | return None, None 85 | 86 | debugger = chrome_master.RemoteDebugger( 87 | debugging_url, lambda: self.create_tunnel(process_name) 88 | ) 89 | debugger.register_handler(chrome_master.RuntimeHandler) 90 | body = debugger.runtime.eval_script(None, "document.body.innerText").strip() 91 | if not body: 92 | # 过滤掉body为空的页面 93 | debugger.close() 94 | return "", "" 95 | url = debugger.runtime.eval_script(None, "location.href") 96 | title = debugger.runtime.eval_script(None, "document.title") 97 | debugger.close() 98 | return title, url 99 | 100 | def get_webview_page_list(self, process_name): 101 | """获取进程打开的WebView页面列表""" 102 | sock = self.create_tunnel(process_name) 103 | if not sock: 104 | raise WebViewDebuggingNotEnabledError( 105 | "WebView debugging in %s not enabled" % process_name 106 | ) 107 | sock.send(b"GET /json HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n") 108 | 109 | body = b"" 110 | time0 = time.time() 111 | while time.time() - time0 < 10: 112 | resp = sock.recv(4096) 113 | if not resp: 114 | break 115 | if not body: 116 | pos = resp.find(b"\r\n\r\n") 117 | body += resp[pos + 4 :] 118 | else: 119 | body += resp 120 | try: 121 | json.loads(body) 122 | except: 123 | continue 124 | else: 125 | break 126 | else: 127 | raise RuntimeError("Recv json response timeout") 128 | sock.close() 129 | # print(body.decode("utf8")) 130 | print(body) 131 | 132 | try: 133 | page_list = json.loads(body) 134 | except: 135 | raise RuntimeError("Invalid json response: %r" % body) 136 | 137 | result = [] 138 | for page in page_list: 139 | if page["type"] != "page": 140 | continue 141 | desc = page["description"] 142 | if desc: 143 | page["description"] = json.loads(desc) 144 | if not page["description"].get("width") or not page["description"].get( 145 | "height" 146 | ): 147 | continue 148 | if not page["description"]["visible"]: 149 | continue 150 | if "webSocketDebuggerUrl" not in page: 151 | raise RuntimeError("请关闭已打开的所有调试页面") 152 | debugging_url = page["webSocketDebuggerUrl"] 153 | if debugging_url.startswith("ws:///"): 154 | debugging_url = "ws://localhost%s" % debugging_url[5:] 155 | page["webSocketDebuggerUrl"] = debugging_url 156 | 157 | if page["url"] == "about:blank" or page["title"] == "about:blank": 158 | # 微信小程序中发现这里返回的url和title可能都不对 159 | if not "webSocketDebuggerUrl" in page: 160 | raise RuntimeError("请关闭已打开的Web调试页面") 161 | title, url = self.get_page_info(process_name, debugging_url) 162 | # if not url or url == 'about:blank': 163 | # continue 164 | page["url"] = url 165 | page["title"] = title 166 | # result.append(page) 167 | else: 168 | title, url = self.get_page_info(process_name, debugging_url) 169 | if title == "" and url == "": 170 | # 页面内容为空 171 | continue 172 | 173 | result.append(page) 174 | return result 175 | 176 | def _get_similar(self, text1, text2): 177 | """计算相似度""" 178 | import difflib 179 | 180 | return difflib.SequenceMatcher(None, text1, text2).ratio() 181 | 182 | @replace_url_func_wrap 183 | def get_debugging_url(self, process_name, multi_page_callback, url, title=None): 184 | """获取WebView调试页面url""" 185 | page_list = self.get_webview_page_list(process_name) 186 | if len(page_list) == 1: 187 | return page_list[0].get("devtoolsFrontendUrl") 188 | 189 | if url == None and title == None: 190 | return multi_page_callback(page_list) 191 | 192 | for page in page_list: 193 | if url and self._get_similar(page["url"], url) >= 0.05: 194 | return page.get("devtoolsFrontendUrl") 195 | if title and page["title"] == title: 196 | return page.get("devtoolsFrontendUrl") 197 | else: 198 | raise RuntimeError( 199 | u"未找到页面:url=%s title=%s" 200 | % (url.decode("utf8"), title.decode("utf8") if title else None) 201 | ) 202 | 203 | 204 | if __name__ == "__main__": 205 | pass 206 | --------------------------------------------------------------------------------