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