@thincoder/core 0.10.5 → 0.10.6
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.
- package/CHANGELOG.md +8 -0
- package/browser/actions.mjs +163 -0
- package/browser/cdp.mjs +161 -0
- package/browser/clipboard.mjs +142 -0
- package/browser/input-actions.mjs +197 -0
- package/browser/input.mjs +256 -0
- package/browser/launch.mjs +143 -0
- package/browser/queue.mjs +171 -0
- package/browser/session.mjs +494 -0
- package/browser/snapshot.mjs +270 -0
- package/package.json +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,14 @@
|
|
|
3
3
|
All notable changes to the core package are documented here.
|
|
4
4
|
Format: Keep a Changelog · 中文 · 号在发布时定(CalVer——见 `docs/RELEASE.md` §4)。
|
|
5
5
|
|
|
6
|
+
## [0.10.6] — 2026-10-13
|
|
7
|
+
|
|
8
|
+
> 0.10.5 → 0.10.6(月内 +1——发布时定号;P0 热修)
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
|
|
12
|
+
- **核包 `files` 白名单补 `browser/`**(P0——0.10.5 装机缺该目录 ⇒ CLI 装后启动崩(`ERR_MODULE_NOT_FOUND`):`tools/browser.mjs:13-15` ∥ `permission.mjs:16` 引 `../browser/*` 而白名单未列;台账 #1362)。发布守则教训:白名单须覆盖运行时引用闭包——发布预检现只对根 `.mjs` ∥ `prompts/` ∥ `tool-docs/` 计数(引闭包断言待另轮补,已登记)。
|
|
13
|
+
|
|
6
14
|
## [0.10.5] — 2026-10-13
|
|
7
15
|
|
|
8
16
|
> 0.10.4 → 0.10.5(月内 +1——发布时定号)
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* browser/actions.mjs — 基线八动作实现(navigate / snapshot / click / type / evaluate / wait / screenshot / close)
|
|
3
|
+
* (BROWSER-TOOL.md §2.2 ∥ §2.4;KD-10:click / type 内部选型**不迁** Input 域——对外语义零回归)。
|
|
4
|
+
*
|
|
5
|
+
* 会话能力一律经句柄取(`h.call` / `h.evalRaw` / `h.takeSnapshot` / `h.ensureSession` / `h.pageError` /
|
|
6
|
+
* `h.step` / `h.sleep` …)——本档不 import `session.mjs`(DAG 单向,§5 拆分决定)。
|
|
7
|
+
* 硬超时(§2.9):wait 的谓词帽(`timeoutMs`:缺省 30s · 上限 120s)语义零变——动作预算 = 谓词帽 + 15s
|
|
8
|
+
* 余量(`queue.mjs` 预算表);轮询循环标 `wait poll` 步名(卡点可诊断——N-BT10)。
|
|
9
|
+
*/
|
|
10
|
+
import { DEFAULT_MAX, clickExpression, typeExpression, truncateText, waitExpression } from "./snapshot.mjs"
|
|
11
|
+
|
|
12
|
+
export const WAIT_TIMEOUT_DEFAULT = 30_000
|
|
13
|
+
export const WAIT_TIMEOUT_MAX = 120_000
|
|
14
|
+
export const WAIT_POLL_MS = 150
|
|
15
|
+
export const NETWORK_QUIET_MS = 500
|
|
16
|
+
export const MAX_EVAL_CHARS = 8_000
|
|
17
|
+
|
|
18
|
+
function normalizeHttpUrl(value) {
|
|
19
|
+
const raw = String(value ?? "")
|
|
20
|
+
let url
|
|
21
|
+
try { url = new URL(raw) } catch { url = null }
|
|
22
|
+
if (!url || (url.protocol !== "http:" && url.protocol !== "https:")) {
|
|
23
|
+
throw new Error(`navigate requires an http/https url — got "${raw}"`)
|
|
24
|
+
}
|
|
25
|
+
return url.toString()
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function jsonOf(value) {
|
|
29
|
+
if (value === undefined) return "undefined"
|
|
30
|
+
try { return JSON.stringify(value) ?? String(value) }
|
|
31
|
+
catch { return String(value) }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** navigate(§2.2):http/https 限;回执 = `[navigate] <final-url>` + 内联新页快照(省一轮 snapshot)。 */
|
|
35
|
+
async function actNavigate(args, ctx, h) {
|
|
36
|
+
const url = normalizeHttpUrl(args.url)
|
|
37
|
+
h.assertHostAllowed(url, ctx) // 静态参数面先检——被拦的 host 不得起浏览器(§3.2「不执行」)
|
|
38
|
+
await h.ensureSession(args)
|
|
39
|
+
await h.call("Page.navigate", { url })
|
|
40
|
+
await h.waitForReady()
|
|
41
|
+
const snap = await h.takeSnapshot({ max: DEFAULT_MAX })
|
|
42
|
+
return `[navigate] ${snap.url}\n${snap.text}`
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** snapshot(§2.2):引用清单(生成 / 复用 / 标记——§2.3)。 */
|
|
46
|
+
async function actSnapshot(args, ctx, h) {
|
|
47
|
+
await h.ensureSession(args)
|
|
48
|
+
const snap = await h.takeSnapshot({ selector: args.selector ?? null, max: args.max ?? DEFAULT_MAX })
|
|
49
|
+
return snap.text
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** click(§2.2):DOM 级语义激活(`el.click()`——KD-10);导航判据 = URL 变化 ⇒ `[navigated]` / `[no navigation]`。 */
|
|
53
|
+
async function actClick(args, ctx, h) {
|
|
54
|
+
await h.ensureSession(args)
|
|
55
|
+
const info = await h.pageInfo()
|
|
56
|
+
h.assertHostAllowed(info.url, ctx)
|
|
57
|
+
const entry = h.resolveRef(args.ref)
|
|
58
|
+
const res = await h.evalRaw(clickExpression(entry.selector, entry.tag))
|
|
59
|
+
if (!res?.found || res.tag) throw h.pageError(`ref ${args.ref} is stale (was "${entry.name}") — run snapshot again`)
|
|
60
|
+
if (res.disabled) throw h.pageError(`ref ${args.ref} is disabled ("${entry.name}")`)
|
|
61
|
+
const out = [`[click] ${args.ref} "${entry.name}"`]
|
|
62
|
+
const landed = await h.waitForUrlChange(info.url)
|
|
63
|
+
if (landed) {
|
|
64
|
+
await h.waitForReady()
|
|
65
|
+
const snap = await h.takeSnapshot({ max: DEFAULT_MAX })
|
|
66
|
+
out.push(`[navigated] ${snap.url}`, snap.text)
|
|
67
|
+
} else {
|
|
68
|
+
out.push("[no navigation]")
|
|
69
|
+
}
|
|
70
|
+
return out.join("\n")
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** type(§2.2):写值(原生 value setter + input/change);password 目标 ⇒ 值不回显(N-BT4)。 */
|
|
74
|
+
async function actType(args, ctx, h) {
|
|
75
|
+
await h.ensureSession(args)
|
|
76
|
+
const info = await h.pageInfo()
|
|
77
|
+
h.assertHostAllowed(info.url, ctx)
|
|
78
|
+
const entry = h.resolveRef(args.ref)
|
|
79
|
+
const text = String(args.text)
|
|
80
|
+
const res = await h.evalRaw(typeExpression(entry.selector, entry.tag, text, args.clear === true))
|
|
81
|
+
if (!res?.found || res.tag) throw h.pageError(`ref ${args.ref} is stale (was "${entry.name}") — run snapshot again`)
|
|
82
|
+
if (res.fillable === false) {
|
|
83
|
+
throw h.pageError(`ref ${args.ref} is not a fillable field ("${entry.name}", <${entry.tag}>) — use snapshot to pick an input/textarea`)
|
|
84
|
+
}
|
|
85
|
+
if (res.disabled) throw h.pageError(`ref ${args.ref} is disabled ("${entry.name}")`)
|
|
86
|
+
return `[type] ${args.ref} "${entry.name}" ← ${text.length} chars${res.password ? " (hidden)" : ""}`
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** evaluate(§2.2):页面上下文 JS;异常 ⇒ `Error: evaluate failed: <message>`;回执受 8000 字符上限(N-BT3)。 */
|
|
90
|
+
async function actEvaluate(args, ctx, h) {
|
|
91
|
+
await h.ensureSession(args)
|
|
92
|
+
const info = await h.pageInfo()
|
|
93
|
+
h.assertHostAllowed(info.url, ctx)
|
|
94
|
+
let value
|
|
95
|
+
try {
|
|
96
|
+
value = await h.evalRaw(String(args.expression))
|
|
97
|
+
} catch (e) {
|
|
98
|
+
if (e?.browserAbort || e?.sessionLost) throw e // 预算中止 / 外部关闭 ⇒ 原样上报(不裹进页面异常句)
|
|
99
|
+
throw h.pageError(`evaluate failed: ${e?.message ?? e}`)
|
|
100
|
+
}
|
|
101
|
+
return `[evaluate @ ${info.url}] ${truncateText(jsonOf(value), MAX_EVAL_CHARS, "narrow the expression")}`
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function waitPredicate(args) {
|
|
105
|
+
if (args.networkIdle === true) return { kind: "networkIdle", value: null, label: "networkIdle" }
|
|
106
|
+
for (const kind of ["selector", "text", "url"]) {
|
|
107
|
+
const value = args[kind]
|
|
108
|
+
if (typeof value === "string" && value !== "") return { kind, value, label: `${kind} ${JSON.stringify(value)}` }
|
|
109
|
+
}
|
|
110
|
+
// 工具层已校验互斥(§2.2)——此处兜底(直调会话面时同样不悬挂)
|
|
111
|
+
throw new Error("wait requires exactly one of selector|text|url|networkIdle")
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** wait(§2.2):四选一谓词 + 150ms 轮询;networkIdle = 事件在飞归零后静默 500ms。 */
|
|
115
|
+
async function actWait(args, ctx, h) {
|
|
116
|
+
await h.ensureSession(args)
|
|
117
|
+
const pred = waitPredicate(args)
|
|
118
|
+
const wanted = Number(args.timeoutMs)
|
|
119
|
+
const timeoutMs = Math.max(1, Math.min(Number.isFinite(wanted) && wanted > 0 ? wanted : WAIT_TIMEOUT_DEFAULT, WAIT_TIMEOUT_MAX))
|
|
120
|
+
const started = Date.now()
|
|
121
|
+
return h.step("wait poll", async () => {
|
|
122
|
+
for (;;) {
|
|
123
|
+
if (pred.kind === "networkIdle") {
|
|
124
|
+
const net = h.networkState()
|
|
125
|
+
if (net.inflight.size === 0 && Date.now() - net.lastEventAt >= NETWORK_QUIET_MS) break
|
|
126
|
+
} else if (await h.evalRaw(waitExpression(pred.kind, pred.value)) === true) break
|
|
127
|
+
if (Date.now() - started >= timeoutMs) throw h.pageError(`wait timed out after ${timeoutMs}ms (${pred.label})`)
|
|
128
|
+
await h.sleep(WAIT_POLL_MS)
|
|
129
|
+
}
|
|
130
|
+
return `[wait] ${pred.label} — ok after ${Date.now() - started}ms`
|
|
131
|
+
})
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** screenshot(§2.2):PNG 落盘返路径(KD-6——模型经 read_image 查看)。 */
|
|
135
|
+
async function actScreenshot(args, ctx, h) {
|
|
136
|
+
await h.ensureSession(args)
|
|
137
|
+
const shot = await h.call("Page.captureScreenshot", {
|
|
138
|
+
format: "png",
|
|
139
|
+
...(args.fullPage === true ? { captureBeyondViewport: true } : {}),
|
|
140
|
+
})
|
|
141
|
+
if (typeof shot?.data !== "string") throw new Error("screenshot failed: the browser returned no image data")
|
|
142
|
+
const saved = await h.saveShot(Buffer.from(shot.data, "base64"))
|
|
143
|
+
return `[screenshot] ${saved.path} (${saved.bytes} bytes)`
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** close(§2.2):幂等——无会话 ⇒ `[close] no browser session`(成功态)。 */
|
|
147
|
+
async function actClose(args, ctx, h) {
|
|
148
|
+
if (!h.connection()) return "[close] no browser session"
|
|
149
|
+
await h.closeSession()
|
|
150
|
+
return "[close] browser session closed"
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** 基线八动作分发表(会话档并入总表——§5)。 */
|
|
154
|
+
export const ACTIONS = {
|
|
155
|
+
navigate: actNavigate,
|
|
156
|
+
snapshot: actSnapshot,
|
|
157
|
+
click: actClick,
|
|
158
|
+
type: actType,
|
|
159
|
+
evaluate: actEvaluate,
|
|
160
|
+
wait: actWait,
|
|
161
|
+
screenshot: actScreenshot,
|
|
162
|
+
close: actClose,
|
|
163
|
+
}
|
package/browser/cdp.mjs
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* browser/cdp.mjs — CDP 传输客户端(零第三方——N-BT1:Node ≥ 24 原生 WebSocket)。
|
|
3
|
+
*
|
|
4
|
+
* 形沿 `scripts/console-walkthrough.mjs:133-141` 的实证客户端(id 派发 ∥ 事件派发)——本文档把它
|
|
5
|
+
* 拆成类以承载多动作会话(connect / call / on / close),并加 HTTP 面 `/json/new` 取标签页。
|
|
6
|
+
* `WebSocketImpl` = 注入缝(缺省 `globalThis.WebSocket`;单元测试注假传输——缺省回落真实现,`??`)。
|
|
7
|
+
*
|
|
8
|
+
* 错误语义:CDP 回 `{ error: { message } }` ⇒ 拒以 `Error(message)`(BROWSER-TOOL.md §5)。
|
|
9
|
+
* 帽(§2.9 三层帽——层二 / 层三):`call` 缺省帽 15s(任何未显式配帽的调用也不悬挂);`connectCdp` open 帽
|
|
10
|
+
* 10s;`/json/new` fetch 帽 5s——动作内调用另随动作控制器(session 侧接线,预算先行)。
|
|
11
|
+
* 断连面:WS `onclose` / `onerror` ⇒ 拒在飞命令**并通知订阅者**(session 据此复位会话态——§2.10 源②)。
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** `call` 缺省兜底帽(§2.9 层三——未显式配帽的调用同样有界)。 */
|
|
15
|
+
export const DEFAULT_CALL_TIMEOUT_MS = 15_000
|
|
16
|
+
/** `connectCdp` open 帽(§2.9 层二)。 */
|
|
17
|
+
export const CONNECT_TIMEOUT_MS = 10_000
|
|
18
|
+
/** `/json/new` fetch 帽(§2.9 层二)。 */
|
|
19
|
+
export const NEWTAB_TIMEOUT_MS = 5_000
|
|
20
|
+
|
|
21
|
+
/** 与浏览器标签页的一条 CDP 连接。 */
|
|
22
|
+
export class CdpClient {
|
|
23
|
+
constructor(ws) {
|
|
24
|
+
this._ws = ws
|
|
25
|
+
this._nextId = 0
|
|
26
|
+
this._pending = new Map()
|
|
27
|
+
this._listeners = new Map()
|
|
28
|
+
this._disconnects = []
|
|
29
|
+
this.closed = false
|
|
30
|
+
ws.onmessage = (ev) => this._onMessage(ev)
|
|
31
|
+
ws.onclose = () => this._die(new Error("CDP connection closed"))
|
|
32
|
+
ws.onerror = () => this._die(new Error("CDP connection error"))
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
_onMessage(ev) {
|
|
36
|
+
let msg
|
|
37
|
+
try { msg = JSON.parse(ev.data) } catch { return } // 非 JSON 帧(调试器噪声)忽略
|
|
38
|
+
if (msg.id !== undefined && this._pending.has(msg.id)) {
|
|
39
|
+
const p = this._pending.get(msg.id)
|
|
40
|
+
this._pending.delete(msg.id)
|
|
41
|
+
if (msg.error) p.reject(new Error(msg.error.message ?? JSON.stringify(msg.error)))
|
|
42
|
+
else p.resolve(msg.result ?? {})
|
|
43
|
+
return
|
|
44
|
+
}
|
|
45
|
+
const handlers = msg.method ? this._listeners.get(msg.method) : null
|
|
46
|
+
if (!handlers) return
|
|
47
|
+
for (const handler of handlers) {
|
|
48
|
+
try { handler(msg.params ?? {}, msg.method) } catch { /* 订阅者自担——一条订阅抛错不打断派发 */ }
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** 连接断(WS close/error)单点:通知订阅者(先)⇒ 拒在飞命令(同一因由实例——§2.10)。 */
|
|
53
|
+
_die(error) {
|
|
54
|
+
if (this.closed) return
|
|
55
|
+
this.closed = true
|
|
56
|
+
for (const handler of this._disconnects) {
|
|
57
|
+
try { handler(error) } catch { /* 订阅者自担 */ }
|
|
58
|
+
}
|
|
59
|
+
this._abortAll(error)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
_abortAll(error) {
|
|
63
|
+
for (const pending of this._pending.values()) pending.reject(error)
|
|
64
|
+
this._pending.clear()
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** 订阅断连(会话健康接线——§2.10 源②);返回退订函数。 */
|
|
68
|
+
onDisconnect(handler) {
|
|
69
|
+
this._disconnects.push(handler)
|
|
70
|
+
return () => { this._disconnects = this._disconnects.filter((h) => h !== handler) }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** 发一条 CDP 命令,解析其 result(失败拒以 `Error`);缺省帽 15s(§2.9 层三)。 */
|
|
74
|
+
call(method, params = {}, { timeoutMs = DEFAULT_CALL_TIMEOUT_MS } = {}) {
|
|
75
|
+
if (this.closed) return Promise.reject(new Error("CDP client is closed"))
|
|
76
|
+
const id = ++this._nextId
|
|
77
|
+
return new Promise((resolve, reject) => {
|
|
78
|
+
const timer = setTimeout(() => {
|
|
79
|
+
this._pending.delete(id)
|
|
80
|
+
const error = new Error(`${method} did not respond within ${timeoutMs}ms`)
|
|
81
|
+
error.cdpTimeout = true
|
|
82
|
+
reject(error)
|
|
83
|
+
}, timeoutMs)
|
|
84
|
+
timer.unref?.() // 挂死型调用不吊住宿主进程(命令真返回时在 settle 里清钟)
|
|
85
|
+
const settle = (fn, value) => { clearTimeout(timer); this._pending.delete(id); fn(value) }
|
|
86
|
+
this._pending.set(id, { resolve: (v) => settle(resolve, v), reject: (e) => settle(reject, e) })
|
|
87
|
+
try { this._ws.send(JSON.stringify({ id, method, params })) }
|
|
88
|
+
catch (e) { settle(reject, e) }
|
|
89
|
+
})
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** 订阅 CDP 事件(无 id 帧);返回退订函数。 */
|
|
93
|
+
on(method, handler) {
|
|
94
|
+
const list = this._listeners.get(method) ?? []
|
|
95
|
+
list.push(handler)
|
|
96
|
+
this._listeners.set(method, list)
|
|
97
|
+
return () => this._listeners.set(method, (this._listeners.get(method) ?? []).filter((h) => h !== handler))
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
close() {
|
|
101
|
+
if (this.closed) return
|
|
102
|
+
this.closed = true
|
|
103
|
+
this._abortAll(new Error("CDP client closed"))
|
|
104
|
+
try { this._ws.close() } catch { /* 已断开 */ }
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* 连标签页调试地址(open 帽 10s——§2.9 层二)。
|
|
110
|
+
* @param {string} wsUrl `webSocketDebuggerUrl`
|
|
111
|
+
* @param {{WebSocketImpl?: Function, openTimeoutMs?: number}} [opts] 注入缝 ∥ open 帽
|
|
112
|
+
*/
|
|
113
|
+
export async function connectCdp(wsUrl, { WebSocketImpl, openTimeoutMs = CONNECT_TIMEOUT_MS } = {}) {
|
|
114
|
+
const Impl = WebSocketImpl ?? globalThis.WebSocket
|
|
115
|
+
if (typeof Impl !== "function") {
|
|
116
|
+
throw new Error("no WebSocket implementation available — Node >= 24 required (CDP transport is stdlib-only)")
|
|
117
|
+
}
|
|
118
|
+
const ws = new Impl(wsUrl)
|
|
119
|
+
await new Promise((resolve, reject) => {
|
|
120
|
+
let settled = false
|
|
121
|
+
const finish = (fn, value) => {
|
|
122
|
+
if (settled) return
|
|
123
|
+
settled = true
|
|
124
|
+
clearTimeout(timer)
|
|
125
|
+
fn(value)
|
|
126
|
+
}
|
|
127
|
+
const timer = setTimeout(
|
|
128
|
+
() => finish(reject, new Error(`failed to connect to CDP at ${wsUrl} (no response within ${openTimeoutMs}ms)`)),
|
|
129
|
+
openTimeoutMs,
|
|
130
|
+
)
|
|
131
|
+
timer.unref?.()
|
|
132
|
+
ws.onopen = () => finish(resolve)
|
|
133
|
+
ws.onerror = () => finish(reject, new Error(`failed to connect to CDP at ${wsUrl}`))
|
|
134
|
+
})
|
|
135
|
+
return new CdpClient(ws)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** 开一张标签页并取其调试地址(`PUT /json/new`,GET 兜底——walkthrough `:131-132` 同式;fetch 帽 5s)。 */
|
|
139
|
+
export async function newTabWebSocketUrl(port, { host = "127.0.0.1", fetchImpl = globalThis.fetch, timeoutMs = NEWTAB_TIMEOUT_MS } = {}) {
|
|
140
|
+
const url = `http://${host}:${port}/json/new?about:blank`
|
|
141
|
+
let timer = null
|
|
142
|
+
const deadline = new Promise((_, reject) => {
|
|
143
|
+
timer = setTimeout(
|
|
144
|
+
() => reject(new Error(`browser did not answer /json/new within ${timeoutMs}ms (port ${port})`)),
|
|
145
|
+
timeoutMs,
|
|
146
|
+
)
|
|
147
|
+
timer.unref?.()
|
|
148
|
+
})
|
|
149
|
+
const fetchTab = async (init) => (await fetchImpl(url, init)).json()
|
|
150
|
+
try {
|
|
151
|
+
let tab
|
|
152
|
+
try { tab = await Promise.race([fetchTab({ method: "PUT" }), deadline]) }
|
|
153
|
+
catch { tab = await Promise.race([fetchTab(), deadline]) } // GET 兜底(同帽——到点即拒,不悬挂)
|
|
154
|
+
if (!tab?.webSocketDebuggerUrl) {
|
|
155
|
+
throw new Error(`browser did not return a debuggable tab from /json/new (port ${port})`)
|
|
156
|
+
}
|
|
157
|
+
return tab.webSocketDebuggerUrl
|
|
158
|
+
} finally {
|
|
159
|
+
if (timer) clearTimeout(timer)
|
|
160
|
+
}
|
|
161
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* browser/clipboard.mjs — 剪贴板动作(read / write / copy / paste)∥ Browser 域授权 ∥ 页侧表达式
|
|
3
|
+
* (BROWSER-TOOL.md §3.5 ∥ §2.2 clipboard ∥ N-BT9)。
|
|
4
|
+
*
|
|
5
|
+
* 路径(KD-13):CDP 无剪贴板直控方法 ⇒ 页面 API(`navigator.clipboard`)+ Browser 域按 origin 惰性授予;
|
|
6
|
+
* `write` 页面拒 / 非安全上下文 ⇒ 回落 `document.execCommand('copy')` 隐藏 textarea 面(再败才报错)。
|
|
7
|
+
* copy / paste = 真加速键(平台分支:darwin ⇒ Meta+C/V;win32 / linux ⇒ Ctrl+C/V——受信按键直驱浏览器复制 / 粘贴)。
|
|
8
|
+
* 隐私(N-BT9):read 回执上限 8000 字符 + 截断标记;write 回执不回显文本;剪贴板内容不落盘、不写日志(本档零 fs 面)。
|
|
9
|
+
*/
|
|
10
|
+
import { pressSequence } from "./input.mjs"
|
|
11
|
+
import { truncateText } from "./snapshot.mjs"
|
|
12
|
+
|
|
13
|
+
export const CLIPBOARD_MAX_CHARS = 8_000
|
|
14
|
+
export const CLIPBOARD_OPS = ["read", "write", "copy", "paste"]
|
|
15
|
+
/** `Browser.setPermission` 取的描述符名(实测在册可用——PermissionDescriptor ≠ PermissionType)。 */
|
|
16
|
+
export const CLIPBOARD_PERMISSION_DESCRIPTORS = ["clipboard-read", "clipboard-write"]
|
|
17
|
+
/** `Browser.grantPermissions` 取的权限枚举(PermissionType——回落面,两法同名不同名面,实测在案)。 */
|
|
18
|
+
export const CLIPBOARD_PERMISSIONS = ["clipboardReadWrite", "clipboardSanitizedWrite"]
|
|
19
|
+
|
|
20
|
+
const mark = (kind) => `/*thincoder-browser:${kind}*/`
|
|
21
|
+
|
|
22
|
+
/** 授权缓存(§3.5:会话内按 origin 缓存——close / 换 origin 重授;键 = 连接身份)。 */
|
|
23
|
+
let grantCache = { client: null, origins: new Set() }
|
|
24
|
+
/** 无头焦点模拟缓存(§3.5:会话内一次性——仅剪贴板动作触发,不进其他动作路径)。 */
|
|
25
|
+
let focusCache = { client: null, done: false }
|
|
26
|
+
|
|
27
|
+
/** 无头焦点适配:`document.hasFocus()` 为假 ⇒ `Emulation.setFocusEmulationEnabled`(一次/会话)。 */
|
|
28
|
+
async function ensureFocusEmulation(h) {
|
|
29
|
+
const client = h.connection()
|
|
30
|
+
if (client && focusCache.client === client && focusCache.done) return
|
|
31
|
+
let hasFocus = true
|
|
32
|
+
try { hasFocus = (await h.evalRaw("document.hasFocus()")) === true } catch { hasFocus = true } // 页不可读 ⇒ 不模拟
|
|
33
|
+
if (!hasFocus) await h.call("Emulation.setFocusEmulationEnabled", { enabled: true })
|
|
34
|
+
focusCache = { client, done: true }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* 按当前页 origin 惰性授予(§3.5):`setPermission` 两权(描述符名)→ 方法缺 / 拒 ⇒ `grantPermissions`
|
|
39
|
+
* 回落(PermissionType 枚举)→ 再败明示错。实测(本机 Edge):`setPermission` 只认描述符名
|
|
40
|
+
* (`clipboard-read` / `clipboard-write`);PermissionType 名在该方法上被拒(版本 / 名面漂移——回落层兜住)。
|
|
41
|
+
*/
|
|
42
|
+
async function ensureClipboardPermission(h, origin) {
|
|
43
|
+
const client = h.connection()
|
|
44
|
+
if (grantCache.client !== client) grantCache = { client, origins: new Set() }
|
|
45
|
+
if (grantCache.origins.has(origin)) return
|
|
46
|
+
try {
|
|
47
|
+
for (const name of CLIPBOARD_PERMISSION_DESCRIPTORS) {
|
|
48
|
+
await h.call("Browser.setPermission", { permission: { name }, setting: "granted", origin })
|
|
49
|
+
}
|
|
50
|
+
} catch (e) {
|
|
51
|
+
try {
|
|
52
|
+
await h.call("Browser.grantPermissions", { permissions: CLIPBOARD_PERMISSIONS, origin })
|
|
53
|
+
} catch (e2) {
|
|
54
|
+
throw h.pageError(`clipboard permission failed — setPermission: ${e?.message ?? e}; grantPermissions fallback: ${e2?.message ?? e2}`)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
grantCache.origins.add(origin)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** 当前页 origin(授权键);无真实页 origin(about:blank 等)⇒ 明示拒(不静默降级)。 */
|
|
61
|
+
async function pageOrigin(h) {
|
|
62
|
+
const info = await h.pageInfo()
|
|
63
|
+
let origin = null
|
|
64
|
+
try { origin = new URL(info.url).origin } catch { origin = null }
|
|
65
|
+
if (!origin || origin === "null") throw h.pageError(`clipboard requires an http/https page origin — current page is "${info.url}"`)
|
|
66
|
+
return origin
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** 页侧写表达式:`navigator.clipboard.writeText` → 失败回落 `document.execCommand('copy')` 隐藏 textarea。 */
|
|
70
|
+
function clipboardWriteExpression(text) {
|
|
71
|
+
return `${mark("clipboard-write")}(async function () {
|
|
72
|
+
var text = ${JSON.stringify(text)}
|
|
73
|
+
try {
|
|
74
|
+
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
75
|
+
await navigator.clipboard.writeText(text)
|
|
76
|
+
return { ok: true, via: 'navigator.clipboard' }
|
|
77
|
+
}
|
|
78
|
+
} catch (e) { /* 页面拒 / 非安全上下文 ⇒ 走 execCommand 回落 */ }
|
|
79
|
+
try {
|
|
80
|
+
var ta = document.createElement('textarea')
|
|
81
|
+
ta.value = text
|
|
82
|
+
ta.setAttribute('readonly', '')
|
|
83
|
+
ta.style.position = 'fixed'
|
|
84
|
+
ta.style.opacity = '0'
|
|
85
|
+
document.body.appendChild(ta)
|
|
86
|
+
ta.select()
|
|
87
|
+
var ok = false
|
|
88
|
+
try { ok = document.execCommand('copy') } finally { document.body.removeChild(ta) }
|
|
89
|
+
return ok ? { ok: true, via: 'execCommand' } : { ok: false, message: 'document.execCommand("copy") returned false' }
|
|
90
|
+
} catch (e2) { return { ok: false, message: String((e2 && e2.message) || e2) } }
|
|
91
|
+
})()`
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** 页侧读表达式:`navigator.clipboard.readText()`(安全上下文限定——非安全上下文给引导句)。 */
|
|
95
|
+
function clipboardReadExpression() {
|
|
96
|
+
return `${mark("clipboard-read")}(async function () {
|
|
97
|
+
try {
|
|
98
|
+
if (!navigator.clipboard || !navigator.clipboard.readText) {
|
|
99
|
+
return { ok: false, message: 'navigator.clipboard.readText is unavailable — a secure context (https or localhost) is required' }
|
|
100
|
+
}
|
|
101
|
+
var text = await navigator.clipboard.readText()
|
|
102
|
+
return { ok: true, text: String(text == null ? '' : text) }
|
|
103
|
+
} catch (e) { return { ok: false, message: String((e && e.message) || e) } }
|
|
104
|
+
})()`
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** 加速键序列(§3.5 平台分支):darwin ⇒ Meta+C/V;win32 / linux ⇒ Ctrl+C/V。 */
|
|
108
|
+
export function acceleratorSequence(op, platform = process.platform) {
|
|
109
|
+
const modifier = platform === "darwin" ? "Meta" : "Control"
|
|
110
|
+
const key = op === "copy" ? "c" : "v"
|
|
111
|
+
return pressSequence({ key, modifiers: [modifier] }).commands
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** clipboard(§2.2):op ∈ read / write / copy / paste;write 必填 text;copy / paste 可携 ref 聚焦。 */
|
|
115
|
+
async function actClipboard(args, ctx, h) {
|
|
116
|
+
const op = String(args.op ?? "")
|
|
117
|
+
if (!CLIPBOARD_OPS.includes(op)) throw new Error(`unknown clipboard op "${op}" — ops: ${CLIPBOARD_OPS.join(", ")}`)
|
|
118
|
+
await h.ensureSession(args)
|
|
119
|
+
await ensureFocusEmulation(h)
|
|
120
|
+
const origin = await pageOrigin(h)
|
|
121
|
+
await ensureClipboardPermission(h, origin)
|
|
122
|
+
if (op === "write") {
|
|
123
|
+
const text = String(args.text)
|
|
124
|
+
const res = await h.evalRaw(clipboardWriteExpression(text))
|
|
125
|
+
if (!res?.ok) throw h.pageError(`clipboard write failed: ${res?.message ?? "the page rejected the write"}`)
|
|
126
|
+
return `[clipboard] write ← ${text.length} chars` // 不回显文本(N-BT9)
|
|
127
|
+
}
|
|
128
|
+
if (op === "read") {
|
|
129
|
+
const res = await h.evalRaw(clipboardReadExpression())
|
|
130
|
+
if (!res?.ok) throw h.pageError(`clipboard read failed: ${res?.message ?? "the page rejected the read"}`)
|
|
131
|
+
const text = String(res.text ?? "")
|
|
132
|
+
const body = text ? `\n${truncateText(text, CLIPBOARD_MAX_CHARS, `clipboard reads are capped at ${CLIPBOARD_MAX_CHARS} chars`)}` : ""
|
|
133
|
+
return `[clipboard] read (${text.length} chars)${body}`
|
|
134
|
+
}
|
|
135
|
+
let target = null
|
|
136
|
+
if (typeof args.ref === "string" && args.ref !== "") target = await h.focusRef(args.ref)
|
|
137
|
+
for (const cmd of acceleratorSequence(op)) await h.call(cmd.method, cmd.params)
|
|
138
|
+
return `[clipboard] ${op}${target ? ` → ${target.label}` : ""}`
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** 剪贴板分发表(会话档并入总表——§5)。 */
|
|
142
|
+
export const CLIPBOARD_ACTIONS = { clipboard: actClipboard }
|