dsh-mobile-plugin 0.5.20 → 0.5.22

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.
Files changed (42) hide show
  1. package/README.md +3 -12
  2. package/assets/pets/README.md +9 -0
  3. package/assets/pets/deepy-whale/attention.gif +0 -0
  4. package/assets/pets/deepy-whale/done.gif +0 -0
  5. package/assets/pets/deepy-whale/error.gif +0 -0
  6. package/assets/pets/deepy-whale/idle.gif +0 -0
  7. package/assets/pets/deepy-whale/pack.json +54 -0
  8. package/assets/pets/deepy-whale/sleeping.gif +0 -0
  9. package/assets/pets/deepy-whale/working-3.gif +0 -0
  10. package/assets/pets/deepy-whale/working.gif +0 -0
  11. package/extensions/README.md +64 -0
  12. package/extensions/catalog.json +1 -0
  13. package/lib/client.js +318 -27
  14. package/lib/impl.js +21 -1
  15. package/lib/mobile/approvals.js +22 -2
  16. package/lib/mobile/cli/acp.js +34 -20
  17. package/lib/mobile/cli/mobile.js +8 -1
  18. package/lib/mobile/cli/service.js +151 -10
  19. package/lib/mobile/extension-catalog.js +244 -0
  20. package/lib/mobile/local-images.js +10 -5
  21. package/lib/mobile/monitor.js +5 -2
  22. package/lib/mobile/pet-tools.js +215 -0
  23. package/lib/mobile/pets.js +559 -0
  24. package/lib/mobile/session-flags.js +61 -8
  25. package/lib/mobile/session-local-image.js +9 -1
  26. package/lib/mobile/stats-collect.cjs +15 -7
  27. package/lib/mobile/stats-ledger.cjs +44 -5
  28. package/lib/mobile/wechat-tools.js +257 -0
  29. package/lib/mobile/wechat.js +489 -0
  30. package/lib/panel/index.html +12 -11
  31. package/lib/relay/pairing-payload.cjs +13 -5
  32. package/lib/relay/snapshot-auth.js +82 -0
  33. package/lib/relay/status.js +3 -0
  34. package/lib/routes/control-plane.js +74 -3
  35. package/lib/routes/mobile-api.js +54 -1
  36. package/lib/session-cleanup.cjs +22 -7
  37. package/lib/sidecar-facade-worker.cjs +8 -0
  38. package/lib/tailcat/host-process.cjs +266 -0
  39. package/lib/tailcat/ingress.cjs +98 -0
  40. package/lib/tailcat/session.cjs +264 -0
  41. package/lib/tailcat/wiring.cjs +96 -0
  42. package/package.json +10 -1
package/lib/client.js CHANGED
@@ -38,8 +38,31 @@ window.__ModuleLoader__.load({
38
38
  .dshm_footAction[aria-disabled="true"]{opacity:.45;cursor:default}
39
39
  .dshm_footAction[aria-disabled="true"]:hover{background:transparent;color:var(--dsw-alias-label-secondary)}
40
40
  .dshm_footActionRail{width:36px;height:36px;margin:2px 0;border-radius:var(--dsw-radius-md)}
41
+ /* 宿主那一半没装载时:整颗转红,右上角那颗红点显出来(data-dshm-alert 是它的记号)。
42
+ 悬停不换回普通颜色——告警态跟着鼠标变浅等于这一颗在说谎。 */
43
+ .dshm_footAction[data-dshm-alert],.dshm_footAction[data-dshm-alert]:hover{color:var(--dsw-alias-state-error-primary)}
44
+ .dshm_alertDot{display:none}
45
+ .dshm_footAction[data-dshm-alert] .dshm_alertDot{display:inline}
41
46
  .dshm_overlay{position:fixed;inset:0;z-index:2147483000;display:flex;align-items:center;justify-content:center;padding:24px;background:rgba(0,0,0,.42)}
42
47
  .dshm_dialog{box-sizing:border-box;width:100%;max-width:560px;max-height:82vh;overflow:auto;display:flex;flex-direction:column;gap:10px;padding:18px 20px;border-radius:var(--dsw-radius-xl);border:.5px solid var(--dsw-alias-settings-card-stroke);background:var(--dsw-alias-settings-card-fill);color:var(--dsw-alias-label-primary);box-shadow:0 12px 40px rgba(0,0,0,.28)}
48
+ /* 控制台那一窗比诊断窗宽:里面嵌的是一整页,窄了排版会挤成一列。 */
49
+ .dshm_dialogWide{max-width:1040px;height:82vh;max-height:82vh;overflow:hidden}
50
+ .dshm_dialogWide .dshm_frameWrap{flex:1;min-height:0;display:flex;position:relative}
51
+ .dshm_frame{flex:1;min-height:0;width:100%;border:0;border-radius:var(--dsw-radius-md);background:var(--dsw-alias-bg-layer-1)}
52
+ /* iframe 一直不 load 时留的那句话:盖在空白之上,撤不撤由 onload 决定。 */
53
+ .dshm_frameTip{position:absolute;inset:auto 0 0;margin:0;padding:8px 10px;font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary)}
54
+ /* 入口那一层:只有两块大字按钮,左「进入控制台」右「报错调试」。 */
55
+ .dshm_choices{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin:4px 0 2px}
56
+ .dshm_choice{display:flex;flex-direction:column;gap:6px;align-items:flex-start;text-align:left;min-height:96px;padding:14px 16px;border:.5px solid var(--dsw-alias-border-l3);border-radius:var(--dsw-radius-lg);background:transparent;color:var(--dsw-alias-label-primary);cursor:pointer;font:inherit}
57
+ .dshm_choice:hover{background:var(--dsw-alias-interactive-bg-hover)}
58
+ .dshm_choice:focus-visible{outline:var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-offset:-2px}
59
+ .dshm_choicePrimary{background:var(--dsw-alias-state-business-primary);border-color:transparent;color:var(--dsw-alias-bg-layer-1)}
60
+ .dshm_choicePrimary:hover{background:var(--dsw-alias-state-business-primary);opacity:.92}
61
+ .dshm_choicePrimary .dshm_choiceHint{color:inherit;opacity:.82}
62
+ .dshm_choiceTitle{font-size:15px;font-weight:600;line-height:22px}
63
+ .dshm_choiceHint{font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary)}
64
+ /* 版面竖向排列的那一列(外壳里没法内嵌时用它摆读数)。 */
65
+ .dshm_dialogColumn{flex:1;min-height:0;overflow:auto;display:flex;flex-direction:column;gap:10px}
43
66
  .dshm_dialogTitle{margin:0;font-size:16px;font-weight:600;line-height:24px}
44
67
  .dshm_dialogHeading{margin:2px 0 0;font-size:12px;font-weight:600;line-height:18px;color:var(--dsw-alias-label-tertiary)}
45
68
  .dshm_dialogLead{margin:0;font-size:13px;line-height:20px}
@@ -521,6 +544,18 @@ window.__ModuleLoader__.load({
521
544
  footerPending: '网页地址还没就绪',
522
545
  footerPublicConfirm: '这个地址位于公网。继续后会开放移动端插件的网页管理权限 2 小时;期间任何能访问该地址的人都可以操作面板。是否继续?',
523
546
  footerLoadMissing: '插件宿主侧没有加载:把 DSH 完整退出再打开一次;还不行就在插件市场重装一遍插件。',
547
+ consoleTitle: '移动端插件控制台',
548
+ consoleEntryTitle: '移动端插件',
549
+ consoleEntryLead: '进控制台配置配对与网络;手机连不上、或者哪儿报错了,走报错调试。',
550
+ consoleEntryConsole: '进入控制台',
551
+ consoleEntryConsoleHint: '配对、网络、插件更新都在那一页',
552
+ consoleEntryDebug: '报错调试',
553
+ consoleEntryDebugHint: '装载、链路、设备、故障账本,出问题时看这里',
554
+ consoleBack: '返回',
555
+ consolePopupBlocked: '浏览器拦掉了新窗口,允许本站弹出窗口后再点一次。',
556
+ consoleLead: '这一屏是插件的排障面板:装载、链路、资源和故障账本都在里面,读的都是宿主此刻的原话。要配置就点「在浏览器里打开」。',
557
+ consoleOpenBrowser: '在浏览器里打开配置页面',
558
+ consoleFrameless: '这一屏没载入进来(桌面壳或代理挡住了内嵌页面)。点下面那颗按钮在浏览器里打开,读数一样看得到。',
524
559
  diagTitleLoad: '打不开移动端配置页',
525
560
  diagTitleOpen: '开放网页管理失败',
526
561
  diagChecking: '正在读取宿主状态…',
@@ -582,6 +617,18 @@ window.__ModuleLoader__.load({
582
617
  footerPending: 'The page address is not ready yet',
583
618
  footerPublicConfirm: 'This address is public. Continuing opens mobile plugin web management for 2 hours; anyone who can reach this address can operate the panel during that time. Continue?',
584
619
  footerLoadMissing: 'The plugin host half is not loaded: quit and reopen DSH; if that does not help, reinstall the plugin from the market.',
620
+ consoleTitle: 'Mobile plugin console',
621
+ consoleEntryTitle: 'Mobile plugin',
622
+ consoleEntryLead: 'Open the console to configure pairing and networking; if the phone cannot connect or something reports an error, use troubleshooting.',
623
+ consoleEntryConsole: 'Open the console',
624
+ consoleEntryConsoleHint: 'pairing, networking and updates live there',
625
+ consoleEntryDebug: 'Troubleshoot',
626
+ consoleEntryDebugHint: 'loading, link, devices and the fault ledger',
627
+ consoleBack: 'Back',
628
+ consolePopupBlocked: 'The browser blocked the new window — allow pop-ups for this site and press again.',
629
+ consoleLead: 'This is the plugin troubleshooting panel: loading, link, resources and the fault ledger, all in the host’s own words. Press “Open the configuration page in a browser” to configure it.',
630
+ consoleOpenBrowser: 'Open the configuration page in a browser',
631
+ consoleFrameless: 'This panel did not load (the desktop shell or a proxy blocked the embedded page). Use the button below to open it in a browser — the readings are the same.',
585
632
  diagTitleLoad: 'Cannot open the mobile configuration page',
586
633
  diagTitleOpen: 'Could not open web management',
587
634
  diagChecking: 'Reading host status…',
@@ -652,10 +699,13 @@ window.__ModuleLoader__.load({
652
699
  */
653
700
  function createDiagnosisDialog(doc) {
654
701
  let overlay = null;
702
+ /** 底下那一句说明(复制结果就地改它,不重画整窗)。 */
703
+ let noteEl = null;
655
704
  let escapeListener = null;
656
705
  const hide = () => {
657
706
  if (overlay && typeof overlay.remove === 'function' && overlay.parentElement) overlay.remove();
658
707
  overlay = null;
708
+ noteEl = null;
659
709
  if (escapeListener && typeof doc.removeEventListener === 'function') {
660
710
  doc.removeEventListener('keydown', escapeListener);
661
711
  }
@@ -689,14 +739,43 @@ window.__ModuleLoader__.load({
689
739
  }
690
740
  if (spec.title) overlay.setAttribute('aria-label', spec.title);
691
741
  // 每次重画:读数与按钮都是这一轮的现场,别留上一轮的字。
692
- for (const child of overlay.children.slice()) child.remove();
693
- const card = make('div', 'dshm_dialog');
742
+ // **`children` 是 HTMLCollection,没有 `slice()`**(2026-11-05 真机现场:点图标一点反应都
743
+ // 没有,控制台一条 TypeError 就把它吞了 —— 假元素的 `children` 是数组,测试里从没红过)。
744
+ // 先摊成数组再边删边走。
745
+ for (const child of Array.from(overlay.children || [])) child.remove();
746
+ const card = spec.wide
747
+ ? make('div', 'dshm_dialog dshm_dialogWide')
748
+ : make('div', 'dshm_dialog');
694
749
  card.addEventListener('click', (event) => {
695
750
  if (event && typeof event.stopPropagation === 'function') event.stopPropagation();
696
751
  });
697
752
  overlay.appendChild(card);
698
753
  if (spec.title) card.appendChild(make('h3', 'dshm_dialogTitle', spec.title));
699
754
  if (spec.summary) card.appendChild(make('p', 'dshm_dialogLead', spec.summary));
755
+ // 自定义内容区(入口那两块就挂在这儿):节点由调用方造,这里只负责挂上。
756
+ if (spec.body) card.appendChild(spec.body);
757
+ // 内嵌一整页(调试那一窗):外壳里 `window.open` 不可靠,所以把页面嵌进来。
758
+ // `src` 已经由调用方按同源地址算好(壳里是 `dsh-app://app/api/relay/panel/debug`,
759
+ // 网页里是同源 http),远程浏览器的那两小时窗口也在拨号那一步开好了。
760
+ //
761
+ // **嵌不进来要有话说**:iframe 被挡(CSP、代理、协议处理器不认这条路径)时它只会
762
+ // 静默留白,用户看到一屏空。所以先摆一句「正在载入」,`onload` 一到就撤掉;一直不
763
+ // load 就一直是那句话 + 底下那颗「在浏览器里打开」——不至于「点了没反应」。
764
+ if (spec.frame && spec.frame.src) {
765
+ const wrap = make('div', 'dshm_frameWrap');
766
+ const frame = doc.createElement('iframe');
767
+ frame.className = 'dshm_frame';
768
+ frame.setAttribute('src', spec.frame.src);
769
+ frame.setAttribute('title', spec.frame.title || spec.title || '');
770
+ frame.setAttribute('loading', 'eager');
771
+ const tip = make('p', 'dshm_frameTip', spec.frame.hint || '');
772
+ frame.onload = () => {
773
+ if (tip && typeof tip.remove === 'function' && tip.parentElement) tip.remove();
774
+ };
775
+ wrap.appendChild(frame);
776
+ wrap.appendChild(tip);
777
+ card.appendChild(wrap);
778
+ }
700
779
  if (spec.facts && spec.facts.length) {
701
780
  if (spec.factsHeading) card.appendChild(make('h4', 'dshm_dialogHeading', spec.factsHeading));
702
781
  const list = make('dl', 'dshm_facts');
@@ -714,7 +793,10 @@ window.__ModuleLoader__.load({
714
793
  for (const step of spec.steps) steps.appendChild(make('li', null, step));
715
794
  card.appendChild(steps);
716
795
  }
717
- if (spec.note) card.appendChild(make('p', 'dshm_dialogNote', spec.note));
796
+ // 这一行**总是挂上**(空着不占位):控制台那一窗的「复制」要就地改这一句,
797
+ // 重画整窗会把嵌进去的页面重新加载一遍。
798
+ const note = make('p', 'dshm_dialogNote', spec.note || '');
799
+ card.appendChild(note);
718
800
  const actions = make('div', 'dshm_dialogActions');
719
801
  for (const action of spec.actions || []) {
720
802
  const button = make('button', action.primary ? 'dshm_btn dshm_btnPrimary' : 'dshm_btn', action.label);
@@ -724,9 +806,14 @@ window.__ModuleLoader__.load({
724
806
  actions.appendChild(button);
725
807
  }
726
808
  card.appendChild(actions);
809
+ noteEl = note;
727
810
  return overlay;
728
811
  };
729
- return { open, hide };
812
+ /** 就地更新底下那一句说明(不重画整窗)。 */
813
+ const note = (text) => {
814
+ if (noteEl) noteEl.textContent = text === undefined ? '' : String(text);
815
+ };
816
+ return { open, hide, note };
730
817
  }
731
818
 
732
819
  /** 复制一段文字:先走剪贴板 API,不行退回 textarea + execCommand。 */
@@ -817,6 +904,28 @@ window.__ModuleLoader__.load({
817
904
  }
818
905
  }
819
906
 
907
+ /**
908
+ * 调试页在**这个页面自己**的同源地址上(不是宿主报的那个绝对地址)。
909
+ *
910
+ * 这里必须按 `document.baseURI` 推,**不能用面板那个绝对 http(s) 地址**:桌面版前端挂在
911
+ * `dsh-app://app/` 下(Electron 把 `dsh-app` 当安全协议),往里面塞一条 http 的 iframe
912
+ * 会被当成混合内容挡掉 —— 现场就是 `isWebPage()` 为假、退回「交给系统浏览器」,点了照旧跳走。
913
+ * 用相对地址推出来的是 `dsh-app://app/api/relay/panel/debug`:与页面同源,壳的协议处理器
914
+ * 会把 `/api/*` 转给本机宿主(客户端半边那些 `api/relay/status` 请求一直就是这么走的)。
915
+ * 反代挂子路径时 `baseURI` 自带前缀,这一条也顺带管住了。
916
+ */
917
+ function consoleUrlOf() {
918
+ try {
919
+ const base = typeof document !== 'undefined' && document.baseURI
920
+ ? document.baseURI
921
+ : (typeof location !== 'undefined' ? location.href : '');
922
+ if (!base) return '';
923
+ return new URL('api/relay/panel/debug', base).href;
924
+ } catch {
925
+ return '';
926
+ }
927
+ }
928
+
820
929
  /**
821
930
  * 一次「打开配置页面」的完整拨号:取地址、必要时开两小时窗口、把地址交出去。
822
931
  *
@@ -835,7 +944,7 @@ window.__ModuleLoader__.load({
835
944
  * @returns {Promise<{ok: true, cancelled?: boolean, url: string, webAccess: object|null}
836
945
  * | {ok: false, missing: boolean, status: number, message: string}>}
837
946
  */
838
- async function openConfigurationPage({ view, loadStatus = 0, t, target = null, confirmed = false }) {
947
+ async function openConfigurationPage({ view, loadStatus = 0, t, target = null, confirmed = false, navigate = true }) {
839
948
  let state = readStatusView(view);
840
949
  const failed = (error) => ({
841
950
  ok: false,
@@ -891,6 +1000,9 @@ window.__ModuleLoader__.load({
891
1000
  target.location.href = url;
892
1001
  return { ok: true, url, webAccess: state.webAccess };
893
1002
  }
1003
+ // `navigate: false` = 只要地址与「控制面已经认这个浏览器」,别开任何窗口:
1004
+ // 控制台那一窗是把这一页**嵌进来**,多开一个浏览器窗口等于点了就跳走。
1005
+ if (navigate === false) return { ok: true, url, webAccess: state.webAccess };
894
1006
  if (!isWebPage(window)) {
895
1007
  // 交回给页面导航:壳会把外部 http(s) 地址转给系统浏览器。
896
1008
  window.location.href = url;
@@ -926,6 +1038,8 @@ window.__ModuleLoader__.load({
926
1038
  const FOOTER_SEAT = '[data-slot="sidebar.settings"]';
927
1039
  /** 自己那颗按钮的记号:同一页面里出现第二份实现时,靠它摘掉上一颗。 */
928
1040
  const FOOTER_MARK = 'data-dshm-mobile-web';
1041
+ /** 告警态的记号:宿主那一半没装载(`/status` 404)时,整颗转红并显出右上角那颗红点。 */
1042
+ const FOOTER_ALERT = 'data-dshm-alert';
929
1043
  /** 还没问到网页地址时的重试间隔。取到就停,不再问。 */
930
1044
  const FOOTER_URL_RETRY_MS = 20000;
931
1045
  /**
@@ -938,6 +1052,15 @@ window.__ModuleLoader__.load({
938
1052
  'M10.4 4.4h3.2',
939
1053
  'M11 19.4h2',
940
1054
  ];
1055
+ /**
1056
+ * 告警态那颗红点(用户 2026-11-05 从四个候选里拍的「整颗转红 + 右上角一颗点」那一款)。
1057
+ *
1058
+ * 圆心在机身右上角外侧:左缘压住右上那圈圆角(机身右边到 17.8),两块同色连成一整块,
1059
+ * 所以不用再抠一圈背景色的缝隙。24 画布内放得下——最右到 22,不越界。
1060
+ * 它常驻在 SVG 里,显示与否交给 CSS 的 `data-dshm-alert`:这一颗会被外壳整棵重建,
1061
+ * 换来换去不如让它挂着。
1062
+ */
1063
+ const PHONE_ALERT_DOT = { cx: 18.9, cy: 5, r: 3.1 };
941
1064
  /**
942
1065
  * 图标边长:展开那一行 20,窄条里的圆钮 22(用户 2026-11-02 拍的,先 18 后嫌小又抬了一档)。
943
1066
  *
@@ -949,7 +1072,7 @@ window.__ModuleLoader__.load({
949
1072
  const PHONE_GLYPH_WIDE = 20;
950
1073
  const PHONE_GLYPH_RAIL = 22;
951
1074
 
952
- /** 造一颗手机图标。文档没有 SVG 能力时返回 null,按钮照样成立(只剩无障碍名字)。 */
1075
+ /** 造一颗手机图标(含那颗常驻的告警红点,显不显示看 `data-dshm-alert`)。文档没有 SVG 能力时返回 null,按钮照样成立(只剩无障碍名字)。 */
953
1076
  function phoneIcon(doc, size) {
954
1077
  if (!doc || typeof doc.createElementNS !== 'function') return null;
955
1078
  const svg = doc.createElementNS('http://www.w3.org/2000/svg', 'svg');
@@ -967,6 +1090,13 @@ window.__ModuleLoader__.load({
967
1090
  path.setAttribute('d', d);
968
1091
  svg.appendChild(path);
969
1092
  }
1093
+ const dot = doc.createElementNS('http://www.w3.org/2000/svg', 'circle');
1094
+ dot.setAttribute('class', 'dshm_alertDot');
1095
+ dot.setAttribute('cx', String(PHONE_ALERT_DOT.cx));
1096
+ dot.setAttribute('cy', String(PHONE_ALERT_DOT.cy));
1097
+ dot.setAttribute('r', String(PHONE_ALERT_DOT.r));
1098
+ dot.setAttribute('fill', 'currentColor');
1099
+ svg.appendChild(dot);
970
1100
  return svg;
971
1101
  }
972
1102
 
@@ -991,11 +1121,14 @@ window.__ModuleLoader__.load({
991
1121
  *
992
1122
  * 地址、网络类别和窗口状态都由 effect 往上同步,组件重渲染不会把它丢掉。
993
1123
  */
994
- function syncFooterIcon(node, url, openLabel, pendingLabel, onClick) {
1124
+ function syncFooterIcon(node, url, openLabel, pendingLabel, onClick, alert) {
995
1125
  const label = url ? openLabel : pendingLabel;
996
1126
  node.setAttribute('aria-label', label);
997
1127
  node.setAttribute('title', label);
998
1128
  node.setAttribute('aria-disabled', 'false');
1129
+ // 告警态只换记号,图形和尺寸不动:CSS 按这个属性把整颗转红、把红点显出来。
1130
+ if (alert) node.setAttribute(FOOTER_ALERT, '');
1131
+ else node.removeAttribute(FOOTER_ALERT);
999
1132
  // 有地址时仍然写 `href`:普通链接照旧能中键 / 新标签打开。只有「浏览器不是从本机来的」
1000
1133
  // 那两种情况要拦下来先开窗口,见 `openPanel`。
1001
1134
  if (url) node.setAttribute('href', url);
@@ -1070,6 +1203,12 @@ window.__ModuleLoader__.load({
1070
1203
  const pendingLabel = t('footerPending');
1071
1204
  const loadMissingLabel = t('footerLoadMissing');
1072
1205
  const publicConfirm = t('footerPublicConfirm');
1206
+ /**
1207
+ * 宿主那一半没装载(`/status` 404):点了也打不开、重装前谁也修不好,所以这一种是
1208
+ * **真故障**,那颗图标转红告警;标题也换成更具体的那一句。地址只是还没读到(后台
1209
+ * 重试中)、或者窗口关着被回 423,都不是插件坏了,不红。
1210
+ */
1211
+ const hostMissing = Boolean(problem && problem.status === 404);
1073
1212
 
1074
1213
  /** 诊断窗单例:同一页面只留一个,反复打开就地重画。 */
1075
1214
  function diagnosisDialog() {
@@ -1113,34 +1252,45 @@ window.__ModuleLoader__.load({
1113
1252
  * 浏览器的弹窗拦截器吃掉;地址确认到之后才把那个空窗口导航过去,失败就关掉它并弹诊断。
1114
1253
  * 只有**网页**(`http(s)`)才需要这一手:桌面壳里那个函数一定被拒,见 `isWebPage`。
1115
1254
  */
1255
+ /**
1256
+ * 点下去做什么(2026-11-05 用户要求改过一次)。
1257
+ *
1258
+ * **从「点了就跳浏览器」改成「点了先在这一页弹一屏」**:用户遇到问题时最缺的不是
1259
+ * 跳过去,而是「它现在到底哪一环断了」——启动、装载、隧道、旁挂进程、自检全都在那一屏
1260
+ * 里,比一个打不开的页面有用。跳转本身没取消,挪进了那一屏里的一颗按钮。
1261
+ *
1262
+ * 那一屏的内容是网页版的 `/api/relay/panel/debug`(React 写的,改完刷新就见效),
1263
+ * 这里只把同源地址算出来嵌进去。**必须先保证控制面认这个浏览器**,否则嵌进来的是一屏
1264
+ * 423 JSON:所以先按 `panelScope` 把两小时窗口开好,再画窗。
1265
+ *
1266
+ * 三种走不通的情况一律回到诊断窗(外壳里 `window.open` 不可靠、宿主那一半没装载、
1267
+ * 窗口开不出来)—— 不许「点了没反应」。
1268
+ */
1116
1269
  function openPanel(event) {
1117
- const knowsUrl = typeof url === 'string' && url !== '';
1118
- const needsWindow = !knowsUrl || (panelScope !== 'loopback' && !(webAccess && webAccess.enabled));
1119
- if (!needsWindow) return; // 普通链接,交给浏览器自己开
1120
1270
  if (event && typeof event.preventDefault === 'function') event.preventDefault();
1121
- // 公网地址先问一句;地址都还没读到时问不了,交由 `openConfigurationPage` 拿到类别后补问。
1122
- if (knowsUrl
1123
- && panelScope === 'public'
1271
+ // 公网地址先问一句——但**只在真要开那两小时窗口的时候问**:窗口已经开着,
1272
+ // 这一下没有扩大任何权限,再弹一次只是把上一轮点过的确认重问一遍。
1273
+ const willOpenWindow = panelScope !== 'loopback' && !(webAccess && webAccess.enabled);
1274
+ if (panelScope === 'public'
1275
+ && url
1276
+ && willOpenWindow
1124
1277
  && typeof window !== 'undefined'
1125
1278
  && typeof window.confirm === 'function'
1126
1279
  && !window.confirm(publicConfirm)) return;
1127
- const target = isWebPage(window) && typeof window.open === 'function'
1128
- ? window.open('', '_blank')
1129
- : null;
1280
+ // 桌面壳(`dsh-app://app`)里不嵌:往自定义协议页里塞一条 http(s) iframe,多半被
1281
+ // 当成混合内容挡掉,用户看到的是一窗空白。那种环境仍走「交给系统浏览器」那条老路
1282
+ //(`navigate` 默认 true,壳的 `will-navigate` 会把它转给系统浏览器)。
1130
1283
  void (async () => {
1131
- const outcome = await openConfigurationPage({
1284
+ const outcome = await openConsole({
1132
1285
  view: { panelUrl: url || '', panelScope, webAccess },
1133
1286
  loadStatus: problem ? problem.status : 0,
1134
1287
  t,
1135
- target,
1136
- confirmed: knowsUrl,
1137
1288
  });
1138
1289
  if (outcome.ok) {
1139
- if (outcome.cancelled && target && typeof target.close === 'function') target.close();
1140
1290
  if (outcome.webAccess) setWebAccess(outcome.webAccess);
1141
1291
  return;
1142
1292
  }
1143
- if (target && typeof target.close === 'function') target.close();
1293
+ // 宿主那一半压根没装载:那是一屏「装完没重启」,不是这一屏能修的。
1144
1294
  if (outcome.missing) {
1145
1295
  void openDiagnosis('load', { status: outcome.status || 404, message: (problem && problem.message) || '' });
1146
1296
  return;
@@ -1149,6 +1299,138 @@ window.__ModuleLoader__.load({
1149
1299
  })();
1150
1300
  }
1151
1301
 
1302
+ /**
1303
+ * 把「控制台」那一屏画出来:标题 + 一颗「在浏览器里打开」+ 内嵌的调试页。
1304
+ *
1305
+ * 嵌的是同一台宿主吐的页面(同源),所以它读 `/api/relay/*` 和面板页一模一样,
1306
+ * 不需要 token、也不新增接口。远程浏览器要先开那两小时窗口:`openConfigurationPage`
1307
+ * 在这一步之前已经把窗口开好了(它的 `target` 传 null,不导航任何地方)。
1308
+ */
1309
+ async function openConsole({ view, loadStatus, t }) {
1310
+ if (typeof document === 'undefined') return { ok: false, missing: false, status: 0, message: t('panelMissing') };
1311
+ const outcome = await openConfigurationPage({ view, loadStatus, t, target: null, confirmed: true, navigate: false });
1312
+ if (!outcome.ok) return outcome;
1313
+ const pageUrl = consoleUrlOf();
1314
+ if (!pageUrl) return { ok: false, missing: false, status: 0, message: t('panelMissing') };
1315
+ const dialog = diagnosisDialog();
1316
+
1317
+ /**
1318
+ * 第二层:那一屏调试页(嵌整页)。
1319
+ *
1320
+ * 从第一层的「报错调试」进来,用「返回」回第一层 —— **不是**关掉整窗:用户多半是
1321
+ * 想「看一眼再回去」,每次都重开一窗等于把位置也丢了。
1322
+ */
1323
+ const showDebug = () => {
1324
+ dialog.open({
1325
+ title: t('consoleTitle'),
1326
+ wide: true,
1327
+ frame: { src: pageUrl, title: t('consoleTitle'), hint: t('consoleFrameless') },
1328
+ actions: [
1329
+ {
1330
+ label: t('consoleOpenBrowser'),
1331
+ primary: true,
1332
+ onClick: () => {
1333
+ if (typeof window !== 'undefined' && typeof window.open === 'function') {
1334
+ const opened = window.open(outcome.url, '_blank', 'noreferrer');
1335
+ if (opened) dialog.hide();
1336
+ }
1337
+ },
1338
+ },
1339
+ { label: t('diagCopy'), onClick: () => void copyConsole() },
1340
+ { label: t('consoleBack'), onClick: () => showEntry() },
1341
+ { label: t('diagClose'), onClick: () => dialog.hide() },
1342
+ ],
1343
+ });
1344
+ };
1345
+
1346
+ /**
1347
+ * 第一层:**只有一个简单入口**(2026-11-05 用户要求)。
1348
+ *
1349
+ * 原来点图标一上来就是整屏读数,太重了 —— 绝大多数时候人只是想去配置页,或者去看
1350
+ * 手机为什么连不上。所以这一层只摆两块:左边「进入控制台」(= 原来那个跳转),
1351
+ * 右边「报错调试」(才进第二层那一屏)。
1352
+ */
1353
+ const showEntry = () => {
1354
+ const choices = makeChoiceGrid(dialog, t);
1355
+ dialog.open({
1356
+ title: t('consoleEntryTitle'),
1357
+ summary: t('consoleEntryLead'),
1358
+ body: choices.body,
1359
+ actions: [{ label: t('diagClose'), onClick: () => dialog.hide() }],
1360
+ });
1361
+ choices.console.onclick = () => {
1362
+ const opened = typeof window !== 'undefined' && typeof window.open === 'function'
1363
+ ? window.open(outcome.url, '_blank', 'noreferrer')
1364
+ : null;
1365
+ if (opened) dialog.hide();
1366
+ else dialog.note(t('consolePopupBlocked'));
1367
+ };
1368
+ choices.debug.onclick = () => showDebug();
1369
+ };
1370
+
1371
+ showEntry();
1372
+ return { ok: true, webAccess: outcome.webAccess };
1373
+ }
1374
+
1375
+ /**
1376
+ * 入口那两块(左「进入控制台」、右「报错调试」)。
1377
+ *
1378
+ * 命令式 DOM,不用 react:客户端半边不经构建。两块都是大按钮,图标 + 标题 + 一句说明,
1379
+ * 点到哪里自己负责 —— 这里只造节点、把两张卡交给调用方填 onclick。
1380
+ */
1381
+ function makeChoiceGrid(dialog, t) {
1382
+ const doc = typeof document !== 'undefined' ? document : null;
1383
+ const make = (tag, className, text) => {
1384
+ const element = doc.createElement(tag);
1385
+ if (className) element.className = className;
1386
+ if (text !== undefined) element.textContent = String(text);
1387
+ return element;
1388
+ };
1389
+ const body = make('div', 'dshm_choices');
1390
+ const card = (title, hint, tone) => {
1391
+ const button = make('button', 'dshm_choice' + (tone ? ` ${tone}` : ''));
1392
+ button.setAttribute('type', 'button');
1393
+ button.appendChild(make('span', 'dshm_choiceTitle', title));
1394
+ button.appendChild(make('span', 'dshm_choiceHint', hint));
1395
+ body.appendChild(button);
1396
+ return button;
1397
+ };
1398
+ const console = card(t('consoleEntryConsole'), t('consoleEntryConsoleHint'), 'dshm_choicePrimary');
1399
+ const debug = card(t('consoleEntryDebug'), t('consoleEntryDebugHint'), '');
1400
+ return { body, console, debug };
1401
+ }
1402
+
1403
+ /** 把「复制诊断信息」那一套复用在控制台上:读的是嵌入页同样的几份,不另开一套。 */
1404
+ async function copyConsole() {
1405
+ const dialog = diagnosisDialog();
1406
+ const lines = ['dsh-mobile-plugin console'];
1407
+ lines.push(`panel_url=${url || ''}`);
1408
+ lines.push(`panel_scope=${panelScope || ''}`);
1409
+ try {
1410
+ const view = await call('/status');
1411
+ const next = view && typeof view === 'object' ? view : {};
1412
+ lines.push(`plugin_version=${next.appVersion || ''}`);
1413
+ lines.push(`tunnel=${next.tunnel && next.tunnel.state ? next.tunnel.state : ''}`);
1414
+ lines.push(`lan=${next.lan && next.lan.state ? next.lan.state : ''}`);
1415
+ lines.push(`sidecar=${next.sidecar && next.sidecar.state ? next.sidecar.state : ''}`);
1416
+ lines.push(`last_mobile_at=${next.lastMobileAt || 0}`);
1417
+ } catch (error) {
1418
+ lines.push(`status_error=${String((error && error.message) || error)}`);
1419
+ }
1420
+ try {
1421
+ const picture = await call(HOST_HALF_ENDPOINT);
1422
+ const half = picture && picture.half && typeof picture.half === 'object' ? picture.half : {};
1423
+ lines.push(`plugin_source=${half.source || ''}`);
1424
+ lines.push(`host_last_error=${half.lastError || ''}`);
1425
+ } catch {
1426
+ lines.push('host_half=unavailable');
1427
+ }
1428
+ if (typeof navigator !== 'undefined' && navigator.userAgent) lines.push(`user_agent=${navigator.userAgent}`);
1429
+ const ok = await copyDiagnosisText(document, lines.join('\n'));
1430
+ // 复制结果就地提示一下即可,不必重画整窗(重画会把嵌着的页面重新加载一遍)。
1431
+ dialog.note(ok ? t('diagCopied') : t('diagCopyFailed'));
1432
+ }
1433
+
1152
1434
  /**
1153
1435
  * 把这一轮的现场摆成一屏**能自己动手**的自查。
1154
1436
  *
@@ -1384,8 +1666,8 @@ window.__ModuleLoader__.load({
1384
1666
  return undefined;
1385
1667
  }
1386
1668
  // 没地址时标题要说的是「为什么没有」:404 是宿主那一半没装载,其余是还没就绪。
1387
- const notReadyLabel = problem && problem.status === 404 ? loadMissingLabel : pendingLabel;
1388
- const sync = () => syncFooterIcon(node, url, openLabel, notReadyLabel, openPanel);
1669
+ const notReadyLabel = hostMissing ? loadMissingLabel : pendingLabel;
1670
+ const sync = () => syncFooterIcon(node, url, openLabel, notReadyLabel, openPanel, hostMissing);
1389
1671
  let attached = false;
1390
1672
  try {
1391
1673
  attached = ensureFooterIcon(doc, node);
@@ -1427,9 +1709,7 @@ window.__ModuleLoader__.load({
1427
1709
 
1428
1710
  // 窄条,或者这一版外壳里认不出那一行:自己占一行,入口不能因为认不出位置就消失。
1429
1711
  if (!wide || !anchored) {
1430
- const label = url
1431
- ? openLabel
1432
- : (problem && problem.status === 404 ? loadMissingLabel : pendingLabel);
1712
+ const label = url ? openLabel : (hostMissing ? loadMissingLabel : pendingLabel);
1433
1713
  return h('a', {
1434
1714
  className: 'dshm_footAction dshm_footActionRail',
1435
1715
  href: url || undefined,
@@ -1438,6 +1718,7 @@ window.__ModuleLoader__.load({
1438
1718
  'aria-label': label,
1439
1719
  title: label,
1440
1720
  'aria-disabled': 'false',
1721
+ [FOOTER_ALERT]: hostMissing ? '' : undefined,
1441
1722
  onClick: openPanel,
1442
1723
  }, h('svg', {
1443
1724
  viewBox: '0 0 24 24',
@@ -1449,7 +1730,17 @@ window.__ModuleLoader__.load({
1449
1730
  strokeLinecap: 'round',
1450
1731
  'aria-hidden': 'true',
1451
1732
  focusable: 'false',
1452
- }, PHONE_PATHS.map((d) => h('path', { key: d, d }))));
1733
+ }, [
1734
+ ...PHONE_PATHS.map((d) => h('path', { key: d, d })),
1735
+ h('circle', {
1736
+ key: 'alert',
1737
+ className: 'dshm_alertDot',
1738
+ cx: PHONE_ALERT_DOT.cx,
1739
+ cy: PHONE_ALERT_DOT.cy,
1740
+ r: PHONE_ALERT_DOT.r,
1741
+ fill: 'currentColor',
1742
+ }),
1743
+ ]));
1453
1744
  }
1454
1745
  return null;
1455
1746
  }
package/lib/impl.js CHANGED
@@ -30,8 +30,9 @@ const { createMirrorPusher } = require('./mirror/pusher.cjs')
30
30
  const { randomBytes } = require('node:crypto')
31
31
  const { lanUrls, advertisedCandidates, phoneCandidates, ipv6Candidates, withManualIpv6Candidate } = require('./relay/lan-network.cjs')
32
32
  // 配对二维码的载荷:面板与终端命令共用一份,拼装在 `./relay/pairing-payload.cjs`。
33
- const { pairingPayload } = require('./relay/pairing-payload.cjs')
33
+ const { pairingPayload, pairingText } = require('./relay/pairing-payload.cjs')
34
34
  const { resolvePort } = require('./relay/port-allocator.cjs')
35
+ const { mountTailcat } = require('./tailcat/wiring.cjs') // 直连隧道:入口 + 助手会话 + 启停
35
36
  const { createPreviewService } = require('./relay/preview.js')
36
37
  const { mountTerminal, mountCli } = require('./mobile/services.js')
37
38
  // 手机打开「电脑上的网页版」时用的那枚一次性登录令牌(见 mobile/host-web.js)。
@@ -53,6 +54,9 @@ const statsScheduler = require('./mobile/stats-scheduler.cjs')
53
54
  // 扩展卡的宿主侧:`extension_present` 工具与卡状态账本(见 mobile/extensions.js)。
54
55
  const { installExtensions } = require('./mobile/extensions.js')
55
56
  const { mountPhoneControl } = require('./mobile/phone/index.js') // 「让 AI 操作手机」,见 mobile/phone/
57
+ // 公众号文章库:给 AI 的两个工具(写 / 读),见 mobile/wechat-tools.js。
58
+ const { installWechatTools } = require('./mobile/wechat-tools.js')
59
+ const { installPetTools } = require('./mobile/pet-tools.js') // 悬浮宠物的两个工具(看 / 写),见 mobile/pet-tools.js。
56
60
  const { activeGuard } = require('./host-guard.cjs')
57
61
  // 桌面端侧栏展开态的快照存储。桌面把这份状态只记在浏览器 localStorage 里,宿主必须
58
62
  // 有一个落盘处:客户端半边(lib/client.js)上报 → 这里 → 移动端读取。
@@ -299,6 +303,8 @@ function apply(ctx) {
299
303
  let lanPortProbe = null
300
304
  const lanPortInUse = () => (state.lanEnabled && effectiveLanPort > 0 ? effectiveLanPort : state.lanPort)
301
305
  const advertisedLanPort = () => (state.lanEnabled ? sidecarLifecycle.getAdvertisedLanPort() : state.lanPort) // 对外报地址用实绑端口,见 lifecycle
306
+ // 直连隧道:不配中转也能连的兜底通道;挂不上就只是没这条通道,中转与局域网照旧。
307
+ const tailcat = mountTailcat({ mount, warn, state, saveState: core.saveState, lanPortInUse, hostName: () => state.name, ctx, name })
302
308
  // 每次同步前都跑一次探测,而不是只在启动时探一次:`/api/relay/config` 改了
303
309
  // lanPort 之后同样要走这条路(否则新端口被占时又会退回死地址)。并发调用复用
304
310
  // 在飞的那次——探测要占一个监听句柄,重复触发没有意义。
@@ -362,6 +368,14 @@ function apply(ctx) {
362
368
  }
363
369
  return undefined
364
370
  }, `${name}: extension tool`)
371
+ ctx.effect(() => {
372
+ // 缺 tools 注入的 profile 只少这两个工具;文章库本身与它的路由照常可用。
373
+ try { installWechatTools(ctx) } catch (error) {
374
+ if (!String(error).includes('without inject')) throw error
375
+ }
376
+ return undefined
377
+ }, `${name}: wechat article tools`)
378
+ mount('宠物工具', () => installPetTools(ctx)) // 缺 tools 注入的 profile 只少这两个,包目录与路由照常可用。
365
379
  mount('统计采集', () => ctx.effect(() => statsScheduler.start(ctx), `${name}: stats collect`))
366
380
  mount('过程播报', () => ctx.effect(() => installNarration(ctx), `${name}: process narration`))
367
381
 
@@ -376,6 +390,8 @@ function apply(ctx) {
376
390
  getSidecarRestartedAt: sidecarLifecycle.getRestartedAt,
377
391
  getSidecarRestartError: sidecarLifecycle.getRestartError,
378
392
  getMirrorPush: mirror.getMirrorPush,
393
+ // 直连隧道那一格:手机轮询 `/status` 时据此自己把通道加进来。
394
+ getTailcat: () => (tailcat ? tailcat.status() : null),
379
395
  })
380
396
  function applyServices() {
381
397
  void syncSidecar().catch(() => undefined)
@@ -420,6 +436,8 @@ function apply(ctx) {
420
436
  lanPortInUse: advertisedLanPort, // 二维码里的端口也必须是实绑值
421
437
  getLanPortAdjusted: () => lanPortAdjusted,
422
438
  pairingPayload,
439
+ // 直连隧道:二维码要多带一段「去这个一次性节点换正式地址」,状态接口要多一行。
440
+ tailcat,
423
441
  restartSidecar,
424
442
  // 「手机上最近来过没有」——控制面状态里补给面板用(见 ./mobile/activity.cjs)。
425
443
  mobileActivity: activity.snapshot,
@@ -487,6 +505,8 @@ function apply(ctx) {
487
505
  // 核对的「路径 → handler」表,不用在 1000 行装配代码里翻。
488
506
  mount('移动端路由', () => registerMobileRoutes({
489
507
  registerRoute,
508
+ // 直连隧道:已配对的手机走这条拿到自己的入口地址,不用重新扫码。
509
+ tailcat,
490
510
  // 预览是 WebSocket 升级路由,走的是 webServer 的另一套名字空间;与 registerRoute
491
511
  // 一样必须包在 ctx.effect 里,否则插件重载时重复注册会直接抛错。
492
512
  registerUpgradeRoute: (route) => mountRoute(route, true),
@@ -18,6 +18,7 @@
18
18
 
19
19
  const { randomUUID } = require('node:crypto');
20
20
  const { sendJson, readJsonBody } = require('../shared/http-util');
21
+ const { cancelForwardedRequests } = require('./host-bridge/transport.js');
21
22
 
22
23
  /** `/approval/respond` 只认这两种结论(与 App 的两个按钮一一对应)。 */
23
24
  const ANSWER_OUTCOMES = new Set(['allowed-once', 'rejected']);
@@ -234,6 +235,8 @@ function noteDecided(sessionId, data) {
234
235
  */
235
236
  function bindRequest(entry, request) {
236
237
  entry.bound = true;
238
+ // 留一个身份引用:手机答完之后要按它找回宿主转发给桌面窗口的那一发(见 cancelForwardedCards)。
239
+ entry.requestRef = request;
237
240
  entry.toolName = entry.toolName || (typeof request.toolName === 'string' ? request.toolName : undefined);
238
241
  entry.callId = entry.callId || (typeof request.callId === 'string' ? request.callId : undefined);
239
242
  const nicer = displayReasonOf(request) || (typeof request.reason === 'string' ? request.reason : undefined);
@@ -261,8 +264,10 @@ function bindRequest(entry, request) {
261
264
  * 输的那张卡由结算帧收掉;两边都没人答就一直挂着。
262
265
  *
263
266
  * 不传 `next`(手机正在看这个会话)就是手机独家作答:不往下游转发,桌面从源头就不弹卡。
264
- * 桌面那张卡挂在被转发的请求上,本桥没有替它收尾的口子,转发出去手机先答,它只会永远
265
- * 悬着。独家作答后结论照常走 `approval/decided`,桌面看得到裁决结果。
267
+ * 独家作答后结论照常走 `approval/decided`,桌面看得到裁决结果。
268
+ *
269
+ * 唯一兜不住的边角是「审批发出时手机没在看、卡已转发到桌面,之后手机才连上来」:那一发
270
+ * 已经转发出去,手机后来答时靠 `cancelForwardedCards` 把它撤掉(见下)。
266
271
  */
267
272
  function holdForPhone(entry, next) {
268
273
  const downstream = next === undefined
@@ -275,6 +280,20 @@ function holdForPhone(entry, next) {
275
280
  return Promise.race([entry.wait.then((result) => result.outcome), downstream]);
276
281
  }
277
282
 
283
+ /**
284
+ * 收掉桌面那张审批卡:把宿主已经发给桌面窗口、还挂着的那一发转发撤掉。
285
+ *
286
+ * 手机在看这条会话时审批桥本就不转发,桌面没有卡可收;会走到这里的是那条边角 —— 审批
287
+ * 发出时手机没在看、卡已转发到桌面,之后手机才连上来答。那一发只有桌面自己答 / 本轮取消
288
+ * / 宿主撤回三条路会消失,本桥替它补上最后一条(与提问桥 2026-10-31 同一手法:把钩子收到
289
+ * 的请求对象交给传输层,按它认回网关 `pendingRemoteEvents` 里那一发并撤回)。认不到(老
290
+ * 宿主没这道网关)就什么都不做 —— 「桌面没卡可收」本来就是对的。
291
+ */
292
+ function cancelForwardedCards(ctx, entry) {
293
+ if (!entry.requestRef) return;
294
+ cancelForwardedRequests(ctx, entry.requestRef, new Error('手机已经回答了这条审批'));
295
+ }
296
+
278
297
  function onApprovalRequest(request, next) {
279
298
  const sessionId = sessionIdOfRequest(request);
280
299
  if (!sessionId) return next();
@@ -333,6 +352,7 @@ async function handleApprovalRespond(ctx, req, res) {
333
352
  if (entry && !entry.settled && entry.bound
334
353
  && entry.sessionId === body.sessionId && entry.approvalId === body.approvalId) {
335
354
  settle(entry, body.outcome);
355
+ cancelForwardedCards(ctx, entry);
336
356
  return sendJson(res, 200, { ok: true, accepted: true });
337
357
  }
338
358
  const receipt = await respondViaLegacyProxy(ctx, {