dsh-chrome-bridge 0.0.0-stage → 1.14.0

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/client.js ADDED
@@ -0,0 +1,341 @@
1
+ /**
2
+ * dsh-chrome-bridge 的网页端:DSH「插件」页里我们这张卡的设置页。
3
+ *
4
+ * 连接状态(扩展 / 本地宿主、版本、正在控制哪些标签页)+ 全部松手
5
+ * 两个开关:允许 DSH 控制浏览器(这里只能关)、页面里显示 DSH 光标
6
+ * 没连上时:差哪一步、一键帮你打开安装页
7
+ *
8
+ * 数据全走插件自己挂在 DSH webServer 上的路由(plugin/panel.js),每 2.5 秒刷一次。
9
+ * 走官方客户端插件机制:window.__ModuleLoader__.load({ id, factory }),factory 里 apply(ctx)
10
+ * → 注册到 plugins.bundle.config(包详情页)和 plugins.row.config(行详情页)两个 keyed slot。
11
+ * 拿不到 react 或 DSH 的组件就安静退出 / 用最朴素的替身,别把宿主页面搞崩。
12
+ */
13
+ window.__ModuleLoader__.load({
14
+ id: 'dsh-chrome-bridge',
15
+ factory: (require) => {
16
+ var module = { exports: {} }
17
+ var exports = module.exports
18
+
19
+ const opt = (id) => { try { return require(id) } catch (_) { return null } }
20
+ const React = opt('react')
21
+ if (!React) {
22
+ exports.apply = () => {}
23
+ exports.inject = ['slots', 'locale']
24
+ return module.exports
25
+ }
26
+ const UI = opt('@deepseek-ai/dsh-client-ui-primitives') || {}
27
+ const h = React.createElement
28
+ const { useState, useEffect, useCallback, useRef } = React
29
+
30
+ const PKG = 'dsh-chrome-bridge'
31
+ const ROW = 'chrome-bridge'
32
+ const NS = 'dsh-chrome-bridge'
33
+ const ROUTE = '/dsh-chrome-bridge/panel'
34
+ const REPO = 'https://github.com/OrderG-X/dsh-chrome-bridge'
35
+
36
+ // 占位符用 %x:不跟 DSH 自己的 {name} 插值撞
37
+ const zh = {
38
+ title: 'Chrome 浏览器控制',
39
+ checking: '正在检查…',
40
+ connected: '已连接',
41
+ paused: '已暂停',
42
+ disconnected: '未连接',
43
+ unsupported: '目前只支持 macOS、Windows、Linux',
44
+ summaryOn: '已连接 · 正在控制 %n 个标签页',
45
+ summaryIdle: '已连接,没有在控制的标签页',
46
+ summaryOff: '还没连上 Chrome,点开看怎么装',
47
+ summaryPaused: '已暂停:DSH 暂时不能操作浏览器',
48
+ storeBuild: '商店版',
49
+ localBuild: '本地版',
50
+ controlling: '正在控制 %n 个标签页',
51
+ controllingIdle: '没有在控制的标签页',
52
+ releaseAll: '全部松手',
53
+ released: '已全部松手',
54
+ allowControl: '允许 DSH 控制浏览器',
55
+ allowControlHint: '关掉后,DSH 的浏览器操作会被直接拒绝。重新打开要到 Chrome 工具栏的 DSH Bridge 面板里点 —— 这样模型自己没法把它打开。',
56
+ resumeInChrome: '要恢复,请点 Chrome 工具栏上的 DSH Bridge 图标,打开「允许 DSH 控制浏览器」。',
57
+ showCursor: '页面里显示 DSH 光标',
58
+ showCursorHint: '关掉只是不画光标,点击、输入照做。',
59
+ outdated: 'Chrome 里的扩展版本较旧,更新后才能在这里切换开关。',
60
+ setupTitle: '还差一步:把扩展装进 Chrome',
61
+ setupStoreTitle: '扩展装了,但没连上',
62
+ setupStep1: '打开 Chrome,地址栏进入 chrome://extensions,右上角打开「开发者模式」',
63
+ setupStep2: '点「加载已解压的扩展程序」,选这个文件夹:',
64
+ setupStep3: '装好后这里会自动变成「已连接」',
65
+ setupStoreAlt: '更省事:直接从 Chrome 应用商店装,不用开发者模式。',
66
+ openStore: '去商店装',
67
+ storeOpened: '已在 Chrome 里打开商店页,点「添加至 Chrome」就行',
68
+ setupStore1: '确认 Chrome 开着,并且 DSH Bridge 扩展是启用状态',
69
+ setupStore2: '还不行就重启一下 Chrome',
70
+ openInstall: '帮我打开安装页',
71
+ installOpened: '已在 Chrome 里打开扩展页',
72
+ pathCopied: ',文件夹路径也复制好了(文件框里按 ⌘⇧G 再 ⌘V)',
73
+ pathCopiedWin: ',文件夹路径也复制好了(粘进文件框顶上的地址栏、回车)',
74
+ plugin: '插件 v%v',
75
+ guide: '使用说明',
76
+ failed: '操作没成功:%e',
77
+ unreachable: '读不到插件状态,重启 DSH 后再试',
78
+ }
79
+ const en = {
80
+ title: 'Chrome Bridge',
81
+ checking: 'Checking…',
82
+ connected: 'Connected',
83
+ paused: 'Paused',
84
+ disconnected: 'Not connected',
85
+ unsupported: 'Supports macOS, Windows and Linux only',
86
+ summaryOn: 'Connected · controlling %n tab(s)',
87
+ summaryIdle: 'Connected, no tabs under control',
88
+ summaryOff: 'Not connected to Chrome yet — open for setup steps',
89
+ summaryPaused: 'Paused: DSH cannot operate the browser',
90
+ storeBuild: 'Web Store',
91
+ localBuild: 'Local',
92
+ controlling: 'Controlling %n tab(s)',
93
+ controllingIdle: 'No tabs under control',
94
+ releaseAll: 'Release all',
95
+ released: 'Released all tabs',
96
+ allowControl: 'Allow DSH to control the browser',
97
+ allowControlHint: 'When off, DSH browser actions are refused. Turning it back on is done from the DSH Bridge panel in Chrome\'s toolbar, so the model cannot re-enable it itself.',
98
+ resumeInChrome: 'To resume, click the DSH Bridge icon in Chrome\'s toolbar and turn on "Allow DSH to control the browser".',
99
+ showCursor: 'Show the DSH cursor on pages',
100
+ showCursorHint: 'Off only hides the cursor; clicks and typing still happen.',
101
+ outdated: 'The extension in Chrome is outdated; update it to change these switches here.',
102
+ setupTitle: 'One step left: add the extension to Chrome',
103
+ setupStoreTitle: 'The extension is installed but not connected',
104
+ setupStep1: 'Open chrome://extensions in Chrome and turn on Developer mode (top right)',
105
+ setupStep2: 'Click "Load unpacked" and choose this folder:',
106
+ setupStep3: 'This page turns to "Connected" once it is loaded',
107
+ setupStoreAlt: 'Easier: install it from the Chrome Web Store, no Developer mode needed.',
108
+ openStore: 'Get it from the store',
109
+ storeOpened: 'Opened the store page in Chrome; click "Add to Chrome"',
110
+ setupStore1: 'Make sure Chrome is running and the DSH Bridge extension is enabled',
111
+ setupStore2: 'If it still does not connect, restart Chrome',
112
+ openInstall: 'Open the install page for me',
113
+ installOpened: 'Opened the extensions page in Chrome',
114
+ pathCopied: '; the folder path is on the clipboard (⌘⇧G then ⌘V in the file picker)',
115
+ pathCopiedWin: '; the folder path is on the clipboard (paste it into the file picker\'s address bar)',
116
+ plugin: 'Plugin v%v',
117
+ guide: 'Guide',
118
+ failed: 'That did not work: %e',
119
+ unreachable: 'Could not read the plugin status; restart DSH and try again',
120
+ }
121
+
122
+ const fmt = (s, vars) => String(s).replace(/%([a-z])/g, (m, k) => (vars && k in vars ? String(vars[k]) : m))
123
+
124
+ const CSS = `
125
+ .dcb{display:grid;gap:12px;color:var(--dsw-alias-label-primary);font-size:13px;line-height:1.5}
126
+ .dcb-card{border:1px solid var(--dsw-alias-settings-card-stroke,var(--dsw-alias-border-l));background:var(--dsw-alias-settings-card-fill,transparent);border-radius:12px;overflow:hidden}
127
+ .dcb-row{display:flex;align-items:flex-start;gap:12px;padding:12px 14px}
128
+ .dcb-row+.dcb-row{border-top:1px solid var(--dsw-alias-border-l)}
129
+ .dcb-main{flex:1;min-width:0;display:grid;gap:2px}
130
+ .dcb-title{font-weight:600;display:flex;align-items:center;gap:8px;flex-wrap:wrap}
131
+ .dcb-sub{color:var(--dsw-alias-label-secondary);font-size:12px}
132
+ .dcb-hint{color:var(--dsw-alias-label-tertiary);font-size:12px;margin:0}
133
+ .dcb-dot{width:9px;height:9px;border-radius:50%;flex:none;margin-top:6px;background:var(--dsw-alias-label-tertiary)}
134
+ .dcb-dot[data-s=on]{background:var(--dsw-alias-state-success-primary,#34c759);box-shadow:0 0 0 3px color-mix(in srgb,var(--dsw-alias-state-success-primary,#34c759) 22%,transparent)}
135
+ .dcb-dot[data-s=paused]{background:var(--dsw-alias-state-warn-primary,#ff9f0a)}
136
+ .dcb-dot[data-s=off]{background:var(--dsw-alias-state-error-primary,#ff453a)}
137
+ .dcb-tabs{margin:6px 0 0;padding:0;list-style:none;display:grid;gap:2px}
138
+ .dcb-tabs li{color:var(--dsw-alias-label-secondary);font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
139
+ .dcb-steps{margin:6px 0 0;padding-left:18px;display:grid;gap:4px;color:var(--dsw-alias-label-secondary)}
140
+ .dcb-path{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;padding:1px 6px;border-radius:6px;background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary);user-select:all}
141
+ .dcb-note{font-size:12px;padding:8px 12px;border-radius:10px;background:color-mix(in srgb,var(--dsw-alias-state-warn-primary,#ff9f0a) 12%,transparent);color:var(--dsw-alias-label-primary)}
142
+ .dcb-note[data-tone=ok]{background:color-mix(in srgb,var(--dsw-alias-state-success-primary,#34c759) 12%,transparent)}
143
+ .dcb-note[data-tone=err]{background:color-mix(in srgb,var(--dsw-alias-state-error-primary,#ff453a) 12%,transparent)}
144
+ .dcb-foot{display:flex;gap:10px;align-items:center;color:var(--dsw-alias-label-tertiary);font-size:12px}
145
+ .dcb-foot a{color:var(--dsw-alias-link);text-decoration:none}
146
+ .dcb-foot a:hover{text-decoration:underline}
147
+ .dcb-tag{font-size:11px;font-weight:500;padding:0 6px;border-radius:6px;border:1px solid var(--dsw-alias-border-l);color:var(--dsw-alias-label-secondary)}
148
+ .dcb-btn{font:inherit;font-size:12px;padding:4px 12px;border-radius:8px;border:1px solid var(--dsw-alias-border-l);background:transparent;color:var(--dsw-alias-label-primary);cursor:pointer;white-space:nowrap}
149
+ .dcb-btn:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}
150
+ .dcb-btn:disabled{opacity:.45;cursor:default}
151
+ .dcb-switch{flex:none;margin-top:2px}`
152
+ function ensureCss() {
153
+ if (typeof document === 'undefined' || document.querySelector('style[data-plugin-css="dsh-chrome-bridge"]')) return
154
+ const tag = document.createElement('style')
155
+ tag.dataset.plugin = PKG
156
+ tag.dataset.pluginCss = 'dsh-chrome-bridge'
157
+ tag.textContent = CSS
158
+ document.head.appendChild(tag)
159
+ }
160
+
161
+ // ── DSH 组件,拿不到就用朴素替身 ────────────────────────────────────────
162
+ function Toggle({ checked, disabled, label, onChange }) {
163
+ if (UI.Switch) return h('span', { className: 'dcb-switch' }, h(UI.Switch, { checked, disabled, label, onChange }))
164
+ return h('input', { className: 'dcb-switch', type: 'checkbox', role: 'switch', 'aria-label': label, checked, disabled, onChange: (e) => onChange(e.target.checked) })
165
+ }
166
+ function Btn({ onClick, disabled, children, primary }) {
167
+ if (UI.Button) return h(UI.Button, { variant: primary ? 'primary' : 'outline', size: 'sm', disabled, onClick }, children)
168
+ return h('button', { type: 'button', className: 'dcb-btn', disabled, onClick }, children)
169
+ }
170
+
171
+ // ── 数据 ──────────────────────────────────────────────────────────────
172
+ async function fetchState(action) {
173
+ const res = await fetch(ROUTE, action
174
+ ? { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(action), credentials: 'same-origin' }
175
+ : { credentials: 'same-origin', cache: 'no-store' })
176
+ if (!res.ok) throw new Error(`HTTP ${res.status}`)
177
+ return res.json()
178
+ }
179
+
180
+ /** 2.5 秒刷一次;页面藏起来时不刷。 */
181
+ function useBridgeState() {
182
+ const [state, setState] = useState(null)
183
+ const [error, setError] = useState('')
184
+ const alive = useRef(true)
185
+ const load = useCallback(async () => {
186
+ try {
187
+ const s = await fetchState()
188
+ if (alive.current) { setState(s); setError('') }
189
+ } catch (e) {
190
+ if (alive.current) setError(String((e && e.message) || e))
191
+ }
192
+ }, [])
193
+ useEffect(() => {
194
+ alive.current = true
195
+ load()
196
+ const timer = setInterval(() => {
197
+ if (typeof document === 'undefined' || document.visibilityState !== 'hidden') load()
198
+ }, 2500)
199
+ return () => { alive.current = false; clearInterval(timer) }
200
+ }, [load])
201
+ const act = useCallback(async (action) => {
202
+ const s = await fetchState(action)
203
+ if (alive.current) setState(s)
204
+ return s
205
+ }, [])
206
+ return { state, error, act }
207
+ }
208
+
209
+ function phaseOf(s) {
210
+ if (!s) return 'checking'
211
+ if (s.platform && !['darwin', 'win32', 'linux'].includes(s.platform)) return 'unsupported'
212
+ if (!s.connected) return 'off'
213
+ return s.paused ? 'paused' : 'on'
214
+ }
215
+
216
+ // ── 概要(行卡片上的一句话)────────────────────────────────────────────
217
+ function Summary({ t }) {
218
+ const { state } = useBridgeState()
219
+ const p = phaseOf(state)
220
+ if (p === 'checking') return t('checking')
221
+ if (p === 'unsupported') return t('unsupported')
222
+ if (p === 'off') return t('summaryOff')
223
+ if (p === 'paused') return t('summaryPaused')
224
+ return state.attachedCount ? fmt(t('summaryOn'), { n: state.attachedCount }) : t('summaryIdle')
225
+ }
226
+
227
+ // ── 设置页 ────────────────────────────────────────────────────────────
228
+ function Page({ t }) {
229
+ const { state, error, act } = useBridgeState()
230
+ const [busy, setBusy] = useState(false)
231
+ const [note, setNote] = useState(null)
232
+ const p = phaseOf(state)
233
+ const can = !!state && state.connected && !state.outdated && !busy
234
+
235
+ const run = async (action, ok) => {
236
+ setBusy(true)
237
+ try {
238
+ const s = await act(action)
239
+ if (s.actionError) setNote({ tone: 'err', text: fmt(t('failed'), { e: s.actionError }) })
240
+ else setNote(ok ? { tone: 'ok', text: typeof ok === 'function' ? ok(s) : ok } : null)
241
+ } catch (e) {
242
+ setNote({ tone: 'err', text: fmt(t('failed'), { e: String((e && e.message) || e) }) })
243
+ } finally {
244
+ setBusy(false)
245
+ }
246
+ }
247
+
248
+ const statusTitle = { checking: t('checking'), on: t('connected'), paused: t('paused'), off: t('disconnected'), unsupported: t('unsupported') }[p]
249
+ const ext = state && state.ext
250
+ const status = h('div', { className: 'dcb-card' },
251
+ h('div', { className: 'dcb-row' },
252
+ h('span', { className: 'dcb-dot', 'data-s': p === 'unsupported' ? 'off' : p }),
253
+ h('div', { className: 'dcb-main' },
254
+ h('div', { className: 'dcb-title' }, statusTitle,
255
+ ext ? h('span', { className: 'dcb-tag' }, `${ext.store ? t('storeBuild') : t('localBuild')} v${ext.version}`) : null),
256
+ state && state.connected
257
+ ? h('div', { className: 'dcb-sub' }, state.attachedCount ? fmt(t('controlling'), { n: state.attachedCount }) : t('controllingIdle'))
258
+ : null,
259
+ state && state.tabs && state.tabs.length
260
+ ? h('ul', { className: 'dcb-tabs' }, state.tabs.map((tab, i) => h('li', { key: i, title: tab.url }, tab.title || tab.url)))
261
+ : null,
262
+ p === 'paused' ? h('p', { className: 'dcb-hint' }, t('resumeInChrome')) : null),
263
+ state && state.connected
264
+ ? h(Btn, { disabled: !can || !state.attachedCount, onClick: () => run({ action: 'releaseAll' }, t('released')) }, t('releaseAll'))
265
+ : null))
266
+
267
+ const setup = p === 'off' ? h('div', { className: 'dcb-card' },
268
+ h('div', { className: 'dcb-row' },
269
+ h('div', { className: 'dcb-main' },
270
+ h('div', { className: 'dcb-title' }, state.store ? t('setupStoreTitle') : t('setupTitle')),
271
+ state.store
272
+ ? h('ol', { className: 'dcb-steps' }, h('li', null, t('setupStore1')), h('li', null, t('setupStore2')))
273
+ : h('ol', { className: 'dcb-steps' },
274
+ h('li', null, t('setupStep1')),
275
+ h('li', null, t('setupStep2'), ' ', h('span', { className: 'dcb-path' }, state.extDir)),
276
+ h('li', null, t('setupStep3'))),
277
+ state.store ? null : h('p', { className: 'dcb-hint' }, t('setupStoreAlt'))),
278
+ state.store ? null : h(Btn, { primary: true, disabled: busy, onClick: () => run({ action: 'install', via: 'store' }, t('storeOpened')) }, t('openStore')),
279
+ h(Btn, { primary: !!state.store, disabled: busy, onClick: () => run({ action: 'install' }, (s) => t('installOpened') + (s.assist && s.assist.clipboard ? t(state.platform === 'win32' ? 'pathCopiedWin' : 'pathCopied') : '')) }, t('openInstall')))) : null
280
+
281
+ const switches = state && state.connected ? h('div', { className: 'dcb-card' },
282
+ h('div', { className: 'dcb-row' },
283
+ h('div', { className: 'dcb-main' },
284
+ h('div', { className: 'dcb-title' }, t('allowControl')),
285
+ h('p', { className: 'dcb-hint' }, t('allowControlHint'))),
286
+ h(Toggle, {
287
+ label: t('allowControl'),
288
+ checked: !state.paused,
289
+ // 只能往「关」的方向拨:打开要去 Chrome 里点
290
+ disabled: !can || state.paused,
291
+ onChange: (on) => { if (!on) run({ action: 'pause' }) },
292
+ })),
293
+ h('div', { className: 'dcb-row' },
294
+ h('div', { className: 'dcb-main' },
295
+ h('div', { className: 'dcb-title' }, t('showCursor')),
296
+ h('p', { className: 'dcb-hint' }, t('showCursorHint'))),
297
+ h(Toggle, { label: t('showCursor'), checked: state.showCursor !== false, disabled: !can, onChange: (on) => run({ action: 'cursor', on }) }))) : null
298
+
299
+ const notes = [
300
+ error && !state ? { tone: 'err', text: t('unreachable') } : null,
301
+ state && state.outdated ? { tone: 'warn', text: t('outdated') } : null,
302
+ note,
303
+ ].filter(Boolean)
304
+
305
+ return h('div', { className: 'dcb' },
306
+ status,
307
+ setup,
308
+ switches,
309
+ ...notes.map((n, i) => h('div', { key: `n${i}`, className: 'dcb-note', 'data-tone': n.tone }, n.text)),
310
+ h('div', { className: 'dcb-foot' },
311
+ state && state.plugin ? h('span', null, fmt(t('plugin'), { v: state.plugin })) : null,
312
+ h('a', { href: REPO, target: '_blank', rel: 'noopener noreferrer' }, t('guide'))))
313
+ }
314
+
315
+ function makeCard(boundT) {
316
+ return function ChromeBridgeCard(props) {
317
+ ensureCss()
318
+ const t = typeof props.t === 'function' ? props.t : boundT
319
+ return props.view === 'summary' ? h(Summary, { t }) : h(Page, { t })
320
+ }
321
+ }
322
+
323
+ function apply(ctx) {
324
+ const t = ctx.locale && typeof ctx.locale.bind === 'function' ? ctx.locale.bind(NS) : (k) => zh[k] || k
325
+ if (ctx.locale && typeof ctx.locale.register === 'function') {
326
+ ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'dsh-chrome-bridge: dictionaries')
327
+ }
328
+ const Card = makeCard(t)
329
+ const mount = (name, key) => {
330
+ const reg = () => ctx.slots.register({ name, key, locale: NS }, Card)
331
+ ctx.effect(() => (typeof ctx.slots.inject === 'function' ? ctx.slots.inject(name, reg) : reg()), `dsh-chrome-bridge: ${name}`)
332
+ }
333
+ mount('plugins.bundle.config', PKG)
334
+ mount('plugins.row.config', `${PKG}#${ROW}`)
335
+ }
336
+
337
+ exports.apply = apply
338
+ exports.inject = ['slots', 'locale']
339
+ return module.exports
340
+ },
341
+ })
@@ -0,0 +1,4 @@
1
+ # Bundle patch for dsh-chrome-bridge: inserts the plugin row into the DSH profile.
2
+ - insert:
3
+ - id: chrome-bridge
4
+ name: 'dsh-chrome-bridge'
package/devtools.js ADDED
@@ -0,0 +1,94 @@
1
+ /**
2
+ * 控制台 / 网络 / 返回内容 —— 调试网页用(browser_devtools)。
3
+ *
4
+ * 记录都在扩展里(console + JS 报错 + 浏览器日志一个环形缓冲,网络请求一个),这里只负责取和排版。
5
+ * 只记得到调试器接上之后的事;不过接上那一刻 V8 会把之前的 console 和报错补发一遍,
6
+ * 所以看 console 前先 attach 一下就能拿到「打开页面以来」的输出。网络请求没有补发,要 reload。
7
+ */
8
+
9
+ const time = (ms) => new Date(ms).toTimeString().slice(0, 8)
10
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
11
+ const LEVELS = {
12
+ error: ['error', 'assert'],
13
+ warning: ['error', 'assert', 'warning', 'warn'],
14
+ }
15
+
16
+ /** "http://x.com/static/app.js?v=3:12" → "app.js?v=3:12" */
17
+ const shortWhere = (w) => {
18
+ const m = /([^/]+)$/.exec(String(w || ''))
19
+ return m ? m[1].slice(0, 60) : ''
20
+ }
21
+
22
+ export async function consoleLog(call, tabId, { level = 'all', filter, limit = 40 } = {}) {
23
+ await call('attach', { tabId })
24
+ await sleep(300) // 等 V8 补发完旧消息
25
+ const r = await call('console', { tabId, limit: 300 })
26
+ let items = r.items || []
27
+ if (LEVELS[level]) items = items.filter((c) => LEVELS[level].includes(c.level))
28
+ if (filter) {
29
+ const f = String(filter).toLowerCase()
30
+ items = items.filter((c) => `${c.level} ${c.text} ${c.where || ''}`.toLowerCase().includes(f))
31
+ }
32
+ const total = items.length
33
+ items = items.slice(-limit)
34
+ if (!items.length) return level === 'all' && !filter ? '(控制台没有输出)' : '(没有匹配的条目)'
35
+ const lines = items.map((c) => {
36
+ const tag = c.kind === 'exception' ? '[未捕获] ' : c.kind && c.kind !== 'console' ? `[${c.kind}] ` : ''
37
+ const where = c.where ? ` (${shortWhere(c.where)})` : ''
38
+ return `${time(c.at)} ${String(c.level).padEnd(7)} ${tag}${c.text}${where}`
39
+ })
40
+ const head = total > items.length ? `最近 ${items.length} 条(共 ${total} 条)` : `${items.length} 条`
41
+ return `${head}\n${lines.join('\n')}`
42
+ }
43
+
44
+ export async function networkLog(call, tabId, { filter, reload = false, limit = 40 } = {}) {
45
+ const r = await call('network', { tabId, limit: 400, reload: !!reload, waitMs: 2500 }, 30000)
46
+ let items = r.items || []
47
+ if (filter) {
48
+ const f = String(filter).toLowerCase()
49
+ items = items.filter((n) => `${n.method} ${n.status} ${n.type} ${n.url}`.toLowerCase().includes(f))
50
+ }
51
+ const total = items.length
52
+ items = items.slice(-limit)
53
+ if (!items.length) {
54
+ return filter ? '(没有匹配的请求)' : '(还没记到请求——只记调试器接上之后的;传 reload=true 刷新一次就能抓到页面加载的全部请求)'
55
+ }
56
+ const hasId = items.some((n) => n.id)
57
+ const lines = items.map((n) => {
58
+ const status = n.failed ? `✗ ${n.failed}` : String(n.status ?? '…')
59
+ const ms = n.ms !== undefined ? `${n.ms}ms` : ''
60
+ const url = n.url.length > 150 ? n.url.slice(0, 149) + '…' : n.url
61
+ return `${hasId ? `#${n.id} ` : ''}${n.method} ${status} ${String(n.type || '').toLowerCase()} ${ms} ${url}`
62
+ })
63
+ const head = (total > items.length ? `最近 ${items.length} 个请求(共 ${total} 个)` : `${items.length} 个请求`) +
64
+ (hasId ? ';看返回内容:action=response,request 传 # 后面的编号' : ';扩展版本旧,看不了返回内容——重载一下扩展')
65
+ return `${head}\n${lines.join('\n')}`
66
+ }
67
+
68
+ export async function responseBody(call, tabId, requestId, limit = 6000) {
69
+ if (!requestId) throw new Error('action=response 需要 request(action=network 列出的 # 编号)')
70
+ const id = String(requestId).replace(/^#/, '')
71
+ let r
72
+ try {
73
+ r = await call('cdp', { tabId, method: 'Network.getResponseBody', params: { requestId: id } })
74
+ } catch (e) {
75
+ if (/No resource|No data found|identifier/i.test(e.message)) {
76
+ throw new Error('拿不到这个请求的返回内容(太早了被清掉、请求还没完成,或调试器断开过)——action=network reload=true 重抓一次')
77
+ }
78
+ throw e
79
+ }
80
+ const { body = '', base64Encoded } = r.result || {}
81
+ if (base64Encoded) return `(二进制内容,约 ${Math.round((body.length * 3) / 4 / 1024)}KB——不是文字)`
82
+ let text = body
83
+ const t = body.trim()
84
+ if (t.startsWith('{') || t.startsWith('[')) {
85
+ try { text = JSON.stringify(JSON.parse(t), null, 2) } catch {}
86
+ }
87
+ return text.length > limit ? text.slice(0, limit) + `\n…(截断,全文 ${text.length} 字)` : text || '(空)'
88
+ }
89
+
90
+ export async function clearLogs(call, tabId) {
91
+ await call('console', { tabId, clear: true })
92
+ await call('network', { tabId, clear: true })
93
+ return '已清空控制台和网络记录'
94
+ }
package/drag.js ADDED
@@ -0,0 +1,132 @@
1
+ /**
2
+ * 拖拽(browser_drag)。页面有两种「拖」,拖法不一样:
3
+ *
4
+ * · 元素带 draggable="true" 的 HTML5 原生拖放:CDP 的鼠标事件在有界面的 Chrome 里
5
+ * 起不了原生拖放会话,所以在页面里合成 dragstart → dragenter → dragover → drop → dragend,
6
+ * 共用同一个 DataTransfer(页面在 dragstart 里塞的数据 drop 时拿得到)
7
+ * · 其它(滑块、排序列表、看板、画布……靠鼠标/指针事件的):发真实的
8
+ * 按下 → 分步移动 → 松开,Chrome 会顺带生成 pointer 事件
9
+ *
10
+ * 起点终点都能用编号 ref / selector / 可见文字 / 坐标给。放置区("拖到这里"那种框)
11
+ * 往往不是可点元素、snapshot 里没编号,所以按文字找最实用。
12
+ */
13
+
14
+ import { refElementJs } from './refs.js'
15
+
16
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
17
+
18
+ /** 按可见文字找元素:包含这段文字、看得见的元素里面积最小的那个(最贴近文字本身)。 */
19
+ const textElementJs = (text) => `((t) => {
20
+ let best = null, area = Infinity;
21
+ for (const el of document.body.querySelectorAll('*')) {
22
+ if (!(el.textContent || '').includes(t) || !(el.innerText || '').includes(t)) continue;
23
+ const r = el.getBoundingClientRect();
24
+ if (r.width < 1 || r.height < 1 || r.width * r.height >= area) continue;
25
+ best = el; area = r.width * r.height;
26
+ }
27
+ return best;
28
+ })(${JSON.stringify(String(text))})`
29
+
30
+ const hasRef = (end) => end.ref !== undefined && end.ref !== null
31
+
32
+ /** 端点 → 页内取元素的表达式;坐标端点返回 'null'。 */
33
+ const elementJs = (end) =>
34
+ hasRef(end) ? refElementJs(end.ref)
35
+ : end.selector ? `document.querySelector(${JSON.stringify(end.selector)})`
36
+ : end.text ? textElementJs(end.text)
37
+ : 'null'
38
+
39
+ const isElementEnd = (end) => hasRef(end) || !!end.selector || !!end.text
40
+ const describe = (end) =>
41
+ hasRef(end) ? `[${end.ref}]` : end.selector || (end.text ? `「${end.text}」` : `(${end.x}, ${end.y})`)
42
+
43
+ /** 一次 eval 算出两端的视口坐标,顺带判断是不是原生拖放。 */
44
+ const PLAN_JS = (from, to) => `(async () => {
45
+ const src = ${elementJs(from)};
46
+ const dst = ${elementJs(to)};
47
+ if (${isElementEnd(from)} && !src) return { missing: 'from' };
48
+ if (${isElementEnd(to)} && !dst) return { missing: 'to' };
49
+ if (src) {
50
+ src.scrollIntoView({ block: 'nearest', inline: 'nearest', behavior: 'instant' });
51
+ // 后台标签页会暂停 rAF,定时器兜底
52
+ await new Promise((res) => { requestAnimationFrame(() => requestAnimationFrame(res)); setTimeout(res, 120); });
53
+ }
54
+ const mid = (el) => { const r = el.getBoundingClientRect(); return { x: r.left + r.width / 2, y: r.top + r.height / 2 }; };
55
+ const a = src ? mid(src) : { x: ${Number(from.x) || 0}, y: ${Number(from.y) || 0} };
56
+ const b = dst ? mid(dst) : { x: ${Number(to.x) || 0}, y: ${Number(to.y) || 0} };
57
+ const hit = src || document.elementFromPoint(a.x, a.y);
58
+ const native = !!(hit && hit.closest && hit.closest('[draggable="true"]'));
59
+ const inView = (p) => p.x >= 0 && p.y >= 0 && p.x <= innerWidth && p.y <= innerHeight;
60
+ return { a, b, native, aIn: inView(a), bIn: inView(b) };
61
+ })()`
62
+
63
+ /** HTML5 原生拖放:页面里合成整套 DragEvent。 */
64
+ const NATIVE_JS = (from, to, a, b) => `(() => {
65
+ const start = ${elementJs(from)} || document.elementFromPoint(${a.x}, ${a.y});
66
+ const src = start.closest('[draggable="true"]') || start;
67
+ const tgt = ${elementJs(to)} || document.elementFromPoint(${b.x}, ${b.y});
68
+ if (!tgt) return { error: 'no-target' };
69
+ const dt = new DataTransfer();
70
+ const fire = (el, type, p) => el.dispatchEvent(new DragEvent(type, {
71
+ bubbles: true, cancelable: true, composed: true, clientX: p.x, clientY: p.y, dataTransfer: dt,
72
+ }));
73
+ const A = { x: ${a.x}, y: ${a.y} }, B = { x: ${b.x}, y: ${b.y} };
74
+ fire(src, 'dragstart', A);
75
+ fire(tgt, 'dragenter', B);
76
+ const accepted = !fire(tgt, 'dragover', B); // 页面 preventDefault 了 dragover = 这里能放
77
+ fire(tgt, 'drop', B);
78
+ fire(src, 'dragend', B);
79
+ return { accepted };
80
+ })()`
81
+
82
+ /**
83
+ * @param from/to { ref?, selector?, text?, x?, y? }
84
+ * @param cursor 可选:页内光标 { move(x, y, { duration?, straight?, label? }) → 滑行 ms, down(), up() },
85
+ * 让用户看得见在拖什么;任何一步出错都不影响真实拖拽
86
+ */
87
+ export async function drag(call, tabId, from, to, { steps = 12, cursor } = {}) {
88
+ const plan = (await call('eval', { tabId, expression: PLAN_JS(from, to), userGesture: false })).value
89
+ if (!plan) throw new Error('页面没回话(可能刚跳转,重试一次)')
90
+ if (plan.missing) {
91
+ const end = plan.missing === 'from' ? from : to
92
+ throw new Error(`没找到${plan.missing === 'from' ? '起点' : '终点'} ${describe(end)}` +
93
+ (hasRef(end) ? '(编号可能已失效,重新 snapshot)' : ''))
94
+ }
95
+ const { a, b } = plan
96
+ if (!plan.bIn) throw new Error(`终点 ${describe(to)} 不在当前屏幕里——先滚动让起点和终点都露出来`)
97
+
98
+ const cur = async (fn) => {
99
+ if (!cursor) return 0
100
+ try { return Number(await fn(cursor)) || 0 } catch { return 0 }
101
+ }
102
+ // 光标先滑到起点亮名牌,到位了再按下
103
+ if (cursor) await sleep(Math.min(700, await cur((c) => c.move(a.x, a.y, { label: `拖拽 ${describe(from)} → ${describe(to)}` }))) + 60)
104
+
105
+ if (plan.native) {
106
+ await cur((c) => c.down())
107
+ await sleep(await cur((c) => c.move(b.x, b.y, { duration: 420, straight: true })))
108
+ const r = (await call('eval', { tabId, expression: NATIVE_JS(from, to, a, b) })).value
109
+ await cur((c) => c.up())
110
+ if (r && r.error) throw new Error('终点那里没有元素')
111
+ return `已拖拽 ${describe(from)} → ${describe(to)}(HTML5 拖放${r && r.accepted ? ',目标接受了' : ',但目标没表示接受——看看结果对不对'})`
112
+ }
113
+
114
+ const mouse = (type, p, extra) => call('cdp', {
115
+ tabId,
116
+ method: 'Input.dispatchMouseEvent',
117
+ params: { type, x: Math.round(p.x), y: Math.round(p.y), ...extra },
118
+ })
119
+ await mouse('mouseMoved', a, { button: 'none', buttons: 0 })
120
+ await cur((c) => c.down())
121
+ await mouse('mousePressed', a, { button: 'left', buttons: 1, clickCount: 1 })
122
+ const n = Math.max(2, Math.min(60, Number(steps) || 12))
123
+ await cur((c) => c.move(b.x, b.y, { duration: n * 24, straight: true })) // 光标和真实鼠标一起走
124
+ for (let i = 1; i <= n; i++) {
125
+ const p = { x: a.x + ((b.x - a.x) * i) / n, y: a.y + ((b.y - a.y) * i) / n }
126
+ await mouse('mouseMoved', p, { button: 'left', buttons: 1 })
127
+ await sleep(16)
128
+ }
129
+ await mouse('mouseReleased', b, { button: 'left', buttons: 0, clickCount: 1 })
130
+ await cur((c) => c.up())
131
+ return `已拖拽 ${describe(from)} → ${describe(to)}:(${Math.round(a.x)}, ${Math.round(a.y)}) → (${Math.round(b.x)}, ${Math.round(b.y)})`
132
+ }
package/icon.svg ADDED
@@ -0,0 +1,5 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" width="128" height="128">
2
+ <rect width="128" height="128" rx="28" fill="#4D6BFE"/>
3
+ <path d="M30 22 L30 96 L50 76 L65 112 L80 105 L65 70 L96 68 Z"
4
+ fill="#FFFFFF" stroke="#FFFFFF" stroke-width="4" stroke-linejoin="round"/>
5
+ </svg>