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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,479 @@
1
+ /**
2
+ * push-reporter — 把电脑端的事件(任务完成 / 需要拍板 / 出错)上报到 SaaS,由服务端决定
3
+ * 是否推送(规则化审批在服务端做、审计在服务端落),本模块只负责**可靠、克制地把事件送到**。
4
+ *
5
+ * 设计边界(每一条都重要)
6
+ * ---------------------------------------------------------------
7
+ * 1. **默认关闭**:只有服务端明确"推送已开启且本账号有订阅"时才启动;任何一步失败都静默降级。
8
+ * 2. **绝不阻塞主链路**:不 await 在 bridge 的事件循环里,所有网络动作都在自己的队列里串行做;
9
+ * 异常一律 catch,绝不冒泡到隧道帧循环(隧道挂 = 全用户断连)。
10
+ * 3. **隐私最小化**:默认只上报"标题 + 一句结果摘要",**不上报完整命令/代码**;
11
+ * 只有 `DSH_PUSH_DETAIL=1` 时才带 `approval.summary`(由服务端规则引擎判断风险)。
12
+ * 4. **幂等**:同一事件(同一 event_id)重试不会让服务端推两次(服务端也有幂等,这里是第一道)。
13
+ * 5. **有界**:队列最多 50 条,满了丢最旧并计数;网络失败指数退避,最多 3 次。
14
+ *
15
+ * 跨仓库契约(企业端 `/api/notify/event`,字段不可改)
16
+ * ---------------------------------------------------------------
17
+ * POST {apiBase}/api/notify/event Authorization: Bearer <device JWT>
18
+ * {
19
+ * event_id, device_id, kind, at,
20
+ * session: { id, title? },
21
+ * title, body,
22
+ * approval?: { request_id, summary?, options: [{value,label}] }
23
+ * }
24
+ * 响应 200 { ok, notified, suppressed, reason? }
25
+ */
26
+
27
+ import { createEventSubscriber, NODE_KINDS, TURN_END_REASONS } from "./dsh-events.mjs";
28
+
29
+ /** 上报体里 kind 的闭集(与服务端契约一致)。 */
30
+ export const NOTIFY_KINDS = Object.freeze({
31
+ TURN_DONE: "turn_done",
32
+ TURN_ERROR: "turn_error",
33
+ TURN_BLOCKED: "turn_blocked",
34
+ APPROVAL_REQUEST: "approval_request",
35
+ });
36
+
37
+ /** 队列上限 / 重试退避 / 单次请求超时。 */
38
+ export const MAX_QUEUE = 50;
39
+ export const RETRY_DELAYS_MS = Object.freeze([5_000, 15_000, 45_000]);
40
+ export const REQUEST_TIMEOUT_MS = 10_000;
41
+ export const MAX_EVENT_ID_LEN = 128;
42
+ /** 上报正文硬上限(服务端也会截,这里先截,省流量)。 */
43
+ export const MAX_BODY_LEN = 200;
44
+
45
+ /** 结果摘要里不该出现的、像"命令"的东西(默认模式下再兜一层)。 */
46
+ const COMMAND_HINT = /(^|\s)(rm|sudo|npm|pnpm|yarn|git|docker|kubectl|curl|wget|ssh|scp|chmod|chown|systemctl|psql|mysql)\s/i;
47
+
48
+ /**
49
+ * 事件节点 → 上报体。返回 `null` 表示"这类节点不上报"(例如 gap/fault/简报)。
50
+ *
51
+ * 导出是为了单测能直接钉住映射(不依赖真实订阅器)。
52
+ *
53
+ * @param {object} node dsh-events 的分类节点
54
+ * @param {{deviceId:string, detail?:boolean, sessionTitle?:(sessionId:string)=>string, now?:()=>number}} opts
55
+ * @returns {object|null}
56
+ */
57
+ export function toNotifyPayload(node, opts = {}) {
58
+ if (!node || typeof node !== "object") return null;
59
+ const deviceId = String(opts.deviceId || "");
60
+ if (!deviceId) return null;
61
+ const now = typeof opts.now === "function" ? opts.now : () => Date.now();
62
+ const at = Number.isFinite(node.at) ? node.at : now();
63
+ const sessionId = typeof node.sessionId === "string" ? node.sessionId : "";
64
+ const sessionTitleRaw = typeof opts.sessionTitle === "function" ? opts.sessionTitle(sessionId) : "";
65
+ const sessionTitle = truncate(String(sessionTitleRaw || ""), 60);
66
+ const session = { id: sessionId };
67
+ if (sessionTitle) session.title = sessionTitle;
68
+
69
+ if (node.kind === NODE_KINDS.APPROVAL_REQUEST || node.kind === NODE_KINDS.PLAN_REVIEW
70
+ || node.kind === NODE_KINDS.USER_QUESTION) {
71
+ const requestId = String(node.eventId || "");
72
+ if (!requestId) return null;
73
+ const isPlan = node.kind === NODE_KINDS.PLAN_REVIEW;
74
+ const isQuestion = node.kind === NODE_KINDS.USER_QUESTION;
75
+ const tool = String(node.toolName || "").trim();
76
+ // 摘要只给"能看懂在等什么"的最小信息;完整命令默认不外发(隐私)。
77
+ const rawSummary = String(node.reason || tool || "").trim();
78
+ // ★ 默认模式一律只给"动作名",完整命令只有 detail 模式才外发(隐私边界)。
79
+ const action = opts.detail ? truncate(rawSummary, MAX_BODY_LEN) : safeSummary(tool, rawSummary);
80
+ const title = isQuestion ? "等你回答" : isPlan ? "等你批准计划" : "需要你拍板";
81
+ const body = isQuestion
82
+ ? "agent 提问:需要你选一个选项才能继续"
83
+ : isPlan
84
+ ? "agent 在等你批准计划"
85
+ : (action ? `需要你拍板:${action}` : "需要你拍板");
86
+ return {
87
+ event_id: makeEventId(deviceId, "approval", requestId),
88
+ device_id: deviceId,
89
+ kind: NOTIFY_KINDS.APPROVAL_REQUEST,
90
+ at,
91
+ session,
92
+ title,
93
+ body,
94
+ approval: {
95
+ request_id: requestId,
96
+ // 默认模式下 summary 只保留"动作名",不带完整命令(detail 模式才带原文)
97
+ summary: action,
98
+ options: normalizeOptions(node.options)
99
+ }
100
+ };
101
+ }
102
+
103
+ if (node.kind === NODE_KINDS.TURN_END) {
104
+ const reason = String(node.reason || "unknown");
105
+ const known = TURN_END_REASONS.includes(reason);
106
+ let kind = NOTIFY_KINDS.TURN_DONE;
107
+ let title = "任务完成";
108
+ let body = sessionTitle ? `${sessionTitle} 跑完了` : "任务跑完了";
109
+ if (reason === "error" || reason === "max-tokens") {
110
+ kind = NOTIFY_KINDS.TURN_ERROR;
111
+ title = reason === "max-tokens" ? "任务被截断" : "任务出错";
112
+ body = sessionTitle ? `${sessionTitle} ${title}` : title;
113
+ } else if (reason === "blocked") {
114
+ kind = NOTIFY_KINDS.TURN_BLOCKED;
115
+ title = "任务被拦下";
116
+ body = sessionTitle ? `${sessionTitle} 被拦下了` : "任务被拦下了,需要你看一下";
117
+ } else if (!known) {
118
+ /* 未知 reason:当成完成上报,但不假装"成功"——正文如实写原因 */
119
+ body = `${sessionTitle || "任务"} 结束了(${reason})`;
120
+ }
121
+ const key = `turn:${sessionId}:${node.turn ?? "?"}:${node.seq ?? "?"}:${reason}`;
122
+ return {
123
+ event_id: makeEventId(deviceId, "turn", key),
124
+ device_id: deviceId,
125
+ kind,
126
+ at,
127
+ session,
128
+ title,
129
+ body: truncate(body, MAX_BODY_LEN)
130
+ };
131
+ }
132
+
133
+ if (node.kind === NODE_KINDS.SESSION_ERROR) {
134
+ const message = truncate(String(node.message || "任务出错"), MAX_BODY_LEN);
135
+ return {
136
+ event_id: makeEventId(deviceId, "error", `${sessionId}:${node.code || message}:${at}`),
137
+ device_id: deviceId,
138
+ kind: NOTIFY_KINDS.TURN_ERROR,
139
+ at,
140
+ session,
141
+ title: "任务出错",
142
+ body: message
143
+ };
144
+ }
145
+
146
+ // gap / fault / digest / quota / membership / expired:本模块不负责(微信通道/其他模块在做)
147
+ return null;
148
+ }
149
+
150
+ function normalizeOptions(raw) {
151
+ const out = [];
152
+ if (Array.isArray(raw)) {
153
+ for (const o of raw) {
154
+ if (!o || typeof o !== "object") continue;
155
+ const value = typeof o.value === "string" ? o.value : "";
156
+ const label = typeof o.label === "string" ? o.label : "";
157
+ if (!value || !label) continue;
158
+ out.push({ value, label: truncate(label, 20) });
159
+ if (out.length >= 4) break;
160
+ }
161
+ }
162
+ return out.length ? out : [
163
+ { value: "allowed-once", label: "允许一次" },
164
+ { value: "rejected", label: "拒绝" }
165
+ ];
166
+ }
167
+
168
+ /** 默认模式下的安全摘要:只留动作名,绝不把完整命令行外发。 */
169
+ function safeSummary(tool, fallback) {
170
+ const t = String(tool || "").trim();
171
+ if (t) return truncate(t, 40);
172
+ const s = String(fallback || "").trim();
173
+ if (!s) return "需要你拍板";
174
+ if (COMMAND_HINT.test(s)) {
175
+ const m = s.split(/\s+/)[0];
176
+ return truncate(m, 40);
177
+ }
178
+ return truncate(s, 40);
179
+ }
180
+
181
+ /** 服务端契约:event_id 只允许 [A-Za-z0-9._:-](其它字符 400)。 */
182
+ const EVENT_ID_SAFE = /[^A-Za-z0-9._:-]/g;
183
+
184
+ function makeEventId(deviceId, scope, key) {
185
+ const raw = `${deviceId}:${scope}:${String(key || "").slice(0, 80)}`;
186
+ // 同 key 同一毫秒重复 = 同一事件;加上 at 会被去重逻辑判成不同事件,故不加
187
+ const id = raw.replace(EVENT_ID_SAFE, "_"); // 空格/问号/斜杠等一律换成下划线
188
+ return id.length > MAX_EVENT_ID_LEN ? id.slice(0, MAX_EVENT_ID_LEN) : id;
189
+ }
190
+
191
+ function truncate(s, n) {
192
+ const str = String(s == null ? "" : s).replace(/\s+/g, " ").trim();
193
+ if (str.length <= n) return str;
194
+ return str.slice(0, Math.max(0, n - 1)) + "…";
195
+ }
196
+
197
+ /**
198
+ * 创建上报器。不自动启动(见 `start()`)。
199
+ *
200
+ * @param {{
201
+ * apiBase: string,
202
+ * tokenProvider: () => (string|Promise<string>),
203
+ * deviceId: string,
204
+ * upstream: () => string,
205
+ * cookie?: () => string,
206
+ * detail?: boolean,
207
+ * sessionTitle?: (sessionId:string)=>string,
208
+ * log?: (level:string, message:string, meta?:object)=>void,
209
+ * fetchImpl?: typeof fetch,
210
+ * WebSocketImpl?: any,
211
+ * now?: () => number,
212
+ * maxQueue?: number,
213
+ * retryDelaysMs?: number[],
214
+ * startOnInit?: boolean,
215
+ * }} options
216
+ */
217
+ export function createPushReporter(options = {}) {
218
+ const apiBase = String(options.apiBase || "").replace(/\/+$/, "");
219
+ const deviceId = String(options.deviceId || "");
220
+ const tokenProvider = typeof options.tokenProvider === "function" ? options.tokenProvider : () => "";
221
+ const fetchImpl = typeof options.fetchImpl === "function" ? options.fetchImpl : globalThis.fetch;
222
+ const now = typeof options.now === "function" ? options.now : () => Date.now();
223
+ const log = typeof options.log === "function" ? options.log : () => {};
224
+ const detail = options.detail === true;
225
+ const maxQueue = Number.isFinite(options.maxQueue) ? options.maxQueue : MAX_QUEUE;
226
+ const retryDelaysMs = Array.isArray(options.retryDelaysMs) ? options.retryDelaysMs : RETRY_DELAYS_MS;
227
+
228
+ /** @type {{payload:object, tries:number}[]} */
229
+ const queue = [];
230
+ const seen = new Map(); // event_id -> at(有界)
231
+ const stats = { started: false, reason: "", enqueued: 0, sent: 0, dropped: 0, duplicates: 0, failed: 0, lastError: "" };
232
+
233
+ let subscriber = null;
234
+ let flushing = false;
235
+ let stopped = false;
236
+ let flushTimer = null;
237
+
238
+ function remember(id) {
239
+ if (seen.has(id)) return false;
240
+ seen.set(id, now());
241
+ // 有界:超过 500 条淘汰最旧(Map 保插入序)
242
+ if (seen.size > 500) {
243
+ const first = seen.keys().next();
244
+ if (!first.done) seen.delete(first.value);
245
+ }
246
+ return true;
247
+ }
248
+
249
+ function enqueue(payload) {
250
+ if (!payload) return false;
251
+ if (!remember(payload.event_id)) {
252
+ stats.duplicates += 1;
253
+ return false;
254
+ }
255
+ if (queue.length >= maxQueue) {
256
+ queue.shift();
257
+ stats.dropped += 1;
258
+ log("warn", "[push] 上报队列已满,丢弃最旧一条");
259
+ }
260
+ queue.push({ payload, tries: 0 });
261
+ stats.enqueued += 1;
262
+ scheduleFlush(0);
263
+ return true;
264
+ }
265
+
266
+ function scheduleFlush(delayMs) {
267
+ if (stopped) return;
268
+ if (flushTimer) return;
269
+ flushTimer = setTimeout(() => {
270
+ flushTimer = null;
271
+ void flush();
272
+ }, Math.max(0, delayMs));
273
+ // 不阻止进程退出(bridge 退出时最多等 1s,见 stop())
274
+ if (flushTimer && typeof flushTimer.unref === "function") flushTimer.unref();
275
+ }
276
+
277
+ async function flush() {
278
+ if (stopped && queue.length === 0) return;
279
+ if (flushing) return;
280
+ flushing = true;
281
+ try {
282
+ while (queue.length > 0) {
283
+ const item = queue[0];
284
+ const token = await Promise.resolve(tokenProvider()).catch(() => "");
285
+ if (!token) {
286
+ stats.lastError = "no_token";
287
+ log("warn", "[push] 没有可用 token,稍后再上报");
288
+ scheduleFlush(retryDelaysMs[0] || 5_000);
289
+ return;
290
+ }
291
+ const result = await postOnce(token, item.payload, apiBase, fetchImpl);
292
+ if (result.ok) {
293
+ queue.shift();
294
+ stats.sent += 1;
295
+ continue;
296
+ }
297
+ if (result.authFailed) {
298
+ // token 失效:丢这批并等下一次 device-login 刷新(不无限重试)
299
+ queue.shift();
300
+ stats.failed += 1;
301
+ stats.lastError = "auth_failed";
302
+ log("warn", "[push] device token 失效,丢弃本批上报(等下次登录刷新)");
303
+ continue;
304
+ }
305
+ if (result.clientError) {
306
+ // 契约错误(400/404/422):重试没意义
307
+ queue.shift();
308
+ stats.failed += 1;
309
+ stats.lastError = `http_${result.status}`;
310
+ log("warn", `[push] 服务端拒绝上报(HTTP ${result.status}),丢弃该条`);
311
+ continue;
312
+ }
313
+ item.tries += 1;
314
+ stats.failed += 1;
315
+ stats.lastError = result.error || `http_${result.status}`;
316
+ if (item.tries > retryDelaysMs.length) {
317
+ queue.shift();
318
+ log("warn", `[push] 上报重试 ${item.tries - 1} 次仍失败,丢弃该条`);
319
+ continue;
320
+ }
321
+ const delay = retryDelaysMs[item.tries - 1] || 5_000;
322
+ scheduleFlush(delay);
323
+ return;
324
+ }
325
+ } catch (e) {
326
+ stats.lastError = String(e && e.message ? e.message : e);
327
+ log("warn", `[push] 上报循环异常(已忽略):${stats.lastError}`);
328
+ } finally {
329
+ flushing = false;
330
+ }
331
+ }
332
+
333
+ /**
334
+ * 启动门禁(两层,都是服务端说了算 —— 客户端开关只是省流量,不是安全边界):
335
+ * ① `GET /api/me` → `notify.enabled`:服务端总开关(push_enabled)。老服务端没有该字段 = 关闭。
336
+ * ② `GET /api/push/public-key` → `subscribed`:本账号有没有订阅;没有就不必起事件订阅器。
337
+ */
338
+ async function probeEnabled(token) {
339
+ try {
340
+ const me = await fetchWithTimeout(`${apiBase}/api/me`, {
341
+ method: "GET",
342
+ headers: { authorization: `Bearer ${token}` }
343
+ }, fetchImpl, REQUEST_TIMEOUT_MS);
344
+ if (!me) return { ok: false, reason: "no_response" };
345
+ if (me.status === 401 || me.status === 403) return { ok: false, reason: "auth_failed" };
346
+ if (me.status !== 200) return { ok: false, reason: `http_${me.status}` };
347
+ const meBody = await safeJson(me);
348
+ const enabled = !!(meBody && meBody.notify && meBody.notify.enabled === true);
349
+ if (!enabled) return { ok: false, reason: "disabled" };
350
+
351
+ const sub = await fetchWithTimeout(`${apiBase}/api/push/public-key`, {
352
+ method: "GET",
353
+ headers: { authorization: `Bearer ${token}` }
354
+ }, fetchImpl, REQUEST_TIMEOUT_MS);
355
+ if (!sub) return { ok: false, reason: "no_response" };
356
+ if (sub.status === 200) {
357
+ const subBody = await safeJson(sub);
358
+ if (subBody && subBody.subscribed === true) return { ok: true };
359
+ return { ok: false, reason: "no_subscription" };
360
+ }
361
+ // 老服务端没有推送端点:当作没有订阅(不启动)
362
+ return { ok: false, reason: `http_${sub.status}` };
363
+ } catch (e) {
364
+ return { ok: false, reason: String(e && e.message ? e.message : e) };
365
+ }
366
+ }
367
+
368
+ return {
369
+ /** 启动:先问服务端"推送开了吗、本账号订阅了吗",再开始订阅事件。 */
370
+ async start() {
371
+ if (stats.started || stopped) return stats;
372
+ if (!apiBase) { stats.reason = "no_api_base"; return stats; }
373
+ if (!deviceId) { stats.reason = "no_device"; return stats; }
374
+ const token = await Promise.resolve(tokenProvider()).catch(() => "");
375
+ if (!token) { stats.reason = "no_token"; return stats; }
376
+ const gate = await probeEnabled(token);
377
+ if (!gate.ok) { stats.reason = gate.reason || "disabled"; log("info", `[push] 未启动上报:${stats.reason}`); return stats; }
378
+ try {
379
+ subscriber = createEventSubscriber({
380
+ upstream: typeof options.upstream === "function" ? options.upstream : String(options.upstream || ""),
381
+ cookie: typeof options.cookie === "function" ? options.cookie : (options.cookie || ""),
382
+ follow: true,
383
+ log: (level, message) => log(level === "error" ? "warn" : level, `[push/events] ${message}`),
384
+ now,
385
+ WebSocketImpl: options.WebSocketImpl,
386
+ fetchImpl
387
+ });
388
+ subscriber.on("event", (node) => {
389
+ try {
390
+ const payload = toNotifyPayload(node, {
391
+ deviceId,
392
+ detail,
393
+ sessionTitle: options.sessionTitle,
394
+ now
395
+ });
396
+ if (payload) enqueue(payload);
397
+ } catch (e) {
398
+ log("warn", `[push] 事件归一化失败(已忽略):${e && e.message ? e.message : e}`);
399
+ }
400
+ });
401
+ subscriber.on("auth-error", () => log("warn", "[push] 事件订阅鉴权失败,等 harness cookie 自愈"));
402
+ subscriber.on("gap", () => log("warn", "[push] 事件订阅断线窗口,期间通知可能漏发"));
403
+ subscriber.start();
404
+ stats.started = true;
405
+ stats.reason = "started";
406
+ } catch (e) {
407
+ stats.reason = `subscribe_failed:${e && e.message ? e.message : e}`;
408
+ log("warn", `[push] 订阅器启动失败(已忽略):${stats.reason}`);
409
+ }
410
+ return stats;
411
+ },
412
+
413
+ /** 停止:最多 1s 尽力 flush,然后释放订阅器。 */
414
+ async stop() {
415
+ stopped = true;
416
+ if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; }
417
+ const deadline = now() + 1_000;
418
+ while (queue.length > 0 && now() < deadline) {
419
+ await flush();
420
+ if (queue.length > 0) await new Promise((r) => setTimeout(r, 50));
421
+ }
422
+ try { if (subscriber && typeof subscriber.close === "function") subscriber.close(); } catch { /* 忽略 */ }
423
+ subscriber = null;
424
+ stats.started = false;
425
+ },
426
+
427
+ /** 只读状态(面板/诊断用;不包含任何事件内容)。 */
428
+ status() {
429
+ return { ...stats, queued: queue.length };
430
+ },
431
+
432
+ /** 仅供单测:直接塞一条(绕过订阅器)。 */
433
+ _enqueue(payload) { return enqueue(payload); },
434
+ /** 仅供单测:立即跑一次发送循环。 */
435
+ _flush: flush,
436
+ /** 仅供单测:把映射函数暴露出去,避免测试重复实现。 */
437
+ _toNotifyPayload: toNotifyPayload
438
+ };
439
+ }
440
+
441
+ async function postOnce(token, payload, apiBase, fetchImpl) {
442
+ try {
443
+ const r = await fetchWithTimeout(`${apiBase}/api/notify/event`, {
444
+ method: "POST",
445
+ headers: {
446
+ authorization: `Bearer ${token}`,
447
+ "content-type": "application/json"
448
+ },
449
+ body: JSON.stringify(payload)
450
+ }, fetchImpl, REQUEST_TIMEOUT_MS);
451
+ if (!r) return { ok: false, error: "no_response" };
452
+ if (r.status === 200) {
453
+ const body = await safeJson(r);
454
+ if (body && body.ok === true) return { ok: true };
455
+ return { ok: false, clientError: true, status: r.status };
456
+ }
457
+ if (r.status === 401 || r.status === 403) return { ok: false, authFailed: true, status: r.status };
458
+ if (r.status >= 400 && r.status < 500) return { ok: false, clientError: true, status: r.status };
459
+ return { ok: false, status: r.status };
460
+ } catch (e) {
461
+ return { ok: false, error: String(e && e.message ? e.message : e) };
462
+ }
463
+ }
464
+
465
+ async function fetchWithTimeout(url, init, fetchImpl, timeoutMs) {
466
+ if (typeof fetchImpl !== "function") return null;
467
+ const ctl = typeof AbortController === "function" ? new AbortController() : null;
468
+ const timer = ctl ? setTimeout(() => { try { ctl.abort(); } catch { /* 忽略 */ } }, timeoutMs) : null;
469
+ if (timer && typeof timer.unref === "function") timer.unref();
470
+ try {
471
+ return await fetchImpl(url, ctl ? { ...init, signal: ctl.signal } : init);
472
+ } finally {
473
+ if (timer) clearTimeout(timer);
474
+ }
475
+ }
476
+
477
+ async function safeJson(r) {
478
+ try { return await r.json(); } catch { return null; }
479
+ }
@@ -2408,6 +2408,11 @@ async function setup(argv) {
2408
2408
  L.push(` 手机端: 打开 ${cfg.tunnel_url.replace(/^ws/, "https")}/app/ ,用访问密钥登录`);
2409
2409
  } else {
2410
2410
  L.push(` 远程访问地址: ${pub.app_url || DEFAULT_APP_URL}`);
2411
+ // 【0.7.10 / W1-T1】安装引导原来停在「注册/登录手机号」—— 用户以为到此结束,
2412
+ // 于是设备在线了、人却从没去过手机端(生产复盘:129 人 C1)。这里把**手机端下一步**一次说全,
2413
+ // 两条路都写清楚(扫码免登录 / 直接打开地址用同一手机号登录),任一都能到同一台电脑。
2414
+ L.push(" 手机端下一步: 用手机浏览器打开上面这个地址 → 用同一手机号登录 → 在「设备列表」里选这台电脑");
2415
+ L.push(" (或在电脑端登录后,回到 设置 → 「远程访问」,用手机扫那里的二维码:免登录直接进入,30 分钟有效)");
2411
2416
  }
2412
2417
  L.push(svcSkipped
2413
2418
  ? ` 自启动服务: ${svcState},可用 \`dsh-remote run\` 手动运行 bridge`