dsh-mobile-plugin 0.1.100 → 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.
Files changed (59) hide show
  1. package/README.md +14 -5
  2. package/dsh-mobile-credentials/lib/credentials.js +405 -84
  3. package/lib/client.js +31 -8
  4. package/lib/http/routing.js +165 -9
  5. package/lib/impl.js +88 -9
  6. package/lib/index.js +3 -2
  7. package/lib/mirror/pusher.cjs +35 -4
  8. package/lib/mobile/approvals.js +62 -15
  9. package/lib/mobile/auth.js +46 -26
  10. package/lib/mobile/credential-upgrade.js +137 -0
  11. package/lib/mobile/entry-window.js +32 -0
  12. package/lib/mobile/events.js +14 -4
  13. package/lib/mobile/extensions.js +279 -0
  14. package/lib/mobile/history-derive.js +209 -11
  15. package/lib/mobile/host-bridge/event-synthesis.js +61 -10
  16. package/lib/mobile/pairing.js +150 -64
  17. package/lib/mobile/plugin-self.js +187 -0
  18. package/lib/mobile/push-notify-route.js +37 -0
  19. package/lib/mobile/push-notify.js +84 -0
  20. package/lib/mobile/questions.js +75 -10
  21. package/lib/mobile/relay-control.js +547 -0
  22. package/lib/mobile/session-cache.js +60 -2
  23. package/lib/mobile/session-file.js +133 -34
  24. package/lib/mobile/session-list-shortcut.js +38 -0
  25. package/lib/mobile/session-model-options.js +3 -1
  26. package/lib/mobile/session-models.js +15 -3
  27. package/lib/mobile/session-send.js +30 -27
  28. package/lib/mobile/session-snapshot.js +12 -12
  29. package/lib/mobile/sessions.js +20 -0
  30. package/lib/mobile/stats-collect.cjs +55 -12
  31. package/lib/mobile/stats-ledger.cjs +140 -21
  32. package/lib/mobile/stream-registry.js +73 -0
  33. package/lib/mobile/stream.js +382 -31
  34. package/lib/mobile/subagents.js +11 -2
  35. package/lib/mobile/uploads.js +91 -5
  36. package/lib/mobile/workspaces.js +39 -8
  37. package/lib/panel/index.html +11 -11
  38. package/lib/relay/core.js +28 -2
  39. package/lib/relay/lan-network.cjs +47 -0
  40. package/lib/relay/lan-proxy.js +283 -122
  41. package/lib/relay/protocol.js +4 -2
  42. package/lib/relay/tunnel.js +20 -14
  43. package/lib/routes/control-plane.js +139 -9
  44. package/lib/routes/mobile-api.js +92 -9
  45. package/lib/shared/e2ee-pair.js +91 -0
  46. package/lib/shared/e2ee-plain.js +79 -0
  47. package/lib/shared/e2ee-request.js +331 -0
  48. package/lib/shared/e2ee.js +173 -0
  49. package/lib/shared/http-util.js +27 -4
  50. package/lib/shared/list-signal.js +16 -0
  51. package/lib/shared/pair-throttle.js +150 -0
  52. package/lib/shared/sse-write.js +16 -5
  53. package/lib/shared/via-header.js +65 -0
  54. package/lib/shell/runtime.js +147 -42
  55. package/lib/shell/self-check-after-swap.js +93 -0
  56. package/lib/shell/updater.js +44 -3
  57. package/lib/sidecar-facade-worker.cjs +70 -6
  58. package/lib/update-install.cjs +13 -5
  59. package/package.json +3 -2
package/README.md CHANGED
@@ -133,12 +133,21 @@ npm test
133
133
 
134
134
  ### 发版流程
135
135
 
136
- 1. 跑 `node scripts/bump-version.mjs` 更新版本号。
136
+ **顺序是死的:抬号重建 → 提交 → 预检全绿 → 同步 → 打 tag → 等流水线。** 预检没过不许往下走,尤其不许「先把 tag 推上去看看」。
137
+
138
+ 1. 跑 `node scripts/bump-version.mjs` 更新版本号(演示页的 `DEMO_VERSION` 会一起改,别手改)。
137
139
  2. 改过 `React/` 就跑 `cd React && pnpm run build`。
138
- 3. 跑 `npm test`,再用 `npm pack --dry-run` 看一眼要发出去的文件。
139
- 4. 提交,把改动同步到公开仓库 `WNLUO/dsh-mobile-plugin`。
140
- 5. 推一个与版本号同名的 tag(版本 0.1.99 就推 `v0.1.99`)。那个仓库的 `.github/workflows/publish.yml` 会跑测试并直接发布,不需要人在浏览器里点二次验证 —— 它走 npm 的 trusted publishing(OIDC),npm 认的是这条流水线而不是某个令牌。
141
- 6. 等流水线绿。它末尾自己会核 `npm view dsh-mobile-plugin version`,你也可以手动看一眼。
140
+ 3. 提交这次发版该带的文件,别 `git add -A`。
141
+ 4. 在**主仓**的插件目录跑 `npm run release:check`。它按固定顺序查六件事:是不是在主仓里(跨仓对账要读 `app/`)、版本号与 tag(三段 `0..999`、这个 tag 没打过、不比上一版低)、工作区干不干净、面板产物跟不跟得上源码(拿当前源码真重建一次,与盘上那份逐字节比)、整套测试、真打一次包看文件清单。任何一步红就停,并说明该修哪里。**它要求在提交之后跑**,因为查的是「发出去的那一份」。
142
+ 5. 把提交同步到公开仓库 `WNLUO/dsh-mobile-plugin`。
143
+ 6. 推一个与版本号同名的 tag(版本 0.1.99 就推 `v0.1.99`)。那个仓库的 `.github/workflows/publish.yml` 会跑测试并直接发布,不需要人在浏览器里点二次验证 —— 它走 npm 的 trusted publishing(OIDC),npm 认的是这条流水线而不是某个令牌。
144
+ 7. 等流水线绿。它末尾自己会核 `npm view dsh-mobile-plugin version`,你也可以手动看一眼。
145
+ 8. 要在本机用上新版就跑 `node scripts/reload-dev.mjs`(打包 + 正规安装 + 就地重载),它报 ✔ 才算数。
146
+
147
+ 两条别弄反:
148
+
149
+ - **流水线是第二道闸,不是唯一一道。** 它在单独检出里跑,跨仓对账(手机端接口常量 ↔ 插件路由表)会自己跳过;面板产物它只查「在不在包里」,查不出新旧。这两件事只有第 4 步能查。
150
+ - **热重载报 ✔ 不算测过。** 它把当前工作区装进 profile 跑一遍,不跑测试、不打包,也碰不到任何跨版本组合(老 App 配新插件之类)。它证明的是装载没问题,不是这一版能发。
142
151
 
143
152
  `npm publish` 仍能在开发机上手动跑,但现在要人过二次验证:账号上只有安全密钥时,npm 会打印一条 `www.npmjs.com/auth/cli/…` 链接,要用密钥在浏览器里点一下,且发起那台机器的出口 IP 要能打开 npm 官网。这条路留着应急,日常发版走 tag。
144
153
 
@@ -1,138 +1,441 @@
1
1
  'use strict';
2
2
 
3
- /** Persistent per-device relay credentials. The bearer secret is returned once;
4
- * only its hash is stored on disk. The host token is retained so a durable
5
- * relay credential can continue to authenticate legacy 30-day clients. */
3
+ /**
4
+ * 每设备凭据库:签发、校验、吊销手机端的持久化凭据。
5
+ *
6
+ * **凭据本身就是 Bearer**(讨论记录 §3.2 的 T1 口径)。配对签发一枚
7
+ * `<instanceId>.<exp>.<random>`,同一枚同时充当 `Authorization: Bearer`、`dsh_lan` cookie
8
+ * 与 `X-Dsh-Device-Credential`。三段形状只为兼容老 App「实例号缺失时从第一段补」那条逻辑;
9
+ * 服务端**只按整串的 sha256 查库**,不看前缀、不解析三段、也不信里面的 `exp`
10
+ * (以记录的 `expiresAt` 为准)。
11
+ *
12
+ * 落盘只存整串的 sha256。**唯一的例外是 `deviceKey`**:协议 v2 的逐请求签名要拿它重算,
13
+ * 验签必须持有密钥本身,所以它按 base64url 原文落盘 —— 「整库只有哈希」这句话在它身上不成立。
14
+ * `hostToken` 只为旧的 HMAC 记录保留:新记录一律留空,代理直接转发请求里出示的那一枚。
15
+ *
16
+ * 写入只有一个方向(§3.7):宿主主进程是唯一写方;旁路子进程拿的是只读副本,靠文件
17
+ * mtime/size 变了就重载来跟住变化,签发与吊销走 IPC 交给主进程(`lib/impl.js`)。
18
+ */
6
19
  const crypto = require('node:crypto');
7
20
  const fs = require('node:fs');
8
21
  const path = require('node:path');
9
22
  const os = require('node:os');
10
23
 
11
24
  const FILE = process.env.DEVICE_CREDENTIALS_FILE || path.join(process.env.DSH_HOME || path.join(os.homedir(), '.dsh'), 'relay-device-credentials.json');
12
- /** Default credential lifetime. `issue(..., ttlMs)` may override; `ttlMs <= 0` opts out. */
25
+ /** 默认有效期一年。`issue(..., ttlMs)` 可覆盖;`ttlMs <= 0` 表示不过期。 */
13
26
  const DEFAULT_TTL_MS = 365 * 24 * 3600 * 1000;
27
+ /** 已吊销或过期超过这么久的记录,下次写入时整条删掉(§3.9):别让每条请求扫一整年的账。 */
28
+ const PRUNE_GRACE_MS = 30 * 24 * 3600 * 1000;
29
+ /** 剩余有效期超过这个比例就不续期:滑动续期只在过掉一半之后发生。 */
30
+ const RENEW_BELOW_RATIO = 0.5;
31
+ /** 设备元信息的落盘截断长度。名字 64 够放「某某的 iPhone 15 Pro Max」;其余是系统版本、 */
32
+ /** App 版本这类短标识,来源是手机自填,长度不可信。 */
33
+ const NAME_MAX = 64;
34
+ const FIELD_MAX = 32;
35
+ /** 一份镜像最多带这么多条摘要:长期反复配对不该把推送清单撑成累赘。 */
36
+ const MAX_ACTIVE_DIGESTS = 200;
37
+ /** `lastSeenAt` 落盘的全局节流。 */
38
+ const TOUCH_FLUSH_MS = 60 * 1000;
39
+
14
40
  let loaded = false;
15
41
  let records = [];
42
+ /** `hash -> record`:每次整体替换,跟着 `records` 一起重建。 */
43
+ let byHash = new Map();
44
+ /** 上次读到的文件指纹(mtime:size)。 */
45
+ let stamp = null;
46
+ /** 只读副本:不写盘、不续期,改动只活在内存里直到下一次重载。 */
47
+ let readOnly = false;
48
+ let lastTouchFlushAt = 0;
49
+
16
50
  function digest(value) { return crypto.createHash('sha256').update(String(value)).digest('hex'); }
17
- /** Constant-time hash comparison (both sides are 64-char hex). */
51
+
52
+ /** 恒时比较两份摘要。两边都是 64 字符 hex,长度一致,能用 timingSafeEqual。 */
18
53
  function hashEquals(a, b) {
19
54
  const left = Buffer.from(String(a), 'utf8');
20
55
  const right = Buffer.from(String(b), 'utf8');
21
56
  if (left.length !== right.length) return false;
22
57
  return crypto.timingSafeEqual(left, right);
23
58
  }
59
+
60
+ /** 文件指纹。读不到就是 null(还不存在 / 刚被删)。 */
61
+ function statOf() {
62
+ try {
63
+ const stat = fs.statSync(FILE);
64
+ return `${stat.mtimeMs}:${stat.size}`;
65
+ } catch {
66
+ return null;
67
+ }
68
+ }
69
+
70
+ function rebuildIndex() {
71
+ byHash = new Map();
72
+ for (const record of records) byHash.set(record.hash, record);
73
+ }
74
+
75
+ function readFromDisk() {
76
+ try {
77
+ const data = JSON.parse(fs.readFileSync(FILE, 'utf8'));
78
+ records = Array.isArray(data)
79
+ ? data.filter((record) => record && typeof record === 'object' && typeof record.hash === 'string' && record.hash)
80
+ : [];
81
+ } catch {
82
+ records = [];
83
+ }
84
+ stamp = statOf();
85
+ rebuildIndex();
86
+ }
87
+
88
+ /** 第一次用到时读一次盘。之后是否重读由 `refresh()` 按文件指纹决定。 */
24
89
  function load() {
25
90
  if (loaded) return;
26
91
  loaded = true;
27
- try { const data = JSON.parse(fs.readFileSync(FILE, 'utf8')); records = Array.isArray(data) ? data : []; } catch { records = []; }
92
+ readFromDisk();
28
93
  }
94
+
29
95
  function save() {
96
+ if (readOnly) return;
30
97
  fs.mkdirSync(path.dirname(FILE), { recursive: true });
31
98
  const tmp = `${FILE}.tmp`;
32
99
  fs.writeFileSync(tmp, JSON.stringify(records, null, 2), { mode: 0o600 });
33
100
  fs.renameSync(tmp, FILE);
101
+ stamp = statOf();
102
+ }
103
+
104
+ /**
105
+ * 只读副本按文件指纹重载。
106
+ *
107
+ * 这是**缓存失效而不是防御**:它替代的是「每条请求都跨进程问一次主进程」,而鉴权是热路径
108
+ * (每台手机每秒好几个请求),跨进程问比一次 stat 贵得多。指纹没变就一个系统调用都不做。
109
+ * 主进程(唯一写方)调它也没坏处:自己刚写完指纹就是新的,立刻返回。
110
+ */
111
+ function refresh() {
112
+ load();
113
+ const now = statOf();
114
+ if (now === stamp) return false;
115
+ readFromDisk();
116
+ return true;
117
+ }
118
+
119
+ /** 切只读副本。只影响写盘与续期,读路径不变。 */
120
+ function setReadOnly(flag) { readOnly = flag !== false; }
121
+
122
+ function isReadOnly() { return readOnly; }
123
+
124
+ function truncate(value, max) {
125
+ if (typeof value !== 'string') return '';
126
+ const text = value.trim();
127
+ return text ? text.slice(0, max) : '';
128
+ }
129
+
130
+ /** 设备自填的元信息:只留这几个,各自截断。空值不写,面板据此显示「未知设备」。 */
131
+ function deviceMeta(device) {
132
+ const meta = {};
133
+ const name = truncate(device?.name, NAME_MAX);
134
+ if (name) meta.name = name;
135
+ const platform = truncate(device?.platform, FIELD_MAX);
136
+ if (platform) meta.platform = platform;
137
+ const osVersion = truncate(device?.osVersion ?? device?.version, FIELD_MAX);
138
+ if (osVersion) meta.osVersion = osVersion;
139
+ const appVersion = truncate(device?.appVersion, FIELD_MAX);
140
+ if (appVersion) meta.appVersion = appVersion;
141
+ return meta;
34
142
  }
35
- function issue(instanceId, device, hostToken, ttlMs = DEFAULT_TTL_MS) {
143
+
144
+ /**
145
+ * 已吊销、或过期超过宽限期的整条删掉(§3.9)。
146
+ *
147
+ * 旧库只追加、吊销只打标记,今天不疼是因为「查不到就回退验 HMAC」把问题掩盖成了可用;
148
+ * 查库优先之后,每条请求都要扫整张表,一年下来几千条就是每条请求扫几千次。
149
+ */
150
+ function prune(now = Date.now()) {
151
+ const before = records.length;
152
+ records = records.filter((record) => {
153
+ if (record.revokedAt && now - record.revokedAt > PRUNE_GRACE_MS) return false;
154
+ if (record.expiresAt && record.expiresAt + PRUNE_GRACE_MS < now) return false;
155
+ return true;
156
+ });
157
+ if (records.length !== before) rebuildIndex();
158
+ return before - records.length;
159
+ }
160
+
161
+ /**
162
+ * 签发一条记录。
163
+ *
164
+ * `extra` 带协议 v2 的密钥材料(`deviceKey` / `keyId`)。老形状(只带 6 位码)没有密钥材料,
165
+ * 那种记录不带 `deviceKey`,设备列表按「旧式」显示、撤销文案也不同。
166
+ *
167
+ * 返回的 `credential` 是**整串 Bearer**:同一枚同时进 `token` / `cookie` / `deviceCredential`。
168
+ * `hostToken` 形参只为兼容旧调用方保留,新记录一律不存。
169
+ */
170
+ function issue(instanceId, device, hostToken, ttlMs = DEFAULT_TTL_MS, extra = {}) {
36
171
  load();
172
+ // 只读副本不许签发:它写在内存里的那条既不会落盘、也会被下一次重载抹掉,于是
173
+ // 「配上了但立刻查不到」这种半截状态只会在真机联调时以随机 401 的形式出现。
174
+ // 旁路子进程要签发走 IPC 交给主进程(`lib/impl.js` 的 `/internal/issue-credential`)。
175
+ if (readOnly) {
176
+ const error = new Error('凭据库的只读副本不能签发;旁路子进程请经 IPC 交给主进程');
177
+ error.code = 'READ_ONLY';
178
+ throw error;
179
+ }
37
180
  const deviceId = String(device?.deviceId || device?.id || crypto.randomUUID()).slice(0, 128);
38
- const credential = `dshc_${crypto.randomBytes(32).toString('base64url')}`;
181
+ const random = crypto.randomBytes(32).toString('base64url');
39
182
  const now = Date.now();
40
- // A host instance may be paired with multiple phone installations.
41
- // deviceId is client metadata and can collide after app-data migration or
42
- // cloning, so it must not be used as a uniqueness constraint for credentials.
43
- const record = { instanceId, deviceId, hash: digest(credential), hostToken: typeof hostToken === 'string' ? hostToken : '', createdAt: now };
44
- // `find()` has always honoured `expiresAt`, but nothing ever set it — so every
45
- // credential was permanent and "revoke" only marked records that the cookie
46
- // path never consulted. Issue an explicit expiry by default.
47
- if (Number.isFinite(ttlMs) && ttlMs > 0) record.expiresAt = now + ttlMs;
183
+ const expiresAt = Number.isFinite(ttlMs) && ttlMs > 0 ? now + ttlMs : null;
184
+ const credential = `${instanceId}.${expiresAt === null ? 0 : Math.floor(expiresAt / 1000)}.${random}`;
185
+ const record = {
186
+ instanceId,
187
+ deviceId,
188
+ hash: digest(credential),
189
+ hostToken: typeof hostToken === 'string' ? hostToken : '',
190
+ createdAt: now,
191
+ ...(expiresAt !== null ? { expiresAt } : {}),
192
+ format: 't1',
193
+ ...deviceMeta(device),
194
+ };
195
+ const deviceKey = typeof extra?.deviceKey === 'string' ? extra.deviceKey : '';
196
+ const keyId = typeof extra?.keyId === 'string' ? extra.keyId : '';
197
+ if (deviceKey) record.deviceKey = deviceKey;
198
+ if (keyId) record.keyId = keyId;
199
+ prune(now);
48
200
  records.push(record);
201
+ rebuildIndex();
49
202
  save();
50
- return { credential, deviceId, expiresAt: record.expiresAt || null };
51
- }
52
- function find(credential, instanceId) {
53
- load();
54
- if (typeof credential !== 'string' || !credential) return null;
55
- const hash = digest(credential);
56
- const now = Date.now();
57
- let match = null;
58
- // Scan every record: an early-exit `Array.find` would leak the matching
59
- // position through response timing.
60
- for (const record of records) {
61
- const same = hashEquals(record.hash, hash);
62
- if (same && record.instanceId === instanceId && isLive(record, now)) match = record;
63
- }
64
- return match;
203
+ // 刚写过盘,所以接下来一分钟内的 `touch` 不必再写一次(节流基准跟着这次写走)。
204
+ lastTouchFlushAt = now;
205
+ return { credential, deviceId, expiresAt, ...(keyId ? { keyId } : {}) };
65
206
  }
207
+
66
208
  /**
67
- * Look up a credential this store issued, whether or not it is still live.
209
+ * 查记录(不看是否有效)。
68
210
  *
69
- * `find()` answers "may this caller proceed?". This answers "did we ever issue
70
- * this here?" — and the difference is load-bearing. The desktop host and the
71
- * Relay keep separate credential stores, so a phone paired over the public
72
- * Relay presents a `dshc_` the host has never seen. That one must fall back to
73
- * its stateless token instead of being rejected. A `dshc_` the host *did* issue
74
- * must instead be authoritative, so that revoking it takes effect immediately.
211
+ * `find()` 回答「能不能放行」,这个回答「我们是不是签发过」。区别是承重的:桌面宿主与中转
212
+ * 各有自己的库,公网配对时凭据由中转签、宿主没见过,那枚必须回退到无状态令牌;而宿主
213
+ * **确实签发过**的凭据必须以记录为准,否则吊销永远不生效。
75
214
  */
76
215
  function lookup(credential, instanceId) {
77
- load();
216
+ // **必须 refresh,不能只 load**:旁路子进程持的是只读副本,`load()` 一生只读一次盘,
217
+ // 于是「子进程起来之后才配对的设备」在这条路上永远查不到 —— 表现是局域网口一律 401
218
+ // (普通请求与 Metro 的预览通道都过不去),App 只能退回公网中转。文件指纹变了才重读,
219
+ // 稳态下只多一次 stat。
220
+ refresh();
78
221
  if (typeof credential !== 'string' || !credential) return null;
79
- const hash = digest(credential);
80
- let match = null;
81
- for (const record of records) {
82
- const same = hashEquals(record.hash, hash);
83
- if (same && record.instanceId === instanceId) match = record;
84
- }
85
- return match;
222
+ const record = byHash.get(digest(credential));
223
+ if (!record) return null;
224
+ if (instanceId && record.instanceId !== instanceId) return null;
225
+ return record;
226
+ }
227
+
228
+ /** 查一条仍然有效的记录。 */
229
+ function find(credential, instanceId) {
230
+ const record = lookup(credential, instanceId);
231
+ return record && isLive(record) ? record : null;
86
232
  }
87
- /** True when a record is neither revoked nor past its expiry. */
233
+
234
+ /** 记录是否既没被吊销也没过期。 */
88
235
  function isLive(record, now = Date.now()) {
89
236
  return Boolean(record) && !record.revokedAt && (!record.expiresAt || record.expiresAt > now);
90
237
  }
238
+
91
239
  /**
92
- * Look up the newest live credential issued to a device.
240
+ * 按密钥标识找设备密钥(协议 v2 的验签入口)。
93
241
  *
94
- * Pairing cookies carry a deviceId instead of the bearer secret, so this is the
95
- * only way for the Relay to notice that a device was revoked (or that its
96
- * credential expired) without the phone having to present the secret itself.
242
+ * **必须按 keyId,不能按 deviceId**:deviceId 是手机自填的,克隆/迁移后会撞,撞了就是
243
+ * 「两台手机能用同一把密钥验签」。keyId 是配对时宿主生成的 8 字节随机,不会撞。
97
244
  */
98
- function isActiveDevice(instanceId, deviceId) {
99
- load();
100
- if (typeof deviceId !== 'string' || !deviceId) return null;
245
+ function findByKeyId(instanceId, keyId) {
246
+ refresh();
247
+ if (typeof keyId !== 'string' || !keyId) return null;
101
248
  const now = Date.now();
102
- let match = null;
103
- for (const record of records) {
104
- if (record.instanceId === instanceId && record.deviceId === deviceId && isLive(record, now)) match = record;
249
+ for (let i = records.length - 1; i >= 0; i -= 1) {
250
+ const record = records[i];
251
+ if (record.instanceId !== instanceId || record.keyId !== keyId) continue;
252
+ // 同一 keyId 只可能有一条(当场生成的随机),命中即终局:不 live 就按不认识处理,
253
+ // 别继续往上翻旧记录 —— 那等于让吊销过的 keyId 靠更老的一条复活。
254
+ return isLive(record, now) ? record : null;
105
255
  }
106
- return match;
256
+ return null;
107
257
  }
258
+
259
+ /**
260
+ * 记一次「这台设备刚连上过」(§3.4)。
261
+ *
262
+ * 内存里每次都更新(面板 3 秒轮询要的是当下的值),落盘按全局 1 分钟节流:每条请求都写一次
263
+ * 盘,是拿磁盘换一个只用来显示「几分钟前」的字段。`issue` / `revoke` 立刻写,不受它影响。
264
+ */
265
+ function touch(record, info = {}) {
266
+ load();
267
+ if (!record || typeof record !== 'object') return false;
268
+ const now = Date.now();
269
+ record.lastSeenAt = now;
270
+ const channel = typeof info.channel === 'string' ? info.channel : '';
271
+ if (channel) record.lastChannel = channel.slice(0, FIELD_MAX);
272
+ if (readOnly) return true;
273
+ if (now - lastTouchFlushAt < TOUCH_FLUSH_MS) return true;
274
+ lastTouchFlushAt = now;
275
+ save();
276
+ return true;
277
+ }
278
+
279
+ /**
280
+ * 滑动续期(§3.9):剩余不到一半就把 `expiresAt` 后推一个新 TTL。
281
+ *
282
+ * 不需要新协议 —— 服务端本来就以记录为准、不验 token 里的 `exp`,所以续期就是改这条记录。
283
+ * **不能改 token 本身**:库按整串哈希查,改一个字节手机上那枚就对不上了。
284
+ * 只读副本不续期:写盘是主进程的事,旁路续了会被主进程那份覆写掉。
285
+ */
286
+ function renew(record, ttlMs = DEFAULT_TTL_MS) {
287
+ load();
288
+ if (readOnly || !record || typeof record !== 'object' || !record.expiresAt) return false;
289
+ if (!Number.isFinite(ttlMs) || ttlMs <= 0) return false;
290
+ const now = Date.now();
291
+ if (record.expiresAt - now > ttlMs * RENEW_BELOW_RATIO) return false;
292
+ record.expiresAt = now + ttlMs;
293
+ prune(now);
294
+ save();
295
+ return true;
296
+ }
297
+
108
298
  function revoke(credential, instanceId) {
109
299
  load();
110
- const record = find(credential, instanceId);
111
- if (!record) return false;
300
+ // 只读副本吊销了也不落盘,下一次重载就「复活」—— 这种「看起来成功、其实没生效」
301
+ // 正是吊销最不能有的失败形态。旁路的吊销同样走 IPC 交给主进程。
302
+ if (readOnly) {
303
+ const error = new Error('凭据库的只读副本不能吊销;旁路子进程请经 IPC 交给主进程');
304
+ error.code = 'READ_ONLY';
305
+ throw error;
306
+ }
307
+ const record = lookup(credential, instanceId);
308
+ if (!record || record.revokedAt) return false;
112
309
  record.revokedAt = Date.now();
310
+ prune();
113
311
  save();
114
312
  return true;
115
313
  }
314
+
315
+ /** 吊销一台设备名下**所有**仍有效的记录:同一台手机重配几次就是几条。 */
316
+ function revokeDevice(instanceId, deviceId) {
317
+ load();
318
+ const now = Date.now();
319
+ let count = 0;
320
+ for (const record of records) {
321
+ if (record.instanceId !== instanceId || record.deviceId !== deviceId || record.revokedAt) continue;
322
+ record.revokedAt = now;
323
+ count += 1;
324
+ }
325
+ if (count && !readOnly) { prune(now); save(); }
326
+ return count;
327
+ }
328
+
329
+ /** 吊销本实例的全部记录。轮换 `lanSecret` 另算,那一刀专杀旧 HMAC。 */
330
+ function revokeAllByInstance(instanceId) {
331
+ load();
332
+ const now = Date.now();
333
+ let count = 0;
334
+ for (const record of records) {
335
+ if (record.instanceId !== instanceId || record.revokedAt) continue;
336
+ record.revokedAt = now;
337
+ count += 1;
338
+ }
339
+ if (count && !readOnly) { prune(now); save(); }
340
+ return count;
341
+ }
342
+
116
343
  function listActive() {
117
344
  load();
118
- return records.filter((record) => !record.revokedAt && (!record.expiresAt || record.expiresAt > Date.now())).map(({ instanceId, deviceId, createdAt, expiresAt }) => ({ instanceId, deviceId, createdAt, ...(expiresAt ? { expiresAt } : {}) }));
345
+ const now = Date.now();
346
+ return records
347
+ .filter((record) => isLive(record, now))
348
+ .map(({ instanceId, deviceId, createdAt, expiresAt }) => ({ instanceId, deviceId, createdAt, ...(expiresAt ? { expiresAt } : {}) }));
119
349
  }
120
- /** 一份镜像最多带这么多条摘要:长期反复配对不该把推送清单撑成累赘。 */
121
- const MAX_ACTIVE_DIGESTS = 200;
350
+
351
+ /**
352
+ * 面板要的那份设备清单(§3.4)。
353
+ *
354
+ * 按 `deviceId` 聚成一行:同一台手机重配几次不该在列表里重复出现。名字与平台取最新一条
355
+ * **有值的**记录(老记录这些字段是空的,空的不能覆盖有值的),绑定时间取最早,最近连接取
356
+ * 最大,走哪条路取最近那条的。
357
+ *
358
+ * `legacy` 标的是「这个 deviceId 下还有不带设备密钥的活记录」:那种凭据的吊销杀不掉旧
359
+ * Bearer,界面上的撤销文案必须区别对待,不能写「立即失效」。
360
+ *
361
+ * **不回 hash / hostToken / deviceKey**:那些是凭据与密钥材料,面板只是显示谁连着。
362
+ */
363
+ function listDevices(instanceId) {
364
+ load();
365
+ const now = Date.now();
366
+ const rows = new Map();
367
+ for (const record of records) {
368
+ if (record.instanceId !== instanceId || !isLive(record, now)) continue;
369
+ const key = String(record.deviceId || '');
370
+ const createdAt = Number(record.createdAt) || 0;
371
+ let row = rows.get(key);
372
+ if (!row) {
373
+ row = {
374
+ deviceId: key,
375
+ name: '',
376
+ platform: '',
377
+ osVersion: '',
378
+ appVersion: '',
379
+ boundAt: createdAt,
380
+ lastSeenAt: 0,
381
+ lastChannel: '',
382
+ lastSeenFrom: 0,
383
+ legacy: false,
384
+ encrypted: false,
385
+ expiresAt: 0,
386
+ };
387
+ rows.set(key, row);
388
+ }
389
+ if (createdAt && (!row.boundAt || createdAt < row.boundAt)) row.boundAt = createdAt;
390
+ // 名字与平台取最新一条有值的:老记录没有这些字段,空不能覆盖有值。
391
+ if (record.name && createdAt >= (row.nameAt || 0)) { row.name = record.name; row.nameAt = createdAt; }
392
+ if (record.platform && createdAt >= (row.platformAt || 0)) { row.platform = record.platform; row.platformAt = createdAt; }
393
+ if (record.osVersion && createdAt >= (row.osVersionAt || 0)) { row.osVersion = record.osVersion; row.osVersionAt = createdAt; }
394
+ if (record.appVersion && createdAt >= (row.appVersionAt || 0)) { row.appVersion = record.appVersion; row.appVersionAt = createdAt; }
395
+ const seenAt = Number(record.lastSeenAt) || 0;
396
+ if (seenAt >= row.lastSeenFrom) {
397
+ row.lastSeenFrom = seenAt;
398
+ row.lastSeenAt = seenAt;
399
+ if (record.lastChannel) row.lastChannel = record.lastChannel;
400
+ }
401
+ if (record.expiresAt) row.expiresAt = Math.max(row.expiresAt, Number(record.expiresAt) || 0);
402
+ if (!record.deviceKey) row.legacy = true;
403
+ // 带设备密钥的记录才走逐请求签名;只带 6 位码配对的那个版本永远是明文。
404
+ if (record.deviceKey) row.encrypted = true;
405
+ }
406
+ const out = [];
407
+ const labelCount = new Map();
408
+ for (const row of rows.values()) {
409
+ const label = `${row.name}\u0000${row.platform}`;
410
+ labelCount.set(label, (labelCount.get(label) || 0) + 1);
411
+ }
412
+ for (const row of rows.values()) {
413
+ const { nameAt, platformAt, osVersionAt, appVersionAt, lastSeenFrom, ...rest } = row;
414
+ // 同名同平台的两台手机在列表里长得一样,加 deviceId 后 4 位才分得清;名字本来就不重复的
415
+ // 不加,别让每行都挂一串十六进制。
416
+ const duplicated = Boolean(row.name) && (labelCount.get(`${row.name}\u0000${row.platform}`) || 0) > 1;
417
+ out.push({
418
+ ...rest,
419
+ name: duplicated ? `${row.name} · ${String(row.deviceId).slice(-4)}` : row.name,
420
+ });
421
+ }
422
+ // 最近连着的排前面;从没连过的按绑定时间倒序垫后。
423
+ out.sort((a, b) => (b.lastSeenAt - a.lastSeenAt) || (b.boundAt - a.boundAt));
424
+ return out;
425
+ }
426
+
122
427
  /**
123
428
  * 本实例签发过、仍然有效的凭据摘要,按签发时间从新到旧。
124
429
  *
125
- * 中转的离线镜像缓存要在桌面宿主上线之前判断「来读缓存的人是不是配对过的手机」,而它手上
126
- * 只有手机发来的 `dshc_` 原文 —— 这里给出同一口径的 sha256(落盘存的本来就是这个摘要,
127
- * 原文从未留存),它自己算一遍就能比对。吊销与过期在这里现读现滤,撤销一台设备后下一次
128
- * 推送就少一条。
430
+ * 中转的离线镜像要在桌面宿主上线之前判断「来读缓存的人是不是配对过的手机」,而它手上只有
431
+ * 手机发来的整串凭据 —— 这里给出同一口径的 sha256(落盘存的本来就是这个摘要),它自己算
432
+ * 一遍就能比对。吊销与过期在这里现读现滤。
129
433
  */
130
434
  function activeDigests(instanceId) {
131
435
  load();
132
436
  const now = Date.now();
133
437
  const live = [];
134
- // 记录只在新签发时追加,所以倒着走就是 createdAt 倒序:超出上限时被截掉的是最老的,
135
- // 同毫秒签发的几条也保持签发顺序而不是凭运气。
438
+ // 记录只在新签发时追加,倒着走就是 createdAt 倒序:超出上限时被截掉的是最老的。
136
439
  for (let i = records.length - 1; i >= 0 && live.length < MAX_ACTIVE_DIGESTS; i -= 1) {
137
440
  const record = records[i];
138
441
  if (record.instanceId !== instanceId || !isLive(record, now)) continue;
@@ -140,19 +443,37 @@ function activeDigests(instanceId) {
140
443
  }
141
444
  return live;
142
445
  }
143
- function revokeDevice(instanceId, deviceId) {
144
- load();
145
- let count = 0;
146
- for (const record of records) if (record.instanceId === instanceId && record.deviceId === deviceId && !record.revokedAt) { record.revokedAt = Date.now(); count += 1; }
147
- if (count) save();
148
- return count;
149
- }
150
- /** Revoke every live credential issued to a host instance (clear-all-authorization). */
151
- function revokeAllByInstance(instanceId) {
152
- load();
153
- let count = 0;
154
- for (const record of records) if (record.instanceId === instanceId && !record.revokedAt) { record.revokedAt = Date.now(); count += 1; }
155
- if (count) save();
156
- return count;
446
+
447
+ /** 测试用:重置内存态(不动盘)。 */
448
+ function resetForTest() {
449
+ loaded = false;
450
+ records = [];
451
+ byHash = new Map();
452
+ stamp = null;
453
+ readOnly = false;
454
+ lastTouchFlushAt = 0;
157
455
  }
158
- module.exports = { FILE, DEFAULT_TTL_MS, issue, find, lookup, isLive, isActiveDevice, activeDigests, revoke, revokeDevice, revokeAllByInstance, listActive };
456
+
457
+ module.exports = {
458
+ FILE,
459
+ DEFAULT_TTL_MS,
460
+ MAX_ACTIVE_DIGESTS,
461
+ prune,
462
+ issue,
463
+ find,
464
+ lookup,
465
+ isLive,
466
+ findByKeyId,
467
+ touch,
468
+ renew,
469
+ refresh,
470
+ setReadOnly,
471
+ isReadOnly,
472
+ listActive,
473
+ listDevices,
474
+ activeDigests,
475
+ revoke,
476
+ revokeDevice,
477
+ revokeAllByInstance,
478
+ resetForTest,
479
+ };