dailo-devtools 0.1.0__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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Dailo contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,45 @@
1
+ Metadata-Version: 2.4
2
+ Name: dailo-devtools
3
+ Version: 0.1.0
4
+ Summary: 通过代劳 DevTools 调试单台 Android 设备的 Python API 与 CLI
5
+ License-Expression: MIT
6
+ Requires-Python: >=3.11
7
+ Description-Content-Type: text/markdown
8
+ License-File: LICENSE
9
+ Requires-Dist: playwright<2,>=1.58
10
+ Requires-Dist: aiohttp<4,>=3.14
11
+ Dynamic: license-file
12
+
13
+ # DevTools
14
+
15
+ ```sh
16
+ pip install dailo-devtools
17
+ dailo-dev install
18
+ dailo-dev serve --connection connection.json
19
+ dailo-dev exec --code "from dailo import text; print(text())"
20
+ dailo-dev exec --file main.py --config account.json
21
+ ```
22
+
23
+ 服务复用一条 WebRTC 连接,仅使用 DataChannel,截图按需获取,不持续传输视频。连接文件从设备开发入口取得,勿提交 Git。也可使用 `async with dailo_devtools.Device(connection)` 调用 `execute`、`run_file`、`inspect`、`screenshot`、`cancel`。默认会话30分钟;持久凭据在Python运行前时间不足时重新准入,授权覆盖本次执行、30秒停止收尾和10秒传输,合计最多7天。正式任务使用自己的执行期限。持久凭据支持断线后自动重新准入,中断操作不重放;`dailo-dev reconnect` / `Device.reconnect()` 可主动重连。
24
+
25
+ `serve` 的 `status` / `cancel` 不触发重连;其他命令在连接失败时先自动重新准入。前检先请求设备状态,再读取服务状态和 `sessionId`,避免用重连前的 `closed` 快照判定当前连接;原设备线程是否退出仍以原生运行计数为准。
26
+
27
+ 长期 `serve` 和长脚本使用持久连接凭据。已配对桌面设备配置为 `{"baseUrl":"实际平台地址","deviceId":"准入返回的公开设备ID","desktopControllerCapability":"控制端凭据"}`;地址与设备 ID 取正式 App 的准入结果,每次重连通过平台公开接口取得新会话。PC 本地控制端口与 `android-local-*` 列表 ID 不提供此准入合同,不能填入该配置。
28
+
29
+ 连接文件也可写为 `{"url":"App 复制的 DevTools 链接"}`,仅用于一次建连。链接中的入场 capability 已消费后不能用于重连,URL 分支不使用 recoveryCapability;断线或到期需从 App 重新获取链接。入场有效期和已建立会话的有效期分别计算,不能把短入场期限当作会话期限。
30
+
31
+ `log()` 按产生顺序实时保存到本机,每条文字及 PNG 落盘后才确认接收。连接文件可设 `"logDirectory":"D:/my-project/output/logs"` 指定根目录;默认位于用户缓存目录。Python 终态返回 `logDirectory`(请求目录)和 `logsPath`(该目录的 `logs.json`),不再返回 `logs` 数组。`exec` 和原始 Python `request` 使用同一流程;日志事件不会提前完成执行请求。
32
+
33
+ `from dailo_devtools import read_logs; entries = read_logs(result['logDirectory'])` 读取图文清单;每项保留完整文字,`image.path` 指向原 PNG。也可用 `dailo-dev logs D:/output/logs/请求ID` 离线查看,无需启动服务或重新连接设备。自然结束、脚本失败和取消回执均保存 `execution.json`(stdout、stderr、错误栈及日志路径)。日志清单与 PNG 不依赖终态回执;断线的 `interruption.json` 同样给出路径,仅标记结果未确认。只保证已经落盘的图文,连接失效时未交付的帧不能补造。
34
+
35
+ Python执行超时后发送停止请求,保留原响应并最多等待30秒完成清理;返回的stdout、stderr、错误栈与图文保存到该请求目录的`execution.json`。停止未响应时关闭旧通道,下一次重新连接;不能把发送停止当作运行已退出。
36
+
37
+ CLI外层HTTP预算为设备预算+30秒停止收尾+10秒传输余量。升级Playwright后重新运行`dailo-dev install`;安装和服务使用同一个`PLAYWRIGHT_BROWSERS_PATH`,可指定共享目录,避免Store内外缓存视图不同。
38
+
39
+ `exec --code` 与 `exec --file` 均自动上传仓库 Python 模块和 `assets/`,可使用 `--config`、`--assets`。内联代码默认属于当前目录,也可用 `--directory` 指定。复用仓库的设备工作目录,下载和写入的文件会保留;每次运行刷新源码导入和字节码,停止后重跑也生效,并移除已删除的模块、资源。
40
+
41
+ 请求按16KiB分片发送,缓冲超过64KiB时等待通道释放,避免多图或大资源堵满发送队列。发送超时或失败关闭旧通道,Python结果记为未确认;下一次调用重新准入,不重放请求。
42
+
43
+ 相同资源不重写,更新资源先移除旧文件,避免破坏已加载原生库的内存映射。原生库内容更新后需重启设备上的 Dailo 应用再运行。
44
+
45
+ 信令断开时立即关闭旧DataChannel,并保存请求目录的interruption.json,标记结果未确认。status的activeRun为空仅表示宿主已结束等待,不能作为原设备线程已退出或脚本成功的依据。
@@ -0,0 +1,33 @@
1
+ # DevTools
2
+
3
+ ```sh
4
+ pip install dailo-devtools
5
+ dailo-dev install
6
+ dailo-dev serve --connection connection.json
7
+ dailo-dev exec --code "from dailo import text; print(text())"
8
+ dailo-dev exec --file main.py --config account.json
9
+ ```
10
+
11
+ 服务复用一条 WebRTC 连接,仅使用 DataChannel,截图按需获取,不持续传输视频。连接文件从设备开发入口取得,勿提交 Git。也可使用 `async with dailo_devtools.Device(connection)` 调用 `execute`、`run_file`、`inspect`、`screenshot`、`cancel`。默认会话30分钟;持久凭据在Python运行前时间不足时重新准入,授权覆盖本次执行、30秒停止收尾和10秒传输,合计最多7天。正式任务使用自己的执行期限。持久凭据支持断线后自动重新准入,中断操作不重放;`dailo-dev reconnect` / `Device.reconnect()` 可主动重连。
12
+
13
+ `serve` 的 `status` / `cancel` 不触发重连;其他命令在连接失败时先自动重新准入。前检先请求设备状态,再读取服务状态和 `sessionId`,避免用重连前的 `closed` 快照判定当前连接;原设备线程是否退出仍以原生运行计数为准。
14
+
15
+ 长期 `serve` 和长脚本使用持久连接凭据。已配对桌面设备配置为 `{"baseUrl":"实际平台地址","deviceId":"准入返回的公开设备ID","desktopControllerCapability":"控制端凭据"}`;地址与设备 ID 取正式 App 的准入结果,每次重连通过平台公开接口取得新会话。PC 本地控制端口与 `android-local-*` 列表 ID 不提供此准入合同,不能填入该配置。
16
+
17
+ 连接文件也可写为 `{"url":"App 复制的 DevTools 链接"}`,仅用于一次建连。链接中的入场 capability 已消费后不能用于重连,URL 分支不使用 recoveryCapability;断线或到期需从 App 重新获取链接。入场有效期和已建立会话的有效期分别计算,不能把短入场期限当作会话期限。
18
+
19
+ `log()` 按产生顺序实时保存到本机,每条文字及 PNG 落盘后才确认接收。连接文件可设 `"logDirectory":"D:/my-project/output/logs"` 指定根目录;默认位于用户缓存目录。Python 终态返回 `logDirectory`(请求目录)和 `logsPath`(该目录的 `logs.json`),不再返回 `logs` 数组。`exec` 和原始 Python `request` 使用同一流程;日志事件不会提前完成执行请求。
20
+
21
+ `from dailo_devtools import read_logs; entries = read_logs(result['logDirectory'])` 读取图文清单;每项保留完整文字,`image.path` 指向原 PNG。也可用 `dailo-dev logs D:/output/logs/请求ID` 离线查看,无需启动服务或重新连接设备。自然结束、脚本失败和取消回执均保存 `execution.json`(stdout、stderr、错误栈及日志路径)。日志清单与 PNG 不依赖终态回执;断线的 `interruption.json` 同样给出路径,仅标记结果未确认。只保证已经落盘的图文,连接失效时未交付的帧不能补造。
22
+
23
+ Python执行超时后发送停止请求,保留原响应并最多等待30秒完成清理;返回的stdout、stderr、错误栈与图文保存到该请求目录的`execution.json`。停止未响应时关闭旧通道,下一次重新连接;不能把发送停止当作运行已退出。
24
+
25
+ CLI外层HTTP预算为设备预算+30秒停止收尾+10秒传输余量。升级Playwright后重新运行`dailo-dev install`;安装和服务使用同一个`PLAYWRIGHT_BROWSERS_PATH`,可指定共享目录,避免Store内外缓存视图不同。
26
+
27
+ `exec --code` 与 `exec --file` 均自动上传仓库 Python 模块和 `assets/`,可使用 `--config`、`--assets`。内联代码默认属于当前目录,也可用 `--directory` 指定。复用仓库的设备工作目录,下载和写入的文件会保留;每次运行刷新源码导入和字节码,停止后重跑也生效,并移除已删除的模块、资源。
28
+
29
+ 请求按16KiB分片发送,缓冲超过64KiB时等待通道释放,避免多图或大资源堵满发送队列。发送超时或失败关闭旧通道,Python结果记为未确认;下一次调用重新准入,不重放请求。
30
+
31
+ 相同资源不重写,更新资源先移除旧文件,避免破坏已加载原生库的内存映射。原生库内容更新后需重启设备上的 Dailo 应用再运行。
32
+
33
+ 信令断开时立即关闭旧DataChannel,并保存请求目录的interruption.json,标记结果未确认。status的activeRun为空仅表示宿主已结束等待,不能作为原设备线程已退出或脚本成功的依据。
@@ -0,0 +1,21 @@
1
+ [build-system]
2
+ requires = ["setuptools>=80"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "dailo-devtools"
7
+ version = "0.1.0"
8
+ description = "通过代劳 DevTools 调试单台 Android 设备的 Python API 与 CLI"
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = "MIT"
12
+ dependencies = ["playwright>=1.58,<2", "aiohttp>=3.14,<4"]
13
+
14
+ [project.scripts]
15
+ dailo-dev = "dailo_devtools.cli:main"
16
+
17
+ [tool.setuptools.packages.find]
18
+ where = ["src"]
19
+
20
+ [tool.setuptools.package-data]
21
+ dailo_devtools = ["py.typed"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,16 @@
1
+ """独立 DevTools 客户端;不依赖代劳私有项目,使用后台 Chromium。"""
2
+ from typing import TYPE_CHECKING
3
+
4
+ if TYPE_CHECKING:
5
+ from .device import Device, DevtoolsError, read_logs
6
+
7
+ __all__ = ["Device", "DevtoolsError", "read_logs"]
8
+
9
+
10
+ def __getattr__(name: str):
11
+ if name not in __all__:
12
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
13
+ from .device import Device, DevtoolsError, read_logs
14
+
15
+ globals().update(Device=Device, DevtoolsError=DevtoolsError, read_logs=read_logs)
16
+ return globals()[name]
@@ -0,0 +1,140 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import asyncio
5
+ import json
6
+ import sys
7
+ import subprocess
8
+ from urllib.error import HTTPError
9
+ from pathlib import Path
10
+ from urllib.request import Request, urlopen
11
+
12
+
13
+ async def serve(args):
14
+ from aiohttp import web
15
+
16
+ from .device import Device
17
+
18
+ connection = json.loads(Path(args.connection).read_text(encoding="utf-8"))
19
+ async with Device(connection) as device:
20
+ reconnecting = asyncio.Lock()
21
+ async def call(request):
22
+ try:
23
+ body = await request.json()
24
+ command = body.pop("command")
25
+ if command not in {"status", "cancel"}:
26
+ async with reconnecting:
27
+ if device.failure or command == "reconnect":
28
+ await device.reconnect()
29
+ if command == "status":
30
+ value = await device.status()
31
+ elif command == "reconnect":
32
+ value = await device.status()
33
+ elif command == "request":
34
+ value = await device.request(**body)
35
+ elif command in {"execute", "run_file", "inspect", "screenshot", "cancel"}:
36
+ value = await getattr(device, command)(**body)
37
+ else:
38
+ raise ValueError("不支持的调试命令")
39
+ return web.json_response({"ok": True, "result": value})
40
+ except Exception as error:
41
+ return web.json_response({"ok": False, "error": str(error)}, status=400)
42
+
43
+ app = web.Application(client_max_size=16 * 1024 * 1024)
44
+ app.router.add_post("/call", call)
45
+ runner = web.AppRunner(app, access_log=None)
46
+ await runner.setup()
47
+ await web.TCPSite(runner, "127.0.0.1", args.port).start()
48
+ print(json.dumps({"ok": True, "port": args.port, "deviceId": device.device_id}), flush=True)
49
+ try:
50
+ await asyncio.Event().wait()
51
+ finally:
52
+ await runner.cleanup()
53
+
54
+
55
+ def main():
56
+ parser = argparse.ArgumentParser(description="单设备 DevTools 调试;后台 WebRTC,无前台窗口")
57
+ parser.add_argument("--port", type=int, default=3766)
58
+ sub = parser.add_subparsers(dest="command", required=True)
59
+ sub.add_parser("install", help="准备后台 Chromium 运行时(只需执行一次)")
60
+ server = sub.add_parser("serve", help="保持单台设备连接,为后续 CLI 提供本地 API")
61
+ server.add_argument("--connection", required=True, help="认证连接 JSON 文件;不输出凭据")
62
+ sub.add_parser("status")
63
+ sub.add_parser("reconnect", help="重新连接同一设备,不重复执行脚本")
64
+ sub.add_parser("cancel")
65
+ logs = sub.add_parser("logs", help="离线读取请求目录的图文清单,不连接设备")
66
+ logs.add_argument("directory")
67
+ screen = sub.add_parser("screenshot")
68
+ screen.add_argument("path")
69
+ observation = sub.add_parser("inspect")
70
+ observation.add_argument("--pixels", action="store_true")
71
+ observation.add_argument("--raw", action="store_true", help="包含内部诊断数据")
72
+ observation.add_argument("--no-nodes", action="store_true")
73
+ observation.add_argument("--no-text", action="store_true")
74
+ execute = sub.add_parser("exec", help="执行 Python 代码或文件,默认 Locator 隐式等待 30 秒")
75
+ source = execute.add_mutually_exclusive_group(required=True)
76
+ source.add_argument("--code")
77
+ source.add_argument("--file")
78
+ execute.add_argument("--config", help="脚本配置 JSON 文件")
79
+ execute.add_argument("--assets", action="append", default=[])
80
+ execute.add_argument("--directory", default=".", help="内联代码所属仓库,默认当前目录")
81
+ execute.add_argument("--timeout", type=float, default=300, help="整个开发运行的传输时限,单位秒")
82
+ raw = sub.add_parser("request", help="直接发送当前 DevTools 协议 JSON")
83
+ raw.add_argument("body")
84
+ args = parser.parse_args()
85
+ if args.command == "logs":
86
+ from .device import read_logs
87
+ print(json.dumps(read_logs(args.directory), ensure_ascii=False, indent=2))
88
+ return
89
+ if args.command == "install":
90
+ sys.exit(subprocess.call([sys.executable, "-m", "playwright", "install", "--only-shell", "chromium"],
91
+ creationflags=subprocess.CREATE_NO_WINDOW if sys.platform == "win32" else 0))
92
+ if args.command == "serve":
93
+ try:
94
+ asyncio.run(serve(args))
95
+ except KeyboardInterrupt:
96
+ pass
97
+ except Exception as error:
98
+ # 网络异常可能携带含 capability 的握手 URL;CLI 不打印该 URL。
99
+ from .device import DevtoolsError
100
+ message = str(error) if isinstance(error, DevtoolsError) else "连接设备失败"
101
+ print(json.dumps({"ok": False, "error": message, "type": type(error).__name__},
102
+ ensure_ascii=False), file=sys.stderr)
103
+ sys.exit(1)
104
+ return
105
+ body = {"command": args.command}
106
+ if args.command == "screenshot":
107
+ body["path"] = str(Path(args.path).resolve())
108
+ elif args.command == "inspect":
109
+ body.update(pixels=args.pixels, nodes=not args.no_nodes, text=not args.no_text, raw=args.raw)
110
+ elif args.command == "exec":
111
+ body.update(timeout=args.timeout, assets=args.assets)
112
+ if args.config:
113
+ body["config"] = json.loads(Path(args.config).read_text(encoding="utf-8"))
114
+ if args.file:
115
+ body.update(command="run_file", path=str(Path(args.file).resolve()))
116
+ else:
117
+ body.update(command="execute", source=args.code, directory=str(Path(args.directory).resolve()))
118
+ elif args.command == "request":
119
+ body["body"] = json.loads(args.body)
120
+ request = Request(f"http://127.0.0.1:{args.port}/call", data=json.dumps(body).encode(),
121
+ headers={"Content-Type": "application/json"})
122
+ try:
123
+ # 设备预算耗尽后还需30秒接收停止结果,另留10秒传输余量。
124
+ with urlopen(request, timeout=getattr(args, "timeout", 30) + 40) as response:
125
+ result = json.load(response)
126
+ print(json.dumps(result, ensure_ascii=False, indent=2))
127
+ payload = result.get("result", {})
128
+ execution = payload.get("result") or {}
129
+ if not result["ok"] or payload.get("ok") is False or execution.get("ok") is False:
130
+ sys.exit(1)
131
+ except HTTPError as error:
132
+ print(error.read().decode(), file=sys.stderr)
133
+ sys.exit(1)
134
+ except Exception as error:
135
+ print(json.dumps({"ok": False, "error": str(error)}, ensure_ascii=False), file=sys.stderr)
136
+ sys.exit(1)
137
+
138
+
139
+ if __name__ == "__main__":
140
+ main()
@@ -0,0 +1,693 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import base64
5
+ import contextlib
6
+ import io
7
+ import os
8
+ import json
9
+ import hashlib
10
+ import math
11
+ import re
12
+ import traceback
13
+ import uuid
14
+ import zipfile
15
+ from pathlib import Path
16
+ from collections.abc import Iterable, Mapping
17
+ from datetime import datetime, timezone
18
+ from typing import Any
19
+ from urllib.parse import parse_qs, quote, urlencode, urlsplit
20
+
21
+ import aiohttp
22
+ from playwright.async_api import async_playwright
23
+
24
+
25
+ TRANSPORT = """async (iceServers) => {
26
+ const peer = window.peer = new RTCPeerConnection({iceServers});
27
+ window.localCandidates = [];
28
+ peer.onicecandidate = event => {
29
+ if (event.candidate) {
30
+ const {candidate, sdpMid, sdpMLineIndex} = event.candidate;
31
+ window.localCandidates.push({candidate, sdpMid, sdpMLineIndex});
32
+ }
33
+ };
34
+ const channels = window.channels = {};
35
+ for (const label of ['control', 'inspection']) {
36
+ const channel = channels[label] = peer.createDataChannel(label, {ordered:true});
37
+ channel.onopen = () => {
38
+ if (Object.values(channels).every(item => item.readyState === 'open')) window.transportEvent('ready');
39
+ };
40
+ channel.onclose = () => window.transportEvent('closed', {
41
+ source: 'channel', label, readyState: channel.readyState,
42
+ connectionState: peer.connectionState, iceConnectionState: peer.iceConnectionState
43
+ });
44
+ }
45
+ for (const channel of Object.values(channels))
46
+ channel.onmessage = event => window.transportMessage(event.data);
47
+ peer.onconnectionstatechange = () => {
48
+ if (['failed','closed'].includes(peer.connectionState)) window.transportEvent('closed', {
49
+ source: 'peer', connectionState: peer.connectionState, iceConnectionState: peer.iceConnectionState
50
+ });
51
+ };
52
+ } """
53
+
54
+ CREATE_OFFER = """async () => {
55
+ 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};
62
+ } """
63
+
64
+ SEND_REQUEST = """async wire => {
65
+ const channel = channels.inspection;
66
+ const bytes = new TextEncoder().encode(wire);
67
+ const upload = window.devtoolsUpload = {totalBytes: bytes.length, queuedBytes: 0, terminatorSent: false};
68
+ channel.bufferedAmountLowThreshold = 65536;
69
+ for (let offset = 0; offset < bytes.length; offset += 16384) {
70
+ if (channel.readyState !== 'open') throw new Error('DevTools inspection channel closed');
71
+ if (channel.bufferedAmount > channel.bufferedAmountLowThreshold) {
72
+ await new Promise((resolve, reject) => {
73
+ const clear = () => {
74
+ channel.removeEventListener('bufferedamountlow', drained);
75
+ channel.removeEventListener('closing', closed);
76
+ channel.removeEventListener('close', closed);
77
+ channel.removeEventListener('error', closed);
78
+ };
79
+ const drained = () => { clear(); resolve(); };
80
+ const closed = () => { clear(); reject(new Error('DevTools inspection channel closed')); };
81
+ channel.addEventListener('bufferedamountlow', drained);
82
+ channel.addEventListener('closing', closed);
83
+ channel.addEventListener('close', closed);
84
+ channel.addEventListener('error', closed);
85
+ });
86
+ }
87
+ channel.send(bytes.slice(offset, offset + 16384));
88
+ upload.queuedBytes = Math.min(offset + 16384, bytes.length);
89
+ }
90
+ channel.send(new Uint8Array());
91
+ upload.terminatorSent = true;
92
+ } """
93
+
94
+ TRANSPORT_SNAPSHOT = """() => ({
95
+ connectionState: peer.connectionState,
96
+ iceConnectionState: peer.iceConnectionState,
97
+ channels: Object.fromEntries(Object.entries(channels).map(([name, channel]) =>
98
+ [name, {readyState: channel.readyState, bufferedAmount: channel.bufferedAmount}])),
99
+ upload: window.devtoolsUpload
100
+ })"""
101
+
102
+
103
+ def _transport_error(error: Exception) -> str:
104
+ # 与持久serve一致,传输证据不保存带认证参数的连接URL。
105
+ return re.sub(r"(?:https?|wss?)://\S+", "<url>", str(error))
106
+
107
+
108
+ def _exception_chain(error: Exception | None) -> list[dict[str, Any]]:
109
+ if error is None:
110
+ return []
111
+ trace = traceback.TracebackException.from_exception(error, lookup_lines=False, capture_locals=False)
112
+ chain = []
113
+ while trace is not None:
114
+ chain.append({"exceptionClass": type(error).__name__, "error": _transport_error(error),
115
+ "frames": [{"file": Path(frame.filename).name, "function": frame.name, "line": frame.lineno}
116
+ for frame in trace.stack]})
117
+ if trace.__cause__ is not None:
118
+ trace, error = trace.__cause__, error.__cause__
119
+ else:
120
+ trace, error = (None if trace.__suppress_context__ else trace.__context__), error.__context__
121
+ return chain
122
+
123
+
124
+ class DevtoolsError(RuntimeError):
125
+ pass
126
+
127
+
128
+ def read_logs(directory: str | Path) -> list[dict[str, Any]]:
129
+ """离线读取已落盘日志;image.path 指向 PNG,不重新装载图像。"""
130
+ return json.loads((Path(directory) / 'logs.json').read_text(encoding='utf-8'))
131
+
132
+
133
+ class Device:
134
+ """一个认证设备、一条持久 WebRTC 通道;所有识别在 Android 上执行。"""
135
+
136
+ def __init__(self, connection: dict[str, Any]):
137
+ self.connection = connection
138
+ self.pending: dict[str, asyncio.Future] = {}
139
+ self.chunks: dict[str, dict[int, str]] = {}
140
+ self.tasks: list[asyncio.Task] = []
141
+ self.log_tasks: set[asyncio.Task] = set()
142
+ self.active_python: str | None = None
143
+ self.send_lock = asyncio.Lock()
144
+ self.ready = asyncio.Event()
145
+ self.failure: Exception | None = None
146
+ self.failure_events: list[dict[str, Any]] = []
147
+ self.session_timeout = 1800.0
148
+
149
+ async def __aenter__(self) -> Device:
150
+ await self.connect()
151
+ return self
152
+
153
+ async def __aexit__(self, *_):
154
+ await self.close()
155
+
156
+ async def connect(self) -> None:
157
+ """建立一次 WebRTC 连接;后续调用复用该连接。"""
158
+ self.http = aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=30))
159
+ try:
160
+ admission, signal_url, local = await self._admit()
161
+ self.connected_at = asyncio.get_running_loop().time()
162
+ self.session_id = admission["sessionId"]
163
+ self.device_id = admission.get("deviceId", self.connection.get("deviceId", ""))
164
+ self.playwright = await async_playwright().start()
165
+ self.browser = await self.playwright.chromium.launch(headless=True)
166
+ self.browser.on("disconnected", lambda _: self._fail(DevtoolsError("DevTools 后台浏览器已关闭"), source="browser"))
167
+ self.page = await self.browser.new_page()
168
+ await self.page.expose_function("transportEvent", self._transport_event)
169
+ 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)
172
+
173
+ self.socket = await self.http.ws_connect(signal_url)
174
+ self.tasks.extend([
175
+ asyncio.create_task(self._signals()),
176
+ asyncio.create_task(self._keepalive()),
177
+ ])
178
+ try:
179
+ await asyncio.wait_for(self.ready.wait(), 30)
180
+ except TimeoutError:
181
+ raise DevtoolsError("WebRTC 通道在 30 秒内未建立,请检查设备内 DevTools 服务与信令连接。") from None
182
+ if self.failure:
183
+ raise self.failure
184
+ except BaseException:
185
+ await self.close()
186
+ raise
187
+
188
+ async def _admit(self):
189
+ conf = self.connection
190
+ if conf.get("url"):
191
+ parsed = urlsplit(conf["url"])
192
+ params = parse_qs(parsed.fragment)
193
+ admission = {key: params[key][0] for key in ("sessionId", "capability", "deviceId")}
194
+ local = bool(params.get("signalBaseUrl"))
195
+ base = (params.get("signalBaseUrl") or params.get("signalFleetBaseUrl") or
196
+ [f"{parsed.scheme}://{parsed.netloc}"])[0]
197
+ else:
198
+ base = conf["baseUrl"].rstrip("/")
199
+ device_id = conf["deviceId"]
200
+ local = bool(conf.get("local"))
201
+ if conf.get("operatorCredential"):
202
+ path = f"/api/operations/devices/{quote(device_id, safe='')}/sessions"
203
+ headers = {"X-Dailo-Operator": conf["operatorCredential"]}
204
+ body = {"scope": "devtools"}
205
+ else:
206
+ path = f"/api/devtools/devices/{quote(device_id, safe='')}/sessions"
207
+ if conf.get("desktopControllerCapability"):
208
+ headers = {"x-dailo-desktop-controller-capability": conf["desktopControllerCapability"]}
209
+ else:
210
+ headers = {"x-dailo-device-id": device_id,
211
+ "x-dailo-devtools-credential": conf["credential"]}
212
+ body = {}
213
+ if self.session_timeout > 1800:
214
+ body["timeoutMs"] = math.ceil(self.session_timeout * 1000)
215
+ async with self.http.post(base + path, json=body, headers=headers) as response:
216
+ value = await response.json()
217
+ if response.status >= 400:
218
+ raise DevtoolsError(value.get("error", f"DevTools 入场失败 HTTP {response.status}"))
219
+ admission = {**value, "deviceId": device_id}
220
+ parsed = urlsplit(base)
221
+ route = "/api/session/ws" if local else "/api/fleet/session"
222
+ signal_url = ("wss" if parsed.scheme == "https" else "ws") + "://" + parsed.netloc + route
223
+ return admission, signal_url + "?" + urlencode({"capability": admission["capability"]}), local
224
+
225
+ async def reconnect(self) -> None:
226
+ """重新入场并连接设备;保留设备当前画面,不重放断线时的操作。"""
227
+ await self.close()
228
+ self.ready.clear()
229
+ self.failure = None
230
+ self.failure_events.clear()
231
+ try:
232
+ await self.connect()
233
+ except Exception as error:
234
+ failure = error if isinstance(error, DevtoolsError) else DevtoolsError("重新连接设备失败")
235
+ self._fail(failure, source="reconnect")
236
+ raise failure from None
237
+
238
+ def _transport_event(self, event, details=None):
239
+ if event == "ready":
240
+ self.ready.set()
241
+ else:
242
+ self._fail(DevtoolsError("DevTools 通道已关闭"), source="transport", details=details)
243
+
244
+ async def status(self) -> dict[str, Any]:
245
+ """读取连接状态与当前运行标识。"""
246
+ try:
247
+ transport = await asyncio.wait_for(self.page.evaluate(TRANSPORT_SNAPSHOT), 1)
248
+ except Exception as error:
249
+ if self.failure is None:
250
+ raise
251
+ transport = {"errorClass": type(error).__name__, "error": _transport_error(error)}
252
+ signal_error = self.socket.exception()
253
+ return {"deviceId": self.device_id, "sessionId": self.session_id,
254
+ "connectionState": "closed" if self.failure else transport["connectionState"],
255
+ "activeRun": self.active_python,
256
+ "error": _transport_error(self.failure) if self.failure else None,
257
+ "diagnostics": {"failureEvents": self.failure_events.copy(), "transport": transport,
258
+ "signal": {"closed": self.socket.closed, "closeCode": self.socket.close_code,
259
+ "exceptionClass": type(signal_error).__name__ if signal_error else None,
260
+ "exception": _transport_error(signal_error) if signal_error else None}}}
261
+
262
+ def _fail(self, failure, *, source="device", details=None):
263
+ self.failure_events.append({"recordedAt": datetime.now(timezone.utc).isoformat(),
264
+ "source": source, "errorClass": type(failure).__name__, "error": _transport_error(failure),
265
+ "details": details})
266
+ if self.failure is None:
267
+ self.failure = failure
268
+ self.ready.set()
269
+ self.chunks.clear()
270
+ for future in list(self.pending.values()):
271
+ if not future.done():
272
+ future.set_exception(self.failure)
273
+
274
+ async def _close_failed_peer(self):
275
+ with contextlib.suppress(Exception):
276
+ await self.page.evaluate("peer.close()")
277
+
278
+ async def _signal(self, kind, payload):
279
+ await self.socket.send_json({"type": "signal", "payload": {
280
+ "sessionId": self.session_id, "scope": "devtools",
281
+ "payload": {"kind": kind, "payload": payload},
282
+ }})
283
+
284
+ def _signal_details(self, phase, error=None):
285
+ socket_error = self.socket.exception()
286
+ return {"phase": phase, "closed": self.socket.closed, "localCloseCode": self.socket.close_code,
287
+ "socketExceptionClass": type(socket_error).__name__ if socket_error else None,
288
+ "exceptionChain": _exception_chain(error if error is not None else socket_error)}
289
+
290
+ async def _signals(self):
291
+ offered = False
292
+ try:
293
+ async for message in self.socket:
294
+ if message.type == aiohttp.WSMsgType.ERROR:
295
+ self._fail(DevtoolsError(f"信令连接错误({type(message.data).__name__}):{_transport_error(message.data)}"),
296
+ source="signaling", details={"messageType": "ERROR", "exceptionClass": type(message.data).__name__,
297
+ "exception": _transport_error(message.data),
298
+ **self._signal_details("receive_error", message.data)})
299
+ break
300
+ if message.type != aiohttp.WSMsgType.TEXT:
301
+ continue
302
+ if message.data == "dailo:signaling:pong":
303
+ continue
304
+ envelope = json.loads(message.data)["payload"]
305
+ signal = envelope["payload"]
306
+ kind, payload = signal["kind"], signal.get("payload", {})
307
+ if kind == "agent_status":
308
+ if not payload.get("ready"):
309
+ raise DevtoolsError("设备服务未就绪:" + json.dumps(payload, ensure_ascii=False))
310
+ if payload["status"] == "session_ready" and not offered:
311
+ offered = True
312
+ offer = await self.page.evaluate(CREATE_OFFER)
313
+ await self._signal("offer", offer)
314
+ elif kind == "answer":
315
+ await self.page.evaluate("sdp => peer.setRemoteDescription({type:'answer', sdp})", payload["sdp"])
316
+ for candidate in payload["candidates"]:
317
+ await self._candidate(candidate)
318
+ elif kind == "ice":
319
+ await self._candidate(payload["candidate"])
320
+ elif kind == "ice_complete":
321
+ await self.page.evaluate("peer.addIceCandidate(null)")
322
+ elif kind == "close":
323
+ raise DevtoolsError("设备结束了开发会话")
324
+ code = self.socket.close_code
325
+ error = self.socket.exception()
326
+ self._fail(DevtoolsError(
327
+ "开发会话授权已到期。"
328
+ if code == 4008 else f"信令连接已关闭({code})" +
329
+ (f";{type(error).__name__}:{_transport_error(error)}" if error else "")), source="signaling",
330
+ details=self._signal_details("iterator_ended", error))
331
+ except asyncio.CancelledError:
332
+ raise
333
+ except Exception as error:
334
+ self._fail(DevtoolsError(_transport_error(error)), source="signaling",
335
+ details=self._signal_details("receive_exception", error))
336
+ finally:
337
+ if self.failure:
338
+ # 信令已失效时立即关闭旧通道,不等下一条命令才释放。
339
+ await self._close_failed_peer()
340
+
341
+ async def _candidate(self, value):
342
+ await self.page.evaluate("candidate => peer.addIceCandidate(candidate)", value)
343
+
344
+ async def _keepalive(self):
345
+ try:
346
+ while True:
347
+ await asyncio.sleep(30)
348
+ await self.socket.send_str("dailo:signaling:ping")
349
+ except asyncio.CancelledError:
350
+ raise
351
+ except Exception as error:
352
+ self._fail(DevtoolsError(f"信令保活发送失败({type(error).__name__}):{_transport_error(error)}"), source="signaling_keepalive")
353
+ await self._close_failed_peer()
354
+
355
+ def _message(self, wire):
356
+ message = json.loads(wire)
357
+ body = message["body"]
358
+ request_id = body.get("id")
359
+ if message["type"] == "devtools_response_chunk":
360
+ chunks = self.chunks.setdefault(request_id, {})
361
+ chunks[body["chunkIndex"]] = body["data"]
362
+ if len(chunks) != body["chunkCount"]:
363
+ return
364
+ message = json.loads("".join(chunks[i] for i in range(body["chunkCount"])))
365
+ body = message["body"]
366
+ self.chunks.pop(request_id, None)
367
+ payload = body.get("body")
368
+ if isinstance(payload, dict) and payload.get("kind") == "execution_log":
369
+ owner = payload["requestId"]
370
+ if owner not in self.pending:
371
+ return
372
+ try:
373
+ self._store_log(payload["log"], owner)
374
+ except Exception as error:
375
+ self._fail(DevtoolsError(f"开发日志保存失败:{error}"), source="execution_log")
376
+ task = asyncio.create_task(self._close_failed_peer())
377
+ else:
378
+ task = asyncio.create_task(self._acknowledge_log(payload["log"]["logId"]))
379
+ self.log_tasks.add(task)
380
+ task.add_done_callback(self.log_tasks.discard)
381
+ return
382
+ request_id = body.get("id")
383
+ if request_id not in self.pending:
384
+ return
385
+ future = self.pending[request_id]
386
+ if not future.done():
387
+ if body["ok"]:
388
+ future.set_result(body["body"])
389
+ else:
390
+ future.set_exception(DevtoolsError(body["error"]))
391
+
392
+ async def request(self, body: dict[str, Any], *, timeout: float = 30,
393
+ request_id: str | None = None) -> dict[str, Any]:
394
+ """复用连接发送请求;已断线时先重新入场,不重放此前请求。"""
395
+ if body["kind"] == "python":
396
+ required = timeout + 40
397
+ if required > 604800:
398
+ raise DevtoolsError("开发运行与清理预算不能超过 7 天")
399
+ if self.connection.get("url") and required >= self.session_timeout:
400
+ raise DevtoolsError("一次性开发链接不能延长授权,请使用持久连接凭据运行长脚本")
401
+ if self.connection.get("baseUrl"):
402
+ async with self.send_lock:
403
+ if self.failure or asyncio.get_running_loop().time() - self.connected_at + required >= self.session_timeout:
404
+ self.session_timeout = max(1800.0, required)
405
+ await self.reconnect()
406
+ elif self.failure:
407
+ await self.reconnect()
408
+ elif self.failure:
409
+ if body["kind"] == "python_cancel":
410
+ raise self.failure
411
+ async with self.send_lock:
412
+ if self.failure:
413
+ await self.reconnect()
414
+ request_id = request_id or uuid.uuid4().hex
415
+ future = asyncio.get_running_loop().create_future()
416
+ self.pending[request_id] = future
417
+ deadline = asyncio.get_running_loop().time() + timeout
418
+ request_evidence = {"kind": body["kind"], "timeoutSeconds": timeout, "sendComplete": False}
419
+ try:
420
+ if body['kind'] == 'python':
421
+ (self._log_folder(request_id) / 'logs.json').write_bytes(b'[]')
422
+ wire = json.dumps({**body, "id": request_id}, ensure_ascii=False).encode("utf-8")
423
+ request_evidence.update(wireBytes=len(wire), wireSha256=hashlib.sha256(wire).hexdigest())
424
+ if body["kind"] == "python_cancel":
425
+ await self.page.evaluate("wire => channels.control.send(wire)", wire.decode("utf-8"))
426
+ else:
427
+ async with self.send_lock:
428
+ try:
429
+ await asyncio.wait_for(self.page.evaluate(SEND_REQUEST, wire.decode("utf-8")),
430
+ max(0, deadline - asyncio.get_running_loop().time()))
431
+ except Exception as error:
432
+ try:
433
+ # 失败后只取本地浏览器状态,不延长发送预算或重发请求。
434
+ request_evidence["transport"] = await asyncio.wait_for(self.page.evaluate(TRANSPORT_SNAPSHOT), 1)
435
+ except Exception as snapshot_error:
436
+ request_evidence["transport"] = {"errorClass": type(snapshot_error).__name__,
437
+ "error": _transport_error(snapshot_error)}
438
+ self._fail(DevtoolsError(f"开发请求发送失败({type(error).__name__}):{_transport_error(error)}"), source="request_send")
439
+ await self._close_failed_peer()
440
+ future.exception()
441
+ raise self.failure from None
442
+ request_evidence["sendComplete"] = True
443
+ if body['kind'] == 'python':
444
+ completed, _ = await asyncio.wait({future}, timeout=max(0, deadline - asyncio.get_running_loop().time()))
445
+ if not completed:
446
+ raise TimeoutError
447
+ result = future.result()
448
+ else:
449
+ result = await asyncio.wait_for(future, max(0, deadline - asyncio.get_running_loop().time()))
450
+ if body['kind'] == 'python':
451
+ self._finish_logs(result, request_id)
452
+ return result
453
+ except TimeoutError:
454
+ if body["kind"] == "python":
455
+ await self.page.evaluate("wire => channels.control.send(wire)", json.dumps({
456
+ "kind": "python_cancel", "id": uuid.uuid4().hex, "requestId": request_id}))
457
+ try:
458
+ # 保留原响应future,接收停止后的stdout、栈和同帧日志。
459
+ result = await asyncio.wait_for(future, 30)
460
+ except TimeoutError:
461
+ failure = DevtoolsError(f"开发运行在 {timeout:g} 秒内未结束,停止30秒未响应,已关闭旧通道。")
462
+ self._fail(failure, source="python_timeout")
463
+ with contextlib.suppress(Exception):
464
+ await self.page.evaluate("peer.close()")
465
+ raise failure from None
466
+ except Exception as error:
467
+ if self.failure:
468
+ raise self._record_interruption(error, request_id, request_evidence) from None
469
+ raise
470
+ self._finish_logs(result, request_id)
471
+ path = Path(result['logDirectory']) / 'execution.json'
472
+ raise DevtoolsError(f"开发运行在 {timeout:g} 秒内未结束,停止结果已保存:{path}") from None
473
+ raise DevtoolsError(f"设备未响应 {body['kind']}({timeout:g} 秒超时)。") from None
474
+ except Exception as error:
475
+ if body["kind"] == "python" and self.failure:
476
+ raise self._record_interruption(error, request_id, request_evidence) from None
477
+ raise
478
+ finally:
479
+ self.pending.pop(request_id, None)
480
+ self.chunks.pop(request_id, None)
481
+
482
+ def _record_interruption(self, error, request_id, request_evidence):
483
+ folder = self._log_folder(request_id)
484
+ path = folder / 'interruption.json'
485
+ path.write_text(json.dumps({
486
+ "recordedAt": datetime.now(timezone.utc).isoformat(),
487
+ "requestId": request_id, "sessionId": self.session_id,
488
+ "deviceId": self.device_id, "outcome": "unconfirmed",
489
+ "error": _transport_error(error), "failureEvents": self.failure_events.copy(),
490
+ "request": request_evidence,
491
+ "logDirectory": str(folder), "logsPath": str(folder / 'logs.json'),
492
+ }, ensure_ascii=False, indent=2), encoding='utf-8')
493
+ return DevtoolsError(f"{error};运行结果未确认,断线记录已保存:{path}")
494
+
495
+ async def _execute(self, source: str, *, timeout: float = 300) -> dict[str, Any]:
496
+ """执行 Python,返回运行结果;result 含 stdout、stderr 和执行状态。"""
497
+ if self.active_python:
498
+ raise DevtoolsError("设备已有一个开发脚本在运行")
499
+ request_id = uuid.uuid4().hex
500
+ self.active_python = request_id
501
+ try:
502
+ result = await self.request({"kind": "python", "command": source}, timeout=timeout, request_id=request_id)
503
+ return result
504
+ finally:
505
+ self.active_python = None
506
+
507
+ def _log_folder(self, request_id: str) -> Path:
508
+ root = Path(self.connection.get('logDirectory') or Path.home() / '.cache' / 'dailo-devtools' / 'logs')
509
+ folder = root.resolve() / request_id
510
+ folder.mkdir(parents=True, exist_ok=True)
511
+ return folder
512
+
513
+ def _store_log(self, entry: dict[str, Any], request_id: str) -> None:
514
+ folder = self._log_folder(request_id)
515
+ picture = entry.get('image')
516
+ if picture:
517
+ path = folder / (hashlib.sha256(entry['logId'].encode()).hexdigest() + '.png')
518
+ path.write_bytes(base64.b64decode(picture.pop('dataBase64')))
519
+ picture['path'] = str(path)
520
+ path = folder / 'logs.json'
521
+ if not path.exists():
522
+ path.write_bytes(b'[]')
523
+ # 原生等落盘收据才会发下一条;追加完整 JSON,不在 PC 累积图文数组。
524
+ with path.open('r+b') as output:
525
+ output.seek(-1, os.SEEK_END)
526
+ prefix = b',\n' if output.tell() > 1 else b'\n'
527
+ output.write(prefix + json.dumps(entry, ensure_ascii=False).encode('utf-8') + b'\n]')
528
+
529
+ async def _acknowledge_log(self, log_id: str) -> None:
530
+ try:
531
+ await self.page.evaluate("wire => channels.control.send(wire)", json.dumps({
532
+ "kind": "execution_log_stored", "logId": log_id}))
533
+ except Exception as error:
534
+ self._fail(DevtoolsError(f"开发日志落盘确认发送失败:{error}"), source="execution_log_ack")
535
+ await self._close_failed_peer()
536
+
537
+ def _finish_logs(self, result: dict[str, Any], request_id: str) -> None:
538
+ folder = self._log_folder(request_id)
539
+ result['logDirectory'] = str(folder)
540
+ result['logsPath'] = str(folder / 'logs.json')
541
+ (folder / 'execution.json').write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding='utf-8')
542
+
543
+ async def cancel(self) -> dict[str, Any]:
544
+ """停止当前开发脚本,保持连接。"""
545
+ if self.active_python:
546
+ return await self.request({"kind": "python_cancel", "requestId": self.active_python})
547
+ return {"ok": True, "running": False}
548
+
549
+ async def screenshot(self, path: str | Path) -> dict[str, Any]:
550
+ """保存设备原图到 PC,返回图片尺寸等信息。"""
551
+ result = await self.request({"kind": "snapshot"})
552
+ Path(path).parent.mkdir(parents=True, exist_ok=True)
553
+ Path(path).write_bytes(base64.b64decode(result["dataBase64"]))
554
+ return {key: value for key, value in result.items() if key != "dataBase64"}
555
+
556
+ async def inspect(self, *, pixels: bool = False, nodes: bool = True,
557
+ text: bool = True, raw: bool = False) -> dict[str, Any]:
558
+ """观察控件、文字和坐标;raw=True 包含识别耗时等内部诊断数据。"""
559
+ result = await self.request({"kind": "inspect", "pixels": pixels, "nodes": nodes, "text": text})
560
+ if raw:
561
+ return result
562
+ value = {"ok": result["ok"], "frameId": result["frameId"],
563
+ "size": [result["width"], result["height"]]}
564
+ if text:
565
+ value["text"] = [{"text": line["text"], "bounds": [line["rect"][key] for key in
566
+ ("left", "top", "right", "bottom")]}
567
+ for line in (result.get("ocr") or {}).get("lines", []) if line["found"]]
568
+ if nodes:
569
+ value["nodes"] = (result.get("nodes") or {}).get("nodes", [])
570
+ if pixels:
571
+ value["snapshot"] = result.get("snapshot")
572
+ if result.get("errors"):
573
+ value["errors"] = result["errors"]
574
+ return value
575
+
576
+ async def run_file(self, path: str | Path, *, config: Mapping[str, Any] | None = None,
577
+ assets: Iterable[str | Path] = (), timeout: float = 300) -> dict[str, Any]:
578
+ """运行入口,自动携带仓库 Python 模块和 assets;保留设备缓存,重跑加载新源码。"""
579
+ path = Path(path).resolve()
580
+ return await self._run_workspace(path, path.read_text(encoding="utf-8"), config, assets, timeout)
581
+
582
+ async def execute(self, source: str, *, directory: str | Path | None = None,
583
+ config: Mapping[str, Any] | None = None,
584
+ assets: Iterable[str | Path] = (), timeout: float = 300) -> dict[str, Any]:
585
+ """在当前仓库的设备工作目录执行代码;模块、相对路径和 assets 与 run_file 一致。"""
586
+ folder = Path(directory or Path.cwd()).resolve()
587
+ return await self._run_workspace(folder / ".dailo-repl.py", source, config, assets, timeout)
588
+
589
+ async def _run_workspace(self, path: Path, source: str, config: Mapping[str, Any] | None,
590
+ assets: Iterable[str | Path], timeout: float) -> dict[str, Any]:
591
+ config = dict(config or {})
592
+ archive = io.BytesIO()
593
+ with zipfile.ZipFile(archive, "w", zipfile.ZIP_DEFLATED) as bundle:
594
+ files = set()
595
+ for folder, directories, names in os.walk(path.parent):
596
+ directories[:] = [name for name in directories if name not in {
597
+ '.git', '.venv', 'node_modules', 'downloads', 'data', 'state', '__pycache__', 'dist',
598
+ }]
599
+ files.update(Path(folder) / name for name in names if name.endswith('.py'))
600
+ for entry in [path.parent / 'assets', *assets]:
601
+ asset = (path.parent / entry).resolve()
602
+ for item in (asset.rglob("*") if asset.is_dir() else [asset]):
603
+ if item.is_file():
604
+ files.add(item)
605
+ for item in sorted(files):
606
+ if item != path:
607
+ bundle.write(item, item.relative_to(path.parent).as_posix())
608
+ bundle.writestr(path.name, source)
609
+ encoded = base64.b64encode(archive.getvalue()).decode()
610
+ workspace = hashlib.sha256(str(path.parent).encode()).hexdigest()[:16]
611
+ program = f'''def __run_local_file():
612
+ import os, sys, io, json, base64, zipfile, importlib.util
613
+ from pathlib import Path
614
+ from dailo._runtime import bind, host, config as get_config
615
+ native_host = host()
616
+ previous_config = get_config()
617
+ workspace = json.loads(native_host._app_operation(json.dumps({{"operation":"workspace", "name":{workspace!r}}})))
618
+ if not workspace['ok']:
619
+ raise RuntimeError(workspace['error'])
620
+ folder = workspace['path']
621
+ bundle = zipfile.ZipFile(io.BytesIO(base64.b64decode({encoded!r})))
622
+ manifest = Path(folder) / '.dailo-devtools-files.json'
623
+ previous_files = json.loads(manifest.read_text()) if manifest.exists() else []
624
+ for name in set(previous_files) - set(bundle.namelist()):
625
+ (Path(folder) / name).unlink(missing_ok=True)
626
+ for entry in bundle.infolist():
627
+ target = Path(folder) / entry.filename
628
+ if entry.is_dir():
629
+ target.mkdir(parents=True, exist_ok=True)
630
+ continue
631
+ content = bundle.read(entry)
632
+ if target.is_file() and target.read_bytes() == content:
633
+ continue
634
+ # 不截断已加载的原生库映射;同内容保留,更新使用新文件。
635
+ target.unlink(missing_ok=True)
636
+ target.parent.mkdir(parents=True, exist_ok=True)
637
+ target.write_bytes(content)
638
+ manifest.write_text(json.dumps(bundle.namelist()))
639
+ # 停止会中断 Python 清理;下一次运行主动清除工作区导入与字节码。
640
+ workspace_root = Path(folder).resolve().parent
641
+ def clear_workspace_modules():
642
+ for name, module in list(sys.modules.items()):
643
+ module_path = getattr(module, '__file__', None)
644
+ if module_path and Path(module_path).resolve().is_relative_to(workspace_root):
645
+ del sys.modules[name]
646
+ clear_workspace_modules()
647
+ for name in bundle.namelist():
648
+ if name.endswith('.py'):
649
+ Path(importlib.util.cache_from_source(str(Path(folder) / name))).unlink(missing_ok=True)
650
+ previous_dir = os.getcwd()
651
+ sys.path[:] = [entry for entry in sys.path
652
+ if not Path(entry or previous_dir).resolve().is_relative_to(workspace_root)]
653
+ previous_path = sys.path.copy()
654
+ os.chdir(folder)
655
+ bind(native_host, {config or {}!r})
656
+ try:
657
+ scope = {{'__file__': os.path.join(folder, {path.name!r}), '__name__': '__main__'}}
658
+ sys.path.insert(0, folder)
659
+ try:
660
+ with open(scope['__file__'], encoding='utf-8') as script:
661
+ exec(compile(script.read(), scope['__file__'], 'exec'), scope)
662
+ finally:
663
+ sys.path[:] = previous_path
664
+ clear_workspace_modules()
665
+ finally:
666
+ os.chdir(previous_dir)
667
+ bind(native_host, previous_config)
668
+ try:
669
+ __run_local_file()
670
+ finally:
671
+ del __run_local_file
672
+ '''
673
+ return await self._execute(program, timeout=timeout)
674
+
675
+ async def close(self) -> None:
676
+ """结束开发会话并关闭后台 Chromium。"""
677
+ tasks = [*self.tasks, *self.log_tasks]
678
+ for task in tasks:
679
+ task.cancel()
680
+ for task in tasks:
681
+ with contextlib.suppress(asyncio.CancelledError, Exception):
682
+ await task
683
+ self.tasks.clear()
684
+ self.log_tasks.clear()
685
+ self.chunks.clear()
686
+ if hasattr(self, "socket"):
687
+ await self.socket.close()
688
+ if hasattr(self, "browser"):
689
+ await self.browser.close()
690
+ if hasattr(self, "playwright"):
691
+ await self.playwright.stop()
692
+ if hasattr(self, "http"):
693
+ await self.http.close()
File without changes
@@ -0,0 +1,45 @@
1
+ Metadata-Version: 2.4
2
+ Name: dailo-devtools
3
+ Version: 0.1.0
4
+ Summary: 通过代劳 DevTools 调试单台 Android 设备的 Python API 与 CLI
5
+ License-Expression: MIT
6
+ Requires-Python: >=3.11
7
+ Description-Content-Type: text/markdown
8
+ License-File: LICENSE
9
+ Requires-Dist: playwright<2,>=1.58
10
+ Requires-Dist: aiohttp<4,>=3.14
11
+ Dynamic: license-file
12
+
13
+ # DevTools
14
+
15
+ ```sh
16
+ pip install dailo-devtools
17
+ dailo-dev install
18
+ dailo-dev serve --connection connection.json
19
+ dailo-dev exec --code "from dailo import text; print(text())"
20
+ dailo-dev exec --file main.py --config account.json
21
+ ```
22
+
23
+ 服务复用一条 WebRTC 连接,仅使用 DataChannel,截图按需获取,不持续传输视频。连接文件从设备开发入口取得,勿提交 Git。也可使用 `async with dailo_devtools.Device(connection)` 调用 `execute`、`run_file`、`inspect`、`screenshot`、`cancel`。默认会话30分钟;持久凭据在Python运行前时间不足时重新准入,授权覆盖本次执行、30秒停止收尾和10秒传输,合计最多7天。正式任务使用自己的执行期限。持久凭据支持断线后自动重新准入,中断操作不重放;`dailo-dev reconnect` / `Device.reconnect()` 可主动重连。
24
+
25
+ `serve` 的 `status` / `cancel` 不触发重连;其他命令在连接失败时先自动重新准入。前检先请求设备状态,再读取服务状态和 `sessionId`,避免用重连前的 `closed` 快照判定当前连接;原设备线程是否退出仍以原生运行计数为准。
26
+
27
+ 长期 `serve` 和长脚本使用持久连接凭据。已配对桌面设备配置为 `{"baseUrl":"实际平台地址","deviceId":"准入返回的公开设备ID","desktopControllerCapability":"控制端凭据"}`;地址与设备 ID 取正式 App 的准入结果,每次重连通过平台公开接口取得新会话。PC 本地控制端口与 `android-local-*` 列表 ID 不提供此准入合同,不能填入该配置。
28
+
29
+ 连接文件也可写为 `{"url":"App 复制的 DevTools 链接"}`,仅用于一次建连。链接中的入场 capability 已消费后不能用于重连,URL 分支不使用 recoveryCapability;断线或到期需从 App 重新获取链接。入场有效期和已建立会话的有效期分别计算,不能把短入场期限当作会话期限。
30
+
31
+ `log()` 按产生顺序实时保存到本机,每条文字及 PNG 落盘后才确认接收。连接文件可设 `"logDirectory":"D:/my-project/output/logs"` 指定根目录;默认位于用户缓存目录。Python 终态返回 `logDirectory`(请求目录)和 `logsPath`(该目录的 `logs.json`),不再返回 `logs` 数组。`exec` 和原始 Python `request` 使用同一流程;日志事件不会提前完成执行请求。
32
+
33
+ `from dailo_devtools import read_logs; entries = read_logs(result['logDirectory'])` 读取图文清单;每项保留完整文字,`image.path` 指向原 PNG。也可用 `dailo-dev logs D:/output/logs/请求ID` 离线查看,无需启动服务或重新连接设备。自然结束、脚本失败和取消回执均保存 `execution.json`(stdout、stderr、错误栈及日志路径)。日志清单与 PNG 不依赖终态回执;断线的 `interruption.json` 同样给出路径,仅标记结果未确认。只保证已经落盘的图文,连接失效时未交付的帧不能补造。
34
+
35
+ Python执行超时后发送停止请求,保留原响应并最多等待30秒完成清理;返回的stdout、stderr、错误栈与图文保存到该请求目录的`execution.json`。停止未响应时关闭旧通道,下一次重新连接;不能把发送停止当作运行已退出。
36
+
37
+ CLI外层HTTP预算为设备预算+30秒停止收尾+10秒传输余量。升级Playwright后重新运行`dailo-dev install`;安装和服务使用同一个`PLAYWRIGHT_BROWSERS_PATH`,可指定共享目录,避免Store内外缓存视图不同。
38
+
39
+ `exec --code` 与 `exec --file` 均自动上传仓库 Python 模块和 `assets/`,可使用 `--config`、`--assets`。内联代码默认属于当前目录,也可用 `--directory` 指定。复用仓库的设备工作目录,下载和写入的文件会保留;每次运行刷新源码导入和字节码,停止后重跑也生效,并移除已删除的模块、资源。
40
+
41
+ 请求按16KiB分片发送,缓冲超过64KiB时等待通道释放,避免多图或大资源堵满发送队列。发送超时或失败关闭旧通道,Python结果记为未确认;下一次调用重新准入,不重放请求。
42
+
43
+ 相同资源不重写,更新资源先移除旧文件,避免破坏已加载原生库的内存映射。原生库内容更新后需重启设备上的 Dailo 应用再运行。
44
+
45
+ 信令断开时立即关闭旧DataChannel,并保存请求目录的interruption.json,标记结果未确认。status的activeRun为空仅表示宿主已结束等待,不能作为原设备线程已退出或脚本成功的依据。
@@ -0,0 +1,13 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/dailo_devtools/__init__.py
5
+ src/dailo_devtools/cli.py
6
+ src/dailo_devtools/device.py
7
+ src/dailo_devtools/py.typed
8
+ src/dailo_devtools.egg-info/PKG-INFO
9
+ src/dailo_devtools.egg-info/SOURCES.txt
10
+ src/dailo_devtools.egg-info/dependency_links.txt
11
+ src/dailo_devtools.egg-info/entry_points.txt
12
+ src/dailo_devtools.egg-info/requires.txt
13
+ src/dailo_devtools.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ dailo-dev = dailo_devtools.cli:main
@@ -0,0 +1,2 @@
1
+ playwright<2,>=1.58
2
+ aiohttp<4,>=3.14
@@ -0,0 +1 @@
1
+ dailo_devtools