@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
|
@@ -2533,6 +2533,24 @@ function scheduleRuntime(relayDir) {
|
|
|
2533
2533
|
logSelfhealReady(relayDir, cfg, "进程探测(pgrep/pid 文件)");
|
|
2534
2534
|
return;
|
|
2535
2535
|
}
|
|
2536
|
+
// ── ②.5 设备绑定硬上限(bridge 退出码 5):永久性失败时**不要**每 12 秒重启一次 ──
|
|
2537
|
+
// 本自愈循环与 ensureConnection 是两条独立的拉起路径。watcher(dsh-setup.mjs)已在按
|
|
2538
|
+
// 10s→1min→5min→15min(封顶 30min)退避等待时,这里再 startBridge(systemctl restart /
|
|
2539
|
+
// launchctl kickstart)会把 watcher 整个换掉、把它的内存退避清零 —— 又回到 10s 空撞一次。
|
|
2540
|
+
// 所以退避窗口内直接放弃本拍(人工操作走面板按钮 = force 路径,不受这条约束)。
|
|
2541
|
+
const bindErrNow = readBindError(relayDir);
|
|
2542
|
+
const bindGateNow = bindFailGate(relayDir, bindErrNow);
|
|
2543
|
+
if (bindGateNow.permanent && bindGateNow.blocked) {
|
|
2544
|
+
const book = connectBook(relayDir);
|
|
2545
|
+
if (book.bindFailLogged !== bindGateNow.until) {
|
|
2546
|
+
book.bindFailLogged = bindGateNow.until;
|
|
2547
|
+
appendLogLine(relayDir, AUTO_INSTALL_LOG,
|
|
2548
|
+
`[dsh-remote-web] 自愈暂停拉起 bridge:设备绑定已达上限(退出码 ${DEVICE_BIND_CAP_EXIT_CODE}),`
|
|
2549
|
+
+ `约 ${Math.ceil(bindGateNow.remainingMs / 1000)} 秒后再试(指数退避,最长 30 分钟);`
|
|
2550
|
+
+ "请在手机端「管理电脑」解绑不再使用的设备,解绑后自动恢复");
|
|
2551
|
+
}
|
|
2552
|
+
return;
|
|
2553
|
+
}
|
|
2536
2554
|
// ── ③ 确实没在跑 → 过护栏后才动手(绝不允许"判据失败"直接变成"每 12 秒杀一次隧道")──
|
|
2537
2555
|
const gate = selfhealRestartGate(relayDir);
|
|
2538
2556
|
if (!gate.allowed) { noteSelfhealBlocked(relayDir, gate); return; }
|
|
@@ -4983,6 +5001,10 @@ function resetBridgeForAccountChange(relayDir, why) {
|
|
|
4983
5001
|
// 它必须无条件生效 —— 所以顺手清掉自愈护栏额度,绝不允许"自愈循环之前用完了 3 次额度"
|
|
4984
5002
|
// 导致切换账号后新 bridge 起不来(那会让设备继续留在旧账号里,是 2026-09-19 那个老故障的翻版)。
|
|
4985
5003
|
resetSelfhealGuard(relayDir);
|
|
5004
|
+
// 换账号 = 设备绑定前提已变:插件半的设备绑定退避也一并复位(新账号的额度/硬上限可能完全不同)
|
|
5005
|
+
const bk = connectBook(relayDir);
|
|
5006
|
+
bk.bindFail = { sig: bindFailSignatureOf(readBindError(relayDir)), step: 0, until: 0 };
|
|
5007
|
+
bk.bindFailLogged = 0;
|
|
4986
5008
|
const stopped = stopBridge(relayDir);
|
|
4987
5009
|
// 脱离进程形态(launchd 托管不住时的兜底)也要收干净,否则它会用旧凭据继续跑
|
|
4988
5010
|
try { stopBridgeDetached(relayDir, { force: true }); } catch { /* 非关键 */ }
|
|
@@ -5078,6 +5100,18 @@ const INSTALL_STALE_MS = 10 * 60 * 1000;
|
|
|
5078
5100
|
const BIND_ERROR_FRESH_MS = 60 * 60 * 1000;
|
|
5079
5101
|
/** 自动重试退避(毫秒):可重试错误按此节奏自动重试,用尽后停在 60s(仍会自动重试,不出现死胡同)。 */
|
|
5080
5102
|
const CONNECT_BACKOFF_MS = [2000, 4000, 8000, 16_000, 30_000, 60_000];
|
|
5103
|
+
/**
|
|
5104
|
+
* 设备登记「永久性失败」的退避(契约 docs/device-overquota-lock-contract.md §6.3/§6.4)。
|
|
5105
|
+
*
|
|
5106
|
+
* bridge 遇到服务端 `device_bind_cap`(防滥用硬上限)会写 `.bind-error.json` 并以**专用退出码 5**
|
|
5107
|
+
* 退出。watcher(dsh-setup.mjs)自己会做 10s→1min→5min→15min(封顶 30min)的指数退避,但它的状态
|
|
5108
|
+
* 在**内存**里 —— 插件半的 startBridge(`launchctl kickstart` / `systemctl restart`)会把 watcher
|
|
5109
|
+
* 一并换掉,等于每次自愈都把它辛苦攒的退避清零、又 10s 空撞一次。所以插件半必须自己记一份:
|
|
5110
|
+
* 退避窗口内不再发起拉起,`.bind-error.json` 的内容签名变化(解绑/换账号/被清掉)时复位。
|
|
5111
|
+
*/
|
|
5112
|
+
const DEVICE_BIND_CAP_EXIT_CODE = 5;
|
|
5113
|
+
const BIND_CAP_BACKOFF_MS = [10_000, 60_000, 300_000, 900_000];
|
|
5114
|
+
const BIND_CAP_BACKOFF_MAX_MS = 30 * 60 * 1000;
|
|
5081
5115
|
|
|
5082
5116
|
/** 连接阶段文案(面向非技术用户;node 半算好,面板直接显示,避免两端各写一套)。 */
|
|
5083
5117
|
const CONNECT_TEXT = {
|
|
@@ -5096,7 +5130,7 @@ const connectBooks = new Map();
|
|
|
5096
5130
|
function connectBook(relayDir) {
|
|
5097
5131
|
let b = connectBooks.get(relayDir);
|
|
5098
5132
|
if (!b) {
|
|
5099
|
-
b = { attempts: 0, lastAttemptAt: 0, reportAt: 0, watcherSeenAt: 0 };
|
|
5133
|
+
b = { attempts: 0, lastAttemptAt: 0, reportAt: 0, watcherSeenAt: 0, bindFail: { sig: "", step: 0, until: 0 } };
|
|
5100
5134
|
connectBooks.set(relayDir, b);
|
|
5101
5135
|
}
|
|
5102
5136
|
return b;
|
|
@@ -5106,6 +5140,63 @@ function connectBackoffMs(attempts) {
|
|
|
5106
5140
|
const i = Math.min(Math.max(attempts, 0), CONNECT_BACKOFF_MS.length - 1);
|
|
5107
5141
|
return CONNECT_BACKOFF_MS[i];
|
|
5108
5142
|
}
|
|
5143
|
+
|
|
5144
|
+
/**
|
|
5145
|
+
* `.bind-error.json` 的稳定签名:只取 code/device_id/message,**不含 at** ——
|
|
5146
|
+
* bridge 每次重试都会刷新 at,拿它当"变了"的依据等于永远变化、退避形同虚设。
|
|
5147
|
+
*/
|
|
5148
|
+
function bindFailSignatureOf(bindError) {
|
|
5149
|
+
if (!bindError || typeof bindError !== "object") return "";
|
|
5150
|
+
return [bindError.code || "", bindError.device_id || "", bindError.message || ""].join("|");
|
|
5151
|
+
}
|
|
5152
|
+
|
|
5153
|
+
/** 是否是「永久性」登记失败(硬上限;用户不解绑就不会自愈,必须指数退避而不是每 10s 重试)。 */
|
|
5154
|
+
function isPermanentBindFailure(bindError) {
|
|
5155
|
+
return Boolean(bindError && typeof bindError === "object" && bindError.code === "device_bind_cap");
|
|
5156
|
+
}
|
|
5157
|
+
|
|
5158
|
+
/** 第 step 次(从 1 开始)拉起后的退避时长:10s→1min→5min→15min,之后封顶 30min。 */
|
|
5159
|
+
function bindCapBackoffMs(step) {
|
|
5160
|
+
const n = Math.max(1, Number(step) || 1);
|
|
5161
|
+
if (n <= BIND_CAP_BACKOFF_MS.length) return Math.min(BIND_CAP_BACKOFF_MAX_MS, BIND_CAP_BACKOFF_MS[n - 1]);
|
|
5162
|
+
return BIND_CAP_BACKOFF_MAX_MS;
|
|
5163
|
+
}
|
|
5164
|
+
|
|
5165
|
+
/**
|
|
5166
|
+
* 插件半的「设备绑定硬上限」退避闸门:**只读**,由 ensureConnection 在动手前调用。
|
|
5167
|
+
* 签名变化(换账号 / 用户解绑后重试得到不同结论 / 成功登记把提示清掉)→ 复位。
|
|
5168
|
+
* @returns {{permanent:boolean, blocked:boolean, until:number, step:number, remainingMs:number}}
|
|
5169
|
+
*/
|
|
5170
|
+
function bindFailGate(relayDir, bindError) {
|
|
5171
|
+
const book = connectBook(relayDir);
|
|
5172
|
+
if (!book.bindFail) book.bindFail = { sig: "", step: 0, until: 0 };
|
|
5173
|
+
const st = book.bindFail;
|
|
5174
|
+
const sig = bindFailSignatureOf(bindError);
|
|
5175
|
+
if (sig !== st.sig) {
|
|
5176
|
+
st.sig = sig;
|
|
5177
|
+
st.step = 0;
|
|
5178
|
+
st.until = 0;
|
|
5179
|
+
}
|
|
5180
|
+
const now = Date.now();
|
|
5181
|
+
return {
|
|
5182
|
+
permanent: isPermanentBindFailure(bindError),
|
|
5183
|
+
blocked: now < st.until,
|
|
5184
|
+
until: st.until,
|
|
5185
|
+
step: st.step,
|
|
5186
|
+
remainingMs: Math.max(0, st.until - now),
|
|
5187
|
+
};
|
|
5188
|
+
}
|
|
5189
|
+
|
|
5190
|
+
/** 记一次「即将(重新)拉起 bridge」:永久性失败时步进退避,返回退避后的状态。 */
|
|
5191
|
+
function noteBindFailAttempt(relayDir, bindError) {
|
|
5192
|
+
const book = connectBook(relayDir);
|
|
5193
|
+
if (!book.bindFail) book.bindFail = { sig: bindFailSignatureOf(bindError), step: 0, until: 0 };
|
|
5194
|
+
const st = book.bindFail;
|
|
5195
|
+
st.sig = bindFailSignatureOf(bindError);
|
|
5196
|
+
st.step = Math.min(st.step + 1, BIND_CAP_BACKOFF_MS.length + 1);
|
|
5197
|
+
st.until = Date.now() + bindCapBackoffMs(st.step);
|
|
5198
|
+
return st;
|
|
5199
|
+
}
|
|
5109
5200
|
/** 本机是否已有可用账号凭据(SaaS 账号或自建访问密钥)——没有就谈不上自动连接,先引导登录。 */
|
|
5110
5201
|
function hasAccountCreds(cfg) {
|
|
5111
5202
|
return Boolean((cfg.phone || cfg.email) && cfg.password) || Boolean(cfg.local_key);
|
|
@@ -5365,6 +5456,31 @@ async function composeConnect(relayDir, opts = {}) {
|
|
|
5365
5456
|
const state = bridgeStateFile(relayDir);
|
|
5366
5457
|
const deviceId = String(cfg.device_id || "");
|
|
5367
5458
|
|
|
5459
|
+
// ── 「设备被超额锁定」证据(契约 §8.1)──
|
|
5460
|
+
// 两个来源都认:① 新 bridge 写的状态文件 phase=locked + locked{plan,limit,upgrade_to};
|
|
5461
|
+
// ② `.bind-error.json` code=device_locked / 旧版 device_limit_exceeded(老 bridge/半升级场景)。
|
|
5462
|
+
// 锁定 ≠ 故障:设备行还在、手机端能看到它,只是中继按套餐额度拒绝接入;bridge 不退出,
|
|
5463
|
+
// 每 30s 轮询 /api/devices,用户解绑或升级后自动解锁重连。面板的任务是把这两条出路讲清楚。
|
|
5464
|
+
const stateLocked = state && state.phase === "locked" && state.locked && typeof state.locked === "object" ? state.locked : null;
|
|
5465
|
+
const bindLocked = bindError && (bindError.code === "device_locked" || bindError.code === "device_limit_exceeded")
|
|
5466
|
+
? bindError
|
|
5467
|
+
: null;
|
|
5468
|
+
const lockRaw = stateLocked || bindLocked || null;
|
|
5469
|
+
const locked = lockRaw
|
|
5470
|
+
? {
|
|
5471
|
+
message: String(lockRaw.message || ""),
|
|
5472
|
+
plan: String(lockRaw.plan || ""),
|
|
5473
|
+
limit: Number.isFinite(Number(lockRaw.limit)) && Number(lockRaw.limit) >= 0 ? Number(lockRaw.limit) : null,
|
|
5474
|
+
upgradeTo: String(lockRaw.upgrade_to || lockRaw.upgradeTo || "pro_max")
|
|
5475
|
+
}
|
|
5476
|
+
: null;
|
|
5477
|
+
/** 契约 §8.1 的固定文案(盘点口径:可同时用 N 台 + 两条出路 + 会自动解锁,不让用户以为要重启)。 */
|
|
5478
|
+
const lockedDetail = locked
|
|
5479
|
+
? `本机已被锁定(当前套餐可同时使用 ${locked.limit === null ? "N" : locked.limit} 台)。`
|
|
5480
|
+
+ "升级 Pro Max 可多台设备同时使用;或到手机端「管理电脑」解绑其他设备后本机会自动解锁。"
|
|
5481
|
+
+ "(后台服务保持运行并每 30 秒检查一次,解锁后自动恢复,无需重启。)"
|
|
5482
|
+
: "";
|
|
5483
|
+
|
|
5368
5484
|
// ── 中继注册证据(只有 bridge 进程在跑时才算「现在能用」) ──
|
|
5369
5485
|
// ⚠️ 先判「这个 bridge 是不是当前账号的」:账号切换后进程不会自己换凭据,
|
|
5370
5486
|
// 旧账号留下的状态文件(phase=online + 旧 device_id)会让面板谎报「已连接」,
|
|
@@ -5432,6 +5548,15 @@ async function composeConnect(relayDir, opts = {}) {
|
|
|
5432
5548
|
+ "正在后台自动补装(约 1~2 分钟),装完会自动启动 bridge。";
|
|
5433
5549
|
}
|
|
5434
5550
|
}
|
|
5551
|
+
} else if (locked) {
|
|
5552
|
+
// 契约 §8.1:锁定态给「error」阶段(用户确实用不了),但**不是**普通故障:
|
|
5553
|
+
// 文案要说清「两条出路」+「会自动解锁」,且不能出现"无限重试"的暗示。
|
|
5554
|
+
// 必须排在「bridge 没在跑」之前:锁定是服务端额度结论,bridge 为等待解锁会一直跑着;
|
|
5555
|
+
// 即使进程被手动停掉,用户看到的也该是「已被锁定」而不是「正在启动」。
|
|
5556
|
+
phase = "error";
|
|
5557
|
+
retryable = true;
|
|
5558
|
+
error = { code: "device_locked", message: locked.message || "本机已被锁定(超出当前套餐设备额度)" };
|
|
5559
|
+
detail = lockedDetail;
|
|
5435
5560
|
} else if (!bridgeRunning) {
|
|
5436
5561
|
if (launchd.crashing) {
|
|
5437
5562
|
phase = "error";
|
|
@@ -5443,7 +5568,11 @@ async function composeConnect(relayDir, opts = {}) {
|
|
|
5443
5568
|
} else if (bindFresh && bindError) {
|
|
5444
5569
|
phase = "error";
|
|
5445
5570
|
error = { code: bindError.code || "bind_failed", message: bindError.message || "设备登记失败" };
|
|
5446
|
-
|
|
5571
|
+
// device_bind_cap = 防滥用硬上限(bridge 退出码 5):守护按指数退避重试(10s→1min→5min→15min,
|
|
5572
|
+
// 封顶 30min),不是"每 10 秒撞一次"。文案要给出用户能做的事,不能暗示无限重试。
|
|
5573
|
+
detail = bindError.code === "device_bind_cap"
|
|
5574
|
+
? "已绑定设备数达到上限:请在手机端「管理电脑」解绑不再使用的设备;解绑后本机会自动恢复(守护已按指数退避重试,无需手动重启)。"
|
|
5575
|
+
: "bridge 因设备登记失败退出,已进入自动重试;请按提示处理后重试。";
|
|
5447
5576
|
} else if (wedge.wedged && wedge.exhausted) {
|
|
5448
5577
|
// 守护活着、却反复拉不起 bridge,且已自动重启到上限 → 这是**真的**卡死了,给明确结论 + 出路。
|
|
5449
5578
|
// 旧实现在这种状态下永远显示「正在启动 Bridge…」,用户只能反复点修复(实测反馈)。
|
|
@@ -5479,8 +5608,10 @@ async function composeConnect(relayDir, opts = {}) {
|
|
|
5479
5608
|
} else if (bindFresh && bindError) {
|
|
5480
5609
|
phase = "error";
|
|
5481
5610
|
error = { code: bindError.code || "bind_failed", message: bindError.message || "设备登记失败" };
|
|
5482
|
-
|
|
5483
|
-
|
|
5611
|
+
// 旧语义(「解绑旧设备才能登录」「无限重试」)已废弃:超额现在一律走 locked 分支(上面),
|
|
5612
|
+
// 只有硬上限/网络类注册失败会落到这里。
|
|
5613
|
+
detail = bindError.code === "device_bind_cap"
|
|
5614
|
+
? "已绑定设备数达到上限:请在手机端「管理电脑」解绑不再使用的设备;解绑后本机会自动恢复(守护已按指数退避重试,无需手动重启)。"
|
|
5484
5615
|
: "请确认网络与账号状态后点「重试」;仍不成功可点「复制诊断信息」发给客服。";
|
|
5485
5616
|
} else if (!accountCurrent) {
|
|
5486
5617
|
// 进程在跑,但它是**上一个账号**的 bridge:必须重启才能用新账号登记
|
|
@@ -5545,7 +5676,19 @@ async function composeConnect(relayDir, opts = {}) {
|
|
|
5545
5676
|
attempts: book.attempts,
|
|
5546
5677
|
nextRetryInMs,
|
|
5547
5678
|
error,
|
|
5548
|
-
|
|
5679
|
+
// 设备被超额锁定(契约 §8):null = 未锁定;非 null 时面板显示锁定文案 + 「🚀 升级 Pro Max」按钮。
|
|
5680
|
+
// limit 为 null 表示服务端没给额度数值(旧版),文案退回占位。
|
|
5681
|
+
locked,
|
|
5682
|
+
bindError: bindFresh && bindError
|
|
5683
|
+
? {
|
|
5684
|
+
code: bindError.code || "",
|
|
5685
|
+
message: bindError.message || "",
|
|
5686
|
+
deviceId: bindError.device_id || "",
|
|
5687
|
+
plan: bindError.plan || "",
|
|
5688
|
+
limit: Number.isFinite(Number(bindError.limit)) && Number(bindError.limit) >= 0 ? Number(bindError.limit) : null,
|
|
5689
|
+
upgradeTo: bindError.upgrade_to || ""
|
|
5690
|
+
}
|
|
5691
|
+
: null,
|
|
5549
5692
|
// 安装/配对自检(2026-10-17):kind/text/backup.action 见 setupStateOf 的长注释。
|
|
5550
5693
|
// 面板据此显示「运行环境缺失:需要重新安装…」「未配对:需要在面板里登录一次…」
|
|
5551
5694
|
// 以及「检测到历史配对备份(时间),可一键恢复」。
|
|
@@ -5786,6 +5929,27 @@ function ensureConnection(relayDir, opts = {}) {
|
|
|
5786
5929
|
if (opts.force) resetSelfhealGuard(relayDir);
|
|
5787
5930
|
if (opts.force) { book.attempts = 0; book.lastAttemptAt = 0; }
|
|
5788
5931
|
const now = Date.now();
|
|
5932
|
+
// ── 设备绑定「永久性失败」→ 插件半指数退避(契约 §6.3/§6.4)────────────────────────────
|
|
5933
|
+
// bridge 撞到防滥用硬上限(device_bind_cap)会写 .bind-error.json 并以**退出码 5** 退出。
|
|
5934
|
+
// watcher 自己会退避,但它的退避状态只存在内存里 —— 插件半的 startBridge(launchctl kickstart /
|
|
5935
|
+
// systemctl restart)会把 watcher 整个换掉,等于每轮自愈都把它的退避清零、又回 10s 空撞一次。
|
|
5936
|
+
// 所以插件半必须自己记一份:退避窗口内**不**发起拉起(也不白耗一次 device-login),
|
|
5937
|
+
// 窗口过了才允许一次新尝试,并按 10s→1min→5min→15min(封顶 30min)继续加倍。
|
|
5938
|
+
// ⚠️ 必须放在「通用 connect 退避」之前判断:否则上一轮动作留下的 2s connect 退避会先 return null,
|
|
5939
|
+
// 面板看到的是"什么也没发生",而不是"设备绑定已达上限、正在退避等待"。
|
|
5940
|
+
// 人工操作(opts.force = 面板「重试 / 一键修复」)永远立即放行:安全阀不能把用户锁在门外。
|
|
5941
|
+
const bindErrorNow = readBindError(relayDir);
|
|
5942
|
+
if (opts.force) book.bindFail = { sig: bindFailSignatureOf(bindErrorNow), step: 0, until: 0 };
|
|
5943
|
+
const bindGateEarly = bindFailGate(relayDir, bindErrorNow);
|
|
5944
|
+
if (bindGateEarly.permanent && !opts.force && bindGateEarly.blocked && runtimeReady(relayDir)) {
|
|
5945
|
+
// 同一条结论只写一行日志(面板每 2~3s 轮询一次,逐轮写会把安装日志刷爆)
|
|
5946
|
+
if (book.bindFailLogged !== bindGateEarly.until) {
|
|
5947
|
+
book.bindFailLogged = bindGateEarly.until;
|
|
5948
|
+
appendLogLine(relayDir, AUTO_INSTALL_LOG,
|
|
5949
|
+
`[dsh-remote-web] 设备绑定已达上限(bridge 退出码 ${DEVICE_BIND_CAP_EXIT_CODE})→ 自愈暂停拉起约 ${Math.ceil(bindGateEarly.remainingMs / 1000)} 秒(指数退避,最长 30 分钟);请在手机端「管理电脑」解绑不再使用的设备,解绑后自动恢复`);
|
|
5950
|
+
}
|
|
5951
|
+
return { action: "bind-cap-backoff", until: bindGateEarly.until, step: bindGateEarly.step };
|
|
5952
|
+
}
|
|
5789
5953
|
if (!opts.force && book.lastAttemptAt && now - book.lastAttemptAt < connectBackoffMs(book.attempts)) return null;
|
|
5790
5954
|
const cfg = loadConfig(relayDir);
|
|
5791
5955
|
if (!hasAccountCreds(cfg)) {
|
|
@@ -5810,6 +5974,9 @@ function ensureConnection(relayDir, opts = {}) {
|
|
|
5810
5974
|
ensureRuntime(relayDir);
|
|
5811
5975
|
return { action: "provision" };
|
|
5812
5976
|
}
|
|
5977
|
+
// 运行时已就绪 → 退避窗口外允许一次新尝试:先记账(下一次要等更久),再照常走下面的判断。
|
|
5978
|
+
if (bindGateEarly.permanent && !opts.force) noteBindFailAttempt(relayDir, bindErrorNow);
|
|
5979
|
+
else book.bindFailLogged = 0;
|
|
5813
5980
|
const launchd = launchdStatus();
|
|
5814
5981
|
const manual = manualStatus(relayDir);
|
|
5815
5982
|
const bridgeProcs = [...(manual.allBridge || []), ...(manual.bridge || [])];
|
|
@@ -6661,8 +6828,8 @@ export const __telemetryInternals = {
|
|
|
6661
6828
|
* 安装信息上报(插件侧通道):登录后把本机安装信息发给企业端,供「已登录用户升级插件后刷新版本号」
|
|
6662
6829
|
* 之类的约定统计。无账号凭据不上报;失败静默(绝不阻塞面板、不弹错误)。
|
|
6663
6830
|
* 契约:POST <api_url>/api/install-report,头 `authorization: Bearer <device-login JWT>`,
|
|
6664
|
-
* body { device_id?, install_source, install_version, harness_version, host_os, host_arch }
|
|
6665
|
-
* 404/400 一律忽略(老服务端没有该接口时不能报错)。
|
|
6831
|
+
* body { device_id?, install_source, install_version, harness_version, ref_code?, host_os, host_arch },
|
|
6832
|
+
* 成功 200 {ok:true};404/400 一律忽略(老服务端没有该接口时不能报错)。
|
|
6666
6833
|
*/
|
|
6667
6834
|
const INSTALL_REPORT_FILE = ".dsh-install-report.json";
|
|
6668
6835
|
const INSTALL_REPORT_TTL_MS = 24 * 3600 * 1000;
|
|
@@ -6675,16 +6842,36 @@ function installSourceOf(relayDir) {
|
|
|
6675
6842
|
if (runtimeReady(relayDir)) return "npx";
|
|
6676
6843
|
return "plugin_market";
|
|
6677
6844
|
}
|
|
6845
|
+
/**
|
|
6846
|
+
* 渠道归因码:安装器 `--ref <code>` 写进 .dsh-config.json 的 `ref_code`(3~12 位字母或数字)。
|
|
6847
|
+
* 规则与 clients/dsh-remote/src/channel-ref.mjs 同一份;插件包**不包含** clients/(files 只有 lib+runtime),
|
|
6848
|
+
* 所以这里保留这几行等价实现 —— 改契约时两边一起改。
|
|
6849
|
+
* 归一化 = NFKC(全角→半角)+ trim + 小写,与后端 channel.js normalizeCode 一致。
|
|
6850
|
+
* 缺失/非法 → 空串,调用方据此**整个省略**字段(绝不上报空串/脏值)。
|
|
6851
|
+
*/
|
|
6852
|
+
function refCodeOf(cfg) {
|
|
6853
|
+
const raw = cfg && cfg.ref_code;
|
|
6854
|
+
let v = "";
|
|
6855
|
+
if (typeof raw === "number") v = Number.isInteger(raw) && raw >= 0 ? String(raw) : "";
|
|
6856
|
+
else if (typeof raw === "string") v = raw;
|
|
6857
|
+
else return "";
|
|
6858
|
+
const normalized = v.normalize("NFKC").trim().toLowerCase();
|
|
6859
|
+
return /^[a-z0-9]{3,12}$/.test(normalized) ? normalized : "";
|
|
6860
|
+
}
|
|
6678
6861
|
async function reportInstallOnce(relayDir, cfgOverride, opts = {}) {
|
|
6679
6862
|
const cfg = cfgOverride || loadConfig(relayDir);
|
|
6680
6863
|
if (!hasAccountCreds(cfg)) return { ok: false, skipped: "no_credentials" };
|
|
6681
6864
|
const source = installSourceOf(relayDir);
|
|
6682
6865
|
const version = PLUGIN_VERSION;
|
|
6866
|
+
// 渠道归因码(可选):服务端字段可选,缺失/非法就整个省略。
|
|
6867
|
+
const refCode = refCodeOf(cfg);
|
|
6683
6868
|
const marker = join(relayDir, INSTALL_REPORT_FILE);
|
|
6684
6869
|
if (!opts.force) {
|
|
6685
6870
|
try {
|
|
6686
6871
|
const prev = JSON.parse(readFileSync(marker, "utf8"));
|
|
6687
|
-
|
|
6872
|
+
// ref 也是去重键的一部分:同一版本下后补 `--ref`(重跑安装器)时必须能补报一次,
|
|
6873
|
+
// 否则归因会在 24h 窗口内被"已上报过"吞掉。
|
|
6874
|
+
if (prev && prev.version === version && prev.source === source && (prev.ref || "") === refCode && Date.now() - Number(prev.at || 0) < INSTALL_REPORT_TTL_MS) {
|
|
6688
6875
|
return { ok: true, skipped: "already_reported" };
|
|
6689
6876
|
}
|
|
6690
6877
|
} catch { /* 首次上报 */ }
|
|
@@ -6699,6 +6886,7 @@ async function reportInstallOnce(relayDir, cfgOverride, opts = {}) {
|
|
|
6699
6886
|
// 宿主(DeepSeek Harness)版本:与 install_version(插件版本)是两个数,
|
|
6700
6887
|
// 只报插件版本时「是不是 DSH 升级导致的故障」答不出来(2026-10 生产反馈的普遍缺口)。
|
|
6701
6888
|
harness_version: harnessVersion(),
|
|
6889
|
+
...(refCode ? { ref_code: refCode } : {}),
|
|
6702
6890
|
host_os: process.platform,
|
|
6703
6891
|
host_arch: process.arch,
|
|
6704
6892
|
};
|
|
@@ -6716,8 +6904,8 @@ async function reportInstallOnce(relayDir, cfgOverride, opts = {}) {
|
|
|
6716
6904
|
// 老服务端没有该接口(404)或字段不认(400)→ 忽略,绝不影响 UI
|
|
6717
6905
|
if (r.status === 400 || r.status === 404) return { ok: false, skipped: `http_${r.status}` };
|
|
6718
6906
|
if (!r.ok) return { ok: false, status: r.status };
|
|
6719
|
-
try { writeFileSync(marker, JSON.stringify({ at: Date.now(), version, source })); } catch { /* 非关键 */ }
|
|
6720
|
-
return { ok: true, status: r.status, install_source: source, install_version: version };
|
|
6907
|
+
try { writeFileSync(marker, JSON.stringify({ at: Date.now(), version, source, ref: refCode })); } catch { /* 非关键 */ }
|
|
6908
|
+
return { ok: true, status: r.status, install_source: source, install_version: version, ...(refCode ? { ref_code: refCode } : {}) };
|
|
6721
6909
|
} catch (e) {
|
|
6722
6910
|
return { ok: false, skipped: `unreachable: ${e.message}` };
|
|
6723
6911
|
}
|
|
@@ -7735,6 +7923,11 @@ function registerRoutes(ctx, relayDir) {
|
|
|
7735
7923
|
const captchaAnswer = body.captcha_answer ?? body.captcha;
|
|
7736
7924
|
if (captchaId !== void 0) payload.captcha_id = String(captchaId);
|
|
7737
7925
|
if (captchaAnswer !== void 0) payload.captcha_answer = String(captchaAnswer);
|
|
7926
|
+
// 渠道归因(安装器 `--ref` → 配置 ref_code):面板注册时带上 `channel_code`,
|
|
7927
|
+
// 与 `invite_code`(C 端裂变)是两个字段、互不影响。请求体里自带 channel_code 时
|
|
7928
|
+
// 以请求体为准(手机端 `?ref=` 路径会自己带),这里绝不覆盖。
|
|
7929
|
+
const refCode = refCodeOf(loadConfig(relayDir));
|
|
7930
|
+
if (refCode && !payload.channel_code) payload.channel_code = refCode;
|
|
7738
7931
|
const r = await relayFetch(relayDir, "/api/register", {
|
|
7739
7932
|
method: "POST",
|
|
7740
7933
|
headers: { "content-type": "application/json" },
|
|
@@ -8213,6 +8406,11 @@ export { pinBrowseDirectoryPicker as __pinBrowseDirectoryPicker, schedulePickerP
|
|
|
8213
8406
|
// (用例必须把 relayDir 指向 mkdtemp 临时目录、HOME 指向假目录,绝不能在真实
|
|
8214
8407
|
// ~/.dsh-remote 或真实 ~/.dsh 上做写入测试)。
|
|
8215
8408
|
setupStateOf as __setupStateOf, ensureConnection as __ensureConnection,
|
|
8409
|
+
// 设备超额锁定(契约 §8):面板文案 / 插件半设备绑定退避都要能被单测钉住
|
|
8410
|
+
composeConnect as __composeConnect, readBindError as __readBindError, bindFailGate as __bindFailGate,
|
|
8411
|
+
bindFailSignatureOf as __bindFailSignatureOf, isPermanentBindFailure as __isPermanentBindFailure,
|
|
8412
|
+
bindCapBackoffMs as __bindCapBackoffMs, connectBook as __connectBook, noteBindFailAttempt as __noteBindFailAttempt,
|
|
8413
|
+
DEVICE_BIND_CAP_EXIT_CODE as __DEVICE_BIND_CAP_EXIT_CODE, BIND_CAP_BACKOFF_MAX_MS as __BIND_CAP_BACKOFF_MAX_MS,
|
|
8216
8414
|
backupConfigFile as __backupConfigFile, listConfigBackups as __listConfigBackups,
|
|
8217
8415
|
restoreConfigBackup as __restoreConfigBackup, runtimeDirPresent as __runtimeDirPresent,
|
|
8218
8416
|
CONFIG_BACKUP_KEEP as __CONFIG_BACKUP_KEEP, CONFIG_BACKUP_DIR as __CONFIG_BACKUP_DIR,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-remote-web",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.7",
|
|
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",
|