dsh-remote-web 0.7.9-beta.1 → 0.7.10-beta.3

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
@@ -497,6 +497,21 @@ window.__ModuleLoader__.load({
497
497
  ".dru-access-tips-title{font-size:11.5px;font-weight:700;color:var(--dru-fg-muted);letter-spacing:.5px}",
498
498
  ".dru-access-tip-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap;font-size:12px;line-height:1.6;color:var(--dru-fg-muted)}",
499
499
  ".dru-access-tip-row b{color:var(--dru-fg)}",
500
+ // ── 📲 激活引导(0.7.10):把「拿手机做什么」写在远程访问卡最顶部 ──
501
+ // 视觉上刻意用「浅色实底 + 左侧色条」而不是又一条灰 hint:它是**下一步该做的事**,
502
+ // 与状态 chip(描述现状)在层级上必须能一眼分开。
503
+ ".dru-activation-guide{display:flex;flex-direction:column;gap:7px;margin-top:8px;padding:10px 12px;border:1px solid var(--dru-accent);border-left-width:3px;border-radius:8px;background:var(--dru-accent-tint, var(--dru-surface-2))}",
504
+ ".dru-activation-done{border-color:var(--dru-success);background:var(--dru-success-soft, var(--dru-surface-2))}",
505
+ ".dru-activation-title{font-size:13px;font-weight:700;color:var(--dru-fg)}",
506
+ ".dru-activation-step{display:flex;align-items:flex-start;gap:8px;font-size:12.5px;line-height:1.6;color:var(--dru-fg)}",
507
+ ".dru-activation-n{flex:none;display:inline-flex;align-items:center;justify-content:center;width:18px;height:18px;margin-top:1px;border-radius:50%;background:var(--dru-accent-fill);color:var(--dru-on-accent);font:700 11px/1 inherit}",
508
+ ".dru-activation-n-ok{background:var(--dru-success, var(--dru-accent-fill))}",
509
+ ".dru-activation-actions{display:flex;flex-wrap:wrap;gap:8px;padding-left:26px}",
510
+ ".dru-activation-alt{font-size:11.5px;line-height:1.6;color:var(--dru-fg-muted);padding-left:26px;word-break:break-all}",
511
+ // ── 🔧 连接失败分档(0.7.10):红色错误条下面是「原因判断 + 该做什么」 ──
512
+ ".dru-fail-class{margin-top:8px;padding:8px 10px;border:1px solid var(--dru-border-ctl);border-radius:8px;background:var(--dru-surface-2)}",
513
+ ".dru-fail-class-title{font-size:12.5px;font-weight:700;color:var(--dru-fg)}",
514
+ ".dru-fail-class-hint{font-size:12px;line-height:1.6;color:var(--dru-fg-muted);margin-top:3px}",
500
515
  // 公告列表(设置面板「📣 公告」卡)
501
516
  ".dru-ann-list{display:flex;flex-direction:column;gap:8px}",
502
517
  ".dru-ann-item{display:flex;align-items:flex-start;gap:8px;width:100%;box-sizing:border-box;text-align:left;background:var(--dru-surface-2);border:1px solid var(--dru-border-soft);border-radius:8px;padding:10px 12px;cursor:pointer;font-family:inherit;color:var(--dru-fg)}",
@@ -1745,6 +1760,189 @@ window.__ModuleLoader__.load({
1745
1760
  */
1746
1761
  var slowConnTrack = { since: 0 };
1747
1762
  var SLOW_CONNECT_HINT_MS = 90000; // 非 online 阶段持续 90s 才认为「明显偏慢」
1763
+ /**
1764
+ * 【0.7.10】「此刻能不能扫码」—— 设备连上即出二维码,反过来也成立:**没连上就先别出**。
1765
+ *
1766
+ * 生产证据(2026-11 复盘,B 段 100 人):面板反复取一次性密钥(中位 4 次、最高 205 次),
1767
+ * 而本机 bridge 从未在中继注册。也就是说:**二维码一直在签发,但它注定进不去** ——
1768
+ * 用户扫了没反应/被拒,于是刷新页面、再点一次,每刷一次又签一把新密钥,人却被耗在一条不通的路上。
1769
+ * 单看面板完全看不出差别:密钥接口走的是账号 API,不依赖 bridge 是否注册。
1770
+ *
1771
+ * 判据只做「确定」的一档,绝不猜:
1772
+ * · 旧 node 半没有 connect / 还没拿到首次轮询结果 → 判断不了 → **维持原行为**(true,向后兼容);
1773
+ * · 有 phase 且是 online → true(设备已注册到中继,此刻链接确实可用);
1774
+ * · 其它 phase(no_runtime/installing/starting/connecting/error…)→ false(先别出二维码)。
1775
+ */
1776
+ function canShowAccessKey(connInfo) {
1777
+ if (!connInfo || !connInfo.phase) return true;
1778
+ return connInfo.phase === "online";
1779
+ }
1780
+ /**
1781
+ * 【0.7.10】连接失败分档:把 node 半既有字段归到 10 类「人话原因 + 该做什么」。
1782
+ *
1783
+ * 为什么要在面板侧再做一层:node 半下发的 error.code/detail 已经很详细(那是给诊断用的),
1784
+ * 但**非技术用户读不出「下一步该点哪」**。生产里 win32 占 install_failed 95.9%,失败原因
1785
+ * 集中在端口占用 / 权限 / PATH(npx) / Node 缺失 / dsh web 没起(上游不通)/ 设备被锁这几类,
1786
+ * 每一类的正确动作不同(重装修复 vs 启动 dsh web vs 升级/解绑)。
1787
+ *
1788
+ * 输入只用**既有**字段(error.code、error.message、detail、upstreamReachable、upstreamError、locked),
1789
+ * 不新增接口、不改契约:字段缺失时逐项跳过,最终落到 unknown 桶 —— 给通用说明,**不编造原因**。
1790
+ * @returns {{bucket:string,title:string,hint:string,repair:boolean,retry:boolean}|null}
1791
+ */
1792
+ function classifyConnectFailure(ci) {
1793
+ if (!ci) return null;
1794
+ var code = String((ci.error && ci.error.code) || "");
1795
+ // node 半 0.7.10+ 直接下发归因码(与匿名遥测同一套枚举)——比正则猜文案准得多;
1796
+ // 旧 host 没有 failCode → 下面继续按 error.message/detail 文本判断(特性检测,非硬依赖)。
1797
+ var fc = String(ci.failCode || "");
1798
+ var text = [
1799
+ (ci.error && ci.error.message) || "",
1800
+ ci.detail || "",
1801
+ ci.upstreamError || ""
1802
+ ].join("\n");
1803
+ // ① 设备额度锁定:不是故障,出路是升级 / 解绑(由锁定态专用 UI 处理,这里只给分类)
1804
+ if (code === "device_locked" || ci.locked) {
1805
+ return {
1806
+ bucket: "locked",
1807
+ title: "本机已被锁定(当前套餐的设备额度已满)",
1808
+ hint: "不用重装:升级 Pro Max、或到手机端「管理电脑」解绑不用的设备,会自动解锁。",
1809
+ repair: false, retry: false
1810
+ };
1811
+ }
1812
+ // ② dsh web 没起 / 端口不对(上游不可达)—— 重装 bridge 也没用,要先让 dsh web 起来
1813
+ if (ci.upstreamReachable === false ||
1814
+ /econnrefused|连接被拒绝|上游.{0,6}(不通|失败)|dsh\s*web.{0,8}(没|未).{0,4}(运行|启动)/i.test(text)) {
1815
+ return {
1816
+ bucket: "upstream",
1817
+ title: "找不到这台电脑上的 dsh web(上游端口)",
1818
+ hint: "① 确认 dsh web 正开着;② 点「一键修复」重装运行环境(会自动重新发现端口);"
1819
+ + "③ 仍不行:启动 dsh web 前设环境变量 DSH_BRIDGE_UPSTREAM=http://127.0.0.1:<端口>。",
1820
+ repair: true, retry: true
1821
+ };
1822
+ }
1823
+ // ③ 绑定设备硬上限:出路在手机端解绑,不是重装
1824
+ if (code === "device_bind_cap" || fc === "bind_device_limit" || /device_bind_cap|绑定设备数达到上限/.test(text)) {
1825
+ return {
1826
+ bucket: "bind_cap",
1827
+ title: "已绑定的设备数到上限了",
1828
+ hint: "到手机端「管理电脑」解绑不用的设备即可,本机会自动恢复(无需重启)。",
1829
+ repair: false, retry: true
1830
+ };
1831
+ }
1832
+ // ③' 归因码优先(node 半 0.7.10+):与匿名遥测同一套枚举,比正则猜文案准。
1833
+ // 命中即返回;旧 host 的 fc 为空串 → 直接落到下面的文本判断(向后兼容)。
1834
+ var BY_FAIL_CODE = {
1835
+ node_missing: "node", node_too_old: "node",
1836
+ npx_cmd_unavailable: "path",
1837
+ npm_eacces: "permission",
1838
+ npm_unreachable: "network", registry_timeout: "network",
1839
+ // 端口占用在归因码里没有独立枚举(白名单冻结):仍然靠文本判断(见 ④)
1840
+ bind_conflict: "bind_cap"
1841
+ };
1842
+ var fcBucket = BY_FAIL_CODE[fc];
1843
+ if (fcBucket === "node") {
1844
+ return {
1845
+ bucket: "node",
1846
+ title: "本机没有 Node.js,或版本太低",
1847
+ hint: "装好 Node.js 20 或更高版本(nodejs.org)后点「一键修复」;装完建议重开一次 DeepSeek Harness。",
1848
+ repair: true, retry: true
1849
+ };
1850
+ }
1851
+ if (fcBucket === "path") {
1852
+ return {
1853
+ bucket: "path",
1854
+ title: "本机找不到安装命令(npx / PATH)",
1855
+ hint: "点「一键修复」会按绝对路径重试;若仍失败:重开一次 DeepSeek Harness(或终端)让 PATH 生效,再点一次。",
1856
+ repair: true, retry: true
1857
+ };
1858
+ }
1859
+ if (fcBucket === "permission") {
1860
+ return {
1861
+ bucket: "permission",
1862
+ title: "没有写入权限(装不进运行目录)",
1863
+ hint: "点「一键修复」重装到可写目录;若装了安全软件(如杀毒/EDR),请允许 dsh-remote 写入用户目录后重试。",
1864
+ repair: true, retry: true
1865
+ };
1866
+ }
1867
+ if (fcBucket === "network") {
1868
+ return {
1869
+ bucket: "network",
1870
+ title: "网络或软件源连不上",
1871
+ hint: "检查代理 / 防火墙后点「一键修复」(会自动换源重试);公司网络可先连手机热点试一次。",
1872
+ repair: true, retry: true
1873
+ };
1874
+ }
1875
+ if (fcBucket === "bind_cap") {
1876
+ return {
1877
+ bucket: "bind_cap",
1878
+ title: "已绑定的设备数到上限了",
1879
+ hint: "到手机端「管理电脑」解绑不用的设备即可,本机会自动恢复(无需重启)。",
1880
+ repair: false, retry: true
1881
+ };
1882
+ }
1883
+ // ④ 端口被占用
1884
+ if (/eaddrinuse|address already in use|端口.{0,8}(占用|冲突|被占)/i.test(text)) {
1885
+ return {
1886
+ bucket: "port",
1887
+ title: "端口被别的程序占用了",
1888
+ hint: "点「一键修复」会重新安装并自动换一个可用端口;仍冲突可重启电脑后再试。",
1889
+ repair: true, retry: true
1890
+ };
1891
+ }
1892
+ // ⑤ 权限不足(只读目录 / 沙箱拦截)
1893
+ if (/eacces|eperm|permission denied|权限不足|只读文件系统|read-only file system/i.test(text)) {
1894
+ return {
1895
+ bucket: "permission",
1896
+ title: "没有写入权限(装不进运行目录)",
1897
+ hint: "点「一键修复」重装到可写目录;若装了安全软件(如杀毒/EDR),请允许 dsh-remote 写入用户目录后重试。",
1898
+ repair: true, retry: true
1899
+ };
1900
+ }
1901
+ // ⑥ Node.js 缺失 / 版本过低(放在 PATH 之前:「'node' 不是内部或外部命令」要归到 Node 缺失,
1902
+ // 而不是泛泛的 PATH 问题 —— 两者的修复动作不同:装 Node vs 修 PATH)
1903
+ if (/node_missing|node_too_old|找不到\s*node|node\.js.{0,8}(未安装|缺失|版本过低)|需要\s*node\s*20|['"]?node['"]?\s*(不是内部|not recognized)|spawn\s+node/i.test(text)) {
1904
+ return {
1905
+ bucket: "node",
1906
+ title: "本机没有 Node.js,或版本太低",
1907
+ hint: "装好 Node.js 20 或更高版本(nodejs.org)后点「一键修复」;装完建议重开一次 DeepSeek Harness。",
1908
+ repair: true, retry: true
1909
+ };
1910
+ }
1911
+ // ⑦ PATH / npx:Windows 上最常见的「命令都调不起来」
1912
+ if (/npx_cmd_unavailable|spawn.{0,8}\.cmd|找不到\s*npx|npx['"]?[^a-z]{0,6}(不可用|没找到|不是内部|not recognized)|不是内部或外部命令/i.test(text)) {
1913
+ return {
1914
+ bucket: "path",
1915
+ title: "本机找不到安装命令(npx / PATH)",
1916
+ hint: "点「一键修复」会按绝对路径重试;若仍失败:重开一次 DeepSeek Harness(或终端)让 PATH 生效,再点一次。",
1917
+ repair: true, retry: true
1918
+ };
1919
+ }
1920
+ // ⑧ 网络 / 软件源
1921
+ if (/npm_unreachable|registry_timeout|enotfound|eai_again|etimedout|连不上软件源|网络.{0,6}(不通|异常)/i.test(text)) {
1922
+ return {
1923
+ bucket: "network",
1924
+ title: "网络或软件源连不上",
1925
+ hint: "检查代理 / 防火墙后点「一键修复」(会自动换源重试);公司网络可先连手机热点试一次。",
1926
+ repair: true, retry: true
1927
+ };
1928
+ }
1929
+ // ⑨ 反复拉不起来 / 安装卡住:重装是对的
1930
+ if (code === "bridge_wedged" || code === "install_stuck" || code === "launchd_crash" || code === "bind_failed") {
1931
+ return {
1932
+ bucket: "runtime",
1933
+ title: "后台服务的运行环境有问题(反复启动失败)",
1934
+ hint: "点「一键修复」重装运行环境;装完会自动启动,不用手动跑命令。",
1935
+ repair: true, retry: true
1936
+ };
1937
+ }
1938
+ // ⑩ 兜底:不编原因,给通用可操作项
1939
+ return {
1940
+ bucket: "unknown",
1941
+ title: "连接没有建立起来",
1942
+ hint: "点「一键修复」重装运行环境;仍不行点「复制诊断信息」发给我们,里面已经有定位所需的全部信息。",
1943
+ repair: true, retry: true
1944
+ };
1945
+ }
1748
1946
  // ── 事实约定常量(🔒 只写线上真值,一个字都不编)─────────────────────────────
1749
1947
  // 邀请奖励:线上 public-config 下发 invite_rule { n: 1, days: 3 }。
1750
1948
  // 此处只在 public-config 取不到时(旧 node 半 / 请求失败)兜底,
@@ -3412,9 +3610,18 @@ window.__ModuleLoader__.load({
3412
3610
  if (view !== "home" || mode !== "saas" || !loggedIn) return undefined;
3413
3611
  cancelAkeyRetry(); cancelDevRetry();
3414
3612
  akeyRetry.attempt = 0; devRetry.attempt = 0;
3415
- loadAccessKey();
3613
+ // 【0.7.10】设备还没连上就先不签发二维码/链接(见 canShowAccessKey 的长注释):
3614
+ // 生产里 100 人反复取 key(最高 205 次)而 bridge 从未注册 —— 每次刷新/重试都在给自己
3615
+ // 签一把「扫了也进不去」的新密钥。连上那一刻由 pollConnect() 自动补取,用户无需刷新。
3616
+ // 旧 node 半没有 connect 字段时 canShowAccessKey → true,行为与改动前完全一致。
3617
+ if (canShowAccessKey(conn || (st && st.connect) || null)) loadAccessKey();
3618
+ else setAkey(null);
3619
+ // 已授权设备列表照旧拉:它同时也是「手机有没有登录过」的唯一证据(激活引导据此收尾)。
3416
3620
  loadDevices();
3417
- var rotateIv = setInterval(function () { loadAccessKey(); }, KEY_AUTO_REFRESH_MS);
3621
+ var rotateIv = setInterval(function () {
3622
+ // 轮换同理:非 online 阶段不签发(connPhaseRef 由 pollConnect 维护,不依赖渲染闭包)
3623
+ if (!connPhaseRef.v || connPhaseRef.v === "online") loadAccessKey();
3624
+ }, KEY_AUTO_REFRESH_MS);
3418
3625
  return function () {
3419
3626
  clearInterval(rotateIv);
3420
3627
  cancelAkeyRetry();
@@ -3496,6 +3703,12 @@ window.__ModuleLoader__.load({
3496
3703
  } else if (devSessions !== null) {
3497
3704
  refreshDeviceList(); // 手机扫码后设备列表自动出现,无需刷新页面
3498
3705
  }
3706
+ } else if (prev === "online") {
3707
+ // 【0.7.10】从「已连接」掉下来(bridge 退出 / 被限流 / 设备被锁):二维码与链接此刻
3708
+ // 一定不可用 —— 立刻撤掉,别让用户对着一个扫了也进不去的码反复重试。
3709
+ // 重新连上时上面的 online 分支会自动补回,全程不需要刷新页面。
3710
+ setAkey(null);
3711
+ setAkeyMsg(null);
3499
3712
  }
3500
3713
  }).catch(function (e) {
3501
3714
  // 【0.6.7】原来这里是空 catch("轮询失败静默:下一轮自动重试")。在 Windows 上
@@ -3531,6 +3744,27 @@ window.__ModuleLoader__.load({
3531
3744
  }).finally(function () { setBusy(""); });
3532
3745
  };
3533
3746
 
3747
+ /**
3748
+ * 【0.7.10】「一键修复 / 重装运行环境」—— 与「🔄 版本与更新」卡里的同一个动作
3749
+ * (POST /dsh-remote/self/update,node 半重跑安装脚本并拉起 bridge)。
3750
+ * 为什么在连接失败块里也放一个:用户看到错误时最该点的就是它,让他往下翻另一张卡
3751
+ * 找同名按钮是纯粹的摩擦(生产诊断里"点了没反应/不知道点哪"正是主要抱怨)。
3752
+ * 旧 node 半没有该路由 → 404 → 退回一句人话(绝不静默失败)。
3753
+ */
3754
+ var repairConnect = function () {
3755
+ setBusy("connect-repair");
3756
+ setAkeyMsg(null);
3757
+ post("/dsh-remote/self/update").then(function (b) {
3758
+ if (!b || !b.ok) {
3759
+ var why = (b && (b.error || b.detail || b.message)) || "服务端没有接受修复请求";
3760
+ throw new Error(why);
3761
+ }
3762
+ setAkeyMsg({ kind: "ok", text: "已开始重装运行环境(首次约 1~2 分钟,装完会自动启动)。进度会显示在「🖥 后台服务」卡。" });
3763
+ }).catch(function (e) {
3764
+ setAkeyMsg({ kind: "err", text: "没能启动一键修复:" + e.message + "(可点「复制诊断信息」把详情发给我们)" });
3765
+ }).finally(function () { setBusy(""); });
3766
+ };
3767
+
3534
3768
  /** 「复制诊断信息」:版本 / 配置目录 / 阶段 / 最近错误 / 进程状态 / 日志路径(node 半已拼好)。
3535
3769
  * ★ 2026-09-25:再追加运行环境 `dsh-remote doctor` 的输出 —— 它把「上游端口是怎么来的 /
3536
3770
  * 身份校验过没过 / 守护与 bridge 进程 / 事件订阅与待补发通知 / 带时间戳的日志尾部」一次讲清。
@@ -4715,6 +4949,75 @@ window.__ModuleLoader__.load({
4715
4949
  : serviceRunning ? "已连接(可远程访问)" : "等待设备连接";
4716
4950
  var dotOn = loggedInSaaS ? (connInfo ? connOnline : serviceRunning) : false;
4717
4951
  var dotCls = "dru-dot " + (dotOn ? "dru-dot-on" : "dru-dot-off");
4952
+ // 【0.7.10】「设备连上即出二维码」的另一半:**没连上就别出**(见 canShowAccessKey)。
4953
+ // 旧 node 半 / 首轮轮询未回来时 canScan=true,行为与改动前一致。
4954
+ var canScan = canShowAccessKey(connInfo);
4955
+ var showKey = hasKey && canScan;
4956
+ // 手机端地址(用于"不扫码也能用"的第二种入口):node 半 /status 下发的 remoteUrl(= app_url),
4957
+ // 旧 host 没有这个字段时只用既有兜底,绝不硬编码域名。
4958
+ var appUrl = String((st && st.remoteUrl) || (pub && pub.app_url) || "");
4959
+ // 是否已经有一台手机登录过这台电脑(= 激活真的发生了)。只认未取消配对的会话。
4960
+ // 这是「设备 online 但手机从未登录」(生产 C1 129 人)唯一可由面板侧观测的完成信号。
4961
+ var activeSessions = (devSessions || []).filter(function (s) { return s && !s.revoked_at; });
4962
+ var activated = activeSessions.length > 0;
4963
+ var lastActiveAt = activeSessions.reduce(function (a, s) {
4964
+ var t = toMs(s && s.last_seen_at) || toMs(s && s.created_at) || 0;
4965
+ return t > a ? t : a;
4966
+ }, 0);
4967
+
4968
+ /**
4969
+ * 【0.7.10】「3 步用上手机」激活引导 —— 放在「📱 远程访问」卡最顶部(置顶于状态 chip 之前)。
4970
+ *
4971
+ * 为什么加这块(2026-11 复盘,业主原话「设备连上即出二维码,先确认一下」):
4972
+ * 二维码链路其实**早就有**(access-key → qr_data_url → 25s 自动轮换 → 在线自动重取),
4973
+ * 缺的不是功能,是**收尾**:安装引导停在「注册/登录手机号」,用户在电脑端登录完就以为结束了,
4974
+ * 而二维码只存在于这个面板里、且不知道要拿手机来扫 —— 于是 129 人设备在线却从未在手机端登录。
4975
+ * 这段只做一件事:把「现在该拿手机做什么」写在第一屏,并用 mobile_sessions 自动确认完成。
4976
+ *
4977
+ * 状态机(只用既有 state,不新增 hook):
4978
+ * · 未登录 / 故障 / 锁定 → 不渲染(交给账号卡与连接块,避免两处抢话);
4979
+ * · 已激活(存在未取消配对的手机会话)→ 一行绿色「已激活」;
4980
+ * · 其余 → 三步:① 本机连接(自动)② 手机扫码/复制链接 ③ 手机打开即完成(自动确认)。
4981
+ */
4982
+ function renderActivationGuide() {
4983
+ if (!loggedInSaaS) return null;
4984
+ if (connPhase === "error" || connLocked) return null;
4985
+ if (activated) {
4986
+ return h("div", { className: "dru-activation-guide dru-activation-done" },
4987
+ h("div", { className: "dru-activation-title" }, "✅ 已激活:手机已经登录过这台电脑"),
4988
+ h("div", { className: "dru-hint" },
4989
+ (lastActiveAt ? "最近活跃 " + fmtDT(lastActiveAt) + "。" : "")
4990
+ + "要给另一台手机用,扫下方二维码或复制链接即可(同一台手机重复扫只更新授权,不新增设备)。")
4991
+ );
4992
+ }
4993
+ var rows = [];
4994
+ rows.push(h("div", { className: "dru-activation-step" },
4995
+ h("span", { className: "dru-activation-n" + (connOnline ? " dru-activation-n-ok" : "") }, connOnline ? "✓" : "1"),
4996
+ h("span", null, connOnline
4997
+ ? "本机已连接(设备已注册到中继)"
4998
+ : "本机正在自动连接:" + (connText || "准备中…") + "(不用操作,连上后这一步会变成 ✓)")));
4999
+ rows.push(h("div", { className: "dru-activation-step" },
5000
+ h("span", { className: "dru-activation-n" }, "2"),
5001
+ h("span", null, showKey
5002
+ ? "用手机摄像头/微信扫下面的二维码;或点「复制」把链接发到手机上打开"
5003
+ : "连上后这里会自动出现二维码/链接(不用刷新页面)")));
5004
+ if (showKey) {
5005
+ rows.push(h("div", { className: "dru-activation-actions" },
5006
+ h("button", { type: "button", className: "dru-btn dru-btn-primary", disabled: akeyBusy, onClick: copyKeyUrl },
5007
+ copiedKey ? "✅ 已复制,发到手机打开即可" : "📋 复制链接发到手机"),
5008
+ h("button", { type: "button", className: "dru-btn dru-btn-ghost", disabled: akeyBusy, onClick: openKeyUrl }, "直接打开")));
5009
+ }
5010
+ if (appUrl && /^https?:\/\//i.test(appUrl)) {
5011
+ rows.push(h("div", { className: "dru-activation-alt" },
5012
+ "不扫码也可以:手机浏览器打开 " + appUrl + " ,用同一手机号登录后在设备列表里选这台电脑。"));
5013
+ }
5014
+ rows.push(h("div", { className: "dru-activation-step" },
5015
+ h("span", { className: "dru-activation-n" }, "3"),
5016
+ h("span", null, "手机打开后即完成登录 —— 这一步不用做任何事,成功后这里会自动变成 ✅")));
5017
+ return h("div", { className: "dru-activation-guide" },
5018
+ h("div", { className: "dru-activation-title" }, "📲 3 步用上手机"),
5019
+ rows);
5020
+ }
4718
5021
 
4719
5022
  // 【0.6.9】「连接偏慢」判定(只用既有字段 + 本地计时,不新增/不假设后端字段):
4720
5023
  // ① 服务端下发 attempts ≥ 3(已经自动重试多次);② 阶段文案里出现限速/带宽/较慢类提示;
@@ -4828,6 +5131,16 @@ window.__ModuleLoader__.load({
4828
5131
  if (phase === "error") {
4829
5132
  rows.push(h("div", { className: "dru-msg dru-msg-err", style: { marginTop: 6 } },
4830
5133
  (isLocked ? "🔒 " : "⚠️ ") + ((connError && connError.message) || connText)));
5134
+ // 【0.7.10】失败分档:把「哪一类问题 + 该点哪个按钮」写在错误原文之前。
5135
+ // 原始 message/detail 是给诊断用的(含 launchd/pgrep/EADDRINUSE 这类术语),
5136
+ // 非技术用户读不出下一步 —— 生产里 win32 占 install_failed 95.9%,失败原因集中在
5137
+ // 端口占用 / 权限 / PATH(npx) / Node 缺失 / 上游(dsh web)不通 / 设备被锁这几类。
5138
+ var cls = isLocked ? null : classifyConnectFailure(connInfo);
5139
+ if (cls) {
5140
+ rows.push(h("div", { className: "dru-fail-class" },
5141
+ h("div", { className: "dru-fail-class-title" }, "🔧 原因判断:" + cls.title),
5142
+ h("div", { className: "dru-fail-class-hint" }, cls.hint)));
5143
+ }
4831
5144
  if (connInfo.detail) rows.push(h("div", { className: "dru-hint", style: { marginTop: 4 } }, connInfo.detail));
4832
5145
  if (!isLocked && connInfo.nextRetryInMs > 0) {
4833
5146
  rows.push(h("div", { className: "dru-hint", style: { marginTop: 4 } },
@@ -4839,8 +5152,17 @@ window.__ModuleLoader__.load({
4839
5152
  h("button", { type: "button", className: "dru-btn dru-btn-primary", onClick: openLockedUpgrade }, "🚀 升级 Pro Max"),
4840
5153
  h("button", { type: "button", className: "dru-btn dru-btn-ghost", onClick: copyDiagnostics }, copiedDiag ? "已复制" : "复制诊断信息")));
4841
5154
  } else {
5155
+ // 主行动按分档给:能靠重装解决的 → 「一键修复 / 重装运行环境」(与「版本与更新」卡同一个动作,
5156
+ // 但放在失败原因旁边,用户不必往下翻找);上游不通/绑定上限 → 「重试」为主,修复为次。
5157
+ var primaryIsRepair = !cls || cls.repair === true;
4842
5158
  rows.push(h("div", { className: "dru-actions", style: { marginTop: 8 } },
4843
- h("button", { type: "button", className: "dru-btn dru-btn-primary", disabled: busy !== "", onClick: retryConnect }, busy === "connect-retry" ? "重试中…" : "重试"),
5159
+ primaryIsRepair
5160
+ ? h("button", { type: "button", className: "dru-btn dru-btn-primary", disabled: busy !== "", onClick: repairConnect }, busy === "connect-repair" ? "修复中…" : "🔧 一键修复 / 重装运行环境")
5161
+ : h("button", { type: "button", className: "dru-btn dru-btn-primary", disabled: busy !== "", onClick: retryConnect }, busy === "connect-retry" ? "重试中…" : "重试"),
5162
+ // 「重试」只在修复是主行动时作为次行动出现 —— 否则会和主按钮重复两个同名按钮。
5163
+ primaryIsRepair
5164
+ ? h("button", { type: "button", className: "dru-btn dru-btn-ghost", disabled: busy !== "", onClick: retryConnect }, busy === "connect-retry" ? "重试中…" : "重试")
5165
+ : null,
4844
5166
  h("button", { type: "button", className: "dru-btn dru-btn-ghost", onClick: copyDiagnostics }, copiedDiag ? "已复制" : "复制诊断信息")));
4845
5167
  }
4846
5168
  rows.push(h("div", { className: "dru-hint", style: { marginTop: 6 } },
@@ -4885,6 +5207,9 @@ window.__ModuleLoader__.load({
4885
5207
  h("span", null, e.text));
4886
5208
  }
4887
5209
  return card("📱 远程访问", [
5210
+ // 【0.7.10】激活引导置顶:二维码链路的**收尾**(见 renderActivationGuide 的长注释)。
5211
+ // 放在状态 chip 之前 = 「设备连上后第一屏看到的就是拿手机做什么」,而不是先读一堆技术状态。
5212
+ renderActivationGuide(),
4888
5213
  // 【0.6.18 面板重排】状态性描述(连接状态 + E2EE)从"各自独占一行"收敛成**一行内的 chip**:
4889
5214
  // · 用 flex-wrap 而不是硬挤:窄屏(手机镜像页也是同一份面板)自动换行,绝不横向溢出;
4890
5215
  // · 文字标签始终在(点 + 文案),颜色只是辅助,不靠颜色单独表意;
@@ -4912,7 +5237,10 @@ window.__ModuleLoader__.load({
4912
5237
  ? h("button", { type: "button", className: "dru-linkbtn", style: { marginLeft: 8 }, disabled: akeyBusy, onClick: akeyMsg.retry }, akeyBusy ? "重试中…" : "立即重试")
4913
5238
  : null)
4914
5239
  : null,
4915
- hasKey ? h("div", null, [
5240
+ // 【0.7.10】二维码/链接只在**设备确实连上**(或旧 host 无法判断 phase)时出现:
5241
+ // 生产里 100 人反复取 key 而 bridge 从未注册 —— 那时候面板画出来的码扫了也进不去。
5242
+ // 没连上时给「等它自动连上」的说明,而不是一个能点却没用的「生成访问链接」。
5243
+ showKey ? h("div", null, [
4916
5244
  h("div", { className: "dru-url big", style: { marginTop: 8 } },
4917
5245
  h("span", null, akey.url),
4918
5246
  h("button", { type: "button", onClick: copyKeyUrl }, copiedKey ? "已复制" : "复制")
@@ -4934,14 +5262,28 @@ window.__ModuleLoader__.load({
4934
5262
  ),
4935
5263
  // 【0.6.18】原来这里只有一行 hint,右列下方空着一大片 —— 现在改成**提醒区**:
4936
5264
  // 未读公告入口 / 重启提醒 / 使用提示集中在此(引导用户"下一步做什么",而不是散落各处)。
4937
- renderAccessTips(hasKey)
5265
+ renderAccessTips(showKey)
4938
5266
  )
4939
5267
  )
5268
+ ]) : !loggedInSaaS ? h("div", null, [
5269
+ h("div", { className: "dru-hint", style: { marginTop: 6 } },
5270
+ "登录下方「🔑 账号」卡片中的手机号账号后,即可生成一次性访问链接,让手机或另一台电脑远程使用同一份 dsh web。"),
5271
+ h("div", { className: "dru-actions", style: { marginTop: 8 } },
5272
+ h("button", { type: "button", className: "dru-btn dru-btn-primary", disabled: akeyBusy, onClick: loadAccessKey },
5273
+ akeyBusy ? "生成中…" : "生成访问链接")
5274
+ )
5275
+ ]) : !canScan ? h("div", null, [
5276
+ // 已登录、但设备还没连上(或刚掉线):这一档**不给**「生成访问链接」。
5277
+ // 签发一把进不去的密钥只会让用户多试几次、多签几把(见 canShowAccessKey 的生产证据)。
5278
+ h("div", { className: "dru-hint", style: { marginTop: 6 } },
5279
+ "设备还没连上(" + (connText || "正在自动连接…") + "):连上后二维码/访问链接会自动出现在这里,"
5280
+ + "不需要刷新页面,也不需要重复点任何按钮。"),
5281
+ h("div", { className: "dru-actions", style: { marginTop: 8 } },
5282
+ h("button", { type: "button", className: "dru-btn dru-btn-primary", disabled: busy !== "", onClick: retryConnect }, busy === "connect-retry" ? "检测中…" : "立即检测连接"),
5283
+ h("button", { type: "button", className: "dru-btn dru-btn-ghost", onClick: copyDiagnostics }, copiedDiag ? "已复制" : "复制诊断信息"))
4940
5284
  ]) : h("div", null, [
4941
5285
  h("div", { className: "dru-hint", style: { marginTop: 6 } },
4942
- loggedInSaaS
4943
- ? "正在生成一次性访问链接…(手机或另一台电脑扫码/打开即可进入)"
4944
- : "登录下方「🔑 账号」卡片中的手机号账号后,即可生成一次性访问链接,让手机或另一台电脑远程使用同一份 dsh web。"),
5286
+ "正在生成一次性访问链接…(手机或另一台电脑扫码/打开即可进入)"),
4945
5287
  h("div", { className: "dru-actions", style: { marginTop: 8 } },
4946
5288
  h("button", { type: "button", className: "dru-btn dru-btn-primary", disabled: akeyBusy, onClick: loadAccessKey },
4947
5289
  akeyBusy ? "生成中…" : "生成访问链接")
package/lib/index.js CHANGED
@@ -5731,6 +5731,21 @@ async function composeConnect(relayDir, opts = {}) {
5731
5731
  const bindFresh = Boolean(bindError && (!bindError.at || now - Number(bindError.at) < BIND_ERROR_FRESH_MS));
5732
5732
  const log = bridgeLogState(relayDir);
5733
5733
  const state = bridgeStateFile(relayDir);
5734
+ // 【0.7.10 / W1-T3】E2EE 死会话拒绝计数(bridge 随心跳写进状态文件)。
5735
+ // 生产复盘:WS 升级被拒里 71.8% 是 e2ee_session_locked(涉 163 用户);0.7.3+ 已闭环
5736
+ // (手机侧清内存会话 + 3 连败作废 + 每页一次受控 reload;bridge 回 retryable+rehandshake)。
5737
+ // 原比例只能在服务端看,支持包答不出来;这里把它带进「复制诊断信息」。
5738
+ // 旧 bridge 没有这些字段 → 全 0,且**只在真的发生过时才输出**(正常用户零新增噪音)。
5739
+ const e2eeReject = {
5740
+ locked: Number(state && state.e2ee_ws_reject_locked) || 0,
5741
+ disabled: Number(state && state.e2ee_ws_reject_disabled) || 0,
5742
+ at: Number(state && state.e2ee_ws_reject_at) || 0
5743
+ };
5744
+ // 【0.7.10 / W1-T2】最近一次安装/更新的**归因码**(与匿名遥测同一套白名单枚举)。
5745
+ // 面板拿它做失败分档(端口/权限/PATH/Node/上游/被锁 → 各自文案 + 一键修复),
5746
+ // 而不是让浏览器半去猜 `launchd state=…` 这类术语。旧 host 没有该字段 →
5747
+ // 面板自动退回按既有文案判断(client.js 的 classifyConnectFailure 里逐项特性检测)。
5748
+ const lastInstallFailure = recentUpdateFailure(relayDir);
5734
5749
  const deviceId = String(cfg.device_id || "");
5735
5750
 
5736
5751
  // ── 「设备被超额锁定」证据(契约 §8.1)──
@@ -5955,6 +5970,11 @@ async function composeConnect(relayDir, opts = {}) {
5955
5970
  launchdCrashLoop: Boolean(launchd.crashing),
5956
5971
  registered,
5957
5972
  registerSource,
5973
+ // 最近一次安装/更新的归因码(0.7.10):面板失败分档用(端口/权限/PATH/Node/上游/被锁)。
5974
+ // 旧 host 无此字段 → 面板退回按既有 error.message/detail 判断,行为不退化。
5975
+ failCode: String((bindFresh && bindError && bindError.code) || (lastInstallFailure && lastInstallFailure.failCode) || ""),
5976
+ // E2EE 死会话拒绝计数(见上方 e2eeReject 说明;只在真的发生过时进诊断文本)
5977
+ e2eeReject,
5958
5978
  attempts: book.attempts,
5959
5979
  nextRetryInMs,
5960
5980
  error,
@@ -6068,6 +6088,12 @@ function buildConnectDiagnostics(relayDir, conn, log) {
6068
6088
  `上游 dsh web: ${conn.upstreamUrl}(${conn.upstreamSource}${conn.upstreamReachable === null ? "" : conn.upstreamReachable ? ",可达" : `,⚠️ 不可达:${conn.upstreamError}`})`,
6069
6089
  `目录选择器: ${pickerPinState.kind === "native" ? "⚠️ 系统原生(远程时对话框弹在电脑那台机器上 → 手机上点「添加工作区」会像没反应)" : (pickerPinState.kind || "未知")}${pickerPinState.detail ? `(${pickerPinState.detail})` : ""}`,
6070
6090
  `中继注册: ${conn.registered ? "已注册(" + conn.registerSource + ")" : "未注册"}`,
6091
+ // E2EE 死会话拒绝计数(0.7.10):正常用户永远没有这一行;出现过就说明这台机器被死会话打过,
6092
+ // 且计数能直接回答「闭环后还剩下多少」——以前这个问题只有服务端日志答得出。
6093
+ conn.e2eeReject && (conn.e2eeReject.locked || conn.e2eeReject.disabled)
6094
+ ? `E2EE 会话拒绝: 会话失效 ${conn.e2eeReject.locked} 次 / 未启用 ${conn.e2eeReject.disabled} 次`
6095
+ + `(本进程累计;手机 0.7.3+ 会自动重握手 —— 若持续增长说明手机端仍是旧版本)`
6096
+ : "",
6071
6097
  `bridge 账号: ${conn.accountCurrent === false ? "⚠️ 仍是上一个账号的身份(需重启 bridge 重新登记)" : "当前账号"}`,
6072
6098
  conn.rateLimitedUntil ? `登录限流: 约 ${Math.ceil((conn.rateLimitedUntil - Date.now()) / 1000)} 秒后自动重试(凭据无误,无需改密码)` : "",
6073
6099
  `最近错误: ${conn.error ? conn.error.code + ": " + conn.error.message : (log && log.lastError ? log.lastError : "无")}`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote-web",
3
- "version": "0.7.9-beta.1",
3
+ "version": "0.7.10-beta.3",
4
4
  "description": "公网远程控制 DeepSeek Harness(dsh web):安装即得专属加密地址,人在外面也能用手机访问电脑上的 dsh——无需同一局域网/WiFi、无需公网 IP 与内网穿透,全程加密;手机端 100% 还原电脑体验(对话/工具/审批/设置)。技术用户可选自建服务,流量走自己的服务器。Remote control DeepSeek Harness (dsh web) from anywhere over the public internet — install, get an encrypted URL, use it from your phone on any network (dual-half cordis plugin: Settings panel + same-origin /dsh-remote routes). (2026-09 由 dsh-remote-ui 更名 / renamed from dsh-remote-ui; dsh-remote 的 dsh web 插件半,不是纯 UI/皮肤插件)",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",