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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dailo-devtools
3
- Version: 0.1.3
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` 命令行工具和异步 Python 客户端,可执行脚本、读取文字与控件、保存截图和查看日志。
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,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "dailo-devtools"
7
- version = "0.1.3"
7
+ version = "0.1.5"
8
8
  description = "通过代劳 DevTools 调试单台 Android 设备的 Python API 与 CLI"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -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="append", default=[])
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
- TRANSPORT = """async (iceServers) => {
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
- if (peer.iceGatheringState !== 'complete') await new Promise(resolve => {
57
- peer.addEventListener('icegatheringstatechange', () => {
58
- if (peer.iceGatheringState === 'complete') resolve();
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
- ice = [] if local else [{"urls": "stun:stun.cloudflare.com:3478"}]
171
- await self.page.evaluate(TRANSPORT, ice)
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
- raise DevtoolsError("WebRTC 通道在 30 秒内未建立,请检查设备内 DevTools 服务与信令连接。") from None
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
- raise DevtoolsError(value.get("error", f"DevTools 入场失败 HTTP {response.status}"))
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
- await self.page.evaluate("sdp => peer.setRemoteDescription({type:'answer', sdp})", payload["sdp"])
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._fail(DevtoolsError(f"信令保活发送失败({type(error).__name__}):{_transport_error(error)}"), source="signaling_keepalive")
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
- if self.failure:
470
- raise self._record_interruption(error, request_id, request_evidence) from None
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
- path = Path(result['logDirectory']) / 'execution.json'
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" and self.failure:
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
- path.write_text(json.dumps({
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), "failureEvents": self.failure_events.copy(),
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
- }, ensure_ascii=False, indent=2), encoding='utf-8')
495
- return DevtoolsError(f"{error};运行结果未确认,断线记录已保存:{path}")
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 / (hashlib.sha256(entry['logId'].encode()).hexdigest() + '.png')
520
- path.write_bytes(base64.b64decode(picture.pop('dataBase64')))
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
- return await self.request({"kind": "python_cancel", "requestId": self.active_python})
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] = (), timeout: float = 300) -> dict[str, Any]:
580
- """运行入口,自动携带仓库 Python 模块和 assets;保留设备缓存,重跑加载新源码。"""
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] = (), timeout: float = 300) -> dict[str, Any]:
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) -> dict[str, Any]:
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 [path.parent / 'assets', *assets]:
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
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` 命令行工具和异步 Python 客户端,可执行脚本、读取文字与控件、保存截图和查看日志。
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
@@ -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