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
|
@@ -44,6 +44,12 @@ const AGENT_INPUT_SUPPRESSION_MS = 900;
|
|
|
44
44
|
export const ELECTRON_BROWSER_PROVIDER_ID = 'electron';
|
|
45
45
|
/** CDP method for a full-page screenshot capture. */
|
|
46
46
|
export const CDP_PAGE_CAPTURE_SCREENSHOT = 'Page.captureScreenshot';
|
|
47
|
+
/** CDP method that renders the current document to PDF. */
|
|
48
|
+
export const CDP_PAGE_PRINT_TO_PDF = 'Page.printToPDF';
|
|
49
|
+
/** CDP methods that draw the DevTools highlight box without touching the DOM. */
|
|
50
|
+
export const CDP_OVERLAY_ENABLE = 'Overlay.enable';
|
|
51
|
+
export const CDP_OVERLAY_HIGHLIGHT_NODE = 'Overlay.highlightNode';
|
|
52
|
+
export const CDP_OVERLAY_HIDE_HIGHLIGHT = 'Overlay.hideHighlight';
|
|
47
53
|
/** CDP method for runtime evaluation (the execute path). */
|
|
48
54
|
export const CDP_RUNTIME_EVALUATE = 'Runtime.evaluate';
|
|
49
55
|
/**
|
|
@@ -196,7 +202,6 @@ export const CDP_PAGE_NAVIGATE_TO_HISTORY_ENTRY = 'Page.navigateToHistoryEntry';
|
|
|
196
202
|
export const CDP_PAGE_RELOAD = 'Page.reload';
|
|
197
203
|
export const CDP_PAGE_STOP_LOADING = 'Page.stopLoading';
|
|
198
204
|
/** Cap on content returned by a snapshot fetch to keep the wire bounded. */
|
|
199
|
-
const SNAPSHOT_LABEL_MAX = 120;
|
|
200
205
|
/** An input dispatch must not outlive this: a blocked renderer never acknowledges. */
|
|
201
206
|
const INPUT_DISPATCH_TIMEOUT_MS = 15_000;
|
|
202
207
|
/**
|
|
@@ -327,6 +332,14 @@ export class ElectronBrowserProvider {
|
|
|
327
332
|
/** Stable task-key index so callers can recover a session after tool-layer state loss. */
|
|
328
333
|
sessionsByTask = new Map();
|
|
329
334
|
taskStates = new Map();
|
|
335
|
+
/**
|
|
336
|
+
* The Agent's todo list per task, kept here as well as in the host.
|
|
337
|
+
*
|
|
338
|
+
* The host's copy is in memory and dies with the host process, and the bridge
|
|
339
|
+
* only pushes on a todo change — so without this cache a host restart left the
|
|
340
|
+
* floating orb with no plan until the Agent happened to write its list again.
|
|
341
|
+
*/
|
|
342
|
+
taskTodos = new Map();
|
|
330
343
|
httpOnly;
|
|
331
344
|
snapshotMaxElements;
|
|
332
345
|
contentMaxChars;
|
|
@@ -449,12 +462,24 @@ export class ElectronBrowserProvider {
|
|
|
449
462
|
if (typeof labelable?.label === 'function')
|
|
450
463
|
await labelable.label(taskLabel).catch(() => undefined);
|
|
451
464
|
}
|
|
465
|
+
// 宿主重启后它内存里那份计划是空的,而这是**已存在会话**唯一会走到的入口
|
|
466
|
+
// (ensureWindowVisible 也补推一次)—— 不推的话球上会一直空着。
|
|
467
|
+
const cached = this.taskTodos.get(taskKey);
|
|
468
|
+
if (cached !== undefined && typeof this.host.setTaskTodos === 'function') {
|
|
469
|
+
void this.host.setTaskTodos(taskKey, cached).catch(() => undefined);
|
|
470
|
+
}
|
|
452
471
|
return existing.id;
|
|
453
472
|
}
|
|
454
473
|
const handle = this.host.createView(taskKey, taskLabel === '' ? undefined : taskLabel);
|
|
455
474
|
const id = `browser:${randomUUID()}`;
|
|
456
475
|
this.sessions.set(id, { id, taskKey, taskLabel, tabs: [this.createTab(handle)], activeIndex: 0, history: [], nextSeq: 1 });
|
|
457
476
|
this.sessionsByTask.set(taskKey, id);
|
|
477
|
+
// A fresh view means a host that may never have seen this task's plan (a host
|
|
478
|
+
// restart drops the host's copy), so replay what the Agent last wrote.
|
|
479
|
+
const cachedTodos = this.taskTodos.get(taskKey);
|
|
480
|
+
if (cachedTodos !== undefined && typeof this.host.setTaskTodos === 'function') {
|
|
481
|
+
void this.host.setTaskTodos(taskKey, cachedTodos).catch(() => undefined);
|
|
482
|
+
}
|
|
458
483
|
if (!this.taskStates.has(taskKey)) {
|
|
459
484
|
this.taskStates.set(taskKey, { status: 'idle', control: 'agent', updatedAt: Date.now() });
|
|
460
485
|
}
|
|
@@ -756,7 +781,7 @@ export class ElectronBrowserProvider {
|
|
|
756
781
|
if (text) return tag + ':has-text("' + text.replace(/"/g, '\\\\"') + '")'
|
|
757
782
|
return tag
|
|
758
783
|
}
|
|
759
|
-
const pathOf = (el) => {
|
|
784
|
+
const pathOf = (el, doc) => {
|
|
760
785
|
if (el.id) return '#' + CSS.escape(el.id)
|
|
761
786
|
const parts = []
|
|
762
787
|
let node = el
|
|
@@ -768,7 +793,7 @@ export class ElectronBrowserProvider {
|
|
|
768
793
|
if (siblings.length > 1) part += ':nth-of-type(' + (siblings.indexOf(node) + 1) + ')'
|
|
769
794
|
}
|
|
770
795
|
parts.unshift(part)
|
|
771
|
-
if (node ===
|
|
796
|
+
if (node === doc.body) break
|
|
772
797
|
node = parent
|
|
773
798
|
}
|
|
774
799
|
return parts.join(' > ')
|
|
@@ -783,42 +808,65 @@ export class ElectronBrowserProvider {
|
|
|
783
808
|
].join('\u001f')
|
|
784
809
|
const url = location.href
|
|
785
810
|
const title = document.title || undefined
|
|
786
|
-
const els = [...document.querySelectorAll('input, textarea, select, button, a[href], [role="button"], [role="searchbox"], [contenteditable="true"]')]
|
|
787
811
|
const out = []
|
|
788
812
|
let capped = false
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
const
|
|
792
|
-
const
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
813
|
+
// 同一套判定,用在顶层文档和每个同源子框架上;dx/dy 把框架内的坐标换算成页面坐标。
|
|
814
|
+
const collect = (doc, frameIndex, dx, dy) => {
|
|
815
|
+
const els = [...doc.querySelectorAll('input, textarea, select, button, a[href], [role="button"], [role="searchbox"], [role="link"], [contenteditable="true"]')]
|
|
816
|
+
for (const el of els) {
|
|
817
|
+
if (el.closest('[data-dsh-browser-chrome]')) continue
|
|
818
|
+
const r = el.getBoundingClientRect()
|
|
819
|
+
const cs = getComputedStyle(el)
|
|
820
|
+
if (r.width < 4 || r.height < 4 || cs.visibility === 'hidden' || cs.display === 'none') continue
|
|
821
|
+
const kind = el.tagName === 'INPUT' ? (el.type === 'checkbox' ? 'checkbox' : (el.type === 'submit' || el.type === 'button' ? 'button' : 'text'))
|
|
822
|
+
: el.tagName === 'TEXTAREA' ? 'textarea'
|
|
823
|
+
: el.tagName === 'SELECT' ? 'select'
|
|
824
|
+
: el.tagName === 'BUTTON' ? 'button'
|
|
825
|
+
: el.tagName === 'A' ? 'link' : 'other'
|
|
826
|
+
const label = (el.getAttribute('aria-label') || el.placeholder || el.textContent || el.value || el.name || el.id || '').replace(/\s+/g, ' ').trim().slice(0, 120)
|
|
827
|
+
if (!label && kind !== 'link') continue
|
|
828
|
+
// 过滤放在计上限**之前**:默认上限是 60,先截断就永远搜不到后面的元素。
|
|
829
|
+
if (query !== '' && (kind + ' ' + label).toLowerCase().indexOf(query) === -1) continue
|
|
830
|
+
if (out.length >= cap) { capped = true; return }
|
|
831
|
+
out.push({
|
|
832
|
+
ref: out.length + 1,
|
|
833
|
+
kind,
|
|
834
|
+
label,
|
|
835
|
+
...(frameIndex === undefined ? {} : { frame: frameIndex }),
|
|
836
|
+
selector: el.id ? '#' + CSS.escape(el.id) : (el.name ? '[name=' + JSON.stringify(el.name) + ']' : ''),
|
|
837
|
+
loc: locatorOf(el),
|
|
838
|
+
path: pathOf(el, doc),
|
|
839
|
+
fingerprint: fingerprintOf(el),
|
|
840
|
+
x: Math.round(r.x + r.width / 2 + dx),
|
|
841
|
+
y: Math.round(r.y + r.height / 2 + dy),
|
|
842
|
+
})
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
collect(document, undefined, 0, 0)
|
|
846
|
+
// 子框架:同源的走进去(元素带 frame 序号、坐标加上框架自己的矩形);
|
|
847
|
+
// 跨源的 contentDocument 读不到,就只记一条 readable:false —— 不假装能读。
|
|
848
|
+
const frames = []
|
|
849
|
+
const frameEls = [...document.querySelectorAll('iframe')]
|
|
850
|
+
for (let i = 0; i < frameEls.length; i++) {
|
|
851
|
+
const frame = frameEls[i]
|
|
852
|
+
const src = frame.getAttribute('src') || ''
|
|
853
|
+
let inner = null
|
|
854
|
+
try { inner = frame.contentDocument } catch (error) { inner = null }
|
|
855
|
+
if (inner === null) {
|
|
856
|
+
frames.push({ index: i, url: src, readable: false })
|
|
857
|
+
continue
|
|
858
|
+
}
|
|
859
|
+
const fr = frame.getBoundingClientRect()
|
|
860
|
+
frames.push({ index: i, url: inner.location.href || src, readable: true })
|
|
861
|
+
if (out.length >= cap) { capped = true; continue }
|
|
862
|
+
collect(inner, i, fr.left, fr.top)
|
|
815
863
|
}
|
|
816
864
|
const challenge = ${CHALLENGE_DETECT_EXPRESSION}
|
|
817
865
|
const chromeHost = document.getElementById('__dsh_browser_chrome_host__')
|
|
818
866
|
const userControlling = chromeHost?.getAttribute('data-dsh-user-active') === '1'
|
|
819
867
|
// Only an early break means truncation: exactly cap candidates is a
|
|
820
868
|
// complete inventory, not a truncated one.
|
|
821
|
-
return { url, title, elements: out, truncated: capped, challenge, userControlling }
|
|
869
|
+
return { url, title, elements: out, truncated: capped, frames, challenge, userControlling }
|
|
822
870
|
})()`;
|
|
823
871
|
// Same hang guard as execute: a renderer that has not committed after
|
|
824
872
|
// navigate would otherwise block snapshot forever.
|
|
@@ -851,7 +899,11 @@ export class ElectronBrowserProvider {
|
|
|
851
899
|
const snapshotId = `snapshot:${randomUUID()}`;
|
|
852
900
|
const targets = new Map();
|
|
853
901
|
for (const element of value.elements) {
|
|
854
|
-
targets.set(element.ref, {
|
|
902
|
+
targets.set(element.ref, {
|
|
903
|
+
path: element.path,
|
|
904
|
+
fingerprint: element.fingerprint,
|
|
905
|
+
...element.frame !== undefined ? { frame: element.frame } : {},
|
|
906
|
+
});
|
|
855
907
|
}
|
|
856
908
|
tab.snapshots.set(snapshotId, { tabId: tab.id, url: value.url, epoch: tab.navigationEpoch, targets });
|
|
857
909
|
while (tab.snapshots.size > 10) {
|
|
@@ -865,6 +917,7 @@ export class ElectronBrowserProvider {
|
|
|
865
917
|
url: value.url,
|
|
866
918
|
...value.title !== undefined ? { title: value.title } : {},
|
|
867
919
|
elements: value.elements.map(({ path: _path, fingerprint: _fingerprint, ...element }) => element),
|
|
920
|
+
...value.frames !== undefined ? { frames: value.frames } : {},
|
|
868
921
|
truncated: value.truncated,
|
|
869
922
|
...value.challenge !== undefined ? { challenge: value.challenge } : {},
|
|
870
923
|
...value.userControlling !== undefined ? { userControlling: value.userControlling } : {},
|
|
@@ -1126,27 +1179,55 @@ export class ElectronBrowserProvider {
|
|
|
1126
1179
|
const s = this.session(session);
|
|
1127
1180
|
return this.waitForElementTab(s, this.activeTab(s), request, signal);
|
|
1128
1181
|
}
|
|
1129
|
-
/** Poll one tab until the selector
|
|
1182
|
+
/** Poll one tab until the selector and/or text reaches the requested state. */
|
|
1130
1183
|
async waitForElementTab(s, tab, request, signal) {
|
|
1131
1184
|
const { handle } = tab;
|
|
1132
1185
|
signal?.throwIfAborted();
|
|
1133
1186
|
const timeoutMs = request.timeoutMs ?? 15_000;
|
|
1134
|
-
const
|
|
1135
|
-
const
|
|
1187
|
+
const selector = request.selector ?? '';
|
|
1188
|
+
const wanted = request.text;
|
|
1189
|
+
const state = request.state ?? (request.visible === false ? 'attached' : 'visible');
|
|
1190
|
+
if (selector === '' && wanted === undefined) {
|
|
1191
|
+
throw new BrowserError('browser: waitForElement needs a selector, some text, or both', 'BROWSER_WAIT_INVALID');
|
|
1192
|
+
}
|
|
1193
|
+
if (selector === '' && (state === 'hidden' || state === 'detached')) {
|
|
1194
|
+
throw new BrowserError(`browser: state "${state}" needs a selector to watch disappear`, 'BROWSER_WAIT_INVALID');
|
|
1195
|
+
}
|
|
1136
1196
|
const script = `(() => {
|
|
1197
|
+
const selector = ${JSON.stringify(selector)}
|
|
1198
|
+
const wanted = ${JSON.stringify(wanted ?? null)}
|
|
1199
|
+
const state = ${JSON.stringify(state)}
|
|
1137
1200
|
let el = null
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1201
|
+
if (selector !== '') {
|
|
1202
|
+
try { el = document.querySelector(selector) } catch (e) { return { error: String(e) } }
|
|
1203
|
+
}
|
|
1204
|
+
const visibleNow = node => {
|
|
1205
|
+
if (node === null) return false
|
|
1206
|
+
const r = node.getBoundingClientRect()
|
|
1207
|
+
if (r.width < 4 || r.height < 4) return false
|
|
1208
|
+
const cs = getComputedStyle(node)
|
|
1209
|
+
return cs.visibility !== 'hidden' && cs.display !== 'none'
|
|
1144
1210
|
}
|
|
1211
|
+
const textOf = node => (node === null ? '' : (node.textContent || '')).replace(/\\s+/g, ' ').trim()
|
|
1212
|
+
const attached = el !== null
|
|
1213
|
+
const visible = visibleNow(el)
|
|
1214
|
+
let met
|
|
1215
|
+
if (selector === '') {
|
|
1216
|
+
// 只等文字时没有元素可量:文字在不在,本身就是那个状态。
|
|
1217
|
+
// (以前这里仍按 visibleNow(null) === false 判定,于是默认的 visible 永远不成立。)
|
|
1218
|
+
const present = textOf(document.body).includes(wanted)
|
|
1219
|
+
met = state === 'hidden' || state === 'detached' ? !present : present
|
|
1220
|
+
} else {
|
|
1221
|
+
met = state === 'attached' ? attached : state === 'visible' ? visible : state === 'hidden' ? !visible : state === 'detached' ? !attached : false
|
|
1222
|
+
if (met && wanted !== null) met = textOf(el).includes(wanted)
|
|
1223
|
+
}
|
|
1224
|
+
if (!met) return null
|
|
1145
1225
|
return {
|
|
1146
1226
|
found: true,
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1227
|
+
state,
|
|
1228
|
+
selector,
|
|
1229
|
+
tag: attached ? el.tagName.toLowerCase() : '',
|
|
1230
|
+
text: (wanted !== null && !attached ? wanted : textOf(selector === '' ? document.body : el)).slice(0, 200),
|
|
1150
1231
|
}
|
|
1151
1232
|
})()`;
|
|
1152
1233
|
const deadline = Date.now() + timeoutMs;
|
|
@@ -1160,8 +1241,8 @@ export class ElectronBrowserProvider {
|
|
|
1160
1241
|
else {
|
|
1161
1242
|
const value = result.value;
|
|
1162
1243
|
if (value?.found === true && typeof value.tag === 'string') {
|
|
1163
|
-
this.record(s, 'waitForElement', { selector,
|
|
1164
|
-
return { found: true, selector, tag: value.tag, text: value.text ?? '' };
|
|
1244
|
+
this.record(s, 'waitForElement', { selector, text: wanted, state, timeoutMs }, true, { result: value.tag ?? '' });
|
|
1245
|
+
return { found: true, state, selector, tag: value.tag, text: value.text ?? '' };
|
|
1165
1246
|
}
|
|
1166
1247
|
if (value?.error !== undefined) {
|
|
1167
1248
|
// A malformed selector can never start matching, so fail now instead of
|
|
@@ -1171,7 +1252,8 @@ export class ElectronBrowserProvider {
|
|
|
1171
1252
|
}
|
|
1172
1253
|
await new Promise(resolve => setTimeout(resolve, Math.min(250, Math.max(deadline - Date.now(), 1))));
|
|
1173
1254
|
}
|
|
1174
|
-
|
|
1255
|
+
const what = selector === '' ? `text ${JSON.stringify(wanted)}` : `"${selector}"`;
|
|
1256
|
+
throw new BrowserError(`browser: ${what} did not reach state "${state}" within ${timeoutMs}ms${lastError !== undefined ? ` (${lastError})` : ''}`, 'BROWSER_WAIT_TIMEOUT');
|
|
1175
1257
|
}
|
|
1176
1258
|
/** Type into the focused element. */
|
|
1177
1259
|
async type(session, request, signal) {
|
|
@@ -1664,6 +1746,103 @@ export class ElectronBrowserProvider {
|
|
|
1664
1746
|
this.showActive(s);
|
|
1665
1747
|
}
|
|
1666
1748
|
/** Capture the current page, optionally full-page. PNG only (CDP JPEG hangs on Electron 43). */
|
|
1749
|
+
/**
|
|
1750
|
+
* Draw the DevTools highlight box over the first match of a selector, or clear it.
|
|
1751
|
+
* Uses CDP Overlay, so the page DOM is never touched.
|
|
1752
|
+
*/
|
|
1753
|
+
async highlight(session, request, signal) {
|
|
1754
|
+
const s = this.session(session);
|
|
1755
|
+
const { handle } = this.activeTab(s);
|
|
1756
|
+
signal?.throwIfAborted();
|
|
1757
|
+
if (request.clear === true) {
|
|
1758
|
+
this.ignoreHostFailure(handle.sendCommand(CDP_OVERLAY_HIDE_HIGHLIGHT, {}));
|
|
1759
|
+
return { matched: false, cleared: true };
|
|
1760
|
+
}
|
|
1761
|
+
const selector = request.selector ?? '';
|
|
1762
|
+
if (selector === '') {
|
|
1763
|
+
throw new BrowserError('browser: highlight needs a selector, or clear: true', 'BROWSER_HIGHLIGHT_INVALID');
|
|
1764
|
+
}
|
|
1765
|
+
const doc = await handle.sendCommand('DOM.getDocument', {});
|
|
1766
|
+
const rootId = doc.root?.nodeId;
|
|
1767
|
+
if (typeof rootId !== 'number') {
|
|
1768
|
+
throw new BrowserError('browser: could not read the document root', 'BROWSER_HIGHLIGHT_FAILED');
|
|
1769
|
+
}
|
|
1770
|
+
const found = await handle.sendCommand('DOM.querySelector', { nodeId: rootId, selector });
|
|
1771
|
+
const nodeId = found.nodeId ?? 0;
|
|
1772
|
+
if (nodeId === 0)
|
|
1773
|
+
return { matched: false, cleared: false };
|
|
1774
|
+
let box;
|
|
1775
|
+
try {
|
|
1776
|
+
const model = await handle.sendCommand('DOM.getBoxModel', { nodeId });
|
|
1777
|
+
const quad = model.model?.content;
|
|
1778
|
+
if (Array.isArray(quad) && quad.length >= 8) {
|
|
1779
|
+
const xs = [quad[0], quad[2], quad[4], quad[6]];
|
|
1780
|
+
const ys = [quad[1], quad[3], quad[5], quad[7]];
|
|
1781
|
+
const x = Math.min(...xs);
|
|
1782
|
+
const y = Math.min(...ys);
|
|
1783
|
+
box = { x, y, width: Math.max(...xs) - x, height: Math.max(...ys) - y };
|
|
1784
|
+
}
|
|
1785
|
+
}
|
|
1786
|
+
catch { /* a zero-size element has no box model; the box is still highlighted */ }
|
|
1787
|
+
await handle.sendCommand(CDP_OVERLAY_ENABLE, {});
|
|
1788
|
+
await handle.sendCommand(CDP_OVERLAY_HIGHLIGHT_NODE, {
|
|
1789
|
+
nodeId,
|
|
1790
|
+
highlightConfig: {
|
|
1791
|
+
showInfo: true,
|
|
1792
|
+
contentColor: { r: 77, g: 107, b: 254, a: 0.28 },
|
|
1793
|
+
borderColor: { r: 77, g: 107, b: 254, a: 0.9 },
|
|
1794
|
+
},
|
|
1795
|
+
});
|
|
1796
|
+
this.record(s, 'highlight', { selector }, true, { result: `node ${nodeId}` });
|
|
1797
|
+
return { matched: true, cleared: false, nodeId, ...box !== undefined ? { box } : {} };
|
|
1798
|
+
}
|
|
1799
|
+
/** Print the active tab to a PDF file (Chrome's "Save as PDF"). */
|
|
1800
|
+
async pdf(session, request, signal) {
|
|
1801
|
+
const s = this.session(session);
|
|
1802
|
+
const { handle } = this.activeTab(s);
|
|
1803
|
+
signal?.throwIfAborted();
|
|
1804
|
+
// Resolve first: a bad path must fail before we spend time printing.
|
|
1805
|
+
const target = resolveWritePath(request.savePath, this.writeRoots);
|
|
1806
|
+
// Electron's debugger does not implement CDP `Page.printToPDF`, so a host
|
|
1807
|
+
// that can print natively does it and hands back base64. Paper size arrives
|
|
1808
|
+
// in inches; Electron wants microns.
|
|
1809
|
+
const timeoutMs = 60_000;
|
|
1810
|
+
const printOnce = async () => {
|
|
1811
|
+
if (handle.printToPdf !== undefined) {
|
|
1812
|
+
const options = {
|
|
1813
|
+
// Backgrounds off by default would print dark pages as white paper.
|
|
1814
|
+
printBackground: request.printBackground !== false,
|
|
1815
|
+
landscape: request.landscape === true,
|
|
1816
|
+
};
|
|
1817
|
+
if (request.paperWidth !== undefined || request.paperHeight !== undefined) {
|
|
1818
|
+
options.pageSize = {
|
|
1819
|
+
width: Math.round((request.paperWidth ?? 8.5) * 25_400),
|
|
1820
|
+
height: Math.round((request.paperHeight ?? 11) * 25_400),
|
|
1821
|
+
};
|
|
1822
|
+
}
|
|
1823
|
+
const printed = await handle.printToPdf(options);
|
|
1824
|
+
return printed.base64;
|
|
1825
|
+
}
|
|
1826
|
+
const params = {
|
|
1827
|
+
printBackground: request.printBackground !== false,
|
|
1828
|
+
landscape: request.landscape === true,
|
|
1829
|
+
};
|
|
1830
|
+
if (request.paperWidth !== undefined)
|
|
1831
|
+
params.paperWidth = request.paperWidth;
|
|
1832
|
+
if (request.paperHeight !== undefined)
|
|
1833
|
+
params.paperHeight = request.paperHeight;
|
|
1834
|
+
const result = await handle.sendCommand(CDP_PAGE_PRINT_TO_PDF, params);
|
|
1835
|
+
return typeof result.data === 'string' ? result.data : '';
|
|
1836
|
+
};
|
|
1837
|
+
const data = await withTimeout(printOnce(), timeoutMs, signal, `browser: pdf timed out after ${timeoutMs}ms`);
|
|
1838
|
+
if (data === '') {
|
|
1839
|
+
throw new BrowserError('browser: printToPDF returned no data', 'BROWSER_PDF_FAILED');
|
|
1840
|
+
}
|
|
1841
|
+
const bytes = Buffer.from(data, 'base64');
|
|
1842
|
+
writeFileSync(target, bytes);
|
|
1843
|
+
this.record(s, 'pdf', { savePath: request.savePath, landscape: request.landscape === true }, true, { result: `${bytes.length} bytes` });
|
|
1844
|
+
return { path: request.savePath, bytes: bytes.length };
|
|
1845
|
+
}
|
|
1667
1846
|
async screenshot(session, request, signal) {
|
|
1668
1847
|
const s = this.session(session);
|
|
1669
1848
|
const { handle } = this.activeTab(s);
|
|
@@ -1932,6 +2111,26 @@ export class ElectronBrowserProvider {
|
|
|
1932
2111
|
}
|
|
1933
2112
|
return this.localTaskInfo(s);
|
|
1934
2113
|
}
|
|
2114
|
+
/**
|
|
2115
|
+
* Mirror one task's Agent todo list into the host so the floating orb can
|
|
2116
|
+
* render it. The list is the DSH session projection for that task's session;
|
|
2117
|
+
* only this side can read it, and only the host can reach the chrome.
|
|
2118
|
+
*
|
|
2119
|
+
* Unknown task keys are still stored: an Agent usually writes its plan before
|
|
2120
|
+
* it opens a page, and the orb should show that plan the moment the task has a
|
|
2121
|
+
* view rather than wait for the next write.
|
|
2122
|
+
*/
|
|
2123
|
+
async pushTaskTodos(taskKey, todos) {
|
|
2124
|
+
this.taskTodos.set(taskKey, todos);
|
|
2125
|
+
if (typeof this.host.setTaskTodos !== 'function')
|
|
2126
|
+
return;
|
|
2127
|
+
// 只在「这个任务真的开过浏览器」时才转发:宿主 RPC 的第一步是 ready(),
|
|
2128
|
+
// 而 ready() 会 spawn 一个 Electron 子进程 —— 一个和浏览器无关的 todo_write
|
|
2129
|
+
// 不该把几百 MB 的宿主拉起来。缓存照存,open() 会给新会话补推。
|
|
2130
|
+
if (!this.sessionsByTask.has(taskKey))
|
|
2131
|
+
return;
|
|
2132
|
+
await this.host.setTaskTodos(taskKey, todos).catch(() => undefined);
|
|
2133
|
+
}
|
|
1935
2134
|
/** Hand control to the user or return it to Agent-driven actions. */
|
|
1936
2135
|
async setHandoff(session, state) {
|
|
1937
2136
|
return this.updateTask(session, state === 'waiting-user'
|
|
@@ -2030,6 +2229,39 @@ export class ElectronBrowserProvider {
|
|
|
2030
2229
|
throw new BrowserError(`browser: history seq ${seq} action "${entry.action}" is not replayable`, 'BROWSER_HISTORY_NOT_REPLAYABLE');
|
|
2031
2230
|
}
|
|
2032
2231
|
}
|
|
2232
|
+
/**
|
|
2233
|
+
* Put the shared window on screen and raise it. With no session yet this
|
|
2234
|
+
* opens the default one, which spawns the host and creates the first view —
|
|
2235
|
+
* that is what makes the window appear. With a session already open the
|
|
2236
|
+
* window exists, so it only has to be raised.
|
|
2237
|
+
*/
|
|
2238
|
+
async ensureWindowVisible() {
|
|
2239
|
+
// With no session yet this opens the default one. Its handle is lazy —
|
|
2240
|
+
// `createView` only records a deferred view — so the raise below is ALSO
|
|
2241
|
+
// what spawns the host and puts the window on screen. Returning right after
|
|
2242
|
+
// `open()` would leave the window to appear whenever some later call
|
|
2243
|
+
// happened to materialize the view.
|
|
2244
|
+
if (this.sessions.size === 0)
|
|
2245
|
+
await this.open();
|
|
2246
|
+
for (const s of this.sessions.values()) {
|
|
2247
|
+
const tab = s.tabs[s.activeIndex] ?? s.tabs[0];
|
|
2248
|
+
if (tab === undefined)
|
|
2249
|
+
continue;
|
|
2250
|
+
if (tab.handle.focusWindow !== undefined)
|
|
2251
|
+
await tab.handle.focusWindow();
|
|
2252
|
+
break;
|
|
2253
|
+
}
|
|
2254
|
+
// 「把窗口带出来」也是「有人要看了」——顺手把手里存的计划补推一遍。
|
|
2255
|
+
// 宿主重启会丢掉它内存里那份,而桥接只在清单**变化**时推;没有这一步,
|
|
2256
|
+
// 重启后球上会一直空着,直到 Agent 再写一次 todo(review 报的 M1)。
|
|
2257
|
+
for (const [taskKey, todos] of this.taskTodos) {
|
|
2258
|
+
if (!this.sessionsByTask.has(taskKey))
|
|
2259
|
+
continue;
|
|
2260
|
+
if (typeof this.host.setTaskTodos !== 'function')
|
|
2261
|
+
break;
|
|
2262
|
+
await this.host.setTaskTodos(taskKey, todos).catch(() => undefined);
|
|
2263
|
+
}
|
|
2264
|
+
}
|
|
2033
2265
|
/** Close the session and destroy all its views. Idempotent. */
|
|
2034
2266
|
close(session) {
|
|
2035
2267
|
const existing = this.sessions.get(session);
|
|
@@ -2046,6 +2278,8 @@ export class ElectronBrowserProvider {
|
|
|
2046
2278
|
}
|
|
2047
2279
|
if (replacement === undefined) {
|
|
2048
2280
|
this.taskStates.delete(existing.taskKey);
|
|
2281
|
+
// 这张表和 taskStates 一样是「每个任务一条」,关掉最后一个会话就该回收。
|
|
2282
|
+
this.taskTodos.delete(existing.taskKey);
|
|
2049
2283
|
}
|
|
2050
2284
|
}
|
|
2051
2285
|
return Promise.resolve();
|
|
@@ -2123,8 +2357,24 @@ export class ElectronBrowserProvider {
|
|
|
2123
2357
|
}
|
|
2124
2358
|
const script = `(() => {
|
|
2125
2359
|
if (location.href !== ${JSON.stringify(record.url)}) return { stale: 'url changed' }
|
|
2360
|
+
// 框架里的元素:先进入对应的同源 iframe,坐标再加上框架自己的矩形。
|
|
2361
|
+
const frameIndex = ${JSON.stringify(target.frame ?? null)}
|
|
2362
|
+
let scope = document
|
|
2363
|
+
let dx = 0
|
|
2364
|
+
let dy = 0
|
|
2365
|
+
if (frameIndex !== null) {
|
|
2366
|
+
const frame = document.querySelectorAll('iframe')[frameIndex]
|
|
2367
|
+
if (!frame) return { stale: 'frame missing' }
|
|
2368
|
+
let inner = null
|
|
2369
|
+
try { inner = frame.contentDocument } catch { inner = null }
|
|
2370
|
+
if (inner === null) return { stale: 'frame is cross-origin' }
|
|
2371
|
+
scope = inner
|
|
2372
|
+
const fr = frame.getBoundingClientRect()
|
|
2373
|
+
dx = fr.left
|
|
2374
|
+
dy = fr.top
|
|
2375
|
+
}
|
|
2126
2376
|
let el
|
|
2127
|
-
try { el =
|
|
2377
|
+
try { el = scope.querySelector(${JSON.stringify(target.path)}) } catch { return { stale: 'selector invalid' } }
|
|
2128
2378
|
if (!el || el.closest('[data-dsh-browser-chrome]')) return { stale: 'element missing' }
|
|
2129
2379
|
const fingerprint = [
|
|
2130
2380
|
el.tagName,
|
|
@@ -2141,8 +2391,8 @@ export class ElectronBrowserProvider {
|
|
|
2141
2391
|
if (rect.width < 4 || rect.height < 4 || style.visibility === 'hidden' || style.display === 'none') return { stale: 'element hidden' }
|
|
2142
2392
|
const root = document.documentElement
|
|
2143
2393
|
return {
|
|
2144
|
-
x: Math.round(rect.x + rect.width / 2),
|
|
2145
|
-
y: Math.round(rect.y + rect.height / 2),
|
|
2394
|
+
x: Math.round(rect.x + rect.width / 2 + dx),
|
|
2395
|
+
y: Math.round(rect.y + rect.height / 2 + dy),
|
|
2146
2396
|
scrollX: window.scrollX,
|
|
2147
2397
|
scrollY: window.scrollY,
|
|
2148
2398
|
maxX: Math.max(0, root.scrollWidth - window.innerWidth),
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* @module dsh-browser-plus/browser-electron/remote-host
|
|
17
17
|
*/
|
|
18
18
|
import type { ChromeHostEvent, ElectronBrowserViewHost, ElectronViewHandle } from './provider.ts';
|
|
19
|
-
import type { BrowserTaskInfo, BrowserTaskUpdate } from '../browser/types.ts';
|
|
19
|
+
import type { BrowserTaskInfo, BrowserTaskTodo, BrowserTaskUpdate } from '../browser/types.ts';
|
|
20
20
|
/**
|
|
21
21
|
* Whether a usable Electron binary can be located right now. Cheap and local:
|
|
22
22
|
* it only probes package metadata and the filesystem (no spawn, no network).
|
|
@@ -136,6 +136,8 @@ export declare class RemoteElectronViewHost implements ElectronBrowserViewHost {
|
|
|
136
136
|
getTask(key: string): Promise<BrowserTaskInfo | undefined>;
|
|
137
137
|
/** Update one task summary in the self-hosted visible workspace. */
|
|
138
138
|
updateTask(key: string, task: BrowserTaskUpdate): Promise<BrowserTaskInfo | undefined>;
|
|
139
|
+
/** Mirror one task's Agent todo list into the self-hosted workspace. */
|
|
140
|
+
setTaskTodos(key: string, todos: readonly BrowserTaskTodo[]): Promise<void>;
|
|
139
141
|
/** Shut the child and the RPC server down. */
|
|
140
142
|
dispose(): void;
|
|
141
143
|
}
|
|
@@ -274,7 +274,8 @@ class ElectronChildClient {
|
|
|
274
274
|
delete env.NODE_OPTIONS;
|
|
275
275
|
// The chrome's world is the child's choice, so it travels as an argument.
|
|
276
276
|
const childArgs = [
|
|
277
|
-
|
|
277
|
+
// 缺省也按 isolated 走:主世界会把 Agent 的计划暴露给页面脚本。
|
|
278
|
+
...this.chromeWorld === 'main' ? [] : ['--chrome-world', 'isolated'],
|
|
278
279
|
...this.fingerprintArgs,
|
|
279
280
|
];
|
|
280
281
|
this.child = spawn(electron, [hostMainPath, '--rpc-port', String(port), ...childArgs], {
|
|
@@ -475,6 +476,28 @@ class RemoteView {
|
|
|
475
476
|
capture() {
|
|
476
477
|
return this.client.call('capture', { viewId: this.id }, RPC_TRANSFER_TIMEOUT_MS);
|
|
477
478
|
}
|
|
479
|
+
/**
|
|
480
|
+
* Print the view to a PDF in the child process. Electron's debugger has no
|
|
481
|
+
* `Page.printToPDF`, so this is the only working print path; the bytes cross
|
|
482
|
+
* the RPC socket base64-encoded.
|
|
483
|
+
*/
|
|
484
|
+
printToPdf(options) {
|
|
485
|
+
return this.client.call('printToPdf', { viewId: this.id, options }, RPC_TRANSFER_TIMEOUT_MS);
|
|
486
|
+
}
|
|
487
|
+
/**
|
|
488
|
+
* Run a snippet in the world the *page's* injected chrome lives in.
|
|
489
|
+
*
|
|
490
|
+
* In isolated mode (the default) the chrome's globals are not reachable from
|
|
491
|
+
* the page's own world, and creating a world with the same name from here
|
|
492
|
+
* yields a different, empty context — the host has to resolve its own.
|
|
493
|
+
*/
|
|
494
|
+
chromeWorldEval(expression) {
|
|
495
|
+
return this.client.call('chromeWorldEval', { viewId: this.id, expression }, RPC_QUERY_TIMEOUT_MS);
|
|
496
|
+
}
|
|
497
|
+
/** Raise the child's window (show + restore + focus). */
|
|
498
|
+
focusWindow() {
|
|
499
|
+
return this.client.call('focusWindow', { viewId: this.id }, RPC_COMMAND_TIMEOUT_MS).then(() => undefined);
|
|
500
|
+
}
|
|
478
501
|
/** Export the session's cookies (login state). */
|
|
479
502
|
flushAuth() {
|
|
480
503
|
return this.client.call('flushAuth', { viewId: this.id }, RPC_COMMAND_TIMEOUT_MS).then(r => r.cookies);
|
|
@@ -793,6 +816,14 @@ export class RemoteElectronViewHost {
|
|
|
793
816
|
const result = await client.call('updateTask', { key, task }, RPC_QUERY_TIMEOUT_MS);
|
|
794
817
|
return result.task ?? undefined;
|
|
795
818
|
}
|
|
819
|
+
/** Mirror one task's Agent todo list into the self-hosted workspace. */
|
|
820
|
+
async setTaskTodos(key, todos) {
|
|
821
|
+
await this.ready();
|
|
822
|
+
const client = this.client;
|
|
823
|
+
if (client === undefined)
|
|
824
|
+
throw new Error('browser host unavailable');
|
|
825
|
+
await client.call('setTaskTodos', { key, todos }, RPC_QUERY_TIMEOUT_MS);
|
|
826
|
+
}
|
|
796
827
|
/** Shut the child and the RPC server down. */
|
|
797
828
|
dispose() {
|
|
798
829
|
this.disposed = true;
|
|
@@ -894,6 +925,16 @@ export class DeferredRemoteView {
|
|
|
894
925
|
await settle?.();
|
|
895
926
|
return this.withView(view => view.sendCommand(method, params), settle, method);
|
|
896
927
|
}
|
|
928
|
+
async printToPdf(options) {
|
|
929
|
+
return this.withView(view => view.printToPdf(options));
|
|
930
|
+
}
|
|
931
|
+
/** Evaluate in the injected chrome's own world (see the view-level method). */
|
|
932
|
+
async chromeWorldEval(expression) {
|
|
933
|
+
return this.withView(view => view.chromeWorldEval(expression));
|
|
934
|
+
}
|
|
935
|
+
async focusWindow() {
|
|
936
|
+
return this.withView(view => view.focusWindow());
|
|
937
|
+
}
|
|
897
938
|
async download(url, savePath) {
|
|
898
939
|
return this.withView(view => view.download(url, savePath));
|
|
899
940
|
}
|