dsh-mini-remote 0.2.0 → 0.2.1
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/CHANGELOG.md +41 -0
- package/README.en.md +198 -147
- package/README.md +198 -154
- package/client/client.js +1931 -3
- package/lib/build.js +32 -1
- package/lib/devices.js +372 -0
- package/lib/index.js +1625 -32
- package/lib/mirror.js +296 -3
- package/lib/page.html +938 -71
- package/lib/page.js +4 -1
- package/lib/remotes.js +279 -0
- package/lib/server.js +937 -55
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -28,6 +28,8 @@ import { randomBytes, randomUUID } from 'node:crypto'
|
|
|
28
28
|
import z from '@deepseek-ai/schemastery'
|
|
29
29
|
|
|
30
30
|
import { createStore } from './store.js'
|
|
31
|
+
import { createDevices, credentialLooksLikeOurs } from './devices.js'
|
|
32
|
+
import { createRemotes, normalizeBaseUrl } from './remotes.js'
|
|
31
33
|
import { createTurnTracker, visibleText, stepSelfTalk } from './events.js'
|
|
32
34
|
import { replayHistory, lastReply } from './history.js'
|
|
33
35
|
import { createTrajectoryTracker, replayTrajectory, capTrajectory } from './trajectory.js'
|
|
@@ -48,7 +50,7 @@ import {
|
|
|
48
50
|
parseSlashLine, describeCommands, commandRow, commandPatch,
|
|
49
51
|
invalidCommandMessage, unknownCommandMessage,
|
|
50
52
|
} from './commands.js'
|
|
51
|
-
import { createMiniServer, miniControl, sendJson, readBody } from './server.js'
|
|
53
|
+
import { createMiniServer, miniControl, sendJson, readBody, tokenEquals, LOCAL_WINDOW_DEVICE } from './server.js'
|
|
52
54
|
import { createMirror } from './mirror.js'
|
|
53
55
|
import { sendExternalNotification } from './notify.js'
|
|
54
56
|
import { createFeishuChannel, feishuQuestionText, approvalText, FEISHU_TEXT_LIMIT } from './lark.js'
|
|
@@ -63,7 +65,7 @@ import { createSessionMetaCache } from './session-meta-cache.js'
|
|
|
63
65
|
import { createSubagentCache } from './subagent-cache.js'
|
|
64
66
|
import { listRoots, listDirectory, makeDirectory } from './browse.js'
|
|
65
67
|
import { listPresets, setPreset, available as presetsAvailable } from './permissions.js'
|
|
66
|
-
import { buildId } from './build.js'
|
|
68
|
+
import { buildId, newestSource } from './build.js'
|
|
67
69
|
|
|
68
70
|
export const name = 'dsh-mini-remote'
|
|
69
71
|
// `sessionController` 是 DSH 的宿主会话业务接口(`@deepseek-ai/dsh-api-session-controller`
|
|
@@ -77,6 +79,15 @@ export const inject = ['agents', 'sessionController']
|
|
|
77
79
|
/** 公网隧道起不来时重试几次——向 Cloudflare 注册会偶发超时,实测大约一半概率。 */
|
|
78
80
|
const TUNNEL_ATTEMPTS = 3
|
|
79
81
|
|
|
82
|
+
/**
|
|
83
|
+
* B 侧去另一台电脑打一次接口,最多等多久。
|
|
84
|
+
*
|
|
85
|
+
* 给一个上限不是可选项:对面不通(地址写错、没开机、不在同一个网络里)时,
|
|
86
|
+
* 界面会一直转圈,而用户看不出是"在等"还是"坏了"。8 秒够内网和 Tailscale 上
|
|
87
|
+
* 一个来回,又不至于让人干等。
|
|
88
|
+
*/
|
|
89
|
+
const REMOTE_CALL_TIMEOUT_MS = 8000
|
|
90
|
+
|
|
80
91
|
/**
|
|
81
92
|
* 手机上最多同时挂着几个「传了但还没发出去」的文件。
|
|
82
93
|
*
|
|
@@ -154,6 +165,29 @@ const DEFAULTS = {
|
|
|
154
165
|
/** 允许的 chat_id,同上。两个都空 = 任何来源都不处理。 */
|
|
155
166
|
chatIds: '',
|
|
156
167
|
},
|
|
168
|
+
/**
|
|
169
|
+
* 跨机「远程窗口」:**允许别的电脑连到这台电脑来**(2026-10-16 跨机第二步)。
|
|
170
|
+
* 默认**关**。
|
|
171
|
+
*
|
|
172
|
+
* ## 为什么默认关(方案文档第五节那份判断,照办)
|
|
173
|
+
*
|
|
174
|
+
* 本机版那张票的前提是「只有本机回环上的宿主发得出来」,而本机的软件本来就有这台
|
|
175
|
+
* 电脑的全部权限,所以它没挂开关。**跨机之后这个前提消失了**——来换票的人在另一台
|
|
176
|
+
* 电脑上,而它换到的是**整个电脑端界面**(会话记录、模型配置、插件装卸全在里面),
|
|
177
|
+
* 比手机那条路能碰到的更多。这个边界该由用户自己划,不能替他默认打开。
|
|
178
|
+
*
|
|
179
|
+
* ## 「关」是一个整体状态(用户拍板)
|
|
180
|
+
*
|
|
181
|
+
* 关着的时候,**两条跨机路由一条都不接**:配对(`/mini-remote/pair`)和跨机取票
|
|
182
|
+
* (`/mini-remote/remote/window`)。只关一条的话,"关"就变成了"少录一项配置",
|
|
183
|
+
* 用户读不懂;两条一起关才等于他要的那句话:**这台电脑不接受别的电脑连过来**。
|
|
184
|
+
*
|
|
185
|
+
* ## 为什么是一个平铺的布尔
|
|
186
|
+
*
|
|
187
|
+
* `tunnel` / `feishu` 那两层是因为各自还有别的字段(路径、凭据、名单)。这里就一个
|
|
188
|
+
* 开关,套一层只会让读的人多跳一次。
|
|
189
|
+
*/
|
|
190
|
+
allowRemoteComputers: false,
|
|
157
191
|
}
|
|
158
192
|
|
|
159
193
|
/**
|
|
@@ -225,6 +259,17 @@ export const Config = z.object({
|
|
|
225
259
|
openIds: z.string(),
|
|
226
260
|
chatIds: z.string(),
|
|
227
261
|
}).description('飞书(长连接):机器人的两个凭据与两个允许名单。改了要重载插件(凭据变了得重建连接)。'),
|
|
262
|
+
/**
|
|
263
|
+
* 跨机「远程窗口」那个总开关(2026-10-16 跨机第二步)。默认关。
|
|
264
|
+
*
|
|
265
|
+
* **不标 volatile**(和飞书同一条口径,理由不同):插件自己的设置页存完**就地生效**
|
|
266
|
+
* ——那两条跨机路由读的是同一个 `settings`(见下面 `pairingPayload` 与那两条路由),
|
|
267
|
+
* 所以不必重载插件;只有手改 settings.json 的人需要重载一次(DSH 只在加载时读那份
|
|
268
|
+
* 文件)。标了 volatile 等于向 DSH 承诺"改它不用重载",而这里兑现得了,但会让这个
|
|
269
|
+
* 开关在 DSH 自己的配置表单里也出现一份——同一个开关两处可点,用户迟早对不上。
|
|
270
|
+
*/
|
|
271
|
+
allowRemoteComputers: z.boolean()
|
|
272
|
+
.description('允许别的电脑连过来(配对 + 跨机取票)。默认关;手改 settings.json 之后要重载插件。'),
|
|
228
273
|
})
|
|
229
274
|
|
|
230
275
|
function deepMerge(base, ...overrides) {
|
|
@@ -246,6 +291,94 @@ function dataDir() {
|
|
|
246
291
|
return join(home, 'dsh-mini-remote')
|
|
247
292
|
}
|
|
248
293
|
|
|
294
|
+
/**
|
|
295
|
+
* 这份插件在磁盘上的版本号(`package.json` 的 `version`)。自检那一栏要它。
|
|
296
|
+
*
|
|
297
|
+
* **每次现读**、不在加载时抄一份:抄一份的话,用户装上新版之后自检里那个号码还是旧的,
|
|
298
|
+
* 而那正是最需要看准的一格。读不到(文件缺了、内容不是 JSON)回 `null`——
|
|
299
|
+
* 自检宁可说「未知」,也不抛错。
|
|
300
|
+
*
|
|
301
|
+
* 「进程加载的是不是这一版」不靠它判断,靠下面 `staleCodeVerdict` 那条时刻比较。
|
|
302
|
+
*/
|
|
303
|
+
function pluginVersion() {
|
|
304
|
+
try {
|
|
305
|
+
const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'))
|
|
306
|
+
return typeof pkg?.version === 'string' && pkg.version ? pkg.version : null
|
|
307
|
+
} catch {
|
|
308
|
+
return null
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* 「现在应答我的,是不是最新那份代码」——自检里最要紧的一句话(2026-10-17)。
|
|
314
|
+
*
|
|
315
|
+
* 判据只有一条:**进程的启动时刻早于插件文件最后被改动的时刻**。早于,说明那份文件
|
|
316
|
+
* 是进程起来之后才写下的,而 Node 只在启动时读一次代码——那些改动现在还没生效。
|
|
317
|
+
* 这正是那天真机上那件怪事的样子:桌面端连开一天,装是装了新版,跑的却是旧的。
|
|
318
|
+
*
|
|
319
|
+
* 两个时刻任何一个拿不到,`stale` 一律回 **`null`(未知)**,不猜:猜错的代价是
|
|
320
|
+
* 用户去重启一次白折腾,或者更糟——真旧了却告诉他"是最新的"。
|
|
321
|
+
*
|
|
322
|
+
* @param {number} startedAtMs 进程启动时刻(`Date.now() - process.uptime()*1000`)
|
|
323
|
+
* @param {{file:string, at:number}|null} newest `newestSource()` 的结果
|
|
324
|
+
* @returns {{stale:boolean|null, file:string|null, message:string|null}}
|
|
325
|
+
*//** 最近错误缓冲的容量与单条长度上限(见 `apply` 里 `recentErrors` 那段)。 */
|
|
326
|
+
const RECENT_ERRORS_MAX = 20
|
|
327
|
+
const RECENT_ERROR_TEXT_MAX = 300
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* 把消息里属于我们自己的凭据换成 `***`。
|
|
331
|
+
*
|
|
332
|
+
* ## 为什么它必须存在
|
|
333
|
+
*
|
|
334
|
+
* 自检会把错误消息**摆到界面上**,而错误消息很容易夹着带凭据的东西:跨机那条路
|
|
335
|
+
* 失败时回的话就带着 `baseUrl`(今天不带凭据,但那是设计约束、不是形状保证),
|
|
336
|
+
* 飞书 SDK 的报错还常把请求详情拼进去。原样进缓冲,等于自己给自己开一条泄漏路。
|
|
337
|
+
*
|
|
338
|
+
* ## 判据和 `lib/server.js` 的 `stripOurSecrets` 是同一条:**看值,不看参数名**
|
|
339
|
+
*
|
|
340
|
+
* 参数名可以换(`token=` 写成 `device=` 照样转发),所以按名字摘是摘不干净的。
|
|
341
|
+
* 设备凭据那个形状复用 `credentialLooksLikeOurs`——那份正则只许有一处
|
|
342
|
+
* (见 lib/devices.js 上面那段注释)。
|
|
343
|
+
*
|
|
344
|
+
* ## 为什么是导出给测试的纯函数
|
|
345
|
+
*
|
|
346
|
+
* 和 `staleCodeVerdict` 同一个理由:脱敏这件事**必须能被单独钉住**。埋在闭包里只能
|
|
347
|
+
* 靠"造一个泄漏事故"来验,而那正是我们不想在测试里排练的事。
|
|
348
|
+
*
|
|
349
|
+
* @param {unknown} text 原始消息(不是字符串就按字符串处理)
|
|
350
|
+
* @param {string[]} secrets 这台电脑上已知的凭据值(口令、飞书密钥……),按**值**换
|
|
351
|
+
* @returns {string} 已脱敏、且截到 `RECENT_ERROR_TEXT_MAX` 字的消息
|
|
352
|
+
*/
|
|
353
|
+
export function redactSecrets(text, secrets = []) {
|
|
354
|
+
let s = String(text ?? '')
|
|
355
|
+
// ① 已知凭据按值换。短于 4 位不换:那多半是 "ab" 这种碎片,换了会伤到正常文字。
|
|
356
|
+
for (const value of Array.isArray(secrets) ? secrets : []) {
|
|
357
|
+
if (typeof value === 'string' && value.length >= 4) s = s.split(value).join('***')
|
|
358
|
+
}
|
|
359
|
+
// ② 查询串里的凭据参数:名字对得上就换(值可能是编码过的,也可能带标点)。
|
|
360
|
+
s = s.replace(/([?&](?:token|device|credential|app_secret)=)[^&\s"'<>]+/gi, '$1***')
|
|
361
|
+
// ③ 兜底:任何一段整体长得像我们的设备凭据,无论出现在哪儿都换。
|
|
362
|
+
s = s.replace(/[^\s"'<>()[\]]+/g, (piece) => (credentialLooksLikeOurs(piece) ? '***' : piece))
|
|
363
|
+
return s.slice(0, RECENT_ERROR_TEXT_MAX)
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
export function staleCodeVerdict(startedAtMs, newest) {
|
|
367
|
+
const file = newest?.file ?? null
|
|
368
|
+
const at = newest?.at
|
|
369
|
+
if (!Number.isFinite(startedAtMs) || !Number.isFinite(at)) {
|
|
370
|
+
return { stale: null, file, message: null }
|
|
371
|
+
}
|
|
372
|
+
const stale = startedAtMs < at
|
|
373
|
+
return {
|
|
374
|
+
stale,
|
|
375
|
+
file,
|
|
376
|
+
message: stale
|
|
377
|
+
? '这台电脑的插件可能不是最新的,重启 DSH 才会加载新版本。'
|
|
378
|
+
: null,
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
249
382
|
function readJson(file) {
|
|
250
383
|
try {
|
|
251
384
|
return JSON.parse(readFileSync(file, 'utf8'))
|
|
@@ -341,6 +474,29 @@ export function apply(ctx, config = {}) {
|
|
|
341
474
|
}
|
|
342
475
|
const token = loadOrCreateToken(dir, settings.token)
|
|
343
476
|
const store = createStore({ file: join(dir, 'state.json'), maxHistory: settings.maxHistory })
|
|
477
|
+
/**
|
|
478
|
+
* 「设备身份」名册(见 lib/devices.js):一台手机一条,落盘在同一个数据目录里。
|
|
479
|
+
*
|
|
480
|
+
* **为什么和 settings.json 分开放**:那是给用户手改的文件,字段会进设置页那张表单
|
|
481
|
+
* ——把密钥哈希混进去,等于让「改个设置」有机会碰坏登录凭据。这份名册由插件自己管,
|
|
482
|
+
* 用户要看也是下一步的设置页按它渲染一份名单。
|
|
483
|
+
*
|
|
484
|
+
* 这份东西**和「远程窗口」那套是两个人**(lib/mirror.js):那张票是一次通行证、
|
|
485
|
+
* 寿命跟着宿主进程、只够进镜像;这里是长期名单、要落盘、认的是手机。两者不合并,
|
|
486
|
+
* 手机也不会因为被登记就多得到镜像权限(那条权限挂在「进阶设置」那个开关上,没动)。
|
|
487
|
+
*/
|
|
488
|
+
const devices = createDevices({ file: join(dir, 'devices.json') })
|
|
489
|
+
/**
|
|
490
|
+
* 「我能连的电脑」名册(**B 侧**,见 lib/remotes.js):B 记着"我连得上哪几台电脑"。
|
|
491
|
+
*
|
|
492
|
+
* 和 `devices.json` **并排、不合并**:那份记的是"谁连得上我"(A 侧,密钥只存哈希),
|
|
493
|
+
* 这一份记的是"我连得上谁",里面那枚凭据是**别人发给我的长期门卡**,必须原样留着。
|
|
494
|
+
* 两个方向、两种东西,混在一份文件里只会让"撤销"和"断开"分不清。
|
|
495
|
+
*
|
|
496
|
+
* 同样**不进 settings.json**(那是给用户手改、会进设置页表单的文件)——理由和
|
|
497
|
+
* devices.json 当初定的一模一样,见 lib/remotes.js 开头那一段。
|
|
498
|
+
*/
|
|
499
|
+
const remotes = createRemotes({ file: join(dir, 'remotes.json') })
|
|
344
500
|
/**
|
|
345
501
|
* 会话标题的落盘缓存(见 lib/title-cache.js)。
|
|
346
502
|
*
|
|
@@ -384,6 +540,45 @@ export function apply(ctx, config = {}) {
|
|
|
384
540
|
warn: (m) => console.warn(`[dsh-mini-remote] ${m}`),
|
|
385
541
|
}
|
|
386
542
|
|
|
543
|
+
/**
|
|
544
|
+
* 最近发生的错误,先进先出,最多 20 条。
|
|
545
|
+
*
|
|
546
|
+
* ## 为什么要有它(2026-10-17,两次真机故障的产物)
|
|
547
|
+
*
|
|
548
|
+
* 三块错误本来各有各的字段(`serverError` / `tunnelError` / `feishuError`),而且都会
|
|
549
|
+
* `log.warn` 一句。问题不在"没有记录",在**记录只到 stdout**:用户看着图形界面,
|
|
550
|
+
* 终端里写了什么他一个字都看不到。而跨机那条路连状态字段都没有 —— `callRemote` 失败时
|
|
551
|
+
* 只把一句话回给界面,**不留痕**。
|
|
552
|
+
*
|
|
553
|
+
* 于是那天两次故障都只能靠猜:一次是桌面端连开 24 小时(跑的还是旧代码),一次是
|
|
554
|
+
* 另一台装的是旧版本。自检能回答"版本对不对",这个缓冲回答"刚才出了什么事"。
|
|
555
|
+
*
|
|
556
|
+
* ## 只留 20 条、每条 300 字,不落盘
|
|
557
|
+
*
|
|
558
|
+
* 它**不是日志**:重启即清空、按条数淘汰、只回答"刚才发生了什么",不回答"上周三下午
|
|
559
|
+
* 发生了什么"。要长历史就去 stdout。
|
|
560
|
+
*/
|
|
561
|
+
const recentErrors = []
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* 记一条错误:进缓冲,同时照旧打一行 stdout。
|
|
565
|
+
*
|
|
566
|
+
* `kind` 是给人看的分类:`service`(手机服务起不来)、`tunnel`(公网隧道)、
|
|
567
|
+
* `feishu`(飞书长连接)、`notify`(外部推送)、`remote`(跨机那一头不通)、
|
|
568
|
+
* `internal`(别的地方兜到的)。分类不是为了统计,是为了让用户一眼看出"是哪一块在坏"。
|
|
569
|
+
*
|
|
570
|
+
* **文本一律先过 `redactSecrets`**:这里是错误消息唯一进入界面的通道,也是唯一可能
|
|
571
|
+
* 把凭据带出去的地方(见那个函数上面那段)。
|
|
572
|
+
*/
|
|
573
|
+
function report(kind, message) {
|
|
574
|
+
// 口令和飞书密钥按**值**换:这两个是这台电脑上最要命的两样。
|
|
575
|
+
const line = redactSecrets(message, [token, settings?.feishu?.appSecret])
|
|
576
|
+
recentErrors.push({ at: Date.now(), kind, message: line })
|
|
577
|
+
if (recentErrors.length > RECENT_ERRORS_MAX) recentErrors.shift()
|
|
578
|
+
// 行为与从前完全一致:以前这儿是裸 `log.warn`,现在只是多存了一份。stdout 那条路没变。
|
|
579
|
+
log.warn(`[${kind}] ${line}`)
|
|
580
|
+
}
|
|
581
|
+
|
|
387
582
|
/** @type {Awaited<ReturnType<typeof createMiniServer>> | null} */
|
|
388
583
|
let server = null
|
|
389
584
|
|
|
@@ -592,7 +787,7 @@ export function apply(ctx, config = {}) {
|
|
|
592
787
|
try {
|
|
593
788
|
writeFileSync(settingsFile, `${JSON.stringify(settings, null, 2)}\n`)
|
|
594
789
|
} catch (err) {
|
|
595
|
-
|
|
790
|
+
report('service', `配置写不进去:${err?.message ?? err}`)
|
|
596
791
|
}
|
|
597
792
|
}
|
|
598
793
|
|
|
@@ -609,7 +804,7 @@ export function apply(ctx, config = {}) {
|
|
|
609
804
|
})
|
|
610
805
|
if (!found.path) {
|
|
611
806
|
tunnelError = found.error
|
|
612
|
-
|
|
807
|
+
report('tunnel', `公网访问没起来:${found.error}`)
|
|
613
808
|
return
|
|
614
809
|
}
|
|
615
810
|
if (found.source === 'downloaded') log.info('cloudflared 准备好了。')
|
|
@@ -634,7 +829,7 @@ export function apply(ctx, config = {}) {
|
|
|
634
829
|
// 我们自己关的不算故障
|
|
635
830
|
if (!tunnelWanted) return
|
|
636
831
|
tunnelError = `隧道断了(cloudflared 退出码 ${code}),公网那边暂时连不上。\n\n${TUNNEL_HINT}`
|
|
637
|
-
|
|
832
|
+
report('tunnel', tunnelError)
|
|
638
833
|
})
|
|
639
834
|
return
|
|
640
835
|
} catch (err) {
|
|
@@ -646,10 +841,10 @@ export function apply(ctx, config = {}) {
|
|
|
646
841
|
}
|
|
647
842
|
}
|
|
648
843
|
tunnelError = `${TUNNEL_ATTEMPTS} 次都没起来。${lastError?.message ?? lastError}\n\n${TUNNEL_HINT}`
|
|
649
|
-
|
|
844
|
+
report('tunnel', `公网访问没起来:${tunnelError}`)
|
|
650
845
|
} catch (err) {
|
|
651
846
|
tunnelError = err?.message ?? String(err)
|
|
652
|
-
|
|
847
|
+
report('tunnel', `公网访问没起来:${tunnelError}`)
|
|
653
848
|
} finally {
|
|
654
849
|
tunnelStarting = false
|
|
655
850
|
}
|
|
@@ -699,7 +894,7 @@ export function apply(ctx, config = {}) {
|
|
|
699
894
|
tunnelError = '隧道本身是通的,但这台电脑解析不了这个域名。\n\n'
|
|
700
895
|
+ '多半是代理或路由器的问题。先试试关掉代理;还不行就在手机上换成流量,'
|
|
701
896
|
+ '或者把电脑的 DNS 改成 1.1.1.1。'
|
|
702
|
-
|
|
897
|
+
report('tunnel', tunnelError)
|
|
703
898
|
return
|
|
704
899
|
}
|
|
705
900
|
tunnelProbeFails += 1
|
|
@@ -707,7 +902,7 @@ export function apply(ctx, config = {}) {
|
|
|
707
902
|
tunnelHealthy = false
|
|
708
903
|
tunnelError = '公网隧道还开着,但已经连不上了:'
|
|
709
904
|
+ `${code || err?.message || err}。手机打开会看到 Cloudflare 的 Error 1033。\n\n${TUNNEL_HINT}`
|
|
710
|
-
|
|
905
|
+
report('tunnel', tunnelError)
|
|
711
906
|
// 判死之后就别再打了:结果不会变,白跑网络。
|
|
712
907
|
stopTunnelProbe()
|
|
713
908
|
}
|
|
@@ -725,7 +920,7 @@ export function apply(ctx, config = {}) {
|
|
|
725
920
|
if (notify.onlyWhenBackground !== false && store.isForeground()) return
|
|
726
921
|
sendExternalNotification(notify, text).then((result) => {
|
|
727
922
|
if (!result.ok && result.error !== 'disabled') {
|
|
728
|
-
|
|
923
|
+
report('notify', `外部推送失败:${result.error}`)
|
|
729
924
|
}
|
|
730
925
|
})
|
|
731
926
|
}
|
|
@@ -767,7 +962,7 @@ export function apply(ctx, config = {}) {
|
|
|
767
962
|
} catch (err) {
|
|
768
963
|
feishu = null
|
|
769
964
|
feishuError = err?.message ?? String(err)
|
|
770
|
-
|
|
965
|
+
report('feishu', `飞书没起来:${feishuError}`)
|
|
771
966
|
} finally {
|
|
772
967
|
feishuStarting = false
|
|
773
968
|
}
|
|
@@ -2246,6 +2441,87 @@ export function apply(ctx, config = {}) {
|
|
|
2246
2441
|
// 不是倒计时,是「手机还在不在」。
|
|
2247
2442
|
// ------------------------------------------------------------------
|
|
2248
2443
|
|
|
2444
|
+
/**
|
|
2445
|
+
* 「哪个会话此刻在等人」——手机导航栏那三枚待处理徽标的**唯一**来源。
|
|
2446
|
+
*
|
|
2447
|
+
* 为什么是这里、而不是去问 DSH 要一个状态字段:PC 侧栏那三个分类(等待审批 / 计划待审 /
|
|
2448
|
+
* 等待回答)**在 Host 侧并不存在**。DSH 自己的做法是让每个功能插件在客户端注册一个
|
|
2449
|
+
* `registerPendingInteraction`,也就是"谁在等,谁自己说"。我们这边真正在等的那两扇门
|
|
2450
|
+
* 就是下面这两条 waterfall(审批与提问),所以正确的做法只有:**在这两扇门上各挂一个
|
|
2451
|
+
* 旁观者**,进门记一笔、落定销一笔。
|
|
2452
|
+
*
|
|
2453
|
+
* 为什么不另写一套轮询去猜:猜出来的徽标会在"其实已经答完了"之后还挂着,那比没有徽标更坏。
|
|
2454
|
+
*
|
|
2455
|
+
* 计数而不是布尔:同一个会话理论上可以同时挂着一条审批和一道题(一次工具调用触发的审批
|
|
2456
|
+
* 还没落定,模型又问了问题),谁先落定都不该把另一枚一起抹掉。
|
|
2457
|
+
*/
|
|
2458
|
+
const pendingBySession = new Map()
|
|
2459
|
+
/** 三类同时挂着时的显示优先级:审批最"堵",计划审阅次之,普通提问最后。 */
|
|
2460
|
+
const PENDING_ORDER = ['approval', 'plan', 'question']
|
|
2461
|
+
const PENDING_LABEL = { approval: 'approval', plan: 'plan', question: 'question' }
|
|
2462
|
+
|
|
2463
|
+
function markPending(sessionId, kind) {
|
|
2464
|
+
if (typeof sessionId !== 'string' || !sessionId) return () => {}
|
|
2465
|
+
let kinds = pendingBySession.get(sessionId)
|
|
2466
|
+
if (!kinds) {
|
|
2467
|
+
kinds = new Map()
|
|
2468
|
+
pendingBySession.set(sessionId, kinds)
|
|
2469
|
+
}
|
|
2470
|
+
kinds.set(kind, (kinds.get(kind) ?? 0) + 1)
|
|
2471
|
+
broadcastPending()
|
|
2472
|
+
return function release() {
|
|
2473
|
+
const left = (kinds.get(kind) ?? 1) - 1
|
|
2474
|
+
if (left > 0) kinds.set(kind, left)
|
|
2475
|
+
else kinds.delete(kind)
|
|
2476
|
+
if (!kinds.size) pendingBySession.delete(sessionId)
|
|
2477
|
+
broadcastPending()
|
|
2478
|
+
}
|
|
2479
|
+
}
|
|
2480
|
+
|
|
2481
|
+
function pendingSnapshot() {
|
|
2482
|
+
const out = {}
|
|
2483
|
+
for (const [id, kinds] of pendingBySession) {
|
|
2484
|
+
for (const kind of PENDING_ORDER) {
|
|
2485
|
+
if (kinds.has(kind)) { out[id] = PENDING_LABEL[kind]; break }
|
|
2486
|
+
}
|
|
2487
|
+
}
|
|
2488
|
+
return out
|
|
2489
|
+
}
|
|
2490
|
+
|
|
2491
|
+
/**
|
|
2492
|
+
* 徽标一变就吱一声。手机那边不改整棵树,只就地换那几行的尾部标记——
|
|
2493
|
+
* 重绘会把用户正在划的滚动位置弹回顶部(和 paintNavCurrent 同一条理由)。
|
|
2494
|
+
*/
|
|
2495
|
+
function broadcastPending() {
|
|
2496
|
+
try {
|
|
2497
|
+
server?.broadcast('navigation-pending', pendingSnapshot())
|
|
2498
|
+
} catch {
|
|
2499
|
+
// 服务还没起来、或者正在关:徽标晚一步知道不影响遥控本身。
|
|
2500
|
+
}
|
|
2501
|
+
}
|
|
2502
|
+
|
|
2503
|
+
/**
|
|
2504
|
+
* **只做旁观**:把"这条链什么时候落定"记下来,返回值**原样**交回去。
|
|
2505
|
+
*
|
|
2506
|
+
* 为什么不能写成 `return next().finally(release)`:那会把一个同步的返回值包成 Promise,
|
|
2507
|
+
* 而这条链的契约是"接不接、给什么,都在同步那一瞬间定下来"(见下面那段说明)。形状一变
|
|
2508
|
+
* 就可能让整条派发认不出谁接了这一手。
|
|
2509
|
+
*
|
|
2510
|
+
* 所以只在它**确实是 thenable** 时才挂回调;给回来一个普通值时,说明下游已经当场答完了,
|
|
2511
|
+
* 徽标立刻销掉是对的——**不挂一枚永远不会落定的假徽标**。
|
|
2512
|
+
*/
|
|
2513
|
+
function watchSettle(value, release) {
|
|
2514
|
+
if (value && typeof value.then === 'function') value.then(release, release)
|
|
2515
|
+
else release()
|
|
2516
|
+
return value
|
|
2517
|
+
}
|
|
2518
|
+
|
|
2519
|
+
/** 这一批题目是不是"计划审阅"。DSH 把计划审批和普通提问放在同一扇门里,靠 intent 分。 */
|
|
2520
|
+
function isPlanReview(questions) {
|
|
2521
|
+
return (Array.isArray(questions) ? questions : [])
|
|
2522
|
+
.some((q) => q && q.intent && q.intent.kind === 'plan-review')
|
|
2523
|
+
}
|
|
2524
|
+
|
|
2249
2525
|
// **注意这个监听器不是 async,这是刻意的,不是漏写。**
|
|
2250
2526
|
//
|
|
2251
2527
|
// 顾问指出的关键一条(对照 DSH 自己那个转发给远端的监听器,写法完全一致):
|
|
@@ -2261,6 +2537,10 @@ export function apply(ctx, config = {}) {
|
|
|
2261
2537
|
const agentId = request?.agent?.id ?? null
|
|
2262
2538
|
const bound = store.snapshot().boundSessionId
|
|
2263
2539
|
const questions = Array.isArray(request?.questions) ? request.questions : []
|
|
2540
|
+
// 进门先记一笔:这个会话此刻在等人。落定(答了、或者本来就没接)时销掉。
|
|
2541
|
+
// 计划审阅走的是同一扇门,只是 intent 不同——不分开记的话,手机上那三个
|
|
2542
|
+
// 分类就只剩两种(PC 侧栏是分三种的)。
|
|
2543
|
+
const release = markPending(agentId, isPlanReview(questions) ? 'plan' : 'question')
|
|
2264
2544
|
// ——— 同步阶段:只做"接不接"的判断,绝不 await ———
|
|
2265
2545
|
// 能力从 `miniControl` 这个固定取用点拿(服务启动时主动放进去的),
|
|
2266
2546
|
// **不再经过 createMiniServer 的回执**——实测那个回执兑现出来是 undefined。
|
|
@@ -2293,15 +2573,15 @@ export function apply(ctx, config = {}) {
|
|
|
2293
2573
|
*/
|
|
2294
2574
|
if (!agentId || agentId !== bound || questions.length === 0) {
|
|
2295
2575
|
console.log(`[dsh-mini-remote] 提问让路:问的=${agentId ?? '无'},手机绑的=${bound ?? '无'},题目数=${questions.length}`)
|
|
2296
|
-
return next()
|
|
2576
|
+
return watchSettle(next(), release)
|
|
2297
2577
|
}
|
|
2298
2578
|
if (!phoneReady && !feishuReady) {
|
|
2299
2579
|
console.log('[dsh-mini-remote] 提问让路:手机和飞书此刻都接不上(页面没开、锁屏或断网,飞书那头没有在等的轮次),交给电脑')
|
|
2300
|
-
return next()
|
|
2580
|
+
return watchSettle(next(), release)
|
|
2301
2581
|
}
|
|
2302
2582
|
if (typeof miniControl.askPhone !== 'function') {
|
|
2303
2583
|
console.log('[dsh-mini-remote] 提问让路:拿不到 askPhone(手机服务还没起来),交给电脑')
|
|
2304
|
-
return next()
|
|
2584
|
+
return watchSettle(next(), release)
|
|
2305
2585
|
}
|
|
2306
2586
|
// ——— 同步交出"将来会有答案"的东西;下面的等待都在这里,不再有同步判断 ———
|
|
2307
2587
|
console.log(`[dsh-mini-remote] 提问推给手机:${questions.length} 题,等它答`
|
|
@@ -2312,7 +2592,7 @@ export function apply(ctx, config = {}) {
|
|
|
2312
2592
|
* 题目的胜负、谁先答谁生效,全在 lib/server.js 那份清单里,这里不另判。
|
|
2313
2593
|
*/
|
|
2314
2594
|
let askId = null
|
|
2315
|
-
return miniControl.askPhone(questions, agentId, {
|
|
2595
|
+
return watchSettle(miniControl.askPhone(questions, agentId, {
|
|
2316
2596
|
onPending: (id) => {
|
|
2317
2597
|
askId = id
|
|
2318
2598
|
// 飞书那边写成一段普通文字问出来。它没接(写不成、或发不出去就当场撤回)时,
|
|
@@ -2337,7 +2617,7 @@ export function apply(ctx, config = {}) {
|
|
|
2337
2617
|
if (askId) feishu?.expireAsk?.(askId)
|
|
2338
2618
|
console.log('[dsh-mini-remote] 接管提问出错,让回电脑:' + String(err))
|
|
2339
2619
|
return next()
|
|
2340
|
-
})
|
|
2620
|
+
}), release)
|
|
2341
2621
|
}, { prepend: true })
|
|
2342
2622
|
// ------------------------------------------------------------------
|
|
2343
2623
|
// 命令确认(审批)也推到手机上
|
|
@@ -2352,6 +2632,8 @@ export function apply(ctx, config = {}) {
|
|
|
2352
2632
|
const agentId = request?.agent?.id ?? null
|
|
2353
2633
|
const bound = store.snapshot().boundSessionId
|
|
2354
2634
|
const toolName = request?.toolName ?? '某个工具'
|
|
2635
|
+
// 和上面那条提问链同一件事:进门记一笔「这个会话在等人」,落定时销掉。
|
|
2636
|
+
const release = markPending(agentId, 'approval')
|
|
2355
2637
|
// 这次到底要跑什么:审批请求只带调用编号,参数在会话事件里记着的那份。
|
|
2356
2638
|
const command = request?.callId ? (toolCalls.get(String(request.callId)) ?? '') : ''
|
|
2357
2639
|
const phoneReady = typeof miniControl.hasPhone === 'function' ? miniControl.hasPhone() : false
|
|
@@ -2368,20 +2650,20 @@ export function apply(ctx, config = {}) {
|
|
|
2368
2650
|
&& feishu.serving()
|
|
2369
2651
|
if (!agentId || agentId !== bound) {
|
|
2370
2652
|
console.log(`[dsh-mini-remote] 审批让路:批的=${agentId ?? '无'},手机绑的=${bound ?? '无'}`)
|
|
2371
|
-
return next()
|
|
2653
|
+
return watchSettle(next(), release)
|
|
2372
2654
|
}
|
|
2373
2655
|
if (!phoneReady && !feishuReady) {
|
|
2374
2656
|
console.log('[dsh-mini-remote] 审批让路:手机和飞书此刻都接不上(页面没开、锁屏或断网,飞书那头没有在等的轮次),交给电脑')
|
|
2375
|
-
return next()
|
|
2657
|
+
return watchSettle(next(), release)
|
|
2376
2658
|
}
|
|
2377
2659
|
if (typeof miniControl.askApproval !== 'function') {
|
|
2378
2660
|
console.log('[dsh-mini-remote] 审批让路:拿不到 askApproval(手机服务还没起来),交给电脑')
|
|
2379
|
-
return next()
|
|
2661
|
+
return watchSettle(next(), release)
|
|
2380
2662
|
}
|
|
2381
2663
|
console.log(`[dsh-mini-remote] 审批推给手机:${toolName},等它拍板`
|
|
2382
2664
|
+ (feishuReady ? '(飞书那边一起问)' : ''))
|
|
2383
2665
|
let askId = null
|
|
2384
|
-
return miniControl.askApproval(request, agentId, command, {
|
|
2666
|
+
return watchSettle(miniControl.askApproval(request, agentId, command, {
|
|
2385
2667
|
onPending: (id) => {
|
|
2386
2668
|
askId = id
|
|
2387
2669
|
feishu?.openAsk?.({ id, kind: 'approval', payload: view })
|
|
@@ -2399,7 +2681,7 @@ export function apply(ctx, config = {}) {
|
|
|
2399
2681
|
if (askId) feishu?.expireAsk?.(askId)
|
|
2400
2682
|
console.log('[dsh-mini-remote] 接管审批出错,让回电脑:' + String(err))
|
|
2401
2683
|
return next()
|
|
2402
|
-
})
|
|
2684
|
+
}), release)
|
|
2403
2685
|
}, { prepend: true })
|
|
2404
2686
|
|
|
2405
2687
|
// ------------------------------------------------------------------
|
|
@@ -2716,6 +2998,325 @@ export function apply(ctx, config = {}) {
|
|
|
2716
2998
|
}
|
|
2717
2999
|
}
|
|
2718
3000
|
|
|
3001
|
+
// ------------------------------------------------------------------
|
|
3002
|
+
// 会话条目的执行功能(2026-10-09):置顶 / 重命名 / 分叉 / 归档
|
|
3003
|
+
// ------------------------------------------------------------------
|
|
3004
|
+
//
|
|
3005
|
+
// 四件都**走 DSH 自己的服务**,不自己写盘:
|
|
3006
|
+
// · 重命名 `sessionController.rename({ sessionId, title })`
|
|
3007
|
+
// · 分叉 `sessionController.fork({ sessionId })`(+照 PC 补一次 rename 递增标题)
|
|
3008
|
+
// · 置顶 `workspaceRegistry.pinSession / unpinSession`
|
|
3009
|
+
// · 归档 `workspaceRegistry.archiveSession / unarchiveSession`
|
|
3010
|
+
//
|
|
3011
|
+
// 读状态也走官方现成的两个 getter(`archivedSessionIds` / `pinnedSessionIds`),
|
|
3012
|
+
// 它们正是 PC 侧栏读的那一份。**不去读 `~/.dsh/storages/workspace.json`**:
|
|
3013
|
+
// 那是 DSH 的内部落盘格式,跟着版本变,而 getter 是它的公开面。
|
|
3014
|
+
//
|
|
3015
|
+
// 拿不到服务时一律**如实说没这个能力**(reason 带回手机),界面据此不摆一个按了没反应的按钮。
|
|
3016
|
+
|
|
3017
|
+
/** `sessionController` 现在拿不拿得到——和 createSession 同一条取用路径。 */
|
|
3018
|
+
function sessionControllerNow() {
|
|
3019
|
+
return canCreate.controller ?? ctx.get('sessionController')
|
|
3020
|
+
}
|
|
3021
|
+
|
|
3022
|
+
/**
|
|
3023
|
+
* 已归档 / 已置顶两个集合。
|
|
3024
|
+
*
|
|
3025
|
+
* 这两个 getter 在服务还没就绪时会抛(`requireState()`)——那时候导航栏少两枚徽标,
|
|
3026
|
+
* 不该影响遥控本身,所以照 lib/tree.js 里 safeList 那条老规矩:读不到就当空。
|
|
3027
|
+
* 但**要把 `ok:false` 带出去**,手机那边据此不显示「没有已归档」这种会被当成事实的说法。
|
|
3028
|
+
*/
|
|
3029
|
+
function readSessionFlags() {
|
|
3030
|
+
const reg = treeServices.registry
|
|
3031
|
+
if (!reg) return { ok: false, reason: 'no-registry', archived: [], pinned: [] }
|
|
3032
|
+
const strings = (v) => (Array.isArray(v) ? v.filter((x) => typeof x === 'string' && x) : [])
|
|
3033
|
+
try {
|
|
3034
|
+
return {
|
|
3035
|
+
ok: true,
|
|
3036
|
+
archived: strings(reg.archivedSessionIds),
|
|
3037
|
+
pinned: strings(reg.pinnedSessionIds),
|
|
3038
|
+
}
|
|
3039
|
+
} catch (err) {
|
|
3040
|
+
return { ok: false, reason: 'not-ready', archived: [], pinned: [], error: String(err?.message ?? err) }
|
|
3041
|
+
}
|
|
3042
|
+
}
|
|
3043
|
+
|
|
3044
|
+
/**
|
|
3045
|
+
* 每个会话的官方摘要(`SessionSummary`):我们要的只有 `blank` 一个字段。
|
|
3046
|
+
*
|
|
3047
|
+
* **为什么非它不可**:「空白新会话」这条判据在我们这边没有第二个来源——
|
|
3048
|
+
* `sessionQuery.listSessions()` 给的记录只有 `{header, live, persisted}`,
|
|
3049
|
+
* 里面没有"用过没用过"。而 `blank` 正是 PC 侧栏用来决定"这一行要不要摆出来"的那个字段。
|
|
3050
|
+
* 编一个(比如"标题为空就算空白")会让一条只是标题没读出来的老会话当场消失,
|
|
3051
|
+
* 那是拿猜测当事实——**拿不到就如实说拿不到**,手机侧退回原来的行为(一条都不隐藏)。
|
|
3052
|
+
*
|
|
3053
|
+
* 缓存 30 秒:和导航那两层同一个口径。展开一个工作区本来就要读日志折标题,
|
|
3054
|
+
* 这一趟相对便宜,但没必要每次展开都重问一遍。
|
|
3055
|
+
*/
|
|
3056
|
+
const SUMMARIES_TTL_MS = 30_000
|
|
3057
|
+
const summariesCache = { at: 0, value: null, inflight: null }
|
|
3058
|
+
async function readSessionSummaries(force = false) {
|
|
3059
|
+
const controller = sessionControllerNow()
|
|
3060
|
+
if (!controller || typeof controller.list !== 'function') {
|
|
3061
|
+
return { ok: false, reason: 'unavailable', byId: new Map() }
|
|
3062
|
+
}
|
|
3063
|
+
const now = Date.now()
|
|
3064
|
+
if (!force && summariesCache.value && now - summariesCache.at < SUMMARIES_TTL_MS) {
|
|
3065
|
+
return summariesCache.value
|
|
3066
|
+
}
|
|
3067
|
+
if (summariesCache.inflight) return summariesCache.inflight
|
|
3068
|
+
summariesCache.inflight = (async () => {
|
|
3069
|
+
try {
|
|
3070
|
+
const res = await controller.list({}, undefined)
|
|
3071
|
+
const byId = new Map()
|
|
3072
|
+
for (const item of res?.items ?? []) {
|
|
3073
|
+
if (item && typeof item.sessionId === 'string') byId.set(item.sessionId, item)
|
|
3074
|
+
}
|
|
3075
|
+
const value = { ok: true, byId }
|
|
3076
|
+
summariesCache.at = Date.now()
|
|
3077
|
+
summariesCache.value = value
|
|
3078
|
+
return value
|
|
3079
|
+
} catch (err) {
|
|
3080
|
+
// 失败不进缓存:下一次展开还会重试。返回的形状和上面一样,只是 ok:false。
|
|
3081
|
+
return { ok: false, reason: 'failed', byId: new Map(), error: String(err?.message ?? err) }
|
|
3082
|
+
} finally {
|
|
3083
|
+
summariesCache.inflight = null
|
|
3084
|
+
}
|
|
3085
|
+
})()
|
|
3086
|
+
return summariesCache.inflight
|
|
3087
|
+
}
|
|
3088
|
+
|
|
3089
|
+
/**
|
|
3090
|
+
* 改一个会话的标题。
|
|
3091
|
+
*
|
|
3092
|
+
* **官方语义(2026-10-09 从 0.2.0-rc.2 的 `dsh-session-title` 读到原文)**:
|
|
3093
|
+
* `rename` 会追加一条 `session/title` 事件、`source.kind = 'user'`,这一条**钉住标题**——
|
|
3094
|
+
* 在途的自动生成当场作废,之后的用户消息也不再生成新标题。也就是说:**传什么都会钉**,
|
|
3095
|
+
* 哪怕跟当前自动标题一模一样。
|
|
3096
|
+
*
|
|
3097
|
+
* 所以"防误钉"这一步**不在这里**,在手机页:新标题与当前标题逐字相同就**根本不发这一趟**
|
|
3098
|
+
* (见 lib/page.html 的 renameSubmit)。服务端这一层只负责把官方的拒绝如实翻译回去。
|
|
3099
|
+
*/
|
|
3100
|
+
async function renameSession(sessionId, title) {
|
|
3101
|
+
const controller = sessionControllerNow()
|
|
3102
|
+
if (!controller || typeof controller.rename !== 'function') {
|
|
3103
|
+
return { ok: false, reason: 'no-service' }
|
|
3104
|
+
}
|
|
3105
|
+
const text = typeof title === 'string' ? title : ''
|
|
3106
|
+
// 空/全空白:官方会抛 SessionTitleInvalidError("归一化之后是空的")。我们在这里就先拦住,
|
|
3107
|
+
// 措辞更直白,也省一趟往返。
|
|
3108
|
+
if (!text.trim()) return { ok: false, reason: 'blank' }
|
|
3109
|
+
try {
|
|
3110
|
+
const res = await controller.rename({ sessionId, title: text })
|
|
3111
|
+
// 官方会把归一化之后真正接受的那一份交回来(可能被截断、被去掉控制字符)。
|
|
3112
|
+
// 界面上要显示的是**它接受的那份**,不是我们递过去的那份。
|
|
3113
|
+
const accepted = typeof res?.title === 'string' ? res.title : text
|
|
3114
|
+
// 就手记到我们自己这两层缓存上。**不记的话**,用户改完名字再展开一次工作区,
|
|
3115
|
+
// 列表里还是旧标题(标题有 7 天的落盘缓存,已知标题优先于去读日志)——
|
|
3116
|
+
// 那就是"我刚改的名字它没记住",界面自己打自己的脸。
|
|
3117
|
+
store.touchSession(sessionId, { title: accepted })
|
|
3118
|
+
titles.set(sessionId, accepted)
|
|
3119
|
+
return { ok: true, title: accepted }
|
|
3120
|
+
} catch (err) {
|
|
3121
|
+
if (err?.code === 'session/title-invalid' || err?.name === 'SessionTitleInvalidError') {
|
|
3122
|
+
return { ok: false, reason: 'blank' }
|
|
3123
|
+
}
|
|
3124
|
+
if (err?.code === 'session/not-found') return { ok: false, reason: 'not-found' }
|
|
3125
|
+
return { ok: false, reason: 'failed', error: String(err?.message ?? err) }
|
|
3126
|
+
}
|
|
3127
|
+
}
|
|
3128
|
+
|
|
3129
|
+
/**
|
|
3130
|
+
* 标题里那个「(N)」,照官方**客户端的原函数**递增。
|
|
3131
|
+
*
|
|
3132
|
+
* 为什么这份逻辑在我们这边:官方 Host 的 `fork` 只管复制事件,**不动标题**——
|
|
3133
|
+
* 子会话继承源会话的标题。PC 是客户端自己再补一次改名:
|
|
3134
|
+
* `ClientSessions.fork({ increaseTitle: true })` 里那一行原文是
|
|
3135
|
+
* `const renamed = await this.manager.rename(childId, increasedForkTitle(sourceTitle));`
|
|
3136
|
+
* 所以要跟电脑端一模一样,就得把 `increasedForkTitle` 这个函数也搬一份。
|
|
3137
|
+
*
|
|
3138
|
+
* 规则照抄(半角/全角括号都认,认不出就补 ` (1)`):
|
|
3139
|
+
* `^(.*?)\((\d+)\)$` → `前缀(数字+1)`
|
|
3140
|
+
* `^(.*?)((\d+))$` → `前缀(数字+1)`
|
|
3141
|
+
* 其它一律 → `${title} (1)`
|
|
3142
|
+
*/
|
|
3143
|
+
function increasedForkTitle(title) {
|
|
3144
|
+
const ascii = /^(.*?)\((\d+)\)$/u.exec(title)
|
|
3145
|
+
if (ascii?.[1] !== undefined && ascii[2] !== undefined) return `${ascii[1]}(${BigInt(ascii[2]) + 1n})`
|
|
3146
|
+
const fullWidth = /^(.*?)((\d+))$/u.exec(title)
|
|
3147
|
+
if (fullWidth?.[1] !== undefined && fullWidth[2] !== undefined) return `${fullWidth[1]}(${BigInt(fullWidth[2]) + 1n})`
|
|
3148
|
+
return `${title} (1)`
|
|
3149
|
+
}
|
|
3150
|
+
|
|
3151
|
+
/**
|
|
3152
|
+
* 分叉时要拿来做递增的那个「源标题」。
|
|
3153
|
+
*
|
|
3154
|
+
* PC 用的是它自己列表快照里的 `title`(`this.list.getSnapshot().byId[id]?.title`)。
|
|
3155
|
+
* 我们这边等价的来源是 Host 的列表投影:`SessionSummary.projections.values.title`,
|
|
3156
|
+
* 也就是 `controller.list()` 给的那一份——和手机列表显示的是同一个字段。
|
|
3157
|
+
*
|
|
3158
|
+
* 拿不到就退到插件自己的标题缓存(就是下面那个 `titles`:读过一次就留着,改名也记一笔)。
|
|
3159
|
+
* **两个都没有就返回空串**——空串表示"不知道源标题",那就**不做这次改名**,
|
|
3160
|
+
* 和 PC 的 `sourceTitle === undefined` 那条路一致(不硬编一个 `(1)` 出来骗人)。
|
|
3161
|
+
*/
|
|
3162
|
+
async function forkSourceTitle(sessionId) {
|
|
3163
|
+
const summaries = await readSessionSummaries()
|
|
3164
|
+
const fromHost = summaries.byId?.get(sessionId)?.projections?.values?.title
|
|
3165
|
+
if (typeof fromHost === 'string' && fromHost.trim()) return fromHost
|
|
3166
|
+
const cached = titles.get(sessionId)
|
|
3167
|
+
return typeof cached === 'string' && cached.trim() ? cached : ''
|
|
3168
|
+
}
|
|
3169
|
+
|
|
3170
|
+
/**
|
|
3171
|
+
* 分叉会话:在源会话**最后一个已完成轮次**处复制出一个新会话。
|
|
3172
|
+
*
|
|
3173
|
+
* 官方语义(2026-10-09 从 0.2.0-rc.2 的 `@deepseek-ai/dsh-api-session-controller` 读到原文):
|
|
3174
|
+
* `fork(request: { sessionId, atSeq? })`,省略 `atSeq` 就是"最近一个已完成的前缀"——
|
|
3175
|
+
* 原文(lib/index.js 第 776-798 行):
|
|
3176
|
+
* ```
|
|
3177
|
+
* async fork(request) {
|
|
3178
|
+
* atSeq = request.atSeq === void 0 ? void 0 : SessionSeq(request.atSeq)
|
|
3179
|
+
* const boundary = atSeq ?? latestCompletedPrefixBoundary(source.events)
|
|
3180
|
+
* if (boundary === void 0 || source.events[boundary]?.seq !== boundary)
|
|
3181
|
+
* throw new RemoteError("session/fork-unavailable", request.atSeq === void 0
|
|
3182
|
+
* ? `session "${request.sessionId}" has no completed turn to fork from`
|
|
3183
|
+
* : `event ${...} does not exist in session "${...}" (last seq: ${...})`,
|
|
3184
|
+
* { sessionId: request.sessionId })
|
|
3185
|
+
* ...
|
|
3186
|
+
* return { sessionId: childId }
|
|
3187
|
+
* }
|
|
3188
|
+
* ```
|
|
3189
|
+
* 也就是说:**源会话里一个已完成轮次都没有时,Host 抛 `session/fork-unavailable`**,
|
|
3190
|
+
* 那句话就是上面那串英文;`not-found` 是另一条(`session "${id}" not found`)。
|
|
3191
|
+
*
|
|
3192
|
+
* **它不会打开新会话、也不会改当前选中的会话**——PC 那条路上
|
|
3193
|
+
* `navigation.forkSession()` 之后就结束了,没有任何 `replaceMain/open` 调用。
|
|
3194
|
+
* 我们这边同样一个字节都不动 `store` 里的绑定会话。
|
|
3195
|
+
*
|
|
3196
|
+
* 失败时**把 Host 那句话原样交给手机**:这是用户点名要的("不要自己编一句『无法分叉』"),
|
|
3197
|
+
* 而且它正是 PC 用来区分「没有已结束轮次」和「真的失败了」的那个判据
|
|
3198
|
+
* (PC 的 `error.rpcError.code === "session/fork-unavailable"`)。
|
|
3199
|
+
* 唯一多写的一句是 `no-turn` 的中文引子——**照抄 PC 自己词条表里的原句**
|
|
3200
|
+
* (`"shortcut.noCompletedTurn": "当前会话没有已结束的轮次"`),让非技术用户看得懂;
|
|
3201
|
+
* Host 原话跟在它后面,一个字都没被改写。
|
|
3202
|
+
*/
|
|
3203
|
+
async function forkSession(sessionId) {
|
|
3204
|
+
const controller = sessionControllerNow()
|
|
3205
|
+
if (!controller || typeof controller.fork !== 'function') {
|
|
3206
|
+
return { ok: false, reason: 'no-service' }
|
|
3207
|
+
}
|
|
3208
|
+
let childId = ''
|
|
3209
|
+
try {
|
|
3210
|
+
const res = await controller.fork({ sessionId })
|
|
3211
|
+
childId = typeof res?.sessionId === 'string' ? res.sessionId : ''
|
|
3212
|
+
} catch (err) {
|
|
3213
|
+
const message = String(err?.message ?? err)
|
|
3214
|
+
if (err?.code === 'session/fork-unavailable') {
|
|
3215
|
+
return { ok: false, reason: 'no-turn', error: `当前会话没有已结束的轮次。Host 原话:${message}` }
|
|
3216
|
+
}
|
|
3217
|
+
if (err?.code === 'session/not-found') {
|
|
3218
|
+
return { ok: false, reason: 'not-found', error: message }
|
|
3219
|
+
}
|
|
3220
|
+
return { ok: false, reason: 'failed', error: message }
|
|
3221
|
+
}
|
|
3222
|
+
// Host 说成功却没交出 id:那是它违约,不能编一个"大概建好了"糊过去。
|
|
3223
|
+
if (!childId) return { ok: false, reason: 'failed', error: 'Host 建好了会话却没有交出它的 id。' }
|
|
3224
|
+
// 标题递增。**失败不算分叉失败**——会话是真建出来了,把它说成没建出来更坏;
|
|
3225
|
+
// 只是标题留在继承来的那一个上(如实把 title 报成空串,手机就不拿标题说事)。
|
|
3226
|
+
let title = ''
|
|
3227
|
+
const source = await forkSourceTitle(sessionId)
|
|
3228
|
+
if (source) {
|
|
3229
|
+
const next = increasedForkTitle(source)
|
|
3230
|
+
try {
|
|
3231
|
+
await controller.rename({ sessionId: childId, title: next })
|
|
3232
|
+
title = next
|
|
3233
|
+
} catch (err) {
|
|
3234
|
+
title = ''
|
|
3235
|
+
}
|
|
3236
|
+
}
|
|
3237
|
+
if (title) {
|
|
3238
|
+
// 和 renameSession 一样就手记上,不然用户刷新回来还是旧标题(标题有 7 天的落盘缓存,
|
|
3239
|
+
// 而这一条是刚建出来的、哪一层都还没有)。
|
|
3240
|
+
store.touchSession(childId, { title })
|
|
3241
|
+
titles.set(childId, title)
|
|
3242
|
+
}
|
|
3243
|
+
return { ok: true, sessionId: childId, title }
|
|
3244
|
+
}
|
|
3245
|
+
|
|
3246
|
+
/**
|
|
3247
|
+
* 归档 / 取消归档。
|
|
3248
|
+
*
|
|
3249
|
+
* `stop` 这一个布尔是**官方那条两段式流程**的全部:
|
|
3250
|
+
* · 不带它(默认):会话**还有工作在跑时服务会拒绝**,抛 `WorkspaceActiveSessionError`,
|
|
3251
|
+
* 错的 `.activity` 上按族列着"会停掉什么"(turn / job / subagent / schedule,各自带 items)。
|
|
3252
|
+
* 我们**原样交给手机**,手机照族列出来问过用户才继续——**不自己猜会停掉什么**。
|
|
3253
|
+
* · 带了它(`{ stopActivity: true }`):跳过检查,写完归档再去请各个 provider 停。
|
|
3254
|
+
*
|
|
3255
|
+
* 官方的原话是「Host 拒绝普通归档并列出这些工作,侧栏随即打开『停止并归档』对话框」——
|
|
3256
|
+
* 我们做的就是同一件事,只是"侧栏"换成了手机页。
|
|
3257
|
+
*/
|
|
3258
|
+
async function archiveSession(sessionId, stop) {
|
|
3259
|
+
const reg = treeServices.registry
|
|
3260
|
+
if (!reg || typeof reg.archiveSession !== 'function') return { ok: false, reason: 'no-registry' }
|
|
3261
|
+
try {
|
|
3262
|
+
await reg.archiveSession(sessionId, stop ? { stopActivity: true } : {})
|
|
3263
|
+
return { ok: true, ...readSessionFlags() }
|
|
3264
|
+
} catch (err) {
|
|
3265
|
+
if (err?.name === 'WorkspaceActiveSessionError' || Array.isArray(err?.activity)) {
|
|
3266
|
+
return { ok: false, reason: 'active', activity: shapeActivity(err?.activity) }
|
|
3267
|
+
}
|
|
3268
|
+
if (err?.name === 'WorkspaceUnknownSessionError') return { ok: false, reason: 'not-found' }
|
|
3269
|
+
return { ok: false, reason: 'failed', error: String(err?.message ?? err) }
|
|
3270
|
+
}
|
|
3271
|
+
}
|
|
3272
|
+
|
|
3273
|
+
/**
|
|
3274
|
+
* 把官方那四族活动收成手机要的形状。**只搬运,不重写**:
|
|
3275
|
+
* `kind` 原样(`turn`/`job`/`subagent`/`schedule`),中文说法在页面上拼——
|
|
3276
|
+
* 界面文字归界面管,服务端不该替它起中文名。
|
|
3277
|
+
*
|
|
3278
|
+
* 每族最多带 20 项:官方的 items 原则上不长,但这是**用户自己机器上的数据**,
|
|
3279
|
+
* 不该让一条超长的清单把手机页撑爆。截断了就把真实条数一起带出去,页面如实说。
|
|
3280
|
+
*/
|
|
3281
|
+
function shapeActivity(activity) {
|
|
3282
|
+
return (Array.isArray(activity) ? activity : [])
|
|
3283
|
+
.filter((a) => a && typeof a.kind === 'string')
|
|
3284
|
+
.map((a) => {
|
|
3285
|
+
const items = (Array.isArray(a.items) ? a.items : [])
|
|
3286
|
+
.map((it) => ({
|
|
3287
|
+
id: typeof it?.id === 'string' ? it.id : '',
|
|
3288
|
+
label: typeof it?.label === 'string' ? it.label : '',
|
|
3289
|
+
}))
|
|
3290
|
+
return { kind: a.kind, count: items.length, items: items.slice(0, 20) }
|
|
3291
|
+
})
|
|
3292
|
+
}
|
|
3293
|
+
|
|
3294
|
+
async function setPinned(sessionId, on) {
|
|
3295
|
+
const reg = treeServices.registry
|
|
3296
|
+
if (!reg || typeof reg.pinSession !== 'function') return { ok: false, reason: 'no-registry' }
|
|
3297
|
+
try {
|
|
3298
|
+
await (on ? reg.pinSession(sessionId) : reg.unpinSession(sessionId))
|
|
3299
|
+
return { ok: true, ...readSessionFlags() }
|
|
3300
|
+
} catch (err) {
|
|
3301
|
+
// 已归档的不能置顶(官方规定两者互斥)。界面上归档行本来就不给置顶入口,
|
|
3302
|
+
// 这条是兜底:真走到了也要说人话,不能回一个"失败"了事。
|
|
3303
|
+
if (err?.name === 'WorkspaceArchivedSessionPinError') return { ok: false, reason: 'archived' }
|
|
3304
|
+
if (err?.name === 'WorkspaceUnknownSessionError') return { ok: false, reason: 'not-found' }
|
|
3305
|
+
return { ok: false, reason: 'failed', error: String(err?.message ?? err) }
|
|
3306
|
+
}
|
|
3307
|
+
}
|
|
3308
|
+
|
|
3309
|
+
async function unarchiveSession(sessionId) {
|
|
3310
|
+
const reg = treeServices.registry
|
|
3311
|
+
if (!reg || typeof reg.unarchiveSession !== 'function') return { ok: false, reason: 'no-registry' }
|
|
3312
|
+
try {
|
|
3313
|
+
await reg.unarchiveSession(sessionId)
|
|
3314
|
+
return { ok: true, ...readSessionFlags() }
|
|
3315
|
+
} catch (err) {
|
|
3316
|
+
return { ok: false, reason: 'failed', error: String(err?.message ?? err) }
|
|
3317
|
+
}
|
|
3318
|
+
}
|
|
3319
|
+
|
|
2719
3320
|
const nav = {
|
|
2720
3321
|
listWorkspaces: (runningIds, force, includeEmpty) =>
|
|
2721
3322
|
listWorkspaces({ registry: treeServices.registry, query: treeServices.query, runningIds, force, includeEmpty, meta: sessionMeta }),
|
|
@@ -2732,6 +3333,18 @@ export function apply(ctx, config = {}) {
|
|
|
2732
3333
|
onTitle,
|
|
2733
3334
|
meta: sessionMeta,
|
|
2734
3335
|
}),
|
|
3336
|
+
// —— 会话条目的执行功能(2026-10-09):都由 lib/server.js 那几条路由调用 ——
|
|
3337
|
+
// 分组口径(隐藏空白、5 条空闲配额、已归档筛选)**不在这一层**:树那一层
|
|
3338
|
+
// (lib/tree.js)只回答"这个工作区有哪些会话",页面口径属于服务层。
|
|
3339
|
+
sessionFlags: () => readSessionFlags(),
|
|
3340
|
+
sessionSummaries: (force) => readSessionSummaries(force),
|
|
3341
|
+
pendingInteractions: () => pendingSnapshot(),
|
|
3342
|
+
renameSession: (sessionId, title) => renameSession(sessionId, title),
|
|
3343
|
+
forkSession: (sessionId) => forkSession(sessionId),
|
|
3344
|
+
pinSession: (sessionId) => setPinned(sessionId, true),
|
|
3345
|
+
unpinSession: (sessionId) => setPinned(sessionId, false),
|
|
3346
|
+
archiveSession: (sessionId, stop) => archiveSession(sessionId, stop),
|
|
3347
|
+
unarchiveSession: (sessionId) => unarchiveSession(sessionId),
|
|
2735
3348
|
// 建会话:先用 inject 拿到的那个,拿不到就现读一次——`ctx.get` 对没写进 inject
|
|
2736
3349
|
// 的服务名是**返回 undefined**(不是抛错),所以这么写是安全的。
|
|
2737
3350
|
createSession: (workspaceId, agentPreset) =>
|
|
@@ -3301,7 +3914,7 @@ export function apply(ctx, config = {}) {
|
|
|
3301
3914
|
try {
|
|
3302
3915
|
return connection.authenticatedUrl(`${base}/`)
|
|
3303
3916
|
} catch (err) {
|
|
3304
|
-
|
|
3917
|
+
report('service', '取电脑端界面的访问凭据失败:' + (err?.message ?? err))
|
|
3305
3918
|
return null
|
|
3306
3919
|
}
|
|
3307
3920
|
},
|
|
@@ -3313,12 +3926,38 @@ export function apply(ctx, config = {}) {
|
|
|
3313
3926
|
// ------------------------------------------------------------------
|
|
3314
3927
|
// 起服务
|
|
3315
3928
|
// ------------------------------------------------------------------
|
|
3929
|
+
/**
|
|
3930
|
+
* 「跨机那两条」在**手机服务那个端口上**的第二份挂载点(2026-10-17 第七步)。
|
|
3931
|
+
*
|
|
3932
|
+
* ## 为什么要有它("B 填哪条地址"那个毛病的答案)
|
|
3933
|
+
*
|
|
3934
|
+
* 这两台电脑之间,B 手上只会填**一条**地址。而 A 这边两条路本来长在两个端口上:
|
|
3935
|
+
* 配对和跨机取票在宿主那一半(DSH 的 web 端口),取票回来的那条地址指向的
|
|
3936
|
+
* `/mini/mirror/` 在手机服务那一半(3090)。取票回的那条地址用的是**请求到达时的
|
|
3937
|
+
* Host**(见下面的 `requestOrigin`),于是 B 填哪一边都有一半对不上。
|
|
3938
|
+
*
|
|
3939
|
+
* 把这两条**原样**挂在手机服务上,B 就只填手机页那条地址——两端同源,`/mini/mirror/`
|
|
3940
|
+
* 本来就在那儿。
|
|
3941
|
+
*
|
|
3942
|
+
* ## 形状是可变的,而且这里只有挂载点
|
|
3943
|
+
*
|
|
3944
|
+
* `createMiniServer` 在 `webServer` 之前就起来了(上面那个 effect),而这两条路由
|
|
3945
|
+
* 要等 webServer 就绪才谈得上注册——所以这里先占位、注册那一刻填上,
|
|
3946
|
+
* 手机服务每次请求现取(见 lib/server.js 的 `remoteRoutes`)。
|
|
3947
|
+
* **逻辑一份都不在这儿**:下面两个处理函数原样交出去,两个端口共用同一份实现
|
|
3948
|
+
* (总开关、限速、口令/凭据的比对、票怎么发,一处都没多)。
|
|
3949
|
+
* 卸载时跟着摘掉(见那个 disposer)。
|
|
3950
|
+
*/
|
|
3951
|
+
const hostHalfRoutes = { pair: null, remoteWindow: null }
|
|
3952
|
+
|
|
3316
3953
|
ctx.effect(() => {
|
|
3317
3954
|
let closed = false
|
|
3318
3955
|
createMiniServer({
|
|
3319
3956
|
store,
|
|
3320
3957
|
config: settings,
|
|
3321
3958
|
token,
|
|
3959
|
+
// 「设备身份」名册:认凭据(每一次请求现查,删掉记录即失效)和登记新设备。
|
|
3960
|
+
devices,
|
|
3322
3961
|
bindAddresses: resolveBindAddresses(settings.bindAddress),
|
|
3323
3962
|
tunnelHost,
|
|
3324
3963
|
log,
|
|
@@ -3327,6 +3966,8 @@ export function apply(ctx, config = {}) {
|
|
|
3327
3966
|
// 「有这条路但它坏了」的 502:前者是实话,后者会让用户以为功能坏了。
|
|
3328
3967
|
mirror,
|
|
3329
3968
|
mirrorEnabled: () => store.state.mirrorEnabled === true && mirror.available(),
|
|
3969
|
+
// 跨机那两条在手机服务上的第二份挂载点(见上面那段)。
|
|
3970
|
+
remoteRoutes: hostHalfRoutes,
|
|
3330
3971
|
onInstruction,
|
|
3331
3972
|
onCommands,
|
|
3332
3973
|
onCommand,
|
|
@@ -3356,7 +3997,7 @@ export function apply(ctx, config = {}) {
|
|
|
3356
3997
|
server = instance
|
|
3357
3998
|
serverError = null
|
|
3358
3999
|
for (const f of instance.failed) {
|
|
3359
|
-
|
|
4000
|
+
report('service', `地址 ${f.address} 没绑上,已跳过:${f.message}`)
|
|
3360
4001
|
}
|
|
3361
4002
|
const usable = reachableAddresses().filter((a) => instance.addresses.includes(a.address))
|
|
3362
4003
|
if (usable.length) {
|
|
@@ -3367,7 +4008,7 @@ export function apply(ctx, config = {}) {
|
|
|
3367
4008
|
}
|
|
3368
4009
|
} else {
|
|
3369
4010
|
log.info(`已启动,但只绑上了本机:http://127.0.0.1:${instance.port}/mini?token=${token}`)
|
|
3370
|
-
|
|
4011
|
+
report('service', '没找到手机能连上的地址——检查 Wi-Fi/网线,或者 Tailscale 是否已登录。')
|
|
3371
4012
|
}
|
|
3372
4013
|
// 服务起来了才谈得上开隧道:cloudflared 要指向这个端口
|
|
3373
4014
|
if (settings.tunnel?.enabled) bringTunnelUp()
|
|
@@ -3379,7 +4020,7 @@ export function apply(ctx, config = {}) {
|
|
|
3379
4020
|
// 记下来。设置页那边要拿它把「还在启动」和「起不来了」分开说——
|
|
3380
4021
|
// 只写日志的话,用户看到的永远是那句「过几秒刷新一下」。
|
|
3381
4022
|
serverError = err?.message ?? String(err)
|
|
3382
|
-
|
|
4023
|
+
report('service', `启动失败:${serverError}`)
|
|
3383
4024
|
})
|
|
3384
4025
|
|
|
3385
4026
|
return () => {
|
|
@@ -3428,7 +4069,7 @@ export function apply(ctx, config = {}) {
|
|
|
3428
4069
|
ctx.inject(['webServer'], (webCtx) => {
|
|
3429
4070
|
const webServer = webCtx.webServer
|
|
3430
4071
|
if (typeof webServer?.register !== 'function') {
|
|
3431
|
-
|
|
4072
|
+
report('service', '拿不到 webServer 服务,设置页里的扫码面板用不了(手机端不受影响)。')
|
|
3432
4073
|
return
|
|
3433
4074
|
}
|
|
3434
4075
|
|
|
@@ -3448,6 +4089,40 @@ export function apply(ctx, config = {}) {
|
|
|
3448
4089
|
return false
|
|
3449
4090
|
}
|
|
3450
4091
|
|
|
4092
|
+
/**
|
|
4093
|
+
* 路由登记簿 + 挂载口(自检要看的「我们注册了哪些 / 哪些现在真的在」)。
|
|
4094
|
+
*
|
|
4095
|
+
* **登记和注册必须同一处发生**。另写一份常量清单看着更省事,但它迟早会和实际注册的
|
|
4096
|
+
* 那几条对不上——而这份清单的用处**恰恰是回答"这台跑的是哪一版"**:对不上的清单会
|
|
4097
|
+
* 把用户引到一个错结论上("接口都在"其实少一条,或者反过来,让他白重启一趟)。
|
|
4098
|
+
*
|
|
4099
|
+
* `mounted` 在注销那一刻翻成 `false`:路由交出去之后,**只有我们自己知道它还在不在**
|
|
4100
|
+
* (`webServer.register` 返回的那个 disposer 被调用过没有)。
|
|
4101
|
+
*/
|
|
4102
|
+
const routeBook = new Map()
|
|
4103
|
+
function mountRoute(route) {
|
|
4104
|
+
routeBook.set(route.path, { kind: route.kind ?? 'exact', mounted: true })
|
|
4105
|
+
const off = webServer.register(route)
|
|
4106
|
+
return () => {
|
|
4107
|
+
const entry = routeBook.get(route.path)
|
|
4108
|
+
if (entry) entry.mounted = false
|
|
4109
|
+
// 注销失败不该盖住真正要报的错(下面那个 disposer 一把全摘,一条出错不能拖着别的)。
|
|
4110
|
+
try { off?.() } catch { /* 见上 */ }
|
|
4111
|
+
}
|
|
4112
|
+
}
|
|
4113
|
+
|
|
4114
|
+
/**
|
|
4115
|
+
* 跨机那个开关的现状,交给设置页。
|
|
4116
|
+
*
|
|
4117
|
+
* **包在 `buildPairing` 外面**(不是当参数传进去):那个函数有四条早退路径
|
|
4118
|
+
* (成功 / 隧道报错 / 绑定配置问题 / 一条地址都没有),逐条补字段就是同一个坑
|
|
4119
|
+
* ——`serve` 和 `feishu` 都在那上面栽过(见 test/tunnel.test.mjs 里那两条测试)。
|
|
4120
|
+
* 包在外面,以后它再加一条早退路径也不会漏。
|
|
4121
|
+
*/
|
|
4122
|
+
function remoteStatus() {
|
|
4123
|
+
return { allowed: Boolean(settings.allowRemoteComputers) }
|
|
4124
|
+
}
|
|
4125
|
+
|
|
3451
4126
|
async function pairingPayload() {
|
|
3452
4127
|
if (!server) {
|
|
3453
4128
|
// 分清「还在启动」和「起不来了」。原来两种情况都回同一句「过几秒刷新一下」,
|
|
@@ -3463,6 +4138,9 @@ export function apply(ctx, config = {}) {
|
|
|
3463
4138
|
// 手机服务没起来,不影响飞书那一块——它和手机服务没有关系,谁用谁的。
|
|
3464
4139
|
// 所以这一条早退路径上也带着 feishu,用户至少改得动它。
|
|
3465
4140
|
feishu: feishuStatus(),
|
|
4141
|
+
// 跨机那个开关同理:它的路由长在宿主这一半(不在手机服务里),所以服务没
|
|
4142
|
+
// 起来的时候它照旧点得动、也照旧该显示出来。
|
|
4143
|
+
remote: remoteStatus(),
|
|
3466
4144
|
},
|
|
3467
4145
|
}
|
|
3468
4146
|
}
|
|
@@ -3493,10 +4171,10 @@ export function apply(ctx, config = {}) {
|
|
|
3493
4171
|
// 飞书那一块的现状(**永远不含 appSecret**,只回答「有没有凭据」)。
|
|
3494
4172
|
feishu: feishuStatus(),
|
|
3495
4173
|
})
|
|
3496
|
-
return { status: payload.ok ? 200 : 500, body: payload }
|
|
4174
|
+
return { status: payload.ok ? 200 : 500, body: { ...payload, remote: remoteStatus() } }
|
|
3497
4175
|
}
|
|
3498
4176
|
|
|
3499
|
-
const offGet =
|
|
4177
|
+
const offGet = mountRoute({
|
|
3500
4178
|
kind: 'exact',
|
|
3501
4179
|
path: '/mini-remote/pairing',
|
|
3502
4180
|
handler: async (req, res) => {
|
|
@@ -3510,7 +4188,7 @@ export function apply(ctx, config = {}) {
|
|
|
3510
4188
|
},
|
|
3511
4189
|
})
|
|
3512
4190
|
|
|
3513
|
-
const offTunnel =
|
|
4191
|
+
const offTunnel = mountRoute({
|
|
3514
4192
|
kind: 'exact',
|
|
3515
4193
|
path: '/mini-remote/tunnel',
|
|
3516
4194
|
handler: async (req, res) => {
|
|
@@ -3550,7 +4228,7 @@ export function apply(ctx, config = {}) {
|
|
|
3550
4228
|
* 官方印出来的开启链接。整个功能里门槛最高的就是这一步,把链接原样递给用户,
|
|
3551
4229
|
* 他点一下就完事;自己写一句「请到后台开启」等于把门槛加回去。
|
|
3552
4230
|
*/
|
|
3553
|
-
const offServe =
|
|
4231
|
+
const offServe = mountRoute({
|
|
3554
4232
|
kind: 'exact',
|
|
3555
4233
|
path: '/mini-remote/serve',
|
|
3556
4234
|
handler: async (req, res) => {
|
|
@@ -3615,7 +4293,7 @@ export function apply(ctx, config = {}) {
|
|
|
3615
4293
|
* 面板上那些二维码仍然是**当前在用的**旧密码编出来的——它们在重启前确实还能用,
|
|
3616
4294
|
* 显示成新的就成了骗人。
|
|
3617
4295
|
*/
|
|
3618
|
-
const offToken =
|
|
4296
|
+
const offToken = mountRoute({
|
|
3619
4297
|
kind: 'exact',
|
|
3620
4298
|
path: '/mini-remote/token',
|
|
3621
4299
|
handler: async (req, res) => {
|
|
@@ -3673,7 +4351,7 @@ export function apply(ctx, config = {}) {
|
|
|
3673
4351
|
* 2. 存完**就地重建连接**。飞书的长连接是绑在凭据上的,不重建的话用户会看到
|
|
3674
4352
|
* 「填好了、开关也开着,就是不回消息」,而屏幕上没有任何东西提示他原因。
|
|
3675
4353
|
*/
|
|
3676
|
-
const offFeishu =
|
|
4354
|
+
const offFeishu = mountRoute({
|
|
3677
4355
|
kind: 'exact',
|
|
3678
4356
|
path: '/mini-remote/feishu',
|
|
3679
4357
|
handler: async (req, res) => {
|
|
@@ -3711,6 +4389,895 @@ export function apply(ctx, config = {}) {
|
|
|
3711
4389
|
},
|
|
3712
4390
|
})
|
|
3713
4391
|
|
|
4392
|
+
/**
|
|
4393
|
+
* 「远程窗口」:给桌面端那个面板换一张**一次性票**(2026-10-15)。
|
|
4394
|
+
*
|
|
4395
|
+
* ## 为什么要有它(而不是让界面自己去拿)
|
|
4396
|
+
*
|
|
4397
|
+
* 桌面端的界面半边跑在 DSH 自己的页面里(实测:那个页面在自定义协议
|
|
4398
|
+
* `dsh-app://app/` 上,见 DSH 桌面端 main.js 的 `protocol.handle(SCHEME…)`),
|
|
4399
|
+
* 我们的镜像在 `http://127.0.0.1:<端口>` 上——**既跨源又跨站**。它自己去要凭据,
|
|
4400
|
+
* 要么被跨源拦住,要么就得把令牌发给浏览器,而令牌一旦进了 iframe 的地址就会留在
|
|
4401
|
+
* 那台电脑的浏览历史里(设计稿那条硬规矩)。
|
|
4402
|
+
*
|
|
4403
|
+
* 所以由**宿主这一半**(就跑在这个进程里、本来就拿着令牌)去换票,再把票交给界面:
|
|
4404
|
+
* 界面只拿得到「票 + 一条地址」,令牌一个字节都过不去。
|
|
4405
|
+
*
|
|
4406
|
+
* ## 票怎么用
|
|
4407
|
+
*
|
|
4408
|
+
* 地址是 `http://127.0.0.1:<我们的端口>/mini/mirror/?ticket=…`。浏览器拿着它进那扇门,
|
|
4409
|
+
* 门里把票兑现成一枚**窗口会话 cookie**(iframe 后续那些请求全靠它认人),
|
|
4410
|
+
* 票自己**只授权那一次**;地址里留着的那张票之后只用来回答「这一页是我嵌出来的吗」
|
|
4411
|
+
* ——取景政策靠它(见 `lib/server.js` 的取景政策与 `lib/mirror.js` 的票)。
|
|
4412
|
+
* **票 30 秒、一次性、用完即废。**
|
|
4413
|
+
*
|
|
4414
|
+
* 用 `127.0.0.1` 而不是本机在局域网里的地址:这个面板只可能在**这台电脑**上打开。
|
|
4415
|
+
*
|
|
4416
|
+
* 和别的 `/mini-remote/*` 一样**只允许本机调用**——能发这张票的只能是本机上的软件。
|
|
4417
|
+
*
|
|
4418
|
+
* **票上要写着这扇窗是谁的**(2026-10-16 第五步):本机这一条给 `local`,兑票那一下
|
|
4419
|
+
* 照着它签窗口会话(见 lib/server.js 的 ticketWindowOwner)。兑票的浏览器在**这台
|
|
4420
|
+
* 电脑**上,所以那一关仍然要求"这一下是这台电脑自己发的"(回环、无转发头)。
|
|
4421
|
+
*/
|
|
4422
|
+
const offWindow = mountRoute({
|
|
4423
|
+
kind: 'exact',
|
|
4424
|
+
path: '/mini-remote/window',
|
|
4425
|
+
handler: (req, res) => {
|
|
4426
|
+
if (!localOnly(req, res)) return
|
|
4427
|
+
if (req.method !== 'POST') {
|
|
4428
|
+
sendJson(res, 405, { ok: false, error: '这个地址只接受 POST。' })
|
|
4429
|
+
return
|
|
4430
|
+
}
|
|
4431
|
+
if (!server) {
|
|
4432
|
+
sendJson(res, 503, { ok: false, error: '手机服务还在启动,过几秒再试。' })
|
|
4433
|
+
return
|
|
4434
|
+
}
|
|
4435
|
+
if (!mirror.available()) {
|
|
4436
|
+
// 这台机器上没拿到「电脑端界面」的地址(宿主刚起来那一段是这样)。
|
|
4437
|
+
sendJson(res, 503, { ok: false, error: '这台电脑上现在找不到可嵌入的「电脑端界面」,过几秒重新打开这一次。' })
|
|
4438
|
+
return
|
|
4439
|
+
}
|
|
4440
|
+
sendJson(res, 200, {
|
|
4441
|
+
ok: true,
|
|
4442
|
+
url: `http://127.0.0.1:${server.port}/mini/mirror/?ticket=${encodeURIComponent(mirror.issueTicket(LOCAL_WINDOW_DEVICE))}`,
|
|
4443
|
+
expiresIn: Math.round(mirror.ticketTtlMs / 1000),
|
|
4444
|
+
})
|
|
4445
|
+
},
|
|
4446
|
+
})
|
|
4447
|
+
|
|
4448
|
+
/**
|
|
4449
|
+
* 回给外面那台电脑的地址里,那个「源」是什么(2026-10-16 跨机第二步)。
|
|
4450
|
+
*
|
|
4451
|
+
* ## 为什么必须是**请求到达时的 Host**,不能写死 `127.0.0.1`
|
|
4452
|
+
*
|
|
4453
|
+
* 本机版那条可以写死(那个面板只可能在这台电脑上打开),跨机这一条**不行**:
|
|
4454
|
+
* 这条地址会原样交到**对面那台电脑**的浏览器里,而在那台电脑上 `127.0.0.1`
|
|
4455
|
+
* 指的是**它自己**——框会去连它自己的本机端口,屏幕上是一片白/一个莫名其妙的
|
|
4456
|
+
* 404,而 A 这边日志干干净净,最难查的就是这种。所以用请求里那个 Host:
|
|
4457
|
+
* 它天然就是"对面用来够到我的那个地址"。
|
|
4458
|
+
*
|
|
4459
|
+
* 协议跟着 `X-Forwarded-Proto` 走:A 很可能藏在 Tailscale 的 https 后面
|
|
4460
|
+
* (那边转发进来的是明文 http),这时对面要的是 `https://`。
|
|
4461
|
+
*
|
|
4462
|
+
* **Host 缺失就回 null**,由调用方当场报错。宁可明说"说不清该回哪条地址",
|
|
4463
|
+
* 也绝不退回一个写死的本机地址——那正是这一条要防的错。
|
|
4464
|
+
*/
|
|
4465
|
+
function requestOrigin(req) {
|
|
4466
|
+
const host = String(req.headers?.host ?? '').trim()
|
|
4467
|
+
if (!host) return null
|
|
4468
|
+
const proto = String(req.headers?.['x-forwarded-proto'] ?? '').split(',')[0].trim().toLowerCase()
|
|
4469
|
+
return `${proto === 'https' ? 'https' : 'http'}://${host}`
|
|
4470
|
+
}
|
|
4471
|
+
|
|
4472
|
+
/**
|
|
4473
|
+
* 「跨机远程窗口」:**另一台电脑拿自己的设备凭据,来换一张带票的地址**
|
|
4474
|
+
* (2026-10-16 跨机第二步)。
|
|
4475
|
+
*
|
|
4476
|
+
* ## 它和本机那条(`/mini-remote/window`)是同一件事的两个人
|
|
4477
|
+
*
|
|
4478
|
+
* 本机那条拿票的是**这台电脑自己的软件**,所以只允许回环(`localOnly`);
|
|
4479
|
+
* 这一条拿票的人在**另一台电脑上**,手上那枚东西是配对时换来的**设备凭据**
|
|
4480
|
+
* ——不是这台电脑的口令,也不是本机回环。所以:
|
|
4481
|
+
*
|
|
4482
|
+
* · **不调用 `localOnly`**(照 `pair` 那条的先例——它也是给外面调的);
|
|
4483
|
+
* · 认的是**设备凭据**,三种带法(`X-Mini-Device` 头 / `?device=` / cookie)
|
|
4484
|
+
* 走**同一份实现**(`server.verifyDevice` → `isAuthorized` 里认设备那一路),
|
|
4485
|
+
* 而且每次都现查名册:那台电脑被撤销之后,这枚凭据当场失效;
|
|
4486
|
+
* · 票还是那一种票(30 秒、一次性、用完即废),换来的会话里签着它的设备身份
|
|
4487
|
+
* ——撤销对**已经开着的那扇窗**也生效(见 lib/server.js 的 windowSession)。
|
|
4488
|
+
*
|
|
4489
|
+
* ## 票上要写着这台设备的 id(2026-10-16 第五步)
|
|
4490
|
+
*
|
|
4491
|
+
* 兑票的那一下在**对面那台电脑的浏览器**里发生:那个浏览器**没有设备凭据**(凭据
|
|
4492
|
+
* 只活在这条路由的调用方——对面那个宿主进程里),也**不是这台电脑的回环**。所以
|
|
4493
|
+
* 身份必须在**发票这一刻**定下来、跟着票走:这里就是刚验过的 `deviceId`
|
|
4494
|
+
* (`server.verifyDevice` 那一步,每次都现查名册)。兑票那一下照它签会话,
|
|
4495
|
+
* 而且**兑现时还会再查一次名册**(见 lib/server.js 的 ticketWindowOwner)。
|
|
4496
|
+
*
|
|
4497
|
+
* ## 少掉 `localOnly` 之后补的是什么(照 `pair` 那套,顺序固定)
|
|
4498
|
+
*
|
|
4499
|
+
* 1. **开关**:`allowRemoteComputers` 关着时**这条路由一条都不接**;
|
|
4500
|
+
* 2. **服务在不在**:不在就 503——限速那一套就长在那个服务实例里;
|
|
4501
|
+
* 3. **限速**:和手机服务**共用同一张表**(`server.rateLimit`)。这条是外面能调的,
|
|
4502
|
+
* 闸不是可选项。**不写 `?.`**:拿不到闸宁可当场报错(关着),也不静默放行;
|
|
4503
|
+
* 4. **凭据**:比对本身走 `devices.verify`(`timingSafeEqual`,定长比较),
|
|
4504
|
+
* 和口令那条同一个口径。
|
|
4505
|
+
*
|
|
4506
|
+
* ## 为什么限速只查不记(和 `pair` 不同的一处,有意)
|
|
4507
|
+
*
|
|
4508
|
+
* `pair` 那条输的是**口令**——用户自己定的、猜得动,所以错一次记一次。这条输的是
|
|
4509
|
+
* **设备凭据**(256 位随机数),猜不动,记失败一个字节的安全都换不来;而它会造出
|
|
4510
|
+
* 一个死结:那台电脑被撤销之后手上那枚凭据必然失败,它的界面会反复重试,5 次就把
|
|
4511
|
+
* 来源封一分钟——用户在同一台电脑上重新配对时撞上的是"错误次数太多"。这正是
|
|
4512
|
+
* `lib/server.js` 的 `noteTokenFailure` 那段写下过的坑,所以这里照它办:
|
|
4513
|
+
* **只 `noteSuccess`,不 `noteFailure`**;闸门照旧查(共用同一张表)。
|
|
4514
|
+
*
|
|
4515
|
+
* ## 回给对面的字段
|
|
4516
|
+
*
|
|
4517
|
+
* `{ ok, url, expiresIn }`——和本机那条**逐字同形状**(对面那台电脑的宿主两件事
|
|
4518
|
+
* 都要做:拿地址、按到期时间决定多久重取一次)。凭据、名册、id 一概不回。
|
|
4519
|
+
*
|
|
4520
|
+
* ## 这一条**两个端口上各挂一份**(2026-10-17 第七步)
|
|
4521
|
+
*
|
|
4522
|
+
* 上面这些规矩一条都不变;变的是"它能从哪儿进来"。同一个处理函数既挂在宿主这一半
|
|
4523
|
+
* (下面 `webServer.register`),也挂在**手机服务那一半**(`hostHalfRoutes.remoteWindow`
|
|
4524
|
+
* → lib/server.js 的 `remoteRoutes`)。理由见 `hostHalfRoutes` 那段:B 只填一条地址,
|
|
4525
|
+
* 而那条地址上必须有配对、取票、`/mini/mirror/` 三样。
|
|
4526
|
+
*/
|
|
4527
|
+
const remoteWindowRoute = {
|
|
4528
|
+
kind: 'exact',
|
|
4529
|
+
path: '/mini-remote/remote/window',
|
|
4530
|
+
handler: (req, res) => {
|
|
4531
|
+
if (req.method !== 'POST') {
|
|
4532
|
+
sendJson(res, 405, { ok: false, error: '这个地址只接受 POST。' })
|
|
4533
|
+
return
|
|
4534
|
+
}
|
|
4535
|
+
if (!settings.allowRemoteComputers) {
|
|
4536
|
+
sendJson(res, 403, {
|
|
4537
|
+
ok: false,
|
|
4538
|
+
error: '这台电脑没有允许别的电脑连接。要连的话,先在这台电脑的设置里打开「允许别的电脑连接」。',
|
|
4539
|
+
})
|
|
4540
|
+
return
|
|
4541
|
+
}
|
|
4542
|
+
if (!server) {
|
|
4543
|
+
sendJson(res, 503, { ok: false, error: '手机服务还在启动,过几秒再试。' })
|
|
4544
|
+
return
|
|
4545
|
+
}
|
|
4546
|
+
if (server.rateLimit.isBlocked(req)) {
|
|
4547
|
+
sendJson(res, 429, { ok: false, error: '口令错误次数太多,等一分钟再试。' })
|
|
4548
|
+
return
|
|
4549
|
+
}
|
|
4550
|
+
// 认人(**三种带法都收**):`?device=` 在地址里,cookie 是浏览器自己带的,
|
|
4551
|
+
// 跨机那条主路是 `X-Mini-Device` 请求头(对面那台电脑的宿主进程带着它发过来)。
|
|
4552
|
+
const deviceId = server.verifyDevice(req, new URL(req.url ?? '/', 'http://placeholder'))
|
|
4553
|
+
if (!deviceId) {
|
|
4554
|
+
sendJson(res, 401, { ok: false, error: '没带对凭据。这一枚是配对的时候换来的,撤销之后也没法再用。' })
|
|
4555
|
+
return
|
|
4556
|
+
}
|
|
4557
|
+
// 认下来了:把这一小段试错历史清掉(和手机页进门那一下同一条口径)。
|
|
4558
|
+
server.rateLimit.noteSuccess(req)
|
|
4559
|
+
if (!mirror.available()) {
|
|
4560
|
+
sendJson(res, 503, { ok: false, error: '这台电脑上现在找不到可嵌入的「电脑端界面」,过几秒再试。' })
|
|
4561
|
+
return
|
|
4562
|
+
}
|
|
4563
|
+
const origin = requestOrigin(req)
|
|
4564
|
+
if (!origin) {
|
|
4565
|
+
sendJson(res, 400, { ok: false, error: '这个请求没有带 Host,说不清该回哪条地址。' })
|
|
4566
|
+
return
|
|
4567
|
+
}
|
|
4568
|
+
// 只记设备 id:不记凭据、不记地址——和 issueDeviceCookie 那条日志同一条规矩。
|
|
4569
|
+
console.log(`[dsh-mini-remote] 跨机取票:给名册里那台电脑(${deviceId})发了一张票`)
|
|
4570
|
+
sendJson(res, 200, {
|
|
4571
|
+
ok: true,
|
|
4572
|
+
// **这一张票记的就是这台设备**(见上面那段):兑票的浏览器手上没有凭据。
|
|
4573
|
+
url: `${origin}/mini/mirror/?ticket=${encodeURIComponent(mirror.issueTicket(deviceId))}`,
|
|
4574
|
+
expiresIn: Math.round(mirror.ticketTtlMs / 1000),
|
|
4575
|
+
})
|
|
4576
|
+
},
|
|
4577
|
+
}
|
|
4578
|
+
// 手机服务那一半也挂这一份(见 hostHalfRoutes 那段):B 填的那条地址上,
|
|
4579
|
+
// 取票和票指向的 `/mini/mirror/` 必须在同一个源上。
|
|
4580
|
+
hostHalfRoutes.remoteWindow = remoteWindowRoute.handler
|
|
4581
|
+
const offRemoteWindow = mountRoute(remoteWindowRoute)
|
|
4582
|
+
|
|
4583
|
+
/**
|
|
4584
|
+
* 「允许别的电脑连接」那个总开关(2026-10-16 跨机第二步)。
|
|
4585
|
+
*
|
|
4586
|
+
* 和公网开关、改密码、飞书那几条**同一个位置、同一条规矩**(`localOnly`):
|
|
4587
|
+
* 它决定**别的电脑能不能连过来**,是同一张 Wi-Fi 下别人不该替你做主的事。
|
|
4588
|
+
*
|
|
4589
|
+
* 存进 `settings.allowRemoteComputers`(落 settings.json),**存完就地生效**——
|
|
4590
|
+
* 那两条跨机路由读的是同一个值,不必重载插件。返回的是整份配对信息
|
|
4591
|
+
* (和公网开关那条一样),界面拿它一次刷新到位。
|
|
4592
|
+
*
|
|
4593
|
+
* **默认关**:新用户第一次进这一页看到的是关着的。**装好不会自动打开**——
|
|
4594
|
+
* 这是刻意留白,那个动作只能由人来点。
|
|
4595
|
+
*/
|
|
4596
|
+
const offRemoteAccess = mountRoute({
|
|
4597
|
+
kind: 'exact',
|
|
4598
|
+
path: '/mini-remote/remote-access',
|
|
4599
|
+
handler: async (req, res) => {
|
|
4600
|
+
if (!localOnly(req, res)) return
|
|
4601
|
+
if (req.method !== 'POST') {
|
|
4602
|
+
sendJson(res, 405, { ok: false, error: '这个地址只接受 POST。' })
|
|
4603
|
+
return
|
|
4604
|
+
}
|
|
4605
|
+
let want
|
|
4606
|
+
try {
|
|
4607
|
+
want = await readBody(req)
|
|
4608
|
+
} catch (err) {
|
|
4609
|
+
sendJson(res, 400, { ok: false, error: `请求体读不了:${err?.message ?? err}` })
|
|
4610
|
+
return
|
|
4611
|
+
}
|
|
4612
|
+
settings.allowRemoteComputers = Boolean(want?.enabled)
|
|
4613
|
+
persistSettings()
|
|
4614
|
+
console.log(`[dsh-mini-remote] 「允许别的电脑连接」现在是${settings.allowRemoteComputers ? '开' : '关'}`)
|
|
4615
|
+
try {
|
|
4616
|
+
const { status, body } = await pairingPayload()
|
|
4617
|
+
sendJson(res, status, body)
|
|
4618
|
+
} catch (err) {
|
|
4619
|
+
sendJson(res, 500, { ok: false, error: `切换之后读不到状态:${err?.message ?? err}` })
|
|
4620
|
+
}
|
|
4621
|
+
},
|
|
4622
|
+
})
|
|
4623
|
+
|
|
4624
|
+
/**
|
|
4625
|
+
* 「设备名册」那三条:**列出 / 改名 / 撤销**(2026-10-16 第三步)。
|
|
4626
|
+
*
|
|
4627
|
+
* ## 为什么在这里、为什么只允许本机
|
|
4628
|
+
*
|
|
4629
|
+
* 和配对信息、改密码、开关公网同一个位置、同一条规矩(`localOnly`):这几条决定
|
|
4630
|
+
* **谁进得来**,是同一张 Wi-Fi 下别人不该替你做主的事。
|
|
4631
|
+
*
|
|
4632
|
+
* ## 为什么三条都只跟 `devices` 说话
|
|
4633
|
+
*
|
|
4634
|
+
* `devices`(见 lib/devices.js)是这份名册**唯一**的读写口。设置页那半边不做第二份
|
|
4635
|
+
* 判断、更不自己去读写那个 JSON:内存一份、磁盘一份,两处各写各的迟早对不上,
|
|
4636
|
+
* 而对不上的表现是「界面上删了,那台手机还能进」——最难查、也最要命的那种。
|
|
4637
|
+
*
|
|
4638
|
+
* 返回里**永远没有 keyHash、也没有凭据本身**:list / rename 回的都是
|
|
4639
|
+
* `devices.js` 的 publicRecord(id、名字、类型、创建时间、最近使用)。
|
|
4640
|
+
*/
|
|
4641
|
+
const offDevices = mountRoute({
|
|
4642
|
+
kind: 'exact',
|
|
4643
|
+
path: '/mini-remote/devices',
|
|
4644
|
+
handler: (req, res) => {
|
|
4645
|
+
if (!localOnly(req, res)) return
|
|
4646
|
+
if (req.method !== 'GET') {
|
|
4647
|
+
sendJson(res, 405, { ok: false, error: '这个地址只接受 GET。' })
|
|
4648
|
+
return
|
|
4649
|
+
}
|
|
4650
|
+
sendJson(res, 200, { ok: true, devices: devices.list() })
|
|
4651
|
+
},
|
|
4652
|
+
})
|
|
4653
|
+
|
|
4654
|
+
/**
|
|
4655
|
+
* 改一台设备的名字。
|
|
4656
|
+
*
|
|
4657
|
+
* **名字只是标签**(见 lib/devices.js 开头):改了它,那台设备照旧进得来、照旧
|
|
4658
|
+
* 什么都做得了。所以这一条不掐连接、不碰凭据,只动那一个字符串。
|
|
4659
|
+
*
|
|
4660
|
+
* 名字的合法性由 devices.rename 判(空名字、超长),错误原样带回去——两处各写一份
|
|
4661
|
+
* 校验,迟早一处改了一处没改,而表现是「界面放行、服务端拒绝」。
|
|
4662
|
+
*/
|
|
4663
|
+
const offDeviceRename = mountRoute({
|
|
4664
|
+
kind: 'exact',
|
|
4665
|
+
path: '/mini-remote/devices/rename',
|
|
4666
|
+
handler: async (req, res) => {
|
|
4667
|
+
if (!localOnly(req, res)) return
|
|
4668
|
+
if (req.method !== 'POST') {
|
|
4669
|
+
sendJson(res, 405, { ok: false, error: '这个地址只接受 POST。' })
|
|
4670
|
+
return
|
|
4671
|
+
}
|
|
4672
|
+
let want
|
|
4673
|
+
try {
|
|
4674
|
+
want = await readBody(req)
|
|
4675
|
+
} catch (err) {
|
|
4676
|
+
sendJson(res, 400, { ok: false, error: `请求体读不了:${err?.message ?? err}` })
|
|
4677
|
+
return
|
|
4678
|
+
}
|
|
4679
|
+
const done = devices.rename(want?.id, want?.name)
|
|
4680
|
+
if (!done.ok) {
|
|
4681
|
+
sendJson(res, 400, { ok: false, error: done.error })
|
|
4682
|
+
return
|
|
4683
|
+
}
|
|
4684
|
+
sendJson(res, 200, { ok: true, device: done.device })
|
|
4685
|
+
},
|
|
4686
|
+
})
|
|
4687
|
+
|
|
4688
|
+
/**
|
|
4689
|
+
* 撤销一台设备:**删掉记录 + 掐掉它手上的活连接**。
|
|
4690
|
+
*
|
|
4691
|
+
* ## 顺序不能反(先删记录,再掐线)
|
|
4692
|
+
*
|
|
4693
|
+
* 反过来的话,那条流断开时客户端会立刻重连一次——而记录还在,它重连就成功了。
|
|
4694
|
+
* 用户看到的就是「撤销了,但那台手机照旧在动」。先删记录:重连撞上 401,
|
|
4695
|
+
* 于是按设计回到门禁页。
|
|
4696
|
+
*
|
|
4697
|
+
* ## 为什么要掐(这一步的核心)
|
|
4698
|
+
*
|
|
4699
|
+
* 手机那边是**服务端单向推**的。只让"下一次请求被拒",那条已经挂着的 SSE 照样
|
|
4700
|
+
* 收得到新消息——用户会以为撤销没生效。掐断是这件事**看得见**的那一半,
|
|
4701
|
+
* 具体在 lib/server.js 的 dropDeviceConnections 里(SSE + 镜像那个框的 WebSocket,
|
|
4702
|
+
* **「远程窗口」那扇窗的常驻连接也算它的**——会话里签着设备身份,见 server.js 的升级处理器)。
|
|
4703
|
+
*
|
|
4704
|
+
* ⚠️ **那扇开着的窗不会"当场断开"**:窗口里的请求是浏览器自动带 cookie 发的短请求,
|
|
4705
|
+
* 不是长连接。撤销之后它**下一次请求**才 401(静态资源、`/api/*`、下一次点击/轮询),
|
|
4706
|
+
* 随后页面自己回门禁页。**给用户的话就照这个说**,别承诺"立刻变"。
|
|
4707
|
+
*
|
|
4708
|
+
* 撤销**不需要二次确认**吗?需要,但那一步在**界面那一侧**(用户拍板的:撤销要
|
|
4709
|
+
* 二次确认,确认框里说清"这台设备要重新扫码才能再用")。服务端这条只管执行——
|
|
4710
|
+
* 它只对本机的软件开口,而本机的软件本来就有这台电脑的全部权限,谈不上绕过。
|
|
4711
|
+
*/
|
|
4712
|
+
const offDeviceRevoke = mountRoute({
|
|
4713
|
+
kind: 'exact',
|
|
4714
|
+
path: '/mini-remote/devices/revoke',
|
|
4715
|
+
handler: async (req, res) => {
|
|
4716
|
+
if (!localOnly(req, res)) return
|
|
4717
|
+
if (req.method !== 'POST') {
|
|
4718
|
+
sendJson(res, 405, { ok: false, error: '这个地址只接受 POST。' })
|
|
4719
|
+
return
|
|
4720
|
+
}
|
|
4721
|
+
let want
|
|
4722
|
+
try {
|
|
4723
|
+
want = await readBody(req)
|
|
4724
|
+
} catch (err) {
|
|
4725
|
+
sendJson(res, 400, { ok: false, error: `请求体读不了:${err?.message ?? err}` })
|
|
4726
|
+
return
|
|
4727
|
+
}
|
|
4728
|
+
const id = typeof want?.id === 'string' ? want.id : ''
|
|
4729
|
+
if (!id) {
|
|
4730
|
+
sendJson(res, 400, { ok: false, error: '要撤销哪一台?请求里没有 id。' })
|
|
4731
|
+
return
|
|
4732
|
+
}
|
|
4733
|
+
const removed = devices.remove(id)
|
|
4734
|
+
if (!removed) {
|
|
4735
|
+
// 另一台电脑/另一个标签页刚撤过:如实说"它已经不在名册里了",别假装成功。
|
|
4736
|
+
sendJson(res, 404, { ok: false, error: '这台设备已经不在名册里了,刷新看看。' })
|
|
4737
|
+
return
|
|
4738
|
+
}
|
|
4739
|
+
// 服务还没起来时它手上不可能有活连接(那条流是它自己起的),所以这里可以缺。
|
|
4740
|
+
const dropped = server?.dropDeviceConnections?.(id) ?? 0
|
|
4741
|
+
console.log(`[dsh-mini-remote] 撤销了一台设备(${id}),掐掉 ${dropped} 条活连接`)
|
|
4742
|
+
sendJson(res, 200, { ok: true, id, dropped })
|
|
4743
|
+
},
|
|
4744
|
+
})
|
|
4745
|
+
|
|
4746
|
+
/**
|
|
4747
|
+
* 「配对」:**另一台电脑拿这台电脑的口令,给自己换一枚设备凭据**(2026-10-16,跨机第一步)。
|
|
4748
|
+
*
|
|
4749
|
+
* ## 为什么它**不走 `localOnly`**(2026-10-16 第二步起,这样的有两条)
|
|
4750
|
+
*
|
|
4751
|
+
* 别的 `/mini-remote/*` 都只允许本机调用,因为它们是"本机的软件替你做主"。这一条
|
|
4752
|
+
* 正相反:**要配对的那台电脑在"外面"**——它不在本机回环上,而且调用的这一刻它手里
|
|
4753
|
+
* 还没有任何凭据(凭据正是它来这儿要的东西)。所以这里**不调用 `localOnly`**。
|
|
4754
|
+
* 另一条是「跨机取票」(`/mini-remote/remote/window`),同一条理由:来换票的人在
|
|
4755
|
+
* 另一台电脑上,手上那枚凭据是这一条刚换给它的。
|
|
4756
|
+
*
|
|
4757
|
+
* 少掉的那道门用三样补上,缺一不可:
|
|
4758
|
+
* · **开关**(`settings.allowRemoteComputers`,默认关):关着时这一条一条都不接
|
|
4759
|
+
* ——"关"是"这台电脑不接受别的电脑连过来"这个整体状态;
|
|
4760
|
+
* · **口令**(`tokenEquals`,定长比较,和手机页同一枚、同一个判据);
|
|
4761
|
+
* · **试错限速**(`server.rateLimit`,和手机服务**共用同一张表**,见 lib/server.js)。
|
|
4762
|
+
* 这条是外面能调的,限速不是可选项。**不另写一套**:现成那一套就是为猜口令准备的,
|
|
4763
|
+
* 两套各数各的,等于给猜的人两份额度。
|
|
4764
|
+
*
|
|
4765
|
+
* ## 口令**不是一次性的**
|
|
4766
|
+
*
|
|
4767
|
+
* 配对成功之后它照旧有效:口令是"这台电脑的入场邀请函",本来就允许多次使用
|
|
4768
|
+
* ——手机页那一路每次带 `?token=` 进来都会发一枚新凭据,用的还是同一枚口令。
|
|
4769
|
+
* 所以一台 A 可以配多台电脑,配完照样能继续用手机。**一次性的东西是"票"**
|
|
4770
|
+
* (`/mini-remote/window` 那张,30 秒、用完即废),不是口令。要收回哪一台,
|
|
4771
|
+
* 走名册那一行撤销(`/mini-remote/devices/revoke`)。
|
|
4772
|
+
*
|
|
4773
|
+
* ## 回给调用方的**只有凭据**
|
|
4774
|
+
*
|
|
4775
|
+
* `{ ok: true, credential }`:名字、创建时间、名册、keyHash 一概不回——对面要的就是
|
|
4776
|
+
* 这一枚,多一个字段就多一处泄漏面。`id` 本来就在凭据里(`<id>.<密钥>`),不用另给一份。
|
|
4777
|
+
*
|
|
4778
|
+
* ## 口令只从请求体里收(**不接受 `?token=`**)
|
|
4779
|
+
*
|
|
4780
|
+
* 口令进了地址,就会被写进对面、以及中间任何一台代理的日志和历史里。别的路由把
|
|
4781
|
+
* 地址里带参数当成方便,这一条不行:它是跨机的,路上不止一台机器。
|
|
4782
|
+
*
|
|
4783
|
+
* ## 这一条也是**两个端口上各挂一份**(2026-10-17 第七步)
|
|
4784
|
+
*
|
|
4785
|
+
* 和「跨机取票」同一条理由(见 `hostHalfRoutes` 那段):B 填的那一条地址必须同时
|
|
4786
|
+
* 容得下配对和 `/mini/mirror/`,而那两样本来分在两个端口上。规矩一个字没改,
|
|
4787
|
+
* 只是同一个处理函数多了一个挂载点。
|
|
4788
|
+
*/
|
|
4789
|
+
const pairRoute = {
|
|
4790
|
+
kind: 'exact',
|
|
4791
|
+
path: '/mini-remote/pair',
|
|
4792
|
+
handler: async (req, res) => {
|
|
4793
|
+
if (req.method !== 'POST') {
|
|
4794
|
+
sendJson(res, 405, { ok: false, error: '这个地址只接受 POST。' })
|
|
4795
|
+
return
|
|
4796
|
+
}
|
|
4797
|
+
// 「允许别的电脑连接」关着:**配对也不接**(用户拍板的:关 = 这台电脑不接受
|
|
4798
|
+
// 别的电脑连过来,是一个整体状态,不是"少录一项配置")。这一闸放在最前面,
|
|
4799
|
+
// 排在限速和口令之前——关着的时候连"猜口令"这件事都不该发生。
|
|
4800
|
+
if (!settings.allowRemoteComputers) {
|
|
4801
|
+
sendJson(res, 403, {
|
|
4802
|
+
ok: false,
|
|
4803
|
+
error: '这台电脑没有允许别的电脑连接。要连的话,先在这台电脑的设置里打开「允许别的电脑连接」。',
|
|
4804
|
+
})
|
|
4805
|
+
return
|
|
4806
|
+
}
|
|
4807
|
+
if (!server) {
|
|
4808
|
+
// 限速那一套长在手机服务里(同一张表)。服务没起来时配对也没有意义——
|
|
4809
|
+
// 换回去的凭据进不了门,所以如实说"还在启动",别让对面以为配好了。
|
|
4810
|
+
sendJson(res, 503, { ok: false, error: '手机服务还在启动,过几秒再试。' })
|
|
4811
|
+
return
|
|
4812
|
+
}
|
|
4813
|
+
// **不写 `?.`**:限速这一套就在上面那个服务实例里(同一个模块给的),拿不到就等于
|
|
4814
|
+
// 这道门没闸——那种情况下宁可当场报错(关着),也不要静悄悄放它过去(开着)。
|
|
4815
|
+
if (server.rateLimit.isBlocked(req)) {
|
|
4816
|
+
sendJson(res, 429, { ok: false, error: '口令错误次数太多,等一分钟再试。' })
|
|
4817
|
+
return
|
|
4818
|
+
}
|
|
4819
|
+
let want
|
|
4820
|
+
try {
|
|
4821
|
+
want = await readBody(req)
|
|
4822
|
+
} catch (err) {
|
|
4823
|
+
sendJson(res, 400, { ok: false, error: `请求体读不了:${err?.message ?? err}` })
|
|
4824
|
+
return
|
|
4825
|
+
}
|
|
4826
|
+
const given = typeof want?.token === 'string' ? want.token : ''
|
|
4827
|
+
if (!tokenEquals(given, token)) {
|
|
4828
|
+
// 记一次试错。**只有外面能调的这条路**才需要在这里数:猜口令的次数就是在
|
|
4829
|
+
// 这一行数出来的(同来源错 5 次封一分钟,下次请求进门就被上面那道 429 挡住)。
|
|
4830
|
+
server.rateLimit.noteFailure(req)
|
|
4831
|
+
sendJson(res, 401, { ok: false, error: '口令不对。' })
|
|
4832
|
+
return
|
|
4833
|
+
}
|
|
4834
|
+
// 口令对了:把这一小段历史清掉(和手机页进门那一下同一条口径)。
|
|
4835
|
+
server.rateLimit.noteSuccess(req)
|
|
4836
|
+
// 名字留空 = register 的默认名,用户可以之后在设置页改名。**不从请求体里收名字**:
|
|
4837
|
+
// 这一条的输入只有口令一样(见上面"只给凭据"那一段的同一条理由)。
|
|
4838
|
+
const issued = devices.register({ kind: 'desktop' })
|
|
4839
|
+
// 只记 id、不记口令、不记凭据:和 issueDeviceCookie 那条日志同一条规矩。
|
|
4840
|
+
console.log(`[dsh-mini-remote] 配对成功,登记了一台电脑(${issued.id})`)
|
|
4841
|
+
sendJson(res, 200, { ok: true, credential: issued.credential })
|
|
4842
|
+
},
|
|
4843
|
+
}
|
|
4844
|
+
// 手机服务那一半也挂这一份(见 hostHalfRoutes 那段)。
|
|
4845
|
+
hostHalfRoutes.pair = pairRoute.handler
|
|
4846
|
+
const offPair = mountRoute(pairRoute)
|
|
4847
|
+
|
|
4848
|
+
/**
|
|
4849
|
+
* 「我能连的电脑」那四条(**B 侧**,2026-10-16 跨机第三步)。
|
|
4850
|
+
*
|
|
4851
|
+
* ## 它管的是哪一件事
|
|
4852
|
+
*
|
|
4853
|
+
* 前两步都在 A 侧(A 面对"别的电脑来连我":配对、跨机取票、那个总开关)。
|
|
4854
|
+
* 这一族在 **B 侧**:B 自己记着"我连得上哪几台电脑"(lib/remotes.js 的
|
|
4855
|
+
* `remotes.json`),并且**替自己的界面**去那台电脑换票。
|
|
4856
|
+
*
|
|
4857
|
+
* ## 为什么四条都只允许本机(`localOnly`)
|
|
4858
|
+
*
|
|
4859
|
+
* 这一族是"**这台电脑的软件替你做主**":添加(要输对面的口令)、列出、取票、断开,
|
|
4860
|
+
* 全都发生在 B 自己的设置页上——同一个 Wi-Fi 下,别人不该替 B 决定"我要连谁"。
|
|
4861
|
+
* 和 `/mini-remote/pair`、`/mini-remote/remote/window` 那两条恰好相反:那两条长在 A 上、
|
|
4862
|
+
* 服务的是**外面**那台电脑,所以它们不调 localOnly。
|
|
4863
|
+
*
|
|
4864
|
+
* ## 凭据一步都不进浏览器
|
|
4865
|
+
*
|
|
4866
|
+
* 界面能拿到的只有 `lib/remotes.js` 的 `publicRecord`(id、名字、地址、两个时间)。
|
|
4867
|
+
* 凭据只在**这一半**(宿主进程)手里:去 A 取票时放进 `X-Mini-Device` **请求头**,
|
|
4868
|
+
* 既不进地址(那会留在浏览历史、中间代理和对面日志里),也不进任何一条回给界面的
|
|
4869
|
+
* JSON。取票回来的 `{url}` 里只有一张 30 秒、一次性的票——**票不是凭据**。
|
|
4870
|
+
*
|
|
4871
|
+
* ## 「重连」没有第五条路由
|
|
4872
|
+
*
|
|
4873
|
+
* 用户拍板的:**重连 = 重新取一次票**。界面点「重连」就是再调一次
|
|
4874
|
+
* `/mini-remote/remotes/window`——票本来就是一次性的、30 秒就过期,界面按回来的
|
|
4875
|
+
* `expiresIn` 自己决定多久重取一次。再加一条"重连"路由,只会多一份做着同一件事的代码。
|
|
4876
|
+
*
|
|
4877
|
+
* ## 「断开」只动 B 本地(用户拍板的那一条)
|
|
4878
|
+
*
|
|
4879
|
+
* **B 不管理它连的那台 A**,所以这里不替用户去 A 撤销——断开 = 从 B 的名册里删掉那条
|
|
4880
|
+
* 记录(`remotes.forget`)。用户看到的文案在 `REMOTE_FORGOTTEN_NOTE`:说清"A 那边
|
|
4881
|
+
* 还留着这条记录,要彻底收回得去 A 的设置页撤销"。
|
|
4882
|
+
*/
|
|
4883
|
+
|
|
4884
|
+
/**
|
|
4885
|
+
* 对面已经撤销了 B 这枚凭据时说的话。
|
|
4886
|
+
*
|
|
4887
|
+
* 为什么不照抄 A 那句("没带对凭据。这一枚是配对的时候换来的,撤销之后也没法再用。"):
|
|
4888
|
+
* 那句是在**交代自己的规则**,没有告诉用户**接下来怎么办**。而在 B 这边,用户手上
|
|
4889
|
+
* 真正能做的只有一件事:拿 A 的口令重新添加一台。
|
|
4890
|
+
*/
|
|
4891
|
+
const REMOTE_REVOKED_HINT = '那台电脑已经不认这枚凭据了——多半是它那边把这条记录撤销了。\n'
|
|
4892
|
+
+ '要重新连,得拿那台电脑的口令在这儿重新添加一次。'
|
|
4893
|
+
|
|
4894
|
+
/**
|
|
4895
|
+
* 断开之后给用户看的那句话。
|
|
4896
|
+
*
|
|
4897
|
+
* **必须说清"A 那边还留着"**:断开的语义是"B 不再管它了",不是"收回那台电脑的权限"。
|
|
4898
|
+
* 用户以为自己收回了、其实 A 名册里那一条还在,是这一步最容易留下的误会。
|
|
4899
|
+
*/
|
|
4900
|
+
const REMOTE_FORGOTTEN_NOTE = '已经从这台电脑的列表里移除了,本地存的那枚凭据也一起删了。\n'
|
|
4901
|
+
+ '那台电脑上还留着「这台电脑」那条记录——要彻底收回,得去它的「手机遥控」设置页里撤销那一台。'
|
|
4902
|
+
|
|
4903
|
+
/**
|
|
4904
|
+
* 对面回了错、又没说原因时,照状态码说一句人话。
|
|
4905
|
+
*
|
|
4906
|
+
* 正常情况下 A 会把原因写在 `error` 里(照抄即可)。这条兜底是给"路上不是 A"准备的:
|
|
4907
|
+
* 地址指到了别的服务(一页 HTML 的 404)、中间挡了一层代理,都可能一个字段都不给。
|
|
4908
|
+
*/
|
|
4909
|
+
function remoteStatusText(status) {
|
|
4910
|
+
if (status === 404) return '那台电脑上没有这个接口(404):多半是它的 DSH 还没更新到这一步,或者地址、端口写错了。'
|
|
4911
|
+
if (status === 403) return '那台电脑现在不接受别的电脑连接。要连的话,先在它的「手机遥控」设置里打开「允许别的电脑连接」。'
|
|
4912
|
+
return `那台电脑没通过这一下(HTTP ${status}),它也没说原因。`
|
|
4913
|
+
}
|
|
4914
|
+
|
|
4915
|
+
/**
|
|
4916
|
+
* 替 B 去 A 打一次接口。返回 `{ ok, status, body?, error? }`。
|
|
4917
|
+
*
|
|
4918
|
+
* **两件事分开说**:网络层失败(连不上、超时)和对面回了个错(401/403/503)是两种
|
|
4919
|
+
* 人话——前者要用户去看地址和网络,后者对面已经写好了原因,照抄就行(加一句"那台电脑说:"
|
|
4920
|
+
* 让用户知道这话是谁说的)。
|
|
4921
|
+
*
|
|
4922
|
+
* 超时用 `AbortSignal.timeout`(和隧道那条探针同一个写法)。
|
|
4923
|
+
*
|
|
4924
|
+
* **这个方法只发两样东西**:请求体里的口令(`pair` 那一下)和请求头里的凭据
|
|
4925
|
+
* (取票那一下)。凭据一个字节都不进地址——那是这一族最硬的一条规矩。
|
|
4926
|
+
*/
|
|
4927
|
+
async function callRemote(baseUrl, path, { body, headers } = {}) {
|
|
4928
|
+
let res
|
|
4929
|
+
try {
|
|
4930
|
+
res = await fetch(`${baseUrl}${path}`, {
|
|
4931
|
+
method: 'POST',
|
|
4932
|
+
headers: { 'content-type': 'application/json', ...headers },
|
|
4933
|
+
body: JSON.stringify(body ?? {}),
|
|
4934
|
+
signal: AbortSignal.timeout(REMOTE_CALL_TIMEOUT_MS),
|
|
4935
|
+
})
|
|
4936
|
+
} catch (err) {
|
|
4937
|
+
const code = err?.cause?.code ?? err?.code ?? ''
|
|
4938
|
+
const text = `连不上那台电脑(${baseUrl}):${code || err?.message || err}。`
|
|
4939
|
+
// **这条路以前完全不留痕**(2026-10-17 补):错误只回给界面,用户一退出面板就再也
|
|
4940
|
+
// 查不到上一次报的是什么。跨机碰的是"另一边的东西",出错时最需要的就是"上一次到底
|
|
4941
|
+
// 报了什么"——地址写错、对面没开、还是网络不通,全靠这一句分。
|
|
4942
|
+
//
|
|
4943
|
+
// 只记网络层失败(连不上 / 超时)。对面回 401/403 那类**不记**:那是业务拒绝,
|
|
4944
|
+
// 界面已经把人话和下一步说清楚了,记下来只会把真正的故障挤出这 20 条。
|
|
4945
|
+
report('remote', text)
|
|
4946
|
+
return {
|
|
4947
|
+
ok: false,
|
|
4948
|
+
status: 0,
|
|
4949
|
+
error: text + '\n先确认它在开着、地址和端口没写错、而且和这台电脑在同一个网络里。',
|
|
4950
|
+
}
|
|
4951
|
+
}
|
|
4952
|
+
let payload = null
|
|
4953
|
+
try {
|
|
4954
|
+
payload = await res.json()
|
|
4955
|
+
} catch {
|
|
4956
|
+
// 对面回的不是 JSON:下面按状态码说人话,别把一页 HTML 丢给用户。
|
|
4957
|
+
}
|
|
4958
|
+
if (res.ok && payload?.ok) return { ok: true, status: res.status, body: payload }
|
|
4959
|
+
const said = typeof payload?.error === 'string' && payload.error ? payload.error : ''
|
|
4960
|
+
return { ok: false, status: res.status, error: said ? `那台电脑说:${said}` : remoteStatusText(res.status) }
|
|
4961
|
+
}
|
|
4962
|
+
|
|
4963
|
+
/**
|
|
4964
|
+
* ① 列出「我能连的电脑」。
|
|
4965
|
+
*
|
|
4966
|
+
* **永远不含凭据**(`remotes.list()` 走的一定是 publicRecord)。这一条是界面那张
|
|
4967
|
+
* 列表的唯一数据来源;名字改了、时间更新了,界面刷一下就知道。
|
|
4968
|
+
*/
|
|
4969
|
+
const offRemotesList = mountRoute({
|
|
4970
|
+
kind: 'exact',
|
|
4971
|
+
path: '/mini-remote/remotes',
|
|
4972
|
+
handler: (req, res) => {
|
|
4973
|
+
if (!localOnly(req, res)) return
|
|
4974
|
+
if (req.method !== 'GET') {
|
|
4975
|
+
sendJson(res, 405, { ok: false, error: '这个地址只接受 GET。' })
|
|
4976
|
+
return
|
|
4977
|
+
}
|
|
4978
|
+
sendJson(res, 200, { ok: true, remotes: remotes.list() })
|
|
4979
|
+
},
|
|
4980
|
+
})
|
|
4981
|
+
|
|
4982
|
+
/**
|
|
4983
|
+
* ② 添加一台电脑:**拿它的口令,换一枚自己的凭据存下来**。
|
|
4984
|
+
*
|
|
4985
|
+
* 收 `{ name?, baseUrl, token }`。口令走**请求体**去 A 的 `/mini-remote/pair`
|
|
4986
|
+
* (A 那条路由只从请求体收口令,理由见它上面那一段:进了地址就会被写进对面和中间
|
|
4987
|
+
* 任何一台代理的日志里)。名字只是 B 这边的标签,可以不给——不给就取地址里的 host。
|
|
4988
|
+
*
|
|
4989
|
+
* ## 失败一律不落盘
|
|
4990
|
+
*
|
|
4991
|
+
* 口令错、对面开关关着、地址不通、对面回的凭据不像话——**一条记录都不许留下**。
|
|
4992
|
+
* 名册里出现一条"其实连不上"的记录,用户下次点它只会得到一个查不出原因的失败。
|
|
4993
|
+
* 所以顺序是:**先换凭据、成功了才 `remotes.add`**(`add` 自己不落盘失败)。
|
|
4994
|
+
*
|
|
4995
|
+
* ## 回给界面的是 publicRecord(**没有凭据**)
|
|
4996
|
+
*/
|
|
4997
|
+
const offRemotesAdd = mountRoute({
|
|
4998
|
+
kind: 'exact',
|
|
4999
|
+
path: '/mini-remote/remotes/add',
|
|
5000
|
+
handler: async (req, res) => {
|
|
5001
|
+
if (!localOnly(req, res)) return
|
|
5002
|
+
if (req.method !== 'POST') {
|
|
5003
|
+
sendJson(res, 405, { ok: false, error: '这个地址只接受 POST。' })
|
|
5004
|
+
return
|
|
5005
|
+
}
|
|
5006
|
+
let want
|
|
5007
|
+
try {
|
|
5008
|
+
want = await readBody(req)
|
|
5009
|
+
} catch (err) {
|
|
5010
|
+
sendJson(res, 400, { ok: false, error: `请求体读不了:${err?.message ?? err}` })
|
|
5011
|
+
return
|
|
5012
|
+
}
|
|
5013
|
+
const baseUrl = normalizeBaseUrl(want?.baseUrl)
|
|
5014
|
+
if (!baseUrl) {
|
|
5015
|
+
sendJson(res, 400, {
|
|
5016
|
+
ok: false,
|
|
5017
|
+
// 不举 `IP:端口` 的例子:这一步只管逻辑,"那条地址到底长什么样"取决于
|
|
5018
|
+
// 两台电脑怎么连上(内网 / Tailscale / 隧道)——那是后面的「网络形态」那一步。
|
|
5019
|
+
// 举一个具体的端口只会把人带到错误的方向上。
|
|
5020
|
+
error: '要填那台电脑的地址,写成 http:// 或 https:// 开头的一整条'
|
|
5021
|
+
+ '(就是那台电脑的「手机遥控」地址:http:// 地址:3090,或者 https:// 的 Tailscale 地址;'
|
|
5022
|
+
+ '不要带 /mini 和它后面的参数)。',
|
|
5023
|
+
})
|
|
5024
|
+
return
|
|
5025
|
+
}
|
|
5026
|
+
const given = typeof want?.token === 'string' ? want.token.trim() : ''
|
|
5027
|
+
if (!given) {
|
|
5028
|
+
sendJson(res, 400, { ok: false, error: '要填那台电脑的口令(在它的「手机遥控」设置里能复制到)。' })
|
|
5029
|
+
return
|
|
5030
|
+
}
|
|
5031
|
+
const answer = await callRemote(baseUrl, '/mini-remote/pair', { body: { token: given } })
|
|
5032
|
+
if (!answer.ok) {
|
|
5033
|
+
// **一个字都不落盘**:这一次没换成凭据,就地结束。
|
|
5034
|
+
// 状态用 502:出错的是"网关那一头"(对面),不是本机这次调用不合规矩。
|
|
5035
|
+
sendJson(res, 502, { ok: false, error: answer.error })
|
|
5036
|
+
return
|
|
5037
|
+
}
|
|
5038
|
+
const added = remotes.add({
|
|
5039
|
+
name: want?.name,
|
|
5040
|
+
baseUrl,
|
|
5041
|
+
credential: typeof answer.body?.credential === 'string' ? answer.body.credential : '',
|
|
5042
|
+
})
|
|
5043
|
+
if (!added.ok) {
|
|
5044
|
+
// 走到这儿只剩一种可能:对面回了个 `ok:true`,里面那枚凭据却不像话
|
|
5045
|
+
//(地址和口令在上面都验过了)。
|
|
5046
|
+
sendJson(res, 502, { ok: false, error: added.error })
|
|
5047
|
+
return
|
|
5048
|
+
}
|
|
5049
|
+
// 只记 id 和地址:**不记口令、不记凭据**(和 devices 那几条日志同一条规矩)。
|
|
5050
|
+
console.log(`[dsh-mini-remote] 添加了一台电脑(${added.remote.id}),地址 ${baseUrl}`)
|
|
5051
|
+
sendJson(res, 200, { ok: true, remote: added.remote })
|
|
5052
|
+
},
|
|
5053
|
+
})
|
|
5054
|
+
|
|
5055
|
+
/**
|
|
5056
|
+
* ③ 取票:**拿自己存的那枚凭据,去那台电脑换一张带票的地址**。
|
|
5057
|
+
*
|
|
5058
|
+
* 这是这一族唯一会用到凭据的地方,也是"凭据只活在宿主进程里"这句话的落点:
|
|
5059
|
+
* 凭据从 `remotes.secret(id)` 出来、直接进 `X-Mini-Device` 请求头,**从头到尾
|
|
5060
|
+
* 不经过界面**。界面拿到的是 A 回的那条 `{url}`(里面只有一张 30 秒、一次性的票)。
|
|
5061
|
+
*
|
|
5062
|
+
* 成功之后记一次 `lastOk`(列表上"最近成功连接"那句话就是它)。
|
|
5063
|
+
*
|
|
5064
|
+
* **重连没有单独一条路由**:再调一次这一条就是重连。
|
|
5065
|
+
*/
|
|
5066
|
+
const offRemotesWindow = mountRoute({
|
|
5067
|
+
kind: 'exact',
|
|
5068
|
+
path: '/mini-remote/remotes/window',
|
|
5069
|
+
handler: async (req, res) => {
|
|
5070
|
+
if (!localOnly(req, res)) return
|
|
5071
|
+
if (req.method !== 'POST') {
|
|
5072
|
+
sendJson(res, 405, { ok: false, error: '这个地址只接受 POST。' })
|
|
5073
|
+
return
|
|
5074
|
+
}
|
|
5075
|
+
let want
|
|
5076
|
+
try {
|
|
5077
|
+
want = await readBody(req)
|
|
5078
|
+
} catch (err) {
|
|
5079
|
+
sendJson(res, 400, { ok: false, error: `请求体读不了:${err?.message ?? err}` })
|
|
5080
|
+
return
|
|
5081
|
+
}
|
|
5082
|
+
const id = typeof want?.id === 'string' ? want.id : ''
|
|
5083
|
+
if (!id) {
|
|
5084
|
+
sendJson(res, 400, { ok: false, error: '要连哪一台?请求里没有 id。' })
|
|
5085
|
+
return
|
|
5086
|
+
}
|
|
5087
|
+
const secret = remotes.secret(id)
|
|
5088
|
+
if (!secret) {
|
|
5089
|
+
// 另一个标签页刚断开过它——如实说,别假装还在。
|
|
5090
|
+
sendJson(res, 404, { ok: false, error: '这台电脑已经不在列表里了,刷新看看。' })
|
|
5091
|
+
return
|
|
5092
|
+
}
|
|
5093
|
+
// ⚠️ 凭据**只走请求头**:进了地址就会留在浏览历史、中间代理和对面日志里。
|
|
5094
|
+
const answer = await callRemote(secret.baseUrl, '/mini-remote/remote/window', {
|
|
5095
|
+
headers: { 'x-mini-device': secret.credential },
|
|
5096
|
+
})
|
|
5097
|
+
if (!answer.ok) {
|
|
5098
|
+
// 401 单独说:那多半是"对面把这条记录撤销了",而用户能做的只有重新配对一次。
|
|
5099
|
+
sendJson(res, 502, { ok: false, error: answer.status === 401 ? REMOTE_REVOKED_HINT : answer.error })
|
|
5100
|
+
return
|
|
5101
|
+
}
|
|
5102
|
+
const url = typeof answer.body?.url === 'string' ? answer.body.url : ''
|
|
5103
|
+
if (!url) {
|
|
5104
|
+
sendJson(res, 502, { ok: false, error: '那台电脑回了话,但里面没有地址。过几秒再试一次。' })
|
|
5105
|
+
return
|
|
5106
|
+
}
|
|
5107
|
+
remotes.markOk(id)
|
|
5108
|
+
// 只记 id:**不记凭据、不记那条地址**(里面那张票够开一扇窗)。
|
|
5109
|
+
console.log(`[dsh-mini-remote] 从「我能连的电脑」里换了一张票(${id})`)
|
|
5110
|
+
sendJson(res, 200, { ok: true, url, expiresIn: Number(answer.body?.expiresIn) || 0 })
|
|
5111
|
+
},
|
|
5112
|
+
})
|
|
5113
|
+
|
|
5114
|
+
/**
|
|
5115
|
+
* ④ 断开:**只从 B 本地删掉这条记录**(见上面「断开」那一段)。
|
|
5116
|
+
*
|
|
5117
|
+
* 不碰 A、不给 A 发任何请求——**B 不管理它连的那台 A**。回给界面的 `note` 是要
|
|
5118
|
+
* 原样显示给用户的那句话:说清"A 那边还留着这条记录,要彻底收回得去 A 撤销"。
|
|
5119
|
+
*/
|
|
5120
|
+
const offRemotesForget = mountRoute({
|
|
5121
|
+
kind: 'exact',
|
|
5122
|
+
path: '/mini-remote/remotes/forget',
|
|
5123
|
+
handler: async (req, res) => {
|
|
5124
|
+
if (!localOnly(req, res)) return
|
|
5125
|
+
if (req.method !== 'POST') {
|
|
5126
|
+
sendJson(res, 405, { ok: false, error: '这个地址只接受 POST。' })
|
|
5127
|
+
return
|
|
5128
|
+
}
|
|
5129
|
+
let want
|
|
5130
|
+
try {
|
|
5131
|
+
want = await readBody(req)
|
|
5132
|
+
} catch (err) {
|
|
5133
|
+
sendJson(res, 400, { ok: false, error: `请求体读不了:${err?.message ?? err}` })
|
|
5134
|
+
return
|
|
5135
|
+
}
|
|
5136
|
+
const id = typeof want?.id === 'string' ? want.id : ''
|
|
5137
|
+
if (!id) {
|
|
5138
|
+
sendJson(res, 400, { ok: false, error: '要断开哪一台?请求里没有 id。' })
|
|
5139
|
+
return
|
|
5140
|
+
}
|
|
5141
|
+
if (!remotes.forget(id)) {
|
|
5142
|
+
// 另一个标签页刚断开过:如实说"它已经不在列表里了",别假装成功。
|
|
5143
|
+
sendJson(res, 404, { ok: false, error: '这台电脑已经不在列表里了,刷新看看。' })
|
|
5144
|
+
return
|
|
5145
|
+
}
|
|
5146
|
+
// 只记 id、只说明"本地删了":**不写"已撤销"**——我们没去 A 那边撤销。
|
|
5147
|
+
console.log(`[dsh-mini-remote] 断开了一台电脑(${id}):只删了本地这一条`)
|
|
5148
|
+
sendJson(res, 200, { ok: true, id, note: REMOTE_FORGOTTEN_NOTE })
|
|
5149
|
+
},
|
|
5150
|
+
})
|
|
5151
|
+
|
|
5152
|
+
/**
|
|
5153
|
+
* 「自检」:**这台电脑到底在跑什么**(2026-10-17)。
|
|
5154
|
+
*
|
|
5155
|
+
* ## 为什么要有它(两个真真的教训)
|
|
5156
|
+
*
|
|
5157
|
+
* 那天排一台真机上的故障花掉很久,根因有两条,而两条都是**"看不见那台机器在跑什么"
|
|
5158
|
+
* 造成的**:A 那台的桌面端连开了二十四小时,加载的是"新功能写下来之前"的旧代码;
|
|
5159
|
+
* B 那台装的插件是旧版本,新接口根本没出现。当时只能靠用户来回反馈、一个个地址去敲、
|
|
5160
|
+
* 靠猜——而这两条各自用一句话就能定位(进程比代码旧 / 接口清单里少一条)。
|
|
5161
|
+
*
|
|
5162
|
+
* 所以它回答的就是那几个问题:这份插件是哪一版、宿主进程什么时候起的、我们注册了
|
|
5163
|
+
* 哪些 `/mini-remote/*`、跨机那个总开关开没开、手机服务在听哪个端口绑了哪些地址、
|
|
5164
|
+
* 数据目录在哪、两份名册各有多少条。
|
|
5165
|
+
*
|
|
5166
|
+
* ## 只给本机看(和别的 `/mini-remote/*` 同一条规矩)
|
|
5167
|
+
*
|
|
5168
|
+
* 它摆的是这台电脑的底细(路径、端口、名册条数、进程何时起的)。**不调 `localOnly`
|
|
5169
|
+
* 的两条是例外**(`pair` / `remote/window`),理由写在它们那儿——这一条没有那个理由。
|
|
5170
|
+
*
|
|
5171
|
+
* ## 一个字节的凭据都不许进这个返回值
|
|
5172
|
+
*
|
|
5173
|
+
* 自检是**给人看**的,不是给机器用的:口令、设备密钥哈希、`remotes.json` 里那枚
|
|
5174
|
+
* 长期凭据、飞书的 appSecret,一个都不出现。所以下面**只数条数、不摆记录**
|
|
5175
|
+
* (`devices.list()` / `remotes.list()` 回来的记录里带着 id、名字、时间——那些也不必要)。
|
|
5176
|
+
*
|
|
5177
|
+
* ## 拿不到就说「未知」,它自己不能成为新的故障点
|
|
5178
|
+
*
|
|
5179
|
+
* 手机服务还没起来(`server` 为 null)、`package.json` 读不到、名册读不出来——
|
|
5180
|
+
* 每一种都对应一个 `null`/空列表,**不抛错**。一条诊断路由要是在被诊断的东西坏掉时
|
|
5181
|
+
* 跟着坏掉,那它就白写了。
|
|
5182
|
+
*/
|
|
5183
|
+
function selfcheckPayload() {
|
|
5184
|
+
const startedAtMs = Date.now() - Math.round(process.uptime() * 1000)
|
|
5185
|
+
const newest = newestSource()
|
|
5186
|
+
const verdict = staleCodeVerdict(startedAtMs, newest)
|
|
5187
|
+
// 只数条数。这两份名册一个是"谁连得上我"、一个是"我连得上谁",
|
|
5188
|
+
// 内容一个字段都不往这儿搬(见上面那段)。
|
|
5189
|
+
const countOf = (fn) => {
|
|
5190
|
+
try {
|
|
5191
|
+
const rows = fn()
|
|
5192
|
+
return Array.isArray(rows) ? rows.length : null
|
|
5193
|
+
} catch {
|
|
5194
|
+
return null
|
|
5195
|
+
}
|
|
5196
|
+
}
|
|
5197
|
+
return {
|
|
5198
|
+
ok: true,
|
|
5199
|
+
/** `package.json` 里的 version;读不到是 null。 */
|
|
5200
|
+
version: pluginVersion(),
|
|
5201
|
+
process: {
|
|
5202
|
+
pid: process.pid,
|
|
5203
|
+
startedAtMs,
|
|
5204
|
+
/** ISO 时刻,给 curl 的人看;界面自己按本地时区格式化那个毫秒值。 */
|
|
5205
|
+
startedAt: new Date(startedAtMs).toISOString(),
|
|
5206
|
+
uptimeSec: Math.round(process.uptime()),
|
|
5207
|
+
},
|
|
5208
|
+
/**
|
|
5209
|
+
* 「进程比代码旧」那一条(见 `staleCodeVerdict`)。
|
|
5210
|
+
* `stale` 三态:`true` 旧了 / `false` 不是 / `null` 拿不到、不知道。
|
|
5211
|
+
*/
|
|
5212
|
+
code: {
|
|
5213
|
+
file: verdict.file,
|
|
5214
|
+
modifiedAtMs: Number.isFinite(newest?.at) ? newest.at : null,
|
|
5215
|
+
stale: verdict.stale,
|
|
5216
|
+
message: verdict.message,
|
|
5217
|
+
},
|
|
5218
|
+
/** 我们注册过的 `/mini-remote/*`:`mounted` 说的是"现在仍挂着"。 */
|
|
5219
|
+
routes: [...routeBook.entries()].map(([path, one]) => ({
|
|
5220
|
+
path,
|
|
5221
|
+
kind: one.kind,
|
|
5222
|
+
mounted: one.mounted,
|
|
5223
|
+
})),
|
|
5224
|
+
/** 「允许别的电脑连接」那个总开关(默认关)。 */
|
|
5225
|
+
allowRemoteComputers: Boolean(settings.allowRemoteComputers),
|
|
5226
|
+
/** 手机服务那一半:只报"在不在听、听哪儿",不报任何凭据。 */
|
|
5227
|
+
phoneService: {
|
|
5228
|
+
listening: Boolean(server),
|
|
5229
|
+
port: server?.port ?? null,
|
|
5230
|
+
addresses: server ? [...server.addresses] : [],
|
|
5231
|
+
// 起不来时把原因(端口被占之类)原样说一句,这比一个光秃秃的 false 有用。
|
|
5232
|
+
error: server ? null : (serverError ? String(serverError) : null),
|
|
5233
|
+
/**
|
|
5234
|
+
* 跨机那两条在**手机服务那一半**也有挂载点(见 `hostHalfRoutes`)。
|
|
5235
|
+
* 它们决定"B 只填一条地址"能不能成——所以两半的状态都要看得见。
|
|
5236
|
+
*/
|
|
5237
|
+
hostHalf: {
|
|
5238
|
+
pair: Boolean(hostHalfRoutes.pair),
|
|
5239
|
+
remoteWindow: Boolean(hostHalfRoutes.remoteWindow),
|
|
5240
|
+
},
|
|
5241
|
+
},
|
|
5242
|
+
dataDir: dir,
|
|
5243
|
+
rosters: {
|
|
5244
|
+
devices: countOf(() => devices.list()),
|
|
5245
|
+
remotes: countOf(() => remotes.list()),
|
|
5246
|
+
},
|
|
5247
|
+
/**
|
|
5248
|
+
* 最近出的错,**最新在前**,最多 20 条(见上面 `recentErrors` 那段)。
|
|
5249
|
+
*
|
|
5250
|
+
* 为什么把 `message` 直接摆出来而不是给个错误码:这几块错误(端口被占、隧道连不上、
|
|
5251
|
+
* 飞书断开、跨机不通)本来就是"一句话就能说清"的东西,码表只会多一层翻译。
|
|
5252
|
+
* 消息已经过 `redact`,不会有凭据。
|
|
5253
|
+
*
|
|
5254
|
+
* 空数组是正常的:那是"这次开机以来没出过错",不是"取不到"。
|
|
5255
|
+
*/
|
|
5256
|
+
errors: recentErrors.slice().reverse(),
|
|
5257
|
+
}
|
|
5258
|
+
}
|
|
5259
|
+
|
|
5260
|
+
const offSelfcheck = mountRoute({
|
|
5261
|
+
kind: 'exact',
|
|
5262
|
+
path: '/mini-remote/selfcheck',
|
|
5263
|
+
handler: (req, res) => {
|
|
5264
|
+
if (!localOnly(req, res)) return
|
|
5265
|
+
if (req.method !== 'GET') {
|
|
5266
|
+
sendJson(res, 405, { ok: false, error: '这个地址只接受 GET。' })
|
|
5267
|
+
return
|
|
5268
|
+
}
|
|
5269
|
+
try {
|
|
5270
|
+
sendJson(res, 200, selfcheckPayload())
|
|
5271
|
+
} catch (err) {
|
|
5272
|
+
// 兜底:这一段本该一步都不会抛(每一项各自拿不到就说"未知")。
|
|
5273
|
+
// 真抛了也不许把自检变成一个报错页面——如实说哪一步没读出来。
|
|
5274
|
+
// `ok:false` 是有意的:界面那一头照别的失败一样把那句原因摆出来,
|
|
5275
|
+
// 而不是拿到一份缺胳膊少腿的"正常"数据。
|
|
5276
|
+
sendJson(res, 200, { ok: false, error: `自检读不出来:${err?.message ?? err}` })
|
|
5277
|
+
}
|
|
5278
|
+
},
|
|
5279
|
+
})
|
|
5280
|
+
|
|
3714
5281
|
/**
|
|
3715
5282
|
* 卸载时把注册过的路由全部注销。
|
|
3716
5283
|
*
|
|
@@ -3719,6 +5286,16 @@ export function apply(ctx, config = {}) {
|
|
|
3719
5286
|
*
|
|
3720
5287
|
* 2026-09-24 补:`offToken` 一直是漏的(改密码那条路由注册了却没注销),
|
|
3721
5288
|
* 加 HTTPS 这条时对照着数了一遍才发现。五条注册,五条注销,现在对得上。
|
|
5289
|
+
* 2026-10-15 加「远程窗口」那条:六条注册,六条注销。
|
|
5290
|
+
* 2026-10-16 加「设备名册」那三条(列出/改名/撤销):**九条注册,九条注销**。
|
|
5291
|
+
* 2026-10-16 加「配对」那一条:**十条注册,十条注销**。
|
|
5292
|
+
* 2026-10-16 加「跨机取票」和「允许别的电脑连接」那两条:**十二条注册,十二条注销**。
|
|
5293
|
+
* 2026-10-16 加「我能连的电脑」那四条(B 侧:列出/添加/取票/断开):**十六条注册,
|
|
5294
|
+
* 十六条注销**。
|
|
5295
|
+
* 2026-10-17 加「跨机那两条在手机服务上再挂一份」:注册数没变(那两条不是新的
|
|
5296
|
+
* `webServer.register`,是同一个处理函数的第二个挂载点),但**两处挂载点都得摘**。
|
|
5297
|
+
* 2026-10-17 加「自检」那一条:**十七条注册,十七条注销**。
|
|
5298
|
+
* (那十六条现在都走 `mountRoute`——它就是我们这一处的登记口,注册和注销同一个门。)
|
|
3722
5299
|
*/
|
|
3723
5300
|
return () => {
|
|
3724
5301
|
offGet?.()
|
|
@@ -3726,6 +5303,22 @@ export function apply(ctx, config = {}) {
|
|
|
3726
5303
|
offServe?.()
|
|
3727
5304
|
offToken?.()
|
|
3728
5305
|
offFeishu?.()
|
|
5306
|
+
offWindow?.()
|
|
5307
|
+
offDevices?.()
|
|
5308
|
+
offDeviceRename?.()
|
|
5309
|
+
offDeviceRevoke?.()
|
|
5310
|
+
offPair?.()
|
|
5311
|
+
offRemoteWindow?.()
|
|
5312
|
+
offRemoteAccess?.()
|
|
5313
|
+
offRemotesList?.()
|
|
5314
|
+
offRemotesAdd?.()
|
|
5315
|
+
offRemotesWindow?.()
|
|
5316
|
+
offRemotesForget?.()
|
|
5317
|
+
offSelfcheck?.()
|
|
5318
|
+
// 手机服务那一半那两个挂载点(见 hostHalfRoutes):那条服务活得比这份 disposer
|
|
5319
|
+
// 久是可能的(先摘路由、后关服务),留着就是两个指向死闭包的口子。
|
|
5320
|
+
hostHalfRoutes.pair = null
|
|
5321
|
+
hostHalfRoutes.remoteWindow = null
|
|
3729
5322
|
}
|
|
3730
5323
|
})
|
|
3731
5324
|
}
|