dsh-mobile-plugin 0.1.95 → 0.1.96

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.
@@ -117,6 +117,29 @@ function listActive() {
117
117
  load();
118
118
  return records.filter((record) => !record.revokedAt && (!record.expiresAt || record.expiresAt > Date.now())).map(({ instanceId, deviceId, createdAt, expiresAt }) => ({ instanceId, deviceId, createdAt, ...(expiresAt ? { expiresAt } : {}) }));
119
119
  }
120
+ /** 一份镜像最多带这么多条摘要:长期反复配对不该把推送清单撑成累赘。 */
121
+ const MAX_ACTIVE_DIGESTS = 200;
122
+ /**
123
+ * 本实例签发过、仍然有效的凭据摘要,按签发时间从新到旧。
124
+ *
125
+ * 中转的离线镜像缓存要在桌面宿主上线之前判断「来读缓存的人是不是配对过的手机」,而它手上
126
+ * 只有手机发来的 `dshc_` 原文 —— 这里给出同一口径的 sha256(落盘存的本来就是这个摘要,
127
+ * 原文从未留存),它自己算一遍就能比对。吊销与过期在这里现读现滤,撤销一台设备后下一次
128
+ * 推送就少一条。
129
+ */
130
+ function activeDigests(instanceId) {
131
+ load();
132
+ const now = Date.now();
133
+ const live = [];
134
+ // 记录只在新签发时追加,所以倒着走就是 createdAt 倒序:超出上限时被截掉的是最老的,
135
+ // 同毫秒签发的几条也保持签发顺序而不是凭运气。
136
+ for (let i = records.length - 1; i >= 0 && live.length < MAX_ACTIVE_DIGESTS; i -= 1) {
137
+ const record = records[i];
138
+ if (record.instanceId !== instanceId || !isLive(record, now)) continue;
139
+ live.push({ hash: record.hash, ...(record.expiresAt ? { expiresAt: record.expiresAt } : {}) });
140
+ }
141
+ return live;
142
+ }
120
143
  function revokeDevice(instanceId, deviceId) {
121
144
  load();
122
145
  let count = 0;
@@ -132,4 +155,4 @@ function revokeAllByInstance(instanceId) {
132
155
  if (count) save();
133
156
  return count;
134
157
  }
135
- module.exports = { FILE, DEFAULT_TTL_MS, issue, find, lookup, isLive, isActiveDevice, revoke, revokeDevice, revokeAllByInstance, listActive };
158
+ module.exports = { FILE, DEFAULT_TTL_MS, issue, find, lookup, isLive, isActiveDevice, activeDigests, revoke, revokeDevice, revokeAllByInstance, listActive };
package/lib/impl.js CHANGED
@@ -294,8 +294,8 @@ function apply(ctx) {
294
294
  })
295
295
  const { sidecarToken, syncSidecar, restartSidecar, tunnelConnected } = sidecarLifecycle
296
296
 
297
- // 镜像推送调度器:会话事件立即推,后台循环每个 tick 补推一个冷会话。
298
- const mirror = createMirrorPusher({ ctx, tunnelConnected, getSidecar: sidecarLifecycle.getSidecar, lastMobileAt: activity.snapshot })
297
+ // 镜像推送调度器:会话事件立即推,后台循环每个 tick 补推一个冷会话;凭据摘要由装配层给取值器(凭据库归本进程),中转靠它认离线缓存的读者。
298
+ const mirror = createMirrorPusher({ ctx, tunnelConnected, getSidecar: sidecarLifecycle.getSidecar, lastMobileAt: activity.snapshot, credentialDigests: () => credentials.activeDigests(state.instanceId) })
299
299
  // 隧道刚连上时补推一次全量:未连通期间每轮推送都被 worker 以 503 拒绝。
300
300
  sidecarLifecycle.onTunnelUp(() => { void mirror.pushMirror(); })
301
301
 
@@ -90,6 +90,14 @@ function createMirrorPusher(deps) {
90
90
  * 别让一处漏接线的装配把镜像悄悄降成 5s 一份。
91
91
  */
92
92
  const lastMobileAt = typeof deps.lastMobileAt === 'function' ? deps.lastMobileAt : null;
93
+ /**
94
+ * 本实例签发过、仍然有效的设备凭据摘要(凭据库 activeDigests 的形态)。
95
+ *
96
+ * 中转加了离线镜像缓存后,桌面还没上线时也要能判断「来读这份缓存的人是不是配对过的
97
+ * 手机」,而它手上只有手机发来的 `dshc_` 原文 —— 摘要随镜像给上去,它才能自己算 sha256
98
+ * 比对。不传就按「一份都没有」处理:镜像照推,只是中转认不出任何缓存读者。
99
+ */
100
+ const credentialDigests = typeof deps.credentialDigests === 'function' ? deps.credentialDigests : () => [];
93
101
  const hasAudience = () => {
94
102
  if (listSignal.watching()) return true;
95
103
  if (!lastMobileAt) return true;
@@ -129,6 +137,12 @@ function createMirrorPusher(deps) {
129
137
  // 消息水位/条数 + 统计投影 + 模型 revision),不含 `at` 之类的时间戳,所以「同一份
130
138
  // 数据」永远得到同一个指纹。只记成功推送的指纹:失败的那次必须能在下一 tick 重试。
131
139
  let lastPushedFingerprint = null;
140
+ // 上一次推上去的摘要清单(判重键见 credsKeyOf)。**刻意不并进 pushFingerprint**:那是与
141
+ // 中转共享的载荷指纹,收的是镜像内容;摘要另立一份,撤销一台设备后哪怕清单一个字都没变,
142
+ // 新摘要也能在一个 tick 内传导过去(否则被撤销的手机还能继续读离线镜像)。
143
+ let lastPushedCreds = null;
144
+ /** 摘要清单的判重键:条数、顺序、过期时间任一变化都算新内容。 */
145
+ const credsKeyOf = (creds) => creds.map((entry) => (entry.expiresAt ? `${entry.hash}@${entry.expiresAt}` : entry.hash)).join(',');
132
146
  // 「中转镜像里那份清单 / 工作区是哪一版」——同样只记真的推出去(或被判重跳过)的那一版,
133
147
  // 供 processBgQueue 的门判断这一轮有没有必要重建再推一份。判据必须是 revision 而不是
134
148
  // pushFingerprint:后者含轮转会话的消息水位与统计投影,每轮都不同。
@@ -238,13 +252,19 @@ function createMirrorPusher(deps) {
238
252
  const workspacesRevision = payload.workspaces && typeof payload.workspaces.revision === 'string'
239
253
  ? payload.workspaces.revision
240
254
  : '';
255
+ // 摘要只在这一处塞进载荷:这里是唯一出口,三条推送路径(事件、整批、后台 tick)自然
256
+ // 都带上,谁也不必记得自己补。放在隧道判定之后 —— 断线时这份清单没有任何去处,
257
+ // 白读一趟凭据库。
258
+ payload.creds = credentialDigests();
259
+ const credsKey = credsKeyOf(payload.creds);
241
260
  const fingerprint = pushFingerprint(payload);
242
- if (fingerprint === lastPushedFingerprint) {
261
+ if (fingerprint === lastPushedFingerprint && credsKey === lastPushedCreds) {
243
262
  // 跳过不等于故障:内容与上一份逐字段相同。但 pushedAt 必须继续走,它是运维与
244
263
  // App 诊断「镜像是否还在更新」的唯一抓手,停在这里会让它看起来像镜像死了。
245
264
  recordMirrorPush(true, '内容未变,跳过重复推送');
246
265
  lastPushedListRevision = listRevision;
247
266
  lastPushedWorkspacesRevision = workspacesRevision;
267
+ lastPushedCreds = credsKey;
248
268
  announceSessionActivity(payload.messages);
249
269
  return false;
250
270
  }
@@ -255,6 +275,7 @@ function createMirrorPusher(deps) {
255
275
  throw error
256
276
  }
257
277
  lastPushedFingerprint = fingerprint;
278
+ lastPushedCreds = credsKey;
258
279
  recordMirrorPush(true, null)
259
280
  // 基准只在真的推进去(或被判重跳过)之后才前移:推送失败必须能在下一轮重试,
260
281
  // 否则「清单变了」会被一次失败吃掉,中转镜像永远停在旧版(见 processBgQueue 的门)。
@@ -679,7 +700,9 @@ function createMirrorPusher(deps) {
679
700
  const retryId = dueRetries(now).find((id) => list.some((s) => String(s.sessionId) === id));
680
701
  if (retryId) await pushSessionNow(retryId, 'batch');
681
702
  const coldRotateDue = now - lastColdRotateAt >= BG_COLD_ROTATE_MIN_INTERVAL_MS;
682
- if (revision === lastPushedListRevision && workspacesRevision === lastPushedWorkspacesRevision && !coldRotateDue) {
703
+ // 摘要清单也是一份要推的内容:撤销一台设备时清单往往一个字都没变,只看 revision
704
+ // 会把这一轮挡回去,被撤销的手机要等到下一个轮转窗口才失去离线镜像。
705
+ if (revision === lastPushedListRevision && workspacesRevision === lastPushedWorkspacesRevision && credsKeyOf(credentialDigests()) === lastPushedCreds && !coldRotateDue) {
683
706
  // 清单与工作区都没变、也还没到轮转时间:这一轮除了刚做的重试什么都不用做。
684
707
  if (retryId) announceSessionsRevision(revision);
685
708
  return;
@@ -181,8 +181,37 @@ async function invoke(ctx, namespace, method, args, signal) {
181
181
  }
182
182
  }
183
183
 
184
+ /**
185
+ * 宿主重启后的那一瞬间,网关服务还没就绪:插件路由挂得上、普通请求 App 会自己重发,
186
+ * 但事件流不会 —— 它拿到空网关就直接报「没能力」退出,App 再重连才有数据。日志里于是
187
+ * 每次重启留一条带堆栈的假故障,看着像坏了一整条通道。
188
+ *
189
+ * 这里只等一次、只等短短一瞬(默认 800ms):真没有这个能力的旧宿主照样要立刻回报,
190
+ * 不能让调用方白等成一秒的延迟。用 ctx.get 重取而不是缓存,是因为服务就绪时就是新值。
191
+ */
192
+ const GATEWAY_READY_RETRY_MS = 800;
193
+
194
+ function sleep(ms, signal) {
195
+ return new Promise((resolve) => {
196
+ const timer = setTimeout(finish, ms);
197
+ function finish() {
198
+ clearTimeout(timer);
199
+ if (signal && typeof signal.removeEventListener === 'function') signal.removeEventListener('abort', finish);
200
+ resolve();
201
+ }
202
+ if (signal && typeof signal.addEventListener === 'function') {
203
+ if (signal.aborted) return finish();
204
+ signal.addEventListener('abort', finish, { once: true });
205
+ }
206
+ });
207
+ }
208
+
184
209
  async function openStream(ctx, namespace, method, args, signal) {
185
- const gateway = gatewayOf(ctx);
210
+ let gateway = gatewayOf(ctx);
211
+ if (!gateway) {
212
+ await sleep(GATEWAY_READY_RETRY_MS, signal);
213
+ gateway = gatewayOf(ctx);
214
+ }
186
215
  if (!gateway) throw Object.assign(new Error(UNAVAILABLE.message), { code: UNAVAILABLE.code });
187
216
  return gateway.stream(hostRequest(ctx, namespace, method, args, signal));
188
217
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-mobile-plugin",
3
- "version": "0.1.95",
3
+ "version": "0.1.96",
4
4
  "description": "Mobile API (/api/mobile/v1) and relay client for the DSH mobile app — desktop profile build (0.2.0-rc.2 compatible, peers resolved by the host)",
5
5
  "license": "MIT",
6
6
  "type": "module",