Skip to content

Commit d8355ff

Browse files
authored
add proxy devsleep (#55)
* add some code for proxy * make it work temperory * add cache proxy support * fix for copilot
1 parent 670c98f commit d8355ff

File tree

6 files changed

+187
-36
lines changed

6 files changed

+187
-36
lines changed

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,4 +161,5 @@ cython_debug/
161161

162162
poetry.lock
163163
window_dump.xml
164-
.DS_Store
164+
.DS_Store
165+
cache/

README.md

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,11 +44,27 @@ uiauto.dev
4444
# Environment
4545

4646
```sh
47-
# 驱动默认为uiautomator2
48-
# 设置下面的环境变量可以调整为adb
47+
# Default driver is uiautomator2
48+
# Set the environment variable below to switch to adb driver
4949
export UIAUTODEV_USE_ADB_DRIVER=1
50+
51+
# Set the env to enable DEBUG log
52+
export UIAUTODEV_DEBUG=1
5053
```
5154

55+
# Offline mode
56+
57+
Currently, the frontend is deployed on a separate server, so internet connection is required.
58+
However, some users have limited network environments or restricted internet access. Therefore, an offline cache mode has been added.
59+
Create a `cache` directory in the directory where uiautodev starts to activate frontend caching.
60+
61+
```sh
62+
mkdir cache
63+
uiautodev
64+
```
65+
66+
Visit <http://localhost:20242> once, and then disconnecting from the internet will not affect usage.
67+
5268
# DEVELOP
5369

5470
see [DEVELOP.md](DEVELOP.md)

uiautodev/app.py

Lines changed: 35 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
#!/usr/bin/env python3
22
# -*- coding: utf-8 -*-
33

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"""
65

76
import logging
87
import os
@@ -12,10 +11,11 @@
1211
from typing import Dict, List
1312

1413
import adbutils
14+
import httpx
1515
import uvicorn
16-
from fastapi import FastAPI, File, UploadFile, WebSocket
16+
from fastapi import FastAPI, File, Request, Response, UploadFile, WebSocket
1717
from fastapi.middleware.cors import CORSMiddleware
18-
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
18+
from fastapi.responses import (FileResponse, JSONResponse, RedirectResponse)
1919
from pydantic import BaseModel
2020
from rich.logging import RichHandler
2121
from starlette.websockets import WebSocketDisconnect
@@ -28,7 +28,7 @@
2828
from uiautodev.remote.scrcpy import ScrcpyServer
2929
from uiautodev.router.android import router as android_device_router
3030
from uiautodev.router.device import make_router
31-
from uiautodev.router.proxy import router as proxy_router
31+
from uiautodev.router.proxy import make_reverse_proxy, router as proxy_router
3232
from uiautodev.router.xml import router as xml_router
3333
from uiautodev.utils.envutils import Environment
3434

@@ -37,14 +37,17 @@
3737
app = FastAPI()
3838

3939

40-
def enable_logger_to_console():
40+
def enable_logger_to_console(level):
4141
_logger = logging.getLogger("uiautodev")
42-
_logger.setLevel(logging.DEBUG)
42+
_logger.setLevel(level)
4343
_logger.addHandler(RichHandler(enable_link_path=False))
4444

4545

4646
if os.getenv("UIAUTODEV_DEBUG"):
47-
enable_logger_to_console()
47+
enable_logger_to_console(level=logging.DEBUG)
48+
logger.debug("verbose logger enabled")
49+
else:
50+
enable_logger_to_console(level=logging.ERROR)
4851

4952
app.add_middleware(
5053
CORSMiddleware,
@@ -78,20 +81,24 @@ def enable_logger_to_console():
7881

7982
app.include_router(xml_router, prefix="/api/xml", tags=["xml"])
8083
app.include_router(android_device_router, prefix="/api/android", tags=["android"])
81-
app.include_router(proxy_router, prefix="/proxy", tags=["proxy"])
84+
app.include_router(proxy_router, tags=["proxy"])
85+
8286

83-
@app.get('/api/{platform}/features')
87+
@app.get("/api/{platform}/features")
8488
def get_features(platform: str) -> Dict[str, bool]:
8589
"""Get features supported by the specified platform"""
8690
features = {}
8791
# 获取所有带有指定平台tag的路由
92+
from starlette.routing import Route
93+
8894
for route in app.routes:
89-
if hasattr(route, 'tags') and platform in route.tags:
90-
if route.path.startswith(f"/api/{platform}/{{serial}}/"):
95+
_route: Route = route # type: ignore
96+
if hasattr(_route, "tags") and platform in _route.tags:
97+
if _route.path.startswith(f"/api/{platform}/{{serial}}/"):
9198
# 提取特性名称
92-
parts = route.path.split('/')
99+
parts = _route.path.split("/")
93100
feature_name = parts[-1]
94-
if not feature_name.startswith('{'):
101+
if not feature_name.startswith("{"):
95102
features[feature_name] = True
96103
return features
97104

@@ -118,7 +125,7 @@ def info() -> InfoResponse:
118125
)
119126

120127

121-
@app.post('/api/ocr_image')
128+
@app.post("/api/ocr_image")
122129
async def _ocr_image(file: UploadFile = File(...)) -> List[Node]:
123130
"""OCR an image"""
124131
image_data = await file.read()
@@ -141,14 +148,19 @@ def demo():
141148
return FileResponse(static_dir / "demo.html")
142149

143150

144-
@app.get("/")
151+
@app.get("/redirect")
145152
def index_redirect():
146-
""" redirect to official homepage """
153+
"""redirect to official homepage"""
147154
url = get_webpage_url()
148155
logger.debug("redirect to %s", url)
149156
return RedirectResponse(url)
150157

151158

159+
@app.get("/api/auth/me")
160+
def mock_auth_me():
161+
# 401 {"detail":"Authentication required"}
162+
return JSONResponse(status_code=401, content={"detail": "Authentication required"})
163+
152164
@app.websocket("/ws/android/scrcpy/{serial}")
153165
async def handle_android_ws(websocket: WebSocket, serial: str):
154166
"""
@@ -176,9 +188,10 @@ def get_harmony_mjpeg_server(serial: str):
176188
from hypium import UiDriver
177189

178190
from uiautodev.remote.harmony_mjpeg import HarmonyMjpegServer
191+
179192
driver = UiDriver.connect(device_sn=serial)
180193
logger.info("create harmony mjpeg server for %s", serial)
181-
logger.info(f'device wake_up_display: {driver.wake_up_display()}')
194+
logger.info(f"device wake_up_display: {driver.wake_up_display()}")
182195
return HarmonyMjpegServer(driver)
183196

184197

@@ -200,7 +213,9 @@ async def unified_harmony_ws(websocket: WebSocket, serial: str):
200213
await server.handle_ws(websocket)
201214
except ImportError as e:
202215
logger.error(f"missing library for harmony: {e}")
203-
await websocket.close(code=1000, reason="missing library, fix by \"pip install uiautodev[harmony]\"")
216+
await websocket.close(
217+
code=1000, reason='missing library, fix by "pip install uiautodev[harmony]"'
218+
)
204219
except WebSocketDisconnect:
205220
logger.info(f"WebSocket disconnected by client.")
206221
except Exception as e:
@@ -210,5 +225,5 @@ async def unified_harmony_ws(websocket: WebSocket, serial: str):
210225
logger.info(f"WebSocket closed for serial={serial}")
211226

212227

213-
if __name__ == '__main__':
228+
if __name__ == "__main__":
214229
uvicorn.run("uiautodev.app:app", port=4000, reload=True, use_colors=True)

uiautodev/cli.py

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -184,10 +184,6 @@ def open_browser_when_server_start(server_url: str):
184184

185185

186186
def main():
187-
# set logger level to INFO
188-
# logging.basicConfig(level=logging.INFO)
189-
logger.setLevel(logging.INFO)
190-
191187
has_command = False
192188
for name in sys.argv[1:]:
193189
if not name.startswith("-"):

uiautodev/router/device.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ def _screenshot(serial: str, id: int) -> Response:
5353
return Response(content=str(e), media_type="text/plain", status_code=500)
5454

5555
@router.get("/{serial}/hierarchy")
56-
def dump_hierarchy(serial: str, format: str = "json") -> Node:
56+
def dump_hierarchy(serial: str, format: str = "json"):
5757
"""Dump the view hierarchy of an Android device"""
5858
try:
5959
driver = provider.get_device_driver(serial)

uiautodev/router/proxy.py

Lines changed: 131 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,115 @@
11
import asyncio
2+
import hashlib
3+
import json
24
import logging
5+
from pathlib import Path
6+
from typing import Optional
37

48
import httpx
59
import websockets
6-
from fastapi import APIRouter, HTTPException, Request, WebSocket, WebSocketDisconnect
7-
from fastapi.responses import Response
10+
from fastapi import APIRouter, BackgroundTasks, HTTPException, Request, WebSocket, WebSocketDisconnect
11+
from fastapi.responses import Response, StreamingResponse
12+
from starlette.background import BackgroundTask
813

914
logger = logging.getLogger(__name__)
1015
router = APIRouter()
11-
16+
cache_dir = Path("./cache")
1217

1318
# HTTP 转发
14-
@router.api_route("/http/{target_url:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"])
19+
@router.api_route("/proxy/http/{target_url:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"])
1520
async def proxy_http(request: Request, target_url: str):
1621
logger.info(f"HTTP target_url: {target_url}")
1722

1823
async with httpx.AsyncClient(timeout=httpx.Timeout(30.0)) as client:
19-
body = await request.body()
24+
body = await request.body() if request.method in {"POST", "PUT", "PATCH", "DELETE"} else None
25+
headers = {k: v for k, v in request.headers.items() if k.lower() not in {"host", "x-target-url"}}
26+
headers['accept-encoding'] = '' # disable gzip
2027
resp = await client.request(
2128
request.method,
2229
target_url,
2330
content=body,
24-
headers={k: v for k, v in request.headers.items() if k.lower() != "host" and k.lower() != "x-target-url"}
31+
headers=headers,
2532
)
2633
return Response(content=resp.content, status_code=resp.status_code, headers=dict(resp.headers))
2734

35+
36+
@router.get("/")
37+
@router.get("/android/{path:path}")
38+
@router.get("/ios/{path:path}")
39+
@router.get("/demo/{path:path}")
40+
@router.get("/harmony/{path:path}")
41+
async def proxy_html(request: Request):
42+
target_url = "https://uiauto.dev/"
43+
cache = HTTPCache(cache_dir, target_url, key='homepage')
44+
response = await cache.proxy_request(request, update_cache=True)
45+
return response
46+
# update
47+
48+
@router.get("/assets/{path:path}")
49+
@router.get('/favicon.ico')
50+
async def proxy_assets(request: Request, path: str = ""):
51+
target_url = f"https://uiauto.dev{request.url.path}"
52+
cache = HTTPCache(cache_dir, target_url)
53+
return await cache.proxy_request(request)
54+
55+
56+
class HTTPCache:
57+
def __init__(self, cache_dir: Path, target_url: str, key: Optional[str] = None):
58+
self.cache_dir = cache_dir
59+
self.target_url = target_url
60+
self.key = key or hashlib.md5(target_url.encode()).hexdigest()
61+
self.file_body = self.cache_dir / 'http' / (self.key + ".body")
62+
self.file_headers = self.file_body.with_suffix(".headers")
63+
64+
async def proxy_request(self, request: Request, update_cache: bool = False):
65+
response = await self.get_cached_response(request)
66+
if not response:
67+
response = await self.proxy_and_save_response(request)
68+
return response
69+
if update_cache:
70+
# async update cache in background
71+
asyncio.create_task(self.update_cache(request))
72+
return response
73+
74+
async def get_cached_response(self, request: Request):
75+
if request.method == 'GET' and self.file_body.exists():
76+
logger.info(f"Cache hit: {self.file_body}")
77+
headers = {}
78+
if self.file_headers.exists():
79+
with self.file_headers.open('rb') as f:
80+
headers = json.load(f)
81+
body_fd = self.file_body.open("rb")
82+
return StreamingResponse(
83+
content=body_fd,
84+
status_code=200,
85+
headers=headers,
86+
background=BackgroundTask(body_fd.close)
87+
)
88+
return None
89+
90+
async def update_cache(self, request: Request):
91+
try:
92+
await self.proxy_and_save_response(request)
93+
except Exception as e:
94+
logger.error("Update cache failed")
95+
96+
async def proxy_and_save_response(self, request: Request) -> Response:
97+
logger.debug(f"Proxying request... {request.url.path}")
98+
response = await proxy_http(request, self.target_url)
99+
# save response to cache
100+
if request.method == "GET" and response.status_code == 200 and self.cache_dir.exists():
101+
self.file_body.parent.mkdir(parents=True, exist_ok=True)
102+
with self.file_body.open("wb") as f:
103+
f.write(response.body)
104+
with self.file_headers.open("w", encoding="utf-8") as f:
105+
headers = response.headers
106+
headers['cache-status'] = 'HIT'
107+
json.dump(dict(headers), f, indent=2, ensure_ascii=False)
108+
return response
109+
110+
28111
# WebSocket 转发
29-
@router.websocket("/ws/{target_url:path}")
112+
@router.websocket("/proxy/ws/{target_url:path}")
30113
async def proxy_ws(websocket: WebSocket, target_url: str):
31114
await websocket.accept()
32115
logger.info(f"WebSocket target_url: {target_url}")
@@ -54,4 +137,44 @@ async def from_server():
54137
pass
55138
except Exception as e:
56139
logger.error(f"WS Error: {e}")
57-
await websocket.close()
140+
await websocket.close()
141+
142+
# ref: https://stackoverflow.com/questions/74555102/how-to-forward-fastapi-requests-to-another-server
143+
def make_reverse_proxy(base_url: str, strip_prefix: str = ""):
144+
async def _reverse_proxy(request: Request):
145+
client = httpx.AsyncClient(base_url=base_url)
146+
client.timeout = httpx.Timeout(30.0, read=300.0)
147+
path = request.url.path
148+
if strip_prefix and path.startswith(strip_prefix):
149+
path = path[len(strip_prefix):]
150+
target_url = httpx.URL(
151+
path=path, query=request.url.query.encode("utf-8")
152+
)
153+
exclude_headers = [b"host", b"connection", b"accept-encoding"]
154+
headers = [(k, v) for k, v in request.headers.raw if k not in exclude_headers]
155+
headers.append((b'accept-encoding', b''))
156+
157+
req = client.build_request(
158+
request.method, target_url, headers=headers, content=request.stream()
159+
)
160+
r = await client.send(req, stream=True)#, follow_redirects=True)
161+
162+
response_headers = {
163+
k: v for k, v in r.headers.items()
164+
if k.lower() not in {"transfer-encoding", "connection", "content-length"}
165+
}
166+
async def gen_content():
167+
async for chunk in r.aiter_bytes(chunk_size=40960):
168+
yield chunk
169+
170+
async def aclose():
171+
await client.aclose()
172+
173+
return StreamingResponse(
174+
content=gen_content(),
175+
status_code=r.status_code,
176+
headers=response_headers,
177+
background=BackgroundTask(aclose),
178+
)
179+
180+
return _reverse_proxy

0 commit comments

Comments
 (0)