webmuxd 0.1.0__py3-none-any.whl
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.
- webmuxd/__init__.py +44 -0
- webmuxd/__main__.py +8 -0
- webmuxd/cli/__init__.py +1 -0
- webmuxd/cli/__main__.py +587 -0
- webmuxd/cli/install.py +151 -0
- webmuxd/cli/registry.py +89 -0
- webmuxd/client/__init__.py +14 -0
- webmuxd/client/manager.py +142 -0
- webmuxd/client/mirror.py +200 -0
- webmuxd/client/observation.py +116 -0
- webmuxd/client/session.py +178 -0
- webmuxd/client/tab.py +376 -0
- webmuxd/client/transport.py +95 -0
- webmuxd/core/__init__.py +1 -0
- webmuxd/core/act.py +675 -0
- webmuxd/core/cdp.py +227 -0
- webmuxd/core/locate.py +345 -0
- webmuxd/core/log.py +260 -0
- webmuxd/core/observe.py +236 -0
- webmuxd/core/shim.py +104 -0
- webmuxd/core/tabs.py +381 -0
- webmuxd/env.py +68 -0
- webmuxd/errors.py +270 -0
- webmuxd/runtime/__init__.py +52 -0
- webmuxd/runtime/base.py +114 -0
- webmuxd/runtime/container.py +256 -0
- webmuxd/runtime/process.py +190 -0
- webmuxd/runtime/remote.py +39 -0
- webmuxd/serve/__init__.py +1 -0
- webmuxd/serve/__main__.py +56 -0
- webmuxd/serve/app.py +380 -0
- webmuxd/serve/session.py +403 -0
- webmuxd-0.1.0.dist-info/METADATA +241 -0
- webmuxd-0.1.0.dist-info/RECORD +38 -0
- webmuxd-0.1.0.dist-info/WHEEL +5 -0
- webmuxd-0.1.0.dist-info/entry_points.txt +2 -0
- webmuxd-0.1.0.dist-info/licenses/LICENSE +201 -0
- webmuxd-0.1.0.dist-info/top_level.txt +1 -0
webmuxd/__init__.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""webmuxd —— tmux + ttyd,只是 pane 里渲染的不是 tty 字符,是浏览器像素。
|
|
2
|
+
|
|
3
|
+
三个对象,一层套一层(docs/v1/sdk/README.md §1):
|
|
4
|
+
|
|
5
|
+
web = Webmuxd() # 管理实例,空壳
|
|
6
|
+
sess = web.session(id="work", port=7900, vnc_port=6901)
|
|
7
|
+
tab = sess.open("https://example.com")
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from webmuxd.client import ( # noqa: F401
|
|
11
|
+
Webmuxd, Session, Tab, ActResult, Observation, Element,
|
|
12
|
+
)
|
|
13
|
+
from webmuxd.errors import ( # noqa: F401
|
|
14
|
+
WebmuxdError,
|
|
15
|
+
ActionError,
|
|
16
|
+
PlatformError,
|
|
17
|
+
UsageError,
|
|
18
|
+
NotFound,
|
|
19
|
+
NotClickable,
|
|
20
|
+
Timeout,
|
|
21
|
+
NavFailed,
|
|
22
|
+
TabGone,
|
|
23
|
+
Busy,
|
|
24
|
+
BusyHuman,
|
|
25
|
+
ChromeGone,
|
|
26
|
+
SessionDead,
|
|
27
|
+
RuntimeUnavailable,
|
|
28
|
+
PortInUse,
|
|
29
|
+
BadRequest,
|
|
30
|
+
BlockedURL,
|
|
31
|
+
ReadOnly,
|
|
32
|
+
SessionExists,
|
|
33
|
+
SessionNotFound,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
__version__ = "0.1.0"
|
|
37
|
+
__all__ = ["__version__", "Webmuxd", "Session", "Tab", "ActResult",
|
|
38
|
+
"Observation", "Element"] + [
|
|
39
|
+
"WebmuxdError", "ActionError", "PlatformError", "UsageError",
|
|
40
|
+
"NotFound", "NotClickable", "Timeout", "NavFailed", "TabGone",
|
|
41
|
+
"Busy", "BusyHuman", "ChromeGone", "SessionDead", "RuntimeUnavailable",
|
|
42
|
+
"PortInUse", "BadRequest", "BlockedURL", "ReadOnly", "SessionExists",
|
|
43
|
+
"SessionNotFound",
|
|
44
|
+
]
|
webmuxd/__main__.py
ADDED
webmuxd/cli/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
webmuxd/cli/__main__.py
ADDED
|
@@ -0,0 +1,587 @@
|
|
|
1
|
+
"""`webmuxd` 命令行 —— docs/v1/cli/。
|
|
2
|
+
|
|
3
|
+
照着 tmux 设计。用过 tmux 的人应该不用查文档。
|
|
4
|
+
|
|
5
|
+
**CLI 是 lib 的一个用户**,和你的代码平级 —— 它自己不实现任何行为,
|
|
6
|
+
每条命令就是一次 lib 调用。多出来的只有两样,都是终端才需要的:
|
|
7
|
+
**目标解析**(`-t work:购物车` 按标题匹配)和**输出格式化**。
|
|
8
|
+
|
|
9
|
+
退出码是给脚本用的契约,**不要靠解析输出**(cli/README §6)。
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import sys
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from webmuxd import Webmuxd, runtime as rt
|
|
21
|
+
from webmuxd.cli.registry import Registry
|
|
22
|
+
from webmuxd.client.session import Session
|
|
23
|
+
from webmuxd.client.tab import Tab
|
|
24
|
+
from webmuxd.errors import WebmuxdError
|
|
25
|
+
|
|
26
|
+
# 退出码 → 错误码(cli/README §6)。4/5/6 可重试,7 该告警。
|
|
27
|
+
EXIT = {
|
|
28
|
+
"bad_request": 2, "blocked_url": 2,
|
|
29
|
+
"session_not_found": 3, "tab_gone": 3, "session_exists": 3,
|
|
30
|
+
"not_found": 4, "not_clickable": 4,
|
|
31
|
+
"timeout": 5,
|
|
32
|
+
"busy": 6, "busy_human": 6,
|
|
33
|
+
"chrome_gone": 7, "session_dead": 7, "runtime_unavailable": 7,
|
|
34
|
+
"port_in_use": 7,
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def main(argv: list[str] | None = None) -> int:
|
|
39
|
+
p = _parser()
|
|
40
|
+
args = p.parse_args(argv)
|
|
41
|
+
if not getattr(args, "cmd", None):
|
|
42
|
+
p.print_help()
|
|
43
|
+
return 2
|
|
44
|
+
try:
|
|
45
|
+
return args.fn(args) or 0
|
|
46
|
+
except WebmuxdError as e:
|
|
47
|
+
print(f"✗ {e.code}: {e.message}", file=sys.stderr)
|
|
48
|
+
for c in (e.details.get("candidates") or [])[:5]:
|
|
49
|
+
print(f" 候选: {c.get('role','') :8} \"{c.get('name','')}\"",
|
|
50
|
+
file=sys.stderr)
|
|
51
|
+
if e.details.get("hint"):
|
|
52
|
+
print(f" {e.details['hint']}", file=sys.stderr)
|
|
53
|
+
return EXIT.get(e.code or "", 1)
|
|
54
|
+
except BrokenPipeError:
|
|
55
|
+
return 0
|
|
56
|
+
except KeyboardInterrupt:
|
|
57
|
+
return 130
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# ---------------------------------------------------------------------------
|
|
61
|
+
# 目标解析 —— `session[:tab]`,**全在客户端做**,服务端只认 tab id
|
|
62
|
+
# ---------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
def _split_target(t: str | None) -> tuple[str | None, str | None]:
|
|
65
|
+
t = t or os.environ.get("WEBMUXD_TARGET") or None
|
|
66
|
+
if not t:
|
|
67
|
+
return None, None
|
|
68
|
+
sid, _, tab = t.partition(":")
|
|
69
|
+
return (sid or None), (tab or None)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _manager(args: argparse.Namespace) -> Webmuxd:
|
|
73
|
+
return Webmuxd(args.host, token=os.environ.get("WEBMUXD_TOKEN"),
|
|
74
|
+
user=args.user or os.environ.get("WEBMUXD_USER") or "cli",
|
|
75
|
+
name=args.socket_name)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _session(args: argparse.Namespace) -> Session:
|
|
79
|
+
sid, _tab = _split_target(args.target)
|
|
80
|
+
reg = Registry(name=args.socket_name)
|
|
81
|
+
rows = reg.list()
|
|
82
|
+
if sid is None:
|
|
83
|
+
live = [r for r in rows if r["state"] == "ready"]
|
|
84
|
+
if len(live) != 1:
|
|
85
|
+
# **不猜** —— 点错浏览器的代价比敲错终端大(cli/README §2)
|
|
86
|
+
raise WebmuxdError(
|
|
87
|
+
f"有 {len(live)} 个 session,得用 -t 指定" if live
|
|
88
|
+
else "没有在跑的 session", code="session_not_found")
|
|
89
|
+
sid = live[0]["id"]
|
|
90
|
+
|
|
91
|
+
row = reg.get(sid)
|
|
92
|
+
if row is None:
|
|
93
|
+
raise WebmuxdError(f"没有叫 {sid!r} 的 session", code="session_not_found")
|
|
94
|
+
return _manager(args).session(id=sid, port=row["api_port"],
|
|
95
|
+
vnc_port=row.get("vnc_port") or None)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _tab(args: argparse.Namespace) -> Tab:
|
|
99
|
+
"""`work:2` / `work:t_7` / `work:购物车` —— 后两种都是本地匹配。"""
|
|
100
|
+
sess = _session(args)
|
|
101
|
+
_sid, key = _split_target(args.target)
|
|
102
|
+
if not key:
|
|
103
|
+
t = sess.active
|
|
104
|
+
if t is None:
|
|
105
|
+
raise WebmuxdError("这个 session 一个 tab 都没有", code="tab_gone")
|
|
106
|
+
return t
|
|
107
|
+
if key.isdigit():
|
|
108
|
+
return sess.tab(int(key))
|
|
109
|
+
if key.startswith("t_"):
|
|
110
|
+
return sess.tab(key)
|
|
111
|
+
return sess.tab(title=key)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# ---------------------------------------------------------------------------
|
|
115
|
+
# 输出
|
|
116
|
+
# ---------------------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
def _out(args: argparse.Namespace, payload: Any, text: str = "") -> None:
|
|
119
|
+
"""`--json` 吐 API 的**原始响应**,不做格式化 —— 方便和 API 混着用。"""
|
|
120
|
+
if getattr(args, "json", False):
|
|
121
|
+
print(json.dumps(payload, ensure_ascii=False))
|
|
122
|
+
elif text:
|
|
123
|
+
print(text)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _fmt(template: str, **fields: Any) -> str:
|
|
127
|
+
out = template
|
|
128
|
+
for k, v in fields.items():
|
|
129
|
+
out = out.replace("#{" + k + "}", "" if v is None else str(v))
|
|
130
|
+
return out
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
# ---------------------------------------------------------------------------
|
|
134
|
+
# 会话
|
|
135
|
+
# ---------------------------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
def cmd_new(args: argparse.Namespace) -> int:
|
|
138
|
+
# **命令行 > 环境变量 > 内置默认。** 没有配置文件那一档 ——
|
|
139
|
+
# 参数从 lib 传(sdk/manager.md),CLI 只是把它们摆成 flag。
|
|
140
|
+
runtime = args.runtime or rt.default()
|
|
141
|
+
url = args.url or os.environ.get("WEBMUXD_START_URL") or "about:blank"
|
|
142
|
+
viewport = args.viewport or os.environ.get("WEBMUXD_VIEWPORT") or "1280x800"
|
|
143
|
+
|
|
144
|
+
reg = Registry(name=args.socket_name)
|
|
145
|
+
impl = rt.get(runtime)
|
|
146
|
+
if reg.get(args.id) and reg.list():
|
|
147
|
+
row = next((r for r in reg.list() if r["id"] == args.id), None)
|
|
148
|
+
if row and row["state"] == "ready":
|
|
149
|
+
# **幂等** —— 同一个 id 再建一次就是接管,不报错(像 tmux new -A -s)
|
|
150
|
+
print(f"{args.id} → 已经在跑了")
|
|
151
|
+
return 0
|
|
152
|
+
reg.forget(args.id)
|
|
153
|
+
|
|
154
|
+
handle = impl.start(args.id, api_port=args.port, vnc_port=args.vnc_port or 0,
|
|
155
|
+
url=url, viewport=viewport,
|
|
156
|
+
volume=args.volume, proxy=args.proxy,
|
|
157
|
+
endpoint=args.endpoint,
|
|
158
|
+
token=os.environ.get("WEBMUXD_TOKEN"))
|
|
159
|
+
reg.put(handle)
|
|
160
|
+
_out(args, {"id": args.id, "api_port": handle.api_port,
|
|
161
|
+
"vnc_port": handle.vnc_port,
|
|
162
|
+
"vnc_url": handle.vnc_url, "api_url": handle.api_url,
|
|
163
|
+
"vnc_user": handle.detail.get("vnc_user", ""),
|
|
164
|
+
"vnc_password": handle.detail.get("vnc_password", "")},
|
|
165
|
+
f"{args.id} → 画面 {handle.vnc_url or '(无)'} API {handle.api_url}")
|
|
166
|
+
if not args.json and handle.detail.get("vnc_password"):
|
|
167
|
+
# 密码是起的时候现生成的,**这是唯一会说出来的一次**
|
|
168
|
+
print(f" 登录 {handle.detail.get('vnc_user')} / "
|
|
169
|
+
f"{handle.detail['vnc_password']} (自签名证书,浏览器会拦一下)")
|
|
170
|
+
for note in handle.detail.get("notes") or []:
|
|
171
|
+
print(f" ⚠ {note}", file=sys.stderr)
|
|
172
|
+
return 0
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def cmd_ls(args: argparse.Namespace) -> int:
|
|
176
|
+
rows = Registry(name=args.socket_name).list()
|
|
177
|
+
_out(args, {"sessions": rows})
|
|
178
|
+
if args.json:
|
|
179
|
+
return 0
|
|
180
|
+
if not rows:
|
|
181
|
+
print("(没有 session)")
|
|
182
|
+
return 0
|
|
183
|
+
for r in rows:
|
|
184
|
+
ports = f"{r.get('vnc_port') or '-'}/{r['api_port']}"
|
|
185
|
+
tail = "" if r["state"] == "ready" else \
|
|
186
|
+
f" dead — webmuxd kill -t {r['id']} 清掉"
|
|
187
|
+
print(f"{r['id']:<10} {r['runtime']:<10} {ports:<12} {r['state']}{tail}")
|
|
188
|
+
return 0
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def cmd_has(args: argparse.Namespace) -> int:
|
|
192
|
+
"""只返回退出码,给脚本用:`webmuxd has -t work || webmuxd new ...`"""
|
|
193
|
+
sid, _ = _split_target(args.target)
|
|
194
|
+
row = next((r for r in Registry(name=args.socket_name).list()
|
|
195
|
+
if r["id"] == sid), None)
|
|
196
|
+
return 0 if row and row["state"] == "ready" else 3
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def cmd_kill(args: argparse.Namespace) -> int:
|
|
200
|
+
sid, _ = _split_target(args.target)
|
|
201
|
+
reg = Registry(name=args.socket_name)
|
|
202
|
+
row = reg.get(sid)
|
|
203
|
+
if row is None:
|
|
204
|
+
raise WebmuxdError(f"没有叫 {sid!r} 的 session", code="session_not_found")
|
|
205
|
+
handle = reg.handle(sid)
|
|
206
|
+
impl = rt.get(row["runtime"])
|
|
207
|
+
impl.stop(handle)
|
|
208
|
+
reg.forget(sid)
|
|
209
|
+
note = "(remote session,对面仍在运行)" if row["runtime"] == "remote" else ""
|
|
210
|
+
_out(args, {"id": sid, "removed": True}, f"{sid} 已停掉 {note}".strip())
|
|
211
|
+
return 0
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def cmd_install(args: argparse.Namespace) -> int:
|
|
215
|
+
from webmuxd.cli.install import install
|
|
216
|
+
install(pull=not args.no_pull)
|
|
217
|
+
return 0
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def cmd_info(args: argparse.Namespace) -> int:
|
|
221
|
+
from webmuxd import env
|
|
222
|
+
rows = Registry(name=args.socket_name).list()
|
|
223
|
+
rec = env.load()
|
|
224
|
+
info = {"version": "0.1.0", "runtimes": rt.detect(),
|
|
225
|
+
"default_runtime": rt.default(),
|
|
226
|
+
"env_record": {"at": rec["at"]} if rec else None,
|
|
227
|
+
"sessions": {"total": len(rows),
|
|
228
|
+
"ready": sum(1 for r in rows if r["state"] == "ready"),
|
|
229
|
+
"dead": sum(1 for r in rows if r["state"] != "ready")}}
|
|
230
|
+
_out(args, info,
|
|
231
|
+
"\n".join([f"版本 {info['version']}",
|
|
232
|
+
f"runtime " + ", ".join(
|
|
233
|
+
f"{k}{'' if v else '(不可用)'}"
|
|
234
|
+
for k, v in info["runtimes"].items()),
|
|
235
|
+
f"session {info['sessions']['ready']} 在跑,"
|
|
236
|
+
f"{info['sessions']['dead']} 死了",
|
|
237
|
+
(f"记录 {rec['at']}(webmuxd install 探的)" if rec
|
|
238
|
+
else "记录 没有 —— 每次都现探,"
|
|
239
|
+
"跑 `webmuxd install` 可以省掉")]))
|
|
240
|
+
return 0
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def cmd_attach(args: argparse.Namespace) -> int:
|
|
244
|
+
sid, _ = _split_target(args.target)
|
|
245
|
+
row = Registry(name=args.socket_name).get(sid)
|
|
246
|
+
if not row:
|
|
247
|
+
raise WebmuxdError(f"没有叫 {sid!r} 的 session", code="session_not_found")
|
|
248
|
+
if not row.get("vnc_port"):
|
|
249
|
+
raise WebmuxdError(f"{sid} 没有画面(起它的时候没有 VNC)",
|
|
250
|
+
code="bad_request")
|
|
251
|
+
url = f"http://127.0.0.1:{row['vnc_port']}"
|
|
252
|
+
print(url)
|
|
253
|
+
if not args.print_only:
|
|
254
|
+
import webbrowser
|
|
255
|
+
webbrowser.open(url)
|
|
256
|
+
return 0
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
# ---------------------------------------------------------------------------
|
|
260
|
+
# tab
|
|
261
|
+
# ---------------------------------------------------------------------------
|
|
262
|
+
|
|
263
|
+
def cmd_tabs(args: argparse.Namespace) -> int:
|
|
264
|
+
sess = _session(args)
|
|
265
|
+
tabs = sess.tabs
|
|
266
|
+
_out(args, {"tabs": [t._row() for t in tabs],
|
|
267
|
+
"active": sess._mirror.active})
|
|
268
|
+
if args.json:
|
|
269
|
+
return 0
|
|
270
|
+
for t in tabs:
|
|
271
|
+
row = t._row()
|
|
272
|
+
if args.format:
|
|
273
|
+
print(_fmt(args.format, tab_id=t.id, tab_index=row.get("index"),
|
|
274
|
+
tab_title=row.get("title"), tab_url=row.get("url"),
|
|
275
|
+
tab_active=1 if t.active else "",
|
|
276
|
+
tab_loading=1 if row.get("loading") else "",
|
|
277
|
+
session_name=sess.id, tab_count=len(tabs)))
|
|
278
|
+
else:
|
|
279
|
+
print(f"{row.get('index', 0)}: {row.get('title','') :<14} "
|
|
280
|
+
f"{row.get('url','')} {'●' if t.active else ''}")
|
|
281
|
+
return 0
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def cmd_new_tab(args: argparse.Namespace) -> int:
|
|
285
|
+
sess = _session(args)
|
|
286
|
+
tab = sess.open(args.url or "about:blank", active=not args.no_switch)
|
|
287
|
+
_out(args, tab._row(), f"✓ {tab.id} {tab.title}")
|
|
288
|
+
return 0
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def cmd_select_tab(args: argparse.Namespace) -> int:
|
|
292
|
+
t = _tab(args)
|
|
293
|
+
t.activate()
|
|
294
|
+
_out(args, t._row(), f"✓ 切到 {t.id}")
|
|
295
|
+
return 0
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def cmd_kill_tab(args: argparse.Namespace) -> int:
|
|
299
|
+
t = _tab(args)
|
|
300
|
+
r = t.close()
|
|
301
|
+
created = (r or {}).get("created")
|
|
302
|
+
_out(args, r, f"✓ 关掉 {t.id}" +
|
|
303
|
+
(f";只剩它了,已新建 about:blank ({created['id']})" if created else ""))
|
|
304
|
+
return 0
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _nav(verb: str):
|
|
308
|
+
def run(args: argparse.Namespace) -> int:
|
|
309
|
+
t = _tab(args)
|
|
310
|
+
if verb == "goto":
|
|
311
|
+
t.goto(args.url)
|
|
312
|
+
else:
|
|
313
|
+
getattr(t, verb)()
|
|
314
|
+
_out(args, t._row(), f"✓ {verb} {t.url}")
|
|
315
|
+
return 0
|
|
316
|
+
return run
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def cmd_dialog(args: argparse.Namespace) -> int:
|
|
320
|
+
t = _tab(args)
|
|
321
|
+
t.answer(accept=not args.dismiss, text=args.text or "")
|
|
322
|
+
_out(args, {"ok": True}, "✓ 已" + ("取消" if args.dismiss else "确定"))
|
|
323
|
+
return 0
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
# ---------------------------------------------------------------------------
|
|
327
|
+
# 操作
|
|
328
|
+
# ---------------------------------------------------------------------------
|
|
329
|
+
|
|
330
|
+
def _locator(args: argparse.Namespace) -> dict:
|
|
331
|
+
spec: dict[str, Any] = {}
|
|
332
|
+
if getattr(args, "what", None):
|
|
333
|
+
spec["text"] = args.what
|
|
334
|
+
for src, dst in (("role", "role"), ("name", "name"), ("label", "label"),
|
|
335
|
+
("css", "css"), ("nth", "nth")):
|
|
336
|
+
v = getattr(args, src, None)
|
|
337
|
+
if v is not None:
|
|
338
|
+
spec[dst] = v
|
|
339
|
+
if getattr(args, "at", None):
|
|
340
|
+
spec["point"] = [float(x) for x in args.at.split(",")]
|
|
341
|
+
return spec
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _do(args: argparse.Namespace, spec: dict) -> int:
|
|
345
|
+
t = _tab(args)
|
|
346
|
+
r = t.act([spec], note=args.note, user=args.user)
|
|
347
|
+
res = r.results[0]
|
|
348
|
+
_out(args, {"results": r.results, "log_from": r.log_from})
|
|
349
|
+
if not res.get("ok"):
|
|
350
|
+
raise WebmuxdError(res.get("message", ""), code=res.get("error"),
|
|
351
|
+
details={"candidates": res.get("candidates") or []})
|
|
352
|
+
if not args.json:
|
|
353
|
+
hit = res.get("hit") or {}
|
|
354
|
+
print(f"✓ {spec['type']} → {hit.get('role','')} \"{hit.get('name','')}\""
|
|
355
|
+
f" {res.get('ms',0)}ms")
|
|
356
|
+
after = res.get("after") or {}
|
|
357
|
+
if after.get("changed"):
|
|
358
|
+
print(f" → {after.get('url','')} {after['changed']}")
|
|
359
|
+
return 0
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def cmd_click(args): return _do(args, {"type": "click", **_locator(args)})
|
|
363
|
+
def cmd_key(args): return _do(args, {"type": "key", "key": args.key})
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def cmd_type(args: argparse.Namespace) -> int:
|
|
367
|
+
spec = {"type": "type", "text": args.text, **_locator(args)}
|
|
368
|
+
spec.pop("text", None) if False else None
|
|
369
|
+
# `type` 的定位不看 text —— 那是内容(api/act.md §4.1)
|
|
370
|
+
if "text" in spec and getattr(args, "what", None):
|
|
371
|
+
spec["label"] = spec.pop("text")
|
|
372
|
+
spec["text"] = args.text
|
|
373
|
+
return _do(args, spec)
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def cmd_scroll(args): return _do(args, {"type": "scroll", "dy": args.dy})
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def cmd_wait(args: argparse.Namespace) -> int:
|
|
380
|
+
spec: dict[str, Any] = {"type": "wait_for",
|
|
381
|
+
"timeout_ms": int(args.timeout * 1000)}
|
|
382
|
+
for k in ("text", "css", "url_contains"):
|
|
383
|
+
v = getattr(args, k, None)
|
|
384
|
+
if v:
|
|
385
|
+
spec[k] = v
|
|
386
|
+
return _do(args, spec)
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def cmd_send(args: argparse.Namespace) -> int:
|
|
390
|
+
"""逃生舱 —— 直接发动作数组,CLI 没做成子命令的动作都能用它。"""
|
|
391
|
+
actions = json.loads(args.actions)
|
|
392
|
+
if not isinstance(actions, list):
|
|
393
|
+
actions = [actions]
|
|
394
|
+
t = _tab(args)
|
|
395
|
+
r = t.act(actions, note=args.note, user=args.user)
|
|
396
|
+
_out(args, {"results": r.results, "log_from": r.log_from})
|
|
397
|
+
bad = r.failed
|
|
398
|
+
if bad:
|
|
399
|
+
raise WebmuxdError(bad.get("message", ""), code=bad.get("error"),
|
|
400
|
+
details={"candidates": bad.get("candidates") or []})
|
|
401
|
+
if not args.json:
|
|
402
|
+
print(f"✓ {len(r.results)} 个动作")
|
|
403
|
+
return 0
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
# ---------------------------------------------------------------------------
|
|
407
|
+
# 看
|
|
408
|
+
# ---------------------------------------------------------------------------
|
|
409
|
+
|
|
410
|
+
def cmd_url(args: argparse.Namespace) -> int:
|
|
411
|
+
t = _tab(args)
|
|
412
|
+
_out(args, t._row(), t.url)
|
|
413
|
+
return 0
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def cmd_status(args: argparse.Namespace) -> int:
|
|
417
|
+
st = _session(args).status()
|
|
418
|
+
_out(args, st,
|
|
419
|
+
f"chrome {'活着' if st['chrome']['alive'] else '没了'} "
|
|
420
|
+
f"{st['tab_count']} 个 tab 当前 {st['active_tab']} "
|
|
421
|
+
f"{'忙' if st['busy'] else '闲'}")
|
|
422
|
+
return 0
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def cmd_capture(args: argparse.Namespace) -> int:
|
|
426
|
+
t = _tab(args)
|
|
427
|
+
if args.shot:
|
|
428
|
+
t.screenshot(args.shot)
|
|
429
|
+
print(f"✓ 存到 {args.shot}")
|
|
430
|
+
else:
|
|
431
|
+
sys.stdout.write(t.text())
|
|
432
|
+
return 0
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def cmd_observe(args: argparse.Namespace) -> int:
|
|
436
|
+
t = _tab(args)
|
|
437
|
+
obs = t.observe()
|
|
438
|
+
if args.shot:
|
|
439
|
+
with open(args.shot, "wb") as fh:
|
|
440
|
+
fh.write(obs.screenshot)
|
|
441
|
+
_out(args, obs._d, obs.as_prompt())
|
|
442
|
+
if not args.json:
|
|
443
|
+
for n in obs.notes:
|
|
444
|
+
print(f" ⚠ {n}", file=sys.stderr)
|
|
445
|
+
return 0
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
# ---------------------------------------------------------------------------
|
|
449
|
+
# 日志
|
|
450
|
+
# ---------------------------------------------------------------------------
|
|
451
|
+
|
|
452
|
+
def cmd_log(args: argparse.Namespace) -> int:
|
|
453
|
+
sess = _session(args)
|
|
454
|
+
_sid, key = _split_target(args.target)
|
|
455
|
+
tab_id = _tab(args).id if key else None
|
|
456
|
+
entries = sess.log(limit=args.n, tab=tab_id, kind=args.kind,
|
|
457
|
+
user=args.log_user,
|
|
458
|
+
only="failed" if args.failed else None)
|
|
459
|
+
_out(args, {"entries": entries})
|
|
460
|
+
if args.json:
|
|
461
|
+
return 0
|
|
462
|
+
for e in entries:
|
|
463
|
+
at = (e.get("at") or "")[11:19]
|
|
464
|
+
if e.get("note"):
|
|
465
|
+
print(f"{at} 💭 {e.get('user','')}:{e['note']}")
|
|
466
|
+
if e.get("kind") != "action":
|
|
467
|
+
print(f"{at} · {e.get('kind')}: {e.get('event','')} {e.get('tab','')}")
|
|
468
|
+
continue
|
|
469
|
+
mark = "✗" if e.get("ok") is False else ("👤" if e.get("user") == "human" else " ")
|
|
470
|
+
hit = (e.get("hit") or {}).get("name")
|
|
471
|
+
print(f"{at} {mark} {e.get('action')} {json.dumps(e.get('target'), ensure_ascii=False)}"
|
|
472
|
+
+ (f" → {hit}" if hit else "")
|
|
473
|
+
+ (f" {e.get('error')}" if e.get("error") else ""))
|
|
474
|
+
after = e.get("after") or {}
|
|
475
|
+
if after.get("changed"):
|
|
476
|
+
print(f" → {after.get('url','')} {after['changed']}")
|
|
477
|
+
return 0
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def cmd_bundle(args: argparse.Namespace) -> int:
|
|
481
|
+
data = _session(args).bundle(args.out)
|
|
482
|
+
print(f"✓ {len(data)} 字节 → {args.out}")
|
|
483
|
+
return 0
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
# ---------------------------------------------------------------------------
|
|
487
|
+
|
|
488
|
+
def _parser() -> argparse.ArgumentParser:
|
|
489
|
+
p = argparse.ArgumentParser(prog="webmuxd", description="tmux + ttyd,给浏览器")
|
|
490
|
+
p.add_argument("-H", "--host", default=os.environ.get("WEBMUXD_HOST"),
|
|
491
|
+
help="远端 server")
|
|
492
|
+
p.add_argument("-L", "--socket-name", default="default", help="换一套独立的登记簿")
|
|
493
|
+
p.add_argument("--json", action="store_true", help="吐 API 的原始响应")
|
|
494
|
+
p.add_argument("--user", default=None, help="操作的署名,进日志")
|
|
495
|
+
p.add_argument("--note", default=None, help="这一步的思考,进日志")
|
|
496
|
+
sub = p.add_subparsers(dest="cmd")
|
|
497
|
+
|
|
498
|
+
def add(name: str, fn, target: bool = True, **kw):
|
|
499
|
+
s = sub.add_parser(name, **kw)
|
|
500
|
+
if target:
|
|
501
|
+
s.add_argument("-t", "--target", default=None,
|
|
502
|
+
help="session[:tab],tab 可以是 index / t_N / 标题")
|
|
503
|
+
s.set_defaults(fn=fn)
|
|
504
|
+
return s
|
|
505
|
+
|
|
506
|
+
n = add("new", cmd_new, target=False, help="起一个 session")
|
|
507
|
+
n.add_argument("-s", "--id", required=True)
|
|
508
|
+
n.add_argument("-p", "--port", type=int, required=True, help="API 口,必填")
|
|
509
|
+
n.add_argument("--vnc-port", type=int, default=0, help="画面口")
|
|
510
|
+
n.add_argument("--runtime", default=None,
|
|
511
|
+
choices=["container", "process", "remote"])
|
|
512
|
+
n.add_argument("-u", "--url", default=None)
|
|
513
|
+
n.add_argument("-v", "--viewport", default=None)
|
|
514
|
+
n.add_argument("--volume", default=None)
|
|
515
|
+
n.add_argument("--proxy", default=None)
|
|
516
|
+
n.add_argument("--endpoint", default=None)
|
|
517
|
+
n.add_argument("-d", action="store_true", help="建完不 attach(默认就是)")
|
|
518
|
+
|
|
519
|
+
ins = add("install", cmd_install, target=False,
|
|
520
|
+
help="探一遍环境、装该装的、把结果记下来")
|
|
521
|
+
ins.add_argument("--no-pull", action="store_true",
|
|
522
|
+
help="不拉镜像,只探(离线时用)")
|
|
523
|
+
add("ls", cmd_ls, target=False, help="列出 session")
|
|
524
|
+
add("info", cmd_info, target=False, help="server 状态和 runtime 探测")
|
|
525
|
+
add("has", cmd_has, help="只返回退出码,给脚本用")
|
|
526
|
+
add("kill", cmd_kill, help="停掉一个 session")
|
|
527
|
+
a = add("attach", cmd_attach, help="打开画面")
|
|
528
|
+
a.add_argument("-p", "--print-only", action="store_true")
|
|
529
|
+
|
|
530
|
+
t = add("tabs", cmd_tabs, help="列出 tab")
|
|
531
|
+
t.add_argument("-F", "--format", default=None, help="#{tab_id} #{tab_url} …")
|
|
532
|
+
nt = add("new-tab", cmd_new_tab, help="新建 tab")
|
|
533
|
+
nt.add_argument("-u", "--url", default=None)
|
|
534
|
+
nt.add_argument("-n", "--no-switch", action="store_true")
|
|
535
|
+
add("select-tab", cmd_select_tab, help="切过去")
|
|
536
|
+
add("kill-tab", cmd_kill_tab, help="关掉")
|
|
537
|
+
g = add("goto", _nav("goto"), help="导航")
|
|
538
|
+
g.add_argument("url")
|
|
539
|
+
for verb in ("back", "forward", "reload", "stop"):
|
|
540
|
+
add(verb, _nav(verb), help=verb)
|
|
541
|
+
d = add("dialog", cmd_dialog, help="回应 alert / confirm / prompt")
|
|
542
|
+
d.add_argument("--dismiss", action="store_true")
|
|
543
|
+
d.add_argument("--text", default=None)
|
|
544
|
+
|
|
545
|
+
for name, fn in (("click", cmd_click),):
|
|
546
|
+
c = add(name, fn, help="点一下")
|
|
547
|
+
c.add_argument("what", nargs="?", default=None, help="可见文字")
|
|
548
|
+
c.add_argument("--role"), c.add_argument("--name")
|
|
549
|
+
c.add_argument("--css"), c.add_argument("--at")
|
|
550
|
+
c.add_argument("--nth", type=int, default=None)
|
|
551
|
+
ty = add("type", cmd_type, help="输入")
|
|
552
|
+
ty.add_argument("what", nargs="?", default=None, help="标签")
|
|
553
|
+
ty.add_argument("text")
|
|
554
|
+
ty.add_argument("--label"), ty.add_argument("--css")
|
|
555
|
+
ty.add_argument("--role"), ty.add_argument("--name")
|
|
556
|
+
ty.add_argument("--nth", type=int, default=None), ty.add_argument("--at")
|
|
557
|
+
k = add("key", cmd_key, help="按键")
|
|
558
|
+
k.add_argument("key")
|
|
559
|
+
sc = add("scroll", cmd_scroll, help="滚动")
|
|
560
|
+
sc.add_argument("--dy", type=float, default=400)
|
|
561
|
+
w = add("wait", cmd_wait, help="等条件")
|
|
562
|
+
w.add_argument("--text"), w.add_argument("--css")
|
|
563
|
+
w.add_argument("--url-contains", dest="url_contains")
|
|
564
|
+
w.add_argument("--timeout", type=float, default=10)
|
|
565
|
+
se = add("send", cmd_send, help="逃生舱:原始动作数组")
|
|
566
|
+
se.add_argument("actions")
|
|
567
|
+
|
|
568
|
+
add("url", cmd_url, help="当前 URL")
|
|
569
|
+
add("status", cmd_status, help="session 状态")
|
|
570
|
+
cap = add("capture", cmd_capture, help="抓正文或截图")
|
|
571
|
+
cap.add_argument("--text", action="store_true")
|
|
572
|
+
cap.add_argument("--shot", default=None)
|
|
573
|
+
ob = add("observe", cmd_observe, help="元素表")
|
|
574
|
+
ob.add_argument("--shot", default=None)
|
|
575
|
+
|
|
576
|
+
lg = add("log", cmd_log, help="操作日志")
|
|
577
|
+
lg.add_argument("-n", type=int, default=50)
|
|
578
|
+
lg.add_argument("--failed", action="store_true")
|
|
579
|
+
lg.add_argument("--kind", default=None, choices=["action", "tab", "session"])
|
|
580
|
+
lg.add_argument("--user", dest="log_user", default=None)
|
|
581
|
+
b = add("bundle", cmd_bundle, help="打包日志和截图")
|
|
582
|
+
b.add_argument("-o", "--out", default="bundle.zip")
|
|
583
|
+
return p
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
if __name__ == "__main__":
|
|
587
|
+
sys.exit(main())
|