vcorp-chrome 2.2.1 → 2.3.5

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.
@@ -702,6 +702,36 @@
702
702
  }
703
703
  };
704
704
 
705
+ // ---------- 穿透 shadow 的命中检测 ----------
706
+ //
707
+ // document.elementFromPoint 碰到 shadow 只返回最外层宿主,Node.contains 又不跨
708
+ // shadow 边界。两个一起用,shadow 里的控件永远「被自己的宿主遮挡」——微前端
709
+ // (wujie 把整个子应用放进 <wujie-app> 的 shadow root)上每个按钮都点不了。
710
+ // 不能简单放行「命中宿主」:宿主里盖着弹层时那才是真遮挡,所以要逐层钻到底。
711
+ // closed root 读不到、停在宿主上:快照本来也进不去,那里头的元素拿不到 ref。
712
+ function deepElementFromPoint(x, y) {
713
+ let hit = document.elementFromPoint(x, y);
714
+ while (hit?.shadowRoot) {
715
+ const inner = hit.shadowRoot.elementFromPoint(x, y);
716
+ if (!inner || inner === hit) break;
717
+ hit = inner;
718
+ }
719
+ return hit;
720
+ }
721
+
722
+ // 沿渲染树往上走(assignedSlot 优先):a 是否在渲染上包含 n。
723
+ // 只走 parentNode 的话,shadow 里的容器「不包含」塞进它 <slot> 的内容。
724
+ function flatContains(a, n) {
725
+ for (; n; n = n.assignedSlot || n.parentNode || n.host) if (n === a) return true;
726
+ return false;
727
+ }
728
+
729
+ // 点这个坐标会打到别的东西上时返回那个东西,否则 null
730
+ function occluderAt(el, x, y) {
731
+ const hit = deepElementFromPoint(x, y);
732
+ return hit && !flatContains(el, hit) && !flatContains(hit, el) ? hit : null;
733
+ }
734
+
705
735
  function realClick(el) {
706
736
  const r = el.getBoundingClientRect();
707
737
  const x = r.left + r.width / 2;
@@ -710,8 +740,8 @@
710
740
  // 真实点击的 event.target 是光标下**最内层**的元素,事件再往上冒泡。
711
741
  // 直接派发在容器上,target 就成了容器本身——组件按 event.target 分支的逻辑
712
742
  // 会走进另一条路。react-select 的 Control 就读 event.target.tagName。
713
- const inner = document.elementFromPoint(x, y);
714
- if (inner && el.contains(inner)) el = inner;
743
+ const inner = deepElementFromPoint(x, y);
744
+ if (inner && flatContains(el, inner)) el = inner;
715
745
  const base = { bubbles: true, cancelable: true, composed: true, view: window, clientX: x, clientY: y, button: 0 };
716
746
  const ptr = { ...base, pointerId: 1, pointerType: 'mouse', isPrimary: true };
717
747
 
@@ -925,15 +955,14 @@
925
955
 
926
956
  // 虚拟光标(mark.js,和本文件同一个 isolated world):坐标就地递过去,
927
957
  // 零消息往返。L1 和 L2 都走 locate,所以两条路的光标一致。
928
- // **必须在下面的 elementFromPoint 之前调**——坐标落在驾驶舱底下时它要
958
+ // **必须在下面的遮挡检测之前调**——坐标落在驾驶舱底下时它要
929
959
  // 先让路(pointer-events:none 同步生效),否则遮挡检测会把我们自己的
930
960
  // 面板当成遮挡物,L2 的真实点击更会直接点进面板里。
931
961
  window.__hcCursor?.(x, y, ({ click: 'click', type: 'type', select: 'type', key: 'key' })[p.forCmd] || 'aim');
932
962
 
933
963
  // 遮挡检测和 doClick 保持同一套判断——L2 打的是坐标,遮挡时点中的是遮挡物,
934
964
  // 比 L1 更危险,更不能放过
935
- const top = document.elementFromPoint(x, y);
936
- if (top && top !== el && !el.contains(top) && !top.contains(el)) {
965
+ if (occluderAt(el, x, y)) {
937
966
  throw fail('NOT_INTERACTABLE', `${p.ref || p.selector} 被其它元素遮挡(可能有弹窗/浮层)。先处理遮挡物。`);
938
967
  }
939
968
  // 视口外的坐标 CDP 打不中。scrollIntoView 之后仍在视口外,说明它在一个
@@ -1228,8 +1257,7 @@
1228
1257
  el.scrollIntoView({ block: 'center', behavior: 'instant' });
1229
1258
  // 被遮挡检测:点下去要是命中别的元素,多半有弹层盖着,这时候点了就是误操作
1230
1259
  const r = el.getBoundingClientRect();
1231
- const top = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2);
1232
- if (top && top !== el && !el.contains(top) && !top.contains(el)) {
1260
+ if (occluderAt(el, r.left + r.width / 2, r.top + r.height / 2)) {
1233
1261
  throw fail('NOT_INTERACTABLE', `${p.ref} 被其它元素遮挡(可能有弹窗/浮层)。先处理遮挡物。`);
1234
1262
  }
1235
1263
  realClick(el);
@@ -1778,10 +1806,15 @@
1778
1806
  async function doUploadUrl(p) {
1779
1807
  let blob;
1780
1808
  try {
1781
- const res = await fetch(p.url, { credentials: 'omit', cache: 'no-store' });
1809
+ // 到点取消,文件就不会在报错之后才进页面,重试不会传出第二份
1810
+ const signal = p.fetchMs ? AbortSignal.timeout(p.fetchMs) : undefined;
1811
+ const res = await fetch(p.url, { credentials: 'omit', cache: 'no-store', signal });
1782
1812
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
1783
1813
  blob = await res.blob();
1784
1814
  } catch (e) {
1815
+ if (e?.name === 'TimeoutError') {
1816
+ throw fail('UPLOAD_SLOW', `素材 ${Math.round((p.bytes || 0) / 1048576)}MB 在 ${Math.round(p.fetchMs / 1000)} 秒内没有下载完,已取消,文件没有进页面。压缩后再传,或请用户手动上传。`);
1817
+ }
1785
1818
  throw fail('FETCH_FAILED', `页面里取素材失败:${e.message}`);
1786
1819
  }
1787
1820
  if (p.bytes && blob.size !== p.bytes) throw fail('INTERNAL', `素材大小不对:应为 ${p.bytes},实际 ${blob.size}`);
@@ -28,6 +28,41 @@ const ID_RE = /^[\w.:-]{1,80}$/;
28
28
 
29
29
  const err = (code, message, details) => Object.assign(new Error(message), { code, ...(details ? { details } : {}) });
30
30
 
31
+ // 一个账号下常有好几个浏览器实例(家里的 Chrome、公司的 Edge、Mac 上的 Brave)。
32
+ // 用户给每个实例起名字来区分;服务器再按浏览器种类和系统画图标。
33
+ // 种类和系统只认下面这几个词,别的一律归到 chromium / 空——它们只决定图标,不决定权限。
34
+ export const BROWSERS = ['chrome', 'edge', 'brave', 'opera', 'chromium'];
35
+ export const PLATFORMS = ['windows', 'macos', 'linux', 'chromeos'];
36
+ const BROWSER_LABEL = { chrome: 'Chrome', edge: 'Edge', brave: 'Brave', opera: 'Opera', chromium: 'Chromium' };
37
+ const PLATFORM_LABEL = { windows: 'Windows', macos: 'macOS', linux: 'Linux', chromeos: 'ChromeOS' };
38
+ export const DEVICE_NAME_MAX = 40;
39
+
40
+ // brands / platform 来自 navigator.userAgentData,ua 是 navigator.userAgent(老 Chrome 没有前者)
41
+ export function browserIdentity({ brands = [], platform = '', ua = '' } = {}) {
42
+ const names = (Array.isArray(brands) ? brands : []).map((b) => String(b?.brand || ''));
43
+ const text = String(ua || '');
44
+ const seen = (brand, inUa) => names.some((n) => brand.test(n)) || (inUa ? inUa.test(text) : false);
45
+ const browser = seen(/Microsoft Edge/, /\bEdg(A|iOS)?\//) ? 'edge'
46
+ : seen(/Brave/) ? 'brave'
47
+ : seen(/Opera/, /\bOPR\//) ? 'opera'
48
+ : seen(/Google Chrome/) || (!names.length && /\bChrome\//.test(text)) ? 'chrome'
49
+ : 'chromium';
50
+ const p = `${platform || ''} ${text}`;
51
+ const os = /Windows/i.test(p) ? 'windows'
52
+ : /Mac/i.test(p) ? 'macos'
53
+ : /CrOS|Chrome OS/i.test(p) ? 'chromeos'
54
+ : /Linux|X11/i.test(p) ? 'linux'
55
+ : '';
56
+ return { browser, platform: os };
57
+ }
58
+
59
+ export const defaultDeviceName = ({ browser, platform } = {}) =>
60
+ [BROWSER_LABEL[browser] || 'Chromium', PLATFORM_LABEL[platform]].filter(Boolean).join(' · ');
61
+
62
+ export function cleanDeviceName(raw) {
63
+ return String(raw ?? '').replace(/\s+/g, ' ').replace(/[\x00-\x1f\x7f]/g, '').trim().slice(0, DEVICE_NAME_MAX);
64
+ }
65
+
31
66
  export function cleanDeviceId(raw) {
32
67
  const s = String(raw ?? '').trim();
33
68
  return ID_RE.test(s) ? s : '';
@@ -206,6 +206,36 @@ export async function listTree(handle, { path = '', depth = 3, glob = '', folder
206
206
  };
207
207
  }
208
208
 
209
+ // 网页上文档库的悬停提示:现场数第一层有多少文件、多少子文件夹,只回数字,不回名字。
210
+ // 不往下层走:一层一般不到一秒,整棵树可能要很久。隐藏项和 node_modules 跳过,和任务看到的一致。
211
+ const COUNT_MAX = 20000;
212
+ export async function countTop(id) {
213
+ const row = await get(String(id || ''));
214
+ if (!row?.handle) return { state: 'missing' };
215
+ let permission;
216
+ try { permission = await row.handle.queryPermission({ mode: 'read' }); } catch { permission = 'denied'; }
217
+ if (permission !== 'granted') return { state: 'locked' };
218
+ let files = 0;
219
+ let readable = 0;
220
+ let dirs = 0;
221
+ let more = false;
222
+ try {
223
+ for await (const [name, h] of row.handle.entries()) {
224
+ if (files + dirs >= COUNT_MAX) { more = true; break; }
225
+ if (skipEntry(name)) continue;
226
+ if (h.kind === 'directory') dirs++;
227
+ else {
228
+ files++;
229
+ if (fileAllowed(name)) readable++;
230
+ }
231
+ }
232
+ } catch (e) {
233
+ const mapped = mapFsError(e);
234
+ return { state: mapped.code === 'FOLDER_LOCKED' ? 'locked' : mapped.code === 'NOT_FOUND' ? 'gone' : 'error' };
235
+ }
236
+ return { state: 'ok', files, readable, dirs, more };
237
+ }
238
+
209
239
  export async function readFile(handle, rel) {
210
240
  await assertGranted(handle);
211
241
  let file;
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "vcorp-chrome",
4
- "version": "2.2.1",
4
+ "version": "2.3.5",
5
5
  "description": "让 AI agent 操控你已登录的 Chrome。基于花叔的 huashu-chrome(MIT)修改。",
6
6
  "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAq6xRqEwG4yTuHRG1Zah3j//T3B72XnazyZ+9fOCa5P4bBLPmNBN7YAGL5WnjtPVJefXT9r2Ol0qvNztayDG3dMjK6AZoWccgKa5rhxcvWgFUjanXtmBBQ0qgYwNhuEJSkdinLLtyUg6YmK7Mn72NWpfa/NfqNHdK5AHslvvtJ50iQAYmabl2Nd3vOJ9DxtLBt8a2DfoGPWizgEB3+gkqm2PB8LywwwoiGKd7acr1wZEWmWK9/s0YWdzIstjlvk5AO5XFn6EtEzW8tAAOwBka9239pcyj0kEogWctHLJn7p9VFru38r+I1USyGn6u9XQ68n9edWddxBYokETCqEMT9QIDAQAB",
7
7
  "minimum_chrome_version": "116",
@@ -40,7 +40,7 @@
40
40
  <main>
41
41
  <div>
42
42
  <h1>挂载本地文件夹</h1>
43
- <p>选一个文件夹之后,配对的网站可以申请读取里面的文档。每次申请你都会在通知里看到文件夹的名字。只读,不会改你的文件。</p>
43
+ <p>选一个文件夹之后,配对的网站可以申请读取里面的文档。挂上就是把它分享给你的任务:开了「自动允许新任务」时按需直接读,没开时每次都会问你。只读,不会改你的文件。</p>
44
44
  </div>
45
45
  <section>
46
46
  <button class="btn" id="pick" type="button">选择文件夹</button>
@@ -83,6 +83,8 @@
83
83
  .row { display: flex; gap: 6px; align-items: center; margin-top: 8px; flex-wrap: wrap; }
84
84
  .msg { font-size: 12px; margin-top: 6px; }
85
85
  .msg.bad { color: var(--bad); }
86
+ .msg.ok { color: var(--ok); }
87
+ .msg.warn { color: var(--warn); }
86
88
  .confirm { border: 1px solid var(--warn); border-radius: 9px; padding: 9px 10px; margin-top: 8px; }
87
89
  .confirm b { word-break: break-all; }
88
90
  .sites { display: flex; flex-direction: column; margin-top: 4px; }
@@ -141,6 +143,10 @@
141
143
  <div class="confirm">
142
144
  <div>要把这个浏览器交给 <b id="claimOrigin"></b> 上的账号 <b id="claimAccount"></b> 吗?</div>
143
145
  <div class="muted" style="margin-top:4px">配对码如果是别人发给你的,点取消。</div>
146
+ <label class="field"><span>给这个浏览器起个名字</span>
147
+ <input type="text" id="claimName" maxlength="40" autocomplete="off" spellcheck="false" />
148
+ <span class="muted">同一个账号连了好几个浏览器时,网页上靠它区分,比如「家里 Mac」「公司 Edge」</span>
149
+ </label>
144
150
  <div class="row"><button class="btn" id="claimYes">确认连接</button><button class="btn ghost" id="claimNo">取消</button></div>
145
151
  </div>
146
152
  </div>
@@ -177,6 +183,13 @@
177
183
  <div id="cloudOn" hidden>
178
184
  <div class="light"><span class="dot" id="cloudDot"></span>
179
185
  <div><div class="name" id="cloudName"></div><div class="sub" id="cloudSub"></div></div></div>
186
+ <label class="field"><span class="lab">这个浏览器的名字</span>
187
+ <span class="row" style="margin-top:0">
188
+ <input type="text" id="deviceName" maxlength="40" autocomplete="off" spellcheck="false" style="flex:1" />
189
+ <button class="btn ghost" id="deviceNameSave" type="button">保存</button>
190
+ </span>
191
+ </label>
192
+ <div class="msg" id="deviceNameMsg" role="status" aria-live="polite" hidden></div>
180
193
  <div id="cloudTasks"></div>
181
194
  <div class="lab" style="margin-top:10px">允许它使用的网站</div>
182
195
  <div class="sites" id="cloudSites"></div>
@@ -257,12 +270,12 @@
257
270
  </details>
258
271
  <details>
259
272
  <summary>自动允许和免确认</summary>
260
- <div>「自动允许新任务」:新任务,以及它一开始就要去的网站,会直接放行。任务开始之后再加的网站,还有本地文件夹,仍然要你逐个点头。</div>
273
+ <div>「自动允许新任务」:新任务,以及它一开始就要去的网站,会直接放行。已挂载的本地文件夹也直接让任务读。任务开始之后再加的网站,仍然要你逐个点头。</div>
261
274
  <div>「对外动作不再逐条确认」:发消息、投递、发帖之前不再问你。扫码、验证码仍然要你本人来。付款一律不做。</div>
262
275
  </details>
263
276
  <details>
264
277
  <summary>本地文件夹</summary>
265
- <div>挂载之后,配对的网站可以申请读取里面的文档(Markdown、文本、PDF、Word、CSV、JSON、HTML)。只读,不会改你的文件。每次真正读取之前还会再问你一次。浏览器重启后如果显示需要恢复,点「恢复访问」。</div>
278
+ <div>挂载之后,配对的网站可以申请读取里面的文档(Markdown、文本、PDF、Word、Excel、PowerPoint、CSV、JSON、HTML、图片)。只读,不会改你的文件。开了「自动允许新任务」时任务按需直接读;没开时每个任务读之前会问你。浏览器重启后如果显示需要恢复,点「恢复访问」。</div>
266
279
  </details>
267
280
  <details>
268
281
  <summary>桌面应用</summary>
@@ -258,9 +258,9 @@ const CLOUD_STATE = {
258
258
  off: ['', '未连接'],
259
259
  };
260
260
  const LOG_TEXT = {
261
- paired: '配对', unpaired: '解除配对', approved: '批准任务', denied: '拒绝任务', ended: '任务结束',
261
+ paired: '配对', unpaired: '解除配对', renamed: '改名为', approved: '批准任务', denied: '拒绝任务', ended: '任务结束',
262
262
  sites_on: '允许了新网站', sites_off: '拒绝了新网站', sites_timeout: '新网站没回应',
263
- folders_on: '允许了本地文件夹', folders_off: '拒绝了本地文件夹', folders_timeout: '本地文件夹没回应',
263
+ folders_on: '允许了本地文件夹', folders_auto: '自动放行本地文件夹', folders_off: '拒绝了本地文件夹', folders_timeout: '本地文件夹没回应',
264
264
  apps_on: '允许了桌面应用', apps_off: '拒绝了桌面应用', apps_timeout: '桌面应用没回应',
265
265
  desk_confirm: '同意了桌面动作', desk_declined: '没同意桌面动作', desktop_on: '启用桌面能力', desktop_off: '停用桌面能力',
266
266
  desktop_reload: '为启用桌面能力重载扩展', desktop_fail: '桌面能力没启用成功:',
@@ -345,8 +345,12 @@ function renderCloud(s) {
345
345
  if (s.claim) {
346
346
  $('claimOrigin').textContent = new URL(s.claim.origin).host;
347
347
  $('claimAccount').textContent = `「${s.claim.account || '(未命名)'}」`;
348
+ if (!$('claimName').value) $('claimName').value = s.claim.name;
348
349
  }
349
350
  if (!on) { syncAlert(); return; }
351
+ if (document.activeElement !== $('deviceName')) $('deviceName').value = s.cfg.name;
352
+ // 改名时没连上:连上那一刻 hello 已把名字写进服务器,提示跟着变
353
+ if (namePending === s.cfg.name && s.status?.state === 'online') nameSaved(s);
350
354
 
351
355
  const [cls, word] = CLOUD_STATE[s.status?.state] || CLOUD_STATE.off;
352
356
  $('cloudDot').className = `dot ${cls}`;
@@ -424,11 +428,47 @@ $('pairGo').onclick = async () => {
424
428
  $('pairCode').onkeydown = (e) => { if (e.key === 'Enter') $('pairGo').click(); };
425
429
  $('claimYes').onclick = async () => {
426
430
  $('claimYes').disabled = true;
427
- await cloudCall('confirm');
431
+ await cloudCall('confirm', { name: $('claimName').value });
428
432
  $('claimYes').disabled = false;
429
433
  $('pairCode').value = '';
434
+ $('claimName').value = '';
430
435
  };
431
- $('claimNo').onclick = () => cloudCall('cancelClaim');
436
+ $('claimNo').onclick = () => { $('claimName').value = ''; cloudCall('cancelClaim'); };
437
+ // 改名的结果就写在输入框下面,不弹窗;服务器确认写入才说「已保存」
438
+ let namePending = null;
439
+ let nameMsgTimer = null;
440
+ function nameMsg(cls, text) {
441
+ clearTimeout(nameMsgTimer);
442
+ const box = $('deviceNameMsg');
443
+ box.className = `msg ${cls}`;
444
+ box.textContent = text;
445
+ box.hidden = !text;
446
+ }
447
+ function nameSaved(s) {
448
+ namePending = null;
449
+ nameMsg('ok', `已保存,${new URL(s.cfg.origin).host} 的「执行设备」里现在显示「${s.cfg.name}」`);
450
+ nameMsgTimer = setTimeout(() => nameMsg('', ''), 8000);
451
+ }
452
+ $('deviceNameSave').onclick = async () => {
453
+ const name = $('deviceName').value;
454
+ $('deviceNameSave').disabled = true;
455
+ $('deviceName').blur();
456
+ namePending = null;
457
+ nameMsg('muted', '保存中…');
458
+ const r = await cloudCall('rename', { name });
459
+ $('deviceNameSave').disabled = false;
460
+ if (!r?.ok) { nameMsg('bad', `没有保存:${r?.error || '扩展没有响应,稍后再试'}`); return; }
461
+ const host = new URL(r.state.cfg.origin).host;
462
+ if (r.state.rename === 'saved') nameSaved(r.state);
463
+ else if (r.state.rename === 'unconfirmed') nameMsg('warn', `已发给 ${host},但没等到确认。请到它的「执行设备」里看一下名字对不对`);
464
+ else {
465
+ namePending = r.state.cfg.name;
466
+ nameMsg('warn', r.state.status?.state === 'paused'
467
+ ? `已存到本机。云端已暂停,恢复后会更新到 ${host}`
468
+ : `已存到本机,还没连上 ${host},连上后会自动更新`);
469
+ }
470
+ };
471
+ $('deviceName').onkeydown = (e) => { if (e.key === 'Enter') $('deviceNameSave').click(); };
432
472
  $('cloudAuto').onchange = (e) => cloudCall('setAuto', { auto: e.target.checked });
433
473
  $('cloudAutoConfirm').onchange = (e) => cloudCall('setAutoConfirm', { autoConfirm: e.target.checked });
434
474
  $('cloudPause').onchange = (e) => cloudCall('setPaused', { paused: e.target.checked });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vcorp-chrome",
3
- "version": "2.2.1",
3
+ "version": "2.3.5",
4
4
  "description": "让任何 AI agent 操控你自己的 Chrome——带着全部登录态。MCP + Chrome 扩展,Claude Code / Codex / Cursor / Gemini CLI 通用。",
5
5
  "type": "module",
6
6
  "bin": {