@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/CHANGELOG.md +74 -0
- package/CHANGELOG_ZH.md +59 -0
- package/LICENSE +21 -0
- package/README.md +183 -2
- package/README_ZH.md +185 -0
- package/cordis.patch.yml +15 -0
- package/icon.svg +9 -0
- package/lib/client.js +4413 -0
- package/lib/discovery.js +354 -0
- package/lib/index.js +1053 -0
- package/lib/lan.js +213 -0
- package/lib/logfile.js +80 -0
- package/lib/probe.js +100 -0
- package/lib/repair.js +150 -0
- package/lib/sessions.js +501 -0
- package/lib/state.js +236 -0
- package/package.json +64 -4
- package/screenshots.json +1 -0
- package/scripts/repair-sessions.mjs +31 -0
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
|
+
}
|