dsh-mobile-plugin 0.5.22 → 0.5.24

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.
@@ -22,7 +22,7 @@
22
22
  * - `{t:'event', kind:'needs-human'|'progress', sessionId, …}` —— 暂只记账,不转发
23
23
  *
24
24
  * 宿主 → 手机:
25
- * - `{t:'op', id, sessionId, action, args}`
25
+ * - `{t:'op', id, sessionId, action, args, timeout}` —— timeout 是这一发自适应算出的预算(ms)
26
26
  * - `{t:'cancel', id}`
27
27
  *
28
28
  * 动作名是封闭的一小张表(见 `ACTIONS`);手机端认不出的动作回 `unsupported`,宿主不会因为
@@ -40,8 +40,13 @@ const { WebSocketServer } = require('ws');
40
40
 
41
41
  /** 一条帧的上限:截图是 base64 塞在 JSON 里的,留足余量;更大的东西手机不会发。 */
42
42
  const MAX_PAYLOAD = 12 * 1024 * 1024;
43
- /** 心跳:手机每 20 秒至少要有一次 ping/pong 往返,否则按掉线处理(等它自己重连)。 */
43
+ /** 心跳间隔:每 20 秒发一次 ping。 */
44
44
  const HEARTBEAT_MS = 20_000;
45
+ /**
46
+ * 连续丢这么多次心跳(约 60 秒)才按掉线处理,对齐手机端 60s 看门狗。
47
+ * 弱网下偶发一两次丢 pong 不算掉线,免得来回假掉线、把在途 op 全抹成 offline。
48
+ */
49
+ const MAX_MISSED_HEARTBEATS = 3;
45
50
  /** 单次 op 的默认预算;每个动作另有自己的一档(见 ACTIONS)。 */
46
51
  const DEFAULT_OP_TIMEOUT_MS = 30_000;
47
52
 
@@ -85,6 +90,44 @@ function createPhoneService({ authorized, onLog } = {}) {
85
90
  let closed = false;
86
91
  let seq = 0;
87
92
 
93
+ /** 耗时 EWMA 的平滑系数:动作耗时用 0.3,心跳 RTT 用 0.2。 */
94
+ const EWMA_ALPHA = 0.3;
95
+ const RTT_ALPHA = 0.2;
96
+
97
+ function ewma(prev, sample, alpha) {
98
+ if (!prev || !(prev.n > 0) || !Number.isFinite(prev.value)) return { value: sample, n: 1 };
99
+ return { value: prev.value * (1 - alpha) + sample * alpha, n: prev.n + 1 };
100
+ }
101
+
102
+ /** 成功回包按「发送 → 收到」耗时更新 perAction 与全体两类统计。 */
103
+ function recordLatency(link, action, sample) {
104
+ if (!Number.isFinite(sample) || sample < 0) return;
105
+ const lat = link.latency;
106
+ lat.perAction.set(action, ewma(lat.perAction.get(action), sample, EWMA_ALPHA));
107
+ lat.all = ewma(lat.all, sample, EWMA_ALPHA);
108
+ }
109
+
110
+ /**
111
+ * 某动作这一发该给多少预算:估时 est 取自本动作(样本够)或全体,套 est*2.5 + rtt*2 + 1500,
112
+ * 冷启动抬到 seed*1.5 兜底,最后夹进 [max(2500, seed/2), min(120000, max(seed*8, floor))]。
113
+ * `ACTIONS` 里的数字只是这个 seed,不再直接当预算。
114
+ */
115
+ function budgetFor(link, action) {
116
+ const seed = ACTIONS.get(action) || DEFAULT_OP_TIMEOUT_MS;
117
+ const lat = link.latency;
118
+ const pa = lat.perAction.get(action);
119
+ let est = seed;
120
+ if (pa && pa.n >= 2) est = pa.value;
121
+ else if (lat.all && lat.all.n >= 2) est = lat.all.value;
122
+ const rtt = lat.rtt > 0 ? lat.rtt : 0;
123
+ let budget = est * 2.5 + rtt * 2 + 1500;
124
+ const cold = Date.now() - link.connectedAt < 20_000 || !lat.all || lat.all.n < 1;
125
+ if (cold) budget = Math.max(budget, seed * 1.5);
126
+ const floor = Math.max(2500, seed * 0.5);
127
+ const ceil = Math.min(120_000, Math.max(seed * 8, floor));
128
+ return Math.round(Math.min(Math.max(budget, floor), ceil));
129
+ }
130
+
88
131
  const log = (message) => { try { onLog?.(message); } catch { /* 日志失败不影响搬运 */ } };
89
132
 
90
133
  const wss = new WebSocketServer({ noServer: true, perMessageDeflate: false, maxPayload: MAX_PAYLOAD });
@@ -108,6 +151,7 @@ function createPhoneService({ authorized, onLog } = {}) {
108
151
  });
109
152
  }
110
153
  link.pending.clear();
154
+ try { clearInterval(link.heartbeat); } catch { /* 已经清过了 */ }
111
155
  try { link.socket.close(); } catch { /* 已经断了 */ }
112
156
  }
113
157
 
@@ -122,6 +166,8 @@ function createPhoneService({ authorized, onLog } = {}) {
122
166
  capabilities: hello && Array.isArray(hello.capabilities) ? hello.capabilities.filter((c) => typeof c === 'string') : [],
123
167
  connectedAt: Date.now(),
124
168
  alive: true,
169
+ missed: 0,
170
+ latency: { perAction: new Map(), all: null, rtt: 0 },
125
171
  pending: new Map(),
126
172
  };
127
173
  devices.set(deviceId, link);
@@ -137,6 +183,12 @@ function createPhoneService({ authorized, onLog } = {}) {
137
183
  if (!frame || typeof frame !== 'object') return;
138
184
  if (frame.t === 'pong') {
139
185
  link.alive = true;
186
+ if (Number.isFinite(link.pingAt) && link.pingAt > 0) {
187
+ const sample = Date.now() - link.pingAt;
188
+ const prev = link.latency.rtt;
189
+ link.latency.rtt = prev > 0 ? prev * (1 - RTT_ALPHA) + sample * RTT_ALPHA : sample;
190
+ link.pingAt = 0;
191
+ }
140
192
  return;
141
193
  }
142
194
  if (frame.t === 'hello') {
@@ -151,8 +203,12 @@ function createPhoneService({ authorized, onLog } = {}) {
151
203
  const pending = link.pending.get(text(frame.id));
152
204
  if (!pending) return;
153
205
  link.pending.delete(text(frame.id));
154
- if (frame.ok) pending.settleOk(frame.data === undefined ? null : frame.data);
155
- else {
206
+ if (frame.ok) {
207
+ if (pending.action && Number.isFinite(pending.sentAt)) {
208
+ recordLatency(link, pending.action, Date.now() - pending.sentAt);
209
+ }
210
+ pending.settleOk(frame.data === undefined ? null : frame.data);
211
+ } else {
156
212
  const error = frame.error && typeof frame.error === 'object' ? frame.error : {};
157
213
  pending.settleErr({
158
214
  code: text(error.code) || 'failed',
@@ -179,12 +235,19 @@ function createPhoneService({ authorized, onLog } = {}) {
179
235
  link.heartbeat = setInterval(() => {
180
236
  if (!statusOf(socket)) return;
181
237
  if (!link.alive) {
182
- log(`phone ${deviceId}: 心跳丢了,按断线处理`);
183
- drop(deviceId, 'offline');
184
- return;
238
+ link.missed += 1;
239
+ if (link.missed >= MAX_MISSED_HEARTBEATS) {
240
+ log(`phone ${deviceId}: 连续 ${link.missed} 次心跳没回应,按断线处理`);
241
+ drop(deviceId, 'offline');
242
+ return;
243
+ }
244
+ log(`phone ${deviceId}: 心跳丢了(${link.missed}/${MAX_MISSED_HEARTBEATS}),再等等`);
245
+ } else {
246
+ link.missed = 0;
185
247
  }
186
248
  link.alive = false;
187
- try { socket.send(JSON.stringify({ t: 'ping', at: Date.now() })); } catch { /* 下面 close 会来 */ }
249
+ link.pingAt = Date.now();
250
+ try { socket.send(JSON.stringify({ t: 'ping', at: link.pingAt })); } catch { /* 下面 close 会来 */ }
188
251
  }, HEARTBEAT_MS);
189
252
  link.heartbeat.unref?.();
190
253
 
@@ -210,7 +273,7 @@ function createPhoneService({ authorized, onLog } = {}) {
210
273
  if (!ACTIONS.has(action)) return Promise.reject({ code: 'unknown-action', message: `不支持的动作 ${action}` });
211
274
 
212
275
  const opId = nextId();
213
- const budget = Number.isFinite(timeoutMs) ? timeoutMs : (ACTIONS.get(action) || DEFAULT_OP_TIMEOUT_MS);
276
+ const budget = Number.isFinite(timeoutMs) ? timeoutMs : budgetFor(link, action);
214
277
  return new Promise((resolve, reject) => {
215
278
  let settled = false;
216
279
  const settle = (fn, value) => {
@@ -222,6 +285,8 @@ function createPhoneService({ authorized, onLog } = {}) {
222
285
  fn(value);
223
286
  };
224
287
  const pending = {
288
+ action,
289
+ sentAt: Date.now(),
225
290
  settleOk: (data) => settle(resolve, { ok: true, data }),
226
291
  settleErr: (error) => settle(reject, error),
227
292
  };
@@ -240,7 +305,7 @@ function createPhoneService({ authorized, onLog } = {}) {
240
305
  }
241
306
  link.pending.set(opId, pending);
242
307
  try {
243
- link.socket.send(JSON.stringify({ t: 'op', id: opId, sessionId: text(sessionId), action, args: args || {} }));
308
+ link.socket.send(JSON.stringify({ t: 'op', id: opId, sessionId: text(sessionId), action, args: args || {}, timeout: budget }));
244
309
  } catch {
245
310
  settle(reject, { code: 'offline', message: '发不出去,那台手机断开了' });
246
311
  }
@@ -63,6 +63,8 @@ async function run(service, deviceId, sessionId, action, args, signal) {
63
63
  if (code === 'offline') throw new Error('那台手机在这期间断开了连接,这一步没有做。');
64
64
  if (code === 'timeout') throw new Error(`那台手机 ${message}。可以再试一次,或者先 phone_screen 看看它现在停在哪。`);
65
65
  if (code === 'unsupported') throw new Error(`那台手机上装的 App 版本还不支持 ${action},需要把手机上的 App 升级到最新。`);
66
+ if (code === 'adb-unavailable') throw new Error('那台手机的无线调试没连上(可能被系统关了),让用户在手机上重新打开无线调试。');
67
+ if (code === 'warming') throw new Error('手机正在准备后台屏,大约还要几秒,稍等再试。');
66
68
  if (code === 'aborted') throw new Error('这一步被取消了。');
67
69
  throw new Error(message);
68
70
  }
@@ -246,7 +248,7 @@ function installPhoneTools(ctx, deps) {
246
248
  try {
247
249
  ref = await store.saveImage({
248
250
  data: Buffer.from(blob, 'base64'),
249
- mediaType: 'image/png',
251
+ mediaType: data && data.format === 'jpeg' ? 'image/jpeg' : 'image/png',
250
252
  name: textOf(args.target, MAX_TEXT) || 'phone-screen',
251
253
  });
252
254
  } catch (error) {
@@ -0,0 +1,77 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * 商店素材接收端(本机那个 Mac-to-eagle 桌面程序)的坐标。
5
+ *
6
+ * 为什么由插件来报:用户在手机 App 里不该填任何东西(2026-11-07 明确要求)。而「这台电脑的
7
+ * 局域网地址 + 桌面程序的配对密钥」只有插件这一侧知道 —— 它本来就跑在这台电脑上,局域网候选
8
+ * 地址(`lan-network`)和桌面程序写下的密钥文件都在本机,读一下就能把两样一起交给手机。
9
+ *
10
+ * 桌面程序不在跑 / 没装 / 还没生成密钥,就回 null:App 那边据此什么也不做(原图照旧留相册),
11
+ * 不会因为「这台电脑上没有它」而报错。
12
+ */
13
+
14
+ const fs = require('node:fs');
15
+ const os = require('node:os');
16
+ const path = require('node:path');
17
+
18
+ /** 桌面程序的默认端口(见 Mac-to-eagle `LocalAPIServer.preferredPort`)。 */
19
+ const DESKTOP_PORT = 47291;
20
+
21
+ /** 密钥文件:桌面程序用 owner-only 权限写在应用数据目录里。 */
22
+ function accessKeyFile() {
23
+ // 与 Mac-to-eagle `AppPaths.appSupport` 同一套规则:环境变量优先,其次用户应用数据目录。
24
+ const override = process.env.MACTOEAGLE_DATA_DIR;
25
+ const base = override && override.startsWith('/')
26
+ ? override
27
+ : path.join(os.homedir(), 'Library', 'Application Support', 'MacToEagle');
28
+ return path.join(base, 'mobile-access-key');
29
+ }
30
+
31
+ /** 读配对密钥;读不到就 null(不是错误,只说明这台电脑上没有那个程序)。 */
32
+ function readAccessKey() {
33
+ try {
34
+ const value = fs.readFileSync(accessKeyFile(), 'utf8').trim();
35
+ return value || null;
36
+ } catch {
37
+ return null;
38
+ }
39
+ }
40
+
41
+ /**
42
+ * 这台电脑上、手机能拨到的那个局域网地址。
43
+ *
44
+ * 直接用 `lan-network` 那套候选(它已经滤掉了 VPN / 虚拟网卡、把私网排在前面),取第一条的
45
+ * 主机部分 —— 素材回传只需要一个能连通的地址,不需要像会话通道那样挨个试。
46
+ */
47
+ function pickLanHost(lanCandidates) {
48
+ const first = Array.isArray(lanCandidates) ? lanCandidates.find((item) => item && item.url) : null;
49
+ if (!first) return null;
50
+ try {
51
+ return new URL(first.url).hostname;
52
+ } catch {
53
+ return null;
54
+ }
55
+ }
56
+
57
+ /**
58
+ * 组装给 App 的那一格。
59
+ *
60
+ * @param {{ lanCandidates?: Array<{url?: string}> }} options 与 `/connection-info` 同一份候选
61
+ * @returns {{ url: string, key: string } | null}
62
+ */
63
+ function promoReceiver({ lanCandidates } = {}) {
64
+ const key = readAccessKey();
65
+ if (!key) return null;
66
+ const host = pickLanHost(lanCandidates);
67
+ if (!host) return null;
68
+ return { url: `http://${host}:${DESKTOP_PORT}`, key };
69
+ }
70
+
71
+ module.exports = {
72
+ DESKTOP_PORT,
73
+ accessKeyFile,
74
+ readAccessKey,
75
+ pickLanHost,
76
+ promoReceiver,
77
+ };
@@ -37,7 +37,7 @@ const { callSessions } = require('./rpc.js');
37
37
  const { unwrap } = require('./envelope.js');
38
38
  const { normalizeJobs } = require('./jobs.js');
39
39
  const { cutEvent, createToolNameIndex } = require('./wire-cut.js');
40
- const { noteLiveEvent, drainLiveSteps, liveStepsOf } = require('./live-steps.js');
40
+ const { noteLiveEvent, drainLiveSteps, liveStepsOf, liveStepsTurnOf } = require('./live-steps.js');
41
41
  const approvals = require('./approvals.js');
42
42
  const questions = require('./questions.js');
43
43
  const streamRegistry = require('./stream-registry.js');
@@ -491,9 +491,9 @@ async function handleStream(ctx, req, res) {
491
491
  * 按 `key` 替换。**不带 seq**:它不是会话事件,不参与序门与水位(口径同 `session/queue`)。
492
492
  * 一行摘要由电脑算好 —— 推理全文与工具参数都不上线路(见 wire-cut.js)。
493
493
  */
494
- const writeLiveSteps = (steps, full, frameArg) => {
494
+ const writeLiveSteps = (steps, full, frameArg, turn) => {
495
495
  if (!Array.isArray(steps)) return;
496
- writeSse(res, { type: 'session/live-steps', sessionId, full, steps }, frameArg, { sealFrame });
496
+ writeSse(res, { type: 'session/live-steps', sessionId, full, turn: typeof turn === 'number' ? turn : null, steps }, frameArg, { sealFrame });
497
497
  };
498
498
 
499
499
  try {
@@ -521,8 +521,9 @@ async function handleStream(ctx, req, res) {
521
521
  // 开流先补一份过程清单:这条流从此刻起只发「变化的那几步」,之前跑过的那些靠这份快照
522
522
  // 铺出来(中途进会话 / 重连都走这里)。**一步都没有时就一个字都不发** —— 空快照只在
523
523
  // 告诉手机「你手上那份作废」,而那种情况由新的一轮开始时清(App 侧 `resetLiveSteps`)。
524
+ const openingTurn = liveStepsTurnOf(ctx, sessionId);
524
525
  const opening = liveStepsOf(ctx, sessionId);
525
- if (!clientGone && opening.length > 0) writeLiveSteps(opening, true, null);
526
+ if (!clientGone && opening.length > 0) writeLiveSteps(opening, true, null, openingTurn);
526
527
 
527
528
  if (!clientGone) {
528
529
  // 写一帧并把三种结果统一收口:丢增量、需要等 drain、该收尾。
@@ -566,8 +567,10 @@ async function handleStream(ctx, req, res) {
566
567
  noteLiveEvent(ctx, sessionId, event);
567
568
  // 过程清单的增量:这一步刚开、或某一步的状态变了,把变化的那几行发出去。
568
569
  // 排在事件本身之前发顺序不影响判断(清单是「到此刻为止」的旁白,不是内容)。
570
+ // 带上这一轮的行号:App 按轮隔离,换轮就整份换掉(漏掉 turn/start 也不会串轮)。
571
+ const liveTurn = liveStepsTurnOf(ctx, sessionId);
569
572
  for (const changed of drainLiveSteps(ctx, sessionId)) {
570
- const value = { type: 'session/live-steps', sessionId, full: false, steps: [changed] };
573
+ const value = { type: 'session/live-steps', sessionId, full: false, turn: liveTurn, steps: [changed] };
571
574
  if (await pushOrdered(value, null) === 'closed') { clientGone = true; break; }
572
575
  }
573
576
  if (clientGone) break;
@@ -0,0 +1,181 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * 同步收件箱:按 `instanceId` 存密文记录的磁盘小仓库(`$DSH_HOME/storages/mobile-plugin/sync-inbox.json`)。
5
+ *
6
+ * 每台电脑给「共享同步组」当一次中转点:别的手机把一条条**已用 SGK 加密**的记录写进来,本机
7
+ * 只存不读、看不懂内容。记录的形状是 `{ cid, blob, createdAt }`:`cid` 是内容标识(去重键),
8
+ * `blob` 是 base64 的密文,`createdAt` 由本仓库在写入时打上。
9
+ *
10
+ * 三条限制决定了它是一个「短时邮箱」而不是数据库:
11
+ * · **TTL 24 小时**:过期的记录在读写时顺手清掉。同步只在手机回前台时跑一次,一天足够;
12
+ * 再久的记录没有意义,留着只会让文件慢慢变大。
13
+ * · **单条 blob 上限 8KB**:记录只是「设备清单的一项 / 一条删除指令」,本该很小;超过这个量
14
+ * 说明写错了东西,整条拒收。
15
+ * · **单次最多 16 条**:一次同步一批设备,超过就该分批。
16
+ *
17
+ * 落盘口径照 `mobile-stats.json` 那一套:整份读、整份原子写(临时文件 + rename)。**读坏文件
18
+ * 时 write 绝不覆盖**——那会把盘上还没发出去的记录静默清空;读失败只有 `read()` 退化成空表。
19
+ */
20
+
21
+ const fs = require('node:fs');
22
+ const path = require('node:path');
23
+
24
+ const { dshHome } = require('../shared/paths');
25
+
26
+ /** 文件格式版本:将来改结构时据此决定丢弃还是迁移。 */
27
+ const VERSION = 1;
28
+
29
+ /** 记录存活上限:24 小时。 */
30
+ const TTL_MS = 24 * 60 * 60 * 1000;
31
+
32
+ /** 单条 blob(base64 密文串)的字节上限。 */
33
+ const MAX_BLOB_BYTES = 8 * 1024;
34
+
35
+ /** 单次写入最多接收的记录条数。 */
36
+ const MAX_RECORDS_PER_WRITE = 16;
37
+
38
+ /** cid 的长度上限:它只是去重键,不拼路径,但也不该是无限长的串。 */
39
+ const MAX_CID_LEN = 200;
40
+
41
+ /**
42
+ * 单实例最多留多少条:TTL 已经把它压到一天的量,这一道只是文件体积的兜底(超了丢最老的)。
43
+ * 一条 8KB 封顶时,512 条约 4MB,仍可接受。
44
+ */
45
+ const MAX_STORED_PER_INSTANCE = 512;
46
+
47
+ /** 收件箱文件:跟其它移动端插件状态放同一个目录(`storages/mobile-plugin/`)。 */
48
+ function defaultFile() {
49
+ return path.join(dshHome(), 'storages', 'mobile-plugin', 'sync-inbox.json');
50
+ }
51
+
52
+ /**
53
+ * 建一个收件箱 store。默认读 `defaultFile()` 的真实时钟;测试可注入 `file` / `now` / `ttlMs`。
54
+ *
55
+ * @param {object} [options]
56
+ * @param {string} [options.file] 落盘文件(测试用)
57
+ * @param {() => number} [options.now] 当前时刻的取值器
58
+ * @param {number} [options.ttlMs] 记录存活时长
59
+ */
60
+ function createSyncInboxStore(options = {}) {
61
+ const now = typeof options.now === 'function' ? options.now : Date.now;
62
+ const ttlMs = Number.isFinite(options.ttlMs) && options.ttlMs > 0 ? options.ttlMs : TTL_MS;
63
+ const file = options.file || defaultFile();
64
+
65
+ /** 读盘并解析。文件不存在 → 空库;存在但读不懂 → 抛错(write 会用它,绝不能吞成空库)。 */
66
+ function readRaw() {
67
+ let text;
68
+ try {
69
+ text = fs.readFileSync(file, 'utf8');
70
+ } catch (error) {
71
+ if (error && error.code === 'ENOENT') return { version: VERSION, instances: {} };
72
+ throw error;
73
+ }
74
+ let parsed;
75
+ try {
76
+ parsed = JSON.parse(text);
77
+ } catch (error) {
78
+ throw Object.assign(new Error(`同步收件箱文件已损坏:${error.message}`), { code: 'sync-inbox-corrupt' });
79
+ }
80
+ if (!parsed || typeof parsed !== 'object' || !parsed.instances || typeof parsed.instances !== 'object') {
81
+ throw Object.assign(new Error('同步收件箱文件形状不对'), { code: 'sync-inbox-corrupt' });
82
+ }
83
+ return parsed;
84
+ }
85
+
86
+ /** 只留形状对、未过期的记录。 */
87
+ function prune(list) {
88
+ const cutoff = now() - ttlMs;
89
+ return list.filter((record) => (
90
+ record
91
+ && typeof record === 'object'
92
+ && typeof record.cid === 'string'
93
+ && typeof record.blob === 'string'
94
+ && Number.isFinite(record.createdAt)
95
+ && record.createdAt > cutoff
96
+ ));
97
+ }
98
+
99
+ /** 原子写:先临时文件再 rename,写一半断电也不留半份 JSON。 */
100
+ function save(db) {
101
+ fs.mkdirSync(path.dirname(file), { recursive: true });
102
+ const tmp = `${file}.${process.pid}.tmp`;
103
+ fs.writeFileSync(tmp, JSON.stringify(db), { mode: 0o600 });
104
+ fs.renameSync(tmp, file);
105
+ }
106
+
107
+ /**
108
+ * 读某实例的记录:`since` 之后、未过期的,按 `createdAt` 升序。
109
+ * 读盘失败按空表(首页拉不到不该把同步引擎整个卡死)。
110
+ *
111
+ * @param {string} instanceId
112
+ * @param {number} [since] 毫秒时间戳,只取严格晚于它的记录
113
+ * @returns {Array<{cid: string, blob: string, createdAt: number}>}
114
+ */
115
+ function read(instanceId, since) {
116
+ let db;
117
+ try {
118
+ db = readRaw();
119
+ } catch {
120
+ return [];
121
+ }
122
+ const list = Array.isArray(db.instances[String(instanceId || '')]) ? db.instances[String(instanceId || '')] : [];
123
+ const sinceMs = Number.isFinite(since) && since > 0 ? since : 0;
124
+ return prune(list)
125
+ .filter((record) => record.createdAt > sinceMs)
126
+ .sort((a, b) => a.createdAt - b.createdAt)
127
+ .map((record) => ({ cid: record.cid, blob: record.blob, createdAt: record.createdAt }));
128
+ }
129
+
130
+ /**
131
+ * 写一批记录:按 cid 去重、裁掉越界 / 过期的,返回被接收的 cid 列表。
132
+ *
133
+ * **读坏文件会抛错、不覆盖**:盘上那份可能还有别的手机没取走的东西,绝不能当成空库重写。
134
+ *
135
+ * @param {string} instanceId
136
+ * @param {Array<{cid: string, blob: string}>} records
137
+ * @returns {string[]} 接收到的 cid(重复的也算「已在库里」)
138
+ */
139
+ function write(instanceId, records) {
140
+ const db = readRaw();
141
+ const id = String(instanceId || '');
142
+ const byCid = new Map(prune(Array.isArray(db.instances[id]) ? db.instances[id] : []).map((r) => [r.cid, r]));
143
+ const accepted = [];
144
+ const at = now();
145
+ let added = 0;
146
+ for (const record of Array.isArray(records) ? records : []) {
147
+ if (!record || typeof record !== 'object') continue;
148
+ const cid = typeof record.cid === 'string' ? record.cid : '';
149
+ const blob = typeof record.blob === 'string' ? record.blob : '';
150
+ if (!cid || cid.length > MAX_CID_LEN || !blob) continue;
151
+ if (Buffer.byteLength(blob, 'utf8') > MAX_BLOB_BYTES) continue;
152
+ if (byCid.has(cid)) {
153
+ accepted.push(cid);
154
+ continue;
155
+ }
156
+ if (added >= MAX_RECORDS_PER_WRITE) break;
157
+ byCid.set(cid, { cid, blob, createdAt: at });
158
+ accepted.push(cid);
159
+ added += 1;
160
+ }
161
+ let list = [...byCid.values()].sort((a, b) => a.createdAt - b.createdAt);
162
+ if (list.length > MAX_STORED_PER_INSTANCE) list = list.slice(list.length - MAX_STORED_PER_INSTANCE);
163
+ db.version = VERSION;
164
+ db.instances[id] = list;
165
+ save(db);
166
+ return accepted;
167
+ }
168
+
169
+ return { read, write };
170
+ }
171
+
172
+ module.exports = {
173
+ createSyncInboxStore,
174
+ defaultFile,
175
+ VERSION,
176
+ TTL_MS,
177
+ MAX_BLOB_BYTES,
178
+ MAX_RECORDS_PER_WRITE,
179
+ MAX_CID_LEN,
180
+ MAX_STORED_PER_INSTANCE,
181
+ };
@@ -0,0 +1,124 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * 设备批量同步的两组接口(`/api/mobile/v1/sync/*`,见 docs/device-batch-sync.md)。
5
+ *
6
+ * · `POST /sync/tickets` —— 已配对的设备替同步组里将来新增的手机申领一次性入场券,连同这台
7
+ * 电脑的概况(名字 / 实例号 / 中转地址 / 直连候选 …)一起回给导出方,拼进二维码。
8
+ * · `POST /sync/inbox` —— 别的手机把 SGK 加密的记录写进本机收件箱。
9
+ * · `GET /sync/inbox` —— 取 `since` 之后、未过期的记录。
10
+ *
11
+ * 宿主只存不读收件箱:它不认识 SGK、解不开 blob。真正的内容加解密、去重与「哪条该做什么」
12
+ * 全在手机上(见契约的「自动同步」节)。这里只做两件事:发券、存取密文。
13
+ *
14
+ * 三个 handler 都要求已配对(路由表里走 `guarded`),否则任何人都能领走配对资格、或往这台
15
+ * 电脑的收件箱塞东西。
16
+ */
17
+
18
+ const { sendJson, readJsonBody } = require('../shared/http-util');
19
+ const { MAX_RECORDS_PER_WRITE } = require('./sync-inbox-store.js');
20
+
21
+ /**
22
+ * 构造同步那组 handler。依赖由移动端路由表装配时注入。
23
+ *
24
+ * @param {object} deps
25
+ * @param {object} deps.state 宿主状态(instanceId / name / relayUrl / lanEnabled …)
26
+ * @param {object} deps.ticketStore 一次性券表(transfer-tickets.js)
27
+ * @param {object} deps.inboxStore 同步收件箱 store(sync-inbox-store.js)
28
+ * @param {(port: number) => Array<object>} [deps.safeLanCandidates] 直连候选(与 /connection-info 同一份)
29
+ * @param {(port: number) => Array<object>} [deps.safeIpv6Candidates] IPv6 直连候选
30
+ * @param {() => number} [deps.lanPortInUse] 宿主实绑的直连端口
31
+ * @param {() => string} [deps.hostPlatform] 宿主系统标识
32
+ * @param {(message: string) => void} [deps.log]
33
+ */
34
+ function createSyncHandlers(deps = {}) {
35
+ const state = deps.state || {};
36
+ const ticketStore = deps.ticketStore || null;
37
+ const inboxStore = deps.inboxStore || null;
38
+ const log = typeof deps.log === 'function' ? deps.log : () => {};
39
+ const instanceIdOf = () => String(state.instanceId || '');
40
+
41
+ /** 这台电脑给手机的概况:新手机配对前靠它知道对面是谁、该往哪拨。 */
42
+ function hostSummary() {
43
+ const port = typeof deps.lanPortInUse === 'function' ? deps.lanPortInUse() : state.lanPort;
44
+ // 直连候选与 /connection-info、/pair 那份同口径:关掉局域网直连时两份都为空。
45
+ const lanUrls = state.lanEnabled && typeof deps.safeLanCandidates === 'function'
46
+ ? deps.safeLanCandidates(port).map((candidate) => candidate && candidate.url).filter(Boolean)
47
+ : [];
48
+ const ipv6Candidates = state.lanEnabled && typeof deps.safeIpv6Candidates === 'function'
49
+ ? deps.safeIpv6Candidates(port).map((candidate) => candidate && candidate.url).filter(Boolean)
50
+ : [];
51
+ const hostPlatform = typeof deps.hostPlatform === 'function' ? deps.hostPlatform() : (state.hostPlatform || process.platform);
52
+ return {
53
+ name: state.name || '',
54
+ instanceId: instanceIdOf(),
55
+ // 券只能走协议 v2 的 sealed 配对,所以这里报协议 2。
56
+ proto: 2,
57
+ relayUrl: state.enabled ? (state.relayUrl || null) : null,
58
+ lanUrls,
59
+ ipv6Candidates,
60
+ hostPlatform,
61
+ };
62
+ }
63
+
64
+ /** POST /sync/tickets:申领一次性入场券(count 默认 1、封顶 8)。 */
65
+ async function handleMintTickets(_ctx, req, res) {
66
+ if (req.method !== 'POST') { sendJson(res, 405, { ok: false, error: 'method not allowed' }); return; }
67
+ if (!ticketStore) { sendJson(res, 503, { ok: false, error: 'ticket store unavailable' }); return; }
68
+ // 空体是合法的(count 缺省 1);读不懂 / 太大也按空对象、让 count 走默认,不因多要一个字段就失败。
69
+ const body = (await readJsonBody(req, 16 * 1024)) || {};
70
+ const tickets = ticketStore.mint(instanceIdOf(), body && body.count, {
71
+ ttlMs: body && body.ttlMs,
72
+ reusable: body && body.reusable,
73
+ });
74
+ sendJson(res, 200, { ok: true, host: hostSummary(), tickets });
75
+ }
76
+
77
+ /** POST /sync/inbox:写入一批密文记录,回被接收的 cid 列表。 */
78
+ async function handleInboxWrite(_ctx, req, res) {
79
+ if (!inboxStore) { sendJson(res, 503, { ok: false, error: 'inbox store unavailable' }); return; }
80
+ const body = await readJsonBody(req);
81
+ if (!body || !Array.isArray(body.records)) {
82
+ sendJson(res, 400, { ok: false, error: 'records 必须是数组' });
83
+ return;
84
+ }
85
+ if (body.records.length > MAX_RECORDS_PER_WRITE) {
86
+ sendJson(res, 400, { ok: false, error: `单次最多写 ${MAX_RECORDS_PER_WRITE} 条记录` });
87
+ return;
88
+ }
89
+ let accepted;
90
+ try {
91
+ accepted = inboxStore.write(instanceIdOf(), body.records);
92
+ } catch (error) {
93
+ // 读坏盘时 store 会抛错(绝不覆盖已有记录);这里是那条错误落到请求上的地方。
94
+ log('sync inbox write failed:', error && error.message);
95
+ sendJson(res, 500, { ok: false, error: 'internal error' });
96
+ return;
97
+ }
98
+ sendJson(res, 200, { ok: true, accepted });
99
+ }
100
+
101
+ /** GET /sync/inbox?since=<ms>:取 `since` 之后、未过期的记录。 */
102
+ async function handleInboxRead(_ctx, req, res) {
103
+ if (!inboxStore) { sendJson(res, 503, { ok: false, error: 'inbox store unavailable' }); return; }
104
+ let since = 0;
105
+ try {
106
+ const query = new URL(req.url || '/', 'http://x').searchParams.get('since');
107
+ if (query !== null) since = Number(query);
108
+ } catch { /* 畸形 URL 当 since=0,返回全部未过期的 */ }
109
+ if (!Number.isFinite(since) || since < 0) since = 0;
110
+ const records = inboxStore.read(instanceIdOf(), since);
111
+ sendJson(res, 200, { ok: true, records });
112
+ }
113
+
114
+ /** 同一条路径上按 method 分派写 / 读(宿主 webServer 一个路径只能挂一个 handler)。 */
115
+ async function handleInbox(ctx, req, res) {
116
+ if (req.method === 'POST') return handleInboxWrite(ctx, req, res);
117
+ if (req.method === 'GET') return handleInboxRead(ctx, req, res);
118
+ sendJson(res, 405, { ok: false, error: 'method not allowed' });
119
+ }
120
+
121
+ return { handleMintTickets, handleInbox, handleInboxWrite, handleInboxRead };
122
+ }
123
+
124
+ module.exports = { createSyncHandlers };