@xxgxiaoyu/dsh-session-sync 0.0.0-stage → 0.8.3

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/index.js ADDED
@@ -0,0 +1,1053 @@
1
+ /**
2
+ * dsh-session-sync — host half.
3
+ *
4
+ * Responsibilities:
5
+ * - own the machine's sync identity and the local cache of peer names;
6
+ * - beacon/observe other devices over UDP (lib/discovery.js);
7
+ * - serve the LAN sync API on its own port (lib/lan.js);
8
+ * - expose the GUI's same-origin API under /session-sync/api;
9
+ * - pull/push sessions through the harness session store (lib/sessions.js).
10
+ *
11
+ * The GUI only ever talks to /session-sync/api on the loopback web server;
12
+ * cross-machine traffic never reaches the GUI port.
13
+ */
14
+ import fs from 'node:fs'
15
+ import os from 'node:os'
16
+ import path from 'node:path'
17
+ import { Discovery } from './discovery.js'
18
+ import { parseTarget, probeTargets } from './probe.js'
19
+ import { LanServer } from './lan.js'
20
+ import { SessionBridge, SessionSyncError, sessionDigest, sessionSignature } from './sessions.js'
21
+ import { repairStoredSessions } from './repair.js'
22
+ import { StateStore, dshHome } from './state.js'
23
+
24
+ /** Every address that resolves to this machine — used to keep “same box, other profile” out of the peer list. */
25
+ function localAddresses() {
26
+ const set = new Set(['127.0.0.1', '::1', 'localhost'])
27
+ for (const entries of Object.values(os.networkInterfaces())) {
28
+ for (const entry of entries ?? []) {
29
+ if (entry.family === 'IPv4' && typeof entry.address === 'string') set.add(entry.address)
30
+ }
31
+ }
32
+ return set
33
+ }
34
+
35
+ /** First dialable IPv4 address — what other machines on the LAN can reach. */
36
+ function localAddress() {
37
+ for (const entries of Object.values(os.networkInterfaces())) {
38
+ for (const entry of entries ?? []) {
39
+ if (entry.family === 'IPv4' && entry.internal !== true) return entry.address
40
+ }
41
+ }
42
+ return '127.0.0.1'
43
+ }
44
+
45
+ export const name = 'dsh-session-sync'
46
+ export const inject = ['webServer']
47
+
48
+ const VERSION = (() => {
49
+ try {
50
+ return JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version
51
+ } catch {
52
+ return '0.0.0'
53
+ }
54
+ })()
55
+ const REPO_URL = 'https://github.com/XXGxiaoyu/dsh-session-sync'
56
+ const LICENSE = 'MIT'
57
+ const PEER_TTL_MS = 12_000
58
+ /**
59
+ * Explicit targets are also probed over plain HTTP: some networks drop UDP
60
+ * between VLANs entirely, and a `GET /v1/info` still gets through. The interval
61
+ * stays under PEER_TTL_MS so an HTTP-only peer never flickers offline.
62
+ */
63
+ const PROBE_INTERVAL_MS = 8_000
64
+ const AUTO_PULL_INTERVAL_MS = 20_000
65
+ const API_PREFIX = '/session-sync/api'
66
+
67
+ function isPlainObject(value) {
68
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
69
+ }
70
+
71
+ const CONCURRENCY_AUTO_CAP = 5
72
+
73
+ /** How many sessions move at once: an explicit 1..16, or a machine-sized default. */
74
+ function resolveConcurrency(value) {
75
+ const asked = Number(value)
76
+ if (Number.isInteger(asked) && asked >= 1 && asked <= 16) return asked
77
+ const cores = typeof os.availableParallelism === 'function' ? os.availableParallelism() : os.cpus().length
78
+ const guessed = Math.max(2, Math.ceil(cores / 2))
79
+ return Math.min(CONCURRENCY_AUTO_CAP, guessed, Math.max(2, cores - 1))
80
+ }
81
+
82
+ function clampInt(value, fallback, min, max) {
83
+ const number = Number(value)
84
+ if (!Number.isFinite(number)) return fallback
85
+ return Math.min(Math.max(Math.trunc(number), min), max)
86
+ }
87
+
88
+ function clampLimit(value, fallback = 200) {
89
+ const number = Number(value)
90
+ if (!Number.isFinite(number)) return fallback
91
+ return Math.min(Math.max(Math.trunc(number), 1), 500)
92
+ }
93
+
94
+ /** `label :: detail` error surfaced to the GUI. */
95
+ function apiError(error) {
96
+ if (error instanceof SessionSyncError) return { code: error.code, message: error.message }
97
+ const message = String(error?.message ?? error)
98
+ return { code: 'internal', message }
99
+ }
100
+
101
+
102
+ /**
103
+ * Attach a freshly imported session to the workspace a drop named, optionally
104
+ * before a given session. DSH's own sidebar uses `workspaceRegistry` for this,
105
+ * so the plugin calls exactly that instead of rewriting `workspace.json`.
106
+ *
107
+ * @param {object|null} registry - `ctx.get('workspaceRegistry')` or a stub.
108
+ * @param {string} localId - imported session id.
109
+ * @param {{ workspace?: string, before?: string }} [target] - drop target.
110
+ * @returns {Promise<boolean>} true when the registry accepted the placement.
111
+ */
112
+ export async function attachImportedSession(registry, localId, target) {
113
+ const workspaceId = typeof target?.workspace === 'string' && target.workspace.length > 0 ? target.workspace : null
114
+ if (workspaceId === null || registry === null || registry === undefined) return false
115
+ const entity = registry.get(workspaceId)
116
+ if (entity === undefined || entity === null) return false
117
+ await entity.attachSession(localId)
118
+ const before = typeof target?.before === 'string' && target.before.length > 0 ? target.before : null
119
+ if (before !== null && before !== localId) await entity.insertSessionBefore(localId, before)
120
+ return true
121
+ }
122
+
123
+ /**
124
+ * @param {object} ctx - host plugin context (webServer required).
125
+ * @param {object} [config] - deployment config from the loader row.
126
+ */
127
+ export function apply(ctx, config = {}) {
128
+ const cfg = isPlainObject(config) ? config : {}
129
+ const log = (message) => {
130
+ if (cfg.verbose === false) return
131
+ try {
132
+ console.log(`[dsh-session-sync] ${message}`)
133
+ } catch {
134
+ /* ignore */
135
+ }
136
+ }
137
+ const home = dshHome(cfg)
138
+ const stateDir = path.join(home, 'session-sync')
139
+ try {
140
+ fs.mkdirSync(stateDir, { recursive: true, mode: 0o700 })
141
+ } catch (error) {
142
+ log(`状态目录创建失败: ${error?.message ?? error}`)
143
+ }
144
+ const store = new StateStore({ dir: stateDir })
145
+ const state = store.load()
146
+ /**
147
+ * One machine is one device, whatever the port. Peers whose address belongs to
148
+ * this host are dropped from the table entirely — two DSH profiles on the same
149
+ * box are the same machine, not a second device. `allowLoopbackPeers` exists
150
+ * for the test suite, which runs several instances on 127.0.0.1.
151
+ */
152
+ const allowLoopbackPeers = cfg.allowLoopbackPeers === true
153
+ const localAddressSet = localAddresses()
154
+ if (!allowLoopbackPeers) {
155
+ for (const [id, record] of Object.entries(state.peers)) {
156
+ if (localAddressSet.has(record?.address ?? '')) delete state.peers[id]
157
+ }
158
+ }
159
+ const autoPullIntervalMs = clampInt(cfg.autoPullIntervalMs, AUTO_PULL_INTERVAL_MS, 1_000, 600_000)
160
+ if (Array.isArray(cfg.subnets)) {
161
+ const extra = cfg.subnets
162
+ .map((value) => String(value).trim())
163
+ .filter((value) => /^(\d{1,3}(\.\d{1,3}){3}(:\d{1,5})?|\d{1,3}(\.\d{1,3}){3}\/\d{1,2})$/.test(value))
164
+ state.settings.subnets = [...new Set([...(state.settings.subnets ?? []), ...extra])].slice(0, 16)
165
+ }
166
+ if (typeof cfg.name === 'string' && cfg.name.trim().length > 0) state.self.name = cfg.name.trim()
167
+ const rememberSignature = (signature, localId) => {
168
+ if (typeof signature !== 'string' || signature.length === 0) return
169
+ state.signatures[signature] = localId
170
+ const keys = Object.keys(state.signatures)
171
+ for (const stale of keys.slice(0, Math.max(0, keys.length - 500))) delete state.signatures[stale]
172
+ store.saveSoon()
173
+ }
174
+
175
+ const bridge = new SessionBridge(ctx, { home, onImported: rememberSignature, onExported: rememberSignature })
176
+
177
+ /** Imports that nobody filed under a workspace live here (DSH shows 未分组). */
178
+ const unsortedDir = () => path.join(stateDir, 'unsorted')
179
+
180
+ function ensureDir(dir) {
181
+ try {
182
+ fs.mkdirSync(dir, { recursive: true })
183
+ return true
184
+ } catch (error) {
185
+ log(`目录不可用 ${dir}: ${error?.message ?? error}`)
186
+ return false
187
+ }
188
+ }
189
+
190
+ /**
191
+ * Where an imported session should land: the configured directory, or the
192
+ * plugin's own unsorted folder, which belongs to no workspace so DSH groups
193
+ * those sessions under 未分组.
194
+ */
195
+ function syncTargetDir() {
196
+ const configured = typeof state.settings.syncDir === 'string' ? state.settings.syncDir : ''
197
+ const dir = configured.length > 0 ? configured : unsortedDir()
198
+ ensureDir(dir)
199
+ return dir
200
+ }
201
+
202
+ /**
203
+ * Workspaces DSH knows about, read from its own storage so the picker offers
204
+ * the same groups the sidebar shows. Missing/unreadable storage is not an
205
+ * error: the UI simply offers 未分组 plus a free-form path.
206
+ */
207
+ function workspaceList() {
208
+ const file = path.join(home, 'storages', 'workspace.json')
209
+ try {
210
+ const raw = JSON.parse(fs.readFileSync(file, 'utf8'))
211
+ const table = raw?.tables?.workspaces ?? {}
212
+ const order = Array.isArray(raw?.global?.workspaceIds) ? raw.global.workspaceIds : Object.keys(table)
213
+ const seen = new Set()
214
+ const list = []
215
+ for (const id of order) {
216
+ const record = table[id]
217
+ if (typeof record?.path !== 'string' || !path.isAbsolute(record.path)) continue
218
+ if (seen.has(record.path)) continue
219
+ seen.add(record.path)
220
+ list.push({ id, path: record.path, title: typeof record.title === 'string' && record.title.length > 0 ? record.title : path.basename(record.path) })
221
+ }
222
+ return list
223
+ } catch {
224
+ return []
225
+ }
226
+ }
227
+
228
+ /** Live peer table: id -> last beacon facts. Names live in `state.peers`. */
229
+ const livePeers = new Map()
230
+ let sessionCountCache = { value: null, at: 0 }
231
+
232
+ const lanPortWanted = Number.isInteger(cfg.lanPort) ? cfg.lanPort : state.settings.lanPort
233
+ const discoveryPortWanted = Number.isInteger(cfg.discoveryPort) ? cfg.discoveryPort : state.settings.discoveryPort
234
+
235
+ // Seeds from the loader row are developer/manual peers ("host:port" of the
236
+ // other instance's UDP discovery port); they are merged into the persisted
237
+ // list so a probe added via config keeps working after a restart.
238
+ if (Array.isArray(cfg.seeds)) {
239
+ const extra = cfg.seeds
240
+ .map((value) => (typeof value === 'string' ? value.trim() : ''))
241
+ .filter((value) => /^[0-9a-zA-Z._:-]+$/.test(value))
242
+ if (extra.length > 0) state.settings.seeds = [...new Set([...extra, ...state.settings.seeds])].slice(0, 32)
243
+ }
244
+ const token = typeof cfg.token === 'string' && cfg.token.length > 0 ? cfg.token : null
245
+
246
+ const lan = new LanServer({
247
+ port: lanPortWanted,
248
+ token,
249
+ bridge,
250
+ log,
251
+ // Remote peers push whole sessions; the signature table is what keeps a
252
+ // round-trip (pull, then push back) from duplicating the conversation.
253
+ duplicateOf: (signature) => state.signatures[signature] ?? null,
254
+ info: () => ({
255
+ id: state.self.id,
256
+ name: state.self.name,
257
+ version: VERSION,
258
+ platform: `${process.platform}-${process.arch}`,
259
+ host: os.hostname(),
260
+ port: lan.port,
261
+ sessions: sessionCountCache.value,
262
+ }),
263
+ })
264
+
265
+ let probeAt = 0
266
+ let probeFound = 0
267
+ /**
268
+ * Live byte counters for transfers in flight, keyed `<peerId>::<sessionId>`.
269
+ * The GUI polls `state` while its queue runs, so a pull can show real
270
+ * progress instead of a spinner that jumps 0 → 100.
271
+ */
272
+ const transfers = new Map()
273
+ const transferKey = (peerId, sessionId) => `${peerId}::${sessionId}`
274
+ const reportTransfer = (key, received, total) => {
275
+ if (received === null) {
276
+ transfers.delete(key)
277
+ return
278
+ }
279
+ transfers.set(key, { received, total, at: Date.now() })
280
+ }
281
+
282
+ /** Adopt one device into the live table, whether it came from UDP or HTTP. */
283
+ const absorb = (peer, via) => {
284
+ if (typeof peer.id !== 'string' || peer.id.length === 0) return
285
+ if (peer.id === state.self.id) return
286
+ if (!allowLoopbackPeers && localAddressSet.has(peer.address ?? '')) return
287
+ const previous = livePeers.get(peer.id)
288
+ livePeers.set(peer.id, {
289
+ id: peer.id,
290
+ name: peer.name,
291
+ address: peer.address,
292
+ port: peer.port,
293
+ platform: peer.platform,
294
+ version: peer.version,
295
+ sessions: peer.sessions,
296
+ via,
297
+ lastSeen: Date.now(),
298
+ })
299
+ const record = state.peers[peer.id] ?? (state.peers[peer.id] = {})
300
+ let changed = false
301
+ if (record.remoteName !== peer.name) {
302
+ record.remoteName = peer.name
303
+ changed = true
304
+ }
305
+ if (record.address !== peer.address) {
306
+ record.address = peer.address
307
+ changed = true
308
+ }
309
+ if (record.port !== peer.port) {
310
+ record.port = peer.port
311
+ changed = true
312
+ }
313
+ if (record.platform !== peer.platform) {
314
+ record.platform = peer.platform
315
+ changed = true
316
+ }
317
+ if (record.version !== peer.version) {
318
+ record.version = peer.version
319
+ changed = true
320
+ }
321
+ if (record.sessions !== peer.sessions) {
322
+ record.sessions = peer.sessions
323
+ changed = true
324
+ }
325
+ if (record.firstSeen === undefined) record.firstSeen = Date.now()
326
+ record.lastSeen = Date.now()
327
+ if (previous === undefined) {
328
+ log(`发现设备 ${peer.name || peer.address} (${peer.address}:${peer.port ?? '?'}) via ${via}`)
329
+ }
330
+ if (changed) store.saveSoon()
331
+ }
332
+
333
+ const discovery = new Discovery({
334
+ port: discoveryPortWanted,
335
+ log,
336
+ identity: () => ({
337
+ id: state.self.id,
338
+ name: state.self.name,
339
+ port: lan.port,
340
+ host: os.hostname(),
341
+ platform: `${process.platform}-${process.arch}`,
342
+ version: VERSION,
343
+ sessions: sessionCountCache.value,
344
+ }),
345
+ seeds: () => state.settings.seeds ?? [],
346
+ subnets: () => state.settings.subnets ?? [],
347
+ onPeer: (peer) => absorb(peer, 'udp'),
348
+ })
349
+
350
+ // ── lifecycle ─────────────────────────────────────────────────────────
351
+ const dispose = []
352
+ const onDispose = (fn) => {
353
+ dispose.push(fn)
354
+ }
355
+
356
+ const started = (async () => {
357
+ try {
358
+ await lan.start()
359
+ } catch (error) {
360
+ log(`局域网同步服务启动失败: ${error?.message ?? error}`)
361
+ }
362
+ try {
363
+ await discovery.start()
364
+ } catch (error) {
365
+ log(`发现服务启动失败: ${error?.message ?? error}`)
366
+ }
367
+ log(`已就绪:设备 ${state.self.name},同步端口 ${lan.port ?? '待定'},发现端口 ${discovery.boundPort ?? '待定'}`)
368
+ })()
369
+ started.catch(() => {})
370
+
371
+ onDispose(() => {
372
+ try {
373
+ discovery.close()
374
+ } catch {
375
+ /* ignore */
376
+ }
377
+ try {
378
+ lan.close()
379
+ } catch {
380
+ /* ignore */
381
+ }
382
+ store.dispose()
383
+ })
384
+
385
+ /** Probe every explicit target (bare IPs and `host:port` entries). */
386
+ let probing = false
387
+ const probeNow = async () => {
388
+ if (probing) return
389
+ const targets = [...(state.settings.subnets ?? []), ...(state.settings.seeds ?? [])]
390
+ .map((spec) => parseTarget(spec))
391
+ .filter((target) => target !== null)
392
+ if (targets.length === 0) return
393
+ probing = true
394
+ probeAt = Date.now()
395
+ try {
396
+ const found = await probeTargets(targets, { token, timeoutMs: 600 })
397
+ probeFound = found.length
398
+ for (const hit of found) {
399
+ absorb({
400
+ id: hit.info.id,
401
+ name: typeof hit.info.name === 'string' ? hit.info.name : '',
402
+ address: hit.host,
403
+ port: hit.port,
404
+ platform: typeof hit.info.platform === 'string' ? hit.info.platform : '',
405
+ version: typeof hit.info.version === 'string' ? hit.info.version : '',
406
+ sessions: Number.isInteger(hit.info.sessions) ? hit.info.sessions : null,
407
+ }, 'http')
408
+ }
409
+ } catch (error) {
410
+ log(`HTTP 探测失败: ${error?.message ?? error}`)
411
+ } finally {
412
+ probing = false
413
+ }
414
+ }
415
+
416
+ started.then(() => { void probeNow() }).catch(() => {})
417
+ const probeTimer = setInterval(() => { void probeNow() }, PROBE_INTERVAL_MS)
418
+ probeTimer.unref?.()
419
+ onDispose(() => clearInterval(probeTimer))
420
+
421
+ const refreshCount = async () => {
422
+ try {
423
+ sessionCountCache = { value: await bridge.count(), at: Date.now() }
424
+ } catch {
425
+ /* the count is decorative */
426
+ }
427
+ }
428
+ void refreshCount()
429
+ const countTimer = setInterval(() => { void refreshCount() }, 30_000)
430
+ countTimer.unref?.()
431
+ onDispose(() => clearInterval(countTimer))
432
+
433
+ // ── peer view ─────────────────────────────────────────────────────────
434
+ const peerView = (id, record, now) => {
435
+ const live = livePeers.get(id)
436
+ const lastSeen = live?.lastSeen ?? record?.lastSeen ?? null
437
+ const online = live !== undefined && now - live.lastSeen < PEER_TTL_MS
438
+ const remoteName = (online ? live.name : record?.remoteName) || record?.remoteName || null
439
+ const customName = typeof record?.customName === 'string' && record.customName.trim().length > 0
440
+ ? record.customName.trim()
441
+ : null
442
+ return {
443
+ id,
444
+ customName,
445
+ remoteName,
446
+ name: customName ?? remoteName ?? (online ? live.address : record?.address) ?? '未知设备',
447
+ online,
448
+ lastSeen,
449
+ address: (online ? live.address : record?.address) ?? null,
450
+ sameMachine: localAddresses().has((online ? live.address : record?.address) ?? ''),
451
+ port: (online ? live.port : record?.port) ?? null,
452
+ platform: (online ? live.platform : record?.platform) ?? null,
453
+ version: (online ? live.version : record?.version) ?? null,
454
+ sessionCount: (online ? live.sessions : record?.sessions) ?? null,
455
+ autoPull: typeof record?.autoPull === 'boolean' ? record.autoPull : null,
456
+ via: online ? (live.via ?? 'udp') : null,
457
+ }
458
+ }
459
+
460
+ const peerList = () => {
461
+ const now = Date.now()
462
+ const ids = new Set([...Object.keys(state.peers), ...livePeers.keys()])
463
+ // One IP is one device, whatever the port: several profiles on the same box
464
+ // collapse into a single row (freshest, online first).
465
+ const byAddress = new Map()
466
+ for (const id of ids) {
467
+ if (id === state.self.id) continue
468
+ const row = peerView(id, state.peers[id], now)
469
+ if (!allowLoopbackPeers && row.sameMachine === true) continue
470
+ const key = row.address ?? row.id
471
+ const current = byAddress.get(key)
472
+ if (current === undefined) { byAddress.set(key, row); continue }
473
+ const score = (peer) => (peer.online === true ? 2 : 0) + (peer.lastSeen ?? 0) / 1e13
474
+ if (score(row) > score(current)) byAddress.set(key, row)
475
+ }
476
+ const rows = [...byAddress.values()]
477
+ rows.sort((a, b) => {
478
+ if (a.online !== b.online) return a.online ? -1 : 1
479
+ return (b.lastSeen ?? 0) - (a.lastSeen ?? 0)
480
+ })
481
+ return rows
482
+ }
483
+
484
+ const importStatus = (peerId, sessionId) => {
485
+ const entry = state.imports[`${peerId}::${sessionId}`]
486
+ return entry === undefined ? null : entry
487
+ }
488
+
489
+ // ── remote calls ──────────────────────────────────────────────────────
490
+ const remoteBase = (peer, override) => {
491
+ const address = override?.address ?? peer.address
492
+ const port = override?.port ?? peer.port
493
+ if (typeof address !== 'string' || address.length === 0) {
494
+ throw new SessionSyncError('peer-offline', '该设备当前不在线,也没有记录到地址', 409)
495
+ }
496
+ if (!Number.isInteger(port) || port <= 0) {
497
+ throw new SessionSyncError('peer-offline', '该设备没有公布同步端口', 409)
498
+ }
499
+ const host = address.includes(':') ? `[${address}]` : address
500
+ return `http://${host}:${port}`
501
+ }
502
+
503
+ const remoteFetch = async (base, pathname, init = {}) => {
504
+ const headers = {
505
+ 'x-dsh-session-sync': '1',
506
+ ...(token === null ? {} : { 'x-dsh-session-sync-token': token }),
507
+ ...(init.body === undefined ? {} : { 'content-type': 'application/json' }),
508
+ ...(init.headers ?? {}),
509
+ }
510
+ let response
511
+ try {
512
+ response = await fetch(`${base}${pathname}`, {
513
+ ...init,
514
+ headers,
515
+ signal: AbortSignal.timeout(init.timeoutMs ?? (init.body === undefined ? 20_000 : 180_000)),
516
+ })
517
+ } catch (error) {
518
+ throw new SessionSyncError('peer-unreachable', `连接设备失败: ${error?.message ?? error}`, 502)
519
+ }
520
+ const text = await response.text()
521
+ let body
522
+ try {
523
+ body = text.length > 0 ? JSON.parse(text) : {}
524
+ } catch {
525
+ throw new SessionSyncError('peer-bad-response', '设备返回了无法解析的数据', 502)
526
+ }
527
+ if (!response.ok || body?.ok === false) {
528
+ throw new SessionSyncError(
529
+ body?.error?.code ?? 'peer-error',
530
+ body?.error?.message ?? `设备返回 ${response.status}`,
531
+ response.status === 401 ? 401 : 502,
532
+ )
533
+ }
534
+ return body
535
+ }
536
+
537
+ /**
538
+ * Fetch one session with a live byte counter. `remoteFetch` buffers the whole
539
+ * response and can only tell the UI "done", which is exactly the gap that made
540
+ * the transfer list feel dead.
541
+ */
542
+ const remoteFetchStreaming = async (base, pathname, onProgress) => {
543
+ const headers = {
544
+ 'x-dsh-session-sync': '1',
545
+ ...(token === null ? {} : { 'x-dsh-session-sync-token': token }),
546
+ }
547
+ let response
548
+ try {
549
+ response = await fetch(`${base}${pathname}`, { headers, signal: AbortSignal.timeout(180_000) })
550
+ } catch (error) {
551
+ throw new SessionSyncError('peer-unreachable', `连接设备失败: ${error?.message ?? error}`, 502)
552
+ }
553
+ const declared = Number(response.headers.get('content-length') ?? '')
554
+ const total = Number.isFinite(declared) && declared > 0 ? declared : null
555
+ if (!response.ok) {
556
+ const text = await response.text()
557
+ let message = `设备返回 ${response.status}`
558
+ try {
559
+ const parsed = JSON.parse(text)
560
+ if (typeof parsed?.error?.message === 'string') message = parsed.error.message
561
+ } catch {
562
+ /* keep the status code message */
563
+ }
564
+ throw new SessionSyncError('peer-error', message, response.status === 401 ? 401 : 502)
565
+ }
566
+ const reader = response.body.getReader()
567
+ const chunks = []
568
+ let received = 0
569
+ onProgress(received, total)
570
+ for (;;) {
571
+ const { done, value } = await reader.read()
572
+ if (done === true) break
573
+ chunks.push(value)
574
+ received += value.length
575
+ onProgress(received, total)
576
+ }
577
+ const text = Buffer.concat(chunks.map((chunk) => Buffer.from(chunk))).toString('utf8')
578
+ let body
579
+ try {
580
+ body = text.length > 0 ? JSON.parse(text) : {}
581
+ } catch {
582
+ throw new SessionSyncError('peer-bad-response', '设备返回了无法解析的数据', 502)
583
+ }
584
+ if (body?.ok === false) {
585
+ throw new SessionSyncError(body?.error?.code ?? 'peer-error', body?.error?.message ?? '设备拒绝了请求', 502)
586
+ }
587
+ return body
588
+ }
589
+
590
+ const findPeer = (peerId) => {
591
+ const view = peerView(peerId, state.peers[peerId], Date.now())
592
+ if (view.address === null) throw new SessionSyncError('unknown-peer', '未知设备', 404)
593
+ return view
594
+ }
595
+
596
+ // ── GUI API ───────────────────────────────────────────────────────────
597
+ /** Attach a freshly imported session to the workspace the drop named. */
598
+ const placeInWorkspace = async (localId, target) => {
599
+ let registry = null
600
+ try {
601
+ registry = ctx.get?.('workspaceRegistry') ?? null
602
+ } catch {
603
+ registry = null
604
+ }
605
+ if (registry === null) return null
606
+ try {
607
+ return await attachImportedSession(registry, localId, target)
608
+ } catch (error) {
609
+ log(`放入工作区失败: ${error?.message ?? error}`)
610
+ return false
611
+ }
612
+ }
613
+
614
+ const api = {
615
+ /**
616
+ * Repair logs that an older build imported with an inherited end-seed
617
+ * marker: DSH reports them as "unseeded Session contains an inherited
618
+ * end-seed marker" and refuses to open the conversation.
619
+ */
620
+ async repairLogs(payload) {
621
+ const dryRun = payload?.dryRun === true
622
+ const summary = repairStoredSessions(path.join(home, 'sessions'), { dryRun, log })
623
+ return { ...summary, dryRun }
624
+ },
625
+ async state() {
626
+ const peers = peerList()
627
+ const imports = Object.entries(state.imports).map(([key, value]) => {
628
+ const [peerId, sessionId] = key.split('::')
629
+ return { peerId, sessionId, ...value }
630
+ })
631
+ return {
632
+ version: VERSION,
633
+ repo: REPO_URL,
634
+ license: LICENSE,
635
+ probe: { at: probeAt, found: probeFound, targets: (state.settings.subnets ?? []).length + (state.settings.seeds ?? []).length },
636
+ self: { ...state.self, ip: localAddress(), port: lan.port },
637
+ platform: process.platform,
638
+ arch: process.arch,
639
+ peers,
640
+ imports,
641
+ settings: { ...state.settings },
642
+ syncDir: syncTargetDir(),
643
+ unsortedDir: unsortedDir(),
644
+ workspaces: workspaceList(),
645
+ ports: { lan: lan.port, discovery: discovery.boundPort },
646
+ concurrency: resolveConcurrency(state.settings.concurrency),
647
+ progress: Object.fromEntries(transfers),
648
+ hasPersistence: bridge.hasPersistence,
649
+ sessionCount: sessionCountCache.value,
650
+ }
651
+ },
652
+
653
+ /** Platform/version facts — rendered by the client's crash panel, never required in normal use. */
654
+ async diag() {
655
+ return {
656
+ platform: process.platform,
657
+ arch: process.arch,
658
+ node: process.version,
659
+ version: VERSION,
660
+ ports: { lan: lan.port, discovery: discovery.boundPort },
661
+ route: API_PREFIX,
662
+ probe: { at: probeAt, found: probeFound, targets: (state.settings.subnets ?? []).length + (state.settings.seeds ?? []).length },
663
+ hasPersistence: bridge.hasPersistence,
664
+ sessionCount: sessionCountCache.value,
665
+ selfIp: localAddress(),
666
+ }
667
+ },
668
+
669
+ async rename(payload) {
670
+ const peerId = String(payload?.peerId ?? '')
671
+ if (peerId.length === 0) throw new SessionSyncError('bad-request', '缺少设备 id', 400)
672
+ const raw = typeof payload?.name === 'string' ? payload.name.trim() : ''
673
+ const record = state.peers[peerId] ?? (state.peers[peerId] = {})
674
+ if (raw.length === 0) delete record.customName
675
+ else record.customName = raw.slice(0, 60)
676
+ store.saveNow()
677
+ return { peer: peerView(peerId, record, Date.now()) }
678
+ },
679
+
680
+ async forget(payload) {
681
+ const peerId = String(payload?.peerId ?? '')
682
+ if (peerId.length === 0) throw new SessionSyncError('bad-request', '缺少设备 id', 400)
683
+ delete state.peers[peerId]
684
+ livePeers.delete(peerId)
685
+ for (const key of Object.keys(state.imports)) {
686
+ if (key.startsWith(`${peerId}::`)) delete state.imports[key]
687
+ }
688
+ store.saveNow()
689
+ return { ok: true }
690
+ },
691
+
692
+ async selfName(payload) {
693
+ const raw = typeof payload?.name === 'string' ? payload.name.trim() : ''
694
+ if (raw.length === 0) throw new SessionSyncError('bad-request', '设备名不能为空', 400)
695
+ state.self.name = raw.slice(0, 60)
696
+ store.saveNow()
697
+ discovery.beacon()
698
+ return { self: { ...state.self } }
699
+ },
700
+
701
+ async settings(payload) {
702
+ const patch = isPlainObject(payload?.patch) ? payload.patch : {}
703
+ if (patch.locale === 'auto' || patch.locale === 'zh' || patch.locale === 'en') state.settings.locale = patch.locale
704
+ if (typeof patch.autoPull === 'boolean') state.settings.autoPull = patch.autoPull
705
+ if (patch.concurrency !== undefined) {
706
+ const asked = Number(patch.concurrency)
707
+ // 0 keeps "decide from the machine"; 1..16 is a hard cap the user chose.
708
+ state.settings.concurrency = Number.isInteger(asked) && asked >= 0 && asked <= 16 ? asked : state.settings.concurrency
709
+ }
710
+ if (typeof patch.autoPush === 'boolean') state.settings.autoPush = patch.autoPush
711
+ if (Array.isArray(patch.subnets)) {
712
+ state.settings.subnets = patch.subnets
713
+ .filter((value) => typeof value === 'string')
714
+ .map((value) => value.trim())
715
+ .filter((value) => value.length > 0)
716
+ .filter((value) => /^(\d{1,3}(\.\d{1,3}){3}(:\d{1,5})?|\d{1,3}(\.\d{1,3}){3}\/\d{1,2})$/.test(value))
717
+ .slice(0, 16)
718
+ }
719
+ if (typeof patch.syncDir === 'string') {
720
+ const dir = patch.syncDir.trim()
721
+ if (dir.length > 0 && !path.isAbsolute(dir)) throw new SessionSyncError('bad-request', '同步目录必须是绝对路径', 400)
722
+ if (dir.length > 0 && !ensureDir(dir)) throw new SessionSyncError('bad-request', '同步目录无法创建', 400)
723
+ state.settings.syncDir = dir
724
+ }
725
+ if (Array.isArray(patch.seeds)) {
726
+ state.settings.seeds = patch.seeds
727
+ .map((value) => String(value).trim())
728
+ .filter((value) => /^[0-9a-zA-Z._-]+(:\d{1,5})?$/.test(value))
729
+ .slice(0, 32)
730
+ }
731
+ store.saveNow()
732
+ discovery.refresh()
733
+ void probeNow()
734
+ return { settings: { ...state.settings } }
735
+ },
736
+
737
+ async peerAutoPull(payload) {
738
+ const peerId = String(payload?.peerId ?? '')
739
+ if (peerId.length === 0) throw new SessionSyncError('bad-request', '缺少设备 id', 400)
740
+ const record = state.peers[peerId] ?? (state.peers[peerId] = {})
741
+ if (payload?.enabled === null || payload?.enabled === undefined) delete record.autoPull
742
+ else record.autoPull = payload.enabled === true
743
+ store.saveNow()
744
+ return { peer: peerView(peerId, record, Date.now()) }
745
+ },
746
+
747
+ async peerSessions(payload) {
748
+ const peer = findPeer(String(payload?.peerId ?? ''))
749
+ const base = remoteBase(peer)
750
+ const body = await remoteFetch(base, `/v1/sessions?limit=${clampLimit(payload?.limit)}`)
751
+ const sessions = Array.isArray(body.sessions) ? body.sessions : []
752
+ return {
753
+ peer,
754
+ sessions: sessions.map((session) => ({
755
+ ...session,
756
+ imported: importStatus(peer.id, session.id),
757
+ })),
758
+ }
759
+ },
760
+
761
+ async pull(payload) {
762
+ const peer = findPeer(String(payload?.peerId ?? ''))
763
+ const sessionId = String(payload?.sessionId ?? '')
764
+ if (sessionId.length === 0) throw new SessionSyncError('bad-request', '缺少会话 id', 400)
765
+ const existing = importStatus(peer.id, sessionId)
766
+ if (existing !== undefined && existing !== null && payload?.force !== true) {
767
+ return { skipped: true, imported: existing }
768
+ }
769
+ const base = remoteBase(peer)
770
+ const key = transferKey(peer.id, sessionId)
771
+ let body
772
+ try {
773
+ body = await remoteFetchStreaming(base, `/v1/sessions/${encodeURIComponent(sessionId)}`, (received, total) => {
774
+ reportTransfer(key, received, total)
775
+ })
776
+ } catch (error) {
777
+ reportTransfer(key, null)
778
+ throw error
779
+ }
780
+ const result = await bridge.importSession(
781
+ { session: body.session, events: body.events, sha256: body.sha256 },
782
+ { cwd: payload?.cwd },
783
+ )
784
+ const entry = {
785
+ localId: result.id,
786
+ at: Date.now(),
787
+ title: (await bridge.titleOf(result.id)) ?? null,
788
+ events: result.events,
789
+ sha256: result.sha256,
790
+ verified: result.verified,
791
+ }
792
+ const placed = await placeInWorkspace(result.id, payload)
793
+ state.imports[`${peer.id}::${sessionId}`] = entry
794
+ store.saveNow()
795
+ void refreshCount()
796
+ reportTransfer(key, null)
797
+ log(`已拉取会话 ${sessionId} → ${result.id}${result.verified ? '(sha256 校验通过)' : ''}${placed === true ? '(已放入工作区)' : ''}`)
798
+ return { imported: entry, peer, placed }
799
+ },
800
+
801
+ async push(payload) {
802
+ const peer = findPeer(String(payload?.peerId ?? ''))
803
+ const sessionId = String(payload?.sessionId ?? '')
804
+ if (sessionId.length === 0) throw new SessionSyncError('bad-request', '缺少会话 id', 400)
805
+ const exported = await bridge.exportSession(sessionId)
806
+ const base = remoteBase(peer)
807
+ const body = await remoteFetch(base, `/v1/sessions${payload?.force === true ? '?force=1' : ''}`, {
808
+ method: 'POST',
809
+ body: JSON.stringify(exported),
810
+ })
811
+ if (body.skipped === true) {
812
+ log(`推送跳过:${peer.name} 上已有相同会话(远端 ${body.id})`)
813
+ return { ok: true, skipped: true, remoteId: body.id ?? null, peer }
814
+ }
815
+ log(`已推送会话 ${sessionId} → ${peer.name}(远端 ${body.id})`)
816
+ return { ok: true, remoteId: body.id ?? null, peer }
817
+ },
818
+
819
+ async refresh() {
820
+ discovery.beacon()
821
+ await refreshCount()
822
+ const probes = peerList()
823
+ .filter((peer) => peer.online && peer.address !== null)
824
+ .slice(0, 32)
825
+ .map(async (peer) => {
826
+ try {
827
+ const body = await remoteFetch(remoteBase(peer), '/v1/info', { timeoutMs: 4000 })
828
+ const record = state.peers[peer.id] ?? (state.peers[peer.id] = {})
829
+ record.remoteName = body.name ?? record.remoteName
830
+ record.sessions = Number.isInteger(body.sessions) ? body.sessions : record.sessions
831
+ record.version = body.version ?? record.version
832
+ record.lastSeen = Date.now()
833
+ } catch {
834
+ /* a probe failure just means the device is busy or gone */
835
+ }
836
+ })
837
+ await Promise.all(probes)
838
+ store.saveSoon()
839
+ return { peers: peerList() }
840
+ },
841
+
842
+ async localSessions(payload) {
843
+ const sessions = await bridge.list({ limit: clampLimit(payload?.limit) })
844
+ return { sessions }
845
+ },
846
+
847
+ async verify(payload) {
848
+ const peer = findPeer(String(payload?.peerId ?? ''))
849
+ const sessionId = String(payload?.sessionId ?? '')
850
+ if (sessionId.length === 0) throw new SessionSyncError('bad-request', '缺少会话 id', 400)
851
+ const body = await remoteFetch(remoteBase(peer), `/v1/sessions/${encodeURIComponent(sessionId)}`)
852
+ const declared = typeof body?.sha256 === 'string' && body.sha256.length > 0
853
+ ? body.sha256.toLowerCase()
854
+ : null
855
+ const actual = sessionDigest(body.session, body.events)
856
+ const local = state.imports[`${peer.id}::${sessionId}`] ?? null
857
+ const localDigest = typeof local?.sha256 === 'string' ? local.sha256 : null
858
+ const result = {
859
+ peer,
860
+ sessionId,
861
+ declared,
862
+ sha256: actual,
863
+ verified: declared === null ? null : declared === actual,
864
+ localMatch: localDigest === null ? null : localDigest === actual,
865
+ events: Array.isArray(body.events) ? body.events.length : 0,
866
+ }
867
+ log(
868
+ result.verified === false
869
+ ? `会话 ${sessionId} 校验失败:期望 ${declared?.slice(0, 12)}…,实际 ${actual.slice(0, 12)}…`
870
+ : `会话 ${sessionId} 校验:sha256 ${actual.slice(0, 12)}…`,
871
+ )
872
+ return result
873
+ },
874
+
875
+ async forgetImport(payload) {
876
+ const peerId = String(payload?.peerId ?? '')
877
+ const sessionId = String(payload?.sessionId ?? '')
878
+ if (peerId.length === 0 || sessionId.length === 0) throw new SessionSyncError('bad-request', '参数不完整', 400)
879
+ delete state.imports[`${peerId}::${sessionId}`]
880
+ store.saveNow()
881
+ return { ok: true }
882
+ },
883
+ }
884
+
885
+ const handleApi = async (req, res) => {
886
+ const url = new URL(req.url ?? '/', 'http://dsh-session-sync.internal')
887
+ if (url.pathname === `${API_PREFIX}/version`) {
888
+ writeJson(res, 200, { ok: true, version: VERSION })
889
+ return
890
+ }
891
+ if (req.method !== 'POST') {
892
+ writeJson(res, 405, { ok: false, error: { code: 'method', message: '只接受 POST' } })
893
+ return
894
+ }
895
+ const connection = ctx.get?.('connection') ?? ctx.connection
896
+ if (typeof connection?.requestRejection === 'function') {
897
+ const rejection = connection.requestRejection(req)
898
+ if (rejection !== undefined) {
899
+ writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: '请求被连接策略拒绝' } })
900
+ return
901
+ }
902
+ }
903
+ let payload = {}
904
+ if (req.method === 'POST') {
905
+ try {
906
+ payload = await readBody(req)
907
+ } catch (error) {
908
+ writeJson(res, 400, { ok: false, error: apiError(error) })
909
+ return
910
+ }
911
+ }
912
+ const action = String(payload.action ?? url.searchParams.get('action') ?? '')
913
+ const handler = Object.hasOwn(api, action) ? api[action] : undefined
914
+ if (typeof handler !== 'function') {
915
+ writeJson(res, 404, { ok: false, error: { code: 'unknown-action', message: `未知操作: ${action}` } })
916
+ return
917
+ }
918
+ try {
919
+ const result = await handler(payload)
920
+ writeJson(res, 200, { ok: true, ...result })
921
+ } catch (error) {
922
+ const mapped = error instanceof SessionSyncError
923
+ ? { code: error.code, message: error.message }
924
+ : apiError(error)
925
+ if (!(error instanceof SessionSyncError)) log(`操作 ${action} 失败: ${error?.stack ?? error}`)
926
+ writeJson(res, error instanceof SessionSyncError ? error.status : 500, { ok: false, error: mapped })
927
+ }
928
+ }
929
+
930
+ const register = () => ctx.webServer.register({
931
+ kind: 'prefix',
932
+ path: '/session-sync',
933
+ handler: (req, res) => {
934
+ handleApi(req, res).catch((error) => {
935
+ writeJson(res, 500, { ok: false, error: apiError(error) })
936
+ })
937
+ },
938
+ })
939
+
940
+ if (typeof ctx.effect === 'function') {
941
+ ctx.effect(register, 'dsh-session-sync: GUI API routes')
942
+ ctx.effect(() => () => {
943
+ for (const fn of dispose) {
944
+ try {
945
+ fn()
946
+ } catch {
947
+ /* ignore */
948
+ }
949
+ }
950
+ }, 'dsh-session-sync: discovery and sync lifecycle')
951
+ } else {
952
+ const disposer = register()
953
+ if (typeof disposer === 'function') onDispose(disposer)
954
+ }
955
+
956
+ // ── optional auto-pull ────────────────────────────────────────────────
957
+ const autoTimer = setInterval(() => {
958
+ void autoPull()
959
+ }, autoPullIntervalMs)
960
+ autoTimer.unref?.()
961
+ onDispose(() => clearInterval(autoTimer))
962
+
963
+ let autoRunning = false
964
+ async function autoPull() {
965
+ if (autoRunning) return
966
+ if (state.settings.autoPull !== true) return
967
+ autoRunning = true
968
+ try {
969
+ const peers = peerList().filter((peer) => peer.online && peer.address !== null)
970
+ for (const peer of peers) {
971
+ const record = state.peers[peer.id]
972
+ const enabled = typeof record?.autoPull === 'boolean' ? record.autoPull : true
973
+ if (!enabled) continue
974
+ try {
975
+ const base = remoteBase(peer)
976
+ const body = await remoteFetch(base, '/v1/sessions?limit=20', { timeoutMs: 8000 })
977
+ const sessions = Array.isArray(body.sessions) ? body.sessions : []
978
+ let importedThisRound = 0
979
+ for (const session of sessions) {
980
+ if (importedThisRound >= 5) break
981
+ if (importStatus(peer.id, session.id) !== null) continue
982
+ const one = await remoteFetch(base, `/v1/sessions/${encodeURIComponent(session.id)}`)
983
+ // Same conversation, new id: skip it instead of bouncing copies
984
+ // between two devices that both auto-pull.
985
+ const signature = sessionSignature(one.session, one.events)
986
+ if (state.signatures[signature] !== undefined) continue
987
+ const result = await bridge.importSession(
988
+ { session: one.session, events: one.events, sha256: one.sha256 },
989
+ { cwd: syncTargetDir() },
990
+ )
991
+ state.imports[`${peer.id}::${session.id}`] = {
992
+ localId: result.id,
993
+ at: Date.now(),
994
+ title: typeof one.session?.title === 'string' ? one.session.title : null,
995
+ events: result.events,
996
+ }
997
+ importedThisRound += 1
998
+ log(`自动拉取 ${peer.name} 的会话 ${session.id} → ${result.id}`)
999
+ }
1000
+ if (importedThisRound > 0) {
1001
+ store.saveNow()
1002
+ void refreshCount()
1003
+ }
1004
+ } catch (error) {
1005
+ log(`自动拉取 ${peer.name} 失败: ${error?.message ?? error}`)
1006
+ }
1007
+ }
1008
+ } finally {
1009
+ autoRunning = false
1010
+ }
1011
+ }
1012
+
1013
+ return { state, store, lan, discovery, bridge, ready: started }
1014
+ }
1015
+
1016
+ function readBody(req) {
1017
+ return new Promise((resolve, reject) => {
1018
+ const chunks = []
1019
+ let size = 0
1020
+ req.on('data', (chunk) => {
1021
+ size += chunk.length
1022
+ if (size > 4 * 1024 * 1024) {
1023
+ reject(new SessionSyncError('too-large', '请求体过大', 413))
1024
+ req.destroy()
1025
+ return
1026
+ }
1027
+ chunks.push(chunk)
1028
+ })
1029
+ req.on('end', () => {
1030
+ if (size === 0) {
1031
+ resolve({})
1032
+ return
1033
+ }
1034
+ try {
1035
+ resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')))
1036
+ } catch {
1037
+ reject(new SessionSyncError('bad-json', '请求体不是合法 JSON', 400))
1038
+ }
1039
+ })
1040
+ req.on('error', reject)
1041
+ })
1042
+ }
1043
+
1044
+ function writeJson(res, status, value) {
1045
+ if (res.headersSent) return
1046
+ const body = JSON.stringify(value)
1047
+ res.writeHead(status, {
1048
+ 'content-type': 'application/json; charset=utf-8',
1049
+ 'cache-control': 'no-store',
1050
+ 'content-length': Buffer.byteLength(body),
1051
+ })
1052
+ res.end(body)
1053
+ }