dsh-mobile-plugin 0.5.11 → 0.5.13

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
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * dsh-mobile-plugin 的**客户端半边**(在 DSH 桌面 Web 页面里运行)。
3
3
  *
4
- * 四件事:桌面侧边栏的展开态与「读过了」报给宿主;手机端的已读回执应用回桌面绿点;
5
- * 提供「设置 → 移动端」那颗由本机打开的网页管理开关。方向是单向的(桌面 → 宿主 → 手机),
4
+ * 五件事:桌面侧边栏的展开态与「读过了」报给宿主;手机端的已读回执应用回桌面绿点;
5
+ * 提供「设置 → 移动端」那颗由本机打开的网页管理开关;在侧边栏最底下那一行挂一颗
6
+ * 手机图标(点一下就是设置里那颗「打开配置页面」)。方向是单向的(桌面 → 宿主 → 手机),
6
7
  * 手机上的折叠只存在 App 本地,不写回。
7
8
  *
8
9
  * 本文件不经任何构建(package.json 直接收录 lib 下的 .js),所以不能写 JSX,统一用
@@ -26,22 +27,34 @@ window.__ModuleLoader__.load({
26
27
  * 照抄 @deepseek-ai/dsh-client-ui-settings-plugins 的注入方式:同一
27
28
  * data-plugin-css 只注入一次,bundle 被重复物化也不会堆出第二份 <style>。
28
29
  *
29
- * 这里只留这一项用得到的几条:界面本体已经搬去网页版(那边自带一套样式),
30
- * 留着旧类名只会让人以为这里还画着那些东西。
30
+ * 这一半只剩侧边栏那一颗图标与它打开的诊断弹窗(设置菜单里那一项已经按用户要求去掉):
31
+ * 界面本体在网页版里,那边自带一套样式。
31
32
  */
32
- const STYLE_ID = 'dsh-mobile-plugin/settings-tab.css';
33
+ const STYLE_ID = 'dsh-mobile-plugin/footer-icon.css';
33
34
  const CSS = `
34
- .dshm_root{display:flex;flex-direction:column;gap:14px;width:100%;max-width:760px;color:var(--dsw-alias-label-primary)}
35
- .dshm_card{border:.5px solid var(--dsw-alias-settings-card-stroke);border-radius:var(--dsw-radius-xl);background:var(--dsw-alias-settings-card-fill);display:flex;flex-direction:column;gap:10px;padding:14px 16px}
36
- .dshm_cardTitle{font-size:14px;font-weight:600;line-height:20px;margin:0;display:flex;align-items:center;gap:8px}
37
- .dshm_heading{font-size:18px;font-weight:600;margin:0}
38
- .dshm_actions{display:flex;flex-wrap:wrap;gap:8px}
39
- .dshm_note{margin:0;font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary)}
35
+ .dshm_footAction{flex:none;width:28px;height:28px;margin:0 2px;padding:0;border:none;border-radius:var(--dsw-radius-sm);background:transparent;color:var(--dsw-alias-label-secondary);display:inline-flex;align-items:center;justify-content:center;cursor:pointer;text-decoration:none}
36
+ .dshm_footAction:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
37
+ .dshm_footAction:focus-visible{outline:var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-offset:-2px}
38
+ .dshm_footAction[aria-disabled="true"]{opacity:.45;cursor:default}
39
+ .dshm_footAction[aria-disabled="true"]:hover{background:transparent;color:var(--dsw-alias-label-secondary)}
40
+ .dshm_footActionRail{width:36px;height:36px;margin:2px 0;border-radius:var(--dsw-radius-md)}
41
+ .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
+ .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)}
43
+ .dshm_dialogTitle{margin:0;font-size:16px;font-weight:600;line-height:24px}
44
+ .dshm_dialogHeading{margin:2px 0 0;font-size:12px;font-weight:600;line-height:18px;color:var(--dsw-alias-label-tertiary)}
45
+ .dshm_dialogLead{margin:0;font-size:13px;line-height:20px}
46
+ .dshm_dialogNote{margin:0;font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary)}
47
+ .dshm_facts{margin:0;display:flex;flex-direction:column;gap:6px}
48
+ .dshm_fact{display:flex;gap:10px;align-items:baseline;font-size:12px;line-height:18px}
49
+ .dshm_factKey{flex:none;min-width:84px;color:var(--dsw-alias-label-tertiary)}
50
+ .dshm_factValue{margin:0;min-width:0;word-break:break-all}
51
+ .dshm_factMono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:11px}
52
+ .dshm_steps{margin:0;padding-left:18px;display:flex;flex-direction:column;gap:4px;font-size:12px;line-height:18px}
53
+ .dshm_dialogActions{display:flex;flex-wrap:wrap;gap:8px;justify-content:flex-end;margin-top:2px}
40
54
  .dshm_btn{border:.5px solid var(--dsw-alias-border-l3);border-radius:var(--dsw-radius-sm);background:transparent;color:var(--dsw-alias-label-primary);cursor:pointer;font:inherit;font-size:13px;line-height:18px;padding:5px 12px;display:inline-flex;align-items:center;gap:6px;text-decoration:none}
41
55
  .dshm_btn:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}
42
56
  .dshm_btn:disabled{cursor:default;opacity:.45}
43
57
  .dshm_btnPrimary{background:var(--dsw-alias-state-business-primary);border-color:transparent;color:var(--dsw-alias-bg-layer-1)}
44
- .dshm_error{margin:0;border-radius:var(--dsw-radius-md);background:color-mix(in srgb, var(--dsw-alias-state-error-primary) 10%, transparent);color:var(--dsw-alias-state-error-primary);font-size:12px;line-height:18px;padding:8px 10px}
45
58
  `;
46
59
  if (typeof document !== 'undefined' && document.querySelector(`style[data-plugin-css="${STYLE_ID}"]`) === null) {
47
60
  const tag = document.createElement('style');
@@ -61,7 +74,7 @@ window.__ModuleLoader__.load({
61
74
  * 浏览器侧的应用路由一律写成**文档相对**形式(没有前导斜杠),与 DSH 自己的客户端
62
75
  * 一致:宿主给 index 注入了 `<base href="./">`,页面挂在哪个前缀下,请求就跟着哪个
63
76
  * 前缀走。写成 `/api/...` 这种站点根绝对地址,DSH 被反向代理挂到子路径(例如
64
- * `/dsh/`)时会直接打到站点根、拿回 404 —— 设置页看到的正是「读取宿主状态失败: HTTP 404」。
77
+ * `/dsh/`)时会直接打到站点根、拿回 404 —— 现场看到的正是「读取宿主状态失败: HTTP 404」。
65
78
  */
66
79
  const DESKTOP_VIEW_ENDPOINT = 'api/relay/desktop-view';
67
80
 
@@ -225,11 +238,11 @@ window.__ModuleLoader__.load({
225
238
  const CLIENT_HALF_ENDPOINT = '/api/relay/client-half';
226
239
  const CLIENT_HALF_REPORT_MS = 10000;
227
240
  /**
228
- * 宿主半边的自检口(由外壳注册,实现挂了它也还在)。
241
+ * 宿主半边的自检口(由**外壳**注册,实现挂了它也还在)。
229
242
  *
230
- * 实现装载失败时整张 `/api/relay/*` 都 404,这一项只显示一句「插件宿主侧没有加载」,
231
- * 真正的原因(等哪个服务超时、哪一代装不上)就没人看得见。这两条是那个原因唯一的出口;
232
- * 第二条点一下就在原地重新装载一次,不必重启整个 DSH。
243
+ * 实现装载失败时整张 `/api/relay/*` 都 404,而「它为什么没起来」只有外壳答得出来
244
+ * (它自己活得好好的是刻意的故障边界)。诊断弹窗靠这两条把原因、版本、后台重试
245
+ * 如实摆出来,并提供「重新装载」——那是唯一不必重启整个 DSH 的出路。
233
246
  */
234
247
  const HOST_HALF_ENDPOINT = '/api/relay/host-half';
235
248
  const HOST_HALF_RELOAD_ENDPOINT = '/api/relay/host-half/reload';
@@ -469,309 +482,818 @@ window.__ModuleLoader__.load({
469
482
  const NS = 'settings.mobileRelay';
470
483
  const DICT = {
471
484
  zh: {
472
- nav: '移动端',
473
- heading: '移动端',
474
- cardPanel: '配置页面',
475
- openPanel: '打开配置页面',
476
- openHint: '设置、配对和相关运维都在这个页面里。',
477
- panelPending: '正在读取页面地址…',
478
- panelMissing: '宿主还没给出页面地址,稍后再打开这一页试试。',
479
- loadFailed: '读取宿主状态失败',
480
- loadMissing: '插件宿主侧没有加载:把 DSH 完整退出再打开一次;还不行就在插件市场重装一遍插件。',
481
- hostHalfReason: '宿主侧报的原因:',
482
- hostHalfRetrying: '它还在后台自己重试,过几分钟刷新这一页就能看到结果;也可以点下面的按钮立刻再试一次。',
483
- hostHalfReload: '重新装载',
484
- hostHalfReloadFailed: '重新装载没成:',
485
- hostHalfUnreachable: '宿主侧的自检口也没有回应:这一版插件可能还没带它(较老的版本)——先更新插件,再完整重启一次 DSH。',
486
- hostHalfNoError: '宿主侧自检没报错,控制面路由却确实不在:把 DSH 完整重启一次;还不行请把这句连同插件版本发给我们。',
487
- hostHalfVersion: '自检版本:',
488
- diagHostReturned: '宿主返回:',
489
- diagSelfCheck: '自检:',
490
- diagSelfCheckNoAnswer: '没有回应(这一版可能还没带它)',
491
- diagSelfCheckNoError: '没有报错',
492
- diagSelfCheckRetry: '后台重试 ',
493
- diagOnDisk: '碟上版本 ',
494
- diagShellRestartAtStartup: '外壳改过,要重启一次 DSH',
495
- webAccessOn: '公网网页管理已开放,到期时间',
496
- webAccessOff: '公网网页管理已关闭',
497
- webAccessOpen: '开放网页管理 2 小时',
498
- webAccessClose: '立即关闭网页管理',
499
- webAccessHint: '默认关闭;开启后 2 小时自动关闭,访问不会延长时间。关闭后公网不可访问,本机和局域网仍可访问;不影响手机 App。只可从本机 DSH 设置页开关。',
500
- webAccessUnavailable: '正在读取本机访问状态…',
501
- webAccessError: '更新网页访问状态失败',
485
+ footerOpen: '打开移动端插件配置网页',
486
+ footerPending: '网页地址还没就绪',
487
+ footerPublicConfirm: '这个地址位于公网。继续后会开放移动端插件的网页管理权限 2 小时;期间任何能访问该地址的人都可以操作面板。是否继续?',
488
+ footerLoadMissing: '插件宿主侧没有加载:把 DSH 完整退出再打开一次;还不行就在插件市场重装一遍插件。',
489
+ diagTitleLoad: '打不开移动端配置页',
490
+ diagTitleOpen: '开放网页管理失败',
491
+ diagChecking: '正在读取宿主状态…',
492
+ diagBusy: '正在重读状态…',
493
+ diagHeadingFacts: '诊断读数(可直接复制发给维护者)',
494
+ diagHeadingSteps: '你可以自己先试这几步',
495
+ diagClose: '关闭',
496
+ diagRetry: '重新读取状态',
497
+ diagReload: '重新装载插件',
498
+ diagReloadBusy: '正在让宿主重新装载…',
499
+ diagReloadOk: '重新装载成功,控制面已经回来了。',
500
+ diagReloadFailed: '重新装载没成:',
501
+ diagCopy: '复制诊断信息',
502
+ diagCopied: '诊断信息已复制,可以直接发给维护者。',
503
+ diagCopyFailed: '复制失败:请手动选中上面的读数再复制。',
504
+ diagOpenPanel: '打开配置页面',
505
+ diagSummaryLoadMissing: '插件宿主侧没有加载:DSH 外壳在跑,插件本体没装起来(最常见的原因是装完没重启)。',
506
+ diagSummaryUnreachable: '宿主没有回应状态查询,这一版插件可能还没带自检口。',
507
+ diagSummaryNoError: '宿主自检说没报错,控制面路由却不在:多半是同一份 DSH 里跑着两份插件。',
508
+ diagSummaryLoadFailed: '读取宿主状态失败。',
509
+ diagSummaryOpenFailed: '两小时网页管理窗口没能打开,所以没有跳转。',
510
+ diagPopupBlocked: '网页管理已经开好了,但浏览器拦掉了这个新窗口。允许本站打开弹窗后再点一次图标。',
511
+ diagSummaryRestored: '状态已经读到了,可以再点一次图标。',
512
+ diagStepRestart: '把 DSH 完整退出(不是只关窗口)再打开一次——装完插件没重启是最常见的原因。',
513
+ diagStepReinstall: '还不行就在插件市场把这个插件重装一遍,装完再完整重启一次 DSH。',
514
+ diagStepRetrying: '宿主还在后台自己重试,等几分钟再点一次图标,不必重装。',
515
+ diagStepDuplicate: '确认没装两份(市场一份、本地目录一份):页面会连到装不上的那一份。',
516
+ diagStepProxy: '如果 DSH 挂在反向代理的子路径下(如 /dsh/),确认代理保留了 Host、Origin 与 X-Forwarded-Proto。',
517
+ diagStepRetryOpen: '先点「重新读取状态」再试一次;刚重启过的 DSH 头几秒常常还没就绪。',
518
+ diagFactReturned: '宿主返回',
519
+ diagFactStatus: 'HTTP 状态',
520
+ diagFactSelfcheck: '宿主自检',
521
+ diagFactUnavailable: '没有回应(这一版可能还没带它)',
522
+ diagFactPlugin: '插件版本',
523
+ diagFactSource: '来源',
524
+ diagFactActivated: '装载时刻',
525
+ diagFactRetry: '后台重试',
526
+ diagFactOnDisk: '盘上版本',
527
+ diagFactShell: '外壳已改',
528
+ diagFactReason: '宿主侧原因',
529
+ diagFactPanelUrl: '面板地址',
530
+ diagFactScope: '地址类别',
531
+ diagFactWindow: '两小时窗口',
502
532
  },
503
533
  en: {
504
- nav: 'Mobile',
505
- heading: 'Mobile',
506
- cardPanel: 'Configuration page',
507
- openPanel: 'Open the configuration page',
508
- openHint: 'Settings, pairing and plugin maintenance live on this page.',
509
- panelPending: 'Reading the page address…',
510
- panelMissing: 'The host has not reported a page address yet — reopen this item in a moment.',
511
- loadFailed: 'Failed to read host status',
512
- loadMissing: 'The plugin host half is not loaded: quit and reopen DSH; if that does not help, reinstall the plugin from the market.',
513
- hostHalfReason: 'Reported by the host: ',
514
- hostHalfRetrying: 'It is still retrying on its own in the background — reopen this item in a few minutes, or press the button below to retry right now.',
515
- hostHalfReload: 'Reload now',
516
- hostHalfReloadFailed: 'Reload failed: ',
517
- hostHalfUnreachable: 'The host-side self-check did not answer either: this version of the plugin may not ship it yet (older build) — update the plugin, then restart DSH completely.',
518
- hostHalfNoError: 'The host self-check reports no error, yet the control-plane routes really are missing: restart DSH completely; if that does not help, send us this line together with the plugin version.',
519
- hostHalfVersion: 'self-check version: ',
520
- diagHostReturned: 'Host returned: ',
521
- diagSelfCheck: 'Self-check: ',
522
- diagSelfCheckNoAnswer: 'no answer (this build may not ship it)',
523
- diagSelfCheckNoError: 'no error reported',
524
- diagSelfCheckRetry: 'background retries: ',
525
- diagOnDisk: 'on disk: ',
526
- diagShellRestartAtStartup: 'shell changed — DSH needs one restart',
527
- webAccessOn: 'Public web management is open until',
528
- webAccessOff: 'Public web management is closed',
529
- webAccessOpen: 'Open web management for 2 hours',
530
- webAccessClose: 'Close web management now',
531
- webAccessHint: 'Closed by default; opening lasts 2 hours and visits do not extend it. When closed, public access is blocked while this machine and its LAN can still access the page. Mobile app access is unaffected. Only this local DSH settings page can toggle it.',
532
- webAccessUnavailable: 'Reading local access status…',
533
- webAccessError: 'Failed to update web access status',
534
+ footerOpen: 'Open the mobile plugin configuration page',
535
+ footerPending: 'The page address is not ready yet',
536
+ 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?',
537
+ footerLoadMissing: 'The plugin host half is not loaded: quit and reopen DSH; if that does not help, reinstall the plugin from the market.',
538
+ diagTitleLoad: 'Cannot open the mobile configuration page',
539
+ diagTitleOpen: 'Could not open web management',
540
+ diagChecking: 'Reading host status…',
541
+ diagBusy: 'Reading host status again…',
542
+ diagHeadingFacts: 'Diagnostics (copy and send these to the maintainer)',
543
+ diagHeadingSteps: 'Things you can check yourself',
544
+ diagClose: 'Close',
545
+ diagRetry: 'Read status again',
546
+ diagReload: 'Reload the plugin',
547
+ diagReloadBusy: 'Asking the host to reload…',
548
+ diagReloadOk: 'Reloaded — the control plane is back.',
549
+ diagReloadFailed: 'Reload failed: ',
550
+ diagCopy: 'Copy diagnostics',
551
+ diagCopied: 'Diagnostics copied — you can send them to the maintainer.',
552
+ diagCopyFailed: 'Copy failed: select the readings above and copy them manually.',
553
+ diagOpenPanel: 'Open the configuration page',
554
+ diagSummaryLoadMissing: 'The plugin host half is not loaded: the DSH shell runs, the plugin itself does not (usually because DSH was not restarted after installing).',
555
+ diagSummaryUnreachable: 'The host did not answer the status query; this plugin build may not ship the self-check route yet.',
556
+ diagSummaryNoError: 'The host self-check reports no error, yet the control-plane routes are missing: most likely two copies of the plugin in one DSH.',
557
+ diagSummaryLoadFailed: 'Reading the host status failed.',
558
+ diagSummaryOpenFailed: 'The two-hour web management window could not be opened, so nothing was opened.',
559
+ diagPopupBlocked: 'Web management is open, but the browser blocked the new window. Allow pop-ups for this site, then press the icon again.',
560
+ diagSummaryRestored: 'Status is readable again — try the icon once more.',
561
+ diagStepRestart: 'Quit DSH completely (not just the window) and open it again — not restarting after installing is the most common cause.',
562
+ diagStepReinstall: 'If that does not help, reinstall the plugin from the market, then restart DSH completely.',
563
+ diagStepRetrying: 'The host is still retrying on its own; wait a few minutes and press the icon again — no need to reinstall.',
564
+ diagStepDuplicate: 'Make sure only one copy is installed (market copy plus a local directory copy): the page connects to the one that failed to load.',
565
+ diagStepProxy: 'If DSH sits under a reverse-proxy subpath (e.g. /dsh/), make sure the proxy keeps Host, Origin and X-Forwarded-Proto.',
566
+ diagStepRetryOpen: 'Press "Read status again" and retry; a DSH that just restarted often needs a few seconds.',
567
+ diagFactReturned: 'Host returned',
568
+ diagFactStatus: 'HTTP status',
569
+ diagFactSelfcheck: 'Host self-check',
570
+ diagFactUnavailable: 'no answer (this build may not ship it)',
571
+ diagFactPlugin: 'Plugin version',
572
+ diagFactSource: 'Source',
573
+ diagFactActivated: 'Activated at',
574
+ diagFactRetry: 'Background retries',
575
+ diagFactOnDisk: 'On disk',
576
+ diagFactShell: 'Shell changed',
577
+ diagFactReason: 'Host-side reason',
578
+ diagFactPanelUrl: 'Panel URL',
579
+ diagFactScope: 'Address class',
580
+ diagFactWindow: 'Two-hour window',
534
581
  },
535
582
  };
536
583
 
537
- /* ── 设置项组件 ───────────────────────────────────────────────────────
538
- * 保持一个薄卡片:一个网页版入口,以及一个可信本机开关,控制网页管理是否短时向公网开放。
539
- * 界面本体仍在宿主吐出的 HTML(源码 `React/`,产物 `lib/panel/index.html`)。
584
+ /* ── 诊断弹窗 ──────────────────────────────────────────────────────────
585
+ * 点这颗图标打不开配置页、或者开窗口失败时,不能只让用户「点了没反应」。
540
586
  *
541
- * 本机设置项半边来自渲染器的 `node_modules`,不可热替换;插件本体由外壳自己装新版
542
- * (`lib/update-install.cjs`),刷新页面才看得到这一半的变化。
543
- * 开关只影响控制面网页路由,不改移动 API、配对凭据或隧道状态。
587
+ * 这一屏刻意做成**能自己动手的自查**,而不是一句报错:
588
+ * - 先把「谁说了什么」摆出来(宿主返回的原文 + 外壳自检那份快照);
589
+ * - 再给一串用户自己就能做的检查与修复(重启、重装、确认没装两份、反代前缀);
590
+ * - 能一键解决的直接给按钮(重新装载实现、重读状态、地址就绪时打开面板);
591
+ * - 最后给「复制诊断信息」:一段 ASCII 记号,贴给维护者就能对着日志查,
592
+ * 不必让人先截图、再问、再等回复。
593
+ *
594
+ * 纯 DOM:客户端半边不经构建,用不了 react 的 portal。所有文字一律走 `textContent`
595
+ * —— 宿主原话里可能有尖括号,拼 HTML 就是把别人的报错当代码执行。
544
596
  */
545
- function MobileSettingsSection({ t }) {
546
- /** null = 还没问到;'' = 问到了但这个宿主没给地址(老版本或还没起来)。 */
547
- const [panelUrl, setPanelUrl] = useState(null);
548
- const [error, setError] = useState(null);
549
- const [webAccess, setWebAccess] = useState(null);
550
- const [webAccessError, setWebAccessError] = useState(null);
551
- const [webAccessBusy, setWebAccessBusy] = useState(false);
552
- /** 最近一次失败的 HTTP 状态码(0 = 网络异常 / 还没失败过)。404 单独说。 */
553
- const [errorStatus, setErrorStatus] = useState(0);
554
- /** 宿主半边自检的结果(外壳给的那份),404 时才有内容。 */
555
- const [half, setHalf] = useState(null);
556
- const [halfBusy, setHalfBusy] = useState(false);
557
- const [halfError, setHalfError] = useState(null);
558
597
 
559
- useEffect(() => {
560
- let alive = true;
561
- let retryTimer = null;
562
- let refreshTimer = null;
563
- const clearTimer = (timer) => {
564
- if (timer !== null) clearInterval(timer);
565
- };
566
- const load = async () => {
567
- try {
568
- const next = await call('/status');
569
- if (!alive) return;
570
- setError(null);
571
- setErrorStatus(0);
572
- setHalf(null);
573
- setHalfError(null);
574
- setPanelUrl(typeof next.panelUrl === 'string' ? next.panelUrl : '');
575
- setWebAccess(next.webAccess && typeof next.webAccess.enabled === 'boolean' ? next.webAccess : null);
576
- if (retryTimer !== null) {
577
- clearTimer(retryTimer);
578
- retryTimer = null;
579
- }
580
- if (refreshTimer === null) {
581
- refreshTimer = setInterval(() => {
582
- if (typeof document !== 'undefined' && document.hidden) return;
583
- void load();
584
- }, 15000);
585
- }
586
- } catch (problem) {
587
- if (!alive) return;
588
- setError(String((problem && problem.message) || problem));
589
- setErrorStatus(Number(problem && problem.status) || 0);
590
- setWebAccess(null);
591
- if (refreshTimer !== null) {
592
- clearTimer(refreshTimer);
593
- refreshTimer = null;
594
- }
595
- // 404 = 实现那一半没装载。它为什么没装载,只有外壳答得出来(这两条路由挂在
596
- // 外壳上,实现挂了它们还在);老版本宿主没有这条口,问不到也得把这个事实留住
597
- // (`unavailable`)——「自检口都没有回应」和「自检说没事」是两种不同的现场,
598
- // 都缩成一句「重启/重装」的话,用户只能对着空白猜(2026-11-01 现场)。
599
- if (problem?.status === 404) {
600
- try {
601
- const picture = await call(HOST_HALF_ENDPOINT);
602
- if (!alive) return;
603
- setHalf(picture && typeof picture.half === 'object' ? picture.half : { unavailable: true });
604
- } catch {
605
- if (!alive) return;
606
- setHalf({ unavailable: true });
607
- }
608
- } else {
609
- setHalf(null);
610
- }
611
- // 无权的远程设置页不轮询;宿主还没启动等临时故障每 3s 重试。
612
- if (problem?.status !== 403 && problem?.status !== 423 && retryTimer === null) {
613
- retryTimer = setInterval(() => {
614
- if (typeof document !== 'undefined' && document.hidden) return;
615
- void load();
616
- }, 3000);
617
- }
618
- }
619
- };
620
- void load();
621
- return () => {
622
- alive = false;
623
- clearTimer(retryTimer);
624
- clearTimer(refreshTimer);
625
- };
626
- }, []);
598
+ /** 诊断弹窗的记号:同一页面只留一份。 */
599
+ const DIAG_MARK = 'data-dshm-mobile-diag';
627
600
 
628
- const toggleWebAccess = async () => {
629
- if (!webAccess || webAccessBusy) return;
630
- setWebAccessBusy(true);
631
- setWebAccessError(null);
632
- try {
633
- const next = await postJson('/web-access', { enabled: !webAccess.enabled });
634
- setWebAccess(next.webAccess && typeof next.webAccess.enabled === 'boolean' ? next.webAccess : null);
635
- } catch (problem) {
636
- setWebAccessError(String((problem && problem.message) || problem));
637
- } finally {
638
- setWebAccessBusy(false);
601
+ /**
602
+ * 造一个可以反复更新的诊断弹窗(`open(model)` 打开或就地重画,`hide()` 收起)。
603
+ *
604
+ * 内容由调用方按这一轮的现场拼好交进来;这里只管画与收,不做任何请求。
605
+ */
606
+ function createDiagnosisDialog(doc) {
607
+ let overlay = null;
608
+ let escapeListener = null;
609
+ const hide = () => {
610
+ if (overlay && typeof overlay.remove === 'function' && overlay.parentElement) overlay.remove();
611
+ overlay = null;
612
+ if (escapeListener && typeof doc.removeEventListener === 'function') {
613
+ doc.removeEventListener('keydown', escapeListener);
614
+ }
615
+ escapeListener = null;
616
+ };
617
+ const make = (tag, className, text) => {
618
+ const element = doc.createElement(tag);
619
+ if (className) element.className = className;
620
+ if (text !== undefined) element.textContent = String(text);
621
+ return element;
622
+ };
623
+ const open = (model) => {
624
+ if (!doc || typeof doc.createElement !== 'function' || !doc.body) return null;
625
+ const spec = model && typeof model === 'object' ? model : {};
626
+ if (!overlay) {
627
+ overlay = make('div', 'dshm_overlay');
628
+ overlay.setAttribute(DIAG_MARK, '');
629
+ overlay.setAttribute('role', 'dialog');
630
+ overlay.setAttribute('aria-modal', 'true');
631
+ // 点遮罩收起;卡片内部的点击不冒到遮罩上(免得点按钮被当成「点外面」)。
632
+ overlay.addEventListener('click', (event) => {
633
+ if (event && event.target === overlay) hide();
634
+ });
635
+ doc.body.appendChild(overlay);
636
+ if (typeof doc.addEventListener === 'function') {
637
+ escapeListener = (event) => {
638
+ if (event && (event.key === 'Escape' || event.key === 'Esc')) hide();
639
+ };
640
+ doc.addEventListener('keydown', escapeListener);
641
+ }
642
+ }
643
+ if (spec.title) overlay.setAttribute('aria-label', spec.title);
644
+ // 每次重画:读数与按钮都是这一轮的现场,别留上一轮的字。
645
+ for (const child of overlay.children.slice()) child.remove();
646
+ const card = make('div', 'dshm_dialog');
647
+ card.addEventListener('click', (event) => {
648
+ if (event && typeof event.stopPropagation === 'function') event.stopPropagation();
649
+ });
650
+ overlay.appendChild(card);
651
+ if (spec.title) card.appendChild(make('h3', 'dshm_dialogTitle', spec.title));
652
+ if (spec.summary) card.appendChild(make('p', 'dshm_dialogLead', spec.summary));
653
+ if (spec.facts && spec.facts.length) {
654
+ if (spec.factsHeading) card.appendChild(make('h4', 'dshm_dialogHeading', spec.factsHeading));
655
+ const list = make('dl', 'dshm_facts');
656
+ for (const fact of spec.facts) {
657
+ const row = make('div', 'dshm_fact');
658
+ row.appendChild(make('dt', 'dshm_factKey', fact.label));
659
+ row.appendChild(make('dd', fact.mono === false ? 'dshm_factValue' : 'dshm_factValue dshm_factMono', fact.value));
660
+ list.appendChild(row);
661
+ }
662
+ card.appendChild(list);
663
+ }
664
+ if (spec.steps && spec.steps.length) {
665
+ if (spec.stepsHeading) card.appendChild(make('h4', 'dshm_dialogHeading', spec.stepsHeading));
666
+ const steps = make('ol', 'dshm_steps');
667
+ for (const step of spec.steps) steps.appendChild(make('li', null, step));
668
+ card.appendChild(steps);
669
+ }
670
+ if (spec.note) card.appendChild(make('p', 'dshm_dialogNote', spec.note));
671
+ const actions = make('div', 'dshm_dialogActions');
672
+ for (const action of spec.actions || []) {
673
+ const button = make('button', action.primary ? 'dshm_btn dshm_btnPrimary' : 'dshm_btn', action.label);
674
+ button.setAttribute('type', 'button');
675
+ if (action.disabled) button.disabled = true;
676
+ button.onclick = () => { void action.onClick(); };
677
+ actions.appendChild(button);
639
678
  }
679
+ card.appendChild(actions);
680
+ return overlay;
640
681
  };
682
+ return { open, hide };
683
+ }
684
+
685
+ /** 复制一段文字:先走剪贴板 API,不行退回 textarea + execCommand。 */
686
+ async function copyDiagnosisText(doc, text) {
687
+ try {
688
+ if (typeof navigator !== 'undefined'
689
+ && navigator.clipboard
690
+ && typeof navigator.clipboard.writeText === 'function') {
691
+ await navigator.clipboard.writeText(text);
692
+ return true;
693
+ }
694
+ } catch {
695
+ // 落到下面那一条:有的环境里剪贴板权限被拒。
696
+ }
697
+ try {
698
+ const area = doc.createElement('textarea');
699
+ area.value = text;
700
+ area.setAttribute('readonly', '');
701
+ doc.body.appendChild(area);
702
+ if (typeof area.select === 'function') area.select();
703
+ const done = typeof doc.execCommand === 'function' ? doc.execCommand('copy') : false;
704
+ if (typeof area.remove === 'function') area.remove();
705
+ return done === true;
706
+ } catch {
707
+ return false;
708
+ }
709
+ }
641
710
 
711
+ /**
712
+ * 当前页面是不是一张**普通网页**(`http(s)`)。
713
+ *
714
+ * 桌面壳把前端挂在 `dsh-app://app/` 下,那一层里 `window.open` 一律被拒 —— 壳只把
715
+ * `http(s)` 地址交给系统浏览器,别的一概静默丢掉。所以「先开一个空窗口、等地址确认
716
+ * 再导航过去」那套在壳里既开不出窗口、还会把整个页面盖成一块黑:那颗图标点下去按
717
+ * 网络类别分流时,得先分清自己在哪种页面里。
718
+ *
719
+ * 不在网页里(自定义协议)就走**导航**:壳的 `will-navigate` 会把外部 http(s) 地址
720
+ * 转给系统浏览器,与点一颗普通链接的结果一样,只是不必先开一个空窗口。
721
+ */
722
+ function isWebPage(win) {
723
+ const protocol = String((win && win.location && win.location.protocol) || '');
724
+ return protocol === 'http:' || protocol === 'https:';
725
+ }
726
+
727
+ /* ── 侧边栏底部那颗手机图标 ────────────────────────────────────────────
728
+ * 用户要的是「点一下等于设置页那颗『打开配置页面』」,并且就在侧边栏最底下、头像
729
+ * 那一行里。外壳那一行没有给插件留座位:那一行里从左到右是设置项自己渲染的一整颗
730
+ * 按钮(弹性铺满)、连接状态提示、桌面更新提示。所以这里是往那一行里插一颗自己的
731
+ * 按钮,而不是换成外壳的某一个座位:
732
+ *
733
+ * - 认位置靠插槽出口的 `data-slot` 标记(外壳给每个出口都留了这个标记,它比哈希
734
+ * 类名稳,也不依赖行里有什么),拿到它的父节点就是整行;
735
+ * - 那一行的按钮是 `flex:1`,插进去之后它自己让出宽度,谁都不会被盖住;
736
+ * - 认不出来(外壳换了版式、设置项还没挂上)就退回 `sidebar.footer.action` 给插件
737
+ * 留的那一行,入口不会消失;
738
+ * - 外壳把这一行整棵重建时(宽窄切换、账户菜单重挂、提示挂上摘下)元素会掉线,
739
+ * 所以挂上之后盯一次 DOM 变更,掉线就补回去。
740
+ *
741
+ * 这一颗是纯 DOM:客户端半边不经构建,用不了 `react-dom` 的 portal。地址、文案、
742
+ * 可用状态都由 effect 直接同步到元素属性上,组件重渲染不会把它丢掉。
743
+ */
744
+ /** 外壳在设置那一行上方留的动作位;没有别的插件占用时是空的。 */
745
+ const FOOTER_SLOT = 'sidebar.footer.action';
746
+ /** 设置那一行里「头像那块」的出口标记。 */
747
+ const FOOTER_ANCHOR = '[data-slot="settings.launcher"]';
748
+ /** 侧栏底部那个座位(头像那一行在它里面)。插之前先确认出口真的在这个座位里。 */
749
+ const FOOTER_SEAT = '[data-slot="sidebar.settings"]';
750
+ /** 自己那颗按钮的记号:同一页面里出现第二份实现时,靠它摘掉上一颗。 */
751
+ const FOOTER_MARK = 'data-dshm-mobile-web';
752
+ /** 还没问到网页地址时的重试间隔。取到就停,不再问。 */
753
+ const FOOTER_URL_RETRY_MS = 20000;
754
+ /**
755
+ * 手机图标(用户 2026-11-02 从八个候选里挑的「刘海 + 底条」那一款):24 画布描边,
756
+ * 机身 6.2→17.8、圆角 2.8,顶上一条刘海、底下一条家条。比早先那版机身更满一点,
757
+ * 因为窄高的形状按名义尺寸画出来会比旁边的方形图标显小。
758
+ */
759
+ const PHONE_PATHS = [
760
+ 'M9 2.6h6A2.8 2.8 0 0 1 17.8 5.4v13.2a2.8 2.8 0 0 1-2.8 2.8H9a2.8 2.8 0 0 1-2.8-2.8V5.4A2.8 2.8 0 0 1 9 2.6Z',
761
+ 'M10.4 4.4h3.2',
762
+ 'M11 19.4h2',
763
+ ];
764
+ /**
765
+ * 图标边长:展开那一行 20,窄条里的圆钮 22(用户 2026-11-02 拍的,先 18 后嫌小又抬了一档)。
766
+ *
767
+ * 那一行的账号按钮高 44、里面真正画出来的只有 24 的头像,同行其它图标是 16。20 是
768
+ * 28 的按钮里还能四边各留 4 像素的上限;窄条那颗放进 36 的圆钮里,用 22。
769
+ * 别再往回调 16 / 18:手机图形窄,按名义尺寸画出来比同行的方形图标显小,用户两次都说小。
770
+ * 真要再大,得连按钮一起撑到 30 以上——那一行其它按钮都是 28,会开始抢。
771
+ */
772
+ const PHONE_GLYPH_WIDE = 20;
773
+ const PHONE_GLYPH_RAIL = 22;
774
+
775
+ /** 造一颗手机图标。文档没有 SVG 能力时返回 null,按钮照样成立(只剩无障碍名字)。 */
776
+ function phoneIcon(doc, size) {
777
+ if (!doc || typeof doc.createElementNS !== 'function') return null;
778
+ const svg = doc.createElementNS('http://www.w3.org/2000/svg', 'svg');
779
+ svg.setAttribute('viewBox', '0 0 24 24');
780
+ svg.setAttribute('width', String(size));
781
+ svg.setAttribute('height', String(size));
782
+ svg.setAttribute('fill', 'none');
783
+ svg.setAttribute('stroke', 'currentColor');
784
+ svg.setAttribute('stroke-width', '1.7');
785
+ svg.setAttribute('stroke-linecap', 'round');
786
+ svg.setAttribute('aria-hidden', 'true');
787
+ svg.setAttribute('focusable', 'false');
788
+ for (const d of PHONE_PATHS) {
789
+ const path = doc.createElementNS('http://www.w3.org/2000/svg', 'path');
790
+ path.setAttribute('d', d);
791
+ svg.appendChild(path);
792
+ }
793
+ return svg;
794
+ }
795
+
796
+ /** 造那一颗按钮:28 点,与同一行里那几颗图标按钮同尺寸。 */
797
+ function createFooterIcon(doc) {
798
+ const anchor = doc.createElement('a');
799
+ anchor.className = 'dshm_footAction';
800
+ anchor.setAttribute(FOOTER_MARK, '');
801
+ anchor.setAttribute('target', '_blank');
802
+ anchor.setAttribute('rel', 'noreferrer');
803
+ const icon = phoneIcon(doc, PHONE_GLYPH_WIDE);
804
+ if (icon) anchor.appendChild(icon);
805
+ return anchor;
806
+ }
807
+
808
+ /**
809
+ * 把「打开哪一页 / 现在能不能点」同步到元素上。
810
+ *
811
+ * 没有地址时**不写 href**:锚点没有 href 就点不动,比挂一个死链诚实;`aria-disabled`
812
+ * 只负责把这一点说出来。
813
+ *
814
+ * 地址、网络类别和窗口状态都由 effect 往上同步,组件重渲染不会把它丢掉。
815
+ */
816
+ function syncFooterIcon(node, url, openLabel, pendingLabel, onClick) {
817
+ const label = url ? openLabel : pendingLabel;
818
+ node.setAttribute('aria-label', label);
819
+ node.setAttribute('title', label);
820
+ node.setAttribute('aria-disabled', url ? 'false' : 'true');
821
+ // 有地址时仍然写 `href`:普通链接照旧能中键 / 新标签打开。只有「浏览器不是从本机来的」
822
+ // 那两种情况要拦下来先开窗口,见 `openPanel`。
823
+ if (url) node.setAttribute('href', url);
824
+ else node.removeAttribute('href');
825
+ node.onclick = url ? onClick : null;
826
+ }
827
+
828
+ /** 把按钮放回设置那一行、紧跟头像出口。返回是否真在那一行里。 */
829
+ function ensureFooterIcon(doc, node) {
830
+ const outlet = typeof doc.querySelector === 'function' ? doc.querySelector(FOOTER_ANCHOR) : null;
831
+ const row = outlet && outlet.parentElement;
832
+ if (!row || typeof row.insertBefore !== 'function') return false;
833
+ // 只认「侧栏底部那个座位里的 launcher」:头像那块万一被外壳渲染到别处(设置面板里、
834
+ // 以后的新位置),按出口插进去就插错地方了。认不出座位就当没认出行,退回自己一行。
835
+ if (typeof outlet.closest === 'function' && outlet.closest(FOOTER_SEAT) === null) return false;
836
+ if (node.parentElement === row && node.previousSibling === outlet) return true;
837
+ row.insertBefore(node, outlet.nextSibling);
838
+ return true;
839
+ }
840
+
841
+ /** 摘掉上一次物化留下的那一颗(插件重装、同一页面里出现第二份实现)。 */
842
+ function dropStaleFooterIcons(doc) {
843
+ if (!doc || typeof doc.querySelectorAll !== 'function') return;
844
+ let stale = null;
845
+ try {
846
+ stale = doc.querySelectorAll(`[${FOOTER_MARK}]`);
847
+ } catch {
848
+ return;
849
+ }
850
+ for (const node of stale || []) {
851
+ if (node && typeof node.remove === 'function') node.remove();
852
+ }
853
+ }
854
+
855
+ /**
856
+ * 侧边栏底部的那颗手机图标(进网页的唯一入口)。
857
+ *
858
+ * 展开态:插进设置那一行(那一行里插得进、也不遮东西),自己不出现在
859
+ * `sidebar.footer.action` 那一行里。
860
+ * 收窄成 56 点的窄条时:那一行只有 36 点、已经被设置那颗占满,插不进第二颗,于是
861
+ * 就画在 `sidebar.footer.action` 自己那一行(居中摞在设置上方)——这一条是物理限制,
862
+ * 不是退路。认不出那一行的时候也走这条路,入口不会消失。
863
+ *
864
+ * 点下去做什么(2026-11-02 用户拍板):宿主在 `/status` 上按请求 Host 报一个
865
+ * `panelScope`,图标据此分流:
866
+ * - `loopback`:人就在这台机器的 DSH 里,控制面本来就认它 —— 普通链接跳转;
867
+ * - `private` / `public`:浏览器是从别的机器来的,控制面不认它、面板会回 423,
868
+ * 所以先把那两小时窗口打开再跳;`public` 多弹一句确认(那个地址谁都能拨)。
869
+ *
870
+ * **打不开的时候不许「点了没反应」**:一律弹诊断窗——宿主与外壳各说了什么、用户自己
871
+ * 能做的几步检查、能一键试的修复(重新装载 / 重读状态),以及一段可复制的读数。
872
+ */
873
+ function MobileWebEntry({ wide, t }) {
874
+ /** null = 还没问到;'' = 问到了但宿主没给地址(老版本或还没起来)。 */
875
+ const [url, setUrl] = useState(null);
876
+ const [webAccess, setWebAccess] = useState(null);
877
+ /** 面板地址落在哪类网络上:`loopback` / `private` / `public`(宿主算好给的)。 */
878
+ const [panelScope, setPanelScope] = useState('');
879
+ /** 最近一次读状态失败的原因(`null` = 没失败过);`status: 404` = 实现那一半没装载。 */
880
+ const [problem, setProblem] = useState(null);
881
+ /** 展开态是否已经插进设置那一行。插不上就自己占一行。 */
882
+ const [anchored, setAnchored] = useState(true);
883
+ const nodeRef = useRef(null);
884
+ const dialogRef = useRef(null);
642
885
  /**
643
- * 让外壳当场把实现再装一次。
886
+ * 文案在这里取、并把结果塞进下面那些 effect 的依赖里。
644
887
  *
645
- * 装载失败那阵子,这是唯一不必重启整个 DSH 的出路;成不成以宿主回来的那份自检为准,
646
- * 失败原因原样贴出来(它往往就是「等哪个服务超时」,过一会儿自己就好了)。
888
+ * 不能只依赖 `t` 本身:它是 `ctx.locale.bind(NS)` 绑出来的一个稳定函数,切语言时
889
+ * 引用不变,effect 就不会重跑,插进那一行的那颗按钮会一直挂着旧的标题。
647
890
  */
648
- const reloadHalf = async () => {
649
- if (halfBusy) return;
650
- setHalfBusy(true);
651
- setHalfError(null);
891
+ const openLabel = t('footerOpen');
892
+ const pendingLabel = t('footerPending');
893
+ const loadMissingLabel = t('footerLoadMissing');
894
+ const publicConfirm = t('footerPublicConfirm');
895
+
896
+ /** 诊断窗单例:同一页面只留一个,反复打开就地重画。 */
897
+ function diagnosisDialog() {
898
+ if (!dialogRef.current) {
899
+ dialogRef.current = createDiagnosisDialog(typeof document !== 'undefined' ? document : null);
900
+ }
901
+ return dialogRef.current;
902
+ }
903
+
904
+ /**
905
+ * 拉一次宿主状态,把地址 / 分类 / 窗口 / 失败原因同步进 state。
906
+ *
907
+ * 返回值给调用方用(诊断窗要拿最新那一份读数,不能读组件里那份可能还没更新的状态):
908
+ * `{ ok: true, status }` 或 `{ ok: false, problem }`。
909
+ */
910
+ async function refreshStatus() {
652
911
  try {
653
- const next = await postJson(HOST_HALF_RELOAD_ENDPOINT, {});
654
- if (next && typeof next.half === 'object') setHalf(next.half);
655
- if (next && next.ok === false) setHalfError(errorText(next, 200));
656
- } catch (problem) {
657
- setHalfError(String((problem && problem.message) || problem));
658
- } finally {
659
- setHalfBusy(false);
912
+ const next = await call('/status');
913
+ const view = next && typeof next === 'object' ? next : {};
914
+ setUrl(typeof view.panelUrl === 'string' ? view.panelUrl : '');
915
+ setWebAccess(view.webAccess && typeof view.webAccess.enabled === 'boolean' ? view.webAccess : null);
916
+ setPanelScope(typeof view.panelScope === 'string' ? view.panelScope : '');
917
+ setProblem(null);
918
+ return { ok: true, status: view };
919
+ } catch (error) {
920
+ const failed = {
921
+ status: Number(error && error.status) || 0,
922
+ message: String((error && error.message) || error),
923
+ };
924
+ setProblem(failed);
925
+ return { ok: false, problem: failed };
660
926
  }
661
- };
927
+ }
662
928
 
663
- const url = typeof panelUrl === 'string' ? panelUrl : '';
664
- const webAccessEnabled = Boolean(webAccess && webAccess.enabled);
665
- const expiresAt = webAccess && Number.isFinite(webAccess.expiresAt)
666
- ? new Date(webAccess.expiresAt).toLocaleTimeString()
667
- : '';
668
- let webAccessStatus = t('webAccessUnavailable');
669
- if (webAccess) webAccessStatus = webAccessEnabled ? `${t('webAccessOn')} ${expiresAt}` : t('webAccessOff');
929
+ /**
930
+ * 点下去要做什么,取决于面板地址落在哪一类网络上(宿主在 `/status` 上算的 `panelScope`)。
931
+ *
932
+ * 新窗口必须在**点击这一帧**就开出来(`window.open('', '_blank')`):等接口回来再开会被
933
+ * 浏览器的弹窗拦截器吃掉。地址确认到之后才把那个空窗口导航过去,失败就关掉它并弹诊断。
934
+ * 只有**网页**(`http(s)`)才需要这一手:桌面壳里那个函数一定被拒,见 `isWebPage`。
935
+ */
936
+ function openPanel(event) {
937
+ if (!url) {
938
+ // 连地址都没问到:点了必须有反应 —— 弹诊断窗,而不是一颗点不动的灰图标。
939
+ void openDiagnosis('load', problem);
940
+ return;
941
+ }
942
+ const needsWindow = panelScope !== 'loopback' && !(webAccess && webAccess.enabled);
943
+ if (!needsWindow) return; // 普通链接,交给浏览器自己开
944
+ if (event && typeof event.preventDefault === 'function') event.preventDefault();
945
+ if (panelScope === 'public'
946
+ && typeof window !== 'undefined'
947
+ && typeof window.confirm === 'function'
948
+ && !window.confirm(publicConfirm)) return;
949
+ const target = isWebPage(window) && typeof window.open === 'function'
950
+ ? window.open('', '_blank')
951
+ : null;
952
+ void openWithWindow(target);
953
+ }
670
954
 
671
- const missing = errorStatus === 404;
955
+ /**
956
+ * 先把窗口开起来、再跳过去;失败就把那个空白页关掉并弹诊断窗。
957
+ *
958
+ * 还有一种「点了没反应」:浏览器把新窗口拦了(`window.open` 返回 null)。那时接口
959
+ * 已经成功、地址也在手上,只是没地方给它 —— 直接告诉用户该允许弹窗,别让他以为坏了。
960
+ *
961
+ * 壳里的页面(`dsh-app://`)不适用这一套:那里本来就没有「新窗口」这回事,`null` 是
962
+ * 正常答复、地址已经由壳交给系统浏览器了。当成被拦只会平白盖一屏诊断。
963
+ */
964
+ async function openWithWindow(target) {
965
+ try {
966
+ const next = await postJson('/web-access', { enabled: true });
967
+ setWebAccess(next && next.webAccess && typeof next.webAccess.enabled === 'boolean'
968
+ ? next.webAccess
969
+ : { enabled: true, expiresAt: 0 });
970
+ if (target) {
971
+ target.location.href = url;
972
+ return;
973
+ }
974
+ if (!isWebPage(window)) {
975
+ // 交回给页面导航:壳会把外部 http(s) 地址转给系统浏览器。
976
+ window.location.href = url;
977
+ return;
978
+ }
979
+ const retry = typeof window !== 'undefined' && typeof window.open === 'function'
980
+ ? window.open(url, '_blank', 'noreferrer')
981
+ : null;
982
+ if (!retry) {
983
+ void openDiagnosis('open', { status: 0, message: t('diagPopupBlocked') });
984
+ }
985
+ } catch (error) {
986
+ if (target && typeof target.close === 'function') target.close();
987
+ void openDiagnosis('open', {
988
+ status: Number(error && error.status) || 0,
989
+ message: String((error && error.message) || error),
990
+ });
991
+ }
992
+ }
672
993
 
673
994
  /**
674
- * 诊断读数:宿主回了什么 + 外壳那份自检说了什么。
995
+ * 把这一轮的现场摆成一屏**能自己动手**的自查。
675
996
  *
676
- * 只拼进这一屏、不落盘也不上报:它的用途是让人截图发给我们(2026-11-01 用户原话:
677
- * 「以后用户发过来 我也不好去排查」)。字段名用 `version=` / `source=` 这种记号而不是
678
- * 翻成中文,是因为它要能贴进日志里对得上宿主那一侧。
997
+ * `kind` 两种:`'load'`(连状态都读不到,所以没有地址可去)、`'open'`(两小时窗口没
998
+ * 开成,没跳过去)。前者的首要原因是「装完没重启」,后者先重试再看反代。
999
+ *
1000
+ * 这一屏的三个层次,顺序就是用户该想的顺序:
1001
+ * 1. 谁说了什么(宿主返回的原文 + 外壳自检那份快照,含版本/来源/后台重试/要不要重启);
1002
+ * 2. 用户自己就能做的检查与修复(重启、重装、确认没装两份、反代前缀);
1003
+ * 3. 能一键试的(重新装载实现、重读状态、地址就绪时直接打开面板)与「复制诊断信息」。
679
1004
  */
680
- const diagLines = [];
681
- if (missing) {
682
- diagLines.push(`${t('diagHostReturned')}${error}`);
683
- if (half && half.unavailable === true) {
684
- diagLines.push(`${t('diagSelfCheck')}${t('diagSelfCheckNoAnswer')}`);
685
- } else if (half) {
686
- const bits = [];
687
- if (half.version) bits.push(`version=${half.version}`);
688
- if (half.source) bits.push(`source=${half.source}`);
689
- if (half.activatedAt) bits.push(`at=${half.activatedAt}`);
690
- if (half.retrying) {
691
- const attempts = Number.isFinite(half.retryAttempts) ? half.retryAttempts : 0;
692
- bits.push(`${t('diagSelfCheckRetry')}${attempts}${half.nextRetryAt ? `→${half.nextRetryAt}` : ''}`);
1005
+ async function openDiagnosis(kind, failure) {
1006
+ if (typeof document === 'undefined') return;
1007
+ const view = diagnosisDialog();
1008
+ /** 这一屏用的现场:重读之后就地更新(组件状态要等下一次渲染,这里不能等它)。 */
1009
+ const snapshot = {
1010
+ url: typeof url === 'string' ? url : '',
1011
+ scope: panelScope || 'unknown',
1012
+ access: webAccess,
1013
+ };
1014
+ let current = failure || problem || null;
1015
+ let half = null;
1016
+
1017
+ const title = kind === 'open' ? t('diagTitleOpen') : t('diagTitleLoad');
1018
+ const closeAction = { label: t('diagClose'), onClick: () => view.hide() };
1019
+ const adopt = (status) => {
1020
+ const next = status && typeof status === 'object' ? status : {};
1021
+ snapshot.url = typeof next.panelUrl === 'string' ? next.panelUrl : '';
1022
+ snapshot.scope = typeof next.panelScope === 'string' ? next.panelScope : 'unknown';
1023
+ snapshot.access = next.webAccess && typeof next.webAccess.enabled === 'boolean' ? next.webAccess : null;
1024
+ };
1025
+
1026
+ /** 问外壳那份自检(实现挂了它还在):原因、版本、后台重试都在这儿。 */
1027
+ const readHalf = async () => {
1028
+ try {
1029
+ const picture = await call(HOST_HALF_ENDPOINT);
1030
+ half = picture && typeof picture.half === 'object' ? picture.half : { unavailable: true };
1031
+ } catch {
1032
+ half = { unavailable: true };
1033
+ }
1034
+ return half;
1035
+ };
1036
+
1037
+ const facts = () => {
1038
+ const rows = [];
1039
+ if (current) {
1040
+ rows.push({ key: 'host_returned', label: t('diagFactReturned'), value: current.message });
1041
+ rows.push({ key: 'http_status', label: t('diagFactStatus'), value: current.status ? String(current.status) : '—' });
1042
+ }
1043
+ if (half && half.unavailable === true) {
1044
+ rows.push({ key: 'self_check', label: t('diagFactSelfcheck'), value: t('diagFactUnavailable') });
1045
+ } else if (half) {
1046
+ rows.push({ key: 'plugin_version', label: t('diagFactPlugin'), value: half.version ? String(half.version) : '—' });
1047
+ if (half.source) rows.push({ key: 'plugin_source', label: t('diagFactSource'), value: half.source });
1048
+ if (half.activatedAt) rows.push({ key: 'activated_at', label: t('diagFactActivated'), value: half.activatedAt });
1049
+ if (half.retrying) {
1050
+ const attempts = Number.isFinite(half.retryAttempts) ? half.retryAttempts : 0;
1051
+ rows.push({
1052
+ key: 'retrying',
1053
+ label: t('diagFactRetry'),
1054
+ value: `${attempts}${half.nextRetryAt ? ` → ${half.nextRetryAt}` : ''}`,
1055
+ });
1056
+ }
1057
+ if (half.installedVersion && half.installedVersion !== half.version) {
1058
+ rows.push({ key: 'on_disk', label: t('diagFactOnDisk'), value: half.installedVersion });
1059
+ }
1060
+ if (half.shellChanged) rows.push({ key: 'shell_changed', label: t('diagFactShell'), value: 'true' });
1061
+ if (half.lastError) rows.push({ key: 'host_last_error', label: t('diagFactReason'), value: half.lastError });
693
1062
  }
694
- if (half.installedVersion && half.installedVersion !== half.version) {
695
- bits.push(`${t('diagOnDisk')}${half.installedVersion}`);
1063
+ rows.push({ key: 'panel_url', label: t('diagFactPanelUrl'), value: snapshot.url || '—' });
1064
+ rows.push({ key: 'panel_scope', label: t('diagFactScope'), value: snapshot.scope });
1065
+ rows.push({
1066
+ key: 'web_access',
1067
+ label: t('diagFactWindow'),
1068
+ value: snapshot.access && snapshot.access.enabled ? `open → ${new Date(snapshot.access.expiresAt).toISOString()}` : 'closed',
1069
+ });
1070
+ return rows;
1071
+ };
1072
+
1073
+ const summaryOf = () => {
1074
+ if (!current) return t('diagSummaryRestored');
1075
+ if (kind === 'open') return t('diagSummaryOpenFailed');
1076
+ if (current.status === 404) {
1077
+ if (half && half.unavailable === true) return t('diagSummaryUnreachable');
1078
+ if (half && !half.lastError) return t('diagSummaryNoError');
1079
+ return t('diagSummaryLoadMissing');
696
1080
  }
697
- if (half.shellChanged) bits.push(t('diagShellRestartAtStartup'));
698
- diagLines.push(`${t('diagSelfCheck')}${bits.length ? bits.join(' · ') : t('diagSelfCheckNoError')}`);
1081
+ return t('diagSummaryLoadFailed');
1082
+ };
1083
+
1084
+ const stepsOf = () => {
1085
+ if (kind === 'open') return [t('diagStepRetryOpen'), t('diagStepProxy')];
1086
+ const steps = [t('diagStepRestart'), t('diagStepReinstall')];
1087
+ if (half && half.retrying) steps.push(t('diagStepRetrying'));
1088
+ steps.push(t('diagStepDuplicate'), t('diagStepProxy'));
1089
+ return steps;
1090
+ };
1091
+
1092
+ const copyTextOf = () => {
1093
+ const lines = ['dsh-mobile-plugin diagnosis'];
1094
+ for (const row of facts()) lines.push(`${row.key}=${row.value}`);
1095
+ if (typeof navigator !== 'undefined' && navigator.userAgent) lines.push(`user_agent=${navigator.userAgent}`);
1096
+ return lines.join('\n');
1097
+ };
1098
+
1099
+ const render = (note) => {
1100
+ const actions = [];
1101
+ if (snapshot.url) {
1102
+ actions.push({
1103
+ label: t('diagOpenPanel'),
1104
+ primary: true,
1105
+ onClick: () => {
1106
+ if (typeof window !== 'undefined' && typeof window.open === 'function') {
1107
+ window.open(snapshot.url, '_blank', 'noreferrer');
1108
+ }
1109
+ view.hide();
1110
+ },
1111
+ });
1112
+ }
1113
+ if (kind === 'load' && (!current || current.status === 404)) {
1114
+ actions.push({ label: t('diagReload'), onClick: () => void reload() });
1115
+ }
1116
+ actions.push({ label: t('diagRetry'), onClick: () => void retry() });
1117
+ actions.push({ label: t('diagCopy'), onClick: () => void copy() });
1118
+ actions.push(closeAction);
1119
+ view.open({
1120
+ title,
1121
+ summary: summaryOf(),
1122
+ factsHeading: t('diagHeadingFacts'),
1123
+ facts: facts(),
1124
+ stepsHeading: t('diagHeadingSteps'),
1125
+ steps: stepsOf(),
1126
+ note,
1127
+ actions,
1128
+ });
1129
+ };
1130
+
1131
+ async function copy() {
1132
+ const ok = await copyDiagnosisText(document, copyTextOf());
1133
+ render(ok ? t('diagCopied') : t('diagCopyFailed'));
1134
+ }
1135
+
1136
+ /** 重读一遍状态;读到就把这一屏的现场换成新的(心跳式的即时自愈)。 */
1137
+ async function retry() {
1138
+ render(t('diagBusy'));
1139
+ const outcome = await refreshStatus();
1140
+ if (outcome.ok) {
1141
+ current = null;
1142
+ adopt(outcome.status);
1143
+ } else {
1144
+ current = outcome.problem;
1145
+ }
1146
+ await readHalf();
1147
+ render(null);
1148
+ }
1149
+
1150
+ /** 让外壳当场把实现再装一次:装载失败时唯一不必重启整个 DSH 的出路。 */
1151
+ async function reload() {
1152
+ render(t('diagReloadBusy'));
1153
+ let note = null;
1154
+ try {
1155
+ const answer = await postJson(HOST_HALF_RELOAD_ENDPOINT, {});
1156
+ if (answer && answer.ok === false) note = `${t('diagReloadFailed')}${errorText(answer, 200)}`;
1157
+ } catch (error) {
1158
+ note = `${t('diagReloadFailed')}${String((error && error.message) || error)}`;
1159
+ }
1160
+ await readHalf();
1161
+ // 成没成以重读状态为准:外壳回了一声 ok 不等于控制面路由这就回来了。
1162
+ const outcome = await refreshStatus();
1163
+ if (outcome.ok) {
1164
+ current = null;
1165
+ adopt(outcome.status);
1166
+ if (note === null) note = t('diagReloadOk');
1167
+ } else {
1168
+ current = outcome.problem;
1169
+ if (note === null) note = `${t('diagReloadFailed')}${current.message}`;
1170
+ }
1171
+ render(note);
699
1172
  }
1173
+
1174
+ view.open({ title, summary: t('diagChecking'), actions: [closeAction] });
1175
+ await readHalf();
1176
+ render(null);
700
1177
  }
701
1178
 
702
- return h('div', { className: 'dshm_root' }, [
703
- h('h2', { className: 'dshm_heading' }, t('heading')),
704
- error
705
- ? h('p', { className: 'dshm_error' }, missing ? t('loadMissing') : `${t('loadFailed')}: ${error}`)
706
- : null,
707
- // 404 那屏必须自带「能拿去排查」的读数(2026-11-01 用户要求):只写一句「宿主侧没起来」
708
- // 的话,用户截图发过来我们还是不知道坏在哪一层,只能让他一次次试。
709
- // 两行足够定位:宿主到底回了什么、外壳那份自检说了什么。
710
- ...diagLines.map((line) => h('p', { className: 'dshm_note', key: line.slice(0, 24) }, line)),
711
- // 404 时把宿主那半边自己的说法贴出来:重启 / 重装这两句提示在「装载超时」那种
712
- // 现场是帮倒忙的,真正该做的是等它后台补齐、或者点一下重新装载。
713
- missing && half && typeof half.lastError === 'string' && half.lastError
714
- ? h('p', { className: 'dshm_note' }, `${t('hostHalfReason')}${half.lastError}`)
715
- : null,
716
- // 问不到自检口:说明这一版插件压根没带它(比 0.5.11 老),用户这时最该做的是更新
717
- // 插件,而不是继续重启 —— 原先这一支是一片空白,只留一句「重启/重装」。
718
- missing && half && half.unavailable === true
719
- ? h('p', { className: 'dshm_note' }, t('hostHalfUnreachable'))
720
- : null,
721
- // 自检说没事、路由却不在:这是「同一份 DSH 里跑着两份插件」那种现场,报出版本
722
- // 才查得下去。
723
- missing && half && half.unavailable !== true && !(typeof half.lastError === 'string' && half.lastError)
724
- ? h('p', { className: 'dshm_note' }, `${t('hostHalfNoError')}${typeof half.version === 'string' && half.version ? `(${t('hostHalfVersion')}${half.version})` : ''}`)
725
- : null,
726
- missing && half && half.retrying === true
727
- ? h('p', { className: 'dshm_note' }, t('hostHalfRetrying'))
728
- : null,
729
- missing
730
- ? h('div', { className: 'dshm_actions' }, [
731
- h('button', {
732
- type: 'button',
733
- className: 'dshm_btn',
734
- disabled: halfBusy,
735
- onClick: reloadHalf,
736
- }, t('hostHalfReload')),
737
- ])
738
- : null,
739
- halfError ? h('p', { className: 'dshm_error' }, `${t('hostHalfReloadFailed')}${halfError}`) : null,
740
-
741
- // 用 `<a target="_blank">` 而不是 iframe:DSH 桌面端会把这个链接交给系统浏览器
742
- // 打开(网页版有自己的地址与刷新节奏,嵌在设置弹窗里反而不好用)。
743
- // href 必须是宿主给的绝对地址:相对地址在桌面端会被解析成 `dsh-app://`,
744
- // 而 Electron 只把 http(s) 交给浏览器,其它协议静默丢弃、点了没反应。
745
- h('section', { className: 'dshm_card', key: 'panel' }, [
746
- h('h3', { className: 'dshm_cardTitle' }, t('cardPanel')),
747
- h('div', { className: 'dshm_actions' }, [
748
- url
749
- ? h('a', {
750
- className: 'dshm_btn dshm_btnPrimary',
751
- href: url,
752
- target: '_blank',
753
- rel: 'noreferrer',
754
- }, t('openPanel'))
755
- : h('button', {
756
- type: 'button',
757
- className: 'dshm_btn',
758
- disabled: true,
759
- }, panelUrl === null ? t('panelPending') : t('panelMissing')),
760
- ]),
761
- h('p', { className: 'dshm_note' }, t('openHint')),
762
- h('p', { className: 'dshm_note' }, webAccessStatus),
763
- h('div', { className: 'dshm_actions' }, [
764
- h('button', {
765
- type: 'button',
766
- className: webAccessEnabled ? 'dshm_btn' : 'dshm_btn dshm_btnPrimary',
767
- disabled: !webAccess || webAccessBusy,
768
- onClick: toggleWebAccess,
769
- }, webAccessEnabled ? t('webAccessClose') : t('webAccessOpen')),
770
- ]),
771
- h('p', { className: 'dshm_note' }, t('webAccessHint')),
772
- webAccessError ? h('p', { className: 'dshm_error' }, `${t('webAccessError')}: ${webAccessError}`) : null,
773
- ]),
774
- ]);
1179
+ // 页面一打开就问一次地址:点下去要么是一次同步的链接跳转、要么先把窗口开好,
1180
+ // 地址与分类都得提前在手上。宿主还没起来时留一个定时器,取到地址就自己收掉。
1181
+ useEffect(() => {
1182
+ let alive = true;
1183
+ let timer = null;
1184
+ const load = async () => {
1185
+ const outcome = await refreshStatus();
1186
+ if (!alive || !outcome.ok) return;
1187
+ const next = outcome.status || {};
1188
+ if (typeof next.panelUrl === 'string' && next.panelUrl && timer !== null) {
1189
+ clearInterval(timer);
1190
+ timer = null;
1191
+ }
1192
+ };
1193
+ void load();
1194
+ if (timer === null) {
1195
+ timer = setInterval(() => {
1196
+ if (typeof document !== 'undefined' && document.hidden) return;
1197
+ void load();
1198
+ }, FOOTER_URL_RETRY_MS);
1199
+ }
1200
+ return () => {
1201
+ alive = false;
1202
+ if (timer !== null) clearInterval(timer);
1203
+ };
1204
+ }, []);
1205
+
1206
+ useEffect(() => {
1207
+ if (typeof document === 'undefined') return undefined;
1208
+ const doc = document;
1209
+ // 窄条那一行塞不下第二颗:不插,自己占一行。
1210
+ if (!wide) {
1211
+ setAnchored(false);
1212
+ return undefined;
1213
+ }
1214
+ let node = nodeRef.current;
1215
+ if (!node) {
1216
+ try {
1217
+ dropStaleFooterIcons(doc);
1218
+ node = createFooterIcon(doc);
1219
+ } catch {
1220
+ node = null;
1221
+ }
1222
+ nodeRef.current = node;
1223
+ }
1224
+ if (!node) {
1225
+ setAnchored(false);
1226
+ return undefined;
1227
+ }
1228
+ // 没地址时标题要说的是「为什么没有」:404 是宿主那一半没装载,其余是还没就绪。
1229
+ const notReadyLabel = problem && problem.status === 404 ? loadMissingLabel : pendingLabel;
1230
+ const sync = () => syncFooterIcon(node, url, openLabel, notReadyLabel, openPanel);
1231
+ let attached = false;
1232
+ try {
1233
+ attached = ensureFooterIcon(doc, node);
1234
+ sync();
1235
+ } catch {
1236
+ attached = false;
1237
+ }
1238
+ setAnchored(attached);
1239
+ // 那一行会被外壳整棵重建(宽窄切换、账户菜单换人、提示挂上/摘下),元素掉线就
1240
+ // 补回去;第一次没找到(设置项比我们晚挂上)也靠它捡回来。变动只排一次检查:
1241
+ // 流式输出时每帧最多一次查询,且不写 DOM 就不触发重排。
1242
+ const Observer = typeof window !== 'undefined' ? window.MutationObserver : undefined;
1243
+ let observer = null;
1244
+ let frame = 0;
1245
+ if (typeof Observer === 'function' && doc.body) {
1246
+ observer = new Observer(() => {
1247
+ if (frame !== 0) return;
1248
+ const raf = typeof requestAnimationFrame === 'function'
1249
+ ? requestAnimationFrame
1250
+ : (fn) => setTimeout(fn, 16);
1251
+ frame = raf(() => {
1252
+ frame = 0;
1253
+ try {
1254
+ sync();
1255
+ setAnchored(ensureFooterIcon(doc, node));
1256
+ } catch {
1257
+ setAnchored(false);
1258
+ }
1259
+ });
1260
+ });
1261
+ observer.observe(doc.body, { childList: true, subtree: true });
1262
+ }
1263
+ return () => {
1264
+ if (observer !== null) observer.disconnect();
1265
+ if (frame !== 0 && typeof cancelAnimationFrame === 'function') cancelAnimationFrame(frame);
1266
+ if (typeof node.remove === 'function' && node.parentElement) node.remove();
1267
+ };
1268
+ }, [wide, url, openLabel, loadMissingLabel, pendingLabel, problem, panelScope, webAccess]);
1269
+
1270
+ // 窄条,或者这一版外壳里认不出那一行:自己占一行,入口不能因为认不出位置就消失。
1271
+ if (!wide || !anchored) {
1272
+ const label = url
1273
+ ? openLabel
1274
+ : (problem && problem.status === 404 ? loadMissingLabel : pendingLabel);
1275
+ return h('a', {
1276
+ className: 'dshm_footAction dshm_footActionRail',
1277
+ href: url || undefined,
1278
+ target: '_blank',
1279
+ rel: 'noreferrer',
1280
+ 'aria-label': label,
1281
+ title: label,
1282
+ 'aria-disabled': url ? 'false' : 'true',
1283
+ onClick: openPanel,
1284
+ }, h('svg', {
1285
+ viewBox: '0 0 24 24',
1286
+ width: PHONE_GLYPH_RAIL,
1287
+ height: PHONE_GLYPH_RAIL,
1288
+ fill: 'none',
1289
+ stroke: 'currentColor',
1290
+ strokeWidth: 1.7,
1291
+ strokeLinecap: 'round',
1292
+ 'aria-hidden': 'true',
1293
+ focusable: 'false',
1294
+ }, PHONE_PATHS.map((d) => h('path', { key: d, d }))));
1295
+ }
1296
+ return null;
775
1297
  }
776
1298
 
777
1299
  /**
@@ -825,7 +1347,7 @@ window.__ModuleLoader__.load({
825
1347
  }
826
1348
 
827
1349
  return {
828
- // slots:注册设置项,由 @deepseek-ai/dsh-client-runtime 提供(它 require
1350
+ // slots:注册侧边栏那颗图标,由 @deepseek-ai/dsh-client-runtime 提供(它 require
829
1351
  // dsh-client-ui-slots 的 SlotCore 再以 `super(ctx, "slots")` 暴露服务)。
830
1352
  // locale:字典,由 @deepseek-ai/dsh-client-locale 提供。
831
1353
  // 这两个服务名对应 package.json 里 dsh.client.inject 注入的两个包。
@@ -838,18 +1360,18 @@ window.__ModuleLoader__.load({
838
1360
  startDesktopReadSync(ctx);
839
1361
  startReadReceiptSync(ctx);
840
1362
  startClientHalfReport(ctx);
841
- // 插槽是「先声明后注册」:宿主可能比我们晚一步声明 settings.section,
842
- // slots.inject 会等它就位再注册——已装的第三方插件(dsh-opencode-go)也是这么做的。
843
- // settings.section 是设置菜单里的顶层一项;settings.plugins.tab 是内置插件页里的页签,
844
- // 这一项原先挂在那里,后来按用户要求搬出来独立成一项。
845
- ctx.slots.inject('settings.section', () => ctx.slots.register({
846
- name: 'settings.section',
847
- id: 'mobile',
848
- order: 20,
849
- label: () => t('nav'),
1363
+ // 侧边栏底部那颗手机图标:**它是进网页的唯一入口**(2026-11-02 用户要求把设置菜单
1364
+ // 里那一项去掉)。走外壳给插件留的动作位注册(`name` 与 `inject()` 的路径必须是同一个
1365
+ // 槽名,改一处不报错、只是静默挂不上);展开态再自己插进设置那一行,细节见
1366
+ // `MobileWebEntry` 上面那段注释。
1367
+ ctx.slots.inject(FOOTER_SLOT, () => ctx.slots.register({
1368
+ name: FOOTER_SLOT,
1369
+ id: 'mobile-web',
1370
+ order: 10,
1371
+ label: () => t('footerOpen'),
850
1372
  locale: NS,
851
1373
  inject: () => ({ t }),
852
- }, MobileSettingsSection));
1374
+ }, MobileWebEntry));
853
1375
  },
854
1376
  };
855
1377
  },