dsh-sidebar-browser-control 0.1.0 → 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/client.js CHANGED
@@ -206,9 +206,9 @@ window.__ModuleLoader__.load({
206
206
  return undefined;
207
207
  }
208
208
 
209
- /** @param frame - a carrier element. @returns whether it is the Electron webview. */
209
+ /** @param frame - a carrier element, possibly absent. @returns whether it is the Electron webview. */
210
210
  function isWebview(frame) {
211
- return frame.tagName !== undefined && frame.tagName.toLowerCase() === 'webview';
211
+ return frame !== undefined && frame !== null && typeof frame.tagName === 'string' && frame.tagName.toLowerCase() === 'webview';
212
212
  }
213
213
 
214
214
  /** @param frame - a carrier element. @returns its current address, when readable. */
@@ -217,10 +217,11 @@ window.__ModuleLoader__.load({
217
217
  try {
218
218
  if (isWebview(frame)) {
219
219
  const url = frame.getURL();
220
- return typeof url === 'string' && url.length > 0 && url !== 'about:blank' ? url : undefined;
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;
221
222
  }
222
223
  const src = frame.getAttribute('src');
223
- return src === null || src.startsWith('about:blank') ? undefined : new URL(src, location.href).href;
224
+ return src === null || !/^https?:/u.test(src) ? undefined : new URL(src, location.href).href;
224
225
  } catch {
225
226
  return undefined;
226
227
  }
@@ -254,11 +255,31 @@ window.__ModuleLoader__.load({
254
255
  return pane?.getAttribute('data-dockkit-pane') ?? pane?.getAttribute('data-dockkit-float') ?? undefined;
255
256
  }
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
+
257
277
  /** @returns every Browser tab, with the addresses its carrier can report. */
258
278
  function listTabs() {
259
279
  const service = sidebar();
260
280
  const screen = onScreenSession();
261
281
  const active = activeTabEntry();
282
+ const capture = captureRoutes();
262
283
  const tabs = browserEntries().map((entry) => {
263
284
  const frame = frameFor(entry.tabId, entry.sessionId);
264
285
  const record = recordFor(entry);
@@ -277,6 +298,7 @@ window.__ModuleLoader__.load({
277
298
  page_control: isDesktop(),
278
299
  panel_open: service.isExpanded?.() === true,
279
300
  active_tab: active?.handle,
301
+ capture,
280
302
  tabs
281
303
  };
282
304
  }
@@ -294,7 +316,7 @@ window.__ModuleLoader__.load({
294
316
  }
295
317
  const frame = frameFor(entry.tabId, entry.sessionId);
296
318
  if (frame === undefined) {
297
- throw new Error(`标签页 ${entry.handle} 当前没有已挂载的页面(它可能在别的会话、已收起,或还未渲染)。可先用 browser_control 的 focus 把它带到屏幕上。`);
319
+ throw new Error(`标签页 ${entry.handle} 当前没有已挂载的页面:它可能是重启后尚未加载、在别的会话、或已收起。用 browser_control 的 navigate 指定地址即可加载它(reload 可重载已保存的地址)。`);
298
320
  }
299
321
  return { entry, frame, tabId: entry.tabId, sessionId: entry.sessionId };
300
322
  }
@@ -384,6 +406,108 @@ window.__ModuleLoader__.load({
384
406
  return `${text.slice(0, limit)}…(已截断,共 ${text.length} 字符)`;
385
407
  }
386
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
+
387
511
  /**
388
512
  * Build the in-page expression that resolves a click/wait target.
389
513
  * @param selector - optional CSS selector.
@@ -395,12 +519,27 @@ window.__ModuleLoader__.load({
395
519
  const selector = ${JSON.stringify(selector ?? null)};
396
520
  const wanted = ${JSON.stringify(text ?? null)};
397
521
  if (selector !== null && selector.length > 0) return document.querySelector(selector);
398
- if (wanted !== null && wanted.length > 0) {
399
- const nodes = [...document.querySelectorAll('a,button,input,textarea,select,summary,label,li,td,th,span,div,p,[role=button],[role=link],[tabindex]')];
400
- const read = (node) => (node.innerText || node.value || node.textContent || '').replace(/\\s+/g, ' ').trim();
401
- return nodes.find((node) => read(node) === wanted) ?? nodes.find((node) => read(node).includes(wanted)) ?? null;
402
- }
403
- return null;
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];
404
543
  })()`;
405
544
  }
406
545
 
@@ -430,6 +569,64 @@ window.__ModuleLoader__.load({
430
569
  return frameUrl(view);
431
570
  }
432
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
+
433
630
  /**
434
631
  * Run one `control` command through the sidebar controller and the carrier.
435
632
  * @param args - command arguments.
@@ -455,12 +652,13 @@ window.__ModuleLoader__.load({
455
652
  const entry = (await waitForNewEntry(known, screen)) ?? (target !== undefined ? target : defaultEntry());
456
653
  if (entry !== undefined) await waitForFrame(entry.tabId, entry.sessionId, 3000);
457
654
  const frame = entry === undefined ? undefined : frameFor(entry.tabId, entry.sessionId);
655
+ const committed = await waitForCommit(frame);
458
656
  return {
459
657
  action,
460
658
  tab_id: entry?.handle,
461
659
  session: entry?.sessionId,
462
- url,
463
- title: frameTitle(frame) || (entry === undefined ? undefined : recordFor(entry)?.title)
660
+ url: committed.url ?? url,
661
+ title: committed.title || (entry === undefined ? undefined : recordFor(entry)?.title)
464
662
  };
465
663
  }
466
664
  case 'navigate': {
@@ -469,11 +667,46 @@ window.__ModuleLoader__.load({
469
667
  if (target === undefined) return await control({ action: 'open', url: args.url });
470
668
  const frame = frameFor(target.tabId, target.sessionId);
471
669
  if (isWebview(frame)) {
472
- await frame.loadURL(url);
473
- return { action, tab_id: target.handle, url, title: frameTitle(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 };
474
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));
475
697
  service.openTab('browser', { params: { url }, replaceTab: target.tabId, revealIfOpened: false });
476
- return { action, tab_id: target.handle, url };
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
+ };
477
710
  }
478
711
  case 'back':
479
712
  case 'forward':
@@ -560,20 +793,35 @@ window.__ModuleLoader__.load({
560
793
  const { frame, entry } = resolveTarget(args);
561
794
  const view = requireWebview(frame);
562
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);
563
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;
564
801
 
565
802
  switch (action) {
566
803
  case 'text': {
804
+ requireLoaded(state, action);
567
805
  const text = await view.executeJavaScript('(() => { const body = document.body; return body === null ? "" : body.innerText; })()');
568
- return { ...base, text: clamp(typeof text === 'string' ? text : JSON.stringify(text), limit) };
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
+ };
569
814
  }
570
815
  case 'links': {
816
+ requireLoaded(state, action);
571
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")))()');
572
- const list = Array.isArray(links) ? links : [];
573
- return { ...base, links: list.slice(0, 120), text: clamp(list.map((link) => `- ${link.text} ${link.href}`).join('\n'), limit) };
818
+ const list = (Array.isArray(links) ? links : []).slice(0, 120);
819
+ return { ...base, links: list, note: list.length === 0 ? '页面里没有 http(s) 链接。' : undefined };
574
820
  }
575
821
  case 'elements': {
822
+ requireLoaded(state, action);
576
823
  const elements = await view.executeJavaScript(`(() => {
824
+ ${pageHelpers()}
577
825
  const path = (el) => {
578
826
  const parts = [];
579
827
  let node = el;
@@ -590,35 +838,265 @@ window.__ModuleLoader__.load({
590
838
  }
591
839
  return parts.join(' > ');
592
840
  };
593
- return [...document.querySelectorAll('a[href],button,input,textarea,select,summary,[role=button],[role=link],[contenteditable=true]')]
594
- .filter((el) => { const rect = el.getBoundingClientRect(); return rect.width > 1 && rect.height > 1; })
595
- .slice(0, 80)
596
- .map((el) => ({ tag: el.tagName.toLowerCase(), selector: path(el), text: (el.innerText || el.value || el.getAttribute('aria-label') || el.placeholder || '').replace(/\\s+/g, ' ').trim().slice(0, 120), href: el.getAttribute('href') || undefined }));
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
+ });
597
863
  })()`);
598
864
  const list = Array.isArray(elements) ? elements : [];
599
- return { ...base, elements: list, text: clamp(list.map((element) => `- ${element.selector} — ${element.text}${element.href !== undefined ? ` (${element.href})` : ''}`).join('\n'), limit) };
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
+ };
600
1045
  }
601
1046
  case 'eval': {
602
1047
  if (typeof args?.code !== 'string' || args.code.trim().length === 0) throw new Error('eval 需要一个 code 参数。');
603
1048
  const value = await view.executeJavaScript(args.code, true);
604
- return { ...base, value: clampValue(value, limit) };
1049
+ return { ...base, value: clampValue(value, limit), note: pageNote };
605
1050
  }
606
1051
  case 'click': {
607
1052
  const selector = typeof args?.selector === 'string' && args.selector.length > 0 ? args.selector : undefined;
608
1053
  const wanted = typeof args?.text === 'string' && args.text.length > 0 ? args.text : undefined;
609
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));
610
1057
  const outcome = await view.executeJavaScript(`(() => {
611
1058
  const el = ${matchExpression(selector, wanted)};
612
1059
  if (el === null) return { ok: false, reason: 'not-found' };
613
1060
  el.scrollIntoView({ block: 'center', inline: 'center' });
614
1061
  el.click();
615
- return { ok: true, tag: el.tagName.toLowerCase(), text: (el.innerText || el.value || el.textContent || '').replace(/\\s+/g, ' ').trim().slice(0, 200) };
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
+ };
616
1069
  })()`, true);
617
1070
  if (outcome?.ok !== true) throw new Error(`没有找到可点击的目标(${selector ?? wanted})。可先用 browser_page 的 elements 查看可用元素。`);
618
- return { ...base, value: outcome, url: frameUrl(view), note: '已派发点击。' };
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
+ };
619
1096
  }
620
1097
  case 'type': {
621
1098
  if (typeof args?.text !== 'string') throw new Error('type 需要一个 text 参数。');
1099
+ requireLoaded(state, action);
622
1100
  const selector = typeof args?.selector === 'string' && args.selector.length > 0 ? args.selector : undefined;
623
1101
  const submit = args?.submit === true;
624
1102
  const outcome = await view.executeJavaScript(`(() => {
@@ -641,6 +1119,7 @@ window.__ModuleLoader__.load({
641
1119
  return { ...base, value: { tag: outcome.tag, value: String(outcome.value ?? '').slice(0, 200) }, note: submit ? '已填写并提交。' : '已填写。' };
642
1120
  }
643
1121
  case 'scroll': {
1122
+ requireLoaded(state, action);
644
1123
  const pixels = Number.isFinite(args?.ms) ? Math.trunc(args.ms) : 0;
645
1124
  await view.executeJavaScript(`(() => { window.scrollBy({ top: ${pixels} === 0 ? Math.round(window.innerHeight * 0.9) : ${pixels}, behavior: 'smooth' }); return true; })()`);
646
1125
  await sleep(450);
@@ -648,19 +1127,24 @@ window.__ModuleLoader__.load({
648
1127
  return { ...base, value: position };
649
1128
  }
650
1129
  case 'wait': {
1130
+ requireLoaded(state, action);
651
1131
  const timeout = Number.isFinite(args?.ms) ? Math.max(200, Math.min(60_000, Math.trunc(args.ms))) : 10_000;
652
1132
  const selector = typeof args?.selector === 'string' && args.selector.length > 0 ? args.selector : undefined;
653
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;
654
1135
  const condition = selector !== undefined || wanted !== undefined
655
1136
  ? `(() => { const el = ${matchExpression(selector, wanted)}; return el !== null; })()`
656
1137
  : '(() => document.readyState === "complete")()';
657
1138
  const deadline = Date.now() + timeout;
658
1139
  while (Date.now() < deadline) {
659
- const ready = await view.executeJavaScript(condition);
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;
660
1144
  if (ready === true) return { ...base, note: '条件已满足。' };
661
1145
  await sleep(250);
662
1146
  }
663
- throw new Error(`等待超时(${timeout}ms):${selector ?? wanted ?? '页面加载'} 未出现。`);
1147
+ throw new Error(`等待超时(${timeout}ms):${urlWanted !== undefined ? `网址未包含 ${urlWanted}` : `${selector ?? wanted ?? '页面加载'} 未出现`}。`);
664
1148
  }
665
1149
  case 'screenshot':
666
1150
  throw new Error('本插件不提供截图:webview.capturePage() 会让 Electron 渲染进程原生崩溃(已实测),因此该能力被刻意移除。需要页面图像时,用 browser_control 的 external 在系统浏览器中打开。');
@@ -682,11 +1166,109 @@ window.__ModuleLoader__.load({
682
1166
  return await control(command.args ?? {});
683
1167
  case 'page':
684
1168
  return await page(command.args ?? {});
1169
+ case 'geometry':
1170
+ return geometry(command.args ?? {});
1171
+ case 'image':
1172
+ return await image(command.args ?? {});
685
1173
  default:
686
1174
  throw new Error(`未知的内置浏览器操作:${command.op}`);
687
1175
  }
688
1176
  }
689
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
+
690
1272
  /**
691
1273
  * Report one settled command back to the Host half.
692
1274
  * @param id - command id.