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/build.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* 这个项目在工作目录里已经踩过一次。
|
|
12
12
|
*/
|
|
13
13
|
import { createHash } from 'node:crypto'
|
|
14
|
-
import { readFileSync, readdirSync } from 'node:fs'
|
|
14
|
+
import { readFileSync, readdirSync, statSync } from 'node:fs'
|
|
15
15
|
import { dirname, join } from 'node:path'
|
|
16
16
|
import { fileURLToPath } from 'node:url'
|
|
17
17
|
|
|
@@ -66,3 +66,34 @@ export function buildId() {
|
|
|
66
66
|
}
|
|
67
67
|
return hash.digest('hex').slice(0, 12)
|
|
68
68
|
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* 参与指纹的那几个文件里,**最后被改动的是什么、什么时候**。
|
|
72
|
+
*
|
|
73
|
+
* 为什么要它(2026-10-17):这份代码是「改完重启 DSH 才生效」的,而重启之前旧进程
|
|
74
|
+
* 照旧应答。把这份时刻和进程的启动时刻比一下,就能一句话回答「现在跑的是不是最新那份」
|
|
75
|
+
* ——那天真机上那件怪事(桌面端连开一天,新功能一个都没出现)就是这么来的:
|
|
76
|
+
* **进程比代码旧**。
|
|
77
|
+
*
|
|
78
|
+
* 和 `buildId()` 是同一份文件清单(`sources()`),所以"哪几个文件算这份代码"只有一处定义。
|
|
79
|
+
*
|
|
80
|
+
* 返回 `{ file, at }`:`file` 是相对插件根目录的路径(好认),`at` 是毫秒时间戳。
|
|
81
|
+
* 一个文件都读不到时回 `null`——调用方(自检)宁可说「未知」,也不抛错。
|
|
82
|
+
*/
|
|
83
|
+
export function newestSource() {
|
|
84
|
+
let newest = null
|
|
85
|
+
for (const [name, file] of sources()) {
|
|
86
|
+
try {
|
|
87
|
+
// `mtimeMs` 是浮点(带小数),摆进接口里难看也没必要:毫秒取整就够用。
|
|
88
|
+
const at = Math.round(statSync(file).mtimeMs)
|
|
89
|
+
if (!newest || at > newest.at) {
|
|
90
|
+
// sources() 里 lib 下那几个只记了光名字(指纹里也是这么用的),
|
|
91
|
+
// 摆给用户看时补上目录,他才知道改的是哪一份。
|
|
92
|
+
newest = { file: name.includes('/') ? name : `lib/${name}`, at }
|
|
93
|
+
}
|
|
94
|
+
} catch {
|
|
95
|
+
// 读不到就跳过:这正是"这份安装少了一部分文件"时该有的样子。
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return newest
|
|
99
|
+
}
|
package/lib/devices.js
ADDED
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 「设备身份」名册:一台设备一条记录,落盘在 `$DSH_HOME/dsh-mini-remote/devices.json`
|
|
3
|
+
* (和 `token`、`settings.json`、`state.json` 并排,见 lib/index.js 的 dataDir)。
|
|
4
|
+
*
|
|
5
|
+
* 设计(2026-10-16 用户拍板的那一份,一个字都没改):
|
|
6
|
+
* · **身份 = 随机 128 位的 id + 随机 128 位的密钥**;**名字只是标签**,随时能改。
|
|
7
|
+
* · **不用指纹**:设备类型只用来给个像样的默认名,认人从不看它——指纹会变
|
|
8
|
+
* (换浏览器、升级系统、隐私模式),拿它认人会**认错人**,比不认更糟。
|
|
9
|
+
* · **只存密钥的 SHA-256**:读了这份文件也冒充不了已有设备。
|
|
10
|
+
* · 不加密、不签名:同一目录下的 `token` 本身就是明文,多一道锁只是自欺。
|
|
11
|
+
* · `kind` 缺省是 `'phone'`,**登记时可以给别的值**(跨机第一条:另一台电脑来配对时
|
|
12
|
+
* 是 `'desktop'`)。它只影响设置页那行小字,认人从不看它——留这个位就是为了
|
|
13
|
+
* **跨机落地时省一次文件迁移**。
|
|
14
|
+
* · **删掉一条记录 = 那台设备当场失效**:认人每次都现查这份表,没有缓存窗口
|
|
15
|
+
* (见下面的 syncFromDisk——手改文件、或者别的进程删掉一条,下一次请求就生效)。
|
|
16
|
+
*
|
|
17
|
+
* 凭据的线上形状是 `<32 位 hex>.<32 位 hex>`(`id.密钥`):
|
|
18
|
+
* id 是明文(它只是个名字),密钥**只在发出去那一下**在服务器手里——之后手上
|
|
19
|
+
* 只剩它的哈希。所以凭据丢了只能重新登记,找不回来。这是对的,不是缺陷。
|
|
20
|
+
*
|
|
21
|
+
* 凭据怎么带(三处都是 lib/server.js 的 isAuthorized 收的,和令牌同一道门):
|
|
22
|
+
* · cookie `dsh_mini_device`——**真机上的主路**:浏览器自己带,手机页一行都不用改
|
|
23
|
+
* (SSE、立绘、镜像框里那些请求全都沾光);
|
|
24
|
+
* · 请求头 `X-Mini-Device`;
|
|
25
|
+
* · 地址里的 `?device=`。
|
|
26
|
+
* **转发到上游之前这三样都要被摘干净**(见 lib/server.js 的 stripOurSecrets 和
|
|
27
|
+
* lib/mirror.js 的 upstreamHeaders):凭据进没进上游的日志,是两回事。
|
|
28
|
+
*
|
|
29
|
+
* ## 这份模块是名册**唯一**的读写口(2026-10-16 第三步)
|
|
30
|
+
*
|
|
31
|
+
* 设置页那张列表要列出、要改名、要撤销,走的是这里的 `list` / `rename` / `remove`,
|
|
32
|
+
* **不是**自己去读写那个 JSON 文件。理由就一句:内存一份、磁盘一份,两处各写各的
|
|
33
|
+
* 迟早对不上——而对不上的表现是「界面上删了,那台手机还能进」,最难查也最要命。
|
|
34
|
+
*
|
|
35
|
+
* ## `lastSeen`:只记时间,不记 IP、不记指纹
|
|
36
|
+
*
|
|
37
|
+
* 用户拍板的:列表上要有「最近使用时间」。**只存一个时间戳**——地址和指纹一概不留
|
|
38
|
+
* (留了就成了一份"谁在哪儿用过"的档案,而我们只想回答"这台设备还活着吗")。
|
|
39
|
+
*
|
|
40
|
+
* **不是每个请求都写盘**:手机刷一下就是几十个请求,每个都写盘等于拿磁盘换一个没人
|
|
41
|
+
* 看得见的秒级精度。所以攒在内存里(`pendingSeen`),**最多每 `SEEN_FLUSH_MS` 写一次**,
|
|
42
|
+
* 详见 touch() 上面那一段。
|
|
43
|
+
*
|
|
44
|
+
* ## 对外形状:**永不外泄 keyHash**
|
|
45
|
+
*
|
|
46
|
+
* `list()` / `rename()` 回的是 `publicRecord()`——id、名字、类型、创建时间、最近使用。
|
|
47
|
+
* 密钥的哈希一个字节都不出去(它是"读了文件也冒充不了"的那道保险,摆在界面上没有任何
|
|
48
|
+
* 用处,只会多一处泄漏面)。
|
|
49
|
+
*/
|
|
50
|
+
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'
|
|
51
|
+
import { mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs'
|
|
52
|
+
import { dirname } from 'node:path'
|
|
53
|
+
|
|
54
|
+
/** id 和密钥各 128 位。 */
|
|
55
|
+
const ID_BYTES = 16
|
|
56
|
+
const KEY_BYTES = 16
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* 名字最长多少字。**只管住界面**:名字是标签,写长了只是那一行撑破,不是安全问题。
|
|
60
|
+
* 40 够写清是哪台手机("妈妈那台"、"客厅的 iPad"),又不至于把列表挤变形。
|
|
61
|
+
*/
|
|
62
|
+
const NAME_MAX = 40
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* `lastSeen` 攒多久写一次盘。
|
|
66
|
+
*
|
|
67
|
+
* 为什么是 60 秒:这个字段的**用途只有屏幕上的那一句话**("3 分钟前用过"),
|
|
68
|
+
* 精度需求本来就是分钟级;而手机连着的时候一秒能来好几个请求。
|
|
69
|
+
* 攒着 + 定期落盘的代价是**崩溃时会丢**:最多丢掉最近 60 秒里发生的那几次使用
|
|
70
|
+
* ——也就是列表上那句"最近使用时间"最多比真实情况早一分钟。
|
|
71
|
+
* **设备进不进得来这件事一点不受影响**:认人查的是名册本身,不是这个时间戳。
|
|
72
|
+
*/
|
|
73
|
+
const SEEN_FLUSH_MS = 60_000
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* 凭据的形状。
|
|
77
|
+
*
|
|
78
|
+
* **只写一处、导出给转发那一层用**:lib/server.js 往上游转之前按「值像不像我们的凭据」
|
|
79
|
+
* 摘参数,用的是同一个正则。两处各写一遍,迟早一边改了另一边没改——而那种不一致的
|
|
80
|
+
* 表现是「凭据悄悄进了上游日志」,谁都不会发现。
|
|
81
|
+
*/
|
|
82
|
+
const CREDENTIAL_SHAPE = /^[0-9a-f]{32}\.[0-9a-f]{32}$/
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* 这个值长得像不像我们的设备凭据。**只看形状,不看参数名。**
|
|
86
|
+
*
|
|
87
|
+
* 为什么按形状而不是「查表里有没有」:撤销掉的那枚凭据已经查不出来了,可它照样
|
|
88
|
+
* 不该被转发出去。形状对就摘,是更保守的那一边——多摘一个我们自己形状的值,
|
|
89
|
+
* 代价只是上游少看到一次它本来也不认识的东西。
|
|
90
|
+
*/
|
|
91
|
+
export function credentialLooksLikeOurs(value) {
|
|
92
|
+
return typeof value === 'string' && CREDENTIAL_SHAPE.test(value)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function sha256(text) {
|
|
96
|
+
return createHash('sha256').update(text).digest()
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function createDevices({ file }) {
|
|
100
|
+
/** 内存这份就是当前名册;磁盘那份是它的落点。 */
|
|
101
|
+
let table = []
|
|
102
|
+
/** 磁盘那份的「改动标记」(mtime + 大小)。null = 还没对过。 */
|
|
103
|
+
let stamp = null
|
|
104
|
+
/** 还没落盘的 `lastSeen`:id → 时间戳(每台设备只留最新那一次,不排队)。 */
|
|
105
|
+
const pendingSeen = new Map()
|
|
106
|
+
/** 上一次把 `lastSeen` 写进磁盘的时刻。**0 = 还没写过**,所以第一台设备一进来就落一次。 */
|
|
107
|
+
let seenFlushedAt = 0
|
|
108
|
+
|
|
109
|
+
function readFromDisk() {
|
|
110
|
+
try {
|
|
111
|
+
const raw = JSON.parse(readFileSync(file, 'utf8'))
|
|
112
|
+
const list = Array.isArray(raw?.devices) ? raw.devices : []
|
|
113
|
+
return list
|
|
114
|
+
.filter((d) => d && typeof d.id === 'string' && typeof d.keyHash === 'string')
|
|
115
|
+
.map((d) => ({
|
|
116
|
+
id: d.id,
|
|
117
|
+
name: typeof d.name === 'string' ? d.name : '',
|
|
118
|
+
// 缺失或写坏一律当 phone(名册里现在还有 'desktop',见下面的 register)。
|
|
119
|
+
kind: typeof d.kind === 'string' && d.kind ? d.kind : 'phone',
|
|
120
|
+
keyHash: d.keyHash,
|
|
121
|
+
createdAt: Number(d.createdAt) || 0,
|
|
122
|
+
// 第三步之前写下的老记录没有这个字段:当 0 = 「还没记过」。
|
|
123
|
+
// **不拿 createdAt 顶替**——那会凭空造出一次"使用"。
|
|
124
|
+
lastSeen: Number(d.lastSeen) || 0,
|
|
125
|
+
}))
|
|
126
|
+
} catch {
|
|
127
|
+
// 首次运行没有这个文件,或文件损坏——都不该让插件起不来。
|
|
128
|
+
// 损坏时名册当作空的:手机重新打开一次页面就重新登记,用户不用做任何事。
|
|
129
|
+
return []
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function fileTag() {
|
|
134
|
+
try {
|
|
135
|
+
const info = statSync(file)
|
|
136
|
+
return `${info.mtimeMs}:${info.size}`
|
|
137
|
+
} catch {
|
|
138
|
+
return 'missing'
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* 把内存那份对齐到磁盘那份。**只在磁盘真的变过时才重读。**
|
|
144
|
+
*
|
|
145
|
+
* 为什么非要这样,而不是启动时读一次就完事:撤销的语义是**「删掉记录,那台设备
|
|
146
|
+
* 立刻失效」**。用户完全可能自己去删 devices.json 里那一行(它是明文 JSON),
|
|
147
|
+
* 设置页那张列表也在改它。要是拿启动时抄的那一份去认人,这些改动都得等重启才生效
|
|
148
|
+
* ——那就不叫「立刻」了。
|
|
149
|
+
*
|
|
150
|
+
* 每次请求多一次 stat:一条 phone 的请求频率下,这点开销看不见。
|
|
151
|
+
*/
|
|
152
|
+
function syncFromDisk() {
|
|
153
|
+
const tag = fileTag()
|
|
154
|
+
if (tag === stamp) return
|
|
155
|
+
stamp = tag
|
|
156
|
+
table = readFromDisk()
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* 一条记录对外的那一面(**keyHash 不在里面**)。
|
|
161
|
+
*
|
|
162
|
+
* `lastSeen` 取「磁盘那份」和「还没落盘那份」里更新的那个:列表刷新时用户看到的
|
|
163
|
+
* 该是刚刚那一次使用,而不是最多 60 秒之前的旧数(落盘只是持久化,不是真相本身)。
|
|
164
|
+
*/
|
|
165
|
+
function publicRecord(record) {
|
|
166
|
+
return {
|
|
167
|
+
id: record.id,
|
|
168
|
+
name: record.name,
|
|
169
|
+
kind: record.kind,
|
|
170
|
+
createdAt: record.createdAt,
|
|
171
|
+
lastSeen: Math.max(record.lastSeen, pendingSeen.get(record.id) ?? 0),
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* 落盘。**照 lib/store.js:386-392 那一段原子写**(先写临时文件、再改名过去):
|
|
177
|
+
* 直接往目标文件上写,写到一半断电就留下一份半截 JSON,名册里那几台设备一起没了。
|
|
178
|
+
*
|
|
179
|
+
* `mode: 0o600`:名册里有密钥的哈希,不该给别人看(Windows 上这条基本被忽略,
|
|
180
|
+
* 留着的意义是别的系统上一致)。
|
|
181
|
+
*/
|
|
182
|
+
function save() {
|
|
183
|
+
try {
|
|
184
|
+
mkdirSync(dirname(file), { recursive: true })
|
|
185
|
+
const tmp = `${file}.tmp`
|
|
186
|
+
writeFileSync(tmp, `${JSON.stringify({ version: 1, devices: table }, null, 2)}\n`, { mode: 0o600 })
|
|
187
|
+
renameSync(tmp, file)
|
|
188
|
+
} catch {
|
|
189
|
+
// 落盘失败不影响本次运行——内存里那台设备这一轮照常能用。
|
|
190
|
+
}
|
|
191
|
+
stamp = fileTag()
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* 把攒着的 `lastSeen` 写进磁盘(stamp 上写的那个策略的落点)。
|
|
196
|
+
*
|
|
197
|
+
* 三条细节都是有意的:
|
|
198
|
+
* · **先对一次磁盘再写**:这中间可能有人撤销了一台设备,拿旧表去写等于把它复活;
|
|
199
|
+
* · 记录已经不在了 → 这条时间戳跟着它一起扔掉(撤销就是撤销,别留半条尾巴);
|
|
200
|
+
* · 只往**更新**的方向改(`at > hit.lastSeen`):万一另一个进程也写了这份文件,
|
|
201
|
+
* 不让这次落盘把时间戳往回拨。
|
|
202
|
+
*/
|
|
203
|
+
function flushSeen() {
|
|
204
|
+
if (!pendingSeen.size) {
|
|
205
|
+
seenFlushedAt = Date.now()
|
|
206
|
+
return
|
|
207
|
+
}
|
|
208
|
+
syncFromDisk()
|
|
209
|
+
for (const [id, at] of pendingSeen) {
|
|
210
|
+
const hit = table.find((d) => d.id === id)
|
|
211
|
+
if (hit && at > hit.lastSeen) hit.lastSeen = at
|
|
212
|
+
}
|
|
213
|
+
pendingSeen.clear()
|
|
214
|
+
save()
|
|
215
|
+
seenFlushedAt = Date.now()
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return {
|
|
219
|
+
/**
|
|
220
|
+
* 登记一台新设备,返回它的凭据(`id.密钥`)。
|
|
221
|
+
*
|
|
222
|
+
* **密钥只在这一刻存在服务器手里**:落盘的只有它的 SHA-256,返回值也没有第二次。
|
|
223
|
+
*
|
|
224
|
+
* @param {{name?: string, kind?: string}} [opts] 名字只是标签,认人从不看它;
|
|
225
|
+
* `kind` 不给就是 `'phone'`(手机页那两处调用点一个字都不用改)
|
|
226
|
+
* @returns {{id: string, name: string, credential: string}}
|
|
227
|
+
*/
|
|
228
|
+
register({ name, kind } = {}) {
|
|
229
|
+
syncFromDisk()
|
|
230
|
+
const id = randomBytes(ID_BYTES).toString('hex')
|
|
231
|
+
const key = randomBytes(KEY_BYTES).toString('hex')
|
|
232
|
+
const label = typeof name === 'string' && name.trim() ? name.trim().slice(0, NAME_MAX) : '新设备'
|
|
233
|
+
table.push({
|
|
234
|
+
id,
|
|
235
|
+
name: label,
|
|
236
|
+
// 缺省 'phone'(**不传还是手机**,手机页那两处调用点照旧):跨机那一条由
|
|
237
|
+
// 「配对」接口传 'desktop' 进来(见 lib/index.js 的 /mini-remote/pair)。
|
|
238
|
+
// 判断口径和 readFromDisk 那条一模一样:空值/非字符串一律当 phone。
|
|
239
|
+
// (桌面端「远程窗口」那张票是另一套(见 lib/mirror.js),不进这份名单:
|
|
240
|
+
// 它是一次通行证、寿命跟着进程。)
|
|
241
|
+
kind: typeof kind === 'string' && kind ? kind : 'phone',
|
|
242
|
+
keyHash: sha256(key).toString('hex'),
|
|
243
|
+
createdAt: Date.now(),
|
|
244
|
+
// 登记发生在**用户真的打开了一次手机页**那一下(见 lib/server.js 那两处调用点),
|
|
245
|
+
// 所以这一刻本身就算一次使用——列表上刚扫完码的那台设备不该显示成"从未使用"。
|
|
246
|
+
lastSeen: Date.now(),
|
|
247
|
+
})
|
|
248
|
+
save()
|
|
249
|
+
return { id, name: label, credential: `${id}.${key}` }
|
|
250
|
+
},
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* 认一枚凭据:对得上就回那条记录(副本),否则 null。
|
|
254
|
+
*
|
|
255
|
+
* **每次都现查磁盘那份**(见 syncFromDisk):记录被删掉之后,同一台设备的下一次
|
|
256
|
+
* 请求就认不出来了,不必重启、也没有缓存窗口。
|
|
257
|
+
*
|
|
258
|
+
* **认人就是认人**:这里一个字都不写盘("这台设备刚露过面"由调用方显式调 touch,
|
|
259
|
+
* 见下面那个的说明)。
|
|
260
|
+
*/
|
|
261
|
+
verify(credential) {
|
|
262
|
+
if (!credentialLooksLikeOurs(credential)) return null
|
|
263
|
+
const dot = credential.indexOf('.')
|
|
264
|
+
const id = credential.slice(0, dot)
|
|
265
|
+
const key = credential.slice(dot + 1)
|
|
266
|
+
syncFromDisk()
|
|
267
|
+
const hit = table.find((d) => d.id === id)
|
|
268
|
+
if (!hit) return null
|
|
269
|
+
const want = Buffer.from(hit.keyHash, 'hex')
|
|
270
|
+
const got = sha256(key)
|
|
271
|
+
// 定长比较(和令牌那边同一条理由):别让人拿响应时间逐字节试出密钥。
|
|
272
|
+
if (want.length !== got.length || !timingSafeEqual(want, got)) return null
|
|
273
|
+
return { ...hit }
|
|
274
|
+
},
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* 名册里**还有没有这条记录**(按 id)。
|
|
278
|
+
*
|
|
279
|
+
* **每次都现查磁盘那份**(见 syncFromDisk),和 verify() 同一条口径:撤销是
|
|
280
|
+
* 「删掉那条记录」,删掉之后这里立刻回 false——不必重启,也没有缓存窗口。
|
|
281
|
+
*
|
|
282
|
+
* 为什么不用 verify() 顶这一条:会话里带的是 **id**,不是那枚凭据。凭据能认人
|
|
283
|
+
* (还能进 `/mini/api/*` 全部接口),而窗口会话只够进镜像那一层——把凭据写进
|
|
284
|
+
* 会话等于顺手给窗口升了一次权,所以这里只问「记录还在不在」。
|
|
285
|
+
*
|
|
286
|
+
* @param {string} id
|
|
287
|
+
* @returns {boolean}
|
|
288
|
+
*/
|
|
289
|
+
has(id) {
|
|
290
|
+
if (typeof id !== 'string' || !id) return false
|
|
291
|
+
syncFromDisk()
|
|
292
|
+
return table.some((d) => d.id === id)
|
|
293
|
+
},
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* 名册(**不含 keyHash**),最近登记的排在前面——用户刚加的那台就在眼皮底下。
|
|
297
|
+
*
|
|
298
|
+
* `lastSeen` 在这里就并上了还没落盘的那一份(见 publicRecord),所以列表上看到的
|
|
299
|
+
* 永远是"此刻最新的"那个数,而不是磁盘上那个可能落后一分钟的。
|
|
300
|
+
*
|
|
301
|
+
* @returns {{id:string,name:string,kind:string,createdAt:number,lastSeen:number}[]}
|
|
302
|
+
*/
|
|
303
|
+
list() {
|
|
304
|
+
syncFromDisk()
|
|
305
|
+
return table
|
|
306
|
+
.map(publicRecord)
|
|
307
|
+
.sort((a, b) => (b.createdAt - a.createdAt) || (a.id < b.id ? -1 : 1))
|
|
308
|
+
},
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* 改名字。**只改名字**:认人从不看它(见文件开头),改了不影响那台设备还进不进得来。
|
|
312
|
+
*
|
|
313
|
+
* @returns {{ok:true, device:object} | {ok:false, error:string}}
|
|
314
|
+
*/
|
|
315
|
+
rename(id, name) {
|
|
316
|
+
const label = typeof name === 'string' ? name.trim() : ''
|
|
317
|
+
if (!label) return { ok: false, error: '名字不能是空的。' }
|
|
318
|
+
if (label.length > NAME_MAX) return { ok: false, error: `名字最多 ${NAME_MAX} 个字。` }
|
|
319
|
+
syncFromDisk()
|
|
320
|
+
const hit = table.find((d) => d.id === id)
|
|
321
|
+
// 找不到就说找不到:界面据此让用户刷新一下(多半是另一个标签页刚撤销了它)。
|
|
322
|
+
if (!hit) return { ok: false, error: '这台设备已经不在名册里了,刷新看看。' }
|
|
323
|
+
hit.name = label
|
|
324
|
+
save()
|
|
325
|
+
return { ok: true, device: publicRecord(hit) }
|
|
326
|
+
},
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* 撤销一台设备:**删掉那条记录**。
|
|
330
|
+
*
|
|
331
|
+
* 删掉之后那枚凭据什么都换不来了(认人现查这份表),但**这一层只负责名册**——
|
|
332
|
+
* 「它手上那条活连接」不归这儿管,由 lib/server.js 的 dropDeviceConnections
|
|
333
|
+
* 掐掉(只靠"下次请求被拒",用户会以为撤销没生效)。
|
|
334
|
+
*
|
|
335
|
+
* 攒着的那条 `lastSeen` 一并扔掉——不然下次落盘会拿着一条已经删掉的 id 去找记录,
|
|
336
|
+
* 白跑一趟(flushSeen 里那条"记录不在了就扔掉"就是给这一条兜底的)。
|
|
337
|
+
*
|
|
338
|
+
* @returns {boolean} 真的删掉了一条
|
|
339
|
+
*/
|
|
340
|
+
remove(id) {
|
|
341
|
+
syncFromDisk()
|
|
342
|
+
const at = table.findIndex((d) => d.id === id)
|
|
343
|
+
if (at < 0) return false
|
|
344
|
+
table.splice(at, 1)
|
|
345
|
+
pendingSeen.delete(id)
|
|
346
|
+
save()
|
|
347
|
+
return true
|
|
348
|
+
},
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* 「这台设备刚露过面」——`lastSeen` 的**唯一**写入点。
|
|
352
|
+
*
|
|
353
|
+
* **不在 verify() 里顺手做**,理由是可读性:认人是认人,记时间是记时间,
|
|
354
|
+
* 两件事分开写,才不会出现「某个认人的路径悄悄漏掉了时间戳」或者反过来。
|
|
355
|
+
*
|
|
356
|
+
* 节流(用户拍板的策略,写在文件头 `SEEN_FLUSH_MS` 那一段):
|
|
357
|
+
* · 时间戳先攒在内存里(每台设备只留最新那一次,不排队);
|
|
358
|
+
* · **最多每 60 秒落一次盘**——所以磁盘上那个数最多比真实情况早一分钟;
|
|
359
|
+
* · 第一次触达(`seenFlushedAt === 0`)当场就落一次,免得"刚用过"这台设备
|
|
360
|
+
* 在一分钟之内看着像从未用过。
|
|
361
|
+
* · **崩溃丢多少**:最多丢掉最近 60 秒内的那几次使用记录。设备能不能进门
|
|
362
|
+
* **一点不受影响**(认人查名册本身,不查时间戳)。
|
|
363
|
+
*/
|
|
364
|
+
touch(id) {
|
|
365
|
+
if (typeof id !== 'string' || !id) return
|
|
366
|
+
const now = Date.now()
|
|
367
|
+
pendingSeen.set(id, now)
|
|
368
|
+
if (now - seenFlushedAt < SEEN_FLUSH_MS) return
|
|
369
|
+
flushSeen()
|
|
370
|
+
},
|
|
371
|
+
}
|
|
372
|
+
}
|