dsh-sidebar-browser-agent 0.0.0-stage → 0.2.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 +69 -0
- package/LICENSE +21 -0
- package/README.md +226 -2
- package/cordis.patch.yml +8 -0
- package/icon.svg +8 -0
- package/lib/client.js +1651 -0
- package/lib/entry-0.2.6.js +1181 -0
- package/lib/index.js +1181 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +61 -4
- package/test/client-harness.mjs +687 -0
- package/test/host-harness.mjs +548 -0
- package/test/run.mjs +22 -0
- package/vendor/NOTICE.md +15 -0
- package/vendor/cosmokit/LICENSE +21 -0
- package/vendor/cosmokit/index.js +418 -0
- package/vendor/schemastery/LICENSE +21 -0
- package/vendor/schemastery/index.mjs +635 -0
package/lib/client.js
ADDED
|
@@ -0,0 +1,1651 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser half of `dsh-sidebar-browser-agent`.
|
|
3
|
+
*
|
|
4
|
+
* Two jobs:
|
|
5
|
+
* 1. Run the long-poll bridge to the Host tool half.
|
|
6
|
+
* 2. Drive the native right-sidebar browser. Tab management uses the public
|
|
7
|
+
* `ctx.sidebarRight` controller; page content, clicking, typing and
|
|
8
|
+
* screenshots use the **native Electron guest** the sidebar browser mounts:
|
|
9
|
+
* the `<webview data-sidebar-browser-frame="webview">` belonging to a tab
|
|
10
|
+
* body. Its Chromium guest API (`executeJavaScript`, `capturePage`,
|
|
11
|
+
* `sendInputEvent`, `loadURL`, `reload`) gives full access to the real
|
|
12
|
+
* page without any proxy or browser automation dependency.
|
|
13
|
+
*
|
|
14
|
+
* DOM mapping: the sidebar renders both a title wrapper and a body wrapper
|
|
15
|
+
* carrying `data-sidebar-right-tab="<tabId>"` inside one
|
|
16
|
+
* `[data-sidebar-right-session]` root; the body wrapper contains the webview.
|
|
17
|
+
*
|
|
18
|
+
* In a Web (non-Electron) build the sidebar browser renders a sandboxed
|
|
19
|
+
* cross-origin iframe instead. Tab management still works there; page actions
|
|
20
|
+
* fail with a clear explanation and the agent can fall back to `web_fetch`.
|
|
21
|
+
*/
|
|
22
|
+
window.__ModuleLoader__.load({
|
|
23
|
+
id: 'dsh-sidebar-browser-agent',
|
|
24
|
+
factory(require) {
|
|
25
|
+
const React = require('react')
|
|
26
|
+
const PLUGIN_ROOT = 'plugin/sidebar-browser-agent'
|
|
27
|
+
const MAX_BACKOFF_MS = 5_000
|
|
28
|
+
const BASE_BACKOFF_MS = 400
|
|
29
|
+
const MAX_EVAL_CHARS = 100_000
|
|
30
|
+
/** Plugin version reported by diagnostics; keep in sync with package.json. */
|
|
31
|
+
const PLUGIN_VERSION = '0.2.6'
|
|
32
|
+
const MAX_SCREENSHOT_WIDTH = 1_400
|
|
33
|
+
const MAX_SCREENSHOT_BASE64 = 12_000_000
|
|
34
|
+
/**
|
|
35
|
+
* Bounds for guest operations. A hung page must never block the poll loop:
|
|
36
|
+
* every awaited guest call is raced against these deadlines, and a timeout
|
|
37
|
+
* becomes a normal command error while the bridge keeps polling.
|
|
38
|
+
*/
|
|
39
|
+
const TIMEOUTS = {
|
|
40
|
+
guest: 25_000,
|
|
41
|
+
capture: 25_000,
|
|
42
|
+
navigation: 20_000,
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Resolve a host route against this page's own base.
|
|
47
|
+
*
|
|
48
|
+
* A relative path keeps a reverse-proxy directory prefix, and the Electron
|
|
49
|
+
* desktop shell forwards every non-static `dsh-app://app/...` path to the
|
|
50
|
+
* Harness host, so the same expression reaches the bridge in both carriers.
|
|
51
|
+
* @param path - route relative to the application root.
|
|
52
|
+
* @returns absolute request URL.
|
|
53
|
+
*/
|
|
54
|
+
function hostUrl(path) {
|
|
55
|
+
const raw = typeof document !== 'undefined' && typeof document.baseURI === 'string' && document.baseURI !== ''
|
|
56
|
+
? document.baseURI
|
|
57
|
+
: 'http://dsh.internal/'
|
|
58
|
+
const base = new URL(raw)
|
|
59
|
+
base.search = ''
|
|
60
|
+
base.hash = ''
|
|
61
|
+
if (!base.pathname.endsWith('/')) base.pathname += '/'
|
|
62
|
+
return new URL(path.replace(/^\/+/, ''), base.href).toString()
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** @returns a fresh opaque page-instance id. */
|
|
66
|
+
function newClientId() {
|
|
67
|
+
try {
|
|
68
|
+
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') return crypto.randomUUID()
|
|
69
|
+
} catch {
|
|
70
|
+
/* fall through */
|
|
71
|
+
}
|
|
72
|
+
return `client-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* @param error - thrown value.
|
|
77
|
+
* @returns a printable message.
|
|
78
|
+
*/
|
|
79
|
+
function messageOf(error) {
|
|
80
|
+
if (error instanceof Error) return error.message
|
|
81
|
+
return String(error)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* @param ms - milliseconds to wait.
|
|
86
|
+
* @returns a promise resolving after the delay.
|
|
87
|
+
*/
|
|
88
|
+
function sleep(ms) {
|
|
89
|
+
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Race one promise against a deadline so a hung platform call cannot block
|
|
94
|
+
* the bridge forever.
|
|
95
|
+
* @param promise - the operation to bound.
|
|
96
|
+
* @param ms - deadline in milliseconds.
|
|
97
|
+
* @param label - what timed out, used in the failure text.
|
|
98
|
+
* @returns the operation's result.
|
|
99
|
+
*/
|
|
100
|
+
function withTimeout(promise, ms, label) {
|
|
101
|
+
return new Promise((resolve, reject) => {
|
|
102
|
+
const timer = setTimeout(() => {
|
|
103
|
+
reject(new Error(`${label} did not finish within ${Math.round(ms / 1000)}s; the page is unresponsive, so the command was abandoned. Reload the tab or wait for the page to settle, then retry.`))
|
|
104
|
+
}, ms)
|
|
105
|
+
Promise.resolve(promise).then(
|
|
106
|
+
(value) => {
|
|
107
|
+
clearTimeout(timer)
|
|
108
|
+
resolve(value)
|
|
109
|
+
},
|
|
110
|
+
(error) => {
|
|
111
|
+
clearTimeout(timer)
|
|
112
|
+
reject(error)
|
|
113
|
+
},
|
|
114
|
+
)
|
|
115
|
+
})
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ---------------------------------------------------------------------
|
|
119
|
+
// Native carrier detection and DOM mapping
|
|
120
|
+
// ---------------------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Describe the carrier this page can use for page content.
|
|
124
|
+
* @returns `{ kind, native }` where kind is desktop-webview, web-iframe or none.
|
|
125
|
+
*/
|
|
126
|
+
function detectCarrier() {
|
|
127
|
+
const desktop = globalThis.dshDesktop
|
|
128
|
+
if (desktop !== null && typeof desktop === 'object' && desktop.browser !== undefined) {
|
|
129
|
+
return { kind: 'electron-webview', native: true }
|
|
130
|
+
}
|
|
131
|
+
if (typeof document !== 'undefined' && document.querySelector('iframe[data-sidebar-browser-frame]') !== null) {
|
|
132
|
+
return { kind: 'web-iframe', native: false }
|
|
133
|
+
}
|
|
134
|
+
return { kind: 'none', native: false }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Locate the sidebar surface root for one session.
|
|
139
|
+
* @param sessionId - owning conversation.
|
|
140
|
+
* @returns the session root element, or undefined when no surface is mounted.
|
|
141
|
+
*/
|
|
142
|
+
function sessionRootOf(sessionId) {
|
|
143
|
+
const roots = document.querySelectorAll('[data-sidebar-right-session]')
|
|
144
|
+
for (const element of roots) {
|
|
145
|
+
if (element.dataset.sidebarRightSession === sessionId) return element
|
|
146
|
+
}
|
|
147
|
+
return undefined
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Locate the native guest of one sidebar browser tab.
|
|
152
|
+
* The title wrapper and the body wrapper share the tab marker; only the
|
|
153
|
+
* body wrapper contains the webview, so every candidate is searched.
|
|
154
|
+
* @param sessionId - owning conversation.
|
|
155
|
+
* @param tabId - sidebar tab id.
|
|
156
|
+
* @returns the live `<webview>` element.
|
|
157
|
+
*/
|
|
158
|
+
function findWebview(sessionId, tabId) {
|
|
159
|
+
const root = sessionRootOf(sessionId)
|
|
160
|
+
if (root === undefined) {
|
|
161
|
+
throw new Error(`The right sidebar has no surface for conversation ${sessionId}; open that conversation and focus its browser tab.`)
|
|
162
|
+
}
|
|
163
|
+
const marker = typeof CSS !== 'undefined' && typeof CSS.escape === 'function' ? CSS.escape(tabId) : tabId
|
|
164
|
+
const wrappers = root.querySelectorAll(`[data-sidebar-right-tab="${marker}"]`)
|
|
165
|
+
for (const wrapper of wrappers) {
|
|
166
|
+
const webview = wrapper.querySelector('webview[data-sidebar-browser-frame]')
|
|
167
|
+
if (webview !== null) return webview
|
|
168
|
+
}
|
|
169
|
+
throw new Error(`Browser tab ${tabId} has no native guest mounted yet. Focus that tab in the sidebar, wait for it to finish loading, or reload it with sidebar_browser action "reload".`)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Find the guest without throwing; used for best-effort URL/title reporting.
|
|
174
|
+
* @param sessionId - owning conversation.
|
|
175
|
+
* @param tabId - sidebar tab id.
|
|
176
|
+
* @returns the webview element or undefined.
|
|
177
|
+
*/
|
|
178
|
+
function tryFindWebview(sessionId, tabId) {
|
|
179
|
+
try {
|
|
180
|
+
return findWebview(sessionId, tabId)
|
|
181
|
+
} catch {
|
|
182
|
+
return undefined
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Run one self-contained function inside the guest page and await its result.
|
|
188
|
+
* The function is serialized, so it must not close over page-module values.
|
|
189
|
+
* @param webview - native guest element.
|
|
190
|
+
* @param fn - self-contained function, optionally async.
|
|
191
|
+
* @param arg - JSON-serializable single argument.
|
|
192
|
+
* @param userGesture - whether Chromium should treat the call as user activation.
|
|
193
|
+
* @returns the guest result.
|
|
194
|
+
*/
|
|
195
|
+
async function runInGuest(webview, fn, arg, userGesture = false) {
|
|
196
|
+
const source = `(${fn.toString()})(${JSON.stringify(arg === undefined ? null : arg)})`
|
|
197
|
+
try {
|
|
198
|
+
return await withTimeout(
|
|
199
|
+
Promise.resolve().then(() => webview.executeJavaScript(source, userGesture)),
|
|
200
|
+
TIMEOUTS.guest,
|
|
201
|
+
'The page script',
|
|
202
|
+
)
|
|
203
|
+
} catch (error) {
|
|
204
|
+
if (messageOf(error).includes('did not finish within')) throw error
|
|
205
|
+
throw new Error(`The page context rejected the script: ${messageOf(error)}`)
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Wait for the guest document to be interactive.
|
|
211
|
+
* @param webview - native guest element.
|
|
212
|
+
*/
|
|
213
|
+
async function ensureGuestReady(webview) {
|
|
214
|
+
// `isLoading()` reflects every outstanding asset, so a slow image or a
|
|
215
|
+
// long-poll can keep it true long after the document is usable (GitHub's
|
|
216
|
+
// login and 2FA screens do this). Readiness therefore follows the
|
|
217
|
+
// document's own `readyState`, bounded so a genuinely stuck parse fails.
|
|
218
|
+
let state
|
|
219
|
+
try {
|
|
220
|
+
state = await runInGuest(webview, function guestDocumentReady() {
|
|
221
|
+
return new Promise((resolve) => {
|
|
222
|
+
const report = () => resolve({ readyState: document.readyState, url: location.href })
|
|
223
|
+
if (document.readyState !== 'loading') {
|
|
224
|
+
report()
|
|
225
|
+
return
|
|
226
|
+
}
|
|
227
|
+
const timer = setTimeout(report, 5000)
|
|
228
|
+
document.addEventListener('DOMContentLoaded', () => {
|
|
229
|
+
clearTimeout(timer)
|
|
230
|
+
report()
|
|
231
|
+
}, { once: true })
|
|
232
|
+
})
|
|
233
|
+
})
|
|
234
|
+
} catch (error) {
|
|
235
|
+
throw new Error(`The native page guest is not ready: ${messageOf(error)}`)
|
|
236
|
+
}
|
|
237
|
+
if (state === null || typeof state !== 'object' || state.readyState === 'loading') {
|
|
238
|
+
throw new Error('The page is still parsing its document; retry in a moment.')
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// ---------------------------------------------------------------------
|
|
243
|
+
// Guest scripts (each self-contained: serialized with fn.toString())
|
|
244
|
+
// ---------------------------------------------------------------------
|
|
245
|
+
|
|
246
|
+
/** @returns page identity, viewport and scroll facts. */
|
|
247
|
+
function guestPageInfo() {
|
|
248
|
+
return {
|
|
249
|
+
url: location.href,
|
|
250
|
+
title: document.title,
|
|
251
|
+
readyState: document.readyState,
|
|
252
|
+
viewport: { width: innerWidth, height: innerHeight },
|
|
253
|
+
scroll: { x: Math.round(scrollX), y: Math.round(scrollY), height: document.documentElement.scrollHeight },
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Read visible text of the body or one element.
|
|
259
|
+
* @param arg - `{ selector?, maxChars? }`.
|
|
260
|
+
*/
|
|
261
|
+
function guestReadText(arg) {
|
|
262
|
+
const options = arg || {}
|
|
263
|
+
const target = options.selector ? document.querySelector(options.selector) : document.body
|
|
264
|
+
if (target === null) return { ok: false, error: `selector not found: ${options.selector}` }
|
|
265
|
+
const text = String(target.innerText ?? '').replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim()
|
|
266
|
+
const max = typeof options.maxChars === 'number' ? options.maxChars : 12000
|
|
267
|
+
return {
|
|
268
|
+
ok: true,
|
|
269
|
+
url: location.href,
|
|
270
|
+
title: document.title,
|
|
271
|
+
text: text.slice(0, max),
|
|
272
|
+
totalChars: text.length,
|
|
273
|
+
truncated: text.length > max,
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Read outer HTML of the document or one element.
|
|
279
|
+
* @param arg - `{ selector?, maxChars? }`.
|
|
280
|
+
*/
|
|
281
|
+
function guestReadHtml(arg) {
|
|
282
|
+
const options = arg || {}
|
|
283
|
+
const target = options.selector ? document.querySelector(options.selector) : document.documentElement
|
|
284
|
+
if (target === null) return { ok: false, error: `selector not found: ${options.selector}` }
|
|
285
|
+
const html = String(target.outerHTML ?? '')
|
|
286
|
+
const max = typeof options.maxChars === 'number' ? options.maxChars : 30000
|
|
287
|
+
return {
|
|
288
|
+
ok: true,
|
|
289
|
+
url: location.href,
|
|
290
|
+
title: document.title,
|
|
291
|
+
html: html.slice(0, max),
|
|
292
|
+
totalChars: html.length,
|
|
293
|
+
truncated: html.length > max,
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* List visible interactive elements with stable selectors and viewport centres.
|
|
299
|
+
* @param arg - `{ maxElements? }`.
|
|
300
|
+
*/
|
|
301
|
+
function guestInteractive(arg) {
|
|
302
|
+
const options = arg || {}
|
|
303
|
+
const max = typeof options.maxElements === 'number' ? options.maxElements : 200
|
|
304
|
+
const selector = 'a[href],button,input,select,textarea,summary,label,[role="button"],[role="link"],[role="checkbox"],[role="radio"],[role="tab"],[role="menuitem"],[role="switch"],[contenteditable="true"],[onclick]'
|
|
305
|
+
function visible(el) {
|
|
306
|
+
const rect = el.getBoundingClientRect()
|
|
307
|
+
if (rect.width < 1 || rect.height < 1) return false
|
|
308
|
+
const style = getComputedStyle(el)
|
|
309
|
+
if (style.visibility === 'hidden' || style.display === 'none') return false
|
|
310
|
+
if (Number(style.opacity) === 0) return false
|
|
311
|
+
return true
|
|
312
|
+
}
|
|
313
|
+
function labelOf(el) {
|
|
314
|
+
const raw = el.getAttribute('aria-label')
|
|
315
|
+
|| el.getAttribute('title')
|
|
316
|
+
|| el.getAttribute('placeholder')
|
|
317
|
+
|| (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.tagName === 'SELECT' ? el.value : '')
|
|
318
|
+
|| el.innerText
|
|
319
|
+
|| ''
|
|
320
|
+
return String(raw).trim().replace(/\s+/g, ' ').slice(0, 120)
|
|
321
|
+
}
|
|
322
|
+
function cssPath(el) {
|
|
323
|
+
if (el.id !== '') {
|
|
324
|
+
const byId = '#' + CSS.escape(el.id)
|
|
325
|
+
try {
|
|
326
|
+
if (document.querySelectorAll(byId).length === 1) return byId
|
|
327
|
+
} catch (error) {
|
|
328
|
+
void error
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
const parts = []
|
|
332
|
+
let node = el
|
|
333
|
+
while (node !== null && node !== document.documentElement && parts.length < 8) {
|
|
334
|
+
let part = node.tagName.toLowerCase()
|
|
335
|
+
const parent = node.parentElement
|
|
336
|
+
if (parent !== null) {
|
|
337
|
+
const sameTag = []
|
|
338
|
+
for (const child of parent.children) if (child.tagName === node.tagName) sameTag.push(child)
|
|
339
|
+
const index = sameTag.indexOf(node)
|
|
340
|
+
if (sameTag.length > 1 && index >= 0) part += `:nth-of-type(${index + 1})`
|
|
341
|
+
}
|
|
342
|
+
parts.unshift(part)
|
|
343
|
+
if (parent === null) break
|
|
344
|
+
const candidate = parts.join(' > ')
|
|
345
|
+
try {
|
|
346
|
+
if (document.querySelectorAll(candidate).length === 1) return candidate
|
|
347
|
+
} catch (error) {
|
|
348
|
+
void error
|
|
349
|
+
}
|
|
350
|
+
node = parent
|
|
351
|
+
}
|
|
352
|
+
return parts.join(' > ')
|
|
353
|
+
}
|
|
354
|
+
const elements = []
|
|
355
|
+
const seen = new Set()
|
|
356
|
+
for (const el of document.querySelectorAll(selector)) {
|
|
357
|
+
if (seen.has(el)) continue
|
|
358
|
+
seen.add(el)
|
|
359
|
+
if (!visible(el)) continue
|
|
360
|
+
const rect = el.getBoundingClientRect()
|
|
361
|
+
const tag = el.tagName.toLowerCase()
|
|
362
|
+
const type = el.getAttribute('type') || ''
|
|
363
|
+
const label = labelOf(el)
|
|
364
|
+
const selectorText = cssPath(el)
|
|
365
|
+
const entry = {
|
|
366
|
+
index: elements.length,
|
|
367
|
+
tag,
|
|
368
|
+
x: Math.round(rect.left + rect.width / 2),
|
|
369
|
+
y: Math.round(rect.top + rect.height / 2),
|
|
370
|
+
}
|
|
371
|
+
if (type !== '') entry.type = type
|
|
372
|
+
if (label !== '') entry.label = label
|
|
373
|
+
if (selectorText !== '') entry.selector = selectorText
|
|
374
|
+
if (tag === 'a' && typeof el.href === 'string' && el.href !== '') entry.href = el.href.slice(0, 300)
|
|
375
|
+
if (el.disabled === true || el.getAttribute('aria-disabled') === 'true') entry.disabled = true
|
|
376
|
+
elements.push(entry)
|
|
377
|
+
if (elements.length >= max) break
|
|
378
|
+
}
|
|
379
|
+
return {
|
|
380
|
+
ok: true,
|
|
381
|
+
url: location.href,
|
|
382
|
+
title: document.title,
|
|
383
|
+
viewport: { width: innerWidth, height: innerHeight },
|
|
384
|
+
scroll: { x: Math.round(scrollX), y: Math.round(scrollY), height: document.documentElement.scrollHeight },
|
|
385
|
+
elements,
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Resolve a click target to viewport coordinates.
|
|
391
|
+
* @param arg - `{ selector?, contains?, x?, y? }`.
|
|
392
|
+
*/
|
|
393
|
+
function guestResolveClickable(arg) {
|
|
394
|
+
const options = arg || {}
|
|
395
|
+
function visible(el) {
|
|
396
|
+
const rect = el.getBoundingClientRect()
|
|
397
|
+
if (rect.width < 1 || rect.height < 1) return false
|
|
398
|
+
const style = getComputedStyle(el)
|
|
399
|
+
return style.visibility !== 'hidden' && style.display !== 'none'
|
|
400
|
+
}
|
|
401
|
+
function labelOf(el) {
|
|
402
|
+
const raw = el.getAttribute('aria-label')
|
|
403
|
+
|| el.getAttribute('title')
|
|
404
|
+
|| el.getAttribute('placeholder')
|
|
405
|
+
|| (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' ? el.value : '')
|
|
406
|
+
|| el.innerText
|
|
407
|
+
|| ''
|
|
408
|
+
return String(raw).trim().replace(/\s+/g, ' ')
|
|
409
|
+
}
|
|
410
|
+
if (typeof options.x === 'number' && typeof options.y === 'number') {
|
|
411
|
+
const hit = document.elementFromPoint(options.x, options.y)
|
|
412
|
+
if (hit === null) return { ok: false, error: 'nothing is rendered at those coordinates' }
|
|
413
|
+
return {
|
|
414
|
+
ok: true,
|
|
415
|
+
x: Math.round(options.x),
|
|
416
|
+
y: Math.round(options.y),
|
|
417
|
+
tag: hit.tagName.toLowerCase(),
|
|
418
|
+
label: labelOf(hit).slice(0, 120),
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
const clickable = 'a[href],button,input,select,textarea,summary,label,[role="button"],[role="link"],[role="checkbox"],[role="radio"],[role="tab"],[role="menuitem"],[role="switch"],[contenteditable="true"],[onclick]'
|
|
422
|
+
let target = null
|
|
423
|
+
if (typeof options.selector === 'string' && options.selector !== '') {
|
|
424
|
+
target = document.querySelector(options.selector)
|
|
425
|
+
if (target === null) return { ok: false, error: `selector not found: ${options.selector}` }
|
|
426
|
+
} else if (typeof options.contains === 'string' && options.contains !== '') {
|
|
427
|
+
const needle = options.contains.toLowerCase()
|
|
428
|
+
for (const el of document.querySelectorAll(clickable)) {
|
|
429
|
+
if (!visible(el)) continue
|
|
430
|
+
if (labelOf(el).toLowerCase().includes(needle)) {
|
|
431
|
+
target = el
|
|
432
|
+
break
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
if (target === null) return { ok: false, error: `no visible clickable element contains "${options.contains}"` }
|
|
436
|
+
} else {
|
|
437
|
+
return { ok: false, error: 'click needs a selector, a contains text, or x/y coordinates' }
|
|
438
|
+
}
|
|
439
|
+
target.scrollIntoView({ block: 'center', inline: 'center' })
|
|
440
|
+
const rect = target.getBoundingClientRect()
|
|
441
|
+
if (rect.width < 1 || rect.height < 1) return { ok: false, error: 'the target has no visible box' }
|
|
442
|
+
return {
|
|
443
|
+
ok: true,
|
|
444
|
+
x: Math.round(rect.left + rect.width / 2),
|
|
445
|
+
y: Math.round(rect.top + rect.height / 2),
|
|
446
|
+
tag: target.tagName.toLowerCase(),
|
|
447
|
+
label: labelOf(target).slice(0, 120),
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* Focus a form field and write text into it, dispatching real input events.
|
|
453
|
+
* @param arg - `{ selector?, text, clear? }`.
|
|
454
|
+
*/
|
|
455
|
+
function guestFocusAndType(arg) {
|
|
456
|
+
const options = arg || {}
|
|
457
|
+
let el = null
|
|
458
|
+
if (typeof options.selector === 'string' && options.selector !== '') {
|
|
459
|
+
el = document.querySelector(options.selector)
|
|
460
|
+
if (el === null) return { ok: false, error: `selector not found: ${options.selector}` }
|
|
461
|
+
} else {
|
|
462
|
+
el = document.activeElement
|
|
463
|
+
if (el === null || el === document.body || el === document.documentElement) {
|
|
464
|
+
return { ok: false, error: 'no field is focused; pass a selector' }
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
el.scrollIntoView({ block: 'center' })
|
|
468
|
+
if (typeof el.focus === 'function') el.focus()
|
|
469
|
+
const text = String(options.text === undefined ? '' : options.text)
|
|
470
|
+
const clear = options.clear !== false
|
|
471
|
+
if (el.isContentEditable === true) {
|
|
472
|
+
const selection = window.getSelection()
|
|
473
|
+
if (selection !== null) {
|
|
474
|
+
const range = document.createRange()
|
|
475
|
+
range.selectNodeContents(el)
|
|
476
|
+
selection.removeAllRanges()
|
|
477
|
+
selection.addRange(range)
|
|
478
|
+
}
|
|
479
|
+
if (clear) document.execCommand('delete', false)
|
|
480
|
+
document.execCommand('insertText', false, text)
|
|
481
|
+
} else {
|
|
482
|
+
const prototype = el instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype
|
|
483
|
+
const descriptor = Object.getOwnPropertyDescriptor(prototype, 'value')
|
|
484
|
+
const next = (clear ? '' : String(el.value ?? '')) + text
|
|
485
|
+
if (descriptor !== undefined && typeof descriptor.set === 'function') descriptor.set.call(el, next)
|
|
486
|
+
else el.value = next
|
|
487
|
+
el.dispatchEvent(new Event('input', { bubbles: true }))
|
|
488
|
+
el.dispatchEvent(new Event('change', { bubbles: true }))
|
|
489
|
+
}
|
|
490
|
+
return {
|
|
491
|
+
ok: true,
|
|
492
|
+
tag: el.tagName.toLowerCase(),
|
|
493
|
+
valueLength: String(el.value === undefined ? el.textContent ?? '' : el.value).length,
|
|
494
|
+
url: location.href,
|
|
495
|
+
title: document.title,
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* Scroll the page or bring one element into view.
|
|
501
|
+
* @param arg - `{ dx?, dy?, selector? }`.
|
|
502
|
+
*/
|
|
503
|
+
function guestScroll(arg) {
|
|
504
|
+
const options = arg || {}
|
|
505
|
+
if (typeof options.selector === 'string' && options.selector !== '') {
|
|
506
|
+
const el = document.querySelector(options.selector)
|
|
507
|
+
if (el === null) return { ok: false, error: `selector not found: ${options.selector}` }
|
|
508
|
+
el.scrollIntoView({ block: 'center', inline: 'nearest' })
|
|
509
|
+
} else {
|
|
510
|
+
scrollBy({ left: Number(options.dx) || 0, top: Number(options.dy) || 0, behavior: 'auto' })
|
|
511
|
+
}
|
|
512
|
+
return {
|
|
513
|
+
ok: true,
|
|
514
|
+
url: location.href,
|
|
515
|
+
title: document.title,
|
|
516
|
+
scrollX: Math.round(scrollX),
|
|
517
|
+
scrollY: Math.round(scrollY),
|
|
518
|
+
scrollHeight: document.documentElement.scrollHeight,
|
|
519
|
+
viewportHeight: innerHeight,
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* Poll the page for a selector or visible text.
|
|
525
|
+
* @param arg - `{ selector?, contains?, timeoutMs? }`.
|
|
526
|
+
*/
|
|
527
|
+
function guestWait(arg) {
|
|
528
|
+
const options = arg || {}
|
|
529
|
+
const timeout = typeof options.timeoutMs === 'number' ? Math.min(Math.max(options.timeoutMs, 100), 15000) : 5000
|
|
530
|
+
const started = performance.now()
|
|
531
|
+
const selector = typeof options.selector === 'string' ? options.selector : ''
|
|
532
|
+
const contains = typeof options.contains === 'string' ? options.contains.toLowerCase() : ''
|
|
533
|
+
return new Promise((resolve) => {
|
|
534
|
+
function check() {
|
|
535
|
+
const elapsed = Math.round(performance.now() - started)
|
|
536
|
+
if (selector !== '') {
|
|
537
|
+
let found = null
|
|
538
|
+
try {
|
|
539
|
+
found = document.querySelector(selector)
|
|
540
|
+
} catch (error) {
|
|
541
|
+
resolve({ ok: false, error: `invalid selector: ${String(error && error.message ? error.message : error)}` })
|
|
542
|
+
return
|
|
543
|
+
}
|
|
544
|
+
if (found !== null) {
|
|
545
|
+
found.scrollIntoView({ block: 'center' })
|
|
546
|
+
resolve({ ok: true, matched: true, waitedMs: elapsed, selector, url: location.href, title: document.title })
|
|
547
|
+
return
|
|
548
|
+
}
|
|
549
|
+
} else if (contains !== '') {
|
|
550
|
+
const body = document.body
|
|
551
|
+
const text = body === null ? '' : String(body.innerText ?? '').toLowerCase()
|
|
552
|
+
if (text.includes(contains)) {
|
|
553
|
+
resolve({ ok: true, matched: true, waitedMs: elapsed, url: location.href, title: document.title })
|
|
554
|
+
return
|
|
555
|
+
}
|
|
556
|
+
} else {
|
|
557
|
+
resolve({ ok: false, error: 'wait needs a selector or contains text' })
|
|
558
|
+
return
|
|
559
|
+
}
|
|
560
|
+
if (performance.now() - started >= timeout) {
|
|
561
|
+
resolve({ ok: true, matched: false, waitedMs: elapsed, url: location.href, title: document.title })
|
|
562
|
+
return
|
|
563
|
+
}
|
|
564
|
+
setTimeout(check, 120)
|
|
565
|
+
}
|
|
566
|
+
check()
|
|
567
|
+
})
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// ---------------------------------------------------------------------
|
|
571
|
+
// Native input helpers (Electron guest)
|
|
572
|
+
// ---------------------------------------------------------------------
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* Dispatch one trusted native mouse click at guest viewport coordinates.
|
|
576
|
+
* @param webview - native guest element.
|
|
577
|
+
* @param point - `{ x, y }` CSS-pixel centre.
|
|
578
|
+
*/
|
|
579
|
+
async function nativeClick(webview, point) {
|
|
580
|
+
const x = Math.max(0, Math.round(point.x))
|
|
581
|
+
const y = Math.max(0, Math.round(point.y))
|
|
582
|
+
if (typeof webview.focus === 'function') webview.focus()
|
|
583
|
+
webview.sendInputEvent({ type: 'mouseMove', x, y })
|
|
584
|
+
await sleep(30)
|
|
585
|
+
webview.sendInputEvent({ type: 'mouseDown', x, y, button: 'left', clickCount: 1 })
|
|
586
|
+
await sleep(20)
|
|
587
|
+
webview.sendInputEvent({ type: 'mouseUp', x, y, button: 'left', clickCount: 1 })
|
|
588
|
+
await sleep(60)
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/** Keys `press` accepts, mapped to Chromium's key codes. */
|
|
592
|
+
const KEY_CODES = {
|
|
593
|
+
enter: 'Enter',
|
|
594
|
+
return: 'Enter',
|
|
595
|
+
tab: 'Tab',
|
|
596
|
+
escape: 'Escape',
|
|
597
|
+
esc: 'Escape',
|
|
598
|
+
backspace: 'Backspace',
|
|
599
|
+
delete: 'Delete',
|
|
600
|
+
arrowup: 'ArrowUp',
|
|
601
|
+
arrowdown: 'ArrowDown',
|
|
602
|
+
arrowleft: 'ArrowLeft',
|
|
603
|
+
arrowright: 'ArrowRight',
|
|
604
|
+
pagedown: 'PageDown',
|
|
605
|
+
pageup: 'PageUp',
|
|
606
|
+
home: 'Home',
|
|
607
|
+
end: 'End',
|
|
608
|
+
space: 'Space',
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/**
|
|
612
|
+
* Dispatch one trusted native key press to the focused guest element.
|
|
613
|
+
* @param webview - native guest element.
|
|
614
|
+
* @param key - user-facing key name.
|
|
615
|
+
* @returns the Chromium key code used.
|
|
616
|
+
*/
|
|
617
|
+
async function nativeKey(webview, key) {
|
|
618
|
+
const raw = String(key).trim().toLowerCase()
|
|
619
|
+
const code = KEY_CODES[raw] ?? (raw.length === 1 ? raw : undefined)
|
|
620
|
+
if (code === undefined) throw new Error(`unsupported key "${key}"; use one of: ${Object.keys(KEY_CODES).join(', ')}`)
|
|
621
|
+
if (typeof webview.focus === 'function') webview.focus()
|
|
622
|
+
webview.sendInputEvent({ type: 'keyDown', keyCode: code })
|
|
623
|
+
if (code === 'Enter') webview.sendInputEvent({ type: 'char', keyCode: '\r' })
|
|
624
|
+
webview.sendInputEvent({ type: 'keyUp', keyCode: code })
|
|
625
|
+
await sleep(80)
|
|
626
|
+
return code
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
// ---------------------------------------------------------------------
|
|
630
|
+
// Screenshot
|
|
631
|
+
// ---------------------------------------------------------------------
|
|
632
|
+
|
|
633
|
+
/**
|
|
634
|
+
* Capture the guest viewport as a bounded PNG.
|
|
635
|
+
* @param webview - native guest element.
|
|
636
|
+
* @returns `{ base64, width, height, originalWidth, originalHeight }`.
|
|
637
|
+
*/
|
|
638
|
+
async function captureScreenshot(webview) {
|
|
639
|
+
if (typeof webview.capturePage !== 'function') throw new Error('the native guest exposes no capturePage API')
|
|
640
|
+
let image
|
|
641
|
+
try {
|
|
642
|
+
image = await withTimeout(
|
|
643
|
+
Promise.resolve().then(() => webview.capturePage()),
|
|
644
|
+
TIMEOUTS.capture,
|
|
645
|
+
'The screenshot',
|
|
646
|
+
)
|
|
647
|
+
} catch (error) {
|
|
648
|
+
if (messageOf(error).includes('did not finish within')) throw error
|
|
649
|
+
throw new Error(`The native page could not be captured (${messageOf(error)}). Chromium cannot capture a guest whose conversation is hidden; switch back to the calling conversation or focus its browser tab, then retry the screenshot.`)
|
|
650
|
+
}
|
|
651
|
+
const size = image.getSize()
|
|
652
|
+
let output = image
|
|
653
|
+
let width = size.width
|
|
654
|
+
const widths = [MAX_SCREENSHOT_WIDTH, 1200, 1000, 800]
|
|
655
|
+
for (const candidate of widths) {
|
|
656
|
+
if (width <= candidate) break
|
|
657
|
+
output = image.resize({ width: candidate, quality: 'good' })
|
|
658
|
+
width = output.getSize().width
|
|
659
|
+
const probe = output.toDataURL()
|
|
660
|
+
if (probe.length - probe.indexOf(',') - 1 <= MAX_SCREENSHOT_BASE64) break
|
|
661
|
+
}
|
|
662
|
+
const dataUrl = output.toDataURL()
|
|
663
|
+
const base64 = dataUrl.slice(dataUrl.indexOf(',') + 1)
|
|
664
|
+
if (base64.length === 0) throw new Error('the native guest returned an empty screenshot')
|
|
665
|
+
if (base64.length > MAX_SCREENSHOT_BASE64) throw new Error('the screenshot is too large to transfer; scroll or zoom out and retry')
|
|
666
|
+
const finalSize = output.getSize()
|
|
667
|
+
return {
|
|
668
|
+
base64,
|
|
669
|
+
width: finalSize.width,
|
|
670
|
+
height: finalSize.height,
|
|
671
|
+
originalWidth: size.width,
|
|
672
|
+
originalHeight: size.height,
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
// ---------------------------------------------------------------------
|
|
677
|
+
// Sidebar state helpers
|
|
678
|
+
// ---------------------------------------------------------------------
|
|
679
|
+
|
|
680
|
+
/**
|
|
681
|
+
* @param ctx - client plugin context.
|
|
682
|
+
* @returns the session the right sidebar currently shows, if any.
|
|
683
|
+
*/
|
|
684
|
+
function mountedSession(ctx) {
|
|
685
|
+
try {
|
|
686
|
+
const value = ctx.sidebarRight?.mounted?.getSnapshot?.()
|
|
687
|
+
return typeof value === 'string' && value !== '' ? value : undefined
|
|
688
|
+
} catch {
|
|
689
|
+
return undefined
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
/**
|
|
694
|
+
* @param ctx - client plugin context.
|
|
695
|
+
* @returns every known open tab record (all sessions).
|
|
696
|
+
*/
|
|
697
|
+
function allOpenTabs(ctx) {
|
|
698
|
+
try {
|
|
699
|
+
const value = ctx.sidebarRight?.openTabs?.getSnapshot?.()
|
|
700
|
+
return Array.isArray(value) ? value : []
|
|
701
|
+
} catch {
|
|
702
|
+
return []
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/**
|
|
707
|
+
* @param ctx - client plugin context.
|
|
708
|
+
* @param sessionId - session to filter by.
|
|
709
|
+
* @returns that session's native browser tabs.
|
|
710
|
+
*/
|
|
711
|
+
function browserTabs(ctx, sessionId) {
|
|
712
|
+
return allOpenTabs(ctx).filter((tab) => tab !== null && typeof tab === 'object' && tab.kind === 'browser' && tab.sessionId === sessionId)
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
/**
|
|
716
|
+
* Require the calling session to be the one on screen.
|
|
717
|
+
* @param ctx - client plugin context.
|
|
718
|
+
* @param sessionId - calling conversation.
|
|
719
|
+
* @returns the on-screen session.
|
|
720
|
+
*/
|
|
721
|
+
function requireMounted(ctx, sessionId) {
|
|
722
|
+
const mounted = mountedSession(ctx)
|
|
723
|
+
if (mounted === undefined) {
|
|
724
|
+
throw new Error('The right sidebar has no conversation on screen; open the calling conversation and retry.')
|
|
725
|
+
}
|
|
726
|
+
if (mounted !== sessionId) {
|
|
727
|
+
throw new Error(`The right sidebar is showing conversation ${mounted}, not the calling conversation ${sessionId}; switch to the calling conversation and retry.`)
|
|
728
|
+
}
|
|
729
|
+
return mounted
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
/**
|
|
733
|
+
* Require the calling conversation's sidebar surface to exist in this page.
|
|
734
|
+
* The surface stays mounted (hidden) while another conversation is on
|
|
735
|
+
* screen, so page work continues in the background.
|
|
736
|
+
* @param sessionId - calling conversation.
|
|
737
|
+
*/
|
|
738
|
+
function assertPageSurface(sessionId) {
|
|
739
|
+
if (sessionRootOf(sessionId) === undefined) {
|
|
740
|
+
throw new Error(`The calling conversation ${sessionId} has no right-sidebar surface in this page yet; switch to that conversation once so its sidebar initializes, then retry.`)
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
/**
|
|
745
|
+
* Resolve a tab the tool named and prove it belongs to the calling session.
|
|
746
|
+
* @param ctx - client plugin context.
|
|
747
|
+
* @param sessionId - calling conversation.
|
|
748
|
+
* @param tabId - model-supplied tab id, or empty for the active tab.
|
|
749
|
+
* @returns the matching open-tab record.
|
|
750
|
+
*/
|
|
751
|
+
function resolveTab(ctx, sessionId, tabId) {
|
|
752
|
+
const tabs = browserTabs(ctx, sessionId)
|
|
753
|
+
if (tabs.length === 0) {
|
|
754
|
+
throw new Error('No native browser tab is open in this conversation; open one with sidebar_browser action "open" first.')
|
|
755
|
+
}
|
|
756
|
+
if (typeof tabId === 'string' && tabId !== '') {
|
|
757
|
+
const tab = tabs.find((candidate) => candidate.tabId === tabId)
|
|
758
|
+
if (tab === undefined) {
|
|
759
|
+
throw new Error(`No native browser tab "${tabId}" is open for this conversation; call sidebar_browser action "list" to see the open tabs.`)
|
|
760
|
+
}
|
|
761
|
+
return tab
|
|
762
|
+
}
|
|
763
|
+
if (mountedSession(ctx) === sessionId) {
|
|
764
|
+
const active = activeTabId(ctx)
|
|
765
|
+
const activeTab = tabs.find((candidate) => candidate.tabId === active)
|
|
766
|
+
if (activeTab !== undefined) return activeTab
|
|
767
|
+
}
|
|
768
|
+
if (tabs.length === 1) return tabs[0]
|
|
769
|
+
throw new Error(`multiple browser tabs are open; pass tabId (${tabs.map((tab) => tab.tabId).join(', ')})`)
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
/**
|
|
773
|
+
* @param ctx - client plugin context.
|
|
774
|
+
* @returns the active tab id for the on-screen session, if any.
|
|
775
|
+
*/
|
|
776
|
+
function activeTabId(ctx) {
|
|
777
|
+
try {
|
|
778
|
+
const record = ctx.sidebarRight?.active?.()
|
|
779
|
+
return typeof record?.id === 'string' ? record.id : undefined
|
|
780
|
+
} catch {
|
|
781
|
+
return undefined
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
/**
|
|
786
|
+
* Best-effort native facts for one tab.
|
|
787
|
+
* @param sessionId - owning conversation.
|
|
788
|
+
* @param tabId - sidebar tab id.
|
|
789
|
+
* @returns `{ url?, title? }`.
|
|
790
|
+
*/
|
|
791
|
+
function nativeFacts(sessionId, tabId) {
|
|
792
|
+
const facts = {}
|
|
793
|
+
const webview = tryFindWebview(sessionId, tabId)
|
|
794
|
+
if (webview === undefined) return facts
|
|
795
|
+
try {
|
|
796
|
+
const url = typeof webview.getURL === 'function' ? webview.getURL() : ''
|
|
797
|
+
if (typeof url === 'string' && url !== '' && url !== 'about:blank') facts.url = url
|
|
798
|
+
} catch (error) {
|
|
799
|
+
void error
|
|
800
|
+
}
|
|
801
|
+
try {
|
|
802
|
+
const title = typeof webview.getTitle === 'function' ? webview.getTitle() : ''
|
|
803
|
+
if (typeof title === 'string' && title !== '') facts.title = title
|
|
804
|
+
} catch (error) {
|
|
805
|
+
void error
|
|
806
|
+
}
|
|
807
|
+
return facts
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
// ---------------------------------------------------------------------
|
|
811
|
+
// Transport relay helpers
|
|
812
|
+
// ---------------------------------------------------------------------
|
|
813
|
+
|
|
814
|
+
/**
|
|
815
|
+
* Viewport CSS-pixel point for one click request.
|
|
816
|
+
* @param webview - native guest.
|
|
817
|
+
* @param command - bridge command.
|
|
818
|
+
* @returns `{ x, y }` in guest viewport coordinates.
|
|
819
|
+
*/
|
|
820
|
+
async function resolveClickPoint(webview, command) {
|
|
821
|
+
const arg = {
|
|
822
|
+
...(typeof command.selector === 'string' && command.selector !== '' ? { selector: command.selector } : {}),
|
|
823
|
+
...(typeof command.contains === 'string' && command.contains !== '' ? { contains: command.contains } : {}),
|
|
824
|
+
...(typeof command.x === 'number' && typeof command.y === 'number' ? { x: command.x, y: command.y } : {}),
|
|
825
|
+
}
|
|
826
|
+
const resolved = await runInGuest(webview, guestResolveClickable, arg)
|
|
827
|
+
if (resolved === null || typeof resolved !== 'object' || resolved.ok !== true) {
|
|
828
|
+
throw new Error(resolved?.error ?? 'click target was not found')
|
|
829
|
+
}
|
|
830
|
+
return resolved
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
/**
|
|
834
|
+
* Execute one page-content command inside the native guest.
|
|
835
|
+
* @param ctx - client plugin context.
|
|
836
|
+
* @param command - bridge command.
|
|
837
|
+
* @returns bridge result `{ ok, value? , error? }`.
|
|
838
|
+
*/
|
|
839
|
+
async function runPageCommand(ctx, command) {
|
|
840
|
+
const sessionId = typeof command.sessionId === 'string' ? command.sessionId : ''
|
|
841
|
+
if (sessionId === '') throw new Error('the host did not name a calling session')
|
|
842
|
+
assertPageSurface(sessionId)
|
|
843
|
+
const carrier = detectCarrier()
|
|
844
|
+
if (carrier.native !== true) {
|
|
845
|
+
throw new Error(`Page content control needs the Desktop native browser carrier (Electron webview); this build uses ${carrier.kind === 'web-iframe' ? 'a sandboxed cross-origin iframe' : 'an unknown carrier'} that page script cannot inspect. Tab management still works; use web_fetch to read page text.`)
|
|
846
|
+
}
|
|
847
|
+
const tab = resolveTab(ctx, sessionId, command.tabId)
|
|
848
|
+
const webview = findWebview(sessionId, tab.tabId)
|
|
849
|
+
await ensureGuestReady(webview)
|
|
850
|
+
const base = { sessionId, tabId: tab.tabId }
|
|
851
|
+
|
|
852
|
+
if (command.action === 'screenshot') {
|
|
853
|
+
const shot = await captureScreenshot(webview)
|
|
854
|
+
const info = await runInGuest(webview, guestPageInfo).catch(() => ({}))
|
|
855
|
+
return {
|
|
856
|
+
ok: true,
|
|
857
|
+
value: {
|
|
858
|
+
action: 'screenshot',
|
|
859
|
+
...base,
|
|
860
|
+
url: typeof info.url === 'string' ? info.url : '',
|
|
861
|
+
title: typeof info.title === 'string' ? info.title : '',
|
|
862
|
+
width: shot.width,
|
|
863
|
+
height: shot.height,
|
|
864
|
+
mediaType: 'image/png',
|
|
865
|
+
base64: shot.base64,
|
|
866
|
+
},
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
if (command.action === 'read') {
|
|
871
|
+
const mode = typeof command.mode === 'string' ? command.mode : 'text'
|
|
872
|
+
if (mode === 'interactive') {
|
|
873
|
+
const info = await runInGuest(webview, guestInteractive, {
|
|
874
|
+
...(typeof command.maxElements === 'number' ? { maxElements: command.maxElements } : {}),
|
|
875
|
+
})
|
|
876
|
+
const { ok: _interactiveOk, ...facts } = info
|
|
877
|
+
void _interactiveOk
|
|
878
|
+
return { ok: true, value: { action: 'read', ...base, mode, ...facts } }
|
|
879
|
+
}
|
|
880
|
+
if (mode === 'html') {
|
|
881
|
+
const info = await runInGuest(webview, guestReadHtml, {
|
|
882
|
+
...(typeof command.selector === 'string' && command.selector !== '' ? { selector: command.selector } : {}),
|
|
883
|
+
...(typeof command.maxChars === 'number' ? { maxChars: command.maxChars } : {}),
|
|
884
|
+
})
|
|
885
|
+
if (info.ok !== true) throw new Error(info.error ?? 'could not read page HTML')
|
|
886
|
+
const { ok: _interactiveOk, ...facts } = info
|
|
887
|
+
void _interactiveOk
|
|
888
|
+
return { ok: true, value: { action: 'read', ...base, mode, ...facts } }
|
|
889
|
+
}
|
|
890
|
+
const info = await runInGuest(webview, guestReadText, {
|
|
891
|
+
...(typeof command.selector === 'string' && command.selector !== '' ? { selector: command.selector } : {}),
|
|
892
|
+
...(typeof command.maxChars === 'number' ? { maxChars: command.maxChars } : {}),
|
|
893
|
+
})
|
|
894
|
+
if (info.ok !== true) throw new Error(info.error ?? 'could not read page text')
|
|
895
|
+
const { ok: _textOk, ...facts } = info
|
|
896
|
+
void _textOk
|
|
897
|
+
return { ok: true, value: { action: 'read', ...base, mode: 'text', ...facts } }
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
if (command.action === 'click') {
|
|
901
|
+
const resolved = await resolveClickPoint(webview, command)
|
|
902
|
+
await nativeClick(webview, resolved)
|
|
903
|
+
const info = await runInGuest(webview, guestPageInfo).catch(() => ({}))
|
|
904
|
+
return {
|
|
905
|
+
ok: true,
|
|
906
|
+
value: {
|
|
907
|
+
action: 'click',
|
|
908
|
+
...base,
|
|
909
|
+
url: typeof info.url === 'string' ? info.url : '',
|
|
910
|
+
title: typeof info.title === 'string' ? info.title : '',
|
|
911
|
+
target: {
|
|
912
|
+
tag: resolved.tag,
|
|
913
|
+
...(typeof resolved.label === 'string' && resolved.label !== '' ? { label: resolved.label } : {}),
|
|
914
|
+
...(typeof resolved.selector === 'string' && resolved.selector !== '' ? { selector: resolved.selector } : {}),
|
|
915
|
+
x: resolved.x,
|
|
916
|
+
y: resolved.y,
|
|
917
|
+
},
|
|
918
|
+
},
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
if (command.action === 'type') {
|
|
923
|
+
const info = await runInGuest(webview, guestFocusAndType, {
|
|
924
|
+
...(typeof command.selector === 'string' && command.selector !== '' ? { selector: command.selector } : {}),
|
|
925
|
+
text: String(command.text === undefined ? '' : command.text),
|
|
926
|
+
clear: command.clear !== false,
|
|
927
|
+
})
|
|
928
|
+
if (info.ok !== true) throw new Error(info.error ?? 'could not type into the page')
|
|
929
|
+
let submitted = false
|
|
930
|
+
if (command.submit === true) {
|
|
931
|
+
await nativeKey(webview, 'Enter')
|
|
932
|
+
submitted = true
|
|
933
|
+
}
|
|
934
|
+
const page = await runInGuest(webview, guestPageInfo).catch(() => ({}))
|
|
935
|
+
return {
|
|
936
|
+
ok: true,
|
|
937
|
+
value: {
|
|
938
|
+
action: 'type',
|
|
939
|
+
...base,
|
|
940
|
+
url: typeof page.url === 'string' ? page.url : '',
|
|
941
|
+
title: typeof page.title === 'string' ? page.title : '',
|
|
942
|
+
target: { tag: info.tag, ...(typeof info.selector === 'string' && info.selector !== '' ? { selector: info.selector } : {}) },
|
|
943
|
+
typed: String(command.text === undefined ? '' : command.text).length,
|
|
944
|
+
submitted,
|
|
945
|
+
},
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
if (command.action === 'press') {
|
|
950
|
+
const code = await nativeKey(webview, command.key)
|
|
951
|
+
const info = await runInGuest(webview, guestPageInfo).catch(() => ({}))
|
|
952
|
+
return {
|
|
953
|
+
ok: true,
|
|
954
|
+
value: {
|
|
955
|
+
action: 'press',
|
|
956
|
+
...base,
|
|
957
|
+
url: typeof info.url === 'string' ? info.url : '',
|
|
958
|
+
title: typeof info.title === 'string' ? info.title : '',
|
|
959
|
+
key: code,
|
|
960
|
+
},
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
if (command.action === 'scroll') {
|
|
965
|
+
const info = await runInGuest(webview, guestScroll, {
|
|
966
|
+
...(typeof command.dx === 'number' ? { dx: command.dx } : {}),
|
|
967
|
+
...(typeof command.dy === 'number' ? { dy: command.dy } : {}),
|
|
968
|
+
...(typeof command.selector === 'string' && command.selector !== '' ? { selector: command.selector } : {}),
|
|
969
|
+
})
|
|
970
|
+
if (info.ok !== true) throw new Error(info.error ?? 'could not scroll the page')
|
|
971
|
+
const { ok: _scrollOk, ...facts } = info
|
|
972
|
+
void _scrollOk
|
|
973
|
+
return { ok: true, value: { action: 'scroll', ...base, ...facts } }
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
if (command.action === 'wait') {
|
|
977
|
+
const info = await runInGuest(webview, guestWait, {
|
|
978
|
+
...(typeof command.selector === 'string' && command.selector !== '' ? { selector: command.selector } : {}),
|
|
979
|
+
...(typeof command.contains === 'string' && command.contains !== '' ? { contains: command.contains } : {}),
|
|
980
|
+
...(typeof command.timeoutMs === 'number' ? { timeoutMs: command.timeoutMs } : {}),
|
|
981
|
+
})
|
|
982
|
+
if (info.ok !== true) throw new Error(info.error ?? 'wait failed')
|
|
983
|
+
const page = await runInGuest(webview, guestPageInfo).catch(() => ({}))
|
|
984
|
+
return {
|
|
985
|
+
ok: true,
|
|
986
|
+
value: {
|
|
987
|
+
action: 'wait',
|
|
988
|
+
...base,
|
|
989
|
+
url: typeof page.url === 'string' ? page.url : '',
|
|
990
|
+
title: typeof page.title === 'string' ? page.title : '',
|
|
991
|
+
matched: info.matched === true,
|
|
992
|
+
waitedMs: typeof info.waitedMs === 'number' ? info.waitedMs : 0,
|
|
993
|
+
...(typeof command.selector === 'string' && command.selector !== '' ? { selector: command.selector } : {}),
|
|
994
|
+
...(typeof command.contains === 'string' && command.contains !== '' ? { contains: command.contains } : {}),
|
|
995
|
+
},
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
if (command.action === 'eval') {
|
|
1000
|
+
const code = typeof command.code === 'string' ? command.code : ''
|
|
1001
|
+
if (code.trim() === '') throw new Error('eval needs a non-empty JavaScript code string')
|
|
1002
|
+
let raw
|
|
1003
|
+
try {
|
|
1004
|
+
raw = await withTimeout(
|
|
1005
|
+
Promise.resolve().then(() => webview.executeJavaScript(code, true)),
|
|
1006
|
+
TIMEOUTS.guest,
|
|
1007
|
+
'The page script',
|
|
1008
|
+
)
|
|
1009
|
+
} catch (error) {
|
|
1010
|
+
if (messageOf(error).includes('did not finish within')) throw error
|
|
1011
|
+
throw new Error(`guest JavaScript failed: ${messageOf(error)}`)
|
|
1012
|
+
}
|
|
1013
|
+
let text
|
|
1014
|
+
try {
|
|
1015
|
+
const serialized = JSON.stringify(raw)
|
|
1016
|
+
text = serialized === undefined ? String(raw) : serialized
|
|
1017
|
+
} catch (error) {
|
|
1018
|
+
text = String(raw)
|
|
1019
|
+
void error
|
|
1020
|
+
}
|
|
1021
|
+
const truncated = text.length > MAX_EVAL_CHARS
|
|
1022
|
+
const info = await runInGuest(webview, guestPageInfo).catch(() => ({}))
|
|
1023
|
+
return {
|
|
1024
|
+
ok: true,
|
|
1025
|
+
value: {
|
|
1026
|
+
action: 'eval',
|
|
1027
|
+
...base,
|
|
1028
|
+
url: typeof info.url === 'string' ? info.url : '',
|
|
1029
|
+
title: typeof info.title === 'string' ? info.title : '',
|
|
1030
|
+
result: truncated ? `${text.slice(0, MAX_EVAL_CHARS)} … (truncated)` : text,
|
|
1031
|
+
truncated,
|
|
1032
|
+
},
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
throw new Error(`unknown page action "${String(command.action)}"`)
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
/**
|
|
1040
|
+
* Collect carrier and contract diagnostics for the `status` action.
|
|
1041
|
+
* @param ctx - client plugin context.
|
|
1042
|
+
* @param sessionId - calling conversation ('' when unknown).
|
|
1043
|
+
* @returns JSON-safe diagnostic facts.
|
|
1044
|
+
*/
|
|
1045
|
+
function carrierChecks(ctx, sessionId) {
|
|
1046
|
+
const carrier = detectCarrier()
|
|
1047
|
+
const checks = {
|
|
1048
|
+
plugin: PLUGIN_VERSION,
|
|
1049
|
+
carrier: carrier.kind,
|
|
1050
|
+
native: carrier.native === true,
|
|
1051
|
+
webviews: document.querySelectorAll('webview').length,
|
|
1052
|
+
tabWrappers: document.querySelectorAll('[data-sidebar-right-tab]').length,
|
|
1053
|
+
}
|
|
1054
|
+
try {
|
|
1055
|
+
const configForms = ctx.get?.('configForms')
|
|
1056
|
+
const form = configForms?.get?.(CONFIG_ENTRY_ID)
|
|
1057
|
+
const snapshot = form?.getSnapshot?.()
|
|
1058
|
+
if (snapshot !== undefined) checks.configForm = snapshot.status
|
|
1059
|
+
} catch (error) {
|
|
1060
|
+
void error
|
|
1061
|
+
}
|
|
1062
|
+
const boot = globalThis.__DSH_BOOT__
|
|
1063
|
+
if (boot !== null && typeof boot === 'object' && boot.release !== null && typeof boot.release === 'object') {
|
|
1064
|
+
checks.harness = typeof boot.release.version === 'string' ? boot.release.version : 'unknown'
|
|
1065
|
+
if (typeof boot.release.commit === 'string') checks.commit = boot.release.commit
|
|
1066
|
+
}
|
|
1067
|
+
const mounted = mountedSession(ctx)
|
|
1068
|
+
const target = sessionId !== '' ? sessionId : (mounted ?? '')
|
|
1069
|
+
const surface = target === '' ? undefined : sessionRootOf(target)
|
|
1070
|
+
checks.surface = surface !== undefined
|
|
1071
|
+
if (surface !== undefined) {
|
|
1072
|
+
const wrappers = surface.querySelectorAll('[data-sidebar-right-tab]')
|
|
1073
|
+
checks.tabWrappers = wrappers.length
|
|
1074
|
+
let webview = null
|
|
1075
|
+
for (const wrapper of wrappers) {
|
|
1076
|
+
const found = wrapper.querySelector('webview[data-sidebar-browser-frame]')
|
|
1077
|
+
if (found !== null) {
|
|
1078
|
+
webview = found
|
|
1079
|
+
break
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
checks.webview = webview !== null
|
|
1083
|
+
if (webview !== null) {
|
|
1084
|
+
const api = ['executeJavaScript', 'capturePage', 'sendInputEvent', 'loadURL', 'reload', 'getURL', 'getTitle']
|
|
1085
|
+
checks.api = api.filter((name) => typeof webview[name] === 'function')
|
|
1086
|
+
const missing = api.filter((name) => typeof webview[name] !== 'function')
|
|
1087
|
+
if (missing.length > 0) checks.apiMissing = missing
|
|
1088
|
+
try {
|
|
1089
|
+
const url = typeof webview.getURL === 'function' ? webview.getURL() : ''
|
|
1090
|
+
if (typeof url === 'string' && url !== '') checks.guestUrl = url
|
|
1091
|
+
} catch (error) {
|
|
1092
|
+
void error
|
|
1093
|
+
}
|
|
1094
|
+
try {
|
|
1095
|
+
checks.guestLoading = typeof webview.isLoading === 'function' ? webview.isLoading() === true : false
|
|
1096
|
+
} catch (error) {
|
|
1097
|
+
void error
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
return checks
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
// ---------------------------------------------------------------------
|
|
1105
|
+
// Plugin row configuration page (Plugin Manager -> bundle -> row)
|
|
1106
|
+
// ---------------------------------------------------------------------
|
|
1107
|
+
|
|
1108
|
+
/** Locale namespace for this plugin's configuration page copy. */
|
|
1109
|
+
const UI_NS = 'sidebarBrowserAgent'
|
|
1110
|
+
/** Row this plugin's Host row is declared under (used as the settings namespace). */
|
|
1111
|
+
const CONFIG_ENTRY_ID = 'sidebar-browser-agent'
|
|
1112
|
+
/** Config page slot key: `<bundle package>#<row id>`. */
|
|
1113
|
+
const CONFIG_ROW_KEY = 'dsh-sidebar-browser-agent#sidebar-browser-agent'
|
|
1114
|
+
|
|
1115
|
+
/** English copy for the configuration page. */
|
|
1116
|
+
const CONFIG_EN = {
|
|
1117
|
+
'config.summary': 'Configure the sidebar browser tools: enabled switches, allowed hosts and permissions.',
|
|
1118
|
+
'config.heading': 'Sidebar browser agent',
|
|
1119
|
+
'config.intro': 'Applies immediately to this profile. Page content work needs the DSH Desktop native browser carrier.',
|
|
1120
|
+
'config.enabled': 'Enable agent tools',
|
|
1121
|
+
'config.enabledHint': 'Register sidebar_browser and sidebar_page for agents in this profile.',
|
|
1122
|
+
'config.hosts': 'Allowed hosts',
|
|
1123
|
+
'config.hostsHint': 'One host per line; empty allows every HTTP(S) host. An entry also allows its subdomains; localhost:8765 matches that exact host and port.',
|
|
1124
|
+
'config.hostsPlaceholder': 'example.com\nlocalhost:8765',
|
|
1125
|
+
'config.allowEval': 'Allow eval',
|
|
1126
|
+
'config.allowEvalHint': 'Run arbitrary JavaScript in the page (sidebar_page action "eval").',
|
|
1127
|
+
'config.allowNativeInput': 'Allow native input',
|
|
1128
|
+
'config.allowNativeInputHint': 'Native click, type and press actions (sidebar_page click/type/press).',
|
|
1129
|
+
'config.save': 'Save',
|
|
1130
|
+
'config.saving': 'Saving…',
|
|
1131
|
+
'config.saved': 'Saved.',
|
|
1132
|
+
'config.failed': 'The host rejected this change; the form was reloaded.',
|
|
1133
|
+
'config.reset': 'Reset',
|
|
1134
|
+
'config.loading': 'Loading configuration…',
|
|
1135
|
+
'config.unavailable': 'Configuration is not editable in this profile (the settings service is unavailable).',
|
|
1136
|
+
'config.readOnly': 'This profile is read-only.',
|
|
1137
|
+
'config.readOnlyHint': 'The host does not accept configuration writes here.',
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
/** Chinese copy for the configuration page. */
|
|
1141
|
+
const CONFIG_ZH = {
|
|
1142
|
+
'config.summary': '配置侧栏浏览器工具:启用开关、域名白名单与权限。',
|
|
1143
|
+
'config.heading': '侧栏浏览器 Agent 控制',
|
|
1144
|
+
'config.intro': '修改立即对本 profile 生效。页面内容操作需要 DSH Desktop 原生浏览器载体。',
|
|
1145
|
+
'config.enabled': '启用 Agent 工具',
|
|
1146
|
+
'config.enabledHint': '为本 profile 中的 Agent 注册 sidebar_browser 与 sidebar_page。',
|
|
1147
|
+
'config.hosts': '允许的域名',
|
|
1148
|
+
'config.hostsHint': '每行一个;留空允许所有 HTTP(S) 域名。写 example.com 同时允许其子域;localhost:8765 精确匹配该主机与端口。',
|
|
1149
|
+
'config.hostsPlaceholder': 'example.com\nlocalhost:8765',
|
|
1150
|
+
'config.allowEval': '允许 eval',
|
|
1151
|
+
'config.allowEvalHint': '允许在页面中执行任意 JavaScript(sidebar_page 的 eval 动作)。',
|
|
1152
|
+
'config.allowNativeInput': '允许原生输入',
|
|
1153
|
+
'config.allowNativeInputHint': '允许原生点击、输入与按键(sidebar_page 的 click/type/press)。',
|
|
1154
|
+
'config.save': '保存',
|
|
1155
|
+
'config.saving': '正在保存…',
|
|
1156
|
+
'config.saved': '已保存。',
|
|
1157
|
+
'config.failed': 'Host 拒绝了这次修改,表单已重新加载。',
|
|
1158
|
+
'config.reset': '重置',
|
|
1159
|
+
'config.loading': '正在加载配置…',
|
|
1160
|
+
'config.unavailable': '此 profile 无法编辑配置(settings 服务不可用)。',
|
|
1161
|
+
'config.readOnly': '此 profile 为只读。',
|
|
1162
|
+
'config.readOnlyHint': 'Host 不接受在此写入配置。',
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
const configStyles = {
|
|
1166
|
+
root: { display: 'flex', flexDirection: 'column', gap: '18px', maxWidth: '620px', color: 'var(--dsw-alias-label-primary)', fontSize: '13px', lineHeight: '20px' },
|
|
1167
|
+
heading: { margin: 0, fontSize: '15px', fontWeight: 600, lineHeight: '22px' },
|
|
1168
|
+
hint: { color: 'var(--dsw-alias-label-secondary)', margin: 0, fontSize: '12px', lineHeight: '18px' },
|
|
1169
|
+
notice: { color: 'var(--dsw-alias-label-secondary)', margin: 0, fontSize: '12px', lineHeight: '18px' },
|
|
1170
|
+
error: { color: 'var(--dsw-alias-state-error-primary)', margin: 0, fontSize: '12px', lineHeight: '18px' },
|
|
1171
|
+
field: { display: 'flex', flexDirection: 'column', gap: '4px' },
|
|
1172
|
+
checkRow: { display: 'flex', alignItems: 'flex-start', gap: '8px', cursor: 'pointer' },
|
|
1173
|
+
checkText: { display: 'flex', flexDirection: 'column', gap: '2px', minWidth: 0 },
|
|
1174
|
+
checkLabel: { fontWeight: 500 },
|
|
1175
|
+
textarea: {
|
|
1176
|
+
boxSizing: 'border-box',
|
|
1177
|
+
width: '100%',
|
|
1178
|
+
minHeight: '88px',
|
|
1179
|
+
padding: '8px 10px',
|
|
1180
|
+
color: 'var(--dsw-alias-label-primary)',
|
|
1181
|
+
background: 'var(--dsw-alias-bg-layer-1)',
|
|
1182
|
+
border: '0.5px solid var(--dsw-alias-border-l4)',
|
|
1183
|
+
borderRadius: '8px',
|
|
1184
|
+
font: 'inherit',
|
|
1185
|
+
resize: 'vertical',
|
|
1186
|
+
outline: 'none',
|
|
1187
|
+
},
|
|
1188
|
+
actions: { display: 'flex', alignItems: 'center', gap: '10px' },
|
|
1189
|
+
button: {
|
|
1190
|
+
height: '30px',
|
|
1191
|
+
padding: '0 14px',
|
|
1192
|
+
color: 'var(--dsw-alias-label-primary)',
|
|
1193
|
+
background: 'var(--dsw-alias-bg-layer-3)',
|
|
1194
|
+
border: '0.5px solid var(--dsw-alias-border-l4)',
|
|
1195
|
+
borderRadius: '8px',
|
|
1196
|
+
cursor: 'pointer',
|
|
1197
|
+
font: 'inherit',
|
|
1198
|
+
},
|
|
1199
|
+
primary: {
|
|
1200
|
+
height: '30px',
|
|
1201
|
+
padding: '0 14px',
|
|
1202
|
+
color: 'var(--dsw-alias-label-primary-foreground, #fff)',
|
|
1203
|
+
background: 'var(--dsw-alias-state-business-primary, #1f6feb)',
|
|
1204
|
+
border: '0.5px solid transparent',
|
|
1205
|
+
borderRadius: '8px',
|
|
1206
|
+
cursor: 'pointer',
|
|
1207
|
+
font: 'inherit',
|
|
1208
|
+
},
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
/**
|
|
1212
|
+
* Render the allowlist as one host per line.
|
|
1213
|
+
* @param hosts - configured host entries.
|
|
1214
|
+
* @returns the textarea text.
|
|
1215
|
+
*/
|
|
1216
|
+
function hostsToText(hosts) {
|
|
1217
|
+
return Array.isArray(hosts) ? hosts.join('\n') : ''
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
/**
|
|
1221
|
+
* Parse the allowlist textarea.
|
|
1222
|
+
* @param text - comma or newline separated host entries.
|
|
1223
|
+
* @returns normalized, de-duplicated host entries.
|
|
1224
|
+
*/
|
|
1225
|
+
function textToHosts(text) {
|
|
1226
|
+
const entries = String(text === undefined || text === null ? '' : text)
|
|
1227
|
+
.split(/[\n,]+/)
|
|
1228
|
+
.map((entry) => entry.trim().toLowerCase())
|
|
1229
|
+
.filter((entry) => entry !== '')
|
|
1230
|
+
return [...new Set(entries)]
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
/**
|
|
1234
|
+
* Build the Host mutation operations for one staged draft.
|
|
1235
|
+
* @param draft - local form draft.
|
|
1236
|
+
* @param value - current Host values for the row.
|
|
1237
|
+
* @returns ordered `{ op: 'set', path, value }` operations (empty when unchanged).
|
|
1238
|
+
*/
|
|
1239
|
+
function configOps(draft, value) {
|
|
1240
|
+
const current = value ?? {}
|
|
1241
|
+
const ops = []
|
|
1242
|
+
for (const field of ['enabled', 'allowEval', 'allowNativeInput']) {
|
|
1243
|
+
const next = draft[field] !== false
|
|
1244
|
+
const before = current[field] !== false
|
|
1245
|
+
if (next !== before) ops.push({ op: 'set', path: [field], value: next })
|
|
1246
|
+
}
|
|
1247
|
+
const nextHosts = textToHosts(draft.hostsText)
|
|
1248
|
+
const beforeHosts = hostsToText(current.allowedHosts).split('\n').map((entry) => entry.trim().toLowerCase()).filter((entry) => entry !== '')
|
|
1249
|
+
if (JSON.stringify(nextHosts) !== JSON.stringify(beforeHosts)) ops.push({ op: 'set', path: ['allowedHosts'], value: nextHosts })
|
|
1250
|
+
return ops
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
/**
|
|
1254
|
+
* Build the local draft from one Host value snapshot.
|
|
1255
|
+
* @param value - projected volatile config.
|
|
1256
|
+
* @returns the draft.
|
|
1257
|
+
*/
|
|
1258
|
+
function draftOf(value) {
|
|
1259
|
+
const current = value ?? {}
|
|
1260
|
+
return {
|
|
1261
|
+
enabled: current.enabled !== false,
|
|
1262
|
+
allowEval: current.allowEval !== false,
|
|
1263
|
+
allowNativeInput: current.allowNativeInput !== false,
|
|
1264
|
+
hostsText: hostsToText(current.allowedHosts),
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
/**
|
|
1269
|
+
* One switch row.
|
|
1270
|
+
* @param t - locale translate function.
|
|
1271
|
+
* @param options - label, hint, checked, disabled, onChange.
|
|
1272
|
+
* @returns the row element.
|
|
1273
|
+
*/
|
|
1274
|
+
function switchRow(t, options) {
|
|
1275
|
+
return React.createElement('label', { style: configStyles.checkRow },
|
|
1276
|
+
React.createElement('input', {
|
|
1277
|
+
type: 'checkbox',
|
|
1278
|
+
checked: options.checked,
|
|
1279
|
+
disabled: options.disabled,
|
|
1280
|
+
onChange: (event) => options.onChange(event.target.checked),
|
|
1281
|
+
}),
|
|
1282
|
+
React.createElement('span', { style: configStyles.checkText },
|
|
1283
|
+
React.createElement('span', { style: configStyles.checkLabel }, t(options.label)),
|
|
1284
|
+
React.createElement('span', { style: configStyles.hint }, t(options.hint)),
|
|
1285
|
+
),
|
|
1286
|
+
)
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
/** The staged form for one row's volatile configuration. */
|
|
1290
|
+
function ConfigEditor(props) {
|
|
1291
|
+
const t = props.t
|
|
1292
|
+
const state = props.form.state
|
|
1293
|
+
const value = state.value ?? {}
|
|
1294
|
+
const [draft, setDraft] = React.useState(() => draftOf(value))
|
|
1295
|
+
const [busy, setBusy] = React.useState(false)
|
|
1296
|
+
const [notice, setNotice] = React.useState('')
|
|
1297
|
+
React.useEffect(() => {
|
|
1298
|
+
setDraft(draftOf(value))
|
|
1299
|
+
setNotice('')
|
|
1300
|
+
}, [state.revision, state.status])
|
|
1301
|
+
if (state.status === 'loading') return React.createElement('p', { style: configStyles.notice }, t('config.loading'))
|
|
1302
|
+
if (state.status !== 'ready' || value === undefined) return React.createElement('p', { style: configStyles.notice }, t('config.unavailable'))
|
|
1303
|
+
const readOnly = state.writable === false
|
|
1304
|
+
const ops = configOps(draft, value)
|
|
1305
|
+
const save = async () => {
|
|
1306
|
+
if (ops.length === 0 || busy) return
|
|
1307
|
+
setBusy(true)
|
|
1308
|
+
setNotice(t('config.saving'))
|
|
1309
|
+
try {
|
|
1310
|
+
const accepted = await props.form.mutate(ops, state.revision)
|
|
1311
|
+
setNotice(accepted === true ? t('config.saved') : t('config.failed'))
|
|
1312
|
+
} catch (error) {
|
|
1313
|
+
setNotice(`${t('config.failed')} (${error instanceof Error ? error.message : String(error)})`)
|
|
1314
|
+
} finally {
|
|
1315
|
+
setBusy(false)
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
1318
|
+
return React.createElement('div', { style: configStyles.root },
|
|
1319
|
+
React.createElement('h3', { style: configStyles.heading }, t('config.heading')),
|
|
1320
|
+
React.createElement('p', { style: configStyles.hint }, t('config.intro')),
|
|
1321
|
+
readOnly ? React.createElement('p', { style: configStyles.hint }, `${t('config.readOnly')} ${t('config.readOnlyHint')}`) : null,
|
|
1322
|
+
switchRow(t, { label: 'config.enabled', hint: 'config.enabledHint', checked: draft.enabled, disabled: readOnly, onChange: (next) => setDraft({ ...draft, enabled: next }) }),
|
|
1323
|
+
React.createElement('div', { style: configStyles.field },
|
|
1324
|
+
React.createElement('span', { style: configStyles.checkLabel }, t('config.hosts')),
|
|
1325
|
+
React.createElement('span', { style: configStyles.hint }, t('config.hostsHint')),
|
|
1326
|
+
React.createElement('textarea', {
|
|
1327
|
+
style: configStyles.textarea,
|
|
1328
|
+
rows: 4,
|
|
1329
|
+
spellCheck: false,
|
|
1330
|
+
disabled: readOnly,
|
|
1331
|
+
placeholder: t('config.hostsPlaceholder'),
|
|
1332
|
+
value: draft.hostsText,
|
|
1333
|
+
onChange: (event) => setDraft({ ...draft, hostsText: event.target.value }),
|
|
1334
|
+
}),
|
|
1335
|
+
),
|
|
1336
|
+
switchRow(t, { label: 'config.allowEval', hint: 'config.allowEvalHint', checked: draft.allowEval, disabled: readOnly, onChange: (next) => setDraft({ ...draft, allowEval: next }) }),
|
|
1337
|
+
switchRow(t, { label: 'config.allowNativeInput', hint: 'config.allowNativeInputHint', checked: draft.allowNativeInput, disabled: readOnly, onChange: (next) => setDraft({ ...draft, allowNativeInput: next }) }),
|
|
1338
|
+
React.createElement('div', { style: configStyles.actions },
|
|
1339
|
+
React.createElement('button', {
|
|
1340
|
+
type: 'button',
|
|
1341
|
+
style: configStyles.primary,
|
|
1342
|
+
disabled: readOnly || busy || ops.length === 0,
|
|
1343
|
+
onClick: save,
|
|
1344
|
+
}, busy ? t('config.saving') : t('config.save')),
|
|
1345
|
+
React.createElement('button', {
|
|
1346
|
+
type: 'button',
|
|
1347
|
+
style: configStyles.button,
|
|
1348
|
+
disabled: readOnly || busy || ops.length === 0,
|
|
1349
|
+
onClick: () => { setDraft(draftOf(value)); setNotice('') },
|
|
1350
|
+
}, t('config.reset')),
|
|
1351
|
+
React.createElement('span', { style: notice.startsWith(t('config.failed')) ? configStyles.error : configStyles.notice }, notice),
|
|
1352
|
+
),
|
|
1353
|
+
)
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
/**
|
|
1357
|
+
* The `plugins.row.config` page: a summary line and the staged form.
|
|
1358
|
+
* @param props - `{ view, form, t }` from the Plugins page.
|
|
1359
|
+
* @returns text for the summary, the form for the page.
|
|
1360
|
+
*/
|
|
1361
|
+
function ConfigPage(props) {
|
|
1362
|
+
if (props.view === 'summary') return props.t('config.summary')
|
|
1363
|
+
if (props.form === undefined) return React.createElement('p', { style: configStyles.hint }, props.t('config.unavailable'))
|
|
1364
|
+
return React.createElement(ConfigEditor, { form: props.form, t: props.t })
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
// ---------------------------------------------------------------------
|
|
1368
|
+
// Command dispatch (tab management + page content)
|
|
1369
|
+
// ---------------------------------------------------------------------
|
|
1370
|
+
|
|
1371
|
+
const PAGE_ACTIONS = new Set(['read', 'click', 'type', 'press', 'scroll', 'eval', 'wait', 'screenshot'])
|
|
1372
|
+
/** Appended to results whose operation ran while another conversation was on screen. */
|
|
1373
|
+
const OFFSCREEN_NOTE = 'The calling conversation is not the one on screen; the operation ran on its hidden sidebar tab and is not visible right now.'
|
|
1374
|
+
|
|
1375
|
+
/**
|
|
1376
|
+
* Execute one bridge command through the sidebar controller.
|
|
1377
|
+
* @param ctx - client plugin context.
|
|
1378
|
+
* @param command - command frame from the host.
|
|
1379
|
+
* @param state - per-page memory (url of tabs this plugin opened).
|
|
1380
|
+
* @returns `{ ok, value? , error? }`; never throws.
|
|
1381
|
+
*/
|
|
1382
|
+
async function executeCommand(ctx, command, state) {
|
|
1383
|
+
try {
|
|
1384
|
+
const sessionId = typeof command.sessionId === 'string' ? command.sessionId : ''
|
|
1385
|
+
const action = command.action
|
|
1386
|
+
|
|
1387
|
+
if (PAGE_ACTIONS.has(action)) {
|
|
1388
|
+
const result = await runPageCommand(ctx, command)
|
|
1389
|
+
if (result.ok === true && result.value !== null && typeof result.value === 'object' && mountedSession(ctx) !== command.sessionId) {
|
|
1390
|
+
result.value = { ...result.value, note: OFFSCREEN_NOTE }
|
|
1391
|
+
}
|
|
1392
|
+
return result
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
if (action === 'status') {
|
|
1396
|
+
const mounted = mountedSession(ctx)
|
|
1397
|
+
const tabs = sessionId === '' ? allOpenTabs(ctx).filter((tab) => tab.kind === 'browser') : browserTabs(ctx, sessionId)
|
|
1398
|
+
const carrier = detectCarrier()
|
|
1399
|
+
const surface = ctx.get?.('sidebarRightTabs')
|
|
1400
|
+
const browserKind = surface?.get?.('browser') === undefined ? 'unknown' : 'registered'
|
|
1401
|
+
const checks = carrierChecks(ctx, sessionId)
|
|
1402
|
+
return {
|
|
1403
|
+
ok: true,
|
|
1404
|
+
value: {
|
|
1405
|
+
action: 'status',
|
|
1406
|
+
...(sessionId === '' ? {} : { sessionId }),
|
|
1407
|
+
pageConnected: true,
|
|
1408
|
+
note: `right sidebar mounted=${mounted ?? 'none'}; carrier=${carrier.kind}; native page control=${carrier.native ? 'available' : 'unavailable'}; native browser tab type=${browserKind}; browser tabs=${tabs.length}; checks=${JSON.stringify(checks)}.`,
|
|
1409
|
+
},
|
|
1410
|
+
}
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
if (sessionId === '') throw new Error('the host did not name a calling session')
|
|
1414
|
+
|
|
1415
|
+
if (action === 'list') {
|
|
1416
|
+
const tabs = browserTabs(ctx, sessionId)
|
|
1417
|
+
const mounted = mountedSession(ctx)
|
|
1418
|
+
const active = mounted === sessionId ? activeTabId(ctx) : undefined
|
|
1419
|
+
return {
|
|
1420
|
+
ok: true,
|
|
1421
|
+
value: {
|
|
1422
|
+
action: 'list',
|
|
1423
|
+
sessionId,
|
|
1424
|
+
tabs: tabs.map((tab) => {
|
|
1425
|
+
const facts = nativeFacts(sessionId, tab.tabId)
|
|
1426
|
+
const url = facts.url ?? (state.urls.has(tab.tabId) ? state.urls.get(tab.tabId) : undefined)
|
|
1427
|
+
return {
|
|
1428
|
+
tabId: tab.tabId,
|
|
1429
|
+
kind: tab.kind,
|
|
1430
|
+
...(url === undefined ? {} : { url }),
|
|
1431
|
+
...(facts.title === undefined ? {} : { title: facts.title }),
|
|
1432
|
+
...(tab.tabId === active ? { active: true } : {}),
|
|
1433
|
+
}
|
|
1434
|
+
}),
|
|
1435
|
+
},
|
|
1436
|
+
}
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
if (action === 'open') {
|
|
1440
|
+
requireMounted(ctx, sessionId)
|
|
1441
|
+
const before = new Set(browserTabs(ctx, sessionId).map((tab) => tab.tabId))
|
|
1442
|
+
ctx.sidebarRight.openTab('browser', { params: { url: command.url } })
|
|
1443
|
+
const created = browserTabs(ctx, sessionId).find((tab) => !before.has(tab.tabId))
|
|
1444
|
+
if (created === undefined) throw new Error('the sidebar opened no new browser tab')
|
|
1445
|
+
state.urls.set(created.tabId, command.url)
|
|
1446
|
+
return {
|
|
1447
|
+
ok: true,
|
|
1448
|
+
value: { action: 'open', sessionId, tabId: created.tabId, url: command.url },
|
|
1449
|
+
}
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
if (action === 'navigate') {
|
|
1453
|
+
const target = resolveTab(ctx, sessionId, command.tabId)
|
|
1454
|
+
const webview = tryFindWebview(sessionId, target.tabId)
|
|
1455
|
+
if (webview !== undefined && typeof webview.loadURL === 'function') {
|
|
1456
|
+
await withTimeout(
|
|
1457
|
+
Promise.resolve().then(() => webview.loadURL(command.url)),
|
|
1458
|
+
TIMEOUTS.navigation,
|
|
1459
|
+
'The navigation',
|
|
1460
|
+
)
|
|
1461
|
+
state.urls.set(target.tabId, command.url)
|
|
1462
|
+
const note = mountedSession(ctx) === sessionId ? {} : { note: OFFSCREEN_NOTE }
|
|
1463
|
+
return { ok: true, value: { action: 'navigate', sessionId, tabId: target.tabId, url: command.url, ...note } }
|
|
1464
|
+
}
|
|
1465
|
+
requireMounted(ctx, sessionId)
|
|
1466
|
+
const before = new Set(browserTabs(ctx, sessionId).map((tab) => tab.tabId))
|
|
1467
|
+
ctx.sidebarRight.openTab('browser', { params: { url: command.url }, replaceTab: target.tabId })
|
|
1468
|
+
const created = browserTabs(ctx, sessionId).find((tab) => !before.has(tab.tabId))
|
|
1469
|
+
const nextTabId = created === undefined ? target.tabId : created.tabId
|
|
1470
|
+
state.urls.delete(target.tabId)
|
|
1471
|
+
state.urls.set(nextTabId, command.url)
|
|
1472
|
+
return { ok: true, value: { action: 'navigate', sessionId, tabId: nextTabId, url: command.url } }
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
if (action === 'reload') {
|
|
1476
|
+
const target = resolveTab(ctx, sessionId, command.tabId)
|
|
1477
|
+
const webview = tryFindWebview(sessionId, target.tabId)
|
|
1478
|
+
if (webview !== undefined && typeof webview.reload === 'function') {
|
|
1479
|
+
webview.reload()
|
|
1480
|
+
const facts = nativeFacts(sessionId, target.tabId)
|
|
1481
|
+
const note = mountedSession(ctx) === sessionId ? {} : { note: OFFSCREEN_NOTE }
|
|
1482
|
+
return {
|
|
1483
|
+
ok: true,
|
|
1484
|
+
value: { action: 'reload', sessionId, tabId: target.tabId, ...(facts.url === undefined ? {} : { url: facts.url }), ...note },
|
|
1485
|
+
}
|
|
1486
|
+
}
|
|
1487
|
+
requireMounted(ctx, sessionId)
|
|
1488
|
+
const url = state.urls.get(target.tabId)
|
|
1489
|
+
if (url === undefined) {
|
|
1490
|
+
throw new Error('This tab was not opened by the agent and has no native guest, so its current address is unknown; use action "navigate" with the target url instead.')
|
|
1491
|
+
}
|
|
1492
|
+
const before = new Set(browserTabs(ctx, sessionId).map((tab) => tab.tabId))
|
|
1493
|
+
ctx.sidebarRight.openTab('browser', { params: { url }, replaceTab: target.tabId })
|
|
1494
|
+
const created = browserTabs(ctx, sessionId).find((tab) => !before.has(tab.tabId))
|
|
1495
|
+
const nextTabId = created === undefined ? target.tabId : created.tabId
|
|
1496
|
+
state.urls.set(nextTabId, url)
|
|
1497
|
+
return { ok: true, value: { action: 'reload', sessionId, tabId: nextTabId, url } }
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
if (action === 'focus') {
|
|
1501
|
+
requireMounted(ctx, sessionId)
|
|
1502
|
+
const target = resolveTab(ctx, sessionId, command.tabId)
|
|
1503
|
+
ctx.sidebarRight.focus(target.tabId)
|
|
1504
|
+
return { ok: true, value: { action, sessionId, tabId: target.tabId } }
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
if (action === 'close') {
|
|
1508
|
+
requireMounted(ctx, sessionId)
|
|
1509
|
+
const target = resolveTab(ctx, sessionId, command.tabId)
|
|
1510
|
+
ctx.sidebarRight.close(target.tabId)
|
|
1511
|
+
state.urls.delete(target.tabId)
|
|
1512
|
+
return { ok: true, value: { action, sessionId, tabId: target.tabId } }
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1515
|
+
return { ok: false, error: `unknown sidebar action "${String(action)}"` }
|
|
1516
|
+
} catch (error) {
|
|
1517
|
+
return { ok: false, error: messageOf(error) }
|
|
1518
|
+
}
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
// ---------------------------------------------------------------------
|
|
1522
|
+
// Bridge
|
|
1523
|
+
// ---------------------------------------------------------------------
|
|
1524
|
+
|
|
1525
|
+
/**
|
|
1526
|
+
* Abortable pause.
|
|
1527
|
+
* @param ms - milliseconds.
|
|
1528
|
+
* @param signal - bridge cancellation.
|
|
1529
|
+
* @returns a promise that resolves after the delay.
|
|
1530
|
+
*/
|
|
1531
|
+
function pause(ms, signal) {
|
|
1532
|
+
return new Promise((resolve) => {
|
|
1533
|
+
if (signal.aborted) {
|
|
1534
|
+
resolve()
|
|
1535
|
+
return
|
|
1536
|
+
}
|
|
1537
|
+
const timer = setTimeout(() => {
|
|
1538
|
+
signal.removeEventListener('abort', onAbort)
|
|
1539
|
+
resolve()
|
|
1540
|
+
}, ms)
|
|
1541
|
+
const onAbort = () => {
|
|
1542
|
+
clearTimeout(timer)
|
|
1543
|
+
resolve()
|
|
1544
|
+
}
|
|
1545
|
+
signal.addEventListener('abort', onAbort, { once: true })
|
|
1546
|
+
})
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
/**
|
|
1550
|
+
* Start the long-poll bridge.
|
|
1551
|
+
* @param ctx - client plugin context.
|
|
1552
|
+
* @returns a disposer stopping the loop.
|
|
1553
|
+
*/
|
|
1554
|
+
function startBridge(ctx) {
|
|
1555
|
+
const controller = new AbortController()
|
|
1556
|
+
const state = { urls: new Map() }
|
|
1557
|
+
const clientId = newClientId()
|
|
1558
|
+
let stopped = false
|
|
1559
|
+
|
|
1560
|
+
const report = (payload) => {
|
|
1561
|
+
if (stopped) return
|
|
1562
|
+
fetch(hostUrl(`${PLUGIN_ROOT}/report`), {
|
|
1563
|
+
method: 'POST',
|
|
1564
|
+
headers: { 'content-type': 'application/json' },
|
|
1565
|
+
body: JSON.stringify({ clientId, ...payload }),
|
|
1566
|
+
signal: controller.signal,
|
|
1567
|
+
}).catch(() => {})
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
const sendResult = async (id, result) => {
|
|
1571
|
+
const body = JSON.stringify({
|
|
1572
|
+
id,
|
|
1573
|
+
ok: result !== null && typeof result === 'object' && result.ok === true,
|
|
1574
|
+
...(result !== null && typeof result === 'object' && result.value !== undefined ? { value: result.value } : {}),
|
|
1575
|
+
...(result !== null && typeof result === 'object' && typeof result.error === 'string' ? { error: result.error } : {}),
|
|
1576
|
+
})
|
|
1577
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
1578
|
+
try {
|
|
1579
|
+
const response = await fetch(hostUrl(`${PLUGIN_ROOT}/result`), {
|
|
1580
|
+
method: 'POST',
|
|
1581
|
+
headers: { 'content-type': 'application/json' },
|
|
1582
|
+
body,
|
|
1583
|
+
signal: controller.signal,
|
|
1584
|
+
})
|
|
1585
|
+
if (response.ok) return
|
|
1586
|
+
} catch {
|
|
1587
|
+
/* retry once */
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
}
|
|
1591
|
+
|
|
1592
|
+
const pollOnce = async () => {
|
|
1593
|
+
const mounted = mountedSession(ctx)
|
|
1594
|
+
const query = `?clientId=${encodeURIComponent(clientId)}&session=${encodeURIComponent(mounted ?? '')}`
|
|
1595
|
+
const response = await fetch(hostUrl(`${PLUGIN_ROOT}/poll`) + query, { signal: controller.signal })
|
|
1596
|
+
if (!response.ok) throw new Error(`bridge poll failed with HTTP ${response.status}`)
|
|
1597
|
+
const payload = await response.json()
|
|
1598
|
+
const commands = Array.isArray(payload?.commands) ? payload.commands : []
|
|
1599
|
+
for (const command of commands) {
|
|
1600
|
+
if (stopped) return
|
|
1601
|
+
if (command === null || typeof command !== 'object' || typeof command.id !== 'string') continue
|
|
1602
|
+
await sendResult(command.id, await executeCommand(ctx, command, state))
|
|
1603
|
+
}
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1606
|
+
const loop = async () => {
|
|
1607
|
+
let failures = 0
|
|
1608
|
+
while (!stopped) {
|
|
1609
|
+
try {
|
|
1610
|
+
await pollOnce()
|
|
1611
|
+
failures = 0
|
|
1612
|
+
} catch (error) {
|
|
1613
|
+
if (stopped) return
|
|
1614
|
+
failures += 1
|
|
1615
|
+
if (failures === 1) report({ status: 'poll-failed', error: messageOf(error) })
|
|
1616
|
+
await pause(Math.min(MAX_BACKOFF_MS, BASE_BACKOFF_MS * failures), controller.signal)
|
|
1617
|
+
}
|
|
1618
|
+
}
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1621
|
+
report({
|
|
1622
|
+
status: 'started',
|
|
1623
|
+
mounted: mountedSession(ctx) ?? '',
|
|
1624
|
+
carrier: detectCarrier().kind,
|
|
1625
|
+
href: typeof location === 'undefined' ? '' : location.href,
|
|
1626
|
+
})
|
|
1627
|
+
loop().catch((error) => {
|
|
1628
|
+
if (!stopped) report({ status: 'loop-failed', error: messageOf(error) })
|
|
1629
|
+
})
|
|
1630
|
+
|
|
1631
|
+
return () => {
|
|
1632
|
+
stopped = true
|
|
1633
|
+
controller.abort()
|
|
1634
|
+
}
|
|
1635
|
+
}
|
|
1636
|
+
|
|
1637
|
+
return {
|
|
1638
|
+
inject: ['sidebarRight', 'slots', 'locale'],
|
|
1639
|
+
apply(ctx) {
|
|
1640
|
+
ctx.effect(() => startBridge(ctx), 'sidebar-browser-agent: bridge')
|
|
1641
|
+
ctx.effect(() => ctx.locale.register(UI_NS, { en: CONFIG_EN, zh: CONFIG_ZH }), 'sidebar-browser-agent: config locale')
|
|
1642
|
+
ctx.effect(() => ctx.slots.inject('plugins.row.config', () => ctx.slots.register({
|
|
1643
|
+
name: 'plugins.row.config',
|
|
1644
|
+
key: CONFIG_ROW_KEY,
|
|
1645
|
+
locale: UI_NS,
|
|
1646
|
+
}, ConfigPage)), 'sidebar-browser-agent: config page')
|
|
1647
|
+
},
|
|
1648
|
+
__test: { hostsToText, textToHosts, configOps, draftOf, withTimeout, TIMEOUTS, CONFIG_ROW_KEY, CONFIG_ENTRY_ID },
|
|
1649
|
+
}
|
|
1650
|
+
},
|
|
1651
|
+
})
|