most-box 0.1.9 → 0.2.1

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 (179) 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-OiOEqXPr.js +1 -0
  5. package/out/assets/ChatUi-Cif5LRF3.js +1 -0
  6. package/out/assets/CopyButton-Dm7krgbq.js +1 -0
  7. package/out/assets/LanguageToggle-B4ZNuBCV.js +1 -0
  8. package/out/assets/LogoIcon-B2fFe0l1.js +1 -0
  9. package/out/assets/MarketingHeader-yIZuQP7m.js +1 -0
  10. package/out/assets/MarketingLayout-DVH0Nx7S.js +1 -0
  11. package/out/assets/MarketingThemeToggle-qlwCZU1o.js +1 -0
  12. package/out/assets/MilkdownEditor-_TGlDyA_.js +1054 -0
  13. package/out/assets/MoveModal-BVr4Q7-b.js +1 -0
  14. package/out/assets/Nav-5xeettNJ.js +1 -0
  15. package/out/assets/NoteSidebar-DpniUKmy.js +1 -0
  16. package/out/assets/OpenSidebarButton-BfgG2HIT.js +1 -0
  17. package/out/assets/PemBlock-CxwIepth.js +1 -0
  18. package/out/assets/SidebarAccount-Zg5DZblE.js +1 -0
  19. package/out/assets/arrow-right-CL9YSDVS.js +1 -0
  20. package/out/assets/channelApi-DNdJfsJ-.js +1 -0
  21. package/out/assets/chevron-down-CnLh_-aO.js +1 -0
  22. package/out/assets/{circle-alert-CqEQz6P4.js → circle-alert-oiiRDvhx.js} +1 -1
  23. package/out/assets/cloud-BEe2N89j.js +1 -0
  24. package/out/assets/code-9LB8QqxL.js +1 -0
  25. package/out/assets/{copy-CM-qWlbv.js → copy-giX4rmFJ.js} +1 -1
  26. package/out/assets/{dist-CvatM4u8.js → dist-8QDHqrPN.js} +1 -1
  27. package/out/assets/{dist-BmtYO1GG.js → dist-B0JrbG7f.js} +5 -5
  28. package/out/assets/{dist-DBpw-A8y.js → dist-BFSyuOuw.js} +1 -1
  29. package/out/assets/{dist-D5Cf0hK8.js → dist-BPs0Xns9.js} +2 -2
  30. package/out/assets/{dist-BQV5zYG_.js → dist-Br_5lLVO.js} +9 -9
  31. package/out/assets/{dist-CrzjJUOw.js → dist-BzqqCPi2.js} +1 -1
  32. package/out/assets/dist-C3lbe8SW.js +9 -0
  33. package/out/assets/{dist-CWJ3323z.js → dist-CfU9fwWz.js} +1 -1
  34. package/out/assets/{dist-BdmTLuCI.js → dist-DCX0ws1K.js} +1 -1
  35. package/out/assets/{dist-DrumcFOX.js → dist-DJdv-Ma3.js} +1 -1
  36. package/out/assets/{dist-C5HibLEW.js → dist-chOCTzB2.js} +1 -1
  37. package/out/assets/{download-0rM8xVCe.js → download-D0oMEYQZ.js} +1 -1
  38. package/out/assets/downloadValidation-Bk1VsBBo.js +1 -0
  39. package/out/assets/external-link-Cm2WCUxv.js +1 -0
  40. package/out/assets/{filePreview-BvbHWUTG.js → filePreview-BZ50vZZf.js} +1 -1
  41. package/out/assets/format-CR8oUWq6.js +1 -0
  42. package/out/assets/game-Bvz4dspe.js +1 -0
  43. package/out/assets/{hard-drive-CCdIvSap.js → hard-drive-B3CQbcp2.js} +1 -1
  44. package/out/assets/index-BkZvz4WA.css +1 -0
  45. package/out/assets/index-WCK14Vja.js +34 -0
  46. package/out/assets/index.lazy-5Q6GuMNT.js +1 -0
  47. package/out/assets/index.lazy-5jq6EFXa.js +3 -0
  48. package/out/assets/index.lazy-7n1Q-NrA.js +3 -0
  49. package/out/assets/index.lazy-BFnOyQFj.js +1 -0
  50. package/out/assets/index.lazy-B_oPp6qK.js +1 -0
  51. package/out/assets/index.lazy-BvY50KVz.js +1 -0
  52. package/out/assets/index.lazy-C0Kn_amZ.js +1 -0
  53. package/out/assets/index.lazy-C3cek3Gn.js +1 -0
  54. package/out/assets/index.lazy-CLpPkdy1.js +1 -0
  55. package/out/assets/index.lazy-Cpr1kApf.js +2 -0
  56. package/out/assets/index.lazy-CuwLZiUK.js +1 -0
  57. package/out/assets/index.lazy-DDc3Ylgf.js +2 -0
  58. package/out/assets/index.lazy-Dg3aqOss.js +1 -0
  59. package/out/assets/{key-round-tIqGrtt_.js → key-round-CzuljhND.js} +1 -1
  60. package/out/assets/lock-D2NhNoJW.js +1 -0
  61. package/out/assets/message-square-DwBq_Go5.js +1 -0
  62. package/out/assets/mp-Bln2MB9G.js +1 -0
  63. package/out/assets/{music-BkZKq879.js → music-CB73K5Gz.js} +1 -1
  64. package/out/assets/{notebook-pen-B4VSbweh.js → notebook-pen-Up7r5zoI.js} +1 -1
  65. package/out/assets/play-OszVgROb.js +1 -0
  66. package/out/assets/plus-BbxQG_Ai.js +1 -0
  67. package/out/assets/{save-BzjzC3eV.js → save-CiqyiifY.js} +1 -1
  68. package/out/assets/search-gqAPOsgS.js +1 -0
  69. package/out/assets/{send-DtQInX0y.js → send-vwCWsZGP.js} +1 -1
  70. package/out/assets/shield-check-CxWxsNLc.js +1 -0
  71. package/out/assets/{trash-2-BhMrUgGM.js → trash-2-DNGr8IgF.js} +1 -1
  72. package/out/assets/triangle-alert-B_1BlX1b.js +1 -0
  73. package/out/assets/upload-Dxl7GUzb.js +1 -0
  74. package/out/assets/useChannelMessages-7bYKXU_R.js +3 -0
  75. package/out/assets/useGameRoom-DqA1mkfk.js +1 -0
  76. package/out/assets/wallet-DlkawdPJ.js +1 -0
  77. package/out/assets/{wifi-v3JpPCNm.js → wifi-sBOKcPFM.js} +1 -1
  78. package/out/avatars/default/LICENSE.md +7 -0
  79. package/out/avatars/default/dusk.svg +100 -0
  80. package/out/avatars/default/ember.svg +55 -0
  81. package/out/avatars/default/mint.svg +29 -0
  82. package/out/avatars/default/ocean.svg +25 -0
  83. package/out/avatars/default/sage.svg +34 -0
  84. package/out/avatars/default/violet.svg +60 -0
  85. package/out/chat/index.html +0 -0
  86. package/out/chat/join/index.html +0 -0
  87. package/out/demo/index.html +0 -0
  88. package/out/download/index.html +7 -2
  89. package/out/game/gandengyan/index.html +0 -0
  90. package/out/game/index.html +0 -0
  91. package/out/game/zhajinhua/index.html +0 -0
  92. package/out/index.html +7 -2
  93. package/out/note/index.html +0 -0
  94. package/out/ping/index.html +7 -2
  95. package/out/{web3/ed25519 → profile}/index.html +0 -0
  96. package/out/web3/index.html +0 -0
  97. package/package.json +2 -2
  98. package/public/avatars/default/LICENSE.md +7 -0
  99. package/public/avatars/default/dusk.svg +100 -0
  100. package/public/avatars/default/ember.svg +55 -0
  101. package/public/avatars/default/mint.svg +29 -0
  102. package/public/avatars/default/ocean.svg +25 -0
  103. package/public/avatars/default/sage.svg +34 -0
  104. package/public/avatars/default/violet.svg +60 -0
  105. package/server/index.js +9 -0
  106. package/server/src/core/channelAttachment.js +7 -3
  107. package/server/src/core/channelIdentity.js +39 -0
  108. package/server/src/core/cid.js +6 -1
  109. package/server/src/core/cidTopic.js +18 -4
  110. package/server/src/core/displayPath.js +10 -0
  111. package/server/src/core/gameRoom.js +2 -2
  112. package/server/src/core/mostLink.js +45 -25
  113. package/server/src/core/ownerMetadata.js +34 -0
  114. package/server/src/core/userSyncKeys.js +36 -0
  115. package/server/src/http/app.js +68 -148
  116. package/server/src/http/dataPath.js +26 -0
  117. package/server/src/http/errors.js +8 -4
  118. package/server/src/http/nodeStatus.js +13 -13
  119. package/server/src/http/rateLimit.js +39 -0
  120. package/server/src/http/routePolicy.js +43 -0
  121. package/server/src/index.js +2010 -823
  122. package/server/src/node/offlineSwarm.js +20 -0
  123. package/server/src/utils/api.js +1 -15
  124. package/server/src/utils/avatar.js +44 -3
  125. package/server/src/utils/downloadMessages.js +17 -18
  126. package/server/src/utils/errors.js +3 -1
  127. package/server/src/utils/noteUtils.js +27 -3
  128. package/out/assets/AppShell-DmZQwVA9.js +0 -1
  129. package/out/assets/ChatUi-CVGqjFdx.js +0 -1
  130. package/out/assets/LogoIcon-CYQ7cHd5.js +0 -1
  131. package/out/assets/MarketingLayout-BTIbv4fW.js +0 -1
  132. package/out/assets/MilkdownEditor-BL8xE7u9.js +0 -1054
  133. package/out/assets/MoveModal-BKkVBvrS.js +0 -1
  134. package/out/assets/Nav-BDGeJnbC.js +0 -1
  135. package/out/assets/NoteSidebar-BIJ8_m5K.js +0 -1
  136. package/out/assets/OpenSidebarButton-Di62DGiu.js +0 -1
  137. package/out/assets/PemBlock-Dxx6k9MH.js +0 -1
  138. package/out/assets/SidebarAccount-CCHZLGdP.js +0 -1
  139. package/out/assets/admin-BepWGXWG.js +0 -2
  140. package/out/assets/app-3D79fY3w.js +0 -1
  141. package/out/assets/arrow-right-D0sGC8QA.js +0 -1
  142. package/out/assets/channelApi-CL7YsIQ-.js +0 -1
  143. package/out/assets/chat-B56sk6od.js +0 -1
  144. package/out/assets/check-DdfnsLKm.js +0 -1
  145. package/out/assets/chevron-down-Xlb3wTxd.js +0 -1
  146. package/out/assets/circle-check-CwAH4dgJ.js +0 -1
  147. package/out/assets/cloud-CcPRoob1.js +0 -1
  148. package/out/assets/code-Dr6STnCn.js +0 -1
  149. package/out/assets/database-DQ7ZtUT9.js +0 -1
  150. package/out/assets/dateTime-D1koKRQU.js +0 -1
  151. package/out/assets/demo-B_6rlIjn.js +0 -3
  152. package/out/assets/dist-BGtXa07s.js +0 -9
  153. package/out/assets/download-BLPU-Kzq.js +0 -1
  154. package/out/assets/downloadMessages-7Xbd-HhS.js +0 -1
  155. package/out/assets/ed25519-BEctXF0E.js +0 -1
  156. package/out/assets/folder-CcbCxm-k.js +0 -1
  157. package/out/assets/game-B0zuqnOh.js +0 -1
  158. package/out/assets/gandengyan-DbQC7hCK.js +0 -1
  159. package/out/assets/index-BLhmAher.css +0 -1
  160. package/out/assets/index-Cf23WD2V.js +0 -29
  161. package/out/assets/join-DQHXjlfH.js +0 -1
  162. package/out/assets/note-DmWqGSS2.js +0 -2
  163. package/out/assets/ping-JILckfMu.js +0 -1
  164. package/out/assets/play-BIl5vwqS.js +0 -1
  165. package/out/assets/plus-DHvLpuuw.js +0 -1
  166. package/out/assets/routes-Dyckj88f.js +0 -1
  167. package/out/assets/search-C-EpsDNl.js +0 -1
  168. package/out/assets/sun-C3IUQTpa.js +0 -1
  169. package/out/assets/tools-BEctXF0E.js +0 -1
  170. package/out/assets/triangle-alert-DUODU79n.js +0 -1
  171. package/out/assets/upload-CpDM23UH.js +0 -1
  172. package/out/assets/useChannelMessages-Bs1hEJyd.js +0 -3
  173. package/out/assets/useGameRoom-C6UgmIGG.js +0 -1
  174. package/out/assets/wallet-YxbxCi7C.js +0 -1
  175. package/out/assets/web3-CRX1YFmw.js +0 -3
  176. package/out/assets/zhajinhua-QDmSZbOp.js +0 -1
  177. package/out/web3/tools/index.html +0 -0
  178. /package/out/assets/{gandengyan-8eWJAjpY.css → index-8eWJAjpY.css} +0 -0
  179. /package/out/assets/{zhajinhua-BZc4blbW.css → index-BZc4blbW.css} +0 -0
@@ -16,10 +16,41 @@ import b4a from 'b4a'
16
16
  import crypto from 'node:crypto'
17
17
  import fs from 'node:fs'
18
18
  import path from 'node:path'
19
+ import { Duplex } from 'node:stream'
19
20
 
20
- import { calculateCid, parseMostLink } from './core/cid.js'
21
+ import { calculateCid, parseMostLink, buildMostLink } from './core/cid.js'
21
22
  import { normalizeChannelAttachment } from './core/channelAttachment.js'
22
23
  import { getCidInfo } from './core/cidTopic.js'
24
+ import {
25
+ CHAT_FILE_ROOT,
26
+ TRANSIENT_CHANNEL_TYPES,
27
+ CHANNEL_DISCOVERY_TIMEOUT,
28
+ CHANNEL_CANDIDATE_TTL,
29
+ normalizeChannelDisplayName,
30
+ normalizeChannelAvatar,
31
+ normalizeChannelId,
32
+ createChannelWriterId,
33
+ buildChannelKey,
34
+ normalizeChannelKey,
35
+ uniqueStrings,
36
+ } from './core/channelIdentity.js'
37
+ import { getPathBaseName, getDisplayPathFolder } from './core/displayPath.js'
38
+ import {
39
+ normalizeOwnerAddress,
40
+ getOwnerBucketKey,
41
+ normalizeMetadataBuckets,
42
+ cloneMetadataRecord,
43
+ } from './core/ownerMetadata.js'
44
+ import {
45
+ USER_SYNC_SCHEMA_VERSION,
46
+ USER_SYNC_NAMESPACE_PREFIX,
47
+ normalizeUserSyncKey,
48
+ deriveUserSyncId,
49
+ getUserSyncName,
50
+ getSyncTimestamp,
51
+ getNextSyncTimestamp,
52
+ } from './core/userSyncKeys.js'
53
+ import { createOfflineSwarm } from './node/offlineSwarm.js'
23
54
  import {
24
55
  sanitizeFilename,
25
56
  validateAndSanitizePath,
@@ -66,89 +97,43 @@ import {
66
97
  } from './config.js'
67
98
 
68
99
  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
100
 
130
- function normalizeChannelAvatar(input) {
131
- const value = String(input || '').trim()
132
- return value ? value.slice(0, 4096) : ''
133
- }
101
+ function createMemoryDuplexPair() {
102
+ let left
103
+ let right
134
104
 
135
- function createOfflineSwarm() {
136
- return {
137
- connections: new Set(),
138
- keyPair: {
139
- publicKey: crypto.randomBytes(32),
105
+ left = new Duplex({
106
+ read() {},
107
+ write(chunk, _encoding, callback) {
108
+ if (!right.destroyed) right.push(chunk)
109
+ callback()
140
110
  },
141
- on() {},
142
- join() {
143
- return {}
111
+ final(callback) {
112
+ if (!right.destroyed) right.push(null)
113
+ callback()
144
114
  },
145
- leave() {
146
- return Promise.resolve()
115
+ })
116
+
117
+ right = new Duplex({
118
+ read() {},
119
+ write(chunk, _encoding, callback) {
120
+ if (!left.destroyed) left.push(chunk)
121
+ callback()
147
122
  },
148
- destroy() {
149
- return Promise.resolve()
123
+ final(callback) {
124
+ if (!left.destroyed) left.push(null)
125
+ callback()
150
126
  },
151
- }
127
+ })
128
+
129
+ left.on('close', () => {
130
+ if (!right.destroyed) right.destroy()
131
+ })
132
+ right.on('close', () => {
133
+ if (!left.destroyed) left.destroy()
134
+ })
135
+
136
+ return [left, right]
152
137
  }
153
138
 
154
139
  export class MostBoxEngine extends EventEmitter {
@@ -161,7 +146,6 @@ export class MostBoxEngine extends EventEmitter {
161
146
  #initialized = false
162
147
  #options = null
163
148
  #activeDownloads = new Map()
164
- #importChecks = new Map()
165
149
  #drivePromises = new Map()
166
150
  #fileDiscoveries = new Map()
167
151
  #fileMonitors = new Map()
@@ -173,7 +157,16 @@ export class MostBoxEngine extends EventEmitter {
173
157
  #channelLocalCoreKey = new Map()
174
158
  #channelDiscoveries = new Map()
175
159
  #channelChatDiscoveries = new Map()
160
+ #channelIdDiscoveries = new Map()
176
161
  #channelPeers = new Map()
162
+ #channelCandidateCache = new Map()
163
+ #channelStreams = new Set()
164
+
165
+ #userSyncSessions = new Map()
166
+ #userSyncCores = new Map()
167
+ #userSyncCoreOffsets = new Map()
168
+ #userSyncDiscoveries = new Map()
169
+ #userSyncMetadata = { sessions: {}, clocks: {} }
177
170
 
178
171
  #chatSwarm = null
179
172
 
@@ -360,52 +353,19 @@ export class MostBoxEngine extends EventEmitter {
360
353
 
361
354
  for (const channel of this.#channels) {
362
355
  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}`)
356
+ await this.#openChannelRuntime(channel)
357
+ await this.#joinChannelDiscoveryTopics(channel)
358
+ console.log(`[MostBox] Rejoined channel: ${channel.channelKey}`)
401
359
  } catch (err) {
402
360
  console.warn(
403
- `[MostBox] Failed to rejoin channel ${channel.name}:`,
361
+ `[MostBox] Failed to rejoin channel ${channel.channelKey}:`,
404
362
  err.message
405
363
  )
406
364
  }
407
365
  }
408
366
 
367
+ this.#userSyncMetadata = this.#loadUserSyncMetadata()
368
+
409
369
  this.#initialized = true
410
370
  console.log(`[MostBox] Engine initialized successfully`)
411
371
  this.emit('ready')
@@ -462,9 +422,25 @@ export class MostBoxEngine extends EventEmitter {
462
422
  this.#channelLocalCoreKey.clear()
463
423
  this.#channelDiscoveries.clear()
464
424
  this.#channelChatDiscoveries.clear()
425
+ this.#channelIdDiscoveries.clear()
465
426
  this.#channelPeers.clear()
427
+ this.#channelCandidateCache.clear()
428
+ this.#channelStreams.clear()
466
429
  this.#channels = []
467
- this.#importChecks.clear()
430
+
431
+ for (const [, coresMap] of this.#userSyncCores) {
432
+ for (const [, core] of coresMap) {
433
+ try {
434
+ await core.close()
435
+ } catch (err) {
436
+ console.warn('[MostBox] Failed to close user sync core:', err.message)
437
+ }
438
+ }
439
+ }
440
+ this.#userSyncSessions.clear()
441
+ this.#userSyncCores.clear()
442
+ this.#userSyncCoreOffsets.clear()
443
+ this.#userSyncDiscoveries.clear()
468
444
 
469
445
  if (this.#store) {
470
446
  await this.#store.close()
@@ -644,13 +620,18 @@ export class MostBoxEngine extends EventEmitter {
644
620
  }
645
621
 
646
622
  // 存储 displayName(用户看到的文件夹路径),不存储 drivePath
647
- publishedBucket.push({
623
+ const now = Date.now()
624
+ const fileRecord = {
648
625
  fileName: safeFileName,
649
626
  cid: cidString,
650
627
  driveName: name,
651
- publishedAt: new Date().toISOString(),
628
+ size: fileSize,
629
+ source: 'published',
630
+ publishedAt: new Date(now).toISOString(),
652
631
  starred: false,
653
- })
632
+ syncUpdatedAt: now,
633
+ }
634
+ publishedBucket.push(fileRecord)
654
635
  this.#savePublishedMetadata()
655
636
  this.#upsertHolding({
656
637
  cid: cidString,
@@ -667,6 +648,9 @@ export class MostBoxEngine extends EventEmitter {
667
648
  }
668
649
 
669
650
  this.emit('publish:success', result)
651
+ this.#appendUserSyncOpSoon(ownerAddress, 'file:upsert', {
652
+ file: this.#formatFileForSync(fileRecord, 'active'),
653
+ })
670
654
  return result
671
655
  }
672
656
 
@@ -697,8 +681,12 @@ export class MostBoxEngine extends EventEmitter {
697
681
 
698
682
  try {
699
683
  const parsed = parseMostLink(link)
700
- if (parsed.error) {
701
- throw new ValidationError(parsed.error)
684
+ if (parsed.errorCode) {
685
+ throw new ValidationError(
686
+ parsed.errorCode,
687
+ parsed.errorCode,
688
+ parsed.details
689
+ )
702
690
  }
703
691
  const cidString = parsed.cid
704
692
  console.log(`[MostBox] Parsed CID: ${cidString}`)
@@ -994,22 +982,32 @@ export class MostBoxEngine extends EventEmitter {
994
982
  ownerAddress,
995
983
  excludeCid: cidString,
996
984
  })
985
+ const savedSize = totalBytes || fs.statSync(savePath).size
986
+ const syncUpdatedAt =
987
+ existingIndex !== -1
988
+ ? getNextSyncTimestamp(publishedBucket[existingIndex].syncUpdatedAt)
989
+ : Date.now()
997
990
  if (existingIndex !== -1) {
998
991
  const existing = publishedBucket[existingIndex]
999
992
  existing.fileName = sanitizedFileName
1000
993
  existing.driveName = name
1001
- existing.publishedAt = new Date().toISOString()
994
+ existing.size = savedSize
995
+ existing.source = 'downloaded'
996
+ existing.publishedAt = new Date(syncUpdatedAt).toISOString()
997
+ existing.syncUpdatedAt = syncUpdatedAt
1002
998
  } else {
1003
999
  publishedBucket.push({
1004
1000
  fileName: sanitizedFileName,
1005
1001
  cid: cidString,
1006
1002
  driveName: name,
1007
- publishedAt: new Date().toISOString(),
1003
+ size: savedSize,
1004
+ source: 'downloaded',
1005
+ publishedAt: new Date(syncUpdatedAt).toISOString(),
1008
1006
  starred: false,
1007
+ syncUpdatedAt,
1009
1008
  })
1010
1009
  }
1011
1010
  this.#savePublishedMetadata()
1012
- const savedSize = totalBytes || fs.statSync(savePath).size
1013
1011
  this.#upsertHolding({
1014
1012
  cid: cidString,
1015
1013
  fileName: sanitizedFileName,
@@ -1019,6 +1017,10 @@ export class MostBoxEngine extends EventEmitter {
1019
1017
  })
1020
1018
 
1021
1019
  this.emit('download:success', result)
1020
+ const syncedFile = publishedBucket.find(file => file.cid === cidString)
1021
+ this.#appendUserSyncOpSoon(ownerAddress, 'file:upsert', {
1022
+ file: this.#formatFileForSync(syncedFile, 'active'),
1023
+ })
1022
1024
  return result
1023
1025
  }
1024
1026
  } finally {
@@ -1033,8 +1035,12 @@ export class MostBoxEngine extends EventEmitter {
1033
1035
  this.#ensureInitialized()
1034
1036
  const ownerAddress = normalizeOwnerAddress(options.ownerAddress)
1035
1037
  const parsed = parseMostLink(link)
1036
- if (parsed.error) {
1037
- throw new ValidationError(parsed.error)
1038
+ if (parsed.errorCode) {
1039
+ throw new ValidationError(
1040
+ parsed.errorCode,
1041
+ parsed.errorCode,
1042
+ parsed.details
1043
+ )
1038
1044
  }
1039
1045
 
1040
1046
  const localContent = await this.#getLocalCidContent(parsed.cid, {
@@ -1068,8 +1074,12 @@ export class MostBoxEngine extends EventEmitter {
1068
1074
 
1069
1075
  const timeout = options.timeout || DRIVE_ENTRY_TIMEOUT
1070
1076
  const parsed = parseMostLink(link)
1071
- if (parsed.error) {
1072
- throw new ValidationError(parsed.error)
1077
+ if (parsed.errorCode) {
1078
+ throw new ValidationError(
1079
+ parsed.errorCode,
1080
+ parsed.errorCode,
1081
+ parsed.details
1082
+ )
1073
1083
  }
1074
1084
 
1075
1085
  const cidString = parsed.cid
@@ -1155,8 +1165,14 @@ export class MostBoxEngine extends EventEmitter {
1155
1165
  cid: f.cid,
1156
1166
  link: `most://${f.cid}?filename=${encodeURIComponent(f.fileName)}`,
1157
1167
  publishedAt: f.publishedAt,
1168
+ size: Number(f.size) || 0,
1158
1169
  starred: f.starred || false,
1159
1170
  ownerAddress: ownerAddress || '',
1171
+ localAvailable: this.#holdings.some(holding => holding.cid === f.cid),
1172
+ seedStatus: this.#seedStates.get(f.cid)?.status || '',
1173
+ holdingSize:
1174
+ Number(this.#holdings.find(holding => holding.cid === f.cid)?.size) ||
1175
+ 0,
1160
1176
  }))
1161
1177
  }
1162
1178
 
@@ -1174,7 +1190,11 @@ export class MostBoxEngine extends EventEmitter {
1174
1190
  throw new Error('File not found')
1175
1191
  }
1176
1192
  files[index].starred = !files[index].starred
1193
+ files[index].syncUpdatedAt = getNextSyncTimestamp(files[index].syncUpdatedAt)
1177
1194
  this.#savePublishedMetadata()
1195
+ this.#appendUserSyncOpSoon(ownerAddress, 'file:upsert', {
1196
+ file: this.#formatFileForSync(files[index], 'active'),
1197
+ })
1178
1198
  return {
1179
1199
  cid,
1180
1200
  starred: files[index].starred,
@@ -1195,8 +1215,9 @@ export class MostBoxEngine extends EventEmitter {
1195
1215
  if (index !== -1) {
1196
1216
  const fileRecord = files[index]
1197
1217
  const holding = this.#holdings.find(item => item.cid === fileRecord.cid)
1218
+ const syncUpdatedAt = getNextSyncTimestamp(fileRecord.syncUpdatedAt)
1198
1219
 
1199
- trashFiles.push({
1220
+ const trashRecord = {
1200
1221
  fileName: fileRecord.fileName,
1201
1222
  cid: fileRecord.cid,
1202
1223
  driveName:
@@ -1205,13 +1226,18 @@ export class MostBoxEngine extends EventEmitter {
1205
1226
  source: holding?.source || 'published',
1206
1227
  publishedAt: fileRecord.publishedAt,
1207
1228
  starred: fileRecord.starred || false,
1208
- deletedAt: new Date().toISOString(),
1209
- })
1229
+ deletedAt: new Date(syncUpdatedAt).toISOString(),
1230
+ syncUpdatedAt,
1231
+ }
1232
+ trashFiles.push(trashRecord)
1210
1233
  this.#saveTrashMetadata()
1211
1234
 
1212
1235
  files.splice(index, 1)
1213
1236
  this.#setPublishedBucket(ownerAddress, files)
1214
1237
  this.#savePublishedMetadata()
1238
+ this.#appendUserSyncOpSoon(ownerAddress, 'file:trash', {
1239
+ file: this.#formatFileForSync(trashRecord, 'trash'),
1240
+ })
1215
1241
 
1216
1242
  if (!this.#hasPublishedReference(fileRecord.cid)) {
1217
1243
  await this.#leaveCidTopic(fileRecord.cid)
@@ -1237,9 +1263,15 @@ export class MostBoxEngine extends EventEmitter {
1237
1263
  cid: f.cid,
1238
1264
  link: `most://${f.cid}?filename=${encodeURIComponent(f.fileName)}`,
1239
1265
  publishedAt: f.publishedAt,
1266
+ size: Number(f.size) || 0,
1240
1267
  starred: f.starred || false,
1241
1268
  ownerAddress: ownerAddress || '',
1242
1269
  deletedAt: f.deletedAt,
1270
+ localAvailable: this.#holdings.some(holding => holding.cid === f.cid),
1271
+ seedStatus: this.#seedStates.get(f.cid)?.status || '',
1272
+ holdingSize:
1273
+ Number(this.#holdings.find(holding => holding.cid === f.cid)?.size) ||
1274
+ 0,
1243
1275
  }))
1244
1276
  }
1245
1277
 
@@ -1269,6 +1301,9 @@ export class MostBoxEngine extends EventEmitter {
1269
1301
  trashFiles.splice(index, 1)
1270
1302
  this.#setTrashBucket(ownerAddress, trashFiles)
1271
1303
  this.#saveTrashMetadata()
1304
+ this.#appendUserSyncOpSoon(ownerAddress, 'file:upsert', {
1305
+ file: this.#formatFileForSync(publishedFiles[existingIndex], 'active'),
1306
+ })
1272
1307
  return this.listPublishedFiles({ ownerAddress })
1273
1308
  }
1274
1309
 
@@ -1277,29 +1312,43 @@ export class MostBoxEngine extends EventEmitter {
1277
1312
  excludeCid: fileRecord.cid,
1278
1313
  })
1279
1314
 
1280
- publishedFiles.push({
1315
+ const syncUpdatedAt = getNextSyncTimestamp(fileRecord.syncUpdatedAt)
1316
+ const publishedRecord = {
1281
1317
  fileName: fileRecord.fileName,
1282
1318
  cid: fileRecord.cid,
1283
1319
  driveName,
1284
1320
  publishedAt: fileRecord.publishedAt,
1285
1321
  starred: fileRecord.starred || false,
1286
- })
1322
+ size: Number(fileRecord.size) || 0,
1323
+ source: fileRecord.source || 'synced',
1324
+ syncUpdatedAt,
1325
+ }
1326
+ publishedFiles.push(publishedRecord)
1287
1327
  this.#savePublishedMetadata()
1288
1328
 
1289
1329
  trashFiles.splice(index, 1)
1290
1330
  this.#setTrashBucket(ownerAddress, trashFiles)
1291
1331
  this.#saveTrashMetadata()
1292
1332
 
1293
- await this.#joinCidTopicInternal(fileRecord.cid, {
1294
- server: true,
1295
- client: false,
1333
+ const localContent = await this.#getLocalCidContent(fileRecord.cid, {
1334
+ ownerAddress,
1335
+ allowHoldingFallback: true,
1296
1336
  })
1297
- this.#upsertHolding({
1298
- cid: fileRecord.cid,
1299
- fileName: fileRecord.fileName,
1300
- size: Number(fileRecord.size) || 0,
1301
- driveName,
1302
- source: fileRecord.source || 'published',
1337
+ if (localContent) {
1338
+ await this.#joinCidTopicInternal(fileRecord.cid, {
1339
+ server: true,
1340
+ client: false,
1341
+ })
1342
+ this.#upsertHolding({
1343
+ cid: fileRecord.cid,
1344
+ fileName: fileRecord.fileName,
1345
+ size: localContent.size || Number(fileRecord.size) || 0,
1346
+ driveName,
1347
+ source: fileRecord.source || 'published',
1348
+ })
1349
+ }
1350
+ this.#appendUserSyncOpSoon(ownerAddress, 'file:upsert', {
1351
+ file: this.#formatFileForSync(publishedRecord, 'active'),
1303
1352
  })
1304
1353
 
1305
1354
  return this.listPublishedFiles({ ownerAddress })
@@ -1323,6 +1372,10 @@ export class MostBoxEngine extends EventEmitter {
1323
1372
  trashFiles.splice(index, 1)
1324
1373
  this.#setTrashBucket(ownerAddress, trashFiles)
1325
1374
  this.#saveTrashMetadata()
1375
+ this.#appendUserSyncOpSoon(ownerAddress, 'file:delete', {
1376
+ cid: fileRecord.cid,
1377
+ syncUpdatedAt: getNextSyncTimestamp(fileRecord.syncUpdatedAt),
1378
+ })
1326
1379
 
1327
1380
  if (!this.#hasAnyUserReference(fileRecord.cid)) {
1328
1381
  try {
@@ -1349,8 +1402,11 @@ export class MostBoxEngine extends EventEmitter {
1349
1402
  const removedTrash = [...this.#getTrashBucket(ownerAddress)]
1350
1403
  this.#setTrashBucket(ownerAddress, [])
1351
1404
  this.#saveTrashMetadata()
1352
-
1353
1405
  for (const fileRecord of removedTrash) {
1406
+ this.#appendUserSyncOpSoon(ownerAddress, 'file:delete', {
1407
+ cid: fileRecord.cid,
1408
+ syncUpdatedAt: getNextSyncTimestamp(fileRecord.syncUpdatedAt),
1409
+ })
1354
1410
  if (this.#hasAnyUserReference(fileRecord.cid)) continue
1355
1411
  const driveName =
1356
1412
  fileRecord.driveName || this.#getCidInfo(fileRecord.cid).driveName
@@ -1455,8 +1511,12 @@ export class MostBoxEngine extends EventEmitter {
1455
1511
  excludeCid: cid,
1456
1512
  })
1457
1513
  files[index].fileName = safeFileName
1458
- files[index].publishedAt = new Date().toISOString()
1514
+ files[index].syncUpdatedAt = getNextSyncTimestamp(files[index].syncUpdatedAt)
1515
+ files[index].publishedAt = new Date(files[index].syncUpdatedAt).toISOString()
1459
1516
  this.#savePublishedMetadata()
1517
+ this.#appendUserSyncOpSoon(ownerAddress, 'file:upsert', {
1518
+ file: this.#formatFileForSync(files[index], 'active'),
1519
+ })
1460
1520
  return {
1461
1521
  cid,
1462
1522
  fileName: safeFileName,
@@ -1497,7 +1557,8 @@ export class MostBoxEngine extends EventEmitter {
1497
1557
 
1498
1558
  const updatedFiles = updates.map(({ file, newFileName }) => {
1499
1559
  file.fileName = newFileName
1500
- file.publishedAt = new Date().toISOString()
1560
+ file.syncUpdatedAt = getNextSyncTimestamp(file.syncUpdatedAt)
1561
+ file.publishedAt = new Date(file.syncUpdatedAt).toISOString()
1501
1562
  return {
1502
1563
  cid: file.cid,
1503
1564
  fileName: file.fileName,
@@ -1507,6 +1568,11 @@ export class MostBoxEngine extends EventEmitter {
1507
1568
 
1508
1569
  if (updatedFiles.length > 0) {
1509
1570
  this.#savePublishedMetadata()
1571
+ for (const { file } of updates) {
1572
+ this.#appendUserSyncOpSoon(ownerAddress, 'file:upsert', {
1573
+ file: this.#formatFileForSync(file, 'active'),
1574
+ })
1575
+ }
1510
1576
  }
1511
1577
 
1512
1578
  return { files: updatedFiles }
@@ -1606,253 +1672,6 @@ export class MostBoxEngine extends EventEmitter {
1606
1672
  }
1607
1673
  }
1608
1674
 
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
1675
  /**
1857
1676
  * 列出当前节点持有的可做种文件副本
1858
1677
  * @returns {Array}
@@ -1904,8 +1723,12 @@ export class MostBoxEngine extends EventEmitter {
1904
1723
 
1905
1724
  if (input.link) {
1906
1725
  const parsed = parseMostLink(input.link)
1907
- if (parsed.error) {
1908
- throw new ValidationError(parsed.error)
1726
+ if (parsed.errorCode) {
1727
+ throw new ValidationError(
1728
+ parsed.errorCode,
1729
+ parsed.errorCode,
1730
+ parsed.details
1731
+ )
1909
1732
  }
1910
1733
  const result = await this.downloadFile(input.link, input.taskId || null, {
1911
1734
  timeout: input.timeout,
@@ -1955,19 +1778,130 @@ export class MostBoxEngine extends EventEmitter {
1955
1778
 
1956
1779
  const left = this.#store.replicate(true, { live: true })
1957
1780
  const right = peerEngine.#store.replicate(false, { live: true })
1781
+ const [leftChat, rightChat] = createMemoryDuplexPair()
1958
1782
 
1959
1783
  left.on('error', () => {})
1960
1784
  right.on('error', () => {})
1785
+ leftChat.on('error', () => {})
1786
+ rightChat.on('error', () => {})
1961
1787
  left.pipe(right).pipe(left)
1788
+ this.#handleChannelConnection(leftChat).catch(() => {})
1789
+ peerEngine.#handleChannelConnection(rightChat).catch(() => {})
1790
+ this.#exchangeUserSyncSessions(peerEngine).catch(() => {})
1791
+ peerEngine.#exchangeUserSyncSessions(this).catch(() => {})
1962
1792
 
1963
1793
  return {
1964
1794
  close: () => {
1965
1795
  left.destroy()
1966
1796
  right.destroy()
1797
+ leftChat.destroy()
1798
+ rightChat.destroy()
1967
1799
  },
1968
1800
  }
1969
1801
  }
1970
1802
 
1803
+ /**
1804
+ * 启动当前账号的隐藏 user.sync 元数据同步。
1805
+ */
1806
+ async startUserSync(ownerAddressInput, input = {}) {
1807
+ this.#ensureInitialized()
1808
+ const ownerAddress = normalizeOwnerAddress(ownerAddressInput)
1809
+ if (!ownerAddress) {
1810
+ throw new ValidationError('valid owner address is required')
1811
+ }
1812
+
1813
+ const syncTopicKey = normalizeUserSyncKey(input.syncTopicKey)
1814
+ const syncCipherKey = normalizeUserSyncKey(input.syncCipherKey)
1815
+ const syncMacKey = normalizeUserSyncKey(input.syncMacKey)
1816
+ if (!syncTopicKey || !syncCipherKey || !syncMacKey) {
1817
+ throw new ValidationError('valid user sync keys are required')
1818
+ }
1819
+
1820
+ const syncId = deriveUserSyncId(syncTopicKey)
1821
+ const syncName = getUserSyncName(syncId)
1822
+ const persisted = this.#userSyncMetadata.sessions?.[ownerAddress]
1823
+ const session = {
1824
+ ownerAddress,
1825
+ syncId,
1826
+ syncName,
1827
+ syncTopicKey,
1828
+ syncCipherKey,
1829
+ syncMacKey,
1830
+ writerId:
1831
+ persisted?.syncId === syncId && persisted?.writerId
1832
+ ? persisted.writerId
1833
+ : createChannelWriterId(),
1834
+ localWriterCoreKey:
1835
+ persisted?.syncId === syncId ? persisted.localWriterCoreKey || '' : '',
1836
+ writerCoreKeys:
1837
+ persisted?.syncId === syncId
1838
+ ? uniqueStrings(persisted.writerCoreKeys)
1839
+ : [],
1840
+ startedAt: new Date().toISOString(),
1841
+ }
1842
+
1843
+ this.#userSyncSessions.set(ownerAddress, session)
1844
+ await this.#openUserSyncRuntime(session)
1845
+ await this.#joinUserSyncDiscovery(session)
1846
+ this.#persistUserSyncSession(session)
1847
+ await this.#appendUserSyncSnapshot(ownerAddress, 'start')
1848
+
1849
+ return this.getUserSyncStatus(ownerAddress)
1850
+ }
1851
+
1852
+ getUserSyncStatus(ownerAddressInput) {
1853
+ this.#ensureInitialized()
1854
+ const ownerAddress = normalizeOwnerAddress(ownerAddressInput)
1855
+ if (!ownerAddress) {
1856
+ throw new ValidationError('valid owner address is required')
1857
+ }
1858
+ const session = this.#userSyncSessions.get(ownerAddress)
1859
+ if (!session) {
1860
+ const persisted = this.#userSyncMetadata.sessions?.[ownerAddress]
1861
+ return {
1862
+ enabled: false,
1863
+ ownerAddress,
1864
+ syncName: persisted?.syncName || '',
1865
+ syncId: persisted?.syncId || '',
1866
+ peerCount: 0,
1867
+ writerCoreKeys: uniqueStrings(persisted?.writerCoreKeys),
1868
+ localWriterCoreKey: persisted?.localWriterCoreKey || '',
1869
+ lastSyncedAt: persisted?.lastSyncedAt || '',
1870
+ }
1871
+ }
1872
+
1873
+ return {
1874
+ enabled: true,
1875
+ ownerAddress,
1876
+ syncName: session.syncName,
1877
+ syncId: session.syncId,
1878
+ peerCount: this.#chatSwarm?.connections?.size || 0,
1879
+ writerCoreKeys: uniqueStrings(session.writerCoreKeys),
1880
+ localWriterCoreKey: session.localWriterCoreKey,
1881
+ lastSyncedAt:
1882
+ this.#userSyncMetadata.sessions?.[ownerAddress]?.lastSyncedAt || '',
1883
+ }
1884
+ }
1885
+
1886
+ async cacheFile(cid, options = {}) {
1887
+ this.#ensureInitialized()
1888
+ const ownerAddress = normalizeOwnerAddress(options.ownerAddress)
1889
+ const fileRecord = this.#getPublishedBucket(ownerAddress).find(
1890
+ item => item.cid === cid
1891
+ )
1892
+ if (!fileRecord) {
1893
+ throw new Error('File not found')
1894
+ }
1895
+ const result = await this.pullByCid({
1896
+ cid,
1897
+ fileName: fileRecord.fileName,
1898
+ ownerAddress,
1899
+ timeout: options.timeout,
1900
+ taskId: options.taskId,
1901
+ })
1902
+ return result
1903
+ }
1904
+
1971
1905
  /**
1972
1906
  * 读取已发布文件的内容(用于预览)
1973
1907
  * Hyperdrive 中用 CID 作为 key 存储
@@ -2162,277 +2096,265 @@ export class MostBoxEngine extends EventEmitter {
2162
2096
  // --- 频道管理 ---
2163
2097
 
2164
2098
  /**
2165
- * 创建或加入频道
2166
- * @param {string} name - 频道名
2099
+ * 创建或加入频道。channelId 是用户输入的短 ID,channelKey 与频道名一致。
2100
+ * @param {string} channelIdInput - 用户可见短频道 ID
2167
2101
  * @param {string} [type='personal'] - 频道类型
2168
- * @returns {Promise<{ name: string, key: string }>}
2102
+ * @returns {Promise<object>}
2169
2103
  */
2170
- async createChannel(name, type = 'personal', options = {}) {
2104
+ async createChannel(channelIdInput, type = 'personal', options = {}) {
2171
2105
  this.#ensureInitialized()
2172
2106
  const ownerAddress = normalizeOwnerAddress(options.ownerAddress)
2107
+ const channelId = normalizeChannelId(channelIdInput)
2173
2108
  const channelType = String(type || 'personal').trim() || 'personal'
2174
2109
 
2175
- if (!CHANNEL_NAME_REGEX.test(name)) {
2110
+ if (channelId.includes('.') && channelType !== 'game') {
2111
+ throw new Error('点号为系统保留,不能用于手动频道 ID')
2112
+ }
2113
+ if (channelType === 'game' && !/^game\.[a-z0-9]+\.[a-z0-9]+$/.test(channelId)) {
2114
+ throw new Error('游戏频道必须使用 game.<gameId>.<roomCode> 格式')
2115
+ }
2116
+ if (channelType !== 'game' && !CHANNEL_NAME_REGEX.test(channelId)) {
2176
2117
  throw new Error('频道名只能包含字母、数字、下划线和连字符')
2177
2118
  }
2178
- if (name.length < CHANNEL_NAME_MIN_LENGTH) {
2119
+ if (channelId.length < CHANNEL_NAME_MIN_LENGTH) {
2179
2120
  throw new Error(`频道名至少 ${CHANNEL_NAME_MIN_LENGTH} 个字符`)
2180
2121
  }
2181
- if (name.length > CHANNEL_NAME_MAX_LENGTH) {
2122
+ if (channelId.length > CHANNEL_NAME_MAX_LENGTH) {
2182
2123
  throw new Error(`频道名最多 ${CHANNEL_NAME_MAX_LENGTH} 个字符`)
2183
2124
  }
2184
2125
 
2185
- const existing = this.#channels.find(c => c.name === name)
2186
- if (existing) {
2187
- if (this.#upsertChannelMember(existing, options)) {
2188
- this.#saveChannelsMetadata()
2189
- }
2190
- return {
2191
- name: existing.name,
2192
- key: existing.coreKey,
2193
- coreKey: existing.coreKey,
2194
- createdAt: existing.createdAt,
2195
- type: existing.type,
2126
+ const localCandidates = this.#getLocalChannelCandidates(channelId)
2127
+ const remoteCandidates = options.discover
2128
+ ? await this.#discoverChannelCandidates(channelId, {
2129
+ timeout: options.discoveryTimeout,
2130
+ })
2131
+ : []
2132
+ const candidates = this.#mergeChannelCandidates([
2133
+ ...localCandidates,
2134
+ ...remoteCandidates,
2135
+ ])
2136
+
2137
+ if (candidates.length > 0) {
2138
+ const candidate = candidates[0]
2139
+ if (candidate.local) {
2140
+ const existing = this.#channels.find(
2141
+ channel => channel.channelKey === candidate.channelKey
2142
+ )
2143
+ if (existing) {
2144
+ const writerKeysChanged = await this.#mergeChannelWriterCoreKeys(
2145
+ existing,
2146
+ candidate.writerCoreKeys
2147
+ )
2148
+ const memberChanged = this.#upsertChannelMember(existing, options)
2149
+ if (writerKeysChanged || memberChanged) {
2150
+ existing.syncUpdatedAt = getNextSyncTimestamp(existing.syncUpdatedAt)
2151
+ this.#saveChannelsMetadata()
2152
+ this.#appendUserSyncChannelUpsertSoon(existing, ownerAddress)
2153
+ this.#broadcastChannelHello()
2154
+ }
2155
+ return this.#formatChannelForResponse(existing, ownerAddress)
2156
+ }
2157
+ const joined = await this.#joinChannelFromCandidate(
2158
+ candidate,
2159
+ channelType,
2160
+ options
2161
+ )
2162
+ const joinedChannel = this.#resolveChannel(joined.channelKey, ownerAddress)
2163
+ this.#appendUserSyncChannelUpsertSoon(joinedChannel, ownerAddress)
2164
+ return joined
2196
2165
  }
2166
+ const joined = await this.#joinChannelFromCandidate(
2167
+ candidate,
2168
+ channelType,
2169
+ options
2170
+ )
2171
+ const joinedChannel = this.#resolveChannel(joined.channelKey, ownerAddress)
2172
+ this.#appendUserSyncChannelUpsertSoon(joinedChannel, ownerAddress)
2173
+ return joined
2197
2174
  }
2198
2175
 
2199
- const ns = this.#store.namespace(`channel-${name}`)
2200
- const core = ns.get({ name: 'messages', valueEncoding: 'json' })
2201
- await core.ready()
2202
-
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)
2215
-
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,
2176
+ const channelInfo = await this.#createLocalChannel(channelId, channelType, {
2177
+ ...options,
2222
2178
  ownerAddress,
2223
- members: [],
2224
- remoteCoreKeys: [],
2225
- }
2226
- this.#upsertChannelMember(channelInfo, options)
2227
-
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()
2179
+ })
2239
2180
 
2240
- console.log(`[MostBox] Channel created: ${name}`)
2241
- this.emit('channel:joined', { name, key: channelInfo.coreKey })
2181
+ console.log(`[MostBox] Channel created: ${channelInfo.channelKey}`)
2182
+ this.emit('channel:joined', {
2183
+ channel: channelInfo.channelKey,
2184
+ channelKey: channelInfo.channelKey,
2185
+ channelId: channelInfo.channelId,
2186
+ key: channelInfo.channelKey,
2187
+ })
2188
+ this.#appendUserSyncChannelUpsertSoon(channelInfo, ownerAddress)
2242
2189
 
2243
- return {
2244
- name,
2245
- key: channelInfo.coreKey,
2246
- coreKey: channelInfo.coreKey,
2247
- createdAt: channelInfo.createdAt,
2248
- type: channelInfo.type,
2249
- }
2190
+ return this.#formatChannelForResponse(channelInfo, ownerAddress)
2250
2191
  }
2251
2192
 
2252
2193
  /**
2253
- * 加入已有频道(通过频道名和 coreKey)
2254
- * @param {string} name - 频道名
2255
- * @param {string} [coreKey] - 频道的 coreKey(加入他人创建的频道时必填)
2256
- * @returns {Promise<{ name: string, key: string }>}
2194
+ * 通过已发现候选加入频道。
2195
+ * @param {string} channelIdInput - 用户可见短频道 ID
2196
+ * @param {object|string|null} candidateInput - 候选对象或 channelKey
2197
+ * @returns {Promise<object>}
2257
2198
  */
2258
- async joinChannel(name, coreKey = null, options = {}) {
2199
+ async joinChannel(channelIdInput, candidateInput = null, options = {}) {
2259
2200
  this.#ensureInitialized()
2260
- const ownerAddress = normalizeOwnerAddress(options.ownerAddress)
2201
+ const channelId = normalizeChannelId(channelIdInput)
2202
+ const candidate =
2203
+ candidateInput && typeof candidateInput === 'object'
2204
+ ? candidateInput
2205
+ : candidateInput
2206
+ ? { channelKey: String(candidateInput), channelId }
2207
+ : null
2261
2208
 
2262
- const existing = this.#channels.find(c => c.name === name)
2209
+ if (!candidate?.channelKey) {
2210
+ return this.createChannel(channelId, options.type || 'group', options)
2211
+ }
2212
+
2213
+ const channelKey = buildChannelKey(channelId)
2214
+ const existing = this.#channels.find(c => c.channelKey === channelKey)
2263
2215
  if (existing) {
2264
- if (this.#upsertChannelMember(existing, options)) {
2216
+ const writerKeysChanged = await this.#mergeChannelWriterCoreKeys(
2217
+ existing,
2218
+ candidate.writerCoreKeys
2219
+ )
2220
+ const memberChanged = this.#upsertChannelMember(existing, options)
2221
+ if (writerKeysChanged || memberChanged) {
2222
+ existing.syncUpdatedAt = getNextSyncTimestamp(existing.syncUpdatedAt)
2265
2223
  this.#saveChannelsMetadata()
2224
+ this.#appendUserSyncChannelUpsertSoon(existing, options.ownerAddress)
2225
+ this.#broadcastChannelHello()
2266
2226
  }
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
- }
2284
- }
2285
-
2286
- if (!coreKey) {
2287
- throw new Error('加入已有频道需要提供 coreKey')
2227
+ return this.#formatChannelForResponse(existing, options.ownerAddress)
2288
2228
  }
2289
2229
 
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,
2230
+ const cached =
2231
+ this.#getCachedChannelCandidate(
2232
+ channelId,
2233
+ normalizeChannelKey(candidate.channelKey)
2234
+ ) || this.#getCachedChannelCandidate(channelId, channelKey)
2235
+ const joined = await this.#joinChannelFromCandidate(cached || candidate, 'group', {
2236
+ ...options,
2237
+ channelKey,
2304
2238
  })
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
- }
2239
+ const joinedChannel = this.#resolveChannel(
2240
+ joined.channelKey,
2241
+ options.ownerAddress
2242
+ )
2243
+ this.#appendUserSyncChannelUpsertSoon(joinedChannel, options.ownerAddress)
2244
+ return joined
2349
2245
  }
2350
2246
 
2351
2247
  /**
2352
2248
  * 离开频道
2353
- * @param {string} name - 频道名
2249
+ * @param {string} channelKeyInput - 内部频道 key,或本地唯一短频道 ID
2354
2250
  * @returns {Promise<string[]>} 剩余频道列表
2355
2251
  */
2356
- async leaveChannel(name, options = {}) {
2252
+ async leaveChannel(channelKeyInput, options = {}) {
2357
2253
  this.#ensureInitialized()
2358
2254
  const ownerAddress = normalizeOwnerAddress(options.ownerAddress)
2359
2255
 
2360
- const index = this.#channels.findIndex(c => c.name === name)
2256
+ const channel = this.#resolveChannel(channelKeyInput, ownerAddress)
2257
+ const index = this.#channels.findIndex(c => c.channelKey === channel.channelKey)
2361
2258
  if (index === -1) {
2362
2259
  throw new Error('频道不存在')
2363
2260
  }
2364
2261
 
2365
- const channel = this.#channels[index]
2366
2262
  if (ownerAddress && Array.isArray(channel.members)) {
2367
2263
  channel.members = channel.members.filter(
2368
2264
  member => normalizeOwnerAddress(member?.address) !== ownerAddress
2369
2265
  )
2266
+ const syncUpdatedAt = getNextSyncTimestamp(channel.syncUpdatedAt)
2267
+ channel.syncUpdatedAt = syncUpdatedAt
2268
+ this.#appendUserSyncChannelLeaveSoon(channel, ownerAddress, syncUpdatedAt)
2370
2269
  if (channel.members.length > 0) {
2371
2270
  this.#saveChannelsMetadata()
2372
2271
  return this.listChannels({ ownerAddress })
2373
2272
  }
2374
2273
  }
2375
2274
 
2376
- const appDiscovery = this.#channelDiscoveries.get(name)
2275
+ const appDiscovery = this.#channelDiscoveries.get(channel.channelKey)
2377
2276
  if (appDiscovery && this.#swarm) {
2378
- this.#channelDiscoveries.delete(name)
2379
- this.#swarm.leave(b4a.from(channel.discoveryKey, 'hex')).catch(err => {
2277
+ this.#channelDiscoveries.delete(channel.channelKey)
2278
+ this.#swarm.leave(this.#generateChannelDiscoveryKey(channel.channelKey)).catch(err => {
2380
2279
  console.warn(
2381
- `[MostBox] Failed to leave app swarm for ${name}:`,
2280
+ `[MostBox] Failed to leave app swarm for ${channel.channelKey}:`,
2382
2281
  err.message
2383
2282
  )
2384
2283
  })
2385
2284
  }
2386
2285
 
2387
- const chatDiscovery = this.#channelChatDiscoveries.get(name)
2286
+ const chatDiscovery = this.#channelChatDiscoveries.get(channel.channelKey)
2388
2287
  if (chatDiscovery && this.#chatSwarm) {
2389
- this.#channelChatDiscoveries.delete(name)
2390
- const chatDiscoveryKey = this.#generateChannelChatDiscoveryKey(name)
2288
+ this.#channelChatDiscoveries.delete(channel.channelKey)
2289
+ const chatDiscoveryKey = this.#generateChannelChatDiscoveryKey(
2290
+ channel.channelKey
2291
+ )
2391
2292
  this.#chatSwarm.leave(chatDiscoveryKey).catch(err => {
2392
2293
  console.warn(
2393
- `[MostBox] Failed to leave chat swarm for ${name}:`,
2294
+ `[MostBox] Failed to leave chat swarm for ${channel.channelKey}:`,
2394
2295
  err.message
2395
2296
  )
2396
2297
  })
2397
2298
  }
2398
2299
 
2399
- const coresMap = this.#channelCores.get(name)
2300
+ const hasSameIdChannel = this.#channels.some(
2301
+ (item, itemIndex) =>
2302
+ itemIndex !== index && item.channelId === channel.channelId
2303
+ )
2304
+ if (!hasSameIdChannel) {
2305
+ const idDiscovery = this.#channelIdDiscoveries.get(channel.channelId)
2306
+ if (idDiscovery && this.#chatSwarm) {
2307
+ this.#channelIdDiscoveries.delete(channel.channelId)
2308
+ this.#chatSwarm
2309
+ .leave(this.#generateChannelIdDiscoveryKey(channel.channelId))
2310
+ .catch(err => {
2311
+ console.warn(
2312
+ `[MostBox] Failed to leave channel ID discovery for ${channel.channelId}:`,
2313
+ err.message
2314
+ )
2315
+ })
2316
+ }
2317
+ }
2318
+
2319
+ const coresMap = this.#channelCores.get(channel.channelKey)
2400
2320
  if (coresMap) {
2401
2321
  for (const [, core] of coresMap) {
2402
2322
  try {
2403
2323
  await core.close()
2404
2324
  } catch (err) {
2405
2325
  console.warn(
2406
- `[MostBox] Failed to close channel core for ${name}:`,
2326
+ `[MostBox] Failed to close channel core for ${channel.channelKey}:`,
2407
2327
  err.message
2408
2328
  )
2409
2329
  }
2410
2330
  }
2411
- this.#channelCores.delete(name)
2331
+ this.#channelCores.delete(channel.channelKey)
2412
2332
  }
2413
- this.#channelLocalCoreKey.delete(name)
2333
+ this.#channelLocalCoreKey.delete(channel.channelKey)
2414
2334
 
2415
- this.#channelPeers.delete(name)
2335
+ this.#channelPeers.delete(channel.channelKey)
2416
2336
  this.#channels.splice(index, 1)
2417
2337
  this.#saveChannelsMetadata()
2418
2338
 
2419
- console.log(`[MostBox] Left channel: ${name}`)
2420
- this.emit('channel:left', { name })
2339
+ console.log(`[MostBox] Left channel: ${channel.channelKey}`)
2340
+ this.emit('channel:left', {
2341
+ channel: channel.channelKey,
2342
+ channelKey: channel.channelKey,
2343
+ channelId: channel.channelId,
2344
+ name: channel.channelId,
2345
+ })
2421
2346
 
2422
2347
  return this.listChannels({ ownerAddress })
2423
2348
  }
2424
2349
 
2425
- setChannelRemark(name, remark, options = {}) {
2350
+ setChannelRemark(channelKeyInput, remark, options = {}) {
2426
2351
  this.#ensureInitialized()
2427
2352
  const ownerAddress = normalizeOwnerAddress(options.ownerAddress)
2428
2353
  if (!ownerAddress) {
2429
2354
  throw new Error('需要登录才能设置备注')
2430
2355
  }
2431
2356
 
2432
- const channel = this.#channels.find(c => c.name === name)
2433
- if (!channel) {
2434
- throw new Error('频道不存在')
2435
- }
2357
+ const channel = this.#resolveChannel(channelKeyInput, ownerAddress)
2436
2358
 
2437
2359
  const trimmed = (remark || '').trim()
2438
2360
  if (trimmed.length > 50) {
@@ -2449,22 +2371,21 @@ export class MostBoxEngine extends EventEmitter {
2449
2371
  delete channel.remarks[ownerAddress]
2450
2372
  }
2451
2373
 
2374
+ channel.syncUpdatedAt = getNextSyncTimestamp(channel.syncUpdatedAt)
2452
2375
  this.#saveChannelsMetadata()
2376
+ this.#appendUserSyncChannelUpsertSoon(channel, ownerAddress)
2453
2377
  return trimmed
2454
2378
  }
2455
2379
 
2456
- setChannelPinned(name, pinned, options = {}) {
2380
+ setChannelPinned(channelKeyInput, pinned, options = {}) {
2457
2381
  this.#ensureInitialized()
2458
2382
  const ownerAddress = normalizeOwnerAddress(options.ownerAddress)
2459
2383
  if (!ownerAddress) {
2460
2384
  throw new Error('需要登录才能设置置顶')
2461
2385
  }
2462
2386
 
2463
- const channel = this.#channels.find(c => c.name === name)
2464
- if (!channel) {
2465
- throw new Error('频道不存在')
2466
- }
2467
- this.#assertChannelMember(name, ownerAddress)
2387
+ const channel = this.#resolveChannel(channelKeyInput, ownerAddress)
2388
+ this.#assertChannelMember(channel.channelKey, ownerAddress)
2468
2389
 
2469
2390
  if (!channel.pinnedBy) {
2470
2391
  channel.pinnedBy = {}
@@ -2476,13 +2397,15 @@ export class MostBoxEngine extends EventEmitter {
2476
2397
  delete channel.pinnedBy[ownerAddress]
2477
2398
  }
2478
2399
 
2400
+ channel.syncUpdatedAt = getNextSyncTimestamp(channel.syncUpdatedAt)
2479
2401
  this.#saveChannelsMetadata()
2402
+ this.#appendUserSyncChannelUpsertSoon(channel, ownerAddress)
2480
2403
  return Boolean(channel.pinnedBy[ownerAddress])
2481
2404
  }
2482
2405
 
2483
2406
  /**
2484
2407
  * 列出所有频道
2485
- * @returns {Array<{ name: string, coreKey: string, createdAt: string, lastMessageAt: string, type: string, peerCount: number, remark: string, pinned: boolean }>}
2408
+ * @returns {Array<{ channelId: string, channelKey: string, name: string, createdAt: string, lastMessageAt: string, type: string, peerCount: number, remark: string, pinned: boolean }>}
2486
2409
  */
2487
2410
  listChannels(options = {}) {
2488
2411
  this.#ensureInitialized()
@@ -2500,45 +2423,33 @@ export class MostBoxEngine extends EventEmitter {
2500
2423
  if (excludeType) return c.type !== excludeType
2501
2424
  return true
2502
2425
  })
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
- }))
2426
+ .map(c => this.#formatChannelForResponse(c, ownerAddress))
2513
2427
  }
2514
2428
 
2515
- getChannelMembers(name, options = {}) {
2429
+ getChannelMembers(channelKeyInput, options = {}) {
2516
2430
  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
- }
2431
+ this.#assertChannelMember(channelKeyInput, options.ownerAddress)
2432
+ const channel = this.#resolveChannel(channelKeyInput, options.ownerAddress)
2523
2433
 
2524
2434
  return this.#getChannelMembers(channel)
2525
2435
  }
2526
2436
 
2527
2437
  /**
2528
2438
  * 获取频道消息
2529
- * @param {string} name - 频道名
2439
+ * @param {string} channelKeyInput - 内部频道 key,或本地唯一短频道 ID
2530
2440
  * @param {object} [options] - 选项
2531
2441
  * @param {number} [options.limit=100] - 消息数量
2532
2442
  * @param {number} [options.offset=0] - 偏移量
2533
2443
  * @returns {Promise<Array>}
2534
2444
  */
2535
- async getChannelMessages(name, options = {}) {
2445
+ async getChannelMessages(channelKeyInput, options = {}) {
2536
2446
  this.#ensureInitialized()
2537
- this.#assertChannelMember(name, options.ownerAddress)
2447
+ this.#assertChannelMember(channelKeyInput, options.ownerAddress)
2448
+ const channel = this.#resolveChannel(channelKeyInput, options.ownerAddress)
2538
2449
 
2539
2450
  const { limit = CHANNEL_MESSAGE_LIMIT, offset = 0 } = options
2540
2451
 
2541
- const coresMap = this.#channelCores.get(name)
2452
+ const coresMap = this.#channelCores.get(channel.channelKey)
2542
2453
  if (!coresMap || coresMap.size === 0) {
2543
2454
  throw new Error('频道未初始化')
2544
2455
  }
@@ -2578,26 +2489,26 @@ export class MostBoxEngine extends EventEmitter {
2578
2489
  return unique
2579
2490
  .slice(start, end)
2580
2491
  .map(({ _coreKey, _index, ...msg }) =>
2581
- this.#normalizeChannelMessageForResponse(name, msg)
2492
+ this.#normalizeChannelMessageForResponse(channel.channelKey, msg)
2582
2493
  )
2583
2494
  }
2584
2495
 
2585
2496
  /**
2586
2497
  * 发送消息到频道
2587
- * @param {string} name - 频道名
2498
+ * @param {string} channelKeyInput - 内部频道 key,或本地唯一短频道 ID
2588
2499
  * @param {string} content - 消息内容
2589
2500
  * @param {string} author - 作者 address
2590
2501
  * @param {string} authorName - 作者显示名
2591
2502
  * @param {object} [options.attachment] - 附件元数据
2592
2503
  * @returns {Promise<object>}
2593
2504
  */
2594
- async sendMessage(name, content, author, authorName, options = {}) {
2505
+ async sendMessage(channelKeyInput, content, author, authorName, options = {}) {
2595
2506
  this.#ensureInitialized()
2596
- this.#assertChannelMember(name, options.ownerAddress)
2597
- const channel = this.#channels.find(c => c.name === name)
2507
+ this.#assertChannelMember(channelKeyInput, options.ownerAddress)
2508
+ const channel = this.#resolveChannel(channelKeyInput, options.ownerAddress)
2598
2509
 
2599
- const localKeyHex = this.#channelLocalCoreKey.get(name)
2600
- const coresMap = this.#channelCores.get(name)
2510
+ const localKeyHex = this.#channelLocalCoreKey.get(channel.channelKey)
2511
+ const coresMap = this.#channelCores.get(channel.channelKey)
2601
2512
  const core = localKeyHex && coresMap ? coresMap.get(localKeyHex) : null
2602
2513
  if (!core) {
2603
2514
  throw new Error('频道未初始化或无可写 core')
@@ -2631,7 +2542,9 @@ export class MostBoxEngine extends EventEmitter {
2631
2542
  author,
2632
2543
  authorName,
2633
2544
  content: trimmed,
2634
- timestamp: Date.now(),
2545
+ timestamp: await this.#getNextChannelMessageTimestamp(
2546
+ channel.channelKey
2547
+ ),
2635
2548
  }
2636
2549
  if (attachment) {
2637
2550
  message.attachment = attachment
@@ -2643,19 +2556,20 @@ export class MostBoxEngine extends EventEmitter {
2643
2556
  this.#saveChannelsMetadata()
2644
2557
  }
2645
2558
 
2646
- return message
2559
+ return this.#normalizeChannelMessageForResponse(channel.channelKey, message)
2647
2560
  }
2648
2561
 
2649
2562
  /**
2650
2563
  * 获取频道内在线用户
2651
- * @param {string} name - 频道名
2564
+ * @param {string} channelKeyInput - 内部频道 key,或本地唯一短频道 ID
2652
2565
  * @returns {Array<{ peerId: string, authorName: string, lastSeen: number }>}
2653
2566
  */
2654
- getChannelPeers(name, options = {}) {
2567
+ getChannelPeers(channelKeyInput, options = {}) {
2655
2568
  this.#ensureInitialized()
2656
- this.#assertChannelMember(name, options.ownerAddress)
2569
+ this.#assertChannelMember(channelKeyInput, options.ownerAddress)
2570
+ const channel = this.#resolveChannel(channelKeyInput, options.ownerAddress)
2657
2571
 
2658
- const peers = this.#channelPeers.get(name)
2572
+ const peers = this.#channelPeers.get(channel.channelKey)
2659
2573
  if (!peers) {
2660
2574
  return []
2661
2575
  }
@@ -2704,6 +2618,332 @@ export class MostBoxEngine extends EventEmitter {
2704
2618
 
2705
2619
  // --- 私有方法 ---
2706
2620
 
2621
+ #resolveChannel(identifier, ownerAddress = '') {
2622
+ const value = normalizeChannelKey(identifier)
2623
+ const owner = normalizeOwnerAddress(ownerAddress)
2624
+ let channel = this.#channels.find(c => c.channelKey === value)
2625
+ if (channel) return channel
2626
+
2627
+ channel = this.#channels.find(c => c.channelId === value)
2628
+ if (channel && (!owner || this.#channelHasMember(channel, owner))) {
2629
+ return channel
2630
+ }
2631
+ throw new Error('频道不存在')
2632
+ }
2633
+
2634
+ async #createLocalChannel(channelId, type = 'personal', options = {}) {
2635
+ const channelKey = buildChannelKey(channelId)
2636
+ const writerId = String(options.writerId || '').trim() || createChannelWriterId()
2637
+ const ns = this.#store.namespace(`channel-${channelKey}`)
2638
+ const localCore = ns.get({
2639
+ name: `messages-${writerId}`,
2640
+ valueEncoding: 'json',
2641
+ })
2642
+ await localCore.ready()
2643
+ const localWriterCoreKey = b4a.toString(localCore.key, 'hex')
2644
+ const writerCoreKeys = uniqueStrings([
2645
+ ...(Array.isArray(options.writerCoreKeys) ? options.writerCoreKeys : []),
2646
+ localWriterCoreKey,
2647
+ ])
2648
+ const channelInfo = {
2649
+ channelId,
2650
+ channelKey,
2651
+ name: channelId,
2652
+ type: String(type || 'personal').trim() || 'personal',
2653
+ createdAt: options.createdAt || new Date().toISOString(),
2654
+ lastMessageAt: options.lastMessageAt || '',
2655
+ writerId,
2656
+ localWriterCoreKey,
2657
+ writerCoreKeys,
2658
+ members: [],
2659
+ syncUpdatedAt: Date.now(),
2660
+ }
2661
+
2662
+ this.#upsertChannelMember(channelInfo, options)
2663
+ const ownerAddress = normalizeOwnerAddress(options.ownerAddress)
2664
+ const remark = String(options.remark || '').trim()
2665
+ if (ownerAddress && remark) {
2666
+ channelInfo.remarks = { [ownerAddress]: remark.slice(0, 50) }
2667
+ }
2668
+
2669
+ this.#channels.push(channelInfo)
2670
+ await this.#openChannelRuntime(channelInfo)
2671
+ await this.#joinChannelDiscoveryTopics(channelInfo)
2672
+ this.#cacheChannelCandidate(this.#channelToCandidate(channelInfo, true))
2673
+ this.#saveChannelsMetadata()
2674
+ this.#broadcastChannelHello()
2675
+ return channelInfo
2676
+ }
2677
+
2678
+ async #joinChannelFromCandidate(candidateInput, type = 'group', options = {}) {
2679
+ const channelId = normalizeChannelId(
2680
+ candidateInput.channelId || options.channelId
2681
+ )
2682
+ const channelKey = buildChannelKey(channelId)
2683
+ if (!channelId || !channelKey) {
2684
+ throw new Error('频道候选缺少身份信息')
2685
+ }
2686
+
2687
+ const existing = this.#channels.find(
2688
+ channel => channel.channelKey === channelKey
2689
+ )
2690
+ if (existing) {
2691
+ if (this.#upsertChannelMember(existing, options)) {
2692
+ this.#saveChannelsMetadata()
2693
+ this.#broadcastChannelHello()
2694
+ }
2695
+ return this.#formatChannelForResponse(existing, options.ownerAddress)
2696
+ }
2697
+
2698
+ const hasSameIdLocal = this.#channels.some(
2699
+ channel => channel.channelId === channelId
2700
+ )
2701
+ const ownerAddress = normalizeOwnerAddress(options.ownerAddress)
2702
+ const remark =
2703
+ ownerAddress && hasSameIdLocal && !String(options.remark || '').trim()
2704
+ ? `${channelId}-网络`
2705
+ : options.remark
2706
+ const channelInfo = await this.#createLocalChannel(channelId, candidateInput.type || type, {
2707
+ ...options,
2708
+ ownerAddress,
2709
+ createdAt: candidateInput.createdAt,
2710
+ lastMessageAt: candidateInput.lastMessageAt,
2711
+ writerCoreKeys: candidateInput.writerCoreKeys,
2712
+ remark,
2713
+ })
2714
+
2715
+ console.log(`[MostBox] Joined channel: ${channelInfo.channelKey}`)
2716
+ this.emit('channel:joined', {
2717
+ channel: channelInfo.channelKey,
2718
+ channelKey: channelInfo.channelKey,
2719
+ channelId: channelInfo.channelId,
2720
+ key: channelInfo.channelKey,
2721
+ })
2722
+
2723
+ return this.#formatChannelForResponse(channelInfo, ownerAddress)
2724
+ }
2725
+
2726
+ async #openChannelRuntime(channel) {
2727
+ const ns = this.#store.namespace(`channel-${channel.channelKey}`)
2728
+ const localCore = channel.localWriterCoreKey
2729
+ ? ns.get({
2730
+ key: b4a.from(channel.localWriterCoreKey, 'hex'),
2731
+ valueEncoding: 'json',
2732
+ })
2733
+ : ns.get({
2734
+ name: `messages-${channel.writerId || createChannelWriterId()}`,
2735
+ valueEncoding: 'json',
2736
+ })
2737
+ await localCore.ready()
2738
+ const localWriterCoreKey = b4a.toString(localCore.key, 'hex')
2739
+ channel.localWriterCoreKey = localWriterCoreKey
2740
+ channel.writerCoreKeys = uniqueStrings([
2741
+ ...(Array.isArray(channel.writerCoreKeys) ? channel.writerCoreKeys : []),
2742
+ localWriterCoreKey,
2743
+ ])
2744
+
2745
+ if (!this.#channelCores.has(channel.channelKey)) {
2746
+ this.#channelCores.set(channel.channelKey, new Map())
2747
+ }
2748
+ this.#channelCores.get(channel.channelKey).set(localWriterCoreKey, localCore)
2749
+ this.#channelLocalCoreKey.set(channel.channelKey, localWriterCoreKey)
2750
+ if (!this.#channelPeers.has(channel.channelKey)) {
2751
+ this.#channelPeers.set(channel.channelKey, new Map())
2752
+ }
2753
+ this.#setupChannelAppendListener(localCore, channel.channelKey)
2754
+
2755
+ for (const writerCoreKey of channel.writerCoreKeys) {
2756
+ if (writerCoreKey && writerCoreKey !== localWriterCoreKey) {
2757
+ await this.#openRemoteChannelCore(channel.channelKey, writerCoreKey)
2758
+ }
2759
+ }
2760
+ }
2761
+
2762
+ async #mergeChannelWriterCoreKeys(channel, writerCoreKeys = []) {
2763
+ const nextKeys = uniqueStrings(writerCoreKeys)
2764
+ if (nextKeys.length === 0) return false
2765
+
2766
+ const previous = new Set(channel.writerCoreKeys || [])
2767
+ let changed = false
2768
+ for (const writerCoreKey of nextKeys) {
2769
+ if (!previous.has(writerCoreKey)) {
2770
+ previous.add(writerCoreKey)
2771
+ changed = true
2772
+ }
2773
+ if (writerCoreKey !== this.#channelLocalCoreKey.get(channel.channelKey)) {
2774
+ await this.#openRemoteChannelCore(channel.channelKey, writerCoreKey)
2775
+ }
2776
+ }
2777
+ if (changed) {
2778
+ channel.writerCoreKeys = [...previous]
2779
+ this.#saveChannelsMetadata()
2780
+ }
2781
+ return changed
2782
+ }
2783
+
2784
+ async #joinChannelDiscoveryTopics(channel) {
2785
+ if (!this.#channelDiscoveries.has(channel.channelKey)) {
2786
+ const appDiscovery = this.#swarm.join(
2787
+ this.#generateChannelDiscoveryKey(channel.channelKey),
2788
+ { server: true, client: true }
2789
+ )
2790
+ this.#channelDiscoveries.set(channel.channelKey, appDiscovery)
2791
+ }
2792
+
2793
+ if (!this.#channelChatDiscoveries.has(channel.channelKey)) {
2794
+ const chatDiscovery = this.#chatSwarm.join(
2795
+ this.#generateChannelChatDiscoveryKey(channel.channelKey),
2796
+ { server: true, client: true }
2797
+ )
2798
+ this.#channelChatDiscoveries.set(channel.channelKey, chatDiscovery)
2799
+ }
2800
+
2801
+ if (!this.#channelIdDiscoveries.has(channel.channelId)) {
2802
+ const idDiscovery = this.#chatSwarm.join(
2803
+ this.#generateChannelIdDiscoveryKey(channel.channelId),
2804
+ { server: true, client: true }
2805
+ )
2806
+ this.#channelIdDiscoveries.set(channel.channelId, idDiscovery)
2807
+ }
2808
+ }
2809
+
2810
+ #getLocalChannelCandidates(channelId, options = {}) {
2811
+ const ownerAddress = normalizeOwnerAddress(options.ownerAddress)
2812
+ return this.#channels
2813
+ .filter(channel => channel.channelId === channelId)
2814
+ .filter(channel => {
2815
+ if (!ownerAddress) return true
2816
+ return this.#channelHasMember(channel, ownerAddress)
2817
+ })
2818
+ .map(channel => this.#channelToCandidate(channel, true))
2819
+ }
2820
+
2821
+ async #discoverChannelCandidates(channelId, options = {}) {
2822
+ const getCachedCandidates = () => {
2823
+ const now = Date.now()
2824
+ return [
2825
+ ...(this.#channelCandidateCache.get(channelId)?.values() || []),
2826
+ ].filter(
2827
+ candidate =>
2828
+ candidate.local ||
2829
+ !candidate.lastSeen ||
2830
+ now - candidate.lastSeen <= CHANNEL_CANDIDATE_TTL
2831
+ )
2832
+ }
2833
+ if (this.#options.disableNetwork) return getCachedCandidates()
2834
+ const timeout =
2835
+ Number(options.timeout) >= 0
2836
+ ? Number(options.timeout)
2837
+ : CHANNEL_DISCOVERY_TIMEOUT
2838
+ const hadDiscovery = this.#channelIdDiscoveries.has(channelId)
2839
+ if (!hadDiscovery) {
2840
+ const discovery = this.#chatSwarm.join(
2841
+ this.#generateChannelIdDiscoveryKey(channelId),
2842
+ { server: true, client: true }
2843
+ )
2844
+ this.#channelIdDiscoveries.set(channelId, discovery)
2845
+ }
2846
+ await sleep(timeout)
2847
+ const candidates = getCachedCandidates()
2848
+ if (!hadDiscovery && !this.#channels.some(c => c.channelId === channelId)) {
2849
+ this.#channelIdDiscoveries.delete(channelId)
2850
+ this.#chatSwarm
2851
+ .leave(this.#generateChannelIdDiscoveryKey(channelId))
2852
+ .catch(() => {})
2853
+ }
2854
+ return candidates
2855
+ }
2856
+
2857
+ #mergeChannelCandidates(candidates) {
2858
+ const byKey = new Map()
2859
+ for (const candidate of candidates) {
2860
+ if (!candidate?.channelKey) continue
2861
+ const existing = byKey.get(candidate.channelKey)
2862
+ if (!existing) {
2863
+ byKey.set(candidate.channelKey, {
2864
+ ...candidate,
2865
+ writerCoreKeys: uniqueStrings(candidate.writerCoreKeys),
2866
+ })
2867
+ continue
2868
+ }
2869
+ byKey.set(candidate.channelKey, {
2870
+ ...existing,
2871
+ ...candidate,
2872
+ local: existing.local || candidate.local,
2873
+ writerCoreKeys: uniqueStrings([
2874
+ ...existing.writerCoreKeys,
2875
+ ...(candidate.writerCoreKeys || []),
2876
+ ]),
2877
+ })
2878
+ }
2879
+ return [...byKey.values()]
2880
+ }
2881
+
2882
+ #channelToCandidate(channel, local = false) {
2883
+ return {
2884
+ channelId: channel.channelId,
2885
+ channelKey: channel.channelKey,
2886
+ type: channel.type,
2887
+ createdAt: channel.createdAt,
2888
+ lastMessageAt: channel.lastMessageAt || '',
2889
+ writerCoreKeys: uniqueStrings(channel.writerCoreKeys),
2890
+ local,
2891
+ }
2892
+ }
2893
+
2894
+ #cacheChannelCandidate(candidate) {
2895
+ const channelId = normalizeChannelId(candidate?.channelId)
2896
+ const channelKey = buildChannelKey(channelId)
2897
+ if (!channelId || !channelKey) return
2898
+ if (!this.#channelCandidateCache.has(channelId)) {
2899
+ this.#channelCandidateCache.set(channelId, new Map())
2900
+ }
2901
+ const cache = this.#channelCandidateCache.get(channelId)
2902
+ const existing = cache.get(channelKey)
2903
+ cache.set(channelKey, {
2904
+ ...existing,
2905
+ ...candidate,
2906
+ channelId,
2907
+ channelKey,
2908
+ writerCoreKeys: uniqueStrings([
2909
+ ...(existing?.writerCoreKeys || []),
2910
+ ...(candidate.writerCoreKeys || []),
2911
+ ]),
2912
+ lastSeen: Date.now(),
2913
+ })
2914
+ }
2915
+
2916
+ #getCachedChannelCandidate(channelId, channelKey) {
2917
+ const normalizedChannelId = normalizeChannelId(channelId)
2918
+ const normalizedChannelKey = buildChannelKey(normalizedChannelId)
2919
+ const cache = this.#channelCandidateCache.get(normalizedChannelId)
2920
+ const candidate = cache?.get(channelKey) || cache?.get(normalizedChannelKey)
2921
+ if (candidate) return candidate
2922
+ const local = this.#channels.find(
2923
+ channel => channel.channelKey === normalizedChannelKey
2924
+ )
2925
+ return local ? this.#channelToCandidate(local, true) : null
2926
+ }
2927
+
2928
+ #formatChannelForResponse(channel, ownerAddress = '') {
2929
+ const owner = normalizeOwnerAddress(ownerAddress)
2930
+ return {
2931
+ name: channel.channelId,
2932
+ channelId: channel.channelId,
2933
+ channelKey: channel.channelKey,
2934
+ key: channel.channelKey,
2935
+ coreKey: channel.localWriterCoreKey,
2936
+ localWriterCoreKey: channel.localWriterCoreKey,
2937
+ writerCoreKeys: uniqueStrings(channel.writerCoreKeys),
2938
+ createdAt: channel.createdAt,
2939
+ lastMessageAt: channel.lastMessageAt || '',
2940
+ type: channel.type,
2941
+ peerCount: (this.#channelPeers.get(channel.channelKey) || new Map()).size,
2942
+ remark: owner && channel.remarks ? channel.remarks[owner] || '' : '',
2943
+ pinned: Boolean(owner && channel.pinnedBy?.[owner]),
2944
+ }
2945
+ }
2946
+
2707
2947
  #ensureInitialized() {
2708
2948
  if (!this.#initialized) {
2709
2949
  throw new EngineNotInitializedError()
@@ -2714,10 +2954,7 @@ export class MostBoxEngine extends EventEmitter {
2714
2954
  const normalizedOwner = normalizeOwnerAddress(ownerAddress)
2715
2955
  if (!normalizedOwner) return
2716
2956
 
2717
- const channel = this.#channels.find(c => c.name === name)
2718
- if (!channel) {
2719
- throw new Error('频道不存在')
2720
- }
2957
+ const channel = this.#resolveChannel(name)
2721
2958
  if (!this.#channelHasMember(channel, normalizedOwner)) {
2722
2959
  throw new PermissionError('未加入该频道')
2723
2960
  }
@@ -2802,175 +3039,893 @@ export class MostBoxEngine extends EventEmitter {
2802
3039
  )
2803
3040
  }
2804
3041
 
2805
- #normalizeChannelMessageForResponse(channelName, message) {
2806
- const attachment = message?.attachment
3042
+ async #getNextChannelMessageTimestamp(channelKey) {
3043
+ const coresMap = this.#channelCores.get(channelKey)
3044
+ let maxTimestamp = 0
3045
+
3046
+ if (coresMap) {
3047
+ for (const [, core] of coresMap) {
3048
+ for (let i = 0; i < core.length; i++) {
3049
+ try {
3050
+ const entry = await core.get(i)
3051
+ if (entry?.type === 'message') {
3052
+ maxTimestamp = Math.max(
3053
+ maxTimestamp,
3054
+ Number(entry.timestamp) || 0
3055
+ )
3056
+ }
3057
+ } catch {
3058
+ break
3059
+ }
3060
+ }
3061
+ }
3062
+ }
3063
+
3064
+ return Math.max(Date.now(), maxTimestamp + 1)
3065
+ }
3066
+
3067
+ #normalizeChannelMessageForResponse(channelKey, message) {
3068
+ const channel = this.#channels.find(item => item.channelKey === channelKey)
3069
+ const authorAddress = normalizeOwnerAddress(message?.author)
3070
+ const member = Array.isArray(channel?.members)
3071
+ ? channel.members.find(
3072
+ item => normalizeOwnerAddress(item?.address) === authorAddress
3073
+ )
3074
+ : null
3075
+ const avatar = normalizeChannelAvatar(member?.avatar)
3076
+ const baseMessage = avatar && message?.avatar !== avatar
3077
+ ? { ...message, avatar }
3078
+ : message
3079
+ const attachment = baseMessage?.attachment
2807
3080
  if (!attachment?.cid || !attachment.fileName) {
2808
- return message
3081
+ return baseMessage
3082
+ }
3083
+
3084
+ const oldFileName = sanitizeFilename(String(attachment.fileName))
3085
+ const channelPathName = channel?.channelId || channelKey
3086
+ const channelPrefix = `${CHAT_FILE_ROOT}/${channelPathName}/`
3087
+ const fileName = oldFileName.startsWith(channelPrefix)
3088
+ ? oldFileName
3089
+ : `${channelPrefix}${getPathBaseName(oldFileName)}`
3090
+ const link = buildMostLink(attachment.cid, fileName)
3091
+ const content =
3092
+ typeof baseMessage.content === 'string' &&
3093
+ (baseMessage.content === attachment.link ||
3094
+ parseMostLink(baseMessage.content).cid === attachment.cid)
3095
+ ? link
3096
+ : baseMessage.content
3097
+
3098
+ if (
3099
+ fileName === attachment.fileName &&
3100
+ link === attachment.link &&
3101
+ content === baseMessage.content
3102
+ ) {
3103
+ return baseMessage
3104
+ }
3105
+
3106
+ return {
3107
+ ...baseMessage,
3108
+ content,
3109
+ attachment: {
3110
+ ...attachment,
3111
+ fileName,
3112
+ link,
3113
+ },
3114
+ }
3115
+ }
3116
+
3117
+ #getCidInfo(cid) {
3118
+ return getCidInfo(cid)
3119
+ }
3120
+
3121
+ #setSeedState(cid, patch = {}) {
3122
+ const previous = this.#seedStates.get(cid) || {}
3123
+ const next = {
3124
+ ...previous,
3125
+ cid,
3126
+ ...patch,
3127
+ updatedAt: new Date().toISOString(),
3128
+ }
3129
+ this.#seedStates.set(cid, next)
3130
+ this.emit('seed:state', next)
3131
+ return next
3132
+ }
3133
+
3134
+ #clearSeedState(cid) {
3135
+ if (this.#seedStates.delete(cid)) {
3136
+ this.emit('seed:state:removed', { cid })
3137
+ }
3138
+ }
3139
+
3140
+ #getUserSyncNamespace(session) {
3141
+ return this.#store.namespace(session.syncName)
3142
+ }
3143
+
3144
+ async #openUserSyncRuntime(session) {
3145
+ const ns = this.#getUserSyncNamespace(session)
3146
+ const localCore = session.localWriterCoreKey
3147
+ ? ns.get({
3148
+ key: b4a.from(session.localWriterCoreKey, 'hex'),
3149
+ valueEncoding: 'json',
3150
+ })
3151
+ : ns.get({
3152
+ name: `writer-${session.writerId}`,
3153
+ valueEncoding: 'json',
3154
+ })
3155
+ await localCore.ready()
3156
+ session.localWriterCoreKey = b4a.toString(localCore.key, 'hex')
3157
+ session.writerCoreKeys = uniqueStrings([
3158
+ ...session.writerCoreKeys,
3159
+ session.localWriterCoreKey,
3160
+ ])
3161
+
3162
+ if (!this.#userSyncCores.has(session.ownerAddress)) {
3163
+ this.#userSyncCores.set(session.ownerAddress, new Map())
3164
+ }
3165
+ this.#userSyncCores
3166
+ .get(session.ownerAddress)
3167
+ .set(session.localWriterCoreKey, localCore)
3168
+ this.#setupUserSyncAppendListener(
3169
+ localCore,
3170
+ session,
3171
+ session.localWriterCoreKey
3172
+ )
3173
+
3174
+ for (const writerCoreKey of session.writerCoreKeys) {
3175
+ if (writerCoreKey && writerCoreKey !== session.localWriterCoreKey) {
3176
+ await this.#openRemoteUserSyncCore(session, writerCoreKey)
3177
+ }
3178
+ }
3179
+ }
3180
+
3181
+ async #joinUserSyncDiscovery(session) {
3182
+ if (this.#userSyncDiscoveries.has(session.ownerAddress)) return
3183
+ const discoveryKey = this.#generateUserSyncDiscoveryKey(session.syncId)
3184
+ const appDiscovery = this.#swarm.join(discoveryKey, {
3185
+ server: true,
3186
+ client: true,
3187
+ })
3188
+ const chatDiscovery = this.#chatSwarm.join(
3189
+ discoveryKey,
3190
+ { server: true, client: true }
3191
+ )
3192
+ this.#userSyncDiscoveries.set(session.ownerAddress, {
3193
+ appDiscovery,
3194
+ chatDiscovery,
3195
+ })
3196
+ }
3197
+
3198
+ async #openRemoteUserSyncCore(session, writerCoreKey) {
3199
+ const normalizedCoreKey = String(writerCoreKey || '').trim()
3200
+ if (
3201
+ !normalizedCoreKey ||
3202
+ normalizedCoreKey === session.localWriterCoreKey
3203
+ ) {
3204
+ return null
3205
+ }
3206
+ if (!this.#userSyncCores.has(session.ownerAddress)) {
3207
+ this.#userSyncCores.set(session.ownerAddress, new Map())
3208
+ }
3209
+ const coresMap = this.#userSyncCores.get(session.ownerAddress)
3210
+ if (coresMap.has(normalizedCoreKey)) return coresMap.get(normalizedCoreKey)
3211
+
3212
+ const ns = this.#getUserSyncNamespace(session)
3213
+ const core = ns.get({
3214
+ key: b4a.from(normalizedCoreKey, 'hex'),
3215
+ valueEncoding: 'json',
3216
+ })
3217
+ await core.ready()
3218
+ coresMap.set(normalizedCoreKey, core)
3219
+ session.writerCoreKeys = uniqueStrings([
3220
+ ...session.writerCoreKeys,
3221
+ normalizedCoreKey,
3222
+ ])
3223
+ this.#persistUserSyncSession(session)
3224
+ this.#setupUserSyncAppendListener(core, session, normalizedCoreKey)
3225
+ return core
3226
+ }
3227
+
3228
+ #setupUserSyncAppendListener(core, session, coreKey) {
3229
+ const offsetKey = `${session.ownerAddress}:${coreKey}`
3230
+ let processing = false
3231
+ const processEntries = async () => {
3232
+ if (processing) return
3233
+ processing = true
3234
+ try {
3235
+ let index = this.#userSyncCoreOffsets.get(offsetKey) || 0
3236
+ while (index < core.length) {
3237
+ const entry = await core.get(index)
3238
+ await this.#applyUserSyncEntry(session, entry)
3239
+ index += 1
3240
+ this.#userSyncCoreOffsets.set(offsetKey, index)
3241
+ }
3242
+ } finally {
3243
+ processing = false
3244
+ }
3245
+ }
3246
+
3247
+ core.on('append', () => {
3248
+ processEntries().catch(err => {
3249
+ console.warn('[MostBox] Failed to process user sync entry:', err.message)
3250
+ })
3251
+ })
3252
+ processEntries().catch(err => {
3253
+ console.warn('[MostBox] Failed to process user sync entries:', err.message)
3254
+ })
3255
+ }
3256
+
3257
+ #encodeUserSyncEntry(session, op) {
3258
+ const nonce = crypto.randomBytes(12)
3259
+ const cipher = crypto.createCipheriv(
3260
+ 'aes-256-gcm',
3261
+ Buffer.from(session.syncCipherKey, 'hex'),
3262
+ nonce
3263
+ )
3264
+ const encrypted = Buffer.concat([
3265
+ cipher.update(JSON.stringify(op), 'utf8'),
3266
+ cipher.final(),
3267
+ ])
3268
+ const tag = cipher.getAuthTag()
3269
+ const body = `${nonce.toString('hex')}.${encrypted.toString('hex')}.${tag.toString('hex')}`
3270
+ const mac = crypto
3271
+ .createHmac('sha256', Buffer.from(session.syncMacKey, 'hex'))
3272
+ .update(body)
3273
+ .digest('hex')
3274
+
3275
+ return {
3276
+ type: 'user-sync-op',
3277
+ schemaVersion: USER_SYNC_SCHEMA_VERSION,
3278
+ ownerAddress: session.ownerAddress,
3279
+ syncId: session.syncId,
3280
+ writerCoreKeys: uniqueStrings(session.writerCoreKeys),
3281
+ body,
3282
+ mac,
3283
+ createdAt: new Date(op.timestamp).toISOString(),
3284
+ }
3285
+ }
3286
+
3287
+ #decodeUserSyncEntry(session, entry) {
3288
+ if (
3289
+ !entry ||
3290
+ entry.type !== 'user-sync-op' ||
3291
+ entry.syncId !== session.syncId ||
3292
+ entry.ownerAddress !== session.ownerAddress ||
3293
+ Number(entry.schemaVersion) !== USER_SYNC_SCHEMA_VERSION
3294
+ ) {
3295
+ return null
3296
+ }
3297
+
3298
+ const expectedMac = crypto
3299
+ .createHmac('sha256', Buffer.from(session.syncMacKey, 'hex'))
3300
+ .update(String(entry.body || ''))
3301
+ .digest('hex')
3302
+ if (expectedMac !== entry.mac) return null
3303
+
3304
+ const [nonceHex, encryptedHex, tagHex] = String(entry.body || '').split('.')
3305
+ if (!nonceHex || !encryptedHex || !tagHex) return null
3306
+
3307
+ try {
3308
+ const decipher = crypto.createDecipheriv(
3309
+ 'aes-256-gcm',
3310
+ Buffer.from(session.syncCipherKey, 'hex'),
3311
+ Buffer.from(nonceHex, 'hex')
3312
+ )
3313
+ decipher.setAuthTag(Buffer.from(tagHex, 'hex'))
3314
+ const decrypted = Buffer.concat([
3315
+ decipher.update(Buffer.from(encryptedHex, 'hex')),
3316
+ decipher.final(),
3317
+ ])
3318
+ return JSON.parse(decrypted.toString('utf8'))
3319
+ } catch {
3320
+ return null
3321
+ }
3322
+ }
3323
+
3324
+ async #appendUserSyncOp(ownerAddressInput, kind, payload = {}) {
3325
+ const ownerAddress = normalizeOwnerAddress(ownerAddressInput)
3326
+ const session = this.#userSyncSessions.get(ownerAddress)
3327
+ if (!session?.localWriterCoreKey) return null
3328
+ const coresMap = this.#userSyncCores.get(ownerAddress)
3329
+ const core = coresMap?.get(session.localWriterCoreKey)
3330
+ if (!core) return null
3331
+
3332
+ const op = {
3333
+ opId: `${Date.now()}-${crypto.randomBytes(6).toString('hex')}`,
3334
+ schemaVersion: USER_SYNC_SCHEMA_VERSION,
3335
+ kind,
3336
+ ownerAddress,
3337
+ timestamp: Date.now(),
3338
+ payload,
3339
+ }
3340
+ this.#markUserSyncClockForOp(op)
3341
+ await core.append(this.#encodeUserSyncEntry(session, op))
3342
+ this.#touchUserSyncSession(session)
3343
+ return op
3344
+ }
3345
+
3346
+ #appendUserSyncOpSoon(ownerAddress, kind, payload = {}) {
3347
+ this.#appendUserSyncOp(ownerAddress, kind, payload).catch(err => {
3348
+ console.warn('[MostBox] Failed to append user sync op:', err.message)
3349
+ })
3350
+ }
3351
+
3352
+ async #appendUserSyncSnapshot(ownerAddressInput, reason = 'snapshot') {
3353
+ const ownerAddress = normalizeOwnerAddress(ownerAddressInput)
3354
+ if (!this.#userSyncSessions.has(ownerAddress)) return null
3355
+ const files = this.#getPublishedBucket(ownerAddress).map(file =>
3356
+ this.#formatFileForSync(file, 'active')
3357
+ )
3358
+ const trashFiles = this.#getTrashBucket(ownerAddress).map(file =>
3359
+ this.#formatFileForSync(file, 'trash')
3360
+ )
3361
+ const channels = this.#channels
3362
+ .filter(channel => this.#channelHasMember(channel, ownerAddress))
3363
+ .map(channel => this.#formatChannelForSync(channel, ownerAddress))
3364
+ .filter(Boolean)
3365
+ return this.#appendUserSyncOp(ownerAddress, 'snapshot', {
3366
+ reason,
3367
+ files,
3368
+ trashFiles,
3369
+ channels,
3370
+ })
3371
+ }
3372
+
3373
+ async #applyUserSyncEntry(session, entry) {
3374
+ const op = this.#decodeUserSyncEntry(session, entry)
3375
+ if (!op || op.ownerAddress !== session.ownerAddress) return false
3376
+ if (Array.isArray(entry.writerCoreKeys)) {
3377
+ await this.#mergeUserSyncWriterCoreKeys(session, entry.writerCoreKeys)
3378
+ }
3379
+ return this.#applyUserSyncOp(session, op)
3380
+ }
3381
+
3382
+ async #applyUserSyncOp(session, op) {
3383
+ let changedFiles = false
3384
+ let changedChannels = false
3385
+ if (op.kind === 'snapshot') {
3386
+ const payload = op.payload || {}
3387
+ for (const file of Array.isArray(payload.files) ? payload.files : []) {
3388
+ changedFiles =
3389
+ this.#applyUserSyncFileRecord(
3390
+ session.ownerAddress,
3391
+ file,
3392
+ 'active',
3393
+ getSyncTimestamp(file.syncUpdatedAt, op.timestamp)
3394
+ ) || changedFiles
3395
+ }
3396
+ for (const file of Array.isArray(payload.trashFiles) ? payload.trashFiles : []) {
3397
+ changedFiles =
3398
+ this.#applyUserSyncFileRecord(
3399
+ session.ownerAddress,
3400
+ file,
3401
+ 'trash',
3402
+ getSyncTimestamp(file.syncUpdatedAt, op.timestamp)
3403
+ ) || changedFiles
3404
+ }
3405
+ for (const channel of Array.isArray(payload.channels) ? payload.channels : []) {
3406
+ changedChannels =
3407
+ (await this.#applyUserSyncChannelRecord(
3408
+ session.ownerAddress,
3409
+ channel,
3410
+ getSyncTimestamp(channel.syncUpdatedAt, op.timestamp)
3411
+ )) || changedChannels
3412
+ }
3413
+ } else if (op.kind === 'file:upsert') {
3414
+ changedFiles = this.#applyUserSyncFileRecord(
3415
+ session.ownerAddress,
3416
+ op.payload?.file,
3417
+ 'active',
3418
+ getSyncTimestamp(op.payload?.file?.syncUpdatedAt, op.timestamp)
3419
+ )
3420
+ } else if (op.kind === 'file:trash') {
3421
+ changedFiles = this.#applyUserSyncFileRecord(
3422
+ session.ownerAddress,
3423
+ op.payload?.file,
3424
+ 'trash',
3425
+ getSyncTimestamp(op.payload?.file?.syncUpdatedAt, op.timestamp)
3426
+ )
3427
+ } else if (op.kind === 'file:delete') {
3428
+ changedFiles = await this.#applyUserSyncFileDelete(
3429
+ session.ownerAddress,
3430
+ op.payload?.cid,
3431
+ getSyncTimestamp(op.payload?.syncUpdatedAt, op.timestamp)
3432
+ )
3433
+ } else if (op.kind === 'channel:upsert') {
3434
+ changedChannels = await this.#applyUserSyncChannelRecord(
3435
+ session.ownerAddress,
3436
+ op.payload?.channel,
3437
+ getSyncTimestamp(op.payload?.channel?.syncUpdatedAt, op.timestamp)
3438
+ )
3439
+ } else if (op.kind === 'channel:leave') {
3440
+ changedChannels = this.#applyUserSyncChannelLeave(
3441
+ session.ownerAddress,
3442
+ op.payload?.channelKey,
3443
+ getSyncTimestamp(op.payload?.syncUpdatedAt, op.timestamp)
3444
+ )
3445
+ }
3446
+
3447
+ if (changedFiles) {
3448
+ this.#savePublishedMetadata()
3449
+ this.#saveTrashMetadata()
3450
+ this.emit('user:metadata:updated', {
3451
+ ownerAddress: session.ownerAddress,
3452
+ scope: 'files',
3453
+ })
3454
+ }
3455
+ if (changedChannels) {
3456
+ this.#saveChannelsMetadata()
3457
+ this.emit('user:metadata:updated', {
3458
+ ownerAddress: session.ownerAddress,
3459
+ scope: 'channels',
3460
+ })
3461
+ }
3462
+ if (changedFiles || changedChannels) {
3463
+ this.#touchUserSyncSession(session)
3464
+ }
3465
+ return changedFiles || changedChannels
3466
+ }
3467
+
3468
+ async #mergeUserSyncWriterCoreKeys(session, writerCoreKeys = []) {
3469
+ const nextKeys = uniqueStrings(writerCoreKeys)
3470
+ if (nextKeys.length === 0) return false
3471
+ const previous = new Set(session.writerCoreKeys || [])
3472
+ let changed = false
3473
+ for (const writerCoreKey of nextKeys) {
3474
+ if (!previous.has(writerCoreKey)) {
3475
+ previous.add(writerCoreKey)
3476
+ changed = true
3477
+ }
3478
+ if (writerCoreKey !== session.localWriterCoreKey) {
3479
+ await this.#openRemoteUserSyncCore(session, writerCoreKey)
3480
+ }
3481
+ }
3482
+ if (changed) {
3483
+ session.writerCoreKeys = [...previous]
3484
+ this.#persistUserSyncSession(session)
3485
+ }
3486
+ return changed
3487
+ }
3488
+
3489
+ async #exchangeUserSyncSessions(peerEngine) {
3490
+ for (const session of this.#userSyncSessions.values()) {
3491
+ const peerSession = peerEngine.#userSyncSessions.get(session.ownerAddress)
3492
+ if (!peerSession || peerSession.syncId !== session.syncId) continue
3493
+ await this.#mergeUserSyncWriterCoreKeys(
3494
+ session,
3495
+ peerSession.writerCoreKeys
3496
+ )
3497
+ await peerEngine.#mergeUserSyncWriterCoreKeys(
3498
+ peerSession,
3499
+ session.writerCoreKeys
3500
+ )
3501
+ }
3502
+ }
3503
+
3504
+ #formatFileForSync(file, state = 'active') {
3505
+ const cid = String(file?.cid || '').trim()
3506
+ if (!cid) return null
3507
+ const { driveName } = this.#getCidInfo(cid)
3508
+ const syncUpdatedAt = getSyncTimestamp(
3509
+ file.syncUpdatedAt || file.deletedAt || file.publishedAt
3510
+ )
3511
+ return {
3512
+ cid,
3513
+ fileName: sanitizeFilename(file.fileName || cid),
3514
+ driveName: file.driveName || driveName,
3515
+ size: Number(file.size) || 0,
3516
+ source: String(file.source || (state === 'active' ? 'published' : 'trash')),
3517
+ publishedAt:
3518
+ typeof file.publishedAt === 'string'
3519
+ ? file.publishedAt
3520
+ : new Date(syncUpdatedAt).toISOString(),
3521
+ deletedAt:
3522
+ typeof file.deletedAt === 'string'
3523
+ ? file.deletedAt
3524
+ : state === 'trash'
3525
+ ? new Date(syncUpdatedAt).toISOString()
3526
+ : '',
3527
+ starred: Boolean(file.starred),
3528
+ syncUpdatedAt,
2809
3529
  }
3530
+ }
2810
3531
 
2811
- const oldFileName = sanitizeFilename(String(attachment.fileName))
2812
- const channelPrefix = `${CHAT_FILE_ROOT}/${channelName}/`
2813
- const fileName = oldFileName.startsWith(channelPrefix)
2814
- ? oldFileName
2815
- : `${channelPrefix}${getPathBaseName(oldFileName)}`
2816
- const link = buildMostLink(attachment.cid, fileName)
2817
- const content =
2818
- typeof message.content === 'string' &&
2819
- (message.content === attachment.link ||
2820
- parseMostLink(message.content).cid === attachment.cid)
2821
- ? link
2822
- : message.content
2823
-
3532
+ #formatChannelForSync(channel, ownerAddress) {
3533
+ const owner = normalizeOwnerAddress(ownerAddress)
2824
3534
  if (
2825
- fileName === attachment.fileName &&
2826
- link === attachment.link &&
2827
- content === message.content
3535
+ !channel ||
3536
+ !owner ||
3537
+ TRANSIENT_CHANNEL_TYPES.has(channel.type) ||
3538
+ !this.#channelHasMember(channel, owner)
2828
3539
  ) {
2829
- return message
3540
+ return null
2830
3541
  }
2831
-
2832
3542
  return {
2833
- ...message,
2834
- content,
2835
- attachment: {
2836
- ...attachment,
2837
- fileName,
2838
- link,
2839
- },
3543
+ channelId: channel.channelId,
3544
+ channelKey: channel.channelKey,
3545
+ type: channel.type,
3546
+ createdAt: channel.createdAt,
3547
+ lastMessageAt: channel.lastMessageAt || '',
3548
+ writerCoreKeys: uniqueStrings(channel.writerCoreKeys),
3549
+ member: this.#getChannelMembers(channel).find(
3550
+ member => member.address === owner
3551
+ ),
3552
+ remark: channel.remarks?.[owner] || '',
3553
+ pinned: Boolean(channel.pinnedBy?.[owner]),
3554
+ syncUpdatedAt: getSyncTimestamp(
3555
+ channel.syncUpdatedAt || channel.lastMessageAt || channel.createdAt
3556
+ ),
2840
3557
  }
2841
3558
  }
2842
3559
 
2843
- #getCidInfo(cid) {
2844
- return getCidInfo(cid)
3560
+ #appendUserSyncChannelUpsertSoon(channel, ownerAddress) {
3561
+ const owner = normalizeOwnerAddress(ownerAddress)
3562
+ const record = this.#formatChannelForSync(channel, owner)
3563
+ if (!record) return
3564
+ this.#appendUserSyncOpSoon(owner, 'channel:upsert', { channel: record })
2845
3565
  }
2846
3566
 
2847
- #setSeedState(cid, patch = {}) {
2848
- const previous = this.#seedStates.get(cid) || {}
2849
- const next = {
2850
- ...previous,
3567
+ #appendUserSyncChannelLeaveSoon(channel, ownerAddress, syncUpdatedAt = Date.now()) {
3568
+ const owner = normalizeOwnerAddress(ownerAddress)
3569
+ if (!owner || !channel || TRANSIENT_CHANNEL_TYPES.has(channel.type)) return
3570
+ this.#appendUserSyncOpSoon(owner, 'channel:leave', {
3571
+ channelKey: channel.channelKey,
3572
+ syncUpdatedAt,
3573
+ })
3574
+ }
3575
+
3576
+ #normalizeSyncFileRecord(record, state, timestamp) {
3577
+ if (!record || typeof record !== 'object') return null
3578
+ const cid = String(record.cid || '').trim()
3579
+ if (!cid) return null
3580
+ let driveName = ''
3581
+ try {
3582
+ driveName = this.#getCidInfo(cid).driveName
3583
+ } catch {
3584
+ return null
3585
+ }
3586
+ const fileName = sanitizeFilename(record.fileName || cid)
3587
+ if (!fileName || fileName === 'unnamed') return null
3588
+ const syncUpdatedAt = getSyncTimestamp(record.syncUpdatedAt, timestamp)
3589
+ return {
2851
3590
  cid,
2852
- ...patch,
2853
- updatedAt: new Date().toISOString(),
3591
+ fileName,
3592
+ driveName: record.driveName || driveName,
3593
+ size: Number(record.size) || 0,
3594
+ source: String(record.source || (state === 'active' ? 'synced' : 'trash')),
3595
+ publishedAt:
3596
+ typeof record.publishedAt === 'string'
3597
+ ? record.publishedAt
3598
+ : new Date(syncUpdatedAt).toISOString(),
3599
+ deletedAt:
3600
+ typeof record.deletedAt === 'string'
3601
+ ? record.deletedAt
3602
+ : state === 'trash'
3603
+ ? new Date(syncUpdatedAt).toISOString()
3604
+ : '',
3605
+ starred: Boolean(record.starred),
3606
+ syncUpdatedAt,
2854
3607
  }
2855
- this.#seedStates.set(cid, next)
2856
- this.emit('seed:state', next)
2857
- return next
2858
3608
  }
2859
3609
 
2860
- #clearSeedState(cid) {
2861
- if (this.#seedStates.delete(cid)) {
2862
- this.emit('seed:state:removed', { cid })
3610
+ #applyUserSyncFileRecord(ownerAddress, record, state, timestamp) {
3611
+ const normalized = this.#normalizeSyncFileRecord(record, state, timestamp)
3612
+ if (!normalized) return false
3613
+ const entityKey = `file:${normalized.cid}`
3614
+ if (!this.#shouldApplyUserSyncEntity(ownerAddress, entityKey, normalized.syncUpdatedAt)) {
3615
+ return false
2863
3616
  }
2864
- }
2865
3617
 
2866
- #hashImportPackage(input) {
2867
- return crypto
2868
- .createHash('sha256')
2869
- .update(JSON.stringify(input || {}))
2870
- .digest('hex')
3618
+ const publishedFiles = [...this.#getPublishedBucket(ownerAddress)]
3619
+ const trashFiles = [...this.#getTrashBucket(ownerAddress)]
3620
+ let changed = false
3621
+
3622
+ const publishedIndex = publishedFiles.findIndex(
3623
+ file => file.cid === normalized.cid
3624
+ )
3625
+ const trashIndex = trashFiles.findIndex(file => file.cid === normalized.cid)
3626
+ const localHolding = this.#holdings.find(
3627
+ holding => holding.cid === normalized.cid
3628
+ )
3629
+ const localSource = localHolding?.source || 'synced'
3630
+
3631
+ if (state === 'active') {
3632
+ const nextRecord = {
3633
+ fileName: normalized.fileName,
3634
+ cid: normalized.cid,
3635
+ driveName: normalized.driveName,
3636
+ size: normalized.size,
3637
+ source: localSource,
3638
+ publishedAt: normalized.publishedAt,
3639
+ starred: normalized.starred,
3640
+ syncUpdatedAt: normalized.syncUpdatedAt,
3641
+ }
3642
+ if (publishedIndex === -1) {
3643
+ publishedFiles.push(nextRecord)
3644
+ changed = true
3645
+ } else if (
3646
+ JSON.stringify(publishedFiles[publishedIndex]) !==
3647
+ JSON.stringify(nextRecord)
3648
+ ) {
3649
+ publishedFiles[publishedIndex] = nextRecord
3650
+ changed = true
3651
+ }
3652
+ if (trashIndex !== -1) {
3653
+ trashFiles.splice(trashIndex, 1)
3654
+ changed = true
3655
+ }
3656
+ } else {
3657
+ const nextRecord = {
3658
+ fileName: normalized.fileName,
3659
+ cid: normalized.cid,
3660
+ driveName: normalized.driveName,
3661
+ size: normalized.size,
3662
+ source: localSource,
3663
+ publishedAt: normalized.publishedAt,
3664
+ starred: normalized.starred,
3665
+ deletedAt: normalized.deletedAt || new Date(normalized.syncUpdatedAt).toISOString(),
3666
+ syncUpdatedAt: normalized.syncUpdatedAt,
3667
+ }
3668
+ if (trashIndex === -1) {
3669
+ trashFiles.push(nextRecord)
3670
+ changed = true
3671
+ } else if (
3672
+ JSON.stringify(trashFiles[trashIndex]) !== JSON.stringify(nextRecord)
3673
+ ) {
3674
+ trashFiles[trashIndex] = nextRecord
3675
+ changed = true
3676
+ }
3677
+ if (publishedIndex !== -1) {
3678
+ publishedFiles.splice(publishedIndex, 1)
3679
+ changed = true
3680
+ }
3681
+ }
3682
+
3683
+ if (changed) {
3684
+ this.#setPublishedBucket(ownerAddress, publishedFiles)
3685
+ this.#setTrashBucket(ownerAddress, trashFiles)
3686
+ this.#setUserSyncClock(ownerAddress, entityKey, normalized.syncUpdatedAt)
3687
+ }
3688
+ return changed
2871
3689
  }
2872
3690
 
2873
- #normalizeUserImportPackage(input = {}) {
2874
- if (!input || typeof input !== 'object') {
2875
- throw new ValidationError('import package must be an object')
3691
+ async #applyUserSyncFileDelete(ownerAddress, cidInput, timestamp) {
3692
+ const cid = String(cidInput || '').trim()
3693
+ if (!cid) return false
3694
+ const syncUpdatedAt = getSyncTimestamp(timestamp)
3695
+ const entityKey = `file:${cid}`
3696
+ if (!this.#shouldApplyUserSyncEntity(ownerAddress, entityKey, syncUpdatedAt)) {
3697
+ return false
3698
+ }
3699
+
3700
+ const publishedFiles = this.#getPublishedBucket(ownerAddress).filter(
3701
+ file => file.cid !== cid
3702
+ )
3703
+ const trashFiles = this.#getTrashBucket(ownerAddress).filter(
3704
+ file => file.cid !== cid
3705
+ )
3706
+ const changed =
3707
+ publishedFiles.length !== this.#getPublishedBucket(ownerAddress).length ||
3708
+ trashFiles.length !== this.#getTrashBucket(ownerAddress).length
3709
+ this.#setPublishedBucket(ownerAddress, publishedFiles)
3710
+ this.#setTrashBucket(ownerAddress, trashFiles)
3711
+ this.#setUserSyncClock(ownerAddress, entityKey, syncUpdatedAt)
3712
+ if (changed && !this.#hasAnyUserReference(cid)) {
3713
+ await this.#cleanupUnreferencedCids([cid])
2876
3714
  }
2877
- if (Number(input.schemaVersion) !== USER_DATA_SCHEMA_VERSION) {
2878
- throw new ValidationError('unsupported import package schemaVersion')
3715
+ return changed
3716
+ }
3717
+
3718
+ async #applyUserSyncChannelRecord(ownerAddress, record, timestamp) {
3719
+ if (!record || typeof record !== 'object') return false
3720
+ const channelId = normalizeChannelId(record.channelId)
3721
+ const expectedChannelKey = buildChannelKey(channelId)
3722
+ const channelKey = expectedChannelKey
3723
+ if (!channelId || !channelKey) return false
3724
+ const syncUpdatedAt = getSyncTimestamp(record.syncUpdatedAt, timestamp)
3725
+ const entityKey = `channel:${channelKey}`
3726
+ if (!this.#shouldApplyUserSyncEntity(ownerAddress, entityKey, syncUpdatedAt)) {
3727
+ return false
2879
3728
  }
2880
3729
 
2881
- const normalizeFile = (record, scope) => {
2882
- if (!record || typeof record !== 'object') {
2883
- throw new ValidationError(`${scope} record must be an object`)
3730
+ let channel = this.#channels.find(item => item.channelKey === channelKey)
3731
+ let changed = false
3732
+ if (!channel) {
3733
+ channel = {
3734
+ channelId,
3735
+ channelKey,
3736
+ name: channelId,
3737
+ createdAt:
3738
+ typeof record.createdAt === 'string'
3739
+ ? record.createdAt
3740
+ : new Date(syncUpdatedAt).toISOString(),
3741
+ lastMessageAt:
3742
+ typeof record.lastMessageAt === 'string' ? record.lastMessageAt : '',
3743
+ type: String(record.type || 'personal').trim() || 'personal',
3744
+ writerId: createChannelWriterId(),
3745
+ localWriterCoreKey: '',
3746
+ writerCoreKeys: uniqueStrings(record.writerCoreKeys),
3747
+ members: [],
3748
+ syncUpdatedAt,
2884
3749
  }
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`)
3750
+ this.#channels.push(channel)
3751
+ changed = true
3752
+ } else {
3753
+ const nextKeys = uniqueStrings([
3754
+ ...(channel.writerCoreKeys || []),
3755
+ ...(record.writerCoreKeys || []),
3756
+ ])
3757
+ if (nextKeys.length !== (channel.writerCoreKeys || []).length) {
3758
+ channel.writerCoreKeys = nextKeys
3759
+ changed = true
2890
3760
  }
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),
3761
+ if (record.lastMessageAt && record.lastMessageAt !== channel.lastMessageAt) {
3762
+ channel.lastMessageAt = record.lastMessageAt
3763
+ changed = true
2901
3764
  }
3765
+ channel.syncUpdatedAt = syncUpdatedAt
2902
3766
  }
2903
3767
 
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
- : []
3768
+ if (
3769
+ this.#upsertChannelMember(channel, {
3770
+ ownerAddress,
3771
+ displayName:
3772
+ record.member?.displayName || record.remark || '',
3773
+ avatar: record.member?.avatar || '',
3774
+ })
3775
+ ) {
3776
+ changed = true
3777
+ }
3778
+ if (record.remark !== undefined) {
3779
+ channel.remarks = channel.remarks || {}
3780
+ const remark = String(record.remark || '').slice(0, 50)
3781
+ if (remark) channel.remarks[ownerAddress] = remark
3782
+ else delete channel.remarks[ownerAddress]
3783
+ changed = true
3784
+ }
3785
+ channel.pinnedBy = channel.pinnedBy || {}
3786
+ if (record.pinned) {
3787
+ channel.pinnedBy[ownerAddress] = true
3788
+ } else {
3789
+ delete channel.pinnedBy[ownerAddress]
3790
+ }
3791
+ this.#setUserSyncClock(ownerAddress, entityKey, syncUpdatedAt)
3792
+
3793
+ if (!this.#channelLocalCoreKey.get(channel.channelKey)) {
3794
+ await this.#openChannelRuntime(channel)
3795
+ await this.#joinChannelDiscoveryTopics(channel)
3796
+ changed = true
3797
+ this.emit('channel:joined', {
3798
+ channel: channel.channelKey,
3799
+ channelKey: channel.channelKey,
3800
+ channelId: channel.channelId,
3801
+ key: channel.channelKey,
3802
+ })
3803
+ }
3804
+ return changed
3805
+ }
2932
3806
 
2933
- return { files, trashFiles, channels }
3807
+ #applyUserSyncChannelLeave(ownerAddress, channelKeyInput, timestamp) {
3808
+ const channelKey = normalizeChannelKey(channelKeyInput)
3809
+ if (!channelKey) return false
3810
+ const syncUpdatedAt = getSyncTimestamp(timestamp)
3811
+ const entityKey = `channel:${channelKey}`
3812
+ if (!this.#shouldApplyUserSyncEntity(ownerAddress, entityKey, syncUpdatedAt)) {
3813
+ return false
3814
+ }
3815
+ const channel = this.#channels.find(item => item.channelKey === channelKey)
3816
+ if (!channel) {
3817
+ this.#setUserSyncClock(ownerAddress, entityKey, syncUpdatedAt)
3818
+ return false
3819
+ }
3820
+ const before = channel.members?.length || 0
3821
+ channel.members = (channel.members || []).filter(
3822
+ member => normalizeOwnerAddress(member?.address) !== ownerAddress
3823
+ )
3824
+ if (channel.remarks) delete channel.remarks[ownerAddress]
3825
+ if (channel.pinnedBy) delete channel.pinnedBy[ownerAddress]
3826
+ this.#setUserSyncClock(ownerAddress, entityKey, syncUpdatedAt)
3827
+ const changed = before !== channel.members.length
3828
+ if (channel.members.length === 0) {
3829
+ this.#channels = this.#channels.filter(item => item.channelKey !== channelKey)
3830
+ }
3831
+ if (changed) {
3832
+ this.emit('channel:left', {
3833
+ channel: channelKey,
3834
+ channelKey,
3835
+ channelId: channel.channelId,
3836
+ name: channel.channelId,
3837
+ })
3838
+ }
3839
+ return changed
2934
3840
  }
2935
3841
 
2936
- #applyImportedChannels(ownerAddress, channels = []) {
2937
- const normalizedOwner = normalizeOwnerAddress(ownerAddress)
2938
- if (!normalizedOwner) return
3842
+ #getUserSyncClock(ownerAddress, entityKey) {
3843
+ const owner = normalizeOwnerAddress(ownerAddress)
3844
+ return Number(this.#userSyncMetadata.clocks?.[owner]?.[entityKey]) || 0
3845
+ }
2939
3846
 
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)
2955
- }
3847
+ #setUserSyncClock(ownerAddress, entityKey, timestamp) {
3848
+ const owner = normalizeOwnerAddress(ownerAddress)
3849
+ if (!owner || !entityKey) return
3850
+ this.#userSyncMetadata.clocks = this.#userSyncMetadata.clocks || {}
3851
+ this.#userSyncMetadata.clocks[owner] =
3852
+ this.#userSyncMetadata.clocks[owner] || {}
3853
+ this.#userSyncMetadata.clocks[owner][entityKey] = Math.max(
3854
+ this.#getUserSyncClock(owner, entityKey),
3855
+ getSyncTimestamp(timestamp)
3856
+ )
3857
+ this.#saveUserSyncMetadata()
3858
+ }
2956
3859
 
2957
- this.#upsertChannelMember(channel, {
2958
- ownerAddress: normalizedOwner,
2959
- displayName: imported.member?.displayName || imported.member?.name || '',
2960
- avatar: imported.member?.avatar || '',
2961
- })
3860
+ #shouldApplyUserSyncEntity(ownerAddress, entityKey, timestamp) {
3861
+ return getSyncTimestamp(timestamp) > this.#getUserSyncClock(ownerAddress, entityKey)
3862
+ }
2962
3863
 
2963
- if (imported.remark) {
2964
- channel.remarks = channel.remarks || {}
2965
- channel.remarks[normalizedOwner] = imported.remark
3864
+ #markUserSyncClockForOp(op) {
3865
+ const ownerAddress = normalizeOwnerAddress(op.ownerAddress)
3866
+ if (!ownerAddress) return
3867
+ if (op.kind === 'file:upsert' || op.kind === 'file:trash') {
3868
+ const cid = op.payload?.file?.cid
3869
+ const timestamp = getSyncTimestamp(
3870
+ op.payload?.file?.syncUpdatedAt,
3871
+ op.timestamp
3872
+ )
3873
+ if (cid) this.#setUserSyncClock(ownerAddress, `file:${cid}`, timestamp)
3874
+ } else if (op.kind === 'file:delete') {
3875
+ const cid = op.payload?.cid
3876
+ const timestamp = getSyncTimestamp(
3877
+ op.payload?.syncUpdatedAt,
3878
+ op.timestamp
3879
+ )
3880
+ if (cid) this.#setUserSyncClock(ownerAddress, `file:${cid}`, timestamp)
3881
+ } else if (op.kind === 'channel:upsert') {
3882
+ const channelKey = op.payload?.channel?.channelKey
3883
+ const timestamp = getSyncTimestamp(
3884
+ op.payload?.channel?.syncUpdatedAt,
3885
+ op.timestamp
3886
+ )
3887
+ if (channelKey) {
3888
+ this.#setUserSyncClock(ownerAddress, `channel:${channelKey}`, timestamp)
2966
3889
  }
2967
- if (imported.pinned) {
2968
- channel.pinnedBy = channel.pinnedBy || {}
2969
- channel.pinnedBy[normalizedOwner] = true
3890
+ } else if (op.kind === 'channel:leave') {
3891
+ const channelKey = op.payload?.channelKey
3892
+ const timestamp = getSyncTimestamp(
3893
+ op.payload?.syncUpdatedAt,
3894
+ op.timestamp
3895
+ )
3896
+ if (channelKey) {
3897
+ this.#setUserSyncClock(ownerAddress, `channel:${channelKey}`, timestamp)
2970
3898
  }
2971
3899
  }
2972
3900
  }
2973
3901
 
3902
+ #persistUserSyncSession(session) {
3903
+ this.#userSyncMetadata.sessions = this.#userSyncMetadata.sessions || {}
3904
+ this.#userSyncMetadata.sessions[session.ownerAddress] = {
3905
+ ownerAddress: session.ownerAddress,
3906
+ syncId: session.syncId,
3907
+ syncName: session.syncName,
3908
+ writerId: session.writerId,
3909
+ localWriterCoreKey: session.localWriterCoreKey,
3910
+ writerCoreKeys: uniqueStrings(session.writerCoreKeys),
3911
+ startedAt: session.startedAt,
3912
+ lastSyncedAt:
3913
+ this.#userSyncMetadata.sessions?.[session.ownerAddress]?.lastSyncedAt ||
3914
+ '',
3915
+ updatedAt: new Date().toISOString(),
3916
+ }
3917
+ this.#saveUserSyncMetadata()
3918
+ }
3919
+
3920
+ #touchUserSyncSession(session) {
3921
+ this.#persistUserSyncSession(session)
3922
+ const persisted = this.#userSyncMetadata.sessions?.[session.ownerAddress]
3923
+ if (persisted) {
3924
+ persisted.lastSyncedAt = new Date().toISOString()
3925
+ this.#saveUserSyncMetadata()
3926
+ }
3927
+ }
3928
+
2974
3929
  #getFileRuntimeStats(cid) {
2975
3930
  const state = this.#fileMonitors.get(cid)
2976
3931
  if (!state) {
@@ -3549,6 +4504,10 @@ export class MostBoxEngine extends EventEmitter {
3549
4504
  return path.join(this.#options.dataPath, 'trash-files.json')
3550
4505
  }
3551
4506
 
4507
+ #getUserSyncMetadataPath() {
4508
+ return path.join(this.#options.dataPath, 'user-sync.json')
4509
+ }
4510
+
3552
4511
  #atomicWrite(filePath, data) {
3553
4512
  const tmpPath = filePath + '.tmp'
3554
4513
  fs.writeFileSync(tmpPath, data, 'utf-8')
@@ -3641,6 +4600,65 @@ export class MostBoxEngine extends EventEmitter {
3641
4600
  }
3642
4601
  }
3643
4602
 
4603
+ #loadUserSyncMetadata() {
4604
+ try {
4605
+ const metadataPath = this.#getUserSyncMetadataPath()
4606
+ if (fs.existsSync(metadataPath)) {
4607
+ const parsed = JSON.parse(fs.readFileSync(metadataPath, 'utf-8'))
4608
+ const sessions = {}
4609
+ for (const [owner, session] of Object.entries(parsed.sessions || {})) {
4610
+ const ownerAddress = normalizeOwnerAddress(owner)
4611
+ const syncId = String(session?.syncId || '').trim()
4612
+ if (!ownerAddress || !syncId) continue
4613
+ sessions[ownerAddress] = {
4614
+ ownerAddress,
4615
+ syncId,
4616
+ syncName: String(session.syncName || getUserSyncName(syncId)),
4617
+ writerId: String(session.writerId || ''),
4618
+ localWriterCoreKey: String(session.localWriterCoreKey || ''),
4619
+ writerCoreKeys: uniqueStrings(session.writerCoreKeys),
4620
+ startedAt: String(session.startedAt || ''),
4621
+ lastSyncedAt: String(session.lastSyncedAt || ''),
4622
+ updatedAt: String(session.updatedAt || ''),
4623
+ }
4624
+ }
4625
+
4626
+ const clocks = {}
4627
+ for (const [owner, records] of Object.entries(parsed.clocks || {})) {
4628
+ const ownerAddress = normalizeOwnerAddress(owner)
4629
+ if (!ownerAddress || !records || typeof records !== 'object') continue
4630
+ clocks[ownerAddress] = {}
4631
+ for (const [entityKey, timestamp] of Object.entries(records)) {
4632
+ const value = Number(timestamp)
4633
+ if (entityKey && Number.isFinite(value) && value > 0) {
4634
+ clocks[ownerAddress][entityKey] = value
4635
+ }
4636
+ }
4637
+ }
4638
+
4639
+ return { sessions, clocks }
4640
+ }
4641
+ } catch (err) {
4642
+ console.warn(
4643
+ 'Failed to load user sync metadata, using empty state:',
4644
+ err.message
4645
+ )
4646
+ }
4647
+ return { sessions: {}, clocks: {} }
4648
+ }
4649
+
4650
+ #saveUserSyncMetadata() {
4651
+ try {
4652
+ const metadataPath = this.#getUserSyncMetadataPath()
4653
+ this.#atomicWrite(
4654
+ metadataPath,
4655
+ JSON.stringify(this.#userSyncMetadata, null, 2)
4656
+ )
4657
+ } catch (err) {
4658
+ console.error('Failed to save user sync metadata:', err.message)
4659
+ }
4660
+ }
4661
+
3644
4662
  #getChannelsMetadataPath() {
3645
4663
  return path.join(this.#options.dataPath, 'channels.json')
3646
4664
  }
@@ -3654,7 +4672,31 @@ export class MostBoxEngine extends EventEmitter {
3654
4672
  const metadataPath = this.#getChannelsMetadataPath()
3655
4673
  if (fs.existsSync(metadataPath)) {
3656
4674
  const data = fs.readFileSync(metadataPath, 'utf-8')
3657
- return JSON.parse(data)
4675
+ const channels = JSON.parse(data)
4676
+ if (!Array.isArray(channels)) return []
4677
+ return channels
4678
+ .filter(channel => channel && typeof channel === 'object')
4679
+ .map(channel => {
4680
+ const channelId = normalizeChannelId(channel.channelId)
4681
+ const channelKey = normalizeChannelKey(channel.channelKey)
4682
+ const expectedChannelKey = buildChannelKey(channelId)
4683
+ return {
4684
+ ...channel,
4685
+ channelId,
4686
+ channelKey,
4687
+ expectedChannelKey,
4688
+ name: channelId,
4689
+ writerCoreKeys: uniqueStrings(channel.writerCoreKeys),
4690
+ }
4691
+ })
4692
+ .filter(
4693
+ channel =>
4694
+ CHANNEL_NAME_REGEX.test(channel.channelId) &&
4695
+ channel.channelKey === channel.expectedChannelKey &&
4696
+ channel.writerId &&
4697
+ channel.localWriterCoreKey
4698
+ )
4699
+ .map(({ expectedChannelKey: _expectedChannelKey, ...channel }) => channel)
3658
4700
  }
3659
4701
  } catch (err) {
3660
4702
  console.warn(
@@ -3668,9 +4710,22 @@ export class MostBoxEngine extends EventEmitter {
3668
4710
  #saveChannelsMetadata() {
3669
4711
  try {
3670
4712
  const metadataPath = this.#getChannelsMetadataPath()
3671
- const persistentChannels = this.#channels.filter(
3672
- channel => !TRANSIENT_CHANNEL_TYPES.has(channel?.type)
3673
- )
4713
+ const persistentChannels = this.#channels
4714
+ .filter(channel => !TRANSIENT_CHANNEL_TYPES.has(channel?.type))
4715
+ .map(channel => ({
4716
+ channelId: channel.channelId,
4717
+ channelKey: channel.channelKey,
4718
+ name: channel.channelId,
4719
+ type: channel.type,
4720
+ createdAt: channel.createdAt,
4721
+ lastMessageAt: channel.lastMessageAt || '',
4722
+ writerId: channel.writerId,
4723
+ localWriterCoreKey: channel.localWriterCoreKey,
4724
+ writerCoreKeys: uniqueStrings(channel.writerCoreKeys),
4725
+ members: Array.isArray(channel.members) ? channel.members : [],
4726
+ remarks: channel.remarks,
4727
+ pinnedBy: channel.pinnedBy,
4728
+ }))
3674
4729
  this.#atomicWrite(
3675
4730
  metadataPath,
3676
4731
  JSON.stringify(persistentChannels, null, 2)
@@ -3680,23 +4735,39 @@ export class MostBoxEngine extends EventEmitter {
3680
4735
  }
3681
4736
  }
3682
4737
 
3683
- #generateChannelDiscoveryKey(name) {
4738
+ #generateChannelDiscoveryKey(channelKey) {
4739
+ const hash = crypto
4740
+ .createHash('sha256')
4741
+ .update(`${CHANNEL_NAME_PREFIX}channel:${channelKey}`)
4742
+ .digest()
4743
+ return hash
4744
+ }
4745
+
4746
+ #generateChannelChatDiscoveryKey(channelKey) {
4747
+ const hash = crypto
4748
+ .createHash('sha256')
4749
+ .update(`${CHANNEL_NAME_PREFIX}channel:${channelKey}:chat`)
4750
+ .digest()
4751
+ return hash
4752
+ }
4753
+
4754
+ #generateChannelIdDiscoveryKey(channelId) {
3684
4755
  const hash = crypto
3685
4756
  .createHash('sha256')
3686
- .update(`${CHANNEL_NAME_PREFIX}${name}`)
4757
+ .update(`${CHANNEL_NAME_PREFIX}id:${channelId}:candidates`)
3687
4758
  .digest()
3688
4759
  return hash
3689
4760
  }
3690
4761
 
3691
- #generateChannelChatDiscoveryKey(name) {
4762
+ #generateUserSyncDiscoveryKey(syncId) {
3692
4763
  const hash = crypto
3693
4764
  .createHash('sha256')
3694
- .update(`${CHANNEL_NAME_PREFIX}${name}:chat`)
4765
+ .update(`${CHANNEL_NAME_PREFIX}${USER_SYNC_NAMESPACE_PREFIX}${syncId}`)
3695
4766
  .digest()
3696
4767
  return hash
3697
4768
  }
3698
4769
 
3699
- #setupChannelAppendListener(core, channelName) {
4770
+ #setupChannelAppendListener(core, channelKey) {
3700
4771
  let lastCoreLength = core.length
3701
4772
  core.on('append', async () => {
3702
4773
  if (core.length > lastCoreLength) {
@@ -3704,7 +4775,9 @@ export class MostBoxEngine extends EventEmitter {
3704
4775
  try {
3705
4776
  const entry = await core.get(i)
3706
4777
  if (entry && entry.type === 'message') {
3707
- const channel = this.#channels.find(c => c.name === channelName)
4778
+ const channel = this.#channels.find(
4779
+ c => c.channelKey === channelKey
4780
+ )
3708
4781
  if (channel) {
3709
4782
  const entryTime = Number(entry.timestamp) || Date.now()
3710
4783
  const currentTime = Date.parse(channel.lastMessageAt || '') || 0
@@ -3714,16 +4787,18 @@ export class MostBoxEngine extends EventEmitter {
3714
4787
  }
3715
4788
  }
3716
4789
  this.emit('channel:message', {
3717
- channel: channelName,
4790
+ channel: channelKey,
4791
+ channelKey,
4792
+ channelId: channel?.channelId || '',
3718
4793
  message: this.#normalizeChannelMessageForResponse(
3719
- channelName,
4794
+ channelKey,
3720
4795
  entry
3721
4796
  ),
3722
4797
  })
3723
4798
  }
3724
4799
  } catch (err) {
3725
4800
  console.error(
3726
- `[MostBox] Failed to read channel message from ${channelName}:`,
4801
+ `[MostBox] Failed to read channel message from ${channelKey}:`,
3727
4802
  err.message
3728
4803
  )
3729
4804
  continue
@@ -3734,13 +4809,13 @@ export class MostBoxEngine extends EventEmitter {
3734
4809
  })
3735
4810
  }
3736
4811
 
3737
- async #openRemoteChannelCore(channelName, coreKeyHex) {
3738
- const coresMap = this.#channelCores.get(channelName)
4812
+ async #openRemoteChannelCore(channelKey, coreKeyHex) {
4813
+ const coresMap = this.#channelCores.get(channelKey)
3739
4814
  if (!coresMap) return
3740
4815
  if (coresMap.has(coreKeyHex)) return
3741
4816
 
3742
4817
  try {
3743
- const ns = this.#store.namespace(`channel-${channelName}`)
4818
+ const ns = this.#store.namespace(`channel-${channelKey}`)
3744
4819
  const core = ns.get({
3745
4820
  key: b4a.from(coreKeyHex, 'hex'),
3746
4821
  valueEncoding: 'json',
@@ -3748,89 +4823,198 @@ export class MostBoxEngine extends EventEmitter {
3748
4823
  await core.ready()
3749
4824
  const normalizedCoreKey = b4a.toString(core.key, 'hex')
3750
4825
  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
- }
4826
+ this.#setupChannelAppendListener(core, channelKey)
4827
+ const channel = this.#channels.find(c => c.channelKey === channelKey)
4828
+ if (channel && !channel.writerCoreKeys?.includes(normalizedCoreKey)) {
4829
+ channel.writerCoreKeys = uniqueStrings([
4830
+ ...(channel.writerCoreKeys || []),
4831
+ normalizedCoreKey,
4832
+ ])
4833
+ this.#saveChannelsMetadata()
3761
4834
  }
3762
4835
  console.log(
3763
- `[MostBox] Opened remote channel core ${normalizedCoreKey.slice(0, 8)}... for ${channelName}`
4836
+ `[MostBox] Opened remote channel core ${normalizedCoreKey.slice(0, 8)}... for ${channelKey}`
3764
4837
  )
3765
4838
  } catch (err) {
3766
4839
  console.warn(
3767
- `[MostBox] Failed to open remote channel core for ${channelName}:`,
4840
+ `[MostBox] Failed to open remote channel core for ${channelKey}:`,
3768
4841
  err.message
3769
4842
  )
3770
4843
  }
3771
4844
  }
3772
4845
 
3773
- async #handleChannelConnection(conn) {
3774
- const stream = conn
3775
- let connectedPeerId = null
3776
-
3777
- const coreKeys = {}
3778
- for (const [name, localKeyHex] of this.#channelLocalCoreKey) {
3779
- coreKeys[name] = localKeyHex
3780
- }
4846
+ #buildChannelHelloMessage() {
4847
+ const channels = this.#channels.map(channel => ({
4848
+ channelId: channel.channelId,
4849
+ channelKey: channel.channelKey,
4850
+ type: channel.type,
4851
+ createdAt: channel.createdAt,
4852
+ lastMessageAt: channel.lastMessageAt || '',
4853
+ writerCoreKeys: uniqueStrings([
4854
+ ...(channel.writerCoreKeys || []),
4855
+ this.#channelLocalCoreKey.get(channel.channelKey),
4856
+ ]),
4857
+ }))
4858
+ const userSyncSessions = [...this.#userSyncSessions.values()].map(
4859
+ session => ({
4860
+ ownerAddress: session.ownerAddress,
4861
+ syncId: session.syncId,
4862
+ syncName: session.syncName,
4863
+ writerCoreKeys: uniqueStrings(session.writerCoreKeys),
4864
+ })
4865
+ )
3781
4866
 
3782
- const helloMessage = JSON.stringify({
4867
+ return {
3783
4868
  type: 'channel-hello',
3784
4869
  peerId: this.getNodeId(),
3785
4870
  authorName: this.getNodeId().slice(0, 4),
3786
- channels: this.#channels.map(c => c.name),
3787
- coreKeys,
3788
- })
4871
+ channels,
4872
+ userSyncSessions,
4873
+ }
4874
+ }
3789
4875
 
4876
+ #sendChannelHello(stream) {
4877
+ if (!stream || stream.destroyed || stream.writableEnded) {
4878
+ this.#channelStreams.delete(stream)
4879
+ return false
4880
+ }
3790
4881
  try {
3791
- stream.write(helloMessage)
4882
+ stream.write(`${JSON.stringify(this.#buildChannelHelloMessage())}\n`)
4883
+ return true
3792
4884
  } catch {
3793
- return
4885
+ this.#channelStreams.delete(stream)
4886
+ return false
3794
4887
  }
4888
+ }
3795
4889
 
3796
- stream.on('data', async data => {
3797
- try {
3798
- const msg = JSON.parse(data.toString())
3799
- if (msg.type === 'channel-hello') {
3800
- connectedPeerId = msg.peerId
3801
-
3802
- const theirChannels = new Set(msg.channels || [])
3803
- for (const [name, peers] of this.#channelPeers) {
3804
- if (theirChannels.has(name)) {
3805
- peers.set(msg.peerId, {
3806
- peerId: msg.peerId,
3807
- authorName: msg.authorName,
3808
- lastSeen: Date.now(),
3809
- })
3810
- }
3811
- }
4890
+ #broadcastChannelHello() {
4891
+ for (const stream of [...this.#channelStreams]) {
4892
+ this.#sendChannelHello(stream)
4893
+ }
4894
+ }
3812
4895
 
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)
3819
- }
3820
- }
3821
- }
4896
+ async #processChannelHelloMessage(msg) {
4897
+ if (msg.type !== 'channel-hello') return null
3822
4898
 
3823
- this.emit('channel:peer:online', {
3824
- peerId: msg.peerId,
3825
- authorName: msg.authorName,
4899
+ const remoteChannels = Array.isArray(msg.channels)
4900
+ ? msg.channels
4901
+ .filter(channel => channel && typeof channel === 'object')
4902
+ .map(channel => {
4903
+ const channelId = normalizeChannelId(channel.channelId)
4904
+ return {
4905
+ channelId,
4906
+ channelKey: buildChannelKey(channelId),
4907
+ type: String(channel.type || 'public').trim() || 'public',
4908
+ createdAt:
4909
+ typeof channel.createdAt === 'string' ? channel.createdAt : '',
4910
+ lastMessageAt:
4911
+ typeof channel.lastMessageAt === 'string'
4912
+ ? channel.lastMessageAt
4913
+ : '',
4914
+ writerCoreKeys: uniqueStrings(channel.writerCoreKeys),
4915
+ }
3826
4916
  })
4917
+ .filter(channel => channel.channelId && channel.channelKey)
4918
+ : []
4919
+
4920
+ for (const remoteChannel of remoteChannels) {
4921
+ this.#cacheChannelCandidate({
4922
+ ...remoteChannel,
4923
+ local: false,
4924
+ peerId: msg.peerId,
4925
+ })
4926
+
4927
+ const localChannel = this.#channels.find(
4928
+ channel => channel.channelKey === remoteChannel.channelKey
4929
+ )
4930
+ if (!localChannel) continue
4931
+
4932
+ const peers = this.#channelPeers.get(localChannel.channelKey)
4933
+ if (peers) {
4934
+ peers.set(msg.peerId, {
4935
+ peerId: msg.peerId,
4936
+ authorName: msg.authorName,
4937
+ lastSeen: Date.now(),
4938
+ })
4939
+ }
4940
+
4941
+ for (const writerCoreKey of remoteChannel.writerCoreKeys) {
4942
+ if (
4943
+ writerCoreKey &&
4944
+ writerCoreKey !== this.#channelLocalCoreKey.get(localChannel.channelKey)
4945
+ ) {
4946
+ await this.#openRemoteChannelCore(
4947
+ localChannel.channelKey,
4948
+ writerCoreKey
4949
+ )
4950
+ }
4951
+ }
4952
+ }
4953
+
4954
+ const remoteUserSyncSessions = Array.isArray(msg.userSyncSessions)
4955
+ ? msg.userSyncSessions
4956
+ .filter(session => session && typeof session === 'object')
4957
+ .map(session => ({
4958
+ ownerAddress: normalizeOwnerAddress(session.ownerAddress),
4959
+ syncId: String(session.syncId || '').trim(),
4960
+ writerCoreKeys: uniqueStrings(session.writerCoreKeys),
4961
+ }))
4962
+ .filter(session => session.ownerAddress && session.syncId)
4963
+ : []
4964
+
4965
+ for (const remoteSession of remoteUserSyncSessions) {
4966
+ const localSession = this.#userSyncSessions.get(
4967
+ remoteSession.ownerAddress
4968
+ )
4969
+ if (!localSession || localSession.syncId !== remoteSession.syncId) {
4970
+ continue
4971
+ }
4972
+ await this.#mergeUserSyncWriterCoreKeys(
4973
+ localSession,
4974
+ remoteSession.writerCoreKeys
4975
+ )
4976
+ }
4977
+
4978
+ this.emit('channel:peer:online', {
4979
+ peerId: msg.peerId,
4980
+ authorName: msg.authorName,
4981
+ })
4982
+
4983
+ return msg.peerId
4984
+ }
4985
+
4986
+ async #handleChannelConnection(conn) {
4987
+ const stream = conn
4988
+ let connectedPeerId = null
4989
+ let readBuffer = ''
4990
+ let closed = false
4991
+
4992
+ this.#channelStreams.add(stream)
4993
+ if (!this.#sendChannelHello(stream)) return
4994
+
4995
+ stream.on('data', async data => {
4996
+ readBuffer += data.toString()
4997
+ let newlineIndex = readBuffer.indexOf('\n')
4998
+ while (newlineIndex !== -1) {
4999
+ const line = readBuffer.slice(0, newlineIndex).trim()
5000
+ readBuffer = readBuffer.slice(newlineIndex + 1)
5001
+ newlineIndex = readBuffer.indexOf('\n')
5002
+ if (!line) continue
5003
+ try {
5004
+ const peerId = await this.#processChannelHelloMessage(
5005
+ JSON.parse(line)
5006
+ )
5007
+ if (peerId) connectedPeerId = peerId
5008
+ } catch (err) {
5009
+ console.warn(`[MostBox] Failed to process channel data:`, err.message)
3827
5010
  }
3828
- } catch (err) {
3829
- console.warn(`[MostBox] Failed to process channel data:`, err.message)
3830
5011
  }
3831
5012
  })
3832
5013
 
3833
- stream.on('close', () => {
5014
+ const cleanup = () => {
5015
+ if (closed) return
5016
+ closed = true
5017
+ this.#channelStreams.delete(stream)
3834
5018
  if (connectedPeerId) {
3835
5019
  for (const [, peers] of this.#channelPeers) {
3836
5020
  if (peers.has(connectedPeerId)) {
@@ -3843,7 +5027,10 @@ export class MostBoxEngine extends EventEmitter {
3843
5027
  }
3844
5028
  }
3845
5029
  }
3846
- })
5030
+ }
5031
+
5032
+ stream.on('close', cleanup)
5033
+ stream.on('error', cleanup)
3847
5034
  }
3848
5035
 
3849
5036
  /**