uiautodev 0.12.0__tar.gz → 0.13.3__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {uiautodev-0.12.0 → uiautodev-0.13.3}/PKG-INFO +27 -3
- {uiautodev-0.12.0 → uiautodev-0.13.3}/README.md +26 -3
- {uiautodev-0.12.0 → uiautodev-0.13.3}/pyproject.toml +1 -1
- {uiautodev-0.12.0 → uiautodev-0.13.3}/uiautodev/__init__.py +1 -1
- {uiautodev-0.12.0 → uiautodev-0.13.3}/uiautodev/app.py +36 -27
- {uiautodev-0.12.0 → uiautodev-0.13.3}/uiautodev/cli.py +35 -11
- {uiautodev-0.12.0 → uiautodev-0.13.3}/uiautodev/command_proxy.py +2 -2
- {uiautodev-0.12.0 → uiautodev-0.13.3}/uiautodev/common.py +4 -3
- uiautodev-0.13.3/uiautodev/driver/android/__init__.py +2 -0
- uiautodev-0.12.0/uiautodev/driver/android.py → uiautodev-0.13.3/uiautodev/driver/android/adb_driver.py +29 -72
- uiautodev-0.13.3/uiautodev/driver/android/common.py +61 -0
- uiautodev-0.13.3/uiautodev/driver/android/u2_driver.py +68 -0
- {uiautodev-0.12.0 → uiautodev-0.13.3}/uiautodev/driver/base_driver.py +0 -2
- {uiautodev-0.12.0 → uiautodev-0.13.3}/uiautodev/driver/harmony.py +1 -1
- {uiautodev-0.12.0 → uiautodev-0.13.3}/uiautodev/provider.py +7 -5
- {uiautodev-0.12.0 → uiautodev-0.13.3}/uiautodev/router/android.py +3 -3
- {uiautodev-0.12.0 → uiautodev-0.13.3}/uiautodev/router/device.py +1 -1
- uiautodev-0.13.3/uiautodev/router/proxy.py +178 -0
- uiautodev-0.12.0/uiautodev/router/proxy.py +0 -57
- {uiautodev-0.12.0 → uiautodev-0.13.3}/LICENSE +0 -0
- {uiautodev-0.12.0 → uiautodev-0.13.3}/uiautodev/__main__.py +0 -0
- {uiautodev-0.12.0 → uiautodev-0.13.3}/uiautodev/appium_proxy.py +0 -0
- {uiautodev-0.12.0 → uiautodev-0.13.3}/uiautodev/binaries/scrcpy_server.jar +0 -0
- {uiautodev-0.12.0 → uiautodev-0.13.3}/uiautodev/case.py +0 -0
- {uiautodev-0.12.0 → uiautodev-0.13.3}/uiautodev/command_types.py +0 -0
- {uiautodev-0.12.0 → uiautodev-0.13.3}/uiautodev/driver/appium.py +0 -0
- {uiautodev-0.12.0 → uiautodev-0.13.3}/uiautodev/driver/ios.py +0 -0
- {uiautodev-0.12.0 → uiautodev-0.13.3}/uiautodev/driver/mock.py +0 -0
- {uiautodev-0.12.0 → uiautodev-0.13.3}/uiautodev/driver/testdata/layout.json +0 -0
- {uiautodev-0.12.0 → uiautodev-0.13.3}/uiautodev/driver/udt/appium-uiautomator2-v5.12.4-light.apk +0 -0
- {uiautodev-0.12.0 → uiautodev-0.13.3}/uiautodev/driver/udt/udt.py +0 -0
- {uiautodev-0.12.0 → uiautodev-0.13.3}/uiautodev/exceptions.py +0 -0
- {uiautodev-0.12.0 → uiautodev-0.13.3}/uiautodev/model.py +0 -0
- {uiautodev-0.12.0 → uiautodev-0.13.3}/uiautodev/remote/android_input.py +0 -0
- {uiautodev-0.12.0 → uiautodev-0.13.3}/uiautodev/remote/harmony_mjpeg.py +0 -0
- {uiautodev-0.12.0 → uiautodev-0.13.3}/uiautodev/remote/keycode.py +0 -0
- {uiautodev-0.12.0 → uiautodev-0.13.3}/uiautodev/remote/scrcpy.py +0 -0
- {uiautodev-0.12.0 → uiautodev-0.13.3}/uiautodev/remote/touch_controller.py +0 -0
- {uiautodev-0.12.0 → uiautodev-0.13.3}/uiautodev/router/xml.py +0 -0
- {uiautodev-0.12.0 → uiautodev-0.13.3}/uiautodev/static/demo.html +0 -0
- {uiautodev-0.12.0 → uiautodev-0.13.3}/uiautodev/utils/common.py +0 -0
- {uiautodev-0.12.0 → uiautodev-0.13.3}/uiautodev/utils/envutils.py +0 -0
- {uiautodev-0.12.0 → uiautodev-0.13.3}/uiautodev/utils/exceptions.py +0 -0
- {uiautodev-0.12.0 → uiautodev-0.13.3}/uiautodev/utils/usbmux.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: uiautodev
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.13.3
|
|
4
4
|
Summary: Mobile UI Automation, include UI hierarchy inspector, script recorder
|
|
5
5
|
License: MIT
|
|
6
6
|
License-File: LICENSE
|
|
@@ -64,12 +64,12 @@ Options:
|
|
|
64
64
|
-h, --help Show this message and exit.
|
|
65
65
|
|
|
66
66
|
Commands:
|
|
67
|
+
server start uiauto.dev local server [Default]
|
|
67
68
|
android COMMAND: tap, tapElement, installApp, currentApp,...
|
|
68
|
-
appium COMMAND: tap, tapElement, installApp, currentApp,...
|
|
69
69
|
ios COMMAND: tap, tapElement, installApp, currentApp,...
|
|
70
70
|
self-update Update uiautodev to latest version
|
|
71
|
-
server start uiauto.dev local server [Default]
|
|
72
71
|
version Print version
|
|
72
|
+
shutdown Shutdown server
|
|
73
73
|
```
|
|
74
74
|
|
|
75
75
|
```bash
|
|
@@ -77,6 +77,29 @@ Commands:
|
|
|
77
77
|
uiauto.dev
|
|
78
78
|
```
|
|
79
79
|
|
|
80
|
+
# Environment
|
|
81
|
+
|
|
82
|
+
```sh
|
|
83
|
+
# Default driver is uiautomator2
|
|
84
|
+
# Set the environment variable below to switch to adb driver
|
|
85
|
+
export UIAUTODEV_USE_ADB_DRIVER=1
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
# Offline mode
|
|
89
|
+
|
|
90
|
+
Start with
|
|
91
|
+
|
|
92
|
+
```sh
|
|
93
|
+
uiautodev server --offline
|
|
94
|
+
|
|
95
|
+
# Specify server url (optional)
|
|
96
|
+
uiautodev server --offline --server-url https://uiauto.dev
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Visit <http://localhost:20242> once, and then disconnecting from the internet will not affect usage.
|
|
100
|
+
|
|
101
|
+
> All frontend resources will be saved to cache/ dir.
|
|
102
|
+
|
|
80
103
|
# DEVELOP
|
|
81
104
|
|
|
82
105
|
see [DEVELOP.md](DEVELOP.md)
|
|
@@ -87,3 +110,4 @@ see [DEVELOP.md](DEVELOP.md)
|
|
|
87
110
|
|
|
88
111
|
# LICENSE
|
|
89
112
|
[MIT](LICENSE)
|
|
113
|
+
|
|
@@ -28,12 +28,12 @@ Options:
|
|
|
28
28
|
-h, --help Show this message and exit.
|
|
29
29
|
|
|
30
30
|
Commands:
|
|
31
|
+
server start uiauto.dev local server [Default]
|
|
31
32
|
android COMMAND: tap, tapElement, installApp, currentApp,...
|
|
32
|
-
appium COMMAND: tap, tapElement, installApp, currentApp,...
|
|
33
33
|
ios COMMAND: tap, tapElement, installApp, currentApp,...
|
|
34
34
|
self-update Update uiautodev to latest version
|
|
35
|
-
server start uiauto.dev local server [Default]
|
|
36
35
|
version Print version
|
|
36
|
+
shutdown Shutdown server
|
|
37
37
|
```
|
|
38
38
|
|
|
39
39
|
```bash
|
|
@@ -41,6 +41,29 @@ Commands:
|
|
|
41
41
|
uiauto.dev
|
|
42
42
|
```
|
|
43
43
|
|
|
44
|
+
# Environment
|
|
45
|
+
|
|
46
|
+
```sh
|
|
47
|
+
# Default driver is uiautomator2
|
|
48
|
+
# Set the environment variable below to switch to adb driver
|
|
49
|
+
export UIAUTODEV_USE_ADB_DRIVER=1
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
# Offline mode
|
|
53
|
+
|
|
54
|
+
Start with
|
|
55
|
+
|
|
56
|
+
```sh
|
|
57
|
+
uiautodev server --offline
|
|
58
|
+
|
|
59
|
+
# Specify server url (optional)
|
|
60
|
+
uiautodev server --offline --server-url https://uiauto.dev
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Visit <http://localhost:20242> once, and then disconnecting from the internet will not affect usage.
|
|
64
|
+
|
|
65
|
+
> All frontend resources will be saved to cache/ dir.
|
|
66
|
+
|
|
44
67
|
# DEVELOP
|
|
45
68
|
|
|
46
69
|
see [DEVELOP.md](DEVELOP.md)
|
|
@@ -50,4 +73,4 @@ see [DEVELOP.md](DEVELOP.md)
|
|
|
50
73
|
- https://docs.tangoapp.dev/scrcpy/video/web-codecs/ H264解码器
|
|
51
74
|
|
|
52
75
|
# LICENSE
|
|
53
|
-
[MIT](LICENSE)
|
|
76
|
+
[MIT](LICENSE)
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
# -*- coding: utf-8 -*-
|
|
3
3
|
|
|
4
|
-
"""Created on Sun Feb 18 2024 13:48:55 by codeskyblue
|
|
5
|
-
"""
|
|
4
|
+
"""Created on Sun Feb 18 2024 13:48:55 by codeskyblue"""
|
|
6
5
|
|
|
7
6
|
import logging
|
|
8
7
|
import os
|
|
@@ -12,21 +11,23 @@ from pathlib import Path
|
|
|
12
11
|
from typing import Dict, List
|
|
13
12
|
|
|
14
13
|
import adbutils
|
|
14
|
+
import httpx
|
|
15
15
|
import uvicorn
|
|
16
|
-
from fastapi import FastAPI, File, UploadFile, WebSocket
|
|
16
|
+
from fastapi import FastAPI, File, Request, Response, UploadFile, WebSocket
|
|
17
17
|
from fastapi.middleware.cors import CORSMiddleware
|
|
18
18
|
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
|
|
19
19
|
from pydantic import BaseModel
|
|
20
|
-
from rich.logging import RichHandler
|
|
21
20
|
from starlette.websockets import WebSocketDisconnect
|
|
22
21
|
|
|
23
22
|
from uiautodev import __version__
|
|
24
23
|
from uiautodev.common import convert_bytes_to_image, get_webpage_url, ocr_image
|
|
24
|
+
from uiautodev.driver.android import ADBAndroidDriver, U2AndroidDriver
|
|
25
25
|
from uiautodev.model import Node
|
|
26
26
|
from uiautodev.provider import AndroidProvider, HarmonyProvider, IOSProvider, MockProvider
|
|
27
27
|
from uiautodev.remote.scrcpy import ScrcpyServer
|
|
28
28
|
from uiautodev.router.android import router as android_device_router
|
|
29
29
|
from uiautodev.router.device import make_router
|
|
30
|
+
from uiautodev.router.proxy import make_reverse_proxy
|
|
30
31
|
from uiautodev.router.proxy import router as proxy_router
|
|
31
32
|
from uiautodev.router.xml import router as xml_router
|
|
32
33
|
from uiautodev.utils.envutils import Environment
|
|
@@ -35,16 +36,6 @@ logger = logging.getLogger(__name__)
|
|
|
35
36
|
|
|
36
37
|
app = FastAPI()
|
|
37
38
|
|
|
38
|
-
|
|
39
|
-
def enable_logger_to_console():
|
|
40
|
-
_logger = logging.getLogger("uiautodev")
|
|
41
|
-
_logger.setLevel(logging.DEBUG)
|
|
42
|
-
_logger.addHandler(RichHandler(enable_link_path=False))
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
if os.getenv("UIAUTODEV_DEBUG"):
|
|
46
|
-
enable_logger_to_console()
|
|
47
|
-
|
|
48
39
|
app.add_middleware(
|
|
49
40
|
CORSMiddleware,
|
|
50
41
|
allow_origins=["*"],
|
|
@@ -53,7 +44,12 @@ app.add_middleware(
|
|
|
53
44
|
allow_headers=["*"],
|
|
54
45
|
)
|
|
55
46
|
|
|
56
|
-
|
|
47
|
+
android_default_driver = U2AndroidDriver
|
|
48
|
+
if os.getenv("UIAUTODEV_USE_ADB_DRIVER") in ("1", "true", "True"):
|
|
49
|
+
android_default_driver = ADBAndroidDriver
|
|
50
|
+
|
|
51
|
+
android_router = make_router(AndroidProvider(driver_class=android_default_driver))
|
|
52
|
+
android_adb_router = make_router(AndroidProvider(driver_class=ADBAndroidDriver))
|
|
57
53
|
ios_router = make_router(IOSProvider())
|
|
58
54
|
harmony_router = make_router(HarmonyProvider())
|
|
59
55
|
mock_router = make_router(MockProvider())
|
|
@@ -66,25 +62,30 @@ if Environment.UIAUTODEV_MOCK:
|
|
|
66
62
|
app.include_router(mock_router, prefix="/api/harmony", tags=["mock"])
|
|
67
63
|
else:
|
|
68
64
|
app.include_router(android_router, prefix="/api/android", tags=["android"])
|
|
65
|
+
app.include_router(android_adb_router, prefix="/api/android_adb", tags=["android_adb"])
|
|
69
66
|
app.include_router(ios_router, prefix="/api/ios", tags=["ios"])
|
|
70
67
|
app.include_router(harmony_router, prefix="/api/harmony", tags=["harmony"])
|
|
71
68
|
|
|
72
69
|
app.include_router(xml_router, prefix="/api/xml", tags=["xml"])
|
|
73
70
|
app.include_router(android_device_router, prefix="/api/android", tags=["android"])
|
|
74
|
-
app.include_router(proxy_router,
|
|
71
|
+
app.include_router(proxy_router, tags=["proxy"])
|
|
75
72
|
|
|
76
|
-
|
|
73
|
+
|
|
74
|
+
@app.get("/api/{platform}/features")
|
|
77
75
|
def get_features(platform: str) -> Dict[str, bool]:
|
|
78
76
|
"""Get features supported by the specified platform"""
|
|
79
77
|
features = {}
|
|
80
78
|
# 获取所有带有指定平台tag的路由
|
|
79
|
+
from starlette.routing import Route
|
|
80
|
+
|
|
81
81
|
for route in app.routes:
|
|
82
|
-
|
|
83
|
-
|
|
82
|
+
_route: Route = route # type: ignore
|
|
83
|
+
if hasattr(_route, "tags") and platform in _route.tags:
|
|
84
|
+
if _route.path.startswith(f"/api/{platform}/{{serial}}/"):
|
|
84
85
|
# 提取特性名称
|
|
85
|
-
parts =
|
|
86
|
+
parts = _route.path.split("/")
|
|
86
87
|
feature_name = parts[-1]
|
|
87
|
-
if not feature_name.startswith(
|
|
88
|
+
if not feature_name.startswith("{"):
|
|
88
89
|
features[feature_name] = True
|
|
89
90
|
return features
|
|
90
91
|
|
|
@@ -111,7 +112,7 @@ def info() -> InfoResponse:
|
|
|
111
112
|
)
|
|
112
113
|
|
|
113
114
|
|
|
114
|
-
@app.post(
|
|
115
|
+
@app.post("/api/ocr_image")
|
|
115
116
|
async def _ocr_image(file: UploadFile = File(...)) -> List[Node]:
|
|
116
117
|
"""OCR an image"""
|
|
117
118
|
image_data = await file.read()
|
|
@@ -134,14 +135,19 @@ def demo():
|
|
|
134
135
|
return FileResponse(static_dir / "demo.html")
|
|
135
136
|
|
|
136
137
|
|
|
137
|
-
@app.get("/")
|
|
138
|
+
@app.get("/redirect")
|
|
138
139
|
def index_redirect():
|
|
139
|
-
"""
|
|
140
|
+
"""redirect to official homepage"""
|
|
140
141
|
url = get_webpage_url()
|
|
141
142
|
logger.debug("redirect to %s", url)
|
|
142
143
|
return RedirectResponse(url)
|
|
143
144
|
|
|
144
145
|
|
|
146
|
+
@app.get("/api/auth/me")
|
|
147
|
+
def mock_auth_me():
|
|
148
|
+
# 401 {"detail":"Authentication required"}
|
|
149
|
+
return JSONResponse(status_code=401, content={"detail": "Authentication required"})
|
|
150
|
+
|
|
145
151
|
@app.websocket("/ws/android/scrcpy/{serial}")
|
|
146
152
|
async def handle_android_ws(websocket: WebSocket, serial: str):
|
|
147
153
|
"""
|
|
@@ -169,9 +175,10 @@ def get_harmony_mjpeg_server(serial: str):
|
|
|
169
175
|
from hypium import UiDriver
|
|
170
176
|
|
|
171
177
|
from uiautodev.remote.harmony_mjpeg import HarmonyMjpegServer
|
|
178
|
+
|
|
172
179
|
driver = UiDriver.connect(device_sn=serial)
|
|
173
180
|
logger.info("create harmony mjpeg server for %s", serial)
|
|
174
|
-
logger.info(f
|
|
181
|
+
logger.info(f"device wake_up_display: {driver.wake_up_display()}")
|
|
175
182
|
return HarmonyMjpegServer(driver)
|
|
176
183
|
|
|
177
184
|
|
|
@@ -193,7 +200,9 @@ async def unified_harmony_ws(websocket: WebSocket, serial: str):
|
|
|
193
200
|
await server.handle_ws(websocket)
|
|
194
201
|
except ImportError as e:
|
|
195
202
|
logger.error(f"missing library for harmony: {e}")
|
|
196
|
-
await websocket.close(
|
|
203
|
+
await websocket.close(
|
|
204
|
+
code=1000, reason='missing library, fix by "pip install uiautodev[harmony]"'
|
|
205
|
+
)
|
|
197
206
|
except WebSocketDisconnect:
|
|
198
207
|
logger.info(f"WebSocket disconnected by client.")
|
|
199
208
|
except Exception as e:
|
|
@@ -203,5 +212,5 @@ async def unified_harmony_ws(websocket: WebSocket, serial: str):
|
|
|
203
212
|
logger.info(f"WebSocket closed for serial={serial}")
|
|
204
213
|
|
|
205
214
|
|
|
206
|
-
if __name__ ==
|
|
215
|
+
if __name__ == "__main__":
|
|
207
216
|
uvicorn.run("uiautodev.app:app", port=4000, reload=True, use_colors=True)
|
|
@@ -20,6 +20,7 @@ import httpx
|
|
|
20
20
|
import pydantic
|
|
21
21
|
import uvicorn
|
|
22
22
|
from retry import retry
|
|
23
|
+
from rich.logging import RichHandler
|
|
23
24
|
|
|
24
25
|
from uiautodev import __version__, command_proxy
|
|
25
26
|
from uiautodev.command_types import Command
|
|
@@ -38,12 +39,21 @@ HARMONY_PACKAGES = [
|
|
|
38
39
|
"https://public.uiauto.devsleep.com/harmony/hypium-5.0.7.200.tar.gz",
|
|
39
40
|
]
|
|
40
41
|
|
|
42
|
+
|
|
43
|
+
def enable_logger_to_console(level):
|
|
44
|
+
_logger = logging.getLogger("uiautodev")
|
|
45
|
+
_logger.setLevel(level)
|
|
46
|
+
_logger.addHandler(RichHandler(enable_link_path=False))
|
|
47
|
+
|
|
48
|
+
|
|
41
49
|
@click.group(context_settings=CONTEXT_SETTINGS)
|
|
42
50
|
@click.option("--verbose", "-v", is_flag=True, default=False, help="verbose mode")
|
|
43
51
|
def cli(verbose: bool):
|
|
44
52
|
if verbose:
|
|
45
|
-
|
|
53
|
+
enable_logger_to_console(level=logging.DEBUG)
|
|
46
54
|
logger.debug("Verbose mode enabled")
|
|
55
|
+
else:
|
|
56
|
+
enable_logger_to_console(level=logging.INFO)
|
|
47
57
|
|
|
48
58
|
|
|
49
59
|
def run_driver_command(provider: BaseProvider, command: Command, params: list[str] = None):
|
|
@@ -140,9 +150,11 @@ def pip_install(package: str):
|
|
|
140
150
|
@click.option("--port", default=20242, help="port number", show_default=True)
|
|
141
151
|
@click.option("--host", default="127.0.0.1", help="host", show_default=True)
|
|
142
152
|
@click.option("--reload", is_flag=True, default=False, help="auto reload, dev only")
|
|
143
|
-
@click.option("-f", "--force", is_flag=True, default=False, help="shutdown
|
|
153
|
+
@click.option("-f", "--force", is_flag=True, default=False, help="shutdown already running server")
|
|
144
154
|
@click.option("-s", "--no-browser", is_flag=True, default=False, help="silent mode, do not open browser")
|
|
145
|
-
|
|
155
|
+
@click.option("--offline", is_flag=True, default=False, help="offline mode, do not use internet")
|
|
156
|
+
@click.option("--server-url", default="https://uiauto.dev", help="uiauto.dev server url", show_default=True)
|
|
157
|
+
def server(port: int, host: str, reload: bool, force: bool, no_browser: bool, offline: bool, server_url: str):
|
|
146
158
|
click.echo(f"uiautodev version: {__version__}")
|
|
147
159
|
if force:
|
|
148
160
|
try:
|
|
@@ -154,32 +166,44 @@ def server(port: int, host: str, reload: bool, force: bool, no_browser: bool):
|
|
|
154
166
|
if platform.system() == 'Windows':
|
|
155
167
|
use_color = False
|
|
156
168
|
|
|
169
|
+
server_url = server_url.rstrip('/')
|
|
170
|
+
from uiautodev.router import proxy
|
|
171
|
+
proxy.base_url = server_url
|
|
172
|
+
|
|
173
|
+
if offline:
|
|
174
|
+
proxy.cache_dir.mkdir(parents=True, exist_ok=True)
|
|
175
|
+
logger.info("offline mode enabled, cache dir: %s, server url: %s", proxy.cache_dir, proxy.base_url)
|
|
176
|
+
|
|
157
177
|
if not no_browser:
|
|
158
|
-
th = threading.Thread(target=open_browser_when_server_start, args=(f"http://{host}:{port}",))
|
|
178
|
+
th = threading.Thread(target=open_browser_when_server_start, args=(f"http://{host}:{port}", offline))
|
|
159
179
|
th.daemon = True
|
|
160
180
|
th.start()
|
|
161
181
|
uvicorn.run("uiautodev.app:app", host=host, port=port, reload=reload, use_colors=use_color)
|
|
162
182
|
|
|
183
|
+
@cli.command(help="shutdown uiauto.dev local server")
|
|
184
|
+
@click.option("--port", default=20242, help="port number", show_default=True)
|
|
185
|
+
def shutdown(port: int):
|
|
186
|
+
try:
|
|
187
|
+
httpx.get(f"http://127.0.0.1:{port}/shutdown", timeout=3)
|
|
188
|
+
except httpx.HTTPError:
|
|
189
|
+
pass
|
|
163
190
|
|
|
164
|
-
|
|
191
|
+
|
|
192
|
+
def open_browser_when_server_start(local_server_url: str, offline: bool = False):
|
|
165
193
|
deadline = time.time() + 10
|
|
166
194
|
while time.time() < deadline:
|
|
167
195
|
try:
|
|
168
|
-
httpx.get(f"{
|
|
196
|
+
httpx.get(f"{local_server_url}/api/info", timeout=1)
|
|
169
197
|
break
|
|
170
198
|
except Exception as e:
|
|
171
199
|
time.sleep(0.5)
|
|
172
200
|
import webbrowser
|
|
173
|
-
web_url = get_webpage_url()
|
|
201
|
+
web_url = get_webpage_url(local_server_url if offline else None)
|
|
174
202
|
logger.info("open browser: %s", web_url)
|
|
175
203
|
webbrowser.open(web_url)
|
|
176
204
|
|
|
177
205
|
|
|
178
206
|
def main():
|
|
179
|
-
# set logger level to INFO
|
|
180
|
-
# logging.basicConfig(level=logging.INFO)
|
|
181
|
-
logger.setLevel(logging.INFO)
|
|
182
|
-
|
|
183
207
|
has_command = False
|
|
184
208
|
for name in sys.argv[1:]:
|
|
185
209
|
if not name.startswith("-"):
|
|
@@ -13,8 +13,8 @@ from typing import Callable, Dict, List, Optional, Union
|
|
|
13
13
|
from pydantic import BaseModel
|
|
14
14
|
|
|
15
15
|
from uiautodev.command_types import AppLaunchRequest, AppTerminateRequest, By, Command, CurrentAppResponse, \
|
|
16
|
-
DumpResponse, FindElementRequest, FindElementResponse, InstallAppRequest, InstallAppResponse, SendKeysRequest,
|
|
17
|
-
WindowSizeResponse
|
|
16
|
+
DumpResponse, FindElementRequest, FindElementResponse, InstallAppRequest, InstallAppResponse, SendKeysRequest, \
|
|
17
|
+
TapRequest, WindowSizeResponse
|
|
18
18
|
from uiautodev.driver.base_driver import BaseDriver
|
|
19
19
|
from uiautodev.exceptions import ElementNotFoundError
|
|
20
20
|
from uiautodev.model import AppInfo, Node
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import io
|
|
9
9
|
import locale
|
|
10
10
|
import logging
|
|
11
|
-
from typing import List
|
|
11
|
+
from typing import List, Optional
|
|
12
12
|
|
|
13
13
|
from PIL import Image
|
|
14
14
|
|
|
@@ -26,8 +26,9 @@ def is_chinese_language() -> bool:
|
|
|
26
26
|
return False
|
|
27
27
|
|
|
28
28
|
|
|
29
|
-
def get_webpage_url() -> str:
|
|
30
|
-
|
|
29
|
+
def get_webpage_url(web_url: Optional[str] = None) -> str:
|
|
30
|
+
if not web_url:
|
|
31
|
+
web_url = "https://uiauto.dev"
|
|
31
32
|
# code will be enabled until uiauto.devsleep.com is ready
|
|
32
33
|
# if is_chinese_language():
|
|
33
34
|
# web_url = "https://uiauto.devsleep.com"
|
|
@@ -7,31 +7,24 @@
|
|
|
7
7
|
import logging
|
|
8
8
|
import re
|
|
9
9
|
import time
|
|
10
|
-
from functools import cached_property, partial
|
|
11
10
|
from typing import Iterator, List, Optional, Tuple
|
|
12
|
-
from xml.etree import ElementTree
|
|
13
11
|
|
|
14
12
|
import adbutils
|
|
15
|
-
import uiautomator2 as u2
|
|
16
13
|
from PIL import Image
|
|
17
14
|
|
|
18
15
|
from uiautodev.command_types import CurrentAppResponse
|
|
16
|
+
from uiautodev.driver.android.common import parse_xml
|
|
19
17
|
from uiautodev.driver.base_driver import BaseDriver
|
|
20
|
-
from uiautodev.exceptions import AndroidDriverException
|
|
18
|
+
from uiautodev.exceptions import AndroidDriverException
|
|
21
19
|
from uiautodev.model import AppInfo, Node, Rect, ShellResponse, WindowSize
|
|
22
|
-
from uiautodev.utils.common import fetch_through_socket
|
|
23
20
|
|
|
24
21
|
logger = logging.getLogger(__name__)
|
|
25
22
|
|
|
26
|
-
class
|
|
23
|
+
class ADBAndroidDriver(BaseDriver):
|
|
27
24
|
def __init__(self, serial: str):
|
|
28
25
|
super().__init__(serial)
|
|
29
26
|
self.adb_device = adbutils.device(serial)
|
|
30
27
|
|
|
31
|
-
@cached_property
|
|
32
|
-
def ud(self) -> u2.Device:
|
|
33
|
-
return u2.connect_usb(self.serial)
|
|
34
|
-
|
|
35
28
|
def get_current_activity(self) -> str:
|
|
36
29
|
ret = self.adb_device.shell2(["dumpsys", "activity", "activities"], rstrip=True, timeout=5)
|
|
37
30
|
# 使用正则查找包含前台 activity 的行
|
|
@@ -44,7 +37,7 @@ class AndroidDriver(BaseDriver):
|
|
|
44
37
|
def screenshot(self, id: int) -> Image.Image:
|
|
45
38
|
if id > 0:
|
|
46
39
|
raise AndroidDriverException("multi-display is not supported yet for uiautomator2")
|
|
47
|
-
return self.
|
|
40
|
+
return self.adb_device.screenshot(display_id=id)
|
|
48
41
|
|
|
49
42
|
def shell(self, command: str) -> ShellResponse:
|
|
50
43
|
try:
|
|
@@ -61,8 +54,11 @@ class AndroidDriver(BaseDriver):
|
|
|
61
54
|
def dump_hierarchy(self, display_id: Optional[int] = 0) -> Tuple[str, Node]:
|
|
62
55
|
"""returns xml string and hierarchy object"""
|
|
63
56
|
start = time.time()
|
|
64
|
-
|
|
65
|
-
|
|
57
|
+
try:
|
|
58
|
+
xml_data = self._dump_hierarchy_raw()
|
|
59
|
+
logger.debug("dump_hierarchy cost: %s", time.time() - start)
|
|
60
|
+
except Exception as e:
|
|
61
|
+
raise AndroidDriverException(f"Failed to dump hierarchy: {str(e)}")
|
|
66
62
|
|
|
67
63
|
wsize = self.adb_device.window_size()
|
|
68
64
|
logger.debug("window size: %s", wsize)
|
|
@@ -78,10 +74,24 @@ class AndroidDriver(BaseDriver):
|
|
|
78
74
|
- ERROR: could not get idle state.
|
|
79
75
|
"""
|
|
80
76
|
try:
|
|
81
|
-
return self.
|
|
82
|
-
except
|
|
83
|
-
|
|
77
|
+
return self.adb_device.dump_hierarchy()
|
|
78
|
+
except adbutils.AdbError as e:
|
|
79
|
+
if "Killed" in str(e):
|
|
80
|
+
self.kill_app_process()
|
|
81
|
+
return self.adb_device.dump_hierarchy()
|
|
84
82
|
|
|
83
|
+
def kill_app_process(self):
|
|
84
|
+
logger.debug("Killing app_process")
|
|
85
|
+
pids = []
|
|
86
|
+
for line in self.adb_device.shell("ps -A || ps").splitlines():
|
|
87
|
+
if "app_process" in line:
|
|
88
|
+
fields = line.split()
|
|
89
|
+
if len(fields) >= 2:
|
|
90
|
+
pids.append(int(fields[1]))
|
|
91
|
+
logger.debug(f"App process PID: {fields[1]}")
|
|
92
|
+
for pid in set(pids):
|
|
93
|
+
self.adb_device.shell(f"kill {pid}")
|
|
94
|
+
|
|
85
95
|
def tap(self, x: int, y: int):
|
|
86
96
|
self.adb_device.click(x, y)
|
|
87
97
|
|
|
@@ -179,61 +189,8 @@ class AndroidDriver(BaseDriver):
|
|
|
179
189
|
yield from self.adb_device.sync.iter_content(remote_path)
|
|
180
190
|
|
|
181
191
|
def send_keys(self, text: str):
|
|
182
|
-
self.
|
|
192
|
+
self.adb_device.send_keys(text)
|
|
183
193
|
|
|
184
194
|
def clear_text(self):
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
def parse_xml(xml_data: str, wsize: WindowSize, display_id: Optional[int] = None) -> Node:
|
|
189
|
-
root = ElementTree.fromstring(xml_data)
|
|
190
|
-
node = parse_xml_element(root, wsize, display_id)
|
|
191
|
-
if node is None:
|
|
192
|
-
raise AndroidDriverException("Failed to parse xml")
|
|
193
|
-
return node
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
def parse_xml_element(element, wsize: WindowSize, display_id: Optional[int], indexes: List[int] = [0]) -> Optional[Node]:
|
|
197
|
-
"""
|
|
198
|
-
Recursively parse an XML element into a dictionary format.
|
|
199
|
-
"""
|
|
200
|
-
name = element.tag
|
|
201
|
-
if name == "node":
|
|
202
|
-
name = element.attrib.get("class", "node")
|
|
203
|
-
if display_id is not None:
|
|
204
|
-
elem_display_id = int(element.attrib.get("display-id", display_id))
|
|
205
|
-
if elem_display_id != display_id:
|
|
206
|
-
return
|
|
207
|
-
|
|
208
|
-
bounds = None
|
|
209
|
-
rect = None
|
|
210
|
-
# eg: bounds="[883,2222][1008,2265]"
|
|
211
|
-
if "bounds" in element.attrib:
|
|
212
|
-
bounds = element.attrib["bounds"]
|
|
213
|
-
bounds = list(map(int, re.findall(r"\d+", bounds)))
|
|
214
|
-
assert len(bounds) == 4
|
|
215
|
-
rect = Rect(x=bounds[0], y=bounds[1], width=bounds[2] - bounds[0], height=bounds[3] - bounds[1])
|
|
216
|
-
bounds = (
|
|
217
|
-
bounds[0] / wsize.width,
|
|
218
|
-
bounds[1] / wsize.height,
|
|
219
|
-
bounds[2] / wsize.width,
|
|
220
|
-
bounds[3] / wsize.height,
|
|
221
|
-
)
|
|
222
|
-
bounds = map(partial(round, ndigits=4), bounds)
|
|
223
|
-
|
|
224
|
-
elem = Node(
|
|
225
|
-
key="-".join(map(str, indexes)),
|
|
226
|
-
name=name,
|
|
227
|
-
bounds=bounds,
|
|
228
|
-
rect=rect,
|
|
229
|
-
properties={key: element.attrib[key] for key in element.attrib},
|
|
230
|
-
children=[],
|
|
231
|
-
)
|
|
232
|
-
|
|
233
|
-
# Construct xpath for children
|
|
234
|
-
for index, child in enumerate(element):
|
|
235
|
-
child_node = parse_xml_element(child, wsize, display_id, indexes + [index])
|
|
236
|
-
if child_node:
|
|
237
|
-
elem.children.append(child_node)
|
|
238
|
-
|
|
239
|
-
return elem
|
|
195
|
+
for _ in range(3):
|
|
196
|
+
self.adb_device.shell2("input keyevent DEL --longpress")
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from functools import partial
|
|
3
|
+
from typing import List, Optional, Tuple
|
|
4
|
+
from xml.etree import ElementTree
|
|
5
|
+
|
|
6
|
+
from uiautodev.exceptions import AndroidDriverException, RequestError
|
|
7
|
+
from uiautodev.model import AppInfo, Node, Rect, ShellResponse, WindowSize
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def parse_xml(xml_data: str, wsize: WindowSize, display_id: Optional[int] = None) -> Node:
|
|
11
|
+
root = ElementTree.fromstring(xml_data)
|
|
12
|
+
node = parse_xml_element(root, wsize, display_id)
|
|
13
|
+
if node is None:
|
|
14
|
+
raise AndroidDriverException("Failed to parse xml")
|
|
15
|
+
return node
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def parse_xml_element(element, wsize: WindowSize, display_id: Optional[int], indexes: List[int] = [0]) -> Optional[Node]:
|
|
19
|
+
"""
|
|
20
|
+
Recursively parse an XML element into a dictionary format.
|
|
21
|
+
"""
|
|
22
|
+
name = element.tag
|
|
23
|
+
if name == "node":
|
|
24
|
+
name = element.attrib.get("class", "node")
|
|
25
|
+
if display_id is not None:
|
|
26
|
+
elem_display_id = int(element.attrib.get("display-id", display_id))
|
|
27
|
+
if elem_display_id != display_id:
|
|
28
|
+
return
|
|
29
|
+
|
|
30
|
+
bounds = None
|
|
31
|
+
rect = None
|
|
32
|
+
# eg: bounds="[883,2222][1008,2265]"
|
|
33
|
+
if "bounds" in element.attrib:
|
|
34
|
+
bounds = element.attrib["bounds"]
|
|
35
|
+
bounds = list(map(int, re.findall(r"\d+", bounds)))
|
|
36
|
+
assert len(bounds) == 4
|
|
37
|
+
rect = Rect(x=bounds[0], y=bounds[1], width=bounds[2] - bounds[0], height=bounds[3] - bounds[1])
|
|
38
|
+
bounds = (
|
|
39
|
+
bounds[0] / wsize.width,
|
|
40
|
+
bounds[1] / wsize.height,
|
|
41
|
+
bounds[2] / wsize.width,
|
|
42
|
+
bounds[3] / wsize.height,
|
|
43
|
+
)
|
|
44
|
+
bounds = map(partial(round, ndigits=4), bounds)
|
|
45
|
+
|
|
46
|
+
elem = Node(
|
|
47
|
+
key="-".join(map(str, indexes)),
|
|
48
|
+
name=name,
|
|
49
|
+
bounds=bounds,
|
|
50
|
+
rect=rect,
|
|
51
|
+
properties={key: element.attrib[key] for key in element.attrib},
|
|
52
|
+
children=[],
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
# Construct xpath for children
|
|
56
|
+
for index, child in enumerate(element):
|
|
57
|
+
child_node = parse_xml_element(child, wsize, display_id, indexes + [index])
|
|
58
|
+
if child_node:
|
|
59
|
+
elem.children.append(child_node)
|
|
60
|
+
|
|
61
|
+
return elem
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
|
|
4
|
+
"""Created on Fri Mar 01 2024 14:19:29 by codeskyblue
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
import re
|
|
9
|
+
import time
|
|
10
|
+
from functools import cached_property
|
|
11
|
+
from typing import Optional, Tuple
|
|
12
|
+
|
|
13
|
+
import uiautomator2 as u2
|
|
14
|
+
from PIL import Image
|
|
15
|
+
|
|
16
|
+
from uiautodev.driver.android.adb_driver import ADBAndroidDriver
|
|
17
|
+
from uiautodev.driver.android.common import parse_xml
|
|
18
|
+
from uiautodev.exceptions import AndroidDriverException
|
|
19
|
+
from uiautodev.model import AppInfo, Node, WindowSize
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
class U2AndroidDriver(ADBAndroidDriver):
|
|
24
|
+
def __init__(self, serial: str):
|
|
25
|
+
super().__init__(serial)
|
|
26
|
+
|
|
27
|
+
@cached_property
|
|
28
|
+
def ud(self) -> u2.Device:
|
|
29
|
+
return u2.connect_usb(self.serial)
|
|
30
|
+
|
|
31
|
+
def screenshot(self, id: int) -> Image.Image:
|
|
32
|
+
if id > 0:
|
|
33
|
+
# u2 is not support multi-display yet
|
|
34
|
+
return super().screenshot(id)
|
|
35
|
+
return self.ud.screenshot()
|
|
36
|
+
|
|
37
|
+
def dump_hierarchy(self, display_id: Optional[int] = 0) -> Tuple[str, Node]:
|
|
38
|
+
"""returns xml string and hierarchy object"""
|
|
39
|
+
start = time.time()
|
|
40
|
+
xml_data = self._dump_hierarchy_raw()
|
|
41
|
+
logger.debug("dump_hierarchy cost: %s", time.time() - start)
|
|
42
|
+
|
|
43
|
+
wsize = self.adb_device.window_size()
|
|
44
|
+
logger.debug("window size: %s", wsize)
|
|
45
|
+
return xml_data, parse_xml(
|
|
46
|
+
xml_data, WindowSize(width=wsize[0], height=wsize[1]), display_id
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
def _dump_hierarchy_raw(self) -> str:
|
|
50
|
+
"""
|
|
51
|
+
uiautomator2 server is conflict with "uiautomator dump" command.
|
|
52
|
+
|
|
53
|
+
uiautomator dump errors:
|
|
54
|
+
- ERROR: could not get idle state.
|
|
55
|
+
"""
|
|
56
|
+
try:
|
|
57
|
+
return self.ud.dump_hierarchy()
|
|
58
|
+
except Exception as e:
|
|
59
|
+
raise AndroidDriverException(f"Failed to dump hierarchy: {str(e)}")
|
|
60
|
+
|
|
61
|
+
def tap(self, x: int, y: int):
|
|
62
|
+
self.ud.click(x, y)
|
|
63
|
+
|
|
64
|
+
def send_keys(self, text: str):
|
|
65
|
+
self.ud.send_keys(text)
|
|
66
|
+
|
|
67
|
+
def clear_text(self):
|
|
68
|
+
self.ud.clear_text()
|
|
@@ -4,11 +4,9 @@
|
|
|
4
4
|
"""Created on Fri Mar 01 2024 14:18:30 by codeskyblue
|
|
5
5
|
"""
|
|
6
6
|
import abc
|
|
7
|
-
from io import FileIO
|
|
8
7
|
from typing import Iterator, List, Tuple
|
|
9
8
|
|
|
10
9
|
from PIL import Image
|
|
11
|
-
from pydantic import BaseModel
|
|
12
10
|
|
|
13
11
|
from uiautodev.command_types import CurrentAppResponse
|
|
14
12
|
from uiautodev.model import AppInfo, Node, ShellResponse, WindowSize
|
|
@@ -7,10 +7,11 @@ from __future__ import annotations
|
|
|
7
7
|
|
|
8
8
|
import abc
|
|
9
9
|
from functools import lru_cache
|
|
10
|
+
from typing import Type
|
|
10
11
|
|
|
11
12
|
import adbutils
|
|
12
13
|
|
|
13
|
-
from uiautodev.driver.android import
|
|
14
|
+
from uiautodev.driver.android import ADBAndroidDriver, U2AndroidDriver
|
|
14
15
|
from uiautodev.driver.base_driver import BaseDriver
|
|
15
16
|
from uiautodev.driver.harmony import HDC, HarmonyDriver
|
|
16
17
|
from uiautodev.driver.ios import IOSDriver
|
|
@@ -40,8 +41,8 @@ class BaseProvider(abc.ABC):
|
|
|
40
41
|
|
|
41
42
|
|
|
42
43
|
class AndroidProvider(BaseProvider):
|
|
43
|
-
def __init__(self):
|
|
44
|
-
|
|
44
|
+
def __init__(self, driver_class: Type[BaseDriver] = U2AndroidDriver):
|
|
45
|
+
self.driver_class = driver_class
|
|
45
46
|
|
|
46
47
|
def list_devices(self) -> list[DeviceInfo]:
|
|
47
48
|
adb = adbutils.AdbClient()
|
|
@@ -61,8 +62,9 @@ class AndroidProvider(BaseProvider):
|
|
|
61
62
|
return ret
|
|
62
63
|
|
|
63
64
|
@lru_cache
|
|
64
|
-
def get_device_driver(self, serial: str) ->
|
|
65
|
-
return
|
|
65
|
+
def get_device_driver(self, serial: str) -> BaseDriver:
|
|
66
|
+
return self.driver_class(serial)
|
|
67
|
+
|
|
66
68
|
|
|
67
69
|
|
|
68
70
|
class IOSProvider(BaseProvider):
|
|
@@ -6,7 +6,7 @@ from typing import Dict, Optional
|
|
|
6
6
|
from fastapi import APIRouter, Request, Response
|
|
7
7
|
from pydantic import BaseModel
|
|
8
8
|
|
|
9
|
-
from uiautodev.driver.android import
|
|
9
|
+
from uiautodev.driver.android import ADBAndroidDriver, U2AndroidDriver
|
|
10
10
|
from uiautodev.model import ShellResponse
|
|
11
11
|
|
|
12
12
|
logger = logging.getLogger(__name__)
|
|
@@ -21,7 +21,7 @@ class AndroidShellPayload(BaseModel):
|
|
|
21
21
|
def shell(serial: str, payload: AndroidShellPayload) -> ShellResponse:
|
|
22
22
|
"""Run a shell command on an Android device"""
|
|
23
23
|
try:
|
|
24
|
-
driver =
|
|
24
|
+
driver = ADBAndroidDriver(serial)
|
|
25
25
|
return driver.shell(payload.command)
|
|
26
26
|
except NotImplementedError as e:
|
|
27
27
|
return Response(content="shell not implemented", media_type="text/plain", status_code=501)
|
|
@@ -34,7 +34,7 @@ def shell(serial: str, payload: AndroidShellPayload) -> ShellResponse:
|
|
|
34
34
|
async def get_current_activity(serial: str) -> Response:
|
|
35
35
|
"""Get the current activity of the Android device"""
|
|
36
36
|
try:
|
|
37
|
-
driver =
|
|
37
|
+
driver = ADBAndroidDriver(serial)
|
|
38
38
|
activity = driver.get_current_activity()
|
|
39
39
|
return Response(content=activity, media_type="text/plain")
|
|
40
40
|
except Exception as e:
|
|
@@ -53,7 +53,7 @@ def make_router(provider: BaseProvider) -> APIRouter:
|
|
|
53
53
|
return Response(content=str(e), media_type="text/plain", status_code=500)
|
|
54
54
|
|
|
55
55
|
@router.get("/{serial}/hierarchy")
|
|
56
|
-
def dump_hierarchy(serial: str, format: str = "json")
|
|
56
|
+
def dump_hierarchy(serial: str, format: str = "json"):
|
|
57
57
|
"""Dump the view hierarchy of an Android device"""
|
|
58
58
|
try:
|
|
59
59
|
driver = provider.get_device_driver(serial)
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import hashlib
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
import websockets
|
|
10
|
+
from fastapi import APIRouter, Request, WebSocket, WebSocketDisconnect
|
|
11
|
+
from fastapi.responses import Response, StreamingResponse
|
|
12
|
+
from starlette.background import BackgroundTask
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
router = APIRouter()
|
|
16
|
+
cache_dir = Path("./cache")
|
|
17
|
+
base_url = 'https://uiauto.dev'
|
|
18
|
+
|
|
19
|
+
@router.get("/")
|
|
20
|
+
@router.get("/android/{path:path}")
|
|
21
|
+
@router.get("/ios/{path:path}")
|
|
22
|
+
@router.get("/demo/{path:path}")
|
|
23
|
+
@router.get("/harmony/{path:path}")
|
|
24
|
+
async def proxy_html(request: Request):
|
|
25
|
+
cache = HTTPCache(cache_dir, base_url, key='homepage')
|
|
26
|
+
response = await cache.proxy_request(request, update_cache=True)
|
|
27
|
+
return response
|
|
28
|
+
# update
|
|
29
|
+
|
|
30
|
+
@router.get("/assets/{path:path}")
|
|
31
|
+
@router.get('/favicon.ico')
|
|
32
|
+
async def proxy_assets(request: Request, path: str = ""):
|
|
33
|
+
target_url = f"{base_url}{request.url.path}"
|
|
34
|
+
cache = HTTPCache(cache_dir, target_url)
|
|
35
|
+
return await cache.proxy_request(request)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class HTTPCache:
|
|
39
|
+
def __init__(self, cache_dir: Path, target_url: str, key: Optional[str] = None):
|
|
40
|
+
self.cache_dir = cache_dir
|
|
41
|
+
self.target_url = target_url
|
|
42
|
+
self.key = key or hashlib.md5(target_url.encode()).hexdigest()
|
|
43
|
+
self.file_body = self.cache_dir / 'http' / (self.key + ".body")
|
|
44
|
+
self.file_headers = self.file_body.with_suffix(".headers")
|
|
45
|
+
|
|
46
|
+
async def proxy_request(self, request: Request, update_cache: bool = False):
|
|
47
|
+
response = await self.get_cached_response(request)
|
|
48
|
+
if not response:
|
|
49
|
+
response = await self.proxy_and_save_response(request)
|
|
50
|
+
return response
|
|
51
|
+
if update_cache:
|
|
52
|
+
# async update cache in background
|
|
53
|
+
asyncio.create_task(self.update_cache(request))
|
|
54
|
+
return response
|
|
55
|
+
|
|
56
|
+
async def get_cached_response(self, request: Request):
|
|
57
|
+
if request.method == 'GET' and self.file_body.exists():
|
|
58
|
+
logger.info(f"Cache hit: {self.file_body}")
|
|
59
|
+
headers = {}
|
|
60
|
+
if self.file_headers.exists():
|
|
61
|
+
with self.file_headers.open('rb') as f:
|
|
62
|
+
headers = json.load(f)
|
|
63
|
+
body_fd = self.file_body.open("rb")
|
|
64
|
+
return StreamingResponse(
|
|
65
|
+
content=body_fd,
|
|
66
|
+
status_code=200,
|
|
67
|
+
headers=headers,
|
|
68
|
+
background=BackgroundTask(body_fd.close)
|
|
69
|
+
)
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
async def update_cache(self, request: Request):
|
|
73
|
+
try:
|
|
74
|
+
await self.proxy_and_save_response(request)
|
|
75
|
+
except Exception as e:
|
|
76
|
+
logger.error("Update cache failed")
|
|
77
|
+
|
|
78
|
+
async def proxy_and_save_response(self, request: Request) -> Response:
|
|
79
|
+
logger.debug(f"Proxying request... {request.url.path}")
|
|
80
|
+
response = await proxy_http(request, self.target_url)
|
|
81
|
+
# save response to cache
|
|
82
|
+
if request.method == "GET" and response.status_code == 200 and self.cache_dir.exists():
|
|
83
|
+
self.file_body.parent.mkdir(parents=True, exist_ok=True)
|
|
84
|
+
with self.file_body.open("wb") as f:
|
|
85
|
+
f.write(response.body)
|
|
86
|
+
with self.file_headers.open("w", encoding="utf-8") as f:
|
|
87
|
+
headers = response.headers
|
|
88
|
+
headers['cache-status'] = 'HIT'
|
|
89
|
+
json.dump(dict(headers), f, indent=2, ensure_ascii=False)
|
|
90
|
+
return response
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# WebSocket 转发
|
|
94
|
+
@router.websocket("/proxy/ws/{target_url:path}")
|
|
95
|
+
async def proxy_ws(websocket: WebSocket, target_url: str):
|
|
96
|
+
await websocket.accept()
|
|
97
|
+
logger.info(f"WebSocket target_url: {target_url}")
|
|
98
|
+
|
|
99
|
+
try:
|
|
100
|
+
async with websockets.connect(target_url) as target_ws:
|
|
101
|
+
async def from_client():
|
|
102
|
+
while True:
|
|
103
|
+
msg = await websocket.receive_text()
|
|
104
|
+
await target_ws.send(msg)
|
|
105
|
+
|
|
106
|
+
async def from_server():
|
|
107
|
+
while True:
|
|
108
|
+
msg = await target_ws.recv()
|
|
109
|
+
if isinstance(msg, bytes):
|
|
110
|
+
await websocket.send_bytes(msg)
|
|
111
|
+
elif isinstance(msg, str):
|
|
112
|
+
await websocket.send_text(msg)
|
|
113
|
+
else:
|
|
114
|
+
raise RuntimeError("Unknown message type", msg)
|
|
115
|
+
|
|
116
|
+
await asyncio.gather(from_client(), from_server())
|
|
117
|
+
|
|
118
|
+
except WebSocketDisconnect:
|
|
119
|
+
pass
|
|
120
|
+
except Exception as e:
|
|
121
|
+
logger.error(f"WS Error: {e}")
|
|
122
|
+
await websocket.close()
|
|
123
|
+
|
|
124
|
+
# ref: https://stackoverflow.com/questions/74555102/how-to-forward-fastapi-requests-to-another-server
|
|
125
|
+
def make_reverse_proxy(base_url: str, strip_prefix: str = ""):
|
|
126
|
+
async def _reverse_proxy(request: Request):
|
|
127
|
+
client = httpx.AsyncClient(base_url=base_url)
|
|
128
|
+
client.timeout = httpx.Timeout(30.0, read=300.0)
|
|
129
|
+
path = request.url.path
|
|
130
|
+
if strip_prefix and path.startswith(strip_prefix):
|
|
131
|
+
path = path[len(strip_prefix):]
|
|
132
|
+
target_url = httpx.URL(
|
|
133
|
+
path=path, query=request.url.query.encode("utf-8")
|
|
134
|
+
)
|
|
135
|
+
exclude_headers = [b"host", b"connection", b"accept-encoding"]
|
|
136
|
+
headers = [(k, v) for k, v in request.headers.raw if k not in exclude_headers]
|
|
137
|
+
headers.append((b'accept-encoding', b''))
|
|
138
|
+
|
|
139
|
+
req = client.build_request(
|
|
140
|
+
request.method, target_url, headers=headers, content=request.stream()
|
|
141
|
+
)
|
|
142
|
+
r = await client.send(req, stream=True)#, follow_redirects=True)
|
|
143
|
+
|
|
144
|
+
response_headers = {
|
|
145
|
+
k: v for k, v in r.headers.items()
|
|
146
|
+
if k.lower() not in {"transfer-encoding", "connection", "content-length"}
|
|
147
|
+
}
|
|
148
|
+
async def gen_content():
|
|
149
|
+
async for chunk in r.aiter_bytes(chunk_size=40960):
|
|
150
|
+
yield chunk
|
|
151
|
+
|
|
152
|
+
async def aclose():
|
|
153
|
+
await client.aclose()
|
|
154
|
+
|
|
155
|
+
return StreamingResponse(
|
|
156
|
+
content=gen_content(),
|
|
157
|
+
status_code=r.status_code,
|
|
158
|
+
headers=response_headers,
|
|
159
|
+
background=BackgroundTask(aclose),
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
return _reverse_proxy
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
async def proxy_http(request: Request, target_url: str):
|
|
166
|
+
logger.info(f"HTTP target_url: {target_url}")
|
|
167
|
+
|
|
168
|
+
async with httpx.AsyncClient(timeout=httpx.Timeout(30.0)) as client:
|
|
169
|
+
body = await request.body() if request.method in {"POST", "PUT", "PATCH", "DELETE"} else None
|
|
170
|
+
headers = {k: v for k, v in request.headers.items() if k.lower() not in {"host", "x-target-url"}}
|
|
171
|
+
headers['accept-encoding'] = '' # disable gzip
|
|
172
|
+
resp = await client.request(
|
|
173
|
+
request.method,
|
|
174
|
+
target_url,
|
|
175
|
+
content=body,
|
|
176
|
+
headers=headers,
|
|
177
|
+
)
|
|
178
|
+
return Response(content=resp.content, status_code=resp.status_code, headers=dict(resp.headers))
|
|
@@ -1,57 +0,0 @@
|
|
|
1
|
-
import asyncio
|
|
2
|
-
import logging
|
|
3
|
-
|
|
4
|
-
import httpx
|
|
5
|
-
import websockets
|
|
6
|
-
from fastapi import APIRouter, HTTPException, Request, WebSocket, WebSocketDisconnect
|
|
7
|
-
from fastapi.responses import Response
|
|
8
|
-
|
|
9
|
-
logger = logging.getLogger(__name__)
|
|
10
|
-
router = APIRouter()
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
# HTTP 转发
|
|
14
|
-
@router.api_route("/http/{target_url:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"])
|
|
15
|
-
async def proxy_http(request: Request, target_url: str):
|
|
16
|
-
logger.info(f"HTTP target_url: {target_url}")
|
|
17
|
-
|
|
18
|
-
async with httpx.AsyncClient(timeout=httpx.Timeout(30.0)) as client:
|
|
19
|
-
body = await request.body()
|
|
20
|
-
resp = await client.request(
|
|
21
|
-
request.method,
|
|
22
|
-
target_url,
|
|
23
|
-
content=body,
|
|
24
|
-
headers={k: v for k, v in request.headers.items() if k.lower() != "host" and k.lower() != "x-target-url"}
|
|
25
|
-
)
|
|
26
|
-
return Response(content=resp.content, status_code=resp.status_code, headers=dict(resp.headers))
|
|
27
|
-
|
|
28
|
-
# WebSocket 转发
|
|
29
|
-
@router.websocket("/ws/{target_url:path}")
|
|
30
|
-
async def proxy_ws(websocket: WebSocket, target_url: str):
|
|
31
|
-
await websocket.accept()
|
|
32
|
-
logger.info(f"WebSocket target_url: {target_url}")
|
|
33
|
-
|
|
34
|
-
try:
|
|
35
|
-
async with websockets.connect(target_url) as target_ws:
|
|
36
|
-
async def from_client():
|
|
37
|
-
while True:
|
|
38
|
-
msg = await websocket.receive_text()
|
|
39
|
-
await target_ws.send(msg)
|
|
40
|
-
|
|
41
|
-
async def from_server():
|
|
42
|
-
while True:
|
|
43
|
-
msg = await target_ws.recv()
|
|
44
|
-
if isinstance(msg, bytes):
|
|
45
|
-
await websocket.send_bytes(msg)
|
|
46
|
-
elif isinstance(msg, str):
|
|
47
|
-
await websocket.send_text(msg)
|
|
48
|
-
else:
|
|
49
|
-
raise RuntimeError("Unknown message type", msg)
|
|
50
|
-
|
|
51
|
-
await asyncio.gather(from_client(), from_server())
|
|
52
|
-
|
|
53
|
-
except WebSocketDisconnect:
|
|
54
|
-
pass
|
|
55
|
-
except Exception as e:
|
|
56
|
-
logger.error(f"WS Error: {e}")
|
|
57
|
-
await websocket.close()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{uiautodev-0.12.0 → uiautodev-0.13.3}/uiautodev/driver/udt/appium-uiautomator2-v5.12.4-light.apk
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|