dsh-mobile-plugin 0.5.20 → 0.5.21

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/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
  }
@@ -26,7 +26,7 @@ const {
26
26
  const { updateCompletionStates, confirmCompletionFromHistory } = require('./completion.js');
27
27
  const { lastTurnOf } = require('./session-last-turn.js');
28
28
  const { sessionPreviewFor } = require('./session-preview.js');
29
- const { workspaceSessionFlags, pendingInteractionOf, pendingCardOf } = require('./session-flags.js');
29
+ const { archivedSessionIdsForMonitor, pendingInteractionOf, pendingCardOf } = require('./session-flags.js');
30
30
 
31
31
  /**
32
32
  * 一条会话在监控视图里的那几格。
@@ -114,7 +114,10 @@ async function buildMonitorState(ctx, deviceKey, extraItems = null) {
114
114
 
115
115
  const generation = completionGenerationOf(ctx, deviceKey);
116
116
  const deleted = deletedSessionIdsOf(ctx);
117
- const { archived } = await workspaceSessionFlags(ctx);
117
+ // 归档集合用「记住上一次真读到的那份」那个口径,不用列表那条 fail-open:
118
+ // 这一条决定要不要在后台叫醒人,一次读不到就按「没归档」画会让已归档的会话重新进清单、
119
+ // 被当成刚跑完再响一遍(见 session-flags.js 的 archivedSessionIdsForMonitor)。
120
+ const archived = await archivedSessionIdsForMonitor(ctx);
118
121
  // 子代理会话不参与后台提醒:它们是主轮内部的执行单元,App 侧栏也不列;把它们算进来
119
122
  // 会在后台弹一堆「子代理跑完了」。
120
123
  const live = summaries.filter((s) => s && typeof s.sessionId === 'string' && s.sessionId
@@ -24,22 +24,69 @@ const questions = require('./questions.js');
24
24
  * - `pinnedSessionIds`:已置顶的会话。摘要里没有置顶字段,不读这份快照就画不出置顶标记、
25
25
  * 也排不到工作区最前。
26
26
  *
27
- * 两个集合合并成一次 RPC:同源同一次快照,而会话列表每 10 秒重建一次。取不到时都回空 ——
28
- * 宁可多显示几行,也不能因为一次 RPC 抖动让整个列表变空。
27
+ * 两个集合合并成一次 RPC:同源同一次快照,而会话列表每 10 秒重建一次。
28
+ *
29
+ * 取不到时两个集合都回空,另外带一个 `known: false`。调用方据此分两种口径:
30
+ *
31
+ * - **列表**(`/sessions`)按空集合画:宁可多显示几行,也不能因为一次 RPC 抖动让整个侧栏变空。
32
+ * - **提醒**(`/monitor`)不许把「不知道」当成「没有归档」:那样一次抖动就会让已归档的会话
33
+ * 重新进监控视图,被当成刚跑完再响一遍。所以那条路由另外用一本账([archivedSessionIds]),
34
+ * 记着最近一次真读到的归档集合,读不到就沿用它。
29
35
  */
30
36
  async function workspaceSessionFlags(ctx) {
31
37
  try {
32
38
  const resp = await ctx.apiProxy.workspace.list({ rpcId: 'mobile', method: 'workspace.list', payload: {} });
33
39
  const value = unwrap(resp).value;
34
- const idSetOf = (field) => new Set(
35
- value && Array.isArray(value[field]) ? value[field].filter(id => typeof id === 'string') : [],
36
- );
37
- return { archived: idSetOf('archivedSessionIds'), pinned: idSetOf('pinnedSessionIds') };
40
+ // 集合被人当成 Set 传过(宿主与本仓库两个来源形状不一样,dsh-devmap 专门归一化过),
41
+ // 所以数组与 Set 都收:只认 `Array.isArray` 会让那一类宿主上的归档过滤恒失效。
42
+ const idSetOf = (field) => {
43
+ const raw = value && value[field];
44
+ const list = Array.isArray(raw) ? raw : (raw instanceof Set ? [...raw] : null);
45
+ return list ? new Set(list.filter(id => typeof id === 'string')) : null;
46
+ };
47
+ const archived = idSetOf('archivedSessionIds');
48
+ // 判据只看归档那一格:置顶是画图用的,缺了顶多个图钉,不值得让它把归档判断一起拖死。
49
+ const pinned = idSetOf('pinnedSessionIds') ?? new Set();
50
+ if (!archived) return { archived: new Set(), pinned, known: false };
51
+ return { archived, pinned, known: true };
38
52
  } catch {
39
- return { archived: new Set(), pinned: new Set() };
53
+ return { archived: new Set(), pinned: new Set(), known: false };
40
54
  }
41
55
  }
42
56
 
57
+ /**
58
+ * `/monitor` 用的归档集合:**只认读到的**,读不到就沿用上一次读到的那一份。
59
+ *
60
+ * 为什么不跟列表一样 fail-open:这条视图的作用是决定「要不要在后台叫醒人」,而它的判据是
61
+ * 「这个会话还在不在清单里」。一次读不到就按空集合画,已归档的会话会重新进清单 —— 原生那边
62
+ * 刚因为它消失而清了基线,于是它成了「第一次见到」,只要那一轮结束在十分钟内就再响一遍
63
+ * (延迟多久取决于宿主的 RPC 超时,反复抖动就会反复响)。列表那边空一下只是多显示几行,
64
+ * 这边空一下是半夜多叮一声,两种代价不是一个量级。
65
+ *
66
+ * 也刻意**不**回 5xx:README 那条规矩是「一拍什么都没取到就退回完整清单」,而这条 RPC
67
+ * 在 0.2.0 宿主要现开 `workspace.follow` 取首帧(8 秒预算),失败率不低 —— 每失败一拍就
68
+ * 让整个后台提醒哑掉,代价比偶尔多滤错一条大得多。沿用旧账的最坏结果是「刚归档的会话还在
69
+ * 清单里挂一会儿」,而它挂着的那些拍,这条会话的 `completed` 还会按已读水位判,不会凭空响。
70
+ *
71
+ * 一本账都没有(进程刚起来就问不到)时退回空集合 —— 那时候原生手上也没有基线,
72
+ * 首拍见到什么都只建基线、不提醒,所以空集合不会凭空造出一次提醒。
73
+ */
74
+ const archivedSessionIds = { ids: null };
75
+
76
+ async function archivedSessionIdsForMonitor(ctx) {
77
+ const { archived, known } = await workspaceSessionFlags(ctx);
78
+ if (known) {
79
+ archivedSessionIds.ids = archived;
80
+ return archived;
81
+ }
82
+ return archivedSessionIds.ids ?? archived;
83
+ }
84
+
85
+ /** 仅供守卫重置进程内那本归档账。 */
86
+ function resetArchivedSessionIdsForTest() {
87
+ archivedSessionIds.ids = null;
88
+ }
89
+
43
90
  /**
44
91
  * 「这条会话是不是在等人回话」以及等的是什么。
45
92
  *
@@ -72,4 +119,10 @@ function pendingCardOf(sessionId, summary) {
72
119
  return approvals.pendingCardOf(sessionId) || questions.pendingCardOf(sessionId);
73
120
  }
74
121
 
75
- module.exports = { workspaceSessionFlags, pendingInteractionOf, pendingCardOf };
122
+ module.exports = {
123
+ workspaceSessionFlags,
124
+ archivedSessionIdsForMonitor,
125
+ resetArchivedSessionIdsForTest,
126
+ pendingInteractionOf,
127
+ pendingCardOf,
128
+ };