dsh-browser-plus 0.5.0 → 0.5.1
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 +131 -144
- package/README.en.md +27 -8
- package/README.md +27 -8
- package/client/index.js +185 -0
- package/cordis.patch.yml +24 -0
- package/docs/README.md +1 -1
- package/docs/SOAK-CHECKLIST.md +1 -1
- package/docs/tool-reference.md +5 -3
- package/docs/user-guide.md +20 -4
- package/lib/browser/runtime.d.ts +14 -1
- package/lib/browser/runtime.js +28 -0
- package/lib/browser/types.d.ts +96 -6
- package/lib/browser-electron/chrome-state.d.ts +24 -0
- package/lib/browser-electron/entry.d.ts +12 -5
- package/lib/browser-electron/entry.js +5 -2
- package/lib/browser-electron/host-main.d.ts +2 -1
- package/lib/browser-electron/host-main.js +205 -8
- package/lib/browser-electron/page-chrome.js +249 -14
- package/lib/browser-electron/provider.d.ts +60 -2
- package/lib/browser-electron/provider.js +300 -50
- package/lib/browser-electron/remote-host.d.ts +3 -1
- package/lib/browser-electron/remote-host.js +42 -1
- package/lib/client.js +185 -0
- package/lib/command-browser/index.d.ts +20 -0
- package/lib/command-browser/index.js +35 -0
- package/lib/http-browser/index.d.ts +28 -0
- package/lib/http-browser/index.js +110 -0
- package/lib/index.d.ts +11 -0
- package/lib/index.js +11 -0
- package/lib/task-todos/index.d.ts +25 -0
- package/lib/task-todos/index.js +100 -0
- package/lib/tool-browser/index.js +190 -95
- package/package.json +27 -2
- package/scripts/build-client.mjs +20 -0
- package/scripts/smoke-browser-tools.mjs +84 -1
- package/scripts/smoke-chrome-world.mjs +6 -4
- package/scripts/test-orb-drag.mjs +83 -0
- package/src/browser/runtime.ts +36 -0
- package/src/browser/types.ts +98 -6
- package/src/browser-electron/chrome-state.ts +18 -0
- package/src/browser-electron/entry.ts +17 -7
- package/src/browser-electron/host-main.ts +207 -11
- package/src/browser-electron/page-chrome.ts +249 -14
- package/src/browser-electron/provider.ts +328 -50
- package/src/browser-electron/remote-host.ts +49 -2
- package/src/command-browser/index.ts +61 -0
- package/src/http-browser/index.ts +139 -0
- package/src/index.ts +13 -0
- package/src/task-todos/index.ts +114 -0
- package/src/tool-browser/index.ts +193 -96
|
@@ -47,8 +47,13 @@ import type {
|
|
|
47
47
|
BrowserTaskUpdate,
|
|
48
48
|
BrowserUploadFileRequest,
|
|
49
49
|
BrowserUploadFileResult,
|
|
50
|
+
BrowserPdfRequest,
|
|
51
|
+
BrowserPdfResult,
|
|
52
|
+
BrowserHighlightRequest,
|
|
53
|
+
BrowserHighlightResult,
|
|
50
54
|
BrowserWaitForRequest,
|
|
51
55
|
BrowserWaitForResult,
|
|
56
|
+
BrowserTaskTodo,
|
|
52
57
|
ExportedCookie,
|
|
53
58
|
} from '../browser/types.ts'
|
|
54
59
|
import { BrowserError } from '../browser/types.ts'
|
|
@@ -188,6 +193,8 @@ export interface ElectronBrowserViewHost {
|
|
|
188
193
|
getTask?(key: string): Promise<BrowserTaskInfo | undefined>
|
|
189
194
|
/** Apply a task status/control update to the visible workspace. */
|
|
190
195
|
updateTask?(key: string, update: BrowserTaskUpdate): Promise<BrowserTaskInfo | undefined>
|
|
196
|
+
/** Mirror one task's Agent todo list into the visible workspace (floating orb). */
|
|
197
|
+
setTaskTodos?(key: string, todos: readonly BrowserTaskTodo[]): Promise<void>
|
|
191
198
|
}
|
|
192
199
|
|
|
193
200
|
/**
|
|
@@ -231,10 +238,28 @@ export interface ElectronViewHandle {
|
|
|
231
238
|
* tokenless copy as a fallback.
|
|
232
239
|
*/
|
|
233
240
|
reinstallChrome?(): Promise<void>
|
|
241
|
+
/**
|
|
242
|
+
* Print this view to a PDF and return it base64-encoded. Optional: a host
|
|
243
|
+
* with no native print path omits it, and the provider falls back to CDP.
|
|
244
|
+
*
|
|
245
|
+
* Electron's debugger does NOT implement CDP `Page.printToPDF` (it answers
|
|
246
|
+
* "'Page.printToPDF' wasn't found"), so the PDF path has to go through the
|
|
247
|
+
* host's own `webContents.printToPDF`.
|
|
248
|
+
* @param options - Electron PrintToPDFOptions (pageSize in microns).
|
|
249
|
+
*/
|
|
250
|
+
printToPdf?(options: Record<string, unknown>): Promise<{ readonly base64: string }>
|
|
251
|
+
/**
|
|
252
|
+
* Raise this view's window (show + restore + focus). Optional: a host that
|
|
253
|
+
* owns no window omits it. Distinct from the provider's own show/switch
|
|
254
|
+
* path, which deliberately leaves a human-selected visible task alone.
|
|
255
|
+
*/
|
|
256
|
+
focusWindow?(): Promise<void>
|
|
234
257
|
}
|
|
235
258
|
|
|
236
259
|
/** Internal selector and fingerprint captured for one snapshot element. */
|
|
237
260
|
interface SnapshotTarget {
|
|
261
|
+
/** iframe index when the target lives inside a frame. */
|
|
262
|
+
readonly frame?: number
|
|
238
263
|
readonly path: string
|
|
239
264
|
readonly fingerprint: string
|
|
240
265
|
}
|
|
@@ -377,6 +402,12 @@ export interface CdpEvaluateParams {
|
|
|
377
402
|
|
|
378
403
|
/** CDP method for a full-page screenshot capture. */
|
|
379
404
|
export const CDP_PAGE_CAPTURE_SCREENSHOT = 'Page.captureScreenshot'
|
|
405
|
+
/** CDP method that renders the current document to PDF. */
|
|
406
|
+
export const CDP_PAGE_PRINT_TO_PDF = 'Page.printToPDF'
|
|
407
|
+
/** CDP methods that draw the DevTools highlight box without touching the DOM. */
|
|
408
|
+
export const CDP_OVERLAY_ENABLE = 'Overlay.enable'
|
|
409
|
+
export const CDP_OVERLAY_HIGHLIGHT_NODE = 'Overlay.highlightNode'
|
|
410
|
+
export const CDP_OVERLAY_HIDE_HIGHLIGHT = 'Overlay.hideHighlight'
|
|
380
411
|
/** CDP method for runtime evaluation (the execute path). */
|
|
381
412
|
export const CDP_RUNTIME_EVALUATE = 'Runtime.evaluate'
|
|
382
413
|
|
|
@@ -530,7 +561,6 @@ export const CDP_PAGE_RELOAD = 'Page.reload'
|
|
|
530
561
|
export const CDP_PAGE_STOP_LOADING = 'Page.stopLoading'
|
|
531
562
|
|
|
532
563
|
/** Cap on content returned by a snapshot fetch to keep the wire bounded. */
|
|
533
|
-
const SNAPSHOT_LABEL_MAX = 120
|
|
534
564
|
|
|
535
565
|
/** An input dispatch must not outlive this: a blocked renderer never acknowledges. */
|
|
536
566
|
const INPUT_DISPATCH_TIMEOUT_MS = 15_000
|
|
@@ -686,6 +716,14 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
686
716
|
/** Stable task-key index so callers can recover a session after tool-layer state loss. */
|
|
687
717
|
private readonly sessionsByTask = new Map<string, BrowserSessionId>()
|
|
688
718
|
private readonly taskStates = new Map<string, LocalTaskState>()
|
|
719
|
+
/**
|
|
720
|
+
* The Agent's todo list per task, kept here as well as in the host.
|
|
721
|
+
*
|
|
722
|
+
* The host's copy is in memory and dies with the host process, and the bridge
|
|
723
|
+
* only pushes on a todo change — so without this cache a host restart left the
|
|
724
|
+
* floating orb with no plan until the Agent happened to write its list again.
|
|
725
|
+
*/
|
|
726
|
+
private readonly taskTodos = new Map<string, readonly BrowserTaskTodo[]>()
|
|
689
727
|
private readonly httpOnly: boolean
|
|
690
728
|
private readonly snapshotMaxElements: number
|
|
691
729
|
private readonly contentMaxChars: number
|
|
@@ -802,12 +840,24 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
802
840
|
const labelable = active as { label?(label: string): Promise<void> } | undefined
|
|
803
841
|
if (typeof labelable?.label === 'function') await labelable.label(taskLabel).catch(() => undefined)
|
|
804
842
|
}
|
|
843
|
+
// 宿主重启后它内存里那份计划是空的,而这是**已存在会话**唯一会走到的入口
|
|
844
|
+
// (ensureWindowVisible 也补推一次)—— 不推的话球上会一直空着。
|
|
845
|
+
const cached = this.taskTodos.get(taskKey)
|
|
846
|
+
if (cached !== undefined && typeof this.host.setTaskTodos === 'function') {
|
|
847
|
+
void this.host.setTaskTodos(taskKey, cached).catch(() => undefined)
|
|
848
|
+
}
|
|
805
849
|
return existing.id
|
|
806
850
|
}
|
|
807
851
|
const handle = this.host.createView(taskKey, taskLabel === '' ? undefined : taskLabel)
|
|
808
852
|
const id = `browser:${randomUUID()}`
|
|
809
853
|
this.sessions.set(id, { id, taskKey, taskLabel, tabs: [this.createTab(handle)], activeIndex: 0, history: [], nextSeq: 1 })
|
|
810
854
|
this.sessionsByTask.set(taskKey, id)
|
|
855
|
+
// A fresh view means a host that may never have seen this task's plan (a host
|
|
856
|
+
// restart drops the host's copy), so replay what the Agent last wrote.
|
|
857
|
+
const cachedTodos = this.taskTodos.get(taskKey)
|
|
858
|
+
if (cachedTodos !== undefined && typeof this.host.setTaskTodos === 'function') {
|
|
859
|
+
void this.host.setTaskTodos(taskKey, cachedTodos).catch(() => undefined)
|
|
860
|
+
}
|
|
811
861
|
if (!this.taskStates.has(taskKey)) {
|
|
812
862
|
this.taskStates.set(taskKey, { status: 'idle', control: 'agent', updatedAt: Date.now() })
|
|
813
863
|
}
|
|
@@ -1133,7 +1183,7 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
1133
1183
|
if (text) return tag + ':has-text("' + text.replace(/"/g, '\\\\"') + '")'
|
|
1134
1184
|
return tag
|
|
1135
1185
|
}
|
|
1136
|
-
const pathOf = (el) => {
|
|
1186
|
+
const pathOf = (el, doc) => {
|
|
1137
1187
|
if (el.id) return '#' + CSS.escape(el.id)
|
|
1138
1188
|
const parts = []
|
|
1139
1189
|
let node = el
|
|
@@ -1145,7 +1195,7 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
1145
1195
|
if (siblings.length > 1) part += ':nth-of-type(' + (siblings.indexOf(node) + 1) + ')'
|
|
1146
1196
|
}
|
|
1147
1197
|
parts.unshift(part)
|
|
1148
|
-
if (node ===
|
|
1198
|
+
if (node === doc.body) break
|
|
1149
1199
|
node = parent
|
|
1150
1200
|
}
|
|
1151
1201
|
return parts.join(' > ')
|
|
@@ -1160,42 +1210,65 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
1160
1210
|
].join('\u001f')
|
|
1161
1211
|
const url = location.href
|
|
1162
1212
|
const title = document.title || undefined
|
|
1163
|
-
const els = [...document.querySelectorAll('input, textarea, select, button, a[href], [role="button"], [role="searchbox"], [contenteditable="true"]')]
|
|
1164
1213
|
const out = []
|
|
1165
1214
|
let capped = false
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
const
|
|
1169
|
-
const
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1215
|
+
// 同一套判定,用在顶层文档和每个同源子框架上;dx/dy 把框架内的坐标换算成页面坐标。
|
|
1216
|
+
const collect = (doc, frameIndex, dx, dy) => {
|
|
1217
|
+
const els = [...doc.querySelectorAll('input, textarea, select, button, a[href], [role="button"], [role="searchbox"], [role="link"], [contenteditable="true"]')]
|
|
1218
|
+
for (const el of els) {
|
|
1219
|
+
if (el.closest('[data-dsh-browser-chrome]')) continue
|
|
1220
|
+
const r = el.getBoundingClientRect()
|
|
1221
|
+
const cs = getComputedStyle(el)
|
|
1222
|
+
if (r.width < 4 || r.height < 4 || cs.visibility === 'hidden' || cs.display === 'none') continue
|
|
1223
|
+
const kind = el.tagName === 'INPUT' ? (el.type === 'checkbox' ? 'checkbox' : (el.type === 'submit' || el.type === 'button' ? 'button' : 'text'))
|
|
1224
|
+
: el.tagName === 'TEXTAREA' ? 'textarea'
|
|
1225
|
+
: el.tagName === 'SELECT' ? 'select'
|
|
1226
|
+
: el.tagName === 'BUTTON' ? 'button'
|
|
1227
|
+
: el.tagName === 'A' ? 'link' : 'other'
|
|
1228
|
+
const label = (el.getAttribute('aria-label') || el.placeholder || el.textContent || el.value || el.name || el.id || '').replace(/\s+/g, ' ').trim().slice(0, 120)
|
|
1229
|
+
if (!label && kind !== 'link') continue
|
|
1230
|
+
// 过滤放在计上限**之前**:默认上限是 60,先截断就永远搜不到后面的元素。
|
|
1231
|
+
if (query !== '' && (kind + ' ' + label).toLowerCase().indexOf(query) === -1) continue
|
|
1232
|
+
if (out.length >= cap) { capped = true; return }
|
|
1233
|
+
out.push({
|
|
1234
|
+
ref: out.length + 1,
|
|
1235
|
+
kind,
|
|
1236
|
+
label,
|
|
1237
|
+
...(frameIndex === undefined ? {} : { frame: frameIndex }),
|
|
1238
|
+
selector: el.id ? '#' + CSS.escape(el.id) : (el.name ? '[name=' + JSON.stringify(el.name) + ']' : ''),
|
|
1239
|
+
loc: locatorOf(el),
|
|
1240
|
+
path: pathOf(el, doc),
|
|
1241
|
+
fingerprint: fingerprintOf(el),
|
|
1242
|
+
x: Math.round(r.x + r.width / 2 + dx),
|
|
1243
|
+
y: Math.round(r.y + r.height / 2 + dy),
|
|
1244
|
+
})
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
collect(document, undefined, 0, 0)
|
|
1248
|
+
// 子框架:同源的走进去(元素带 frame 序号、坐标加上框架自己的矩形);
|
|
1249
|
+
// 跨源的 contentDocument 读不到,就只记一条 readable:false —— 不假装能读。
|
|
1250
|
+
const frames = []
|
|
1251
|
+
const frameEls = [...document.querySelectorAll('iframe')]
|
|
1252
|
+
for (let i = 0; i < frameEls.length; i++) {
|
|
1253
|
+
const frame = frameEls[i]
|
|
1254
|
+
const src = frame.getAttribute('src') || ''
|
|
1255
|
+
let inner = null
|
|
1256
|
+
try { inner = frame.contentDocument } catch (error) { inner = null }
|
|
1257
|
+
if (inner === null) {
|
|
1258
|
+
frames.push({ index: i, url: src, readable: false })
|
|
1259
|
+
continue
|
|
1260
|
+
}
|
|
1261
|
+
const fr = frame.getBoundingClientRect()
|
|
1262
|
+
frames.push({ index: i, url: inner.location.href || src, readable: true })
|
|
1263
|
+
if (out.length >= cap) { capped = true; continue }
|
|
1264
|
+
collect(inner, i, fr.left, fr.top)
|
|
1192
1265
|
}
|
|
1193
1266
|
const challenge = ${CHALLENGE_DETECT_EXPRESSION}
|
|
1194
1267
|
const chromeHost = document.getElementById('__dsh_browser_chrome_host__')
|
|
1195
1268
|
const userControlling = chromeHost?.getAttribute('data-dsh-user-active') === '1'
|
|
1196
1269
|
// Only an early break means truncation: exactly cap candidates is a
|
|
1197
1270
|
// complete inventory, not a truncated one.
|
|
1198
|
-
return { url, title, elements: out, truncated: capped, challenge, userControlling }
|
|
1271
|
+
return { url, title, elements: out, truncated: capped, frames, challenge, userControlling }
|
|
1199
1272
|
})()`
|
|
1200
1273
|
// Same hang guard as execute: a renderer that has not committed after
|
|
1201
1274
|
// navigate would otherwise block snapshot forever.
|
|
@@ -1235,7 +1308,11 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
1235
1308
|
const snapshotId = `snapshot:${randomUUID()}`
|
|
1236
1309
|
const targets = new Map<number, SnapshotTarget>()
|
|
1237
1310
|
for (const element of value.elements) {
|
|
1238
|
-
targets.set(element.ref, {
|
|
1311
|
+
targets.set(element.ref, {
|
|
1312
|
+
path: element.path,
|
|
1313
|
+
fingerprint: element.fingerprint,
|
|
1314
|
+
...element.frame !== undefined ? { frame: element.frame } : {},
|
|
1315
|
+
})
|
|
1239
1316
|
}
|
|
1240
1317
|
tab.snapshots.set(snapshotId, { tabId: tab.id, url: value.url, epoch: tab.navigationEpoch, targets })
|
|
1241
1318
|
while (tab.snapshots.size > 10) {
|
|
@@ -1248,6 +1325,7 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
1248
1325
|
url: value.url,
|
|
1249
1326
|
...value.title !== undefined ? { title: value.title } : {},
|
|
1250
1327
|
elements: value.elements.map(({ path: _path, fingerprint: _fingerprint, ...element }) => element),
|
|
1328
|
+
...value.frames !== undefined ? { frames: value.frames } : {},
|
|
1251
1329
|
truncated: value.truncated,
|
|
1252
1330
|
...value.challenge !== undefined ? { challenge: value.challenge } : {},
|
|
1253
1331
|
...value.userControlling !== undefined ? { userControlling: value.userControlling } : {},
|
|
@@ -1528,27 +1606,55 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
1528
1606
|
return this.waitForElementTab(s, this.activeTab(s), request, signal)
|
|
1529
1607
|
}
|
|
1530
1608
|
|
|
1531
|
-
/** Poll one tab until the selector
|
|
1609
|
+
/** Poll one tab until the selector and/or text reaches the requested state. */
|
|
1532
1610
|
private async waitForElementTab(s: Session, tab: Tab, request: BrowserWaitForRequest, signal?: AbortSignal): Promise<BrowserWaitForResult> {
|
|
1533
1611
|
const { handle } = tab
|
|
1534
1612
|
signal?.throwIfAborted()
|
|
1535
1613
|
const timeoutMs = request.timeoutMs ?? 15_000
|
|
1536
|
-
const
|
|
1537
|
-
const
|
|
1614
|
+
const selector = request.selector ?? ''
|
|
1615
|
+
const wanted = request.text
|
|
1616
|
+
const state: 'visible' | 'attached' | 'hidden' | 'detached' = request.state ?? (request.visible === false ? 'attached' : 'visible')
|
|
1617
|
+
if (selector === '' && wanted === undefined) {
|
|
1618
|
+
throw new BrowserError('browser: waitForElement needs a selector, some text, or both', 'BROWSER_WAIT_INVALID')
|
|
1619
|
+
}
|
|
1620
|
+
if (selector === '' && (state === 'hidden' || state === 'detached')) {
|
|
1621
|
+
throw new BrowserError(`browser: state "${state}" needs a selector to watch disappear`, 'BROWSER_WAIT_INVALID')
|
|
1622
|
+
}
|
|
1538
1623
|
const script = `(() => {
|
|
1624
|
+
const selector = ${JSON.stringify(selector)}
|
|
1625
|
+
const wanted = ${JSON.stringify(wanted ?? null)}
|
|
1626
|
+
const state = ${JSON.stringify(state)}
|
|
1539
1627
|
let el = null
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
if (${visible}) {
|
|
1543
|
-
const r = el.getBoundingClientRect()
|
|
1544
|
-
const cs = getComputedStyle(el)
|
|
1545
|
-
if (r.width < 4 || r.height < 4 || cs.visibility === 'hidden' || cs.display === 'none') return null
|
|
1628
|
+
if (selector !== '') {
|
|
1629
|
+
try { el = document.querySelector(selector) } catch (e) { return { error: String(e) } }
|
|
1546
1630
|
}
|
|
1631
|
+
const visibleNow = node => {
|
|
1632
|
+
if (node === null) return false
|
|
1633
|
+
const r = node.getBoundingClientRect()
|
|
1634
|
+
if (r.width < 4 || r.height < 4) return false
|
|
1635
|
+
const cs = getComputedStyle(node)
|
|
1636
|
+
return cs.visibility !== 'hidden' && cs.display !== 'none'
|
|
1637
|
+
}
|
|
1638
|
+
const textOf = node => (node === null ? '' : (node.textContent || '')).replace(/\\s+/g, ' ').trim()
|
|
1639
|
+
const attached = el !== null
|
|
1640
|
+
const visible = visibleNow(el)
|
|
1641
|
+
let met
|
|
1642
|
+
if (selector === '') {
|
|
1643
|
+
// 只等文字时没有元素可量:文字在不在,本身就是那个状态。
|
|
1644
|
+
// (以前这里仍按 visibleNow(null) === false 判定,于是默认的 visible 永远不成立。)
|
|
1645
|
+
const present = textOf(document.body).includes(wanted)
|
|
1646
|
+
met = state === 'hidden' || state === 'detached' ? !present : present
|
|
1647
|
+
} else {
|
|
1648
|
+
met = state === 'attached' ? attached : state === 'visible' ? visible : state === 'hidden' ? !visible : state === 'detached' ? !attached : false
|
|
1649
|
+
if (met && wanted !== null) met = textOf(el).includes(wanted)
|
|
1650
|
+
}
|
|
1651
|
+
if (!met) return null
|
|
1547
1652
|
return {
|
|
1548
1653
|
found: true,
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1654
|
+
state,
|
|
1655
|
+
selector,
|
|
1656
|
+
tag: attached ? el.tagName.toLowerCase() : '',
|
|
1657
|
+
text: (wanted !== null && !attached ? wanted : textOf(selector === '' ? document.body : el)).slice(0, 200),
|
|
1552
1658
|
}
|
|
1553
1659
|
})()`
|
|
1554
1660
|
const deadline = Date.now() + timeoutMs
|
|
@@ -1561,8 +1667,8 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
1561
1667
|
} else {
|
|
1562
1668
|
const value = result.value as { found?: boolean; tag?: string; text?: string; error?: string } | null
|
|
1563
1669
|
if (value?.found === true && typeof value.tag === 'string') {
|
|
1564
|
-
this.record(s, 'waitForElement', { selector,
|
|
1565
|
-
return { found: true, selector, tag: value.tag, text: value.text ?? '' }
|
|
1670
|
+
this.record(s, 'waitForElement', { selector, text: wanted, state, timeoutMs }, true, { result: value.tag ?? '' })
|
|
1671
|
+
return { found: true, state, selector, tag: value.tag, text: value.text ?? '' }
|
|
1566
1672
|
}
|
|
1567
1673
|
if (value?.error !== undefined) {
|
|
1568
1674
|
// A malformed selector can never start matching, so fail now instead of
|
|
@@ -1572,7 +1678,8 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
1572
1678
|
}
|
|
1573
1679
|
await new Promise(resolve => setTimeout(resolve, Math.min(250, Math.max(deadline - Date.now(), 1))))
|
|
1574
1680
|
}
|
|
1575
|
-
|
|
1681
|
+
const what = selector === '' ? `text ${JSON.stringify(wanted)}` : `"${selector}"`
|
|
1682
|
+
throw new BrowserError(`browser: ${what} did not reach state "${state}" within ${timeoutMs}ms${lastError !== undefined ? ` (${lastError})` : ''}`, 'BROWSER_WAIT_TIMEOUT')
|
|
1576
1683
|
}
|
|
1577
1684
|
|
|
1578
1685
|
/** Type into the focused element. */
|
|
@@ -2085,6 +2192,111 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
2085
2192
|
}
|
|
2086
2193
|
|
|
2087
2194
|
/** Capture the current page, optionally full-page. PNG only (CDP JPEG hangs on Electron 43). */
|
|
2195
|
+
/**
|
|
2196
|
+
* Draw the DevTools highlight box over the first match of a selector, or clear it.
|
|
2197
|
+
* Uses CDP Overlay, so the page DOM is never touched.
|
|
2198
|
+
*/
|
|
2199
|
+
async highlight(
|
|
2200
|
+
session: BrowserSessionId,
|
|
2201
|
+
request: BrowserHighlightRequest,
|
|
2202
|
+
signal?: AbortSignal,
|
|
2203
|
+
): Promise<BrowserHighlightResult> {
|
|
2204
|
+
const s = this.session(session)
|
|
2205
|
+
const { handle } = this.activeTab(s)
|
|
2206
|
+
signal?.throwIfAborted()
|
|
2207
|
+
if (request.clear === true) {
|
|
2208
|
+
this.ignoreHostFailure(handle.sendCommand(CDP_OVERLAY_HIDE_HIGHLIGHT, {}))
|
|
2209
|
+
return { matched: false, cleared: true }
|
|
2210
|
+
}
|
|
2211
|
+
const selector = request.selector ?? ''
|
|
2212
|
+
if (selector === '') {
|
|
2213
|
+
throw new BrowserError('browser: highlight needs a selector, or clear: true', 'BROWSER_HIGHLIGHT_INVALID')
|
|
2214
|
+
}
|
|
2215
|
+
const doc = await handle.sendCommand('DOM.getDocument', {})
|
|
2216
|
+
const rootId = (doc as { root?: { nodeId?: number } }).root?.nodeId
|
|
2217
|
+
if (typeof rootId !== 'number') {
|
|
2218
|
+
throw new BrowserError('browser: could not read the document root', 'BROWSER_HIGHLIGHT_FAILED')
|
|
2219
|
+
}
|
|
2220
|
+
const found = await handle.sendCommand('DOM.querySelector', { nodeId: rootId, selector })
|
|
2221
|
+
const nodeId = (found as { nodeId?: number }).nodeId ?? 0
|
|
2222
|
+
if (nodeId === 0) return { matched: false, cleared: false }
|
|
2223
|
+
let box: { x: number; y: number; width: number; height: number } | undefined
|
|
2224
|
+
try {
|
|
2225
|
+
const model = await handle.sendCommand('DOM.getBoxModel', { nodeId })
|
|
2226
|
+
const quad = (model as { model?: { content?: number[] } }).model?.content
|
|
2227
|
+
if (Array.isArray(quad) && quad.length >= 8) {
|
|
2228
|
+
const xs = [quad[0], quad[2], quad[4], quad[6]]
|
|
2229
|
+
const ys = [quad[1], quad[3], quad[5], quad[7]]
|
|
2230
|
+
const x = Math.min(...xs)
|
|
2231
|
+
const y = Math.min(...ys)
|
|
2232
|
+
box = { x, y, width: Math.max(...xs) - x, height: Math.max(...ys) - y }
|
|
2233
|
+
}
|
|
2234
|
+
} catch { /* a zero-size element has no box model; the box is still highlighted */ }
|
|
2235
|
+
await handle.sendCommand(CDP_OVERLAY_ENABLE, {})
|
|
2236
|
+
await handle.sendCommand(CDP_OVERLAY_HIGHLIGHT_NODE, {
|
|
2237
|
+
nodeId,
|
|
2238
|
+
highlightConfig: {
|
|
2239
|
+
showInfo: true,
|
|
2240
|
+
contentColor: { r: 77, g: 107, b: 254, a: 0.28 },
|
|
2241
|
+
borderColor: { r: 77, g: 107, b: 254, a: 0.9 },
|
|
2242
|
+
},
|
|
2243
|
+
})
|
|
2244
|
+
this.record(s, 'highlight', { selector }, true, { result: `node ${nodeId}` })
|
|
2245
|
+
return { matched: true, cleared: false, nodeId, ...box !== undefined ? { box } : {} }
|
|
2246
|
+
}
|
|
2247
|
+
|
|
2248
|
+
|
|
2249
|
+
/** Print the active tab to a PDF file (Chrome's "Save as PDF"). */
|
|
2250
|
+
async pdf(
|
|
2251
|
+
session: BrowserSessionId,
|
|
2252
|
+
request: BrowserPdfRequest,
|
|
2253
|
+
signal?: AbortSignal,
|
|
2254
|
+
): Promise<BrowserPdfResult> {
|
|
2255
|
+
const s = this.session(session)
|
|
2256
|
+
const { handle } = this.activeTab(s)
|
|
2257
|
+
signal?.throwIfAborted()
|
|
2258
|
+
// Resolve first: a bad path must fail before we spend time printing.
|
|
2259
|
+
const target = resolveWritePath(request.savePath, this.writeRoots)
|
|
2260
|
+
// Electron's debugger does not implement CDP `Page.printToPDF`, so a host
|
|
2261
|
+
// that can print natively does it and hands back base64. Paper size arrives
|
|
2262
|
+
// in inches; Electron wants microns.
|
|
2263
|
+
const timeoutMs = 60_000
|
|
2264
|
+
const printOnce = async (): Promise<string> => {
|
|
2265
|
+
if (handle.printToPdf !== undefined) {
|
|
2266
|
+
const options: Record<string, unknown> = {
|
|
2267
|
+
// Backgrounds off by default would print dark pages as white paper.
|
|
2268
|
+
printBackground: request.printBackground !== false,
|
|
2269
|
+
landscape: request.landscape === true,
|
|
2270
|
+
}
|
|
2271
|
+
if (request.paperWidth !== undefined || request.paperHeight !== undefined) {
|
|
2272
|
+
options.pageSize = {
|
|
2273
|
+
width: Math.round((request.paperWidth ?? 8.5) * 25_400),
|
|
2274
|
+
height: Math.round((request.paperHeight ?? 11) * 25_400),
|
|
2275
|
+
}
|
|
2276
|
+
}
|
|
2277
|
+
const printed = await handle.printToPdf(options)
|
|
2278
|
+
return printed.base64
|
|
2279
|
+
}
|
|
2280
|
+
const params: Record<string, unknown> = {
|
|
2281
|
+
printBackground: request.printBackground !== false,
|
|
2282
|
+
landscape: request.landscape === true,
|
|
2283
|
+
}
|
|
2284
|
+
if (request.paperWidth !== undefined) params.paperWidth = request.paperWidth
|
|
2285
|
+
if (request.paperHeight !== undefined) params.paperHeight = request.paperHeight
|
|
2286
|
+
const result = await handle.sendCommand(CDP_PAGE_PRINT_TO_PDF, params)
|
|
2287
|
+
return typeof result.data === 'string' ? result.data : ''
|
|
2288
|
+
}
|
|
2289
|
+
const data = await withTimeout(printOnce(), timeoutMs, signal, `browser: pdf timed out after ${timeoutMs}ms`)
|
|
2290
|
+
if (data === '') {
|
|
2291
|
+
throw new BrowserError('browser: printToPDF returned no data', 'BROWSER_PDF_FAILED')
|
|
2292
|
+
}
|
|
2293
|
+
const bytes = Buffer.from(data, 'base64')
|
|
2294
|
+
writeFileSync(target, bytes)
|
|
2295
|
+
this.record(s, 'pdf', { savePath: request.savePath, landscape: request.landscape === true }, true, { result: `${bytes.length} bytes` })
|
|
2296
|
+
return { path: request.savePath, bytes: bytes.length }
|
|
2297
|
+
}
|
|
2298
|
+
|
|
2299
|
+
|
|
2088
2300
|
async screenshot(
|
|
2089
2301
|
session: BrowserSessionId,
|
|
2090
2302
|
request?: { readonly fullPage?: boolean; readonly savePath?: string },
|
|
@@ -2372,6 +2584,25 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
2372
2584
|
return this.localTaskInfo(s)
|
|
2373
2585
|
}
|
|
2374
2586
|
|
|
2587
|
+
/**
|
|
2588
|
+
* Mirror one task's Agent todo list into the host so the floating orb can
|
|
2589
|
+
* render it. The list is the DSH session projection for that task's session;
|
|
2590
|
+
* only this side can read it, and only the host can reach the chrome.
|
|
2591
|
+
*
|
|
2592
|
+
* Unknown task keys are still stored: an Agent usually writes its plan before
|
|
2593
|
+
* it opens a page, and the orb should show that plan the moment the task has a
|
|
2594
|
+
* view rather than wait for the next write.
|
|
2595
|
+
*/
|
|
2596
|
+
async pushTaskTodos(taskKey: string, todos: readonly BrowserTaskTodo[]): Promise<void> {
|
|
2597
|
+
this.taskTodos.set(taskKey, todos)
|
|
2598
|
+
if (typeof this.host.setTaskTodos !== 'function') return
|
|
2599
|
+
// 只在「这个任务真的开过浏览器」时才转发:宿主 RPC 的第一步是 ready(),
|
|
2600
|
+
// 而 ready() 会 spawn 一个 Electron 子进程 —— 一个和浏览器无关的 todo_write
|
|
2601
|
+
// 不该把几百 MB 的宿主拉起来。缓存照存,open() 会给新会话补推。
|
|
2602
|
+
if (!this.sessionsByTask.has(taskKey)) return
|
|
2603
|
+
await this.host.setTaskTodos(taskKey, todos).catch(() => undefined)
|
|
2604
|
+
}
|
|
2605
|
+
|
|
2375
2606
|
/** Hand control to the user or return it to Agent-driven actions. */
|
|
2376
2607
|
async setHandoff(session: BrowserSessionId, state: BrowserHandoffState): Promise<BrowserTaskInfo> {
|
|
2377
2608
|
return this.updateTask(session, state === 'waiting-user'
|
|
@@ -2474,6 +2705,35 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
2474
2705
|
}
|
|
2475
2706
|
}
|
|
2476
2707
|
|
|
2708
|
+
/**
|
|
2709
|
+
* Put the shared window on screen and raise it. With no session yet this
|
|
2710
|
+
* opens the default one, which spawns the host and creates the first view —
|
|
2711
|
+
* that is what makes the window appear. With a session already open the
|
|
2712
|
+
* window exists, so it only has to be raised.
|
|
2713
|
+
*/
|
|
2714
|
+
async ensureWindowVisible(): Promise<void> {
|
|
2715
|
+
// With no session yet this opens the default one. Its handle is lazy —
|
|
2716
|
+
// `createView` only records a deferred view — so the raise below is ALSO
|
|
2717
|
+
// what spawns the host and puts the window on screen. Returning right after
|
|
2718
|
+
// `open()` would leave the window to appear whenever some later call
|
|
2719
|
+
// happened to materialize the view.
|
|
2720
|
+
if (this.sessions.size === 0) await this.open()
|
|
2721
|
+
for (const s of this.sessions.values()) {
|
|
2722
|
+
const tab = s.tabs[s.activeIndex] ?? s.tabs[0]
|
|
2723
|
+
if (tab === undefined) continue
|
|
2724
|
+
if (tab.handle.focusWindow !== undefined) await tab.handle.focusWindow()
|
|
2725
|
+
break
|
|
2726
|
+
}
|
|
2727
|
+
// 「把窗口带出来」也是「有人要看了」——顺手把手里存的计划补推一遍。
|
|
2728
|
+
// 宿主重启会丢掉它内存里那份,而桥接只在清单**变化**时推;没有这一步,
|
|
2729
|
+
// 重启后球上会一直空着,直到 Agent 再写一次 todo(review 报的 M1)。
|
|
2730
|
+
for (const [taskKey, todos] of this.taskTodos) {
|
|
2731
|
+
if (!this.sessionsByTask.has(taskKey)) continue
|
|
2732
|
+
if (typeof this.host.setTaskTodos !== 'function') break
|
|
2733
|
+
await this.host.setTaskTodos(taskKey, todos).catch(() => undefined)
|
|
2734
|
+
}
|
|
2735
|
+
}
|
|
2736
|
+
|
|
2477
2737
|
/** Close the session and destroy all its views. Idempotent. */
|
|
2478
2738
|
close(session: BrowserSessionId): Promise<void> {
|
|
2479
2739
|
const existing = this.sessions.get(session)
|
|
@@ -2487,6 +2747,8 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
2487
2747
|
}
|
|
2488
2748
|
if (replacement === undefined) {
|
|
2489
2749
|
this.taskStates.delete(existing.taskKey)
|
|
2750
|
+
// 这张表和 taskStates 一样是「每个任务一条」,关掉最后一个会话就该回收。
|
|
2751
|
+
this.taskTodos.delete(existing.taskKey)
|
|
2490
2752
|
}
|
|
2491
2753
|
}
|
|
2492
2754
|
return Promise.resolve()
|
|
@@ -2588,8 +2850,24 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
2588
2850
|
}
|
|
2589
2851
|
const script = `(() => {
|
|
2590
2852
|
if (location.href !== ${JSON.stringify(record.url)}) return { stale: 'url changed' }
|
|
2853
|
+
// 框架里的元素:先进入对应的同源 iframe,坐标再加上框架自己的矩形。
|
|
2854
|
+
const frameIndex = ${JSON.stringify(target.frame ?? null)}
|
|
2855
|
+
let scope = document
|
|
2856
|
+
let dx = 0
|
|
2857
|
+
let dy = 0
|
|
2858
|
+
if (frameIndex !== null) {
|
|
2859
|
+
const frame = document.querySelectorAll('iframe')[frameIndex]
|
|
2860
|
+
if (!frame) return { stale: 'frame missing' }
|
|
2861
|
+
let inner = null
|
|
2862
|
+
try { inner = frame.contentDocument } catch { inner = null }
|
|
2863
|
+
if (inner === null) return { stale: 'frame is cross-origin' }
|
|
2864
|
+
scope = inner
|
|
2865
|
+
const fr = frame.getBoundingClientRect()
|
|
2866
|
+
dx = fr.left
|
|
2867
|
+
dy = fr.top
|
|
2868
|
+
}
|
|
2591
2869
|
let el
|
|
2592
|
-
try { el =
|
|
2870
|
+
try { el = scope.querySelector(${JSON.stringify(target.path)}) } catch { return { stale: 'selector invalid' } }
|
|
2593
2871
|
if (!el || el.closest('[data-dsh-browser-chrome]')) return { stale: 'element missing' }
|
|
2594
2872
|
const fingerprint = [
|
|
2595
2873
|
el.tagName,
|
|
@@ -2606,8 +2884,8 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
2606
2884
|
if (rect.width < 4 || rect.height < 4 || style.visibility === 'hidden' || style.display === 'none') return { stale: 'element hidden' }
|
|
2607
2885
|
const root = document.documentElement
|
|
2608
2886
|
return {
|
|
2609
|
-
x: Math.round(rect.x + rect.width / 2),
|
|
2610
|
-
y: Math.round(rect.y + rect.height / 2),
|
|
2887
|
+
x: Math.round(rect.x + rect.width / 2 + dx),
|
|
2888
|
+
y: Math.round(rect.y + rect.height / 2 + dy),
|
|
2611
2889
|
scrollX: window.scrollX,
|
|
2612
2890
|
scrollY: window.scrollY,
|
|
2613
2891
|
maxX: Math.max(0, root.scrollWidth - window.innerWidth),
|
|
@@ -24,7 +24,7 @@ import { createServer, type Server, type Socket } from 'node:net'
|
|
|
24
24
|
import { fileURLToPath } from 'node:url'
|
|
25
25
|
import type { ChromeHostEvent, ElectronBrowserViewHost, ElectronViewHandle } from './provider.ts'
|
|
26
26
|
import { BrowserError } from '../browser/types.ts'
|
|
27
|
-
import type { BrowserTaskInfo, BrowserTaskUpdate, ExportedCookie } from '../browser/types.ts'
|
|
27
|
+
import type { BrowserTaskInfo, BrowserTaskTodo, BrowserTaskUpdate, ExportedCookie } from '../browser/types.ts'
|
|
28
28
|
|
|
29
29
|
/** How long to wait for the child to signal readiness before failing. */
|
|
30
30
|
const READY_TIMEOUT_MS = 20_000
|
|
@@ -294,7 +294,8 @@ class ElectronChildClient {
|
|
|
294
294
|
delete env.NODE_OPTIONS
|
|
295
295
|
// The chrome's world is the child's choice, so it travels as an argument.
|
|
296
296
|
const childArgs = [
|
|
297
|
-
|
|
297
|
+
// 缺省也按 isolated 走:主世界会把 Agent 的计划暴露给页面脚本。
|
|
298
|
+
...this.chromeWorld === 'main' ? [] : ['--chrome-world', 'isolated'],
|
|
298
299
|
...this.fingerprintArgs,
|
|
299
300
|
]
|
|
300
301
|
this.child = spawn(electron, [hostMainPath, '--rpc-port', String(port), ...childArgs], {
|
|
@@ -481,6 +482,31 @@ class RemoteView implements ElectronViewHandle {
|
|
|
481
482
|
return this.client.call<{ base64: string; width: number; height: number }>('capture', { viewId: this.id }, RPC_TRANSFER_TIMEOUT_MS)
|
|
482
483
|
}
|
|
483
484
|
|
|
485
|
+
/**
|
|
486
|
+
* Print the view to a PDF in the child process. Electron's debugger has no
|
|
487
|
+
* `Page.printToPDF`, so this is the only working print path; the bytes cross
|
|
488
|
+
* the RPC socket base64-encoded.
|
|
489
|
+
*/
|
|
490
|
+
printToPdf(options: Record<string, unknown>): Promise<{ base64: string }> {
|
|
491
|
+
return this.client.call<{ base64: string }>('printToPdf', { viewId: this.id, options }, RPC_TRANSFER_TIMEOUT_MS)
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* Run a snippet in the world the *page's* injected chrome lives in.
|
|
496
|
+
*
|
|
497
|
+
* In isolated mode (the default) the chrome's globals are not reachable from
|
|
498
|
+
* the page's own world, and creating a world with the same name from here
|
|
499
|
+
* yields a different, empty context — the host has to resolve its own.
|
|
500
|
+
*/
|
|
501
|
+
chromeWorldEval(expression: string): Promise<{ result?: { value?: unknown } }> {
|
|
502
|
+
return this.client.call<{ result?: { value?: unknown } }>('chromeWorldEval', { viewId: this.id, expression }, RPC_QUERY_TIMEOUT_MS)
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/** Raise the child's window (show + restore + focus). */
|
|
506
|
+
focusWindow(): Promise<void> {
|
|
507
|
+
return this.client.call<Record<string, never>>('focusWindow', { viewId: this.id }, RPC_COMMAND_TIMEOUT_MS).then(() => undefined)
|
|
508
|
+
}
|
|
509
|
+
|
|
484
510
|
/** Export the session's cookies (login state). */
|
|
485
511
|
flushAuth(): Promise<ExportedCookie[]> {
|
|
486
512
|
return this.client.call<{ cookies: ExportedCookie[] }>('flushAuth', { viewId: this.id }, RPC_COMMAND_TIMEOUT_MS).then(r => r.cookies)
|
|
@@ -819,6 +845,14 @@ export class RemoteElectronViewHost implements ElectronBrowserViewHost {
|
|
|
819
845
|
return result.task ?? undefined
|
|
820
846
|
}
|
|
821
847
|
|
|
848
|
+
/** Mirror one task's Agent todo list into the self-hosted workspace. */
|
|
849
|
+
async setTaskTodos(key: string, todos: readonly BrowserTaskTodo[]): Promise<void> {
|
|
850
|
+
await this.ready()
|
|
851
|
+
const client = this.client
|
|
852
|
+
if (client === undefined) throw new Error('browser host unavailable')
|
|
853
|
+
await client.call<Record<string, never>>('setTaskTodos', { key, todos }, RPC_QUERY_TIMEOUT_MS)
|
|
854
|
+
}
|
|
855
|
+
|
|
822
856
|
/** Shut the child and the RPC server down. */
|
|
823
857
|
dispose(): void {
|
|
824
858
|
this.disposed = true
|
|
@@ -933,6 +967,19 @@ export class DeferredRemoteView implements ElectronViewHandle {
|
|
|
933
967
|
return this.withView(view => view.sendCommand(method, params), settle, method)
|
|
934
968
|
}
|
|
935
969
|
|
|
970
|
+
async printToPdf(options: Record<string, unknown>): Promise<{ base64: string }> {
|
|
971
|
+
return this.withView(view => view.printToPdf(options))
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
/** Evaluate in the injected chrome's own world (see the view-level method). */
|
|
975
|
+
async chromeWorldEval(expression: string): Promise<{ result?: { value?: unknown } }> {
|
|
976
|
+
return this.withView(view => view.chromeWorldEval(expression))
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
async focusWindow(): Promise<void> {
|
|
980
|
+
return this.withView(view => view.focusWindow())
|
|
981
|
+
}
|
|
982
|
+
|
|
936
983
|
async download(url: string, savePath: string): Promise<void> {
|
|
937
984
|
return this.withView(view => view.download(url, savePath))
|
|
938
985
|
}
|