@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.
@@ -0,0 +1,197 @@
1
+ /**
2
+ * browser/input-actions.mjs — 新七动作实现(press / hover / wheel / mouse / drag / touch / insert)
3
+ * + 目标解析(ref → 真点 ∥ x,y)(BROWSER-TOOL.md §2.2 ∥ §2.7 ∥ §6 KD-14 / KD-16 / KD-17)。
4
+ *
5
+ * 会话能力一律经句柄取(`h.call` / `h.evalRaw` / `h.ensureSession` / `h.resolveRef` / `h.focusRef` /
6
+ * `h.once` / `h.pageError`)——本档不 import `session.mjs`(DAG 单向,§5 拆分决定)。
7
+ * 一切 ref 寻址的输入动作经 `pointExpression` 自动滚动到视(F-BT14——视口外目标同样可驱)。
8
+ */
9
+ import { clampSteps, dragSequence, mouseSequence, moveSequence, parseTargetSpec, pressSequence, TOUCH_GESTURES, TOUCH_TAP_GAP_MS, touchSequence, wheelSequence } from "./input.mjs"
10
+ import { pointExpression } from "./snapshot.mjs"
11
+
12
+ export const WHEEL_SETTLE_MS = 800 // 卷动稳定读数上限(§2.2 wheel 判据)
13
+ export const IME_COMMIT_BREATH_MS = 50 // 组合与提交之间留一拍(中间态可观察——KD-17)
14
+ export const DRAG_INTERCEPT_TIMEOUT_MS = 5_000 // html5 拖拽拦截等待(无拦截事件 ⇒ 明示拒——不悬挂)
15
+
16
+ async function sendAll(h, commands) {
17
+ for (const cmd of commands) await h.call(cmd.method, cmd.params)
18
+ }
19
+
20
+ /** 视口中心(wheel 落点缺省——§2.2)。 */
21
+ async function viewportCenter(h) {
22
+ const v = await h.evalRaw("({ x: Math.round(innerWidth / 2), y: Math.round(innerHeight / 2) })")
23
+ const x = Number(v?.x ?? 0)
24
+ const y = Number(v?.y ?? 0)
25
+ return { x, y, ref: null, name: null, label: `${x},${y}` }
26
+ }
27
+
28
+ /** 目标解析(§2.7):ref ⇒ 表查 + `pointExpression`(滚动到视 + 中心点);x,y ⇒ 直读;缺省 ⇒ fallback。 */
29
+ async function targetOf(args, h, fallback = null) {
30
+ const hasRef = typeof args?.ref === "string" && args.ref !== ""
31
+ const hasXY = args?.x !== undefined && args?.y !== undefined && Number.isFinite(Number(args.x)) && Number.isFinite(Number(args.y))
32
+ if (hasRef) {
33
+ const entry = h.resolveRef(args.ref)
34
+ const p = await h.evalRaw(pointExpression(entry.selector, entry.tag))
35
+ if (!p?.found || p.tag) throw h.pageError(`ref ${args.ref} is stale (was "${entry.name}") — run snapshot again`)
36
+ return { x: Number(p.x), y: Number(p.y), ref: args.ref, name: entry.name, label: `${args.ref} "${entry.name}"` }
37
+ }
38
+ if (hasXY) return { x: Number(args.x), y: Number(args.y), ref: null, name: null, label: `${Number(args.x)},${Number(args.y)}` }
39
+ if (fallback) return fallback(h)
40
+ throw new Error(`${args?.action ?? "action"} requires ref or x,y`)
41
+ }
42
+
43
+ /** `e<N>` ∥ `"x,y"` 形目标(drag / touch swipe 的 from、to——§2.2)。 */
44
+ async function specTarget(spec, h, action, param) {
45
+ const parsed = parseTargetSpec(spec)
46
+ if (!parsed) throw new Error(`${action} ${param} must be an element ref (e<N>) or "x,y"`)
47
+ if (parsed.kind === "ref") return targetOf({ ref: parsed.ref }, h)
48
+ return { x: parsed.x, y: parsed.y, ref: null, name: null, label: `${parsed.x},${parsed.y}` }
49
+ }
50
+
51
+ /** press(§2.2):真按键链 + 携 ref 先聚焦(§2.7);未知键 ⇒ 未起会话先拒(不执行)。 */
52
+ async function actPress(args, ctx, h) {
53
+ const seq = pressSequence({ key: args.key, modifiers: args.modifiers, phase: args.phase, repeat: args.repeat })
54
+ await h.ensureSession(args)
55
+ let target = ""
56
+ if (typeof args.ref === "string" && args.ref !== "") {
57
+ const f = await h.focusRef(args.ref)
58
+ target = ` → ${f.label}`
59
+ }
60
+ await sendAll(h, seq.commands)
61
+ const repeat = seq.repeat > 0 ? ` ×${seq.repeat}` : ""
62
+ const phase = args.phase === "down" || args.phase === "up" ? ` (${args.phase})` : ""
63
+ return `[press] ${seq.combo}${repeat}${phase}${target}`
64
+ }
65
+
66
+ /** hover(§2.2):指针移动(无激活)。 */
67
+ async function actHover(args, ctx, h) {
68
+ await h.ensureSession(args)
69
+ const t = await targetOf(args, h)
70
+ await sendAll(h, moveSequence({ x: t.x, y: t.y }))
71
+ return `[hover] ${t.label}`
72
+ }
73
+
74
+ /** 卷动稳定读数(≤800ms——两次连读相等即定);标 `scroll settle` 步名(§2.9 卡点可诊断)。 */
75
+ async function settleScroll(h, maxMs = WHEEL_SETTLE_MS) {
76
+ return h.step("scroll settle", async () => {
77
+ const deadline = Date.now() + maxMs
78
+ let last = null
79
+ for (;;) {
80
+ const v = await h.evalRaw("({ x: Math.round(window.scrollX), y: Math.round(window.scrollY) })")
81
+ const reading = { x: Number(v?.x ?? 0), y: Number(v?.y ?? 0) }
82
+ if (last && last.x === reading.x && last.y === reading.y) return reading
83
+ if (Date.now() >= deadline) return reading
84
+ last = reading
85
+ await h.sleep(100)
86
+ }
87
+ })
88
+ }
89
+
90
+ /** wheel(§2.2):落点缺省 = 视口中心;回执 = 卷动后 window 读数。 */
91
+ async function actWheel(args, ctx, h) {
92
+ await h.ensureSession(args)
93
+ const deltaX = Number(args.deltaX ?? 0) || 0
94
+ const deltaY = Number(args.deltaY ?? 0) || 0
95
+ const t = await targetOf(args, h, viewportCenter)
96
+ await sendAll(h, wheelSequence({ x: t.x, y: t.y, deltaX, deltaY }))
97
+ const win = await settleScroll(h)
98
+ return `[wheel] Δ(${deltaX},${deltaY}) at ${Math.round(t.x)},${Math.round(t.y)} — window (${win.x},${win.y})`
99
+ }
100
+
101
+ /** mouse(§2.2):真指针(命中测试生效);形 = click(默认)∥ down ∥ up;double ⇒ clickCount 1、2。 */
102
+ async function actMouse(args, ctx, h) {
103
+ await h.ensureSession(args)
104
+ const t = await targetOf(args, h)
105
+ const phase = args.phase === "down" || args.phase === "up" ? args.phase : "click"
106
+ const double = args.double === true
107
+ const button = String(args.button ?? "left")
108
+ await sendAll(h, mouseSequence({ x: t.x, y: t.y, button, phase, double }))
109
+ const shape = phase === "click" ? (double ? "doubleClick" : "click") : phase
110
+ return `[mouse] ${button} ${shape} ${t.label}`
111
+ }
112
+
113
+ /** HTML5 原生拖放(KD-16,实验):拦截 → `dragIntercepted` 取 DragData → 事件三连 → 投递 → 抬起。 */
114
+ async function html5Drag(h, from, to, steps) {
115
+ await h.call("Input.setInterceptDrags", { enabled: true })
116
+ try {
117
+ const intercepted = h.once("Input.dragIntercepted", DRAG_INTERCEPT_TIMEOUT_MS)
118
+ // 占位消费者:下方 sendAll 抛出时该 promise 无人 await——无此挂载即 unhandled 拒绝(宿主进程级 fatal)。
119
+ intercepted.catch(() => {})
120
+ await sendAll(h, dragSequence({ from, to, steps, release: false }))
121
+ let data
122
+ try { data = (await intercepted).data } catch (e) {
123
+ if (e?.browserAbort || e?.sessionLost) throw e // 预算中止 / 外部关闭 ⇒ 原样上报(不冒名「未拦截」)
124
+ throw new Error("html5 drag was not intercepted — the source may not be a native draggable; retry without html5")
125
+ }
126
+ await h.call("Input.dispatchDragEvent", { type: "dragEnter", x: to.x, y: to.y, data })
127
+ await h.call("Input.dispatchDragEvent", { type: "dragOver", x: to.x, y: to.y, data })
128
+ await h.call("Input.dispatchDragEvent", { type: "drop", x: to.x, y: to.y, data })
129
+ await sendAll(h, mouseSequence({ x: to.x, y: to.y, phase: "up" }))
130
+ } finally {
131
+ try { await h.call("Input.setInterceptDrags", { enabled: false }) } catch { /* 会话已断——拦截位随进程 */ }
132
+ }
133
+ }
134
+
135
+ /** drag(§2.2):默认鼠标序列(按下 → 移动×steps → 抬起);`html5:true` = 拦截链(KD-16)。 */
136
+ async function actDrag(args, ctx, h) {
137
+ await h.ensureSession(args)
138
+ const from = await specTarget(args.from, h, "drag", "from")
139
+ const to = await specTarget(args.to, h, "drag", "to")
140
+ const steps = clampSteps(args.steps)
141
+ if (args.html5 === true) await html5Drag(h, from, to, steps)
142
+ else await sendAll(h, dragSequence({ from, to, steps }))
143
+ return `[drag] ${from.label} → ${to.label}${args.html5 === true ? " (html5)" : ""}`
144
+ }
145
+
146
+ /** touch(§2.2):tap / doubleTap / pinch(落点 + scale)∥ swipe(from → to);KD-14 分工在引擎档。 */
147
+ async function actTouch(args, ctx, h) {
148
+ const gesture = String(args.gesture ?? "tap")
149
+ if (!TOUCH_GESTURES.includes(gesture)) throw new Error(`unknown touch gesture "${gesture}" — gestures: ${TOUCH_GESTURES.join(", ")}`)
150
+ await h.ensureSession(args)
151
+ if (gesture === "swipe") {
152
+ const from = await specTarget(args.from, h, "touch", "from")
153
+ const to = await specTarget(args.to, h, "touch", "to")
154
+ await sendAll(h, touchSequence({ gesture, from, to, steps: clampSteps(args.steps) }))
155
+ return `[touch] swipe ${from.label} → ${to.label}`
156
+ }
157
+ const t = await targetOf(args, h)
158
+ if (gesture === "pinch") {
159
+ const scale = Number(args.scale)
160
+ await sendAll(h, touchSequence({ gesture, x: t.x, y: t.y, scale }))
161
+ return `[touch] pinch ×${scale} at ${Math.round(t.x)},${Math.round(t.y)}`
162
+ }
163
+ // tap / doubleTap:显式序列;doubleTap = 两轮 + 双击窗内间隔(KD-14 收正)
164
+ const rounds = gesture === "doubleTap" ? 2 : 1
165
+ for (let i = 0; i < rounds; i++) {
166
+ if (i > 0) await h.sleep(TOUCH_TAP_GAP_MS)
167
+ await sendAll(h, touchSequence({ gesture, x: t.x, y: t.y }))
168
+ }
169
+ return `[touch] ${gesture} ${t.label}`
170
+ }
171
+
172
+ /** insert(§2.2):纯文本插入(不按键——长文本 / emoji 路径);`ime` = 组合候选 → `insertText` 提交(KD-17)。 */
173
+ async function actInsert(args, ctx, h) {
174
+ const text = String(args.text)
175
+ const ime = typeof args.ime === "string" && args.ime !== "" ? args.ime : null
176
+ await h.ensureSession(args)
177
+ let target = null
178
+ if (typeof args.ref === "string" && args.ref !== "") target = await h.focusRef(args.ref)
179
+ if (ime) {
180
+ await h.call("Input.imeSetComposition", { text: ime, selectionStart: ime.length, selectionEnd: ime.length })
181
+ await h.sleep(IME_COMMIT_BREATH_MS)
182
+ }
183
+ await h.call("Input.insertText", { text })
184
+ const chars = `← ${text.length} chars${ime ? " (ime)" : ""}`
185
+ return target ? `[insert] ${target.label} ${chars}` : `[insert] (focused) ${chars}`
186
+ }
187
+
188
+ /** 新七动作分发表(会话档并入总表——§5)。 */
189
+ export const INPUT_ACTIONS = {
190
+ press: actPress,
191
+ hover: actHover,
192
+ wheel: actWheel,
193
+ mouse: actMouse,
194
+ drag: actDrag,
195
+ touch: actTouch,
196
+ insert: actInsert,
197
+ }
@@ -0,0 +1,256 @@
1
+ /**
2
+ * browser/input.mjs — 输入引擎(纯函数):键表 ∥ 修饰 / 移位解析 ∥ 序列构造器(键 ∥ 鼠标 ∥ 滚轮 ∥ 拖拽 ∥ 触屏)。
3
+ * 设计权威 = `docs/core/design/BROWSER-TOOL.md` §2.8(键面)∥ §2.7(坐标语义)∥ §6 KD-14 / KD-16;
4
+ * 本档零会话依赖(DAG 叶面——动作模块 import 本档,本档不 import 动作 / 会话 / 传输)。
5
+ *
6
+ * 序列形沿 CDP `Input` 域实证口径(对照 Puppeteer `cdp/Input.ts`):
7
+ * - 有文本产出的键 ⇒ `keyDown` + `text`;无 ⇒ `rawKeyDown`(协议注记:Control + A 一类不发文本);
8
+ * - 任一非 Shift 修饰在位 ⇒ 不发文本(`modifiers & ~8` 口径);
9
+ * - 修饰键按下 / 抬起逐事件演进 `modifiers` 位(按下先置位、抬起先清位——修饰态可被页面观察)。
10
+ * 坐标一律视口 CSS 像素(§2.7)。
11
+ */
12
+
13
+ /** 修饰位(CDP `modifiers` 位域)。 */
14
+ export const MODIFIER_BITS = { Alt: 1, Control: 2, Meta: 4, Shift: 8 }
15
+ export const MODIFIER_ORDER = ["Alt", "Control", "Meta", "Shift"]
16
+
17
+ /** 修饰键本体(按下 / 抬起事件面)。 */
18
+ const MODIFIER_SLOTS = {
19
+ Alt: { key: "Alt", code: "AltLeft", vk: 18, loc: 1 },
20
+ Control: { key: "Control", code: "ControlLeft", vk: 17, loc: 1 },
21
+ Meta: { key: "Meta", code: "MetaLeft", vk: 91, loc: 1 },
22
+ Shift: { key: "Shift", code: "ShiftLeft", vk: 16, loc: 1 },
23
+ }
24
+
25
+ const FN_KEYS = {}
26
+ for (let i = 1; i <= 12; i++) FN_KEYS[`F${i}`] = { key: `F${i}`, code: `F${i}`, vk: 111 + i }
27
+
28
+ /** 命名键表(§2.8):`{ key, code, vk, text?, loc? }`——值 = US 布局传统键码。 */
29
+ export const NAMED_KEYS = {
30
+ Enter: { key: "Enter", code: "Enter", vk: 13, text: "\r" },
31
+ Escape: { key: "Escape", code: "Escape", vk: 27 },
32
+ Tab: { key: "Tab", code: "Tab", vk: 9, text: "\t" },
33
+ Backspace: { key: "Backspace", code: "Backspace", vk: 8 },
34
+ Delete: { key: "Delete", code: "Delete", vk: 46 },
35
+ Space: { key: " ", code: "Space", vk: 32, text: " " },
36
+ Insert: { key: "Insert", code: "Insert", vk: 45 },
37
+ Home: { key: "Home", code: "Home", vk: 36 },
38
+ End: { key: "End", code: "End", vk: 35 },
39
+ PageUp: { key: "PageUp", code: "PageUp", vk: 33 },
40
+ PageDown: { key: "PageDown", code: "PageDown", vk: 34 },
41
+ ArrowUp: { key: "ArrowUp", code: "ArrowUp", vk: 38 },
42
+ ArrowDown: { key: "ArrowDown", code: "ArrowDown", vk: 40 },
43
+ ArrowLeft: { key: "ArrowLeft", code: "ArrowLeft", vk: 37 },
44
+ ArrowRight: { key: "ArrowRight", code: "ArrowRight", vk: 39 },
45
+ ...FN_KEYS,
46
+ ...Object.fromEntries(Object.entries(MODIFIER_SLOTS).map(([name, slot]) => [name, { key: slot.key, code: slot.code, vk: slot.vk, loc: slot.loc }])),
47
+ }
48
+
49
+ /** 单字符行表(US 布局):`[基键, 移位键, code, vk]`(`1`→`!`、`-`→`_`……)。 */
50
+ const CHAR_ROWS = [
51
+ ["1", "!", "Digit1", 49], ["2", "@", "Digit2", 50], ["3", "#", "Digit3", 51], ["4", "$", "Digit4", 52],
52
+ ["5", "%", "Digit5", 53], ["6", "^", "Digit6", 54], ["7", "&", "Digit7", 55], ["8", "*", "Digit8", 56],
53
+ ["9", "(", "Digit9", 57], ["0", ")", "Digit0", 48],
54
+ ["-", "_", "Minus", 189], ["=", "+", "Equal", 187],
55
+ ["[", "{", "BracketLeft", 219], ["]", "}", "BracketRight", 221], ["\\", "|", "Backslash", 220],
56
+ [";", ":", "Semicolon", 186], ["'", "\"", "Quote", 222], ["`", "~", "Backquote", 192],
57
+ [",", "<", "Comma", 188], [".", ">", "Period", 190], ["/", "?", "Slash", 191],
58
+ ]
59
+
60
+ /** 单字符 → 键面描述(`shift` = 该字符本身需 Shift;`alt` = 显式 Shift 时的产出形)。 */
61
+ function charSpec(ch) {
62
+ if (ch === " ") return { key: " ", text: " ", code: "Space", vk: 32, shift: false, alt: null }
63
+ if (/^[a-z]$/.test(ch)) {
64
+ const up = ch.toUpperCase()
65
+ return { key: ch, text: ch, code: `Key${up}`, vk: up.charCodeAt(0), shift: false, alt: { key: up, text: up } }
66
+ }
67
+ if (/^[A-Z]$/.test(ch)) return { key: ch, text: ch, code: `Key${ch}`, vk: ch.charCodeAt(0), shift: true, alt: null }
68
+ for (const [base, shifted, code, vk] of CHAR_ROWS) {
69
+ if (ch === base) return { key: ch, text: ch, code, vk, shift: false, alt: { key: shifted, text: shifted } }
70
+ if (ch === shifted) return { key: ch, text: ch, code, vk, shift: true, alt: null }
71
+ }
72
+ return null
73
+ }
74
+
75
+ /** 键解析(§2.8):命名键 ∥ 单字符(US 布局);未列 ⇒ 抛 `unknown key "<k>" — keys: …`(不执行)。 */
76
+ export function resolveKey(key) {
77
+ const raw = String(key ?? "")
78
+ const named = NAMED_KEYS[raw]
79
+ if (named) return { ...named, shift: false, alt: null }
80
+ if (raw.length === 1) {
81
+ const spec = charSpec(raw)
82
+ if (spec) return spec
83
+ }
84
+ throw new Error(`unknown key "${raw}" — keys: ${Object.keys(NAMED_KEYS).join(", ")} — or a single US-layout character (a-z, 0-9, punctuation)`)
85
+ }
86
+
87
+ /** 修饰数组归一(大小写不敏感 → 固定序 Alt/Control/Meta/Shift);未列名 ⇒ 拒(不静默发裸键)。 */
88
+ export function normalizeModifiers(list) {
89
+ const given = Array.isArray(list) ? list : []
90
+ const out = []
91
+ for (const raw of given) {
92
+ const name = MODIFIER_ORDER.find((m) => m.toLowerCase() === String(raw).toLowerCase())
93
+ if (!name) throw new Error(`unknown modifier "${raw}" — modifiers: ${MODIFIER_ORDER.join(", ")}`)
94
+ if (!out.includes(name)) out.push(name)
95
+ }
96
+ return MODIFIER_ORDER.filter((m) => out.includes(m))
97
+ }
98
+
99
+ function keyDownParams(slot, bits, { text = null, autoRepeat = false } = {}) {
100
+ const params = { type: text ? "keyDown" : "rawKeyDown", modifiers: bits, windowsVirtualKeyCode: slot.vk, code: slot.code, key: slot.key }
101
+ if (slot.loc) params.location = slot.loc
102
+ if (text) { params.text = text; params.unmodifiedText = text }
103
+ if (autoRepeat) params.autoRepeat = true
104
+ return { method: "Input.dispatchKeyEvent", params }
105
+ }
106
+
107
+ function keyUpParams(slot, bits) {
108
+ const params = { type: "keyUp", modifiers: bits, windowsVirtualKeyCode: slot.vk, code: slot.code, key: slot.key }
109
+ if (slot.loc) params.location = slot.loc
110
+ return { method: "Input.dispatchKeyEvent", params }
111
+ }
112
+
113
+ /**
114
+ * 按键序列(§2.8):修饰键按下 → 主键(+`autoRepeat`×repeat)→ 主键抬起 → 修饰键抬起。
115
+ * `phase` = down(只按)∥ up(只抬)∥ press(默认 = 按 + 抬);`repeat` 0–100(越界夹取)。
116
+ * @returns {{combo: string, commands: Array<{method: string, params: object}>}}
117
+ */
118
+ export function pressSequence({ key, modifiers = [], phase = "press", repeat = 0 } = {}) {
119
+ const spec = resolveKey(key)
120
+ const mods = normalizeModifiers(modifiers)
121
+ const wanted = Number(repeat)
122
+ const repeatCount = Math.max(0, Math.min(Number.isFinite(wanted) ? Math.floor(wanted) : 0, 100))
123
+ const autoShift = spec.shift && !mods.includes("Shift") // 大写字母 / 移位符号 ⇒ 自动置 Shift 位(§2.8)
124
+ const shift = spec.shift || mods.includes("Shift")
125
+ const main = (shift && spec.alt ? spec.alt : { key: spec.key, text: spec.text })
126
+ const mainSlot = { key: main.key, code: spec.code, vk: spec.vk, loc: spec.loc }
127
+ const text = mods.some((m) => m !== "Shift") ? null : (main.text ?? null) // 非 Shift 修饰 ⇒ 不发文本
128
+ const bitsAll = mods.reduce((acc, m) => acc | MODIFIER_BITS[m], 0) | (autoShift ? MODIFIER_BITS.Shift : 0)
129
+ const commands = []
130
+ if (phase !== "up") {
131
+ let bits = 0
132
+ for (const name of mods) {
133
+ bits |= MODIFIER_BITS[name]
134
+ commands.push(keyDownParams(MODIFIER_SLOTS[name], bits))
135
+ }
136
+ for (let i = 0; i <= repeatCount; i++) commands.push(keyDownParams(mainSlot, bitsAll, { text, autoRepeat: i > 0 }))
137
+ }
138
+ if (phase !== "down") {
139
+ commands.push(keyUpParams(mainSlot, bitsAll))
140
+ let bits = bitsAll
141
+ for (const name of [...mods].reverse()) {
142
+ bits &= ~MODIFIER_BITS[name]
143
+ commands.push(keyUpParams(MODIFIER_SLOTS[name], bits))
144
+ }
145
+ }
146
+ return { combo: [...mods, spec.key].join("+"), repeat: repeatCount, commands }
147
+ }
148
+
149
+ /** 鼠标按钮位(CDP `buttons` 位域)——键序 = 报错枚举序。 */
150
+ export const MOUSE_BUTTONS = { left: 1, middle: 4, right: 2, back: 8, forward: 16 }
151
+
152
+ function mouseEvent(type, x, y, extra = {}) {
153
+ return { method: "Input.dispatchMouseEvent", params: { type, x, y, ...extra } }
154
+ }
155
+
156
+ /** 指针移动序列(hover——仅 `mouseMoved`)。 */
157
+ export function moveSequence({ x, y } = {}) {
158
+ return [mouseEvent("mouseMoved", x, y, { button: "none", buttons: 0 })]
159
+ }
160
+
161
+ /** 鼠标序列(§2.2 mouse):click = 移动 → 按下 → 抬起(double ⇒ clickCount 1、2);down / up = 单边。 */
162
+ export function mouseSequence({ x, y, button = "left", phase = "click", double = false } = {}) {
163
+ const flag = MOUSE_BUTTONS[button]
164
+ if (!flag) throw new Error(`unknown mouse button "${button}" — buttons: ${Object.keys(MOUSE_BUTTONS).join(", ")}`)
165
+ const shape = phase === "down" || phase === "up" ? phase : "click"
166
+ const commands = []
167
+ if (shape !== "up") commands.push(mouseEvent("mouseMoved", x, y, { button: "none", buttons: 0 }))
168
+ if (shape === "down") {
169
+ commands.push(mouseEvent("mousePressed", x, y, { button, buttons: flag, clickCount: 1 }))
170
+ } else if (shape === "up") {
171
+ commands.push(mouseEvent("mouseReleased", x, y, { button, buttons: 0, clickCount: 1 }))
172
+ } else {
173
+ const times = double ? 2 : 1
174
+ for (let i = 1; i <= times; i++) {
175
+ commands.push(mouseEvent("mousePressed", x, y, { button, buttons: flag, clickCount: i }))
176
+ commands.push(mouseEvent("mouseReleased", x, y, { button, buttons: 0, clickCount: i }))
177
+ }
178
+ }
179
+ return commands
180
+ }
181
+
182
+ /** 滚轮序列(§2.2 wheel):落点处一发 `mouseWheel`(`deltaX` / `deltaY` 至少一非零——校验在工具面)。 */
183
+ export function wheelSequence({ x, y, deltaX = 0, deltaY = 0 } = {}) {
184
+ return [mouseEvent("mouseWheel", x, y, { button: "none", buttons: 0, pointerType: "mouse", deltaX, deltaY })]
185
+ }
186
+
187
+ /** 中间步数夹取(§2.2:默认 10 · 2–50)。 */
188
+ export function clampSteps(value, fallback = 10) {
189
+ const n = Number(value)
190
+ const base = Number.isFinite(n) ? Math.floor(n) : fallback
191
+ return Math.max(2, Math.min(base, 50))
192
+ }
193
+
194
+ /** 拖拽序列(KD-16 默认形——鼠标序列):按下 → 移动×steps(末步落于 `to`)→ 抬起(`release:false` ⇒ 只按不放——html5 拦截链用)。 */
195
+ export function dragSequence({ from, to, steps = 10, release = true } = {}) {
196
+ const n = clampSteps(steps)
197
+ const commands = [mouseEvent("mouseMoved", from.x, from.y, { button: "none", buttons: 0 })]
198
+ commands.push(mouseEvent("mousePressed", from.x, from.y, { button: "left", buttons: MOUSE_BUTTONS.left, clickCount: 1 }))
199
+ for (let i = 1; i <= n; i++) {
200
+ const x = from.x + (to.x - from.x) * (i / n)
201
+ const y = from.y + (to.y - from.y) * (i / n)
202
+ commands.push(mouseEvent("mouseMoved", x, y, { button: "left", buttons: MOUSE_BUTTONS.left }))
203
+ }
204
+ if (release) commands.push(mouseEvent("mouseReleased", to.x, to.y, { button: "left", buttons: 0, clickCount: 1 }))
205
+ return commands
206
+ }
207
+
208
+ /** 触屏手势面(§2.2 touch gesture——工具面同表校验)。 */
209
+ export const TOUCH_GESTURES = ["tap", "doubleTap", "swipe", "pinch"]
210
+ /** doubleTap 两次序列之间的间隔(同一双击窗内——双击计数仍由浏览器手势引擎管)。 */
211
+ export const TOUCH_TAP_GAP_MS = 60
212
+
213
+ /** 触屏点形(CDP TouchPoint——坐标取整)。 */
214
+ function touchPoint(px, py) {
215
+ return { x: Math.round(px), y: Math.round(py), radiusX: 0.5, radiusY: 0.5, force: 0.5, id: 1 }
216
+ }
217
+
218
+ /** 单次打字序列(tap):touchStart → touchEnd(同点同 id)——全链 click 实证(KD-14 收正)。 */
219
+ function touchTapCommands(x, y) {
220
+ return [
221
+ { method: "Input.dispatchTouchEvent", params: { type: "touchStart", touchPoints: [touchPoint(x, y)] } },
222
+ { method: "Input.dispatchTouchEvent", params: { type: "touchEnd", touchPoints: [touchPoint(x, y)] } },
223
+ ]
224
+ }
225
+
226
+ /**
227
+ * 触屏序列(KD-14 实证收正):tap / doubleTap 走 `dispatchTouchEvent` 显式序列(全链 mousedown/mouseup/click
228
+ * 实证;synthesizeTapGesture 实测不产 click);pinch 走 `synthesizePinchGesture`(实测有效);
229
+ * swipe 走显式路径(手指路径语义——from → to 插值 ×steps)。
230
+ * doubleTap = 同序列×2 + `TOUCH_TAP_GAP_MS` 间隔(时序在动作层控——双击计数由浏览器管)。
231
+ */
232
+ export function touchSequence({ gesture = "tap", x, y, from, to, steps = 10, scale } = {}) {
233
+ if (gesture === "tap" || gesture === "doubleTap") return touchTapCommands(x, y)
234
+ if (gesture === "pinch") {
235
+ return [{ method: "Input.synthesizePinchGesture", params: { x, y, scaleFactor: scale } }]
236
+ }
237
+ if (!TOUCH_GESTURES.includes(gesture)) throw new Error(`unknown touch gesture "${gesture}" — gestures: ${TOUCH_GESTURES.join(", ")}`)
238
+ const n = clampSteps(steps)
239
+ const commands = [{ method: "Input.dispatchTouchEvent", params: { type: "touchStart", touchPoints: [touchPoint(from.x, from.y)] } }]
240
+ for (let i = 1; i <= n; i++) {
241
+ const px = from.x + (to.x - from.x) * (i / n)
242
+ const py = from.y + (to.y - from.y) * (i / n)
243
+ commands.push({ method: "Input.dispatchTouchEvent", params: { type: "touchMove", touchPoints: [touchPoint(px, py)] } })
244
+ }
245
+ commands.push({ method: "Input.dispatchTouchEvent", params: { type: "touchEnd", touchPoints: [touchPoint(to.x, to.y)] } })
246
+ return commands
247
+ }
248
+
249
+ /** 目标形解析(§2.2:`e<N>` ∥ `"x,y"`);不符 ⇒ null(调用面拒——`must be an element ref (e<N>) or "x,y"`)。 */
250
+ export function parseTargetSpec(spec) {
251
+ const raw = String(spec ?? "").trim()
252
+ if (/^e\d+$/.test(raw)) return { kind: "ref", ref: raw }
253
+ const m = /^(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)$/.exec(raw)
254
+ if (m) return { kind: "point", x: Number(m[1]), y: Number(m[2]) }
255
+ return null
256
+ }
@@ -0,0 +1,143 @@
1
+ /**
2
+ * browser/launch.mjs — 浏览器发现 ∥ profile 锁 ∥ 启动 ∥ 杀树(BROWSER-TOOL.md §4)。
3
+ *
4
+ * 候选表与启动参数沿 `scripts/console-walkthrough.mjs:33-44`(Edge 先于 Chrome)∥ `:123`
5
+ * (`--headless=new` ∥ `--remote-debugging-port=0` ∥ `stdio:"ignore"` ∥ POSIX `detached`)——
6
+ * 本档不 import 该脚本,只内化其形。
7
+ * profile = `~/.thincoder/browser/profile`(`configDir` 同源——`config-io.mjs:32`)= **持久**
8
+ * (F-BT6:有头登录一次 ⇒ 后续含无头复用登录态)。
9
+ *
10
+ * 注入缝(N-BT6):`resolveBrowser({platform, env, pathExists, which})` 与 `acquireProfileLock`
11
+ * 的 `io`——缺省回落真实现,单元测试零真实文件系统。
12
+ */
13
+ import { execFileSync, spawn } from "node:child_process"
14
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"
15
+ import { dirname, join } from "node:path"
16
+ import { configDir } from "../config-io.mjs"
17
+
18
+ /** 平台候选表(edge 先于 chrome——walkthrough 实证表)。 */
19
+ export const CANDIDATES = {
20
+ edge: {
21
+ win32: ["C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe", "C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe"],
22
+ darwin: ["/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"],
23
+ linux: ["microsoft-edge", "microsoft-edge-stable"],
24
+ },
25
+ chrome: {
26
+ win32: ["C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe", "C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe"],
27
+ darwin: ["/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"],
28
+ linux: ["google-chrome", "google-chrome-stable", "chromium", "chromium-browser"],
29
+ },
30
+ }
31
+
32
+ /** 全候选落空(N-BT5)——装配期不剔除工具,首用显式报此句(§2.6)。 */
33
+ export const BROWSER_NOT_FOUND = "no Chromium-based browser found (Edge/Chrome) — install one or set BROWSER_PATH"
34
+ export const START_TIMEOUT_MS = 12_000
35
+
36
+ const defaultPathExists = (p) => existsSync(p)
37
+ function defaultWhich(name) {
38
+ try { return execFileSync("which", [name], { stdio: ["ignore", "pipe", "ignore"] }).toString().trim() || null }
39
+ catch { return null }
40
+ }
41
+
42
+ /** 浏览器可执行文件解析:`BROWSER_PATH`(exe 路径 ∥ 名字)→ 候选表 edge → chrome;全空 ⇒ 抛钉死句。 */
43
+ export function resolveBrowser({ platform = process.platform, env = process.env, pathExists = defaultPathExists, which = defaultWhich } = {}) {
44
+ const lookup = (candidate) => (candidate.includes("/") || candidate.includes("\\")
45
+ ? (pathExists(candidate) ? candidate : null)
46
+ : which(candidate))
47
+ const override = String(env?.BROWSER_PATH ?? "").trim()
48
+ if (override) {
49
+ const byName = CANDIDATES[override.toLowerCase()]
50
+ for (const candidate of byName ? byName[platform] ?? [] : [override]) {
51
+ const found = lookup(candidate)
52
+ if (found) return found
53
+ }
54
+ // 覆盖值不可用 ⇒ 落回候选表(不静默吞:表亦空时抛钉死句——「设置 BROWSER_PATH」指引仍成立)
55
+ }
56
+ for (const family of Object.values(CANDIDATES)) {
57
+ for (const candidate of family[platform] ?? []) {
58
+ const found = lookup(candidate)
59
+ if (found) return found
60
+ }
61
+ }
62
+ throw new Error(BROWSER_NOT_FOUND)
63
+ }
64
+
65
+ /** 本工具的持久化面(profile ∥ 锁 ∥ 截图目录)。 */
66
+ export function browserPaths(base = configDir) {
67
+ const root = join(base, "browser")
68
+ return { root, profile: join(root, "profile"), lock: join(root, "session.lock"), shots: join(root, "shots") }
69
+ }
70
+
71
+ function defaultPidAlive(pid) {
72
+ try { process.kill(pid, 0); return true }
73
+ catch (e) { return e?.code === "EPERM" } // 存在但无权限 ⇒ 视为活着(保守)
74
+ }
75
+
76
+ /**
77
+ * profile 锁(单会话/进程——KD-5):他进程持有 ⇒ 抛 `browser profile in use by another
78
+ * ThinCoder instance (pid <N>)`;陈旧锁(pid 死)⇒ 接管。
79
+ * `io` = 注入缝(readText ∥ writeText ∥ isAlive ∥ pid)——缺省真文件系统。
80
+ */
81
+ export function acquireProfileLock(lockPath, io = {}) {
82
+ const readText = io.readText ?? ((p) => { try { return readFileSync(p, "utf8") } catch { return "" } })
83
+ const writeText = io.writeText ?? ((p, text) => { mkdirSync(dirname(p), { recursive: true }); writeFileSync(p, text, "utf8") })
84
+ const isAlive = io.isAlive ?? defaultPidAlive
85
+ const pid = io.pid ?? process.pid
86
+ const held = Number(String(readText(lockPath) ?? "").trim())
87
+ if (Number.isInteger(held) && held > 0 && held !== pid && isAlive(held)) {
88
+ throw new Error(`browser profile in use by another ThinCoder instance (pid ${held})`)
89
+ }
90
+ writeText(lockPath, String(pid))
91
+ return { pid }
92
+ }
93
+
94
+ export function releaseProfileLock(lockPath, io = {}) {
95
+ const removeText = io.removeText ?? ((p) => { try { rmSync(p, { force: true }) } catch { /* 已被接管/清除 */ } })
96
+ removeText(lockPath)
97
+ }
98
+
99
+ /**
100
+ * 启动浏览器(spawn + `DevToolsActivePort` 轮询 ≤12s)。
101
+ * 持久 profile ⇒ 启动前清陈旧端口文件(否则旧值被当本次读数——发现即失效)。
102
+ * @returns {Promise<{child: import("node:child_process").ChildProcess, port: number, profile: string}>}
103
+ */
104
+ export async function launchBrowser({ exe, headless = true, profile, timeoutMs = START_TIMEOUT_MS, sleep = (ms) => new Promise((r) => setTimeout(r, ms)) } = {}) {
105
+ const args = [
106
+ ...(headless ? ["--headless=new"] : []),
107
+ "--disable-gpu", "--no-first-run", "--hide-scrollbars",
108
+ `--user-data-dir=${profile}`, "--remote-debugging-port=0",
109
+ "--window-size=1440,900", "about:blank",
110
+ ]
111
+ mkdirSync(profile, { recursive: true })
112
+ const portFile = join(profile, "DevToolsActivePort")
113
+ rmSync(portFile, { force: true })
114
+ const child = spawn(exe, args, { stdio: "ignore", detached: process.platform !== "win32" })
115
+ let spawnError = null
116
+ child.on("error", (e) => { spawnError = e }) // 捕获(否则 unhandled 'error' 事件崩进程);下面显式上报
117
+ const deadline = Date.now() + timeoutMs
118
+ while (!existsSync(portFile)) {
119
+ if (spawnError) { killBrowser(child); throw new Error(`browser failed to start: ${spawnError.message} (${exe})`) }
120
+ if (child.exitCode !== null || Date.now() >= deadline) {
121
+ killBrowser(child)
122
+ throw new Error(`browser failed to start: DevToolsActivePort not written within ${Math.round(timeoutMs / 1000)}s (${exe})`)
123
+ }
124
+ await sleep(50)
125
+ }
126
+ const port = Number(String(readFileSync(portFile, "utf8")).split("\n")[0])
127
+ if (!Number.isInteger(port) || port <= 0) {
128
+ killBrowser(child)
129
+ throw new Error(`browser failed to start: DevToolsActivePort is malformed (${exe})`)
130
+ }
131
+ return { child, port, profile }
132
+ }
133
+
134
+ /** 杀整棵进程树(win32 `taskkill /T /F`;POSIX 组杀 + 单杀兜底——walkthrough `:192-193` 同式)。 */
135
+ export function killBrowser(child, { platform = process.platform, exec = execFileSync } = {}) {
136
+ if (!child || typeof child.pid !== "number") return
137
+ if (platform === "win32") {
138
+ try { exec("taskkill", ["/PID", String(child.pid), "/T", "/F"], { stdio: "ignore" }) } catch { /* 已退出 */ }
139
+ return
140
+ }
141
+ try { process.kill(-child.pid, "SIGKILL") }
142
+ catch { try { child.kill("SIGKILL") } catch { /* 已退出 */ } }
143
+ }