dailo-devtools 0.1.3__tar.gz → 0.1.5__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.
- {dailo_devtools-0.1.3/src/dailo_devtools.egg-info → dailo_devtools-0.1.5}/PKG-INFO +44 -2
- dailo_devtools-0.1.5/README.md +137 -0
- {dailo_devtools-0.1.3 → dailo_devtools-0.1.5}/pyproject.toml +1 -1
- {dailo_devtools-0.1.3 → dailo_devtools-0.1.5}/src/dailo_devtools/__init__.py +4 -1
- {dailo_devtools-0.1.3 → dailo_devtools-0.1.5}/src/dailo_devtools/cli.py +34 -2
- {dailo_devtools-0.1.3 → dailo_devtools-0.1.5}/src/dailo_devtools/device.py +169 -48
- dailo_devtools-0.1.5/src/dailo_devtools/windows.py +80 -0
- {dailo_devtools-0.1.3 → dailo_devtools-0.1.5/src/dailo_devtools.egg-info}/PKG-INFO +44 -2
- {dailo_devtools-0.1.3 → dailo_devtools-0.1.5}/src/dailo_devtools.egg-info/SOURCES.txt +1 -0
- dailo_devtools-0.1.3/README.md +0 -95
- {dailo_devtools-0.1.3 → dailo_devtools-0.1.5}/LICENSE +0 -0
- {dailo_devtools-0.1.3 → dailo_devtools-0.1.5}/setup.cfg +0 -0
- {dailo_devtools-0.1.3 → dailo_devtools-0.1.5}/src/dailo_devtools/py.typed +0 -0
- {dailo_devtools-0.1.3 → dailo_devtools-0.1.5}/src/dailo_devtools.egg-info/dependency_links.txt +0 -0
- {dailo_devtools-0.1.3 → dailo_devtools-0.1.5}/src/dailo_devtools.egg-info/entry_points.txt +0 -0
- {dailo_devtools-0.1.3 → dailo_devtools-0.1.5}/src/dailo_devtools.egg-info/requires.txt +0 -0
- {dailo_devtools-0.1.3 → dailo_devtools-0.1.5}/src/dailo_devtools.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: dailo-devtools
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.5
|
|
4
4
|
Summary: 通过代劳 DevTools 调试单台 Android 设备的 Python API 与 CLI
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
Project-URL: Script SDK, https://pypi.org/project/dailo-sdk/
|
|
@@ -19,7 +19,7 @@ Dynamic: license-file
|
|
|
19
19
|
|
|
20
20
|
# Dailo DevTools
|
|
21
21
|
|
|
22
|
-
在电脑上运行和调试代劳 Android 自动化脚本。提供 `dailo-dev`
|
|
22
|
+
在电脑上运行和调试代劳 Android 与 Windows 自动化脚本。提供 `dailo-dev` 命令行工具与 Python 客户端,可执行脚本、读取文字与控件、保存截图和查看日志。
|
|
23
23
|
|
|
24
24
|
- 命令行执行 Python 文件或短代码。
|
|
25
25
|
- Python API 支持连接设备、截图、观察和停止运行。
|
|
@@ -55,6 +55,28 @@ dailo-dev serve --connection connection.json
|
|
|
55
55
|
|
|
56
56
|
保持服务运行,在另一个终端执行后续命令。连接文件包含访问设备的凭据,请保存在本机;链接过期或重新连接时,从 App 获取新链接。
|
|
57
57
|
|
|
58
|
+
## Windows 本机调试
|
|
59
|
+
|
|
60
|
+
Windows 使用代劳安装目录中的 `runtime/windows-x64`,复用产品的 Rust 宿主和脚本执行通路,无需安装 Python 系统或视觉库。`--hwnd` 或 `--executable` 选择目标;省略时观察当前显示桌面。
|
|
61
|
+
|
|
62
|
+
```sh
|
|
63
|
+
dailo-dev windows --runtime "C:\Apps\代劳\runtime\windows-x64" applications
|
|
64
|
+
dailo-dev windows --runtime "C:\Apps\代劳\runtime\windows-x64" desktops
|
|
65
|
+
dailo-dev windows --runtime "C:\Apps\代劳\runtime\windows-x64" --hwnd 123456 inspect
|
|
66
|
+
dailo-dev windows --runtime "C:\Apps\代劳\runtime\windows-x64" --hwnd 123456 screenshot screen.png
|
|
67
|
+
dailo-dev windows --runtime "C:\Apps\代劳\runtime\windows-x64" --hwnd 123456 exec --file main.py --timeout 300
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Python 客户端提供同一能力;`cancel()` 可由调用方其他线程停止正在执行的脚本:
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
from dailo_devtools import Windows
|
|
74
|
+
|
|
75
|
+
device = Windows(r"C:\Apps\代劳\runtime\windows-x64", hwnd=123456)
|
|
76
|
+
print(device.inspect())
|
|
77
|
+
device.run_file("main.py", config={"rounds": 5})
|
|
78
|
+
```
|
|
79
|
+
|
|
58
80
|
## 运行脚本
|
|
59
81
|
|
|
60
82
|
执行一段代码:
|
|
@@ -71,6 +93,13 @@ dailo-dev exec --file main.py --config settings.json
|
|
|
71
93
|
|
|
72
94
|
脚本可使用 [dailo-sdk](https://pypi.org/project/dailo-sdk/) 的文字、控件和图片定位 API。文件模式会自动上传脚本目录中的 Python 模块和 `assets/`;`--timeout` 设置执行时限,单位为秒,默认 300。
|
|
73
95
|
|
|
96
|
+
只携带本次所需资源时,使用 `--assets` 指定相对脚本目录的文件或目录;设备上保留相同相对路径。省略此参数时携带整个 `assets/`,单独使用 `--assets` 时不携带资源。Python 模块始终自动携带。
|
|
97
|
+
|
|
98
|
+
```sh
|
|
99
|
+
dailo-dev exec --file main.py --assets assets/battle.png assets/maps
|
|
100
|
+
dailo-dev exec --code "from dailo import text; print(text())" --assets
|
|
101
|
+
```
|
|
102
|
+
|
|
74
103
|
## 常用命令
|
|
75
104
|
|
|
76
105
|
| 命令 | 用途 |
|
|
@@ -109,6 +138,19 @@ asyncio.run(main())
|
|
|
109
138
|
|
|
110
139
|
`Device` 还提供 `run_file(path, config=...)`、`inspect()` 和 `cancel()`。使用 Python 客户端时无需启动 `dailo-dev serve`;异步上下文管理器负责建立和关闭连接。
|
|
111
140
|
|
|
141
|
+
Android执行超时后请求停止;收到终态时直接返回原结果并标记`transportDeadlineExceeded`,可核`errorCode`和日志。停止后仍未收到结果或连接中断时才抛异常,保留未确认记录,不自动重放。
|
|
142
|
+
|
|
143
|
+
`run_file()` 和 `execute()` 的 `assets=None` 默认携带 `assets/`。传入显式列表会只携带选定资源,`assets=[]` 不携带资源,例如:
|
|
144
|
+
|
|
145
|
+
```python
|
|
146
|
+
await device.run_file("main.py", assets=["assets/battle.png", "assets/maps"])
|
|
147
|
+
await device.execute("from dailo import text; print(text())", assets=[])
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Windows 的 `run_file()`、`execute()` 每次生成独立 `devtools:windows:…` 执行 ID,脚本通过 `execution()` 读取。明确续接某次失败时传入 `retry_of_execution_id=原执行ID`;新调用默认不关联旧父。调试 ID 不代表产品任务记录。
|
|
151
|
+
|
|
152
|
+
Android 的同名参数沿用Host提供的本父真实执行ID,只设置本次重试来源,退出后恢复原上下文。续接连续延期时,传入上一父脚本中`dailo.execution().execution_id`的完整原值;不要传请求短ID、sessionId或自行拼接ID。CLI使用`--retry-of-execution-id`,持久服务的`execute`/`run_file`请求使用`retry_of_execution_id`。省略参数表示新调用,不沿用旧延期链;该参数不修改原延期时间,也不绕过最早执行时间。
|
|
153
|
+
|
|
112
154
|
## 许可
|
|
113
155
|
|
|
114
156
|
MIT。
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# Dailo DevTools
|
|
2
|
+
|
|
3
|
+
在电脑上运行和调试代劳 Android 与 Windows 自动化脚本。提供 `dailo-dev` 命令行工具与 Python 客户端,可执行脚本、读取文字与控件、保存截图和查看日志。
|
|
4
|
+
|
|
5
|
+
- 命令行执行 Python 文件或短代码。
|
|
6
|
+
- Python API 支持连接设备、截图、观察和停止运行。
|
|
7
|
+
- 自动携带脚本目录中的 Python 模块与 `assets/`。
|
|
8
|
+
- 图文日志保存到电脑,可离线查看。
|
|
9
|
+
|
|
10
|
+
## 安装
|
|
11
|
+
|
|
12
|
+
需要 Python 3.11 或更新版本,以及已安装代劳 App、开启 DevTools 的 Android 设备。
|
|
13
|
+
|
|
14
|
+
```sh
|
|
15
|
+
python -m pip install dailo-devtools
|
|
16
|
+
dailo-dev install
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
`dailo-dev install` 安装后台连接所需的 Chromium 运行时,只需执行一次。
|
|
20
|
+
|
|
21
|
+
## 连接设备
|
|
22
|
+
|
|
23
|
+
从代劳 App 的 DevTools 入口复制连接链接,保存为 `connection.json`:
|
|
24
|
+
|
|
25
|
+
```json
|
|
26
|
+
{
|
|
27
|
+
"url": "<从代劳 App 复制的 DevTools 链接>"
|
|
28
|
+
}
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
启动连接服务:
|
|
32
|
+
|
|
33
|
+
```sh
|
|
34
|
+
dailo-dev serve --connection connection.json
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
保持服务运行,在另一个终端执行后续命令。连接文件包含访问设备的凭据,请保存在本机;链接过期或重新连接时,从 App 获取新链接。
|
|
38
|
+
|
|
39
|
+
## Windows 本机调试
|
|
40
|
+
|
|
41
|
+
Windows 使用代劳安装目录中的 `runtime/windows-x64`,复用产品的 Rust 宿主和脚本执行通路,无需安装 Python 系统或视觉库。`--hwnd` 或 `--executable` 选择目标;省略时观察当前显示桌面。
|
|
42
|
+
|
|
43
|
+
```sh
|
|
44
|
+
dailo-dev windows --runtime "C:\Apps\代劳\runtime\windows-x64" applications
|
|
45
|
+
dailo-dev windows --runtime "C:\Apps\代劳\runtime\windows-x64" desktops
|
|
46
|
+
dailo-dev windows --runtime "C:\Apps\代劳\runtime\windows-x64" --hwnd 123456 inspect
|
|
47
|
+
dailo-dev windows --runtime "C:\Apps\代劳\runtime\windows-x64" --hwnd 123456 screenshot screen.png
|
|
48
|
+
dailo-dev windows --runtime "C:\Apps\代劳\runtime\windows-x64" --hwnd 123456 exec --file main.py --timeout 300
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Python 客户端提供同一能力;`cancel()` 可由调用方其他线程停止正在执行的脚本:
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
from dailo_devtools import Windows
|
|
55
|
+
|
|
56
|
+
device = Windows(r"C:\Apps\代劳\runtime\windows-x64", hwnd=123456)
|
|
57
|
+
print(device.inspect())
|
|
58
|
+
device.run_file("main.py", config={"rounds": 5})
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## 运行脚本
|
|
62
|
+
|
|
63
|
+
执行一段代码:
|
|
64
|
+
|
|
65
|
+
```sh
|
|
66
|
+
dailo-dev exec --code "from dailo import text; print(text())"
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
执行 Python 文件并传入任务设置:
|
|
70
|
+
|
|
71
|
+
```sh
|
|
72
|
+
dailo-dev exec --file main.py --config settings.json
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
脚本可使用 [dailo-sdk](https://pypi.org/project/dailo-sdk/) 的文字、控件和图片定位 API。文件模式会自动上传脚本目录中的 Python 模块和 `assets/`;`--timeout` 设置执行时限,单位为秒,默认 300。
|
|
76
|
+
|
|
77
|
+
只携带本次所需资源时,使用 `--assets` 指定相对脚本目录的文件或目录;设备上保留相同相对路径。省略此参数时携带整个 `assets/`,单独使用 `--assets` 时不携带资源。Python 模块始终自动携带。
|
|
78
|
+
|
|
79
|
+
```sh
|
|
80
|
+
dailo-dev exec --file main.py --assets assets/battle.png assets/maps
|
|
81
|
+
dailo-dev exec --code "from dailo import text; print(text())" --assets
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## 常用命令
|
|
85
|
+
|
|
86
|
+
| 命令 | 用途 |
|
|
87
|
+
| --- | --- |
|
|
88
|
+
| `dailo-dev status` | 查看连接和运行状态 |
|
|
89
|
+
| `dailo-dev inspect` | 读取设备画面的文字与控件 |
|
|
90
|
+
| `dailo-dev screenshot screen.png` | 保存设备截图 |
|
|
91
|
+
| `dailo-dev cancel` | 停止当前脚本 |
|
|
92
|
+
| `dailo-dev reconnect` | 使用持久连接配置重新连接设备 |
|
|
93
|
+
| `dailo-dev logs <日志目录>` | 离线查看图文日志 |
|
|
94
|
+
|
|
95
|
+
运行 `dailo-dev --help` 或 `dailo-dev exec --help` 查看完整选项。
|
|
96
|
+
|
|
97
|
+
## Python API
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
import asyncio
|
|
101
|
+
import json
|
|
102
|
+
from pathlib import Path
|
|
103
|
+
|
|
104
|
+
from dailo_devtools import Device, read_logs
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
async def main():
|
|
108
|
+
connection = json.loads(Path("connection.json").read_text(encoding="utf-8"))
|
|
109
|
+
|
|
110
|
+
async with Device(connection) as device:
|
|
111
|
+
result = await device.execute("from dailo import text; print(text())")
|
|
112
|
+
await device.screenshot("screen.png")
|
|
113
|
+
print(result)
|
|
114
|
+
print(read_logs(result["logDirectory"]))
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
asyncio.run(main())
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
`Device` 还提供 `run_file(path, config=...)`、`inspect()` 和 `cancel()`。使用 Python 客户端时无需启动 `dailo-dev serve`;异步上下文管理器负责建立和关闭连接。
|
|
121
|
+
|
|
122
|
+
Android执行超时后请求停止;收到终态时直接返回原结果并标记`transportDeadlineExceeded`,可核`errorCode`和日志。停止后仍未收到结果或连接中断时才抛异常,保留未确认记录,不自动重放。
|
|
123
|
+
|
|
124
|
+
`run_file()` 和 `execute()` 的 `assets=None` 默认携带 `assets/`。传入显式列表会只携带选定资源,`assets=[]` 不携带资源,例如:
|
|
125
|
+
|
|
126
|
+
```python
|
|
127
|
+
await device.run_file("main.py", assets=["assets/battle.png", "assets/maps"])
|
|
128
|
+
await device.execute("from dailo import text; print(text())", assets=[])
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Windows 的 `run_file()`、`execute()` 每次生成独立 `devtools:windows:…` 执行 ID,脚本通过 `execution()` 读取。明确续接某次失败时传入 `retry_of_execution_id=原执行ID`;新调用默认不关联旧父。调试 ID 不代表产品任务记录。
|
|
132
|
+
|
|
133
|
+
Android 的同名参数沿用Host提供的本父真实执行ID,只设置本次重试来源,退出后恢复原上下文。续接连续延期时,传入上一父脚本中`dailo.execution().execution_id`的完整原值;不要传请求短ID、sessionId或自行拼接ID。CLI使用`--retry-of-execution-id`,持久服务的`execute`/`run_file`请求使用`retry_of_execution_id`。省略参数表示新调用,不沿用旧延期链;该参数不修改原延期时间,也不绕过最早执行时间。
|
|
134
|
+
|
|
135
|
+
## 许可
|
|
136
|
+
|
|
137
|
+
MIT。
|
|
@@ -4,10 +4,13 @@ from typing import TYPE_CHECKING
|
|
|
4
4
|
if TYPE_CHECKING:
|
|
5
5
|
from .device import Device, DevtoolsError, read_logs
|
|
6
6
|
|
|
7
|
-
__all__ = ["Device", "DevtoolsError", "read_logs"]
|
|
7
|
+
__all__ = ["Device", "DevtoolsError", "read_logs", "Windows"]
|
|
8
8
|
|
|
9
9
|
|
|
10
10
|
def __getattr__(name: str):
|
|
11
|
+
if name == 'Windows':
|
|
12
|
+
from .windows import Windows
|
|
13
|
+
return Windows
|
|
11
14
|
if name not in __all__:
|
|
12
15
|
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
13
16
|
from .device import Device, DevtoolsError, read_logs
|
|
@@ -76,12 +76,44 @@ def main():
|
|
|
76
76
|
source.add_argument("--code")
|
|
77
77
|
source.add_argument("--file")
|
|
78
78
|
execute.add_argument("--config", help="脚本配置 JSON 文件")
|
|
79
|
-
execute.add_argument("--assets", action="
|
|
79
|
+
execute.add_argument("--assets", action="extend", nargs="*", default=None,
|
|
80
|
+
help="仅携带指定资源;省略时携带 assets/,不提供路径时不携带资源")
|
|
80
81
|
execute.add_argument("--directory", default=".", help="内联代码所属仓库,默认当前目录")
|
|
81
82
|
execute.add_argument("--timeout", type=float, default=300, help="整个开发运行的传输时限,单位秒")
|
|
83
|
+
execute.add_argument("--retry-of-execution-id", help="明确续接的上一父真实执行ID;省略时新建延期链")
|
|
82
84
|
raw = sub.add_parser("request", help="直接发送当前 DevTools 协议 JSON")
|
|
83
85
|
raw.add_argument("body")
|
|
86
|
+
windows = sub.add_parser('windows', help='通过代劳随包 Rust 宿主调试本机 Windows')
|
|
87
|
+
windows.add_argument('--runtime', required=True, help='代劳安装目录中的 runtime/windows-x64')
|
|
88
|
+
windows.add_argument('--executable')
|
|
89
|
+
windows.add_argument('--hwnd', type=lambda value: int(value, 0))
|
|
90
|
+
commands = windows.add_subparsers(dest='windows_command', required=True)
|
|
91
|
+
commands.add_parser('applications')
|
|
92
|
+
commands.add_parser('desktops')
|
|
93
|
+
commands.add_parser('inspect')
|
|
94
|
+
commands.add_parser('screenshot').add_argument('path')
|
|
95
|
+
run = commands.add_parser('exec')
|
|
96
|
+
source = run.add_mutually_exclusive_group(required=True)
|
|
97
|
+
source.add_argument('--file')
|
|
98
|
+
source.add_argument('--code')
|
|
99
|
+
run.add_argument('--directory', default='.')
|
|
100
|
+
run.add_argument('--config')
|
|
101
|
+
run.add_argument('--timeout', type=float, default=300)
|
|
84
102
|
args = parser.parse_args()
|
|
103
|
+
if args.command == 'windows':
|
|
104
|
+
from .windows import Windows
|
|
105
|
+
device = Windows(args.runtime, executable=args.executable, hwnd=args.hwnd)
|
|
106
|
+
if args.windows_command == 'exec':
|
|
107
|
+
config = json.loads(Path(args.config).read_text(encoding='utf-8')) if args.config else {}
|
|
108
|
+
callback = lambda value: print(json.dumps(value, ensure_ascii=False), flush=True)
|
|
109
|
+
events = (device.run_file(args.file, config=config, timeout=args.timeout, on_event=callback)
|
|
110
|
+
if args.file else device.execute(args.code, directory=args.directory,
|
|
111
|
+
config=config, timeout=args.timeout, on_event=callback))
|
|
112
|
+
sys.exit(0 if events[-1].get('status') == 'success' else 1)
|
|
113
|
+
result = (device.screenshot(args.path) if args.windows_command == 'screenshot'
|
|
114
|
+
else getattr(device, args.windows_command)())
|
|
115
|
+
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
116
|
+
return
|
|
85
117
|
if args.command == "logs":
|
|
86
118
|
from .device import read_logs
|
|
87
119
|
print(json.dumps(read_logs(args.directory), ensure_ascii=False, indent=2))
|
|
@@ -108,7 +140,7 @@ def main():
|
|
|
108
140
|
elif args.command == "inspect":
|
|
109
141
|
body.update(pixels=args.pixels, nodes=not args.no_nodes, text=not args.no_text, raw=args.raw)
|
|
110
142
|
elif args.command == "exec":
|
|
111
|
-
body.update(timeout=args.timeout, assets=args.assets)
|
|
143
|
+
body.update(timeout=args.timeout, assets=args.assets, retry_of_execution_id=args.retry_of_execution_id)
|
|
112
144
|
if args.config:
|
|
113
145
|
body["config"] = json.loads(Path(args.config).read_text(encoding="utf-8"))
|
|
114
146
|
if args.file:
|
|
@@ -21,14 +21,30 @@ from urllib.parse import parse_qs, quote, urlencode, urlsplit
|
|
|
21
21
|
import aiohttp
|
|
22
22
|
from playwright.async_api import async_playwright
|
|
23
23
|
|
|
24
|
+
CLIENT_SOURCE_SHA256 = hashlib.sha256(Path(__file__).read_bytes()).hexdigest()
|
|
24
25
|
|
|
25
|
-
|
|
26
|
+
|
|
27
|
+
TRANSPORT = """async ({iceServers, localIceAddresses}) => {
|
|
26
28
|
const peer = window.peer = new RTCPeerConnection({iceServers});
|
|
29
|
+
window.localIceAddresses = localIceAddresses;
|
|
27
30
|
window.localCandidates = [];
|
|
28
31
|
peer.onicecandidate = event => {
|
|
29
32
|
if (event.candidate) {
|
|
33
|
+
if (localIceAddresses !== undefined && event.candidate.type !== 'host') return;
|
|
30
34
|
const {candidate, sdpMid, sdpMLineIndex} = event.candidate;
|
|
31
35
|
window.localCandidates.push({candidate, sdpMid, sdpMLineIndex});
|
|
36
|
+
window.transportSignal('ice', {candidate: {candidate, sdpMid, sdpMLineIndex}});
|
|
37
|
+
if (event.candidate.protocol === 'udp' && event.candidate.type === 'host'
|
|
38
|
+
&& event.candidate.address?.endsWith('.local')) {
|
|
39
|
+
for (const address of localIceAddresses ?? []) {
|
|
40
|
+
window.transportSignal('ice', {candidate: {
|
|
41
|
+
candidate: candidate.replace(` ${event.candidate.address} `, ` ${address} `),
|
|
42
|
+
sdpMid, sdpMLineIndex
|
|
43
|
+
}});
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
} else {
|
|
47
|
+
window.transportSignal('ice_complete', {});
|
|
32
48
|
}
|
|
33
49
|
};
|
|
34
50
|
const channels = window.channels = {};
|
|
@@ -51,20 +67,17 @@ TRANSPORT = """async (iceServers) => {
|
|
|
51
67
|
};
|
|
52
68
|
} """
|
|
53
69
|
|
|
54
|
-
CREATE_OFFER = """async () => {
|
|
70
|
+
CREATE_OFFER = r"""async () => {
|
|
55
71
|
await peer.setLocalDescription(await peer.createOffer());
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
});
|
|
60
|
-
});
|
|
61
|
-
return {sdp: peer.localDescription.sdp, candidates: window.localCandidates};
|
|
72
|
+
const sdp = peer.localDescription.sdp;
|
|
73
|
+
return {sdp: window.localIceAddresses === undefined ? sdp : sdp.split('\r\n').filter(line =>
|
|
74
|
+
!line.startsWith('a=candidate:') || line.split(' ')[7] === 'host').join('\r\n'), candidates: []};
|
|
62
75
|
} """
|
|
63
76
|
|
|
64
77
|
SEND_REQUEST = """async wire => {
|
|
65
78
|
const channel = channels.inspection;
|
|
66
79
|
const bytes = new TextEncoder().encode(wire);
|
|
67
|
-
const upload = window.devtoolsUpload = {totalBytes: bytes.length, queuedBytes: 0, terminatorSent: false};
|
|
80
|
+
const upload = window.devtoolsUpload = {requestId: JSON.parse(wire).id, totalBytes: bytes.length, queuedBytes: 0, terminatorSent: false};
|
|
68
81
|
channel.bufferedAmountLowThreshold = 65536;
|
|
69
82
|
for (let offset = 0; offset < bytes.length; offset += 16384) {
|
|
70
83
|
if (channel.readyState !== 'open') throw new Error('DevTools inspection channel closed');
|
|
@@ -99,6 +112,15 @@ TRANSPORT_SNAPSHOT = """() => ({
|
|
|
99
112
|
upload: window.devtoolsUpload
|
|
100
113
|
})"""
|
|
101
114
|
|
|
115
|
+
CANCEL_UPLOAD = """requestId => {
|
|
116
|
+
const upload = window.devtoolsUpload;
|
|
117
|
+
if (!upload || upload.requestId !== requestId || upload.terminatorSent) return null;
|
|
118
|
+
upload.cancelled = true;
|
|
119
|
+
const receipt = {...upload};
|
|
120
|
+
peer.close();
|
|
121
|
+
return receipt;
|
|
122
|
+
}"""
|
|
123
|
+
|
|
102
124
|
|
|
103
125
|
def _transport_error(error: Exception) -> str:
|
|
104
126
|
# 与持久serve一致,传输证据不保存带认证参数的连接URL。
|
|
@@ -145,6 +167,7 @@ class Device:
|
|
|
145
167
|
self.failure: Exception | None = None
|
|
146
168
|
self.failure_events: list[dict[str, Any]] = []
|
|
147
169
|
self.session_timeout = 1800.0
|
|
170
|
+
self.signal_activity: dict[str, Any] = {"pingCount": 0, "pongCount": 0}
|
|
148
171
|
|
|
149
172
|
async def __aenter__(self) -> Device:
|
|
150
173
|
await self.connect()
|
|
@@ -165,12 +188,19 @@ class Device:
|
|
|
165
188
|
self.browser = await self.playwright.chromium.launch(headless=True)
|
|
166
189
|
self.browser.on("disconnected", lambda _: self._fail(DevtoolsError("DevTools 后台浏览器已关闭"), source="browser"))
|
|
167
190
|
self.page = await self.browser.new_page()
|
|
191
|
+
self.offer_sent = asyncio.Event()
|
|
168
192
|
await self.page.expose_function("transportEvent", self._transport_event)
|
|
169
193
|
await self.page.expose_function("transportMessage", self._message)
|
|
170
|
-
|
|
171
|
-
|
|
194
|
+
await self.page.expose_function("transportSignal", self._local_signal)
|
|
195
|
+
ice = [] if local or self.local_ice_addresses is not None else [{"urls": "stun:stun.cloudflare.com:3478"}]
|
|
196
|
+
transport_config = {"iceServers": ice}
|
|
197
|
+
if self.local_ice_addresses is not None:
|
|
198
|
+
transport_config["localIceAddresses"] = self.local_ice_addresses
|
|
199
|
+
await self.page.evaluate(TRANSPORT, transport_config)
|
|
172
200
|
|
|
173
201
|
self.socket = await self.http.ws_connect(signal_url)
|
|
202
|
+
self.signal_activity = {"connectedAt": datetime.now(timezone.utc).isoformat(),
|
|
203
|
+
"requestedSessionTimeoutSeconds": self.session_timeout, "pingCount": 0, "pongCount": 0}
|
|
174
204
|
self.tasks.extend([
|
|
175
205
|
asyncio.create_task(self._signals()),
|
|
176
206
|
asyncio.create_task(self._keepalive()),
|
|
@@ -178,7 +208,9 @@ class Device:
|
|
|
178
208
|
try:
|
|
179
209
|
await asyncio.wait_for(self.ready.wait(), 30)
|
|
180
210
|
except TimeoutError:
|
|
181
|
-
|
|
211
|
+
failure = DevtoolsError("WebRTC 通道在 30 秒内未建立,请检查设备内 DevTools 服务与信令连接。")
|
|
212
|
+
self._fail(failure, source="connect_timeout", details=(await self.status())["diagnostics"])
|
|
213
|
+
raise failure from None
|
|
182
214
|
if self.failure:
|
|
183
215
|
raise self.failure
|
|
184
216
|
except BaseException:
|
|
@@ -192,12 +224,14 @@ class Device:
|
|
|
192
224
|
params = parse_qs(parsed.fragment)
|
|
193
225
|
admission = {key: params[key][0] for key in ("sessionId", "capability", "deviceId")}
|
|
194
226
|
local = bool(params.get("signalBaseUrl"))
|
|
227
|
+
self.local_ice_addresses = params.get("localIceAddress")
|
|
195
228
|
base = (params.get("signalBaseUrl") or params.get("signalFleetBaseUrl") or
|
|
196
229
|
[f"{parsed.scheme}://{parsed.netloc}"])[0]
|
|
197
230
|
else:
|
|
198
231
|
base = conf["baseUrl"].rstrip("/")
|
|
199
232
|
device_id = conf["deviceId"]
|
|
200
233
|
local = bool(conf.get("local"))
|
|
234
|
+
self.local_ice_addresses = conf.get("localIceAddresses")
|
|
201
235
|
if conf.get("operatorCredential"):
|
|
202
236
|
path = f"/api/operations/devices/{quote(device_id, safe='')}/sessions"
|
|
203
237
|
headers = {"X-Dailo-Operator": conf["operatorCredential"]}
|
|
@@ -215,7 +249,10 @@ class Device:
|
|
|
215
249
|
async with self.http.post(base + path, json=body, headers=headers) as response:
|
|
216
250
|
value = await response.json()
|
|
217
251
|
if response.status >= 400:
|
|
218
|
-
|
|
252
|
+
request_id = response.headers.get("cf-ray")
|
|
253
|
+
request = f"(CF-Ray {request_id})" if request_id else ""
|
|
254
|
+
raise DevtoolsError(f"DevTools 入场失败 HTTP {response.status}{request}: "
|
|
255
|
+
+ value.get("error", "服务未提供错误详情"))
|
|
219
256
|
admission = {**value, "deviceId": device_id}
|
|
220
257
|
parsed = urlsplit(base)
|
|
221
258
|
route = "/api/session/ws" if local else "/api/fleet/session"
|
|
@@ -241,7 +278,12 @@ class Device:
|
|
|
241
278
|
if event == "ready":
|
|
242
279
|
self.ready.set()
|
|
243
280
|
else:
|
|
281
|
+
first_failure = self.failure is None
|
|
244
282
|
self._fail(DevtoolsError("DevTools 通道已关闭"), source="transport", details=details)
|
|
283
|
+
if first_failure:
|
|
284
|
+
task = asyncio.create_task(self._close_failed_peer())
|
|
285
|
+
self.log_tasks.add(task)
|
|
286
|
+
task.add_done_callback(self.log_tasks.discard)
|
|
245
287
|
|
|
246
288
|
async def status(self) -> dict[str, Any]:
|
|
247
289
|
"""读取连接状态与当前运行标识。"""
|
|
@@ -251,15 +293,27 @@ class Device:
|
|
|
251
293
|
if self.failure is None:
|
|
252
294
|
raise
|
|
253
295
|
transport = {"errorClass": type(error).__name__, "error": _transport_error(error)}
|
|
296
|
+
try:
|
|
297
|
+
rtc_stats = await asyncio.wait_for(self.page.evaluate("""async () => {
|
|
298
|
+
const stats = await peer.getStats();
|
|
299
|
+
return Array.from(stats.values()).filter(value =>
|
|
300
|
+
['transport','candidate-pair','local-candidate','remote-candidate',
|
|
301
|
+
'data-channel','sctp-transport'].includes(value.type));
|
|
302
|
+
}"""), 1)
|
|
303
|
+
except Exception as error:
|
|
304
|
+
rtc_stats = {'errorClass': type(error).__name__, 'error': _transport_error(error)}
|
|
254
305
|
signal_error = self.socket.exception()
|
|
255
306
|
return {"deviceId": self.device_id, "sessionId": self.session_id,
|
|
256
307
|
"connectionState": "closed" if self.failure else transport["connectionState"],
|
|
257
308
|
"activeRun": self.active_python,
|
|
258
309
|
"error": _transport_error(self.failure) if self.failure else None,
|
|
259
310
|
"diagnostics": {"failureEvents": self.failure_events.copy(), "transport": transport,
|
|
311
|
+
"rtcStats": rtc_stats,
|
|
312
|
+
"client": {"file": __file__, "sha256": CLIENT_SOURCE_SHA256},
|
|
260
313
|
"signal": {"closed": self.socket.closed, "closeCode": self.socket.close_code,
|
|
261
314
|
"exceptionClass": type(signal_error).__name__ if signal_error else None,
|
|
262
|
-
"exception": _transport_error(signal_error) if signal_error else None
|
|
315
|
+
"exception": _transport_error(signal_error) if signal_error else None,
|
|
316
|
+
**self.signal_activity}}}
|
|
263
317
|
|
|
264
318
|
def _fail(self, failure, *, source="device", details=None):
|
|
265
319
|
self.failure_events.append({"recordedAt": datetime.now(timezone.utc).isoformat(),
|
|
@@ -287,13 +341,35 @@ class Device:
|
|
|
287
341
|
socket_error = self.socket.exception()
|
|
288
342
|
return {"phase": phase, "closed": self.socket.closed, "localCloseCode": self.socket.close_code,
|
|
289
343
|
"socketExceptionClass": type(socket_error).__name__ if socket_error else None,
|
|
290
|
-
"exceptionChain": _exception_chain(error if error is not None else socket_error)
|
|
344
|
+
"exceptionChain": _exception_chain(error if error is not None else socket_error),
|
|
345
|
+
**self.signal_activity}
|
|
346
|
+
|
|
347
|
+
async def _keep_established_rtc(self, phase, error=None):
|
|
348
|
+
"""信令异常断开不撤销已建立的RTC;授权期限仍由Agent执行。"""
|
|
349
|
+
if self.failure or self.socket.close_code != 1006:
|
|
350
|
+
return False
|
|
351
|
+
try:
|
|
352
|
+
transport = await self.page.evaluate(TRANSPORT_SNAPSHOT)
|
|
353
|
+
except Exception:
|
|
354
|
+
return False
|
|
355
|
+
if (transport["connectionState"] != "connected" or
|
|
356
|
+
not all(channel["readyState"] == "open" for channel in transport["channels"].values())):
|
|
357
|
+
return False
|
|
358
|
+
self.signal_activity.setdefault("interruptedAt", datetime.now(timezone.utc).isoformat())
|
|
359
|
+
self.signal_activity["interruption"] = {
|
|
360
|
+
"phase": phase, "transport": transport,
|
|
361
|
+
"exceptionChain": _exception_chain(error if error is not None else self.socket.exception()),
|
|
362
|
+
}
|
|
363
|
+
return True
|
|
291
364
|
|
|
292
365
|
async def _signals(self):
|
|
293
366
|
offered = False
|
|
294
367
|
try:
|
|
295
368
|
async for message in self.socket:
|
|
369
|
+
self.signal_activity["lastReceivedAt"] = datetime.now(timezone.utc).isoformat()
|
|
296
370
|
if message.type == aiohttp.WSMsgType.ERROR:
|
|
371
|
+
if await self._keep_established_rtc("receive_error", message.data):
|
|
372
|
+
return
|
|
297
373
|
self._fail(DevtoolsError(f"信令连接错误({type(message.data).__name__}):{_transport_error(message.data)}"),
|
|
298
374
|
source="signaling", details={"messageType": "ERROR", "exceptionClass": type(message.data).__name__,
|
|
299
375
|
"exception": _transport_error(message.data),
|
|
@@ -302,6 +378,8 @@ class Device:
|
|
|
302
378
|
if message.type != aiohttp.WSMsgType.TEXT:
|
|
303
379
|
continue
|
|
304
380
|
if message.data == "dailo:signaling:pong":
|
|
381
|
+
self.signal_activity["lastPongReceivedAt"] = self.signal_activity["lastReceivedAt"]
|
|
382
|
+
self.signal_activity["pongCount"] += 1
|
|
305
383
|
continue
|
|
306
384
|
envelope = json.loads(message.data)["payload"]
|
|
307
385
|
signal = envelope["payload"]
|
|
@@ -313,8 +391,13 @@ class Device:
|
|
|
313
391
|
offered = True
|
|
314
392
|
offer = await self.page.evaluate(CREATE_OFFER)
|
|
315
393
|
await self._signal("offer", offer)
|
|
394
|
+
self.offer_sent.set()
|
|
316
395
|
elif kind == "answer":
|
|
317
|
-
|
|
396
|
+
sdp = payload['sdp']
|
|
397
|
+
if self.local_ice_addresses is not None:
|
|
398
|
+
sdp = '\r\n'.join(line for line in sdp.split('\r\n')
|
|
399
|
+
if not line.startswith('a=candidate:') or line.split(' ')[7] == 'host')
|
|
400
|
+
await self.page.evaluate("sdp => peer.setRemoteDescription({type:'answer', sdp})", sdp)
|
|
318
401
|
for candidate in payload["candidates"]:
|
|
319
402
|
await self._candidate(candidate)
|
|
320
403
|
elif kind == "ice":
|
|
@@ -325,6 +408,8 @@ class Device:
|
|
|
325
408
|
raise DevtoolsError("设备结束了开发会话")
|
|
326
409
|
code = self.socket.close_code
|
|
327
410
|
error = self.socket.exception()
|
|
411
|
+
if await self._keep_established_rtc("iterator_ended", error):
|
|
412
|
+
return
|
|
328
413
|
self._fail(DevtoolsError(
|
|
329
414
|
"开发会话授权已到期。"
|
|
330
415
|
if code == 4008 else f"信令连接已关闭({code})" +
|
|
@@ -340,18 +425,32 @@ class Device:
|
|
|
340
425
|
# 信令已失效时立即关闭旧通道,不等下一条命令才释放。
|
|
341
426
|
await self._close_failed_peer()
|
|
342
427
|
|
|
428
|
+
async def _local_signal(self, kind, payload):
|
|
429
|
+
# ICE 可持续收集;候选地址必须在 offer 发出后逐个送达设备。
|
|
430
|
+
await self.offer_sent.wait()
|
|
431
|
+
await self._signal(kind, payload)
|
|
432
|
+
|
|
343
433
|
async def _candidate(self, value):
|
|
434
|
+
if self.local_ice_addresses is not None and value['candidate'].split(' ')[7] != 'host':
|
|
435
|
+
return
|
|
344
436
|
await self.page.evaluate("candidate => peer.addIceCandidate(candidate)", value)
|
|
345
437
|
|
|
346
438
|
async def _keepalive(self):
|
|
347
439
|
try:
|
|
348
440
|
while True:
|
|
349
441
|
await asyncio.sleep(30)
|
|
442
|
+
if self.signal_activity.get("interruption"):
|
|
443
|
+
return
|
|
350
444
|
await self.socket.send_str("dailo:signaling:ping")
|
|
445
|
+
self.signal_activity["lastPingSentAt"] = datetime.now(timezone.utc).isoformat()
|
|
446
|
+
self.signal_activity["pingCount"] += 1
|
|
351
447
|
except asyncio.CancelledError:
|
|
352
448
|
raise
|
|
353
449
|
except Exception as error:
|
|
354
|
-
self.
|
|
450
|
+
if await self._keep_established_rtc("keepalive_send_failed", error):
|
|
451
|
+
return
|
|
452
|
+
self._fail(DevtoolsError(f"信令保活发送失败({type(error).__name__}):{_transport_error(error)}"),
|
|
453
|
+
source="signaling_keepalive", details=self._signal_details("keepalive_send_failed", error))
|
|
355
454
|
await self._close_failed_peer()
|
|
356
455
|
|
|
357
456
|
def _message(self, wire):
|
|
@@ -454,9 +553,9 @@ class Device:
|
|
|
454
553
|
return result
|
|
455
554
|
except TimeoutError:
|
|
456
555
|
if body["kind"] == "python":
|
|
457
|
-
await self.page.evaluate("wire => channels.control.send(wire)", json.dumps({
|
|
458
|
-
"kind": "python_cancel", "id": uuid.uuid4().hex, "requestId": request_id}))
|
|
459
556
|
try:
|
|
557
|
+
await self.page.evaluate("wire => channels.control.send(wire)", json.dumps({
|
|
558
|
+
"kind": "python_cancel", "id": uuid.uuid4().hex, "requestId": request_id}))
|
|
460
559
|
# 保留原响应future,接收停止后的stdout、栈和同帧日志。
|
|
461
560
|
result = await asyncio.wait_for(future, 30)
|
|
462
561
|
except TimeoutError:
|
|
@@ -464,17 +563,15 @@ class Device:
|
|
|
464
563
|
self._fail(failure, source="python_timeout")
|
|
465
564
|
with contextlib.suppress(Exception):
|
|
466
565
|
await self.page.evaluate("peer.close()")
|
|
467
|
-
raise failure from None
|
|
566
|
+
raise self._record_interruption(failure, request_id, request_evidence) from None
|
|
468
567
|
except Exception as error:
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
raise
|
|
568
|
+
raise self._record_interruption(error, request_id, request_evidence) from None
|
|
569
|
+
result['transportDeadlineExceeded'] = True
|
|
472
570
|
self._finish_logs(result, request_id)
|
|
473
|
-
|
|
474
|
-
raise DevtoolsError(f"开发运行在 {timeout:g} 秒内未结束,停止结果已保存:{path}") from None
|
|
571
|
+
return result
|
|
475
572
|
raise DevtoolsError(f"设备未响应 {body['kind']}({timeout:g} 秒超时)。") from None
|
|
476
573
|
except Exception as error:
|
|
477
|
-
if body["kind"] == "python"
|
|
574
|
+
if body["kind"] == "python":
|
|
478
575
|
raise self._record_interruption(error, request_id, request_evidence) from None
|
|
479
576
|
raise
|
|
480
577
|
finally:
|
|
@@ -484,15 +581,22 @@ class Device:
|
|
|
484
581
|
def _record_interruption(self, error, request_id, request_evidence):
|
|
485
582
|
folder = self._log_folder(request_id)
|
|
486
583
|
path = folder / 'interruption.json'
|
|
487
|
-
|
|
584
|
+
upload = (request_evidence.get('transport') or {}).get('upload') or {}
|
|
585
|
+
upload_cancelled = (upload.get('requestId') == request_id
|
|
586
|
+
and upload.get('cancelled') and not upload.get('terminatorSent'))
|
|
587
|
+
record = {
|
|
488
588
|
"recordedAt": datetime.now(timezone.utc).isoformat(),
|
|
489
589
|
"requestId": request_id, "sessionId": self.session_id,
|
|
490
|
-
"deviceId": self.device_id, "outcome": "unconfirmed",
|
|
491
|
-
"error": _transport_error(error), "
|
|
590
|
+
"deviceId": self.device_id, "outcome": "cancelled_before_dispatch" if upload_cancelled else "unconfirmed",
|
|
591
|
+
"error": _transport_error(error), "exceptionClass": type(error).__name__,
|
|
592
|
+
"failureEvents": self.failure_events.copy(),
|
|
492
593
|
"request": request_evidence,
|
|
493
594
|
"logDirectory": str(folder), "logsPath": str(folder / 'logs.json'),
|
|
494
|
-
}
|
|
495
|
-
|
|
595
|
+
}
|
|
596
|
+
path.write_text(json.dumps(record, ensure_ascii=False, indent=2), encoding='utf-8')
|
|
597
|
+
self._finish_logs({"ok": False, **record, "interruptionPath": str(path)}, request_id)
|
|
598
|
+
outcome = "上传已取消,脚本未提交" if upload_cancelled else "运行结果未确认"
|
|
599
|
+
return DevtoolsError(f"{record['error']};{outcome},断线记录已保存:{path}")
|
|
496
600
|
|
|
497
601
|
async def _execute(self, source: str, *, timeout: float = 300) -> dict[str, Any]:
|
|
498
602
|
"""执行 Python,返回运行结果;result 含 stdout、stderr 和执行状态。"""
|
|
@@ -516,8 +620,10 @@ class Device:
|
|
|
516
620
|
folder = self._log_folder(request_id)
|
|
517
621
|
picture = entry.get('image')
|
|
518
622
|
if picture:
|
|
519
|
-
path = folder / (
|
|
520
|
-
|
|
623
|
+
path = folder / (picture['sha256'] + '.png')
|
|
624
|
+
data = picture.pop('dataBase64')
|
|
625
|
+
if not path.exists():
|
|
626
|
+
path.write_bytes(base64.b64decode(data))
|
|
521
627
|
picture['path'] = str(path)
|
|
522
628
|
path = folder / 'logs.json'
|
|
523
629
|
if not path.exists():
|
|
@@ -543,9 +649,14 @@ class Device:
|
|
|
543
649
|
(folder / 'execution.json').write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding='utf-8')
|
|
544
650
|
|
|
545
651
|
async def cancel(self) -> dict[str, Any]:
|
|
546
|
-
"""
|
|
652
|
+
"""停止开发脚本;未提交的上传关闭旧通道,避免半包留到下一请求。"""
|
|
547
653
|
if self.active_python:
|
|
548
|
-
|
|
654
|
+
request_id = self.active_python
|
|
655
|
+
upload = await self.page.evaluate(CANCEL_UPLOAD, request_id)
|
|
656
|
+
if upload:
|
|
657
|
+
return {"ok": True, "running": False, "cancelRequested": True,
|
|
658
|
+
"requestId": request_id, "uploadAborted": True, "upload": upload}
|
|
659
|
+
return await self.request({"kind": "python_cancel", "requestId": request_id})
|
|
549
660
|
return {"ok": True, "running": False}
|
|
550
661
|
|
|
551
662
|
async def screenshot(self, path: str | Path) -> dict[str, Any]:
|
|
@@ -576,30 +687,36 @@ class Device:
|
|
|
576
687
|
return value
|
|
577
688
|
|
|
578
689
|
async def run_file(self, path: str | Path, *, config: Mapping[str, Any] | None = None,
|
|
579
|
-
assets: Iterable[str | Path] =
|
|
580
|
-
|
|
690
|
+
assets: Iterable[str | Path] | None = None, timeout: float = 300,
|
|
691
|
+
retry_of_execution_id: str | None = None) -> dict[str, Any]:
|
|
692
|
+
"""携带仓库 Python 模块;assets=None 默认携带 assets/,显式列表仅携带指定资源。"""
|
|
581
693
|
path = Path(path).resolve()
|
|
582
|
-
return await self._run_workspace(path, path.read_text(encoding="utf-8"), config, assets, timeout
|
|
694
|
+
return await self._run_workspace(path, path.read_text(encoding="utf-8"), config, assets, timeout,
|
|
695
|
+
retry_of_execution_id)
|
|
583
696
|
|
|
584
697
|
async def execute(self, source: str, *, directory: str | Path | None = None,
|
|
585
698
|
config: Mapping[str, Any] | None = None,
|
|
586
|
-
assets: Iterable[str | Path] =
|
|
699
|
+
assets: Iterable[str | Path] | None = None, timeout: float = 300,
|
|
700
|
+
retry_of_execution_id: str | None = None) -> dict[str, Any]:
|
|
587
701
|
"""在当前仓库的设备工作目录执行代码;模块、相对路径和 assets 与 run_file 一致。"""
|
|
588
702
|
folder = Path(directory or Path.cwd()).resolve()
|
|
589
|
-
return await self._run_workspace(folder / ".dailo-repl.py", source, config, assets, timeout
|
|
703
|
+
return await self._run_workspace(folder / ".dailo-repl.py", source, config, assets, timeout,
|
|
704
|
+
retry_of_execution_id)
|
|
590
705
|
|
|
591
706
|
async def _run_workspace(self, path: Path, source: str, config: Mapping[str, Any] | None,
|
|
592
|
-
assets: Iterable[str | Path], timeout: float
|
|
707
|
+
assets: Iterable[str | Path] | None, timeout: float,
|
|
708
|
+
retry_of_execution_id: str | None) -> dict[str, Any]:
|
|
593
709
|
config = dict(config or {})
|
|
594
710
|
archive = io.BytesIO()
|
|
595
711
|
with zipfile.ZipFile(archive, "w", zipfile.ZIP_DEFLATED) as bundle:
|
|
596
712
|
files = set()
|
|
597
713
|
for folder, directories, names in os.walk(path.parent):
|
|
598
714
|
directories[:] = [name for name in directories if name not in {
|
|
599
|
-
'.git', '.venv', 'node_modules', 'downloads', 'data', 'state', '__pycache__', 'dist',
|
|
715
|
+
'.git', '.venv', 'node_modules', 'downloads', 'data', 'state', '__pycache__', 'dist', 'assets',
|
|
716
|
+
'artifacts', 'target',
|
|
600
717
|
}]
|
|
601
718
|
files.update(Path(folder) / name for name in names if name.endswith('.py'))
|
|
602
|
-
for entry in [
|
|
719
|
+
for entry in ['assets'] if assets is None else assets:
|
|
603
720
|
asset = (path.parent / entry).resolve()
|
|
604
721
|
for item in (asset.rglob("*") if asset.is_dir() else [asset]):
|
|
605
722
|
if item.is_file():
|
|
@@ -613,9 +730,10 @@ class Device:
|
|
|
613
730
|
program = f'''def __run_local_file():
|
|
614
731
|
import os, sys, io, json, base64, zipfile, importlib.util
|
|
615
732
|
from pathlib import Path
|
|
616
|
-
from dailo._runtime import bind, host, config as get_config
|
|
733
|
+
from dailo._runtime import bind, host, config as get_config, execution as get_execution
|
|
617
734
|
native_host = host()
|
|
618
735
|
previous_config = get_config()
|
|
736
|
+
previous_execution = get_execution()
|
|
619
737
|
workspace = json.loads(native_host._app_operation(json.dumps({{"operation":"workspace", "name":{workspace!r}}})))
|
|
620
738
|
if not workspace['ok']:
|
|
621
739
|
raise RuntimeError(workspace['error'])
|
|
@@ -655,7 +773,8 @@ class Device:
|
|
|
655
773
|
if not Path(entry or previous_dir).resolve().is_relative_to(workspace_root)]
|
|
656
774
|
previous_path = sys.path.copy()
|
|
657
775
|
os.chdir(folder)
|
|
658
|
-
bind(native_host, {config or {}!r}
|
|
776
|
+
bind(native_host, {config or {}!r}, {{**previous_execution,
|
|
777
|
+
'retry_of_execution_id': {retry_of_execution_id!r}}})
|
|
659
778
|
try:
|
|
660
779
|
scope = {{'__file__': os.path.join(folder, {path.name!r}), '__name__': '__main__'}}
|
|
661
780
|
sys.path.insert(0, folder)
|
|
@@ -668,7 +787,7 @@ class Device:
|
|
|
668
787
|
scope.clear()
|
|
669
788
|
finally:
|
|
670
789
|
os.chdir(previous_dir)
|
|
671
|
-
bind(native_host, previous_config)
|
|
790
|
+
bind(native_host, previous_config, previous_execution)
|
|
672
791
|
try:
|
|
673
792
|
__run_local_file()
|
|
674
793
|
finally:
|
|
@@ -678,6 +797,10 @@ finally:
|
|
|
678
797
|
|
|
679
798
|
async def close(self) -> None:
|
|
680
799
|
"""结束开发会话并关闭后台 Chromium。"""
|
|
800
|
+
if hasattr(self, "socket") and not self.socket.closed:
|
|
801
|
+
self.signal_activity["localCloseRequestedAt"] = datetime.now(timezone.utc).isoformat()
|
|
802
|
+
if hasattr(self, "socket"):
|
|
803
|
+
await self.socket.close()
|
|
681
804
|
tasks = [*self.tasks, *self.log_tasks]
|
|
682
805
|
for task in tasks:
|
|
683
806
|
task.cancel()
|
|
@@ -687,8 +810,6 @@ finally:
|
|
|
687
810
|
self.tasks.clear()
|
|
688
811
|
self.log_tasks.clear()
|
|
689
812
|
self.chunks.clear()
|
|
690
|
-
if hasattr(self, "socket"):
|
|
691
|
-
await self.socket.close()
|
|
692
813
|
if hasattr(self, "browser"):
|
|
693
814
|
await self.browser.close()
|
|
694
815
|
if hasattr(self, "playwright"):
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Windows 调试复用代劳随包 Rust 宿主,无需安装系统或视觉库。"""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import base64
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import subprocess
|
|
7
|
+
import tempfile
|
|
8
|
+
import time
|
|
9
|
+
import uuid
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Windows:
|
|
13
|
+
def __init__(self, runtime, *, executable=None, hwnd=None):
|
|
14
|
+
self.runtime = Path(runtime).resolve()
|
|
15
|
+
self.target = {'executable': str(Path(executable).resolve()) if executable else None, 'hwnd': hwnd}
|
|
16
|
+
self._process = None
|
|
17
|
+
|
|
18
|
+
def _call(self, operation, *, on_event=None, **payload):
|
|
19
|
+
request = {'operation': operation, 'runtime': str(self.runtime), 'target': self.target, **payload}
|
|
20
|
+
events = []
|
|
21
|
+
with subprocess.Popen([str(self.runtime/'python.exe'), '-u', '-m', 'dailo.windows._worker'],
|
|
22
|
+
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
|
23
|
+
text=True, encoding='utf-8', creationflags=subprocess.CREATE_NO_WINDOW) as process:
|
|
24
|
+
self._process = process
|
|
25
|
+
try:
|
|
26
|
+
process.stdin.write(json.dumps(request, ensure_ascii=False)+'\n')
|
|
27
|
+
process.stdin.flush()
|
|
28
|
+
for line in process.stdout:
|
|
29
|
+
event = json.loads(line)
|
|
30
|
+
events.append(event)
|
|
31
|
+
if on_event:
|
|
32
|
+
on_event(event)
|
|
33
|
+
if event['type'] == 'finished' or operation in ('inventory', 'desktops'):
|
|
34
|
+
break
|
|
35
|
+
process.stdin.close()
|
|
36
|
+
process.wait()
|
|
37
|
+
if process.returncode:
|
|
38
|
+
raise RuntimeError(f"Windows 宿主退出({process.returncode}):{process.stderr.read().strip()}")
|
|
39
|
+
finally:
|
|
40
|
+
self._process = None
|
|
41
|
+
return events
|
|
42
|
+
|
|
43
|
+
def applications(self):
|
|
44
|
+
return self._call('inventory')[0]['windows']
|
|
45
|
+
|
|
46
|
+
def desktops(self):
|
|
47
|
+
return self._call('desktops')[0]['desktops']
|
|
48
|
+
|
|
49
|
+
def inspect(self):
|
|
50
|
+
return next(e for e in self._call('inspect') if e['type'] == 'inspect')
|
|
51
|
+
|
|
52
|
+
def screenshot(self, path):
|
|
53
|
+
image = next(e['image'] for e in self._call('preview') if e['type'] == 'preview')
|
|
54
|
+
Path(path).write_bytes(base64.b64decode(image['dataBase64']))
|
|
55
|
+
return {'path': str(Path(path).resolve()), 'width': image['width'], 'height': image['height']}
|
|
56
|
+
|
|
57
|
+
def run_file(self, path, *, config=None, timeout=300, on_event=None, retry_of_execution_id=None):
|
|
58
|
+
path = Path(path).resolve()
|
|
59
|
+
return self._call('execute', scriptRoot=str(path.parent), entryPoint=path.name,
|
|
60
|
+
execution=self._execution(config, timeout, retry_of_execution_id),
|
|
61
|
+
on_event=on_event)
|
|
62
|
+
|
|
63
|
+
def execute(self, source, *, directory='.', config=None, timeout=300, on_event=None, retry_of_execution_id=None):
|
|
64
|
+
with tempfile.TemporaryDirectory(prefix='dailo-dev-') as temporary:
|
|
65
|
+
path = Path(temporary)/'main.py'
|
|
66
|
+
path.write_text(source, encoding='utf-8')
|
|
67
|
+
return self._call('execute', scriptRoot=str(Path(directory).resolve()), entryPoint=str(path),
|
|
68
|
+
execution=self._execution(config, timeout, retry_of_execution_id),
|
|
69
|
+
on_event=on_event)
|
|
70
|
+
|
|
71
|
+
@staticmethod
|
|
72
|
+
def _execution(config, timeout, retry_of_execution_id):
|
|
73
|
+
return {'executionId': 'devtools:windows:' + uuid.uuid4().hex,
|
|
74
|
+
'retryOfExecutionId': retry_of_execution_id,
|
|
75
|
+
'config': config or {}, 'deadlineAtMs': round((time.time()+timeout)*1000)}
|
|
76
|
+
|
|
77
|
+
def cancel(self):
|
|
78
|
+
if self._process is not None:
|
|
79
|
+
self._process.stdin.write('{"type":"cancel"}\n')
|
|
80
|
+
self._process.stdin.flush()
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: dailo-devtools
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.5
|
|
4
4
|
Summary: 通过代劳 DevTools 调试单台 Android 设备的 Python API 与 CLI
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
Project-URL: Script SDK, https://pypi.org/project/dailo-sdk/
|
|
@@ -19,7 +19,7 @@ Dynamic: license-file
|
|
|
19
19
|
|
|
20
20
|
# Dailo DevTools
|
|
21
21
|
|
|
22
|
-
在电脑上运行和调试代劳 Android 自动化脚本。提供 `dailo-dev`
|
|
22
|
+
在电脑上运行和调试代劳 Android 与 Windows 自动化脚本。提供 `dailo-dev` 命令行工具与 Python 客户端,可执行脚本、读取文字与控件、保存截图和查看日志。
|
|
23
23
|
|
|
24
24
|
- 命令行执行 Python 文件或短代码。
|
|
25
25
|
- Python API 支持连接设备、截图、观察和停止运行。
|
|
@@ -55,6 +55,28 @@ dailo-dev serve --connection connection.json
|
|
|
55
55
|
|
|
56
56
|
保持服务运行,在另一个终端执行后续命令。连接文件包含访问设备的凭据,请保存在本机;链接过期或重新连接时,从 App 获取新链接。
|
|
57
57
|
|
|
58
|
+
## Windows 本机调试
|
|
59
|
+
|
|
60
|
+
Windows 使用代劳安装目录中的 `runtime/windows-x64`,复用产品的 Rust 宿主和脚本执行通路,无需安装 Python 系统或视觉库。`--hwnd` 或 `--executable` 选择目标;省略时观察当前显示桌面。
|
|
61
|
+
|
|
62
|
+
```sh
|
|
63
|
+
dailo-dev windows --runtime "C:\Apps\代劳\runtime\windows-x64" applications
|
|
64
|
+
dailo-dev windows --runtime "C:\Apps\代劳\runtime\windows-x64" desktops
|
|
65
|
+
dailo-dev windows --runtime "C:\Apps\代劳\runtime\windows-x64" --hwnd 123456 inspect
|
|
66
|
+
dailo-dev windows --runtime "C:\Apps\代劳\runtime\windows-x64" --hwnd 123456 screenshot screen.png
|
|
67
|
+
dailo-dev windows --runtime "C:\Apps\代劳\runtime\windows-x64" --hwnd 123456 exec --file main.py --timeout 300
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Python 客户端提供同一能力;`cancel()` 可由调用方其他线程停止正在执行的脚本:
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
from dailo_devtools import Windows
|
|
74
|
+
|
|
75
|
+
device = Windows(r"C:\Apps\代劳\runtime\windows-x64", hwnd=123456)
|
|
76
|
+
print(device.inspect())
|
|
77
|
+
device.run_file("main.py", config={"rounds": 5})
|
|
78
|
+
```
|
|
79
|
+
|
|
58
80
|
## 运行脚本
|
|
59
81
|
|
|
60
82
|
执行一段代码:
|
|
@@ -71,6 +93,13 @@ dailo-dev exec --file main.py --config settings.json
|
|
|
71
93
|
|
|
72
94
|
脚本可使用 [dailo-sdk](https://pypi.org/project/dailo-sdk/) 的文字、控件和图片定位 API。文件模式会自动上传脚本目录中的 Python 模块和 `assets/`;`--timeout` 设置执行时限,单位为秒,默认 300。
|
|
73
95
|
|
|
96
|
+
只携带本次所需资源时,使用 `--assets` 指定相对脚本目录的文件或目录;设备上保留相同相对路径。省略此参数时携带整个 `assets/`,单独使用 `--assets` 时不携带资源。Python 模块始终自动携带。
|
|
97
|
+
|
|
98
|
+
```sh
|
|
99
|
+
dailo-dev exec --file main.py --assets assets/battle.png assets/maps
|
|
100
|
+
dailo-dev exec --code "from dailo import text; print(text())" --assets
|
|
101
|
+
```
|
|
102
|
+
|
|
74
103
|
## 常用命令
|
|
75
104
|
|
|
76
105
|
| 命令 | 用途 |
|
|
@@ -109,6 +138,19 @@ asyncio.run(main())
|
|
|
109
138
|
|
|
110
139
|
`Device` 还提供 `run_file(path, config=...)`、`inspect()` 和 `cancel()`。使用 Python 客户端时无需启动 `dailo-dev serve`;异步上下文管理器负责建立和关闭连接。
|
|
111
140
|
|
|
141
|
+
Android执行超时后请求停止;收到终态时直接返回原结果并标记`transportDeadlineExceeded`,可核`errorCode`和日志。停止后仍未收到结果或连接中断时才抛异常,保留未确认记录,不自动重放。
|
|
142
|
+
|
|
143
|
+
`run_file()` 和 `execute()` 的 `assets=None` 默认携带 `assets/`。传入显式列表会只携带选定资源,`assets=[]` 不携带资源,例如:
|
|
144
|
+
|
|
145
|
+
```python
|
|
146
|
+
await device.run_file("main.py", assets=["assets/battle.png", "assets/maps"])
|
|
147
|
+
await device.execute("from dailo import text; print(text())", assets=[])
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Windows 的 `run_file()`、`execute()` 每次生成独立 `devtools:windows:…` 执行 ID,脚本通过 `execution()` 读取。明确续接某次失败时传入 `retry_of_execution_id=原执行ID`;新调用默认不关联旧父。调试 ID 不代表产品任务记录。
|
|
151
|
+
|
|
152
|
+
Android 的同名参数沿用Host提供的本父真实执行ID,只设置本次重试来源,退出后恢复原上下文。续接连续延期时,传入上一父脚本中`dailo.execution().execution_id`的完整原值;不要传请求短ID、sessionId或自行拼接ID。CLI使用`--retry-of-execution-id`,持久服务的`execute`/`run_file`请求使用`retry_of_execution_id`。省略参数表示新调用,不沿用旧延期链;该参数不修改原延期时间,也不绕过最早执行时间。
|
|
153
|
+
|
|
112
154
|
## 许可
|
|
113
155
|
|
|
114
156
|
MIT。
|
|
@@ -5,6 +5,7 @@ src/dailo_devtools/__init__.py
|
|
|
5
5
|
src/dailo_devtools/cli.py
|
|
6
6
|
src/dailo_devtools/device.py
|
|
7
7
|
src/dailo_devtools/py.typed
|
|
8
|
+
src/dailo_devtools/windows.py
|
|
8
9
|
src/dailo_devtools.egg-info/PKG-INFO
|
|
9
10
|
src/dailo_devtools.egg-info/SOURCES.txt
|
|
10
11
|
src/dailo_devtools.egg-info/dependency_links.txt
|
dailo_devtools-0.1.3/README.md
DELETED
|
@@ -1,95 +0,0 @@
|
|
|
1
|
-
# Dailo DevTools
|
|
2
|
-
|
|
3
|
-
在电脑上运行和调试代劳 Android 自动化脚本。提供 `dailo-dev` 命令行工具和异步 Python 客户端,可执行脚本、读取文字与控件、保存截图和查看日志。
|
|
4
|
-
|
|
5
|
-
- 命令行执行 Python 文件或短代码。
|
|
6
|
-
- Python API 支持连接设备、截图、观察和停止运行。
|
|
7
|
-
- 自动携带脚本目录中的 Python 模块与 `assets/`。
|
|
8
|
-
- 图文日志保存到电脑,可离线查看。
|
|
9
|
-
|
|
10
|
-
## 安装
|
|
11
|
-
|
|
12
|
-
需要 Python 3.11 或更新版本,以及已安装代劳 App、开启 DevTools 的 Android 设备。
|
|
13
|
-
|
|
14
|
-
```sh
|
|
15
|
-
python -m pip install dailo-devtools
|
|
16
|
-
dailo-dev install
|
|
17
|
-
```
|
|
18
|
-
|
|
19
|
-
`dailo-dev install` 安装后台连接所需的 Chromium 运行时,只需执行一次。
|
|
20
|
-
|
|
21
|
-
## 连接设备
|
|
22
|
-
|
|
23
|
-
从代劳 App 的 DevTools 入口复制连接链接,保存为 `connection.json`:
|
|
24
|
-
|
|
25
|
-
```json
|
|
26
|
-
{
|
|
27
|
-
"url": "<从代劳 App 复制的 DevTools 链接>"
|
|
28
|
-
}
|
|
29
|
-
```
|
|
30
|
-
|
|
31
|
-
启动连接服务:
|
|
32
|
-
|
|
33
|
-
```sh
|
|
34
|
-
dailo-dev serve --connection connection.json
|
|
35
|
-
```
|
|
36
|
-
|
|
37
|
-
保持服务运行,在另一个终端执行后续命令。连接文件包含访问设备的凭据,请保存在本机;链接过期或重新连接时,从 App 获取新链接。
|
|
38
|
-
|
|
39
|
-
## 运行脚本
|
|
40
|
-
|
|
41
|
-
执行一段代码:
|
|
42
|
-
|
|
43
|
-
```sh
|
|
44
|
-
dailo-dev exec --code "from dailo import text; print(text())"
|
|
45
|
-
```
|
|
46
|
-
|
|
47
|
-
执行 Python 文件并传入任务设置:
|
|
48
|
-
|
|
49
|
-
```sh
|
|
50
|
-
dailo-dev exec --file main.py --config settings.json
|
|
51
|
-
```
|
|
52
|
-
|
|
53
|
-
脚本可使用 [dailo-sdk](https://pypi.org/project/dailo-sdk/) 的文字、控件和图片定位 API。文件模式会自动上传脚本目录中的 Python 模块和 `assets/`;`--timeout` 设置执行时限,单位为秒,默认 300。
|
|
54
|
-
|
|
55
|
-
## 常用命令
|
|
56
|
-
|
|
57
|
-
| 命令 | 用途 |
|
|
58
|
-
| --- | --- |
|
|
59
|
-
| `dailo-dev status` | 查看连接和运行状态 |
|
|
60
|
-
| `dailo-dev inspect` | 读取设备画面的文字与控件 |
|
|
61
|
-
| `dailo-dev screenshot screen.png` | 保存设备截图 |
|
|
62
|
-
| `dailo-dev cancel` | 停止当前脚本 |
|
|
63
|
-
| `dailo-dev reconnect` | 使用持久连接配置重新连接设备 |
|
|
64
|
-
| `dailo-dev logs <日志目录>` | 离线查看图文日志 |
|
|
65
|
-
|
|
66
|
-
运行 `dailo-dev --help` 或 `dailo-dev exec --help` 查看完整选项。
|
|
67
|
-
|
|
68
|
-
## Python API
|
|
69
|
-
|
|
70
|
-
```python
|
|
71
|
-
import asyncio
|
|
72
|
-
import json
|
|
73
|
-
from pathlib import Path
|
|
74
|
-
|
|
75
|
-
from dailo_devtools import Device, read_logs
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
async def main():
|
|
79
|
-
connection = json.loads(Path("connection.json").read_text(encoding="utf-8"))
|
|
80
|
-
|
|
81
|
-
async with Device(connection) as device:
|
|
82
|
-
result = await device.execute("from dailo import text; print(text())")
|
|
83
|
-
await device.screenshot("screen.png")
|
|
84
|
-
print(result)
|
|
85
|
-
print(read_logs(result["logDirectory"]))
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
asyncio.run(main())
|
|
89
|
-
```
|
|
90
|
-
|
|
91
|
-
`Device` 还提供 `run_file(path, config=...)`、`inspect()` 和 `cancel()`。使用 Python 客户端时无需启动 `dailo-dev serve`;异步上下文管理器负责建立和关闭连接。
|
|
92
|
-
|
|
93
|
-
## 许可
|
|
94
|
-
|
|
95
|
-
MIT。
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{dailo_devtools-0.1.3 → dailo_devtools-0.1.5}/src/dailo_devtools.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|