Skip to content

Commit 68505eb

Browse files
committed
feat: support iOS ui viewer
1 parent b0e0862 commit 68505eb

File tree

9 files changed

+163
-101
lines changed

9 files changed

+163
-101
lines changed

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@ fastapi = "^0.68.0"
1414
aiofiles = "^23.1.0"
1515
uiautomator2 = "^3.0.0"
1616
facebook-wda = "^1.0.5"
17-
hmdriver2 = "^1.2.8"
17+
tidevice = "^0.12.10"
18+
hmdriver2 = "^1.2.9"
1819

1920
[tool.poetry.extras]
2021

uiviewer/_device.py

Lines changed: 66 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,16 @@
22

33
import abc
44
import tempfile
5-
from typing import List, Dict, Union
5+
from typing import List, Dict, Union, Tuple
6+
from functools import cached_property # python3.8+
67

78
from PIL import Image
9+
import tidevice
810
import adbutils
11+
import wda
912
import uiautomator2 as u2
1013
from hmdriver2 import hdc
14+
from hmdriver2.driver import Driver
1115
from fastapi import HTTPException
1216

1317
from uiviewer._utils import file_to_base64, image_to_base64
@@ -20,10 +24,13 @@ def list_serials(platform: str) -> List[str]:
2024
if platform == Platform.ANDROID:
2125
raws = adbutils.AdbClient().device_list()
2226
devices = [item.serial for item in raws]
23-
if platform == Platform.IOS:
24-
devices = []
25-
if platform == Platform.HARMONY:
27+
elif platform == Platform.IOS:
28+
raw = tidevice.Usbmux().device_list()
29+
devices = [d.udid for d in raw]
30+
elif platform == Platform.HARMONY:
2631
devices = hdc.list_devices()
32+
else:
33+
raise HTTPException(status_code=200, detail="Unsupported platform")
2734

2835
return devices
2936

@@ -41,7 +48,11 @@ def dump_hierarchy(self) -> Dict:
4148
class HarmonyDevice(DeviceMeta):
4249
def __init__(self, serial: str):
4350
self.serial = serial
44-
self.client = hdc.HdcWrapper(serial)
51+
self.client = Driver(serial)
52+
53+
@cached_property
54+
def display_size(self) -> Tuple:
55+
return self.client.display_size
4556

4657
def take_screenshot(self) -> str:
4758
with tempfile.NamedTemporaryFile(delete=True, suffix=".png") as f:
@@ -50,15 +61,27 @@ def take_screenshot(self) -> str:
5061
return file_to_base64(path)
5162

5263
def dump_hierarchy(self) -> BaseHierarchy:
53-
raw = self.client.dump_hierarchy()
54-
return harmony_hierarchy.convert_harmony_hierarchy(raw)
64+
packageName, pageName = self.client.current_app()
65+
raw: Dict = self.client.dump_hierarchy()
66+
hierarchy: Dict = harmony_hierarchy.convert_harmony_hierarchy(raw)
67+
return BaseHierarchy(
68+
jsonHierarchy=hierarchy,
69+
activityName=pageName,
70+
packageName=packageName,
71+
windowSize=self.display_size,
72+
scale=1
73+
)
5574

5675

5776
class AndroidDevice(DeviceMeta):
5877
def __init__(self, serial: str):
5978
self.serial = serial
6079
self.d: u2.Device = u2.connect(serial)
6180

81+
@cached_property
82+
def window_size(self) -> Tuple:
83+
return self.d.window_size()
84+
6285
def take_screenshot(self) -> str:
6386
img: Image.Image = self.d.screenshot()
6487
return image_to_base64(img)
@@ -69,20 +92,51 @@ def dump_hierarchy(self) -> BaseHierarchy:
6992
page_json = android_hierarchy.get_android_hierarchy(page_xml)
7093
return BaseHierarchy(
7194
jsonHierarchy=page_json,
72-
activity=current['activity'],
95+
activityName=current['activity'],
7396
packageName=current['package'],
74-
windowSize=self.d.window_size(),
97+
windowSize=self.window_size,
7598
scale=1
7699
)
77100

78101

79-
def get_device(platform: str, serial: str) -> Union[HarmonyDevice, AndroidDevice]:
102+
class IosDevice(DeviceMeta):
103+
def __init__(self, udid: str, wda_url: str) -> None:
104+
self.udid = udid
105+
self.client = wda.Client(wda_url)
106+
107+
@cached_property
108+
def scale(self) -> int:
109+
return self.client.scale
110+
111+
@cached_property
112+
def window_size(self) -> Tuple:
113+
return self.client.window_size()
114+
115+
def take_screenshot(self) -> str:
116+
img: Image.Image = self.client.screenshot()
117+
return image_to_base64(img)
118+
119+
def dump_hierarchy(self) -> BaseHierarchy:
120+
data: Dict = self.client.source(format="json")
121+
hierarchy: Dict = ios_hierarchy.convert_ios_hierarchy(data, self.scale)
122+
return BaseHierarchy(
123+
jsonHierarchy=hierarchy,
124+
activityName=None,
125+
packageName=self.client.bundle_id,
126+
windowSize=self.window_size,
127+
scale=self.scale
128+
)
129+
130+
131+
def get_device(platform: str, serial: str, wda_url: str = None) -> Union[HarmonyDevice, AndroidDevice]:
80132
if serial not in list_serials(platform):
81133
raise HTTPException(status_code=200, detail="Device not found")
82134
if platform == Platform.HARMONY:
83135
return HarmonyDevice(serial)
84136
elif platform == Platform.ANDROID:
85137
return AndroidDevice(serial)
138+
elif platform == Platform.IOS:
139+
return IosDevice(serial, wda_url)
86140
else:
87141
raise HTTPException(status_code=200, detail="Unsupported platform")
88142

@@ -91,7 +145,7 @@ def get_device(platform: str, serial: str) -> Union[HarmonyDevice, AndroidDevice
91145
cached_devices = {}
92146

93147

94-
def init_device(platform: str, serial: str):
95-
device = get_device(platform, serial)
148+
def init_device(platform: str, serial: str, wda_url: str = None):
149+
device = get_device(platform, serial, wda_url)
96150
cached_devices[(platform, serial)] = device
97151
return platform, serial

uiviewer/app.py

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,21 @@
33
import os
44
import webbrowser
55
import uvicorn
6+
from typing import Union,Optional
67

7-
from fastapi import FastAPI
8-
from fastapi import Request
9-
from fastapi import HTTPException
8+
from fastapi import FastAPI, Request, Query, HTTPException
109
from fastapi.staticfiles import StaticFiles
1110
from fastapi.responses import JSONResponse, RedirectResponse
1211

12+
from uiviewer._device import (
13+
list_serials,
14+
init_device,
15+
cached_devices,
16+
AndroidDevice,
17+
IosDevice,
18+
HarmonyDevice
19+
)
1320
from uiviewer._models import ApiResponse
14-
from uiviewer._device import list_serials, init_device, cached_devices
1521

1622

1723
app = FastAPI()
@@ -60,21 +66,21 @@ async def get_serials(platform: str):
6066

6167

6268
@app.post("/{platform}/{serial}/init", response_model=ApiResponse)
63-
async def init(platform: str, serial: str):
64-
device = init_device(platform, serial)
69+
async def init(platform: str, serial: str, wdaUrl: Optional[str] = Query(None)):
70+
device = init_device(platform, serial, wdaUrl)
6571
return ApiResponse.doSuccess(device)
6672

6773

6874
@app.get("/{platform}/{serial}/screenshot", response_model=ApiResponse)
6975
async def screenshot(platform: str, serial: str):
70-
device = cached_devices.get((platform, serial))
76+
device: Union[AndroidDevice, IosDevice, HarmonyDevice] = cached_devices.get((platform, serial))
7177
data = device.take_screenshot()
7278
return ApiResponse.doSuccess(data)
7379

7480

7581
@app.get("/{platform}/{serial}/hierarchy", response_model=ApiResponse)
7682
async def dump_hierarchy(platform: str, serial: str):
77-
device = cached_devices.get((platform, serial))
83+
device: Union[AndroidDevice, IosDevice, HarmonyDevice] = cached_devices.get((platform, serial))
7884
data = device.dump_hierarchy()
7985
return ApiResponse.doSuccess(data)
8086

uiviewer/parser/harmony_hierarchy.py

Lines changed: 5 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,11 @@
11
# -*- coding: utf-8 -*-
22

33
import uuid
4-
from uiviewer._models import BaseHierarchy
4+
from typing import Dict
55

66

7-
def convert_harmony_hierarchy(data: dict) -> BaseHierarchy:
8-
ret = {
9-
"jsonHierarchy": {
10-
"_id": "3e7bbe34-c6ed-4a06-abef-f44c3b852467",
11-
"children": []
12-
},
13-
"abilityName": "",
14-
"bundleName": "",
15-
"windowSize": [0, 0],
16-
"scale": 1
17-
}
7+
def convert_harmony_hierarchy(data: Dict) -> Dict:
8+
ret = {"_id": str(uuid.uuid4()), "children": []}
189

1910
def __recursive_convert(node_a):
2011
node_b = {
@@ -60,32 +51,13 @@ def __recursive_convert(node_a):
6051
node_b["rect"]["width"] = int(bounds[1].split(",")[0]) - int(bounds[0].split(",")[0])
6152
node_b["rect"]["height"] = int(bounds[1].split(",")[1]) - int(bounds[0].split(",")[1])
6253

63-
# TODO Remove unnecessary attributes
64-
6554
children = node_a.get("children", [])
6655
if children:
6756
node_b["children"] = [__recursive_convert(child) for child in children]
6857

6958
return node_b
7059

7160
# Recursively convert children of a to match b's structure
72-
ret["jsonHierarchy"]["children"] = [__recursive_convert(child) for child in data.get("children", [])]
73-
74-
# Set windowSize based on the bounds of the first attributes in a
75-
first_bounds = data["attributes"].get("bounds", "")
76-
if first_bounds:
77-
first_bounds = first_bounds.strip("[]").split("][")
78-
ret["windowSize"] = [
79-
int(first_bounds[1].split(",")[0]) - int(first_bounds[0].split(",")[0]),
80-
int(first_bounds[1].split(",")[1]) - int(first_bounds[0].split(",")[1])
81-
]
82-
83-
# Set abilityName based on the abilityName of the first attributes in the first children of a
84-
first_child_attributes = data.get("children", [{}])[0].get("attributes", {})
85-
ret["activityName"] = first_child_attributes.get("abilityName", "")
86-
87-
# Set bundleName based on the bundleName of the first attributes in the first children of a
88-
first_child_attributes = data.get("children", [{}])[0].get("attributes", {})
89-
ret["packageName"] = first_child_attributes.get("bundleName", "")
61+
ret["children"] = [__recursive_convert(child) for child in data.get("children", [])]
9062

91-
return BaseHierarchy(**ret)
63+
return ret

uiviewer/parser/ios_hierarchy.py

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,10 @@
11
# -*- coding: utf-8 -*-
22

33
import uuid
4+
from typing import Dict
45

56

6-
def get_ios_hierarchy(client, scale, md=None):
7-
if md:
8-
client.appium_settings({"snapshotMaxDepth": md})
9-
sourcejson = client.source(format='json')
7+
def convert_ios_hierarchy(data: Dict, scale: int) -> Dict:
108

119
def __travel(node):
1210
node['_id'] = str(uuid.uuid4())
@@ -18,4 +16,4 @@ def __travel(node):
1816
__travel(child)
1917
return node
2018

21-
return __travel(sourcejson)
19+
return __travel(data)

uiviewer/static/css/style.css

Lines changed: 16 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,13 @@ body, html {
1212
border-color: #3966C3 !important;
1313
color: white !important;
1414
}
15+
1516
.el-select .el-input__inner:focus {
1617
border-color: #3966C3 !important;
1718
}
19+
.el-select .el-input__inner::placeholder {
20+
color: #a5a2a2;
21+
}
1822
.el-select-dropdown,
1923
.el-select .el-input__inner,
2024
.el-select-dropdown .el-select-dropdown__item {
@@ -23,6 +27,17 @@ body, html {
2327
border-color: #212933 !important;
2428
}
2529

30+
.custom-input .el-input__inner {
31+
border-color: #2D3744;
32+
}
33+
.custom-input .el-input__inner::placeholder {
34+
color: #a5a2a2;
35+
}
36+
.custom-input .el-input__inner:focus,
37+
.custom-input .el-input__inner:hover {
38+
border-color: #3966C3;
39+
}
40+
2641

2742
#app {
2843
display: flex;
@@ -140,19 +155,7 @@ body, html {
140155
}
141156
.divider-hover, .divider-dragging {
142157
background-color: #3679E3;
143-
}
144-
145-
.no-border-input ::placeholder {
146-
color: #BBBBBB;
147-
opacity: 1;
148-
}
149-
150-
.no-border-input {
151-
margin-left: 10px;
152-
}
153-
.no-border-input .el-input__inner:focus,
154-
.no-border-input .el-input__inner:hover {
155-
border-color: #3966C3;
158+
width: 3px;
156159
}
157160

158161
.custom-link {

uiviewer/static/index.html

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,14 @@
3737
</el-option>
3838
</el-select>
3939

40+
<el-input
41+
class="custom-input"
42+
v-if="platform === 'ios'"
43+
v-model="wdaUrl"
44+
style="width: 250px; margin-right: 20px;"
45+
placeholder="Please input WDA url">
46+
</el-input>
47+
4048
<el-button
4149
:disabled="isConnecting"
4250
style="margin-right: 10px; width: 100px;"
@@ -89,7 +97,7 @@
8997
<div class="center" :style="{ width: centerWidth + 'px' }">
9098
<p class="region-title">Selected Element Info</p>
9199
<el-table
92-
:data="selectedNodeDetailsArray"
100+
:data="selectedNodeDetails"
93101
style="width: 100%"
94102
class="custom-table">
95103
<el-table-column prop="key" label="Key" width="30%"></el-table-column>
@@ -116,18 +124,19 @@
116124
<p class="region-title">UI hierarchy
117125
</p>
118126
<el-input
119-
class="no-border-input"
120-
placeholder="Search for..."
121-
v-model="nodeFilterText">
127+
class="custom-input"
128+
placeholder="Search for ..."
129+
style="margin-left: 10px;"
130+
v-model="nodeFilterText">
122131
</el-input>
123132
<el-tree
133+
class="custom-tree"
124134
ref="treeRef"
125135
:data="treeData"
126136
:props="defaultProps"
127137
@node-click="handleTreeNodeClick"
128138
node-key="_id"
129139
default-expand-all
130-
show-line
131140
:expand-on-click-node="false"
132141
:filter-node-method="filterNode">
133142
</el-tree>

0 commit comments

Comments
 (0)