@mrrisega/dsh-remote 0.7.6-beta.9 → 0.7.7
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/clients/dsh-remote/dsh-bridge.mjs +300 -16
- package/clients/dsh-remote/src/channel-ref.mjs +69 -0
- package/clients/dsh-remote/test/channel-ref.test.mjs +250 -0
- package/clients/dsh-remote/test/device-locked.test.mjs +271 -0
- package/dsh-setup.mjs +97 -3
- package/package.json +1 -1
- package/packages/dsh-remote-web/lib/client.js +101 -16
- package/packages/dsh-remote-web/lib/index.js +208 -10
- package/packages/dsh-remote-web/package.json +1 -1
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-bridge.mjs +300 -16
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/src/channel-ref.mjs +69 -0
- package/packages/dsh-remote-web/runtime/dsh-setup.mjs +97 -3
- package/packages/dsh-remote-web/test/device-locked-panel.test.mjs +301 -0
package/dsh-setup.mjs
CHANGED
|
@@ -30,6 +30,7 @@ import { spawn, spawnSync, execSync } from "node:child_process";
|
|
|
30
30
|
import { fileURLToPath } from "node:url";
|
|
31
31
|
import { childStopped, stopChildGracefully } from "./clients/dsh-remote/src/lifecycle.mjs";
|
|
32
32
|
import { discoverUpstream, resolveUpstreamHint, FALLBACK_UPSTREAM } from "./clients/dsh-remote/upstream-discovery.mjs";
|
|
33
|
+
import { normalizeRefCode, applyRefCodeToConfig } from "./clients/dsh-remote/src/channel-ref.mjs";
|
|
33
34
|
|
|
34
35
|
const THIS_DIR = path.dirname(fileURLToPath(import.meta.url)); // 本包目录(仓库或 node_modules)
|
|
35
36
|
const IS_NPM_INSTALL = THIS_DIR.includes(`${path.sep}node_modules${path.sep}`);
|
|
@@ -74,6 +75,39 @@ const BRIDGE_PID_FILE = ".dsh-bridge.pid";
|
|
|
74
75
|
*/
|
|
75
76
|
const DUPLICATE_INSTANCE_EXIT_CODE = 4;
|
|
76
77
|
const DUPLICATE_BRIDGE_BACKOFF_MS = 5 * 60 * 1000;
|
|
78
|
+
/**
|
|
79
|
+
* 设备绑定「永久性失败」的退避(契约 docs/device-overquota-lock-contract.md §6.3/§6.4)。
|
|
80
|
+
*
|
|
81
|
+
* bridge 遇到服务端 `device_bind_cap`(防滥用硬上限)会写 `.bind-error.json` 并以**专用退出码 5**
|
|
82
|
+
* 退出:这是永久性失败 —— 用户不去手机端解绑就不会好。旧逻辑与普通失败共用 exit(1),
|
|
83
|
+
* watcher 于是每 10s 拉起一次、每 10s 撞一次 409(日志刷屏、白耗一次 device-login 额度)。
|
|
84
|
+
* 现在按 10s→1min→5min→15min 指数退避(封顶 30min);`.bind-error.json` 的**内容签名**变化
|
|
85
|
+
* (换账号 / 解绑后重试得到了不同结论 / 提示被清掉)时复位,用户处理完不必等满退避窗口。
|
|
86
|
+
*/
|
|
87
|
+
const DEVICE_BIND_CAP_EXIT_CODE = 5;
|
|
88
|
+
const BIND_CAP_BACKOFF_MS = [10_000, 60_000, 300_000, 900_000];
|
|
89
|
+
const BIND_CAP_BACKOFF_MAX_MS = 30 * 60 * 1000;
|
|
90
|
+
/** bridge 写的「设备登记失败提示」(面板读它展示原因;watcher 用它的签名判断是否需要复位退避)。 */
|
|
91
|
+
const BIND_ERROR_FILE = path.join(CONFIG_DIR, ".bind-error.json");
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* `.bind-error.json` 的稳定签名:只取 code/device_id/message,**不含 at** ——
|
|
95
|
+
* bridge 每次重试都会刷新 at,拿它当变化依据等于"永远变化",退避会形同虚设。
|
|
96
|
+
*/
|
|
97
|
+
function bindErrorSignature() {
|
|
98
|
+
try {
|
|
99
|
+
const j = JSON.parse(fs.readFileSync(BIND_ERROR_FILE, "utf8")) || {};
|
|
100
|
+
return [j.code || "", j.device_id || "", j.message || ""].join("|");
|
|
101
|
+
} catch { return ""; }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** 第 step 次(从 1 开始)退出码 5 后的退避时长:10s→1min→5min→15min,之后翻倍到封顶 30min。 */
|
|
105
|
+
function bindCapBackoffMs(step) {
|
|
106
|
+
const list = BIND_CAP_BACKOFF_MS;
|
|
107
|
+
const n = Math.max(1, Number(step) || 1);
|
|
108
|
+
if (n <= list.length) return Math.min(BIND_CAP_BACKOFF_MAX_MS, list[n - 1]);
|
|
109
|
+
return BIND_CAP_BACKOFF_MAX_MS;
|
|
110
|
+
}
|
|
77
111
|
|
|
78
112
|
// ---------- 运行时自物化(npm/npx 安装 → 固化到配置目录,脱离 npx 缓存) ----------
|
|
79
113
|
// npx 每次安装的缓存目录(~/.npm/_npx/<hash>)不固定:缓存一旦清理,指向它的自启动服务
|
|
@@ -104,9 +138,9 @@ function findDepWs(startDir) {
|
|
|
104
138
|
* 同步运行时文件时一并覆盖的客户端脚本(bridge 会把这些直接发给手机/桌面,必须与装置器一致)。
|
|
105
139
|
* ⚠️ `src/lifecycle.mjs` 也在表里:本安装器**自己**import 它(进程优雅停机的共用实现),
|
|
106
140
|
* 漏同步就会出现「入口脚本在、依赖不在」的半装运行时(插件半据此判定「运行环境已就绪」,
|
|
107
|
-
* 却永远起不来 bridge —— 2026-09-23
|
|
141
|
+
* 却永远起不来 bridge —— 2026-09-23 用户反馈的现场)。`src/channel-ref.mjs` 同理(本安装器也 import)。
|
|
108
142
|
*/
|
|
109
|
-
const RUNTIME_CLIENT_FILES = ["mobile-adapter.mjs", "dsh-bridge.mjs", "e2ee-shim.mjs", "e2ee-client.mjs", "e2ee-shim-script.js", "mobile-adapter.test.mjs", "wechat-channel.mjs", "dsh-events.mjs", "wechat-runtime.mjs", "src/lifecycle.mjs"];
|
|
143
|
+
const RUNTIME_CLIENT_FILES = ["mobile-adapter.mjs", "dsh-bridge.mjs", "e2ee-shim.mjs", "e2ee-client.mjs", "e2ee-shim-script.js", "mobile-adapter.test.mjs", "wechat-channel.mjs", "dsh-events.mjs", "wechat-runtime.mjs", "src/lifecycle.mjs", "src/channel-ref.mjs"];
|
|
110
144
|
|
|
111
145
|
/**
|
|
112
146
|
* 把"客户端脚本"补齐到配置目录。
|
|
@@ -196,6 +230,9 @@ function printHelp() {
|
|
|
196
230
|
自建模式(可选):
|
|
197
231
|
dsh-remote setup --server wss://你的域名:端口 --key 访问密钥
|
|
198
232
|
|
|
233
|
+
可选参数:
|
|
234
|
+
--ref <标识> 指定接入标识(可选,用于路由/灰度分组)
|
|
235
|
+
|
|
199
236
|
登录/连接配置: 打开 dsh web → 设置 → 「远程访问」→ 注册或登录手机号即可
|
|
200
237
|
(自建用户切「自建服务」标签或直接用上方 setup 命令,无需另开页面)。
|
|
201
238
|
文档: ${REPO_URL}
|
|
@@ -204,7 +241,7 @@ function printHelp() {
|
|
|
204
241
|
|
|
205
242
|
// 认识的参数(只给 flag 也要能装上去,所以不认识时才拦)。
|
|
206
243
|
const KNOWN_FLAGS = new Set([
|
|
207
|
-
"--api", "--server", "--key", "--profile",
|
|
244
|
+
"--api", "--server", "--key", "--profile", "--ref",
|
|
208
245
|
"--no-autostart", "--no-plugin", "--no-restart", "--uninstall", "--force"
|
|
209
246
|
]);
|
|
210
247
|
|
|
@@ -1803,6 +1840,12 @@ async function runBridge() {
|
|
|
1803
1840
|
let warnedDuplicateBridge = false;
|
|
1804
1841
|
/** 限流提示只打一次,避免刷屏。 */
|
|
1805
1842
|
let warnedRateLimit = false;
|
|
1843
|
+
/** 设备绑定硬上限(bridge 退出码 5)的指数退避状态(契约 §6.4)。 */
|
|
1844
|
+
let bindCapBackoffStep = 0;
|
|
1845
|
+
let bindCapBackoffUntil = 0;
|
|
1846
|
+
let bindCapErrorSig = "";
|
|
1847
|
+
/** 退避提示只打一次,避免刷屏。 */
|
|
1848
|
+
let warnedBindCap = false;
|
|
1806
1849
|
|
|
1807
1850
|
// watcher:检测 dsh web 是否存活(端口动态解析,绝不写死 3080),存活才启动 bridge。
|
|
1808
1851
|
// 走 isDshWebUp():探测失败会自动做一次动态发现(DSH Desktop 的 43120 / 自定义端口都靠它)。
|
|
@@ -1863,6 +1906,7 @@ async function runBridge() {
|
|
|
1863
1906
|
await stopChildGracefully(bridgeProc); // 先请它优雅退出,跑完微信通道下线通知
|
|
1864
1907
|
bridgeAccountFp = "";
|
|
1865
1908
|
duplicateBridgeBackoffUntil = 0; // 用户明确换了账号:清掉"让位"暂停,立即用新凭据拉起
|
|
1909
|
+
bindCapBackoffStep = 0; bindCapBackoffUntil = 0; bindCapErrorSig = ""; // 换账号 = 绑定前提已变:设备绑定退避一并复位
|
|
1866
1910
|
return; // 下一轮(10s 内)用新配置重新拉起
|
|
1867
1911
|
}
|
|
1868
1912
|
// 指纹又回到一致(或本来就没变)→ 之前的"疑似变化"作废,绝不让它跨轮累积。
|
|
@@ -1891,6 +1935,28 @@ async function runBridge() {
|
|
|
1891
1935
|
}
|
|
1892
1936
|
warnedDuplicateBridge = false;
|
|
1893
1937
|
|
|
1938
|
+
// 设备绑定硬上限(bridge 退出码 5):永久性失败 → 指数退避,别每 10s 空撞一次。
|
|
1939
|
+
// 复位:`.bind-error.json` 的内容签名变了(换账号 / 用户解绑后重试得到不同结论 / 提示被清掉)。
|
|
1940
|
+
// 提示只打一次;退避窗口内直接返回、不拉起 bridge(也不占用一次 device-login 额度)。
|
|
1941
|
+
const bindSig = bindErrorSignature();
|
|
1942
|
+
if (bindSig !== bindCapErrorSig) {
|
|
1943
|
+
if (bindCapBackoffUntil > Date.now()) {
|
|
1944
|
+
console.log("[dsh-remote] 设备登记失败提示已变化(解绑/换账号/服务端结论变了)→ 重置设备绑定退避,立即重试。");
|
|
1945
|
+
}
|
|
1946
|
+
bindCapErrorSig = bindSig;
|
|
1947
|
+
bindCapBackoffStep = 0;
|
|
1948
|
+
bindCapBackoffUntil = 0;
|
|
1949
|
+
}
|
|
1950
|
+
if (Date.now() < bindCapBackoffUntil) {
|
|
1951
|
+
if (!warnedBindCap) {
|
|
1952
|
+
warnedBindCap = true;
|
|
1953
|
+
console.log(`[dsh-remote] 设备绑定已达上限(bridge code=${DEVICE_BIND_CAP_EXIT_CODE})→ 本守护暂停拉起约 ${Math.ceil((bindCapBackoffUntil - Date.now()) / 1000)} 秒(指数退避,最长 30 分钟)。`);
|
|
1954
|
+
console.log(" 请在手机端「管理电脑」解绑不再使用的设备;解绑后会自动恢复,无需重启。");
|
|
1955
|
+
}
|
|
1956
|
+
return;
|
|
1957
|
+
}
|
|
1958
|
+
warnedBindCap = false;
|
|
1959
|
+
|
|
1894
1960
|
reconcileUpstreamEnvFromFile(); // dsh 可能刚重启并换了端口:拉起前再以最新端口文件为准
|
|
1895
1961
|
const alive = await checkUpstream();
|
|
1896
1962
|
if (alive && childStopped(bridgeProc)) {
|
|
@@ -1954,6 +2020,24 @@ async function runBridge() {
|
|
|
1954
2020
|
console.log(`[dsh-remote] bridge 检测到同 deviceId 的另一个实例并退出(code=${code})——本守护暂停 ${Math.round(DUPLICATE_BRIDGE_BACKOFF_MS / 60000)} 分钟后再试,避免两个实例反复互踢。`);
|
|
1955
2021
|
return;
|
|
1956
2022
|
}
|
|
2023
|
+
// 设备绑定硬上限(退出码 5,契约 §6.3/§6.4):永久性失败 → 指数退避重试,
|
|
2024
|
+
// 绝不沿用"10s 后照常重拉"(那会每 10s 撞一次 409,日志刷屏且白耗登录额度)。
|
|
2025
|
+
if (code === DEVICE_BIND_CAP_EXIT_CODE) {
|
|
2026
|
+
const sig = bindErrorSignature();
|
|
2027
|
+
if (sig && sig !== bindCapErrorSig) {
|
|
2028
|
+
bindCapErrorSig = sig;
|
|
2029
|
+
bindCapBackoffStep = 1;
|
|
2030
|
+
console.log("[dsh-remote] 设备绑定失败提示已变化 → 重置设备绑定退避。");
|
|
2031
|
+
} else {
|
|
2032
|
+
bindCapBackoffStep = Math.min(bindCapBackoffStep + 1, 99);
|
|
2033
|
+
}
|
|
2034
|
+
const waitMs = bindCapBackoffMs(bindCapBackoffStep);
|
|
2035
|
+
bindCapBackoffUntil = Date.now() + waitMs;
|
|
2036
|
+
warnedBindCap = false;
|
|
2037
|
+
console.log(`[dsh-remote] bridge 因设备绑定达到硬上限退出(code=${code},第 ${bindCapBackoffStep} 次)——本守护 ${Math.round(waitMs / 1000)} 秒后再试(指数退避,最长 30 分钟)。`);
|
|
2038
|
+
console.log(" 请在手机端「管理电脑」解绑不再使用的设备;解绑后自动恢复,无需重启。");
|
|
2039
|
+
return;
|
|
2040
|
+
}
|
|
1957
2041
|
console.log("[dsh-remote] bridge 退出,等待重启...");
|
|
1958
2042
|
});
|
|
1959
2043
|
// Windows:落 pid 文件,供插件半发现进程(Windows 没有 pgrep/ps)。
|
|
@@ -2022,6 +2106,11 @@ async function setup(argv) {
|
|
|
2022
2106
|
const selfHosted = Boolean(server || key);
|
|
2023
2107
|
|
|
2024
2108
|
let cfg = loadConfig();
|
|
2109
|
+
// 接入标识(可选):本次带合法 `--ref <数字>`(或环境变量 DSH_REF)才写入;没带/非法 →
|
|
2110
|
+
// **保留**配置里已有的值。⚠️ saveConfig 是整对象覆盖写,所以必须在刚 loadConfig() 的这份
|
|
2111
|
+
// 对象上合并再写回 —— 绝不能新建对象,否则升级装一次就把上一次的归因清空了。
|
|
2112
|
+
// 非法值静默忽略:归因是附加信息,绝不能因此让安装失败。
|
|
2113
|
+
cfg = applyRefCodeToConfig(cfg, argv);
|
|
2025
2114
|
|
|
2026
2115
|
if (selfHosted) {
|
|
2027
2116
|
if (!server || !key) {
|
|
@@ -3446,6 +3535,11 @@ else if (cmd === "status") {
|
|
|
3446
3535
|
console.log("配置文件:", CONFIG_PATH);
|
|
3447
3536
|
console.log("连接模式:", local ? `自建服务(${cfg.tunnel_url || "未设置服务器地址"})` : `SaaS 云端服务(${cfg.phone || "未配置账号"})`);
|
|
3448
3537
|
console.log("API:", cfg.api_url || (local ? "(自建模式无需账号 API)" : DEFAULT_API));
|
|
3538
|
+
// 接入标识(安装时 --ref 写入):运维自查用;没设置就不打这一行,保持默认输出稳定。
|
|
3539
|
+
{
|
|
3540
|
+
const refCode = normalizeRefCode(cfg.ref_code);
|
|
3541
|
+
if (refCode) console.log("接入标识:", refCode);
|
|
3542
|
+
}
|
|
3449
3543
|
console.log("远程地址/登录: 打开 dsh web → 设置 → 「远程访问」查看与操作");
|
|
3450
3544
|
// 插件挂载诊断:报障时一眼看出是"链接坏了"还是"别的毛病"
|
|
3451
3545
|
const profileDir = resolveProfileDir([]);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mrrisega/dsh-remote",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.7",
|
|
4
4
|
"description": "手机远程控制 DeepSeek Harness · Remote control DeepSeek Harness (dsh web) from any phone browser — 100% 全功能 App 级体验:发消息、看工具执行、审批权限、改设置、管凭据,含特权操作,免内网穿透。一条命令安装 npx @mrrisega/dsh-remote。Mobile remote control for DSH, self-host or SaaS, no server needed on LAN.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"deepseek-harness",
|
|
@@ -1266,6 +1266,7 @@ window.__ModuleLoader__.load({
|
|
|
1266
1266
|
* 之所以是模块级的:拉取发生在 apply() 里(组件还没挂载),而消费方有两个
|
|
1267
1267
|
* (设置面板的卡片 / shell.overlay 上的弹窗),模块级 store 才能让两边看到同一份数据。
|
|
1268
1268
|
*/
|
|
1269
|
+
var annRetryCount = 0; // 公告首次拉取瞬态失败的有界重试计数(成功即清零)
|
|
1269
1270
|
var annState = {
|
|
1270
1271
|
ok: false, // 拉取成功且结构是我们认识的那一种(老服务端 404 / 结构不符 → 一直是 false)
|
|
1271
1272
|
bubble: false, // bubble_enabled === true 才置真(字段缺失一律当 false = fail closed)
|
|
@@ -1428,6 +1429,7 @@ window.__ModuleLoader__.load({
|
|
|
1428
1429
|
var norm = annNormalize(b);
|
|
1429
1430
|
if (!norm) { annState.ok = false; annState.bubble = false; annState.items = []; annState.unread = 0; annEmit(); return false; }
|
|
1430
1431
|
annState.ok = true;
|
|
1432
|
+
annRetryCount = 0;
|
|
1431
1433
|
annState.bubble = norm.bubble;
|
|
1432
1434
|
annState.items = norm.items;
|
|
1433
1435
|
annState.unread = norm.unread;
|
|
@@ -1436,13 +1438,24 @@ window.__ModuleLoader__.load({
|
|
|
1436
1438
|
}
|
|
1437
1439
|
annEmit();
|
|
1438
1440
|
return true;
|
|
1439
|
-
}).catch(function () {
|
|
1441
|
+
}).catch(function (err) {
|
|
1440
1442
|
// 404(老服务端)/ 不可达 / 超时:fail closed —— 不显示入口、不弹窗,**绝不抛**
|
|
1441
1443
|
annState.ok = false;
|
|
1442
1444
|
annState.bubble = false;
|
|
1443
1445
|
annState.items = [];
|
|
1444
1446
|
annState.unread = 0;
|
|
1445
1447
|
annEmit();
|
|
1448
|
+
// 2026-10-25(用户反馈「公告板块没了」):dsh 重启后页面常常比插件宿主路由先就绪,
|
|
1449
|
+
// 首次拉取失败一次,整页(sessionStorage 一次性标记)就再也没有公告入口。
|
|
1450
|
+
// 这里只对**瞬态失败**做有界重试(最多 2 次、间隔 1.5s/3s);404 明确是老服务端,不重试。
|
|
1451
|
+
// 注意:这只发生在**失败后**,成功路径仍然严格"每次页面加载只拉一次"。
|
|
1452
|
+
var st = Number(err && (err.status || err.statusCode));
|
|
1453
|
+
if (st !== 404 && annRetryCount < 2) {
|
|
1454
|
+
annRetryCount += 1;
|
|
1455
|
+
try {
|
|
1456
|
+
setTimeout(function () { annFetch({ openPopup: false }); }, 1500 * annRetryCount);
|
|
1457
|
+
} catch (e2) { /* 忽略 */ }
|
|
1458
|
+
}
|
|
1446
1459
|
return false;
|
|
1447
1460
|
});
|
|
1448
1461
|
}
|
|
@@ -1623,6 +1636,33 @@ window.__ModuleLoader__.load({
|
|
|
1623
1636
|
try { clearTimeout(t); } catch (e) { /* 忽略 */ }
|
|
1624
1637
|
}
|
|
1625
1638
|
|
|
1639
|
+
// ── 设备超额锁定(契约 docs/device-overquota-lock-contract.md §8)─────────────
|
|
1640
|
+
/**
|
|
1641
|
+
* 「设备被超额锁定」的统一文案(远程访问红条 / 后台服务卡共用,避免两处各写一套漂移)。
|
|
1642
|
+
* 契约 §8.1 要求同时讲清三件事:① 当前套餐可同时用 N 台;② 两条出路(升级 Pro Max / 解绑);
|
|
1643
|
+
* ③ 解绑或升级后**本机会自动解锁**(不是"点重试直到成功",也不能暗示无限重试)。
|
|
1644
|
+
* @param {number|null} limit 服务端给的可用额度;null/未知时用 N 占位
|
|
1645
|
+
*/
|
|
1646
|
+
function lockedPanelDetail(limit) {
|
|
1647
|
+
var n = (limit === 0 || limit) ? String(limit) : "N";
|
|
1648
|
+
return "本机已被锁定(当前套餐可同时使用 " + n + " 台)。"
|
|
1649
|
+
+ "升级 Pro Max 可多台设备同时使用;或到手机端「管理电脑」解绑其他设备后本机会自动解锁。";
|
|
1650
|
+
}
|
|
1651
|
+
/**
|
|
1652
|
+
* 从云端地址派生「升级 Pro Max」页 URL:`https://host/app/…` → `https://host/app/promo`。
|
|
1653
|
+
* **不硬编码任何域名**(官方/自建/测试环境都走同一个派生规则);拿不到地址返回 "",
|
|
1654
|
+
* 调用方回退到既有「带登录态升级」通道(openUpgradeAuth)。
|
|
1655
|
+
*/
|
|
1656
|
+
function upgradePromoUrlOf(base) {
|
|
1657
|
+
var s = String(base || "");
|
|
1658
|
+
if (!s) return "";
|
|
1659
|
+
try {
|
|
1660
|
+
var origin = s.split("/app")[0];
|
|
1661
|
+
if (origin && /^https?:\/\//i.test(origin)) return origin.replace(/\/+$/, "") + "/app/promo";
|
|
1662
|
+
} catch (e) { /* 落到返回空 */ }
|
|
1663
|
+
return "";
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1626
1666
|
// ── 剪贴板(0.6.9 邀请区「一键复制」)────────────────────────────────────
|
|
1627
1667
|
/**
|
|
1628
1668
|
* 复制文本到剪贴板,返回 Promise<boolean>。
|
|
@@ -4664,11 +4704,15 @@ window.__ModuleLoader__.load({
|
|
|
4664
4704
|
// 状态行优先用连接阶段文案(node 半下发的 user-facing 文案):
|
|
4665
4705
|
// 「正在准备运行环境(首次约 1~2 分钟)… / 正在启动 Bridge… / 正在连接中继… / 已连接 ✅ …」;
|
|
4666
4706
|
// 旧版 host 没有 connect 字段 → 完全回退到原来的 running 文案(既有行为不变)。
|
|
4707
|
+
// 锁定态的状态行不能沿用 CONNECT_TEXT.error("连接失败,请按下方提示处理"):用户会以为是自己网络/软件坏了。
|
|
4708
|
+
var connLocked = Boolean(connInfo && connInfo.locked);
|
|
4667
4709
|
var statusTxt = st === null
|
|
4668
4710
|
? "查询中…"
|
|
4669
4711
|
: !loggedInSaaS
|
|
4670
4712
|
? "请先登录(下方账号卡片)后启用远程访问"
|
|
4671
|
-
: connInfo
|
|
4713
|
+
: connInfo
|
|
4714
|
+
? (connLocked ? "本机已被锁定(当前套餐设备额度已满,升级 Pro Max 或解绑其他设备后自动恢复)" : connText)
|
|
4715
|
+
: serviceRunning ? "已连接(可远程访问)" : "等待设备连接";
|
|
4672
4716
|
var dotOn = loggedInSaaS ? (connInfo ? connOnline : serviceRunning) : false;
|
|
4673
4717
|
var dotCls = "dru-dot " + (dotOn ? "dru-dot-on" : "dru-dot-off");
|
|
4674
4718
|
|
|
@@ -4751,6 +4795,22 @@ window.__ModuleLoader__.load({
|
|
|
4751
4795
|
return h("div", null, rows);
|
|
4752
4796
|
}
|
|
4753
4797
|
|
|
4798
|
+
/**
|
|
4799
|
+
* 锁定态的主行动:「🚀 升级 Pro Max」。
|
|
4800
|
+
* 链接从云端账号信息派生(public-config 的 app_url / 面板已拿到的 remoteUrl → origin + /app/promo),
|
|
4801
|
+
* **不硬编码域名**;派生不出地址(未登录/取配置失败)时退回既有「带登录态升级」通道。
|
|
4802
|
+
*/
|
|
4803
|
+
function openLockedUpgrade() {
|
|
4804
|
+
var promo = "";
|
|
4805
|
+
try { promo = upgradePromoUrlOf((st && st.remoteUrl) || (pub && pub.app_url)); } catch (e) { promo = ""; }
|
|
4806
|
+
if (promo) {
|
|
4807
|
+
try { window.open(promo, "_blank", "noopener"); } catch (e2) { /* 弹窗被拦截:下面给提示 */ }
|
|
4808
|
+
return;
|
|
4809
|
+
}
|
|
4810
|
+
try { openUpgradeAuth(); } catch (e3) {
|
|
4811
|
+
try { setMessage({ kind: "err", text: "打开升级页失败,请在手机端「我的 → 升级 Pro Max」完成升级" }); } catch (e4) { /* 忽略 */ }
|
|
4812
|
+
}
|
|
4813
|
+
}
|
|
4754
4814
|
function renderConnectBlock() {
|
|
4755
4815
|
if (!loggedInSaaS || !connInfo) return null;
|
|
4756
4816
|
var phase = connInfo.phase || "";
|
|
@@ -4760,18 +4820,29 @@ window.__ModuleLoader__.load({
|
|
|
4760
4820
|
? "设备已登记到你的账号:手机端「设备列表」可以看到这台电脑。"
|
|
4761
4821
|
: "设备已注册到中继:手机端扫码或打开链接即可进入这台电脑。");
|
|
4762
4822
|
}
|
|
4823
|
+
// 锁定态(契约 §8.1):设备被超额锁定 ≠ 普通故障 —— bridge 活着并在 30s 轮询等解锁,
|
|
4824
|
+
// 所以文案给「升级 / 解绑」两条出路,主行动是升级按钮,且**不显示"自动重试 N 次"**(那会暗示死循环)。
|
|
4825
|
+
var lockedInfo = connInfo.locked && typeof connInfo.locked === "object" ? connInfo.locked : null;
|
|
4826
|
+
var isLocked = Boolean(lockedInfo) || Boolean(connError && connError.code === "device_locked");
|
|
4763
4827
|
var rows = [];
|
|
4764
4828
|
if (phase === "error") {
|
|
4765
4829
|
rows.push(h("div", { className: "dru-msg dru-msg-err", style: { marginTop: 6 } },
|
|
4766
|
-
"⚠️ " + ((connError && connError.message) || connText)));
|
|
4830
|
+
(isLocked ? "🔒 " : "⚠️ ") + ((connError && connError.message) || connText)));
|
|
4767
4831
|
if (connInfo.detail) rows.push(h("div", { className: "dru-hint", style: { marginTop: 4 } }, connInfo.detail));
|
|
4768
|
-
if (connInfo.nextRetryInMs > 0) {
|
|
4832
|
+
if (!isLocked && connInfo.nextRetryInMs > 0) {
|
|
4769
4833
|
rows.push(h("div", { className: "dru-hint", style: { marginTop: 4 } },
|
|
4770
4834
|
"已自动重试 " + (connInfo.attempts || 0) + " 次,约 " + Math.ceil(connInfo.nextRetryInMs / 1000) + " 秒后自动再试(也可以点下面按钮立刻重试)"));
|
|
4771
4835
|
}
|
|
4772
|
-
|
|
4773
|
-
|
|
4774
|
-
h("
|
|
4836
|
+
if (isLocked) {
|
|
4837
|
+
// 主行动:升级 Pro Max(红色错误条里最该点的就是它)
|
|
4838
|
+
rows.push(h("div", { className: "dru-actions", style: { marginTop: 8 } },
|
|
4839
|
+
h("button", { type: "button", className: "dru-btn dru-btn-primary", onClick: openLockedUpgrade }, "🚀 升级 Pro Max"),
|
|
4840
|
+
h("button", { type: "button", className: "dru-btn dru-btn-ghost", onClick: copyDiagnostics }, copiedDiag ? "已复制" : "复制诊断信息")));
|
|
4841
|
+
} else {
|
|
4842
|
+
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" ? "重试中…" : "重试"),
|
|
4844
|
+
h("button", { type: "button", className: "dru-btn dru-btn-ghost", onClick: copyDiagnostics }, copiedDiag ? "已复制" : "复制诊断信息")));
|
|
4845
|
+
}
|
|
4775
4846
|
rows.push(h("div", { className: "dru-hint", style: { marginTop: 6 } },
|
|
4776
4847
|
"查看日志:后台服务 " + (connInfo.logPath || "") + " ,安装 " + (connInfo.installLogPath || "") + "(把「复制诊断信息」的内容发给客服可加速定位)"));
|
|
4777
4848
|
var diagToolsErr = renderDiagTools();
|
|
@@ -5121,7 +5192,8 @@ window.__ModuleLoader__.load({
|
|
|
5121
5192
|
// 自动连接进度(登录后由面板 2.5s 短轮询自动推进):让用户在 bridge 卡也能一眼看到
|
|
5122
5193
|
// 「到底走到哪一步了」——进程在跑≠能用,注册到中继才算。
|
|
5123
5194
|
connInfo && connPhase && connPhase !== "no_account"
|
|
5124
|
-
? h("div", { className: "dru-hint", style: { marginTop: 6 } },
|
|
5195
|
+
? h("div", { className: "dru-hint", style: { marginTop: 6 } },
|
|
5196
|
+
"自动连接进度:" + (connInfo.locked ? "本机已被锁定(当前套餐额度已满,升级或解绑后自动恢复)" : connText))
|
|
5125
5197
|
: null,
|
|
5126
5198
|
h("div", { className: "dru-actions", style: { marginTop: 10 } },
|
|
5127
5199
|
!serviceRunning
|
|
@@ -5145,13 +5217,22 @@ window.__ModuleLoader__.load({
|
|
|
5145
5217
|
"⚠️ 上一次启动失败(自启动入口失效),已自动清理失效自启动项并重新补装运行环境。稍候会自动恢复;仍失败请点「一键更新」。")
|
|
5146
5218
|
: null,
|
|
5147
5219
|
st && st.service && st.service.bindError
|
|
5148
|
-
?
|
|
5149
|
-
|
|
5150
|
-
|
|
5151
|
-
|
|
5152
|
-
|
|
5153
|
-
|
|
5154
|
-
|
|
5220
|
+
? (function (be) {
|
|
5221
|
+
var isLocked = be.code === "device_locked" || be.code === "device_limit_exceeded";
|
|
5222
|
+
var isBindCap = be.code === "device_bind_cap";
|
|
5223
|
+
var beLimit = (be.limit === 0 || be.limit) ? be.limit : null;
|
|
5224
|
+
return h("div", { className: "dru-msg dru-msg-err", style: { marginTop: 8 } },
|
|
5225
|
+
(isLocked ? "🔒 本机已被锁定:" : "⚠️ 设备注册失败:") + (be.message || "未说明原因"),
|
|
5226
|
+
h("div", { className: "dru-hint", style: { marginTop: 4 } },
|
|
5227
|
+
isLocked
|
|
5228
|
+
// 契约 §8.1 的新语义:不是"解绑旧设备才能登录",而是"超额锁定 → 升级或解绑,本机自动解锁"
|
|
5229
|
+
? lockedPanelDetail(beLimit)
|
|
5230
|
+
: isBindCap
|
|
5231
|
+
// device_bind_cap = 防滥用硬上限(bridge 退出码 5):守护按指数退避重试,不是无限重试
|
|
5232
|
+
? "已绑定设备数达到上限:请在手机端「管理电脑」解绑不再使用的设备;解绑后本机会自动恢复(后台守护会按指数退避重试,无需反复点按钮)。"
|
|
5233
|
+
: "请确认网络与账号状态后重试;仍未解决可点下方「彻底卸载」后重新安装。")
|
|
5234
|
+
);
|
|
5235
|
+
})(st.service.bindError)
|
|
5155
5236
|
: null
|
|
5156
5237
|
),
|
|
5157
5238
|
// 版本与更新(自管理:检测新版 / 一键在线更新 / 彻底卸载)
|
|
@@ -5332,8 +5413,12 @@ window.__ModuleLoader__.load({
|
|
|
5332
5413
|
function renderAnnouncementsCard() {
|
|
5333
5414
|
if (!annState.ok || !annState.bubble) return null;
|
|
5334
5415
|
var items = annState.items;
|
|
5335
|
-
if (!items.length) return null;
|
|
5336
5416
|
var unread = annState.unread;
|
|
5417
|
+
// 2026-10-25:列表为空时**保留板块**(显示「暂无公告」)而不是整卡消失 ——
|
|
5418
|
+
// 板块本身是用户确认"公告功能还在"的锚点,静默消失会被当成功能坏了。
|
|
5419
|
+
if (!items.length) {
|
|
5420
|
+
return card("📣 公告", [h("div", { className: "dru-hint" }, "暂无公告")]);
|
|
5421
|
+
}
|
|
5337
5422
|
return card("📣 公告" + (unread > 0 ? "(" + unread + " 条未读)" : ""), [
|
|
5338
5423
|
h("div", { className: "dru-ann-list" }, items.map(function (it) {
|
|
5339
5424
|
return h("button", {
|