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/lib/server.js CHANGED
@@ -17,9 +17,55 @@ import { appendFile, mkdir } from 'node:fs/promises'
17
17
  import { dirname } from 'node:path'
18
18
  import { fileURLToPath } from 'node:url'
19
19
  import { renderPage, readArt, readImage, readSw } from './page.js'
20
+ import { credentialLooksLikeOurs } from './devices.js'
21
+ import { isLocalRequest } from './net.js'
20
22
 
21
23
  const COOKIE = 'dsh_mini_token'
22
24
 
25
+ /**
26
+ * 「设备身份」那枚凭据存放的名字(见 lib/devices.js)。
27
+ *
28
+ * **另起一个名字,不共用令牌那个 cookie**:令牌是「入场邀请函」(带配对码打开
29
+ * `/mini?token=…` 那一下用它),设备凭据是进门之后的长期身份。两者的寿命和撤销
30
+ * 方式都不一样——混在一个 cookie 里,撤销一台设备就成了「连口令一起作废」。
31
+ *
32
+ * `HttpOnly`:手机页一行 JS 都不需要读它(浏览器自己带),而**不放进 JS 能碰到的
33
+ * 地方**顺带堵掉一条路——页面里那些拼 URL 的代码(立绘、SSE、镜像框)就永远
34
+ * 不会把它写进地址里。
35
+ */
36
+ const DEVICE_COOKIE = 'dsh_mini_device'
37
+
38
+ /**
39
+ * 「远程窗口」那张一次性票换来的窗口会话,存在浏览器里的名字。
40
+ *
41
+ * **它不是令牌**(所以另起一个名字,不共用上面那个 cookie):它只够进
42
+ * `/mini/mirror` 这一层,由 `lib/mirror.js` 的 `windowValid` 认,**寿命跟着宿主进程走**
43
+ * (DSH 一重启就不认了,见 lib/mirror.js 的 PROCESS_SECRET)。
44
+ * 桌面端的「远程窗口」面板就靠它——**令牌全程不进浏览器**(设计稿那条硬规矩)。
45
+ *
46
+ * 会话里还签着**这一扇窗是谁的**(2026-10-16 第四步):一台设备的 id,或者下面那个
47
+ * `LOCAL_WINDOW_DEVICE`。设备 id 那一种**每次验完会话都要回头查一次名册**,记录不在
48
+ * 就当场拒——这就是「撤销一台设备,它开着的窗口下一次请求就 401」(见 windowSession)。
49
+ *
50
+ * `HttpOnly`:框里跑的是官方界面那一整套客户端代码,它没有理由读得到这枚值。
51
+ */
52
+ const WINDOW_COOKIE = 'dsh_mini_window'
53
+
54
+ /**
55
+ * 「本机那一半」在窗口会话里的身份标记。
56
+ *
57
+ * 桌面端那个面板的票是**这台电脑自己的软件**来换的(`/mini-remote/window`,只允许
58
+ * 本机调用,见 lib/index.js),它跟名册里的设备不是一回事:本机的软件本来就有这台
59
+ * 电脑的全部权限,也没有"把这一台撤销掉"这种操作。所以按 `local` 记,**不查名册**。
60
+ *
61
+ * 它不是凭据、也换不出任何东西——只是把「这扇窗是谁的」这一格填上,让远程设备那一
62
+ * 路必须有名册里的身份才能开窗(见 ticketWindowOwner 的第三条)。
63
+ * **发票那一方要给的就是这个值**(lib/index.js 那条本机路由把它记进票里,兑票时再
64
+ * 从票上读出来,见 ticketWindowOwner),所以它导出去给那边用——不在第二个文件里
65
+ * 另写一个 'local' 字面量。
66
+ */
67
+ export const LOCAL_WINDOW_DEVICE = 'local'
68
+
23
69
  /**
24
70
  * 镜像页「诊断模式」那份样本落在哪。
25
71
  *
@@ -71,8 +117,13 @@ const HEARTBEAT_MS = 25_000
71
117
  const RATE_WINDOW_MS = 60_000
72
118
  const RATE_MAX_FAILURES = 5
73
119
 
74
- /** 定长比较,避免 token 被逐字节试探。 */
75
- function tokenEquals(a, b) {
120
+ /**
121
+ * 定长比较,避免 token 被逐字节试探。
122
+ *
123
+ * 导出是给「配对」那条路由用的(lib/index.js 的 `/mini-remote/pair`,它拿口令换凭据):
124
+ * 同一件事在第二处再写一遍,迟早一处改了另一处没改——而这里写错的表现是"口令变得猜得动"。
125
+ */
126
+ export function tokenEquals(a, b) {
76
127
  if (typeof a !== 'string' || typeof b !== 'string') return false
77
128
  const ba = Buffer.from(a)
78
129
  const bb = Buffer.from(b)
@@ -247,12 +298,69 @@ export function sendMirrorUnavailable(res) {
247
298
  res.end(buf)
248
299
  }
249
300
 
301
+ /**
302
+ * 「这张票不作数了」那一页(票不对、过期、或者已经用过)。
303
+ *
304
+ * 为什么也要一页人话:这张票唯一的用处就是被塞进 iframe 的地址(见
305
+ * `lib/index.js` 的 `/mini-remote/window`),所以**它的读者是框,不是程序**。
306
+ * 回 JSON 的话,用户看到的就是框里一段外星文——和 2026-10-08 那次
307
+ * `{"error":"not found"}` 是同一个毛病(见上面 sendMirrorUnavailable)。
308
+ *
309
+ * 说清两件事就够了:为什么没进来(票是一次性的、只活 30 秒),下一步做什么
310
+ * (回到「远程窗口」重新打开一次)。**不给重试按钮**:票已经废了,重试一万次
311
+ * 也还是这张废票,得由面板那边重新要一张。
312
+ *
313
+ * 状态码 403:这次请求确实没有通行资格(不是「这条路不存在」)。
314
+ */
315
+ export function sendWindowTicketBad(res) {
316
+ const html = `<!doctype html>
317
+ <html lang="zh-CN">
318
+ <head>
319
+ <meta charset="utf-8">
320
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
321
+ <title>这个窗口的通行证过期了</title>
322
+ <style>
323
+ :root { color-scheme: light dark; }
324
+ body { margin: 0; padding: 24px 18px; color: #1b1b1b; background: #fff;
325
+ font: 15px/1.7 system-ui, -apple-system, "Noto Sans CJK SC", sans-serif; }
326
+ h1 { margin: 0 0 12px; font-size: 17px; }
327
+ p { margin: 0 0 10px; color: #555; }
328
+ @media (prefers-color-scheme: dark) {
329
+ body { color: #eee; background: #111; }
330
+ p { color: #aaa; }
331
+ }
332
+ </style>
333
+ </head>
334
+ <body>
335
+ <h1>这个窗口的通行证过期了</h1>
336
+ <p>通往电脑端界面的通行证是一次性的,而且只活 30 秒。</p>
337
+ <p>回到「远程窗口」,重新打开一次就会换一张新的。</p>
338
+ </body>
339
+ </html>`
340
+ const out = Buffer.from(html, 'utf8')
341
+ res.writeHead(403, {
342
+ 'Content-Type': 'text/html; charset=utf-8',
343
+ 'Content-Length': out.length,
344
+ 'Cache-Control': 'no-store',
345
+ })
346
+ res.end(out)
347
+ }
348
+
250
349
  /**
251
350
  * @param {object} opts
252
351
  * @param {import('./store.js').createStore extends (...a:any)=>infer R ? R : never} opts.store
253
352
  * @param {{port:number, defaultMode:string}} opts.config
254
353
  * @param {string[]} opts.bindAddresses 要监听的地址;第一个应是 127.0.0.1
255
354
  * @param {string} opts.token
355
+ * @param {{pair?:Function, remoteWindow?:Function}} [opts.remoteRoutes]
356
+ * 「跨机那两条」(配对 / 跨机取票)**在手机服务这个端口上的第二份挂载点**——
357
+ * 处理函数由 lib/index.js 原样递进来(那边是唯一的实现),这里只负责"这个地址
358
+ * 也走那一份"。见 `handle` 里那段说明:加上它就是"B 只填一条地址"成立的原因。
359
+ * 两个字段都可以先空着(createMiniServer 起来的时候那两条路由还没挂),
360
+ * 每次请求现取。不给这个参数,手机服务就跟从前一模一样。
361
+ * @param {{verify:(credential:string)=>object|null, register:(opts?:object)=>object}} [opts.devices]
362
+ * 「设备身份」名册(见 lib/devices.js)。不给就是「这一步没开设备身份」——
363
+ * 手机照旧靠令牌进来,只是不会被登记。
256
364
  */
257
365
  // ---------------------------------------------------------------------------
258
366
  // 插件主体取用「推提问给手机」「手机还在不在」这两个能力的地方。
@@ -282,7 +390,7 @@ export const miniControl = {
282
390
  * (由插件递进「当前已注册工作区的目录」,见 extraImageRoots 那段)
283
391
  * @returns {Promise<{port:number, addresses:string[], failed:{address:string,message:string}[], close:()=>Promise<void>, broadcast:(event:string, data:unknown)=>void, broadcastTrajectory:(payload:object)=>void, broadcastTrajectoryLive:(payload:object)=>void}>}
284
392
  */
285
- export async function createMiniServer({ store, config, token, bindAddresses, tunnelHost, onInstruction, onCommands, onCommand, onStop, onUnqueue, onUpload, onSessionOpened, log, tree, browse, build, imageRoots, mirror, mirrorEnabled }) {
393
+ export async function createMiniServer({ store, config, token, bindAddresses, tunnelHost, onInstruction, onCommands, onCommand, onStop, onUnqueue, onUpload, onSessionOpened, log, tree, browse, build, imageRoots, mirror, mirrorEnabled, remoteRoutes, devices }) {
286
394
  /** @type {Set<import('node:http').ServerResponse>} */
287
395
  const clients = new Set()
288
396
 
@@ -308,6 +416,24 @@ export async function createMiniServer({ store, config, token, bindAddresses, tu
308
416
  const trajectorySubscribers = new Set()
309
417
  let clientSeq = 0
310
418
 
419
+ /**
420
+ * 「这条活连接是哪台设备的」——撤销时要**主动掐断**它,所以得先记得住。
421
+ *
422
+ * 为什么非记不可(用户拍板的第 4 条):手机那边是 SSE 单向推送,撤销之后它只要
423
+ * 还挂着那条流就照样收得到新消息。只靠「下一次请求被拒」,用户看到的是"撤销了但
424
+ * 那台手机还在动",于是以为没生效——这正是要避免的假象。
425
+ *
426
+ * 两张表分别对两条长连接:
427
+ * · `deviceStreams` —— `/mini/api/stream` 那条 SSE(手机页的主路);
428
+ * · `deviceSockets` —— 镜像里那个框的 WebSocket(`/api/remote.mux`,官方界面
429
+ * 那条常驻连接)。框里的 HTTP 请求撤销后自然会 401,但已经握手成功的 WS
430
+ * 不查权限、会一直活着,所以它也得跟着断。
431
+ *
432
+ * **只记 id,不记凭据**:凭据(密钥那半截)从不进内存表,也从不进日志。
433
+ */
434
+ const deviceStreams = new Map()
435
+ const deviceSockets = new Map()
436
+
311
437
  /**
312
438
  * 「这一代服务」的编号。进程重启、服务重起都会换一个。
313
439
  *
@@ -392,6 +518,17 @@ export async function createMiniServer({ store, config, token, bindAddresses, tu
392
518
  subagentTranscript: async () => ({ ok: false, error: '这台电脑上的 DSH 没提供子智能体。' }),
393
519
  stopSubagent: async () => ({ ok: false, error: '这台电脑上的 DSH 没提供子智能体。' }),
394
520
  askSubagent: async () => ({ ok: false, error: '这台电脑上的 DSH 没提供子智能体。' }),
521
+ // 会话条目那六个动作、以及三个读状态的空壳。同样如实说"没这个能力"——
522
+ // 假装成功会让手机把一次没发生的归档画成已归档,那是最坏的一种假。
523
+ sessionFlags: () => ({ ok: false, reason: 'no-registry', archived: [], pinned: [] }),
524
+ sessionSummaries: async () => ({ ok: false, reason: 'unavailable', byId: new Map() }),
525
+ pendingInteractions: () => ({}),
526
+ renameSession: async () => ({ ok: false, reason: 'no-service' }),
527
+ forkSession: async () => ({ ok: false, reason: 'no-service' }),
528
+ pinSession: async () => ({ ok: false, reason: 'no-registry' }),
529
+ unpinSession: async () => ({ ok: false, reason: 'no-registry' }),
530
+ archiveSession: async () => ({ ok: false, reason: 'no-registry' }),
531
+ unarchiveSession: async () => ({ ok: false, reason: 'no-registry' }),
395
532
  }
396
533
 
397
534
  // 目录浏览的空壳。它**只依赖 node:fs**,不像 tree 那样依赖 DSH 的服务,
@@ -402,6 +539,83 @@ export async function createMiniServer({ store, config, token, bindAddresses, tu
402
539
  makeDirectory: async () => ({ ok: false, reason: 'unavailable' }),
403
540
  }
404
541
 
542
+ /**
543
+ * 会话列表的**分组口径**——手机导航栏那一屏照着它画。
544
+ *
545
+ * 这套口径是照 PC 侧栏的官方说法对齐的(0.2.0-rc.2 的 `sidebar.workspaces` 文档原文):
546
+ * · 「视图选项以一组显式三选一控制显隐:**隐藏已归档(默认项)**隐藏已归档 Session,
547
+ * 全部对话(显示已归档)将其纳入列表……」→ 我们只做两档(默认隐藏 / 显示),
548
+ * 因为手机这个抽屉只有一层筛选,多一档就是多一次"我到底在哪一档"的困惑。
549
+ * · 「**正在运行的 Session(包括有子会话正在运行的父会话)始终按原顺序显示,
550
+ * 不占用这五条配额**」→ 配额只数空闲的(这一层只管"是不是在跑",5 条那个数
551
+ * 属于界面状态,在页面上算)。
552
+ * · 「当前选中的空白**新会话**在首条提示词落地前也作为额外行」→ 空白会话整条不列,
553
+ * 但**当前绑定的那一个例外**——你自己刚在手机上建的那个不能当场消失。
554
+ * · 「未保存标题的历史会话显示本地化名称『未命名 / Untitled』,**不使用目录名兜底**」
555
+ * → 标题为空就是空,页面显示「未命名会话」,我们不在这一层编一个名字。
556
+ *
557
+ * **空白这条判据拿不到时一条都不隐藏**(`blankKnown:false`):拿"标题为空"当空白
558
+ * 会让一条只是标题没读出来的老会话当场消失,那是拿猜测当事实。宁可少做,不编。
559
+ */
560
+ async function shapeSessions(result, { showArchived, boundSessionId }) {
561
+ const flags = nav.sessionFlags ? nav.sessionFlags() : { ok: false, archived: [], pinned: [] }
562
+ const archivedSet = new Set(flags.archived ?? [])
563
+ // 官方置顶顺序是"最近置顶的在前",界面就照这个顺序排——自己按时间重排会把
564
+ // 用户刚置顶的那一条排到第二位,那是明的错的。
565
+ const pinRank = new Map((flags.pinned ?? []).map((id, index) => [id, index]))
566
+ const pending = nav.pendingInteractions ? nav.pendingInteractions() : {}
567
+ const summaries = nav.sessionSummaries ? await nav.sessionSummaries() : { ok: false }
568
+ const blankKnown = summaries?.ok === true
569
+ const summaryOf = (id) => (summaries?.ok ? summaries.byId.get(id) : undefined)
570
+ const isBlank = (id) => blankKnown && summaryOf(id)?.blank === true
571
+
572
+ const rows = []
573
+ let hiddenBlank = 0
574
+ let hiddenArchived = 0
575
+ for (const s of result.sessions ?? []) {
576
+ const archived = archivedSet.has(s.id)
577
+ if (archived && !showArchived) { hiddenArchived += 1; continue }
578
+ // 空白那一条**不适用于已归档的行**:用户把「显示已归档」打开,要的就是
579
+ // "把我收起来的那几条拿回来看看",而归档会话里恰恰有一批是没来得及起名字的
580
+ // (本机实测:3 条已归档里就有 1 条没有标题)。这时候还按空白把它藏起来,
581
+ // 那个开关就等于白按,而且归档提示里那句"之后可以在『显示已归档』里找回来"会变成假话。
582
+ if (isBlank(s.id) && s.id !== boundSessionId && !archived) { hiddenBlank += 1; continue }
583
+ const summary = summaryOf(s.id)
584
+ rows.push({
585
+ id: s.id,
586
+ title: typeof s.title === 'string' ? s.title : '',
587
+ createdAt: s.createdAt ?? 0,
588
+ // 两个来源取"或":`agents` 那份是插件自己看着的活会话,官方摘要那份覆盖更全
589
+ // (比如不是经由我们事件流起来的)。任一说在跑,这一行就不该被配额吃掉。
590
+ running: s.running === true || summary?.running === true,
591
+ archived,
592
+ pinned: pinRank.has(s.id),
593
+ pending: typeof pending[s.id] === 'string' ? pending[s.id] : null,
594
+ blank: isBlank(s.id),
595
+ })
596
+ }
597
+ rows.sort((a, b) => {
598
+ // 置顶的永远在最前,且按官方的置顶顺序;其余按创建时间倒序(和原来一样)。
599
+ const pa = a.pinned ? (pinRank.get(a.id) ?? 0) : Number.POSITIVE_INFINITY
600
+ const pb = b.pinned ? (pinRank.get(b.id) ?? 0) : Number.POSITIVE_INFINITY
601
+ if (pa !== pb) return pa - pb
602
+ return (b.createdAt ?? 0) - (a.createdAt ?? 0)
603
+ })
604
+
605
+ return {
606
+ // 树那一层给的东西**原样留着**(workspaceId / total / truncated / pending,以及
607
+ // 将来它会加的任何字段):这一层只负责"筛选和排序",不该顺手把别人的字段丢掉。
608
+ ...result,
609
+ sessions: rows,
610
+ // 这几个数是给页面和验收用的**事实**,不是估算:
611
+ shown: rows.length,
612
+ hiddenBlank,
613
+ hiddenArchived,
614
+ blankKnown,
615
+ archivedKnown: flags.ok === true,
616
+ }
617
+ }
618
+
405
619
  /**
406
620
  * 正文里写相对路径的图(`![](scratch/a.png)`)要多试的几个根:**当前已注册的工作区目录**。
407
621
  *
@@ -498,6 +712,52 @@ export async function createMiniServer({ store, config, token, bindAddresses, tu
498
712
  return group.size
499
713
  }
500
714
 
715
+ /**
716
+ * **把某台设备手上的活连接全掐掉**(撤销那一步调它,见 lib/index.js 的
717
+ * `/mini-remote/devices/revoke`)。
718
+ *
719
+ * 为什么非要主动掐,而不是等它下一次请求被拒:手机那边是**服务端单向推**的。
720
+ * 撤销之后那条 SSE 要是还挂着,新消息照旧往那台手机上送,用户看在眼里就是
721
+ * "撤销了但它还在动"——于是他会怀疑撤销没生效,再点几次、甚至以为功能坏了。
722
+ * 掐断的**可见信号**是即时且明确的:客户端那条流当场 EOF,页面自己会知道要说
723
+ * 什么(手机页当前的做法是重连,重连撞上 401,于是回到门禁页)。
724
+ *
725
+ * 断的方式就是 `res.end()` / `socket.destroy()`——**不去删表**:断开的收尾交给
726
+ * 它们自己那两条 close 监听(SSE 的 cleanup、WS 的 socket close),这样"谁负责
727
+ * 清理"只有一处,不会出现两张表各删一半、状态对不上的情况。
728
+ *
729
+ * ## 「远程窗口」那一扇窗:掐的是它的长连接,不是它的每一条请求(2026-10-16 第四步)
730
+ *
731
+ * 窗口里那些请求是**浏览器自动带 cookie 发的短请求**(静态资源、`/api/*`),不是一条
732
+ * 常驻连接——所以**掐不掉"下一次请求"**,也不需要掐:撤销之后 `windowSession` 每次
733
+ * 都回头查名册,那条记录已经不在了,**下一次请求当场 401**(这才是窗口失效的那一步)。
734
+ *
735
+ * 这里能掐的、也必须掐的是它**那条 WebSocket**(`/api/remote.mux`,界面和主机之间那条
736
+ * 常驻连接):它握手之后就一直在,不因为 HTTP 401 而消失。所以升级那一下要把
737
+ * 窗口会话里的设备身份也记上(见上面 upgrade),这里才点得到名。
738
+ *
739
+ * **给用户看的那句话**:撤销之后那扇窗**不会立刻变**——它会等到下一次点击/自动轮询
740
+ * 才变成 401(页面随后回门禁页)。别把它说成"当场断开"。
741
+ *
742
+ * @param {string} deviceId 要撤销的那台设备
743
+ * @returns {number} 真的掐掉了几条(0 = 它手上本来就没有活连接,这也是正常结果)
744
+ */
745
+ function dropDeviceConnections(deviceId) {
746
+ if (typeof deviceId !== 'string' || !deviceId) return 0
747
+ let dropped = 0
748
+ for (const [res, owner] of deviceStreams) {
749
+ if (owner !== deviceId) continue
750
+ dropped += 1
751
+ try { res.end() } catch { /* 已经断了 */ }
752
+ }
753
+ for (const [socket, owner] of deviceSockets) {
754
+ if (owner !== deviceId) continue
755
+ dropped += 1
756
+ try { socket.destroy() } catch { /* 已经断了 */ }
757
+ }
758
+ return dropped
759
+ }
760
+
501
761
  // ---- 手机上答题 ------------------------------------------------------
502
762
  //
503
763
  // **这两样必须放在这一层**(和 `clients`、`broadcast` 同一层,全程只建一次)。
@@ -756,26 +1016,219 @@ export async function createMiniServer({ store, config, token, bindAddresses, tu
756
1016
  : (u ? '其他' : '无 UA')
757
1017
  return kind + (ip ? '@' + String(ip).replace('::ffff:', '') : '')
758
1018
  }
1019
+ /**
1020
+ * 这次请求到底带了什么凭据(**只取原值,不判对错**)。
1021
+ *
1022
+ * 三处和令牌一一对应:地址里的参数、cookie、请求头。认哪一处由调用方决定——
1023
+ * `isAuthorized` 要判对错,`hasCredential` 只问「带没带」(用来把「没登录」和
1024
+ * 「密码错了」分开:前者不算试错,后者才记一次失败)。
1025
+ *
1026
+ * **设备身份走的是同一道门**(见下面 isAuthorized 的第二路):不是另开一条路,
1027
+ * 也不是「先过令牌再看设备」——两条路谁对上都放行。
1028
+ */
1029
+ function presentCredentials(req, url) {
1030
+ const jar = parseCookies(req.headers.cookie)
1031
+ return {
1032
+ token: [url.searchParams.get('token'), jar[COOKIE], req.headers['x-mini-token']],
1033
+ device: [url.searchParams.get('device'), jar[DEVICE_COOKIE], req.headers['x-mini-device']],
1034
+ }
1035
+ }
1036
+
1037
+ /**
1038
+ * 这次请求手上那几枚设备凭据,兑出来是哪一台(没有就 null)。
1039
+ *
1040
+ * **单独一个函数**(而不是塞进 isAuthorized 里就算了):SSE 连上来那一下要知道
1041
+ * 「这条流是谁的」,撤销时才掐得准。认人一律走 `devices.verify`——每一条现查名册,
1042
+ * 记录被删掉当场认不出来(见 lib/devices.js)。
1043
+ */
1044
+ function deviceIdOf(cred) {
1045
+ if (!devices) return null
1046
+ for (const value of cred.device) {
1047
+ const hit = devices.verify(value)
1048
+ if (hit) return hit.id
1049
+ }
1050
+ return null
1051
+ }
1052
+
759
1053
  function isAuthorized(req, url) {
760
- const fromQuery = url.searchParams.get('token')
761
- const fromCookie = parseCookies(req.headers.cookie)[COOKIE]
762
- const fromHeader = req.headers['x-mini-token']
763
- const ok = tokenEquals(fromQuery, token)
764
- || tokenEquals(fromCookie, token)
765
- || tokenEquals(fromHeader, token)
1054
+ const cred = presentCredentials(req, url)
1055
+ // **设备那一路先认,不管令牌认没认下来**:老设备可能同时带着令牌 cookie 和刚补上的
1056
+ // 设备凭据(第二步那一下之后就不带了),而 lastSeen 是按设备记的——让令牌那条短路
1057
+ // 掉它,那台设备的"最近使用时间"就永远不动。
1058
+ const deviceId = deviceIdOf(cred)
1059
+ const ok = cred.token.some((value) => tokenEquals(value, token))
1060
+ // 设备凭据:**每一枚都现查名册**(见 lib/devices.js 的 verify)。
1061
+ // 记录被删掉之后,同一台设备的下一次请求就过不去了——「删掉即失效」就落在这儿。
1062
+ || Boolean(deviceId)
766
1063
  // 手机每来一次请求都算"露过面"——这是「手机还在不在」的第二个信号。
767
1064
  // 只看 SSE 连接数会被抖动骗到(见 hasPhone 上面那段),请求时间戳稳得多。
768
1065
  if (ok) notePhoneSeen()
1066
+ // 「最近使用时间」的**唯一**写入点就在这一行。名册那边自己节流(最多一分钟写一次
1067
+ // 盘,见 lib/devices.js 的 touch),所以这里不必再挡——认人本身一个字节都不写。
1068
+ if (deviceId) devices.touch(deviceId)
769
1069
  return ok
770
1070
  }
771
1071
 
772
1072
  /** 这次请求到底有没有带凭据。没带只是没登录,不算试错。 */
773
1073
  function hasCredential(req, url) {
774
- return Boolean(
775
- url.searchParams.get('token')
776
- || parseCookies(req.headers.cookie)[COOKIE]
777
- || req.headers['x-mini-token'],
778
- )
1074
+ const cred = presentCredentials(req, url)
1075
+ return cred.token.some(Boolean) || cred.device.some(Boolean)
1076
+ }
1077
+
1078
+ /**
1079
+ * 这次请求没被放行时,**该不该记一次"试错"**。
1080
+ *
1081
+ * ## 为什么只记令牌、不记设备凭据(2026-10-16 第三步,真机链路里试出来的)
1082
+ *
1083
+ * 限速是给**令牌**准备的:令牌是**用户自己定的**(最短 12 位),猜得动,所以要挡。
1084
+ * 设备凭据不是——它是 256 位随机数,猜不动,限速它一个字节的安全都换不来。
1085
+ *
1086
+ * 而把它算成失败会造出一个死结,**恰恰发生在撤销之后**:
1087
+ * · 那台手机页上挂着一条 SSE(EventSource);被掐断之后浏览器**每 3 秒自动重连一次**,
1088
+ * 每次都是 401;
1089
+ * · 5 次就是 5 次"试错"(`RATE_MAX_FAILURES`),来源当场被封一分钟;
1090
+ * · 于是用户拿着电脑上那张新二维码重新扫码时,撞上的是「密码错误次数太多」——
1091
+ * 撤销之后本来就要重新扫码,这条路上他一定会踩到,而屏幕上没有半个字说明
1092
+ * 这是他刚才那个动作造成的。
1093
+ *
1094
+ * 判据:**只有令牌那一枚算数**。既没带令牌(或带的令牌是对的)就不记——带设备凭据的
1095
+ * 请求再多也只是"那台设备进不来",不是有人在猜密码。
1096
+ */
1097
+ function noteTokenFailure(req, url) {
1098
+ if (!presentCredentials(req, url).token.some(Boolean)) return
1099
+ noteFailure(req)
1100
+ }
1101
+
1102
+ /**
1103
+ * 新设备默认叫什么。
1104
+ *
1105
+ * **名字只是标签**:设备类型只用来给个像样的默认名,认人从不看它(也从不看指纹,
1106
+ * 见 lib/devices.js 开头那段)。复用 describeClient 那份判断,免得同一件事在两处
1107
+ * 各写一遍——两处迟早不一致,而不一致的表现是「同一个设备两个叫法」。
1108
+ */
1109
+ function defaultDeviceName(ua) {
1110
+ const kind = describeClient(ua)
1111
+ return kind === '无 UA' ? '新设备' : kind
1112
+ }
1113
+
1114
+ /**
1115
+ * 打开手机页那一下顺手发的设备凭据(登记点只有两个调用处,都在页面那一支里)。
1116
+ *
1117
+ * 已经有**有效**凭据的设备回 null:不重发、不新建、不写盘——绝大多数页面加载
1118
+ * 走的都是这一条,所以响应头逐字不变。
1119
+ *
1120
+ * 拿到的凭据放在 cookie 里(HttpOnly):浏览器自己带,手机页一行 JS 都不用改,
1121
+ * SSE、立绘、镜像框那些请求全都自动沾光。寿命给一年,和令牌 cookie 一样——
1122
+ * 换个短寿命只会让用户莫名其妙地重新扫码,而他什么都没做错。
1123
+ */
1124
+ function issueDeviceCookie(req) {
1125
+ if (!devices || typeof devices.register !== 'function') return null
1126
+ const had = parseCookies(req.headers.cookie)[DEVICE_COOKIE]
1127
+ if (had && devices.verify(had)) return null
1128
+ const issued = devices.register({ name: defaultDeviceName(req.headers['user-agent']) })
1129
+ // 这一行是**给以后查问题用的**:哪台设备什么时候进来的。不记凭据、不记密钥。
1130
+ if (issued) console.log(`[dsh-mini-remote] 新设备已登记:${issued.name}(${issued.id})`)
1131
+ return issued
1132
+ ? `${DEVICE_COOKIE}=${encodeURIComponent(issued.credential)}; Path=/; Max-Age=31536000; SameSite=Lax; HttpOnly`
1133
+ : null
1134
+ }
1135
+
1136
+ /**
1137
+ * 「远程窗口」那一侧的凭据:票换来的窗口会话(见 lib/mirror.js 的 redeemTicket)。
1138
+ *
1139
+ * **和令牌是两码事,各有各的用处**:
1140
+ * · 令牌 = 这台电脑的口令。它进得了 `/mini/api/*` 全部接口,所以**绝不能进浏览器**;
1141
+ * · 窗口会话 = 只够进镜像这一层,**寿命跟着宿主进程走**(见 lib/mirror.js 的 PROCESS_SECRET)。
1142
+ *
1143
+ * 桌面端那个面板拿票进门,之后框里那些请求(静态资源、`/api/*`、WebSocket)
1144
+ * 带不上票,就靠这一枚认人。
1145
+ *
1146
+ * ## 认人不只看签名,还要**回头查一次名册**(2026-10-16 第四步)
1147
+ *
1148
+ * 签名只说明「是本进程签的」。会话里还签着「这一扇窗是谁的」:
1149
+ * · 一台设备的 id → **每一次都现查名册**(`devices.has`,它自己也先对一次磁盘,
1150
+ * 见 lib/devices.js):记录不在了(被撤销、或者文件里那一行被人手删了)就当场拒。
1151
+ * 这就是「撤销之后那扇开着的窗下一次请求 401」落地的位置。
1152
+ * · `local`(本机那一半)→ 不查名册:它不是名册里的设备,也没有"撤销这一台"这回事。
1153
+ *
1154
+ * **没有身份的会话一律不认**:生产路径上兑票那一下必须给出身份(拿不到就不兑现,
1155
+ * 见 ticketWindowOwner),所以匿名会话只可能是别人的手笔——宁可拒。
1156
+ * 桩(测试里那个假 mirror)没有 `windowDevice` 这个口子:那种情况下照旧只看签名。
1157
+ *
1158
+ * @returns {{session:string, deviceId:string} | null} 会话本身 + 它是谁的;进不来回 null
1159
+ */
1160
+ function windowSession(req) {
1161
+ if (!mirror || typeof mirror.windowValid !== 'function') return null
1162
+ const raw = parseCookies(req.headers.cookie)[WINDOW_COOKIE]
1163
+ if (!raw || !mirror.windowValid(raw)) return null
1164
+ if (typeof mirror.windowDevice !== 'function') return { session: raw, deviceId: '' }
1165
+ const owner = mirror.windowDevice(raw)
1166
+ // 没有身份(老形状 / 兑票时就没给):不认。
1167
+ if (!owner) return null
1168
+ // 本机那一半:它不是名册里的设备,没有"撤销"这一说。
1169
+ if (owner === LOCAL_WINDOW_DEVICE) return { session: raw, deviceId: owner }
1170
+ // 来自远程设备的那一扇窗:**回头查名册**——那条记录还在不在。
1171
+ if (!devices || typeof devices.has !== 'function' || !devices.has(owner)) return null
1172
+ return { session: raw, deviceId: owner }
1173
+ }
1174
+
1175
+ /**
1176
+ * 这一扇窗是**哪台设备**的(本机那一半、或者认不出设备就回 null)。
1177
+ *
1178
+ * 只给"要按设备掐线"的地方用(镜像那个框的 WebSocket,见下面升级处理器)。
1179
+ */
1180
+ function windowDeviceId(req) {
1181
+ const open = windowSession(req)
1182
+ if (!open || !open.deviceId || open.deviceId === LOCAL_WINDOW_DEVICE) return null
1183
+ return open.deviceId
1184
+ }
1185
+
1186
+ /**
1187
+ * **这一扇窗是谁的**——兑票那一下的身份判据。**只在这一处用。**
1188
+ *
1189
+ * ## 身份从票上读,不再问"这次请求带了什么"(2026-10-16 第五步)
1190
+ *
1191
+ * 票发出去的时候就把主人定下来了(见 lib/mirror.js 的 issueTicket,两条取票路由
1192
+ * 在 lib/index.js)。兑票这一下只做三件事:
1193
+ *
1194
+ * ① **票上没写主人** → `null`:**这张票不兑现**,进不去。将来的新取票口要是忘了
1195
+ * 说明这扇窗是谁的,表现是这一扇窗打不开,而**不是**悄悄开成一扇撤不掉的窗
1196
+ * (fail closed)。
1197
+ * ② **票上写的是 `local`**(本机那一半,桌面端面板那条)→ 还要**这次请求确实是
1198
+ * 这台电脑自己发的**(回环、也不是隧道转进来的)才认。**桌面端面板走的就是
1199
+ * 这一条**:票由这台电脑自己的软件发出(见 lib/index.js 的 `/mini-remote/window`,
1200
+ * 只允许本机调用),浏览器也在这台电脑上。
1201
+ * ③ **票上写的是名册里那台设备** → **兑现这一刻也回头查一次名册**(`devices.has`,
1202
+ * 它自己也先对一次磁盘,见 lib/devices.js):记录不在了就不兑现。**跨机那条路
1203
+ * 走的是这一条**(见 lib/index.js 的 `/mini-remote/remote/window`:票是拿已验过的
1204
+ * 设备凭据换的)。
1205
+ *
1206
+ * ## 为什么不看请求里的凭据了
1207
+ *
1208
+ * 跨机那条路上,**票是在对面那台电脑的浏览器里打开的**:那个浏览器**没有设备凭据**
1209
+ * (凭据只活在对面那个宿主进程里,永远不进浏览器),也**不是本机回环**。照原来的
1210
+ * 口径它两条都不占,于是永远是"不兑现"——票发得出来,对面兑不了。
1211
+ *
1212
+ * ## 为什么这样**不比原来松**
1213
+ *
1214
+ * 原来的口径是**靠来源猜身份**:没带凭据、又看着像本机 → 记 `local`。而隧道流量在
1215
+ * 回环上也长得像本机(cloudflared 就是从 `127.0.0.1` 回连我们的,见 clientKey 上面
1216
+ * 那段),所以那一条得靠"带转发头就不算本机"这个补丁兜着。现在 `local` **不再由
1217
+ * 来源猜出来**,只能来自「只对本机开放的那条取票路由」发出来的票——请求自称不了
1218
+ * `local`。② 保留回环这一关,等于**只多不少**:带转发头的隧道请求拿到一张本机票
1219
+ * 也照样进不来。
1220
+ *
1221
+ * **票上写着身份 ≠ 可以不查名册**:这里查的是"兑现这一刻",会话签出来之后
1222
+ * **每一次放行都还要再查一次**(见 windowSession),撤销对已经开着的窗同样生效。
1223
+ */
1224
+ function ticketWindowOwner(req, ticket) {
1225
+ const owner = typeof mirror.ticketOwner === 'function' ? mirror.ticketOwner(ticket) : null
1226
+ if (!owner) return null
1227
+ if (owner === LOCAL_WINDOW_DEVICE) {
1228
+ return isLocalRequest(req) && !forwardedClient(req) ? owner : null
1229
+ }
1230
+ if (!devices || typeof devices.has !== 'function' || !devices.has(owner)) return null
1231
+ return owner
779
1232
  }
780
1233
 
781
1234
  /** 来源 -> { firstAt, count, blockedUntil } */
@@ -809,24 +1262,39 @@ export async function createMiniServer({ store, config, token, bindAddresses, tu
809
1262
  }
810
1263
 
811
1264
  /**
812
- * 把**我们自己那个** `token` 查询参数摘掉,其余一个字符都不动。
1265
+ * 把**我们自己那些凭据**从地址里摘掉,其余一个字符都不动。
813
1266
  *
814
- * 为什么必须摘:那个界面的插件脚本走一条合并加载路径(`plugins/??a/client.js&rev=x`),
815
- * 上游对它是**精确匹配**——多一个 `&token=...` 就当场 404,脚本一 404,界面就卡在
816
- * 启动画面报「HTML did not preload @deepseek-ai/dsh-client-modules/client.js」。
817
- * 这是真机上试出来的,不是推的。
1267
+ * 为什么必须摘(两条都是真机上试出来的):
1268
+ * ① 那个界面的插件脚本走一条合并加载路径(`plugins/??a/client.js&rev=x`),
1269
+ * 上游对它是**精确匹配**——多一个 `&token=...` 就当场 404,脚本一 404,
1270
+ * 界面就卡在启动画面报「HTML did not preload …」。
1271
+ * ② **凭据绝不能跟着请求进上游**(日志、链路、第三方)。上游本来就不认识它们。
1272
+ *
1273
+ * **判据是「值」,不是「参数名」**(2026-10-16 修)。原来只摘「参数名是 `token`
1274
+ * 且值等于我们令牌」的那一种,而设备身份那枚凭据只要换个参数名(哪怕只是写成
1275
+ * `device=`)就会被原样转发出去——凭据泄漏,或者上游精确匹配 404 白屏。
1276
+ * 现在两档,都不看参数名:
1277
+ * · 值等于我们的令牌(`token=` 这个名字照旧,别的名字也一并摘);
1278
+ * · 值长得像我们的设备凭据(`<32 位 hex>.<32 位 hex>`,见 lib/devices.js)。
1279
+ * 只多摘「确实是我们自己的东西」:同名但值不是我们的参数**一个都不许碰**
1280
+ * (那可能是界面自己在用的),这条有测试钉着。
818
1281
  *
819
1282
  * **逐段切、再拼回去,不走 URLSearchParams**:那条合并路径里有 `@`、`/`、`,` 这些字符,
820
1283
  * 交给它解析再序列化会被百分号编码,路径就变了——那等于换个法子把它弄坏。
821
- * 只在值确实等于我们的令牌时摘,免得误伤界面自己可能用到的同名参数。
822
1284
  */
823
- function stripOurToken(rawUrl) {
1285
+ function stripOurSecrets(rawUrl) {
824
1286
  const at = rawUrl.indexOf('?')
825
1287
  if (at < 0) return rawUrl
826
1288
  const head = rawUrl.slice(0, at)
827
1289
  const parts = rawUrl.slice(at + 1).split('&')
828
1290
  const encoded = encodeURIComponent(token)
829
- const kept = parts.filter((p) => p !== `token=${token}` && p !== `token=${encoded}`)
1291
+ const kept = parts.filter((p) => {
1292
+ const eq = p.indexOf('=')
1293
+ if (eq < 0) return true
1294
+ const value = p.slice(eq + 1)
1295
+ if (value === token || value === encoded) return false
1296
+ return !credentialLooksLikeOurs(value)
1297
+ })
830
1298
  if (kept.length === parts.length) return rawUrl
831
1299
  return kept.length ? `${head}?${kept.join('&')}` : head
832
1300
  }
@@ -851,6 +1319,38 @@ export async function createMiniServer({ store, config, token, bindAddresses, tu
851
1319
  return kept.length ? `${head}?${kept.join('&')}` : head
852
1320
  }
853
1321
 
1322
+ /**
1323
+ * 手机页这一趟该写下的 cookie:**设备凭据(可能)+ 把令牌 cookie 收回去(可能)**。
1324
+ *
1325
+ * 为什么要有「收回去」这一手(2026-10-16 第二步):令牌是**入场邀请函**,只在门口
1326
+ * 用一次。它一旦长期留在手机浏览器里,「名册里删掉那条记录 = 那台设备当场什么都做
1327
+ * 不了」就不成立——那台设备还能拿邀请函进门,撤销就是假的。所以从这一步起:入场
1328
+ * 不再把令牌 cookie 递回手机,手上已经有的(老设备)在补上设备凭据的**同一个响应**
1329
+ * 里作废——**人不被踢**(凭据已经给了),但从这一刻起走的是设备身份。
1330
+ *
1331
+ * 什么时候**不许**收:这台设备接下来没有凭据回来的时候。设备身份没开(`devices`
1332
+ * 缺失),或者既没发下新凭据、手上那枚又无效——这时候收走邀请函等于把用户关在门外。
1333
+ * 判据就一句:**要么这次发了新凭据,要么手上那枚还是有效的**。
1334
+ */
1335
+ function pageCookies(req, { entry = false } = {}) {
1336
+ const jar = parseCookies(req.headers.cookie)
1337
+ const device = issueDeviceCookie(req)
1338
+ const out = []
1339
+ if (device) out.push(device)
1340
+ const hasIdentity = Boolean(device)
1341
+ || Boolean(devices && devices.verify(jar[DEVICE_COOKIE]))
1342
+ if (jar[COOKIE] && hasIdentity) {
1343
+ // 手里那枚邀请函收回来(见上面那段)。
1344
+ out.push(`${COOKIE}=; Path=/; Max-Age=0; SameSite=Lax`)
1345
+ } else if (entry && !hasIdentity) {
1346
+ // **设备身份没开**(`devices` 缺失,见上面 createMiniServer 的参数说明)时,
1347
+ // 门口只能照旧把令牌 cookie 递回去:什么都不给的话,手机下一趟请求就一个凭据都
1348
+ // 带不上,等于把用户关在门外。真机上这条路走不到——lib/index.js 永远传名册。
1349
+ out.push(`${COOKIE}=${encodeURIComponent(token)}; Path=/; Max-Age=31536000; SameSite=Lax`)
1350
+ }
1351
+ return out
1352
+ }
1353
+
854
1354
  async function handle(req, res) {
855
1355
  const url = new URL(req.url, `http://${req.headers.host ?? 'localhost'}`)
856
1356
  const path = url.pathname
@@ -862,38 +1362,122 @@ export async function createMiniServer({ store, config, token, bindAddresses, tu
862
1362
  return
863
1363
  }
864
1364
 
1365
+ /**
1366
+ * 「跨机那两条」在**手机服务这个端口**上也接一份(2026-10-17 第七步)。
1367
+ *
1368
+ * ## 为什么非挂不可("B 填哪条地址"那个毛病的根)
1369
+ *
1370
+ * A 上本来有两个端口,两条跨机路各长在一边:
1371
+ * · `/mini-remote/pair`(配对)和 `/mini-remote/remote/window`(跨机取票)——
1372
+ * **宿主那一半**,挂在 DSH 自己的 web 端口上(见 lib/index.js);
1373
+ * · `/mini/mirror/` —— **服务那一半**,就是这个服务器(默认 3090)。
1374
+ *
1375
+ * 而取票回来的那条地址用的是**请求到达时的 Host**(见 lib/index.js 的
1376
+ * `requestOrigin`):B 拿哪条地址来换票,那张票就开在哪条地址上。B 手上只有一条
1377
+ * 地址,于是**填哪一边都有一半对不上**——填宿主那一半,框里那条 `/mini/mirror/`
1378
+ * 打不到(404);填这一半,配对那一下先被门拦住(401/404:这条请求手上还没有
1379
+ * 任何凭据,而手机服务那道门只认令牌/设备凭据)。
1380
+ *
1381
+ * 挂上这两条之后,**B 只填一条地址**——就是手机页那条地址:配对、取票、兑票、
1382
+ * 框里那些请求全在同一个源上(`/mini/mirror/` 本来就是这儿的)。
1383
+ *
1384
+ * ## 为什么是"同一份实现多一个挂载点",不是在这边重写一遍
1385
+ *
1386
+ * 这两条路自己的规矩(`allowRemoteComputers` 总开关、口令的定长比较、设备凭据的
1387
+ * 三种带法、"撤销立刻失效"、试错限速那张**和手机服务共用的表**、票怎么发)全都在
1388
+ * lib/index.js 那一处,**一个字节都不搬过来**:处理函数由那边原样递进来。复制一份的
1389
+ * 下场是两处各改各的,而那种不一致的表现是"这头能进那头不能进"。
1390
+ *
1391
+ * ## 为什么**排在手机服务自己那道鉴权之前**
1392
+ *
1393
+ * 这两条本来就是给**外面**那台电脑调的(见 lib/index.js 的 `pair` 那一段:来配对的
1394
+ * 人手上还没有凭据,凭据正是它来这儿要的东西),所以它们**不能**过 `isAuthorized`
1395
+ * ——和它们在宿主那一半的做法一致(不调 `localOnly`),自己带闸:总开关 + 口令/凭据
1396
+ * + 限速。**手机服务原有的鉴权一个字都没动**:其余每一条路径照旧走下面那道门。
1397
+ *
1398
+ * 位置挑在**镜像那一块之前**:那一段会把 `/mini-remote/*` 转给上游(镜像里的客户端
1399
+ * 插件要读宿主那些状态),但它挂在「进阶设置」开关上、还要先过鉴权——配对这条两条
1400
+ * 都不满足,转上去只会是 401/404。这两条得先接住。
1401
+ */
1402
+ if (remoteRoutes) {
1403
+ const hostHalf = path === '/mini-remote/pair' ? remoteRoutes.pair
1404
+ : path === '/mini-remote/remote/window' ? remoteRoutes.remoteWindow
1405
+ : null
1406
+ if (typeof hostHalf === 'function') {
1407
+ await hostHalf(req, res)
1408
+ return
1409
+ }
1410
+ }
1411
+
865
1412
  // ---- 页面本身 -------------------------------------------------------
866
1413
  // 允许用 ?token=xxx 直接进(二维码扫的就是这个地址),
867
1414
  // 校验通过后写 cookie,后续请求就不必再带参数。
1415
+ //
1416
+ // ## 「设备身份」的**登记点就是这里**(2026-10-16)
1417
+ //
1418
+ // 只有「打开手机页」这一下会登记一台新设备,普通接口请求一次都不登记。
1419
+ // 判据是**这个 if 的位置**,不是某个函数内部的开关:issueDeviceCookie() 的
1420
+ // 调用点只有下面两处(都在这个分支里,都经由 pageCookies),要改这一条,
1421
+ // 先看这两处并想清楚后果。
1422
+ //
1423
+ // 为什么非要这么卡:登记要是跟着普通请求走,客户端一抖、页面一刷新就会不停
1424
+ // 造新设备——「谁是谁」当场失真,名册也成了流水账。而这两处是**用户真的在
1425
+ // 拿一台设备进门**的那一下:
1426
+ // · 第一处 = 第一次带配对码(`?token=`)进来,也就是扫码那一下 → 顺手发凭据,
1427
+ // 用户零动作(设计上的硬要求:首次仍是两步,之后 0 步);
1428
+ // · 第二处 = 已经有凭据的老设备打开手机页(老设备只有令牌 cookie)→
1429
+ // **自动升级成设备身份,但不踢它**:令牌照旧认,只是顺手补一枚凭据。
868
1430
  if (req.method === 'GET' && (path === '/' || path === '/mini' || path === '/mini/')) {
869
1431
  const provided = url.searchParams.get('token')
870
1432
  if (provided && tokenEquals(provided, token)) {
871
1433
  noteSuccess(req)
872
- res.writeHead(200, {
1434
+ const cookies = pageCookies(req, { entry: true })
1435
+ const head = {
873
1436
  'Content-Type': 'text/html; charset=utf-8',
874
1437
  'Cache-Control': 'no-store',
875
- 'Set-Cookie': `${COOKIE}=${encodeURIComponent(token)}; Path=/; Max-Age=31536000; SameSite=Lax`,
876
- })
1438
+ }
1439
+ // **入场那一下不再递回令牌 cookie**(2026-10-16 第二步):门口验过就换成设备
1440
+ // 凭据,手机手上不留邀请函——留着它,「删掉记录」就永远撤不掉这台设备。
1441
+ if (cookies.length) head['Set-Cookie'] = cookies
1442
+ res.writeHead(200, head)
877
1443
  res.end(renderPage({ defaultMode: config.defaultMode, build, maxUpload: MAX_UPLOAD }))
878
1444
  return
879
1445
  }
880
1446
  if (isAuthorized(req, url)) {
881
1447
  noteSuccess(req)
882
- res.writeHead(200, {
1448
+ // 已经有有效设备凭据的这一次(绝大多数页面加载)什么都不发,响应头和从前逐字一样。
1449
+ const cookies = pageCookies(req)
1450
+ const head = {
883
1451
  'Content-Type': 'text/html; charset=utf-8',
884
1452
  'Cache-Control': 'no-store',
885
- })
1453
+ }
1454
+ if (cookies.length) head['Set-Cookie'] = cookies
1455
+ res.writeHead(200, head)
886
1456
  res.end(renderPage({ defaultMode: config.defaultMode, build, maxUpload: MAX_UPLOAD }))
887
1457
  return
888
1458
  }
889
1459
  // 带了密码但不对,记一次失败;压根没带不算——那只是打开页面而已
890
- if (hasCredential(req, url)) noteFailure(req)
1460
+ if (hasCredential(req, url)) noteTokenFailure(req, url)
1461
+ /**
1462
+ * 这台设备是**被撤销了**,还是**从来没进过门**?
1463
+ *
1464
+ * 两种都是"进不来",但下一步动作完全不同:前者要去电脑上重新扫码(他什么都没
1465
+ * 做错,所以得告诉他发生了什么),后者只是第一次来(照旧给输入框)。
1466
+ *
1467
+ * 判据只有一条:手上那枚凭据**长得像我们的**(`<32hex>.<32hex>`,见 lib/devices.js),
1468
+ * 可名册里已经查不出来了——那只能是被删掉的那一枚(乱填的撞不上这个形状,
1469
+ * 真凭据又只可能来自我们)。**不看 IP、不看指纹,也不给"是谁"留任何记录。**
1470
+ */
1471
+ const revoked = Boolean(devices) && presentCredentials(req, url).device
1472
+ .some((value) => credentialLooksLikeOurs(value) && !devices.verify(value))
891
1473
  // 没带 token:给一个只用于输入 token 的极简页面。
892
1474
  res.writeHead(200, {
893
1475
  'Content-Type': 'text/html; charset=utf-8',
894
1476
  'Cache-Control': 'no-store',
895
1477
  })
896
- res.end(renderPage({ defaultMode: config.defaultMode, needsToken: true, build, maxUpload: MAX_UPLOAD }))
1478
+ res.end(renderPage({
1479
+ defaultMode: config.defaultMode, needsToken: true, deviceRevoked: revoked, build, maxUpload: MAX_UPLOAD,
1480
+ }))
897
1481
  return
898
1482
  }
899
1483
 
@@ -903,7 +1487,7 @@ export async function createMiniServer({ store, config, token, bindAddresses, tu
903
1487
  // 不依赖 cookie(从门禁页手输 token 进来时是没有 cookie 的)。
904
1488
  if (req.method === 'GET' && path.startsWith('/mini/art/')) {
905
1489
  if (!isAuthorized(req, url)) {
906
- if (hasCredential(req, url)) noteFailure(req)
1490
+ if (hasCredential(req, url)) noteTokenFailure(req, url)
907
1491
  sendJson(res, 401, { error: '未授权:token 不正确' })
908
1492
  return
909
1493
  }
@@ -1005,8 +1589,98 @@ export async function createMiniServer({ store, config, token, bindAddresses, tu
1005
1589
  // 「HTML did not preload @deepseek-ai/dsh-client-modules/client.js」。
1006
1590
  // 所以转发前先把自己那个参数摘掉(**只在它确实是我们的令牌时摘**,
1007
1591
  // 免得误伤界面自己可能用到的同名参数)。
1008
- if (mirror && typeof mirrorEnabled === 'function' && mirrorEnabled()) {
1009
- const MIRROR_PREFIX = '/mini/mirror'
1592
+ // **认路要在门之前算出来**:这次要不要接,同时看「开关」和「是不是持票来的」两件事。
1593
+ const MIRROR_PREFIX = '/mini/mirror'
1594
+ const mirrorEntry = req.method === 'GET'
1595
+ && (path === MIRROR_PREFIX || path === `${MIRROR_PREFIX}/`)
1596
+ const mirrorUnder = path.startsWith(`${MIRROR_PREFIX}/`)
1597
+ // 票只可能在镜像那几条路径上(宿主把它拼进 iframe 的地址,见 lib/index.js)。
1598
+ const windowTicket = (mirrorEntry || mirrorUnder)
1599
+ ? String(url.searchParams.get('ticket') ?? '')
1600
+ : ''
1601
+ const windowOpen = Boolean(windowSession(req))
1602
+ /**
1603
+ * 票这一下**兑现没兑现**(`ticketOpen`)与**这一页是不是凭票嵌出来的**(`frameOpen`)。
1604
+ *
1605
+ * 两个分开是有原因的,见下面那段「两条路」:**票只授权那一次**,之后这条地址
1606
+ * 里就只剩一个「这一页是我嵌的」标记——它本身什么都打不开。
1607
+ */
1608
+ let ticketOpen = false
1609
+ let frameOpen = false
1610
+ if (windowTicket && mirror) {
1611
+ if (typeof mirror.ticketGranted === 'function' && mirror.ticketGranted(windowTicket)) {
1612
+ // 兑现过的票(iframe 重新加载同一条地址时就是这样):只算取景凭据,不算通行证。
1613
+ frameOpen = true
1614
+ } else {
1615
+ // **兑现这一下先把「这扇窗是谁的」定下来**(2026-10-16 第四步):身份会被签进
1616
+ // 会话体,之后每一次验会话都拿它回头查名册(见 windowSession)。
1617
+ // 这一下**读的是票上写着的主人**(发票那一刻定下的,见 lib/mirror.js 的
1618
+ // issueTicket / ticketOwner),不看这次请求带了什么凭据——跨机那条路上,票是在
1619
+ // 对面那台电脑的浏览器里打开的,它手上只有这张票(见 ticketWindowOwner)。
1620
+ // 定不下来(票没写主人、或者那台设备已经被撤销)就**不兑现**:票留着过期,
1621
+ // 门给一页人话(`sendWindowTicketBad` 认得这两种:票不对 / 通行证不对)。
1622
+ const owner = ticketWindowOwner(req, windowTicket)
1623
+ const opened = owner && typeof mirror.redeemTicket === 'function'
1624
+ ? mirror.redeemTicket(windowTicket, owner)
1625
+ : null
1626
+ if (!opened) {
1627
+ // 票不对、过期、或者已经用过:**不给门、也不给取景**,回一页人话走人。
1628
+ sendWindowTicketBad(res)
1629
+ return
1630
+ }
1631
+ ticketOpen = true
1632
+ frameOpen = true
1633
+ // 窗口会话:iframe 里那些请求(静态资源、`/api/*`、WebSocket)带不上票,只能靠它。
1634
+ //
1635
+ // **`SameSite=None; Secure` 不是随便选的**(2026-10-15 实测):桌面端自己的页面
1636
+ // 跑在自定义协议 `dsh-app://app/` 上,对我们的 `http://127.0.0.1:<端口>` 来说是
1637
+ // **跨站**——`Lax` 的 cookie 在跨站子框架里根本不会被带上,界面上就是一片 401。
1638
+ // `Secure` 在 `http://127.0.0.1` 上是允许的(回环算可信来源),所以这一条成立。
1639
+ res.setHeader('Set-Cookie',
1640
+ // **不写 `Max-Age`**(2026-10-15):窗口会话的寿命是「这个宿主进程活着」
1641
+ // (见 lib/mirror.js 的 PROCESS_SECRET),浏览器这边就跟着做成**会话 cookie**。
1642
+ // 写死一个过期时刻,等于又造一个固定寿命,还会在进程好好活着的时候先把面板弄成 401。
1643
+ //
1644
+ // ## 「没有闲置超时」是有意选的,不是漏了(2026-10-17 记下)
1645
+ //
1646
+ // 窗口会话只在两件事上失效:**DSH 重启**(换进程密钥,旧会话的签名当场对不上)
1647
+ // 与**对面那台电脑被撤销**(设备身份那一种每次放行都现查名册,见 windowSession)。
1648
+ // **没有"闲置多久就过期"这种机制**。竞品主流是给会话一个过期时刻,我们**不做**:
1649
+ // 撤销已经是即时的,安全那一半已经拿到;再加一个"闲置就断"只会把"开着看很久"
1650
+ // 变成一次次重新取票,体验变差,换来的安全边际接近于零。
1651
+ // 本机那一半(`local`)连撤销都不适用(它不是名册里的设备),寿命就是进程本身。
1652
+ `${WINDOW_COOKIE}=${opened.session}; Path=/; SameSite=None; Secure; HttpOnly`)
1653
+ }
1654
+ }
1655
+ const openForWindow = (ticketOpen || frameOpen || windowOpen)
1656
+ && typeof mirror.available === 'function' && mirror.available()
1657
+
1658
+ /**
1659
+ * ## 进这一层有**两条路**(2026-10-15)
1660
+ *
1661
+ * **① 手机页那条(原来就有的)**:带我们的令牌进来(`?token=`,或者入口那一趟
1662
+ * 写下的 `dsh_mini_token` cookie)。整块路由挂在「进阶设置」开关上,
1663
+ * 关着时一条都不接。
1664
+ *
1665
+ * **② 桌面端「远程窗口」那条(新的)**:地址里带着宿主发的**一次性票**
1666
+ * (`?ticket=`)。票在门这里兑现成窗口会话 cookie(进门之后全靠它认人),
1667
+ * 而票自己**只授权那一次**。**这条路上没有令牌**——令牌拼进 iframe 地址
1668
+ * 会留在那台电脑的浏览历史里(见 lib/mirror.js 的票)。
1669
+ *
1670
+ * ## 取景政策:**只有带合法票的请求才放行被嵌**(设计稿门 1)
1671
+ *
1672
+ * 默认给的是「只许同源」(见 lib/mirror.js 的 FRAME_SELF_ONLY,手机页那一层照常能嵌)。
1673
+ * 跨源那一层(桌面端在 `dsh-app://app`,镜像在 `http://127.0.0.1:<端口>`)要嵌,
1674
+ * 地址里必须带着那张票。**别有用心的站点把这条地址嵌进自己的页面时地址里没有票**,
1675
+ * 于是取景政策当场把它挡下——`SameSite=None` 的窗口会话会被浏览器带过去,
1676
+ * 所以这一条不能靠 cookie 来判,只能靠票。
1677
+ *
1678
+ * ②为什么不挂「进阶设置」那个开关:那是**给手机令牌扩权**的开关(手机令牌本来
1679
+ * 只够用我们那张手机页),而这里的票只有**本机回环上的宿主那一半**发得出来
1680
+ * (见 `lib/index.js` 的 `/mini-remote/window`,`localOnly`)。本机的软件本来
1681
+ * 就有这台电脑的全部权限,谈不上绕过。**开关状态本身一个字都没改。**
1682
+ */
1683
+ if (mirror && typeof mirrorEnabled === 'function' && (mirrorEnabled() || openForWindow)) {
1010
1684
  /**
1011
1685
  * **诊断模式开关**(`?diag=1`)。
1012
1686
  *
@@ -1017,9 +1691,6 @@ export async function createMiniServer({ store, config, token, bindAddresses, tu
1017
1691
  * (上游那条精确匹配的路由不认我们这些参数),丢掉之后就没得读了。
1018
1692
  */
1019
1693
  const diag = url.searchParams.get('diag') === '1'
1020
- const mirrorEntry = req.method === 'GET'
1021
- && (path === MIRROR_PREFIX || path === `${MIRROR_PREFIX}/`)
1022
- const mirrorUnder = path.startsWith(`${MIRROR_PREFIX}/`)
1023
1694
  const ours = path === '/' || path === '/mini' || path === '/mini/'
1024
1695
  || path === '/mini/sw.js' || path.startsWith('/mini/art/')
1025
1696
  // 三种都要放行:入口、挂在 `/mini/mirror/` 下面的相对路径、以及界面自己发的
@@ -1045,25 +1716,31 @@ export async function createMiniServer({ store, config, token, bindAddresses, tu
1045
1716
  const toUpstream = mirrorEntry || mirrorUnder || miniRemote
1046
1717
  || (!ours && !path.startsWith('/mini'))
1047
1718
  if (toUpstream) {
1048
- if (!isAuthorized(req, url)) {
1049
- if (hasCredential(req, url)) noteFailure(req)
1719
+ // 令牌、窗口会话、或者**刚兑现的那张票**(只有兑现它的那一次请求),有一枚对的就放行。
1720
+ // 注意票**不在这里兑现**——门口那一段已经兑现过了(那里才知道能不能兑现)。
1721
+ if (!isAuthorized(req, url) && !windowOpen && !ticketOpen) {
1722
+ if (hasCredential(req, url)) noteTokenFailure(req, url)
1050
1723
  sendJson(res, 401, { error: '未授权:token 不正确' })
1051
1724
  return
1052
1725
  }
1053
1726
  noteSuccess(req)
1054
1727
 
1055
1728
  if (mirrorEntry) {
1056
- // **入口这一下要把 cookie 写上**,和首页那一条同款。
1729
+ // **入口这一下不再把令牌写成 cookie**(2026-10-16 第三步,用户点名的那个缝)。
1057
1730
  //
1058
- // 为什么非要这样:那个界面进去之后,它自己会发一堆 `/api/...`、
1059
- // `/plugins/...` 的请求——**那些请求不会带 `?token=`**,只能靠 cookie 认。
1060
- // 入口这一刻是唯一一次「用户带着 token 主动进来」的机会,错过它,
1061
- // 界面能打开但里面每一格都是 401。
1062
- const provided = url.searchParams.get('token')
1063
- if (provided && tokenEquals(provided, token)) {
1064
- res.setHeader('Set-Cookie',
1065
- `${COOKIE}=${encodeURIComponent(token)}; Path=/; Max-Age=31536000; SameSite=Lax`)
1066
- }
1731
+ // 原来这里会在 `?token=…` 进门时顺手种一枚一年期的令牌 cookie。手机页已经不
1732
+ // 这么拼地址了(第二步:框里就是 `/mini/mirror/`,靠的是设备 cookie),可**谁
1733
+ // 手动打开过那个地址,那台浏览器里就留下一枚令牌**——它进得了 `/mini/api/*`
1734
+ // 全部接口。于是「名册里删掉那条记录 = 那台手机什么都做不了」在那台浏览器上
1735
+ // 就不成立了,撤销成了半真的。
1736
+ //
1737
+ // 那界面进去之后确实会发一堆 `/api/…`、`/plugins/…`(**那些请求不会带
1738
+ // `?token=`**),靠什么认:
1739
+ // · 手机页那条路——`dsh_mini_device` 那枚设备 cookie(Path=/,框里每一条请求
1740
+ // 都自动带上,撤销之后整框一起 401,这才是我们要的);
1741
+ // · 桌面端「远程窗口」——进门那一下兑现的窗口会话 cookie。
1742
+ // 两样都没有的浏览器(有人拿地址栏手敲 `?token=…`):这一页会打开,但里面
1743
+ // 每一格 401。**这是有意的**——宁可让那一格空着,也不把令牌留在浏览器里。
1067
1744
  if (path === MIRROR_PREFIX) {
1068
1745
  // 补上尾斜杠。**这一步不是洁癖**:外壳写着 `<base href="./">`,
1069
1746
  // 在 `/mini/mirror` 上它解析成 `/mini/`,在 `/mini/mirror/` 上才是
@@ -1083,7 +1760,7 @@ export async function createMiniServer({ store, config, token, bindAddresses, tu
1083
1760
  const saved = req.url
1084
1761
  req.url = '/'
1085
1762
  try {
1086
- await mirror.handle(req, res, { diag })
1763
+ await mirror.handle(req, res, { diag, embed: frameOpen })
1087
1764
  } finally {
1088
1765
  req.url = saved
1089
1766
  }
@@ -1095,9 +1772,9 @@ export async function createMiniServer({ store, config, token, bindAddresses, tu
1095
1772
  // 第一版这里写成了「不是前缀就映射到 `/`」,等于把每个 API 调用都打到首页上,
1096
1773
  // 是测试当场抓住的。
1097
1774
  const saved = req.url
1098
- req.url = stripDiagFlag(stripOurToken(mirrorUnder ? req.url.slice(MIRROR_PREFIX.length) : req.url))
1775
+ req.url = stripDiagFlag(stripOurSecrets(mirrorUnder ? req.url.slice(MIRROR_PREFIX.length) : req.url))
1099
1776
  try {
1100
- await mirror.handle(req, res)
1777
+ await mirror.handle(req, res, { embed: frameOpen })
1101
1778
  } finally {
1102
1779
  req.url = saved
1103
1780
  }
@@ -1124,7 +1801,7 @@ export async function createMiniServer({ store, config, token, bindAddresses, tu
1124
1801
  }
1125
1802
  if (!isAuthorized(req, url)) {
1126
1803
  // 只有「带了密码但不对」才算试错;压根没带只是没登录。
1127
- if (hasCredential(req, url)) noteFailure(req)
1804
+ if (hasCredential(req, url)) noteTokenFailure(req, url)
1128
1805
  sendJson(res, 401, { error: '未授权:token 不正确' })
1129
1806
  return
1130
1807
  }
@@ -1140,6 +1817,11 @@ export async function createMiniServer({ store, config, token, bindAddresses, tu
1140
1817
  })
1141
1818
  res.write(': connected\n\n')
1142
1819
  clients.add(res)
1820
+ // 这条流是**哪台设备**的:撤销那一下就是按这个 id 掐的(见 dropDeviceConnections)。
1821
+ // 令牌进来的连接(老路)认不出设备,于是也记不上——那种连接不受"撤销设备"影响,
1822
+ // 它归令牌管(令牌换掉就重启,那是另一件事)。
1823
+ const ownerId = deviceIdOf(presentCredentials(req, url))
1824
+ if (ownerId) deviceStreams.set(res, ownerId)
1143
1825
  // 这条连接的身份 + 它要不要轨迹。身份是客户端自己报的随机串(见 clientsById),
1144
1826
  // 没报就编一个:编出来的那种订不了轨迹,也就不用替这份体积付钱。
1145
1827
  const askedClientId = String(url.searchParams.get('clientId') ?? '').slice(0, 64)
@@ -1186,6 +1868,7 @@ export async function createMiniServer({ store, config, token, bindAddresses, tu
1186
1868
  clearInterval(beat)
1187
1869
  clients.delete(res)
1188
1870
  trajectorySubscribers.delete(res)
1871
+ deviceStreams.delete(res)
1189
1872
  const group = clientsById.get(clientId)
1190
1873
  if (group) {
1191
1874
  group.delete(res)
@@ -1455,7 +2138,13 @@ export async function createMiniServer({ store, config, token, bindAddresses, tu
1455
2138
  sendJson(res, 404, { ok: false, error: '没有这个工作区。' })
1456
2139
  return
1457
2140
  }
1458
- sendJson(res, 200, { ok: true, ...result })
2141
+ // 分组口径(隐藏已归档 / 隐藏空白 / 排序)在服务端这一层做:
2142
+ // 树那一层(lib/tree.js)只回答"这个工作区有哪些会话",界面口径不该渗进去。
2143
+ const shaped = await shapeSessions(result, {
2144
+ showArchived: url.searchParams.get('archived') === '1',
2145
+ boundSessionId: store.snapshot().boundSessionId,
2146
+ })
2147
+ sendJson(res, 200, { ok: true, ...shaped })
1459
2148
  return
1460
2149
  }
1461
2150
 
@@ -1822,6 +2511,149 @@ export async function createMiniServer({ store, config, token, bindAddresses, tu
1822
2511
  return
1823
2512
  }
1824
2513
 
2514
+ // ---- 会话条目的执行功能(2026-10-09)------------------------------
2515
+ //
2516
+ // 五条动作都**作用在传进来的那个会话上**,不是"当前绑定的会话"。
2517
+ // 为什么和 send / stop 的口径不一样:那一套是"你在遥控哪一个会话",
2518
+ // 而这一套是从列表里对着**某一行**动的手——用户点的就是那一行,
2519
+ // 拿绑定会话去改会改错人(列表里明明点的是别人)。
2520
+ //
2521
+ // 每条都把官方的拒绝**翻译成一句人话**,并把失败原因(reason)一起带回去。
2522
+ // 手机那边照着 reason 决定说什么,不靠中文字面去猜。
2523
+ if (path === '/mini/api/session/rename') {
2524
+ const sessionId = typeof body.sessionId === 'string' ? body.sessionId : ''
2525
+ // 空标题在**请求边界**先挡一次(另一道在 lib/index.js 的 renameSession,
2526
+ // 那里是"官方会抛"的那一种)。两处都留:这一处省一趟往返、并且让 400 这句话
2527
+ // 和其它路由的措辞放在一起;那一处保证任何调用方都绕不过去。
2528
+ if (typeof body.title !== 'string' || !body.title.trim()) {
2529
+ sendJson(res, 400, { ok: false, reason: 'blank', error: '标题不能为空。' })
2530
+ return
2531
+ }
2532
+ const result = await nav.renameSession(sessionId, body.title)
2533
+ if (!result.ok) {
2534
+ const known = {
2535
+ 'no-service': [503, '这台电脑上的 DSH 没提供改标题的能力。'],
2536
+ blank: [400, '标题不能为空。'],
2537
+ 'not-found': [404, '这个会话不在了,刷新一下再看看。'],
2538
+ }
2539
+ const [status, message] = known[result.reason]
2540
+ ?? [500, `改标题失败:${result.error ?? '原因未知'}`]
2541
+ sendJson(res, status, { ok: false, reason: result.reason, error: message })
2542
+ return
2543
+ }
2544
+ sendJson(res, 200, { ok: true, sessionId, title: result.title })
2545
+ return
2546
+ }
2547
+
2548
+ if (path === '/mini/api/session/fork') {
2549
+ const sessionId = typeof body.sessionId === 'string' ? body.sessionId : ''
2550
+ const result = await nav.forkSession(sessionId)
2551
+ if (!result.ok) {
2552
+ const known = {
2553
+ 'no-service': [503, '这台电脑上的 DSH 没提供分叉会话的能力。'],
2554
+ // 这两条**原话转发**:`result.error` 里装的就是 Host 抛出来的那句话
2555
+ // (见 lib/index.js 的 forkSession)。在这里再包一层我们自己的话,
2556
+ // 用户就看不到真正的原因了——那是这一条路唯一要防的事。
2557
+ 'no-turn': [409, result.error],
2558
+ 'not-found': [404, result.error],
2559
+ }
2560
+ const [status, message] = known[result.reason]
2561
+ ?? [500, `分叉失败:${result.error ?? '原因未知'}`]
2562
+ sendJson(res, status, { ok: false, reason: result.reason, error: message })
2563
+ return
2564
+ }
2565
+ sendJson(res, 200, {
2566
+ ok: true,
2567
+ sessionId: result.sessionId,
2568
+ sourceSessionId: sessionId,
2569
+ title: result.title,
2570
+ })
2571
+ return
2572
+ }
2573
+
2574
+ if (path === '/mini/api/session/pin') {
2575
+ const sessionId = typeof body.sessionId === 'string' ? body.sessionId : ''
2576
+ const want = body.pinned !== false
2577
+ const result = want
2578
+ ? await nav.pinSession(sessionId)
2579
+ : await nav.unpinSession(sessionId)
2580
+ if (!result.ok) {
2581
+ const known = {
2582
+ 'no-registry': [503, '这台电脑上的 DSH 没提供工作区服务。'],
2583
+ archived: [409, '已归档的会话不能置顶,先取消归档。'],
2584
+ 'not-found': [404, '这个会话不在了,刷新一下再看看。'],
2585
+ }
2586
+ const [status, message] = known[result.reason]
2587
+ ?? [500, `置顶失败:${result.error ?? '原因未知'}`]
2588
+ sendJson(res, status, { ok: false, reason: result.reason, error: message })
2589
+ return
2590
+ }
2591
+ sendJson(res, 200, {
2592
+ ok: true,
2593
+ sessionId,
2594
+ pinned: want,
2595
+ archivedSessionIds: result.archived,
2596
+ pinnedSessionIds: result.pinned,
2597
+ })
2598
+ return
2599
+ }
2600
+
2601
+ if (path === '/mini/api/session/archive') {
2602
+ const sessionId = typeof body.sessionId === 'string' ? body.sessionId : ''
2603
+ const result = await nav.archiveSession(sessionId, body.stop === true)
2604
+ if (!result.ok) {
2605
+ // 「还有工作在跑」不是错误,是**两段式流程的第一段**:官方拒一次,同时给出
2606
+ // 会停掉什么。原样交给手机,由它弹确认框问过用户再带 stop:true 回来。
2607
+ if (result.reason === 'active') {
2608
+ sendJson(res, 409, {
2609
+ ok: false,
2610
+ reason: 'active',
2611
+ sessionId,
2612
+ activity: result.activity,
2613
+ error: '这个会话还有工作在跑,归档会先停掉它们。',
2614
+ })
2615
+ return
2616
+ }
2617
+ const known = {
2618
+ 'no-registry': [503, '这台电脑上的 DSH 没提供工作区服务。'],
2619
+ 'not-found': [404, '这个会话不在了,刷新一下再看看。'],
2620
+ }
2621
+ const [status, message] = known[result.reason]
2622
+ ?? [500, `归档失败:${result.error ?? '原因未知'}`]
2623
+ sendJson(res, status, { ok: false, reason: result.reason, error: message })
2624
+ return
2625
+ }
2626
+ sendJson(res, 200, {
2627
+ ok: true,
2628
+ sessionId,
2629
+ archived: true,
2630
+ stopped: body.stop === true,
2631
+ archivedSessionIds: result.archived,
2632
+ pinnedSessionIds: result.pinned,
2633
+ })
2634
+ return
2635
+ }
2636
+
2637
+ if (path === '/mini/api/session/unarchive') {
2638
+ const sessionId = typeof body.sessionId === 'string' ? body.sessionId : ''
2639
+ const result = await nav.unarchiveSession(sessionId)
2640
+ if (!result.ok) {
2641
+ const known = { 'no-registry': [503, '这台电脑上的 DSH 没提供工作区服务。'] }
2642
+ const [status, message] = known[result.reason]
2643
+ ?? [500, `取消归档失败:${result.error ?? '原因未知'}`]
2644
+ sendJson(res, status, { ok: false, reason: result.reason, error: message })
2645
+ return
2646
+ }
2647
+ sendJson(res, 200, {
2648
+ ok: true,
2649
+ sessionId,
2650
+ archived: false,
2651
+ archivedSessionIds: result.archived,
2652
+ pinnedSessionIds: result.pinned,
2653
+ })
2654
+ return
2655
+ }
2656
+
1825
2657
  // 切权限档位。作用在当前绑定的那个会话上——手机只绑一个会话,
1826
2658
  // 所以「当前会话」和「绑定会话」是同一件事。
1827
2659
  if (path === '/mini/api/permissions') {
@@ -1970,8 +2802,13 @@ export async function createMiniServer({ store, config, token, bindAddresses, tu
1970
2802
  // 会把它一起拒掉,界面就永远在重连。(第一版就是这么写的,靠测试改成真实路径形态才发现。)
1971
2803
  const MIRROR_PREFIX = '/mini/mirror'
1972
2804
  server.on('upgrade', (req, socket, head) => {
2805
+ // 和 HTTP 那边同一条口径:开关开着**或者**这次是持窗口会话来的(桌面端「远程窗口」)。
2806
+ // 漏掉后面那一半的话,镜像里的界面会一直「正在重新连接」——
2807
+ // 官方那条常驻连接就是 WebSocket(`/api/remote.mux`),握手被拒就全盘不对。
2808
+ const sessionOpen = Boolean(windowSession(req))
1973
2809
  if (!mirror || typeof mirror.upgrade !== 'function'
1974
- || typeof mirrorEnabled !== 'function' || !mirrorEnabled()) {
2810
+ || typeof mirrorEnabled !== 'function'
2811
+ || (!mirrorEnabled() && !sessionOpen)) {
1975
2812
  socket.destroy()
1976
2813
  return
1977
2814
  }
@@ -1983,11 +2820,24 @@ export async function createMiniServer({ store, config, token, bindAddresses, tu
1983
2820
  socket.destroy()
1984
2821
  return
1985
2822
  }
2823
+ // 这条长连接是哪台设备的——撤销时要跟着断(见 dropDeviceConnections)。
2824
+ // 镜像这条 WS**本身不查我们的凭据**(它靠上游那套认人),所以这里只做"记得住":
2825
+ // 撤销一台设备时,它手上那个已经握手的框不会因为 HTTP 401 而消失,得点名断。
2826
+ //
2827
+ // **两种身份都要记**(2026-10-16 第四步):手机页那条框带的是设备 cookie(第一路);
2828
+ // 桌面端「远程窗口」那个框带的是**窗口会话**——会话里签着它是哪台设备的
2829
+ // (第二路,见 windowDeviceId)。少了第二路,跨机那扇窗在撤销之后就漏了一条活连接。
2830
+ const wsOwner = deviceIdOf(presentCredentials(req, new URL(req.url ?? '/', 'http://x')))
2831
+ ?? windowDeviceId(req)
2832
+ if (wsOwner) {
2833
+ deviceSockets.set(socket, wsOwner)
2834
+ socket.on('close', () => deviceSockets.delete(socket))
2835
+ }
1986
2836
  // 挂在挂载点下面的剥掉前缀,绝对路径(`/api/...`)原样转——和 HTTP 那边一致。
1987
2837
  // `diag=1` 同样要摘掉(和 HTTP 那边同一个道理:上游不认它)。
1988
2838
  req.url = stripDiagFlag(under
1989
- ? stripOurToken(req.url.slice(MIRROR_PREFIX.length) || '/')
1990
- : stripOurToken(req.url))
2839
+ ? stripOurSecrets(req.url.slice(MIRROR_PREFIX.length) || '/')
2840
+ : stripOurSecrets(req.url))
1991
2841
  mirror.upgrade(req, socket, head)
1992
2842
  })
1993
2843
  try {
@@ -2040,6 +2890,38 @@ export async function createMiniServer({ store, config, token, bindAddresses, tu
2040
2890
  /** 想绑但失败的地址,交给调用方决定怎么提示。 */
2041
2891
  failed,
2042
2892
  broadcast,
2893
+ /**
2894
+ * 掐掉某台设备手上的活连接(SSE 流 + 镜像那个框的 WebSocket)。**撤销设备时调它**
2895
+ * ——只靠"下一次请求被拒",用户会以为撤销没生效(见 dropDeviceConnections)。
2896
+ * @param {string} deviceId
2897
+ * @returns {number} 真的掐掉了几条
2898
+ */
2899
+ dropDeviceConnections,
2900
+ /**
2901
+ * 试错限速那一套(`attempts` 那张表本身,**只有这一份**)。
2902
+ *
2903
+ * 为什么要交出去:「配对」那条路由(lib/index.js 的 `/mini-remote/pair`)是**唯一一条
2904
+ * 允许外面调的 `/mini-remote/*`**——它拿口令换设备凭据,也就是一条猜口令的路,必须挂闸。
2905
+ * 而"同一来源一分钟错 5 次就挡一分钟"这套闸门本来就长在这里,**不另写第二份**:
2906
+ * 同一个来源在手机页上猜错、在配对接口上猜错,记的是同一个桶——换个入口不该重新拿到
2907
+ * 5 次机会。(分桶见 clientKey:走隧道按真实客户端地址,其余按源地址。)
2908
+ */
2909
+ rateLimit: { isBlocked, noteFailure, noteSuccess },
2910
+ /**
2911
+ * 认一枚**设备凭据**:对得上就回那台设备的 id,否则 `null`。
2912
+ *
2913
+ * 为什么交出去:「跨机取票」那条路由(lib/index.js 的
2914
+ * `/mini-remote/remote/window`)跑在宿主那一半、不在这个服务器里,而它要认的正是
2915
+ * 设备凭据(不是令牌)。**不另写一套**:三种带法(`?device=` / cookie /
2916
+ * `X-Mini-Device` 头)、定长比对(`devices.verify` 里的 `timingSafeEqual`)、
2917
+ * 「撤销之后当场失效」(每次都现查名册)全都在这一份实现里——第二处复制一遍,
2918
+ * 迟早一处改了一处没改,而那种不一致的表现是"撤销了还进得来"。
2919
+ *
2920
+ * **只认不记**(和 `devices.verify` 同一条口径):不回写 `lastSeen`,也不动
2921
+ * `hasPhone` 那个时间戳。这是有意的——后者是"手机还在不在"的信号,一台电脑来换票
2922
+ * 并不代表手机在看;记错了会让提问被推给一台根本没开着的手机。
2923
+ */
2924
+ verifyDevice: (req, url) => deviceIdOf(presentCredentials(req, url)),
2043
2925
  /** 执行轨迹的增量帧:只发给声明了「完整」模式的那几条连接(见 broadcastTrajectory)。 */
2044
2926
  broadcastTrajectory,
2045
2927
  /** 轨迹的活片段帧:同一批连接,不占 seq/epoch(见 broadcastTrajectoryLive)。 */