dsh-remote-web 0.7.2-beta.1 → 0.7.2
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 +152 -155
- package/lib/index.js +396 -14
- package/package.json +1 -1
- package/runtime/clients/dsh-remote/mobile-adapter.mjs +236 -22
package/lib/client.js
CHANGED
|
@@ -246,7 +246,12 @@ window.__ModuleLoader__.load({
|
|
|
246
246
|
".dru-captcha .dru-input{flex:1;min-width:0}",
|
|
247
247
|
".dru-captcha-box{width:118px;height:44px;flex:none;border-radius:8px;border:1px solid var(--dru-border-ctl);cursor:pointer;background:var(--dru-surface);display:flex;align-items:center;justify-content:center;font-size:12px;color:var(--dru-fg-muted);overflow:hidden}",
|
|
248
248
|
".dru-captcha-box svg{display:block;width:100%;height:100%}",
|
|
249
|
-
".dru-user{display:flex;align-items:center;gap:10px;margin-bottom:12px}",
|
|
249
|
+
".dru-user{display:flex;align-items:center;gap:10px;margin-bottom:12px;flex-wrap:wrap}",
|
|
250
|
+
// 退出登录:跟着账号行走(业主 2026-10-17),右侧对齐、次要视觉(不抢主行动):
|
|
251
|
+
// 触控目标 ≥40px、与账号信息 8px 间距;危险色但不用实底(它不是主行动)。
|
|
252
|
+
".dru-user-logout{margin-left:auto;flex:none;min-height:40px;padding:8px 12px;border:1px solid var(--dru-border-ctl);border-radius:8px;background:transparent;color:var(--dru-danger);font:500 12.5px/1.2 inherit;font-family:inherit;cursor:pointer;white-space:nowrap}",
|
|
253
|
+
".dru-user-logout:hover:not(:disabled){background:var(--dru-danger-soft);border-color:var(--dru-danger-border)}",
|
|
254
|
+
".dru-user-logout:disabled{opacity:.55;cursor:default}",
|
|
250
255
|
".dru-avatar{width:40px;height:40px;border-radius:50%;background:var(--dru-accent-fill);color:var(--dru-on-accent);display:flex;align-items:center;justify-content:center;font-size:17px;font-weight:700;flex-shrink:0}",
|
|
251
256
|
".dru-user .mail{font-size:14px;font-weight:600;color:var(--dru-fg);word-break:break-all}",
|
|
252
257
|
".dru-user .plan{font-size:12px;color:var(--dru-fg-muted);margin-top:2px}",
|
|
@@ -1368,16 +1373,6 @@ window.__ModuleLoader__.load({
|
|
|
1368
1373
|
function annReportClick(id) {
|
|
1369
1374
|
try { post("/dsh-remote/announcements/click", { id: id }).catch(function () { /* 静默:服务端才是权威 */ }); } catch (e) { /* 忽略 */ }
|
|
1370
1375
|
}
|
|
1371
|
-
/** 本地把某条标记为已读(只为了让徽标/标签立刻更新;真正的已读在服务端)。 */
|
|
1372
|
-
function annMarkReadLocal(id) {
|
|
1373
|
-
var hit = false;
|
|
1374
|
-
annState.items = annState.items.map(function (x) {
|
|
1375
|
-
if (x.id !== id || !x.unread) return x;
|
|
1376
|
-
hit = true;
|
|
1377
|
-
return Object.assign({}, x, { unread: false });
|
|
1378
|
-
});
|
|
1379
|
-
if (hit) { annState.unread = Math.max(0, annState.unread - 1); annEmit(); }
|
|
1380
|
-
}
|
|
1381
1376
|
|
|
1382
1377
|
// ── 🤖 微信机器人「命令帮助」镜像 ─────────────────────────────────────────
|
|
1383
1378
|
// ⚠️ 真身是 clients/dsh-remote/wechat-channel.mjs 的 HELP_TEXT(用户在微信里看到的唯一说明书)。
|
|
@@ -2894,7 +2889,7 @@ window.__ModuleLoader__.load({
|
|
|
2894
2889
|
var restartPending = !!restartInfo.pending;
|
|
2895
2890
|
// 宿主形态(node 半下发):桌面端(Electron)Host 由桌面应用托管,重启这个动作
|
|
2896
2891
|
// 在那边**做不到也不该做**(强行退出会弹原生致命错误)。面板据此:
|
|
2897
|
-
// ① 不自动倒计时重启;②
|
|
2892
|
+
// ① 不自动倒计时重启;② 桌面端只提示「刷新页面即可」,不引导重启/重开应用(2026-10-17)。
|
|
2898
2893
|
// 旧版 host 的响应里没有该字段 → false,行为与改动前完全一致。
|
|
2899
2894
|
// 🔴 2026-10-11 修:node 半把该字段下在 **connect** 里(`connect.desktopHost`),
|
|
2900
2895
|
// 这里以前读顶层 `st.desktopHost` → **永远 false** → 桌面端被当成 Web 版:
|
|
@@ -3988,6 +3983,55 @@ window.__ModuleLoader__.load({
|
|
|
3988
3983
|
// 「更多」里只留下面那两个**折叠的营销入口**(升级 PRO / 带新用户换会员),用户仍要它们收在这里。
|
|
3989
3984
|
// 🔒 顺带更强的一条不变量:整个面板从此不渲染任何 Mbps / GB / 价格数字
|
|
3990
3985
|
// (「已限速 / 不限速」仍由服务端 quota.limit_enabled / quota.max_mbps 驱动,但只给定性措辞)。
|
|
3986
|
+
/**
|
|
3987
|
+
* 🔧 安装/配对自检提示(2026-10-17):node 半在 /dsh-remote/status 的 `setup` 里下发
|
|
3988
|
+
* `{ kind: ok|runtime_missing|pairing_missing|all_missing, text, detail, backup? }`。
|
|
3989
|
+
*
|
|
3990
|
+
* 由来:真机事故 —— 用户本机 `~/.dsh-remote` 被误删后,面板只显示模糊的"未运行",
|
|
3991
|
+
* 用户不知道是"运行环境没了"还是"没登录",最后手机端干等 47 秒才报错。
|
|
3992
|
+
* 所以这里**必须说清结论 + 给出下一步动作**,但绝不阻塞面板其它功能。
|
|
3993
|
+
*/
|
|
3994
|
+
function renderSetupAlert() {
|
|
3995
|
+
var setup = (st && (st.setup || (st.connect && st.connect.setup))) || null;
|
|
3996
|
+
if (!setup || !setup.kind || setup.kind === "ok") return null;
|
|
3997
|
+
var backup = setup.backup && setup.backup.available ? setup.backup : null;
|
|
3998
|
+
return h("div", { className: "dru-restart-alert", role: "status", style: { marginBottom: 12 } },
|
|
3999
|
+
h("span", { className: "dru-restart-alert-icon" }, "🔧"),
|
|
4000
|
+
h("div", { style: { flex: "1" } },
|
|
4001
|
+
h("div", { className: "dru-restart-alert-title" }, setup.text || "运行环境需要处理"),
|
|
4002
|
+
setup.detail ? h("div", { className: "dru-restart-alert-sub" }, setup.detail) : null,
|
|
4003
|
+
backup ? h("div", { className: "dru-restart-alert-sub" }, backup.text || "检测到历史配对备份,可一键恢复") : null,
|
|
4004
|
+
h("div", { className: "dru-actions", style: { marginTop: 10 } },
|
|
4005
|
+
backup
|
|
4006
|
+
? h("button", {
|
|
4007
|
+
type: "button",
|
|
4008
|
+
className: "dru-btn dru-btn-primary",
|
|
4009
|
+
disabled: busy !== "",
|
|
4010
|
+
onClick: function () {
|
|
4011
|
+
var action = backup.action || {};
|
|
4012
|
+
setBusy("setup");
|
|
4013
|
+
post(action.path || "/dsh-remote/config/restore", action.body || {})
|
|
4014
|
+
.then(function (body) {
|
|
4015
|
+
if (body && body.ok) setMessage({ kind: "ok", text: "已恢复历史配对,正在重新启动 bridge…" });
|
|
4016
|
+
else setMessage({ kind: "err", text: (body && body.error && body.error.message) || "恢复失败,请重试" });
|
|
4017
|
+
if (body && body.connect) setSt(body); else refresh();
|
|
4018
|
+
})
|
|
4019
|
+
.catch(function (e) { setMessage({ kind: "err", text: "恢复失败: " + e.message }); })
|
|
4020
|
+
.finally(function () { setBusy(""); });
|
|
4021
|
+
}
|
|
4022
|
+
}, busy === "setup" ? "恢复中…" : "恢复历史配对")
|
|
4023
|
+
: null,
|
|
4024
|
+
h("button", {
|
|
4025
|
+
type: "button",
|
|
4026
|
+
className: "dru-btn dru-btn-ghost",
|
|
4027
|
+
disabled: busy !== "",
|
|
4028
|
+
onClick: function () { refresh(); }
|
|
4029
|
+
}, "刷新状态")
|
|
4030
|
+
)
|
|
4031
|
+
)
|
|
4032
|
+
);
|
|
4033
|
+
}
|
|
4034
|
+
|
|
3991
4035
|
function renderAccount() {
|
|
3992
4036
|
var a = account;
|
|
3993
4037
|
var plan = a ? a.plan : "free";
|
|
@@ -4038,17 +4082,11 @@ window.__ModuleLoader__.load({
|
|
|
4038
4082
|
var invTipOn = !invOff && !inviteTipDismissed;
|
|
4039
4083
|
var invTipRule = invTipOn ? inviteRuleOf() : null;
|
|
4040
4084
|
// 【0.6.10】营销入口「在哪一层」的四个开关(约定:新用户第一次看得见,用户关掉后收进「更多」):
|
|
4041
|
-
//
|
|
4042
|
-
//
|
|
4043
|
-
//
|
|
4044
|
-
//
|
|
4045
|
-
//
|
|
4046
|
-
// · 付费用户的按钮是「续费 / 转正式 PRO」—— 那是账号维护、不是营销露出,收纳后仍留在正面:
|
|
4047
|
-
// 把付费用户的续费入口藏进「更多」是真实风险(续费靠它,:3096-3098 的到期日也靠它提醒)
|
|
4048
|
-
var showUpsellOnFace = !mktFolded || isMember;
|
|
4049
|
-
var showInviteOnFace = !invOff && !mktFolded;
|
|
4050
|
-
var showUpsellInMore = mktFolded && !isMember;
|
|
4051
|
-
var showInviteInMore = mktFolded && !invOff;
|
|
4085
|
+
// 2026-10-17 版式简化(业主:按钮太多、换行逻辑太多):
|
|
4086
|
+
// 正面**只留一行三个按钮** —— 主行动(升级/续费)+ 用户反馈 + 「更多」;
|
|
4087
|
+
// 「带新用户换会员 / 修改密码 / 加入交流群」全部收进「更多」。
|
|
4088
|
+
// 位置稳定 > 首次曝光:入口永远在同一个地方,用户不用记"上次在哪",也不再需要
|
|
4089
|
+
// mktFolded 那套"先摆正面、关过才收纳"的两层逻辑(气泡仍会提示一次,见 renderInviteTip)。
|
|
4052
4090
|
return h("div", null,
|
|
4053
4091
|
h("div", { className: "dru-user" },
|
|
4054
4092
|
h("div", { className: "dru-avatar" }, (st.config.phone || "D").charAt(0).toUpperCase()),
|
|
@@ -4060,7 +4098,16 @@ window.__ModuleLoader__.load({
|
|
|
4060
4098
|
// (「PRO 会员 · 到期 …」)讲的是**功能性**的到期时间,不是营销。
|
|
4061
4099
|
// 免费用户这里什么都不显示 —— 把「免费档」当标签挂在脸上,正是要减少的营销露出。
|
|
4062
4100
|
isMember ? h("div", { className: "plan" }, plan === "pro_max" ? "Pro Max" : "PRO") : null
|
|
4063
|
-
)
|
|
4101
|
+
),
|
|
4102
|
+
// 2026-10-17(业主):退出登录从按钮行挪到**账号行右侧** —— 它属于"账号维护",
|
|
4103
|
+
// 与账号信息同一行动线;主按钮行也因此少一个按钮。
|
|
4104
|
+
h("button", {
|
|
4105
|
+
type: "button",
|
|
4106
|
+
className: "dru-user-logout",
|
|
4107
|
+
disabled: busy !== "",
|
|
4108
|
+
title: "退出登录(电脑端后台服务与手机端连接不受影响)",
|
|
4109
|
+
onClick: function () { doLogout(false); }
|
|
4110
|
+
}, "退出登录")
|
|
4064
4111
|
),
|
|
4065
4112
|
// 额度状态:不限量说明 / 剩余额度百分比 / 会员到期日。
|
|
4066
4113
|
// 首屏只说「还剩多少」,不提档位名、不写带宽数值(数值在下面「更多」里)。
|
|
@@ -4069,90 +4116,57 @@ window.__ModuleLoader__.load({
|
|
|
4069
4116
|
h("span", null, planText)
|
|
4070
4117
|
),
|
|
4071
4118
|
h("div", { className: "dru-actions", style: { marginTop: 10 } },
|
|
4072
|
-
//
|
|
4073
|
-
|
|
4074
|
-
|
|
4075
|
-
|
|
4076
|
-
|
|
4077
|
-
|
|
4078
|
-
|
|
4079
|
-
//
|
|
4080
|
-
// 按钮文字自带可访问名称;气泡在屏时用 aria-describedby 把「能得多少」也读出来。
|
|
4081
|
-
showInviteOnFace ? h("button", {
|
|
4082
|
-
type: "button",
|
|
4083
|
-
className: "dru-btn dru-btn-ghost",
|
|
4084
|
-
disabled: busy !== "",
|
|
4085
|
-
title: invTipRule ? "带 " + invTipRule.n + " 位新用户可得 " + invTipRule.days + " 天 PRO 会员" : "带新用户换会员",
|
|
4086
|
-
"aria-describedby": invTipOn ? INVITE_TIP_ID : undefined,
|
|
4087
|
-
onClick: function () { setView("invite"); loadInvite(); }
|
|
4088
|
-
}, "🎁 带新用户换会员") : null,
|
|
4119
|
+
// 主行动:免费=升级入口,会员=续费/转正(账号维护,永远在正面第一位)
|
|
4120
|
+
h("button", {
|
|
4121
|
+
type: "button", className: "dru-btn dru-btn-primary",
|
|
4122
|
+
style: { display: "inline-flex", alignItems: "center" }, disabled: busy !== "",
|
|
4123
|
+
onClick: openUpgradeAuth
|
|
4124
|
+
},
|
|
4125
|
+
busy === "upgrade" ? "生成链接中…" : (!isMember ? "🚀 升级 PRO" : source === "trial" ? "🚀 转正式 PRO" : "🔄 续费会员")),
|
|
4126
|
+
// 次要:用户反馈("联系我们"的高频入口,留在正面)
|
|
4089
4127
|
h("button", { type: "button", className: "dru-btn dru-btn-ghost", disabled: busy !== "", onClick: function () { setView("feedback"); } }, "💬 用户反馈"),
|
|
4090
|
-
//
|
|
4091
|
-
//
|
|
4092
|
-
|
|
4093
|
-
|
|
4094
|
-
|
|
4095
|
-
|
|
4096
|
-
|
|
4097
|
-
|
|
4098
|
-
|
|
4099
|
-
|
|
4100
|
-
|
|
4101
|
-
:
|
|
4102
|
-
|
|
4103
|
-
h("button", { type: "button", className: "dru-btn dru-btn-danger", disabled: busy !== "", onClick: function () { doLogout(false); } }, "退出登录")
|
|
4128
|
+
// 「更多」变成同一行里的普通按钮(原来是一条整宽披露条,视觉重量和按钮完全不匹配)。
|
|
4129
|
+
// 无障碍不变:id / aria-expanded / aria-controls 保持原样(用例按它们定位)。
|
|
4130
|
+
h("button", {
|
|
4131
|
+
type: "button",
|
|
4132
|
+
id: MKT_MORE_TOGGLE_ID,
|
|
4133
|
+
className: "dru-btn dru-btn-ghost dru-disclose-inline",
|
|
4134
|
+
"aria-expanded": moreOpen ? "true" : "false",
|
|
4135
|
+
"aria-controls": MKT_MORE_BODY_ID,
|
|
4136
|
+
onClick: function () { setMoreOpen(!moreOpen); }
|
|
4137
|
+
},
|
|
4138
|
+
h("span", null, "更多"),
|
|
4139
|
+
h("span", { className: "dru-disclose-caret", "aria-hidden": "true" }, moreOpen ? "▲" : "▼")
|
|
4140
|
+
)
|
|
4104
4141
|
),
|
|
4105
|
-
// 🎁
|
|
4106
|
-
// 它的 ✕ 同时是「营销入口收纳」的触发信号(0.6.10):点过之后两个入口搬进下面的「更多」。
|
|
4142
|
+
// 🎁 邀请引导气泡:紧跟按钮行(不遮挡按钮)。点 ✕ 只做"不再提示",不再改变入口位置。
|
|
4107
4143
|
renderInviteTip(),
|
|
4108
|
-
|
|
4109
|
-
"升级/续费以带登录态方式打开:点击后生成一次性访问链接并直接跳转,无需重新登录。" +
|
|
4110
|
-
(endsAt && isMember ? "到期后如需继续使用会员权益,请在到期前续费。" : "")),
|
|
4111
|
-
// 🧰 「更多」:主界面上唯一常驻的那一个营销入口(0.6.10)—— 收纳态下正面不再摆按钮行的一部分。
|
|
4112
|
-
// 折叠时它就是一个 .dru-disclose 按钮:命中区 ≥44px、键盘可聚焦(焦点环见样式表末尾的
|
|
4113
|
-
// :focus-visible 基线),开合状态用「展开 ▼ / 收起 ▲」**文字**加箭头表达,不靠颜色区分。
|
|
4114
|
-
// 无障碍:aria-expanded 表达开合,aria-controls 指向下面的内容体(内容体始终在 DOM 里、
|
|
4115
|
-
// 收起时 hidden,所以 aria-controls 永远指向一个真实存在的元素,不指向空气)。
|
|
4116
|
-
// 【0.6.18】加 .dru-disclose-inline:原来它继承 .dru-disclose 的 width:100%,
|
|
4117
|
-
// 在账号卡里被撑成一条横贯整卡的宽条,和上面那排按钮完全不是一个视觉重量(用户:"宽而丑")。
|
|
4118
|
-
// ⚠️ 变体只加在**这一处**:邀请页的「活动规则」披露仍是整行,那是对的(它是那一屏的主折叠)。
|
|
4119
|
-
// id / aria-controls / aria-expanded 一个都不能动 —— remote-access-ui.test.mjs 按它们定位。
|
|
4120
|
-
h("button", {
|
|
4121
|
-
type: "button",
|
|
4122
|
-
id: MKT_MORE_TOGGLE_ID,
|
|
4123
|
-
className: "dru-disclose dru-disclose-inline",
|
|
4124
|
-
style: { marginTop: 10 },
|
|
4125
|
-
"aria-expanded": moreOpen ? "true" : "false",
|
|
4126
|
-
"aria-controls": MKT_MORE_BODY_ID,
|
|
4127
|
-
onClick: function () { setMoreOpen(!moreOpen); }
|
|
4128
|
-
},
|
|
4129
|
-
h("span", null, "更多"),
|
|
4130
|
-
h("span", { className: "dru-disclose-caret", "aria-hidden": "true" }, moreOpen ? "收起 ▲" : "展开 ▼")
|
|
4131
|
-
),
|
|
4144
|
+
// 🧰 「更多」折叠体:带新用户 / 修改密码 / 加入交流群 + 修改密码表单
|
|
4132
4145
|
h("div", {
|
|
4133
4146
|
id: MKT_MORE_BODY_ID,
|
|
4134
4147
|
className: "dru-more-body",
|
|
4135
4148
|
hidden: !moreOpen
|
|
4136
4149
|
},
|
|
4137
|
-
|
|
4138
|
-
|
|
4139
|
-
|
|
4140
|
-
|
|
4141
|
-
|
|
4142
|
-
|
|
4143
|
-
|
|
4144
|
-
|
|
4145
|
-
|
|
4146
|
-
|
|
4147
|
-
|
|
4148
|
-
:
|
|
4149
|
-
|
|
4150
|
+
h("div", { className: "dru-more-actions" },
|
|
4151
|
+
invOff ? null : h("button", {
|
|
4152
|
+
type: "button",
|
|
4153
|
+
className: "dru-btn dru-btn-ghost",
|
|
4154
|
+
disabled: busy !== "",
|
|
4155
|
+
title: invTipRule ? "带 " + invTipRule.n + " 位新用户可得 " + invTipRule.days + " 天 PRO 会员" : "带新用户换会员",
|
|
4156
|
+
onClick: function () { setView("invite"); loadInvite(); }
|
|
4157
|
+
}, "🎁 带新用户换会员"),
|
|
4158
|
+
h("button", { type: "button", className: "dru-btn dru-btn-ghost", disabled: busy !== "", onClick: togglePwdForm }, pwdOpen ? "收起修改密码" : "🔒 修改密码"),
|
|
4159
|
+
community && community.qrcode
|
|
4160
|
+
? h("button", {
|
|
4161
|
+
type: "button",
|
|
4162
|
+
className: "dru-btn dru-btn-ghost",
|
|
4163
|
+
disabled: busy !== "",
|
|
4164
|
+
onClick: function () { setCommOpen(true); }
|
|
4165
|
+
}, "💬 加入交流群")
|
|
4166
|
+
: null
|
|
4167
|
+
),
|
|
4168
|
+
pwdOpen ? renderResetPwdForm(false) : null
|
|
4150
4169
|
),
|
|
4151
|
-
// 额度将尽 / 已被限速 → 🔀 双路块:升级套餐(转化)or 带新用户换会员时长(拉新)。
|
|
4152
|
-
// 用户收纳过(mktFolded)时 renderDualPath 自己返回 null —— 那两条路这时都在「更多」里,
|
|
4153
|
-
// 这里不再重复摆一遍(也不留一个没有按钮的空壳块)。
|
|
4154
|
-
quotaTight ? renderDualPath("quota") : null,
|
|
4155
|
-
pwdOpen ? renderResetPwdForm(false) : null
|
|
4156
4170
|
);
|
|
4157
4171
|
}
|
|
4158
4172
|
|
|
@@ -5021,7 +5035,7 @@ window.__ModuleLoader__.load({
|
|
|
5021
5035
|
// 在线更新成功后出现,若照旧 15 秒自动重启,等于"更新完必定打断用户正在做的事"。
|
|
5022
5036
|
useEffect(function () {
|
|
5023
5037
|
// ⚠️ desktopHost 时**不自动重启**:桌面端重启 Host 由 Electron 托管,插件无权也不该做
|
|
5024
|
-
// (见 node 半 restartHarness
|
|
5038
|
+
// (见 node 半 restartHarness 的说明)。此时只提示刷新页面,不再引导重开应用。
|
|
5025
5039
|
if (!restartPending || isRefreshOnly || isRestartHard || desktopHost || busy !== "" || restartCancelled) { setRestartCountdown(null); return undefined; }
|
|
5026
5040
|
var at = restartInfo.at || 0;
|
|
5027
5041
|
if (autoRestartCancelled(at)) { setRestartCountdown(null); return undefined; }
|
|
@@ -5094,10 +5108,10 @@ window.__ModuleLoader__.load({
|
|
|
5094
5108
|
function openAnnItem(it) {
|
|
5095
5109
|
if (!it) return;
|
|
5096
5110
|
setAnnOpenId(it.id);
|
|
5097
|
-
|
|
5098
|
-
|
|
5099
|
-
|
|
5100
|
-
|
|
5111
|
+
// 2026-10-17 业主约定:**点开不等于已读** —— 点开只上报「点击」,不动「已读」。
|
|
5112
|
+
// 「已读」由服务端在列表/弹窗下发时记(shown),后台把已读与点击分开统计;
|
|
5113
|
+
// 客户端也不在本地改 unread(下一次拉取以服务端为准,避免客户端自造已读口径)。
|
|
5114
|
+
if (it.unread) annReportClick(it.id);
|
|
5101
5115
|
}
|
|
5102
5116
|
/**
|
|
5103
5117
|
* 📣 公告卡:插在「🔑 账号」卡**之前**(运营要用户看到的东西,与"远程访问/账号"同属首屏)。
|
|
@@ -5130,14 +5144,6 @@ window.__ModuleLoader__.load({
|
|
|
5130
5144
|
h("span", { className: "dru-ann-item-time" }, it.created_at ? fmtDate(it.created_at) : "")
|
|
5131
5145
|
);
|
|
5132
5146
|
})),
|
|
5133
|
-
h("div", { className: "dru-hint", style: { marginTop: 8 } },
|
|
5134
|
-
"点开即视为已读(后台按已读与点击分别统计);公告顺序由后台设定。"),
|
|
5135
|
-
h("div", { className: "dru-actions", style: { marginTop: 8 } },
|
|
5136
|
-
h("button", {
|
|
5137
|
-
type: "button",
|
|
5138
|
-
className: "dru-btn dru-btn-ghost",
|
|
5139
|
-
onClick: function () { annFetch({ openPopup: false }); } // 主动刷新:只更新列表,不弹窗
|
|
5140
|
-
}, "刷新公告"))
|
|
5141
5147
|
]);
|
|
5142
5148
|
}
|
|
5143
5149
|
/** 公告详情(复用 .dru-popup 视觉;点遮罩/关闭即收起)。正文按**纯文本**渲染(一期不做富文本)。 */
|
|
@@ -5155,7 +5161,6 @@ window.__ModuleLoader__.load({
|
|
|
5155
5161
|
it.image ? h("img", { className: "dru-ann-img", src: it.image, alt: "公告图片" }) : null
|
|
5156
5162
|
),
|
|
5157
5163
|
h("div", { className: "dru-popup-foot" },
|
|
5158
|
-
h("span", null, "已读状态已同步到后台"),
|
|
5159
5164
|
h("button", { type: "button", onClick: close }, "关闭")
|
|
5160
5165
|
)
|
|
5161
5166
|
)
|
|
@@ -5181,17 +5186,15 @@ window.__ModuleLoader__.load({
|
|
|
5181
5186
|
if (restartPending) {
|
|
5182
5187
|
// 桌面端:刷新类照旧(刷新页面即可);需要重启 Host 的那一类**不给「立即重启」按钮** ——
|
|
5183
5188
|
// 桌面端 Host 由 Electron 托管,插件点了也做不到,给个点不动的按钮只会让人以为是 bug。
|
|
5184
|
-
//
|
|
5189
|
+
// 改成只提示刷新页面(2026-10-17:不再引导重开应用)。
|
|
5185
5190
|
// ⚠️ 非桌面端必须保持**原有判据 isRestartHard**:改成 isRefreshOnly 会让"历史遗留 kind"
|
|
5186
5191
|
// 的文案从"刷新页面即可生效"变成"重启 dsh web 后生效" —— 属于发布版用户可见的文案变更,
|
|
5187
5192
|
// 不该混在桌面端适配里顺手做掉。桌面端才走新文案。
|
|
5188
|
-
|
|
5189
|
-
|
|
5190
|
-
|
|
5191
|
-
|
|
5192
|
-
:
|
|
5193
|
-
? "🔄 插件已更新:重启 dsh web 后生效"
|
|
5194
|
-
: "🔄 插件已更新:刷新页面即可生效");
|
|
5193
|
+
// 2026-10-17(业主):桌面端运行环境由应用托管、更新自动热加载 ——
|
|
5194
|
+
// **不再引导用户重启 DeepSeek Harness**,统一只说「刷新页面即可生效」。
|
|
5195
|
+
var restartRowText = (desktopHost || !isRestartHard)
|
|
5196
|
+
? "🔄 插件已更新:刷新页面即可生效"
|
|
5197
|
+
: "🔄 插件已更新:重启 dsh web 后生效";
|
|
5195
5198
|
rows.push(h("div", { className: "dru-access-tip-row", key: "restart" },
|
|
5196
5199
|
h("span", null, restartRowText),
|
|
5197
5200
|
(isRefreshOnly || !desktopHost) ? h("button", {
|
|
@@ -5228,6 +5231,7 @@ window.__ModuleLoader__.load({
|
|
|
5228
5231
|
)
|
|
5229
5232
|
),
|
|
5230
5233
|
h("div", { className: "dru-settings-body" },
|
|
5234
|
+
renderSetupAlert(),
|
|
5231
5235
|
restartPending
|
|
5232
5236
|
// 【0.6.18】三种"待用户动一下"的情形,文案与主按钮**按 kind 一一对应**:
|
|
5233
5237
|
// · refresh(插件文件在运行中被改写)→ 软提示 + 主按钮「刷新页面」(零风险动作)
|
|
@@ -5238,28 +5242,25 @@ window.__ModuleLoader__.load({
|
|
|
5238
5242
|
className: "dru-restart-alert" + (isRestartHard ? " dru-restart-alert-strong" : ""),
|
|
5239
5243
|
role: isRestartHard ? "alert" : "status"
|
|
5240
5244
|
},
|
|
5241
|
-
h("span", { className: "dru-restart-alert-icon" }, isRestartHard ? "⚠️" : "🔔"),
|
|
5245
|
+
h("span", { className: "dru-restart-alert-icon" }, (!desktopHost && isRestartHard) ? "⚠️" : "🔔"),
|
|
5242
5246
|
h("div", { style: { flex: "1" } },
|
|
5243
5247
|
h("div", { className: "dru-restart-alert-title" },
|
|
5244
|
-
//
|
|
5245
|
-
//
|
|
5246
|
-
//
|
|
5247
|
-
|
|
5248
|
-
?
|
|
5249
|
-
|
|
5250
|
-
|
|
5251
|
-
: (
|
|
5252
|
-
: "需要重启 dsh web 才能载入新插件")),
|
|
5248
|
+
// 2026-10-17(业主):桌面端运行环境由应用托管、插件安装/更新自动热加载 ——
|
|
5249
|
+
// **任何情况都不再引导"重启/退出重开应用"**,只说「刷新页面即可生效」。
|
|
5250
|
+
// 非桌面端(自建 dsh web)保留原有 isRestartHard 判据,文案不变。
|
|
5251
|
+
desktopHost
|
|
5252
|
+
? "插件已更新:刷新页面即可生效"
|
|
5253
|
+
: (isRestartHard
|
|
5254
|
+
? "插件已更新,重启 dsh web 后生效"
|
|
5255
|
+
: (isRefreshOnly ? "插件已更新,刷新页面即可生效" : "需要重启 dsh web 才能载入新插件"))),
|
|
5253
5256
|
h("div", { className: "dru-restart-alert-sub" },
|
|
5254
|
-
|
|
5255
|
-
?
|
|
5256
|
-
|
|
5257
|
-
|
|
5258
|
-
|
|
5259
|
-
|
|
5260
|
-
|
|
5261
|
-
? "当前运行中的 DeepSeek Harness 里还没有载入这个插件。先刷新页面;如果刷新后仍然看不到面板,请退出并重新打开 DeepSeek Harness 应用。"
|
|
5262
|
-
: "当前运行中的 dsh web 里还没有载入这个插件。先刷新页面;如果刷新后仍然看不到面板,再重启一次 dsh web。")),
|
|
5257
|
+
desktopHost
|
|
5258
|
+
? "新插件已经在磁盘上,刷新一下这个页面即可载入(手机端连接与已登录状态都不受影响)。如果刷新后仍看不到面板,在应用内「插件」页重新安装一次即可。"
|
|
5259
|
+
: (isRestartHard
|
|
5260
|
+
? "新版本已经装到这台电脑上,但当前运行的 dsh web 还是旧代码(新的后台路由与面板要等它重新载入)。手机端连接与已登录状态都不受影响;重启期间页面会短暂断开,然后自动恢复。"
|
|
5261
|
+
: (isRefreshOnly
|
|
5262
|
+
? "刚装好/刚更新的插件已在磁盘上,浏览器里的面板还是旧版本。刷新一下这个页面即可(手机端连接与已登录状态都不受影响)。"
|
|
5263
|
+
: "当前运行中的 dsh web 里还没有载入这个插件。先刷新页面;如果刷新后仍然看不到面板,再重启一次 dsh web。"))),
|
|
5263
5264
|
// 【自动化】待重启时自动倒计时重启(用户零操作);倒计时期间可一键取消
|
|
5264
5265
|
restartCountdown !== null && restartCountdown > 0
|
|
5265
5266
|
? h("div", { className: "dru-restart-auto", role: "status" },
|
|
@@ -5311,21 +5312,17 @@ window.__ModuleLoader__.load({
|
|
|
5311
5312
|
: view === "invite" ? renderInvite()
|
|
5312
5313
|
: renderHome()
|
|
5313
5314
|
),
|
|
5314
|
-
//
|
|
5315
|
-
//
|
|
5316
|
-
//
|
|
5317
|
-
h("div", { className: "dru-restart-foot" },
|
|
5318
|
-
h("div", { className: "dru-hint" },
|
|
5319
|
-
|
|
5320
|
-
: "
|
|
5321
|
-
|
|
5322
|
-
|
|
5323
|
-
:
|
|
5324
|
-
|
|
5325
|
-
className: "dru-btn " + (restartPending ? "dru-btn-primary" : "dru-btn-ghost"),
|
|
5326
|
-
disabled: busy !== "",
|
|
5327
|
-
onClick: function () { restartHarness(); }
|
|
5328
|
-
}, busy === "restart" ? "重启中…" : "🔄 重启 dsh web")
|
|
5315
|
+
// 2026-10-17(业主):删除「重启 DeepSeek Harness」整段说明 ——
|
|
5316
|
+
// 桌面端运行环境由应用本身托管、插件安装/更新自动热加载,刷新页面即可;
|
|
5317
|
+
// 只有非桌面端(自建 dsh web)才保留重启入口,且文案只讲这一种场景。
|
|
5318
|
+
desktopHost ? null : h("div", { className: "dru-restart-foot" },
|
|
5319
|
+
h("div", { className: "dru-hint" }, "刷新页面后仍看不到面板时,才需要重启 dsh web;日常安装/更新插件会自动热加载。"),
|
|
5320
|
+
h("button", {
|
|
5321
|
+
type: "button",
|
|
5322
|
+
className: "dru-btn " + (restartPending ? "dru-btn-primary" : "dru-btn-ghost"),
|
|
5323
|
+
disabled: busy !== "",
|
|
5324
|
+
onClick: function () { restartHarness(); }
|
|
5325
|
+
}, busy === "restart" ? "重启中…" : "🔄 重启 dsh web")
|
|
5329
5326
|
),
|
|
5330
5327
|
renderCommunityModal(),
|
|
5331
5328
|
// 📣 公告详情弹窗(与交流群弹窗同一层:都挂在面板根上,点遮罩即收起)
|
package/lib/index.js
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
// - 0.1.2+ ?token 浏览器鉴权会话代持(0.4.1 起)
|
|
21
21
|
//
|
|
22
22
|
// 不依赖任何第三方包:只使用 node 内置模块与 cordis 注入的 webServer 服务。
|
|
23
|
-
import { readFileSync, writeFileSync, mkdirSync, existsSync, realpathSync, accessSync, chmodSync, openSync, closeSync, readSync, fstatSync, rmSync, renameSync, statSync, copyFileSync, cpSync, constants as fsConstants } from "node:fs";
|
|
23
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync, realpathSync, accessSync, chmodSync, openSync, closeSync, readSync, fstatSync, rmSync, renameSync, statSync, copyFileSync, cpSync, readdirSync, constants as fsConstants } from "node:fs";
|
|
24
24
|
import { join, dirname, sep, delimiter, isAbsolute, relative } from "node:path";
|
|
25
25
|
import { execSync, spawn, spawnSync } from "node:child_process";
|
|
26
26
|
import { randomUUID } from "node:crypto";
|
|
@@ -738,6 +738,309 @@ function applySaaSMode(cfg) {
|
|
|
738
738
|
return cfg;
|
|
739
739
|
}
|
|
740
740
|
|
|
741
|
+
// ---------- 安装/配对自检 + 配对信息备份(2026-10-17 事故) ----------
|
|
742
|
+
//
|
|
743
|
+
// 事故:用户本机 `~/.dsh-remote`(bridge 运行时 + **账号配对信息/设备密钥**)被误删 →
|
|
744
|
+
// bridge 无法启动、设备离线;手机端镜像页干等 47 秒;面板状态机只显示模糊的「未运行/准备中」,
|
|
745
|
+
// 用户完全不知道该**重新安装**还是该**重新登录**。
|
|
746
|
+
//
|
|
747
|
+
// 这一段的职责(全部只读 + 明确的下一步动作,绝不偷偷替用户做决定):
|
|
748
|
+
// ① setupStateOf:把「运行时在不在(clients/)」「配对信息在不在(.dsh-config.json)」分开如实报告,
|
|
749
|
+
// 并给出面向非技术用户的一句话结论(缺什么、下一步做什么);
|
|
750
|
+
// ② 运行时缺失 → 复用既有 ensureRuntime 从插件自带 runtime/ 自愈补装(配对缺失**必须用户登录**,
|
|
751
|
+
// 绝不自动填入任何凭据);
|
|
752
|
+
// ③ 配对备份(**双份**,2026-10-17 二次加固):
|
|
753
|
+
// · 配置目录:<relayDir>/backup/dsh-config-<ISO>.json;
|
|
754
|
+
// · **插件 profile 下的独立数据目录**:<profile>/dsh-remote-web-data/config-backup/。
|
|
755
|
+
// 真机事故里用户删的是**整个 ~/.dsh-remote 目录** —— 只备份在 relayDir 里等于没备份,
|
|
756
|
+
// 所以必须再存一份在不随 relayDir 删除、也不随插件包升级(`<id>-plugin/` 会被整体替换)
|
|
757
|
+
// 消失的位置。落点与插件 profile 同源(profile 目录推导规则见 pluginProfileDir,与
|
|
758
|
+
// registerRoutes 用的是同一份),目录 700 / 文件 600 / 每处保留最近 3 份。
|
|
759
|
+
// 启动时发现主配置缺失但备份存在 → **不自动恢复**(防误用旧账号/别人的账号),
|
|
760
|
+
// 只在状态里提示「检测到历史配对备份(时间),可一键恢复」并给恢复入口。
|
|
761
|
+
//
|
|
762
|
+
// 🔒 安全边界(备份文件里是明文密码/设备私钥):
|
|
763
|
+
// · 日志只写**文件名与时间**,绝不写内容;状态对象里也不带任何凭据字段;
|
|
764
|
+
// · 备份失败一律静默(不影响安装/登录;两份里成功一份就算成功);
|
|
765
|
+
// · 恢复前先把"当前状态"再备份一份(可回滚);恢复必须由用户在面板里显式点击;
|
|
766
|
+
// · 彻底卸载时 profile 数据目录里的这份也一起删(否则"已卸载"还留着账号密钥,
|
|
767
|
+
// 而且重装后会提示恢复一个用户已经不要了的旧账号)。
|
|
768
|
+
|
|
769
|
+
/** 配对备份目录(相对 relayDir;第二份在 profile 数据目录,见 PROFILE_* 常量)。 */
|
|
770
|
+
const CONFIG_BACKUP_DIR = "backup";
|
|
771
|
+
const CONFIG_BACKUP_KEEP = 3;
|
|
772
|
+
const CONFIG_BACKUP_PREFIX = "dsh-config-";
|
|
773
|
+
/** profile 下本插件自己的**独立数据目录**:不随 `<id>-plugin/`(升级时整体替换)消失。 */
|
|
774
|
+
const PROFILE_DATA_DIR = "dsh-remote-web-data";
|
|
775
|
+
const PROFILE_BACKUP_SUBDIR = "config-backup";
|
|
776
|
+
/**
|
|
777
|
+
* 备份来源与优先级:`profile` = 插件 profile 数据目录(抗整目录删除),`relay` = 配置目录。
|
|
778
|
+
* 数组顺序 = 默认优先级(恢复默认选"最新的一份",时间相同时 **profile 胜** ——
|
|
779
|
+
* 它能在 relayDir 整个被删时活下来,是同一次写入里更耐久的那份副本)。
|
|
780
|
+
*/
|
|
781
|
+
const CONFIG_BACKUP_SOURCES = ["profile", "relay"];
|
|
782
|
+
/** 来源 → 用户可见的中文标签(只说明位置,不含凭据信息)。 */
|
|
783
|
+
const CONFIG_BACKUP_SOURCE_TEXT = { profile: "插件持久目录", relay: "配置目录" };
|
|
784
|
+
|
|
785
|
+
/**
|
|
786
|
+
* 本插件所在的 profile 目录(与 registerRoutes 同一份推导规则:
|
|
787
|
+
* 由插件自身文件位置推导,覆盖市场 git 安装 / 本地 include 两种形态;取不到用默认 web profile)。
|
|
788
|
+
*
|
|
789
|
+
* ⚠️ 为什么不直接用 plugin 包目录:升级/重装会整目录替换 `<profile>/<id>-plugin/`,
|
|
790
|
+
* 放在它里面的备份会跟着没 —— 所以数据目录放在 profile 下的**同级独立目录**。
|
|
791
|
+
*/
|
|
792
|
+
function pluginProfileDir() {
|
|
793
|
+
let dir = join(homedir(), ".dsh", "profiles", "web");
|
|
794
|
+
try {
|
|
795
|
+
const here = fileURLToPath(import.meta.url);
|
|
796
|
+
// 依次尝试两种安装布局(当前名优先,历史名兜底);split()[0] 未命中时返回原串,需显式判断后再试下一种
|
|
797
|
+
let m = here.split("/dsh-remote-web-plugin/")[0];
|
|
798
|
+
if (m === here) m = here.split("/node_modules/dsh-remote-web/")[0];
|
|
799
|
+
if (m === here) m = here.split("/dsh-remote-ui-plugin/")[0];
|
|
800
|
+
if (m === here) m = here.split("/node_modules/dsh-remote-ui/")[0];
|
|
801
|
+
if (m !== here) dir = m;
|
|
802
|
+
} catch { /* 保持默认 */ }
|
|
803
|
+
return dir;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
/** 某个来源的备份目录绝对路径。 */
|
|
807
|
+
function configBackupDirOf(relayDir, source = "relay") {
|
|
808
|
+
if (source === "relay") return join(relayDir, CONFIG_BACKUP_DIR);
|
|
809
|
+
return join(pluginProfileDir(), PROFILE_DATA_DIR, PROFILE_BACKUP_SUBDIR);
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
/** 人类可读时间(只用于状态/日志的"时间"提示,不含任何凭据)。 */
|
|
813
|
+
function fmtLocalTime(ms) {
|
|
814
|
+
const n = Number(ms);
|
|
815
|
+
if (!Number.isFinite(n) || n <= 0) return "";
|
|
816
|
+
try {
|
|
817
|
+
const d = new Date(n);
|
|
818
|
+
const p = (v) => String(v).padStart(2, "0");
|
|
819
|
+
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
|
|
820
|
+
} catch { return ""; }
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
/** 备份文件名的唯一合法形态(时间戳 + 可选序号;拒绝任何路径成分)。 */
|
|
824
|
+
const CONFIG_BACKUP_FILE_RE = /^dsh-config-[\w.-]+\.json$/;
|
|
825
|
+
|
|
826
|
+
/**
|
|
827
|
+
* 列出**某一个来源**的备份(新 → 旧)。只读目录;**绝不读取/返回备份文件内容**。
|
|
828
|
+
* @returns {Array<{source:string, file:string, name:string, at:number, size:number}>}
|
|
829
|
+
*/
|
|
830
|
+
function listConfigBackupsIn(relayDir, source) {
|
|
831
|
+
try {
|
|
832
|
+
const dir = configBackupDirOf(relayDir, source);
|
|
833
|
+
const files = readdirSync(dir).filter((n) => CONFIG_BACKUP_FILE_RE.test(String(n)));
|
|
834
|
+
return files.map((file) => {
|
|
835
|
+
let at = 0;
|
|
836
|
+
let size = 0;
|
|
837
|
+
try {
|
|
838
|
+
const st = statSync(join(dir, file));
|
|
839
|
+
at = Number(st.mtimeMs) || 0;
|
|
840
|
+
size = Number(st.size) || 0;
|
|
841
|
+
} catch { /* 文件刚被删/无权限:忽略该项时间 */ }
|
|
842
|
+
// name 里体现来源:profile:<file> / relay:<file>(状态/恢复接口都用它,绝不把绝对路径发散出去)
|
|
843
|
+
return { source, file, name: `${source}:${file}`, at, size };
|
|
844
|
+
});
|
|
845
|
+
} catch { return []; }
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
/**
|
|
849
|
+
* 列出**两个来源合并**后的备份(新 → 旧)。
|
|
850
|
+
* 时间相同时 **profile 排前面**(默认优先恢复它,见 CONFIG_BACKUP_SOURCES 注释)。
|
|
851
|
+
* @returns {Array<{source:string, file:string, name:string, at:number, size:number}>}
|
|
852
|
+
*/
|
|
853
|
+
function listConfigBackups(relayDir) {
|
|
854
|
+
const order = new Map(CONFIG_BACKUP_SOURCES.map((s, i) => [s, i]));
|
|
855
|
+
const all = [];
|
|
856
|
+
for (const source of CONFIG_BACKUP_SOURCES) all.push(...listConfigBackupsIn(relayDir, source));
|
|
857
|
+
return all.sort((a, b) => (b.at - a.at) || ((order.get(a.source) ?? 9) - (order.get(b.source) ?? 9))
|
|
858
|
+
|| String(b.file).localeCompare(String(a.file)));
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
/**
|
|
862
|
+
* 解析用户/状态给出的备份引用 → 列表里的真实条目(**只认列表内文件**,防路径穿越/任意文件读)。
|
|
863
|
+
* · 省略 / 空串 → 默认:最新一份(时间相同优先 profile)
|
|
864
|
+
* · `profile:<file>` / `relay:<file>` → 指定来源(状态里下发的就是这种 name)
|
|
865
|
+
* · 裸 `<file>` → 兼容旧状态(2026-10-17 之前只写 relay 一份):两个来源里找,命中多个时优先 profile
|
|
866
|
+
* @returns {{source:string,file:string,name:string,at:number,size:number}|null}
|
|
867
|
+
*/
|
|
868
|
+
function findConfigBackup(relayDir, ref = "") {
|
|
869
|
+
const raw = String(ref || "").trim();
|
|
870
|
+
const all = listConfigBackups(relayDir);
|
|
871
|
+
if (!all.length) return null;
|
|
872
|
+
if (!raw) return all[0]; // listConfigBackups 已按"新→旧、时间相同 profile 先"排好
|
|
873
|
+
if (/[\\/]/.test(raw) || raw.includes("..")) return null; // 明显是路径/穿越:直接拒绝
|
|
874
|
+
const m = /^(profile|relay):(.+)$/.exec(raw);
|
|
875
|
+
if (m) return all.find((b) => b.source === m[1] && b.file === m[2]) || null;
|
|
876
|
+
// 裸文件名(兼容旧状态):同名的可能两份都在 → 优先 profile
|
|
877
|
+
const hits = all.filter((b) => b.file === raw);
|
|
878
|
+
return hits.find((b) => b.source === "profile") || hits[0] || null;
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
/** 只保留**每个来源**最近 CONFIG_BACKUP_KEEP 份(更老的删掉;失败静默)。 */
|
|
882
|
+
function pruneConfigBackups(relayDir, keep = CONFIG_BACKUP_KEEP) {
|
|
883
|
+
for (const source of CONFIG_BACKUP_SOURCES) {
|
|
884
|
+
try {
|
|
885
|
+
const all = listConfigBackupsIn(relayDir, source);
|
|
886
|
+
for (const b of all.slice(Math.max(0, keep))) {
|
|
887
|
+
try { rmSync(join(configBackupDirOf(relayDir, source), b.file), { force: true }); } catch { /* 忽略 */ }
|
|
888
|
+
}
|
|
889
|
+
} catch { /* 单个来源失败不影响另一个 */ }
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
/**
|
|
894
|
+
* 把当前 `.dsh-config.json` **同时**复制到两处(relayDir/backup 与 profile 数据目录),
|
|
895
|
+
* 每处权限 600(目录 700)、各保留最近 3 份。
|
|
896
|
+
*
|
|
897
|
+
* 调用点:① 面板登录/配对成功(POST /dsh-remote/config);② 运行环境安装完成(ensureRuntime 成功);
|
|
898
|
+
* ③ 恢复前先备份当前状态。
|
|
899
|
+
* 🔒 日志只写文件名+时间,**绝不写内容**;任何失败静默返回,绝不影响安装/登录主流程。
|
|
900
|
+
* 单个落点失败(例如 relayDir 只读)不影响另一份成功 —— 至少有一份就返回 ok。
|
|
901
|
+
* @returns {{ok:boolean, at?:number, name?:string, sources?:{profile?:string, relay?:string}, reason?:string}}
|
|
902
|
+
* name = 默认恢复会选中的那一份(profile 优先);sources = 两个落点各自的文件名
|
|
903
|
+
*/
|
|
904
|
+
function backupConfigFile(relayDir) {
|
|
905
|
+
try {
|
|
906
|
+
const src = configPathOf(relayDir);
|
|
907
|
+
if (!existsSync(src)) return { ok: false, reason: "no-config" }; // 还没配对:无可备份
|
|
908
|
+
const at = Date.now();
|
|
909
|
+
const stamp = new Date(at).toISOString().replace(/[:.]/g, "-");
|
|
910
|
+
const sources = {};
|
|
911
|
+
for (const source of CONFIG_BACKUP_SOURCES) {
|
|
912
|
+
try {
|
|
913
|
+
const dir = configBackupDirOf(relayDir, source);
|
|
914
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
915
|
+
// 文件名只含时间戳(不含账号/手机号等任何内容);同一毫秒重复备份时加序号,绝不覆盖已有备份
|
|
916
|
+
let file = `${CONFIG_BACKUP_PREFIX}${stamp}.json`;
|
|
917
|
+
for (let i = 2; existsSync(join(dir, file)) && i < 100; i += 1) {
|
|
918
|
+
file = `${CONFIG_BACKUP_PREFIX}${stamp}-${i}.json`;
|
|
919
|
+
}
|
|
920
|
+
copyFileSync(src, join(dir, file));
|
|
921
|
+
// 备份里是明文密码/设备私钥:POSIX 上 0600;Windows 显式收紧 ACL(mode 是空操作)
|
|
922
|
+
try { chmodSync(join(dir, file), 0o600); } catch { /* 非关键 */ }
|
|
923
|
+
try { hardenFile(join(dir, file)); } catch { /* 非关键 */ }
|
|
924
|
+
sources[source] = file;
|
|
925
|
+
} catch { /* 单个落点失败:另一份仍然有效(relayDir 只读/磁盘满等情况) */ }
|
|
926
|
+
}
|
|
927
|
+
if (!Object.keys(sources).length) return { ok: false, reason: "failed" }; // 两份都失败:静默返回
|
|
928
|
+
pruneConfigBackups(relayDir);
|
|
929
|
+
// 日志只写文件名与时间(两份各列出,便于排查到底哪一份没写成)
|
|
930
|
+
const names = CONFIG_BACKUP_SOURCES.filter((s) => sources[s]).map((s) => `${s}:${sources[s]}`).join("、");
|
|
931
|
+
appendLogLine(relayDir, AUTO_INSTALL_LOG,
|
|
932
|
+
`[dsh-remote-web] 已备份配对配置 ${names}(${fmtLocalTime(at)},权限 600;内容不写入日志)`);
|
|
933
|
+
const preferred = sources.profile ? `profile:${sources.profile}` : `relay:${sources.relay}`;
|
|
934
|
+
return { ok: true, at, name: preferred, sources };
|
|
935
|
+
} catch {
|
|
936
|
+
return { ok: false, reason: "failed" }; // 备份失败静默:绝不影响安装/登录
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
/**
|
|
941
|
+
* 从备份恢复配对配置。**只能由用户在面板里显式触发**(绝不自动调用 —— 旧账号备份可能是
|
|
942
|
+
* 别人的/已失效的,误用会把设备绑到错误账号)。
|
|
943
|
+
* 恢复前先把当前配置再备份一份(同时写两份),失败静默。
|
|
944
|
+
* @param {string} relayDir
|
|
945
|
+
* @param {string} [name] `profile:<file>` / `relay:<file>` / 裸文件名(兼容旧状态);省略 = 最新一份
|
|
946
|
+
* @returns {{ok:boolean, restored?:string, source?:string, at?:number, reason?:string}}
|
|
947
|
+
*/
|
|
948
|
+
function restoreConfigBackup(relayDir, name = "") {
|
|
949
|
+
try {
|
|
950
|
+
const list = listConfigBackups(relayDir);
|
|
951
|
+
if (!list.length) return { ok: false, reason: "no-backup" };
|
|
952
|
+
// ⚠️ 只接受**列表里真实存在**的文件名(防路径穿越/任意文件读)
|
|
953
|
+
const target = findConfigBackup(relayDir, name);
|
|
954
|
+
if (!target) return { ok: false, reason: "no-such-backup" };
|
|
955
|
+
const src = join(configBackupDirOf(relayDir, target.source), target.file);
|
|
956
|
+
if (existsSync(configPathOf(relayDir))) backupConfigFile(relayDir); // 恢复前先备份当前状态(可回滚)
|
|
957
|
+
mkdirSync(relayDir, { recursive: true });
|
|
958
|
+
copyFileSync(src, configPathOf(relayDir));
|
|
959
|
+
try { chmodSync(configPathOf(relayDir), 0o600); } catch { /* 非关键 */ }
|
|
960
|
+
try { hardenFile(configPathOf(relayDir)); } catch { /* 非关键 */ }
|
|
961
|
+
appendLogLine(relayDir, AUTO_INSTALL_LOG,
|
|
962
|
+
`[dsh-remote-web] 用户一键恢复配对配置:${target.name}`
|
|
963
|
+
+ `(来自${CONFIG_BACKUP_SOURCE_TEXT[target.source] || target.source},备份时间 ${fmtLocalTime(target.at)};内容不写入日志)`);
|
|
964
|
+
return { ok: true, restored: target.name, source: target.source, at: target.at };
|
|
965
|
+
} catch {
|
|
966
|
+
return { ok: false, reason: "failed" };
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
/** 运行时本体是否在:`<relayDir>/clients`(插件自带 runtime/ 自愈补装的就是它)。 */
|
|
971
|
+
function runtimeDirPresent(relayDir) {
|
|
972
|
+
try { return existsSync(join(relayDir, "clients")); } catch { return false; }
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
/** 配对信息是否在:`<relayDir>/.dsh-config.json`(账号密码 + 设备密钥)。 */
|
|
976
|
+
function configPresent(relayDir) {
|
|
977
|
+
try { return existsSync(configPathOf(relayDir)); } catch { return false; }
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
/**
|
|
981
|
+
* 安装/配对自检(对外状态的核心):明确区分「运行时缺失」与「配对缺失」。
|
|
982
|
+
*
|
|
983
|
+
* kind:ok | runtime_missing | pairing_missing | all_missing | uninstalled
|
|
984
|
+
* text:面向非技术用户的一句话结论(面板直接展示;旧客户端也可从 connect.detail 看到)
|
|
985
|
+
* @returns {{kind:string, runtimePresent:boolean, configPresent:boolean, text:string, detail:string,
|
|
986
|
+
* relayDir:string, backup:null|{available:boolean,name:string,at:number,text:string,action:object}}}
|
|
987
|
+
*/
|
|
988
|
+
function setupStateOf(relayDir) {
|
|
989
|
+
const rt = runtimeDirPresent(relayDir);
|
|
990
|
+
const cfg = configPresent(relayDir);
|
|
991
|
+
// 两份来源合并(新→旧;时间相同 profile 先)。只要任一份存在就能恢复 ——
|
|
992
|
+
// 这正是「整个 ~/.dsh-remote 被删、但 profile 持久目录那份还在」时的救命路径。
|
|
993
|
+
const backups = listConfigBackups(relayDir);
|
|
994
|
+
const latest = backups[0] || null;
|
|
995
|
+
const backup = (!cfg && latest)
|
|
996
|
+
? {
|
|
997
|
+
available: true,
|
|
998
|
+
name: latest.name, // profile:<file> / relay:<file>(来源可区分)
|
|
999
|
+
source: latest.source, // profile | relay
|
|
1000
|
+
at: latest.at,
|
|
1001
|
+
// 两份都在时把清单也带上(面板/诊断可以显示"还有哪一份");仍不含任何凭据内容
|
|
1002
|
+
sources: backups.map((b) => ({ name: b.name, at: b.at })),
|
|
1003
|
+
text: `检测到历史配对备份(${fmtLocalTime(latest.at) || "时间未知"},`
|
|
1004
|
+
+ `${CONFIG_BACKUP_SOURCE_TEXT[latest.source] || latest.source}),可一键恢复`,
|
|
1005
|
+
action: {
|
|
1006
|
+
kind: "restore_config",
|
|
1007
|
+
method: "POST",
|
|
1008
|
+
path: "/dsh-remote/config/restore",
|
|
1009
|
+
body: { backup: latest.name },
|
|
1010
|
+
},
|
|
1011
|
+
}
|
|
1012
|
+
: null;
|
|
1013
|
+
const base = { runtimePresent: rt, configPresent: cfg, relayDir, backup };
|
|
1014
|
+
|
|
1015
|
+
// 已彻底卸载:不再提示"重新安装"(那是用户自己的选择;重启宿主后自然回到未安装状态)
|
|
1016
|
+
if (UNINSTALLED_DIRS.has(relayDir)) {
|
|
1017
|
+
return { ...base, kind: "uninstalled", text: "", detail: "本插件已彻底卸载(配置与运行时已清理)" };
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
const RUNTIME_TEXT = "运行环境缺失:需要重新安装(应用内插件页重装或 npx @mrrisega/dsh-remote@latest)";
|
|
1021
|
+
const PAIRING_TEXT = "未配对:需要在面板里登录一次(账号密码),登录后会自动重建运行环境并启动 bridge";
|
|
1022
|
+
const runtimeDetail = () => {
|
|
1023
|
+
const inst = installStateOf(relayDir);
|
|
1024
|
+
if (inst.installing) return "插件正在后台用自带的运行环境自动补装(无需操作);若 1~2 分钟后仍缺失,请按上面方式重新安装。";
|
|
1025
|
+
return "插件会在下一轮状态查询时自动尝试用自带的运行环境补装;若补装失败,请按上面方式重新安装。";
|
|
1026
|
+
};
|
|
1027
|
+
const pairingDetail = () => (backup
|
|
1028
|
+
? `登录会重新生成配对信息;也可以先点「恢复历史配对备份」(${fmtLocalTime(latest.at) || "时间未知"})再用旧配对。`
|
|
1029
|
+
: "登录是唯一会写入配对信息的入口:插件绝不自动填入任何凭据。");
|
|
1030
|
+
|
|
1031
|
+
if (!rt && !cfg) {
|
|
1032
|
+
return { ...base, kind: "all_missing", text: RUNTIME_TEXT + "\n" + PAIRING_TEXT,
|
|
1033
|
+
detail: runtimeDetail() + " " + pairingDetail() };
|
|
1034
|
+
}
|
|
1035
|
+
if (!rt) {
|
|
1036
|
+
return { ...base, kind: "runtime_missing", text: RUNTIME_TEXT, detail: runtimeDetail() };
|
|
1037
|
+
}
|
|
1038
|
+
if (!cfg) {
|
|
1039
|
+
return { ...base, kind: "pairing_missing", text: PAIRING_TEXT, detail: pairingDetail() };
|
|
1040
|
+
}
|
|
1041
|
+
return { ...base, kind: "ok", text: "", detail: "" };
|
|
1042
|
+
}
|
|
1043
|
+
|
|
741
1044
|
// ---------- Harness 浏览器会话代持(0.1.2-rc.1+ 的 ?token 鉴权) ----------
|
|
742
1045
|
|
|
743
1046
|
/**
|
|
@@ -1764,6 +2067,7 @@ function ensureRuntime(relayDir) {
|
|
|
1764
2067
|
if (local.ok) {
|
|
1765
2068
|
appendLogLine(relayDir, AUTO_INSTALL_LOG,
|
|
1766
2069
|
`[dsh-remote-web] 已用插件自带的运行环境就地补齐(v${PLUGIN_VERSION},本地拷贝,未使用 npx)`);
|
|
2070
|
+
backupConfigFile(relayDir); // 安装完成后备份配对信息(文件不存在则静默跳过;内容绝不写日志)
|
|
1767
2071
|
telemetryRuntimeReady(relayDir);
|
|
1768
2072
|
return true;
|
|
1769
2073
|
}
|
|
@@ -1845,6 +2149,7 @@ function ensureRuntime(relayDir) {
|
|
|
1845
2149
|
appendLogLine(relayDir, AUTO_INSTALL_LOG, `[auto-install] npx 退出 code=${code ?? "?"}`);
|
|
1846
2150
|
if (code === 0 && runtimeReady(relayDir)) {
|
|
1847
2151
|
noteProvisionSuccess(relayDir);
|
|
2152
|
+
backupConfigFile(relayDir); // 安装完成后备份配对信息(无配置/失败都静默)
|
|
1848
2153
|
telemetryRuntimeReady(relayDir); // 退出码 0 ≠ 装好了
|
|
1849
2154
|
} else {
|
|
1850
2155
|
// 退出码非 0、卡住超时、或退出码 0 但安装脚本没落盘(装到一半/装错包)→ 都算失败并进入退避
|
|
@@ -3386,6 +3691,10 @@ async function composeStatus(relayDir) {
|
|
|
3386
3691
|
// 是否需要重启 DeepSeek harness(首次安装 / 在线更新后必须重启才加载插件本体):
|
|
3387
3692
|
// {pending, kind: first-install|update, reason, at},供面板顶部醒目提示 + 重启按钮。
|
|
3388
3693
|
restart: readRestartState(relayDir),
|
|
3694
|
+
// 安装/配对自检(2026-10-17 事故):运行时(clients/)与配对(.dsh-config.json)是否齐全、
|
|
3695
|
+
// 缺什么、下一步该做什么;配对主配置缺失但备份存在时给出可一键恢复的 backup.action。
|
|
3696
|
+
// 字段契约见 setupStateOf;connect.setup 是同一份对象(bridge-status 短轮询也能拿到)。
|
|
3697
|
+
setup: setupStateOf(relayDir),
|
|
3389
3698
|
// 连接阶段(登录后自动闭环:环境 → bridge 进程 → 设备已在中继注册):
|
|
3390
3699
|
// 面板用它显示「正在准备运行环境…/正在启动 Bridge…/正在连接中继…/已连接 ✅」,
|
|
3391
3700
|
// 并用 /dsh-remote/bridge-status 以 2~3s 短轮询自动推进(无需用户点按钮或刷新页面)。
|
|
@@ -4447,6 +4756,10 @@ function uninstallSelf(relayDir, profileDir, patchFile, pkgFile) {
|
|
|
4447
4756
|
rmSync(join(profileDir, `${id}-plugin`), { recursive: true, force: true });
|
|
4448
4757
|
rmSync(join(profileDir, "node_modules", id), { recursive: true, force: true });
|
|
4449
4758
|
}
|
|
4759
|
+
// 第二份配对备份住在 profile 的独立数据目录(2026-10-17 双份加固):里面是账号密码/设备私钥,
|
|
4760
|
+
// 「彻底卸载」必须一起清掉 —— 否则卸载后仍留着凭据副本,且重装后还会提示恢复一个
|
|
4761
|
+
// 用户已经明确不要了的旧账号配对。
|
|
4762
|
+
rmSync(join(profileDir, PROFILE_DATA_DIR), { recursive: true, force: true });
|
|
4450
4763
|
out.removedDir = true;
|
|
4451
4764
|
} catch { /* ignore */ }
|
|
4452
4765
|
return out;
|
|
@@ -4897,6 +5210,13 @@ async function composeConnect(relayDir, opts = {}) {
|
|
|
4897
5210
|
const elapsed = book.lastAttemptAt ? now - book.lastAttemptAt : wait;
|
|
4898
5211
|
const autoRetrying = phase !== "online" && phase !== "no_account" && retryable !== false;
|
|
4899
5212
|
const nextRetryInMs = autoRetrying ? Math.max(0, wait - elapsed) : 0;
|
|
5213
|
+
// 【2026-10-17 事故】把「安装/配对自检」结论作为独立字段下发:`~/.dsh-remote` 被误删后,
|
|
5214
|
+
// 面板以前只能显示模糊的「未运行/准备中」,用户不知道该重装还是该重新登录。
|
|
5215
|
+
// 只在**需要用户动手**的状态把结论前置到 detail(运行时缺失但插件正在自动补装时仍由既有
|
|
5216
|
+
// no_runtime/installing 文案说明进度,不制造"需要重装"的误导)。
|
|
5217
|
+
const setup = setupStateOf(relayDir);
|
|
5218
|
+
const setupNeedsUser = setup.kind === "all_missing" || setup.kind === "pairing_missing";
|
|
5219
|
+
if (setupNeedsUser) detail = detail ? setup.text + "\n" + detail : setup.text;
|
|
4900
5220
|
const conn = {
|
|
4901
5221
|
phase,
|
|
4902
5222
|
online: phase === "online",
|
|
@@ -4941,6 +5261,10 @@ async function composeConnect(relayDir, opts = {}) {
|
|
|
4941
5261
|
nextRetryInMs,
|
|
4942
5262
|
error,
|
|
4943
5263
|
bindError: bindFresh && bindError ? { code: bindError.code || "", message: bindError.message || "" } : null,
|
|
5264
|
+
// 安装/配对自检(2026-10-17):kind/text/backup.action 见 setupStateOf 的长注释。
|
|
5265
|
+
// 面板据此显示「运行环境缺失:需要重新安装…」「未配对:需要在面板里登录一次…」
|
|
5266
|
+
// 以及「检测到历史配对备份(时间),可一键恢复」。
|
|
5267
|
+
setup,
|
|
4944
5268
|
logPath: join(relayDir, BRIDGE_LOG_FILE),
|
|
4945
5269
|
installLogPath: join(relayDir, AUTO_INSTALL_LOG),
|
|
4946
5270
|
};
|
|
@@ -5179,7 +5503,21 @@ function ensureConnection(relayDir, opts = {}) {
|
|
|
5179
5503
|
const now = Date.now();
|
|
5180
5504
|
if (!opts.force && book.lastAttemptAt && now - book.lastAttemptAt < connectBackoffMs(book.attempts)) return null;
|
|
5181
5505
|
const cfg = loadConfig(relayDir);
|
|
5182
|
-
if (!hasAccountCreds(cfg))
|
|
5506
|
+
if (!hasAccountCreds(cfg)) {
|
|
5507
|
+
// 【2026-10-17 事故】旧规则「未登录 → 不补装也不拉服务」在**运行时**上必须放宽:
|
|
5508
|
+
// 运行时与配对信息都住在 ~/.dsh-remote 里,用户整目录被删后,登录前 ensureRuntime 一次都不会跑,
|
|
5509
|
+
// 于是「登录后自动完成剩余步骤」的第一步就永远缺失。现在:运行时缺失就先复用既有 ensureRuntime
|
|
5510
|
+
// 从插件自带 runtime/ 本地补齐(零网络);**配对信息仍然只能由用户登录重建**,
|
|
5511
|
+
// 绝不自动填入任何账号/密码(安全边界),也绝不因为有备份就自动恢复旧配对。
|
|
5512
|
+
if (!runtimeReady(relayDir)) {
|
|
5513
|
+
if (installStateOf(relayDir).stale) clearStaleInstallMarker(relayDir);
|
|
5514
|
+
book.lastAttemptAt = now;
|
|
5515
|
+
book.attempts += 1;
|
|
5516
|
+
ensureRuntime(relayDir);
|
|
5517
|
+
return { action: "provision" };
|
|
5518
|
+
}
|
|
5519
|
+
return null; // 运行时已就绪但未登录:不拉服务,先让用户登录
|
|
5520
|
+
}
|
|
5183
5521
|
if (!runtimeReady(relayDir)) {
|
|
5184
5522
|
if (installStateOf(relayDir).stale) clearStaleInstallMarker(relayDir);
|
|
5185
5523
|
book.lastAttemptAt = now;
|
|
@@ -6412,17 +6750,9 @@ export function localRequestBlocked(req) {
|
|
|
6412
6750
|
}
|
|
6413
6751
|
|
|
6414
6752
|
function registerRoutes(ctx, relayDir) {
|
|
6415
|
-
// 本插件所在 profile
|
|
6416
|
-
|
|
6417
|
-
|
|
6418
|
-
const here = fileURLToPath(import.meta.url);
|
|
6419
|
-
// 依次尝试两种安装布局(当前名优先,历史名兜底);split()[0] 未命中时返回原串,需显式判断后再试下一种
|
|
6420
|
-
let m = here.split("/dsh-remote-web-plugin/")[0];
|
|
6421
|
-
if (m === here) m = here.split("/node_modules/dsh-remote-web/")[0];
|
|
6422
|
-
if (m === here) m = here.split("/dsh-remote-ui-plugin/")[0];
|
|
6423
|
-
if (m === here) m = here.split("/node_modules/dsh-remote-ui/")[0];
|
|
6424
|
-
if (m !== here) profileDir = m;
|
|
6425
|
-
} catch { /* 保持默认 */ }
|
|
6753
|
+
// 本插件所在 profile:与配对备份的持久数据目录共用同一份推导(pluginProfileDir),
|
|
6754
|
+
// 避免"卸载删的目录"和"备份写的目录"各算各的(2026-10-17 双份备份加固时抽出)。
|
|
6755
|
+
const profileDir = pluginProfileDir();
|
|
6426
6756
|
const routes = [
|
|
6427
6757
|
// 自管理:版本信息 / 检查更新 / 一键更新 / 更新日志 / 彻底卸载
|
|
6428
6758
|
{
|
|
@@ -6900,6 +7230,9 @@ function registerRoutes(ctx, relayDir) {
|
|
|
6900
7230
|
// 账号/模式变化 → 先停掉旧 bridge 并作废它的注册证据,否则新账号永远等不到这台设备
|
|
6901
7231
|
const accountFpBefore = accountFingerprintOf(loadConfig(relayDir));
|
|
6902
7232
|
saveConfig(relayDir, cfg);
|
|
7233
|
+
// 配对成功即备份(2026-10-17 事故):.dsh-config.json 里是账号配对+设备密钥,
|
|
7234
|
+
// 一旦被误删/写坏,用户可以一键恢复最近一份(不自动恢复,见 setupStateOf)。
|
|
7235
|
+
backupConfigFile(relayDir);
|
|
6903
7236
|
invalidateRelayToken(relayDir); // 账号可能变了:丢弃旧 token 缓存,立即用新账号认证
|
|
6904
7237
|
if (accountFingerprintOf(cfg) !== accountFpBefore) resetBridgeForAccountChange(relayDir, "账号/模式已变更");
|
|
6905
7238
|
const bridgeRestart = startBridge(relayDir);
|
|
@@ -6908,6 +7241,43 @@ function registerRoutes(ctx, relayDir) {
|
|
|
6908
7241
|
sendJson(res, 200, { ok: true, bridgeRestart, ...(await composeStatus(relayDir)) });
|
|
6909
7242
|
},
|
|
6910
7243
|
},
|
|
7244
|
+
{
|
|
7245
|
+
/**
|
|
7246
|
+
* 一键恢复历史配对备份(2026-10-17 事故)。
|
|
7247
|
+
*
|
|
7248
|
+
* 只在用户**显式点击**时调用:绝不自动恢复 —— 旧备份可能是别人的账号/已失效的凭据,
|
|
7249
|
+
* 自动套用会把这台设备绑到错误账号(比"未配对"更糟)。恢复前 restoreConfigBackup 会先把
|
|
7250
|
+
* 当前配置再备份一份(若存在),所以这一步随时可回滚。
|
|
7251
|
+
* body = {} → 恢复最新一份;body = { backup: "<文件名>" } → 恢复指定一份。
|
|
7252
|
+
*/
|
|
7253
|
+
method: "POST",
|
|
7254
|
+
path: "/dsh-remote/config/restore",
|
|
7255
|
+
handler: async (req, res) => {
|
|
7256
|
+
const body = await readJsonBody(req);
|
|
7257
|
+
if (body.__parseError) return sendJson(res, 400, { ok: false, error: "JSON 解析失败" });
|
|
7258
|
+
const backupName = typeof body.backup === "string" ? body.backup : "";
|
|
7259
|
+
const restored = restoreConfigBackup(relayDir, backupName);
|
|
7260
|
+
if (!restored.ok) {
|
|
7261
|
+
const msg = restored.reason === "no-backup" || restored.reason === "no-such-backup"
|
|
7262
|
+
? "没有可恢复的配对备份(备份目录为空或文件名不存在)"
|
|
7263
|
+
: "恢复配对备份失败(磁盘不可写/文件损坏),请重新登录一次";
|
|
7264
|
+
return sendJson(res, restored.reason === "failed" ? 500 : 404, { ok: false, code: restored.reason, error: msg });
|
|
7265
|
+
}
|
|
7266
|
+
// 恢复的可能是旧账号的配对:与"登录"同一套收尾 —— 旧 token 作废、旧 bridge 停掉重拉。
|
|
7267
|
+
invalidateRelayToken(relayDir);
|
|
7268
|
+
resetBridgeForAccountChange(relayDir, "从历史备份恢复配对信息");
|
|
7269
|
+
const bridgeRestart = startBridge(relayDir);
|
|
7270
|
+
// 恢复成功本身就是一次配对状态写入 → 让最新备份也指向它(保留时间线)
|
|
7271
|
+
backupConfigFile(relayDir);
|
|
7272
|
+
sendJson(res, 200, {
|
|
7273
|
+
ok: true,
|
|
7274
|
+
restored: restored.restored,
|
|
7275
|
+
restoredAt: restored.at,
|
|
7276
|
+
bridgeRestart,
|
|
7277
|
+
...(await composeStatus(relayDir)),
|
|
7278
|
+
});
|
|
7279
|
+
},
|
|
7280
|
+
},
|
|
6911
7281
|
{
|
|
6912
7282
|
method: "POST",
|
|
6913
7283
|
path: "/dsh-remote/logout",
|
|
@@ -7531,4 +7901,16 @@ export { pinBrowseDirectoryPicker as __pinBrowseDirectoryPicker, schedulePickerP
|
|
|
7531
7901
|
linuxSystemdUnitState as __linuxSystemdUnitState,
|
|
7532
7902
|
selfhealRestartGate as __selfhealRestartGate, noteSelfhealRestart as __noteSelfhealRestart,
|
|
7533
7903
|
noteSelfhealBlocked as __noteSelfhealBlocked, resetSelfhealGuard as __resetSelfhealGuard,
|
|
7534
|
-
selfhealGuardOf as __selfhealGuardOf, SELFHEAL_MAX_RESTARTS as __SELFHEAL_MAX_RESTARTS
|
|
7904
|
+
selfhealGuardOf as __selfhealGuardOf, SELFHEAL_MAX_RESTARTS as __SELFHEAL_MAX_RESTARTS,
|
|
7905
|
+
// 2026-10-17 事故:安装/配对自检 + 配对备份/恢复。见 test/repair-backup.test.mjs
|
|
7906
|
+
// (用例必须把 relayDir 指向 mkdtemp 临时目录、HOME 指向假目录,绝不能在真实
|
|
7907
|
+
// ~/.dsh-remote 或真实 ~/.dsh 上做写入测试)。
|
|
7908
|
+
setupStateOf as __setupStateOf, ensureConnection as __ensureConnection,
|
|
7909
|
+
backupConfigFile as __backupConfigFile, listConfigBackups as __listConfigBackups,
|
|
7910
|
+
restoreConfigBackup as __restoreConfigBackup, runtimeDirPresent as __runtimeDirPresent,
|
|
7911
|
+
CONFIG_BACKUP_KEEP as __CONFIG_BACKUP_KEEP, CONFIG_BACKUP_DIR as __CONFIG_BACKUP_DIR,
|
|
7912
|
+
pruneConfigBackups as __pruneConfigBackups,
|
|
7913
|
+
// 双份备份(relayDir + profile 独立数据目录)的落点/推导/卸载清理都要能被行为级单测钉住
|
|
7914
|
+
configBackupDirOf as __configBackupDirOf, pluginProfileDir as __pluginProfileDir,
|
|
7915
|
+
listConfigBackupsIn as __listConfigBackupsIn, uninstallSelf as __uninstallSelf,
|
|
7916
|
+
PROFILE_DATA_DIR as __PROFILE_DATA_DIR, PROFILE_BACKUP_SUBDIR as __PROFILE_BACKUP_SUBDIR };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-remote-web",
|
|
3
|
-
"version": "0.7.2
|
|
3
|
+
"version": "0.7.2",
|
|
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",
|
|
@@ -554,6 +554,67 @@ div.dsh-ma-menu .dsh-ma-load-warn[hidden] { display: none !important; }
|
|
|
554
554
|
div.dsh-ma-menu .dsh-ma-load-warn .ico { flex: none; }
|
|
555
555
|
div.dsh-ma-menu .dsh-ma-load-warn .txt { min-width: 0; overflow-wrap: anywhere; }
|
|
556
556
|
|
|
557
|
+
/* ===== 加载疑似卡住时的**可操作**底部提示条(2026-10-17 事故新增) =====
|
|
558
|
+
事故现场:用户本机 ~/.dsh-remote(bridge 运行时 + 账号配对信息/设备密钥)被误删 → bridge 起不来、
|
|
559
|
+
手机端镜像页第一次加载**干等 47 秒**才报错;页面上只有悬浮菜单里一行"已等 N 秒…复制给客服",
|
|
560
|
+
没有任何可点的操作。这里补一条**底部轻量提示条**(不是全屏层、不遮挡主界面):
|
|
561
|
+
· 只在 12 秒看门狗判定 slow 之后才**懒创建**(解析期一个节点都不往页面塞);
|
|
562
|
+
· 只占视口底部一条、高度受限(max-height),浮层外的区域照常可点 → 用户还能继续操作;
|
|
563
|
+
· 三个按钮:重试 / 返回设备列表 / 复制诊断信息,外加 ✕ 关闭;关掉后本页不再弹;
|
|
564
|
+
· 认出官方界面后自动收起(判断错了也不会一直挂着)——失败方向仍然安全:
|
|
565
|
+
最坏只是底部多一条**可关闭**的提示,而不是"永远盖住整片界面"。
|
|
566
|
+
动画只在「支持 prefers-reduced-motion 且用户没要求减少动效」时挂上:
|
|
567
|
+
老浏览器不认识这个媒体查询 → 不匹配 → 无动画(无需用 matchMedia 判断,也不会有兼容异常)。 */
|
|
568
|
+
div.dsh-ma-loadbar {
|
|
569
|
+
position: fixed;
|
|
570
|
+
left: max(10px, env(safe-area-inset-left));
|
|
571
|
+
right: max(10px, env(safe-area-inset-right));
|
|
572
|
+
bottom: calc(10px + env(safe-area-inset-bottom));
|
|
573
|
+
/* 低于 FAB(400) 与菜单(401):提示条不得压住"自救出口"(悬浮按钮永远点得到) */
|
|
574
|
+
z-index: 399;
|
|
575
|
+
display: flex; flex-wrap: wrap; align-items: center; gap: 8px;
|
|
576
|
+
box-sizing: border-box;
|
|
577
|
+
width: auto;
|
|
578
|
+
max-height: min(38vh, 260px);
|
|
579
|
+
overflow-y: auto;
|
|
580
|
+
-webkit-overflow-scrolling: touch;
|
|
581
|
+
padding: 10px 12px;
|
|
582
|
+
border-radius: 12px;
|
|
583
|
+
background: var(--dsw-alias-bg-elevated, #fff);
|
|
584
|
+
border: .5px solid var(--dsw-alias-border-l3, rgba(127,127,127,.3));
|
|
585
|
+
box-shadow: 0 6px 24px rgba(0,0,0,.24);
|
|
586
|
+
color: var(--dsw-alias-label-primary, #111);
|
|
587
|
+
font: 400 13px/1.5 -apple-system, system-ui, sans-serif;
|
|
588
|
+
}
|
|
589
|
+
/* ⚠️ 必须显式写 [hidden]:UA 的 [hidden]{display:none} 会被上面的 display:flex 盖过(本仓踩过)。 */
|
|
590
|
+
div.dsh-ma-loadbar[hidden] { display: none !important; }
|
|
591
|
+
div.dsh-ma-loadbar .dsh-ma-loadbar-txt { flex: 1 1 160px; min-width: 0; overflow-wrap: anywhere; }
|
|
592
|
+
div.dsh-ma-loadbar .dsh-ma-loadbar-actions { display: flex; flex-wrap: wrap; gap: 6px; }
|
|
593
|
+
div.dsh-ma-loadbar button {
|
|
594
|
+
flex: none; padding: 8px 12px; border-radius: 9px; cursor: pointer;
|
|
595
|
+
border: .5px solid var(--dsw-alias-border-l3, rgba(127,127,127,.35));
|
|
596
|
+
background: var(--dsw-alias-button-elevated-fill, rgba(127,127,127,.08));
|
|
597
|
+
color: var(--dsw-alias-label-primary, #111);
|
|
598
|
+
font: 600 13px/1 -apple-system, system-ui, sans-serif;
|
|
599
|
+
touch-action: manipulation; -webkit-tap-highlight-color: transparent;
|
|
600
|
+
}
|
|
601
|
+
div.dsh-ma-loadbar button:active { transform: scale(.98); }
|
|
602
|
+
div.dsh-ma-loadbar button.dsh-ma-loadbar-close {
|
|
603
|
+
padding: 6px 9px; border-color: transparent; background: transparent;
|
|
604
|
+
color: var(--dsw-alias-label-tertiary, #6b7280);
|
|
605
|
+
}
|
|
606
|
+
@media (prefers-reduced-motion: no-preference) {
|
|
607
|
+
div.dsh-ma-loadbar { animation: dsh-ma-loadbar-in .18s ease-out; }
|
|
608
|
+
@keyframes dsh-ma-loadbar-in {
|
|
609
|
+
from { opacity: 0; transform: translateY(6px); }
|
|
610
|
+
to { opacity: 1; transform: none; }
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
/* 双保险:显式要求减少动效时一律无动画(老浏览器不匹配上面的 no-preference → 天然无动画) */
|
|
614
|
+
@media (prefers-reduced-motion: reduce) {
|
|
615
|
+
div.dsh-ma-loadbar { animation: none !important; transition: none !important; }
|
|
616
|
+
}
|
|
617
|
+
|
|
557
618
|
/* 会话过期引导条:镜像页在会话过期后只会"连不上",这里给一句能照做的话 + 一个出口。
|
|
558
619
|
非阻塞(顶部细条),点关闭即消失;不拦截任何请求。 */
|
|
559
620
|
div.dsh-ma-sessionbar {
|
|
@@ -656,21 +717,33 @@ const SCRIPT = `(() => {
|
|
|
656
717
|
}
|
|
657
718
|
})();
|
|
658
719
|
|
|
659
|
-
/* ===== 「加载疑似没完成」看门狗(2026-09-25
|
|
660
|
-
|
|
661
|
-
(用户实测:秒表走到 187
|
|
720
|
+
/* ===== 「加载疑似没完成」看门狗(2026-09-25 起;2026-10-17 提速并改成**可操作**提示)=====
|
|
721
|
+
历史:0.6.14 的首屏提示层是全屏覆盖 + 启发式退出 —— 判断错一次就把**已经加载完**的界面永久盖住
|
|
722
|
+
(用户实测:秒表走到 187 秒,刷新也一样)。所以 0.6.16 换了安全的失败方向:只置 slow 标记,
|
|
723
|
+
在**已有的**悬浮菜单里多一行字。这个方向本身是对的,但**提示太晚、且没有操作入口**——
|
|
724
|
+
2026-10-17 真实事故:用户本机 ~/.dsh-remote(运行时 + 账号配对信息/设备密钥)被误删后,
|
|
725
|
+
手机端镜像页第一次加载**干等 47 秒**才报错,页面上只有一行"已等 N 秒…复制给客服",
|
|
726
|
+
用户完全不知道该重新安装还是重新登录。
|
|
662
727
|
|
|
663
|
-
|
|
728
|
+
现在(2026-10-17):
|
|
729
|
+
· 解析期仍然**不创建任何 DOM**(只有几个变量 + 一个定时器)—— 页面还没过主机门,
|
|
664
730
|
更不该有任何覆盖层;
|
|
665
|
-
·
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
·
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
731
|
+
· **12 秒**后仍认不出官方界面 → 置 slow + 显示**底部轻量提示条**(懒创建):
|
|
732
|
+
文案「电脑端可能未连接 / 页面加载较慢」+ 已等待秒数;
|
|
733
|
+
按钮:重试(location.reload)/ 返回设备列表(/app/)/ 复制诊断信息 / ✕ 关闭;
|
|
734
|
+
· 提示条**不是全屏层**:只占底部一条、高度受限、浮层外照常可点、可关闭,
|
|
735
|
+
关掉后本页不再弹;认出官方界面后自动收起 —— 失败方向仍然安全;
|
|
736
|
+
· 菜单里那一行字**保留**(被动展开,不打扰,也仍可复制诊断话术)。
|
|
737
|
+
|
|
738
|
+
⚠️ 12 秒是"开始提示"的阈值,不是"判定失败":限速放开后正常首屏 1~3 秒,
|
|
739
|
+
12 秒既能及时帮到用户,也不至于刚打开就吓人(宁可晚一点,也别误报)。
|
|
740
|
+
⚠️ 注入体是模板字符串:本段注释/文案**一律用「」**,不得出现反引号。 */
|
|
672
741
|
var maLoadWatch = { startedAt: Date.now(), slow: false, wired: false, bound: false, warnEl: null, warnTxt: null,
|
|
673
|
-
reported: false, reportMs: 0
|
|
742
|
+
reported: false, reportMs: 0,
|
|
743
|
+
// 2026-10-17:底部可操作提示条(barEl = 懒创建的节点;barTimer = 秒数刷新)
|
|
744
|
+
barEl: null, barTxt: null, barTimer: 0, dismissed: false };
|
|
745
|
+
/** 看门狗阈值(毫秒):12 秒仍认不出官方界面就开始提示。测试按常量断言,不要散落魔法数。 */
|
|
746
|
+
var MA_LOAD_WATCH_MS = 12 * 1000;
|
|
674
747
|
/** 官方界面是否已经可用(只看最强信号;只用于决定"要不要多显示一行字")。 */
|
|
675
748
|
function maLoadReadyNow() {
|
|
676
749
|
try {
|
|
@@ -718,8 +791,8 @@ const SCRIPT = `(() => {
|
|
|
718
791
|
* ② **全程静默**:任何异常都吞掉,失败绝不影响页面,也绝不做重试;
|
|
719
792
|
* ③ 只在**已有的**同一批请求之外发这一次(一次 POST,几十字节),不是测速、不轮询。
|
|
720
793
|
* body = { ms, outcome, at }:ms=耗时毫秒(截到 [0,600000],与契约一致);outcome ∈ {ok,slow}
|
|
721
|
-
* (slow =
|
|
722
|
-
*/
|
|
794
|
+
* (slow = 12 秒看门狗兜底,是"删失下限",后台只把 ok 计入 P50/P90);at=事件发生的 epoch ms。
|
|
795
|
+
* ⚠️ 2026-10-17:阈值 25s→12s,**字段与一次语义一律不变**(后台契约不动)。 */
|
|
723
796
|
function maReportMirrorLoad(outcome) {
|
|
724
797
|
if (maLoadWatch.reported) return;
|
|
725
798
|
maLoadWatch.reported = true;
|
|
@@ -748,10 +821,140 @@ const SCRIPT = `(() => {
|
|
|
748
821
|
try { ua = String((navigator && navigator.userAgent) || "").slice(0, 120); } catch (e) { /* 忽略 */ }
|
|
749
822
|
return "dsh-remote 手机端加载疑似卡住:等待 " + maLoadWaitedSec() + " 秒后仍未出现对话界面。UA=" + ua;
|
|
750
823
|
}
|
|
824
|
+
|
|
825
|
+
/* ===== 底部可操作提示条(2026-10-17,见上方看门狗注释)=====
|
|
826
|
+
纪律:
|
|
827
|
+
· **懒创建**:只有 slow 且用户没关过时才创建节点;
|
|
828
|
+
· **绝不阻塞**:只占底部一条(CSS 限高),浮层外照常可点;按钮动作全部就地完成;
|
|
829
|
+
· **任何异常都吞掉**:提示层坏掉也绝不影响页面(每个入口都有 try/catch);
|
|
830
|
+
· **只提示一次**:用户点 ✕ 后 dismissed=true,本页不再弹(不骚扰)。 */
|
|
831
|
+
/** 提示条主文案:电脑端未连接/加载慢 + 已等待秒数(秒数由 barTimer 每秒刷新)。 */
|
|
832
|
+
function maLoadBarText() {
|
|
833
|
+
return "电脑端可能未连接 / 页面加载较慢(已等待 " + maLoadWaitedSec() + " 秒)";
|
|
834
|
+
}
|
|
835
|
+
/** 收起提示条并停掉秒数刷新(不改变 dismissed:用于"认出界面后自动收起"这类临时收起)。 */
|
|
836
|
+
function maLoadHideBar() {
|
|
837
|
+
try { if (maLoadWatch.barEl) maLoadWatch.barEl.hidden = true; } catch (e) { /* 忽略 */ }
|
|
838
|
+
try {
|
|
839
|
+
if (maLoadWatch.barTimer) { clearInterval(maLoadWatch.barTimer); maLoadWatch.barTimer = 0; }
|
|
840
|
+
} catch (e2) { /* 忽略 */ }
|
|
841
|
+
}
|
|
842
|
+
/** 用户点 ✕:本页不再弹(dismissed 是**本页**语义;刷新页面会重新开始一次提示机会)。 */
|
|
843
|
+
function maLoadDismissBar() {
|
|
844
|
+
try {
|
|
845
|
+
maLoadWatch.dismissed = true;
|
|
846
|
+
maLoadHideBar();
|
|
847
|
+
maLoadRenderWarn(); // 菜单里那一行也一起收起(关闭 = 本页只提示一次)
|
|
848
|
+
} catch (e) { /* 忽略 */ }
|
|
849
|
+
}
|
|
850
|
+
/** 导航(返回设备列表):assign 失败退回 href。 */
|
|
851
|
+
function maLoadGoto(to) {
|
|
852
|
+
try { window.location.assign(to); return; } catch (e) { /* 继续 */ }
|
|
853
|
+
try { window.location.href = to; } catch (e2) { /* 忽略 */ }
|
|
854
|
+
}
|
|
855
|
+
/** 复制诊断信息(沿用 maLoadCopyLine:含 UA 与耗时);剪贴板不可用时把整句显示在提示条上让用户截图。 */
|
|
856
|
+
function maLoadCopyDiag() {
|
|
857
|
+
var line = maLoadCopyLine();
|
|
858
|
+
var show = function (t) {
|
|
859
|
+
try { if (maLoadWatch.barTxt) maLoadWatch.barTxt.textContent = t; } catch (e) { /* 忽略 */ }
|
|
860
|
+
};
|
|
861
|
+
try {
|
|
862
|
+
if (navigator && navigator.clipboard && navigator.clipboard.writeText) {
|
|
863
|
+
var p = navigator.clipboard.writeText(line);
|
|
864
|
+
if (p && typeof p.then === "function") {
|
|
865
|
+
p.then(function () { show("已复制诊断信息(含等待时长与机型),可直接发给客服。"); },
|
|
866
|
+
function () { show(line); });
|
|
867
|
+
return;
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
} catch (e) { /* 落到显示 */ }
|
|
871
|
+
show(line);
|
|
872
|
+
}
|
|
873
|
+
/** 懒创建提示条(只在 slow 之后调用;解析期绝不碰 DOM)。 */
|
|
874
|
+
function maLoadMountBar() {
|
|
875
|
+
try {
|
|
876
|
+
if (maLoadWatch.barEl || maLoadWatch.dismissed) return maLoadWatch.barEl;
|
|
877
|
+
var body = document.body || document.documentElement;
|
|
878
|
+
if (!body || !body.appendChild) return null;
|
|
879
|
+
var bar = document.createElement("div");
|
|
880
|
+
bar.className = "dsh-ma-loadbar";
|
|
881
|
+
/* role=status + aria-live=polite:读屏软件会把"加载较慢"念出来,但不会抢焦点;
|
|
882
|
+
它**不是** dialog/alert,不抢键盘、不锁滚动。 */
|
|
883
|
+
bar.setAttribute("role", "status");
|
|
884
|
+
bar.setAttribute("aria-live", "polite");
|
|
885
|
+
bar.innerHTML =
|
|
886
|
+
'<div class="dsh-ma-loadbar-txt" data-role="loadbar-txt"></div>' +
|
|
887
|
+
'<div class="dsh-ma-loadbar-actions">' +
|
|
888
|
+
'<button type="button" class="dsh-ma-loadbar-btn" data-role="loadbar-retry">重试</button>' +
|
|
889
|
+
'<button type="button" class="dsh-ma-loadbar-btn" data-role="loadbar-back">返回设备列表</button>' +
|
|
890
|
+
'<button type="button" class="dsh-ma-loadbar-btn" data-role="loadbar-copy">复制诊断信息</button>' +
|
|
891
|
+
'</div>' +
|
|
892
|
+
'<button type="button" class="dsh-ma-loadbar-close" data-role="loadbar-close" aria-label="关闭提示">✕</button>';
|
|
893
|
+
bar.hidden = true;
|
|
894
|
+
maLoadWatch.barEl = bar;
|
|
895
|
+
maLoadWatch.barTxt = bar.querySelector('[data-role="loadbar-txt"]');
|
|
896
|
+
bar.addEventListener("click", function (ev) {
|
|
897
|
+
// 事件委托:真实浏览器里目标可能是按钮内的文字节点;从 target 往上找到 [data-role]
|
|
898
|
+
try {
|
|
899
|
+
if (ev && typeof ev.preventDefault === "function") ev.preventDefault();
|
|
900
|
+
} catch (e0) { /* 忽略 */ }
|
|
901
|
+
try {
|
|
902
|
+
var t = ev && ev.target ? ev.target : null;
|
|
903
|
+
var role = "";
|
|
904
|
+
var guard = 0;
|
|
905
|
+
while (t && t !== bar && guard < 16 && !role) {
|
|
906
|
+
guard += 1;
|
|
907
|
+
try {
|
|
908
|
+
if (t.getAttribute) role = String(t.getAttribute("data-role") || "");
|
|
909
|
+
} catch (e1) { role = ""; }
|
|
910
|
+
t = t.parentNode || null;
|
|
911
|
+
}
|
|
912
|
+
if (role === "loadbar-retry") {
|
|
913
|
+
try { window.location.reload(); } catch (e2) { /* 忽略 */ }
|
|
914
|
+
} else if (role === "loadbar-back") {
|
|
915
|
+
maLoadGoto("/app/"); // 绝对路径:镜像页当前路径可能是 /remote/<deviceId>/…
|
|
916
|
+
} else if (role === "loadbar-copy") {
|
|
917
|
+
maLoadCopyDiag();
|
|
918
|
+
} else if (role === "loadbar-close") {
|
|
919
|
+
maLoadDismissBar();
|
|
920
|
+
}
|
|
921
|
+
} catch (e3) { /* 提示条失败绝不影响页面 */ }
|
|
922
|
+
});
|
|
923
|
+
body.appendChild(bar);
|
|
924
|
+
return bar;
|
|
925
|
+
} catch (e) { return null; }
|
|
926
|
+
}
|
|
927
|
+
/** 按当前状态渲染提示条(slow + 未关闭 → 显示;否则收起)。 */
|
|
928
|
+
function maLoadRenderBar() {
|
|
929
|
+
try {
|
|
930
|
+
if (!maLoadWatch.slow || maLoadWatch.dismissed) { maLoadHideBar(); return; }
|
|
931
|
+
var bar = maLoadWatch.barEl || maLoadMountBar();
|
|
932
|
+
if (!bar) return;
|
|
933
|
+
if (maLoadWatch.barTxt) maLoadWatch.barTxt.textContent = maLoadBarText();
|
|
934
|
+
bar.hidden = false;
|
|
935
|
+
if (!maLoadWatch.barTimer) {
|
|
936
|
+
// 每秒刷新「已等待 N 秒」;同时复核"是不是其实早就好了"——好了就自动收起(不长期挂着)。
|
|
937
|
+
maLoadWatch.barTimer = setInterval(function () {
|
|
938
|
+
try {
|
|
939
|
+
if (!maLoadWatch.slow || maLoadWatch.dismissed) { maLoadHideBar(); return; }
|
|
940
|
+
if (maLoadReadyNow()) {
|
|
941
|
+
maLoadWatch.slow = false;
|
|
942
|
+
maLoadHideBar();
|
|
943
|
+
maLoadRenderWarn(); // 菜单那一行也一起收起
|
|
944
|
+
return;
|
|
945
|
+
}
|
|
946
|
+
if (maLoadWatch.barTxt) maLoadWatch.barTxt.textContent = maLoadBarText();
|
|
947
|
+
} catch (e) { /* 忽略 */ }
|
|
948
|
+
}, 1000);
|
|
949
|
+
try { if (maLoadWatch.barTimer && maLoadWatch.barTimer.unref) maLoadWatch.barTimer.unref(); } catch (e2) { /* 忽略 */ }
|
|
950
|
+
}
|
|
951
|
+
} catch (e) { /* 提示条失败绝不影响页面 */ }
|
|
952
|
+
}
|
|
751
953
|
function maLoadRenderWarn() {
|
|
752
954
|
try {
|
|
753
955
|
if (!maLoadWatch.warnEl) return;
|
|
754
|
-
|
|
956
|
+
// 用户点过 ✕ = 本页不想再被这事打扰:菜单里那一行也一起收起("只提示一次")
|
|
957
|
+
if (!maLoadWatch.slow || maLoadWatch.dismissed) { maLoadWatch.warnEl.hidden = true; return; }
|
|
755
958
|
if (maLoadWatch.warnTxt) {
|
|
756
959
|
maLoadWatch.warnTxt.textContent = "本次页面可能没加载完整(已等 " + maLoadWaitedSec()
|
|
757
960
|
+ " 秒)。点这里复制一句话发给客服,或点下方「返回设备列表」重来。";
|
|
@@ -785,18 +988,18 @@ const SCRIPT = `(() => {
|
|
|
785
988
|
}
|
|
786
989
|
} catch (e) { /* 忽略 */ }
|
|
787
990
|
}
|
|
788
|
-
//
|
|
789
|
-
//
|
|
790
|
-
//
|
|
791
|
-
// 必然是"≥25 秒",这是**删失下限**而不是真实耗时,后台只把它单列,不混进 P50/P90。
|
|
991
|
+
// 12 秒后仍认不出官方界面 → 置慢标记 + 懒创建底部提示条(**只在这时才碰 DOM**)。
|
|
992
|
+
// 同一处顺手把 mirror_load 的**兜底样本**发出去(outcome="slow"):12 秒没就绪的页面上报的
|
|
993
|
+
// 必然是"≥12 秒",这是**删失下限**而不是真实耗时,后台只把它单列,不混进 P50/P90。
|
|
792
994
|
setTimeout(function () {
|
|
793
995
|
try {
|
|
794
996
|
if (maLoadReadyNow()) return;
|
|
795
997
|
maLoadWatch.slow = true;
|
|
796
998
|
if (maLoadWatch.wired) maLoadRenderWarn();
|
|
999
|
+
maLoadRenderBar(); // 底部可操作提示条(重试 / 返回设备列表 / 复制诊断 / 关闭)
|
|
797
1000
|
maReportMirrorLoad("slow");
|
|
798
1001
|
} catch (e) { /* 忽略 */ }
|
|
799
|
-
},
|
|
1002
|
+
}, MA_LOAD_WATCH_MS);
|
|
800
1003
|
|
|
801
1004
|
try {
|
|
802
1005
|
const HOST_RE = new RegExp(${JSON.stringify(RUNTIME_HOST_RE.source)});
|
|
@@ -2511,13 +2714,24 @@ const SCRIPT = `(() => {
|
|
|
2511
2714
|
maObserver = null;
|
|
2512
2715
|
try { if (maInterval) clearInterval(maInterval); } catch (eD2) { /* 忽略 */ }
|
|
2513
2716
|
maInterval = 0;
|
|
2717
|
+
// 加载提示条的秒数刷新也一起停掉(页面卸载/进 bfcache 后不留悬挂回调)
|
|
2718
|
+
try { if (maLoadWatch.barTimer) { clearInterval(maLoadWatch.barTimer); maLoadWatch.barTimer = 0; } } catch (eD3) { /* 忽略 */ }
|
|
2514
2719
|
};
|
|
2515
2720
|
try { window.addEventListener("pagehide", maTeardown); } catch (ePh) { /* 忽略 */ }
|
|
2516
2721
|
try { window.addEventListener("unload", maTeardown); } catch (eUl) { /* 忽略 */ }
|
|
2517
2722
|
/* 手机上"切到别的 App 再切回来"走的常是 bfcache:pagehide(persisted=true) → pageshow(persisted=true)。
|
|
2518
|
-
DOM 原样保留但计时器/观察者已断 —— 这里重新武装,否则回到页面后看门狗就永久失灵了。
|
|
2723
|
+
DOM 原样保留但计时器/观察者已断 —— 这里重新武装,否则回到页面后看门狗就永久失灵了。
|
|
2724
|
+
2026-10-17:加载提示条的秒数刷新也在 maTeardown 里停了,回到前台要按现状重新挂上
|
|
2725
|
+
(否则用户切回来看的是"冻结的已等待 N 秒")。 */
|
|
2519
2726
|
try {
|
|
2520
|
-
window.addEventListener("pageshow", (ev) => {
|
|
2727
|
+
window.addEventListener("pageshow", (ev) => {
|
|
2728
|
+
try {
|
|
2729
|
+
if (ev && ev.persisted) {
|
|
2730
|
+
maArm();
|
|
2731
|
+
if (maLoadWatch.slow && !maLoadWatch.dismissed) maLoadRenderBar();
|
|
2732
|
+
}
|
|
2733
|
+
} catch (ePs) { /* 忽略 */ }
|
|
2734
|
+
});
|
|
2521
2735
|
} catch (ePs2) { /* 忽略 */ }
|
|
2522
2736
|
} catch (e) { if (window.console) console.warn("[dsh-mobile-adapter]", e && e.message); }
|
|
2523
2737
|
})();`;
|