most-box 0.1.9 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (157) hide show
  1. package/README.md +16 -3
  2. package/out/admin/index.html +0 -0
  3. package/out/app/index.html +0 -0
  4. package/out/assets/AppShell-CQhg6DJU.js +1 -0
  5. package/out/assets/ChatUi-BepWs-ZU.js +1 -0
  6. package/out/assets/LanguageToggle-CtzCCAYv.js +1 -0
  7. package/out/assets/{LogoIcon-CYQ7cHd5.js → LogoIcon-Dxto3Sb4.js} +1 -1
  8. package/out/assets/{MarketingLayout-BTIbv4fW.js → MarketingLayout-BQw0IS2i.js} +1 -1
  9. package/out/assets/MarketingThemeToggle-DBaC9bjz.js +1 -0
  10. package/out/assets/MilkdownEditor-BqJzntYE.js +1054 -0
  11. package/out/assets/MoveModal-4D9n11Kw.js +1 -0
  12. package/out/assets/Nav-9MDdvgNs.js +1 -0
  13. package/out/assets/NoteSidebar-C-rIt32H.js +1 -0
  14. package/out/assets/OpenSidebarButton-Dd0JmKuE.js +1 -0
  15. package/out/assets/PemBlock-C8dEIzu-.js +1 -0
  16. package/out/assets/SidebarAccount-ClS-N0lq.js +1 -0
  17. package/out/assets/arrow-right-urE9Rd7j.js +1 -0
  18. package/out/assets/channelApi-BwQU0-h1.js +1 -0
  19. package/out/assets/check-DUNsD2t6.js +1 -0
  20. package/out/assets/chevron-down-D6mpsfv4.js +1 -0
  21. package/out/assets/{circle-alert-CqEQz6P4.js → circle-alert-W0iyN4sC.js} +1 -1
  22. package/out/assets/cloud-BMyOoC2x.js +1 -0
  23. package/out/assets/code-B1Cb_Icm.js +1 -0
  24. package/out/assets/{copy-CM-qWlbv.js → copy-C1MttOli.js} +1 -1
  25. package/out/assets/{dist-CvatM4u8.js → dist-8QDHqrPN.js} +1 -1
  26. package/out/assets/{dist-BmtYO1GG.js → dist-B0JrbG7f.js} +5 -5
  27. package/out/assets/{dist-DBpw-A8y.js → dist-BFSyuOuw.js} +1 -1
  28. package/out/assets/{dist-D5Cf0hK8.js → dist-BPs0Xns9.js} +2 -2
  29. package/out/assets/{dist-BQV5zYG_.js → dist-Br_5lLVO.js} +9 -9
  30. package/out/assets/{dist-CrzjJUOw.js → dist-BzqqCPi2.js} +1 -1
  31. package/out/assets/dist-C3lbe8SW.js +9 -0
  32. package/out/assets/{dist-CWJ3323z.js → dist-CfU9fwWz.js} +1 -1
  33. package/out/assets/{dist-BdmTLuCI.js → dist-DCX0ws1K.js} +1 -1
  34. package/out/assets/{dist-DrumcFOX.js → dist-DJdv-Ma3.js} +1 -1
  35. package/out/assets/{dist-C5HibLEW.js → dist-chOCTzB2.js} +1 -1
  36. package/out/assets/{download-0rM8xVCe.js → download-y7SZXu6E.js} +1 -1
  37. package/out/assets/downloadValidation-B0p9Ai_9.js +1 -0
  38. package/out/assets/filePreview-UI9NH34f.js +1 -0
  39. package/out/assets/format-CR8oUWq6.js +1 -0
  40. package/out/assets/game-CdU3xnZo.js +1 -0
  41. package/out/assets/{hard-drive-CCdIvSap.js → hard-drive-D13Qbobu.js} +1 -1
  42. package/out/assets/image-DJCA16l_.js +1 -0
  43. package/out/assets/index-BdaFEQG-.css +1 -0
  44. package/out/assets/index-QxXZzOUL.js +33 -0
  45. package/out/assets/index.lazy-BBTTFanX.js +1 -0
  46. package/out/assets/index.lazy-BG4ZylHD.js +2 -0
  47. package/out/assets/index.lazy-Bi-6ZXZX.js +1 -0
  48. package/out/assets/index.lazy-BixWVr0B.js +1 -0
  49. package/out/assets/index.lazy-BjFwNYy5.js +3 -0
  50. package/out/assets/index.lazy-C8EIQsXY.js +2 -0
  51. package/out/assets/index.lazy-CarNe2uu.js +1 -0
  52. package/out/assets/index.lazy-DEuGu3H3.js +1 -0
  53. package/out/assets/index.lazy-GPyILCA7.js +3 -0
  54. package/out/assets/index.lazy-I8ofndXl.js +1 -0
  55. package/out/assets/index.lazy-TxhWsA7y.js +1 -0
  56. package/out/assets/index.lazy-azfky8k7.js +1 -0
  57. package/out/assets/{key-round-tIqGrtt_.js → key-round-CZniN9lv.js} +1 -1
  58. package/out/assets/lock-D5OSNhep.js +1 -0
  59. package/out/assets/log-out-B6phyZ5z.js +1 -0
  60. package/out/assets/{music-BkZKq879.js → music-CbUskKgg.js} +1 -1
  61. package/out/assets/{notebook-pen-B4VSbweh.js → notebook-pen-DqKDQ6MJ.js} +1 -1
  62. package/out/assets/play-BIl8q9eU.js +1 -0
  63. package/out/assets/plus-BxxbpH6Q.js +1 -0
  64. package/out/assets/{save-BzjzC3eV.js → save-DkH1n_Ov.js} +1 -1
  65. package/out/assets/search-BQi5Z0E-.js +1 -0
  66. package/out/assets/{send-DtQInX0y.js → send-Cl6NtD2T.js} +1 -1
  67. package/out/assets/{trash-2-BhMrUgGM.js → trash-2-BBjpgK_f.js} +1 -1
  68. package/out/assets/triangle-alert-l98G8u9O.js +1 -0
  69. package/out/assets/upload-ByP6Ydde.js +1 -0
  70. package/out/assets/{useChannelMessages-Bs1hEJyd.js → useChannelMessages-BgbYfF2c.js} +2 -2
  71. package/out/assets/useGameRoom-DPmweWwe.js +1 -0
  72. package/out/assets/{wallet-YxbxCi7C.js → wallet-c7zIhNSM.js} +1 -1
  73. package/out/assets/{wifi-v3JpPCNm.js → wifi-Bm4biAjc.js} +1 -1
  74. package/out/chat/index.html +0 -0
  75. package/out/chat/join/index.html +0 -0
  76. package/out/demo/index.html +0 -0
  77. package/out/download/index.html +6 -2
  78. package/out/game/gandengyan/index.html +0 -0
  79. package/out/game/index.html +0 -0
  80. package/out/game/zhajinhua/index.html +0 -0
  81. package/out/index.html +6 -2
  82. package/out/note/index.html +0 -0
  83. package/out/ping/index.html +6 -2
  84. package/out/web3/index.html +0 -0
  85. package/package.json +2 -2
  86. package/server/index.js +9 -0
  87. package/server/src/core/channelAttachment.js +7 -3
  88. package/server/src/core/channelIdentity.js +50 -0
  89. package/server/src/core/cid.js +6 -1
  90. package/server/src/core/cidTopic.js +18 -4
  91. package/server/src/core/displayPath.js +10 -0
  92. package/server/src/core/gameRoom.js +2 -2
  93. package/server/src/core/mostLink.js +45 -25
  94. package/server/src/core/ownerMetadata.js +34 -0
  95. package/server/src/core/userSyncKeys.js +36 -0
  96. package/server/src/http/app.js +71 -149
  97. package/server/src/http/dataPath.js +26 -0
  98. package/server/src/http/errors.js +8 -4
  99. package/server/src/http/nodeStatus.js +13 -13
  100. package/server/src/http/rateLimit.js +39 -0
  101. package/server/src/http/routePolicy.js +43 -0
  102. package/server/src/index.js +1909 -759
  103. package/server/src/node/offlineSwarm.js +20 -0
  104. package/server/src/utils/api.js +1 -15
  105. package/server/src/utils/downloadMessages.js +17 -18
  106. package/server/src/utils/errors.js +3 -1
  107. package/server/src/utils/noteUtils.js +27 -3
  108. package/out/assets/AppShell-DmZQwVA9.js +0 -1
  109. package/out/assets/ChatUi-CVGqjFdx.js +0 -1
  110. package/out/assets/MilkdownEditor-BL8xE7u9.js +0 -1054
  111. package/out/assets/MoveModal-BKkVBvrS.js +0 -1
  112. package/out/assets/Nav-BDGeJnbC.js +0 -1
  113. package/out/assets/NoteSidebar-BIJ8_m5K.js +0 -1
  114. package/out/assets/OpenSidebarButton-Di62DGiu.js +0 -1
  115. package/out/assets/PemBlock-Dxx6k9MH.js +0 -1
  116. package/out/assets/SidebarAccount-CCHZLGdP.js +0 -1
  117. package/out/assets/admin-BepWGXWG.js +0 -2
  118. package/out/assets/app-3D79fY3w.js +0 -1
  119. package/out/assets/arrow-right-D0sGC8QA.js +0 -1
  120. package/out/assets/channelApi-CL7YsIQ-.js +0 -1
  121. package/out/assets/chat-B56sk6od.js +0 -1
  122. package/out/assets/check-DdfnsLKm.js +0 -1
  123. package/out/assets/chevron-down-Xlb3wTxd.js +0 -1
  124. package/out/assets/circle-check-CwAH4dgJ.js +0 -1
  125. package/out/assets/cloud-CcPRoob1.js +0 -1
  126. package/out/assets/code-Dr6STnCn.js +0 -1
  127. package/out/assets/database-DQ7ZtUT9.js +0 -1
  128. package/out/assets/dateTime-D1koKRQU.js +0 -1
  129. package/out/assets/demo-B_6rlIjn.js +0 -3
  130. package/out/assets/dist-BGtXa07s.js +0 -9
  131. package/out/assets/download-BLPU-Kzq.js +0 -1
  132. package/out/assets/downloadMessages-7Xbd-HhS.js +0 -1
  133. package/out/assets/ed25519-BEctXF0E.js +0 -1
  134. package/out/assets/filePreview-BvbHWUTG.js +0 -1
  135. package/out/assets/folder-CcbCxm-k.js +0 -1
  136. package/out/assets/game-B0zuqnOh.js +0 -1
  137. package/out/assets/gandengyan-DbQC7hCK.js +0 -1
  138. package/out/assets/index-BLhmAher.css +0 -1
  139. package/out/assets/index-Cf23WD2V.js +0 -29
  140. package/out/assets/join-DQHXjlfH.js +0 -1
  141. package/out/assets/note-DmWqGSS2.js +0 -2
  142. package/out/assets/ping-JILckfMu.js +0 -1
  143. package/out/assets/play-BIl5vwqS.js +0 -1
  144. package/out/assets/plus-DHvLpuuw.js +0 -1
  145. package/out/assets/routes-Dyckj88f.js +0 -1
  146. package/out/assets/search-C-EpsDNl.js +0 -1
  147. package/out/assets/sun-C3IUQTpa.js +0 -1
  148. package/out/assets/tools-BEctXF0E.js +0 -1
  149. package/out/assets/triangle-alert-DUODU79n.js +0 -1
  150. package/out/assets/upload-CpDM23UH.js +0 -1
  151. package/out/assets/useGameRoom-C6UgmIGG.js +0 -1
  152. package/out/assets/web3-CRX1YFmw.js +0 -3
  153. package/out/assets/zhajinhua-QDmSZbOp.js +0 -1
  154. package/out/web3/ed25519/index.html +0 -0
  155. package/out/web3/tools/index.html +0 -0
  156. /package/out/assets/{gandengyan-8eWJAjpY.css → index-8eWJAjpY.css} +0 -0
  157. /package/out/assets/{zhajinhua-BZc4blbW.css → index-BZc4blbW.css} +0 -0
@@ -17,9 +17,41 @@ import crypto from 'node:crypto'
17
17
  import fs from 'node:fs'
18
18
  import path from 'node:path'
19
19
 
20
- import { calculateCid, parseMostLink } from './core/cid.js'
20
+ import { calculateCid, parseMostLink, buildMostLink } from './core/cid.js'
21
21
  import { normalizeChannelAttachment } from './core/channelAttachment.js'
22
22
  import { getCidInfo } from './core/cidTopic.js'
23
+ import {
24
+ CHAT_FILE_ROOT,
25
+ TRANSIENT_CHANNEL_TYPES,
26
+ CHANNEL_DISCOVERY_TIMEOUT,
27
+ CHANNEL_CANDIDATE_TTL,
28
+ normalizeChannelDisplayName,
29
+ normalizeChannelAvatar,
30
+ normalizeChannelId,
31
+ createChannelFingerprint,
32
+ createChannelWriterId,
33
+ buildChannelKey,
34
+ normalizeChannelKey,
35
+ getChannelFingerprintFromKey,
36
+ uniqueStrings,
37
+ } from './core/channelIdentity.js'
38
+ import { getPathBaseName, getDisplayPathFolder } from './core/displayPath.js'
39
+ import {
40
+ normalizeOwnerAddress,
41
+ getOwnerBucketKey,
42
+ normalizeMetadataBuckets,
43
+ cloneMetadataRecord,
44
+ } from './core/ownerMetadata.js'
45
+ import {
46
+ USER_SYNC_SCHEMA_VERSION,
47
+ USER_SYNC_NAMESPACE_PREFIX,
48
+ normalizeUserSyncKey,
49
+ deriveUserSyncId,
50
+ getUserSyncName,
51
+ getSyncTimestamp,
52
+ getNextSyncTimestamp,
53
+ } from './core/userSyncKeys.js'
54
+ import { createOfflineSwarm } from './node/offlineSwarm.js'
23
55
  import {
24
56
  sanitizeFilename,
25
57
  validateAndSanitizePath,
@@ -66,90 +98,6 @@ import {
66
98
  } from './config.js'
67
99
 
68
100
  const sleep = ms => new Promise(resolve => setTimeout(resolve, ms))
69
- const CHAT_FILE_ROOT = 'chat-file'
70
- const TRANSIENT_CHANNEL_TYPES = new Set(['game'])
71
- const DEFAULT_OWNER_BUCKET = '__local__'
72
- const USER_DATA_SCHEMA_VERSION = 1
73
- const IMPORT_CHECK_TTL_MS = 10 * 60 * 1000
74
- const IMPORT_CHECK_MAX_FILES = 5000
75
-
76
- function normalizeOwnerAddress(address) {
77
- const value = String(address || '').trim()
78
- return /^0x[a-fA-F0-9]{40}$/.test(value) ? value.toLowerCase() : ''
79
- }
80
-
81
- function getOwnerBucketKey(address) {
82
- return normalizeOwnerAddress(address) || DEFAULT_OWNER_BUCKET
83
- }
84
-
85
- function normalizeMetadataBuckets(input) {
86
- if (!input || typeof input !== 'object' || Array.isArray(input)) {
87
- return {}
88
- }
89
- const buckets = {}
90
- for (const [rawOwner, records] of Object.entries(input)) {
91
- const ownerKey =
92
- rawOwner === DEFAULT_OWNER_BUCKET
93
- ? DEFAULT_OWNER_BUCKET
94
- : normalizeOwnerAddress(rawOwner)
95
- if (!ownerKey || !Array.isArray(records)) continue
96
- buckets[ownerKey] = records.map(record => ({ ...record }))
97
- }
98
- return buckets
99
- }
100
-
101
- function cloneMetadataRecord(record, ownerAddress = '') {
102
- return {
103
- ...record,
104
- ownerAddress:
105
- ownerAddress && ownerAddress !== DEFAULT_OWNER_BUCKET ? ownerAddress : '',
106
- }
107
- }
108
-
109
- function getPathBaseName(fileName) {
110
- const parts = String(fileName || '').split('/').filter(Boolean)
111
- return parts[parts.length - 1] || 'unnamed_file'
112
- }
113
-
114
- function getDisplayPathFolder(fileName) {
115
- const parts = String(fileName || '').split('/').filter(Boolean)
116
- parts.pop()
117
- return parts.join('/')
118
- }
119
-
120
- function buildMostLink(cid, fileName) {
121
- return `most://${cid}?filename=${encodeURIComponent(fileName)}`
122
- }
123
-
124
- function normalizeChannelDisplayName(input, fallbackAddress = '') {
125
- const value = String(input || '').trim()
126
- if (value) return value.slice(0, 50)
127
- return fallbackAddress ? fallbackAddress.slice(0, 10) : ''
128
- }
129
-
130
- function normalizeChannelAvatar(input) {
131
- const value = String(input || '').trim()
132
- return value ? value.slice(0, 4096) : ''
133
- }
134
-
135
- function createOfflineSwarm() {
136
- return {
137
- connections: new Set(),
138
- keyPair: {
139
- publicKey: crypto.randomBytes(32),
140
- },
141
- on() {},
142
- join() {
143
- return {}
144
- },
145
- leave() {
146
- return Promise.resolve()
147
- },
148
- destroy() {
149
- return Promise.resolve()
150
- },
151
- }
152
- }
153
101
 
154
102
  export class MostBoxEngine extends EventEmitter {
155
103
  #store = null
@@ -161,7 +109,6 @@ export class MostBoxEngine extends EventEmitter {
161
109
  #initialized = false
162
110
  #options = null
163
111
  #activeDownloads = new Map()
164
- #importChecks = new Map()
165
112
  #drivePromises = new Map()
166
113
  #fileDiscoveries = new Map()
167
114
  #fileMonitors = new Map()
@@ -173,7 +120,15 @@ export class MostBoxEngine extends EventEmitter {
173
120
  #channelLocalCoreKey = new Map()
174
121
  #channelDiscoveries = new Map()
175
122
  #channelChatDiscoveries = new Map()
123
+ #channelIdDiscoveries = new Map()
176
124
  #channelPeers = new Map()
125
+ #channelCandidateCache = new Map()
126
+
127
+ #userSyncSessions = new Map()
128
+ #userSyncCores = new Map()
129
+ #userSyncCoreOffsets = new Map()
130
+ #userSyncDiscoveries = new Map()
131
+ #userSyncMetadata = { sessions: {}, clocks: {} }
177
132
 
178
133
  #chatSwarm = null
179
134
 
@@ -360,52 +315,19 @@ export class MostBoxEngine extends EventEmitter {
360
315
 
361
316
  for (const channel of this.#channels) {
362
317
  try {
363
- const ns = this.#store.namespace(`channel-${channel.name}`)
364
- const core = ns.get({
365
- key: b4a.from(channel.coreKey, 'hex'),
366
- valueEncoding: 'json',
367
- })
368
- await core.ready()
369
- const coreKeyHex = b4a.toString(core.key, 'hex')
370
- if (!this.#channelCores.has(channel.name)) {
371
- this.#channelCores.set(channel.name, new Map())
372
- }
373
- this.#channelCores.get(channel.name).set(coreKeyHex, core)
374
- this.#channelLocalCoreKey.set(channel.name, coreKeyHex)
375
- this.#channelPeers.set(channel.name, new Map())
376
- this.#setupChannelAppendListener(core, channel.name)
377
- const remoteCoreKeys = Array.isArray(channel.remoteCoreKeys)
378
- ? channel.remoteCoreKeys
379
- : []
380
- for (const remoteCoreKey of remoteCoreKeys) {
381
- if (remoteCoreKey && remoteCoreKey !== coreKeyHex) {
382
- await this.#openRemoteChannelCore(channel.name, remoteCoreKey)
383
- }
384
- }
385
-
386
- const discoveryKey = b4a.from(channel.discoveryKey, 'hex')
387
- const chatDiscoveryKey = this.#generateChannelChatDiscoveryKey(
388
- channel.name
389
- )
390
- const appDiscovery = this.#swarm.join(discoveryKey, {
391
- server: true,
392
- client: true,
393
- })
394
- this.#channelDiscoveries.set(channel.name, appDiscovery)
395
- const chatDiscovery = this.#chatSwarm.join(chatDiscoveryKey, {
396
- server: true,
397
- client: true,
398
- })
399
- this.#channelChatDiscoveries.set(channel.name, chatDiscovery)
400
- console.log(`[MostBox] Rejoined channel: ${channel.name}`)
318
+ await this.#openChannelRuntime(channel)
319
+ await this.#joinChannelDiscoveryTopics(channel)
320
+ console.log(`[MostBox] Rejoined channel: ${channel.channelKey}`)
401
321
  } catch (err) {
402
322
  console.warn(
403
- `[MostBox] Failed to rejoin channel ${channel.name}:`,
323
+ `[MostBox] Failed to rejoin channel ${channel.channelKey}:`,
404
324
  err.message
405
325
  )
406
326
  }
407
327
  }
408
328
 
329
+ this.#userSyncMetadata = this.#loadUserSyncMetadata()
330
+
409
331
  this.#initialized = true
410
332
  console.log(`[MostBox] Engine initialized successfully`)
411
333
  this.emit('ready')
@@ -462,9 +384,24 @@ export class MostBoxEngine extends EventEmitter {
462
384
  this.#channelLocalCoreKey.clear()
463
385
  this.#channelDiscoveries.clear()
464
386
  this.#channelChatDiscoveries.clear()
387
+ this.#channelIdDiscoveries.clear()
465
388
  this.#channelPeers.clear()
389
+ this.#channelCandidateCache.clear()
466
390
  this.#channels = []
467
- this.#importChecks.clear()
391
+
392
+ for (const [, coresMap] of this.#userSyncCores) {
393
+ for (const [, core] of coresMap) {
394
+ try {
395
+ await core.close()
396
+ } catch (err) {
397
+ console.warn('[MostBox] Failed to close user sync core:', err.message)
398
+ }
399
+ }
400
+ }
401
+ this.#userSyncSessions.clear()
402
+ this.#userSyncCores.clear()
403
+ this.#userSyncCoreOffsets.clear()
404
+ this.#userSyncDiscoveries.clear()
468
405
 
469
406
  if (this.#store) {
470
407
  await this.#store.close()
@@ -644,13 +581,18 @@ export class MostBoxEngine extends EventEmitter {
644
581
  }
645
582
 
646
583
  // 存储 displayName(用户看到的文件夹路径),不存储 drivePath
647
- publishedBucket.push({
584
+ const now = Date.now()
585
+ const fileRecord = {
648
586
  fileName: safeFileName,
649
587
  cid: cidString,
650
588
  driveName: name,
651
- publishedAt: new Date().toISOString(),
589
+ size: fileSize,
590
+ source: 'published',
591
+ publishedAt: new Date(now).toISOString(),
652
592
  starred: false,
653
- })
593
+ syncUpdatedAt: now,
594
+ }
595
+ publishedBucket.push(fileRecord)
654
596
  this.#savePublishedMetadata()
655
597
  this.#upsertHolding({
656
598
  cid: cidString,
@@ -667,6 +609,9 @@ export class MostBoxEngine extends EventEmitter {
667
609
  }
668
610
 
669
611
  this.emit('publish:success', result)
612
+ this.#appendUserSyncOpSoon(ownerAddress, 'file:upsert', {
613
+ file: this.#formatFileForSync(fileRecord, 'active'),
614
+ })
670
615
  return result
671
616
  }
672
617
 
@@ -697,8 +642,12 @@ export class MostBoxEngine extends EventEmitter {
697
642
 
698
643
  try {
699
644
  const parsed = parseMostLink(link)
700
- if (parsed.error) {
701
- throw new ValidationError(parsed.error)
645
+ if (parsed.errorCode) {
646
+ throw new ValidationError(
647
+ parsed.errorCode,
648
+ parsed.errorCode,
649
+ parsed.details
650
+ )
702
651
  }
703
652
  const cidString = parsed.cid
704
653
  console.log(`[MostBox] Parsed CID: ${cidString}`)
@@ -994,22 +943,32 @@ export class MostBoxEngine extends EventEmitter {
994
943
  ownerAddress,
995
944
  excludeCid: cidString,
996
945
  })
946
+ const savedSize = totalBytes || fs.statSync(savePath).size
947
+ const syncUpdatedAt =
948
+ existingIndex !== -1
949
+ ? getNextSyncTimestamp(publishedBucket[existingIndex].syncUpdatedAt)
950
+ : Date.now()
997
951
  if (existingIndex !== -1) {
998
952
  const existing = publishedBucket[existingIndex]
999
953
  existing.fileName = sanitizedFileName
1000
954
  existing.driveName = name
1001
- existing.publishedAt = new Date().toISOString()
955
+ existing.size = savedSize
956
+ existing.source = 'downloaded'
957
+ existing.publishedAt = new Date(syncUpdatedAt).toISOString()
958
+ existing.syncUpdatedAt = syncUpdatedAt
1002
959
  } else {
1003
960
  publishedBucket.push({
1004
961
  fileName: sanitizedFileName,
1005
962
  cid: cidString,
1006
963
  driveName: name,
1007
- publishedAt: new Date().toISOString(),
964
+ size: savedSize,
965
+ source: 'downloaded',
966
+ publishedAt: new Date(syncUpdatedAt).toISOString(),
1008
967
  starred: false,
968
+ syncUpdatedAt,
1009
969
  })
1010
970
  }
1011
971
  this.#savePublishedMetadata()
1012
- const savedSize = totalBytes || fs.statSync(savePath).size
1013
972
  this.#upsertHolding({
1014
973
  cid: cidString,
1015
974
  fileName: sanitizedFileName,
@@ -1019,6 +978,10 @@ export class MostBoxEngine extends EventEmitter {
1019
978
  })
1020
979
 
1021
980
  this.emit('download:success', result)
981
+ const syncedFile = publishedBucket.find(file => file.cid === cidString)
982
+ this.#appendUserSyncOpSoon(ownerAddress, 'file:upsert', {
983
+ file: this.#formatFileForSync(syncedFile, 'active'),
984
+ })
1022
985
  return result
1023
986
  }
1024
987
  } finally {
@@ -1033,8 +996,12 @@ export class MostBoxEngine extends EventEmitter {
1033
996
  this.#ensureInitialized()
1034
997
  const ownerAddress = normalizeOwnerAddress(options.ownerAddress)
1035
998
  const parsed = parseMostLink(link)
1036
- if (parsed.error) {
1037
- throw new ValidationError(parsed.error)
999
+ if (parsed.errorCode) {
1000
+ throw new ValidationError(
1001
+ parsed.errorCode,
1002
+ parsed.errorCode,
1003
+ parsed.details
1004
+ )
1038
1005
  }
1039
1006
 
1040
1007
  const localContent = await this.#getLocalCidContent(parsed.cid, {
@@ -1068,8 +1035,12 @@ export class MostBoxEngine extends EventEmitter {
1068
1035
 
1069
1036
  const timeout = options.timeout || DRIVE_ENTRY_TIMEOUT
1070
1037
  const parsed = parseMostLink(link)
1071
- if (parsed.error) {
1072
- throw new ValidationError(parsed.error)
1038
+ if (parsed.errorCode) {
1039
+ throw new ValidationError(
1040
+ parsed.errorCode,
1041
+ parsed.errorCode,
1042
+ parsed.details
1043
+ )
1073
1044
  }
1074
1045
 
1075
1046
  const cidString = parsed.cid
@@ -1155,8 +1126,14 @@ export class MostBoxEngine extends EventEmitter {
1155
1126
  cid: f.cid,
1156
1127
  link: `most://${f.cid}?filename=${encodeURIComponent(f.fileName)}`,
1157
1128
  publishedAt: f.publishedAt,
1129
+ size: Number(f.size) || 0,
1158
1130
  starred: f.starred || false,
1159
1131
  ownerAddress: ownerAddress || '',
1132
+ localAvailable: this.#holdings.some(holding => holding.cid === f.cid),
1133
+ seedStatus: this.#seedStates.get(f.cid)?.status || '',
1134
+ holdingSize:
1135
+ Number(this.#holdings.find(holding => holding.cid === f.cid)?.size) ||
1136
+ 0,
1160
1137
  }))
1161
1138
  }
1162
1139
 
@@ -1174,7 +1151,11 @@ export class MostBoxEngine extends EventEmitter {
1174
1151
  throw new Error('File not found')
1175
1152
  }
1176
1153
  files[index].starred = !files[index].starred
1154
+ files[index].syncUpdatedAt = getNextSyncTimestamp(files[index].syncUpdatedAt)
1177
1155
  this.#savePublishedMetadata()
1156
+ this.#appendUserSyncOpSoon(ownerAddress, 'file:upsert', {
1157
+ file: this.#formatFileForSync(files[index], 'active'),
1158
+ })
1178
1159
  return {
1179
1160
  cid,
1180
1161
  starred: files[index].starred,
@@ -1195,8 +1176,9 @@ export class MostBoxEngine extends EventEmitter {
1195
1176
  if (index !== -1) {
1196
1177
  const fileRecord = files[index]
1197
1178
  const holding = this.#holdings.find(item => item.cid === fileRecord.cid)
1179
+ const syncUpdatedAt = getNextSyncTimestamp(fileRecord.syncUpdatedAt)
1198
1180
 
1199
- trashFiles.push({
1181
+ const trashRecord = {
1200
1182
  fileName: fileRecord.fileName,
1201
1183
  cid: fileRecord.cid,
1202
1184
  driveName:
@@ -1205,13 +1187,18 @@ export class MostBoxEngine extends EventEmitter {
1205
1187
  source: holding?.source || 'published',
1206
1188
  publishedAt: fileRecord.publishedAt,
1207
1189
  starred: fileRecord.starred || false,
1208
- deletedAt: new Date().toISOString(),
1209
- })
1190
+ deletedAt: new Date(syncUpdatedAt).toISOString(),
1191
+ syncUpdatedAt,
1192
+ }
1193
+ trashFiles.push(trashRecord)
1210
1194
  this.#saveTrashMetadata()
1211
1195
 
1212
1196
  files.splice(index, 1)
1213
1197
  this.#setPublishedBucket(ownerAddress, files)
1214
1198
  this.#savePublishedMetadata()
1199
+ this.#appendUserSyncOpSoon(ownerAddress, 'file:trash', {
1200
+ file: this.#formatFileForSync(trashRecord, 'trash'),
1201
+ })
1215
1202
 
1216
1203
  if (!this.#hasPublishedReference(fileRecord.cid)) {
1217
1204
  await this.#leaveCidTopic(fileRecord.cid)
@@ -1237,9 +1224,15 @@ export class MostBoxEngine extends EventEmitter {
1237
1224
  cid: f.cid,
1238
1225
  link: `most://${f.cid}?filename=${encodeURIComponent(f.fileName)}`,
1239
1226
  publishedAt: f.publishedAt,
1227
+ size: Number(f.size) || 0,
1240
1228
  starred: f.starred || false,
1241
1229
  ownerAddress: ownerAddress || '',
1242
1230
  deletedAt: f.deletedAt,
1231
+ localAvailable: this.#holdings.some(holding => holding.cid === f.cid),
1232
+ seedStatus: this.#seedStates.get(f.cid)?.status || '',
1233
+ holdingSize:
1234
+ Number(this.#holdings.find(holding => holding.cid === f.cid)?.size) ||
1235
+ 0,
1243
1236
  }))
1244
1237
  }
1245
1238
 
@@ -1269,6 +1262,9 @@ export class MostBoxEngine extends EventEmitter {
1269
1262
  trashFiles.splice(index, 1)
1270
1263
  this.#setTrashBucket(ownerAddress, trashFiles)
1271
1264
  this.#saveTrashMetadata()
1265
+ this.#appendUserSyncOpSoon(ownerAddress, 'file:upsert', {
1266
+ file: this.#formatFileForSync(publishedFiles[existingIndex], 'active'),
1267
+ })
1272
1268
  return this.listPublishedFiles({ ownerAddress })
1273
1269
  }
1274
1270
 
@@ -1277,29 +1273,43 @@ export class MostBoxEngine extends EventEmitter {
1277
1273
  excludeCid: fileRecord.cid,
1278
1274
  })
1279
1275
 
1280
- publishedFiles.push({
1276
+ const syncUpdatedAt = getNextSyncTimestamp(fileRecord.syncUpdatedAt)
1277
+ const publishedRecord = {
1281
1278
  fileName: fileRecord.fileName,
1282
1279
  cid: fileRecord.cid,
1283
1280
  driveName,
1284
1281
  publishedAt: fileRecord.publishedAt,
1285
1282
  starred: fileRecord.starred || false,
1286
- })
1283
+ size: Number(fileRecord.size) || 0,
1284
+ source: fileRecord.source || 'synced',
1285
+ syncUpdatedAt,
1286
+ }
1287
+ publishedFiles.push(publishedRecord)
1287
1288
  this.#savePublishedMetadata()
1288
1289
 
1289
1290
  trashFiles.splice(index, 1)
1290
1291
  this.#setTrashBucket(ownerAddress, trashFiles)
1291
1292
  this.#saveTrashMetadata()
1292
1293
 
1293
- await this.#joinCidTopicInternal(fileRecord.cid, {
1294
- server: true,
1295
- client: false,
1294
+ const localContent = await this.#getLocalCidContent(fileRecord.cid, {
1295
+ ownerAddress,
1296
+ allowHoldingFallback: true,
1296
1297
  })
1297
- this.#upsertHolding({
1298
- cid: fileRecord.cid,
1299
- fileName: fileRecord.fileName,
1300
- size: Number(fileRecord.size) || 0,
1301
- driveName,
1302
- source: fileRecord.source || 'published',
1298
+ if (localContent) {
1299
+ await this.#joinCidTopicInternal(fileRecord.cid, {
1300
+ server: true,
1301
+ client: false,
1302
+ })
1303
+ this.#upsertHolding({
1304
+ cid: fileRecord.cid,
1305
+ fileName: fileRecord.fileName,
1306
+ size: localContent.size || Number(fileRecord.size) || 0,
1307
+ driveName,
1308
+ source: fileRecord.source || 'published',
1309
+ })
1310
+ }
1311
+ this.#appendUserSyncOpSoon(ownerAddress, 'file:upsert', {
1312
+ file: this.#formatFileForSync(publishedRecord, 'active'),
1303
1313
  })
1304
1314
 
1305
1315
  return this.listPublishedFiles({ ownerAddress })
@@ -1323,6 +1333,10 @@ export class MostBoxEngine extends EventEmitter {
1323
1333
  trashFiles.splice(index, 1)
1324
1334
  this.#setTrashBucket(ownerAddress, trashFiles)
1325
1335
  this.#saveTrashMetadata()
1336
+ this.#appendUserSyncOpSoon(ownerAddress, 'file:delete', {
1337
+ cid: fileRecord.cid,
1338
+ syncUpdatedAt: getNextSyncTimestamp(fileRecord.syncUpdatedAt),
1339
+ })
1326
1340
 
1327
1341
  if (!this.#hasAnyUserReference(fileRecord.cid)) {
1328
1342
  try {
@@ -1349,8 +1363,11 @@ export class MostBoxEngine extends EventEmitter {
1349
1363
  const removedTrash = [...this.#getTrashBucket(ownerAddress)]
1350
1364
  this.#setTrashBucket(ownerAddress, [])
1351
1365
  this.#saveTrashMetadata()
1352
-
1353
1366
  for (const fileRecord of removedTrash) {
1367
+ this.#appendUserSyncOpSoon(ownerAddress, 'file:delete', {
1368
+ cid: fileRecord.cid,
1369
+ syncUpdatedAt: getNextSyncTimestamp(fileRecord.syncUpdatedAt),
1370
+ })
1354
1371
  if (this.#hasAnyUserReference(fileRecord.cid)) continue
1355
1372
  const driveName =
1356
1373
  fileRecord.driveName || this.#getCidInfo(fileRecord.cid).driveName
@@ -1455,8 +1472,12 @@ export class MostBoxEngine extends EventEmitter {
1455
1472
  excludeCid: cid,
1456
1473
  })
1457
1474
  files[index].fileName = safeFileName
1458
- files[index].publishedAt = new Date().toISOString()
1475
+ files[index].syncUpdatedAt = getNextSyncTimestamp(files[index].syncUpdatedAt)
1476
+ files[index].publishedAt = new Date(files[index].syncUpdatedAt).toISOString()
1459
1477
  this.#savePublishedMetadata()
1478
+ this.#appendUserSyncOpSoon(ownerAddress, 'file:upsert', {
1479
+ file: this.#formatFileForSync(files[index], 'active'),
1480
+ })
1460
1481
  return {
1461
1482
  cid,
1462
1483
  fileName: safeFileName,
@@ -1497,7 +1518,8 @@ export class MostBoxEngine extends EventEmitter {
1497
1518
 
1498
1519
  const updatedFiles = updates.map(({ file, newFileName }) => {
1499
1520
  file.fileName = newFileName
1500
- file.publishedAt = new Date().toISOString()
1521
+ file.syncUpdatedAt = getNextSyncTimestamp(file.syncUpdatedAt)
1522
+ file.publishedAt = new Date(file.syncUpdatedAt).toISOString()
1501
1523
  return {
1502
1524
  cid: file.cid,
1503
1525
  fileName: file.fileName,
@@ -1507,6 +1529,11 @@ export class MostBoxEngine extends EventEmitter {
1507
1529
 
1508
1530
  if (updatedFiles.length > 0) {
1509
1531
  this.#savePublishedMetadata()
1532
+ for (const { file } of updates) {
1533
+ this.#appendUserSyncOpSoon(ownerAddress, 'file:upsert', {
1534
+ file: this.#formatFileForSync(file, 'active'),
1535
+ })
1536
+ }
1510
1537
  }
1511
1538
 
1512
1539
  return { files: updatedFiles }
@@ -1606,253 +1633,6 @@ export class MostBoxEngine extends EventEmitter {
1606
1633
  }
1607
1634
  }
1608
1635
 
1609
- exportUserMetadata(ownerAddressInput) {
1610
- this.#ensureInitialized()
1611
- const ownerAddress = normalizeOwnerAddress(ownerAddressInput)
1612
- if (!ownerAddress) {
1613
- throw new ValidationError('valid owner address is required')
1614
- }
1615
-
1616
- const files = this.#getPublishedBucket(ownerAddress).map(file => ({
1617
- fileName: file.fileName,
1618
- cid: file.cid,
1619
- driveName: file.driveName || this.#getCidInfo(file.cid).driveName,
1620
- publishedAt: file.publishedAt,
1621
- starred: file.starred || false,
1622
- link: buildMostLink(file.cid, file.fileName),
1623
- }))
1624
- const trashFiles = this.#getTrashBucket(ownerAddress).map(file => ({
1625
- fileName: file.fileName,
1626
- cid: file.cid,
1627
- driveName: file.driveName || this.#getCidInfo(file.cid).driveName,
1628
- size: Number(file.size) || 0,
1629
- source: file.source || 'published',
1630
- publishedAt: file.publishedAt,
1631
- starred: file.starred || false,
1632
- deletedAt: file.deletedAt,
1633
- link: buildMostLink(file.cid, file.fileName),
1634
- }))
1635
- const channels = this.#channels
1636
- .filter(channel => this.#channelHasMember(channel, ownerAddress))
1637
- .map(channel => ({
1638
- name: channel.name,
1639
- type: channel.type,
1640
- coreKey: channel.coreKey,
1641
- createdAt: channel.createdAt,
1642
- lastMessageAt: channel.lastMessageAt || '',
1643
- member: this.#getChannelMembers(channel).find(
1644
- member => member.address === ownerAddress
1645
- ),
1646
- remark: channel.remarks?.[ownerAddress] || '',
1647
- pinned: Boolean(channel.pinnedBy?.[ownerAddress]),
1648
- }))
1649
-
1650
- return {
1651
- schemaVersion: USER_DATA_SCHEMA_VERSION,
1652
- exportedAt: new Date().toISOString(),
1653
- ownerAddress,
1654
- files,
1655
- trashFiles,
1656
- channels,
1657
- }
1658
- }
1659
-
1660
- async checkUserImport(input = {}, options = {}) {
1661
- this.#ensureInitialized()
1662
- const ownerAddress = normalizeOwnerAddress(options.ownerAddress)
1663
- if (!ownerAddress) {
1664
- throw new ValidationError('valid owner address is required')
1665
- }
1666
-
1667
- const normalized = this.#normalizeUserImportPackage(input)
1668
- const failures = []
1669
- const seenPaths = new Set()
1670
- const checkedActiveCids = new Set()
1671
- const importedCids = new Set([
1672
- ...normalized.files.map(file => file.cid),
1673
- ...normalized.trashFiles.map(file => file.cid),
1674
- ])
1675
- const currentCids = this.#collectUserCids(ownerAddress)
1676
- let requiredBytes = 0
1677
-
1678
- if (normalized.files.length > IMPORT_CHECK_MAX_FILES) {
1679
- failures.push({
1680
- scope: 'package',
1681
- error: `too many files: max ${IMPORT_CHECK_MAX_FILES}`,
1682
- })
1683
- }
1684
-
1685
- for (const file of normalized.files) {
1686
- const pathKey = sanitizeFilename(file.fileName).toLowerCase()
1687
- if (seenPaths.has(pathKey)) {
1688
- failures.push({
1689
- cid: file.cid,
1690
- fileName: file.fileName,
1691
- error: 'duplicate file path',
1692
- })
1693
- continue
1694
- }
1695
- seenPaths.add(pathKey)
1696
-
1697
- const link = buildMostLink(file.cid, file.fileName)
1698
- try {
1699
- const result = await this.checkDownloadAvailability(link, {
1700
- ownerAddress,
1701
- timeout: options.timeout || DRIVE_ENTRY_TIMEOUT,
1702
- })
1703
- if (!checkedActiveCids.has(file.cid) && !result.alreadyExists) {
1704
- requiredBytes += Number(result.size) || Number(file.size) || 0
1705
- }
1706
- checkedActiveCids.add(file.cid)
1707
- } catch (err) {
1708
- failures.push({
1709
- cid: file.cid,
1710
- fileName: file.fileName,
1711
- error: err.message,
1712
- code: err.code || 'UNAVAILABLE',
1713
- })
1714
- }
1715
- }
1716
-
1717
- let reclaimableBytes = 0
1718
- for (const cid of currentCids) {
1719
- if (importedCids.has(cid)) continue
1720
- const referencedByOtherUser =
1721
- this.#allPublishedRecords().some(
1722
- file => file.cid === cid && file.ownerAddress !== ownerAddress
1723
- ) ||
1724
- this.#allTrashRecords().some(
1725
- file => file.cid === cid && file.ownerAddress !== ownerAddress
1726
- )
1727
- if (referencedByOtherUser) continue
1728
- const holding = this.#holdings.find(item => item.cid === cid)
1729
- reclaimableBytes += Number(holding?.size) || 0
1730
- }
1731
- const availableBytes = Math.max(
1732
- 0,
1733
- this.#options.capacityBytes - this.#getUsedBytes() + reclaimableBytes
1734
- )
1735
- if (requiredBytes > availableBytes) {
1736
- failures.push({
1737
- scope: 'capacity',
1738
- error: 'insufficient capacity',
1739
- requiredBytes,
1740
- availableBytes,
1741
- })
1742
- }
1743
-
1744
- const ready = failures.length === 0
1745
- const checkId = `import_${Date.now()}_${crypto.randomBytes(6).toString('hex')}`
1746
- if (ready) {
1747
- this.#importChecks.set(checkId, {
1748
- ownerAddress,
1749
- packageHash: this.#hashImportPackage(input),
1750
- expiresAt: Date.now() + IMPORT_CHECK_TTL_MS,
1751
- })
1752
- }
1753
-
1754
- return {
1755
- ready,
1756
- checkId: ready ? checkId : '',
1757
- failures,
1758
- currentFileCount: this.#getPublishedBucket(ownerAddress).length,
1759
- currentTrashCount: this.#getTrashBucket(ownerAddress).length,
1760
- currentCidCount: currentCids.size,
1761
- importFileCount: normalized.files.length,
1762
- importTrashCount: normalized.trashFiles.length,
1763
- requiredBytes,
1764
- availableBytes,
1765
- }
1766
- }
1767
-
1768
- async importUserMetadata(input = {}, options = {}) {
1769
- this.#ensureInitialized()
1770
- const ownerAddress = normalizeOwnerAddress(options.ownerAddress)
1771
- if (!ownerAddress) {
1772
- throw new ValidationError('valid owner address is required')
1773
- }
1774
-
1775
- const checkId = String(options.checkId || '').trim()
1776
- const check = this.#importChecks.get(checkId)
1777
- if (
1778
- !check ||
1779
- check.ownerAddress !== ownerAddress ||
1780
- check.expiresAt < Date.now() ||
1781
- check.packageHash !== this.#hashImportPackage(input)
1782
- ) {
1783
- throw new ValidationError('valid import check is required')
1784
- }
1785
- this.#importChecks.delete(checkId)
1786
-
1787
- const normalized = this.#normalizeUserImportPackage(input)
1788
- const previousCids = this.#collectUserCids(ownerAddress)
1789
- const previous = {
1790
- removedFiles: this.#getPublishedBucket(ownerAddress).length,
1791
- removedTrashFiles: this.#getTrashBucket(ownerAddress).length,
1792
- }
1793
- const now = new Date().toISOString()
1794
-
1795
- this.#removeUserFromChannels(ownerAddress)
1796
- this.#setPublishedBucket(
1797
- ownerAddress,
1798
- normalized.files.map(file => ({
1799
- fileName: file.fileName,
1800
- cid: file.cid,
1801
- driveName: file.driveName || this.#getCidInfo(file.cid).driveName,
1802
- publishedAt: file.publishedAt || now,
1803
- starred: file.starred || false,
1804
- }))
1805
- )
1806
- this.#setTrashBucket(
1807
- ownerAddress,
1808
- normalized.trashFiles.map(file => ({
1809
- fileName: file.fileName,
1810
- cid: file.cid,
1811
- driveName: file.driveName || this.#getCidInfo(file.cid).driveName,
1812
- size: Number(file.size) || 0,
1813
- source: file.source || 'imported',
1814
- publishedAt: file.publishedAt || now,
1815
- starred: file.starred || false,
1816
- deletedAt: file.deletedAt || now,
1817
- }))
1818
- )
1819
- this.#applyImportedChannels(ownerAddress, normalized.channels)
1820
- this.#savePublishedMetadata()
1821
- this.#saveTrashMetadata()
1822
- this.#saveChannelsMetadata()
1823
- previous.removedReplicas = await this.#cleanupUnreferencedCids(previousCids)
1824
-
1825
- const importedFiles = []
1826
- const failedFiles = []
1827
- for (const file of normalized.files) {
1828
- try {
1829
- const result = await this.pullByCid({
1830
- link: buildMostLink(file.cid, file.fileName),
1831
- ownerAddress,
1832
- timeout: options.timeout,
1833
- })
1834
- importedFiles.push({ cid: file.cid, fileName: file.fileName, result })
1835
- } catch (err) {
1836
- failedFiles.push({
1837
- cid: file.cid,
1838
- fileName: file.fileName,
1839
- error: err.message,
1840
- code: err.code || 'IMPORT_PULL_FAILED',
1841
- })
1842
- }
1843
- }
1844
-
1845
- return {
1846
- success: failedFiles.length === 0,
1847
- ownerAddress,
1848
- replacedFiles: previous.removedFiles,
1849
- replacedTrashFiles: previous.removedTrashFiles,
1850
- importedFiles: importedFiles.length,
1851
- importedTrashFiles: normalized.trashFiles.length,
1852
- failedFiles,
1853
- }
1854
- }
1855
-
1856
1636
  /**
1857
1637
  * 列出当前节点持有的可做种文件副本
1858
1638
  * @returns {Array}
@@ -1904,8 +1684,12 @@ export class MostBoxEngine extends EventEmitter {
1904
1684
 
1905
1685
  if (input.link) {
1906
1686
  const parsed = parseMostLink(input.link)
1907
- if (parsed.error) {
1908
- throw new ValidationError(parsed.error)
1687
+ if (parsed.errorCode) {
1688
+ throw new ValidationError(
1689
+ parsed.errorCode,
1690
+ parsed.errorCode,
1691
+ parsed.details
1692
+ )
1909
1693
  }
1910
1694
  const result = await this.downloadFile(input.link, input.taskId || null, {
1911
1695
  timeout: input.timeout,
@@ -1959,6 +1743,8 @@ export class MostBoxEngine extends EventEmitter {
1959
1743
  left.on('error', () => {})
1960
1744
  right.on('error', () => {})
1961
1745
  left.pipe(right).pipe(left)
1746
+ this.#exchangeUserSyncSessions(peerEngine).catch(() => {})
1747
+ peerEngine.#exchangeUserSyncSessions(this).catch(() => {})
1962
1748
 
1963
1749
  return {
1964
1750
  close: () => {
@@ -1968,6 +1754,108 @@ export class MostBoxEngine extends EventEmitter {
1968
1754
  }
1969
1755
  }
1970
1756
 
1757
+ /**
1758
+ * 启动当前账号的隐藏 user.sync 元数据同步。
1759
+ */
1760
+ async startUserSync(ownerAddressInput, input = {}) {
1761
+ this.#ensureInitialized()
1762
+ const ownerAddress = normalizeOwnerAddress(ownerAddressInput)
1763
+ if (!ownerAddress) {
1764
+ throw new ValidationError('valid owner address is required')
1765
+ }
1766
+
1767
+ const syncTopicKey = normalizeUserSyncKey(input.syncTopicKey)
1768
+ const syncCipherKey = normalizeUserSyncKey(input.syncCipherKey)
1769
+ const syncMacKey = normalizeUserSyncKey(input.syncMacKey)
1770
+ if (!syncTopicKey || !syncCipherKey || !syncMacKey) {
1771
+ throw new ValidationError('valid user sync keys are required')
1772
+ }
1773
+
1774
+ const syncId = deriveUserSyncId(syncTopicKey)
1775
+ const syncName = getUserSyncName(syncId)
1776
+ const persisted = this.#userSyncMetadata.sessions?.[ownerAddress]
1777
+ const session = {
1778
+ ownerAddress,
1779
+ syncId,
1780
+ syncName,
1781
+ syncTopicKey,
1782
+ syncCipherKey,
1783
+ syncMacKey,
1784
+ writerId:
1785
+ persisted?.syncId === syncId && persisted?.writerId
1786
+ ? persisted.writerId
1787
+ : createChannelWriterId(),
1788
+ localWriterCoreKey:
1789
+ persisted?.syncId === syncId ? persisted.localWriterCoreKey || '' : '',
1790
+ writerCoreKeys:
1791
+ persisted?.syncId === syncId
1792
+ ? uniqueStrings(persisted.writerCoreKeys)
1793
+ : [],
1794
+ startedAt: new Date().toISOString(),
1795
+ }
1796
+
1797
+ this.#userSyncSessions.set(ownerAddress, session)
1798
+ await this.#openUserSyncRuntime(session)
1799
+ await this.#joinUserSyncDiscovery(session)
1800
+ this.#persistUserSyncSession(session)
1801
+ await this.#appendUserSyncSnapshot(ownerAddress, 'start')
1802
+
1803
+ return this.getUserSyncStatus(ownerAddress)
1804
+ }
1805
+
1806
+ getUserSyncStatus(ownerAddressInput) {
1807
+ this.#ensureInitialized()
1808
+ const ownerAddress = normalizeOwnerAddress(ownerAddressInput)
1809
+ if (!ownerAddress) {
1810
+ throw new ValidationError('valid owner address is required')
1811
+ }
1812
+ const session = this.#userSyncSessions.get(ownerAddress)
1813
+ if (!session) {
1814
+ const persisted = this.#userSyncMetadata.sessions?.[ownerAddress]
1815
+ return {
1816
+ enabled: false,
1817
+ ownerAddress,
1818
+ syncName: persisted?.syncName || '',
1819
+ syncId: persisted?.syncId || '',
1820
+ peerCount: 0,
1821
+ writerCoreKeys: uniqueStrings(persisted?.writerCoreKeys),
1822
+ localWriterCoreKey: persisted?.localWriterCoreKey || '',
1823
+ lastSyncedAt: persisted?.lastSyncedAt || '',
1824
+ }
1825
+ }
1826
+
1827
+ return {
1828
+ enabled: true,
1829
+ ownerAddress,
1830
+ syncName: session.syncName,
1831
+ syncId: session.syncId,
1832
+ peerCount: this.#chatSwarm?.connections?.size || 0,
1833
+ writerCoreKeys: uniqueStrings(session.writerCoreKeys),
1834
+ localWriterCoreKey: session.localWriterCoreKey,
1835
+ lastSyncedAt:
1836
+ this.#userSyncMetadata.sessions?.[ownerAddress]?.lastSyncedAt || '',
1837
+ }
1838
+ }
1839
+
1840
+ async cacheFile(cid, options = {}) {
1841
+ this.#ensureInitialized()
1842
+ const ownerAddress = normalizeOwnerAddress(options.ownerAddress)
1843
+ const fileRecord = this.#getPublishedBucket(ownerAddress).find(
1844
+ item => item.cid === cid
1845
+ )
1846
+ if (!fileRecord) {
1847
+ throw new Error('File not found')
1848
+ }
1849
+ const result = await this.pullByCid({
1850
+ cid,
1851
+ fileName: fileRecord.fileName,
1852
+ ownerAddress,
1853
+ timeout: options.timeout,
1854
+ taskId: options.taskId,
1855
+ })
1856
+ return result
1857
+ }
1858
+
1971
1859
  /**
1972
1860
  * 读取已发布文件的内容(用于预览)
1973
1861
  * Hyperdrive 中用 CID 作为 key 存储
@@ -2162,277 +2050,286 @@ export class MostBoxEngine extends EventEmitter {
2162
2050
  // --- 频道管理 ---
2163
2051
 
2164
2052
  /**
2165
- * 创建或加入频道
2166
- * @param {string} name - 频道名
2053
+ * 创建或加入频道。channelId 是用户输入的短 ID,channelKey 是内部唯一身份。
2054
+ * @param {string} channelIdInput - 用户可见短频道 ID
2167
2055
  * @param {string} [type='personal'] - 频道类型
2168
- * @returns {Promise<{ name: string, key: string }>}
2056
+ * @returns {Promise<object>}
2169
2057
  */
2170
- async createChannel(name, type = 'personal', options = {}) {
2058
+ async createChannel(channelIdInput, type = 'personal', options = {}) {
2171
2059
  this.#ensureInitialized()
2172
2060
  const ownerAddress = normalizeOwnerAddress(options.ownerAddress)
2061
+ const channelId = normalizeChannelId(channelIdInput)
2173
2062
  const channelType = String(type || 'personal').trim() || 'personal'
2063
+ const selectedChannelKey = normalizeChannelKey(options.channelKey)
2064
+ const selectedFingerprint = String(options.fingerprint || '').trim()
2174
2065
 
2175
- if (!CHANNEL_NAME_REGEX.test(name)) {
2066
+ if (channelId.includes('.') && channelType !== 'game') {
2067
+ throw new Error('点号为系统保留,不能用于手动频道 ID')
2068
+ }
2069
+ if (channelType === 'game' && !/^game\.[a-z0-9]+\.[a-z0-9]+$/.test(channelId)) {
2070
+ throw new Error('游戏频道必须使用 game.<gameId>.<roomCode> 格式')
2071
+ }
2072
+ if (channelType !== 'game' && !CHANNEL_NAME_REGEX.test(channelId)) {
2176
2073
  throw new Error('频道名只能包含字母、数字、下划线和连字符')
2177
2074
  }
2178
- if (name.length < CHANNEL_NAME_MIN_LENGTH) {
2075
+ if (channelId.length < CHANNEL_NAME_MIN_LENGTH) {
2179
2076
  throw new Error(`频道名至少 ${CHANNEL_NAME_MIN_LENGTH} 个字符`)
2180
2077
  }
2181
- if (name.length > CHANNEL_NAME_MAX_LENGTH) {
2078
+ if (channelId.length > CHANNEL_NAME_MAX_LENGTH) {
2182
2079
  throw new Error(`频道名最多 ${CHANNEL_NAME_MAX_LENGTH} 个字符`)
2183
2080
  }
2184
2081
 
2185
- const existing = this.#channels.find(c => c.name === name)
2186
- if (existing) {
2187
- if (this.#upsertChannelMember(existing, options)) {
2188
- this.#saveChannelsMetadata()
2082
+ if (selectedChannelKey || selectedFingerprint) {
2083
+ const channelKey =
2084
+ selectedChannelKey || buildChannelKey(channelId, selectedFingerprint)
2085
+ const existing = this.#channels.find(c => c.channelKey === channelKey)
2086
+ if (existing) {
2087
+ await this.#mergeChannelWriterCoreKeys(
2088
+ existing,
2089
+ options.writerCoreKeys
2090
+ )
2091
+ if (this.#upsertChannelMember(existing, options)) {
2092
+ existing.syncUpdatedAt = getNextSyncTimestamp(existing.syncUpdatedAt)
2093
+ this.#saveChannelsMetadata()
2094
+ this.#appendUserSyncChannelUpsertSoon(existing, ownerAddress)
2095
+ }
2096
+ return this.#formatChannelForResponse(existing, ownerAddress)
2189
2097
  }
2190
- return {
2191
- name: existing.name,
2192
- key: existing.coreKey,
2193
- coreKey: existing.coreKey,
2194
- createdAt: existing.createdAt,
2195
- type: existing.type,
2098
+
2099
+ const candidate = this.#getCachedChannelCandidate(channelId, channelKey)
2100
+ if (!candidate) {
2101
+ throw new Error('未发现该频道候选,请重新搜索频道')
2196
2102
  }
2103
+ return this.#joinChannelFromCandidate(candidate, channelType, options)
2197
2104
  }
2198
2105
 
2199
- const ns = this.#store.namespace(`channel-${name}`)
2200
- const core = ns.get({ name: 'messages', valueEncoding: 'json' })
2201
- await core.ready()
2106
+ const localCandidates = this.#getLocalChannelCandidates(channelId)
2107
+ const remoteCandidates = options.discover
2108
+ ? await this.#discoverChannelCandidates(channelId, {
2109
+ timeout: options.discoveryTimeout,
2110
+ })
2111
+ : []
2112
+ const candidates = this.#mergeChannelCandidates([
2113
+ ...localCandidates,
2114
+ ...remoteCandidates,
2115
+ ])
2202
2116
 
2203
- const discoveryKey = this.#generateChannelDiscoveryKey(name)
2204
- const chatDiscoveryKey = this.#generateChannelChatDiscoveryKey(name)
2205
- const appDiscovery = this.#swarm.join(discoveryKey, {
2206
- server: true,
2207
- client: true,
2208
- })
2209
- const chatDiscovery = this.#chatSwarm.join(chatDiscoveryKey, {
2210
- server: true,
2211
- client: true,
2212
- })
2213
-
2214
- this.#setupChannelAppendListener(core, name)
2117
+ if (candidates.length > 1) {
2118
+ return {
2119
+ conflict: true,
2120
+ channelId,
2121
+ candidates: candidates.map(candidate =>
2122
+ this.#formatChannelCandidateForResponse(candidate, ownerAddress)
2123
+ ),
2124
+ }
2125
+ }
2215
2126
 
2216
- const channelInfo = {
2217
- name,
2218
- discoveryKey: b4a.toString(discoveryKey, 'hex'),
2219
- coreKey: b4a.toString(core.key, 'hex'),
2220
- createdAt: new Date().toISOString(),
2221
- type: channelType,
2222
- ownerAddress,
2223
- members: [],
2224
- remoteCoreKeys: [],
2127
+ if (candidates.length === 1) {
2128
+ const candidate = candidates[0]
2129
+ if (candidate.local) {
2130
+ const existing = this.#channels.find(
2131
+ channel => channel.channelKey === candidate.channelKey
2132
+ )
2133
+ if (existing && this.#upsertChannelMember(existing, options)) {
2134
+ existing.syncUpdatedAt = getNextSyncTimestamp(existing.syncUpdatedAt)
2135
+ this.#saveChannelsMetadata()
2136
+ this.#appendUserSyncChannelUpsertSoon(existing, ownerAddress)
2137
+ }
2138
+ if (existing) return this.#formatChannelForResponse(existing, ownerAddress)
2139
+ const joined = await this.#joinChannelFromCandidate(
2140
+ candidate,
2141
+ channelType,
2142
+ options
2143
+ )
2144
+ const joinedChannel = this.#resolveChannel(joined.channelKey, ownerAddress)
2145
+ this.#appendUserSyncChannelUpsertSoon(joinedChannel, ownerAddress)
2146
+ return joined
2147
+ }
2148
+ const joined = await this.#joinChannelFromCandidate(
2149
+ candidate,
2150
+ channelType,
2151
+ options
2152
+ )
2153
+ const joinedChannel = this.#resolveChannel(joined.channelKey, ownerAddress)
2154
+ this.#appendUserSyncChannelUpsertSoon(joinedChannel, ownerAddress)
2155
+ return joined
2225
2156
  }
2226
- this.#upsertChannelMember(channelInfo, options)
2227
2157
 
2228
- this.#channels.push(channelInfo)
2229
- const coreKeyHex = b4a.toString(core.key, 'hex')
2230
- if (!this.#channelCores.has(name)) {
2231
- this.#channelCores.set(name, new Map())
2232
- }
2233
- this.#channelCores.get(name).set(coreKeyHex, core)
2234
- this.#channelLocalCoreKey.set(name, coreKeyHex)
2235
- this.#channelPeers.set(name, new Map())
2236
- this.#channelDiscoveries.set(name, appDiscovery)
2237
- this.#channelChatDiscoveries.set(name, chatDiscovery)
2238
- this.#saveChannelsMetadata()
2158
+ const channelInfo = await this.#createLocalChannel(channelId, channelType, {
2159
+ ...options,
2160
+ ownerAddress,
2161
+ })
2239
2162
 
2240
- console.log(`[MostBox] Channel created: ${name}`)
2241
- this.emit('channel:joined', { name, key: channelInfo.coreKey })
2163
+ console.log(`[MostBox] Channel created: ${channelInfo.channelKey}`)
2164
+ this.emit('channel:joined', {
2165
+ channel: channelInfo.channelKey,
2166
+ channelKey: channelInfo.channelKey,
2167
+ channelId: channelInfo.channelId,
2168
+ key: channelInfo.channelKey,
2169
+ })
2170
+ this.#appendUserSyncChannelUpsertSoon(channelInfo, ownerAddress)
2242
2171
 
2243
- return {
2244
- name,
2245
- key: channelInfo.coreKey,
2246
- coreKey: channelInfo.coreKey,
2247
- createdAt: channelInfo.createdAt,
2248
- type: channelInfo.type,
2249
- }
2172
+ return this.#formatChannelForResponse(channelInfo, ownerAddress)
2250
2173
  }
2251
2174
 
2252
2175
  /**
2253
- * 加入已有频道(通过频道名和 coreKey)
2254
- * @param {string} name - 频道名
2255
- * @param {string} [coreKey] - 频道的 coreKey(加入他人创建的频道时必填)
2256
- * @returns {Promise<{ name: string, key: string }>}
2176
+ * 通过已发现候选加入频道。
2177
+ * @param {string} channelIdInput - 用户可见短频道 ID
2178
+ * @param {object|string|null} candidateInput - 候选对象或 channelKey
2179
+ * @returns {Promise<object>}
2257
2180
  */
2258
- async joinChannel(name, coreKey = null, options = {}) {
2181
+ async joinChannel(channelIdInput, candidateInput = null, options = {}) {
2259
2182
  this.#ensureInitialized()
2260
- const ownerAddress = normalizeOwnerAddress(options.ownerAddress)
2261
-
2262
- const existing = this.#channels.find(c => c.name === name)
2183
+ const channelId = normalizeChannelId(channelIdInput)
2184
+ const candidate =
2185
+ candidateInput && typeof candidateInput === 'object'
2186
+ ? candidateInput
2187
+ : candidateInput
2188
+ ? { channelKey: String(candidateInput), channelId }
2189
+ : null
2190
+
2191
+ if (!candidate?.channelKey && !candidate?.fingerprint) {
2192
+ return this.createChannel(channelId, options.type || 'group', options)
2193
+ }
2194
+
2195
+ const channelKey =
2196
+ normalizeChannelKey(candidate.channelKey) ||
2197
+ buildChannelKey(channelId, String(candidate.fingerprint || '').trim())
2198
+ const existing = this.#channels.find(c => c.channelKey === channelKey)
2263
2199
  if (existing) {
2200
+ await this.#mergeChannelWriterCoreKeys(existing, candidate.writerCoreKeys)
2264
2201
  if (this.#upsertChannelMember(existing, options)) {
2202
+ existing.syncUpdatedAt = getNextSyncTimestamp(existing.syncUpdatedAt)
2265
2203
  this.#saveChannelsMetadata()
2204
+ this.#appendUserSyncChannelUpsertSoon(existing, options.ownerAddress)
2266
2205
  }
2267
- if (coreKey && coreKey !== existing.coreKey) {
2268
- if (!Array.isArray(existing.remoteCoreKeys)) {
2269
- existing.remoteCoreKeys = []
2270
- }
2271
- if (!existing.remoteCoreKeys.includes(coreKey)) {
2272
- existing.remoteCoreKeys.push(coreKey)
2273
- this.#saveChannelsMetadata()
2274
- }
2275
- await this.#openRemoteChannelCore(name, coreKey)
2276
- }
2277
- return {
2278
- name: existing.name,
2279
- key: existing.coreKey,
2280
- coreKey: existing.coreKey,
2281
- createdAt: existing.createdAt,
2282
- type: existing.type,
2283
- }
2206
+ return this.#formatChannelForResponse(existing, options.ownerAddress)
2284
2207
  }
2285
2208
 
2286
- if (!coreKey) {
2287
- throw new Error('加入已有频道需要提供 coreKey')
2288
- }
2289
-
2290
- const ns = this.#store.namespace(`channel-${name}`)
2291
- const remoteCoreKeyHex = b4a.toString(b4a.from(coreKey, 'hex'), 'hex')
2292
- const localCore = ns.get({
2293
- name: `messages-${this.getNodeId()}`,
2294
- valueEncoding: 'json',
2295
- })
2296
- await localCore.ready()
2297
- const localCoreKeyHex = b4a.toString(localCore.key, 'hex')
2298
-
2299
- const discoveryKey = this.#generateChannelDiscoveryKey(name)
2300
- const chatDiscoveryKey = this.#generateChannelChatDiscoveryKey(name)
2301
- const appDiscovery = this.#swarm.join(discoveryKey, {
2302
- server: true,
2303
- client: true,
2209
+ const cached = this.#getCachedChannelCandidate(channelId, channelKey)
2210
+ const joined = await this.#joinChannelFromCandidate(cached || candidate, 'group', {
2211
+ ...options,
2212
+ channelKey,
2304
2213
  })
2305
- const chatDiscovery = this.#chatSwarm.join(chatDiscoveryKey, {
2306
- server: true,
2307
- client: true,
2308
- })
2309
-
2310
- this.#setupChannelAppendListener(localCore, name)
2311
-
2312
- const channelInfo = {
2313
- name,
2314
- discoveryKey: b4a.toString(discoveryKey, 'hex'),
2315
- coreKey: localCoreKeyHex,
2316
- createdAt: new Date().toISOString(),
2317
- type: 'group',
2318
- ownerAddress,
2319
- members: [],
2320
- remoteCoreKeys:
2321
- remoteCoreKeyHex === localCoreKeyHex ? [] : [remoteCoreKeyHex],
2322
- }
2323
- this.#upsertChannelMember(channelInfo, options)
2324
-
2325
- this.#channels.push(channelInfo)
2326
- if (!this.#channelCores.has(name)) {
2327
- this.#channelCores.set(name, new Map())
2328
- }
2329
- this.#channelCores.get(name).set(localCoreKeyHex, localCore)
2330
- this.#channelLocalCoreKey.set(name, localCoreKeyHex)
2331
- this.#channelPeers.set(name, new Map())
2332
- this.#channelDiscoveries.set(name, appDiscovery)
2333
- this.#channelChatDiscoveries.set(name, chatDiscovery)
2334
- this.#saveChannelsMetadata()
2335
- if (remoteCoreKeyHex !== localCoreKeyHex) {
2336
- await this.#openRemoteChannelCore(name, remoteCoreKeyHex)
2337
- }
2338
-
2339
- console.log(`[MostBox] Joined channel: ${name}`)
2340
- this.emit('channel:joined', { name, key: localCoreKeyHex })
2341
-
2342
- return {
2343
- name,
2344
- key: localCoreKeyHex,
2345
- coreKey: localCoreKeyHex,
2346
- createdAt: channelInfo.createdAt,
2347
- type: channelInfo.type,
2348
- }
2214
+ const joinedChannel = this.#resolveChannel(
2215
+ joined.channelKey,
2216
+ options.ownerAddress
2217
+ )
2218
+ this.#appendUserSyncChannelUpsertSoon(joinedChannel, options.ownerAddress)
2219
+ return joined
2349
2220
  }
2350
2221
 
2351
2222
  /**
2352
2223
  * 离开频道
2353
- * @param {string} name - 频道名
2224
+ * @param {string} channelKeyInput - 内部频道 key,或本地唯一短频道 ID
2354
2225
  * @returns {Promise<string[]>} 剩余频道列表
2355
2226
  */
2356
- async leaveChannel(name, options = {}) {
2227
+ async leaveChannel(channelKeyInput, options = {}) {
2357
2228
  this.#ensureInitialized()
2358
2229
  const ownerAddress = normalizeOwnerAddress(options.ownerAddress)
2359
2230
 
2360
- const index = this.#channels.findIndex(c => c.name === name)
2231
+ const channel = this.#resolveChannel(channelKeyInput, ownerAddress)
2232
+ const index = this.#channels.findIndex(c => c.channelKey === channel.channelKey)
2361
2233
  if (index === -1) {
2362
2234
  throw new Error('频道不存在')
2363
2235
  }
2364
2236
 
2365
- const channel = this.#channels[index]
2366
2237
  if (ownerAddress && Array.isArray(channel.members)) {
2367
2238
  channel.members = channel.members.filter(
2368
2239
  member => normalizeOwnerAddress(member?.address) !== ownerAddress
2369
2240
  )
2241
+ const syncUpdatedAt = getNextSyncTimestamp(channel.syncUpdatedAt)
2242
+ channel.syncUpdatedAt = syncUpdatedAt
2243
+ this.#appendUserSyncChannelLeaveSoon(channel, ownerAddress, syncUpdatedAt)
2370
2244
  if (channel.members.length > 0) {
2371
2245
  this.#saveChannelsMetadata()
2372
2246
  return this.listChannels({ ownerAddress })
2373
2247
  }
2374
2248
  }
2375
2249
 
2376
- const appDiscovery = this.#channelDiscoveries.get(name)
2250
+ const appDiscovery = this.#channelDiscoveries.get(channel.channelKey)
2377
2251
  if (appDiscovery && this.#swarm) {
2378
- this.#channelDiscoveries.delete(name)
2379
- this.#swarm.leave(b4a.from(channel.discoveryKey, 'hex')).catch(err => {
2252
+ this.#channelDiscoveries.delete(channel.channelKey)
2253
+ this.#swarm.leave(this.#generateChannelDiscoveryKey(channel.channelKey)).catch(err => {
2380
2254
  console.warn(
2381
- `[MostBox] Failed to leave app swarm for ${name}:`,
2255
+ `[MostBox] Failed to leave app swarm for ${channel.channelKey}:`,
2382
2256
  err.message
2383
2257
  )
2384
2258
  })
2385
2259
  }
2386
2260
 
2387
- const chatDiscovery = this.#channelChatDiscoveries.get(name)
2261
+ const chatDiscovery = this.#channelChatDiscoveries.get(channel.channelKey)
2388
2262
  if (chatDiscovery && this.#chatSwarm) {
2389
- this.#channelChatDiscoveries.delete(name)
2390
- const chatDiscoveryKey = this.#generateChannelChatDiscoveryKey(name)
2263
+ this.#channelChatDiscoveries.delete(channel.channelKey)
2264
+ const chatDiscoveryKey = this.#generateChannelChatDiscoveryKey(
2265
+ channel.channelKey
2266
+ )
2391
2267
  this.#chatSwarm.leave(chatDiscoveryKey).catch(err => {
2392
2268
  console.warn(
2393
- `[MostBox] Failed to leave chat swarm for ${name}:`,
2269
+ `[MostBox] Failed to leave chat swarm for ${channel.channelKey}:`,
2394
2270
  err.message
2395
2271
  )
2396
2272
  })
2397
2273
  }
2398
2274
 
2399
- const coresMap = this.#channelCores.get(name)
2275
+ const hasSameIdChannel = this.#channels.some(
2276
+ (item, itemIndex) =>
2277
+ itemIndex !== index && item.channelId === channel.channelId
2278
+ )
2279
+ if (!hasSameIdChannel) {
2280
+ const idDiscovery = this.#channelIdDiscoveries.get(channel.channelId)
2281
+ if (idDiscovery && this.#chatSwarm) {
2282
+ this.#channelIdDiscoveries.delete(channel.channelId)
2283
+ this.#chatSwarm
2284
+ .leave(this.#generateChannelIdDiscoveryKey(channel.channelId))
2285
+ .catch(err => {
2286
+ console.warn(
2287
+ `[MostBox] Failed to leave channel ID discovery for ${channel.channelId}:`,
2288
+ err.message
2289
+ )
2290
+ })
2291
+ }
2292
+ }
2293
+
2294
+ const coresMap = this.#channelCores.get(channel.channelKey)
2400
2295
  if (coresMap) {
2401
2296
  for (const [, core] of coresMap) {
2402
2297
  try {
2403
2298
  await core.close()
2404
2299
  } catch (err) {
2405
2300
  console.warn(
2406
- `[MostBox] Failed to close channel core for ${name}:`,
2301
+ `[MostBox] Failed to close channel core for ${channel.channelKey}:`,
2407
2302
  err.message
2408
2303
  )
2409
2304
  }
2410
2305
  }
2411
- this.#channelCores.delete(name)
2306
+ this.#channelCores.delete(channel.channelKey)
2412
2307
  }
2413
- this.#channelLocalCoreKey.delete(name)
2308
+ this.#channelLocalCoreKey.delete(channel.channelKey)
2414
2309
 
2415
- this.#channelPeers.delete(name)
2310
+ this.#channelPeers.delete(channel.channelKey)
2416
2311
  this.#channels.splice(index, 1)
2417
2312
  this.#saveChannelsMetadata()
2418
2313
 
2419
- console.log(`[MostBox] Left channel: ${name}`)
2420
- this.emit('channel:left', { name })
2314
+ console.log(`[MostBox] Left channel: ${channel.channelKey}`)
2315
+ this.emit('channel:left', {
2316
+ channel: channel.channelKey,
2317
+ channelKey: channel.channelKey,
2318
+ channelId: channel.channelId,
2319
+ name: channel.channelId,
2320
+ })
2421
2321
 
2422
2322
  return this.listChannels({ ownerAddress })
2423
2323
  }
2424
2324
 
2425
- setChannelRemark(name, remark, options = {}) {
2325
+ setChannelRemark(channelKeyInput, remark, options = {}) {
2426
2326
  this.#ensureInitialized()
2427
2327
  const ownerAddress = normalizeOwnerAddress(options.ownerAddress)
2428
2328
  if (!ownerAddress) {
2429
2329
  throw new Error('需要登录才能设置备注')
2430
2330
  }
2431
2331
 
2432
- const channel = this.#channels.find(c => c.name === name)
2433
- if (!channel) {
2434
- throw new Error('频道不存在')
2435
- }
2332
+ const channel = this.#resolveChannel(channelKeyInput, ownerAddress)
2436
2333
 
2437
2334
  const trimmed = (remark || '').trim()
2438
2335
  if (trimmed.length > 50) {
@@ -2449,22 +2346,21 @@ export class MostBoxEngine extends EventEmitter {
2449
2346
  delete channel.remarks[ownerAddress]
2450
2347
  }
2451
2348
 
2349
+ channel.syncUpdatedAt = getNextSyncTimestamp(channel.syncUpdatedAt)
2452
2350
  this.#saveChannelsMetadata()
2351
+ this.#appendUserSyncChannelUpsertSoon(channel, ownerAddress)
2453
2352
  return trimmed
2454
2353
  }
2455
2354
 
2456
- setChannelPinned(name, pinned, options = {}) {
2355
+ setChannelPinned(channelKeyInput, pinned, options = {}) {
2457
2356
  this.#ensureInitialized()
2458
2357
  const ownerAddress = normalizeOwnerAddress(options.ownerAddress)
2459
2358
  if (!ownerAddress) {
2460
2359
  throw new Error('需要登录才能设置置顶')
2461
2360
  }
2462
2361
 
2463
- const channel = this.#channels.find(c => c.name === name)
2464
- if (!channel) {
2465
- throw new Error('频道不存在')
2466
- }
2467
- this.#assertChannelMember(name, ownerAddress)
2362
+ const channel = this.#resolveChannel(channelKeyInput, ownerAddress)
2363
+ this.#assertChannelMember(channel.channelKey, ownerAddress)
2468
2364
 
2469
2365
  if (!channel.pinnedBy) {
2470
2366
  channel.pinnedBy = {}
@@ -2476,13 +2372,15 @@ export class MostBoxEngine extends EventEmitter {
2476
2372
  delete channel.pinnedBy[ownerAddress]
2477
2373
  }
2478
2374
 
2375
+ channel.syncUpdatedAt = getNextSyncTimestamp(channel.syncUpdatedAt)
2479
2376
  this.#saveChannelsMetadata()
2377
+ this.#appendUserSyncChannelUpsertSoon(channel, ownerAddress)
2480
2378
  return Boolean(channel.pinnedBy[ownerAddress])
2481
2379
  }
2482
2380
 
2483
2381
  /**
2484
2382
  * 列出所有频道
2485
- * @returns {Array<{ name: string, coreKey: string, createdAt: string, lastMessageAt: string, type: string, peerCount: number, remark: string, pinned: boolean }>}
2383
+ * @returns {Array<{ channelId: string, channelKey: string, name: string, createdAt: string, lastMessageAt: string, type: string, peerCount: number, remark: string, pinned: boolean }>}
2486
2384
  */
2487
2385
  listChannels(options = {}) {
2488
2386
  this.#ensureInitialized()
@@ -2500,45 +2398,33 @@ export class MostBoxEngine extends EventEmitter {
2500
2398
  if (excludeType) return c.type !== excludeType
2501
2399
  return true
2502
2400
  })
2503
- .map(c => ({
2504
- name: c.name,
2505
- coreKey: c.coreKey,
2506
- createdAt: c.createdAt,
2507
- lastMessageAt: c.lastMessageAt || '',
2508
- type: c.type,
2509
- peerCount: (this.#channelPeers.get(c.name) || new Map()).size,
2510
- remark: ownerAddress && c.remarks ? c.remarks[ownerAddress] || '' : '',
2511
- pinned: Boolean(ownerAddress && c.pinnedBy?.[ownerAddress]),
2512
- }))
2401
+ .map(c => this.#formatChannelForResponse(c, ownerAddress))
2513
2402
  }
2514
2403
 
2515
- getChannelMembers(name, options = {}) {
2404
+ getChannelMembers(channelKeyInput, options = {}) {
2516
2405
  this.#ensureInitialized()
2517
- this.#assertChannelMember(name, options.ownerAddress)
2518
-
2519
- const channel = this.#channels.find(c => c.name === name)
2520
- if (!channel) {
2521
- throw new Error('频道不存在')
2522
- }
2406
+ this.#assertChannelMember(channelKeyInput, options.ownerAddress)
2407
+ const channel = this.#resolveChannel(channelKeyInput, options.ownerAddress)
2523
2408
 
2524
2409
  return this.#getChannelMembers(channel)
2525
2410
  }
2526
2411
 
2527
2412
  /**
2528
2413
  * 获取频道消息
2529
- * @param {string} name - 频道名
2414
+ * @param {string} channelKeyInput - 内部频道 key,或本地唯一短频道 ID
2530
2415
  * @param {object} [options] - 选项
2531
2416
  * @param {number} [options.limit=100] - 消息数量
2532
2417
  * @param {number} [options.offset=0] - 偏移量
2533
2418
  * @returns {Promise<Array>}
2534
2419
  */
2535
- async getChannelMessages(name, options = {}) {
2420
+ async getChannelMessages(channelKeyInput, options = {}) {
2536
2421
  this.#ensureInitialized()
2537
- this.#assertChannelMember(name, options.ownerAddress)
2422
+ this.#assertChannelMember(channelKeyInput, options.ownerAddress)
2423
+ const channel = this.#resolveChannel(channelKeyInput, options.ownerAddress)
2538
2424
 
2539
2425
  const { limit = CHANNEL_MESSAGE_LIMIT, offset = 0 } = options
2540
2426
 
2541
- const coresMap = this.#channelCores.get(name)
2427
+ const coresMap = this.#channelCores.get(channel.channelKey)
2542
2428
  if (!coresMap || coresMap.size === 0) {
2543
2429
  throw new Error('频道未初始化')
2544
2430
  }
@@ -2578,26 +2464,26 @@ export class MostBoxEngine extends EventEmitter {
2578
2464
  return unique
2579
2465
  .slice(start, end)
2580
2466
  .map(({ _coreKey, _index, ...msg }) =>
2581
- this.#normalizeChannelMessageForResponse(name, msg)
2467
+ this.#normalizeChannelMessageForResponse(channel.channelKey, msg)
2582
2468
  )
2583
2469
  }
2584
2470
 
2585
2471
  /**
2586
2472
  * 发送消息到频道
2587
- * @param {string} name - 频道名
2473
+ * @param {string} channelKeyInput - 内部频道 key,或本地唯一短频道 ID
2588
2474
  * @param {string} content - 消息内容
2589
2475
  * @param {string} author - 作者 address
2590
2476
  * @param {string} authorName - 作者显示名
2591
2477
  * @param {object} [options.attachment] - 附件元数据
2592
2478
  * @returns {Promise<object>}
2593
2479
  */
2594
- async sendMessage(name, content, author, authorName, options = {}) {
2480
+ async sendMessage(channelKeyInput, content, author, authorName, options = {}) {
2595
2481
  this.#ensureInitialized()
2596
- this.#assertChannelMember(name, options.ownerAddress)
2597
- const channel = this.#channels.find(c => c.name === name)
2482
+ this.#assertChannelMember(channelKeyInput, options.ownerAddress)
2483
+ const channel = this.#resolveChannel(channelKeyInput, options.ownerAddress)
2598
2484
 
2599
- const localKeyHex = this.#channelLocalCoreKey.get(name)
2600
- const coresMap = this.#channelCores.get(name)
2485
+ const localKeyHex = this.#channelLocalCoreKey.get(channel.channelKey)
2486
+ const coresMap = this.#channelCores.get(channel.channelKey)
2601
2487
  const core = localKeyHex && coresMap ? coresMap.get(localKeyHex) : null
2602
2488
  if (!core) {
2603
2489
  throw new Error('频道未初始化或无可写 core')
@@ -2643,19 +2529,20 @@ export class MostBoxEngine extends EventEmitter {
2643
2529
  this.#saveChannelsMetadata()
2644
2530
  }
2645
2531
 
2646
- return message
2532
+ return this.#normalizeChannelMessageForResponse(channel.channelKey, message)
2647
2533
  }
2648
2534
 
2649
2535
  /**
2650
2536
  * 获取频道内在线用户
2651
- * @param {string} name - 频道名
2537
+ * @param {string} channelKeyInput - 内部频道 key,或本地唯一短频道 ID
2652
2538
  * @returns {Array<{ peerId: string, authorName: string, lastSeen: number }>}
2653
2539
  */
2654
- getChannelPeers(name, options = {}) {
2540
+ getChannelPeers(channelKeyInput, options = {}) {
2655
2541
  this.#ensureInitialized()
2656
- this.#assertChannelMember(name, options.ownerAddress)
2542
+ this.#assertChannelMember(channelKeyInput, options.ownerAddress)
2543
+ const channel = this.#resolveChannel(channelKeyInput, options.ownerAddress)
2657
2544
 
2658
- const peers = this.#channelPeers.get(name)
2545
+ const peers = this.#channelPeers.get(channel.channelKey)
2659
2546
  if (!peers) {
2660
2547
  return []
2661
2548
  }
@@ -2704,6 +2591,366 @@ export class MostBoxEngine extends EventEmitter {
2704
2591
 
2705
2592
  // --- 私有方法 ---
2706
2593
 
2594
+ #resolveChannel(identifier, ownerAddress = '') {
2595
+ const value = normalizeChannelKey(identifier)
2596
+ const owner = normalizeOwnerAddress(ownerAddress)
2597
+ let channel = this.#channels.find(c => c.channelKey === value)
2598
+ if (channel) return channel
2599
+
2600
+ const matches = this.#channels.filter(c => c.channelId === value)
2601
+ const visibleMatches = owner
2602
+ ? matches.filter(c => this.#channelHasMember(c, owner))
2603
+ : matches
2604
+ if (visibleMatches.length === 1) return visibleMatches[0]
2605
+ if (visibleMatches.length > 1) {
2606
+ throw new Error('频道 ID 存在多个候选,请使用 channelKey')
2607
+ }
2608
+ throw new Error('频道不存在')
2609
+ }
2610
+
2611
+ async #createLocalChannel(channelId, type = 'personal', options = {}) {
2612
+ const fingerprint =
2613
+ String(options.fingerprint || '').trim() || createChannelFingerprint()
2614
+ const channelKey = buildChannelKey(channelId, fingerprint)
2615
+ const writerId = String(options.writerId || '').trim() || createChannelWriterId()
2616
+ const ns = this.#store.namespace(`channel-${channelKey}`)
2617
+ const localCore = ns.get({
2618
+ name: `messages-${writerId}`,
2619
+ valueEncoding: 'json',
2620
+ })
2621
+ await localCore.ready()
2622
+ const localWriterCoreKey = b4a.toString(localCore.key, 'hex')
2623
+ const writerCoreKeys = uniqueStrings([
2624
+ ...(Array.isArray(options.writerCoreKeys) ? options.writerCoreKeys : []),
2625
+ localWriterCoreKey,
2626
+ ])
2627
+ const channelInfo = {
2628
+ channelId,
2629
+ fingerprint,
2630
+ channelKey,
2631
+ name: channelId,
2632
+ type: String(type || 'personal').trim() || 'personal',
2633
+ createdAt: options.createdAt || new Date().toISOString(),
2634
+ lastMessageAt: options.lastMessageAt || '',
2635
+ writerId,
2636
+ localWriterCoreKey,
2637
+ writerCoreKeys,
2638
+ members: [],
2639
+ syncUpdatedAt: Date.now(),
2640
+ }
2641
+
2642
+ this.#upsertChannelMember(channelInfo, options)
2643
+ const ownerAddress = normalizeOwnerAddress(options.ownerAddress)
2644
+ const remark = String(options.remark || '').trim()
2645
+ if (ownerAddress && remark) {
2646
+ channelInfo.remarks = { [ownerAddress]: remark.slice(0, 50) }
2647
+ }
2648
+
2649
+ this.#channels.push(channelInfo)
2650
+ await this.#openChannelRuntime(channelInfo)
2651
+ await this.#joinChannelDiscoveryTopics(channelInfo)
2652
+ this.#cacheChannelCandidate(this.#channelToCandidate(channelInfo, true))
2653
+ this.#saveChannelsMetadata()
2654
+ return channelInfo
2655
+ }
2656
+
2657
+ async #joinChannelFromCandidate(candidateInput, type = 'group', options = {}) {
2658
+ const channelId = normalizeChannelId(
2659
+ candidateInput.channelId || options.channelId
2660
+ )
2661
+ const channelKey = normalizeChannelKey(candidateInput.channelKey)
2662
+ const fingerprint =
2663
+ String(candidateInput.fingerprint || '').trim() ||
2664
+ getChannelFingerprintFromKey(channelId, channelKey)
2665
+ if (!channelId || !fingerprint) {
2666
+ throw new Error('频道候选缺少身份信息')
2667
+ }
2668
+ const expectedChannelKey = buildChannelKey(channelId, fingerprint)
2669
+ if (channelKey && channelKey !== expectedChannelKey) {
2670
+ throw new Error('频道候选身份格式不匹配')
2671
+ }
2672
+
2673
+ const existing = this.#channels.find(
2674
+ channel => channel.channelKey === expectedChannelKey
2675
+ )
2676
+ if (existing) {
2677
+ if (this.#upsertChannelMember(existing, options)) {
2678
+ this.#saveChannelsMetadata()
2679
+ }
2680
+ return this.#formatChannelForResponse(existing, options.ownerAddress)
2681
+ }
2682
+
2683
+ const hasSameIdLocal = this.#channels.some(
2684
+ channel => channel.channelId === channelId
2685
+ )
2686
+ const ownerAddress = normalizeOwnerAddress(options.ownerAddress)
2687
+ const remark =
2688
+ ownerAddress && hasSameIdLocal && !String(options.remark || '').trim()
2689
+ ? `${channelId}-网络`
2690
+ : options.remark
2691
+ const channelInfo = await this.#createLocalChannel(channelId, candidateInput.type || type, {
2692
+ ...options,
2693
+ ownerAddress,
2694
+ fingerprint,
2695
+ createdAt: candidateInput.createdAt,
2696
+ lastMessageAt: candidateInput.lastMessageAt,
2697
+ writerCoreKeys: candidateInput.writerCoreKeys,
2698
+ remark,
2699
+ })
2700
+
2701
+ console.log(`[MostBox] Joined channel: ${channelInfo.channelKey}`)
2702
+ this.emit('channel:joined', {
2703
+ channel: channelInfo.channelKey,
2704
+ channelKey: channelInfo.channelKey,
2705
+ channelId: channelInfo.channelId,
2706
+ key: channelInfo.channelKey,
2707
+ })
2708
+
2709
+ return this.#formatChannelForResponse(channelInfo, ownerAddress)
2710
+ }
2711
+
2712
+ async #openChannelRuntime(channel) {
2713
+ const ns = this.#store.namespace(`channel-${channel.channelKey}`)
2714
+ const localCore = channel.localWriterCoreKey
2715
+ ? ns.get({
2716
+ key: b4a.from(channel.localWriterCoreKey, 'hex'),
2717
+ valueEncoding: 'json',
2718
+ })
2719
+ : ns.get({
2720
+ name: `messages-${channel.writerId || createChannelWriterId()}`,
2721
+ valueEncoding: 'json',
2722
+ })
2723
+ await localCore.ready()
2724
+ const localWriterCoreKey = b4a.toString(localCore.key, 'hex')
2725
+ channel.localWriterCoreKey = localWriterCoreKey
2726
+ channel.writerCoreKeys = uniqueStrings([
2727
+ ...(Array.isArray(channel.writerCoreKeys) ? channel.writerCoreKeys : []),
2728
+ localWriterCoreKey,
2729
+ ])
2730
+
2731
+ if (!this.#channelCores.has(channel.channelKey)) {
2732
+ this.#channelCores.set(channel.channelKey, new Map())
2733
+ }
2734
+ this.#channelCores.get(channel.channelKey).set(localWriterCoreKey, localCore)
2735
+ this.#channelLocalCoreKey.set(channel.channelKey, localWriterCoreKey)
2736
+ if (!this.#channelPeers.has(channel.channelKey)) {
2737
+ this.#channelPeers.set(channel.channelKey, new Map())
2738
+ }
2739
+ this.#setupChannelAppendListener(localCore, channel.channelKey)
2740
+
2741
+ for (const writerCoreKey of channel.writerCoreKeys) {
2742
+ if (writerCoreKey && writerCoreKey !== localWriterCoreKey) {
2743
+ await this.#openRemoteChannelCore(channel.channelKey, writerCoreKey)
2744
+ }
2745
+ }
2746
+ }
2747
+
2748
+ async #mergeChannelWriterCoreKeys(channel, writerCoreKeys = []) {
2749
+ const nextKeys = uniqueStrings(writerCoreKeys)
2750
+ if (nextKeys.length === 0) return false
2751
+
2752
+ const previous = new Set(channel.writerCoreKeys || [])
2753
+ let changed = false
2754
+ for (const writerCoreKey of nextKeys) {
2755
+ if (!previous.has(writerCoreKey)) {
2756
+ previous.add(writerCoreKey)
2757
+ changed = true
2758
+ }
2759
+ if (writerCoreKey !== this.#channelLocalCoreKey.get(channel.channelKey)) {
2760
+ await this.#openRemoteChannelCore(channel.channelKey, writerCoreKey)
2761
+ }
2762
+ }
2763
+ if (changed) {
2764
+ channel.writerCoreKeys = [...previous]
2765
+ this.#saveChannelsMetadata()
2766
+ }
2767
+ return changed
2768
+ }
2769
+
2770
+ async #joinChannelDiscoveryTopics(channel) {
2771
+ if (!this.#channelDiscoveries.has(channel.channelKey)) {
2772
+ const appDiscovery = this.#swarm.join(
2773
+ this.#generateChannelDiscoveryKey(channel.channelKey),
2774
+ { server: true, client: true }
2775
+ )
2776
+ this.#channelDiscoveries.set(channel.channelKey, appDiscovery)
2777
+ }
2778
+
2779
+ if (!this.#channelChatDiscoveries.has(channel.channelKey)) {
2780
+ const chatDiscovery = this.#chatSwarm.join(
2781
+ this.#generateChannelChatDiscoveryKey(channel.channelKey),
2782
+ { server: true, client: true }
2783
+ )
2784
+ this.#channelChatDiscoveries.set(channel.channelKey, chatDiscovery)
2785
+ }
2786
+
2787
+ if (!this.#channelIdDiscoveries.has(channel.channelId)) {
2788
+ const idDiscovery = this.#chatSwarm.join(
2789
+ this.#generateChannelIdDiscoveryKey(channel.channelId),
2790
+ { server: true, client: true }
2791
+ )
2792
+ this.#channelIdDiscoveries.set(channel.channelId, idDiscovery)
2793
+ }
2794
+ }
2795
+
2796
+ #getLocalChannelCandidates(channelId, options = {}) {
2797
+ const ownerAddress = normalizeOwnerAddress(options.ownerAddress)
2798
+ return this.#channels
2799
+ .filter(channel => channel.channelId === channelId)
2800
+ .filter(channel => {
2801
+ if (!ownerAddress) return true
2802
+ return this.#channelHasMember(channel, ownerAddress)
2803
+ })
2804
+ .map(channel => this.#channelToCandidate(channel, true))
2805
+ }
2806
+
2807
+ async #discoverChannelCandidates(channelId, options = {}) {
2808
+ if (this.#options.disableNetwork) return []
2809
+ const timeout =
2810
+ Number(options.timeout) >= 0
2811
+ ? Number(options.timeout)
2812
+ : CHANNEL_DISCOVERY_TIMEOUT
2813
+ const hadDiscovery = this.#channelIdDiscoveries.has(channelId)
2814
+ if (!hadDiscovery) {
2815
+ const discovery = this.#chatSwarm.join(
2816
+ this.#generateChannelIdDiscoveryKey(channelId),
2817
+ { server: true, client: true }
2818
+ )
2819
+ this.#channelIdDiscoveries.set(channelId, discovery)
2820
+ }
2821
+ await sleep(timeout)
2822
+ const now = Date.now()
2823
+ const candidates = [
2824
+ ...(this.#channelCandidateCache.get(channelId)?.values() || []),
2825
+ ].filter(
2826
+ candidate =>
2827
+ candidate.local ||
2828
+ !candidate.lastSeen ||
2829
+ now - candidate.lastSeen <= CHANNEL_CANDIDATE_TTL
2830
+ )
2831
+ if (!hadDiscovery && !this.#channels.some(c => c.channelId === channelId)) {
2832
+ this.#channelIdDiscoveries.delete(channelId)
2833
+ this.#chatSwarm
2834
+ .leave(this.#generateChannelIdDiscoveryKey(channelId))
2835
+ .catch(() => {})
2836
+ }
2837
+ return candidates
2838
+ }
2839
+
2840
+ #mergeChannelCandidates(candidates) {
2841
+ const byKey = new Map()
2842
+ for (const candidate of candidates) {
2843
+ if (!candidate?.channelKey) continue
2844
+ const existing = byKey.get(candidate.channelKey)
2845
+ if (!existing) {
2846
+ byKey.set(candidate.channelKey, {
2847
+ ...candidate,
2848
+ writerCoreKeys: uniqueStrings(candidate.writerCoreKeys),
2849
+ onlineCount: Number(candidate.onlineCount) || (candidate.local ? 0 : 1),
2850
+ })
2851
+ continue
2852
+ }
2853
+ byKey.set(candidate.channelKey, {
2854
+ ...existing,
2855
+ ...candidate,
2856
+ local: existing.local || candidate.local,
2857
+ writerCoreKeys: uniqueStrings([
2858
+ ...existing.writerCoreKeys,
2859
+ ...(candidate.writerCoreKeys || []),
2860
+ ]),
2861
+ onlineCount:
2862
+ Math.max(Number(existing.onlineCount) || 0, 0) +
2863
+ (candidate.local ? 0 : 1),
2864
+ })
2865
+ }
2866
+ return [...byKey.values()]
2867
+ }
2868
+
2869
+ #channelToCandidate(channel, local = false) {
2870
+ return {
2871
+ channelId: channel.channelId,
2872
+ fingerprint: channel.fingerprint,
2873
+ channelKey: channel.channelKey,
2874
+ type: channel.type,
2875
+ createdAt: channel.createdAt,
2876
+ lastMessageAt: channel.lastMessageAt || '',
2877
+ writerCoreKeys: uniqueStrings(channel.writerCoreKeys),
2878
+ local,
2879
+ onlineCount: local ? 0 : 1,
2880
+ }
2881
+ }
2882
+
2883
+ #cacheChannelCandidate(candidate) {
2884
+ if (!candidate?.channelId || !candidate?.channelKey) return
2885
+ if (!this.#channelCandidateCache.has(candidate.channelId)) {
2886
+ this.#channelCandidateCache.set(candidate.channelId, new Map())
2887
+ }
2888
+ const cache = this.#channelCandidateCache.get(candidate.channelId)
2889
+ const existing = cache.get(candidate.channelKey)
2890
+ cache.set(candidate.channelKey, {
2891
+ ...existing,
2892
+ ...candidate,
2893
+ writerCoreKeys: uniqueStrings([
2894
+ ...(existing?.writerCoreKeys || []),
2895
+ ...(candidate.writerCoreKeys || []),
2896
+ ]),
2897
+ onlineCount: Math.max(Number(existing?.onlineCount) || 0, 0) + 1,
2898
+ lastSeen: Date.now(),
2899
+ })
2900
+ }
2901
+
2902
+ #getCachedChannelCandidate(channelId, channelKey) {
2903
+ const candidate = this.#channelCandidateCache.get(channelId)?.get(channelKey)
2904
+ if (candidate) return candidate
2905
+ const local = this.#channels.find(channel => channel.channelKey === channelKey)
2906
+ return local ? this.#channelToCandidate(local, true) : null
2907
+ }
2908
+
2909
+ #formatChannelCandidateForResponse(candidate, ownerAddress = '') {
2910
+ const owner = normalizeOwnerAddress(ownerAddress)
2911
+ const localChannel = this.#channels.find(
2912
+ channel => channel.channelKey === candidate.channelKey
2913
+ )
2914
+ const remark =
2915
+ localChannel && owner
2916
+ ? localChannel.remarks?.[owner] || ''
2917
+ : candidate.local
2918
+ ? ''
2919
+ : `${candidate.channelId}-网络`
2920
+ return {
2921
+ channelId: candidate.channelId,
2922
+ fingerprint: candidate.fingerprint,
2923
+ channelKey: candidate.channelKey,
2924
+ name: candidate.channelId,
2925
+ type: candidate.type || 'public',
2926
+ createdAt: candidate.createdAt || '',
2927
+ lastMessageAt: candidate.lastMessageAt || '',
2928
+ remark,
2929
+ local: Boolean(candidate.local),
2930
+ onlineCount: Number(candidate.onlineCount) || 0,
2931
+ }
2932
+ }
2933
+
2934
+ #formatChannelForResponse(channel, ownerAddress = '') {
2935
+ const owner = normalizeOwnerAddress(ownerAddress)
2936
+ return {
2937
+ name: channel.channelId,
2938
+ channelId: channel.channelId,
2939
+ fingerprint: channel.fingerprint,
2940
+ channelKey: channel.channelKey,
2941
+ key: channel.channelKey,
2942
+ coreKey: channel.localWriterCoreKey,
2943
+ localWriterCoreKey: channel.localWriterCoreKey,
2944
+ writerCoreKeys: uniqueStrings(channel.writerCoreKeys),
2945
+ createdAt: channel.createdAt,
2946
+ lastMessageAt: channel.lastMessageAt || '',
2947
+ type: channel.type,
2948
+ peerCount: (this.#channelPeers.get(channel.channelKey) || new Map()).size,
2949
+ remark: owner && channel.remarks ? channel.remarks[owner] || '' : '',
2950
+ pinned: Boolean(owner && channel.pinnedBy?.[owner]),
2951
+ }
2952
+ }
2953
+
2707
2954
  #ensureInitialized() {
2708
2955
  if (!this.#initialized) {
2709
2956
  throw new EngineNotInitializedError()
@@ -2714,10 +2961,7 @@ export class MostBoxEngine extends EventEmitter {
2714
2961
  const normalizedOwner = normalizeOwnerAddress(ownerAddress)
2715
2962
  if (!normalizedOwner) return
2716
2963
 
2717
- const channel = this.#channels.find(c => c.name === name)
2718
- if (!channel) {
2719
- throw new Error('频道不存在')
2720
- }
2964
+ const channel = this.#resolveChannel(name)
2721
2965
  if (!this.#channelHasMember(channel, normalizedOwner)) {
2722
2966
  throw new PermissionError('未加入该频道')
2723
2967
  }
@@ -2802,35 +3046,47 @@ export class MostBoxEngine extends EventEmitter {
2802
3046
  )
2803
3047
  }
2804
3048
 
2805
- #normalizeChannelMessageForResponse(channelName, message) {
2806
- const attachment = message?.attachment
3049
+ #normalizeChannelMessageForResponse(channelKey, message) {
3050
+ const channel = this.#channels.find(item => item.channelKey === channelKey)
3051
+ const authorAddress = normalizeOwnerAddress(message?.author)
3052
+ const member = Array.isArray(channel?.members)
3053
+ ? channel.members.find(
3054
+ item => normalizeOwnerAddress(item?.address) === authorAddress
3055
+ )
3056
+ : null
3057
+ const avatar = normalizeChannelAvatar(member?.avatar)
3058
+ const baseMessage = avatar && message?.avatar !== avatar
3059
+ ? { ...message, avatar }
3060
+ : message
3061
+ const attachment = baseMessage?.attachment
2807
3062
  if (!attachment?.cid || !attachment.fileName) {
2808
- return message
3063
+ return baseMessage
2809
3064
  }
2810
3065
 
2811
3066
  const oldFileName = sanitizeFilename(String(attachment.fileName))
2812
- const channelPrefix = `${CHAT_FILE_ROOT}/${channelName}/`
3067
+ const channelPathName = channel?.channelId || channelKey
3068
+ const channelPrefix = `${CHAT_FILE_ROOT}/${channelPathName}/`
2813
3069
  const fileName = oldFileName.startsWith(channelPrefix)
2814
3070
  ? oldFileName
2815
3071
  : `${channelPrefix}${getPathBaseName(oldFileName)}`
2816
3072
  const link = buildMostLink(attachment.cid, fileName)
2817
3073
  const content =
2818
- typeof message.content === 'string' &&
2819
- (message.content === attachment.link ||
2820
- parseMostLink(message.content).cid === attachment.cid)
3074
+ typeof baseMessage.content === 'string' &&
3075
+ (baseMessage.content === attachment.link ||
3076
+ parseMostLink(baseMessage.content).cid === attachment.cid)
2821
3077
  ? link
2822
- : message.content
3078
+ : baseMessage.content
2823
3079
 
2824
3080
  if (
2825
3081
  fileName === attachment.fileName &&
2826
3082
  link === attachment.link &&
2827
- content === message.content
3083
+ content === baseMessage.content
2828
3084
  ) {
2829
- return message
3085
+ return baseMessage
2830
3086
  }
2831
3087
 
2832
3088
  return {
2833
- ...message,
3089
+ ...baseMessage,
2834
3090
  content,
2835
3091
  attachment: {
2836
3092
  ...attachment,
@@ -2863,114 +3119,803 @@ export class MostBoxEngine extends EventEmitter {
2863
3119
  }
2864
3120
  }
2865
3121
 
2866
- #hashImportPackage(input) {
2867
- return crypto
2868
- .createHash('sha256')
2869
- .update(JSON.stringify(input || {}))
3122
+ #getUserSyncNamespace(session) {
3123
+ return this.#store.namespace(session.syncName)
3124
+ }
3125
+
3126
+ async #openUserSyncRuntime(session) {
3127
+ const ns = this.#getUserSyncNamespace(session)
3128
+ const localCore = session.localWriterCoreKey
3129
+ ? ns.get({
3130
+ key: b4a.from(session.localWriterCoreKey, 'hex'),
3131
+ valueEncoding: 'json',
3132
+ })
3133
+ : ns.get({
3134
+ name: `writer-${session.writerId}`,
3135
+ valueEncoding: 'json',
3136
+ })
3137
+ await localCore.ready()
3138
+ session.localWriterCoreKey = b4a.toString(localCore.key, 'hex')
3139
+ session.writerCoreKeys = uniqueStrings([
3140
+ ...session.writerCoreKeys,
3141
+ session.localWriterCoreKey,
3142
+ ])
3143
+
3144
+ if (!this.#userSyncCores.has(session.ownerAddress)) {
3145
+ this.#userSyncCores.set(session.ownerAddress, new Map())
3146
+ }
3147
+ this.#userSyncCores
3148
+ .get(session.ownerAddress)
3149
+ .set(session.localWriterCoreKey, localCore)
3150
+ this.#setupUserSyncAppendListener(
3151
+ localCore,
3152
+ session,
3153
+ session.localWriterCoreKey
3154
+ )
3155
+
3156
+ for (const writerCoreKey of session.writerCoreKeys) {
3157
+ if (writerCoreKey && writerCoreKey !== session.localWriterCoreKey) {
3158
+ await this.#openRemoteUserSyncCore(session, writerCoreKey)
3159
+ }
3160
+ }
3161
+ }
3162
+
3163
+ async #joinUserSyncDiscovery(session) {
3164
+ if (this.#userSyncDiscoveries.has(session.ownerAddress)) return
3165
+ const discoveryKey = this.#generateUserSyncDiscoveryKey(session.syncId)
3166
+ const appDiscovery = this.#swarm.join(discoveryKey, {
3167
+ server: true,
3168
+ client: true,
3169
+ })
3170
+ const chatDiscovery = this.#chatSwarm.join(
3171
+ discoveryKey,
3172
+ { server: true, client: true }
3173
+ )
3174
+ this.#userSyncDiscoveries.set(session.ownerAddress, {
3175
+ appDiscovery,
3176
+ chatDiscovery,
3177
+ })
3178
+ }
3179
+
3180
+ async #openRemoteUserSyncCore(session, writerCoreKey) {
3181
+ const normalizedCoreKey = String(writerCoreKey || '').trim()
3182
+ if (
3183
+ !normalizedCoreKey ||
3184
+ normalizedCoreKey === session.localWriterCoreKey
3185
+ ) {
3186
+ return null
3187
+ }
3188
+ if (!this.#userSyncCores.has(session.ownerAddress)) {
3189
+ this.#userSyncCores.set(session.ownerAddress, new Map())
3190
+ }
3191
+ const coresMap = this.#userSyncCores.get(session.ownerAddress)
3192
+ if (coresMap.has(normalizedCoreKey)) return coresMap.get(normalizedCoreKey)
3193
+
3194
+ const ns = this.#getUserSyncNamespace(session)
3195
+ const core = ns.get({
3196
+ key: b4a.from(normalizedCoreKey, 'hex'),
3197
+ valueEncoding: 'json',
3198
+ })
3199
+ await core.ready()
3200
+ coresMap.set(normalizedCoreKey, core)
3201
+ session.writerCoreKeys = uniqueStrings([
3202
+ ...session.writerCoreKeys,
3203
+ normalizedCoreKey,
3204
+ ])
3205
+ this.#persistUserSyncSession(session)
3206
+ this.#setupUserSyncAppendListener(core, session, normalizedCoreKey)
3207
+ return core
3208
+ }
3209
+
3210
+ #setupUserSyncAppendListener(core, session, coreKey) {
3211
+ const offsetKey = `${session.ownerAddress}:${coreKey}`
3212
+ let processing = false
3213
+ const processEntries = async () => {
3214
+ if (processing) return
3215
+ processing = true
3216
+ try {
3217
+ let index = this.#userSyncCoreOffsets.get(offsetKey) || 0
3218
+ while (index < core.length) {
3219
+ const entry = await core.get(index)
3220
+ await this.#applyUserSyncEntry(session, entry)
3221
+ index += 1
3222
+ this.#userSyncCoreOffsets.set(offsetKey, index)
3223
+ }
3224
+ } finally {
3225
+ processing = false
3226
+ }
3227
+ }
3228
+
3229
+ core.on('append', () => {
3230
+ processEntries().catch(err => {
3231
+ console.warn('[MostBox] Failed to process user sync entry:', err.message)
3232
+ })
3233
+ })
3234
+ processEntries().catch(err => {
3235
+ console.warn('[MostBox] Failed to process user sync entries:', err.message)
3236
+ })
3237
+ }
3238
+
3239
+ #encodeUserSyncEntry(session, op) {
3240
+ const nonce = crypto.randomBytes(12)
3241
+ const cipher = crypto.createCipheriv(
3242
+ 'aes-256-gcm',
3243
+ Buffer.from(session.syncCipherKey, 'hex'),
3244
+ nonce
3245
+ )
3246
+ const encrypted = Buffer.concat([
3247
+ cipher.update(JSON.stringify(op), 'utf8'),
3248
+ cipher.final(),
3249
+ ])
3250
+ const tag = cipher.getAuthTag()
3251
+ const body = `${nonce.toString('hex')}.${encrypted.toString('hex')}.${tag.toString('hex')}`
3252
+ const mac = crypto
3253
+ .createHmac('sha256', Buffer.from(session.syncMacKey, 'hex'))
3254
+ .update(body)
3255
+ .digest('hex')
3256
+
3257
+ return {
3258
+ type: 'user-sync-op',
3259
+ schemaVersion: USER_SYNC_SCHEMA_VERSION,
3260
+ ownerAddress: session.ownerAddress,
3261
+ syncId: session.syncId,
3262
+ writerCoreKeys: uniqueStrings(session.writerCoreKeys),
3263
+ body,
3264
+ mac,
3265
+ createdAt: new Date(op.timestamp).toISOString(),
3266
+ }
3267
+ }
3268
+
3269
+ #decodeUserSyncEntry(session, entry) {
3270
+ if (
3271
+ !entry ||
3272
+ entry.type !== 'user-sync-op' ||
3273
+ entry.syncId !== session.syncId ||
3274
+ entry.ownerAddress !== session.ownerAddress ||
3275
+ Number(entry.schemaVersion) !== USER_SYNC_SCHEMA_VERSION
3276
+ ) {
3277
+ return null
3278
+ }
3279
+
3280
+ const expectedMac = crypto
3281
+ .createHmac('sha256', Buffer.from(session.syncMacKey, 'hex'))
3282
+ .update(String(entry.body || ''))
2870
3283
  .digest('hex')
3284
+ if (expectedMac !== entry.mac) return null
3285
+
3286
+ const [nonceHex, encryptedHex, tagHex] = String(entry.body || '').split('.')
3287
+ if (!nonceHex || !encryptedHex || !tagHex) return null
3288
+
3289
+ try {
3290
+ const decipher = crypto.createDecipheriv(
3291
+ 'aes-256-gcm',
3292
+ Buffer.from(session.syncCipherKey, 'hex'),
3293
+ Buffer.from(nonceHex, 'hex')
3294
+ )
3295
+ decipher.setAuthTag(Buffer.from(tagHex, 'hex'))
3296
+ const decrypted = Buffer.concat([
3297
+ decipher.update(Buffer.from(encryptedHex, 'hex')),
3298
+ decipher.final(),
3299
+ ])
3300
+ return JSON.parse(decrypted.toString('utf8'))
3301
+ } catch {
3302
+ return null
3303
+ }
2871
3304
  }
2872
3305
 
2873
- #normalizeUserImportPackage(input = {}) {
2874
- if (!input || typeof input !== 'object') {
2875
- throw new ValidationError('import package must be an object')
3306
+ async #appendUserSyncOp(ownerAddressInput, kind, payload = {}) {
3307
+ const ownerAddress = normalizeOwnerAddress(ownerAddressInput)
3308
+ const session = this.#userSyncSessions.get(ownerAddress)
3309
+ if (!session?.localWriterCoreKey) return null
3310
+ const coresMap = this.#userSyncCores.get(ownerAddress)
3311
+ const core = coresMap?.get(session.localWriterCoreKey)
3312
+ if (!core) return null
3313
+
3314
+ const op = {
3315
+ opId: `${Date.now()}-${crypto.randomBytes(6).toString('hex')}`,
3316
+ schemaVersion: USER_SYNC_SCHEMA_VERSION,
3317
+ kind,
3318
+ ownerAddress,
3319
+ timestamp: Date.now(),
3320
+ payload,
2876
3321
  }
2877
- if (Number(input.schemaVersion) !== USER_DATA_SCHEMA_VERSION) {
2878
- throw new ValidationError('unsupported import package schemaVersion')
3322
+ this.#markUserSyncClockForOp(op)
3323
+ await core.append(this.#encodeUserSyncEntry(session, op))
3324
+ this.#touchUserSyncSession(session)
3325
+ return op
3326
+ }
3327
+
3328
+ #appendUserSyncOpSoon(ownerAddress, kind, payload = {}) {
3329
+ this.#appendUserSyncOp(ownerAddress, kind, payload).catch(err => {
3330
+ console.warn('[MostBox] Failed to append user sync op:', err.message)
3331
+ })
3332
+ }
3333
+
3334
+ async #appendUserSyncSnapshot(ownerAddressInput, reason = 'snapshot') {
3335
+ const ownerAddress = normalizeOwnerAddress(ownerAddressInput)
3336
+ if (!this.#userSyncSessions.has(ownerAddress)) return null
3337
+ const files = this.#getPublishedBucket(ownerAddress).map(file =>
3338
+ this.#formatFileForSync(file, 'active')
3339
+ )
3340
+ const trashFiles = this.#getTrashBucket(ownerAddress).map(file =>
3341
+ this.#formatFileForSync(file, 'trash')
3342
+ )
3343
+ const channels = this.#channels
3344
+ .filter(channel => this.#channelHasMember(channel, ownerAddress))
3345
+ .map(channel => this.#formatChannelForSync(channel, ownerAddress))
3346
+ .filter(Boolean)
3347
+ return this.#appendUserSyncOp(ownerAddress, 'snapshot', {
3348
+ reason,
3349
+ files,
3350
+ trashFiles,
3351
+ channels,
3352
+ })
3353
+ }
3354
+
3355
+ async #applyUserSyncEntry(session, entry) {
3356
+ const op = this.#decodeUserSyncEntry(session, entry)
3357
+ if (!op || op.ownerAddress !== session.ownerAddress) return false
3358
+ if (Array.isArray(entry.writerCoreKeys)) {
3359
+ await this.#mergeUserSyncWriterCoreKeys(session, entry.writerCoreKeys)
2879
3360
  }
3361
+ return this.#applyUserSyncOp(session, op)
3362
+ }
2880
3363
 
2881
- const normalizeFile = (record, scope) => {
2882
- if (!record || typeof record !== 'object') {
2883
- throw new ValidationError(`${scope} record must be an object`)
3364
+ async #applyUserSyncOp(session, op) {
3365
+ let changedFiles = false
3366
+ let changedChannels = false
3367
+ if (op.kind === 'snapshot') {
3368
+ const payload = op.payload || {}
3369
+ for (const file of Array.isArray(payload.files) ? payload.files : []) {
3370
+ changedFiles =
3371
+ this.#applyUserSyncFileRecord(
3372
+ session.ownerAddress,
3373
+ file,
3374
+ 'active',
3375
+ getSyncTimestamp(file.syncUpdatedAt, op.timestamp)
3376
+ ) || changedFiles
2884
3377
  }
2885
- const cid = String(record.cid || '').trim()
2886
- const { driveName } = this.#getCidInfo(cid)
2887
- const fileName = sanitizeFilename(record.fileName || cid)
2888
- if (!fileName || fileName === 'unnamed') {
2889
- throw new ValidationError(`${scope} fileName is invalid`)
3378
+ for (const file of Array.isArray(payload.trashFiles) ? payload.trashFiles : []) {
3379
+ changedFiles =
3380
+ this.#applyUserSyncFileRecord(
3381
+ session.ownerAddress,
3382
+ file,
3383
+ 'trash',
3384
+ getSyncTimestamp(file.syncUpdatedAt, op.timestamp)
3385
+ ) || changedFiles
2890
3386
  }
2891
- return {
2892
- cid,
2893
- fileName,
2894
- driveName,
2895
- size: Number(record.size) || 0,
2896
- source: String(record.source || 'imported'),
2897
- publishedAt:
2898
- typeof record.publishedAt === 'string' ? record.publishedAt : '',
2899
- deletedAt: typeof record.deletedAt === 'string' ? record.deletedAt : '',
2900
- starred: Boolean(record.starred),
3387
+ for (const channel of Array.isArray(payload.channels) ? payload.channels : []) {
3388
+ changedChannels =
3389
+ (await this.#applyUserSyncChannelRecord(
3390
+ session.ownerAddress,
3391
+ channel,
3392
+ getSyncTimestamp(channel.syncUpdatedAt, op.timestamp)
3393
+ )) || changedChannels
2901
3394
  }
3395
+ } else if (op.kind === 'file:upsert') {
3396
+ changedFiles = this.#applyUserSyncFileRecord(
3397
+ session.ownerAddress,
3398
+ op.payload?.file,
3399
+ 'active',
3400
+ getSyncTimestamp(op.payload?.file?.syncUpdatedAt, op.timestamp)
3401
+ )
3402
+ } else if (op.kind === 'file:trash') {
3403
+ changedFiles = this.#applyUserSyncFileRecord(
3404
+ session.ownerAddress,
3405
+ op.payload?.file,
3406
+ 'trash',
3407
+ getSyncTimestamp(op.payload?.file?.syncUpdatedAt, op.timestamp)
3408
+ )
3409
+ } else if (op.kind === 'file:delete') {
3410
+ changedFiles = await this.#applyUserSyncFileDelete(
3411
+ session.ownerAddress,
3412
+ op.payload?.cid,
3413
+ getSyncTimestamp(op.payload?.syncUpdatedAt, op.timestamp)
3414
+ )
3415
+ } else if (op.kind === 'channel:upsert') {
3416
+ changedChannels = await this.#applyUserSyncChannelRecord(
3417
+ session.ownerAddress,
3418
+ op.payload?.channel,
3419
+ getSyncTimestamp(op.payload?.channel?.syncUpdatedAt, op.timestamp)
3420
+ )
3421
+ } else if (op.kind === 'channel:leave') {
3422
+ changedChannels = this.#applyUserSyncChannelLeave(
3423
+ session.ownerAddress,
3424
+ op.payload?.channelKey,
3425
+ getSyncTimestamp(op.payload?.syncUpdatedAt, op.timestamp)
3426
+ )
2902
3427
  }
2903
3428
 
2904
- const files = Array.isArray(input.files)
2905
- ? input.files.map(record => normalizeFile(record, 'files'))
2906
- : []
2907
- const trashFiles = Array.isArray(input.trashFiles)
2908
- ? input.trashFiles.map(record => normalizeFile(record, 'trashFiles'))
2909
- : []
2910
- const channels = Array.isArray(input.channels)
2911
- ? input.channels
2912
- .filter(channel => channel && typeof channel === 'object')
2913
- .map(channel => ({
2914
- name: String(channel.name || '').trim(),
2915
- type: String(channel.type || 'personal').trim() || 'personal',
2916
- coreKey: String(channel.coreKey || '').trim(),
2917
- createdAt:
2918
- typeof channel.createdAt === 'string' ? channel.createdAt : '',
2919
- lastMessageAt:
2920
- typeof channel.lastMessageAt === 'string'
2921
- ? channel.lastMessageAt
2922
- : '',
2923
- member:
2924
- channel.member && typeof channel.member === 'object'
2925
- ? channel.member
2926
- : null,
2927
- remark: String(channel.remark || '').slice(0, 50),
2928
- pinned: Boolean(channel.pinned),
2929
- }))
2930
- .filter(channel => CHANNEL_NAME_REGEX.test(channel.name))
2931
- : []
3429
+ if (changedFiles) {
3430
+ this.#savePublishedMetadata()
3431
+ this.#saveTrashMetadata()
3432
+ this.emit('user:metadata:updated', {
3433
+ ownerAddress: session.ownerAddress,
3434
+ scope: 'files',
3435
+ })
3436
+ }
3437
+ if (changedChannels) {
3438
+ this.#saveChannelsMetadata()
3439
+ this.emit('user:metadata:updated', {
3440
+ ownerAddress: session.ownerAddress,
3441
+ scope: 'channels',
3442
+ })
3443
+ }
3444
+ if (changedFiles || changedChannels) {
3445
+ this.#touchUserSyncSession(session)
3446
+ }
3447
+ return changedFiles || changedChannels
3448
+ }
2932
3449
 
2933
- return { files, trashFiles, channels }
3450
+ async #mergeUserSyncWriterCoreKeys(session, writerCoreKeys = []) {
3451
+ const nextKeys = uniqueStrings(writerCoreKeys)
3452
+ if (nextKeys.length === 0) return false
3453
+ const previous = new Set(session.writerCoreKeys || [])
3454
+ let changed = false
3455
+ for (const writerCoreKey of nextKeys) {
3456
+ if (!previous.has(writerCoreKey)) {
3457
+ previous.add(writerCoreKey)
3458
+ changed = true
3459
+ }
3460
+ if (writerCoreKey !== session.localWriterCoreKey) {
3461
+ await this.#openRemoteUserSyncCore(session, writerCoreKey)
3462
+ }
3463
+ }
3464
+ if (changed) {
3465
+ session.writerCoreKeys = [...previous]
3466
+ this.#persistUserSyncSession(session)
3467
+ }
3468
+ return changed
2934
3469
  }
2935
3470
 
2936
- #applyImportedChannels(ownerAddress, channels = []) {
2937
- const normalizedOwner = normalizeOwnerAddress(ownerAddress)
2938
- if (!normalizedOwner) return
3471
+ async #exchangeUserSyncSessions(peerEngine) {
3472
+ for (const session of this.#userSyncSessions.values()) {
3473
+ const peerSession = peerEngine.#userSyncSessions.get(session.ownerAddress)
3474
+ if (!peerSession || peerSession.syncId !== session.syncId) continue
3475
+ await this.#mergeUserSyncWriterCoreKeys(
3476
+ session,
3477
+ peerSession.writerCoreKeys
3478
+ )
3479
+ await peerEngine.#mergeUserSyncWriterCoreKeys(
3480
+ peerSession,
3481
+ session.writerCoreKeys
3482
+ )
3483
+ }
3484
+ }
2939
3485
 
2940
- for (const imported of channels) {
2941
- let channel = this.#channels.find(item => item.name === imported.name)
2942
- if (!channel) {
2943
- const discoveryKey = this.#generateChannelDiscoveryKey(imported.name)
2944
- channel = {
2945
- name: imported.name,
2946
- discoveryKey: b4a.toString(discoveryKey, 'hex'),
2947
- coreKey: imported.coreKey,
2948
- createdAt: imported.createdAt || new Date().toISOString(),
2949
- lastMessageAt: imported.lastMessageAt || '',
2950
- type: imported.type,
2951
- members: [],
2952
- remoteCoreKeys: [],
2953
- }
2954
- this.#channels.push(channel)
3486
+ #formatFileForSync(file, state = 'active') {
3487
+ const cid = String(file?.cid || '').trim()
3488
+ if (!cid) return null
3489
+ const { driveName } = this.#getCidInfo(cid)
3490
+ const syncUpdatedAt = getSyncTimestamp(
3491
+ file.syncUpdatedAt || file.deletedAt || file.publishedAt
3492
+ )
3493
+ return {
3494
+ cid,
3495
+ fileName: sanitizeFilename(file.fileName || cid),
3496
+ driveName: file.driveName || driveName,
3497
+ size: Number(file.size) || 0,
3498
+ source: String(file.source || (state === 'active' ? 'published' : 'trash')),
3499
+ publishedAt:
3500
+ typeof file.publishedAt === 'string'
3501
+ ? file.publishedAt
3502
+ : new Date(syncUpdatedAt).toISOString(),
3503
+ deletedAt:
3504
+ typeof file.deletedAt === 'string'
3505
+ ? file.deletedAt
3506
+ : state === 'trash'
3507
+ ? new Date(syncUpdatedAt).toISOString()
3508
+ : '',
3509
+ starred: Boolean(file.starred),
3510
+ syncUpdatedAt,
3511
+ }
3512
+ }
3513
+
3514
+ #formatChannelForSync(channel, ownerAddress) {
3515
+ const owner = normalizeOwnerAddress(ownerAddress)
3516
+ if (
3517
+ !channel ||
3518
+ !owner ||
3519
+ TRANSIENT_CHANNEL_TYPES.has(channel.type) ||
3520
+ !this.#channelHasMember(channel, owner)
3521
+ ) {
3522
+ return null
3523
+ }
3524
+ return {
3525
+ channelId: channel.channelId,
3526
+ fingerprint: channel.fingerprint,
3527
+ channelKey: channel.channelKey,
3528
+ type: channel.type,
3529
+ createdAt: channel.createdAt,
3530
+ lastMessageAt: channel.lastMessageAt || '',
3531
+ writerCoreKeys: uniqueStrings(channel.writerCoreKeys),
3532
+ member: this.#getChannelMembers(channel).find(
3533
+ member => member.address === owner
3534
+ ),
3535
+ remark: channel.remarks?.[owner] || '',
3536
+ pinned: Boolean(channel.pinnedBy?.[owner]),
3537
+ syncUpdatedAt: getSyncTimestamp(
3538
+ channel.syncUpdatedAt || channel.lastMessageAt || channel.createdAt
3539
+ ),
3540
+ }
3541
+ }
3542
+
3543
+ #appendUserSyncChannelUpsertSoon(channel, ownerAddress) {
3544
+ const owner = normalizeOwnerAddress(ownerAddress)
3545
+ const record = this.#formatChannelForSync(channel, owner)
3546
+ if (!record) return
3547
+ this.#appendUserSyncOpSoon(owner, 'channel:upsert', { channel: record })
3548
+ }
3549
+
3550
+ #appendUserSyncChannelLeaveSoon(channel, ownerAddress, syncUpdatedAt = Date.now()) {
3551
+ const owner = normalizeOwnerAddress(ownerAddress)
3552
+ if (!owner || !channel || TRANSIENT_CHANNEL_TYPES.has(channel.type)) return
3553
+ this.#appendUserSyncOpSoon(owner, 'channel:leave', {
3554
+ channelKey: channel.channelKey,
3555
+ syncUpdatedAt,
3556
+ })
3557
+ }
3558
+
3559
+ #normalizeSyncFileRecord(record, state, timestamp) {
3560
+ if (!record || typeof record !== 'object') return null
3561
+ const cid = String(record.cid || '').trim()
3562
+ if (!cid) return null
3563
+ let driveName = ''
3564
+ try {
3565
+ driveName = this.#getCidInfo(cid).driveName
3566
+ } catch {
3567
+ return null
3568
+ }
3569
+ const fileName = sanitizeFilename(record.fileName || cid)
3570
+ if (!fileName || fileName === 'unnamed') return null
3571
+ const syncUpdatedAt = getSyncTimestamp(record.syncUpdatedAt, timestamp)
3572
+ return {
3573
+ cid,
3574
+ fileName,
3575
+ driveName: record.driveName || driveName,
3576
+ size: Number(record.size) || 0,
3577
+ source: String(record.source || (state === 'active' ? 'synced' : 'trash')),
3578
+ publishedAt:
3579
+ typeof record.publishedAt === 'string'
3580
+ ? record.publishedAt
3581
+ : new Date(syncUpdatedAt).toISOString(),
3582
+ deletedAt:
3583
+ typeof record.deletedAt === 'string'
3584
+ ? record.deletedAt
3585
+ : state === 'trash'
3586
+ ? new Date(syncUpdatedAt).toISOString()
3587
+ : '',
3588
+ starred: Boolean(record.starred),
3589
+ syncUpdatedAt,
3590
+ }
3591
+ }
3592
+
3593
+ #applyUserSyncFileRecord(ownerAddress, record, state, timestamp) {
3594
+ const normalized = this.#normalizeSyncFileRecord(record, state, timestamp)
3595
+ if (!normalized) return false
3596
+ const entityKey = `file:${normalized.cid}`
3597
+ if (!this.#shouldApplyUserSyncEntity(ownerAddress, entityKey, normalized.syncUpdatedAt)) {
3598
+ return false
3599
+ }
3600
+
3601
+ const publishedFiles = [...this.#getPublishedBucket(ownerAddress)]
3602
+ const trashFiles = [...this.#getTrashBucket(ownerAddress)]
3603
+ let changed = false
3604
+
3605
+ const publishedIndex = publishedFiles.findIndex(
3606
+ file => file.cid === normalized.cid
3607
+ )
3608
+ const trashIndex = trashFiles.findIndex(file => file.cid === normalized.cid)
3609
+ const localHolding = this.#holdings.find(
3610
+ holding => holding.cid === normalized.cid
3611
+ )
3612
+ const localSource = localHolding?.source || 'synced'
3613
+
3614
+ if (state === 'active') {
3615
+ const nextRecord = {
3616
+ fileName: normalized.fileName,
3617
+ cid: normalized.cid,
3618
+ driveName: normalized.driveName,
3619
+ size: normalized.size,
3620
+ source: localSource,
3621
+ publishedAt: normalized.publishedAt,
3622
+ starred: normalized.starred,
3623
+ syncUpdatedAt: normalized.syncUpdatedAt,
3624
+ }
3625
+ if (publishedIndex === -1) {
3626
+ publishedFiles.push(nextRecord)
3627
+ changed = true
3628
+ } else if (
3629
+ JSON.stringify(publishedFiles[publishedIndex]) !==
3630
+ JSON.stringify(nextRecord)
3631
+ ) {
3632
+ publishedFiles[publishedIndex] = nextRecord
3633
+ changed = true
3634
+ }
3635
+ if (trashIndex !== -1) {
3636
+ trashFiles.splice(trashIndex, 1)
3637
+ changed = true
3638
+ }
3639
+ } else {
3640
+ const nextRecord = {
3641
+ fileName: normalized.fileName,
3642
+ cid: normalized.cid,
3643
+ driveName: normalized.driveName,
3644
+ size: normalized.size,
3645
+ source: localSource,
3646
+ publishedAt: normalized.publishedAt,
3647
+ starred: normalized.starred,
3648
+ deletedAt: normalized.deletedAt || new Date(normalized.syncUpdatedAt).toISOString(),
3649
+ syncUpdatedAt: normalized.syncUpdatedAt,
3650
+ }
3651
+ if (trashIndex === -1) {
3652
+ trashFiles.push(nextRecord)
3653
+ changed = true
3654
+ } else if (
3655
+ JSON.stringify(trashFiles[trashIndex]) !== JSON.stringify(nextRecord)
3656
+ ) {
3657
+ trashFiles[trashIndex] = nextRecord
3658
+ changed = true
3659
+ }
3660
+ if (publishedIndex !== -1) {
3661
+ publishedFiles.splice(publishedIndex, 1)
3662
+ changed = true
3663
+ }
3664
+ }
3665
+
3666
+ if (changed) {
3667
+ this.#setPublishedBucket(ownerAddress, publishedFiles)
3668
+ this.#setTrashBucket(ownerAddress, trashFiles)
3669
+ this.#setUserSyncClock(ownerAddress, entityKey, normalized.syncUpdatedAt)
3670
+ }
3671
+ return changed
3672
+ }
3673
+
3674
+ async #applyUserSyncFileDelete(ownerAddress, cidInput, timestamp) {
3675
+ const cid = String(cidInput || '').trim()
3676
+ if (!cid) return false
3677
+ const syncUpdatedAt = getSyncTimestamp(timestamp)
3678
+ const entityKey = `file:${cid}`
3679
+ if (!this.#shouldApplyUserSyncEntity(ownerAddress, entityKey, syncUpdatedAt)) {
3680
+ return false
3681
+ }
3682
+
3683
+ const publishedFiles = this.#getPublishedBucket(ownerAddress).filter(
3684
+ file => file.cid !== cid
3685
+ )
3686
+ const trashFiles = this.#getTrashBucket(ownerAddress).filter(
3687
+ file => file.cid !== cid
3688
+ )
3689
+ const changed =
3690
+ publishedFiles.length !== this.#getPublishedBucket(ownerAddress).length ||
3691
+ trashFiles.length !== this.#getTrashBucket(ownerAddress).length
3692
+ this.#setPublishedBucket(ownerAddress, publishedFiles)
3693
+ this.#setTrashBucket(ownerAddress, trashFiles)
3694
+ this.#setUserSyncClock(ownerAddress, entityKey, syncUpdatedAt)
3695
+ if (changed && !this.#hasAnyUserReference(cid)) {
3696
+ await this.#cleanupUnreferencedCids([cid])
3697
+ }
3698
+ return changed
3699
+ }
3700
+
3701
+ async #applyUserSyncChannelRecord(ownerAddress, record, timestamp) {
3702
+ if (!record || typeof record !== 'object') return false
3703
+ const channelId = normalizeChannelId(record.channelId)
3704
+ const fingerprint = String(record.fingerprint || '').trim()
3705
+ const expectedChannelKey =
3706
+ channelId && fingerprint ? buildChannelKey(channelId, fingerprint) : ''
3707
+ const recordChannelKey = normalizeChannelKey(record.channelKey)
3708
+ if (recordChannelKey && recordChannelKey !== expectedChannelKey) {
3709
+ return false
3710
+ }
3711
+ const channelKey = recordChannelKey || expectedChannelKey
3712
+ if (!channelId || !fingerprint || !channelKey) return false
3713
+ const syncUpdatedAt = getSyncTimestamp(record.syncUpdatedAt, timestamp)
3714
+ const entityKey = `channel:${channelKey}`
3715
+ if (!this.#shouldApplyUserSyncEntity(ownerAddress, entityKey, syncUpdatedAt)) {
3716
+ return false
3717
+ }
3718
+
3719
+ let channel = this.#channels.find(item => item.channelKey === channelKey)
3720
+ let changed = false
3721
+ if (!channel) {
3722
+ channel = {
3723
+ channelId,
3724
+ fingerprint,
3725
+ channelKey,
3726
+ name: channelId,
3727
+ createdAt:
3728
+ typeof record.createdAt === 'string'
3729
+ ? record.createdAt
3730
+ : new Date(syncUpdatedAt).toISOString(),
3731
+ lastMessageAt:
3732
+ typeof record.lastMessageAt === 'string' ? record.lastMessageAt : '',
3733
+ type: String(record.type || 'personal').trim() || 'personal',
3734
+ writerId: createChannelWriterId(),
3735
+ localWriterCoreKey: '',
3736
+ writerCoreKeys: uniqueStrings(record.writerCoreKeys),
3737
+ members: [],
3738
+ syncUpdatedAt,
3739
+ }
3740
+ this.#channels.push(channel)
3741
+ changed = true
3742
+ } else {
3743
+ const nextKeys = uniqueStrings([
3744
+ ...(channel.writerCoreKeys || []),
3745
+ ...(record.writerCoreKeys || []),
3746
+ ])
3747
+ if (nextKeys.length !== (channel.writerCoreKeys || []).length) {
3748
+ channel.writerCoreKeys = nextKeys
3749
+ changed = true
3750
+ }
3751
+ if (record.lastMessageAt && record.lastMessageAt !== channel.lastMessageAt) {
3752
+ channel.lastMessageAt = record.lastMessageAt
3753
+ changed = true
2955
3754
  }
3755
+ channel.syncUpdatedAt = syncUpdatedAt
3756
+ }
2956
3757
 
3758
+ if (
2957
3759
  this.#upsertChannelMember(channel, {
2958
- ownerAddress: normalizedOwner,
2959
- displayName: imported.member?.displayName || imported.member?.name || '',
2960
- avatar: imported.member?.avatar || '',
3760
+ ownerAddress,
3761
+ displayName:
3762
+ record.member?.displayName || record.remark || '',
3763
+ avatar: record.member?.avatar || '',
3764
+ })
3765
+ ) {
3766
+ changed = true
3767
+ }
3768
+ if (record.remark !== undefined) {
3769
+ channel.remarks = channel.remarks || {}
3770
+ const remark = String(record.remark || '').slice(0, 50)
3771
+ if (remark) channel.remarks[ownerAddress] = remark
3772
+ else delete channel.remarks[ownerAddress]
3773
+ changed = true
3774
+ }
3775
+ channel.pinnedBy = channel.pinnedBy || {}
3776
+ if (record.pinned) {
3777
+ channel.pinnedBy[ownerAddress] = true
3778
+ } else {
3779
+ delete channel.pinnedBy[ownerAddress]
3780
+ }
3781
+ this.#setUserSyncClock(ownerAddress, entityKey, syncUpdatedAt)
3782
+
3783
+ if (!this.#channelLocalCoreKey.get(channel.channelKey)) {
3784
+ await this.#openChannelRuntime(channel)
3785
+ await this.#joinChannelDiscoveryTopics(channel)
3786
+ changed = true
3787
+ this.emit('channel:joined', {
3788
+ channel: channel.channelKey,
3789
+ channelKey: channel.channelKey,
3790
+ channelId: channel.channelId,
3791
+ key: channel.channelKey,
2961
3792
  })
3793
+ }
3794
+ return changed
3795
+ }
2962
3796
 
2963
- if (imported.remark) {
2964
- channel.remarks = channel.remarks || {}
2965
- channel.remarks[normalizedOwner] = imported.remark
3797
+ #applyUserSyncChannelLeave(ownerAddress, channelKeyInput, timestamp) {
3798
+ const channelKey = normalizeChannelKey(channelKeyInput)
3799
+ if (!channelKey) return false
3800
+ const syncUpdatedAt = getSyncTimestamp(timestamp)
3801
+ const entityKey = `channel:${channelKey}`
3802
+ if (!this.#shouldApplyUserSyncEntity(ownerAddress, entityKey, syncUpdatedAt)) {
3803
+ return false
3804
+ }
3805
+ const channel = this.#channels.find(item => item.channelKey === channelKey)
3806
+ if (!channel) {
3807
+ this.#setUserSyncClock(ownerAddress, entityKey, syncUpdatedAt)
3808
+ return false
3809
+ }
3810
+ const before = channel.members?.length || 0
3811
+ channel.members = (channel.members || []).filter(
3812
+ member => normalizeOwnerAddress(member?.address) !== ownerAddress
3813
+ )
3814
+ if (channel.remarks) delete channel.remarks[ownerAddress]
3815
+ if (channel.pinnedBy) delete channel.pinnedBy[ownerAddress]
3816
+ this.#setUserSyncClock(ownerAddress, entityKey, syncUpdatedAt)
3817
+ const changed = before !== channel.members.length
3818
+ if (channel.members.length === 0) {
3819
+ this.#channels = this.#channels.filter(item => item.channelKey !== channelKey)
3820
+ }
3821
+ if (changed) {
3822
+ this.emit('channel:left', {
3823
+ channel: channelKey,
3824
+ channelKey,
3825
+ channelId: channel.channelId,
3826
+ name: channel.channelId,
3827
+ })
3828
+ }
3829
+ return changed
3830
+ }
3831
+
3832
+ #getUserSyncClock(ownerAddress, entityKey) {
3833
+ const owner = normalizeOwnerAddress(ownerAddress)
3834
+ return Number(this.#userSyncMetadata.clocks?.[owner]?.[entityKey]) || 0
3835
+ }
3836
+
3837
+ #setUserSyncClock(ownerAddress, entityKey, timestamp) {
3838
+ const owner = normalizeOwnerAddress(ownerAddress)
3839
+ if (!owner || !entityKey) return
3840
+ this.#userSyncMetadata.clocks = this.#userSyncMetadata.clocks || {}
3841
+ this.#userSyncMetadata.clocks[owner] =
3842
+ this.#userSyncMetadata.clocks[owner] || {}
3843
+ this.#userSyncMetadata.clocks[owner][entityKey] = Math.max(
3844
+ this.#getUserSyncClock(owner, entityKey),
3845
+ getSyncTimestamp(timestamp)
3846
+ )
3847
+ this.#saveUserSyncMetadata()
3848
+ }
3849
+
3850
+ #shouldApplyUserSyncEntity(ownerAddress, entityKey, timestamp) {
3851
+ return getSyncTimestamp(timestamp) > this.#getUserSyncClock(ownerAddress, entityKey)
3852
+ }
3853
+
3854
+ #markUserSyncClockForOp(op) {
3855
+ const ownerAddress = normalizeOwnerAddress(op.ownerAddress)
3856
+ if (!ownerAddress) return
3857
+ if (op.kind === 'file:upsert' || op.kind === 'file:trash') {
3858
+ const cid = op.payload?.file?.cid
3859
+ const timestamp = getSyncTimestamp(
3860
+ op.payload?.file?.syncUpdatedAt,
3861
+ op.timestamp
3862
+ )
3863
+ if (cid) this.#setUserSyncClock(ownerAddress, `file:${cid}`, timestamp)
3864
+ } else if (op.kind === 'file:delete') {
3865
+ const cid = op.payload?.cid
3866
+ const timestamp = getSyncTimestamp(
3867
+ op.payload?.syncUpdatedAt,
3868
+ op.timestamp
3869
+ )
3870
+ if (cid) this.#setUserSyncClock(ownerAddress, `file:${cid}`, timestamp)
3871
+ } else if (op.kind === 'channel:upsert') {
3872
+ const channelKey = op.payload?.channel?.channelKey
3873
+ const timestamp = getSyncTimestamp(
3874
+ op.payload?.channel?.syncUpdatedAt,
3875
+ op.timestamp
3876
+ )
3877
+ if (channelKey) {
3878
+ this.#setUserSyncClock(ownerAddress, `channel:${channelKey}`, timestamp)
2966
3879
  }
2967
- if (imported.pinned) {
2968
- channel.pinnedBy = channel.pinnedBy || {}
2969
- channel.pinnedBy[normalizedOwner] = true
3880
+ } else if (op.kind === 'channel:leave') {
3881
+ const channelKey = op.payload?.channelKey
3882
+ const timestamp = getSyncTimestamp(
3883
+ op.payload?.syncUpdatedAt,
3884
+ op.timestamp
3885
+ )
3886
+ if (channelKey) {
3887
+ this.#setUserSyncClock(ownerAddress, `channel:${channelKey}`, timestamp)
2970
3888
  }
2971
3889
  }
2972
3890
  }
2973
3891
 
3892
+ #persistUserSyncSession(session) {
3893
+ this.#userSyncMetadata.sessions = this.#userSyncMetadata.sessions || {}
3894
+ this.#userSyncMetadata.sessions[session.ownerAddress] = {
3895
+ ownerAddress: session.ownerAddress,
3896
+ syncId: session.syncId,
3897
+ syncName: session.syncName,
3898
+ writerId: session.writerId,
3899
+ localWriterCoreKey: session.localWriterCoreKey,
3900
+ writerCoreKeys: uniqueStrings(session.writerCoreKeys),
3901
+ startedAt: session.startedAt,
3902
+ lastSyncedAt:
3903
+ this.#userSyncMetadata.sessions?.[session.ownerAddress]?.lastSyncedAt ||
3904
+ '',
3905
+ updatedAt: new Date().toISOString(),
3906
+ }
3907
+ this.#saveUserSyncMetadata()
3908
+ }
3909
+
3910
+ #touchUserSyncSession(session) {
3911
+ this.#persistUserSyncSession(session)
3912
+ const persisted = this.#userSyncMetadata.sessions?.[session.ownerAddress]
3913
+ if (persisted) {
3914
+ persisted.lastSyncedAt = new Date().toISOString()
3915
+ this.#saveUserSyncMetadata()
3916
+ }
3917
+ }
3918
+
2974
3919
  #getFileRuntimeStats(cid) {
2975
3920
  const state = this.#fileMonitors.get(cid)
2976
3921
  if (!state) {
@@ -3549,6 +4494,10 @@ export class MostBoxEngine extends EventEmitter {
3549
4494
  return path.join(this.#options.dataPath, 'trash-files.json')
3550
4495
  }
3551
4496
 
4497
+ #getUserSyncMetadataPath() {
4498
+ return path.join(this.#options.dataPath, 'user-sync.json')
4499
+ }
4500
+
3552
4501
  #atomicWrite(filePath, data) {
3553
4502
  const tmpPath = filePath + '.tmp'
3554
4503
  fs.writeFileSync(tmpPath, data, 'utf-8')
@@ -3641,6 +4590,65 @@ export class MostBoxEngine extends EventEmitter {
3641
4590
  }
3642
4591
  }
3643
4592
 
4593
+ #loadUserSyncMetadata() {
4594
+ try {
4595
+ const metadataPath = this.#getUserSyncMetadataPath()
4596
+ if (fs.existsSync(metadataPath)) {
4597
+ const parsed = JSON.parse(fs.readFileSync(metadataPath, 'utf-8'))
4598
+ const sessions = {}
4599
+ for (const [owner, session] of Object.entries(parsed.sessions || {})) {
4600
+ const ownerAddress = normalizeOwnerAddress(owner)
4601
+ const syncId = String(session?.syncId || '').trim()
4602
+ if (!ownerAddress || !syncId) continue
4603
+ sessions[ownerAddress] = {
4604
+ ownerAddress,
4605
+ syncId,
4606
+ syncName: String(session.syncName || getUserSyncName(syncId)),
4607
+ writerId: String(session.writerId || ''),
4608
+ localWriterCoreKey: String(session.localWriterCoreKey || ''),
4609
+ writerCoreKeys: uniqueStrings(session.writerCoreKeys),
4610
+ startedAt: String(session.startedAt || ''),
4611
+ lastSyncedAt: String(session.lastSyncedAt || ''),
4612
+ updatedAt: String(session.updatedAt || ''),
4613
+ }
4614
+ }
4615
+
4616
+ const clocks = {}
4617
+ for (const [owner, records] of Object.entries(parsed.clocks || {})) {
4618
+ const ownerAddress = normalizeOwnerAddress(owner)
4619
+ if (!ownerAddress || !records || typeof records !== 'object') continue
4620
+ clocks[ownerAddress] = {}
4621
+ for (const [entityKey, timestamp] of Object.entries(records)) {
4622
+ const value = Number(timestamp)
4623
+ if (entityKey && Number.isFinite(value) && value > 0) {
4624
+ clocks[ownerAddress][entityKey] = value
4625
+ }
4626
+ }
4627
+ }
4628
+
4629
+ return { sessions, clocks }
4630
+ }
4631
+ } catch (err) {
4632
+ console.warn(
4633
+ 'Failed to load user sync metadata, using empty state:',
4634
+ err.message
4635
+ )
4636
+ }
4637
+ return { sessions: {}, clocks: {} }
4638
+ }
4639
+
4640
+ #saveUserSyncMetadata() {
4641
+ try {
4642
+ const metadataPath = this.#getUserSyncMetadataPath()
4643
+ this.#atomicWrite(
4644
+ metadataPath,
4645
+ JSON.stringify(this.#userSyncMetadata, null, 2)
4646
+ )
4647
+ } catch (err) {
4648
+ console.error('Failed to save user sync metadata:', err.message)
4649
+ }
4650
+ }
4651
+
3644
4652
  #getChannelsMetadataPath() {
3645
4653
  return path.join(this.#options.dataPath, 'channels.json')
3646
4654
  }
@@ -3654,7 +4662,37 @@ export class MostBoxEngine extends EventEmitter {
3654
4662
  const metadataPath = this.#getChannelsMetadataPath()
3655
4663
  if (fs.existsSync(metadataPath)) {
3656
4664
  const data = fs.readFileSync(metadataPath, 'utf-8')
3657
- return JSON.parse(data)
4665
+ const channels = JSON.parse(data)
4666
+ if (!Array.isArray(channels)) return []
4667
+ return channels
4668
+ .filter(channel => channel && typeof channel === 'object')
4669
+ .map(channel => {
4670
+ const channelId = normalizeChannelId(channel.channelId)
4671
+ const fingerprint = String(channel.fingerprint || '').trim()
4672
+ const expectedChannelKey =
4673
+ channelId && fingerprint
4674
+ ? buildChannelKey(channelId, fingerprint)
4675
+ : ''
4676
+ const channelKey = normalizeChannelKey(channel.channelKey)
4677
+ return {
4678
+ ...channel,
4679
+ channelId,
4680
+ fingerprint,
4681
+ channelKey,
4682
+ expectedChannelKey,
4683
+ name: channelId,
4684
+ writerCoreKeys: uniqueStrings(channel.writerCoreKeys),
4685
+ }
4686
+ })
4687
+ .filter(
4688
+ channel =>
4689
+ CHANNEL_NAME_REGEX.test(channel.channelId) &&
4690
+ channel.fingerprint &&
4691
+ channel.channelKey === channel.expectedChannelKey &&
4692
+ channel.writerId &&
4693
+ channel.localWriterCoreKey
4694
+ )
4695
+ .map(({ expectedChannelKey: _expectedChannelKey, ...channel }) => channel)
3658
4696
  }
3659
4697
  } catch (err) {
3660
4698
  console.warn(
@@ -3668,9 +4706,23 @@ export class MostBoxEngine extends EventEmitter {
3668
4706
  #saveChannelsMetadata() {
3669
4707
  try {
3670
4708
  const metadataPath = this.#getChannelsMetadataPath()
3671
- const persistentChannels = this.#channels.filter(
3672
- channel => !TRANSIENT_CHANNEL_TYPES.has(channel?.type)
3673
- )
4709
+ const persistentChannels = this.#channels
4710
+ .filter(channel => !TRANSIENT_CHANNEL_TYPES.has(channel?.type))
4711
+ .map(channel => ({
4712
+ channelId: channel.channelId,
4713
+ fingerprint: channel.fingerprint,
4714
+ channelKey: channel.channelKey,
4715
+ name: channel.channelId,
4716
+ type: channel.type,
4717
+ createdAt: channel.createdAt,
4718
+ lastMessageAt: channel.lastMessageAt || '',
4719
+ writerId: channel.writerId,
4720
+ localWriterCoreKey: channel.localWriterCoreKey,
4721
+ writerCoreKeys: uniqueStrings(channel.writerCoreKeys),
4722
+ members: Array.isArray(channel.members) ? channel.members : [],
4723
+ remarks: channel.remarks,
4724
+ pinnedBy: channel.pinnedBy,
4725
+ }))
3674
4726
  this.#atomicWrite(
3675
4727
  metadataPath,
3676
4728
  JSON.stringify(persistentChannels, null, 2)
@@ -3680,23 +4732,39 @@ export class MostBoxEngine extends EventEmitter {
3680
4732
  }
3681
4733
  }
3682
4734
 
3683
- #generateChannelDiscoveryKey(name) {
4735
+ #generateChannelDiscoveryKey(channelKey) {
3684
4736
  const hash = crypto
3685
4737
  .createHash('sha256')
3686
- .update(`${CHANNEL_NAME_PREFIX}${name}`)
4738
+ .update(`${CHANNEL_NAME_PREFIX}channel:${channelKey}`)
3687
4739
  .digest()
3688
4740
  return hash
3689
4741
  }
3690
4742
 
3691
- #generateChannelChatDiscoveryKey(name) {
4743
+ #generateChannelChatDiscoveryKey(channelKey) {
3692
4744
  const hash = crypto
3693
4745
  .createHash('sha256')
3694
- .update(`${CHANNEL_NAME_PREFIX}${name}:chat`)
4746
+ .update(`${CHANNEL_NAME_PREFIX}channel:${channelKey}:chat`)
3695
4747
  .digest()
3696
4748
  return hash
3697
4749
  }
3698
4750
 
3699
- #setupChannelAppendListener(core, channelName) {
4751
+ #generateChannelIdDiscoveryKey(channelId) {
4752
+ const hash = crypto
4753
+ .createHash('sha256')
4754
+ .update(`${CHANNEL_NAME_PREFIX}id:${channelId}:candidates`)
4755
+ .digest()
4756
+ return hash
4757
+ }
4758
+
4759
+ #generateUserSyncDiscoveryKey(syncId) {
4760
+ const hash = crypto
4761
+ .createHash('sha256')
4762
+ .update(`${CHANNEL_NAME_PREFIX}${USER_SYNC_NAMESPACE_PREFIX}${syncId}`)
4763
+ .digest()
4764
+ return hash
4765
+ }
4766
+
4767
+ #setupChannelAppendListener(core, channelKey) {
3700
4768
  let lastCoreLength = core.length
3701
4769
  core.on('append', async () => {
3702
4770
  if (core.length > lastCoreLength) {
@@ -3704,7 +4772,9 @@ export class MostBoxEngine extends EventEmitter {
3704
4772
  try {
3705
4773
  const entry = await core.get(i)
3706
4774
  if (entry && entry.type === 'message') {
3707
- const channel = this.#channels.find(c => c.name === channelName)
4775
+ const channel = this.#channels.find(
4776
+ c => c.channelKey === channelKey
4777
+ )
3708
4778
  if (channel) {
3709
4779
  const entryTime = Number(entry.timestamp) || Date.now()
3710
4780
  const currentTime = Date.parse(channel.lastMessageAt || '') || 0
@@ -3714,16 +4784,18 @@ export class MostBoxEngine extends EventEmitter {
3714
4784
  }
3715
4785
  }
3716
4786
  this.emit('channel:message', {
3717
- channel: channelName,
4787
+ channel: channelKey,
4788
+ channelKey,
4789
+ channelId: channel?.channelId || '',
3718
4790
  message: this.#normalizeChannelMessageForResponse(
3719
- channelName,
4791
+ channelKey,
3720
4792
  entry
3721
4793
  ),
3722
4794
  })
3723
4795
  }
3724
4796
  } catch (err) {
3725
4797
  console.error(
3726
- `[MostBox] Failed to read channel message from ${channelName}:`,
4798
+ `[MostBox] Failed to read channel message from ${channelKey}:`,
3727
4799
  err.message
3728
4800
  )
3729
4801
  continue
@@ -3734,13 +4806,13 @@ export class MostBoxEngine extends EventEmitter {
3734
4806
  })
3735
4807
  }
3736
4808
 
3737
- async #openRemoteChannelCore(channelName, coreKeyHex) {
3738
- const coresMap = this.#channelCores.get(channelName)
4809
+ async #openRemoteChannelCore(channelKey, coreKeyHex) {
4810
+ const coresMap = this.#channelCores.get(channelKey)
3739
4811
  if (!coresMap) return
3740
4812
  if (coresMap.has(coreKeyHex)) return
3741
4813
 
3742
4814
  try {
3743
- const ns = this.#store.namespace(`channel-${channelName}`)
4815
+ const ns = this.#store.namespace(`channel-${channelKey}`)
3744
4816
  const core = ns.get({
3745
4817
  key: b4a.from(coreKeyHex, 'hex'),
3746
4818
  valueEncoding: 'json',
@@ -3748,23 +4820,21 @@ export class MostBoxEngine extends EventEmitter {
3748
4820
  await core.ready()
3749
4821
  const normalizedCoreKey = b4a.toString(core.key, 'hex')
3750
4822
  coresMap.set(normalizedCoreKey, core)
3751
- this.#setupChannelAppendListener(core, channelName)
3752
- const channel = this.#channels.find(c => c.name === channelName)
3753
- if (channel && normalizedCoreKey !== channel.coreKey) {
3754
- if (!Array.isArray(channel.remoteCoreKeys)) {
3755
- channel.remoteCoreKeys = []
3756
- }
3757
- if (!channel.remoteCoreKeys.includes(normalizedCoreKey)) {
3758
- channel.remoteCoreKeys.push(normalizedCoreKey)
3759
- this.#saveChannelsMetadata()
3760
- }
4823
+ this.#setupChannelAppendListener(core, channelKey)
4824
+ const channel = this.#channels.find(c => c.channelKey === channelKey)
4825
+ if (channel && !channel.writerCoreKeys?.includes(normalizedCoreKey)) {
4826
+ channel.writerCoreKeys = uniqueStrings([
4827
+ ...(channel.writerCoreKeys || []),
4828
+ normalizedCoreKey,
4829
+ ])
4830
+ this.#saveChannelsMetadata()
3761
4831
  }
3762
4832
  console.log(
3763
- `[MostBox] Opened remote channel core ${normalizedCoreKey.slice(0, 8)}... for ${channelName}`
4833
+ `[MostBox] Opened remote channel core ${normalizedCoreKey.slice(0, 8)}... for ${channelKey}`
3764
4834
  )
3765
4835
  } catch (err) {
3766
4836
  console.warn(
3767
- `[MostBox] Failed to open remote channel core for ${channelName}:`,
4837
+ `[MostBox] Failed to open remote channel core for ${channelKey}:`,
3768
4838
  err.message
3769
4839
  )
3770
4840
  }
@@ -3774,17 +4844,33 @@ export class MostBoxEngine extends EventEmitter {
3774
4844
  const stream = conn
3775
4845
  let connectedPeerId = null
3776
4846
 
3777
- const coreKeys = {}
3778
- for (const [name, localKeyHex] of this.#channelLocalCoreKey) {
3779
- coreKeys[name] = localKeyHex
3780
- }
4847
+ const channels = this.#channels.map(channel => ({
4848
+ channelId: channel.channelId,
4849
+ fingerprint: channel.fingerprint,
4850
+ channelKey: channel.channelKey,
4851
+ type: channel.type,
4852
+ createdAt: channel.createdAt,
4853
+ lastMessageAt: channel.lastMessageAt || '',
4854
+ writerCoreKeys: uniqueStrings([
4855
+ ...(channel.writerCoreKeys || []),
4856
+ this.#channelLocalCoreKey.get(channel.channelKey),
4857
+ ]),
4858
+ }))
4859
+ const userSyncSessions = [...this.#userSyncSessions.values()].map(
4860
+ session => ({
4861
+ ownerAddress: session.ownerAddress,
4862
+ syncId: session.syncId,
4863
+ syncName: session.syncName,
4864
+ writerCoreKeys: uniqueStrings(session.writerCoreKeys),
4865
+ })
4866
+ )
3781
4867
 
3782
4868
  const helloMessage = JSON.stringify({
3783
4869
  type: 'channel-hello',
3784
4870
  peerId: this.getNodeId(),
3785
4871
  authorName: this.getNodeId().slice(0, 4),
3786
- channels: this.#channels.map(c => c.name),
3787
- coreKeys,
4872
+ channels,
4873
+ userSyncSessions,
3788
4874
  })
3789
4875
 
3790
4876
  try {
@@ -3799,27 +4885,91 @@ export class MostBoxEngine extends EventEmitter {
3799
4885
  if (msg.type === 'channel-hello') {
3800
4886
  connectedPeerId = msg.peerId
3801
4887
 
3802
- const theirChannels = new Set(msg.channels || [])
3803
- for (const [name, peers] of this.#channelPeers) {
3804
- if (theirChannels.has(name)) {
4888
+ const remoteChannels = Array.isArray(msg.channels)
4889
+ ? msg.channels
4890
+ .filter(channel => channel && typeof channel === 'object')
4891
+ .map(channel => ({
4892
+ channelId: normalizeChannelId(channel.channelId),
4893
+ fingerprint: String(channel.fingerprint || '').trim(),
4894
+ channelKey: normalizeChannelKey(channel.channelKey),
4895
+ type: String(channel.type || 'public').trim() || 'public',
4896
+ createdAt:
4897
+ typeof channel.createdAt === 'string'
4898
+ ? channel.createdAt
4899
+ : '',
4900
+ lastMessageAt:
4901
+ typeof channel.lastMessageAt === 'string'
4902
+ ? channel.lastMessageAt
4903
+ : '',
4904
+ writerCoreKeys: uniqueStrings(channel.writerCoreKeys),
4905
+ }))
4906
+ .filter(
4907
+ channel =>
4908
+ channel.channelId &&
4909
+ channel.fingerprint &&
4910
+ channel.channelKey
4911
+ )
4912
+ : []
4913
+
4914
+ for (const remoteChannel of remoteChannels) {
4915
+ this.#cacheChannelCandidate({
4916
+ ...remoteChannel,
4917
+ local: false,
4918
+ peerId: msg.peerId,
4919
+ onlineCount: 1,
4920
+ })
4921
+
4922
+ const localChannel = this.#channels.find(
4923
+ channel => channel.channelKey === remoteChannel.channelKey
4924
+ )
4925
+ if (!localChannel) continue
4926
+
4927
+ const peers = this.#channelPeers.get(localChannel.channelKey)
4928
+ if (peers) {
3805
4929
  peers.set(msg.peerId, {
3806
4930
  peerId: msg.peerId,
3807
4931
  authorName: msg.authorName,
3808
4932
  lastSeen: Date.now(),
3809
4933
  })
3810
4934
  }
3811
- }
3812
4935
 
3813
- if (msg.coreKeys && typeof msg.coreKeys === 'object') {
3814
- for (const [channelName, coreKeyHex] of Object.entries(
3815
- msg.coreKeys
3816
- )) {
3817
- if (this.#channelCores.has(channelName) && coreKeyHex) {
3818
- await this.#openRemoteChannelCore(channelName, coreKeyHex)
4936
+ for (const writerCoreKey of remoteChannel.writerCoreKeys) {
4937
+ if (
4938
+ writerCoreKey &&
4939
+ writerCoreKey !== this.#channelLocalCoreKey.get(localChannel.channelKey)
4940
+ ) {
4941
+ await this.#openRemoteChannelCore(
4942
+ localChannel.channelKey,
4943
+ writerCoreKey
4944
+ )
3819
4945
  }
3820
4946
  }
3821
4947
  }
3822
4948
 
4949
+ const remoteUserSyncSessions = Array.isArray(msg.userSyncSessions)
4950
+ ? msg.userSyncSessions
4951
+ .filter(session => session && typeof session === 'object')
4952
+ .map(session => ({
4953
+ ownerAddress: normalizeOwnerAddress(session.ownerAddress),
4954
+ syncId: String(session.syncId || '').trim(),
4955
+ writerCoreKeys: uniqueStrings(session.writerCoreKeys),
4956
+ }))
4957
+ .filter(session => session.ownerAddress && session.syncId)
4958
+ : []
4959
+
4960
+ for (const remoteSession of remoteUserSyncSessions) {
4961
+ const localSession = this.#userSyncSessions.get(
4962
+ remoteSession.ownerAddress
4963
+ )
4964
+ if (!localSession || localSession.syncId !== remoteSession.syncId) {
4965
+ continue
4966
+ }
4967
+ await this.#mergeUserSyncWriterCoreKeys(
4968
+ localSession,
4969
+ remoteSession.writerCoreKeys
4970
+ )
4971
+ }
4972
+
3823
4973
  this.emit('channel:peer:online', {
3824
4974
  peerId: msg.peerId,
3825
4975
  authorName: msg.authorName,