yunti-browser-runtime 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -338,7 +338,7 @@ function isExtensionContextInvalidated(error) {
338
338
  }
339
339
 
340
340
  function contextInvalidatedResponse() {
341
- return { ok: false, error: "插件已重新加载,请刷新 browser page恢复连接。" }
341
+ return { ok: false, error: "插件已重新加载,正在等待扩展自动恢复连接;必要时再刷新页面。" }
342
342
  }
343
343
 
344
344
  function markExtensionContextInvalidated() {
@@ -382,6 +382,8 @@ async function executeTool(tool, args) {
382
382
  return clickElement(args)
383
383
  case "yunti_click_at":
384
384
  return clickAt(args)
385
+ case "yunti_hover":
386
+ return hoverElement(args)
385
387
  case "yunti_fill":
386
388
  return fillElement(args)
387
389
  case "yunti_type_text":
@@ -548,10 +550,13 @@ function rollbackPreviewPatch(patchId) {
548
550
  }
549
551
 
550
552
  function clickElement(args) {
551
- const element = mustFind(args.selector)
552
- element.scrollIntoView({ block: "center", inline: "center" })
553
- element.click()
554
- return { clicked: true, element: describeElement(element) }
553
+ if (args.selector) {
554
+ const element = mustFind(args.selector)
555
+ element.scrollIntoView({ block: "center", inline: "center" })
556
+ element.click()
557
+ return { clicked: true, element: describeElement(element) }
558
+ }
559
+ return clickAt(args)
555
560
  }
556
561
 
557
562
  function clickAt(args) {
@@ -569,12 +574,36 @@ function clickAt(args) {
569
574
  }
570
575
  }
571
576
 
577
+ function hoverElement(args) {
578
+ const element = resolveTargetElement(args)
579
+ if (!element) throw new Error("yunti_hover requires selector or x/y coordinates")
580
+ element.scrollIntoView({ block: "center", inline: "center" })
581
+ const rect = element.getBoundingClientRect()
582
+ const x = Number.isFinite(Number(args.x)) ? Number(args.x) : rect.left + rect.width / 2
583
+ const y = Number.isFinite(Number(args.y)) ? Number(args.y) : rect.top + rect.height / 2
584
+ focusElement(element)
585
+ dispatchHoverSequence(element, x, y)
586
+ return {
587
+ hovered: true,
588
+ x: Math.round(x),
589
+ y: Math.round(y),
590
+ element: describeElement(element),
591
+ }
592
+ }
593
+
572
594
  function fillElement(args) {
573
- const element = mustFind(args.selector)
595
+ const element = resolveTargetElement(args)
596
+ if (!element) throw new Error("yunti_fill requires selector or x/y coordinates")
574
597
  const value = String(args.value ?? "")
575
598
  element.scrollIntoView({ block: "center", inline: "center" })
576
599
  setEditableText(element, value, { replace: true })
577
- return { filled: true, element: describeElement(element), valueLength: value.length }
600
+ return {
601
+ filled: true,
602
+ element: describeElement(element),
603
+ valueLength: value.length,
604
+ valueApplied: getEditableText(element) === value,
605
+ method: element.isContentEditable ? "contenteditable" : element instanceof HTMLSelectElement ? "select" : "dom",
606
+ }
578
607
  }
579
608
 
580
609
  function typeText(args) {
@@ -612,32 +641,55 @@ function pressKey(args) {
612
641
  }
613
642
 
614
643
  function selectElement(args) {
615
- const element = mustFind(args.selector)
644
+ const element = resolveTargetElement(args)
645
+ if (!element) throw new Error("yunti_select requires selector or x/y coordinates")
616
646
  if (!(element instanceof HTMLSelectElement)) throw new Error("Target is not a select element")
617
647
  const value = String(args.value ?? "")
648
+ const text = String(args.text ?? "")
649
+ const matchMode = text ? "text" : "value"
650
+ const targetOption = text || value
618
651
  const options = Array.from(element.options)
619
- const option = options.find((item) => item.value === value)
652
+ const option = options.find((item) => matchMode === "text" ? item.text.trim() === targetOption : item.value === targetOption)
653
+ if (!option) {
654
+ return {
655
+ selected: false,
656
+ element: describeElement(element),
657
+ value: element.value,
658
+ code: "OPTION_NOT_FOUND",
659
+ error: matchMode === "text" ? "Option text not found" : "Option value not found",
660
+ matchMode,
661
+ targetOption,
662
+ availableValues: options.map((item) => item.value).slice(0, 50),
663
+ availableTexts: options.map((item) => item.text.trim()).slice(0, 50),
664
+ options: summarizeSelectOptions(options),
665
+ }
666
+ }
620
667
  if (option?.disabled) {
621
668
  return {
622
669
  selected: false,
623
670
  element: describeElement(element),
624
671
  value: element.value,
625
672
  code: "OPTION_DISABLED",
626
- error: "Option value is disabled",
673
+ error: matchMode === "text" ? "Option text is disabled" : "Option value is disabled",
674
+ matchMode,
675
+ targetOption,
627
676
  disabledValue: option.value,
628
677
  disabledText: option.text.trim(),
629
- options: options.slice(0, 50).map((item) => ({
630
- value: item.value,
631
- text: item.text.trim(),
632
- disabled: Boolean(item.disabled),
633
- selected: Boolean(item.selected),
634
- })),
678
+ availableValues: options.map((item) => item.value).slice(0, 50),
679
+ availableTexts: options.map((item) => item.text.trim()).slice(0, 50),
680
+ options: summarizeSelectOptions(options),
635
681
  }
636
682
  }
637
- element.value = value
683
+ element.value = option.value
638
684
  element.dispatchEvent(new Event("input", { bubbles: true }))
639
685
  element.dispatchEvent(new Event("change", { bubbles: true }))
640
- return { selected: true, element: describeElement(element), value: element.value }
686
+ return {
687
+ selected: true,
688
+ element: describeElement(element),
689
+ value: element.value,
690
+ text: option.text.trim(),
691
+ selectedIndex: element.selectedIndex,
692
+ }
641
693
  }
642
694
 
643
695
  function scrollPage(args) {
@@ -693,6 +745,15 @@ function resolveTargetElement(args = {}) {
693
745
  return null
694
746
  }
695
747
 
748
+ function summarizeSelectOptions(options) {
749
+ return options.slice(0, 50).map((item) => ({
750
+ value: item.value,
751
+ text: item.text.trim(),
752
+ disabled: Boolean(item.disabled),
753
+ selected: Boolean(item.selected),
754
+ }))
755
+ }
756
+
696
757
  function focusElement(element) {
697
758
  if (element && typeof element.focus === "function") {
698
759
  element.focus({ preventScroll: true })
@@ -720,6 +781,27 @@ function dispatchPointerMouseSequence(element, x, y) {
720
781
  }
721
782
  }
722
783
 
784
+ function dispatchHoverSequence(element, x, y) {
785
+ const common = {
786
+ bubbles: true,
787
+ cancelable: true,
788
+ composed: true,
789
+ clientX: x,
790
+ clientY: y,
791
+ view: window,
792
+ }
793
+ for (const type of ["pointerover", "pointerenter", "pointermove"]) {
794
+ try {
795
+ element.dispatchEvent(new PointerEvent(type, { ...common, pointerType: "mouse", isPrimary: true }))
796
+ } catch {
797
+ break
798
+ }
799
+ }
800
+ for (const type of ["mouseover", "mouseenter", "mousemove"]) {
801
+ element.dispatchEvent(new MouseEvent(type, common))
802
+ }
803
+ }
804
+
723
805
  function setEditableText(element, text, options = {}) {
724
806
  assertEditableTarget(element)
725
807
  if (element.isContentEditable) {
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "Yunti Browser Runtime",
4
- "version": "0.2.0",
4
+ "version": "0.2.1",
5
5
  "description": "Local browser runtime for AI agents through MCP.",
6
6
  "permissions": [
7
7
  "activeTab",
8
8
  "debugger",
9
+ "scripting",
9
10
  "storage",
10
11
  "tabs",
11
12
  "webRequest"
@@ -33,7 +33,7 @@ async function save() {
33
33
  return
34
34
  }
35
35
  renderSettings(state.settings || {})
36
- setStatus("设置已保存。请刷新目标页面完成重新注册。")
36
+ setStatus("设置已保存。扩展会自动尝试重新注册可访问页面。")
37
37
  }
38
38
 
39
39
  function renderSettings(settings) {
@@ -56,7 +56,7 @@ function statusText(state) {
56
56
  if (!state.bridge?.ok) {
57
57
  return "Bridge 未连接。请确认 Agent MCP 已启动,或运行 yunti-browser-runtime bridge。"
58
58
  }
59
- return "默认无需设置。打开或刷新任意 http/https 页面即可连接。"
59
+ return "默认无需设置。打开任意 http/https 页面后会自动连接。"
60
60
  }
61
61
 
62
62
  function setStatus(text) {
@@ -12,6 +12,8 @@ import {
12
12
  export function createSessionManager() {
13
13
  const sessionsByTab = new Map()
14
14
  const pollers = new Map()
15
+ const pendingTabRecovery = new Map()
16
+ const lastInjectionAtByTab = new Map()
15
17
  let cachedPlatformMatches = null
16
18
  let toolRequestHandler = null
17
19
 
@@ -38,6 +40,104 @@ export function createSessionManager() {
38
40
  }).catch(() => {})
39
41
  }
40
42
 
43
+ async function ensureAllTabsRegistered(options = {}) {
44
+ const settings = await getSettings()
45
+ cachedPlatformMatches = settings.platformMatches
46
+ const tabs = await chrome.tabs.query({})
47
+ const results = await Promise.allSettled(
48
+ tabs.map((tab) => ensureTabRegistered(tab.id, { ...options, tab, settings }))
49
+ )
50
+ return {
51
+ ok: true,
52
+ reason: options.reason || "manual",
53
+ checked: tabs.length,
54
+ recovered: results.filter((result) => result.value?.registered || result.value?.injected)
55
+ .length,
56
+ skipped: results.filter((result) => result.value?.skipped).length,
57
+ failed: results.filter((result) => result.status === "rejected" || result.value?.ok === false)
58
+ .length,
59
+ }
60
+ }
61
+
62
+ async function ensureTabRegistered(tabId, options = {}) {
63
+ if (!Number.isFinite(Number(tabId))) {
64
+ return { ok: false, skipped: true, reason: "missing_tab_id" }
65
+ }
66
+ const key = Number(tabId)
67
+ if (pendingTabRecovery.has(key)) return pendingTabRecovery.get(key)
68
+ const promise = recoverTabRegistration(key, options).finally(() => {
69
+ pendingTabRecovery.delete(key)
70
+ })
71
+ pendingTabRecovery.set(key, promise)
72
+ return promise
73
+ }
74
+
75
+ async function recoverTabRegistration(tabId, options = {}) {
76
+ const settings = options.settings || (await getSettings())
77
+ cachedPlatformMatches = settings.platformMatches
78
+ const tab = options.tab || (await chrome.tabs.get(tabId).catch(() => null))
79
+ if (!isInjectableTab(tab, settings)) {
80
+ return { ok: true, skipped: true, reason: "unsupported_tab" }
81
+ }
82
+ if (sessionsByTab.has(tabId)) {
83
+ await refreshTabRegistration(tabId).catch(() => null)
84
+ return { ok: true, registered: true, reason: "already_registered" }
85
+ }
86
+ const ping = await refreshTabRegistration(tabId).catch((error) => ({
87
+ ok: false,
88
+ error: error?.message || String(error),
89
+ }))
90
+ if (ping?.ok) {
91
+ return { ok: true, registered: true, reason: "content_script_present" }
92
+ }
93
+ const lastInjectionAt = lastInjectionAtByTab.get(tabId) || 0
94
+ if (Date.now() - lastInjectionAt < 5000) {
95
+ return { ok: true, skipped: true, reason: "recently_injected" }
96
+ }
97
+ const injected = await injectContentScripts(tabId).catch((error) => ({
98
+ ok: false,
99
+ error: error?.message || String(error),
100
+ }))
101
+ if (!injected?.ok) {
102
+ return { ok: false, reason: "inject_failed", error: injected?.error || "inject failed" }
103
+ }
104
+ lastInjectionAtByTab.set(tabId, Date.now())
105
+ await delay(50)
106
+ await refreshTabRegistration(tabId).catch(() => null)
107
+ return { ok: true, injected: true, reason: options.reason || "auto_recovery" }
108
+ }
109
+
110
+ function isInjectableTab(tab, settings) {
111
+ if (!tab?.id) return false
112
+ if (tab.discarded) return false
113
+ return isPlatformUrl(tab.url, settings)
114
+ }
115
+
116
+ async function refreshTabRegistration(tabId) {
117
+ return chrome.tabs.sendMessage(tabId, { type: "yunti_refresh_registration" })
118
+ }
119
+
120
+ async function injectContentScripts(tabId) {
121
+ if (!chrome.scripting?.executeScript) {
122
+ return { ok: false, error: "chrome.scripting permission is unavailable" }
123
+ }
124
+ if (chrome.scripting.insertCSS) {
125
+ await chrome.scripting.insertCSS({
126
+ target: { tabId },
127
+ files: ["content.css"],
128
+ })
129
+ }
130
+ await chrome.scripting.executeScript({
131
+ target: { tabId },
132
+ files: ["dom-observer.js"],
133
+ })
134
+ await chrome.scripting.executeScript({
135
+ target: { tabId },
136
+ files: ["content.js"],
137
+ })
138
+ return { ok: true }
139
+ }
140
+
41
141
  async function handleMessage(message, sender) {
42
142
  switch (message?.type) {
43
143
  case "yunti_content_ready":
@@ -244,9 +344,7 @@ export function createSessionManager() {
244
344
  async function refreshActiveTab() {
245
345
  const [tab] = await chrome.tabs.query({ active: true, currentWindow: true })
246
346
  if (!tab?.id) return { ok: false, error: "no active tab" }
247
- await chrome.tabs
248
- .sendMessage(tab.id, { type: "yunti_refresh_registration" })
249
- .catch(() => null)
347
+ await ensureTabRegistered(tab.id, { tab, reason: "popup_refresh" }).catch(() => null)
250
348
  return getPanelState()
251
349
  }
252
350
 
@@ -254,6 +352,8 @@ export function createSessionManager() {
254
352
  sessionsByTab,
255
353
  pollers,
256
354
  activateTab,
355
+ ensureAllTabsRegistered,
356
+ ensureTabRegistered,
257
357
  forgetTab,
258
358
  forwardConsoleEvent,
259
359
  getPlatformMatches,
@@ -279,7 +379,7 @@ function normalizePageAuth(auth, source = "unknown") {
279
379
  return {
280
380
  state: "missing_from_content_script",
281
381
  loggedIn: false,
282
- reason: "Content script did not report page state; reload the extension and refresh the page",
382
+ reason: "Content script did not report page state; Yunti will try automatic registration recovery before asking for a page refresh",
283
383
  checkedAt: new Date().toISOString(),
284
384
  checkedBy: source,
285
385
  }