├── .github └── workflows │ └── python.yml ├── .gitignore ├── DEVELOP.md ├── LICENSE ├── README.md ├── codecov.yml ├── poetry.toml ├── pyproject.toml ├── runtest.sh ├── scripts ├── run-all.sh └── run-isort.sh ├── tests ├── test_api.py ├── test_help.py ├── test_utils_common.py └── test_utils_download.py └── tidevice3 ├── __init__.py ├── __main__.py ├── api.py ├── cli ├── __init__.py ├── app.py ├── cli_common.py ├── developer.py ├── exec.py ├── fsync.py ├── info.py ├── install.py ├── list.py ├── reboot.py ├── relay.py ├── runwda.py ├── screenrecord.py ├── screenshot.py └── tunneld.py ├── exceptions.py └── utils ├── __init__.py ├── common.py └── download.py /.github/workflows/python.yml: -------------------------------------------------------------------------------- 1 | name: Python Package 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | tags: 7 | - v* 8 | pull_request: 9 | branches: [master] 10 | 11 | jobs: 12 | test: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v2 16 | 17 | - name: Set up Python 3.8 18 | uses: actions/setup-python@v4 19 | with: 20 | python-version: 3.8 21 | 22 | - name: Install dependencies 23 | run: | 24 | python -m pip install --upgrade pip 25 | pip install poetry 26 | poetry install 27 | 28 | - name: Run tests with coverage 29 | run: | 30 | poetry run pytest --cov=. --cov-report xml --cov-report term 31 | 32 | - name: Upload coverage to Codecov 33 | uses: codecov/codecov-action@v3 34 | 35 | publish: 36 | runs-on: ubuntu-latest 37 | needs: test # 声明依赖于先前的测试作业 38 | if: startsWith(github.ref, 'refs/tags/') # 仅在标签推送时运行 39 | steps: 40 | - uses: actions/checkout@v3 41 | 42 | - name: Set up Python 43 | uses: actions/setup-python@v4 44 | with: 45 | python-version: 3.8 46 | 47 | - name: Install dependencies 48 | run: | 49 | python -m pip install --upgrade pip 50 | pip install poetry 51 | 52 | - name: Build 53 | run: | 54 | poetry self add "poetry-dynamic-versioning[plugin]" 55 | rm -fr dist/ && poetry build 56 | 57 | - name: Publish distribution 📦 to PyPI 58 | uses: pypa/gh-action-pypi-publish@release/v1 59 | with: 60 | password: ${{ secrets.PYPI_TOKEN }} 61 | 62 | - name: Refresh Badge 63 | run: | 64 | curl -X PURGE https://camo.githubusercontent.com/8646ff049d2c966e398ae621ed8cd99ffe4b95ae9f5efe0fd57eda9425844104/68747470733a2f2f62616467652e667572792e696f2f70792f7469646576696365332e737667 65 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/#use-with-ide 110 | .pdm.toml 111 | 112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 113 | __pypackages__/ 114 | 115 | # Celery stuff 116 | celerybeat-schedule 117 | celerybeat.pid 118 | 119 | # SageMath parsed files 120 | *.sage.py 121 | 122 | # Environments 123 | .env 124 | .venv 125 | env/ 126 | venv/ 127 | ENV/ 128 | env.bak/ 129 | venv.bak/ 130 | 131 | # Spyder project settings 132 | .spyderproject 133 | .spyproject 134 | 135 | # Rope project settings 136 | .ropeproject 137 | 138 | # mkdocs documentation 139 | /site 140 | 141 | # mypy 142 | .mypy_cache/ 143 | .dmypy.json 144 | dmypy.json 145 | 146 | # Pyre type checker 147 | .pyre/ 148 | 149 | # pytype static type analyzer 150 | .pytype/ 151 | 152 | # Cython debug symbols 153 | cython_debug/ 154 | 155 | # PyCharm 156 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 157 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 158 | # and can be added to the global gitignore or merged into this file. For a more nuclear 159 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 160 | #.idea/ 161 | *.lock 162 | Pipfile 163 | -------------------------------------------------------------------------------- /DEVELOP.md: -------------------------------------------------------------------------------- 1 | # Developent Guide 2 | tidevice3 primarily encapsulates pymobiledevice3, aiming to offer a better command-line user experience. 3 | 4 | # Code Structure 5 | Within the cli directory, apart from cli_common.py, the name of each other file represents a subcommand. When adding a new subcommand, you need to register it in cli_common.py. 6 | 7 | # The project uses poetry for dependency management and publishing 8 | 9 | ```bash 10 | # Install poetry 11 | pip install poetry 12 | 13 | # Install dependencies to the local directory .venv 14 | poetry install 15 | 16 | # Run unit tests 17 | poetry run pytest -v 18 | 19 | # Test a single subcommand 20 | poetry run t3 list 21 | ``` 22 | 23 | Refer to .github/workflows for release processes. 24 | 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 codeskyblue 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # tidevice3 2 | [![PyPI version](https://badge.fury.io/py/tidevice3.svg)](https://badge.fury.io/py/tidevice3) 3 | [![codecov](https://codecov.io/gh/codeskyblue/tidevice3/graph/badge.svg?token=twFRe9igek)](https://codecov.io/gh/codeskyblue/tidevice3) 4 | 5 | wrapper for pymobiledevice3 to make it more easy to use. 6 | 7 | 8 | # Install 9 | 10 | Mac 11 | 12 | ```bash 13 | pip install tidevice3 14 | 15 | # or install as Isolated environment 16 | brew install pipx 17 | pipx install tidevice3 18 | ``` 19 | 20 | Linux 21 | 22 | ```bash 23 | # required by pytun-pmd3 24 | sudo apt install python3-dev gcc pipx 25 | pipx install tidevice3 26 | pipx ensurepath 27 | ``` 28 | 29 | # CLI Usage 30 | 31 | iOS >= 17 `screenshot,app:ps` connect through Ethernet over USB (NCM device) instead of usbmuxd 32 | So tunneld should start first. 33 | 34 | ```bash 35 | # start tunneld for iOS>=17 36 | # launch process (pmd3 remote start-tunnel) when new usb device connected 37 | # root required 38 | $ sudo t3 tunneld 39 | ``` 40 | 41 | Basic usage 42 | 43 | ```bash 44 | $ t3 list 45 | ... 46 | 47 | # enable developer mode and mount develoepr image 48 | $ t3 developer 49 | 50 | # install & uninstall 51 | $ t3 install https://....ipa 52 | $ t3 install ./some.ipa 53 | $ t3 uninstall com.example 54 | 55 | # take screenshot 56 | $ t3 screenshot out.png 57 | 58 | # reboot 59 | $ t3 reboot 60 | 61 | # file operation 62 | $ t3 fsync [Arguments...] 63 | 64 | # app 65 | $ t3 app 66 | 67 | # install 68 | # alias for app install 69 | $ t3 install 70 | 71 | # screenrecord 72 | $ t3 screenrecord out.mp4 73 | 74 | # relay (like iproxy LOCAL_PORT DEVICE_PORT) 75 | $ t3 relay 8100 8100 76 | $ t3 relay 8100 8100 --source 0.0.0.0 --daemonize 77 | 78 | # show help 79 | $ t3 --help 80 | ``` 81 | 82 | # API Usage 83 | The API alone is insufficient for all operations; combining it with the pymobiledevice3 library can accomplish more things. 84 | 85 | ```python 86 | from tidevice3.api import list_devices, connect_service_provider, screenshot, app_install 87 | 88 | for d in list_devices(usb=True): 89 | print("UDID:", d.Identifier) 90 | service_provider = connect_service_provider(d.Identifier) 91 | pil_im = screenshot(service_provider) 92 | pil_im.save("screenshot.png") 93 | 94 | # install ipa from URL or local 95 | app_install(service_provider, "https://example.org/some.ipa") 96 | ``` 97 | 98 | # iOS 17 support 99 | - Mac (supported) 100 | - Windows (https://github.com/doronz88/pymobiledevice3/issues/569) 101 | - Linux (https://github.com/doronz88/pymobiledevice3/issues/566) 102 | 103 | Mac,Windows,Linux all supported iOS<17 104 | 105 | # WDA 106 | 其实WDA启动可以不用XCUITest,下面是具体的方法(适用于iOS >= 15) 107 | 108 | - iOS企业重签名方法 https://zhuanlan.zhihu.com/p/673521212 109 | - iOS WDA脱离xcode & tidevice运行自动化 https://zhuanlan.zhihu.com/p/673319266 110 | 111 | # DEVELOP & CONTRIBUTE 112 | see [DEVELOP.md](DEVELOP.md) 113 | 114 | # LICENSE 115 | [MIT](LICENSE) 116 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | codecov: 2 | token: 7d823b9b-5568-4146-997f-b2a292f10ec5 -------------------------------------------------------------------------------- /poetry.toml: -------------------------------------------------------------------------------- 1 | [virtualenvs] 2 | create = true 3 | in-project = true 4 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "tidevice3" 3 | version = "0.1.0" 4 | description = "wrapper for pymobiledevice3 for easy use with iphone device" 5 | homepage = "https://github.com/codeskyblue/tidevice3" 6 | authors = ["codeskyblue "] 7 | license = "MIT" 8 | readme = "README.md" 9 | 10 | [tool.poetry.dependencies] 11 | python = "^3.8" 12 | pymobiledevice3 = "^4.2.3" 13 | click = "*" 14 | pydantic = "^2.5.3" 15 | fastapi = "*" 16 | requests = "*" 17 | numpy = "*" 18 | imageio = {extras = ["ffmpeg"], version = "^2.33.1"} 19 | pillow = "^10.0" 20 | zeroconf = "^0.132.2" 21 | 22 | [tool.poetry.group.dev.dependencies] 23 | pytest = "^7.4.4" 24 | pytest-cov = "^4.1.0" 25 | pytest-httpserver = "^1.0.8" 26 | isort = "^5.13.2" 27 | 28 | [tool.poetry.scripts] 29 | t3 = "tidevice3.__main__:main" 30 | 31 | # 根据tag来动态配置版本号,tag需要v开头,比如v0.0.1 32 | [tool.poetry-dynamic-versioning] 33 | enable = true 34 | 35 | # 需要将原本的build-system替换掉 36 | [build-system] 37 | requires = ["poetry-core>=1.0.0", "poetry-dynamic-versioning"] 38 | build-backend = "poetry_dynamic_versioning.backend" 39 | 40 | [tool.coverage.run] 41 | branch = true 42 | 43 | [tool.coverage.report] 44 | # Regexes for lines to exclude from consideration 45 | exclude_also = [ 46 | # Don't complain about missing debug-only code: 47 | "def __repr__", 48 | "if self\\.debug", 49 | 50 | # Don't complain if tests don't hit defensive assertion code: 51 | "raise AssertionError", 52 | "raise NotImplementedError", 53 | 54 | # Don't complain if non-runnable code isn't run: 55 | "if 0:", 56 | "if __name__ == .__main__.:", 57 | 58 | # Don't complain about abstract methods, they aren't run: 59 | "@(abc\\.)?abstractmethod", 60 | ] 61 | 62 | ignore_errors = true 63 | omit = [ 64 | "tests/*", 65 | "docs/*", 66 | "__main__.py", 67 | ] 68 | -------------------------------------------------------------------------------- /runtest.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -ex 2 | # 3 | 4 | poetry run isort . -m HANGING_INDENT -l 120 --check-only 5 | poetry run pytest -v 6 | -------------------------------------------------------------------------------- /scripts/run-all.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # 3 | # 4 | 5 | set -e 6 | 7 | t3(){ 8 | echo ">>> t3 $@" 9 | poetry run t3 "$@" 10 | } 11 | 12 | test_fsync(){ 13 | t3 fsync ls /Downloads 14 | rm -f a.txt b.txt 15 | echo -n hello > a.txt 16 | t3 fsync push a.txt /Downloads 17 | t3 fsync pull /Downloads/a.txt b.txt 18 | if ! cmp a.txt b.txt 19 | then 20 | echo ">>> ERROR: a.txt != b.txt" 21 | exit 1 22 | fi 23 | t3 fsync rm /Downloads/a.txt 24 | rm a.txt b.txt 25 | } 26 | 27 | test_app(){ 28 | t3 app install --help 29 | t3 app uninstall --help 30 | t3 app launch com.apple.Preferences 31 | t3 app kill --help 32 | t3 app list 33 | t3 app info com.apple.stocks 34 | t3 app ps 35 | t3 app current || true 36 | } 37 | 38 | t3 list 39 | t3 developer 40 | t3 exec version 41 | t3 screenshot a.png && rm a.png 42 | t3 screenrecord --help 43 | t3 reboot --help 44 | 45 | test_fsync 46 | test_app 47 | 48 | 49 | 50 | -------------------------------------------------------------------------------- /scripts/run-isort.sh: -------------------------------------------------------------------------------- 1 | isort . -m HANGING_INDENT -l 120 2 | -------------------------------------------------------------------------------- /tests/test_api.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from pathlib import Path 3 | 4 | import pytest 5 | 6 | from tidevice3.api import connect_service_provider, list_devices, screenshot 7 | 8 | 9 | @pytest.mark.skipif(sys.platform != "darwin", reason="only run on mac") 10 | def test_api(tmp_path: Path): 11 | for d in list_devices(usb=True): 12 | print("UDID:", d.Identifier) 13 | service_provider = connect_service_provider(d.Identifier) 14 | with service_provider: 15 | pil_im = screenshot(service_provider) 16 | pil_im.save(tmp_path / "screenshot.png") 17 | -------------------------------------------------------------------------------- /tests/test_help.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | """Created on Fri Jan 05 2024 18:56:00 by codeskyblue 5 | """ 6 | 7 | from click.testing import CliRunner 8 | 9 | from tidevice3.cli.cli_common import CLI_GROUPS, cli 10 | 11 | runner = CliRunner() 12 | 13 | def test_cli_help(): 14 | result = runner.invoke(cli, ['--help']) 15 | assert result.exit_code == 0, result.output 16 | 17 | for subcommand in CLI_GROUPS: 18 | result = runner.invoke(cli, [subcommand, '--help']) 19 | assert result.exit_code == 0, (subcommand, result.output) 20 | -------------------------------------------------------------------------------- /tests/test_utils_common.py: -------------------------------------------------------------------------------- 1 | import threading 2 | 3 | import pytest 4 | 5 | from tidevice3.utils.common import print_dict_as_table, threadsafe_function 6 | 7 | 8 | def test_threadsafe_function(): 9 | # Define a shared variable 10 | shared_variable = 0 11 | 12 | # Define a threadsafe function 13 | @threadsafe_function 14 | def increment_shared_variable(): 15 | nonlocal shared_variable 16 | shared_variable += 1 17 | 18 | # Define a helper function to run the threads 19 | def run_threads(): 20 | for _ in range(1000): 21 | increment_shared_variable() 22 | 23 | # Create multiple threads to increment the shared variable 24 | threads = [] 25 | for _ in range(10): 26 | thread = threading.Thread(target=run_threads) 27 | threads.append(thread) 28 | thread.start() 29 | 30 | # Wait for all threads to finish 31 | for thread in threads: 32 | thread.join() 33 | 34 | # Check if the shared variable has been incremented correctly 35 | assert shared_variable == 10000 36 | 37 | 38 | def test_print_dict_as_table(capsys: pytest.CaptureFixture[str]): 39 | # expect output: 40 | # a bb 41 | # 123 2 42 | print_dict_as_table([{"a": 123, "bb": "2"}, {"a": 1}], headers=["a", "bb"], sep="-") 43 | captured = capsys.readouterr() 44 | expected_output = "".join([ 45 | "a -bb\n", 46 | "123-2\n", 47 | "1 -\n" 48 | ]) 49 | assert captured.out == expected_output 50 | 51 | # expect output: 52 | # a bb 53 | print_dict_as_table([], headers=["a", "bb"], sep="-") 54 | captured = capsys.readouterr() 55 | assert captured.out == "a-bb\n" 56 | 57 | -------------------------------------------------------------------------------- /tests/test_utils_download.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | """Created on Sat Jan 06 2024 00:04:47 by codeskyblue 5 | """ 6 | 7 | import base64 8 | import hashlib 9 | import pathlib 10 | 11 | import pytest 12 | from pytest_httpserver import HTTPServer 13 | 14 | from tidevice3.exceptions import DownloadError 15 | from tidevice3.utils.download import CACHE_DOWNLOAD_SUFFIX, download_file, guess_filename_from_url 16 | 17 | 18 | def test_download_file(httpserver: HTTPServer, tmp_path: pathlib.Path): 19 | url = httpserver.url_for("/hello") 20 | filepath = tmp_path / 'hello.txt' 21 | with pytest.raises(DownloadError): 22 | download_file(url, filepath) 23 | 24 | httpserver.expect_oneshot_request("/hello").respond_with_data("hello12345") 25 | download_file(url, filepath) 26 | assert filepath.read_text() == 'hello12345' 27 | 28 | with pytest.raises(DownloadError): 29 | download_file("ftp://123.txt", filepath) 30 | 31 | 32 | def test_download_file_with_range(httpserver: HTTPServer, tmp_path: pathlib.Path): 33 | filepath = tmp_path / 'test1.txt' 34 | url = httpserver.url_for("/test1") 35 | tmpfpath = pathlib.Path(str(filepath) + CACHE_DOWNLOAD_SUFFIX) 36 | tmpfpath.write_bytes(b"hixxx") 37 | httpserver.expect_oneshot_request("/test1") \ 38 | .respond_with_data("test1ABCDE", 39 | headers={"Accept-Ranges": "bytes"}) 40 | httpserver.expect_oneshot_request("/test1", headers={"Range": "bytes=5-"}) \ 41 | .respond_with_data("ABCDE", 42 | headers={"Accept-Ranges": "bytes", "Content-Range": "bytes 5-9/10"}) 43 | download_file(httpserver.url_for("/test1"), filepath) 44 | assert filepath.read_text() == 'hixxxABCDE' 45 | 46 | 47 | def test_download_with_md5(httpserver: HTTPServer, tmp_path: pathlib.Path): 48 | filepath = tmp_path / 'test2.txt' 49 | url = httpserver.url_for("/test2") 50 | filepath.write_bytes(b"4444") 51 | 52 | def hash_md5_string(data: str) -> str: 53 | """ return base64 md5 digest """ 54 | m = hashlib.md5() 55 | m.update(data.encode()) 56 | return base64.b64encode(m.digest()).decode() 57 | 58 | httpserver.expect_request("/test2") \ 59 | .respond_with_data("1234", 60 | headers={"Accept-Ranges": "bytes", 61 | "Content-Md5": hash_md5_string("4444")}) 62 | download_file(url, filepath) 63 | assert filepath.read_text() == '4444' 64 | filepath.unlink() 65 | 66 | # md5 not match, should raise error 67 | with pytest.raises(DownloadError): 68 | download_file(url, filepath) 69 | 70 | httpserver.expect_request("/test3") \ 71 | .respond_with_data("333", 72 | headers={"Accept-Ranges": "bytes", 73 | "Content-Md5": "xxxx1122<>??"}) 74 | url = httpserver.url_for("/test3") 75 | download_file(url, filepath) 76 | assert filepath.read_text() == '333' 77 | 78 | 79 | def test_download_guess_filename(): 80 | assert guess_filename_from_url("http://example.com/b/test.txt?foo=1") == "test.txt" 81 | -------------------------------------------------------------------------------- /tidevice3/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codeskyblue/tidevice3/d83c345b2c5216c82baafb8e3c09574f8020ff00/tidevice3/__init__.py -------------------------------------------------------------------------------- /tidevice3/__main__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | """Created on Fri Jan 05 2024 17:47:29 by codeskyblue 5 | """ 6 | import logging 7 | import sys 8 | 9 | import click 10 | from pymobiledevice3.exceptions import NoDeviceConnectedError 11 | 12 | from tidevice3.cli.cli_common import cli 13 | from tidevice3.exceptions import BaseException, FatalError 14 | 15 | logger = logging.getLogger(__name__) 16 | 17 | 18 | def main(): 19 | try: 20 | cli(auto_envvar_prefix='T3') 21 | except (FatalError, ValueError) as e: 22 | click.echo(f"Error: {e}") 23 | sys.exit(1) 24 | except NoDeviceConnectedError: 25 | logger.error("No device connected") 26 | sys.exit(1) 27 | except BaseException as e: 28 | click.echo(f"Error: {type(e)} {e}") 29 | sys.exit(1) 30 | except Exception as e: 31 | logger.exception("unhandled exception: %s", e) 32 | sys.exit(2) 33 | 34 | 35 | if __name__ == '__main__': 36 | main() -------------------------------------------------------------------------------- /tidevice3/api.py: -------------------------------------------------------------------------------- 1 | 2 | from __future__ import annotations 3 | 4 | import datetime 5 | import io 6 | import logging 7 | import os 8 | import socket 9 | from typing import Any, Dict, Iterator, Optional 10 | 11 | import requests 12 | from packaging.version import Version 13 | from PIL import Image 14 | from pydantic import BaseModel 15 | from pymobiledevice3.common import get_home_folder 16 | from pymobiledevice3.exceptions import AlreadyMountedError 17 | from pymobiledevice3.lockdown import LockdownClient, create_using_usbmux, usbmux 18 | from pymobiledevice3.lockdown_service_provider import LockdownServiceProvider 19 | from pymobiledevice3.remote.remote_service_discovery import RemoteServiceDiscoveryService 20 | from pymobiledevice3.services.amfi import AmfiService 21 | from pymobiledevice3.services.dvt.dvt_secure_socket_proxy import DvtSecureSocketProxyService 22 | from pymobiledevice3.services.dvt.instruments.device_info import DeviceInfo 23 | from pymobiledevice3.services.dvt.instruments.screenshot import Screenshot 24 | from pymobiledevice3.services.installation_proxy import InstallationProxyService 25 | from pymobiledevice3.services.mobile_image_mounter import auto_mount 26 | from pymobiledevice3.services.screenshot import ScreenshotService 27 | from pymobiledevice3.utils import get_asyncio_loop 28 | 29 | from tidevice3.exceptions import FatalError 30 | from tidevice3.utils.download import download_file, is_hyperlink 31 | 32 | logger = logging.getLogger(__name__) 33 | 34 | class DeviceShortInfo(BaseModel): 35 | BuildVersion: str 36 | ConnectionType: Optional[str] 37 | DeviceClass: str 38 | DeviceName: str 39 | Identifier: str 40 | ProductType: str 41 | ProductVersion: str 42 | 43 | 44 | class ProcessInfo(BaseModel): 45 | isApplication: bool 46 | pid: int 47 | name: str 48 | realAppName: str 49 | startDate: datetime.datetime 50 | bundleIdentifier: Optional[str] = None 51 | foregroundRunning: Optional[bool] = None 52 | 53 | 54 | def list_devices( 55 | usb: bool = True, network: bool = False, usbmux_address: Optional[str] = None 56 | ) -> list[DeviceShortInfo]: 57 | connected_devices = [] 58 | for device in usbmux.list_devices(usbmux_address=usbmux_address): 59 | udid = device.serial 60 | 61 | if usb and not device.is_usb: 62 | continue 63 | 64 | if network and not device.is_network: 65 | continue 66 | 67 | lockdown = create_using_usbmux( 68 | udid, 69 | autopair=False, 70 | connection_type=device.connection_type, 71 | usbmux_address=usbmux_address, 72 | ) 73 | info = DeviceShortInfo.model_validate(lockdown.short_info) 74 | connected_devices.append(info) 75 | return connected_devices 76 | 77 | 78 | DEFAULT_TIMEOUT = 60 79 | 80 | def connect_service_provider(udid: Optional[str], force_usbmux: bool = False, usbmux_address: Optional[str] = None) -> LockdownServiceProvider: 81 | """Connect to device and return LockdownServiceProvider""" 82 | lockdown = create_using_usbmux(serial=udid, usbmux_address=usbmux_address) 83 | if force_usbmux: 84 | return lockdown 85 | if lockdown.product_version >= "17": 86 | return connect_remote_service_discovery_service(lockdown.udid) 87 | return lockdown 88 | 89 | 90 | class EnterableRemoteServiceDiscoveryService(RemoteServiceDiscoveryService): 91 | def __enter__(self) -> EnterableRemoteServiceDiscoveryService: 92 | get_asyncio_loop().run_until_complete(self.connect()) 93 | return self 94 | 95 | def __exit__(self, exc_type, exc_val, exc_tb) -> None: 96 | get_asyncio_loop().run_until_complete(self.close()) 97 | 98 | 99 | def is_port_open(ip: str, port: int) -> bool: 100 | with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: 101 | return s.connect_ex((ip, port)) == 0 102 | 103 | 104 | def connect_remote_service_discovery_service(udid: str, tunneld_url: str = None) -> EnterableRemoteServiceDiscoveryService: 105 | if tunneld_url is None: 106 | if is_port_open("localhost", 49151): 107 | tunneld_url = "http://localhost:49151" 108 | else: 109 | tunneld_url = "http://localhost:5555" # for backward compatibility 110 | 111 | try: 112 | resp = requests.get(tunneld_url, timeout=DEFAULT_TIMEOUT) 113 | tunnels: Dict[str, Any] = resp.json() 114 | ipv6_address = tunnels.get(udid) 115 | if ipv6_address is None: 116 | raise FatalError("tunneld not ready for device", udid) 117 | rsd = EnterableRemoteServiceDiscoveryService(ipv6_address) 118 | return rsd 119 | except requests.RequestException: 120 | raise FatalError("Please run `sudo t3 tunneld` first") 121 | except (TimeoutError, ConnectionError): 122 | raise FatalError("RemoteServiceDiscoveryService connect failed") 123 | 124 | def iter_screenshot(service_provider: LockdownClient) -> Iterator[bytes]: 125 | if int(service_provider.product_version.split(".")[0]) >= 17: 126 | with DvtSecureSocketProxyService(lockdown=service_provider) as dvt: 127 | screenshot_service = Screenshot(dvt) 128 | while True: 129 | yield screenshot_service.get_screenshot() 130 | else: 131 | screenshot_service = ScreenshotService(service_provider) 132 | while True: 133 | yield screenshot_service.take_screenshot() 134 | 135 | 136 | def screenshot_png(service_provider: LockdownClient) -> bytes: 137 | """ get screenshot as png data """ 138 | it = iter_screenshot(service_provider) 139 | png_data = next(it) 140 | it.close() 141 | return png_data 142 | 143 | 144 | def screenshot(service_provider: LockdownClient) -> Image.Image: 145 | """ get screenshot as PIL.Image.Image """ 146 | png_data = screenshot_png(service_provider) 147 | return Image.open(io.BytesIO(png_data)).convert("RGB") 148 | 149 | 150 | def proclist(service_provider: LockdownClient) -> Iterator[ProcessInfo]: 151 | """ list running processes""" 152 | with DvtSecureSocketProxyService(lockdown=service_provider) as dvt: 153 | processes = DeviceInfo(dvt).proclist() 154 | for process in processes: 155 | if 'startDate' in process: 156 | process['startDate'] = str(process['startDate']) 157 | yield ProcessInfo.model_validate(process) 158 | 159 | 160 | def app_install(service_provider: LockdownClient, path_or_url: str): 161 | if is_hyperlink(path_or_url): 162 | ipa_path = download_file(path_or_url) 163 | elif os.path.isfile(path_or_url): 164 | ipa_path = path_or_url 165 | else: 166 | raise ValueError("local file not found", path_or_url) 167 | InstallationProxyService(lockdown=service_provider).install_from_local(ipa_path) 168 | 169 | 170 | def enable_developer_mode(service_provider: LockdownClient): 171 | """ enable developer mode """ 172 | if Version(service_provider.product_version) >= Version("16"): 173 | if not service_provider.developer_mode_status: 174 | logger.info('enable developer mode') 175 | AmfiService(service_provider).enable_developer_mode() 176 | else: 177 | logger.info('developer mode already enabled') 178 | 179 | try: 180 | xcode = get_home_folder() / 'Xcode.app' 181 | xcode.mkdir(parents=True, exist_ok=True) 182 | auto_mount(service_provider, xcode=xcode) 183 | logger.info('mount developer image') 184 | except AlreadyMountedError: 185 | logger.info('developer image already mounted') 186 | -------------------------------------------------------------------------------- /tidevice3/cli/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codeskyblue/tidevice3/d83c345b2c5216c82baafb8e3c09574f8020ff00/tidevice3/cli/__init__.py -------------------------------------------------------------------------------- /tidevice3/cli/app.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | """Created on Thu Jan 11 2024 14:12:29 by codeskyblue 5 | """ 6 | 7 | 8 | from __future__ import annotations 9 | 10 | import logging 11 | import os 12 | import shlex 13 | 14 | import click 15 | from pymobiledevice3.cli.cli_common import print_json 16 | from pymobiledevice3.lockdown import LockdownClient 17 | from pymobiledevice3.services.dvt.dvt_secure_socket_proxy import DvtSecureSocketProxyService 18 | from pymobiledevice3.services.dvt.instruments.process_control import ProcessControl 19 | from pymobiledevice3.services.installation_proxy import InstallationProxyService 20 | 21 | from tidevice3.api import app_install, proclist 22 | from tidevice3.cli.cli_common import cli, pass_rsd, pass_service_provider 23 | from tidevice3.exceptions import FatalError 24 | from tidevice3.utils.common import print_dict_as_table 25 | 26 | logger = logging.getLogger(__name__) 27 | 28 | @cli.group() 29 | def app(): 30 | """app related commands""" 31 | pass 32 | 33 | 34 | @app.command("install") 35 | @click.argument("path_or_url") 36 | @pass_service_provider 37 | def cli_app_install(service_provider: LockdownClient, path_or_url: str): 38 | """install given .ipa or url""" 39 | app_install(service_provider, path_or_url) 40 | 41 | 42 | @app.command("list") 43 | @click.option('app_type', '-t', '--type', type=click.Choice(['System', 'User', 'Hidden', 'Any']), default='User', 44 | help='include only applications of given type') 45 | @click.option("--calculate-sizes/--no-calculate-size", default=False) 46 | @pass_service_provider 47 | def app_list(service_provider: LockdownClient, app_type: str, calculate_sizes: bool): 48 | """list installed apps""" 49 | app_infos = InstallationProxyService(lockdown=service_provider).get_apps(app_type, calculate_sizes=calculate_sizes) 50 | print_dict_as_table(app_infos.values(), ["CFBundleIdentifier", "CFBundleDisplayName", "CFBundleVersion", "CFBundleShortVersionString"]) 51 | 52 | 53 | @app.command("uninstall") 54 | @click.argument("bundle_identifier") 55 | @pass_service_provider 56 | def app_uninstall(service_provider: LockdownClient, bundle_identifier: str): 57 | """uninstall application""" 58 | InstallationProxyService(lockdown=service_provider).uninstall(bundle_identifier) 59 | 60 | 61 | @app.command("launch") 62 | @click.argument("arguments", type=click.STRING) 63 | @click.option("--kill-existing/--no-kill-existing", default=True, help="Whether to kill an existing instance of this process") 64 | @click.option("--suspended", is_flag=True, help="Same as WaitForDebugger") 65 | @click.option("--env", multiple=True, type=click.Tuple((str, str)), help="Environment variables to pass to process given as a list of key value") 66 | @click.option("--stream", is_flag=True) 67 | @pass_rsd 68 | def app_launch(service_provider, arguments: str, kill_existing: bool, suspended: bool, env: tuple, stream: bool): 69 | """launch application""" 70 | with DvtSecureSocketProxyService(lockdown=service_provider) as dvt: 71 | process_control = ProcessControl(dvt) 72 | parsed_arguments = shlex.split(arguments) 73 | pid = process_control.launch( 74 | bundle_id=parsed_arguments[0], 75 | arguments=parsed_arguments[1:], 76 | kill_existing=kill_existing, 77 | start_suspended=suspended, 78 | environment=dict(env), 79 | ) 80 | print(f"Process launched with pid {pid}") 81 | while stream: 82 | for output_received in process_control: 83 | logging.getLogger(f"PID:{output_received.pid}").info( 84 | output_received.message.strip() 85 | ) 86 | 87 | 88 | @app.command("kill") 89 | @click.argument("pid", type=click.INT) 90 | @pass_rsd 91 | def app_kill(service_provider, pid: int): 92 | """kill application""" 93 | with DvtSecureSocketProxyService(lockdown=service_provider) as dvt: 94 | process_control = ProcessControl(dvt) 95 | process_control.kill(pid) 96 | 97 | 98 | @app.command("ps") 99 | @click.option('--json/--no-json', default=False) 100 | @click.option("--color/--no-color", default=True, help="print colord") 101 | @pass_rsd 102 | def app_ps(service_provider: LockdownClient, json: bool, color: bool): 103 | """list running processes""" 104 | if service_provider.product_version < "17": 105 | logger.warning('iOS<17 have FD leak, which will cause an error when calling round more than 250 times.') 106 | processes = list(proclist(service_provider)) 107 | processes = [p.model_dump(exclude_none=True) for p in processes if p.isApplication] 108 | if json: 109 | print_json(processes, color) 110 | else: 111 | print_dict_as_table(processes, ["pid", "name", "bundleIdentifier", "realAppName"]) 112 | 113 | 114 | @app.command("foreground") 115 | @pass_rsd 116 | def app_foreground(service_provider: LockdownClient): 117 | """show foreground running app, requires iOS>=17""" 118 | if service_provider.product_version < "17": 119 | raise FatalError("iOS<17 not supported") 120 | for p in proclist(service_provider): 121 | if p.foregroundRunning: 122 | print(p.bundleIdentifier, f"pid:{p.pid}") 123 | 124 | 125 | @app.command("info") 126 | @click.argument("bundle_identifier") 127 | @click.option("--color/--no-color", default=True, help="print colord") 128 | @pass_rsd 129 | def app_info(service_provider: LockdownClient, bundle_identifier: str, color: bool): 130 | with InstallationProxyService(lockdown=service_provider) as iproxy: 131 | apps = iproxy.get_apps(bundle_identifiers=[bundle_identifier]) 132 | print_json(apps, color) 133 | -------------------------------------------------------------------------------- /tidevice3/cli/cli_common.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | """Created on Fri Jan 05 2024 18:10:32 by codeskyblue 5 | """ 6 | from __future__ import annotations 7 | 8 | import collections 9 | from functools import update_wrapper 10 | 11 | import click 12 | from pymobiledevice3.cli.cli_common import USBMUX_OPTION_HELP 13 | 14 | from tidevice3.api import connect_service_provider 15 | 16 | 17 | class OrderedGroup(click.Group): 18 | def __init__(self, name=None, commands=None, *args, **attrs): 19 | super(OrderedGroup, self).__init__(name, commands, *args, **attrs) 20 | #: the registered subcommands by their exported names. 21 | self.commands = commands or collections.OrderedDict() 22 | 23 | def list_commands(self, ctx): 24 | return self.commands 25 | 26 | 27 | @click.group(cls=OrderedGroup, context_settings=dict(help_option_names=["-h", "--help"])) 28 | @click.option("-u", "--udid", default=None, help="udid of device") 29 | @click.option("usbmux_address", "--usbmux", help=USBMUX_OPTION_HELP) 30 | @click.pass_context 31 | def cli(ctx: click.Context, udid: str, usbmux_address: str): 32 | ctx.ensure_object(dict) 33 | ctx.obj['udid'] = udid 34 | ctx.obj['usbmux_address'] = usbmux_address 35 | 36 | 37 | def pass_service_provider(func): 38 | @click.pass_context 39 | def new_func(ctx, *args, **kwargs): 40 | udid = ctx.obj['udid'] 41 | usbmux_address = ctx.obj['usbmux_address'] 42 | service_provider = connect_service_provider(udid, force_usbmux=True, usbmux_address=usbmux_address) 43 | with service_provider: 44 | return ctx.invoke(func, service_provider, *args, **kwargs) 45 | return update_wrapper(new_func, func) 46 | 47 | 48 | def pass_rsd(func): 49 | @click.pass_context 50 | def new_func(ctx, *args, **kwargs): 51 | udid = ctx.obj['udid'] 52 | usbmux_address = ctx.obj['usbmux_address'] 53 | service_provider = connect_service_provider(udid=udid, usbmux_address=usbmux_address) 54 | with service_provider: 55 | return ctx.invoke(func, service_provider, *args, **kwargs) 56 | return update_wrapper(new_func, func) 57 | 58 | 59 | CLI_GROUPS = ["list", "info", "developer", "screenshot", "screenrecord", "install", "fsync", "app", "reboot", "tunneld", "runwda", "relay", "exec"] 60 | for group in CLI_GROUPS: 61 | __import__(f"tidevice3.cli.{group}") 62 | -------------------------------------------------------------------------------- /tidevice3/cli/developer.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | """Created on Fri Jan 05 2024 18:10:01 by codeskyblue 5 | """ 6 | import logging 7 | 8 | from packaging.version import Version 9 | from pymobiledevice3.common import get_home_folder 10 | from pymobiledevice3.exceptions import AlreadyMountedError 11 | from pymobiledevice3.lockdown import LockdownClient 12 | from pymobiledevice3.services.amfi import AmfiService 13 | from pymobiledevice3.services.mobile_image_mounter import auto_mount 14 | 15 | from tidevice3.cli.cli_common import cli, pass_service_provider 16 | 17 | logger = logging.getLogger(__name__) 18 | 19 | 20 | @cli.command(name="developer") 21 | @pass_service_provider 22 | def cli_developer(service_provider: LockdownClient): 23 | """ enable developer mode """ 24 | if Version(service_provider.product_version) >= Version("16"): 25 | if not service_provider.developer_mode_status: 26 | logger.info('enable developer mode') 27 | AmfiService(service_provider).enable_developer_mode() 28 | else: 29 | logger.info('developer mode already enabled') 30 | 31 | try: 32 | xcode = get_home_folder() / 'Xcode.app' 33 | xcode.mkdir(parents=True, exist_ok=True) 34 | auto_mount(service_provider, xcode=xcode) 35 | logger.info('mount developer image') 36 | except AlreadyMountedError: 37 | logger.info('developer image already mounted') 38 | -------------------------------------------------------------------------------- /tidevice3/cli/exec.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | """Created on Mon Jan 15 2024 18:54:35 by codeskyblue 5 | """ 6 | 7 | from __future__ import annotations 8 | 9 | import os 10 | import sys 11 | 12 | import click 13 | 14 | from tidevice3.cli.cli_common import cli 15 | 16 | 17 | @cli.command(name="exec", context_settings={"ignore_unknown_options": True}) 18 | @click.argument("args", nargs=-1) 19 | @click.pass_context 20 | def _exec(ctx: click.Context, args: list[str]): 21 | """ translate to pymobiledevice3 command, eg: t3 exec version """ 22 | args = [sys.executable, '-m', 'pymobiledevice3'] + list(args) 23 | if ctx.obj['udid']: 24 | args += ['--udid', ctx.obj['udid']] 25 | os.execv(args[0], args) 26 | -------------------------------------------------------------------------------- /tidevice3/cli/fsync.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | """Created on Tue Jan 09 2024 14:05:31 by codeskyblue 5 | """ 6 | 7 | from __future__ import annotations 8 | 9 | import datetime 10 | import pathlib 11 | import posixpath 12 | from functools import update_wrapper 13 | from typing import List 14 | 15 | import click 16 | from pydantic import BaseModel 17 | from pymobiledevice3.exceptions import AfcException, AfcFileNotFoundError 18 | from pymobiledevice3.lockdown import LockdownClient 19 | from pymobiledevice3.services.afc import AfcService 20 | from pymobiledevice3.services.house_arrest import HouseArrestService 21 | 22 | from tidevice3.cli.cli_common import cli, pass_service_provider 23 | from tidevice3.exceptions import FatalError 24 | 25 | 26 | def pass_afc(func): 27 | @pass_service_provider 28 | @click.pass_context 29 | def new_func(ctx: click.Context, service_provider: LockdownClient, *args, **kwargs): 30 | if ctx.obj['bundle_id']: 31 | afc = HouseArrestService(lockdown=service_provider, 32 | bundle_id=ctx.obj['bundle_id'], 33 | documents_only=ctx.obj["documents"]) 34 | else: 35 | afc = AfcService(lockdown=service_provider) 36 | 37 | with afc: 38 | try: 39 | return ctx.invoke(func, afc, *args, **kwargs) 40 | except AfcFileNotFoundError as e: 41 | raise FatalError(f"File not found: {e}") 42 | return update_wrapper(new_func, func) 43 | 44 | 45 | @cli.group() 46 | @click.option("-B", "--bundle-id", help="bundle id of app") 47 | @click.option('--documents', is_flag=True) 48 | @click.pass_context 49 | def fsync(ctx: click.Context, bundle_id: str, documents: bool): 50 | """file sync""" 51 | ctx.ensure_object(dict) 52 | ctx.obj["bundle_id"] = bundle_id 53 | ctx.obj["documents"] = documents 54 | 55 | 56 | 57 | @fsync.command(name="ls") 58 | @click.argument("remote_path", required=True) 59 | @click.option('-r', '--recursive', is_flag=True) 60 | @pass_afc 61 | def list(afc: AfcService, remote_path: str, recursive: bool): 62 | """ perform a dirlist rooted at /var/mobile/Media """ 63 | items: List[FileInfo] = [] 64 | for name in afc.listdir(remote_path): 65 | try: 66 | file_info = stat_file(afc, posixpath.join(remote_path, name)) 67 | items.append(file_info) 68 | except AfcException as e: 69 | pass 70 | items.sort(key=lambda x: [x.is_dir(), x.mtime], reverse=True) 71 | for item in items: 72 | name = item.name + ("/" if item.is_dir() else "") 73 | size = byte2humansize(item.size) 74 | click.echo(f"{item.ifmt[2:]}\t{size}\t{name}") 75 | 76 | 77 | @fsync.command(name="rm") 78 | @click.argument("path", default="/") 79 | @pass_afc 80 | def remove(afc: AfcService, path: str): 81 | """ remove a file rooted at /var/mobile/Media """ 82 | afc.rm(path) 83 | click.echo(path) 84 | 85 | 86 | @fsync.command(name="push") 87 | @click.argument('local_file', type=click.File('rb')) 88 | @click.argument('remote_file', type=click.Path(exists=False)) 89 | @pass_afc 90 | def afc_push(afc: AfcService, local_file, remote_file): 91 | """ push local file into /var/mobile/Media """ 92 | finfo = stat_file(afc, remote_file) 93 | if finfo.is_dir(): 94 | remote_file = posixpath.join(remote_file, local_file.name) 95 | afc.set_file_contents(remote_file, local_file.read()) 96 | 97 | 98 | @fsync.command('pull') 99 | @click.option("-f", "--force", is_flag=True, help="force overwrite") 100 | @click.argument('remote_file', type=click.Path(exists=False)) 101 | @click.argument('local_file', default="./", type=click.Path(exists=False, path_type=pathlib.Path)) 102 | @pass_afc 103 | def afc_pull(afc: AfcService, remote_file, local_file: pathlib.Path, force: bool): 104 | """ pull remote file from /var/mobile/Media """ 105 | if local_file.is_dir(): 106 | local_file /= posixpath.basename(remote_file) 107 | 108 | if local_file.exists(): 109 | if not force: 110 | raise click.BadParameter("local_file already exists") 111 | elif not local_file.parent.exists(): 112 | raise click.BadParameter("local_file's parent not exists") 113 | 114 | finfo = stat_file(afc, remote_file) 115 | if finfo.is_dir(): 116 | raise click.BadParameter("remote_file is a directory") 117 | local_file.write_bytes(afc.get_file_contents(remote_file)) 118 | click.echo(f"remote:{remote_file} -> local:{local_file}") 119 | 120 | 121 | class FileInfo(BaseModel): 122 | name: str 123 | size: int 124 | mtime: datetime.datetime 125 | ifmt: str 126 | 127 | def is_dir(self) -> bool: 128 | return self.ifmt == "S_IFDIR" 129 | 130 | 131 | def stat2fileinfo(info: dict) -> FileInfo: 132 | # {'st_size': 326, 'st_blocks': 8, 'st_nlink': 1, 'st_ifmt': 'S_IFREG', 133 | # 'st_mtime': datetime.datetime(2023, 7, 7, 18, 55, 10, 755297), 134 | # 'st_birthtime': datetime.datetime(2023, 7, 7, 18, 55, 10, 754835), 135 | # 'st_name': 'com.apple.ibooks-sync.plist'} 136 | return FileInfo( 137 | name=info["st_name"], 138 | size=info["st_size"], 139 | ifmt=info["st_ifmt"], 140 | mtime=info["st_mtime"], 141 | ) 142 | 143 | 144 | def stat_file(afc: AfcService, path: str) -> FileInfo: 145 | info = afc.stat(path) 146 | info['st_name'] = posixpath.basename(path) 147 | return stat2fileinfo(info) 148 | 149 | 150 | def byte2humansize(num_bytes): 151 | """ 152 | Convert a size in bytes to a more human-readable format. 153 | 154 | :param num_bytes: Size in bytes. 155 | :return: Human-readable size. 156 | """ 157 | if num_bytes < 1024.0: 158 | return f"{num_bytes}" 159 | for unit in ['K', 'M', 'G', 'T', 'P', 'E', 'Z']: 160 | num_bytes /= 1024.0 161 | if num_bytes < 1024.0: 162 | return f"{num_bytes:3.1f}{unit}" 163 | return f"{num_bytes:.1f}Y" 164 | 165 | -------------------------------------------------------------------------------- /tidevice3/cli/info.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | """Created on Tue Feb 27 2024 10:38:24 by codeskyblue 5 | """ 6 | import click 7 | 8 | from pymobiledevice3.cli.cli_common import print_json 9 | from pymobiledevice3.lockdown import LockdownClient 10 | 11 | from tidevice3.cli.cli_common import cli, pass_service_provider 12 | 13 | 14 | @cli.command("info") 15 | @click.option("--color/--no-color", default=True, help="print colord") 16 | @pass_service_provider 17 | def info(service_provider: LockdownClient, color: bool): 18 | """ print device info """ 19 | print_json(service_provider.short_info, color) 20 | 21 | -------------------------------------------------------------------------------- /tidevice3/cli/install.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | """Created on Tue Feb 27 2024 10:05:20 by codeskyblue 5 | """ 6 | 7 | import click 8 | from pymobiledevice3.lockdown import LockdownClient 9 | 10 | from tidevice3.api import app_install 11 | from tidevice3.cli.cli_common import cli, pass_rsd, pass_service_provider 12 | 13 | 14 | @cli.command("install") 15 | @click.argument("path_or_url") 16 | @pass_service_provider 17 | def cli_install(service_provider: LockdownClient, path_or_url: str): 18 | """install given .ipa or url, alias for app install""" 19 | app_install(service_provider, path_or_url) 20 | -------------------------------------------------------------------------------- /tidevice3/cli/list.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | """Created on Fri Jan 05 2024 18:10:01 by codeskyblue 5 | """ 6 | from __future__ import annotations 7 | 8 | import click 9 | from pymobiledevice3.cli.cli_common import print_json 10 | 11 | from tidevice3.api import list_devices 12 | from tidevice3.cli.cli_common import cli 13 | from tidevice3.utils.common import print_dict_as_table 14 | 15 | 16 | @cli.command(name="list") 17 | @click.option("-u", "--usb", is_flag=True, help="show only usb devices") 18 | @click.option("-n", "--network", is_flag=True, help="show only network devices") 19 | @click.option("--json", is_flag=True, help="output as json format") 20 | @click.option("--color/--no-color", default=True, help="print colord") 21 | @click.pass_context 22 | def cli_list(ctx: click.Context, usb: bool, network: bool, json: bool, color: bool): 23 | """list connected devices""" 24 | usbmux_address = ctx.obj["usbmux_address"] 25 | devices = list_devices(usb, network, usbmux_address) 26 | if json: 27 | print_json([d.model_dump() for d in devices], color) 28 | else: 29 | headers = ["Identifier", "DeviceName", "ProductType", "ProductVersion", "ConnectionType"] 30 | print_dict_as_table([d.model_dump() for d in devices], headers) 31 | 32 | 33 | -------------------------------------------------------------------------------- /tidevice3/cli/reboot.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | """Created on Tue Jan 09 2024 19:09:47 by codeskyblue 5 | """ 6 | 7 | from __future__ import annotations 8 | 9 | from pymobiledevice3.lockdown import LockdownClient 10 | from pymobiledevice3.services.diagnostics import DiagnosticsService 11 | 12 | from tidevice3.cli.cli_common import cli, pass_service_provider 13 | 14 | 15 | @cli.command() 16 | @pass_service_provider 17 | def reboot(service_provider: LockdownClient): 18 | """reboot device""" 19 | with DiagnosticsService(service_provider) as diagnostics: 20 | diagnostics.restart() -------------------------------------------------------------------------------- /tidevice3/cli/relay.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | """Created on Mon Mar 18 2024 14:03:09 by codeskyblue 5 | 6 | Ref: https://github.com/doronz88/pymobiledevice3/blob/master/pymobiledevice3/cli/usbmux.py#L32 7 | """ 8 | 9 | import logging 10 | import tempfile 11 | import threading 12 | from functools import partial 13 | 14 | import click 15 | from pymobiledevice3.lockdown import LockdownClient 16 | from pymobiledevice3.tcp_forwarder import UsbmuxTcpForwarder 17 | 18 | from tidevice3.cli.cli_common import cli, pass_service_provider 19 | 20 | logger = logging.getLogger(__name__) 21 | 22 | @cli.command('relay') 23 | @click.argument("local_port", type=click.IntRange(1, 0xffff)) 24 | @click.argument("device_port", type=click.IntRange(1, 0xffff)) 25 | @click.option('-s', '--source', default='127.0.0.1', help="source address for listening socket", show_default=True) 26 | @click.option('-d', '--daemonize', is_flag=True) 27 | @pass_service_provider 28 | def relay(service_provider: LockdownClient, local_port: int, device_port: int, source: str, daemonize: bool): 29 | """Relay tcp connection from local to device""" 30 | listening_event = threading.Event() 31 | forwarder = UsbmuxTcpForwarder(service_provider.udid, device_port, local_port, listening_event=listening_event) 32 | logger.info("Relay from %s:%d to device:%d", source, local_port, device_port) 33 | if daemonize: 34 | try: 35 | from daemonize import Daemonize 36 | except ImportError: 37 | raise NotImplementedError('daemonizing is only supported on unix platforms') 38 | 39 | with tempfile.NamedTemporaryFile('wt') as pid_file: 40 | daemon = Daemonize( 41 | app=f'forwarder {local_port}->{device_port}', 42 | pid=pid_file.name, 43 | action=partial(forwarder.start, source), 44 | verbose=True) 45 | daemon.start() 46 | else: 47 | forwarder.start(source) -------------------------------------------------------------------------------- /tidevice3/cli/runwda.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | """Created on Mon Jan 29 2024 14:15:52 by codeskyblue 5 | """ 6 | 7 | 8 | import logging 9 | import threading 10 | import time 11 | import typing 12 | 13 | import click 14 | from pymobiledevice3.lockdown import LockdownClient 15 | from pymobiledevice3.services.dvt.testmanaged.xcuitest import XCUITestService 16 | from pymobiledevice3.services.installation_proxy import InstallationProxyService 17 | from pymobiledevice3.tcp_forwarder import UsbmuxTcpForwarder 18 | 19 | from tidevice3.cli.cli_common import cli, pass_rsd 20 | 21 | logger = logging.getLogger(__name__) 22 | 23 | 24 | def guess_wda_bundle_id(service_provider: LockdownClient) -> typing.Optional[str]: 25 | app_infos = InstallationProxyService(lockdown=service_provider).get_apps('User') 26 | wda_bundle_ids = [] 27 | for bundle_id in app_infos.keys(): 28 | if bundle_id.endswith(".xctrunner"): 29 | wda_bundle_ids.append(bundle_id) 30 | wda_bundle_ids.sort(key=lambda x: x.find('WebDriverAgentRunner'), reverse=True) 31 | if not wda_bundle_ids: 32 | return None 33 | return wda_bundle_ids[0] 34 | 35 | 36 | @cli.command("runwda") 37 | @click.option('--bundle-id', default=None, help="WebDriverAgent bundle id") 38 | @click.option("--src-port", default=8100, help="WebDriverAgent listen port") 39 | @click.option("--dst-port", default=8100, help="local listen port") 40 | @click.option("--mjpeg-src-port", default=9100, help="MJPEG listen port") 41 | @click.option("--mjpeg-dst-port", default=9100, help="MJPEG local listen port") 42 | @pass_rsd 43 | def cli_runwda(service_provider: LockdownClient, bundle_id: str, src_port: int, dst_port: int, mjpeg_src_port: int, mjpeg_dst_port: int): 44 | """run WebDriverAgent""" 45 | if not bundle_id: 46 | bundle_id = guess_wda_bundle_id(service_provider) 47 | if not bundle_id: 48 | raise ValueError("WebDriverAgent not found") 49 | 50 | def tcp_forwarder(): 51 | logger.info("forwarder started, listen on %s", dst_port) 52 | forwarder = UsbmuxTcpForwarder(service_provider.udid, dst_port, src_port) 53 | forwarder.start() 54 | 55 | def mjpeg_forwarder(): 56 | logger.info("MJPEG forwarder started, listening on :%s", mjpeg_dst_port) 57 | forwarder = UsbmuxTcpForwarder(service_provider.udid, mjpeg_dst_port, mjpeg_src_port) 58 | forwarder.start() 59 | 60 | def xcuitest(): 61 | XCUITestService(service_provider).run(bundle_id, {"MJPEG_SERVER_PORT": mjpeg_src_port, "USE_PORT": src_port}) 62 | 63 | thread0 = threading.Thread(target=mjpeg_forwarder, daemon=True) 64 | thread1 = threading.Thread(target=tcp_forwarder, daemon=True) 65 | thread2 = threading.Thread(target=xcuitest, daemon=True) 66 | thread0.start() 67 | thread1.start() 68 | thread2.start() 69 | 70 | while thread0.is_alive() and thread1.is_alive() and thread2.is_alive(): 71 | time.sleep(0.1) 72 | logger.info("Program exited") 73 | -------------------------------------------------------------------------------- /tidevice3/cli/screenrecord.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | import datetime 5 | import io 6 | import logging 7 | import time 8 | from typing import Any, Iterator 9 | 10 | import click 11 | import imageio.v2 as imageio 12 | import numpy as np 13 | from PIL import Image, ImageDraw, ImageFont 14 | from pymobiledevice3.lockdown import LockdownClient 15 | 16 | from tidevice3.api import iter_screenshot 17 | from tidevice3.cli.cli_common import cli, pass_rsd 18 | 19 | logger = logging.getLogger(__name__) 20 | 21 | 22 | def limit_fps(screenshot_iterator: Iterator[Any], fps: int, debug: bool = False) -> Iterator[Any]: 23 | """ Limit the frame rate of the screenshot iterator to the given FPS """ 24 | frame_duration = 1.0 / fps 25 | next_frame_time = time.time() 26 | last_screenshot = None 27 | 28 | for screenshot in screenshot_iterator: 29 | current_time = time.time() 30 | 31 | if current_time >= next_frame_time: 32 | last_screenshot = screenshot 33 | 34 | # Write frame to video 35 | if debug: 36 | print(".", end="", flush=True) 37 | yield screenshot 38 | 39 | # Schedule next frame 40 | next_frame_time += frame_duration 41 | 42 | # Fill in with the last image if the next frame time is still in the future 43 | while next_frame_time <= current_time: 44 | if last_screenshot is not None: 45 | if debug: 46 | print("o", end="", flush=True) 47 | yield last_screenshot 48 | next_frame_time += frame_duration 49 | 50 | 51 | def draw_text(pil_img: Image.Image, text: str): 52 | """ GPT生成的,效果勉强吧,不太好,总比没有的强 """ 53 | draw = ImageDraw.Draw(pil_img) 54 | font = ImageFont.load_default() 55 | 56 | # Calculate the bounding box of the text 57 | text_bbox = font.getbbox(text) 58 | text_width = text_bbox[2] - text_bbox[0] 59 | text_height = text_bbox[3] - text_bbox[1] 60 | text_x = 20 61 | text_y = 50 62 | 63 | # Define text color and background color 64 | text_color = (255, 0, 0) # Red color 65 | background_color = (128, 128, 128, 128) # Gray color with 50% transparency 66 | 67 | # Create a rectangle background for text 68 | background_rectangle = [ 69 | (text_x - 10, text_y - 10), # Upper left corner 70 | (text_x + text_width + 10, text_y + text_height + 10) # Lower right corner 71 | ] 72 | draw.rectangle(background_rectangle, fill=background_color) 73 | draw.text((text_x, text_y), text, fill=text_color, font=font) 74 | return pil_img 75 | 76 | 77 | def resize_for_ffmpeg(img: Image.Image) -> Image.Image: 78 | """ 79 | 部分机型截图的尺寸不对,所以需要resize,目前发现机型:iPhone x 80 | """ 81 | w, h = img.size 82 | if w % 2 != 0: 83 | w -= 1 84 | if h % 2 != 0: 85 | h -= 1 86 | img = img.crop((0, 0, w, h)) 87 | return img 88 | 89 | 90 | @cli.command("screenrecord") 91 | @click.option("--fps", default=5, help="frame per second") 92 | @click.option("--show-time/--no-show-time", default=True, help="show time on screen") 93 | @click.argument("out") 94 | @pass_rsd 95 | def cli_screenrecord(service_provider: LockdownClient, out: str, fps: int, show_time: bool): 96 | """ screenrecord to mp4 """ 97 | writer = imageio.get_writer(out, fps=fps) 98 | frame_index = 0 99 | try: 100 | for png_data in limit_fps(iter_screenshot(service_provider), fps, debug=True): 101 | pil_img = Image.open(io.BytesIO(png_data)) 102 | pil_img = resize_for_ffmpeg(pil_img) 103 | if show_time: 104 | time_str = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") 105 | draw_text(pil_img, f'Time: {time_str} Frame: {frame_index}') 106 | 107 | writer.append_data(np.array(pil_img)) 108 | frame_index += 1 109 | except KeyboardInterrupt: 110 | print("") 111 | finally: 112 | writer.close() 113 | logger.info("screenrecord saved to %s", out) 114 | -------------------------------------------------------------------------------- /tidevice3/cli/screenshot.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | """Created on Mon Jan 08 2024 14:50:21 by codeskyblue 5 | """ 6 | 7 | import logging 8 | import typing 9 | 10 | import click 11 | from pymobiledevice3.lockdown import LockdownClient 12 | 13 | from tidevice3.api import screenshot, screenshot_png 14 | from tidevice3.cli.cli_common import cli, pass_rsd 15 | 16 | logger = logging.getLogger(__name__) 17 | 18 | 19 | @cli.command("screenshot") 20 | @click.argument("out", type=click.File("wb")) 21 | @pass_rsd 22 | def cli_screenshot(service_provider: LockdownClient, out: typing.BinaryIO): 23 | """get device screenshot""" 24 | if out.name.endswith(".png"): 25 | out.write(screenshot_png(service_provider)) 26 | else: 27 | im = screenshot(service_provider) 28 | im.save(out) 29 | -------------------------------------------------------------------------------- /tidevice3/cli/tunneld.py: -------------------------------------------------------------------------------- 1 | # For iOS 17 auto start-tunnel 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | import os 7 | import shlex 8 | import shutil 9 | import signal 10 | import subprocess 11 | import sys 12 | import threading 13 | import time 14 | from typing import List, Mapping, NamedTuple, Tuple 15 | 16 | import click 17 | import fastapi 18 | import uvicorn 19 | from fastapi import FastAPI 20 | from packaging.version import Version 21 | from pymobiledevice3.exceptions import MuxException 22 | from pymobiledevice3.osu.os_utils import OsUtils 23 | 24 | from tidevice3.cli.cli_common import cli 25 | from tidevice3.cli.list import list_devices 26 | from tidevice3.utils.common import threadsafe_function 27 | 28 | logger = logging.getLogger(__name__) 29 | os_utils = OsUtils.create() 30 | 31 | 32 | class Address(NamedTuple): 33 | ip: str 34 | port: int 35 | 36 | 37 | def get_connected_devices() -> list[str]: 38 | """return list of udid""" 39 | try: 40 | devices = list_devices(usb=True, network=False) 41 | except MuxException as e: 42 | logger.error("list_devices failed: %s", e) 43 | return [] 44 | return [d.Identifier for d in devices if Version(d.ProductVersion) >= Version("17")] 45 | 46 | 47 | def get_need_lockdown_devices() -> list[str]: 48 | """return list of udid""" 49 | try: 50 | devices = list_devices(usb=True, network=False) 51 | except MuxException as e: 52 | logger.error("list_devices failed: %s", e) 53 | return [] 54 | return [d.Identifier for d in devices if Version(d.ProductVersion) >= Version("17.4")] 55 | 56 | 57 | def guess_pymobiledevice3_cmd() -> List[str]: 58 | pmd3path = shutil.which("pymobiledevice3") 59 | if not pmd3path: 60 | return [sys.executable, '-m', 'pymobiledevice3'] 61 | return [pmd3path] 62 | 63 | 64 | class TunnelError(Exception): 65 | pass 66 | 67 | 68 | @threadsafe_function 69 | def start_tunnel(pmd3_path: List[str], udid: str) -> Tuple[Address, subprocess.Popen]: 70 | """ 71 | Start program, should be killed when the main program quit 72 | 73 | Raises: 74 | TunnelError 75 | """ 76 | # cmd = ["bash", "-c", "echo ::1 1234; sleep 10001"] 77 | log_prefix = f"[{udid}]" 78 | start_tunnel_cmd = "remote" 79 | if udid in get_need_lockdown_devices(): 80 | start_tunnel_cmd = "lockdown" 81 | cmdargs = pmd3_path + f"{start_tunnel_cmd} start-tunnel --script-mode --udid {udid}".split() 82 | logger.info("%s cmd: %s", log_prefix, shlex.join(cmdargs)) 83 | process = subprocess.Popen( 84 | cmdargs, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE 85 | ) 86 | output_str = process.stdout.readline().decode("utf-8").strip() 87 | if output_str == "": 88 | raise TunnelError("pmd3 start-tunnel empty response") 89 | address, port_str = output_str.split() 90 | port = int(port_str) 91 | logger.info("%s tunnel address: %s", log_prefix, [address, port]) 92 | process.stdout = subprocess.DEVNULL # maybe not working 93 | return Address(address, port), process 94 | 95 | 96 | class DeviceManager: 97 | def __init__(self): 98 | self.active_monitors: Mapping[str, subprocess.Popen] = {} 99 | self.running = True 100 | self.addresses: Mapping[str, Address] = {} 101 | self.pmd3_cmd = ["pymobiledevice3"] 102 | 103 | def update_devices(self): 104 | current_devices = set(get_connected_devices()) 105 | active_udids = set(self.active_monitors.keys()) 106 | 107 | # Start monitors for new devices 108 | for udid in current_devices - active_udids: 109 | self.active_monitors[udid] = None 110 | try: 111 | threading.Thread(name=f"{udid} keeper", 112 | target=self._start_tunnel_keeper, 113 | args=(udid,), 114 | daemon=True).start() 115 | except Exception as e: 116 | logger.error("udid: %s start-tunnel failed: %s", udid, e) 117 | 118 | # Stop monitors for disconnected devices 119 | for udid in active_udids - current_devices: 120 | logger.info("udid: %s quit, terminate related process", udid) 121 | process = self.active_monitors[udid] 122 | if process: 123 | process.terminate() 124 | self.active_monitors.pop(udid, None) 125 | self.addresses.pop(udid, None) 126 | 127 | def _start_tunnel_keeper(self, udid: str): 128 | while udid in self.active_monitors: 129 | try: 130 | addr, process = start_tunnel(self.pmd3_cmd, udid) 131 | self.active_monitors[udid] = process 132 | self.addresses[udid] = addr 133 | self._wait_process_exit(process, udid) 134 | except TunnelError: 135 | logger.exception("udid: %s start-tunnel failed", udid) 136 | time.sleep(3) 137 | 138 | def _wait_process_exit(self, process: subprocess.Popen, udid: str): 139 | while True: 140 | try: 141 | process.wait(1.0) 142 | self.addresses.pop(udid, None) 143 | logger.warning("udid: %s process exit with code: %s", udid, process.returncode) 144 | break 145 | except subprocess.TimeoutExpired: 146 | continue 147 | 148 | def shutdown(self): 149 | logger.info("terminate all processes") 150 | for process in self.active_monitors.values(): 151 | if process: 152 | process.terminate() 153 | self.running = False 154 | 155 | def run_forever(self): 156 | while self.running: 157 | try: 158 | self.update_devices() 159 | except Exception as e: 160 | logger.exception("update_devices failed: %s", e) 161 | time.sleep(1) 162 | 163 | 164 | @cli.command(context_settings={"show_default": True}) 165 | @click.option( 166 | "--pmd3-path", 167 | "pmd3_path", 168 | help="pymobiledevice3 cli path", 169 | default=None, 170 | ) 171 | @click.option("--port", "port", help="listen port", default=5555) 172 | def tunneld(pmd3_path: str, port: int): 173 | """start server for iOS >= 17 auto start-tunnel, function like pymobiledevice3 remote tunneld""" 174 | if not os_utils.is_admin: 175 | logger.error("Please run as root(Mac) or administrator(Windows)") 176 | sys.exit(1) 177 | 178 | manager = DeviceManager() 179 | app = FastAPI() 180 | 181 | @app.get("/") 182 | def get_devices(): 183 | return manager.addresses 184 | 185 | @app.get("/shutdown") 186 | def shutdown(): 187 | manager.shutdown() 188 | os.kill(os.getpid(), signal.SIGINT) 189 | return fastapi.Response(status_code=200, content="Server shutting down...") 190 | 191 | if pmd3_path is None: 192 | manager.pmd3_cmd = guess_pymobiledevice3_cmd() 193 | else: 194 | manager.pmd3_cmd = [pmd3_path] 195 | 196 | threading.Thread( 197 | target=manager.run_forever, daemon=True, name="device_manager" 198 | ).start() 199 | try: 200 | uvicorn.run(app, host="0.0.0.0", port=port) 201 | finally: 202 | logger.info("Shutting down...") 203 | manager.shutdown() 204 | -------------------------------------------------------------------------------- /tidevice3/exceptions.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | """Created on Fri Jan 05 2024 23:11:55 by codeskyblue 5 | """ 6 | 7 | class BaseException(Exception): 8 | pass 9 | 10 | class DownloadError(BaseException): 11 | pass 12 | 13 | class FatalError(BaseException): 14 | pass -------------------------------------------------------------------------------- /tidevice3/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codeskyblue/tidevice3/d83c345b2c5216c82baafb8e3c09574f8020ff00/tidevice3/utils/__init__.py -------------------------------------------------------------------------------- /tidevice3/utils/common.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | """Created on Tue Jan 09 2024 16:56:42 by codeskyblue 5 | """ 6 | from __future__ import annotations 7 | 8 | import functools 9 | import threading 10 | import unicodedata 11 | 12 | 13 | def threadsafe_function(fn): 14 | """ 15 | A decorator to make thread-safe functions by using a lock. 16 | 17 | Args: 18 | fn (function): The function to be decorated. 19 | 20 | Returns: 21 | function: The decorated thread-safe function. 22 | """ 23 | lock = threading.Lock() 24 | 25 | @functools.wraps(fn) 26 | def wrapper(*args, **kwargs): 27 | with lock: 28 | return fn(*args, **kwargs) 29 | 30 | return wrapper 31 | 32 | 33 | def unicode_len(s: str) -> int: 34 | """ printable length of string """ 35 | length = 0 36 | for char in s: 37 | if unicodedata.east_asian_width(char) in ('F', 'W'): 38 | length += 2 39 | else: 40 | length += 1 41 | return length 42 | 43 | 44 | def ljust(s, length: int): 45 | s = str(s) 46 | return s + ' ' * (length - unicode_len(s)) 47 | 48 | 49 | def print_dict_as_table(dict_values: list[dict], headers: list[str], sep: str = " "): 50 | """ 51 | Output as format 52 | ---------------------------------------- 53 | Identifier DeviceName ProductType ProductVersion ConnectionType 54 | 00000000-1234567890123456 MIMM iPhone13,3 17.2 USB 55 | """ 56 | header_with_lengths = [] 57 | for header in headers: 58 | if dict_values: 59 | max_len = max([unicode_len(str(item.get(header, ""))) for item in dict_values]) 60 | else: 61 | max_len = 0 62 | header_with_lengths.append((header, max(max_len, unicode_len(header)))) 63 | rows = [] 64 | # print header 65 | for header, _len in header_with_lengths: 66 | rows.append(ljust(header, _len)) 67 | print(sep.join(rows).rstrip()) 68 | # print rows 69 | for item in dict_values: 70 | rows = [] 71 | for header, _len in header_with_lengths: 72 | rows.append(ljust(item.get(header, ""), _len)) 73 | print(sep.join(rows).rstrip()) 74 | -------------------------------------------------------------------------------- /tidevice3/utils/download.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | """Created on Fri Jan 05 2024 23:09:36 by codeskyblue 5 | """ 6 | 7 | from __future__ import annotations 8 | 9 | import re 10 | 11 | __all__ = ["download_file", "is_hyperlink", "DownloadError"] 12 | 13 | import base64 14 | import hashlib 15 | import logging 16 | import os 17 | import pathlib 18 | import shutil 19 | import time 20 | from typing import Optional, Union 21 | 22 | import requests 23 | from requests.structures import CaseInsensitiveDict 24 | 25 | from tidevice3.exceptions import DownloadError 26 | 27 | logger = logging.getLogger(__name__) 28 | 29 | CACHE_DOWNLOAD_SUFFIX = ".t3-download-cache" 30 | DEFAULT_DOWNLOAD_TIMEOUT = 600 # 10 minutes 31 | 32 | StrOrPathLike = Union[str, pathlib.Path] 33 | 34 | 35 | def md5sum(filepath: StrOrPathLike) -> str: 36 | """return md5sum of given file""" 37 | m = hashlib.md5() 38 | with open(filepath, "rb") as f: 39 | while True: 40 | data = f.read(1<<20) 41 | if not data: 42 | break 43 | m.update(data) 44 | return m.hexdigest() 45 | 46 | 47 | class RemoteFileInfo: 48 | content_md5: str | None = None 49 | content_length: int = 0 50 | accept_ranges: bool = False 51 | 52 | 53 | def get_remote_file_info(headers: CaseInsensitiveDict) -> RemoteFileInfo: 54 | """ 55 | Get remote file info, such as content-length, content-md5, accept-ranges 56 | """ 57 | info = RemoteFileInfo() 58 | info.content_length = int(headers.get("content-length", 0)) 59 | info.accept_ranges = headers.get("accept-ranges") == "bytes" 60 | md5_base64 = headers.get("content-md5") 61 | if md5_base64: 62 | try: 63 | content_md5 = base64.b64decode(md5_base64).hex() 64 | if len(content_md5) == 32: 65 | info.content_md5 = content_md5 66 | except: 67 | pass 68 | return info 69 | 70 | 71 | def check_if_already_downloaded( 72 | filepath: pathlib.Path, remote_file_info: RemoteFileInfo 73 | ) -> bool: 74 | if not filepath.exists(): 75 | return False 76 | if filepath.stat().st_size != remote_file_info.content_length: 77 | return False 78 | if remote_file_info.content_md5: 79 | if md5sum(filepath) != remote_file_info.content_md5: 80 | return False 81 | return True 82 | 83 | 84 | def update_file_mtime(filepath: pathlib.Path): 85 | """update file mtime to avoid flie being deleted by clean script""" 86 | _atime, _mtime = (time.time(), time.time()) 87 | os.utime(filepath, (_atime, _mtime)) 88 | 89 | 90 | def download_file_from_range( 91 | url: str, filepath: pathlib.Path, bytes_start: int, timeout: float 92 | ): 93 | r = make_request_get_stream( 94 | url, timeout, headers={"Range": f"bytes={bytes_start}-"} 95 | ) 96 | with filepath.open("ab") as f: 97 | shutil.copyfileobj(r.raw, f) 98 | 99 | 100 | def get_bytes_start( 101 | tmpfpath: pathlib.Path, remote_file_info: RemoteFileInfo 102 | ) -> Optional[int]: 103 | if ( 104 | remote_file_info.accept_ranges 105 | and tmpfpath.exists() 106 | and tmpfpath.stat().st_mtime > time.time() - 60 107 | ): 108 | return tmpfpath.stat().st_size 109 | 110 | 111 | def make_request_get_stream( 112 | url: str, timeout: float, headers: dict = None 113 | ) -> requests.Response: 114 | r = requests.get(url, stream=True, timeout=timeout, headers=headers) 115 | try: 116 | r.raise_for_status() 117 | except requests.exceptions.HTTPError as e: 118 | raise DownloadError(e, url) 119 | return r 120 | 121 | 122 | def is_hyperlink(url: str) -> bool: 123 | return url.startswith("http://") or url.startswith("https://") 124 | 125 | 126 | def guess_filename_from_url(url: str, headers: CaseInsensitiveDict = {}) -> str: 127 | """ 128 | Guess filename from url and headers 129 | """ 130 | filename = url.split("/")[-1] 131 | filename = re.sub(r"\?.*$", "", filename) 132 | if "content-disposition" in headers: 133 | for part in headers["content-disposition"].split(";"): 134 | if part.strip().startswith("filename="): 135 | filename = part.split("=")[-1].strip('"') 136 | return filename 137 | 138 | 139 | def download_file( 140 | url: str, filepath: StrOrPathLike | None = None, timeout: float = DEFAULT_DOWNLOAD_TIMEOUT 141 | ) -> pathlib.Path: 142 | """ 143 | Download file from given url to filepath 144 | 145 | :param url: url to download 146 | :param filepath: local file path 147 | :param timeout: timeout in seconds 148 | 149 | raise DownloadError if download failed 150 | """ 151 | if not is_hyperlink(url): 152 | raise DownloadError("only support http/https url", url) 153 | 154 | logger.info("download from url: %s", url) 155 | r = make_request_get_stream(url, timeout) 156 | 157 | if filepath is None: 158 | filepath = guess_filename_from_url(url, r.headers) 159 | filepath = pathlib.Path(filepath) 160 | tmpfpath = pathlib.Path( 161 | str(filepath) + CACHE_DOWNLOAD_SUFFIX 162 | ) # 文件先下载到这里,等检查完后再Move过去 163 | 164 | remote_file_info = get_remote_file_info(r.headers) 165 | if check_if_already_downloaded(filepath, remote_file_info): 166 | logger.debug("use cached asset: %s", filepath) 167 | update_file_mtime(filepath) 168 | return filepath 169 | 170 | bytes_start = get_bytes_start(tmpfpath, remote_file_info) 171 | if bytes_start: 172 | logger.debug("resume download from %s", bytes_start) 173 | download_file_from_range(url, tmpfpath, bytes_start, timeout) 174 | else: 175 | with tmpfpath.open("wb") as f: 176 | shutil.copyfileobj(r.raw, f) 177 | if not check_if_already_downloaded(tmpfpath, remote_file_info): 178 | tmpfpath.unlink(missing_ok=True) 179 | raise DownloadError("download file not complete", url) 180 | os.rename(tmpfpath, filepath) 181 | return filepath 182 | --------------------------------------------------------------------------------