browserwright 0.6.2__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.
- browserwright/__init__.py +33 -0
- browserwright/__main__.py +6 -0
- browserwright/_executor/__init__.py +47 -0
- browserwright/_executor/__main__.py +9 -0
- browserwright/_executor/client.py +127 -0
- browserwright/_executor/process.py +652 -0
- browserwright/_executor/protocol.py +152 -0
- browserwright/api.py +66 -0
- browserwright/cdp.py +285 -0
- browserwright/cli.py +741 -0
- browserwright/daemon/__init__.py +8 -0
- browserwright/daemon/_ipc.py +444 -0
- browserwright/daemon/active_tab.py +183 -0
- browserwright/daemon/auth.py +395 -0
- browserwright/daemon/backends/__init__.py +59 -0
- browserwright/daemon/backends/base.py +120 -0
- browserwright/daemon/backends/cloud.py +222 -0
- browserwright/daemon/backends/env.py +119 -0
- browserwright/daemon/backends/extension.py +185 -0
- browserwright/daemon/backends/rdp.py +214 -0
- browserwright/daemon/cli.py +1437 -0
- browserwright/daemon/config.py +380 -0
- browserwright/daemon/doctor.py +179 -0
- browserwright/daemon/errors.py +34 -0
- browserwright/daemon/launch_chrome.py +353 -0
- browserwright/daemon/observability.py +181 -0
- browserwright/daemon/platforms.py +234 -0
- browserwright/daemon/resolver.py +72 -0
- browserwright/daemon/server/__init__.py +6 -0
- browserwright/daemon/server/daemon.py +229 -0
- browserwright/daemon/server/executor_registry.py +434 -0
- browserwright/daemon/server/extension_upstream.py +677 -0
- browserwright/daemon/server/facade.py +375 -0
- browserwright/daemon/server/facade_extension.py +969 -0
- browserwright/daemon/server/listener.py +1058 -0
- browserwright/daemon/server/proxy.py +1991 -0
- browserwright/daemon/server/relay.py +783 -0
- browserwright/daemon/server/state.py +432 -0
- browserwright/daemon/server/upstream.py +266 -0
- browserwright/daemon/userscripts.py +150 -0
- browserwright/discovery.py +213 -0
- browserwright/errors.py +177 -0
- browserwright/health.py +169 -0
- browserwright/install.py +628 -0
- browserwright/memory/__init__.py +15 -0
- browserwright/memory/_md.py +120 -0
- browserwright/memory/_yaml.py +217 -0
- browserwright/memory/global_mem.py +201 -0
- browserwright/memory/repl_mem.py +28 -0
- browserwright/memory/session_decisions.py +53 -0
- browserwright/memory/site_mem.py +381 -0
- browserwright/mode_b_client.py +590 -0
- browserwright/multitask.py +131 -0
- browserwright/output_schema.py +99 -0
- browserwright/primitives/__init__.py +67 -0
- browserwright/primitives/discovery_api.py +79 -0
- browserwright/primitives/http.py +42 -0
- browserwright/primitives/inspect.py +876 -0
- browserwright/primitives/interact.py +518 -0
- browserwright/primitives/page.py +556 -0
- browserwright/primitives/site.py +143 -0
- browserwright/release_install.py +466 -0
- browserwright/repl/__init__.py +6 -0
- browserwright/repl/_namespace.py +106 -0
- browserwright/repl/_smart_goto.py +236 -0
- browserwright/repl/inline.py +180 -0
- browserwright/repl/playwright_handle.py +449 -0
- browserwright/repl/snapshot.py +150 -0
- browserwright/session.py +229 -0
- browserwright/session_create.py +252 -0
- browserwright/session_ctx.py +24 -0
- browserwright/session_registry.py +133 -0
- browserwright/session_runtime.py +133 -0
- browserwright/site_skills_starter/github.com/SKILL.md +14 -0
- browserwright/site_skills_starter/github.com/memory.md +29 -0
- browserwright/site_skills_starter/github.com/tasks/list_issues.py +55 -0
- browserwright/site_skills_starter/google.com/SKILL.md +16 -0
- browserwright/site_skills_starter/google.com/memory.md +27 -0
- browserwright/site_skills_starter/google.com/tasks/search.py +53 -0
- browserwright/site_skills_starter/producthunt.com/SKILL.md +7 -0
- browserwright/site_skills_starter/producthunt.com/memory.md +26 -0
- browserwright/site_skills_starter/producthunt.com/tasks/today.py +64 -0
- browserwright/site_skills_starter/wikipedia.org/SKILL.md +7 -0
- browserwright/site_skills_starter/wikipedia.org/memory.md +22 -0
- browserwright/site_skills_starter/wikipedia.org/tasks/lookup.py +55 -0
- browserwright/site_skills_starter/ycombinator.com/SKILL.md +8 -0
- browserwright/site_skills_starter/ycombinator.com/memory.md +25 -0
- browserwright/site_skills_starter/ycombinator.com/tasks/front_page.py +63 -0
- browserwright/skill_doc.py +140 -0
- browserwright/skill_runtime.md +194 -0
- browserwright/subscriptions.py +213 -0
- browserwright/task_runner.py +125 -0
- browserwright/version.py +117 -0
- browserwright-0.6.2.dist-info/METADATA +12 -0
- browserwright-0.6.2.dist-info/RECORD +98 -0
- browserwright-0.6.2.dist-info/WHEEL +5 -0
- browserwright-0.6.2.dist-info/entry_points.txt +3 -0
- browserwright-0.6.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1437 @@
|
|
|
1
|
+
"""argparse + subcommand dispatch.
|
|
2
|
+
|
|
3
|
+
Spec §5: Skill talks via subprocess and parses stdout/stderr/exit codes.
|
|
4
|
+
Stdout discipline (every subcommand):
|
|
5
|
+
- Success: one well-defined payload line, no decoration.
|
|
6
|
+
- Failure: stderr gets 1-3 human lines; stdout stays empty.
|
|
7
|
+
|
|
8
|
+
Exit codes (§5.1):
|
|
9
|
+
0 success
|
|
10
|
+
1 user error (bad CLI args, unknown backend)
|
|
11
|
+
2 backend(s) unavailable
|
|
12
|
+
3 internal / unexpected error
|
|
13
|
+
6 launch-chrome: Chrome binary not found
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import argparse
|
|
18
|
+
import asyncio
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import sys
|
|
22
|
+
from xml.sax.saxutils import escape as _xml_escape
|
|
23
|
+
from typing import NoReturn
|
|
24
|
+
|
|
25
|
+
from . import __version__
|
|
26
|
+
from .backends import names
|
|
27
|
+
from .config import load, Config
|
|
28
|
+
from .errors import ChromeBinaryNotFound, DaemonError, Unavailable, UserError
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# ---- entrypoint ------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def main(argv: list[str] | None = None) -> int:
|
|
35
|
+
parser = _build_parser()
|
|
36
|
+
args = parser.parse_args(argv)
|
|
37
|
+
handler = _DISPATCH.get(args.cmd)
|
|
38
|
+
if handler is None:
|
|
39
|
+
parser.print_help(sys.stderr)
|
|
40
|
+
return 1
|
|
41
|
+
try:
|
|
42
|
+
cfg = _cfg_from_args(args)
|
|
43
|
+
return handler(args, cfg)
|
|
44
|
+
except UserError as e:
|
|
45
|
+
print(f"error: {e}", file=sys.stderr)
|
|
46
|
+
return 1
|
|
47
|
+
except ChromeBinaryNotFound as e:
|
|
48
|
+
print(f"error: {e}", file=sys.stderr)
|
|
49
|
+
return 6
|
|
50
|
+
except Unavailable as e:
|
|
51
|
+
print(f"error: {e}", file=sys.stderr)
|
|
52
|
+
if e.attempts and getattr(args, "verbose", False):
|
|
53
|
+
for name, why in e.attempts.items():
|
|
54
|
+
print(f" {name}: {why}", file=sys.stderr)
|
|
55
|
+
return 2
|
|
56
|
+
except DaemonError as e:
|
|
57
|
+
# Catch-all daemon-internal failure that isn't UserError or Unavailable.
|
|
58
|
+
print(f"error: {e}", file=sys.stderr)
|
|
59
|
+
return 3
|
|
60
|
+
except KeyboardInterrupt:
|
|
61
|
+
return 130
|
|
62
|
+
except Exception as e: # truly unexpected
|
|
63
|
+
print(f"internal error: {type(e).__name__}: {e}", file=sys.stderr)
|
|
64
|
+
return 3
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _entry() -> NoReturn:
|
|
68
|
+
sys.exit(main())
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# ---- parser ----------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
75
|
+
p = argparse.ArgumentParser(
|
|
76
|
+
prog="browserwright-daemon",
|
|
77
|
+
description=(
|
|
78
|
+
"Resolve a browser-level CDP WebSocket from any local Chrome. "
|
|
79
|
+
"v0.1 is Mode A only (one-shot CLI). Mode B socket arrives in v0.2."
|
|
80
|
+
),
|
|
81
|
+
)
|
|
82
|
+
sub = p.add_subparsers(dest="cmd", metavar="<subcommand>")
|
|
83
|
+
|
|
84
|
+
# url
|
|
85
|
+
p_url = sub.add_parser("url", help="resolve a CDP ws URL and print it")
|
|
86
|
+
_add_common(p_url)
|
|
87
|
+
_add_port(p_url)
|
|
88
|
+
p_url.add_argument("--json", action="store_true", help="emit a JSON object instead of a bare URL")
|
|
89
|
+
p_url.add_argument("--mode-b-proxy", action="store_true",
|
|
90
|
+
help="instead of upstream ws, output the daemon socket endpoint (v0.2)")
|
|
91
|
+
_add_name(p_url)
|
|
92
|
+
|
|
93
|
+
# serve (v0.2)
|
|
94
|
+
p_serve = sub.add_parser("serve", help="run the long-lived Mode B daemon (v0.2)")
|
|
95
|
+
_add_common(p_serve)
|
|
96
|
+
_add_port(p_serve)
|
|
97
|
+
_add_name(p_serve)
|
|
98
|
+
# v0.5.3 Task #24: extension relay port override. Useful when default
|
|
99
|
+
# 19989 is occupied (e.g., a stale daemon process). playwriter sits on
|
|
100
|
+
# 19988, so the default no longer collides with it.
|
|
101
|
+
# Precedence: this flag > BD_EXTENSION_PORT > toml > 19989 default.
|
|
102
|
+
p_serve.add_argument(
|
|
103
|
+
"--extension-port", type=int, default=None, metavar="N",
|
|
104
|
+
help=("Bind the extension relay ws server on this port instead of "
|
|
105
|
+
"the default 19989. Only relevant to the shared extension relay. "
|
|
106
|
+
"Equivalent to BD_EXTENSION_PORT env or "
|
|
107
|
+
"[backends.extension].port in config.toml."))
|
|
108
|
+
# Playwright facade (Phase C: auto-enabled). Expose a Playwright-facing
|
|
109
|
+
# CDP ws+HTTP endpoint a real `chromium.connect_over_cdp` can connect to.
|
|
110
|
+
# ON by default (port 19990) — the skill layer's heredoc `page`/`context`
|
|
111
|
+
# depend on it. Pass an explicit port to override, or `--facade-port 0` to
|
|
112
|
+
# disable. Equivalent to BD_FACADE_PORT env or `facade_port` in config.toml.
|
|
113
|
+
p_serve.add_argument(
|
|
114
|
+
"--facade-port", type=int, default=None, metavar="N",
|
|
115
|
+
help=("bind the Playwright-facing CDP facade on this port "
|
|
116
|
+
"(default: ON at 19990; pass 0 to disable). Lets a real "
|
|
117
|
+
"Playwright client `connect_over_cdp(ws://127.0.0.1:N/cdp)` and "
|
|
118
|
+
"the skill heredoc `page`/`context` drive the resolved Chrome."))
|
|
119
|
+
|
|
120
|
+
# stop (v0.2)
|
|
121
|
+
p_stop = sub.add_parser("stop", help="stop the running daemon")
|
|
122
|
+
_add_name(p_stop)
|
|
123
|
+
p_stop.add_argument("--timeout", type=float, default=5.0,
|
|
124
|
+
help="seconds to wait for graceful shutdown before SIGKILL")
|
|
125
|
+
|
|
126
|
+
p_restart = sub.add_parser(
|
|
127
|
+
"restart",
|
|
128
|
+
help="restart the installed LaunchAgent daemon after an upgrade")
|
|
129
|
+
_add_name(p_restart)
|
|
130
|
+
p_restart.add_argument("--timeout", type=float, default=5.0,
|
|
131
|
+
help="seconds to wait for graceful unload/load")
|
|
132
|
+
|
|
133
|
+
# status (v0.2)
|
|
134
|
+
p_status = sub.add_parser("status", help="report the daemon's IPC endpoint + liveness")
|
|
135
|
+
_add_name(p_status)
|
|
136
|
+
p_status.add_argument("--json", action="store_true")
|
|
137
|
+
|
|
138
|
+
# disconnect (v0.2 §6.6)
|
|
139
|
+
p_disc = sub.add_parser("disconnect",
|
|
140
|
+
help="ask the running daemon to close its upstream ws (banner goes away)")
|
|
141
|
+
_add_name(p_disc)
|
|
142
|
+
p_disc.add_argument("--session", default=os.environ.get("BD_SESSION"),
|
|
143
|
+
help="browserwright session id (defaults to BD_SESSION)")
|
|
144
|
+
p_disc.add_argument("--reason", default="skill_disconnect",
|
|
145
|
+
help="reason string surfaced in upstreamClosed event")
|
|
146
|
+
|
|
147
|
+
# logs (v0.2)
|
|
148
|
+
p_logs = sub.add_parser("logs", help="print the daemon log file path or tail it")
|
|
149
|
+
_add_name(p_logs)
|
|
150
|
+
p_logs.add_argument("--follow", "-f", action="store_true", help="tail -f the log")
|
|
151
|
+
|
|
152
|
+
# doctor
|
|
153
|
+
p_doc = sub.add_parser("doctor", help="probe all backends (zero ws side effects by default)")
|
|
154
|
+
_add_common(p_doc)
|
|
155
|
+
p_doc.add_argument("--probe-ws", action="store_true",
|
|
156
|
+
help="opt-in: actually open a ws on each available backend (NOT IMPLEMENTED in v0.1)")
|
|
157
|
+
p_doc.add_argument("--json", action="store_true", help="emit JSON instead of pretty text")
|
|
158
|
+
|
|
159
|
+
# list-backends
|
|
160
|
+
p_lb = sub.add_parser("list-backends", help="enumerate backends statically (no probe)")
|
|
161
|
+
_add_common(p_lb)
|
|
162
|
+
p_lb.add_argument("--json", action="store_true")
|
|
163
|
+
|
|
164
|
+
# active-tab
|
|
165
|
+
p_at = sub.add_parser("active-tab", help="best-guess user-active tab (heuristic, opens ws)")
|
|
166
|
+
_add_common(p_at)
|
|
167
|
+
_add_port(p_at)
|
|
168
|
+
p_at.add_argument("--session", default=os.environ.get("BD_SESSION"),
|
|
169
|
+
help="browserwright session id (defaults to BD_SESSION)")
|
|
170
|
+
p_at.add_argument("--json", action="store_true")
|
|
171
|
+
|
|
172
|
+
# backend-info — what backend is the running daemon serving?
|
|
173
|
+
# Mode B Skill clients shell out to this to decide whether the current
|
|
174
|
+
# daemon matches their expected backend (refused-mismatch guard) and to
|
|
175
|
+
# branch primitives on backend-specific quirks (e.g. extension's "0
|
|
176
|
+
# attached tabs is actionable, not empty Chrome").
|
|
177
|
+
p_bi = sub.add_parser(
|
|
178
|
+
"backend-info",
|
|
179
|
+
help="report the running daemon's backend identity (Mode B identity probe)")
|
|
180
|
+
_add_name(p_bi)
|
|
181
|
+
p_bi.add_argument("--session", default=os.environ.get("BD_SESSION"),
|
|
182
|
+
help="browserwright session id (defaults to BD_SESSION)")
|
|
183
|
+
p_bi.add_argument("--json", action="store_true")
|
|
184
|
+
|
|
185
|
+
# attach-active (v0.5.4 — extension backend only)
|
|
186
|
+
p_aa = sub.add_parser(
|
|
187
|
+
"attach-active",
|
|
188
|
+
help="(extension backend) attach the focused-window active tab without a popup click")
|
|
189
|
+
_add_name(p_aa)
|
|
190
|
+
p_aa.add_argument("--session", default=os.environ.get("BD_SESSION"),
|
|
191
|
+
help="browserwright session id (defaults to BD_SESSION)")
|
|
192
|
+
p_aa.add_argument("--json", action="store_true")
|
|
193
|
+
|
|
194
|
+
# launch-chrome
|
|
195
|
+
p_lc = sub.add_parser("launch-chrome", help="launch a detached isolated-profile Chrome and print its ws URL")
|
|
196
|
+
p_lc.add_argument("--profile", default="isolated")
|
|
197
|
+
g = p_lc.add_mutually_exclusive_group()
|
|
198
|
+
g.add_argument("--persistent", action="store_true", default=True,
|
|
199
|
+
help="reuse a profile dir across launches (default)")
|
|
200
|
+
g.add_argument("--tmp", action="store_true", default=False,
|
|
201
|
+
help="allocate a fresh tmpdir per launch (caller cleans it up)")
|
|
202
|
+
p_lc.add_argument("--chrome-binary", help="absolute path to chrome / chromium / msedge / brave")
|
|
203
|
+
p_lc.add_argument("--port", type=int, default=None,
|
|
204
|
+
help="--remote-debugging-port=N; default 0 (OS-picked)")
|
|
205
|
+
# v0.5.3 F-18: `--detach` flag was reserved-in-v0.1 placeholder for a
|
|
206
|
+
# non-detach mode we never shipped + never needed (Chrome always
|
|
207
|
+
# detaches via `_spawn_kwargs()`). Removed to declutter the help text;
|
|
208
|
+
# if a future need arises, re-add with real behavior.
|
|
209
|
+
p_lc.add_argument("--timeout", type=float, default=30.0)
|
|
210
|
+
p_lc.add_argument("--json", action="store_true")
|
|
211
|
+
p_lc.add_argument("-v", "--verbose", action="store_true")
|
|
212
|
+
# v0.5 Task #11 expert escape: bypass the "refuse user-default profile"
|
|
213
|
+
# guard. STRONGLY discouraged — see chrome-popup-accumulation-bug memory.
|
|
214
|
+
p_lc.add_argument(
|
|
215
|
+
"--allow-default-profile", action="store_true", default=False,
|
|
216
|
+
help=(
|
|
217
|
+
"EXPERT ESCAPE HATCH: allow launch-chrome to target the user's "
|
|
218
|
+
"default Chrome profile. Doing so permanently taints the daily "
|
|
219
|
+
"Chrome with --remote-debugging-port; every ws upgrade triggers "
|
|
220
|
+
"a Chrome 'Allow remote debugging?' popup. Equivalent to env "
|
|
221
|
+
"BD_LAUNCH_CHROME_ALLOW_DEFAULT_PROFILE=1."))
|
|
222
|
+
|
|
223
|
+
# version
|
|
224
|
+
p_ver = sub.add_parser("version", help="print the installed version and exit")
|
|
225
|
+
p_ver.add_argument("--json", action="store_true")
|
|
226
|
+
p_ver.add_argument("action", nargs="?", choices=["check"])
|
|
227
|
+
|
|
228
|
+
# stats (v0.5 observability)
|
|
229
|
+
p_stats = sub.add_parser("stats", help="dump in-process metrics counters")
|
|
230
|
+
_add_name(p_stats)
|
|
231
|
+
p_stats.add_argument("--json", action="store_true",
|
|
232
|
+
help="emit as JSON (default: tab-separated)")
|
|
233
|
+
|
|
234
|
+
# open-background (Phase B — extension backend only)
|
|
235
|
+
p_ob = sub.add_parser(
|
|
236
|
+
"open-background",
|
|
237
|
+
help=("open a Chrome tab in the background (group=Agent by default), "
|
|
238
|
+
"attach chrome.debugger, print {sessionId,targetId,tabId,url,title,groupId}"),
|
|
239
|
+
)
|
|
240
|
+
_add_name(p_ob)
|
|
241
|
+
p_ob.add_argument("--url", required=True, help="URL to open in the background tab")
|
|
242
|
+
p_ob.add_argument("--group", default="Agent",
|
|
243
|
+
help="Chrome tab-group title to place the new tab in (default: Agent)")
|
|
244
|
+
p_ob.add_argument("--session", default=os.environ.get("BD_SESSION"),
|
|
245
|
+
help="browserwright session id (defaults to BD_SESSION)")
|
|
246
|
+
# Output is always JSON (spec §5.1 single-line discipline); no --json flag.
|
|
247
|
+
|
|
248
|
+
# close-tab (Phase B — extension backend only)
|
|
249
|
+
p_ct = sub.add_parser(
|
|
250
|
+
"close-tab",
|
|
251
|
+
help="close a tab by sessionId (persistent ws) or targetId (CLI)",
|
|
252
|
+
)
|
|
253
|
+
_add_name(p_ct)
|
|
254
|
+
p_ct.add_argument("--session", default=os.environ.get("BD_SESSION"),
|
|
255
|
+
help="browserwright session id (defaults to BD_SESSION)")
|
|
256
|
+
p_ct.add_argument("--session-id", default=None,
|
|
257
|
+
help="local sessionId from a persistent ws (Skill REPL)")
|
|
258
|
+
p_ct.add_argument("--target-id", default=None,
|
|
259
|
+
help="globally-addressable targetId (e.g. ext-tab-42); "
|
|
260
|
+
"use this from one-shot CLI calls since transient "
|
|
261
|
+
"ws can't share per-client session state")
|
|
262
|
+
# Output is always JSON (spec §5.1 single-line discipline); no --json flag.
|
|
263
|
+
|
|
264
|
+
# end-session (P5 — extension backend only)
|
|
265
|
+
p_es = sub.add_parser(
|
|
266
|
+
"end-session",
|
|
267
|
+
help="tear down a browserwright session's tabs: close owned, keep borrowed",
|
|
268
|
+
)
|
|
269
|
+
_add_name(p_es)
|
|
270
|
+
p_es.add_argument("--session", required=True,
|
|
271
|
+
help="the browserwright session id whose tabs to clean up")
|
|
272
|
+
p_es.add_argument("--group-id", default=None, type=int,
|
|
273
|
+
help="durable numeric tab-group id for session teardown: "
|
|
274
|
+
"when the daemon lost this session's in-memory "
|
|
275
|
+
"binding (reconnect / restart), close the tabs in "
|
|
276
|
+
"this group instead (names aren't unique, so the id "
|
|
277
|
+
"is the key)")
|
|
278
|
+
# Output is always JSON (single-line discipline); no --json flag.
|
|
279
|
+
|
|
280
|
+
# kill-executor (Phase B) — reap ONLY a session's resident executor, no
|
|
281
|
+
# browser teardown. Used by `session end` to avoid leaking an attach
|
|
282
|
+
# session's executor (the full end-session path is create-only).
|
|
283
|
+
p_ke = sub.add_parser(
|
|
284
|
+
"kill-executor",
|
|
285
|
+
help="reap a session's persistent executor subprocess (no browser teardown)",
|
|
286
|
+
)
|
|
287
|
+
_add_name(p_ke)
|
|
288
|
+
p_ke.add_argument("--session", required=True,
|
|
289
|
+
help="the browserwright session id whose executor to reap")
|
|
290
|
+
|
|
291
|
+
# userscript — resident extension userscripts
|
|
292
|
+
p_us = sub.add_parser("userscript", help="manage resident extension userscripts")
|
|
293
|
+
_add_name(p_us)
|
|
294
|
+
p_us.add_argument("--session", default=os.environ.get("BD_SESSION"),
|
|
295
|
+
help="browserwright session id (defaults to BD_SESSION)")
|
|
296
|
+
us_sub = p_us.add_subparsers(dest="userscript_cmd", metavar="<action>")
|
|
297
|
+
p_us_push = us_sub.add_parser("push", help="install or update a .user.js file")
|
|
298
|
+
p_us_push.add_argument("file", help=".user.js path, or - for stdin")
|
|
299
|
+
p_us_install = us_sub.add_parser("install", help="alias for push")
|
|
300
|
+
p_us_install.add_argument("file", help=".user.js path, or - for stdin")
|
|
301
|
+
p_us_list = us_sub.add_parser("list", help="list resident userscripts")
|
|
302
|
+
p_us_list.add_argument("--site", default=None, help="filter by matching site URL")
|
|
303
|
+
p_us_remove = us_sub.add_parser("remove", help="remove by identity or id")
|
|
304
|
+
p_us_remove.add_argument("key", help="identity or id")
|
|
305
|
+
p_us_toggle = us_sub.add_parser("toggle", help="enable or disable by identity or id")
|
|
306
|
+
p_us_toggle.add_argument("key", help="identity or id")
|
|
307
|
+
p_us_toggle.add_argument("--enabled", required=True, help="true or false")
|
|
308
|
+
p_us_logs = us_sub.add_parser("logs", help="print userscript injection logs")
|
|
309
|
+
p_us_logs.add_argument("--id", default=None, help="filter by userscript id")
|
|
310
|
+
p_us_logs.add_argument("--limit", type=int, default=50, help="max log rows")
|
|
311
|
+
|
|
312
|
+
# install / uninstall / list — long-running service (macOS LaunchAgent).
|
|
313
|
+
# The daemon was designed as a one-shot `serve` subprocess, but for the
|
|
314
|
+
# "zero manual ops after install" extension flow it needs to be a
|
|
315
|
+
# supervised background service: starts at login, restarts on crash,
|
|
316
|
+
# and is reachable on the same socket across reboots.
|
|
317
|
+
p_inst = sub.add_parser(
|
|
318
|
+
"install",
|
|
319
|
+
help=("register the single global daemon as a macOS LaunchAgent "
|
|
320
|
+
"(auto-start + KeepAlive)"))
|
|
321
|
+
_add_name(p_inst)
|
|
322
|
+
p_inst.add_argument("--backend", choices=names(), default=None,
|
|
323
|
+
help=argparse.SUPPRESS)
|
|
324
|
+
p_inst.add_argument("--extension-port", type=int, default=None, metavar="N",
|
|
325
|
+
help="override the relay ws port (default 19989)")
|
|
326
|
+
p_inst.add_argument("--force", action="store_true",
|
|
327
|
+
help="replace an existing LaunchAgent with the same name")
|
|
328
|
+
|
|
329
|
+
p_uninst = sub.add_parser(
|
|
330
|
+
"uninstall",
|
|
331
|
+
help="remove the LaunchAgent (stops auto-start)")
|
|
332
|
+
_add_name(p_uninst)
|
|
333
|
+
|
|
334
|
+
p_ls = sub.add_parser(
|
|
335
|
+
"list",
|
|
336
|
+
help="enumerate installed LaunchAgents + running daemon instances")
|
|
337
|
+
p_ls.add_argument("--json", action="store_true",
|
|
338
|
+
help="emit JSON instead of pretty text")
|
|
339
|
+
|
|
340
|
+
return p
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _add_common(sp: argparse.ArgumentParser) -> None:
|
|
344
|
+
sp.add_argument("--backend", choices=names(),
|
|
345
|
+
help=("pin backend for this command; for `serve`, this only "
|
|
346
|
+
"chooses the shared upstream while session backends "
|
|
347
|
+
"still route per session"))
|
|
348
|
+
sp.add_argument("--timeout", type=float, default=None,
|
|
349
|
+
help="per-backend timeout in seconds (default 5)")
|
|
350
|
+
sp.add_argument("--config", help="optional toml config path; otherwise reads BD_CONFIG")
|
|
351
|
+
sp.add_argument("-v", "--verbose", action="store_true")
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _add_port(sp: argparse.ArgumentParser) -> None:
|
|
355
|
+
sp.add_argument("--port", type=int, default=None,
|
|
356
|
+
help="rdp backend port (default 9222 / config-backends.rdp.port)")
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _add_name(sp: argparse.ArgumentParser) -> None:
|
|
360
|
+
"""No-op. The `--name` / BD_NAME daemon-instance concept was removed: there
|
|
361
|
+
is exactly one global daemon on a fixed socket (docs/refactor-single-daemon.md).
|
|
362
|
+
Kept as a no-op so the (many) call sites need not all be deleted at once;
|
|
363
|
+
they carry no flag now."""
|
|
364
|
+
return None
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
# ---- shared config building ------------------------------------------------
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _cfg_from_args(args) -> Config:
|
|
371
|
+
return load(
|
|
372
|
+
cli_backend=getattr(args, "backend", None),
|
|
373
|
+
cli_timeout=getattr(args, "timeout", None),
|
|
374
|
+
cli_port=getattr(args, "port", None),
|
|
375
|
+
cli_chrome_binary=getattr(args, "chrome_binary", None),
|
|
376
|
+
cli_config_path=getattr(args, "config", None),
|
|
377
|
+
# v0.5.3 Task #24: serve-only flag; argparse Namespace shape varies
|
|
378
|
+
# per subcommand, so getattr-with-default keeps non-serve calls clean.
|
|
379
|
+
cli_extension_port=getattr(args, "extension_port", None),
|
|
380
|
+
cli_facade_port=getattr(args, "facade_port", None),
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _run(coro):
|
|
385
|
+
return asyncio.run(coro)
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
# ---- subcommand handlers ---------------------------------------------------
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _cmd_url(args, cfg: Config) -> int:
|
|
392
|
+
# --mode-b-proxy → output the daemon socket endpoint, not an upstream URL.
|
|
393
|
+
# (Spec §6.1: bare socket path on POSIX, host:port + token on Windows.)
|
|
394
|
+
if getattr(args, "mode_b_proxy", False):
|
|
395
|
+
from . import _ipc
|
|
396
|
+
ep = _ipc.endpoint_describe()
|
|
397
|
+
if args.json:
|
|
398
|
+
print(json.dumps(ep, sort_keys=True))
|
|
399
|
+
else:
|
|
400
|
+
if ep["transport"] == "unix":
|
|
401
|
+
print(ep["path"])
|
|
402
|
+
else:
|
|
403
|
+
# `host:port token=...` so a one-liner shell consumer can split.
|
|
404
|
+
token = ep["token"] or ""
|
|
405
|
+
if ep["port"] is None:
|
|
406
|
+
# Daemon not running. Exit 2 so Skill can react.
|
|
407
|
+
print("error: no daemon running (no port file)", file=sys.stderr)
|
|
408
|
+
return 2
|
|
409
|
+
print(f"{ep['host']}:{ep['port']} token={token}")
|
|
410
|
+
return 0
|
|
411
|
+
|
|
412
|
+
from .resolver import resolve
|
|
413
|
+
|
|
414
|
+
rr = _run(resolve(cfg))
|
|
415
|
+
if args.json:
|
|
416
|
+
print(json.dumps({
|
|
417
|
+
"schema_version": 1,
|
|
418
|
+
"ws_url": rr.ws_url,
|
|
419
|
+
"backend": rr.backend,
|
|
420
|
+
"extras": rr.extras,
|
|
421
|
+
}, sort_keys=True))
|
|
422
|
+
else:
|
|
423
|
+
# Bare URL — spec §5.1 stdout discipline: ONE line, no decoration.
|
|
424
|
+
print(rr.ws_url)
|
|
425
|
+
return 0
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def _cmd_serve(args, cfg: Config) -> int:
|
|
429
|
+
"""Run the long-lived Mode B daemon (§5 v0.2).
|
|
430
|
+
|
|
431
|
+
Phase 2 (docs/refactor-single-daemon.md): there is exactly ONE global
|
|
432
|
+
daemon and it serves BOTH backends simultaneously, routing per session. So
|
|
433
|
+
`serve` no longer requires an explicit backend — a missing backend defaults
|
|
434
|
+
to `extension`, which becomes the daemon's shared (real-browser) upstream
|
|
435
|
+
with the always-on relay. rdp sessions get their own per-session upstream
|
|
436
|
+
on top, dispatched by the ledger's immutable per-session backend. The old
|
|
437
|
+
"fail loud on missing backend (to avoid a silent rdp fallback)" guard is
|
|
438
|
+
gone: there is no single-backend lifetime to protect anymore.
|
|
439
|
+
"""
|
|
440
|
+
from .server.listener import run_serve
|
|
441
|
+
return _run(run_serve(cfg))
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def _cmd_stop(args, cfg: Config) -> int:
|
|
445
|
+
"""Send SIGTERM to a running daemon, wait briefly, fall back to SIGKILL.
|
|
446
|
+
|
|
447
|
+
We do NOT trust the pid file alone — we ping first to verify it's our
|
|
448
|
+
daemon, then signal that pid. (Mirrors browser-harness `_ipc.identify`.)
|
|
449
|
+
|
|
450
|
+
PID-reuse guard: between the ping and the kill the daemon could die and the
|
|
451
|
+
OS recycle its pid for an unrelated process. We fingerprint the pid's
|
|
452
|
+
process start-time and re-verify it just before each signal; if it changed,
|
|
453
|
+
the pid was reused and we refuse to signal it. When the platform can't
|
|
454
|
+
report a start-time (``proc_start_time`` → None), we degrade to the old
|
|
455
|
+
behaviour rather than block the stop.
|
|
456
|
+
"""
|
|
457
|
+
from . import _ipc
|
|
458
|
+
from . import platforms
|
|
459
|
+
import signal, time
|
|
460
|
+
|
|
461
|
+
pid = _ipc.ping_sync(timeout=1.0)
|
|
462
|
+
if pid is None:
|
|
463
|
+
# No live daemon. Still clean up stale files so the next `serve` can
|
|
464
|
+
# bind freshly without manual intervention.
|
|
465
|
+
_ipc.cleanup_endpoint()
|
|
466
|
+
print("no live daemon; cleaned up stale files", file=sys.stderr)
|
|
467
|
+
return 0
|
|
468
|
+
|
|
469
|
+
start0 = platforms.proc_start_time(pid)
|
|
470
|
+
|
|
471
|
+
def _same_process() -> bool:
|
|
472
|
+
"""True if pid still names the daemon we pinged. Unknowable (None
|
|
473
|
+
start-time) counts as True so the guard never blocks a legit stop."""
|
|
474
|
+
if start0 is None:
|
|
475
|
+
return True
|
|
476
|
+
return platforms.proc_start_time(pid) == start0
|
|
477
|
+
|
|
478
|
+
if not _same_process():
|
|
479
|
+
_ipc.cleanup_endpoint()
|
|
480
|
+
print(f"daemon pid {pid} was recycled by another process; not "
|
|
481
|
+
f"signalling it", file=sys.stderr)
|
|
482
|
+
return 0
|
|
483
|
+
try:
|
|
484
|
+
os.kill(pid, signal.SIGTERM)
|
|
485
|
+
except ProcessLookupError:
|
|
486
|
+
_ipc.cleanup_endpoint()
|
|
487
|
+
return 0
|
|
488
|
+
# Wait for the daemon to exit gracefully.
|
|
489
|
+
deadline = time.monotonic() + args.timeout
|
|
490
|
+
while time.monotonic() < deadline:
|
|
491
|
+
if _ipc.ping_sync(timeout=0.3) is None:
|
|
492
|
+
return 0
|
|
493
|
+
time.sleep(0.1)
|
|
494
|
+
# Still alive — force, but only if the pid is still the same process.
|
|
495
|
+
if _same_process():
|
|
496
|
+
try:
|
|
497
|
+
os.kill(pid, signal.SIGKILL)
|
|
498
|
+
except ProcessLookupError:
|
|
499
|
+
pass
|
|
500
|
+
else:
|
|
501
|
+
print(f"daemon pid {pid} was recycled before SIGKILL; not signalling "
|
|
502
|
+
f"it", file=sys.stderr)
|
|
503
|
+
_ipc.cleanup_endpoint()
|
|
504
|
+
return 0
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def _cmd_backend_info(args, cfg: Config) -> int:
|
|
508
|
+
"""Probe the running daemon for its backend identity. Same shape as
|
|
509
|
+
`BrowserwrightDaemon.getBackendInfo`'s ws response so the mode_b_client
|
|
510
|
+
subprocess shim can parse it directly."""
|
|
511
|
+
import asyncio
|
|
512
|
+
return asyncio.run(_run_backend_info(args, cfg))
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
async def _run_backend_info(args, cfg: Config) -> int:
|
|
516
|
+
from . import _ipc
|
|
517
|
+
pid = await _ipc.ping_async(timeout=1.0)
|
|
518
|
+
if pid is None:
|
|
519
|
+
if args.json:
|
|
520
|
+
print(json.dumps({"running": False}, sort_keys=True))
|
|
521
|
+
else:
|
|
522
|
+
print("daemon not running", file=sys.stderr)
|
|
523
|
+
return 2
|
|
524
|
+
try:
|
|
525
|
+
params = {"bsSession": args.session} if args.session else {}
|
|
526
|
+
info = await _rpc_via_ws(
|
|
527
|
+
cfg, "BrowserwrightDaemon.getBackendInfo", params,
|
|
528
|
+
client_label="cli-backend-info", timeout=5.0,
|
|
529
|
+
browser_session=args.session,
|
|
530
|
+
)
|
|
531
|
+
except (Unavailable, DaemonError) as e:
|
|
532
|
+
print(f"error: {e}", file=sys.stderr)
|
|
533
|
+
return 3
|
|
534
|
+
# Surface as `backend` (alias of `name`) for callers like
|
|
535
|
+
# ModeBClient.get_backend_info that read either key.
|
|
536
|
+
payload = {
|
|
537
|
+
"running": True,
|
|
538
|
+
"name": info.get("name"),
|
|
539
|
+
"backend": info.get("name"),
|
|
540
|
+
"kind": info.get("kind"),
|
|
541
|
+
"schema_version": info.get("schema_version", 1),
|
|
542
|
+
}
|
|
543
|
+
print(json.dumps(payload, sort_keys=True))
|
|
544
|
+
return 0
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def _cmd_stats(args, cfg: Config) -> int:
|
|
548
|
+
"""v0.5: query the running daemon's in-process metrics via the
|
|
549
|
+
`BrowserwrightDaemon.stats` CDP-namespace method, print to stdout.
|
|
550
|
+
|
|
551
|
+
Connects to the daemon's unix socket as a normal client. Exits with
|
|
552
|
+
code 2 if the daemon isn't running (matching `status`).
|
|
553
|
+
"""
|
|
554
|
+
import asyncio
|
|
555
|
+
return asyncio.run(_run_stats(args, cfg))
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
async def _run_stats(args, cfg: Config) -> int:
|
|
559
|
+
from . import _ipc
|
|
560
|
+
# We're inside `asyncio.run()` already (via _cmd_stats). Nested
|
|
561
|
+
# asyncio.run raises RuntimeError, so use the async ping directly —
|
|
562
|
+
# ping_sync's fallback returns None and we'd misreport "not running"
|
|
563
|
+
# even when a daemon was listening.
|
|
564
|
+
pid = await _ipc.ping_async(timeout=1.0)
|
|
565
|
+
if pid is None:
|
|
566
|
+
print("daemon not running", file=sys.stderr)
|
|
567
|
+
return 2
|
|
568
|
+
|
|
569
|
+
import websockets
|
|
570
|
+
sock_path = _ipc.sock_path()
|
|
571
|
+
if _ipc.IS_WINDOWS:
|
|
572
|
+
ep = _ipc.endpoint_describe()
|
|
573
|
+
ws_url = f"ws://127.0.0.1:{ep['port']}/?client=stats-cli&token={ep['token']}"
|
|
574
|
+
conn = await websockets.connect(ws_url, compression=None)
|
|
575
|
+
else:
|
|
576
|
+
conn = await websockets.unix_connect(
|
|
577
|
+
str(sock_path),
|
|
578
|
+
uri="ws://localhost/?client=stats-cli",
|
|
579
|
+
compression=None,
|
|
580
|
+
)
|
|
581
|
+
try:
|
|
582
|
+
await conn.send(json.dumps({"id": 1, "method": "BrowserwrightDaemon.stats"}))
|
|
583
|
+
# Drain until we see id=1.
|
|
584
|
+
for _ in range(20):
|
|
585
|
+
raw = await asyncio.wait_for(conn.recv(), timeout=3.0)
|
|
586
|
+
msg = json.loads(raw)
|
|
587
|
+
if msg.get("id") == 1 and "result" in msg:
|
|
588
|
+
snap = msg["result"]
|
|
589
|
+
if args.json:
|
|
590
|
+
print(json.dumps(snap, sort_keys=True))
|
|
591
|
+
else:
|
|
592
|
+
# Tab-separated key=value, one per line.
|
|
593
|
+
for k in sorted(snap.keys()):
|
|
594
|
+
print(f"{k}\t{snap[k]}")
|
|
595
|
+
return 0
|
|
596
|
+
print("daemon did not respond to BrowserwrightDaemon.stats", file=sys.stderr)
|
|
597
|
+
return 3
|
|
598
|
+
finally:
|
|
599
|
+
try:
|
|
600
|
+
await conn.close()
|
|
601
|
+
except Exception:
|
|
602
|
+
pass
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def _cmd_status(args, cfg: Config) -> int:
|
|
606
|
+
"""Report endpoint + liveness. JSON shape used by Skill for status pings."""
|
|
607
|
+
from . import _ipc
|
|
608
|
+
pid, version = _ipc.ping_status_sync(timeout=1.0)
|
|
609
|
+
ep = _ipc.endpoint_describe()
|
|
610
|
+
facade_ws, facade_port = _ipc.read_facade_file()
|
|
611
|
+
status = {
|
|
612
|
+
"schema_version": 1,
|
|
613
|
+
"alive": pid is not None,
|
|
614
|
+
"pid": pid,
|
|
615
|
+
"version": version,
|
|
616
|
+
"endpoint": ep,
|
|
617
|
+
# Playwright facade discovery (Phase C). None when the facade is
|
|
618
|
+
# disabled or the daemon predates auto-enable. The skill layer reads
|
|
619
|
+
# this to `connect_over_cdp` the heredoc `page`/`context`.
|
|
620
|
+
"facade": (
|
|
621
|
+
{"ws": facade_ws, "port": facade_port}
|
|
622
|
+
if (pid is not None and facade_ws) else None
|
|
623
|
+
),
|
|
624
|
+
}
|
|
625
|
+
if args.json:
|
|
626
|
+
print(json.dumps(status, sort_keys=True))
|
|
627
|
+
else:
|
|
628
|
+
if pid is None:
|
|
629
|
+
print("daemon not running")
|
|
630
|
+
else:
|
|
631
|
+
print(f"daemon alive (pid {pid})")
|
|
632
|
+
if ep["transport"] == "unix":
|
|
633
|
+
print(f" socket: {ep['path']}")
|
|
634
|
+
else:
|
|
635
|
+
print(f" tcp: 127.0.0.1:{ep['port']} token={ep['token']}")
|
|
636
|
+
if facade_ws:
|
|
637
|
+
print(f" facade: {facade_ws}")
|
|
638
|
+
return 0 if pid is not None else 2
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
def _cmd_disconnect(args, cfg: Config) -> int:
|
|
642
|
+
"""Open a transient ws to the daemon, fire BrowserwrightDaemon.disconnect, exit.
|
|
643
|
+
|
|
644
|
+
Equivalent to the RPC over an established connection — Skill can use either.
|
|
645
|
+
"""
|
|
646
|
+
if not args.session:
|
|
647
|
+
print("error: provide --session or set BD_SESSION", file=sys.stderr)
|
|
648
|
+
return 2
|
|
649
|
+
return _run(_disconnect_via_ws(cfg, args.reason, args.session))
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
async def _disconnect_via_ws(cfg: Config, reason: str, session: str) -> int:
|
|
653
|
+
"""Lightweight ws client that says BrowserwrightDaemon.disconnect and reads the
|
|
654
|
+
ack. We bypass cdp-use intentionally — we don't need framing, just one
|
|
655
|
+
request + one response."""
|
|
656
|
+
import websockets
|
|
657
|
+
from . import _ipc
|
|
658
|
+
from urllib.parse import quote
|
|
659
|
+
|
|
660
|
+
session_q = f"&session={quote(str(session), safe='')}"
|
|
661
|
+
|
|
662
|
+
if _ipc.IS_WINDOWS:
|
|
663
|
+
port, token = _ipc.read_port_file()
|
|
664
|
+
if port is None:
|
|
665
|
+
print("no daemon running", file=sys.stderr)
|
|
666
|
+
return 2
|
|
667
|
+
url = f"ws://127.0.0.1:{port}/?token={token}&client=cli-disconnect{session_q}"
|
|
668
|
+
try:
|
|
669
|
+
async with websockets.connect(url, compression=None) as ws:
|
|
670
|
+
await ws.send(json.dumps({
|
|
671
|
+
"id": 1, "method": "BrowserwrightDaemon.disconnect",
|
|
672
|
+
"params": {"reason": reason, "bsSession": session},
|
|
673
|
+
}))
|
|
674
|
+
await asyncio.wait_for(ws.recv(), timeout=2.0)
|
|
675
|
+
except Exception as e:
|
|
676
|
+
print(f"disconnect failed: {e}", file=sys.stderr)
|
|
677
|
+
return 2
|
|
678
|
+
else:
|
|
679
|
+
path = _ipc.sock_path()
|
|
680
|
+
if not path.exists():
|
|
681
|
+
print("no daemon running", file=sys.stderr)
|
|
682
|
+
return 2
|
|
683
|
+
# `ws+unix:` URL scheme + path: websockets accepts unix= kwarg.
|
|
684
|
+
try:
|
|
685
|
+
async with websockets.unix_connect(
|
|
686
|
+
str(path), uri=f"ws://localhost/?client=cli-disconnect{session_q}",
|
|
687
|
+
compression=None,
|
|
688
|
+
) as ws:
|
|
689
|
+
await ws.send(json.dumps({
|
|
690
|
+
"id": 1, "method": "BrowserwrightDaemon.disconnect",
|
|
691
|
+
"params": {"reason": reason, "bsSession": session},
|
|
692
|
+
}))
|
|
693
|
+
await asyncio.wait_for(ws.recv(), timeout=2.0)
|
|
694
|
+
except Exception as e:
|
|
695
|
+
print(f"disconnect failed: {e}", file=sys.stderr)
|
|
696
|
+
return 2
|
|
697
|
+
return 0
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
def _cmd_attach_active(args, cfg: Config) -> int:
|
|
701
|
+
"""Ask the running daemon (extension backend) to attach the
|
|
702
|
+
currently-focused-window active tab. Prints the result as JSON or as
|
|
703
|
+
`targetId<TAB>url<TAB>title`. Exits 1 if the daemon errored.
|
|
704
|
+
"""
|
|
705
|
+
if not args.session:
|
|
706
|
+
print("error: provide --session or set BD_SESSION", file=sys.stderr)
|
|
707
|
+
return 2
|
|
708
|
+
return _run(_attach_active_via_ws(cfg, args))
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
async def _attach_active_via_ws(cfg: Config, args) -> int:
|
|
712
|
+
import websockets
|
|
713
|
+
from . import _ipc
|
|
714
|
+
from urllib.parse import quote
|
|
715
|
+
|
|
716
|
+
session = getattr(args, "session", None)
|
|
717
|
+
session_q = f"&session={quote(str(session), safe='')}" if session else ""
|
|
718
|
+
|
|
719
|
+
if _ipc.IS_WINDOWS:
|
|
720
|
+
port, token = _ipc.read_port_file()
|
|
721
|
+
if port is None:
|
|
722
|
+
print("no daemon running", file=sys.stderr)
|
|
723
|
+
return 2
|
|
724
|
+
url = f"ws://127.0.0.1:{port}/?token={token}&client=cli-attach-active{session_q}"
|
|
725
|
+
try:
|
|
726
|
+
async with websockets.connect(url, compression=None) as ws:
|
|
727
|
+
return await _attach_active_roundtrip(ws, args)
|
|
728
|
+
except Exception as e:
|
|
729
|
+
print(f"attach-active failed: {e}", file=sys.stderr)
|
|
730
|
+
return 1
|
|
731
|
+
path = _ipc.sock_path()
|
|
732
|
+
if not path.exists():
|
|
733
|
+
print("no daemon running", file=sys.stderr)
|
|
734
|
+
return 2
|
|
735
|
+
try:
|
|
736
|
+
async with websockets.unix_connect(
|
|
737
|
+
str(path), uri=f"ws://localhost/?client=cli-attach-active{session_q}",
|
|
738
|
+
compression=None) as ws:
|
|
739
|
+
return await _attach_active_roundtrip(ws, args)
|
|
740
|
+
except Exception as e:
|
|
741
|
+
print(f"attach-active failed: {e}", file=sys.stderr)
|
|
742
|
+
return 1
|
|
743
|
+
|
|
744
|
+
|
|
745
|
+
async def _attach_active_roundtrip(ws, args) -> int:
|
|
746
|
+
params = {}
|
|
747
|
+
if getattr(args, "session", None):
|
|
748
|
+
params["bsSession"] = args.session
|
|
749
|
+
await ws.send(json.dumps({
|
|
750
|
+
"id": 1, "method": "BrowserwrightDaemon.attachActiveTab",
|
|
751
|
+
"params": params,
|
|
752
|
+
}))
|
|
753
|
+
# Drain until we see id=1 — lifecycle events (upstreamConnecting,
|
|
754
|
+
# upstreamReady) can arrive ahead of the response.
|
|
755
|
+
for _ in range(20):
|
|
756
|
+
raw = await asyncio.wait_for(ws.recv(), timeout=15.0)
|
|
757
|
+
msg = json.loads(raw)
|
|
758
|
+
if msg.get("id") != 1:
|
|
759
|
+
continue
|
|
760
|
+
if "error" in msg:
|
|
761
|
+
err = msg["error"] or {}
|
|
762
|
+
print(f"attach-active error: {err.get('message', str(err))}",
|
|
763
|
+
file=sys.stderr)
|
|
764
|
+
return 1
|
|
765
|
+
result = msg.get("result") or {}
|
|
766
|
+
if args.json:
|
|
767
|
+
print(json.dumps(result, sort_keys=True))
|
|
768
|
+
else:
|
|
769
|
+
print(f"{result.get('targetId')}\t{result.get('url', '')}\t"
|
|
770
|
+
f"{result.get('title', '')}")
|
|
771
|
+
return 0
|
|
772
|
+
print("daemon did not respond to BrowserwrightDaemon.attachActiveTab",
|
|
773
|
+
file=sys.stderr)
|
|
774
|
+
return 1
|
|
775
|
+
|
|
776
|
+
|
|
777
|
+
def _cmd_logs(args, cfg: Config) -> int:
|
|
778
|
+
"""Print log file path, or tail -f it."""
|
|
779
|
+
from . import _ipc
|
|
780
|
+
log = _ipc.log_path()
|
|
781
|
+
if not args.follow:
|
|
782
|
+
print(log)
|
|
783
|
+
return 0
|
|
784
|
+
if not log.exists():
|
|
785
|
+
print(f"no log file at {log}", file=sys.stderr)
|
|
786
|
+
return 2
|
|
787
|
+
# tail -f — best to just exec tail rather than reimplement.
|
|
788
|
+
os.execvp("tail", ["tail", "-n", "+0", "-f", str(log)])
|
|
789
|
+
return 0 # unreachable
|
|
790
|
+
|
|
791
|
+
|
|
792
|
+
def _cmd_doctor(args, cfg: Config) -> int:
|
|
793
|
+
from .doctor import doctor
|
|
794
|
+
|
|
795
|
+
out = _run(doctor(cfg, backend=getattr(args, "backend", None),
|
|
796
|
+
probe_ws=getattr(args, "probe_ws", False)))
|
|
797
|
+
if args.json:
|
|
798
|
+
print(json.dumps(out, sort_keys=True))
|
|
799
|
+
else:
|
|
800
|
+
_pretty_doctor(out)
|
|
801
|
+
return 0
|
|
802
|
+
|
|
803
|
+
|
|
804
|
+
def _cmd_list_backends(args, cfg: Config) -> int:
|
|
805
|
+
from .doctor import list_backends
|
|
806
|
+
|
|
807
|
+
out = _run(list_backends(cfg))
|
|
808
|
+
if args.json:
|
|
809
|
+
print(json.dumps(out, sort_keys=True))
|
|
810
|
+
else:
|
|
811
|
+
for entry in out["backends"]:
|
|
812
|
+
print(f"{entry['name']:<12} kind={entry['kind']:<12} "
|
|
813
|
+
f"recommended_mode={entry['recommended_mode']} "
|
|
814
|
+
f"ux_cost={entry['ux_cost']}")
|
|
815
|
+
return 0
|
|
816
|
+
|
|
817
|
+
|
|
818
|
+
def _cmd_active_tab(args, cfg: Config) -> int:
|
|
819
|
+
if not args.session:
|
|
820
|
+
print("error: provide --session or set BD_SESSION", file=sys.stderr)
|
|
821
|
+
return 2
|
|
822
|
+
try:
|
|
823
|
+
info = _run(_rpc_via_ws(
|
|
824
|
+
cfg,
|
|
825
|
+
"BrowserwrightDaemon.getActiveTab",
|
|
826
|
+
{"bsSession": args.session},
|
|
827
|
+
client_label="cli-active-tab",
|
|
828
|
+
timeout=8.0,
|
|
829
|
+
browser_session=args.session,
|
|
830
|
+
))
|
|
831
|
+
except Unavailable as e:
|
|
832
|
+
print(f"error: {e}", file=sys.stderr)
|
|
833
|
+
return 2
|
|
834
|
+
except DaemonError as e:
|
|
835
|
+
print(f"error: {e}", file=sys.stderr)
|
|
836
|
+
return 3
|
|
837
|
+
if not info or not info.get("targetId"):
|
|
838
|
+
if args.json:
|
|
839
|
+
print(json.dumps({
|
|
840
|
+
"schema_version": 1,
|
|
841
|
+
"targetId": None,
|
|
842
|
+
"accuracy": "unknown",
|
|
843
|
+
"since_seconds": None,
|
|
844
|
+
}, sort_keys=True))
|
|
845
|
+
else:
|
|
846
|
+
print("") # spec §5.4: empty line + exit 2 when no active tab
|
|
847
|
+
return 2
|
|
848
|
+
if args.json:
|
|
849
|
+
print(json.dumps({"schema_version": 1, **info}, sort_keys=True))
|
|
850
|
+
else:
|
|
851
|
+
# tab-separated: targetId\turl\ttitle\taccuracy
|
|
852
|
+
print(f"{info['targetId']}\t{info['url']}\t{info['title']}\t{info['accuracy']}")
|
|
853
|
+
return 0
|
|
854
|
+
|
|
855
|
+
|
|
856
|
+
def _cmd_launch_chrome(args, cfg: Config) -> int:
|
|
857
|
+
from .launch_chrome import launch_chrome
|
|
858
|
+
|
|
859
|
+
out = _run(launch_chrome(
|
|
860
|
+
cfg,
|
|
861
|
+
profile=args.profile,
|
|
862
|
+
persistent=not args.tmp, # --tmp wins over --persistent default
|
|
863
|
+
chrome_binary=args.chrome_binary,
|
|
864
|
+
port=args.port,
|
|
865
|
+
timeout=args.timeout,
|
|
866
|
+
allow_default_profile=args.allow_default_profile,
|
|
867
|
+
))
|
|
868
|
+
if args.json:
|
|
869
|
+
print(json.dumps(out, sort_keys=True))
|
|
870
|
+
else:
|
|
871
|
+
# Bare URL, matching `url` discipline.
|
|
872
|
+
print(out["ws_url"])
|
|
873
|
+
return 0
|
|
874
|
+
|
|
875
|
+
|
|
876
|
+
def _cmd_version(args, cfg: Config) -> int:
|
|
877
|
+
from browserwright.version import version_info
|
|
878
|
+
|
|
879
|
+
info = version_info()
|
|
880
|
+
if getattr(args, "action", None) == "check":
|
|
881
|
+
if args.json:
|
|
882
|
+
print(json.dumps(info, sort_keys=True))
|
|
883
|
+
elif info["ok"]:
|
|
884
|
+
print(f"browserwright-daemon {__version__} (versions ok)")
|
|
885
|
+
else:
|
|
886
|
+
for issue in info["issues"]:
|
|
887
|
+
print(f"{issue['code']}: {issue['message']}", file=sys.stderr)
|
|
888
|
+
return 0 if info["ok"] else 1
|
|
889
|
+
if getattr(args, "json", False):
|
|
890
|
+
print(json.dumps(info, sort_keys=True))
|
|
891
|
+
return 0
|
|
892
|
+
print(f"browserwright-daemon {__version__}")
|
|
893
|
+
return 0
|
|
894
|
+
|
|
895
|
+
|
|
896
|
+
async def _rpc_via_ws(cfg: Config, method: str, params: dict,
|
|
897
|
+
*, client_label: str, timeout: float = 10.0,
|
|
898
|
+
browser_session: str | None = None) -> dict:
|
|
899
|
+
"""Open a transient ws to the running daemon, send one BrowserwrightDaemon.*
|
|
900
|
+
RPC, read the response, close. Mirrors `_disconnect_via_ws` but returns
|
|
901
|
+
the parsed result (or raises with the daemon's error message).
|
|
902
|
+
|
|
903
|
+
Used by `open-background` and `close-tab` subcommands.
|
|
904
|
+
"""
|
|
905
|
+
import websockets
|
|
906
|
+
from . import _ipc
|
|
907
|
+
from urllib.parse import quote
|
|
908
|
+
|
|
909
|
+
session_q = (
|
|
910
|
+
f"&session={quote(str(browser_session), safe='')}"
|
|
911
|
+
if browser_session else "")
|
|
912
|
+
|
|
913
|
+
async def _drain_until_response(ws) -> dict:
|
|
914
|
+
# Lifecycle events (upstreamConnecting/Ready) can arrive ahead of the
|
|
915
|
+
# actual id=1 response, especially when lazy-open is triggered by the
|
|
916
|
+
# RPC. Drain frames until we see ours. Mirrors `_attach_active_roundtrip`.
|
|
917
|
+
for _ in range(20):
|
|
918
|
+
raw = await asyncio.wait_for(ws.recv(), timeout=timeout)
|
|
919
|
+
msg = json.loads(raw)
|
|
920
|
+
if msg.get("id") == 1:
|
|
921
|
+
return msg
|
|
922
|
+
raise DaemonError(f"{method} no id=1 response after 20 frames")
|
|
923
|
+
|
|
924
|
+
if _ipc.IS_WINDOWS:
|
|
925
|
+
port, token = _ipc.read_port_file()
|
|
926
|
+
if port is None:
|
|
927
|
+
raise Unavailable("no daemon running")
|
|
928
|
+
url = f"ws://127.0.0.1:{port}/?token={token}&client={client_label}{session_q}"
|
|
929
|
+
async with websockets.connect(url, compression=None) as ws:
|
|
930
|
+
await ws.send(json.dumps({
|
|
931
|
+
"id": 1, "method": method, "params": params,
|
|
932
|
+
}))
|
|
933
|
+
msg = await _drain_until_response(ws)
|
|
934
|
+
else:
|
|
935
|
+
path = _ipc.sock_path()
|
|
936
|
+
if not path.exists():
|
|
937
|
+
raise Unavailable("no daemon running")
|
|
938
|
+
async with websockets.unix_connect(
|
|
939
|
+
str(path),
|
|
940
|
+
uri=f"ws://localhost/?client={client_label}{session_q}",
|
|
941
|
+
compression=None,
|
|
942
|
+
) as ws:
|
|
943
|
+
await ws.send(json.dumps({
|
|
944
|
+
"id": 1, "method": method, "params": params,
|
|
945
|
+
}))
|
|
946
|
+
msg = await _drain_until_response(ws)
|
|
947
|
+
if "error" in msg:
|
|
948
|
+
err = msg["error"] or {}
|
|
949
|
+
raise DaemonError(
|
|
950
|
+
f"{method} failed: {err.get('message', err)} (code={err.get('code')})"
|
|
951
|
+
)
|
|
952
|
+
result = msg.get("result")
|
|
953
|
+
if not isinstance(result, dict):
|
|
954
|
+
raise DaemonError(f"{method} returned non-dict result: {result!r}")
|
|
955
|
+
return result
|
|
956
|
+
|
|
957
|
+
|
|
958
|
+
async def _userscript_call_ws(cfg: Config, method: str, params: dict,
|
|
959
|
+
*, session: str, timeout: float = 5.0) -> dict:
|
|
960
|
+
params = {**params, "bsSession": session}
|
|
961
|
+
return await _rpc_via_ws(
|
|
962
|
+
cfg, method, params, client_label="cli-userscript", timeout=timeout,
|
|
963
|
+
browser_session=session)
|
|
964
|
+
|
|
965
|
+
|
|
966
|
+
def _cmd_userscript(args, cfg: Config | None = None) -> int:
|
|
967
|
+
if cfg is None:
|
|
968
|
+
cfg = load()
|
|
969
|
+
if isinstance(args, list):
|
|
970
|
+
parser = _build_parser()
|
|
971
|
+
ns = parser.parse_args(["userscript", *args])
|
|
972
|
+
else:
|
|
973
|
+
ns = args
|
|
974
|
+
action = getattr(ns, "userscript_cmd", None)
|
|
975
|
+
if not action:
|
|
976
|
+
print("usage: browserwright-daemon userscript {push,list,remove,toggle,logs} ...",
|
|
977
|
+
file=sys.stderr)
|
|
978
|
+
return 1
|
|
979
|
+
if not getattr(ns, "session", None):
|
|
980
|
+
print("error: provide --session or set BD_SESSION", file=sys.stderr)
|
|
981
|
+
return 2
|
|
982
|
+
|
|
983
|
+
try:
|
|
984
|
+
if action in {"push", "install"}:
|
|
985
|
+
from .userscripts import parse_userscript
|
|
986
|
+
if ns.file == "-":
|
|
987
|
+
text = sys.stdin.read()
|
|
988
|
+
else:
|
|
989
|
+
with open(ns.file, encoding="utf-8") as f:
|
|
990
|
+
text = f.read()
|
|
991
|
+
us = parse_userscript(text)
|
|
992
|
+
result = _run(_userscript_call_ws(
|
|
993
|
+
cfg, "BrowserwrightDaemon.userscript.install",
|
|
994
|
+
{"script": us.to_payload()}, session=ns.session))
|
|
995
|
+
sync = result.get("sync", {}) or {}
|
|
996
|
+
print(json.dumps({
|
|
997
|
+
"id": result.get("id", us.id),
|
|
998
|
+
"identity": result.get("identity", us.identity),
|
|
999
|
+
"warnings": us.warnings,
|
|
1000
|
+
"sync": sync,
|
|
1001
|
+
}, sort_keys=True))
|
|
1002
|
+
# Surface header warnings and (crucially) a failed sync to stderr so
|
|
1003
|
+
# a stored-but-not-injected script doesn't read as plain success.
|
|
1004
|
+
for w in us.warnings:
|
|
1005
|
+
print(f"warning: {w}", file=sys.stderr)
|
|
1006
|
+
if sync.get("ok") is False:
|
|
1007
|
+
reason = sync.get("reason")
|
|
1008
|
+
if reason:
|
|
1009
|
+
print(f"warning: stored but NOT active: {reason}", file=sys.stderr)
|
|
1010
|
+
if "userScripts API unavailable" in str(reason):
|
|
1011
|
+
print("hint: enable the extension's 'Allow user scripts' "
|
|
1012
|
+
"toggle at chrome://extensions (Chrome 138+), or "
|
|
1013
|
+
"developer mode on older Chrome.", file=sys.stderr)
|
|
1014
|
+
for f in sync.get("failed") or []:
|
|
1015
|
+
print(f"warning: registration failed for "
|
|
1016
|
+
f"{f.get('identity') or f.get('id')}: {f.get('error')}",
|
|
1017
|
+
file=sys.stderr)
|
|
1018
|
+
return 2
|
|
1019
|
+
return 0
|
|
1020
|
+
|
|
1021
|
+
if action == "list":
|
|
1022
|
+
params = {"site": ns.site} if ns.site else {}
|
|
1023
|
+
result = _run(_userscript_call_ws(
|
|
1024
|
+
cfg, "BrowserwrightDaemon.userscript.list", params,
|
|
1025
|
+
session=ns.session))
|
|
1026
|
+
elif action == "remove":
|
|
1027
|
+
result = _run(_userscript_call_ws(
|
|
1028
|
+
cfg, "BrowserwrightDaemon.userscript.remove", {"key": ns.key},
|
|
1029
|
+
session=ns.session))
|
|
1030
|
+
elif action == "toggle":
|
|
1031
|
+
enabled = str(ns.enabled).lower() in {"1", "true", "yes", "on"}
|
|
1032
|
+
result = _run(_userscript_call_ws(
|
|
1033
|
+
cfg, "BrowserwrightDaemon.userscript.toggle",
|
|
1034
|
+
{"key": ns.key, "enabled": enabled}, session=ns.session))
|
|
1035
|
+
elif action == "logs":
|
|
1036
|
+
# NB: the relay envelope reserves the "id" key for the RPC
|
|
1037
|
+
# request id (relay._request overwrites it), so the script-id
|
|
1038
|
+
# filter must travel under a non-colliding key.
|
|
1039
|
+
params = {"limit": ns.limit}
|
|
1040
|
+
if ns.id:
|
|
1041
|
+
params["scriptId"] = ns.id
|
|
1042
|
+
result = _run(_userscript_call_ws(
|
|
1043
|
+
cfg, "BrowserwrightDaemon.userscript.logs", params,
|
|
1044
|
+
session=ns.session))
|
|
1045
|
+
else:
|
|
1046
|
+
print(f"unknown userscript action: {action}", file=sys.stderr)
|
|
1047
|
+
return 1
|
|
1048
|
+
except UserError as e:
|
|
1049
|
+
print(f"error: {e}", file=sys.stderr)
|
|
1050
|
+
return 1
|
|
1051
|
+
except Unavailable as e:
|
|
1052
|
+
print(f"error: {e}", file=sys.stderr)
|
|
1053
|
+
return 2
|
|
1054
|
+
except DaemonError as e:
|
|
1055
|
+
print(f"error: {e}", file=sys.stderr)
|
|
1056
|
+
return 3
|
|
1057
|
+
|
|
1058
|
+
print(json.dumps(result, sort_keys=True))
|
|
1059
|
+
return 0
|
|
1060
|
+
|
|
1061
|
+
|
|
1062
|
+
def _cmd_open_background(args, cfg: Config) -> int:
|
|
1063
|
+
if not args.session:
|
|
1064
|
+
print("error: provide --session or set BD_SESSION", file=sys.stderr)
|
|
1065
|
+
return 2
|
|
1066
|
+
try:
|
|
1067
|
+
result = _run(_rpc_via_ws(
|
|
1068
|
+
cfg,
|
|
1069
|
+
"BrowserwrightDaemon.openBackgroundTab",
|
|
1070
|
+
{"url": args.url, "groupName": args.group, "bsSession": args.session},
|
|
1071
|
+
client_label="cli-open-background",
|
|
1072
|
+
timeout=15.0,
|
|
1073
|
+
browser_session=args.session,
|
|
1074
|
+
))
|
|
1075
|
+
except Unavailable as e:
|
|
1076
|
+
print(f"error: {e}", file=sys.stderr)
|
|
1077
|
+
return 2
|
|
1078
|
+
except DaemonError as e:
|
|
1079
|
+
print(f"error: {e}", file=sys.stderr)
|
|
1080
|
+
return 3
|
|
1081
|
+
# Always emit JSON — single-line spec §5.1 discipline; the response is
|
|
1082
|
+
# structured so a tab-separated form would lose fields.
|
|
1083
|
+
print(json.dumps(result, sort_keys=True))
|
|
1084
|
+
return 0
|
|
1085
|
+
|
|
1086
|
+
|
|
1087
|
+
def _cmd_close_tab(args, cfg: Config) -> int:
|
|
1088
|
+
if not args.session:
|
|
1089
|
+
print("error: provide --session or set BD_SESSION", file=sys.stderr)
|
|
1090
|
+
return 2
|
|
1091
|
+
if not args.session_id and not args.target_id:
|
|
1092
|
+
print("error: provide --session-id or --target-id", file=sys.stderr)
|
|
1093
|
+
return 2
|
|
1094
|
+
params: dict = {"bsSession": args.session}
|
|
1095
|
+
if args.session_id:
|
|
1096
|
+
params["sessionId"] = args.session_id
|
|
1097
|
+
if args.target_id:
|
|
1098
|
+
params["targetId"] = args.target_id
|
|
1099
|
+
try:
|
|
1100
|
+
result = _run(_rpc_via_ws(
|
|
1101
|
+
cfg,
|
|
1102
|
+
"BrowserwrightDaemon.closeTab",
|
|
1103
|
+
params,
|
|
1104
|
+
client_label="cli-close-tab",
|
|
1105
|
+
timeout=10.0,
|
|
1106
|
+
browser_session=args.session,
|
|
1107
|
+
))
|
|
1108
|
+
except Unavailable as e:
|
|
1109
|
+
print(f"error: {e}", file=sys.stderr)
|
|
1110
|
+
return 2
|
|
1111
|
+
except DaemonError as e:
|
|
1112
|
+
print(f"error: {e}", file=sys.stderr)
|
|
1113
|
+
return 3
|
|
1114
|
+
print(json.dumps(result, sort_keys=True))
|
|
1115
|
+
return 0
|
|
1116
|
+
|
|
1117
|
+
|
|
1118
|
+
def _cmd_end_session(args, cfg: Config) -> int:
|
|
1119
|
+
"""P5: tear down a browserwright session's extension tabs (owned closed,
|
|
1120
|
+
borrowed kept). Prints the {closed, kept} JSON result."""
|
|
1121
|
+
es_params: dict = {"session": args.session}
|
|
1122
|
+
if getattr(args, "group_id", None) is not None:
|
|
1123
|
+
es_params["groupId"] = args.group_id
|
|
1124
|
+
try:
|
|
1125
|
+
result = _run(_rpc_via_ws(
|
|
1126
|
+
cfg,
|
|
1127
|
+
"BrowserwrightDaemon.endSession",
|
|
1128
|
+
es_params,
|
|
1129
|
+
client_label="cli-end-session",
|
|
1130
|
+
timeout=10.0,
|
|
1131
|
+
browser_session=args.session,
|
|
1132
|
+
))
|
|
1133
|
+
except Unavailable as e:
|
|
1134
|
+
print(f"error: {e}", file=sys.stderr)
|
|
1135
|
+
return 2
|
|
1136
|
+
except DaemonError as e:
|
|
1137
|
+
print(f"error: {e}", file=sys.stderr)
|
|
1138
|
+
return 3
|
|
1139
|
+
print(json.dumps(result, sort_keys=True))
|
|
1140
|
+
return 0
|
|
1141
|
+
|
|
1142
|
+
|
|
1143
|
+
def _cmd_kill_executor(args, cfg: Config) -> int:
|
|
1144
|
+
"""Phase B: reap a session's resident executor (no browser teardown).
|
|
1145
|
+
|
|
1146
|
+
Best-effort by design — a dead daemon / missing executor must not fail
|
|
1147
|
+
`session end`, so every failure mode maps to a clean exit code the caller
|
|
1148
|
+
tolerates. Prints the `{ok, killed}` JSON result on success."""
|
|
1149
|
+
try:
|
|
1150
|
+
result = _run(_rpc_via_ws(
|
|
1151
|
+
cfg,
|
|
1152
|
+
"BrowserwrightDaemon.killExecutor",
|
|
1153
|
+
{"session": args.session},
|
|
1154
|
+
client_label="cli-kill-executor",
|
|
1155
|
+
timeout=10.0,
|
|
1156
|
+
browser_session=args.session,
|
|
1157
|
+
))
|
|
1158
|
+
except Unavailable as e:
|
|
1159
|
+
print(f"error: {e}", file=sys.stderr)
|
|
1160
|
+
return 2
|
|
1161
|
+
except DaemonError as e:
|
|
1162
|
+
print(f"error: {e}", file=sys.stderr)
|
|
1163
|
+
return 3
|
|
1164
|
+
print(json.dumps(result, sort_keys=True))
|
|
1165
|
+
return 0
|
|
1166
|
+
|
|
1167
|
+
|
|
1168
|
+
# ---- LaunchAgent service (macOS) ----------------------------------------
|
|
1169
|
+
#
|
|
1170
|
+
# Goal: the daemon is a long-running service, not a per-session subprocess.
|
|
1171
|
+
# macOS LaunchAgents are the right primitive — Linux/systemd-user support
|
|
1172
|
+
# is deferred (no users hitting it yet on this codebase).
|
|
1173
|
+
|
|
1174
|
+
# There is exactly one global daemon, so the LaunchAgent has one fixed label
|
|
1175
|
+
# and one plist (no per-instance name — BD_NAME was removed).
|
|
1176
|
+
_LAUNCHAGENT_LABEL = "com.browserwright-daemon"
|
|
1177
|
+
|
|
1178
|
+
|
|
1179
|
+
def _launchagent_dir() -> "Path":
|
|
1180
|
+
from pathlib import Path
|
|
1181
|
+
return Path.home() / "Library" / "LaunchAgents"
|
|
1182
|
+
|
|
1183
|
+
|
|
1184
|
+
def _launchagent_plist_path() -> "Path":
|
|
1185
|
+
return _launchagent_dir() / f"{_LAUNCHAGENT_LABEL}.plist"
|
|
1186
|
+
|
|
1187
|
+
|
|
1188
|
+
def _resolve_browserwright_daemon_bin() -> str:
|
|
1189
|
+
"""Find the absolute path to the `browserwright-daemon` console script. The
|
|
1190
|
+
plist needs a fully-qualified path; LaunchAgents don't inherit the
|
|
1191
|
+
user's shell PATH."""
|
|
1192
|
+
import shutil
|
|
1193
|
+
path = shutil.which("browserwright-daemon")
|
|
1194
|
+
if path:
|
|
1195
|
+
return path
|
|
1196
|
+
# Fallback to "<sys.prefix>/bin/browserwright-daemon".
|
|
1197
|
+
from pathlib import Path
|
|
1198
|
+
candidate = Path(sys.prefix) / "bin" / "browserwright-daemon"
|
|
1199
|
+
if candidate.exists():
|
|
1200
|
+
return str(candidate)
|
|
1201
|
+
raise UserError(
|
|
1202
|
+
"browserwright-daemon binary not found on PATH; "
|
|
1203
|
+
"install it via pip/uv before running `browserwright-daemon install`"
|
|
1204
|
+
)
|
|
1205
|
+
|
|
1206
|
+
|
|
1207
|
+
def _build_plist(*, extension_port: int | None) -> str:
|
|
1208
|
+
"""Emit the plist content. Kept inline (no XML lib) — the schema is
|
|
1209
|
+
fixed + tiny, and we avoid a dependency. Every interpolated value passes
|
|
1210
|
+
through ``xml.sax.saxutils.escape``. Pure: no side effects — the caller is
|
|
1211
|
+
responsible for creating the log dir.
|
|
1212
|
+
"""
|
|
1213
|
+
bin_path = _resolve_browserwright_daemon_bin()
|
|
1214
|
+
args = [bin_path, "serve"]
|
|
1215
|
+
if extension_port is not None:
|
|
1216
|
+
args += ["--extension-port", str(extension_port)]
|
|
1217
|
+
log_dir = os.path.expanduser("~/.cache/browserwright-daemon/logs")
|
|
1218
|
+
stdout_path = f"{log_dir}/browserwright-daemon.stdout.log"
|
|
1219
|
+
stderr_path = f"{log_dir}/browserwright-daemon.stderr.log"
|
|
1220
|
+
label = _LAUNCHAGENT_LABEL
|
|
1221
|
+
# PATH carried over so any subprocess the daemon spawns (e.g. chrome)
|
|
1222
|
+
# is discoverable. /usr/local/bin + /opt/homebrew/bin cover both
|
|
1223
|
+
# Intel and Apple Silicon Homebrew layouts. Constant, no escape needed.
|
|
1224
|
+
env_path = "/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin"
|
|
1225
|
+
arg_xml = "\n ".join(
|
|
1226
|
+
f"<string>{_xml_escape(a)}</string>" for a in args
|
|
1227
|
+
)
|
|
1228
|
+
return (
|
|
1229
|
+
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
|
1230
|
+
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" '
|
|
1231
|
+
'"http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n'
|
|
1232
|
+
'<plist version="1.0">\n'
|
|
1233
|
+
'<dict>\n'
|
|
1234
|
+
f' <key>Label</key><string>{_xml_escape(label)}</string>\n'
|
|
1235
|
+
' <key>ProgramArguments</key>\n'
|
|
1236
|
+
' <array>\n'
|
|
1237
|
+
f' {arg_xml}\n'
|
|
1238
|
+
' </array>\n'
|
|
1239
|
+
' <key>RunAtLoad</key><true/>\n'
|
|
1240
|
+
' <key>KeepAlive</key>\n'
|
|
1241
|
+
' <dict>\n'
|
|
1242
|
+
' <key>SuccessfulExit</key><false/>\n'
|
|
1243
|
+
' <key>Crashed</key><true/>\n'
|
|
1244
|
+
' </dict>\n'
|
|
1245
|
+
' <key>EnvironmentVariables</key>\n'
|
|
1246
|
+
' <dict>\n'
|
|
1247
|
+
f' <key>PATH</key><string>{env_path}</string>\n'
|
|
1248
|
+
' </dict>\n'
|
|
1249
|
+
f' <key>StandardOutPath</key><string>{_xml_escape(stdout_path)}</string>\n'
|
|
1250
|
+
f' <key>StandardErrorPath</key><string>{_xml_escape(stderr_path)}</string>\n'
|
|
1251
|
+
f' <key>WorkingDirectory</key><string>{_xml_escape(os.path.expanduser("~"))}</string>\n'
|
|
1252
|
+
'</dict>\n'
|
|
1253
|
+
'</plist>\n'
|
|
1254
|
+
)
|
|
1255
|
+
|
|
1256
|
+
|
|
1257
|
+
def _launchctl(*args: str) -> tuple[int, str, str]:
|
|
1258
|
+
import subprocess
|
|
1259
|
+
try:
|
|
1260
|
+
proc = subprocess.run(["launchctl", *args],
|
|
1261
|
+
capture_output=True, text=True, timeout=10)
|
|
1262
|
+
return proc.returncode, proc.stdout, proc.stderr
|
|
1263
|
+
except (FileNotFoundError, subprocess.TimeoutExpired) as e:
|
|
1264
|
+
return -1, "", str(e)
|
|
1265
|
+
|
|
1266
|
+
|
|
1267
|
+
def _cmd_install(args, cfg: Config) -> int:
|
|
1268
|
+
if sys.platform != "darwin":
|
|
1269
|
+
print("error: `install` is macOS-only (LaunchAgent); "
|
|
1270
|
+
"for Linux run `browserwright-daemon serve` from a systemd-user "
|
|
1271
|
+
"unit yourself for now", file=sys.stderr)
|
|
1272
|
+
return 1
|
|
1273
|
+
if getattr(args, "backend", None):
|
|
1274
|
+
print("warning: `browserwright-daemon install --backend` is ignored; "
|
|
1275
|
+
"the LaunchAgent runs the single global daemon and session "
|
|
1276
|
+
"backends route per session", file=sys.stderr)
|
|
1277
|
+
label = _LAUNCHAGENT_LABEL
|
|
1278
|
+
plist_path = _launchagent_plist_path()
|
|
1279
|
+
if plist_path.exists() and not args.force:
|
|
1280
|
+
print(f"error: {plist_path} already exists. "
|
|
1281
|
+
f"Use --force to replace.", file=sys.stderr)
|
|
1282
|
+
return 1
|
|
1283
|
+
plist_path.parent.mkdir(parents=True, exist_ok=True)
|
|
1284
|
+
# Create the log dir before launchctl tries to write to it. Kept out of
|
|
1285
|
+
# `_build_plist` so the generator stays pure / unit-testable (N-1).
|
|
1286
|
+
log_dir = os.path.expanduser("~/.cache/browserwright-daemon/logs")
|
|
1287
|
+
os.makedirs(log_dir, exist_ok=True)
|
|
1288
|
+
content = _build_plist(extension_port=args.extension_port)
|
|
1289
|
+
# If --force and the plist exists, unload the old one first so launchctl
|
|
1290
|
+
# picks up the new ProgramArguments cleanly.
|
|
1291
|
+
if plist_path.exists():
|
|
1292
|
+
_launchctl("unload", str(plist_path))
|
|
1293
|
+
plist_path.write_text(content)
|
|
1294
|
+
rc, _, err = _launchctl("load", "-w", str(plist_path))
|
|
1295
|
+
if rc != 0:
|
|
1296
|
+
# Rollback: remove the just-written plist so a re-run isn't blocked
|
|
1297
|
+
# by the "already exists" check (L-3).
|
|
1298
|
+
plist_path.unlink(missing_ok=True)
|
|
1299
|
+
print(f"error: launchctl load failed: {err.strip()}",
|
|
1300
|
+
file=sys.stderr)
|
|
1301
|
+
return 3
|
|
1302
|
+
print(json.dumps({
|
|
1303
|
+
"ok": True,
|
|
1304
|
+
"label": label,
|
|
1305
|
+
"plist": str(plist_path),
|
|
1306
|
+
"extension_port": args.extension_port,
|
|
1307
|
+
}, sort_keys=True))
|
|
1308
|
+
return 0
|
|
1309
|
+
|
|
1310
|
+
|
|
1311
|
+
def _cmd_uninstall(args, cfg: Config) -> int:
|
|
1312
|
+
if sys.platform != "darwin":
|
|
1313
|
+
print("error: `uninstall` is macOS-only", file=sys.stderr)
|
|
1314
|
+
return 1
|
|
1315
|
+
plist_path = _launchagent_plist_path()
|
|
1316
|
+
if not plist_path.exists():
|
|
1317
|
+
print(json.dumps({"ok": False,
|
|
1318
|
+
"reason": "no LaunchAgent installed"},
|
|
1319
|
+
sort_keys=True))
|
|
1320
|
+
return 0
|
|
1321
|
+
rc, _, err = _launchctl("unload", str(plist_path))
|
|
1322
|
+
# Even on unload failure (e.g. wasn't loaded), we still remove the plist.
|
|
1323
|
+
plist_path.unlink()
|
|
1324
|
+
payload = {"ok": True, "removed": str(plist_path)}
|
|
1325
|
+
if rc != 0 and err.strip():
|
|
1326
|
+
payload["unload_warning"] = err.strip()
|
|
1327
|
+
print(json.dumps(payload, sort_keys=True))
|
|
1328
|
+
return 0
|
|
1329
|
+
|
|
1330
|
+
|
|
1331
|
+
def _cmd_restart(args, cfg: Config) -> int:
|
|
1332
|
+
if sys.platform != "darwin":
|
|
1333
|
+
print("error: `restart` is macOS-only (LaunchAgent)", file=sys.stderr)
|
|
1334
|
+
return 1
|
|
1335
|
+
plist_path = _launchagent_plist_path()
|
|
1336
|
+
if not plist_path.exists():
|
|
1337
|
+
print(
|
|
1338
|
+
"error: no LaunchAgent installed; run `browserwright-daemon install` "
|
|
1339
|
+
"or restart your foreground `serve` process manually",
|
|
1340
|
+
file=sys.stderr,
|
|
1341
|
+
)
|
|
1342
|
+
return 2
|
|
1343
|
+
_launchctl("unload", str(plist_path))
|
|
1344
|
+
rc, _, err = _launchctl("load", "-w", str(plist_path))
|
|
1345
|
+
if rc != 0:
|
|
1346
|
+
print(f"error: launchctl load failed: {err.strip()}", file=sys.stderr)
|
|
1347
|
+
return 3
|
|
1348
|
+
print(json.dumps({"ok": True, "restarted": str(plist_path)}, sort_keys=True))
|
|
1349
|
+
return 0
|
|
1350
|
+
|
|
1351
|
+
|
|
1352
|
+
def _cmd_list(args, cfg: Config) -> int:
|
|
1353
|
+
"""Report the single global daemon: whether it's installed as a
|
|
1354
|
+
LaunchAgent and whether it's running on the socket."""
|
|
1355
|
+
plist_path = _launchagent_plist_path()
|
|
1356
|
+
installed = plist_path.exists()
|
|
1357
|
+
service = "launchagent" if installed else "manual"
|
|
1358
|
+
running_pid = _ipc_ping()
|
|
1359
|
+
info = {
|
|
1360
|
+
"service": service,
|
|
1361
|
+
"plist": str(plist_path) if installed else None,
|
|
1362
|
+
"running": running_pid is not None,
|
|
1363
|
+
"pid": running_pid,
|
|
1364
|
+
}
|
|
1365
|
+
if args.json:
|
|
1366
|
+
print(json.dumps(info, sort_keys=True))
|
|
1367
|
+
return 0
|
|
1368
|
+
if not installed and running_pid is None:
|
|
1369
|
+
print("no daemon installed or running")
|
|
1370
|
+
return 0
|
|
1371
|
+
running = "yes" if info["running"] else "no"
|
|
1372
|
+
pid = str(running_pid) if running_pid else "-"
|
|
1373
|
+
print(f"{'SERVICE':<12} {'RUNNING':<8} {'PID':<8}")
|
|
1374
|
+
print(f"{service:<12} {running:<8} {pid:<8}")
|
|
1375
|
+
return 0
|
|
1376
|
+
|
|
1377
|
+
|
|
1378
|
+
def _ipc_ping() -> int | None:
|
|
1379
|
+
"""Tiny wrapper around ipc.ping_sync that swallows everything."""
|
|
1380
|
+
try:
|
|
1381
|
+
from . import _ipc
|
|
1382
|
+
return _ipc.ping_sync(timeout=0.5)
|
|
1383
|
+
except Exception:
|
|
1384
|
+
return None
|
|
1385
|
+
|
|
1386
|
+
|
|
1387
|
+
_DISPATCH = {
|
|
1388
|
+
"url": _cmd_url,
|
|
1389
|
+
"doctor": _cmd_doctor,
|
|
1390
|
+
"list-backends": _cmd_list_backends,
|
|
1391
|
+
"active-tab": _cmd_active_tab,
|
|
1392
|
+
"launch-chrome": _cmd_launch_chrome,
|
|
1393
|
+
"version": _cmd_version,
|
|
1394
|
+
# v0.2
|
|
1395
|
+
"serve": _cmd_serve,
|
|
1396
|
+
"stop": _cmd_stop,
|
|
1397
|
+
"restart": _cmd_restart,
|
|
1398
|
+
"status": _cmd_status,
|
|
1399
|
+
"disconnect": _cmd_disconnect,
|
|
1400
|
+
"logs": _cmd_logs,
|
|
1401
|
+
# v0.5
|
|
1402
|
+
"stats": _cmd_stats,
|
|
1403
|
+
"backend-info": _cmd_backend_info,
|
|
1404
|
+
# v0.5.4 — extension backend
|
|
1405
|
+
"attach-active": _cmd_attach_active,
|
|
1406
|
+
"open-background": _cmd_open_background,
|
|
1407
|
+
"close-tab": _cmd_close_tab,
|
|
1408
|
+
"end-session": _cmd_end_session,
|
|
1409
|
+
"kill-executor": _cmd_kill_executor,
|
|
1410
|
+
"userscript": _cmd_userscript,
|
|
1411
|
+
# v0.5.5 — LaunchAgent service (macOS) so the daemon is long-running.
|
|
1412
|
+
"install": _cmd_install,
|
|
1413
|
+
"uninstall": _cmd_uninstall,
|
|
1414
|
+
"list": _cmd_list,
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
|
|
1418
|
+
# ---- pretty print ----------------------------------------------------------
|
|
1419
|
+
|
|
1420
|
+
|
|
1421
|
+
def _pretty_doctor(out: dict) -> None:
|
|
1422
|
+
rec = out.get("recommended")
|
|
1423
|
+
print(f"recommended: {rec or '(none available)'}")
|
|
1424
|
+
print()
|
|
1425
|
+
for entry in out["backends"]:
|
|
1426
|
+
mark = "OK " if entry["available"] else "-- "
|
|
1427
|
+
print(f" {mark} {entry['name']:<12} ux_cost={entry['ux_cost']}")
|
|
1428
|
+
if entry["detail"]:
|
|
1429
|
+
print(f" detail: {entry['detail']}")
|
|
1430
|
+
if entry["ux_warning"]:
|
|
1431
|
+
print(f" warning: {entry['ux_warning']}")
|
|
1432
|
+
if entry["needs_user_action"]:
|
|
1433
|
+
print(f" next: {entry['needs_user_action']}")
|
|
1434
|
+
|
|
1435
|
+
|
|
1436
|
+
if __name__ == "__main__":
|
|
1437
|
+
_entry()
|