dsh-sidebar-browser-control 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +202 -2
- package/README.zh.md +149 -0
- package/client.js +1365 -0
- package/cordis.patch.yml +12 -0
- package/icon.svg +7 -0
- package/index.js +912 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +70 -4
package/client.js
ADDED
|
@@ -0,0 +1,1365 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-sidebar-browser-control — Client half.
|
|
3
|
+
*
|
|
4
|
+
* Runs in the DSH Web page (Desktop app or browser tab) and owns everything the
|
|
5
|
+
* Host cannot reach: `ctx.sidebarRight` (the right-hand Sidebar's navigation
|
|
6
|
+
* controller) and, on Desktop, the live `<webview>` of each Browser tab.
|
|
7
|
+
*
|
|
8
|
+
* It holds one long-poll open to the Host half, executes the commands that
|
|
9
|
+
* arrive through it, and reports each result back. Nothing here runs on a timer.
|
|
10
|
+
*
|
|
11
|
+
* Tab identity: a Sidebar layout tab id is unique per Session only, so every
|
|
12
|
+
* tab this plugin reports carries the Session that owns it. A `tab_id` that is
|
|
13
|
+
* ambiguous across Sessions is reported and accepted as `<sessionId>#<tabId>`,
|
|
14
|
+
* and every lookup is scoped by Session when one is known.
|
|
15
|
+
*
|
|
16
|
+
* Carrier note: the Browser tab is an Electron `<webview>` on Desktop and a
|
|
17
|
+
* sandboxed `<iframe>` in the browser. Layout, tab lifecycle and navigation are
|
|
18
|
+
* available on both; reading the page, evaluating JavaScript, clicking and
|
|
19
|
+
* typing need the webview, so they report an explicit limitation in a browser
|
|
20
|
+
* tab instead of failing silently.
|
|
21
|
+
*
|
|
22
|
+
* Deliberately absent: screenshots. `webview.capturePage()` crashed the Electron
|
|
23
|
+
* renderer on this deployment (2026-10-11, app 0.2.0-rc.2 / Electron 44), so the
|
|
24
|
+
* capability is not offered at all rather than shipped as a landmine.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
window.__ModuleLoader__.load({
|
|
28
|
+
id: 'dsh-sidebar-browser-control',
|
|
29
|
+
factory: () => {
|
|
30
|
+
/** Route prefix owned by the Host half. */
|
|
31
|
+
const BASE = '/sidebar-browser-control';
|
|
32
|
+
/** Retry delay after a transport failure. */
|
|
33
|
+
const RETRY_MS = 1500;
|
|
34
|
+
|
|
35
|
+
/** Bridge state, owned by one page. */
|
|
36
|
+
const state = {
|
|
37
|
+
clientId: undefined,
|
|
38
|
+
stopped: true,
|
|
39
|
+
refusals: 0
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/** Plugin context, captured on apply. */
|
|
43
|
+
let context;
|
|
44
|
+
|
|
45
|
+
/** @param ms - milliseconds to wait. @returns a promise resolving afterwards. */
|
|
46
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
47
|
+
|
|
48
|
+
/** @returns whether this page is the Desktop application. */
|
|
49
|
+
function isDesktop() {
|
|
50
|
+
const carrier = globalThis.dshDesktop;
|
|
51
|
+
return carrier !== undefined && carrier !== null && carrier.protocolVersion === 1;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** @returns the platform token reported to the Host. */
|
|
55
|
+
function platformName() {
|
|
56
|
+
return isDesktop() ? 'desktop' : 'web';
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** @returns the capability list reported to the Host. */
|
|
60
|
+
function capabilities() {
|
|
61
|
+
return isDesktop() ? ['tabs', 'control', 'page'] : ['tabs', 'control'];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** @returns the sidebar navigation controller. */
|
|
65
|
+
function sidebar() {
|
|
66
|
+
const service = context?.get('sidebarRight');
|
|
67
|
+
if (service === undefined || service === null) {
|
|
68
|
+
throw new Error('DSH 的右侧栏服务(sidebarRight)当前不可用,因此无法控制内置浏览器。');
|
|
69
|
+
}
|
|
70
|
+
return service;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** @param text - candidate text. @param limit - maximum length. @returns truncated text. */
|
|
74
|
+
function clamp(text, limit) {
|
|
75
|
+
if (text.length <= limit) return text;
|
|
76
|
+
return `${text.slice(0, limit)}\n…(已截断,共 ${text.length} 字符)`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** @returns every Browser tab DSH knows about, across Sessions. */
|
|
80
|
+
function browserEntries() {
|
|
81
|
+
const entries = (sidebar().openTabs?.getSnapshot?.() ?? []).filter((entry) => entry.kind === 'browser');
|
|
82
|
+
const counts = new Map();
|
|
83
|
+
for (const entry of entries) counts.set(entry.tabId, (counts.get(entry.tabId) ?? 0) + 1);
|
|
84
|
+
return entries.map((entry) => ({
|
|
85
|
+
...entry,
|
|
86
|
+
handle: counts.get(entry.tabId) > 1 ? `${entry.sessionId}#${entry.tabId}` : entry.tabId
|
|
87
|
+
}));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Split a tab handle into its Session and layout id.
|
|
92
|
+
* @param handle - `tab7`, or `sessionId#tab7` for an ambiguous id.
|
|
93
|
+
* @returns `{ tabId, sessionId }`.
|
|
94
|
+
*/
|
|
95
|
+
function parseHandle(handle) {
|
|
96
|
+
const text = String(handle ?? '').trim();
|
|
97
|
+
if (text.length === 0) throw new Error('标签页标识不能为空。');
|
|
98
|
+
const separator = text.indexOf('#');
|
|
99
|
+
if (separator < 0) return { tabId: text, sessionId: undefined };
|
|
100
|
+
return { tabId: text.slice(separator + 1), sessionId: text.slice(0, separator) };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** @returns the on-screen Session id, when a Session is on screen. */
|
|
104
|
+
function onScreenSession() {
|
|
105
|
+
try {
|
|
106
|
+
return sidebar().mounted?.getSnapshot?.();
|
|
107
|
+
} catch {
|
|
108
|
+
return undefined;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Resolve one tab handle to the entry that owns it.
|
|
114
|
+
* @param handle - tab id or `sessionId#tabId`.
|
|
115
|
+
* @returns the matching inventory entry.
|
|
116
|
+
*/
|
|
117
|
+
function findEntry(handle) {
|
|
118
|
+
const { tabId, sessionId } = parseHandle(handle);
|
|
119
|
+
const entries = browserEntries();
|
|
120
|
+
const scoped = sessionId === undefined ? entries : entries.filter((entry) => entry.sessionId === sessionId);
|
|
121
|
+
const exact = scoped.filter((entry) => entry.tabId === tabId);
|
|
122
|
+
if (exact.length === 1) return exact[0];
|
|
123
|
+
if (exact.length === 0) {
|
|
124
|
+
throw new Error(sessionId === undefined
|
|
125
|
+
? `找不到内置浏览器标签页 ${tabId};可用 browser_tabs 查看当前打开的内置浏览器标签页。`
|
|
126
|
+
: `会话 ${sessionId} 里没有内置浏览器标签页 ${tabId}。`);
|
|
127
|
+
}
|
|
128
|
+
const screen = onScreenSession();
|
|
129
|
+
const preferred = exact.find((entry) => entry.sessionId === screen);
|
|
130
|
+
if (preferred !== undefined) return preferred;
|
|
131
|
+
throw new Error(`标签页 ${tabId} 在多个会话里都存在;请改用带会话前缀的标识:${exact.map((entry) => entry.handle).join(' 或 ')}`);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* The tab a command without `tab_id` acts on: the active tab when it is a
|
|
136
|
+
* Browser tab, otherwise the newest Browser tab of the on-screen Session.
|
|
137
|
+
* @returns an inventory entry, or undefined when no Browser tab is open.
|
|
138
|
+
*/
|
|
139
|
+
function defaultEntry() {
|
|
140
|
+
const entries = browserEntries();
|
|
141
|
+
if (entries.length === 0) return undefined;
|
|
142
|
+
const screen = onScreenSession();
|
|
143
|
+
const active = activeTabEntry();
|
|
144
|
+
if (active !== undefined && entries.some((entry) => entry.handle === active.handle)) return entries.find((entry) => entry.handle === active.handle);
|
|
145
|
+
const sameSession = entries.filter((entry) => entry.sessionId === screen);
|
|
146
|
+
const pool = sameSession.length > 0 ? sameSession : entries;
|
|
147
|
+
return pool[pool.length - 1];
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** @returns the active tab of the active pane as an inventory entry, if it is a Browser tab. */
|
|
151
|
+
function activeTabEntry() {
|
|
152
|
+
try {
|
|
153
|
+
const record = sidebar().active?.();
|
|
154
|
+
if (record === undefined || record.kind !== 'browser') return undefined;
|
|
155
|
+
const screen = onScreenSession();
|
|
156
|
+
if (screen === undefined) return undefined;
|
|
157
|
+
return browserEntries().find((entry) => entry.sessionId === screen && entry.tabId === record.id);
|
|
158
|
+
} catch {
|
|
159
|
+
return undefined;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Find one Session's Sidebar subtree in the document.
|
|
165
|
+
* @param sessionId - owning Session.
|
|
166
|
+
* @returns the subtree root, or undefined when it is not rendered.
|
|
167
|
+
*/
|
|
168
|
+
function sessionScope(sessionId) {
|
|
169
|
+
if (sessionId === undefined) return undefined;
|
|
170
|
+
for (const node of document.querySelectorAll('[data-sidebar-right-session]')) {
|
|
171
|
+
if (node.dataset.sidebarRightSession === sessionId) return node;
|
|
172
|
+
}
|
|
173
|
+
return undefined;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Find a tab's marker element, scoped by Session when it is known.
|
|
178
|
+
* @param tabId - layout tab id.
|
|
179
|
+
* @param sessionId - owning Session.
|
|
180
|
+
* @returns the marker, if rendered.
|
|
181
|
+
*/
|
|
182
|
+
function tabMarker(tabId, sessionId) {
|
|
183
|
+
const scopes = sessionScope(sessionId) === undefined ? [document] : [sessionScope(sessionId)];
|
|
184
|
+
for (const scope of scopes) {
|
|
185
|
+
for (const node of scope.querySelectorAll('[data-sidebar-right-tab]')) {
|
|
186
|
+
if (node.dataset.sidebarRightTab === tabId) return node;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return undefined;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Find a tab's rendered page carrier.
|
|
194
|
+
* @param tabId - layout tab id.
|
|
195
|
+
* @param sessionId - owning Session.
|
|
196
|
+
* @returns the webview or iframe element, if mounted.
|
|
197
|
+
*/
|
|
198
|
+
function frameFor(tabId, sessionId) {
|
|
199
|
+
const scopes = sessionScope(sessionId) === undefined ? [document] : [sessionScope(sessionId)];
|
|
200
|
+
for (const scope of scopes) {
|
|
201
|
+
for (const frame of scope.querySelectorAll('webview[data-sidebar-browser-frame], iframe[data-sidebar-browser-frame]')) {
|
|
202
|
+
const marker = frame.closest('[data-sidebar-right-tab]');
|
|
203
|
+
if (marker !== null && marker.dataset.sidebarRightTab === tabId) return frame;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return undefined;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** @param frame - a carrier element, possibly absent. @returns whether it is the Electron webview. */
|
|
210
|
+
function isWebview(frame) {
|
|
211
|
+
return frame !== undefined && frame !== null && typeof frame.tagName === 'string' && frame.tagName.toLowerCase() === 'webview';
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** @param frame - a carrier element. @returns its current address, when readable. */
|
|
215
|
+
function frameUrl(frame) {
|
|
216
|
+
if (frame === undefined) return undefined;
|
|
217
|
+
try {
|
|
218
|
+
if (isWebview(frame)) {
|
|
219
|
+
const url = frame.getURL();
|
|
220
|
+
/* A guest is created at `about:blank#<lease>`; that is not an address. */
|
|
221
|
+
return typeof url === 'string' && /^https?:/u.test(url) ? url : undefined;
|
|
222
|
+
}
|
|
223
|
+
const src = frame.getAttribute('src');
|
|
224
|
+
return src === null || !/^https?:/u.test(src) ? undefined : new URL(src, location.href).href;
|
|
225
|
+
} catch {
|
|
226
|
+
return undefined;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** @param frame - a carrier element. @returns its document title, when readable. */
|
|
231
|
+
function frameTitle(frame) {
|
|
232
|
+
if (frame === undefined) return '';
|
|
233
|
+
try {
|
|
234
|
+
return isWebview(frame) ? String(frame.getTitle() ?? '') : '';
|
|
235
|
+
} catch {
|
|
236
|
+
return '';
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** @param entry - inventory entry. @returns the committed tab record. */
|
|
241
|
+
function recordFor(entry) {
|
|
242
|
+
try {
|
|
243
|
+
const service = sidebar();
|
|
244
|
+
if (typeof service.tabsIn !== 'function') return undefined;
|
|
245
|
+
return service.tabsIn(entry.sessionId).find((record) => record.id === entry.tabId);
|
|
246
|
+
} catch {
|
|
247
|
+
return undefined;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** @param entry - inventory entry. @returns the pane id holding the tab, when rendered. */
|
|
252
|
+
function paneFor(entry) {
|
|
253
|
+
const marker = tabMarker(entry.tabId, entry.sessionId);
|
|
254
|
+
const pane = marker?.closest('[data-dockkit-pane], [data-dockkit-float]');
|
|
255
|
+
return pane?.getAttribute('data-dockkit-pane') ?? pane?.getAttribute('data-dockkit-float') ?? undefined;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* What this page can actually do about pixels.
|
|
260
|
+
*
|
|
261
|
+
* Screenshots are the one capability with a native failure mode, so the
|
|
262
|
+
* answer is reported instead of guessed: `guest` is the webview's own
|
|
263
|
+
* `capturePage` (crashed the renderer once — see docs/architecture.md),
|
|
264
|
+
* `window` needs a renderer capture API, `dom` is the pure-JS fallback.
|
|
265
|
+
* @returns a diagnostic snapshot of the available capture routes.
|
|
266
|
+
*/
|
|
267
|
+
function captureRoutes() {
|
|
268
|
+
const carrier = globalThis.dshDesktop;
|
|
269
|
+
return {
|
|
270
|
+
guest: typeof document.createElement('webview').capturePage === 'function',
|
|
271
|
+
desktop_capturer: typeof globalThis.desktopCapturer?.getSources === 'function',
|
|
272
|
+
display_media: typeof navigator.mediaDevices?.getDisplayMedia === 'function',
|
|
273
|
+
carrier_keys: carrier === undefined || carrier === null ? [] : Object.keys(carrier).slice(0, 20)
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** @returns every Browser tab, with the addresses its carrier can report. */
|
|
278
|
+
function listTabs() {
|
|
279
|
+
const service = sidebar();
|
|
280
|
+
const screen = onScreenSession();
|
|
281
|
+
const active = activeTabEntry();
|
|
282
|
+
const capture = captureRoutes();
|
|
283
|
+
const tabs = browserEntries().map((entry) => {
|
|
284
|
+
const frame = frameFor(entry.tabId, entry.sessionId);
|
|
285
|
+
const record = recordFor(entry);
|
|
286
|
+
return {
|
|
287
|
+
tab_id: entry.handle,
|
|
288
|
+
title: frameTitle(frame) || record?.title || '',
|
|
289
|
+
url: frameUrl(frame),
|
|
290
|
+
session: entry.sessionId,
|
|
291
|
+
on_screen: entry.sessionId === screen,
|
|
292
|
+
active: active !== undefined && active.handle === entry.handle,
|
|
293
|
+
pane: paneFor(entry)
|
|
294
|
+
};
|
|
295
|
+
});
|
|
296
|
+
return {
|
|
297
|
+
platform: platformName(),
|
|
298
|
+
page_control: isDesktop(),
|
|
299
|
+
panel_open: service.isExpanded?.() === true,
|
|
300
|
+
active_tab: active?.handle,
|
|
301
|
+
capture,
|
|
302
|
+
tabs
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Resolve the carrier and identity a command targets.
|
|
308
|
+
* @param args - command arguments.
|
|
309
|
+
* @returns `{ entry, frame, tabId, sessionId }`.
|
|
310
|
+
*/
|
|
311
|
+
function resolveTarget(args) {
|
|
312
|
+
const requested = typeof args?.tab_id === 'string' && args.tab_id.trim().length > 0 ? args.tab_id.trim() : undefined;
|
|
313
|
+
const entry = requested === undefined ? defaultEntry() : findEntry(requested);
|
|
314
|
+
if (entry === undefined) {
|
|
315
|
+
throw new Error('内置浏览器当前没有标签页;请先用 browser_control 的 open 打开一个网址。');
|
|
316
|
+
}
|
|
317
|
+
const frame = frameFor(entry.tabId, entry.sessionId);
|
|
318
|
+
if (frame === undefined) {
|
|
319
|
+
throw new Error(`标签页 ${entry.handle} 当前没有已挂载的页面:它可能是重启后尚未加载、在别的会话、或已收起。用 browser_control 的 navigate 指定地址即可加载它(reload 可重载已保存的地址)。`);
|
|
320
|
+
}
|
|
321
|
+
return { entry, frame, tabId: entry.tabId, sessionId: entry.sessionId };
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Require the Desktop carrier for page-level operations.
|
|
326
|
+
* @param frame - resolved carrier.
|
|
327
|
+
* @returns the webview element.
|
|
328
|
+
*/
|
|
329
|
+
function requireWebview(frame) {
|
|
330
|
+
if (!isWebview(frame)) {
|
|
331
|
+
throw new Error('页面级操作(读取、eval、点击、输入、截图)只在 Desktop 应用的内置浏览器里可用:浏览器版的 iframe 受 sandbox 与同源策略限制,无法读取页面内容。');
|
|
332
|
+
}
|
|
333
|
+
return frame;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Complete a bare host name and validate an address exactly as the Browser's
|
|
338
|
+
* own address bar does.
|
|
339
|
+
* @param input - user or model supplied address.
|
|
340
|
+
* @returns the normalized absolute URL.
|
|
341
|
+
*/
|
|
342
|
+
function parseAddress(input) {
|
|
343
|
+
const raw = String(input ?? '').trim();
|
|
344
|
+
if (raw.length === 0) throw new Error('需要一个网址。');
|
|
345
|
+
if (/\s/u.test(raw)) throw new Error(`网址不能包含空格:${raw}`);
|
|
346
|
+
const candidate = /^[a-z][a-z0-9+.-]*:/iu.test(raw) ? raw : `https://${raw}`;
|
|
347
|
+
let url;
|
|
348
|
+
try {
|
|
349
|
+
url = new URL(candidate);
|
|
350
|
+
} catch {
|
|
351
|
+
throw new Error(`无法识别的网址:${raw}`);
|
|
352
|
+
}
|
|
353
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
|
354
|
+
throw new Error('内置浏览器只支持 http/https 网址(本地文件请用文档预览)。');
|
|
355
|
+
}
|
|
356
|
+
if (url.username.length > 0 || url.password.length > 0) throw new Error('网址不能包含用户名或密码。');
|
|
357
|
+
if (url.origin === location.origin) throw new Error('不能在嵌入式浏览器里打开 DSH 应用自身。');
|
|
358
|
+
return url.href;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Wait for a freshly opened Browser tab to appear in the inventory.
|
|
363
|
+
* @param known - handles that existed before the open.
|
|
364
|
+
* @param sessionId - the Session the tab was opened in.
|
|
365
|
+
* @param timeoutMs - how long to wait.
|
|
366
|
+
* @returns the new entry, or undefined when the layout never reported one.
|
|
367
|
+
*/
|
|
368
|
+
async function waitForNewEntry(known, sessionId, timeoutMs = 3000) {
|
|
369
|
+
const deadline = Date.now() + timeoutMs;
|
|
370
|
+
while (Date.now() < deadline) {
|
|
371
|
+
const fresh = browserEntries().find((entry) => !known.has(entry.handle) && (sessionId === undefined || entry.sessionId === sessionId));
|
|
372
|
+
if (fresh !== undefined) return fresh;
|
|
373
|
+
await sleep(50);
|
|
374
|
+
}
|
|
375
|
+
return undefined;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Wait for a tab's carrier element to be mounted.
|
|
380
|
+
* @param tabId - layout tab id.
|
|
381
|
+
* @param sessionId - owning Session.
|
|
382
|
+
* @param timeoutMs - how long to wait.
|
|
383
|
+
* @returns the carrier, or undefined.
|
|
384
|
+
*/
|
|
385
|
+
async function waitForFrame(tabId, sessionId, timeoutMs = 5000) {
|
|
386
|
+
const deadline = Date.now() + timeoutMs;
|
|
387
|
+
while (Date.now() < deadline) {
|
|
388
|
+
const frame = frameFor(tabId, sessionId);
|
|
389
|
+
if (frame !== undefined) return frame;
|
|
390
|
+
await sleep(50);
|
|
391
|
+
}
|
|
392
|
+
return undefined;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/** @param value - page value. @param limit - maximum JSON length. @returns a JSON-safe value. */
|
|
396
|
+
function clampValue(value, limit) {
|
|
397
|
+
if (value === undefined) return null;
|
|
398
|
+
let text;
|
|
399
|
+
try {
|
|
400
|
+
text = JSON.stringify(value);
|
|
401
|
+
} catch {
|
|
402
|
+
return String(value).slice(0, limit);
|
|
403
|
+
}
|
|
404
|
+
if (text === undefined) return String(value).slice(0, limit);
|
|
405
|
+
if (text.length <= limit) return value;
|
|
406
|
+
return `${text.slice(0, limit)}…(已截断,共 ${text.length} 字符)`;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* In-page helpers shared by every form-aware action.
|
|
411
|
+
*
|
|
412
|
+
* Login forms are the reason these exist: fields have to be found by label,
|
|
413
|
+
* name, placeholder or autocomplete rather than by a hand-written selector,
|
|
414
|
+
* and the form itself may live in a same-origin iframe.
|
|
415
|
+
* @returns JavaScript source declaring `visible`, `docs`, `labelled`,
|
|
416
|
+
* `fields`, `findField`, `setValue` and `submitForm`.
|
|
417
|
+
*/
|
|
418
|
+
function pageHelpers() {
|
|
419
|
+
return `
|
|
420
|
+
const visible = (el) => { const rect = el.getBoundingClientRect(); return rect.width > 1 && rect.height > 1; };
|
|
421
|
+
const docs = () => {
|
|
422
|
+
const list = [document];
|
|
423
|
+
for (const frame of document.querySelectorAll('iframe')) {
|
|
424
|
+
try { if (frame.contentDocument !== null) list.push(frame.contentDocument); } catch (error) { /* cross-origin */ }
|
|
425
|
+
}
|
|
426
|
+
return list;
|
|
427
|
+
};
|
|
428
|
+
const labelled = (el) => {
|
|
429
|
+
const parts = [];
|
|
430
|
+
if (el.id !== undefined && el.id.length > 0) {
|
|
431
|
+
const root = el.ownerDocument;
|
|
432
|
+
const label = root.querySelector('label[for="' + el.id.replace(/"/g, '\\\\"') + '"]');
|
|
433
|
+
if (label !== null) parts.push(label.innerText);
|
|
434
|
+
}
|
|
435
|
+
const wrapping = el.closest === undefined ? null : el.closest('label');
|
|
436
|
+
if (wrapping !== null && wrapping !== undefined) parts.push(wrapping.innerText);
|
|
437
|
+
for (const attribute of ['aria-label', 'placeholder', 'title', 'name', 'id']) {
|
|
438
|
+
const value = el.getAttribute === undefined ? null : el.getAttribute(attribute);
|
|
439
|
+
if (typeof value === 'string' && value.length > 0) parts.push(value);
|
|
440
|
+
}
|
|
441
|
+
return parts.join(' ').replace(/\\s+/g, ' ').trim().slice(0, 160);
|
|
442
|
+
};
|
|
443
|
+
const fields = () => {
|
|
444
|
+
const list = [];
|
|
445
|
+
for (const doc of docs()) {
|
|
446
|
+
for (const el of doc.querySelectorAll('input,textarea,select')) {
|
|
447
|
+
if (el.type === 'hidden' || !visible(el)) continue;
|
|
448
|
+
list.push(el);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
return list;
|
|
452
|
+
};
|
|
453
|
+
const describe = (el) => ({
|
|
454
|
+
tag: el.tagName.toLowerCase(),
|
|
455
|
+
type: (el.getAttribute('type') || el.tagName.toLowerCase()).toLowerCase(),
|
|
456
|
+
name: el.getAttribute('name') || undefined,
|
|
457
|
+
id: el.id || undefined,
|
|
458
|
+
autocomplete: el.getAttribute('autocomplete') || undefined,
|
|
459
|
+
placeholder: el.getAttribute('placeholder') || undefined,
|
|
460
|
+
label: labelled(el),
|
|
461
|
+
value: el.type === 'password' ? '' : String(el.value ?? '').slice(0, 60),
|
|
462
|
+
checked: el.checked === undefined ? undefined : el.checked
|
|
463
|
+
});
|
|
464
|
+
/* Resolve one field description to an element: explicit selector, then
|
|
465
|
+
the semantic signals a login form actually carries. */
|
|
466
|
+
const findField = (spec) => {
|
|
467
|
+
const pool = fields();
|
|
468
|
+
const selector = typeof spec.selector === 'string' ? spec.selector : '';
|
|
469
|
+
if (selector.length > 0) {
|
|
470
|
+
for (const doc of docs()) {
|
|
471
|
+
const found = doc.querySelector(selector);
|
|
472
|
+
if (found !== null) return found;
|
|
473
|
+
}
|
|
474
|
+
return null;
|
|
475
|
+
}
|
|
476
|
+
const wanted = String(spec.name || spec.label || spec.placeholder || spec.type || '').toLowerCase();
|
|
477
|
+
if (wanted.length === 0) return null;
|
|
478
|
+
const scored = pool.map((el) => {
|
|
479
|
+
const info = describe(el);
|
|
480
|
+
const haystack = [info.name, info.id, info.placeholder, info.label, info.autocomplete, info.type]
|
|
481
|
+
.filter((part) => typeof part === 'string').join(' ').toLowerCase();
|
|
482
|
+
let score = 0;
|
|
483
|
+
if (info.type === wanted) score += 4;
|
|
484
|
+
if (info.name !== undefined && info.name.toLowerCase() === wanted) score += 8;
|
|
485
|
+
if (haystack.includes(wanted)) score += 3;
|
|
486
|
+
if (wanted === 'password' && info.type === 'password') score += 10;
|
|
487
|
+
if (wanted === 'username' && /user|email|phone|account|login|mobile/i.test(haystack)) score += 6;
|
|
488
|
+
return { el, score };
|
|
489
|
+
}).filter((candidate) => candidate.score > 0);
|
|
490
|
+
scored.sort((left, right) => right.score - left.score);
|
|
491
|
+
return scored.length === 0 ? null : scored[0].el;
|
|
492
|
+
};
|
|
493
|
+
const setValue = (el, value) => {
|
|
494
|
+
const proto = el instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
|
|
495
|
+
const setter = Object.getOwnPropertyDescriptor(proto, 'value')?.set;
|
|
496
|
+
if (setter !== undefined) setter.call(el, value); else el.value = value;
|
|
497
|
+
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
498
|
+
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
499
|
+
};
|
|
500
|
+
const submitForm = (el) => {
|
|
501
|
+
const form = el.form ?? (el.closest === undefined ? null : el.closest('form'));
|
|
502
|
+
if (form !== null && form !== undefined && typeof form.requestSubmit === 'function') { form.requestSubmit(); return true; }
|
|
503
|
+
const button = el.ownerDocument.querySelector('button[type=submit],input[type=submit],button:not([type])');
|
|
504
|
+
if (button !== null && button !== undefined) { button.click(); return true; }
|
|
505
|
+
el.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', keyCode: 13, which: 13, bubbles: true }));
|
|
506
|
+
return false;
|
|
507
|
+
};
|
|
508
|
+
`;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* Build the in-page expression that resolves a click/wait target.
|
|
513
|
+
* @param selector - optional CSS selector.
|
|
514
|
+
* @param text - optional visible text.
|
|
515
|
+
* @returns JavaScript source evaluating to an element or null.
|
|
516
|
+
*/
|
|
517
|
+
function matchExpression(selector, text) {
|
|
518
|
+
return `(() => {
|
|
519
|
+
const selector = ${JSON.stringify(selector ?? null)};
|
|
520
|
+
const wanted = ${JSON.stringify(text ?? null)};
|
|
521
|
+
if (selector !== null && selector.length > 0) return document.querySelector(selector);
|
|
522
|
+
if (wanted === null || wanted.length === 0) return null;
|
|
523
|
+
const nodes = [...document.querySelectorAll('a,button,input,textarea,select,summary,label,li,td,th,span,div,p,[role=button],[role=link],[tabindex]')];
|
|
524
|
+
const read = (node) => (node.innerText || node.value || node.textContent || '').replace(/\\s+/g, ' ').trim();
|
|
525
|
+
const actionable = (node) => node.matches('a[href],button,input,select,textarea,summary,[role=button],[role=link]');
|
|
526
|
+
const matches = nodes.filter((node) => { const value = read(node); return value === wanted || value.includes(wanted); });
|
|
527
|
+
if (matches.length === 0) return null;
|
|
528
|
+
/* Rank: exact text, then something clickable, then the smallest match —
|
|
529
|
+
so a link beats the container that merely contains its text. */
|
|
530
|
+
const score = (node) => {
|
|
531
|
+
const value = read(node);
|
|
532
|
+
return [value === wanted ? 0 : 1, actionable(node) ? 0 : 1, value.length];
|
|
533
|
+
};
|
|
534
|
+
matches.sort((left, right) => {
|
|
535
|
+
const a = score(left);
|
|
536
|
+
const b = score(right);
|
|
537
|
+
for (let index = 0; index < a.length; index += 1) {
|
|
538
|
+
if (a[index] !== b[index]) return a[index] - b[index];
|
|
539
|
+
}
|
|
540
|
+
return 0;
|
|
541
|
+
});
|
|
542
|
+
return matches[0];
|
|
543
|
+
})()`;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* Wait for a navigation the carrier started to finish, so a reported URL is
|
|
548
|
+
* the one the model will actually act on.
|
|
549
|
+
* @param view - carrier element.
|
|
550
|
+
* @param previousUrl - address observed before the command.
|
|
551
|
+
* @param timeoutMs - how long to wait.
|
|
552
|
+
* @returns the settled address.
|
|
553
|
+
*/
|
|
554
|
+
async function settle(view, previousUrl, timeoutMs = 8000) {
|
|
555
|
+
const started = Date.now();
|
|
556
|
+
await sleep(150);
|
|
557
|
+
const deadline = started + timeoutMs;
|
|
558
|
+
while (Date.now() < deadline) {
|
|
559
|
+
let loading = false;
|
|
560
|
+
try {
|
|
561
|
+
loading = view.isLoading() === true;
|
|
562
|
+
} catch {
|
|
563
|
+
loading = false;
|
|
564
|
+
}
|
|
565
|
+
const url = frameUrl(view);
|
|
566
|
+
if (!loading && (url !== previousUrl || Date.now() - started > 700)) return url;
|
|
567
|
+
await sleep(120);
|
|
568
|
+
}
|
|
569
|
+
return frameUrl(view);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/**
|
|
573
|
+
* Inspect the document a command is about to act on.
|
|
574
|
+
*
|
|
575
|
+
* A failed load leaves Chromium's error page in the webview, where every
|
|
576
|
+
* extraction legitimately returns nothing — which reads to a model as "the
|
|
577
|
+
* page is empty". This makes that state visible instead.
|
|
578
|
+
* @param view - carrier element.
|
|
579
|
+
* @returns `{ failed, href, title, empty }` for the loaded document.
|
|
580
|
+
*/
|
|
581
|
+
async function inspectPage(view) {
|
|
582
|
+
let state;
|
|
583
|
+
try {
|
|
584
|
+
state = await view.executeJavaScript('(() => ({ href: location.href, protocol: location.protocol, title: document.title, body: (document.body === null ? "" : document.body.innerText).slice(0, 60) }))()');
|
|
585
|
+
} catch {
|
|
586
|
+
return { failed: false, href: frameUrl(view) ?? '', title: frameTitle(view), empty: false };
|
|
587
|
+
}
|
|
588
|
+
const href = typeof state?.href === 'string' ? state.href : '';
|
|
589
|
+
const protocol = typeof state?.protocol === 'string' ? state.protocol : '';
|
|
590
|
+
return {
|
|
591
|
+
failed: href.startsWith('chrome-error:') || protocol === 'chrome-error:',
|
|
592
|
+
href,
|
|
593
|
+
title: typeof state?.title === 'string' ? state.title : '',
|
|
594
|
+
empty: typeof state?.body === 'string' && state.body.trim().length === 0
|
|
595
|
+
};
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/**
|
|
599
|
+
* Refuse to act on a document that never loaded.
|
|
600
|
+
* @param state - result of {@link inspectPage}.
|
|
601
|
+
* @param action - page action name, for the message.
|
|
602
|
+
*/
|
|
603
|
+
function requireLoaded(state, action) {
|
|
604
|
+
if (!state.failed) return;
|
|
605
|
+
throw new Error(`页面没有加载成功(内置浏览器里是 Chromium 错误页,通常是 DNS、网络或证书失败),"${action}" 没有内容可操作。可以先用 browser_control 的 reload 重试,或用 external 交给系统浏览器。`);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/**
|
|
609
|
+
* Wait for a freshly opened tab to commit its first document, so the
|
|
610
|
+
* reported title and address belong to the page rather than to the guest's
|
|
611
|
+
* creation state (`about:blank#<lease>`, whose "title" is that string).
|
|
612
|
+
* @param frame - carrier element, when it is already mounted.
|
|
613
|
+
* @param timeoutMs - how long to wait for a committed document.
|
|
614
|
+
* @returns `{ title, url }` as observed; both empty/undefined until commit.
|
|
615
|
+
*/
|
|
616
|
+
async function waitForCommit(frame, timeoutMs = 4000) {
|
|
617
|
+
if (frame === undefined) return { title: '', url: undefined };
|
|
618
|
+
const deadline = Date.now() + timeoutMs;
|
|
619
|
+
for (;;) {
|
|
620
|
+
const url = frameUrl(frame);
|
|
621
|
+
const title = frameTitle(frame);
|
|
622
|
+
const committed = url !== undefined && title.length > 0 && !title.startsWith('about:');
|
|
623
|
+
if (committed || Date.now() >= deadline) {
|
|
624
|
+
return { title: title.startsWith('about:') ? '' : title, url };
|
|
625
|
+
}
|
|
626
|
+
await sleep(150);
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/**
|
|
631
|
+
* Run one `control` command through the sidebar controller and the carrier.
|
|
632
|
+
* @param args - command arguments.
|
|
633
|
+
* @returns the operation result.
|
|
634
|
+
*/
|
|
635
|
+
async function control(args) {
|
|
636
|
+
const service = sidebar();
|
|
637
|
+
const action = String(args?.action ?? '').toLowerCase();
|
|
638
|
+
const requested = typeof args?.tab_id === 'string' && args.tab_id.trim().length > 0 ? args.tab_id.trim() : undefined;
|
|
639
|
+
const url = typeof args?.url === 'string' && args.url.length > 0 ? parseAddress(args.url) : undefined;
|
|
640
|
+
|
|
641
|
+
switch (action) {
|
|
642
|
+
case 'open': {
|
|
643
|
+
if (url === undefined) throw new Error('open 需要一个 url 参数。');
|
|
644
|
+
const target = requested === undefined ? undefined : findEntry(requested);
|
|
645
|
+
const known = new Set(browserEntries().map((entry) => entry.handle));
|
|
646
|
+
service.openTab('browser', {
|
|
647
|
+
params: { url },
|
|
648
|
+
...target !== undefined ? { replaceTab: target.tabId } : {},
|
|
649
|
+
revealIfOpened: false
|
|
650
|
+
});
|
|
651
|
+
const screen = onScreenSession();
|
|
652
|
+
const entry = (await waitForNewEntry(known, screen)) ?? (target !== undefined ? target : defaultEntry());
|
|
653
|
+
if (entry !== undefined) await waitForFrame(entry.tabId, entry.sessionId, 3000);
|
|
654
|
+
const frame = entry === undefined ? undefined : frameFor(entry.tabId, entry.sessionId);
|
|
655
|
+
const committed = await waitForCommit(frame);
|
|
656
|
+
return {
|
|
657
|
+
action,
|
|
658
|
+
tab_id: entry?.handle,
|
|
659
|
+
session: entry?.sessionId,
|
|
660
|
+
url: committed.url ?? url,
|
|
661
|
+
title: committed.title || (entry === undefined ? undefined : recordFor(entry)?.title)
|
|
662
|
+
};
|
|
663
|
+
}
|
|
664
|
+
case 'navigate': {
|
|
665
|
+
if (url === undefined) throw new Error('navigate 需要一个 url 参数。');
|
|
666
|
+
const target = requested === undefined ? defaultEntry() : findEntry(requested);
|
|
667
|
+
if (target === undefined) return await control({ action: 'open', url: args.url });
|
|
668
|
+
const frame = frameFor(target.tabId, target.sessionId);
|
|
669
|
+
if (isWebview(frame)) {
|
|
670
|
+
try {
|
|
671
|
+
await frame.loadURL(url);
|
|
672
|
+
} catch (error) {
|
|
673
|
+
/* A failed load still leaves the tab at that address, showing
|
|
674
|
+
Chromium's error page; report it instead of failing the call. */
|
|
675
|
+
const failed = await inspectPage(frame);
|
|
676
|
+
return {
|
|
677
|
+
action,
|
|
678
|
+
tab_id: target.handle,
|
|
679
|
+
url: frameUrl(frame) ?? url,
|
|
680
|
+
note: failed.failed
|
|
681
|
+
? `页面加载失败(${String(error?.message ?? error)}):内置浏览器停在了 Chromium 错误页,通常是 DNS、网络或证书问题。可以换一个地址,或先用 external 交给系统浏览器。`
|
|
682
|
+
: `导航被中断:${String(error?.message ?? error)}`
|
|
683
|
+
};
|
|
684
|
+
}
|
|
685
|
+
const committed = await waitForCommit(frame, 2000);
|
|
686
|
+
return { action, tab_id: target.handle, url: committed.url ?? url, title: committed.title };
|
|
687
|
+
}
|
|
688
|
+
/* A tab restored after a restart has no carrier until something loads
|
|
689
|
+
it, so a missing frame is a load request rather than an error. DSH's
|
|
690
|
+
replace rule closes the old tab and opens a new one, which means the
|
|
691
|
+
caller must be given the NEW id — the old one is already gone. */
|
|
692
|
+
const screen = onScreenSession();
|
|
693
|
+
if (target.sessionId !== screen) {
|
|
694
|
+
throw new Error(`标签页 ${target.handle} 属于另一个会话,无法从当前会话加载它。请先切换到那个会话(或在当前会话里用 open 打开同样的地址)。`);
|
|
695
|
+
}
|
|
696
|
+
const known = new Set(browserEntries().map((entry) => entry.handle));
|
|
697
|
+
service.openTab('browser', { params: { url }, replaceTab: target.tabId, revealIfOpened: false });
|
|
698
|
+
const fresh = (await waitForNewEntry(known, screen)) ?? target;
|
|
699
|
+
await waitForFrame(fresh.tabId, fresh.sessionId, 3000);
|
|
700
|
+
const loaded = await waitForCommit(frameFor(fresh.tabId, fresh.sessionId), 2500);
|
|
701
|
+
return {
|
|
702
|
+
action,
|
|
703
|
+
tab_id: fresh.handle,
|
|
704
|
+
url: loaded.url ?? url,
|
|
705
|
+
title: loaded.title,
|
|
706
|
+
note: fresh.handle === target.handle
|
|
707
|
+
? undefined
|
|
708
|
+
: `原标签 ${target.handle} 重启后尚未加载页面,已由新标签 ${fresh.handle} 加载它(DSH 的替换语义会新建标签)。请用 ${fresh.handle} 继续。`
|
|
709
|
+
};
|
|
710
|
+
}
|
|
711
|
+
case 'back':
|
|
712
|
+
case 'forward':
|
|
713
|
+
case 'reload': {
|
|
714
|
+
const { frame, entry } = resolveTarget(args);
|
|
715
|
+
const view = requireWebview(frame);
|
|
716
|
+
const before = frameUrl(view);
|
|
717
|
+
if (action === 'reload') view.reload();
|
|
718
|
+
else if (action === 'back') view.goBack();
|
|
719
|
+
else view.goForward();
|
|
720
|
+
return { action, tab_id: entry.handle, url: await settle(view, before), title: frameTitle(view) };
|
|
721
|
+
}
|
|
722
|
+
case 'close': {
|
|
723
|
+
const entry = requested === undefined ? defaultEntry() : findEntry(requested);
|
|
724
|
+
if (entry === undefined) throw new Error('没有可关闭的标签页。');
|
|
725
|
+
if (entry.sessionId !== onScreenSession()) {
|
|
726
|
+
throw new Error(`标签页 ${entry.handle} 属于另一个会话;请先切换到该会话,或改用带会话前缀的标识在屏幕上打开它。`);
|
|
727
|
+
}
|
|
728
|
+
service.close(entry.tabId);
|
|
729
|
+
return { action, tab_id: entry.handle };
|
|
730
|
+
}
|
|
731
|
+
case 'focus': {
|
|
732
|
+
const entry = requested === undefined ? defaultEntry() : findEntry(requested);
|
|
733
|
+
if (entry === undefined) throw new Error('没有可聚焦的标签页。');
|
|
734
|
+
service.focus(entry.tabId);
|
|
735
|
+
const frame = frameFor(entry.tabId, entry.sessionId);
|
|
736
|
+
return { action, tab_id: entry.handle, title: frameTitle(frame) || recordFor(entry)?.title };
|
|
737
|
+
}
|
|
738
|
+
case 'expand': {
|
|
739
|
+
const wasOpen = service.isExpanded() === true;
|
|
740
|
+
if (!wasOpen) service.toggleExpanded();
|
|
741
|
+
return { action, note: wasOpen ? '右侧栏本来就是展开的。' : undefined };
|
|
742
|
+
}
|
|
743
|
+
case 'collapse': {
|
|
744
|
+
const wasOpen = service.isExpanded() === true;
|
|
745
|
+
if (wasOpen) service.toggleExpanded();
|
|
746
|
+
return { action, note: wasOpen ? undefined : '右侧栏本来就是收起的。' };
|
|
747
|
+
}
|
|
748
|
+
case 'fullscreen': {
|
|
749
|
+
const target = service.commandTarget();
|
|
750
|
+
if (target === undefined) throw new Error('当前没有可以切换全屏的停靠分栏(需要屏幕上有会话,且右侧栏已展开)。');
|
|
751
|
+
service.toggleFullscreen(target);
|
|
752
|
+
return { action, tab_id: target.tabId, note: '已切换右侧栏的显示模式。' };
|
|
753
|
+
}
|
|
754
|
+
case 'split': {
|
|
755
|
+
const paneId = service.split();
|
|
756
|
+
return { action, pane: paneId, note: paneId === undefined ? '当前没有可分割的停靠分栏(或已到两格上限)。' : undefined };
|
|
757
|
+
}
|
|
758
|
+
case 'float': {
|
|
759
|
+
const entry = requested === undefined ? defaultEntry() : findEntry(requested);
|
|
760
|
+
if (entry === undefined) throw new Error('没有可浮出的标签页。');
|
|
761
|
+
service.float(entry.tabId);
|
|
762
|
+
return { action, tab_id: entry.handle };
|
|
763
|
+
}
|
|
764
|
+
case 'dock': {
|
|
765
|
+
const entry = requested === undefined ? defaultEntry() : findEntry(requested);
|
|
766
|
+
if (entry === undefined) throw new Error('没有可停靠的标签页。');
|
|
767
|
+
const marker = tabMarker(entry.tabId, entry.sessionId);
|
|
768
|
+
const floatPane = marker?.closest('[data-dockkit-float]');
|
|
769
|
+
const paneId = floatPane?.getAttribute('data-dockkit-float') ?? undefined;
|
|
770
|
+
if (paneId === undefined) throw new Error('该标签页当前不在浮窗里。');
|
|
771
|
+
service.dock(paneId);
|
|
772
|
+
return { action, tab_id: entry.handle, pane: paneId };
|
|
773
|
+
}
|
|
774
|
+
case 'external': {
|
|
775
|
+
const { frame, entry } = resolveTarget(args);
|
|
776
|
+
const href = frameUrl(frame);
|
|
777
|
+
if (href === undefined) throw new Error('读不到当前页面的网址,无法在系统浏览器中打开。');
|
|
778
|
+
window.open(href, '_blank', 'noopener,noreferrer');
|
|
779
|
+
return { action, tab_id: entry.handle, url: href };
|
|
780
|
+
}
|
|
781
|
+
default:
|
|
782
|
+
throw new Error(`未知的 control 动作:${action}`);
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
/**
|
|
787
|
+
* Run one `page` command against the tab's webview.
|
|
788
|
+
* @param args - command arguments.
|
|
789
|
+
* @returns the operation result.
|
|
790
|
+
*/
|
|
791
|
+
async function page(args) {
|
|
792
|
+
const action = String(args?.action ?? '').toLowerCase();
|
|
793
|
+
const { frame, entry } = resolveTarget(args);
|
|
794
|
+
const view = requireWebview(frame);
|
|
795
|
+
const limit = Number.isFinite(args?.max_chars) ? Math.max(200, Math.min(200_000, Math.trunc(args.max_chars))) : 8000;
|
|
796
|
+
const state = await inspectPage(view);
|
|
797
|
+
const base = { action, tab_id: entry.handle, url: frameUrl(view), title: frameTitle(view) };
|
|
798
|
+
const pageNote = state.failed
|
|
799
|
+
? '页面是 Chromium 错误页(加载失败),内容为空是正常的:可用 browser_control 的 reload 重试。'
|
|
800
|
+
: undefined;
|
|
801
|
+
|
|
802
|
+
switch (action) {
|
|
803
|
+
case 'text': {
|
|
804
|
+
requireLoaded(state, action);
|
|
805
|
+
const text = await view.executeJavaScript('(() => { const body = document.body; return body === null ? "" : body.innerText; })()');
|
|
806
|
+
const rendered = typeof text === 'string' ? text : JSON.stringify(text);
|
|
807
|
+
return {
|
|
808
|
+
...base,
|
|
809
|
+
text: clamp(rendered, limit),
|
|
810
|
+
note: rendered.trim().length === 0
|
|
811
|
+
? '页面正文为空:可能是跳转还没完成(试试 wait)或内容由脚本延迟渲染(试试 eval 读具体节点)。'
|
|
812
|
+
: undefined
|
|
813
|
+
};
|
|
814
|
+
}
|
|
815
|
+
case 'links': {
|
|
816
|
+
requireLoaded(state, action);
|
|
817
|
+
const links = await view.executeJavaScript('(() => [...document.querySelectorAll("a[href]")].slice(0, 300).map((node) => ({ text: (node.innerText || node.textContent || "").replace(/\\s+/g, " ").trim().slice(0, 160), href: node.href })).filter((link) => link.href.startsWith("http")))()');
|
|
818
|
+
const list = (Array.isArray(links) ? links : []).slice(0, 120);
|
|
819
|
+
return { ...base, links: list, note: list.length === 0 ? '页面里没有 http(s) 链接。' : undefined };
|
|
820
|
+
}
|
|
821
|
+
case 'elements': {
|
|
822
|
+
requireLoaded(state, action);
|
|
823
|
+
const elements = await view.executeJavaScript(`(() => {
|
|
824
|
+
${pageHelpers()}
|
|
825
|
+
const path = (el) => {
|
|
826
|
+
const parts = [];
|
|
827
|
+
let node = el;
|
|
828
|
+
while (node !== null && node.nodeType === 1 && parts.length < 6) {
|
|
829
|
+
let part = node.tagName.toLowerCase();
|
|
830
|
+
if (node.id) { parts.unshift(part + '#' + node.id); break; }
|
|
831
|
+
const parent = node.parentElement;
|
|
832
|
+
if (parent !== null) {
|
|
833
|
+
const same = [...parent.children].filter((child) => child.tagName === node.tagName);
|
|
834
|
+
if (same.length > 1) part += ':nth-of-type(' + (same.indexOf(node) + 1) + ')';
|
|
835
|
+
}
|
|
836
|
+
parts.unshift(part);
|
|
837
|
+
node = parent;
|
|
838
|
+
}
|
|
839
|
+
return parts.join(' > ');
|
|
840
|
+
};
|
|
841
|
+
const nodes = [];
|
|
842
|
+
for (const doc of docs()) {
|
|
843
|
+
for (const el of doc.querySelectorAll('a[href],button,input,textarea,select,summary,[role=button],[role=link],[contenteditable=true]')) {
|
|
844
|
+
if (visible(el)) nodes.push(el);
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
return nodes.slice(0, 80).map((el) => {
|
|
848
|
+
const info = describe(el);
|
|
849
|
+
return {
|
|
850
|
+
tag: info.tag,
|
|
851
|
+
type: info.type,
|
|
852
|
+
selector: path(el),
|
|
853
|
+
text: (el.innerText || el.value || el.getAttribute('aria-label') || el.placeholder || '').replace(/\\s+/g, ' ').trim().slice(0, 120),
|
|
854
|
+
name: info.name,
|
|
855
|
+
placeholder: info.placeholder,
|
|
856
|
+
label: info.label || undefined,
|
|
857
|
+
autocomplete: info.autocomplete,
|
|
858
|
+
value: info.value || undefined,
|
|
859
|
+
checked: info.checked,
|
|
860
|
+
href: el.getAttribute('href') || undefined
|
|
861
|
+
};
|
|
862
|
+
});
|
|
863
|
+
})()`);
|
|
864
|
+
const list = Array.isArray(elements) ? elements : [];
|
|
865
|
+
return { ...base, elements: list, note: list.length === 0 ? '这个页面没有可交互元素(或它们都不可见)。' : undefined };
|
|
866
|
+
}
|
|
867
|
+
case 'fill': {
|
|
868
|
+
requireLoaded(state, action);
|
|
869
|
+
const specs = Array.isArray(args?.fields) ? args.fields : [];
|
|
870
|
+
if (specs.length === 0) throw new Error('fill 需要一个 fields 数组,例如 [{ label: "username", value: "alice" }, { label: "password", value: "…" }]。');
|
|
871
|
+
const outcome = await view.executeJavaScript(`(() => {
|
|
872
|
+
${pageHelpers()}
|
|
873
|
+
const specs = ${JSON.stringify(specs)};
|
|
874
|
+
const report = [];
|
|
875
|
+
let last = null;
|
|
876
|
+
for (const spec of specs) {
|
|
877
|
+
const el = findField(spec);
|
|
878
|
+
if (el === null) { report.push({ target: spec.selector || spec.label || spec.name || spec.placeholder || spec.type, ok: false, reason: 'not-found' }); continue; }
|
|
879
|
+
if (spec.value === undefined || spec.value === null) { report.push({ target: spec.selector || spec.label, ok: false, reason: 'no-value' }); continue; }
|
|
880
|
+
if (el.type === 'checkbox' || el.type === 'radio') {
|
|
881
|
+
const wanted = spec.value === true || spec.value === 'true' || spec.value === 'on';
|
|
882
|
+
if (el.checked !== wanted) el.click();
|
|
883
|
+
report.push({ target: spec.selector || spec.label || spec.name, ok: true, kind: el.type, checked: el.checked });
|
|
884
|
+
} else if (el.tagName === 'SELECT') {
|
|
885
|
+
el.value = String(spec.value);
|
|
886
|
+
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
887
|
+
report.push({ target: spec.selector || spec.label || spec.name, ok: true, kind: 'select', value: String(el.value) });
|
|
888
|
+
} else {
|
|
889
|
+
el.focus();
|
|
890
|
+
setValue(el, String(spec.value));
|
|
891
|
+
report.push({ target: spec.selector || spec.label || spec.name, ok: true, kind: el.type, filled: el.type === 'password' ? '••••' : String(el.value).slice(0, 40) });
|
|
892
|
+
}
|
|
893
|
+
last = el;
|
|
894
|
+
}
|
|
895
|
+
let submitted = false;
|
|
896
|
+
if (${args?.submit === true ? 'true' : 'false'} && last !== null) submitted = submitForm(last);
|
|
897
|
+
return { report, submitted };
|
|
898
|
+
})()`, true);
|
|
899
|
+
const filled = Array.isArray(outcome?.report) ? outcome.report.filter((entry) => entry.ok).length : 0;
|
|
900
|
+
const missing = Array.isArray(outcome?.report) ? outcome.report.filter((entry) => entry.ok !== true) : [];
|
|
901
|
+
const settled = outcome?.submitted === true ? await settle(view, base.url, 5000) : base.url;
|
|
902
|
+
return {
|
|
903
|
+
...base,
|
|
904
|
+
value: outcome,
|
|
905
|
+
url: settled,
|
|
906
|
+
title: frameTitle(view),
|
|
907
|
+
note: `${filled}/${specs.length} 个字段已填写${outcome?.submitted === true ? '并已提交表单' : ''}。${missing.length > 0 ? `未找到:${missing.map((entry) => entry.target).join('、')}(先用 elements 看可用字段)。` : ''} 密码不会出现在任何返回值里。`
|
|
908
|
+
};
|
|
909
|
+
}
|
|
910
|
+
case 'key': {
|
|
911
|
+
requireLoaded(state, action);
|
|
912
|
+
const key = typeof args?.key === 'string' && args.key.length > 0 ? args.key : 'Enter';
|
|
913
|
+
const selector = typeof args?.selector === 'string' && args.selector.length > 0 ? args.selector : undefined;
|
|
914
|
+
const outcome = await view.executeJavaScript(`(() => {
|
|
915
|
+
${pageHelpers()}
|
|
916
|
+
const el = ${selector !== undefined ? `document.querySelector(${JSON.stringify(selector)})` : 'document.activeElement'};
|
|
917
|
+
const target = el === null || el === undefined ? document.body : el;
|
|
918
|
+
if (target.focus !== undefined) target.focus();
|
|
919
|
+
const key = ${JSON.stringify(key)};
|
|
920
|
+
const code = key.length === 1 ? 'Key' + key.toUpperCase() : key;
|
|
921
|
+
const init = { key, code, keyCode: key === 'Enter' ? 13 : key === 'Escape' ? 27 : key === 'Tab' ? 9 : 0, which: 0, bubbles: true, cancelable: true };
|
|
922
|
+
target.dispatchEvent(new KeyboardEvent('keydown', init));
|
|
923
|
+
target.dispatchEvent(new KeyboardEvent('keypress', init));
|
|
924
|
+
target.dispatchEvent(new KeyboardEvent('keyup', init));
|
|
925
|
+
const submitted = key === 'Enter' && target.tagName !== 'TEXTAREA' ? submitForm(target) : false;
|
|
926
|
+
return { key, tag: target.tagName.toLowerCase(), submitted };
|
|
927
|
+
})()`, true);
|
|
928
|
+
const submittedKey = outcome?.submitted === true;
|
|
929
|
+
const settledKey = submittedKey ? await settle(view, base.url, 5000) : base.url;
|
|
930
|
+
return { ...base, value: outcome, url: settledKey, title: frameTitle(view), note: submittedKey ? `已按下 ${key} 并提交所在表单。` : `已按下 ${key}。` };
|
|
931
|
+
}
|
|
932
|
+
case 'login': {
|
|
933
|
+
requireLoaded(state, action);
|
|
934
|
+
const user = typeof args?.user === 'string' ? args.user : undefined;
|
|
935
|
+
const password = typeof args?.password === 'string' ? args.password : undefined;
|
|
936
|
+
if (user === undefined || password === undefined) throw new Error('login 需要 user 和 password 两个参数(密码只用于这一次填写,不会存入日志或返回)。');
|
|
937
|
+
const userSelector = typeof args?.user_selector === 'string' && args.user_selector.length > 0 ? args.user_selector : undefined;
|
|
938
|
+
const passwordSelector = typeof args?.password_selector === 'string' && args.password_selector.length > 0 ? args.password_selector : undefined;
|
|
939
|
+
const pre = await view.executeJavaScript(`(() => {
|
|
940
|
+
${pageHelpers()}
|
|
941
|
+
const list = fields().map(describe);
|
|
942
|
+
return { hasPassword: list.some((field) => field.type === 'password'), fields: list.slice(0, 12) };
|
|
943
|
+
})()`);
|
|
944
|
+
if (pre?.hasPassword !== true && passwordSelector === undefined) {
|
|
945
|
+
throw new Error('这个页面里没有可见的密码输入框(可能已经登录,或登录表单在跨域 iframe 里)。先用 elements 确认。');
|
|
946
|
+
}
|
|
947
|
+
const outcome = await view.executeJavaScript(`(() => {
|
|
948
|
+
${pageHelpers()}
|
|
949
|
+
const password = findField({ selector: ${JSON.stringify(passwordSelector ?? null)}, type: 'password' });
|
|
950
|
+
let account = findField({ selector: ${JSON.stringify(userSelector ?? null)}, type: 'username' });
|
|
951
|
+
if (account === null && password !== null) {
|
|
952
|
+
/* The account field is normally the last text input before the password. */
|
|
953
|
+
const before = fields().filter((el) => el.compareDocumentPosition(password) & Node.DOCUMENT_POSITION_FOLLOWING || el === password);
|
|
954
|
+
const textish = before.filter((el) => ['text', 'email', 'tel', 'number', ''].includes((el.getAttribute('type') || '').toLowerCase()) || el.tagName === 'INPUT');
|
|
955
|
+
account = textish.length > 0 ? textish[textish.length - 1] : null;
|
|
956
|
+
}
|
|
957
|
+
const report = [];
|
|
958
|
+
if (account !== null && account !== undefined) { account.focus(); setValue(account, ${JSON.stringify(user)}); report.push({ field: 'account', ok: true, name: describe(account).name ?? describe(account).label ?? '' }); }
|
|
959
|
+
else report.push({ field: 'account', ok: false });
|
|
960
|
+
if (password !== null && password !== undefined) { password.focus(); setValue(password, ${JSON.stringify(password)}); report.push({ field: 'password', ok: true }); }
|
|
961
|
+
else report.push({ field: 'password', ok: false });
|
|
962
|
+
const submitted = password !== null && password !== undefined ? submitForm(password) : false;
|
|
963
|
+
return { report, submitted };
|
|
964
|
+
})()`, true);
|
|
965
|
+
const accountOk = Array.isArray(outcome?.report) && outcome.report.some((entry) => entry.field === 'account' && entry.ok === true);
|
|
966
|
+
const passwordOk = Array.isArray(outcome?.report) && outcome.report.some((entry) => entry.field === 'password' && entry.ok === true);
|
|
967
|
+
const before = base.url;
|
|
968
|
+
const settledUrl = await settle(view, before, 8000);
|
|
969
|
+
const after = await view.executeJavaScript(`(() => {
|
|
970
|
+
${pageHelpers()}
|
|
971
|
+
const list = fields().map(describe);
|
|
972
|
+
const body = (document.body === null ? '' : document.body.innerText).slice(0, 4000);
|
|
973
|
+
return {
|
|
974
|
+
hasPassword: list.some((field) => field.type === 'password'),
|
|
975
|
+
signals: /log ?out|sign ?out|退出|注销|我的账号|my account|profile|dashboard/i.test(body) ? 'account-ui' : undefined,
|
|
976
|
+
error: /incorrect|invalid|wrong password|密码错误|验证码|错误|失败|captcha|验证/i.test(body) ? 'page-reports-a-problem' : undefined
|
|
977
|
+
};
|
|
978
|
+
})()`);
|
|
979
|
+
/* A login form inside an iframe navigates the frame, not the tab, so
|
|
980
|
+
both the frame's fields and the top document are re-checked. */
|
|
981
|
+
const frameLanded = await view.executeJavaScript(`(() => {
|
|
982
|
+
for (const doc of (() => {
|
|
983
|
+
const list = [document];
|
|
984
|
+
for (const frame of document.querySelectorAll('iframe')) { try { if (frame.contentDocument !== null) list.push(frame.contentDocument); } catch (error) { /* cross-origin */ } }
|
|
985
|
+
return list;
|
|
986
|
+
})()) {
|
|
987
|
+
const text = (doc.body === null ? '' : doc.body.innerText).slice(0, 2000);
|
|
988
|
+
if (/欢迎|welcome|dashboard|账号中心|已登录/i.test(text)) return { landed: true, url: doc.location.href, excerpt: text.replace(/\\s+/g, ' ').trim().slice(0, 120) };
|
|
989
|
+
}
|
|
990
|
+
return { landed: false };
|
|
991
|
+
})()`);
|
|
992
|
+
return {
|
|
993
|
+
...base,
|
|
994
|
+
url: settledUrl,
|
|
995
|
+
value: {
|
|
996
|
+
filled_account: accountOk,
|
|
997
|
+
filled_password: passwordOk,
|
|
998
|
+
submitted: outcome?.submitted === true,
|
|
999
|
+
password_field_gone: after?.hasPassword === false,
|
|
1000
|
+
landed: frameLanded?.landed === true ? frameLanded.url : undefined,
|
|
1001
|
+
signal: after?.signals,
|
|
1002
|
+
warning: after?.error
|
|
1003
|
+
},
|
|
1004
|
+
title: frameTitle(view),
|
|
1005
|
+
note: [
|
|
1006
|
+
accountOk && passwordOk ? '账号与密码已填写' : `字段查找:账号${accountOk ? '✓' : '✗'} 密码${passwordOk ? '✓' : '✗'}(可用 *_selector 明确指定)`,
|
|
1007
|
+
outcome?.submitted === true ? '已提交' : '未提交(请检查提交按钮,或用 click 点它)',
|
|
1008
|
+
frameLanded?.landed === true ? `已看到登录后的页面内容:${frameLanded.excerpt}` : undefined,
|
|
1009
|
+
after?.hasPassword === false ? '登录框已消失,通常表示登录成功' : undefined,
|
|
1010
|
+
after?.signals === 'account-ui' ? '页面上出现了账号相关文字,可进一步确认' : undefined,
|
|
1011
|
+
after?.error === 'page-reports-a-problem' ? '页面文字里出现了错误/验证码类提示,可能需要人工处理' : undefined
|
|
1012
|
+
].filter((part) => typeof part === 'string').join(';')
|
|
1013
|
+
};
|
|
1014
|
+
}
|
|
1015
|
+
case 'images': {
|
|
1016
|
+
requireLoaded(state, action);
|
|
1017
|
+
const images = await view.executeJavaScript(`(() => {
|
|
1018
|
+
const out = [];
|
|
1019
|
+
for (const el of document.querySelectorAll('img,picture source,svg image,canvas')) {
|
|
1020
|
+
const rect = el.getBoundingClientRect();
|
|
1021
|
+
if (!(rect.width > 1 && rect.height > 1 && rect.bottom > -50 && rect.top < window.innerHeight + 50)) continue;
|
|
1022
|
+
const source = el.currentSrc || el.src || '';
|
|
1023
|
+
out.push({
|
|
1024
|
+
index: out.length + 1,
|
|
1025
|
+
tag: el.tagName.toLowerCase(),
|
|
1026
|
+
alt: el.getAttribute('alt') || undefined,
|
|
1027
|
+
width: Math.round(rect.width),
|
|
1028
|
+
height: Math.round(rect.height),
|
|
1029
|
+
natural_width: el.naturalWidth || undefined,
|
|
1030
|
+
natural_height: el.naturalHeight || undefined,
|
|
1031
|
+
src: String(source).slice(0, 200)
|
|
1032
|
+
});
|
|
1033
|
+
if (out.length >= 40) break;
|
|
1034
|
+
}
|
|
1035
|
+
return out;
|
|
1036
|
+
})()`);
|
|
1037
|
+
const list = Array.isArray(images) ? images : [];
|
|
1038
|
+
return {
|
|
1039
|
+
...base,
|
|
1040
|
+
images: list,
|
|
1041
|
+
note: list.length === 0
|
|
1042
|
+
? '当前视口里没有可见图片(可以先 scroll 或 wait 再试)。'
|
|
1043
|
+
: '用 browser_page { action: "image", index: N } 就能直接看到其中一张。'
|
|
1044
|
+
};
|
|
1045
|
+
}
|
|
1046
|
+
case 'eval': {
|
|
1047
|
+
if (typeof args?.code !== 'string' || args.code.trim().length === 0) throw new Error('eval 需要一个 code 参数。');
|
|
1048
|
+
const value = await view.executeJavaScript(args.code, true);
|
|
1049
|
+
return { ...base, value: clampValue(value, limit), note: pageNote };
|
|
1050
|
+
}
|
|
1051
|
+
case 'click': {
|
|
1052
|
+
const selector = typeof args?.selector === 'string' && args.selector.length > 0 ? args.selector : undefined;
|
|
1053
|
+
const wanted = typeof args?.text === 'string' && args.text.length > 0 ? args.text : undefined;
|
|
1054
|
+
if (selector === undefined && wanted === undefined) throw new Error('click 需要 selector 或 text 参数。');
|
|
1055
|
+
requireLoaded(state, action);
|
|
1056
|
+
const before = new Set(browserEntries().map((candidate) => candidate.handle));
|
|
1057
|
+
const outcome = await view.executeJavaScript(`(() => {
|
|
1058
|
+
const el = ${matchExpression(selector, wanted)};
|
|
1059
|
+
if (el === null) return { ok: false, reason: 'not-found' };
|
|
1060
|
+
el.scrollIntoView({ block: 'center', inline: 'center' });
|
|
1061
|
+
el.click();
|
|
1062
|
+
return {
|
|
1063
|
+
ok: true,
|
|
1064
|
+
tag: el.tagName.toLowerCase(),
|
|
1065
|
+
text: (el.innerText || el.value || el.textContent || '').replace(/\\s+/g, ' ').trim().slice(0, 200),
|
|
1066
|
+
target: el.getAttribute('target') || undefined,
|
|
1067
|
+
actionable: el.matches('a[href],button,input,select,textarea,summary,[role=button],[role=link]')
|
|
1068
|
+
};
|
|
1069
|
+
})()`, true);
|
|
1070
|
+
if (outcome?.ok !== true) throw new Error(`没有找到可点击的目标(${selector ?? wanted})。可先用 browser_page 的 elements 查看可用元素。`);
|
|
1071
|
+
await sleep(700);
|
|
1072
|
+
const opened = browserEntries().find((candidate) => !before.has(candidate.handle));
|
|
1073
|
+
if (opened !== undefined) {
|
|
1074
|
+
const openedFrame = (await waitForFrame(opened.tabId, opened.sessionId, 3000)) ?? undefined;
|
|
1075
|
+
const committed = await waitForCommit(openedFrame, 2500);
|
|
1076
|
+
return {
|
|
1077
|
+
...base,
|
|
1078
|
+
value: outcome,
|
|
1079
|
+
url: await settle(view, base.url, 2000),
|
|
1080
|
+
opened_tab: opened.handle,
|
|
1081
|
+
opened_url: committed.url,
|
|
1082
|
+
opened_title: committed.title,
|
|
1083
|
+
note: `该链接在新标签页打开:DSH 把 popup 请求变成了新的侧栏标签 ${opened.handle}。请用它继续操作,原标签 ${entry.handle} 仍停在当前页面。`
|
|
1084
|
+
};
|
|
1085
|
+
}
|
|
1086
|
+
const settledUrl = await settle(view, base.url, 3000);
|
|
1087
|
+
const stalled = outcome.actionable === false && settledUrl === base.url;
|
|
1088
|
+
return {
|
|
1089
|
+
...base,
|
|
1090
|
+
value: outcome,
|
|
1091
|
+
url: settledUrl,
|
|
1092
|
+
note: stalled
|
|
1093
|
+
? `点击的元素不是链接或按钮(匹配到的是 <${outcome.tag}> 这样的外层容器),页面没有任何变化。请改用 browser_page 的 elements 取一个精确 selector,再点那个链接或按钮。`
|
|
1094
|
+
: '已派发点击。'
|
|
1095
|
+
};
|
|
1096
|
+
}
|
|
1097
|
+
case 'type': {
|
|
1098
|
+
if (typeof args?.text !== 'string') throw new Error('type 需要一个 text 参数。');
|
|
1099
|
+
requireLoaded(state, action);
|
|
1100
|
+
const selector = typeof args?.selector === 'string' && args.selector.length > 0 ? args.selector : undefined;
|
|
1101
|
+
const submit = args?.submit === true;
|
|
1102
|
+
const outcome = await view.executeJavaScript(`(() => {
|
|
1103
|
+
const el = ${selector !== undefined ? `document.querySelector(${JSON.stringify(selector)})` : 'document.activeElement'};
|
|
1104
|
+
if (el === null || el === undefined || !('value' in el)) return { ok: false, reason: 'not-editable' };
|
|
1105
|
+
el.focus();
|
|
1106
|
+
const proto = el instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
|
|
1107
|
+
const setter = Object.getOwnPropertyDescriptor(proto, 'value')?.set;
|
|
1108
|
+
if (setter !== undefined) setter.call(el, ${JSON.stringify(args.text)}); else el.value = ${JSON.stringify(args.text)};
|
|
1109
|
+
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
1110
|
+
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
1111
|
+
if (${submit ? 'true' : 'false'}) {
|
|
1112
|
+
const form = el.form;
|
|
1113
|
+
if (form !== null && form !== undefined && typeof form.requestSubmit === 'function') form.requestSubmit();
|
|
1114
|
+
else el.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', keyCode: 13, which: 13, bubbles: true }));
|
|
1115
|
+
}
|
|
1116
|
+
return { ok: true, tag: el.tagName.toLowerCase(), value: el.value };
|
|
1117
|
+
})()`, true);
|
|
1118
|
+
if (outcome?.ok !== true) throw new Error('没有找到可输入的输入框(可用 selector 指定,或先 click 聚焦它)。');
|
|
1119
|
+
return { ...base, value: { tag: outcome.tag, value: String(outcome.value ?? '').slice(0, 200) }, note: submit ? '已填写并提交。' : '已填写。' };
|
|
1120
|
+
}
|
|
1121
|
+
case 'scroll': {
|
|
1122
|
+
requireLoaded(state, action);
|
|
1123
|
+
const pixels = Number.isFinite(args?.ms) ? Math.trunc(args.ms) : 0;
|
|
1124
|
+
await view.executeJavaScript(`(() => { window.scrollBy({ top: ${pixels} === 0 ? Math.round(window.innerHeight * 0.9) : ${pixels}, behavior: 'smooth' }); return true; })()`);
|
|
1125
|
+
await sleep(450);
|
|
1126
|
+
const position = await view.executeJavaScript('(() => ({ x: window.scrollX, y: window.scrollY, height: document.documentElement.scrollHeight, viewport: window.innerHeight }))()');
|
|
1127
|
+
return { ...base, value: position };
|
|
1128
|
+
}
|
|
1129
|
+
case 'wait': {
|
|
1130
|
+
requireLoaded(state, action);
|
|
1131
|
+
const timeout = Number.isFinite(args?.ms) ? Math.max(200, Math.min(60_000, Math.trunc(args.ms))) : 10_000;
|
|
1132
|
+
const selector = typeof args?.selector === 'string' && args.selector.length > 0 ? args.selector : undefined;
|
|
1133
|
+
const wanted = typeof args?.text === 'string' && args.text.length > 0 ? args.text : undefined;
|
|
1134
|
+
const urlWanted = typeof args?.url_contains === 'string' && args.url_contains.length > 0 ? args.url_contains : undefined;
|
|
1135
|
+
const condition = selector !== undefined || wanted !== undefined
|
|
1136
|
+
? `(() => { const el = ${matchExpression(selector, wanted)}; return el !== null; })()`
|
|
1137
|
+
: '(() => document.readyState === "complete")()';
|
|
1138
|
+
const deadline = Date.now() + timeout;
|
|
1139
|
+
while (Date.now() < deadline) {
|
|
1140
|
+
if (urlWanted !== undefined && (frameUrl(view) ?? '').includes(urlWanted)) {
|
|
1141
|
+
return { ...base, url: frameUrl(view), note: `网址已包含 ${urlWanted}。` };
|
|
1142
|
+
}
|
|
1143
|
+
const ready = urlWanted === undefined ? await view.executeJavaScript(condition) : false;
|
|
1144
|
+
if (ready === true) return { ...base, note: '条件已满足。' };
|
|
1145
|
+
await sleep(250);
|
|
1146
|
+
}
|
|
1147
|
+
throw new Error(`等待超时(${timeout}ms):${urlWanted !== undefined ? `网址未包含 ${urlWanted}` : `${selector ?? wanted ?? '页面加载'} 未出现`}。`);
|
|
1148
|
+
}
|
|
1149
|
+
case 'screenshot':
|
|
1150
|
+
throw new Error('本插件不提供截图:webview.capturePage() 会让 Electron 渲染进程原生崩溃(已实测),因此该能力被刻意移除。需要页面图像时,用 browser_control 的 external 在系统浏览器中打开。');
|
|
1151
|
+
default:
|
|
1152
|
+
throw new Error(`未知的 page 动作:${action}`);
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
/**
|
|
1157
|
+
* Execute one command from the Host half.
|
|
1158
|
+
* @param command - `{ id, op, args }`.
|
|
1159
|
+
* @returns the operation result.
|
|
1160
|
+
*/
|
|
1161
|
+
async function execute(command) {
|
|
1162
|
+
switch (command.op) {
|
|
1163
|
+
case 'tabs':
|
|
1164
|
+
return listTabs();
|
|
1165
|
+
case 'control':
|
|
1166
|
+
return await control(command.args ?? {});
|
|
1167
|
+
case 'page':
|
|
1168
|
+
return await page(command.args ?? {});
|
|
1169
|
+
case 'geometry':
|
|
1170
|
+
return geometry(command.args ?? {});
|
|
1171
|
+
case 'image':
|
|
1172
|
+
return await image(command.args ?? {});
|
|
1173
|
+
default:
|
|
1174
|
+
throw new Error(`未知的内置浏览器操作:${command.op}`);
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
/**
|
|
1179
|
+
* Where the Browser pane sits inside the DSH window.
|
|
1180
|
+
*
|
|
1181
|
+
* The Host half captures the window itself (there is no safe in-renderer
|
|
1182
|
+
* capture API), so it needs the pane's viewport rectangle plus the frame
|
|
1183
|
+
* offsets that separate the viewport from the window rectangle.
|
|
1184
|
+
* @param args - `{ tab_id? }`.
|
|
1185
|
+
* @returns geometry for the Host's crop maths.
|
|
1186
|
+
*/
|
|
1187
|
+
function geometry(args) {
|
|
1188
|
+
const { frame, entry } = resolveTarget(args);
|
|
1189
|
+
const rect = frame.getBoundingClientRect();
|
|
1190
|
+
return {
|
|
1191
|
+
tab_id: entry.handle,
|
|
1192
|
+
url: frameUrl(frame),
|
|
1193
|
+
title: frameTitle(frame),
|
|
1194
|
+
rect: { left: rect.left, top: rect.top, width: rect.width, height: rect.height },
|
|
1195
|
+
devicePixelRatio: window.devicePixelRatio || 1,
|
|
1196
|
+
outer: {
|
|
1197
|
+
width: window.outerWidth,
|
|
1198
|
+
height: window.outerHeight,
|
|
1199
|
+
innerWidth: window.innerWidth,
|
|
1200
|
+
innerHeight: window.innerHeight
|
|
1201
|
+
}
|
|
1202
|
+
};
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
/**
|
|
1206
|
+
* Hand the Host one picture from the page so it can attach it to a result.
|
|
1207
|
+
*
|
|
1208
|
+
* Bytes travel from the Host, not from here: the page's own `fetch` is bound
|
|
1209
|
+
* by CORS, while the Host's Node fetch is not. Only `data:` and `blob:`
|
|
1210
|
+
* sources — which the Host cannot read — are decoded in the page.
|
|
1211
|
+
* @param args - `{ tab_id?, index?, selector?, src? }`.
|
|
1212
|
+
* @returns `{ url }` for the Host to fetch, or `{ inline, media_type }`.
|
|
1213
|
+
*/
|
|
1214
|
+
async function image(args) {
|
|
1215
|
+
const { frame, entry } = resolveTarget(args);
|
|
1216
|
+
requireWebview(frame);
|
|
1217
|
+
const index = Number.isFinite(args?.index) ? Math.trunc(args.index) : undefined;
|
|
1218
|
+
const selector = typeof args?.selector === 'string' && args.selector.length > 0 ? args.selector : undefined;
|
|
1219
|
+
const wanted = typeof args?.src === 'string' && args.src.length > 0 ? args.src : undefined;
|
|
1220
|
+
if (index === undefined && selector === undefined && wanted === undefined) {
|
|
1221
|
+
throw new Error('image 需要 index(来自 images)、selector 或 src 之一。');
|
|
1222
|
+
}
|
|
1223
|
+
const found = await frame.executeJavaScript(`(() => {
|
|
1224
|
+
/* Same ordering as the images action: viewport-visible pictures only. */
|
|
1225
|
+
const all = [...document.querySelectorAll('img,picture source,svg image,canvas')].filter((el) => {
|
|
1226
|
+
const rect = el.getBoundingClientRect();
|
|
1227
|
+
return rect.width > 1 && rect.height > 1 && rect.bottom > -50 && rect.top < window.innerHeight + 50;
|
|
1228
|
+
});
|
|
1229
|
+
const pick = ${index !== undefined ? `all[${index - 1}] ?? null` : 'null'};
|
|
1230
|
+
const bySelector = ${selector !== undefined ? `document.querySelector(${JSON.stringify(selector)})` : 'null'};
|
|
1231
|
+
const el = pick ?? bySelector ?? null;
|
|
1232
|
+
if (el === null) return { ok: false };
|
|
1233
|
+
const toBlobSource = ${wanted !== undefined ? JSON.stringify(wanted) : 'null'};
|
|
1234
|
+
const source = toBlobSource ?? el.currentSrc ?? el.src ?? (el.tagName === 'CANVAS' ? el.toDataURL('image/png') : null);
|
|
1235
|
+
if (source === null || source === undefined || String(source).length === 0) return { ok: false, reason: 'no-source' };
|
|
1236
|
+
const rect = el.getBoundingClientRect();
|
|
1237
|
+
return {
|
|
1238
|
+
ok: true,
|
|
1239
|
+
src: String(source),
|
|
1240
|
+
alt: el.getAttribute('alt') || undefined,
|
|
1241
|
+
width: Math.round(rect.width),
|
|
1242
|
+
height: Math.round(rect.height),
|
|
1243
|
+
natural_width: el.naturalWidth || undefined,
|
|
1244
|
+
natural_height: el.naturalHeight || undefined,
|
|
1245
|
+
tag: el.tagName.toLowerCase()
|
|
1246
|
+
};
|
|
1247
|
+
})()`);
|
|
1248
|
+
if (found?.ok !== true) throw new Error('没有找到这张图片(先用 images 看列表,或用 selector 指定)。');
|
|
1249
|
+
const src = String(found.src);
|
|
1250
|
+
const base = { action: 'image', tab_id: entry.handle, url: frameUrl(frame), title: frameTitle(frame), source: src.slice(0, 300), alt: found.alt, width: found.width, height: found.height };
|
|
1251
|
+
const inline = /^data:([^;,]+)?(;base64)?,/u.exec(src);
|
|
1252
|
+
if (inline !== null) {
|
|
1253
|
+
const comma = src.indexOf(',');
|
|
1254
|
+
const bytes = inline[2] === ';base64' ? src.slice(comma + 1) : btoa(unescape(encodeURIComponent(decodeURIComponent(src.slice(comma + 1)))));
|
|
1255
|
+
return { ...base, inline: bytes, media_type: inline[1] ?? 'image/png' };
|
|
1256
|
+
}
|
|
1257
|
+
if (src.startsWith('blob:')) {
|
|
1258
|
+
const drawn = await frame.executeJavaScript(`(async () => {
|
|
1259
|
+
const response = await fetch(${JSON.stringify(src)});
|
|
1260
|
+
const buffer = await response.arrayBuffer();
|
|
1261
|
+
const bytes = new Uint8Array(buffer);
|
|
1262
|
+
let binary = '';
|
|
1263
|
+
for (let index = 0; index < bytes.length; index += 0x8000) binary += String.fromCharCode.apply(null, bytes.subarray(index, index + 0x8000));
|
|
1264
|
+
return { base64: btoa(binary), type: response.headers.get('content-type') || 'image/png' };
|
|
1265
|
+
})()`);
|
|
1266
|
+
if (typeof drawn?.base64 === 'string') return { ...base, image_url: src, inline: drawn.base64, media_type: drawn.type };
|
|
1267
|
+
throw new Error('无法读取 blob: 图片。');
|
|
1268
|
+
}
|
|
1269
|
+
return { ...base, image_url: src };
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
/**
|
|
1273
|
+
* Report one settled command back to the Host half.
|
|
1274
|
+
* @param id - command id.
|
|
1275
|
+
* @param ok - whether the operation succeeded.
|
|
1276
|
+
* @param value - result value.
|
|
1277
|
+
* @param error - failure message.
|
|
1278
|
+
*/
|
|
1279
|
+
async function report(id, ok, value, error) {
|
|
1280
|
+
try {
|
|
1281
|
+
await fetch(`${BASE}/result`, {
|
|
1282
|
+
method: 'POST',
|
|
1283
|
+
headers: { 'content-type': 'application/json' },
|
|
1284
|
+
body: JSON.stringify({ clientId: state.clientId, id, ok, value: ok ? value : undefined, error: ok ? undefined : error })
|
|
1285
|
+
});
|
|
1286
|
+
} catch (failure) {
|
|
1287
|
+
console.error('[sidebar-browser-control] 汇报结果失败', failure);
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
/**
|
|
1292
|
+
* Run one command and report its outcome.
|
|
1293
|
+
* @param command - `{ id, op, args }`.
|
|
1294
|
+
*/
|
|
1295
|
+
async function run(command) {
|
|
1296
|
+
try {
|
|
1297
|
+
const value = await execute(command);
|
|
1298
|
+
await report(command.id, true, value === undefined ? null : value, undefined);
|
|
1299
|
+
} catch (error) {
|
|
1300
|
+
await report(command.id, false, undefined, String(error?.message ?? error));
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
/** Commands run one at a time, so two calls cannot interleave inside one page. */
|
|
1305
|
+
let queue = Promise.resolve();
|
|
1306
|
+
|
|
1307
|
+
/**
|
|
1308
|
+
* Queue one command behind the commands already running.
|
|
1309
|
+
* @param command - `{ id, op, args }`.
|
|
1310
|
+
*/
|
|
1311
|
+
function enqueue(command) {
|
|
1312
|
+
queue = queue.then(() => run(command), () => run(command));
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
/** Announce this page to the Host half and keep one long-poll open. */
|
|
1316
|
+
async function bridge() {
|
|
1317
|
+
while (!state.stopped) {
|
|
1318
|
+
try {
|
|
1319
|
+
if (state.clientId === undefined) {
|
|
1320
|
+
const answer = await fetch(`${BASE}/hello`, {
|
|
1321
|
+
method: 'POST',
|
|
1322
|
+
headers: { 'content-type': 'application/json' },
|
|
1323
|
+
body: JSON.stringify({
|
|
1324
|
+
platform: platformName(),
|
|
1325
|
+
capabilities: capabilities(),
|
|
1326
|
+
href: location.href
|
|
1327
|
+
})
|
|
1328
|
+
});
|
|
1329
|
+
const body = await answer.json();
|
|
1330
|
+
if (body?.ok !== true || typeof body.clientId !== 'string') throw new Error('握手被拒绝');
|
|
1331
|
+
state.clientId = body.clientId;
|
|
1332
|
+
state.refusals = 0;
|
|
1333
|
+
}
|
|
1334
|
+
const answer = await fetch(`${BASE}/poll?clientId=${encodeURIComponent(state.clientId)}&focus=${document.hasFocus() ? 1 : 0}`, { cache: 'no-store' });
|
|
1335
|
+
if (answer.status === 410) {
|
|
1336
|
+
state.clientId = undefined;
|
|
1337
|
+
continue;
|
|
1338
|
+
}
|
|
1339
|
+
if (!answer.ok) throw new Error(`poll ${answer.status}`);
|
|
1340
|
+
const body = await answer.json();
|
|
1341
|
+
const commands = Array.isArray(body?.commands) ? body.commands : [];
|
|
1342
|
+
for (const command of commands) enqueue(command);
|
|
1343
|
+
} catch (error) {
|
|
1344
|
+
state.refusals += 1;
|
|
1345
|
+
if (state.refusals <= 2) console.error('[sidebar-browser-control] 与宿主的桥接中断,稍后重试', error);
|
|
1346
|
+
state.clientId = undefined;
|
|
1347
|
+
await sleep(Math.min(RETRY_MS * state.refusals, 8000));
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
return {
|
|
1353
|
+
apply(pluginContext) {
|
|
1354
|
+
context = pluginContext;
|
|
1355
|
+
pluginContext.effect(() => {
|
|
1356
|
+
state.stopped = false;
|
|
1357
|
+
void bridge();
|
|
1358
|
+
return () => {
|
|
1359
|
+
state.stopped = true;
|
|
1360
|
+
};
|
|
1361
|
+
}, 'sidebar-browser-control.bridge');
|
|
1362
|
+
}
|
|
1363
|
+
};
|
|
1364
|
+
}
|
|
1365
|
+
});
|