most-box 0.2.3 → 0.2.5

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 (111) hide show
  1. package/README.md +3 -26
  2. package/electron/main.js +366 -30
  3. package/electron/preload.js +16 -1
  4. package/electron/updateChecker.js +18 -11
  5. package/electron/updateChecker.test.js +23 -7
  6. package/out/admin/index.html +0 -0
  7. package/out/app/index.html +0 -0
  8. package/out/assets/AppShell-oF8wPXde.js +1 -0
  9. package/out/assets/FilePreviewOverlay-DTBZt89J.js +1 -0
  10. package/out/assets/LanguageToggle-HAJ3D6Ne.js +1 -0
  11. package/out/assets/MarketingHeader-B3vYocUz.js +1 -0
  12. package/out/assets/MarketingLayout-CapVVXTP.js +1 -0
  13. package/out/assets/{MarketingThemeToggle-DHVvLtms.js → MarketingThemeToggle-B47H68ro.js} +1 -1
  14. package/out/assets/{MilkdownEditor-WdGJyEa8.js → MilkdownEditor-CRA9oxMp.js} +1 -1
  15. package/out/assets/{OpenSidebarButton-CtsDYSq0.js → OpenSidebarButton-D3JRO-Hh.js} +1 -1
  16. package/out/assets/{SidebarAccount-4NFTcAF7.js → SidebarAccount-8_hQAYXx.js} +1 -1
  17. package/out/assets/arrow-right-mNO53HQi.js +1 -0
  18. package/out/assets/{circle-alert-BQoSDxUe.js → circle-alert-CofRTRHr.js} +1 -1
  19. package/out/assets/cloud-DAGFCFIp.js +1 -0
  20. package/out/assets/{copy-BtJHJnqH.js → copy-5aEv78Eq.js} +1 -1
  21. package/out/assets/{download-Bg-OdoxM.js → download-Bhqn6hL1.js} +1 -1
  22. package/out/assets/{downloadValidation-DS9_BV-n.js → downloadValidation-eInMiVSX.js} +1 -1
  23. package/out/assets/{external-link-m8ZIQe4p.js → external-link-CNR21yFA.js} +1 -1
  24. package/out/assets/{file-text-DG1orIkZ.js → file-text-CFfvvCnM.js} +1 -1
  25. package/out/assets/folder-BGJCjyba.js +1 -0
  26. package/out/assets/{hard-drive-BB-sllXA.js → hard-drive-4vHpA5kz.js} +1 -1
  27. package/out/assets/index--p9MKNEl.js +31 -0
  28. package/out/assets/index-DEu4udaa.css +1 -0
  29. package/out/assets/index.lazy-2K3f5PZe.js +1 -0
  30. package/out/assets/index.lazy-B-2etVwK.js +1 -0
  31. package/out/assets/index.lazy-B4WTQF5E.js +1 -0
  32. package/out/assets/index.lazy-BFfsQrRe.js +1 -0
  33. package/out/assets/index.lazy-C5udBnrO.js +1 -0
  34. package/out/assets/{index.lazy-DwKnzBXo.js → index.lazy-C84HrZpm.js} +1 -1
  35. package/out/assets/index.lazy-CBC2N5p_.js +3 -0
  36. package/out/assets/index.lazy-CzANViGX.js +1 -0
  37. package/out/assets/index.lazy-D3cw7yyA.js +2 -0
  38. package/out/assets/{index.lazy-C3z-lGsL.js → index.lazy-DVVZzbCN.js} +1 -1
  39. package/out/assets/index.lazy-DxZgywoV.js +1 -0
  40. package/out/assets/{index.lazy-DSjaivix.js → index.lazy-RBLN4SE9.js} +1 -1
  41. package/out/assets/index.lazy-s4E2z9AY.js +2 -0
  42. package/out/assets/{key-round-DIQ3Xt5F.js → key-round-BpSdpOHi.js} +1 -1
  43. package/out/assets/loader-ByMmV2_4.js +1 -0
  44. package/out/assets/{lock-tf1t2yuy.js → lock-C0pdaery.js} +1 -1
  45. package/out/assets/{message-square-DaQH7q-P.js → message-square-Eu86zZup.js} +1 -1
  46. package/out/assets/{mp-DYPYks13.js → mp-CJWipckT.js} +1 -1
  47. package/out/assets/{music-DaFvU2DL.js → music-Btrhub6z.js} +1 -1
  48. package/out/assets/{notebook-pen-CqEFOHKx.js → notebook-pen-CfdFgaDE.js} +1 -1
  49. package/out/assets/play-D5r9Yvbq.js +1 -0
  50. package/out/assets/plus-Mlh7r_bm.js +1 -0
  51. package/out/assets/{refresh-cw-roxAhABl.js → refresh-cw--JB8k6mH.js} +1 -1
  52. package/out/assets/{save-DR0O9ReR.js → save-DN4eprSt.js} +1 -1
  53. package/out/assets/search-CbzMyENy.js +1 -0
  54. package/out/assets/{send-m1XCcuPn.js → send-D8p6f8OD.js} +1 -1
  55. package/out/assets/{shield-check-BwcvTU4U.js → shield-check-c0labdRH.js} +1 -1
  56. package/out/assets/{trash-2-CNpsqYc1.js → trash-2-zdhWec_U.js} +1 -1
  57. package/out/assets/triangle-alert-CO_5uXy2.js +1 -0
  58. package/out/assets/upload-BD23c5Pz.js +1 -0
  59. package/out/assets/{useChannelMessages-qJTlBnzX.js → useChannelMessages-D_306Ymv.js} +2 -2
  60. package/out/assets/useGameRoom-CURx56m6.js +1 -0
  61. package/out/assets/userProfile-oeBvU6ch.js +1 -0
  62. package/out/assets/{userStore-C4vdYsQp.js → userStore-q0BBZsIV.js} +1 -1
  63. package/out/assets/{wallet-CozFU6yK.js → wallet-DmZMdK9A.js} +1 -1
  64. package/out/assets/{wifi-DIR3g_8A.js → wifi-CVqaPDcv.js} +1 -1
  65. package/out/chat/index.html +0 -0
  66. package/out/chat/join/index.html +0 -0
  67. package/out/download/index.html +2 -2
  68. package/out/game/gandengyan/index.html +0 -0
  69. package/out/game/index.html +0 -0
  70. package/out/game/zhajinhua/index.html +0 -0
  71. package/out/index.html +2 -2
  72. package/out/note/index.html +0 -0
  73. package/out/ping/index.html +2 -2
  74. package/out/profile/index.html +0 -0
  75. package/out/web3/index.html +0 -0
  76. package/package.json +8 -1
  77. package/server/index.js +2 -2
  78. package/server/src/core/syncTimestamp.js +10 -0
  79. package/server/src/http/nodeStatus.js +16 -16
  80. package/server/src/http/routePolicy.js +2 -2
  81. package/server/src/http/routes/nodeRoutes.js +21 -19
  82. package/server/src/index.js +578 -960
  83. package/server/src/utils/accountBackup.js +134 -0
  84. package/server/src/utils/api.js +149 -9
  85. package/out/assets/AppShell-B1U4lDqV.js +0 -1
  86. package/out/assets/FilePreviewOverlay-Cme4vUhr.js +0 -1
  87. package/out/assets/LanguageToggle-CBpXWdO9.js +0 -1
  88. package/out/assets/MarketingHeader-CkcBATx2.js +0 -1
  89. package/out/assets/MarketingLayout-mQV6ciYc.js +0 -1
  90. package/out/assets/arrow-right-RldN7Rwi.js +0 -1
  91. package/out/assets/cloud-DHoTMeZY.js +0 -1
  92. package/out/assets/index-BJ7E8LJN.js +0 -31
  93. package/out/assets/index-C0xqKeu-.css +0 -1
  94. package/out/assets/index.lazy-B5hOQuoO.js +0 -2
  95. package/out/assets/index.lazy-B9ligvAg.js +0 -1
  96. package/out/assets/index.lazy-BZ7nMBcY.js +0 -3
  97. package/out/assets/index.lazy-C-2BI6Ew.js +0 -1
  98. package/out/assets/index.lazy-Cda9sU_h.js +0 -1
  99. package/out/assets/index.lazy-D5ybxeOu.js +0 -1
  100. package/out/assets/index.lazy-DMZi3gid.js +0 -1
  101. package/out/assets/index.lazy-Dvgt53xN.js +0 -1
  102. package/out/assets/index.lazy-Dvo4Ncqe.js +0 -2
  103. package/out/assets/index.lazy-GQILyi6o.js +0 -1
  104. package/out/assets/play-B7q6F75-.js +0 -1
  105. package/out/assets/plus-H2i2mspM.js +0 -1
  106. package/out/assets/search-ncblG-zw.js +0 -1
  107. package/out/assets/triangle-alert-DP9EP7IM.js +0 -1
  108. package/out/assets/upload-V--8p13l.js +0 -1
  109. package/out/assets/useGameRoom-C9hu7Cez.js +0 -1
  110. package/server/src/core/userSyncKeys.js +0 -36
  111. package/server/src/utils/noteBackup.js +0 -116
@@ -43,14 +43,9 @@ import {
43
43
  cloneMetadataRecord,
44
44
  } from './core/ownerMetadata.js'
45
45
  import {
46
- USER_SYNC_SCHEMA_VERSION,
47
- USER_SYNC_NAMESPACE_PREFIX,
48
- normalizeUserSyncKey,
49
- deriveUserSyncId,
50
- getUserSyncName,
51
46
  getSyncTimestamp,
52
47
  getNextSyncTimestamp,
53
- } from './core/userSyncKeys.js'
48
+ } from './core/syncTimestamp.js'
54
49
  import { createOfflineSwarm } from './node/offlineSwarm.js'
55
50
  import {
56
51
  sanitizeFilename,
@@ -163,11 +158,7 @@ export class MostBoxEngine extends EventEmitter {
163
158
  #channelCandidateCache = new Map()
164
159
  #channelStreams = new Set()
165
160
 
166
- #userSyncSessions = new Map()
167
- #userSyncCores = new Map()
168
- #userSyncCoreOffsets = new Map()
169
- #userSyncDiscoveries = new Map()
170
- #userSyncMetadata = { sessions: {}, clocks: {}, profiles: {} }
161
+ #accountMetadata = { profiles: {} }
171
162
 
172
163
  #chatSwarm = null
173
164
 
@@ -365,7 +356,7 @@ export class MostBoxEngine extends EventEmitter {
365
356
  }
366
357
  }
367
358
 
368
- this.#userSyncMetadata = this.#loadUserSyncMetadata()
359
+ this.#accountMetadata = this.#loadAccountMetadata()
369
360
 
370
361
  this.#initialized = true
371
362
  console.log(`[MostBox] Engine initialized successfully`)
@@ -429,20 +420,6 @@ export class MostBoxEngine extends EventEmitter {
429
420
  this.#channelStreams.clear()
430
421
  this.#channels = []
431
422
 
432
- for (const [, coresMap] of this.#userSyncCores) {
433
- for (const [, core] of coresMap) {
434
- try {
435
- await core.close()
436
- } catch (err) {
437
- console.warn('[MostBox] Failed to close user sync core:', err.message)
438
- }
439
- }
440
- }
441
- this.#userSyncSessions.clear()
442
- this.#userSyncCores.clear()
443
- this.#userSyncCoreOffsets.clear()
444
- this.#userSyncDiscoveries.clear()
445
-
446
423
  if (this.#store) {
447
424
  await this.#store.close()
448
425
  this.#store = null
@@ -649,9 +626,6 @@ export class MostBoxEngine extends EventEmitter {
649
626
  }
650
627
 
651
628
  this.emit('publish:success', result)
652
- this.#appendUserSyncOpSoon(ownerAddress, 'file:upsert', {
653
- file: this.#formatFileForSync(fileRecord, 'active'),
654
- })
655
629
  return result
656
630
  }
657
631
 
@@ -1018,10 +992,6 @@ export class MostBoxEngine extends EventEmitter {
1018
992
  })
1019
993
 
1020
994
  this.emit('download:success', result)
1021
- const syncedFile = publishedBucket.find(file => file.cid === cidString)
1022
- this.#appendUserSyncOpSoon(ownerAddress, 'file:upsert', {
1023
- file: this.#formatFileForSync(syncedFile, 'active'),
1024
- })
1025
995
  return result
1026
996
  }
1027
997
  } finally {
@@ -1193,9 +1163,6 @@ export class MostBoxEngine extends EventEmitter {
1193
1163
  files[index].starred = !files[index].starred
1194
1164
  files[index].syncUpdatedAt = getNextSyncTimestamp(files[index].syncUpdatedAt)
1195
1165
  this.#savePublishedMetadata()
1196
- this.#appendUserSyncOpSoon(ownerAddress, 'file:upsert', {
1197
- file: this.#formatFileForSync(files[index], 'active'),
1198
- })
1199
1166
  return {
1200
1167
  cid,
1201
1168
  starred: files[index].starred,
@@ -1236,9 +1203,6 @@ export class MostBoxEngine extends EventEmitter {
1236
1203
  files.splice(index, 1)
1237
1204
  this.#setPublishedBucket(ownerAddress, files)
1238
1205
  this.#savePublishedMetadata()
1239
- this.#appendUserSyncOpSoon(ownerAddress, 'file:trash', {
1240
- file: this.#formatFileForSync(trashRecord, 'trash'),
1241
- })
1242
1206
 
1243
1207
  if (!this.#hasPublishedReference(fileRecord.cid)) {
1244
1208
  await this.#leaveCidTopic(fileRecord.cid)
@@ -1302,9 +1266,6 @@ export class MostBoxEngine extends EventEmitter {
1302
1266
  trashFiles.splice(index, 1)
1303
1267
  this.#setTrashBucket(ownerAddress, trashFiles)
1304
1268
  this.#saveTrashMetadata()
1305
- this.#appendUserSyncOpSoon(ownerAddress, 'file:upsert', {
1306
- file: this.#formatFileForSync(publishedFiles[existingIndex], 'active'),
1307
- })
1308
1269
  return this.listPublishedFiles({ ownerAddress })
1309
1270
  }
1310
1271
 
@@ -1348,9 +1309,6 @@ export class MostBoxEngine extends EventEmitter {
1348
1309
  source: fileRecord.source || 'published',
1349
1310
  })
1350
1311
  }
1351
- this.#appendUserSyncOpSoon(ownerAddress, 'file:upsert', {
1352
- file: this.#formatFileForSync(publishedRecord, 'active'),
1353
- })
1354
1312
 
1355
1313
  return this.listPublishedFiles({ ownerAddress })
1356
1314
  }
@@ -1373,10 +1331,6 @@ export class MostBoxEngine extends EventEmitter {
1373
1331
  trashFiles.splice(index, 1)
1374
1332
  this.#setTrashBucket(ownerAddress, trashFiles)
1375
1333
  this.#saveTrashMetadata()
1376
- this.#appendUserSyncOpSoon(ownerAddress, 'file:delete', {
1377
- cid: fileRecord.cid,
1378
- syncUpdatedAt: getNextSyncTimestamp(fileRecord.syncUpdatedAt),
1379
- })
1380
1334
 
1381
1335
  if (!this.#hasAnyUserReference(fileRecord.cid)) {
1382
1336
  try {
@@ -1404,10 +1358,6 @@ export class MostBoxEngine extends EventEmitter {
1404
1358
  this.#setTrashBucket(ownerAddress, [])
1405
1359
  this.#saveTrashMetadata()
1406
1360
  for (const fileRecord of removedTrash) {
1407
- this.#appendUserSyncOpSoon(ownerAddress, 'file:delete', {
1408
- cid: fileRecord.cid,
1409
- syncUpdatedAt: getNextSyncTimestamp(fileRecord.syncUpdatedAt),
1410
- })
1411
1361
  if (this.#hasAnyUserReference(fileRecord.cid)) continue
1412
1362
  const driveName =
1413
1363
  fileRecord.driveName || this.#getCidInfo(fileRecord.cid).driveName
@@ -1515,9 +1465,6 @@ export class MostBoxEngine extends EventEmitter {
1515
1465
  files[index].syncUpdatedAt = getNextSyncTimestamp(files[index].syncUpdatedAt)
1516
1466
  files[index].publishedAt = new Date(files[index].syncUpdatedAt).toISOString()
1517
1467
  this.#savePublishedMetadata()
1518
- this.#appendUserSyncOpSoon(ownerAddress, 'file:upsert', {
1519
- file: this.#formatFileForSync(files[index], 'active'),
1520
- })
1521
1468
  return {
1522
1469
  cid,
1523
1470
  fileName: safeFileName,
@@ -1569,11 +1516,6 @@ export class MostBoxEngine extends EventEmitter {
1569
1516
 
1570
1517
  if (updatedFiles.length > 0) {
1571
1518
  this.#savePublishedMetadata()
1572
- for (const { file } of updates) {
1573
- this.#appendUserSyncOpSoon(ownerAddress, 'file:upsert', {
1574
- file: this.#formatFileForSync(file, 'active'),
1575
- })
1576
- }
1577
1519
  }
1578
1520
 
1579
1521
  return { files: updatedFiles }
@@ -1788,8 +1730,6 @@ export class MostBoxEngine extends EventEmitter {
1788
1730
  left.pipe(right).pipe(left)
1789
1731
  this.#handleChannelConnection(leftChat).catch(() => {})
1790
1732
  peerEngine.#handleChannelConnection(rightChat).catch(() => {})
1791
- this.#exchangeUserSyncSessions(peerEngine).catch(() => {})
1792
- peerEngine.#exchangeUserSyncSessions(this).catch(() => {})
1793
1733
 
1794
1734
  return {
1795
1735
  close: () => {
@@ -1801,96 +1741,13 @@ export class MostBoxEngine extends EventEmitter {
1801
1741
  }
1802
1742
  }
1803
1743
 
1804
- /**
1805
- * 启动当前账号的隐藏 user.sync 元数据同步。
1806
- */
1807
- async startUserSync(ownerAddressInput, input = {}) {
1808
- this.#ensureInitialized()
1809
- const ownerAddress = normalizeOwnerAddress(ownerAddressInput)
1810
- if (!ownerAddress) {
1811
- throw new ValidationError('valid owner address is required')
1812
- }
1813
-
1814
- const syncTopicKey = normalizeUserSyncKey(input.syncTopicKey)
1815
- const syncCipherKey = normalizeUserSyncKey(input.syncCipherKey)
1816
- const syncMacKey = normalizeUserSyncKey(input.syncMacKey)
1817
- if (!syncTopicKey || !syncCipherKey || !syncMacKey) {
1818
- throw new ValidationError('valid user sync keys are required')
1819
- }
1820
-
1821
- const syncId = deriveUserSyncId(syncTopicKey)
1822
- const syncName = getUserSyncName(syncId)
1823
- const persisted = this.#userSyncMetadata.sessions?.[ownerAddress]
1824
- const session = {
1825
- ownerAddress,
1826
- syncId,
1827
- syncName,
1828
- syncTopicKey,
1829
- syncCipherKey,
1830
- syncMacKey,
1831
- writerId:
1832
- persisted?.syncId === syncId && persisted?.writerId
1833
- ? persisted.writerId
1834
- : createChannelWriterId(),
1835
- localWriterCoreKey:
1836
- persisted?.syncId === syncId ? persisted.localWriterCoreKey || '' : '',
1837
- writerCoreKeys:
1838
- persisted?.syncId === syncId
1839
- ? uniqueStrings(persisted.writerCoreKeys)
1840
- : [],
1841
- startedAt: new Date().toISOString(),
1842
- }
1843
-
1844
- this.#userSyncSessions.set(ownerAddress, session)
1845
- await this.#openUserSyncRuntime(session)
1846
- await this.#joinUserSyncDiscovery(session)
1847
- this.#persistUserSyncSession(session)
1848
- await this.#appendUserSyncSnapshot(ownerAddress, 'start')
1849
-
1850
- return this.getUserSyncStatus(ownerAddress)
1851
- }
1852
-
1853
- getUserSyncStatus(ownerAddressInput) {
1854
- this.#ensureInitialized()
1855
- const ownerAddress = normalizeOwnerAddress(ownerAddressInput)
1856
- if (!ownerAddress) {
1857
- throw new ValidationError('valid owner address is required')
1858
- }
1859
- const session = this.#userSyncSessions.get(ownerAddress)
1860
- if (!session) {
1861
- const persisted = this.#userSyncMetadata.sessions?.[ownerAddress]
1862
- return {
1863
- enabled: false,
1864
- ownerAddress,
1865
- syncName: persisted?.syncName || '',
1866
- syncId: persisted?.syncId || '',
1867
- peerCount: 0,
1868
- writerCoreKeys: uniqueStrings(persisted?.writerCoreKeys),
1869
- localWriterCoreKey: persisted?.localWriterCoreKey || '',
1870
- lastSyncedAt: persisted?.lastSyncedAt || '',
1871
- }
1872
- }
1873
-
1874
- return {
1875
- enabled: true,
1876
- ownerAddress,
1877
- syncName: session.syncName,
1878
- syncId: session.syncId,
1879
- peerCount: this.#chatSwarm?.connections?.size || 0,
1880
- writerCoreKeys: uniqueStrings(session.writerCoreKeys),
1881
- localWriterCoreKey: session.localWriterCoreKey,
1882
- lastSyncedAt:
1883
- this.#userSyncMetadata.sessions?.[ownerAddress]?.lastSyncedAt || '',
1884
- }
1885
- }
1886
-
1887
1744
  getUserProfile(ownerAddressInput) {
1888
1745
  this.#ensureInitialized()
1889
1746
  const ownerAddress = normalizeOwnerAddress(ownerAddressInput)
1890
1747
  if (!ownerAddress) {
1891
1748
  throw new ValidationError('valid owner address is required')
1892
1749
  }
1893
- const profile = this.#userSyncMetadata.profiles?.[ownerAddress]
1750
+ const profile = this.#accountMetadata.profiles?.[ownerAddress]
1894
1751
  return profile ? { ...profile } : null
1895
1752
  }
1896
1753
 
@@ -1901,22 +1758,21 @@ export class MostBoxEngine extends EventEmitter {
1901
1758
  throw new ValidationError('valid owner address is required')
1902
1759
  }
1903
1760
  const existing = this.getUserProfile(ownerAddress)
1904
- const profile = this.#normalizeUserProfileRecord(
1761
+ const profile = this.#normalizeAccountProfileRecord(
1905
1762
  ownerAddress,
1906
1763
  profileInput,
1907
- getNextSyncTimestamp(existing?.syncUpdatedAt)
1764
+ getNextSyncTimestamp(existing?.updatedAt)
1908
1765
  )
1909
1766
  if (!profile) {
1910
1767
  throw new ValidationError('valid profile is required')
1911
1768
  }
1912
- if (existing && profile.syncUpdatedAt <= existing.syncUpdatedAt) {
1769
+ if (existing && profile.updatedAt <= existing.updatedAt) {
1913
1770
  return { ...existing }
1914
1771
  }
1915
1772
 
1916
- this.#userSyncMetadata.profiles = this.#userSyncMetadata.profiles || {}
1917
- this.#userSyncMetadata.profiles[ownerAddress] = profile
1918
- this.#setUserSyncClock(ownerAddress, 'profile', profile.syncUpdatedAt)
1919
- this.#appendUserSyncOpSoon(ownerAddress, 'profile:upsert', { profile })
1773
+ this.#accountMetadata.profiles = this.#accountMetadata.profiles || {}
1774
+ this.#accountMetadata.profiles[ownerAddress] = profile
1775
+ this.#saveAccountMetadata()
1920
1776
 
1921
1777
  const changedChannels = this.#applyUserProfileToJoinedChannels(
1922
1778
  ownerAddress,
@@ -1936,6 +1792,134 @@ export class MostBoxEngine extends EventEmitter {
1936
1792
  return { ...profile }
1937
1793
  }
1938
1794
 
1795
+ exportUserData(ownerAddressInput) {
1796
+ this.#ensureInitialized()
1797
+ const ownerAddress = normalizeOwnerAddress(ownerAddressInput)
1798
+ if (!ownerAddress) {
1799
+ throw new ValidationError('valid owner address is required')
1800
+ }
1801
+
1802
+ const profile = this.getUserProfile(ownerAddress)
1803
+ const files = this.#getPublishedBucket(ownerAddress)
1804
+ .map(file => this.#formatAccountFileForBackup(file, 'active'))
1805
+ .filter(Boolean)
1806
+ const trashFiles = this.#getTrashBucket(ownerAddress)
1807
+ .map(file => this.#formatAccountFileForBackup(file, 'trash'))
1808
+ .filter(Boolean)
1809
+ const channels = this.#channels
1810
+ .filter(channel => this.#channelHasMember(channel, ownerAddress))
1811
+ .map(channel => this.#formatAccountChannelForBackup(channel, ownerAddress))
1812
+ .filter(Boolean)
1813
+
1814
+ return {
1815
+ type: 'mostbox.account-backup',
1816
+ schemaVersion: 1,
1817
+ ownerAddress,
1818
+ exportedAt: new Date().toISOString(),
1819
+ profile,
1820
+ files,
1821
+ trashFiles,
1822
+ channels,
1823
+ }
1824
+ }
1825
+
1826
+ async importUserData(ownerAddressInput, backupInput = {}, options = {}) {
1827
+ this.#ensureInitialized()
1828
+ const ownerAddress = normalizeOwnerAddress(ownerAddressInput)
1829
+ if (!ownerAddress) {
1830
+ throw new ValidationError('valid owner address is required')
1831
+ }
1832
+ if (
1833
+ !backupInput ||
1834
+ typeof backupInput !== 'object' ||
1835
+ backupInput.type !== 'mostbox.account-backup' ||
1836
+ Number(backupInput.schemaVersion) !== 1
1837
+ ) {
1838
+ throw new ValidationError('invalid account backup data')
1839
+ }
1840
+ if (normalizeOwnerAddress(backupInput.ownerAddress) !== ownerAddress) {
1841
+ throw new PermissionError('backup owner does not match current user')
1842
+ }
1843
+
1844
+ const result = {
1845
+ profileUpdated: false,
1846
+ filesAdded: 0,
1847
+ filesUpdated: 0,
1848
+ trashFilesAdded: 0,
1849
+ trashFilesUpdated: 0,
1850
+ channelsAdded: 0,
1851
+ channelsUpdated: 0,
1852
+ skipped: 0,
1853
+ }
1854
+
1855
+ const profileResult = this.#mergeAccountProfileRecord(
1856
+ ownerAddress,
1857
+ backupInput.profile,
1858
+ {
1859
+ overwrite: options.overwriteProfile === true,
1860
+ }
1861
+ )
1862
+ result.profileUpdated = profileResult.changed
1863
+ if (profileResult.skipped) result.skipped += 1
1864
+
1865
+ let filesChanged = false
1866
+ for (const file of Array.isArray(backupInput.files) ? backupInput.files : []) {
1867
+ const mergeResult = this.#mergeAccountFileRecord(ownerAddress, file, 'active')
1868
+ if (mergeResult.added) result.filesAdded += 1
1869
+ else if (mergeResult.updated) result.filesUpdated += 1
1870
+ else result.skipped += 1
1871
+ filesChanged = mergeResult.changed || filesChanged
1872
+ }
1873
+
1874
+ for (const file of Array.isArray(backupInput.trashFiles)
1875
+ ? backupInput.trashFiles
1876
+ : []) {
1877
+ const mergeResult = this.#mergeAccountFileRecord(ownerAddress, file, 'trash')
1878
+ if (mergeResult.added) result.trashFilesAdded += 1
1879
+ else if (mergeResult.updated) result.trashFilesUpdated += 1
1880
+ else result.skipped += 1
1881
+ filesChanged = mergeResult.changed || filesChanged
1882
+ }
1883
+
1884
+ let channelsChanged = false
1885
+ for (const channel of Array.isArray(backupInput.channels)
1886
+ ? backupInput.channels
1887
+ : []) {
1888
+ const mergeResult = await this.#mergeAccountChannelRecord(
1889
+ ownerAddress,
1890
+ channel
1891
+ )
1892
+ if (mergeResult.added) result.channelsAdded += 1
1893
+ else if (mergeResult.updated) result.channelsUpdated += 1
1894
+ else result.skipped += 1
1895
+ channelsChanged = mergeResult.changed || channelsChanged
1896
+ }
1897
+
1898
+ if (filesChanged) {
1899
+ this.#savePublishedMetadata()
1900
+ this.#saveTrashMetadata()
1901
+ this.emit('user:metadata:updated', {
1902
+ ownerAddress,
1903
+ scope: 'files',
1904
+ })
1905
+ }
1906
+ if (channelsChanged) {
1907
+ this.#saveChannelsMetadata()
1908
+ this.emit('user:metadata:updated', {
1909
+ ownerAddress,
1910
+ scope: 'channels',
1911
+ })
1912
+ }
1913
+ if (result.profileUpdated) {
1914
+ this.emit('user:metadata:updated', {
1915
+ ownerAddress,
1916
+ scope: 'profile',
1917
+ })
1918
+ }
1919
+
1920
+ return result
1921
+ }
1922
+
1939
1923
  async cacheFile(cid, options = {}) {
1940
1924
  this.#ensureInitialized()
1941
1925
  const ownerAddress = normalizeOwnerAddress(options.ownerAddress)
@@ -1955,6 +1939,290 @@ export class MostBoxEngine extends EventEmitter {
1955
1939
  return result
1956
1940
  }
1957
1941
 
1942
+ /**
1943
+ * 按 CID 将内容下载到指定路径,不写入用户文件列表。
1944
+ * 用于桌面客户端更新等内部缓存场景。
1945
+ * @param {object} input - 下载参数
1946
+ * @param {string} input.cid - 文件 CID
1947
+ * @param {string} input.targetPath - 目标文件路径
1948
+ * @param {string} [input.fileName] - 持有记录展示名
1949
+ * @param {string} [input.taskId] - 下载任务 ID
1950
+ * @param {number} [input.timeout] - 等待 P2P 内容的超时时间
1951
+ * @param {number} [input.streamReadTimeout] - 下载流无进度超时时间
1952
+ * @param {boolean} [input.overwrite=true] - 是否覆盖已存在目标文件
1953
+ * @param {string} [input.source='update'] - holding 来源
1954
+ */
1955
+ async downloadCidToPath(input = {}) {
1956
+ this.#ensureInitialized()
1957
+
1958
+ const cid = input.cid
1959
+ const targetPath = input.targetPath
1960
+ if (!cid) throw new ValidationError('cid is required')
1961
+ if (!targetPath) throw new ValidationError('targetPath is required')
1962
+
1963
+ this.#getCidInfo(cid)
1964
+ const taskId =
1965
+ input.taskId || `cid_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
1966
+ const taskState = { aborted: false, readStream: null, writeStream: null }
1967
+ const timeout = input.timeout || this.#options.downloadTimeout
1968
+ const streamReadTimeout =
1969
+ input.streamReadTimeout ?? STREAM_READ_TIMEOUT
1970
+ const fileName = sanitizeFilename(input.fileName || path.basename(targetPath))
1971
+ const overwrite = input.overwrite !== false
1972
+ const resolvedTargetPath = path.resolve(targetPath)
1973
+ const targetDir = path.dirname(resolvedTargetPath)
1974
+ const tempPath = `${resolvedTargetPath}.part`
1975
+
1976
+ this.#activeDownloads.set(taskId, taskState)
1977
+
1978
+ try {
1979
+ const writableCheck = await checkDirectoryWritable(targetDir)
1980
+ if (!writableCheck.writable) {
1981
+ throw new PermissionError(writableCheck.error)
1982
+ }
1983
+ if (!overwrite && fs.existsSync(resolvedTargetPath)) {
1984
+ throw new ConflictError(`File already exists: ${resolvedTargetPath}`)
1985
+ }
1986
+ fs.rmSync(tempPath, { force: true })
1987
+
1988
+ const { driveName } = this.#getCidInfo(cid)
1989
+ let drive = this.#drives.get(driveName)
1990
+ if (!drive) {
1991
+ drive = await this.#getOrCreateDrive(driveName, {
1992
+ server: true,
1993
+ client: true,
1994
+ })
1995
+ this.emit('download:status', { taskId, status: 'connecting' })
1996
+ }
1997
+
1998
+ await this.#joinCidTopicInternal(cid, {
1999
+ server: false,
2000
+ client: true,
2001
+ })
2002
+
2003
+ if (taskState.aborted) throw new Error('Download cancelled')
2004
+ this.emit('download:status', { taskId, status: 'finding-peers' })
2005
+
2006
+ const driveKey = '/' + cid
2007
+ const entry = await this.#waitForDriveEntry(
2008
+ drive,
2009
+ driveKey,
2010
+ timeout,
2011
+ taskId,
2012
+ taskState
2013
+ )
2014
+
2015
+ if (!entry) {
2016
+ throw new PeerNotFoundError(
2017
+ '当前没有发现可下载的在线种子,请稍后重试或确认发布者在线'
2018
+ )
2019
+ }
2020
+
2021
+ let totalBytes = 0
2022
+ try {
2023
+ const stat = await drive.entry(entry.key)
2024
+ totalBytes = Number(stat?.value?.blob?.byteLength) || 0
2025
+ } catch {}
2026
+
2027
+ this.emit('download:status', {
2028
+ taskId,
2029
+ status: 'downloading',
2030
+ file: fileName,
2031
+ size: totalBytes ? formatFileSize(totalBytes) : null,
2032
+ })
2033
+
2034
+ const rs = drive.createReadStream(entry.key)
2035
+ const ws = fs.createWriteStream(tempPath)
2036
+ taskState.readStream = rs
2037
+ taskState.writeStream = ws
2038
+
2039
+ let loadedBytes = 0
2040
+ let lastProgressUpdate = 0
2041
+
2042
+ await new Promise((resolve, reject) => {
2043
+ let settled = false
2044
+ let readTimer = null
2045
+
2046
+ const clearReadTimer = () => {
2047
+ if (readTimer) {
2048
+ clearTimeout(readTimer)
2049
+ readTimer = null
2050
+ }
2051
+ }
2052
+
2053
+ const fail = err => {
2054
+ if (settled) return
2055
+ settled = true
2056
+ clearReadTimer()
2057
+ rs.destroy(err)
2058
+ ws.destroy()
2059
+ fs.unlink(tempPath, () => {})
2060
+ reject(err)
2061
+ }
2062
+
2063
+ const complete = () => {
2064
+ if (settled) return
2065
+ settled = true
2066
+ clearReadTimer()
2067
+ resolve()
2068
+ }
2069
+
2070
+ const resetReadTimer = () => {
2071
+ clearReadTimer()
2072
+ if (streamReadTimeout > 0) {
2073
+ readTimer = setTimeout(() => {
2074
+ fail(
2075
+ new Error(
2076
+ `Download stalled: no data received for ${streamReadTimeout / 1000}s`
2077
+ )
2078
+ )
2079
+ }, streamReadTimeout)
2080
+ }
2081
+ }
2082
+
2083
+ resetReadTimer()
2084
+
2085
+ rs.on('data', chunk => {
2086
+ if (taskState.aborted) {
2087
+ fail(new Error('Download cancelled'))
2088
+ return
2089
+ }
2090
+ resetReadTimer()
2091
+ loadedBytes += chunk.length
2092
+ const now = Date.now()
2093
+ if (
2094
+ totalBytes > 0 &&
2095
+ now - lastProgressUpdate > PROGRESS_THROTTLE
2096
+ ) {
2097
+ lastProgressUpdate = now
2098
+ this.emit('download:progress', {
2099
+ taskId,
2100
+ loaded: loadedBytes,
2101
+ total: totalBytes,
2102
+ percent: Math.round((loadedBytes / totalBytes) * 100),
2103
+ })
2104
+ }
2105
+ })
2106
+
2107
+ rs.pipe(ws)
2108
+ ws.on('finish', complete)
2109
+ ws.on('error', fail)
2110
+ rs.on('error', fail)
2111
+ rs.on('close', () => {
2112
+ if (taskState.aborted) fail(new Error('Download cancelled'))
2113
+ })
2114
+ ws.on('close', () => {
2115
+ if (taskState.aborted) fail(new Error('Download cancelled'))
2116
+ })
2117
+ })
2118
+
2119
+ if (taskState.aborted) throw new Error('Download cancelled')
2120
+
2121
+ this.emit('download:status', { taskId, status: 'verifying' })
2122
+ const { cid: downloadedCid, size } = await calculateCid(tempPath)
2123
+ if (downloadedCid.toString() !== cid) {
2124
+ fs.unlinkSync(tempPath)
2125
+ throw new IntegrityError(
2126
+ `File content CID mismatch. Expected ${cid}, got ${downloadedCid.toString()}.`
2127
+ )
2128
+ }
2129
+
2130
+ if (fs.existsSync(resolvedTargetPath)) {
2131
+ fs.rmSync(resolvedTargetPath, { force: true })
2132
+ }
2133
+ fs.renameSync(tempPath, resolvedTargetPath)
2134
+
2135
+ await this.seedCidFileFromPath(cid, resolvedTargetPath, {
2136
+ fileName,
2137
+ source: input.source || 'update',
2138
+ })
2139
+
2140
+ const result = {
2141
+ taskId,
2142
+ cid,
2143
+ fileName,
2144
+ savedPath: resolvedTargetPath,
2145
+ size,
2146
+ }
2147
+ this.emit('download:success', result)
2148
+ return result
2149
+ } finally {
2150
+ this.#activeDownloads.delete(taskId)
2151
+ fs.rmSync(tempPath, { force: true })
2152
+ }
2153
+ }
2154
+
2155
+ /**
2156
+ * 将本地文件按既有 CID 写入 Hyperdrive 并记录 holding,不写入用户文件列表。
2157
+ * @param {string} cid - 文件 CID
2158
+ * @param {string} filePath - 本地文件路径
2159
+ * @param {object} [options] - 选项
2160
+ * @param {string} [options.fileName] - 持有记录展示名
2161
+ * @param {string} [options.source='update'] - holding 来源
2162
+ */
2163
+ async seedCidFileFromPath(cid, filePath, options = {}) {
2164
+ this.#ensureInitialized()
2165
+ if (!cid) throw new ValidationError('cid is required')
2166
+ if (!filePath) throw new ValidationError('filePath is required')
2167
+
2168
+ this.#getCidInfo(cid)
2169
+ const sizeValidation = await validateFileSize(
2170
+ filePath,
2171
+ this.#options.maxFileSize
2172
+ )
2173
+ if (!sizeValidation.valid) {
2174
+ throw new FileSizeError(sizeValidation.error, sizeValidation.size)
2175
+ }
2176
+
2177
+ const { cid: actualCid } = await calculateCid(filePath)
2178
+ const actualCidString = actualCid.toString()
2179
+ if (actualCidString !== cid) {
2180
+ throw new IntegrityError(
2181
+ `File content CID mismatch. Expected ${cid}, got ${actualCidString}.`
2182
+ )
2183
+ }
2184
+
2185
+ const existingHolding = this.#holdings.find(holding => holding.cid === cid)
2186
+ if (!existingHolding) {
2187
+ this.#checkCapacity(sizeValidation.size || 0)
2188
+ }
2189
+
2190
+ const { driveName } = this.#getCidInfo(cid)
2191
+ const drive = await this.#getOrCreateDrive(driveName, {
2192
+ server: true,
2193
+ client: false,
2194
+ })
2195
+ const driveKey = '/' + cid
2196
+ const localContent = await this.#getLocalCidContent(cid, {
2197
+ public: true,
2198
+ allowHoldingFallback: true,
2199
+ })
2200
+
2201
+ if (!localContent) {
2202
+ const readStream = fs.createReadStream(filePath)
2203
+ const writeStream = drive.createWriteStream(driveKey)
2204
+ await new Promise((resolve, reject) => {
2205
+ readStream.pipe(writeStream)
2206
+ writeStream.on('finish', resolve)
2207
+ writeStream.on('error', reject)
2208
+ readStream.on('error', reject)
2209
+ })
2210
+ }
2211
+
2212
+ await this.#joinCidTopicInternal(cid, {
2213
+ server: true,
2214
+ client: false,
2215
+ })
2216
+
2217
+ return this.#upsertHolding({
2218
+ cid,
2219
+ fileName: sanitizeFilename(options.fileName || path.basename(filePath)),
2220
+ size: sizeValidation.size || 0,
2221
+ driveName,
2222
+ source: options.source || 'update',
2223
+ })
2224
+ }
2225
+
1958
2226
  /**
1959
2227
  * 读取已发布文件的内容(用于预览)
1960
2228
  * Hyperdrive 中用 CID 作为 key 存储
@@ -2202,7 +2470,6 @@ export class MostBoxEngine extends EventEmitter {
2202
2470
  if (writerKeysChanged || memberChanged) {
2203
2471
  existing.syncUpdatedAt = getNextSyncTimestamp(existing.syncUpdatedAt)
2204
2472
  this.#saveChannelsMetadata()
2205
- this.#appendUserSyncChannelUpsertSoon(existing, ownerAddress)
2206
2473
  this.#broadcastChannelHello()
2207
2474
  }
2208
2475
  return this.#formatChannelForResponse(existing, ownerAddress)
@@ -2212,8 +2479,6 @@ export class MostBoxEngine extends EventEmitter {
2212
2479
  channelType,
2213
2480
  options
2214
2481
  )
2215
- const joinedChannel = this.#resolveChannel(joined.channelKey, ownerAddress)
2216
- this.#appendUserSyncChannelUpsertSoon(joinedChannel, ownerAddress)
2217
2482
  return joined
2218
2483
  }
2219
2484
  const joined = await this.#joinChannelFromCandidate(
@@ -2221,8 +2486,6 @@ export class MostBoxEngine extends EventEmitter {
2221
2486
  channelType,
2222
2487
  options
2223
2488
  )
2224
- const joinedChannel = this.#resolveChannel(joined.channelKey, ownerAddress)
2225
- this.#appendUserSyncChannelUpsertSoon(joinedChannel, ownerAddress)
2226
2489
  return joined
2227
2490
  }
2228
2491
 
@@ -2238,7 +2501,6 @@ export class MostBoxEngine extends EventEmitter {
2238
2501
  channelId: channelInfo.channelId,
2239
2502
  key: channelInfo.channelKey,
2240
2503
  })
2241
- this.#appendUserSyncChannelUpsertSoon(channelInfo, ownerAddress)
2242
2504
 
2243
2505
  return this.#formatChannelForResponse(channelInfo, ownerAddress)
2244
2506
  }
@@ -2274,7 +2536,6 @@ export class MostBoxEngine extends EventEmitter {
2274
2536
  if (writerKeysChanged || memberChanged) {
2275
2537
  existing.syncUpdatedAt = getNextSyncTimestamp(existing.syncUpdatedAt)
2276
2538
  this.#saveChannelsMetadata()
2277
- this.#appendUserSyncChannelUpsertSoon(existing, options.ownerAddress)
2278
2539
  this.#broadcastChannelHello()
2279
2540
  }
2280
2541
  return this.#formatChannelForResponse(existing, options.ownerAddress)
@@ -2289,11 +2550,6 @@ export class MostBoxEngine extends EventEmitter {
2289
2550
  ...options,
2290
2551
  channelKey,
2291
2552
  })
2292
- const joinedChannel = this.#resolveChannel(
2293
- joined.channelKey,
2294
- options.ownerAddress
2295
- )
2296
- this.#appendUserSyncChannelUpsertSoon(joinedChannel, options.ownerAddress)
2297
2553
  return joined
2298
2554
  }
2299
2555
 
@@ -2318,7 +2574,6 @@ export class MostBoxEngine extends EventEmitter {
2318
2574
  )
2319
2575
  const syncUpdatedAt = getNextSyncTimestamp(channel.syncUpdatedAt)
2320
2576
  channel.syncUpdatedAt = syncUpdatedAt
2321
- this.#appendUserSyncChannelLeaveSoon(channel, ownerAddress, syncUpdatedAt)
2322
2577
  if (channel.members.length > 0) {
2323
2578
  this.#saveChannelsMetadata()
2324
2579
  return this.listChannels({ ownerAddress })
@@ -2426,7 +2681,6 @@ export class MostBoxEngine extends EventEmitter {
2426
2681
 
2427
2682
  channel.syncUpdatedAt = getNextSyncTimestamp(channel.syncUpdatedAt)
2428
2683
  this.#saveChannelsMetadata()
2429
- this.#appendUserSyncChannelUpsertSoon(channel, ownerAddress)
2430
2684
  return trimmed
2431
2685
  }
2432
2686
 
@@ -2452,7 +2706,6 @@ export class MostBoxEngine extends EventEmitter {
2452
2706
 
2453
2707
  channel.syncUpdatedAt = getNextSyncTimestamp(channel.syncUpdatedAt)
2454
2708
  this.#saveChannelsMetadata()
2455
- this.#appendUserSyncChannelUpsertSoon(channel, ownerAddress)
2456
2709
  return Boolean(channel.pinnedBy[ownerAddress])
2457
2710
  }
2458
2711
 
@@ -3210,403 +3463,12 @@ export class MostBoxEngine extends EventEmitter {
3210
3463
  }
3211
3464
  }
3212
3465
 
3213
- #getUserSyncNamespace(session) {
3214
- return this.#store.namespace(session.syncName)
3215
- }
3216
-
3217
- async #openUserSyncRuntime(session) {
3218
- const ns = this.#getUserSyncNamespace(session)
3219
- const localCore = session.localWriterCoreKey
3220
- ? ns.get({
3221
- key: b4a.from(session.localWriterCoreKey, 'hex'),
3222
- valueEncoding: 'json',
3223
- })
3224
- : ns.get({
3225
- name: `writer-${session.writerId}`,
3226
- valueEncoding: 'json',
3227
- })
3228
- await localCore.ready()
3229
- session.localWriterCoreKey = b4a.toString(localCore.key, 'hex')
3230
- session.writerCoreKeys = uniqueStrings([
3231
- ...session.writerCoreKeys,
3232
- session.localWriterCoreKey,
3233
- ])
3234
-
3235
- if (!this.#userSyncCores.has(session.ownerAddress)) {
3236
- this.#userSyncCores.set(session.ownerAddress, new Map())
3237
- }
3238
- this.#userSyncCores
3239
- .get(session.ownerAddress)
3240
- .set(session.localWriterCoreKey, localCore)
3241
- this.#setupUserSyncAppendListener(
3242
- localCore,
3243
- session,
3244
- session.localWriterCoreKey
3245
- )
3246
-
3247
- for (const writerCoreKey of session.writerCoreKeys) {
3248
- if (writerCoreKey && writerCoreKey !== session.localWriterCoreKey) {
3249
- await this.#openRemoteUserSyncCore(session, writerCoreKey)
3250
- }
3251
- }
3252
- }
3253
-
3254
- async #joinUserSyncDiscovery(session) {
3255
- if (this.#userSyncDiscoveries.has(session.ownerAddress)) return
3256
- const discoveryKey = this.#generateUserSyncDiscoveryKey(session.syncId)
3257
- const appDiscovery = this.#swarm.join(discoveryKey, {
3258
- server: true,
3259
- client: true,
3260
- })
3261
- const chatDiscovery = this.#chatSwarm.join(
3262
- discoveryKey,
3263
- { server: true, client: true }
3264
- )
3265
- this.#userSyncDiscoveries.set(session.ownerAddress, {
3266
- appDiscovery,
3267
- chatDiscovery,
3268
- })
3269
- }
3270
-
3271
- async #openRemoteUserSyncCore(session, writerCoreKey) {
3272
- const normalizedCoreKey = String(writerCoreKey || '').trim()
3273
- if (
3274
- !normalizedCoreKey ||
3275
- normalizedCoreKey === session.localWriterCoreKey
3276
- ) {
3277
- return null
3278
- }
3279
- if (!this.#userSyncCores.has(session.ownerAddress)) {
3280
- this.#userSyncCores.set(session.ownerAddress, new Map())
3281
- }
3282
- const coresMap = this.#userSyncCores.get(session.ownerAddress)
3283
- if (coresMap.has(normalizedCoreKey)) return coresMap.get(normalizedCoreKey)
3284
-
3285
- const ns = this.#getUserSyncNamespace(session)
3286
- const core = ns.get({
3287
- key: b4a.from(normalizedCoreKey, 'hex'),
3288
- valueEncoding: 'json',
3289
- })
3290
- await core.ready()
3291
- coresMap.set(normalizedCoreKey, core)
3292
- session.writerCoreKeys = uniqueStrings([
3293
- ...session.writerCoreKeys,
3294
- normalizedCoreKey,
3295
- ])
3296
- this.#persistUserSyncSession(session)
3297
- this.#setupUserSyncAppendListener(core, session, normalizedCoreKey)
3298
- return core
3299
- }
3300
-
3301
- #setupUserSyncAppendListener(core, session, coreKey) {
3302
- const offsetKey = `${session.ownerAddress}:${coreKey}`
3303
- let processing = false
3304
- const processEntries = async () => {
3305
- if (processing) return
3306
- processing = true
3307
- try {
3308
- let index = this.#userSyncCoreOffsets.get(offsetKey) || 0
3309
- while (index < core.length) {
3310
- const entry = await core.get(index)
3311
- await this.#applyUserSyncEntry(session, entry)
3312
- index += 1
3313
- this.#userSyncCoreOffsets.set(offsetKey, index)
3314
- }
3315
- } finally {
3316
- processing = false
3317
- }
3318
- }
3319
-
3320
- core.on('append', () => {
3321
- processEntries().catch(err => {
3322
- console.warn('[MostBox] Failed to process user sync entry:', err.message)
3323
- })
3324
- })
3325
- processEntries().catch(err => {
3326
- console.warn('[MostBox] Failed to process user sync entries:', err.message)
3327
- })
3328
- }
3329
-
3330
- #encodeUserSyncEntry(session, op) {
3331
- const nonce = crypto.randomBytes(12)
3332
- const cipher = crypto.createCipheriv(
3333
- 'aes-256-gcm',
3334
- Buffer.from(session.syncCipherKey, 'hex'),
3335
- nonce
3336
- )
3337
- const encrypted = Buffer.concat([
3338
- cipher.update(JSON.stringify(op), 'utf8'),
3339
- cipher.final(),
3340
- ])
3341
- const tag = cipher.getAuthTag()
3342
- const body = `${nonce.toString('hex')}.${encrypted.toString('hex')}.${tag.toString('hex')}`
3343
- const mac = crypto
3344
- .createHmac('sha256', Buffer.from(session.syncMacKey, 'hex'))
3345
- .update(body)
3346
- .digest('hex')
3347
-
3348
- return {
3349
- type: 'user-sync-op',
3350
- schemaVersion: USER_SYNC_SCHEMA_VERSION,
3351
- ownerAddress: session.ownerAddress,
3352
- syncId: session.syncId,
3353
- writerCoreKeys: uniqueStrings(session.writerCoreKeys),
3354
- body,
3355
- mac,
3356
- createdAt: new Date(op.timestamp).toISOString(),
3357
- }
3358
- }
3359
-
3360
- #decodeUserSyncEntry(session, entry) {
3361
- if (
3362
- !entry ||
3363
- entry.type !== 'user-sync-op' ||
3364
- entry.syncId !== session.syncId ||
3365
- entry.ownerAddress !== session.ownerAddress ||
3366
- Number(entry.schemaVersion) !== USER_SYNC_SCHEMA_VERSION
3367
- ) {
3368
- return null
3369
- }
3370
-
3371
- const expectedMac = crypto
3372
- .createHmac('sha256', Buffer.from(session.syncMacKey, 'hex'))
3373
- .update(String(entry.body || ''))
3374
- .digest('hex')
3375
- if (expectedMac !== entry.mac) return null
3376
-
3377
- const [nonceHex, encryptedHex, tagHex] = String(entry.body || '').split('.')
3378
- if (!nonceHex || !encryptedHex || !tagHex) return null
3379
-
3380
- try {
3381
- const decipher = crypto.createDecipheriv(
3382
- 'aes-256-gcm',
3383
- Buffer.from(session.syncCipherKey, 'hex'),
3384
- Buffer.from(nonceHex, 'hex')
3385
- )
3386
- decipher.setAuthTag(Buffer.from(tagHex, 'hex'))
3387
- const decrypted = Buffer.concat([
3388
- decipher.update(Buffer.from(encryptedHex, 'hex')),
3389
- decipher.final(),
3390
- ])
3391
- return JSON.parse(decrypted.toString('utf8'))
3392
- } catch {
3393
- return null
3394
- }
3395
- }
3396
-
3397
- async #appendUserSyncOp(ownerAddressInput, kind, payload = {}) {
3398
- const ownerAddress = normalizeOwnerAddress(ownerAddressInput)
3399
- const session = this.#userSyncSessions.get(ownerAddress)
3400
- if (!session?.localWriterCoreKey) return null
3401
- const coresMap = this.#userSyncCores.get(ownerAddress)
3402
- const core = coresMap?.get(session.localWriterCoreKey)
3403
- if (!core) return null
3404
-
3405
- const op = {
3406
- opId: `${Date.now()}-${crypto.randomBytes(6).toString('hex')}`,
3407
- schemaVersion: USER_SYNC_SCHEMA_VERSION,
3408
- kind,
3409
- ownerAddress,
3410
- timestamp: Date.now(),
3411
- payload,
3412
- }
3413
- this.#markUserSyncClockForOp(op)
3414
- await core.append(this.#encodeUserSyncEntry(session, op))
3415
- this.#touchUserSyncSession(session)
3416
- return op
3417
- }
3418
-
3419
- #appendUserSyncOpSoon(ownerAddress, kind, payload = {}) {
3420
- this.#appendUserSyncOp(ownerAddress, kind, payload).catch(err => {
3421
- console.warn('[MostBox] Failed to append user sync op:', err.message)
3422
- })
3423
- }
3424
-
3425
- async #appendUserSyncSnapshot(ownerAddressInput, reason = 'snapshot') {
3426
- const ownerAddress = normalizeOwnerAddress(ownerAddressInput)
3427
- if (!this.#userSyncSessions.has(ownerAddress)) return null
3428
- const files = this.#getPublishedBucket(ownerAddress).map(file =>
3429
- this.#formatFileForSync(file, 'active')
3430
- )
3431
- const trashFiles = this.#getTrashBucket(ownerAddress).map(file =>
3432
- this.#formatFileForSync(file, 'trash')
3433
- )
3434
- const channels = this.#channels
3435
- .filter(channel => this.#channelHasMember(channel, ownerAddress))
3436
- .map(channel => this.#formatChannelForSync(channel, ownerAddress))
3437
- .filter(Boolean)
3438
- const profile = this.#formatUserProfileForSync(ownerAddress)
3439
- return this.#appendUserSyncOp(ownerAddress, 'snapshot', {
3440
- reason,
3441
- profile,
3442
- files,
3443
- trashFiles,
3444
- channels,
3445
- })
3446
- }
3447
-
3448
- async #applyUserSyncEntry(session, entry) {
3449
- const op = this.#decodeUserSyncEntry(session, entry)
3450
- if (!op || op.ownerAddress !== session.ownerAddress) return false
3451
- if (Array.isArray(entry.writerCoreKeys)) {
3452
- await this.#mergeUserSyncWriterCoreKeys(session, entry.writerCoreKeys)
3453
- }
3454
- return this.#applyUserSyncOp(session, op)
3455
- }
3456
-
3457
- async #applyUserSyncOp(session, op) {
3458
- let changedFiles = false
3459
- let changedChannels = false
3460
- let changedProfile = false
3461
- if (op.kind === 'snapshot') {
3462
- const payload = op.payload || {}
3463
- const profileResult = this.#applyUserSyncProfileRecord(
3464
- session.ownerAddress,
3465
- payload.profile,
3466
- getSyncTimestamp(payload.profile?.syncUpdatedAt, op.timestamp)
3467
- )
3468
- changedProfile = profileResult.changedProfile || changedProfile
3469
- changedChannels = profileResult.changedChannels || changedChannels
3470
- for (const file of Array.isArray(payload.files) ? payload.files : []) {
3471
- changedFiles =
3472
- this.#applyUserSyncFileRecord(
3473
- session.ownerAddress,
3474
- file,
3475
- 'active',
3476
- getSyncTimestamp(file.syncUpdatedAt, op.timestamp)
3477
- ) || changedFiles
3478
- }
3479
- for (const file of Array.isArray(payload.trashFiles) ? payload.trashFiles : []) {
3480
- changedFiles =
3481
- this.#applyUserSyncFileRecord(
3482
- session.ownerAddress,
3483
- file,
3484
- 'trash',
3485
- getSyncTimestamp(file.syncUpdatedAt, op.timestamp)
3486
- ) || changedFiles
3487
- }
3488
- for (const channel of Array.isArray(payload.channels) ? payload.channels : []) {
3489
- changedChannels =
3490
- (await this.#applyUserSyncChannelRecord(
3491
- session.ownerAddress,
3492
- channel,
3493
- getSyncTimestamp(channel.syncUpdatedAt, op.timestamp)
3494
- )) || changedChannels
3495
- }
3496
- } else if (op.kind === 'file:upsert') {
3497
- changedFiles = this.#applyUserSyncFileRecord(
3498
- session.ownerAddress,
3499
- op.payload?.file,
3500
- 'active',
3501
- getSyncTimestamp(op.payload?.file?.syncUpdatedAt, op.timestamp)
3502
- )
3503
- } else if (op.kind === 'file:trash') {
3504
- changedFiles = this.#applyUserSyncFileRecord(
3505
- session.ownerAddress,
3506
- op.payload?.file,
3507
- 'trash',
3508
- getSyncTimestamp(op.payload?.file?.syncUpdatedAt, op.timestamp)
3509
- )
3510
- } else if (op.kind === 'file:delete') {
3511
- changedFiles = await this.#applyUserSyncFileDelete(
3512
- session.ownerAddress,
3513
- op.payload?.cid,
3514
- getSyncTimestamp(op.payload?.syncUpdatedAt, op.timestamp)
3515
- )
3516
- } else if (op.kind === 'channel:upsert') {
3517
- changedChannels = await this.#applyUserSyncChannelRecord(
3518
- session.ownerAddress,
3519
- op.payload?.channel,
3520
- getSyncTimestamp(op.payload?.channel?.syncUpdatedAt, op.timestamp)
3521
- )
3522
- } else if (op.kind === 'channel:leave') {
3523
- changedChannels = this.#applyUserSyncChannelLeave(
3524
- session.ownerAddress,
3525
- op.payload?.channelKey,
3526
- getSyncTimestamp(op.payload?.syncUpdatedAt, op.timestamp)
3527
- )
3528
- } else if (op.kind === 'profile:upsert') {
3529
- const profileResult = this.#applyUserSyncProfileRecord(
3530
- session.ownerAddress,
3531
- op.payload?.profile,
3532
- getSyncTimestamp(op.payload?.profile?.syncUpdatedAt, op.timestamp)
3533
- )
3534
- changedProfile = profileResult.changedProfile
3535
- changedChannels = changedChannels || profileResult.changedChannels
3536
- }
3537
-
3538
- if (changedProfile) {
3539
- this.emit('user:metadata:updated', {
3540
- ownerAddress: session.ownerAddress,
3541
- scope: 'profile',
3542
- })
3543
- }
3544
- if (changedFiles) {
3545
- this.#savePublishedMetadata()
3546
- this.#saveTrashMetadata()
3547
- this.emit('user:metadata:updated', {
3548
- ownerAddress: session.ownerAddress,
3549
- scope: 'files',
3550
- })
3551
- }
3552
- if (changedChannels) {
3553
- this.#saveChannelsMetadata()
3554
- this.emit('user:metadata:updated', {
3555
- ownerAddress: session.ownerAddress,
3556
- scope: 'channels',
3557
- })
3558
- }
3559
- if (changedFiles || changedChannels) {
3560
- this.#touchUserSyncSession(session)
3561
- }
3562
- if (changedProfile) {
3563
- this.#touchUserSyncSession(session)
3564
- }
3565
- return changedFiles || changedChannels || changedProfile
3566
- }
3567
-
3568
- async #mergeUserSyncWriterCoreKeys(session, writerCoreKeys = []) {
3569
- const nextKeys = uniqueStrings(writerCoreKeys)
3570
- if (nextKeys.length === 0) return false
3571
- const previous = new Set(session.writerCoreKeys || [])
3572
- let changed = false
3573
- for (const writerCoreKey of nextKeys) {
3574
- if (!previous.has(writerCoreKey)) {
3575
- previous.add(writerCoreKey)
3576
- changed = true
3577
- }
3578
- if (writerCoreKey !== session.localWriterCoreKey) {
3579
- await this.#openRemoteUserSyncCore(session, writerCoreKey)
3580
- }
3581
- }
3582
- if (changed) {
3583
- session.writerCoreKeys = [...previous]
3584
- this.#persistUserSyncSession(session)
3585
- }
3586
- return changed
3587
- }
3588
-
3589
- async #exchangeUserSyncSessions(peerEngine) {
3590
- for (const session of this.#userSyncSessions.values()) {
3591
- const peerSession = peerEngine.#userSyncSessions.get(session.ownerAddress)
3592
- if (!peerSession || peerSession.syncId !== session.syncId) continue
3593
- await this.#mergeUserSyncWriterCoreKeys(
3594
- session,
3595
- peerSession.writerCoreKeys
3596
- )
3597
- await peerEngine.#mergeUserSyncWriterCoreKeys(
3598
- peerSession,
3599
- session.writerCoreKeys
3600
- )
3601
- }
3602
- }
3603
-
3604
- #formatFileForSync(file, state = 'active') {
3466
+ #formatAccountFileForBackup(file, state = 'active') {
3605
3467
  const cid = String(file?.cid || '').trim()
3606
3468
  if (!cid) return null
3607
3469
  const { driveName } = this.#getCidInfo(cid)
3608
- const syncUpdatedAt = getSyncTimestamp(
3609
- file.syncUpdatedAt || file.deletedAt || file.publishedAt
3470
+ const updatedAt = getSyncTimestamp(
3471
+ file.updatedAt || file.syncUpdatedAt || file.deletedAt || file.publishedAt
3610
3472
  )
3611
3473
  return {
3612
3474
  cid,
@@ -3617,19 +3479,21 @@ export class MostBoxEngine extends EventEmitter {
3617
3479
  publishedAt:
3618
3480
  typeof file.publishedAt === 'string'
3619
3481
  ? file.publishedAt
3620
- : new Date(syncUpdatedAt).toISOString(),
3621
- deletedAt:
3622
- typeof file.deletedAt === 'string'
3623
- ? file.deletedAt
3624
- : state === 'trash'
3625
- ? new Date(syncUpdatedAt).toISOString()
3626
- : '',
3482
+ : new Date(updatedAt).toISOString(),
3483
+ ...(state === 'trash'
3484
+ ? {
3485
+ deletedAt:
3486
+ typeof file.deletedAt === 'string'
3487
+ ? file.deletedAt
3488
+ : new Date(updatedAt).toISOString(),
3489
+ }
3490
+ : {}),
3627
3491
  starred: Boolean(file.starred),
3628
- syncUpdatedAt,
3492
+ updatedAt,
3629
3493
  }
3630
3494
  }
3631
3495
 
3632
- #formatChannelForSync(channel, ownerAddress) {
3496
+ #formatAccountChannelForBackup(channel, ownerAddress) {
3633
3497
  const owner = normalizeOwnerAddress(ownerAddress)
3634
3498
  if (
3635
3499
  !channel ||
@@ -3639,6 +3503,9 @@ export class MostBoxEngine extends EventEmitter {
3639
3503
  ) {
3640
3504
  return null
3641
3505
  }
3506
+ const updatedAt = getSyncTimestamp(
3507
+ channel.updatedAt || channel.syncUpdatedAt || channel.lastMessageAt || channel.createdAt
3508
+ )
3642
3509
  return {
3643
3510
  channelId: channel.channelId,
3644
3511
  channelKey: channel.channelKey,
@@ -3651,93 +3518,54 @@ export class MostBoxEngine extends EventEmitter {
3651
3518
  ),
3652
3519
  remark: channel.remarks?.[owner] || '',
3653
3520
  pinned: Boolean(channel.pinnedBy?.[owner]),
3654
- syncUpdatedAt: getSyncTimestamp(
3655
- channel.syncUpdatedAt || channel.lastMessageAt || channel.createdAt
3656
- ),
3521
+ updatedAt,
3657
3522
  }
3658
3523
  }
3659
3524
 
3660
- #formatUserProfileForSync(ownerAddress) {
3661
- const owner = normalizeOwnerAddress(ownerAddress)
3662
- if (!owner) return null
3663
- const profile = this.#userSyncMetadata.profiles?.[owner]
3664
- return profile ? { ...profile } : null
3665
- }
3666
-
3667
- #normalizeUserProfileRecord(ownerAddress, record, timestamp = Date.now()) {
3525
+ #normalizeAccountProfileRecord(ownerAddress, record, timestamp = Date.now()) {
3668
3526
  const owner = normalizeOwnerAddress(ownerAddress)
3669
3527
  if (!owner || !record || typeof record !== 'object') return null
3670
- const displayName = normalizeChannelDisplayName(record.displayName, owner)
3671
- const avatar = normalizeChannelAvatar(record.avatar)
3672
- const syncUpdatedAt = getSyncTimestamp(record.syncUpdatedAt, timestamp)
3673
- return {
3674
- displayName,
3675
- avatar,
3676
- syncUpdatedAt,
3677
- }
3678
- }
3679
-
3680
- #applyUserSyncProfileRecord(ownerAddress, record, timestamp) {
3681
- const owner = normalizeOwnerAddress(ownerAddress)
3682
- const profile = this.#normalizeUserProfileRecord(owner, record, timestamp)
3683
- if (!profile) {
3684
- return { changedProfile: false, changedChannels: false }
3685
- }
3686
- if (!this.#shouldApplyUserSyncEntity(owner, 'profile', profile.syncUpdatedAt)) {
3687
- return { changedProfile: false, changedChannels: false }
3688
- }
3689
-
3690
- const existing = this.#userSyncMetadata.profiles?.[owner]
3691
- this.#userSyncMetadata.profiles = this.#userSyncMetadata.profiles || {}
3692
- this.#userSyncMetadata.profiles[owner] = profile
3693
- this.#setUserSyncClock(owner, 'profile', profile.syncUpdatedAt)
3694
- const changedChannels = this.#applyUserProfileToJoinedChannels(
3695
- owner,
3696
- profile
3528
+ const updatedAt = getSyncTimestamp(
3529
+ record.updatedAt || record.syncUpdatedAt,
3530
+ timestamp
3697
3531
  )
3698
3532
  return {
3699
- changedProfile:
3700
- !existing ||
3701
- existing.displayName !== profile.displayName ||
3702
- existing.avatar !== profile.avatar ||
3703
- Number(existing.syncUpdatedAt) !== profile.syncUpdatedAt,
3704
- changedChannels,
3533
+ displayName: normalizeChannelDisplayName(record.displayName, owner),
3534
+ avatar: normalizeChannelAvatar(record.avatar),
3535
+ updatedAt,
3705
3536
  }
3706
3537
  }
3707
3538
 
3708
- #applyUserProfileToJoinedChannels(ownerAddress, profile) {
3539
+ #mergeAccountProfileRecord(ownerAddress, record, options = {}) {
3709
3540
  const owner = normalizeOwnerAddress(ownerAddress)
3710
- if (!owner || !profile) return false
3711
- let changed = false
3712
- for (const channel of this.#channels) {
3713
- if (!this.#channelHasMember(channel, owner)) continue
3714
- changed =
3715
- this.#upsertChannelMember(channel, {
3716
- ownerAddress: owner,
3717
- displayName: profile.displayName,
3718
- avatar: profile.avatar,
3719
- }) || changed
3541
+ const profile = this.#normalizeAccountProfileRecord(owner, record)
3542
+ if (!profile) return { changed: false, skipped: true }
3543
+ const existing = this.#accountMetadata.profiles?.[owner]
3544
+ if (
3545
+ existing &&
3546
+ !options.overwrite &&
3547
+ profile.updatedAt <= getSyncTimestamp(existing.updatedAt, 0)
3548
+ ) {
3549
+ return { changed: false, skipped: true }
3550
+ }
3551
+ if (
3552
+ existing &&
3553
+ existing.displayName === profile.displayName &&
3554
+ existing.avatar === profile.avatar &&
3555
+ getSyncTimestamp(existing.updatedAt, 0) === profile.updatedAt
3556
+ ) {
3557
+ return { changed: false, skipped: true }
3720
3558
  }
3721
- return changed
3722
- }
3723
-
3724
- #appendUserSyncChannelUpsertSoon(channel, ownerAddress) {
3725
- const owner = normalizeOwnerAddress(ownerAddress)
3726
- const record = this.#formatChannelForSync(channel, owner)
3727
- if (!record) return
3728
- this.#appendUserSyncOpSoon(owner, 'channel:upsert', { channel: record })
3729
- }
3730
3559
 
3731
- #appendUserSyncChannelLeaveSoon(channel, ownerAddress, syncUpdatedAt = Date.now()) {
3732
- const owner = normalizeOwnerAddress(ownerAddress)
3733
- if (!owner || !channel || TRANSIENT_CHANNEL_TYPES.has(channel.type)) return
3734
- this.#appendUserSyncOpSoon(owner, 'channel:leave', {
3735
- channelKey: channel.channelKey,
3736
- syncUpdatedAt,
3737
- })
3560
+ this.#accountMetadata.profiles = this.#accountMetadata.profiles || {}
3561
+ this.#accountMetadata.profiles[owner] = profile
3562
+ this.#saveAccountMetadata()
3563
+ const changedChannels = this.#applyUserProfileToJoinedChannels(owner, profile)
3564
+ if (changedChannels) this.#saveChannelsMetadata()
3565
+ return { changed: true, skipped: false }
3738
3566
  }
3739
3567
 
3740
- #normalizeSyncFileRecord(record, state, timestamp) {
3568
+ #normalizeAccountFileRecord(record, state) {
3741
3569
  if (!record || typeof record !== 'object') return null
3742
3570
  const cid = String(record.cid || '').trim()
3743
3571
  if (!cid) return null
@@ -3749,7 +3577,9 @@ export class MostBoxEngine extends EventEmitter {
3749
3577
  }
3750
3578
  const fileName = sanitizeFilename(record.fileName || cid)
3751
3579
  if (!fileName || fileName === 'unnamed') return null
3752
- const syncUpdatedAt = getSyncTimestamp(record.syncUpdatedAt, timestamp)
3580
+ const updatedAt = getSyncTimestamp(
3581
+ record.updatedAt || record.syncUpdatedAt || record.deletedAt || record.publishedAt
3582
+ )
3753
3583
  return {
3754
3584
  cid,
3755
3585
  fileName,
@@ -3759,38 +3589,53 @@ export class MostBoxEngine extends EventEmitter {
3759
3589
  publishedAt:
3760
3590
  typeof record.publishedAt === 'string'
3761
3591
  ? record.publishedAt
3762
- : new Date(syncUpdatedAt).toISOString(),
3592
+ : new Date(updatedAt).toISOString(),
3763
3593
  deletedAt:
3764
3594
  typeof record.deletedAt === 'string'
3765
3595
  ? record.deletedAt
3766
3596
  : state === 'trash'
3767
- ? new Date(syncUpdatedAt).toISOString()
3597
+ ? new Date(updatedAt).toISOString()
3768
3598
  : '',
3769
3599
  starred: Boolean(record.starred),
3770
- syncUpdatedAt,
3600
+ updatedAt,
3771
3601
  }
3772
3602
  }
3773
3603
 
3774
- #applyUserSyncFileRecord(ownerAddress, record, state, timestamp) {
3775
- const normalized = this.#normalizeSyncFileRecord(record, state, timestamp)
3776
- if (!normalized) return false
3777
- const entityKey = `file:${normalized.cid}`
3778
- if (!this.#shouldApplyUserSyncEntity(ownerAddress, entityKey, normalized.syncUpdatedAt)) {
3779
- return false
3780
- }
3604
+ #getRecordUpdatedAt(record) {
3605
+ return getSyncTimestamp(
3606
+ record?.updatedAt || record?.syncUpdatedAt || record?.deletedAt || record?.publishedAt,
3607
+ 0
3608
+ )
3609
+ }
3781
3610
 
3782
- const publishedFiles = [...this.#getPublishedBucket(ownerAddress)]
3783
- const trashFiles = [...this.#getTrashBucket(ownerAddress)]
3784
- let changed = false
3611
+ #mergeAccountFileRecord(ownerAddress, record, state) {
3612
+ const owner = normalizeOwnerAddress(ownerAddress)
3613
+ const normalized = this.#normalizeAccountFileRecord(record, state)
3614
+ if (!owner || !normalized) {
3615
+ return { changed: false, added: false, updated: false }
3616
+ }
3785
3617
 
3618
+ const publishedFiles = [...this.#getPublishedBucket(owner)]
3619
+ const trashFiles = [...this.#getTrashBucket(owner)]
3786
3620
  const publishedIndex = publishedFiles.findIndex(
3787
3621
  file => file.cid === normalized.cid
3788
3622
  )
3789
3623
  const trashIndex = trashFiles.findIndex(file => file.cid === normalized.cid)
3624
+ const existingActive = publishedIndex === -1 ? null : publishedFiles[publishedIndex]
3625
+ const existingTrash = trashIndex === -1 ? null : trashFiles[trashIndex]
3626
+ const existingUpdatedAt = Math.max(
3627
+ this.#getRecordUpdatedAt(existingActive),
3628
+ this.#getRecordUpdatedAt(existingTrash)
3629
+ )
3630
+ if (normalized.updatedAt <= existingUpdatedAt) {
3631
+ return { changed: false, added: false, updated: false }
3632
+ }
3633
+
3790
3634
  const localHolding = this.#holdings.find(
3791
3635
  holding => holding.cid === normalized.cid
3792
3636
  )
3793
- const localSource = localHolding?.source || 'synced'
3637
+ const localSource = localHolding?.source || normalized.source
3638
+ const wasKnown = Boolean(existingActive || existingTrash)
3794
3639
 
3795
3640
  if (state === 'active') {
3796
3641
  const nextRecord = {
@@ -3801,22 +3646,11 @@ export class MostBoxEngine extends EventEmitter {
3801
3646
  source: localSource,
3802
3647
  publishedAt: normalized.publishedAt,
3803
3648
  starred: normalized.starred,
3804
- syncUpdatedAt: normalized.syncUpdatedAt,
3805
- }
3806
- if (publishedIndex === -1) {
3807
- publishedFiles.push(nextRecord)
3808
- changed = true
3809
- } else if (
3810
- JSON.stringify(publishedFiles[publishedIndex]) !==
3811
- JSON.stringify(nextRecord)
3812
- ) {
3813
- publishedFiles[publishedIndex] = nextRecord
3814
- changed = true
3815
- }
3816
- if (trashIndex !== -1) {
3817
- trashFiles.splice(trashIndex, 1)
3818
- changed = true
3649
+ syncUpdatedAt: normalized.updatedAt,
3819
3650
  }
3651
+ if (publishedIndex === -1) publishedFiles.push(nextRecord)
3652
+ else publishedFiles[publishedIndex] = nextRecord
3653
+ if (trashIndex !== -1) trashFiles.splice(trashIndex, 1)
3820
3654
  } else {
3821
3655
  const nextRecord = {
3822
3656
  fileName: normalized.fileName,
@@ -3826,72 +3660,46 @@ export class MostBoxEngine extends EventEmitter {
3826
3660
  source: localSource,
3827
3661
  publishedAt: normalized.publishedAt,
3828
3662
  starred: normalized.starred,
3829
- deletedAt: normalized.deletedAt || new Date(normalized.syncUpdatedAt).toISOString(),
3830
- syncUpdatedAt: normalized.syncUpdatedAt,
3831
- }
3832
- if (trashIndex === -1) {
3833
- trashFiles.push(nextRecord)
3834
- changed = true
3835
- } else if (
3836
- JSON.stringify(trashFiles[trashIndex]) !== JSON.stringify(nextRecord)
3837
- ) {
3838
- trashFiles[trashIndex] = nextRecord
3839
- changed = true
3840
- }
3841
- if (publishedIndex !== -1) {
3842
- publishedFiles.splice(publishedIndex, 1)
3843
- changed = true
3663
+ deletedAt: normalized.deletedAt || new Date(normalized.updatedAt).toISOString(),
3664
+ syncUpdatedAt: normalized.updatedAt,
3844
3665
  }
3666
+ if (trashIndex === -1) trashFiles.push(nextRecord)
3667
+ else trashFiles[trashIndex] = nextRecord
3668
+ if (publishedIndex !== -1) publishedFiles.splice(publishedIndex, 1)
3845
3669
  }
3846
3670
 
3847
- if (changed) {
3848
- this.#setPublishedBucket(ownerAddress, publishedFiles)
3849
- this.#setTrashBucket(ownerAddress, trashFiles)
3850
- this.#setUserSyncClock(ownerAddress, entityKey, normalized.syncUpdatedAt)
3671
+ this.#setPublishedBucket(owner, publishedFiles)
3672
+ this.#setTrashBucket(owner, trashFiles)
3673
+ return {
3674
+ changed: true,
3675
+ added: !wasKnown,
3676
+ updated: wasKnown,
3851
3677
  }
3852
- return changed
3853
3678
  }
3854
3679
 
3855
- async #applyUserSyncFileDelete(ownerAddress, cidInput, timestamp) {
3856
- const cid = String(cidInput || '').trim()
3857
- if (!cid) return false
3858
- const syncUpdatedAt = getSyncTimestamp(timestamp)
3859
- const entityKey = `file:${cid}`
3860
- if (!this.#shouldApplyUserSyncEntity(ownerAddress, entityKey, syncUpdatedAt)) {
3861
- return false
3862
- }
3863
-
3864
- const publishedFiles = this.#getPublishedBucket(ownerAddress).filter(
3865
- file => file.cid !== cid
3866
- )
3867
- const trashFiles = this.#getTrashBucket(ownerAddress).filter(
3868
- file => file.cid !== cid
3869
- )
3870
- const changed =
3871
- publishedFiles.length !== this.#getPublishedBucket(ownerAddress).length ||
3872
- trashFiles.length !== this.#getTrashBucket(ownerAddress).length
3873
- this.#setPublishedBucket(ownerAddress, publishedFiles)
3874
- this.#setTrashBucket(ownerAddress, trashFiles)
3875
- this.#setUserSyncClock(ownerAddress, entityKey, syncUpdatedAt)
3876
- if (changed && !this.#hasAnyUserReference(cid)) {
3877
- await this.#cleanupUnreferencedCids([cid])
3680
+ async #mergeAccountChannelRecord(ownerAddress, record) {
3681
+ const owner = normalizeOwnerAddress(ownerAddress)
3682
+ if (!owner || !record || typeof record !== 'object') {
3683
+ return { changed: false, added: false, updated: false }
3878
3684
  }
3879
- return changed
3880
- }
3881
-
3882
- async #applyUserSyncChannelRecord(ownerAddress, record, timestamp) {
3883
- if (!record || typeof record !== 'object') return false
3884
3685
  const channelId = normalizeChannelId(record.channelId)
3885
- const expectedChannelKey = buildChannelKey(channelId)
3886
- const channelKey = expectedChannelKey
3887
- if (!channelId || !channelKey) return false
3888
- const syncUpdatedAt = getSyncTimestamp(record.syncUpdatedAt, timestamp)
3889
- const entityKey = `channel:${channelKey}`
3890
- if (!this.#shouldApplyUserSyncEntity(ownerAddress, entityKey, syncUpdatedAt)) {
3891
- return false
3686
+ const channelKey = buildChannelKey(channelId)
3687
+ if (
3688
+ !channelId ||
3689
+ !channelKey ||
3690
+ TRANSIENT_CHANNEL_TYPES.has(String(record.type || ''))
3691
+ ) {
3692
+ return { changed: false, added: false, updated: false }
3892
3693
  }
3893
3694
 
3695
+ const updatedAt = getSyncTimestamp(record.updatedAt || record.syncUpdatedAt)
3894
3696
  let channel = this.#channels.find(item => item.channelKey === channelKey)
3697
+ const existingUpdatedAt = this.#getRecordUpdatedAt(channel)
3698
+ if (channel && updatedAt <= existingUpdatedAt) {
3699
+ return { changed: false, added: false, updated: false }
3700
+ }
3701
+
3702
+ const added = !channel
3895
3703
  let changed = false
3896
3704
  if (!channel) {
3897
3705
  channel = {
@@ -3901,7 +3709,7 @@ export class MostBoxEngine extends EventEmitter {
3901
3709
  createdAt:
3902
3710
  typeof record.createdAt === 'string'
3903
3711
  ? record.createdAt
3904
- : new Date(syncUpdatedAt).toISOString(),
3712
+ : new Date(updatedAt).toISOString(),
3905
3713
  lastMessageAt:
3906
3714
  typeof record.lastMessageAt === 'string' ? record.lastMessageAt : '',
3907
3715
  type: String(record.type || 'personal').trim() || 'personal',
@@ -3909,7 +3717,7 @@ export class MostBoxEngine extends EventEmitter {
3909
3717
  localWriterCoreKey: '',
3910
3718
  writerCoreKeys: uniqueStrings(record.writerCoreKeys),
3911
3719
  members: [],
3912
- syncUpdatedAt,
3720
+ syncUpdatedAt: updatedAt,
3913
3721
  }
3914
3722
  this.#channels.push(channel)
3915
3723
  changed = true
@@ -3926,174 +3734,58 @@ export class MostBoxEngine extends EventEmitter {
3926
3734
  channel.lastMessageAt = record.lastMessageAt
3927
3735
  changed = true
3928
3736
  }
3929
- channel.syncUpdatedAt = syncUpdatedAt
3737
+ channel.type = String(record.type || channel.type || 'personal').trim()
3738
+ channel.syncUpdatedAt = updatedAt
3930
3739
  }
3931
3740
 
3932
3741
  if (
3933
3742
  this.#upsertChannelMember(channel, {
3934
- ownerAddress,
3935
- displayName:
3936
- record.member?.displayName || record.remark || '',
3743
+ ownerAddress: owner,
3744
+ displayName: record.member?.displayName || record.remark || '',
3937
3745
  avatar: record.member?.avatar || '',
3938
3746
  })
3939
3747
  ) {
3940
3748
  changed = true
3941
3749
  }
3942
- if (record.remark !== undefined) {
3943
- channel.remarks = channel.remarks || {}
3944
- const remark = String(record.remark || '').slice(0, 50)
3945
- if (remark) channel.remarks[ownerAddress] = remark
3946
- else delete channel.remarks[ownerAddress]
3947
- changed = true
3948
- }
3750
+
3751
+ channel.remarks = channel.remarks || {}
3752
+ const remark = String(record.remark || '').slice(0, 50)
3753
+ if (remark) channel.remarks[owner] = remark
3754
+ else delete channel.remarks[owner]
3755
+
3949
3756
  channel.pinnedBy = channel.pinnedBy || {}
3950
- if (record.pinned) {
3951
- channel.pinnedBy[ownerAddress] = true
3952
- } else {
3953
- delete channel.pinnedBy[ownerAddress]
3954
- }
3955
- this.#setUserSyncClock(ownerAddress, entityKey, syncUpdatedAt)
3757
+ if (record.pinned) channel.pinnedBy[owner] = true
3758
+ else delete channel.pinnedBy[owner]
3956
3759
 
3957
3760
  if (!this.#channelLocalCoreKey.get(channel.channelKey)) {
3958
3761
  await this.#openChannelRuntime(channel)
3959
3762
  await this.#joinChannelDiscoveryTopics(channel)
3960
- changed = true
3961
3763
  this.emit('channel:joined', {
3962
3764
  channel: channel.channelKey,
3963
3765
  channelKey: channel.channelKey,
3964
3766
  channelId: channel.channelId,
3965
3767
  key: channel.channelKey,
3966
3768
  })
3769
+ changed = true
3967
3770
  }
3968
- return changed
3969
- }
3970
-
3971
- #applyUserSyncChannelLeave(ownerAddress, channelKeyInput, timestamp) {
3972
- const channelKey = normalizeChannelKey(channelKeyInput)
3973
- if (!channelKey) return false
3974
- const syncUpdatedAt = getSyncTimestamp(timestamp)
3975
- const entityKey = `channel:${channelKey}`
3976
- if (!this.#shouldApplyUserSyncEntity(ownerAddress, entityKey, syncUpdatedAt)) {
3977
- return false
3978
- }
3979
- const channel = this.#channels.find(item => item.channelKey === channelKey)
3980
- if (!channel) {
3981
- this.#setUserSyncClock(ownerAddress, entityKey, syncUpdatedAt)
3982
- return false
3983
- }
3984
- const before = channel.members?.length || 0
3985
- channel.members = (channel.members || []).filter(
3986
- member => normalizeOwnerAddress(member?.address) !== ownerAddress
3987
- )
3988
- if (channel.remarks) delete channel.remarks[ownerAddress]
3989
- if (channel.pinnedBy) delete channel.pinnedBy[ownerAddress]
3990
- this.#setUserSyncClock(ownerAddress, entityKey, syncUpdatedAt)
3991
- const changed = before !== channel.members.length
3992
- if (channel.members.length === 0) {
3993
- this.#channels = this.#channels.filter(item => item.channelKey !== channelKey)
3994
- }
3995
- if (changed) {
3996
- this.emit('channel:left', {
3997
- channel: channelKey,
3998
- channelKey,
3999
- channelId: channel.channelId,
4000
- name: channel.channelId,
4001
- })
4002
- }
4003
- return changed
4004
- }
4005
3771
 
4006
- #getUserSyncClock(ownerAddress, entityKey) {
4007
- const owner = normalizeOwnerAddress(ownerAddress)
4008
- return Number(this.#userSyncMetadata.clocks?.[owner]?.[entityKey]) || 0
3772
+ return { changed: true, added, updated: !added || changed }
4009
3773
  }
4010
3774
 
4011
- #setUserSyncClock(ownerAddress, entityKey, timestamp) {
3775
+ #applyUserProfileToJoinedChannels(ownerAddress, profile) {
4012
3776
  const owner = normalizeOwnerAddress(ownerAddress)
4013
- if (!owner || !entityKey) return
4014
- this.#userSyncMetadata.clocks = this.#userSyncMetadata.clocks || {}
4015
- this.#userSyncMetadata.clocks[owner] =
4016
- this.#userSyncMetadata.clocks[owner] || {}
4017
- this.#userSyncMetadata.clocks[owner][entityKey] = Math.max(
4018
- this.#getUserSyncClock(owner, entityKey),
4019
- getSyncTimestamp(timestamp)
4020
- )
4021
- this.#saveUserSyncMetadata()
4022
- }
4023
-
4024
- #shouldApplyUserSyncEntity(ownerAddress, entityKey, timestamp) {
4025
- return getSyncTimestamp(timestamp) > this.#getUserSyncClock(ownerAddress, entityKey)
4026
- }
4027
-
4028
- #markUserSyncClockForOp(op) {
4029
- const ownerAddress = normalizeOwnerAddress(op.ownerAddress)
4030
- if (!ownerAddress) return
4031
- if (op.kind === 'file:upsert' || op.kind === 'file:trash') {
4032
- const cid = op.payload?.file?.cid
4033
- const timestamp = getSyncTimestamp(
4034
- op.payload?.file?.syncUpdatedAt,
4035
- op.timestamp
4036
- )
4037
- if (cid) this.#setUserSyncClock(ownerAddress, `file:${cid}`, timestamp)
4038
- } else if (op.kind === 'file:delete') {
4039
- const cid = op.payload?.cid
4040
- const timestamp = getSyncTimestamp(
4041
- op.payload?.syncUpdatedAt,
4042
- op.timestamp
4043
- )
4044
- if (cid) this.#setUserSyncClock(ownerAddress, `file:${cid}`, timestamp)
4045
- } else if (op.kind === 'channel:upsert') {
4046
- const channelKey = op.payload?.channel?.channelKey
4047
- const timestamp = getSyncTimestamp(
4048
- op.payload?.channel?.syncUpdatedAt,
4049
- op.timestamp
4050
- )
4051
- if (channelKey) {
4052
- this.#setUserSyncClock(ownerAddress, `channel:${channelKey}`, timestamp)
4053
- }
4054
- } else if (op.kind === 'channel:leave') {
4055
- const channelKey = op.payload?.channelKey
4056
- const timestamp = getSyncTimestamp(
4057
- op.payload?.syncUpdatedAt,
4058
- op.timestamp
4059
- )
4060
- if (channelKey) {
4061
- this.#setUserSyncClock(ownerAddress, `channel:${channelKey}`, timestamp)
4062
- }
4063
- } else if (op.kind === 'profile:upsert') {
4064
- const timestamp = getSyncTimestamp(
4065
- op.payload?.profile?.syncUpdatedAt,
4066
- op.timestamp
4067
- )
4068
- this.#setUserSyncClock(ownerAddress, 'profile', timestamp)
4069
- }
4070
- }
4071
-
4072
- #persistUserSyncSession(session) {
4073
- this.#userSyncMetadata.sessions = this.#userSyncMetadata.sessions || {}
4074
- this.#userSyncMetadata.sessions[session.ownerAddress] = {
4075
- ownerAddress: session.ownerAddress,
4076
- syncId: session.syncId,
4077
- syncName: session.syncName,
4078
- writerId: session.writerId,
4079
- localWriterCoreKey: session.localWriterCoreKey,
4080
- writerCoreKeys: uniqueStrings(session.writerCoreKeys),
4081
- startedAt: session.startedAt,
4082
- lastSyncedAt:
4083
- this.#userSyncMetadata.sessions?.[session.ownerAddress]?.lastSyncedAt ||
4084
- '',
4085
- updatedAt: new Date().toISOString(),
4086
- }
4087
- this.#saveUserSyncMetadata()
4088
- }
4089
-
4090
- #touchUserSyncSession(session) {
4091
- this.#persistUserSyncSession(session)
4092
- const persisted = this.#userSyncMetadata.sessions?.[session.ownerAddress]
4093
- if (persisted) {
4094
- persisted.lastSyncedAt = new Date().toISOString()
4095
- this.#saveUserSyncMetadata()
3777
+ if (!owner || !profile) return false
3778
+ let changed = false
3779
+ for (const channel of this.#channels) {
3780
+ if (!this.#channelHasMember(channel, owner)) continue
3781
+ changed =
3782
+ this.#upsertChannelMember(channel, {
3783
+ ownerAddress: owner,
3784
+ displayName: profile.displayName,
3785
+ avatar: profile.avatar,
3786
+ }) || changed
4096
3787
  }
3788
+ return changed
4097
3789
  }
4098
3790
 
4099
3791
  #getFileRuntimeStats(cid) {
@@ -4674,8 +4366,8 @@ export class MostBoxEngine extends EventEmitter {
4674
4366
  return path.join(this.#options.dataPath, 'trash-files.json')
4675
4367
  }
4676
4368
 
4677
- #getUserSyncMetadataPath() {
4678
- return path.join(this.#options.dataPath, 'user-sync.json')
4369
+ #getAccountMetadataPath() {
4370
+ return path.join(this.#options.dataPath, 'account-metadata.json')
4679
4371
  }
4680
4372
 
4681
4373
  #atomicWrite(filePath, data) {
@@ -4770,73 +4462,41 @@ export class MostBoxEngine extends EventEmitter {
4770
4462
  }
4771
4463
  }
4772
4464
 
4773
- #loadUserSyncMetadata() {
4465
+ #loadAccountMetadata() {
4774
4466
  try {
4775
- const metadataPath = this.#getUserSyncMetadataPath()
4467
+ const metadataPath = this.#getAccountMetadataPath()
4776
4468
  if (fs.existsSync(metadataPath)) {
4777
4469
  const parsed = JSON.parse(fs.readFileSync(metadataPath, 'utf-8'))
4778
- const sessions = {}
4779
- for (const [owner, session] of Object.entries(parsed.sessions || {})) {
4780
- const ownerAddress = normalizeOwnerAddress(owner)
4781
- const syncId = String(session?.syncId || '').trim()
4782
- if (!ownerAddress || !syncId) continue
4783
- sessions[ownerAddress] = {
4784
- ownerAddress,
4785
- syncId,
4786
- syncName: String(session.syncName || getUserSyncName(syncId)),
4787
- writerId: String(session.writerId || ''),
4788
- localWriterCoreKey: String(session.localWriterCoreKey || ''),
4789
- writerCoreKeys: uniqueStrings(session.writerCoreKeys),
4790
- startedAt: String(session.startedAt || ''),
4791
- lastSyncedAt: String(session.lastSyncedAt || ''),
4792
- updatedAt: String(session.updatedAt || ''),
4793
- }
4794
- }
4795
-
4796
- const clocks = {}
4797
- for (const [owner, records] of Object.entries(parsed.clocks || {})) {
4798
- const ownerAddress = normalizeOwnerAddress(owner)
4799
- if (!ownerAddress || !records || typeof records !== 'object') continue
4800
- clocks[ownerAddress] = {}
4801
- for (const [entityKey, timestamp] of Object.entries(records)) {
4802
- const value = Number(timestamp)
4803
- if (entityKey && Number.isFinite(value) && value > 0) {
4804
- clocks[ownerAddress][entityKey] = value
4805
- }
4806
- }
4807
- }
4808
-
4809
4470
  const profiles = {}
4810
4471
  for (const [owner, profile] of Object.entries(parsed.profiles || {})) {
4811
4472
  const ownerAddress = normalizeOwnerAddress(owner)
4812
- const normalized = this.#normalizeUserProfileRecord(
4473
+ const normalized = this.#normalizeAccountProfileRecord(
4813
4474
  ownerAddress,
4814
4475
  profile,
4815
- profile?.syncUpdatedAt
4476
+ profile?.updatedAt || profile?.syncUpdatedAt
4816
4477
  )
4817
4478
  if (normalized) profiles[ownerAddress] = normalized
4818
4479
  }
4819
-
4820
- return { sessions, clocks, profiles }
4480
+ return { profiles }
4821
4481
  }
4822
4482
  } catch (err) {
4823
4483
  console.warn(
4824
- 'Failed to load user sync metadata, using empty state:',
4484
+ 'Failed to load account metadata, using empty state:',
4825
4485
  err.message
4826
4486
  )
4827
4487
  }
4828
- return { sessions: {}, clocks: {}, profiles: {} }
4488
+ return { profiles: {} }
4829
4489
  }
4830
4490
 
4831
- #saveUserSyncMetadata() {
4491
+ #saveAccountMetadata() {
4832
4492
  try {
4833
- const metadataPath = this.#getUserSyncMetadataPath()
4493
+ const metadataPath = this.#getAccountMetadataPath()
4834
4494
  this.#atomicWrite(
4835
4495
  metadataPath,
4836
- JSON.stringify(this.#userSyncMetadata, null, 2)
4496
+ JSON.stringify(this.#accountMetadata, null, 2)
4837
4497
  )
4838
4498
  } catch (err) {
4839
- console.error('Failed to save user sync metadata:', err.message)
4499
+ console.error('Failed to save account metadata:', err.message)
4840
4500
  }
4841
4501
  }
4842
4502
 
@@ -4940,14 +4600,6 @@ export class MostBoxEngine extends EventEmitter {
4940
4600
  return hash
4941
4601
  }
4942
4602
 
4943
- #generateUserSyncDiscoveryKey(syncId) {
4944
- const hash = crypto
4945
- .createHash('sha256')
4946
- .update(`${CHANNEL_NAME_PREFIX}${USER_SYNC_NAMESPACE_PREFIX}${syncId}`)
4947
- .digest()
4948
- return hash
4949
- }
4950
-
4951
4603
  #setupChannelAppendListener(core, channelKey) {
4952
4604
  let lastCoreLength = core.length
4953
4605
  core.on('append', async () => {
@@ -5036,21 +4688,11 @@ export class MostBoxEngine extends EventEmitter {
5036
4688
  this.#channelLocalCoreKey.get(channel.channelKey),
5037
4689
  ]),
5038
4690
  }))
5039
- const userSyncSessions = [...this.#userSyncSessions.values()].map(
5040
- session => ({
5041
- ownerAddress: session.ownerAddress,
5042
- syncId: session.syncId,
5043
- syncName: session.syncName,
5044
- writerCoreKeys: uniqueStrings(session.writerCoreKeys),
5045
- })
5046
- )
5047
-
5048
4691
  return {
5049
4692
  type: 'channel-hello',
5050
4693
  peerId: this.getNodeId(),
5051
4694
  authorName: this.getNodeId().slice(0, 4),
5052
4695
  channels,
5053
- userSyncSessions,
5054
4696
  }
5055
4697
  }
5056
4698
 
@@ -5132,30 +4774,6 @@ export class MostBoxEngine extends EventEmitter {
5132
4774
  }
5133
4775
  }
5134
4776
 
5135
- const remoteUserSyncSessions = Array.isArray(msg.userSyncSessions)
5136
- ? msg.userSyncSessions
5137
- .filter(session => session && typeof session === 'object')
5138
- .map(session => ({
5139
- ownerAddress: normalizeOwnerAddress(session.ownerAddress),
5140
- syncId: String(session.syncId || '').trim(),
5141
- writerCoreKeys: uniqueStrings(session.writerCoreKeys),
5142
- }))
5143
- .filter(session => session.ownerAddress && session.syncId)
5144
- : []
5145
-
5146
- for (const remoteSession of remoteUserSyncSessions) {
5147
- const localSession = this.#userSyncSessions.get(
5148
- remoteSession.ownerAddress
5149
- )
5150
- if (!localSession || localSession.syncId !== remoteSession.syncId) {
5151
- continue
5152
- }
5153
- await this.#mergeUserSyncWriterCoreKeys(
5154
- localSession,
5155
- remoteSession.writerCoreKeys
5156
- )
5157
- }
5158
-
5159
4777
  this.emit('channel:peer:online', {
5160
4778
  peerId: msg.peerId,
5161
4779
  authorName: msg.authorName,