most-box 0.0.4 → 0.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (187) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +222 -182
  3. package/electron/main.js +67 -0
  4. package/out/404/index.html +2 -2
  5. package/out/404.html +2 -2
  6. package/out/__next.__PAGE__.txt +6 -4
  7. package/out/__next._full.txt +20 -17
  8. package/out/__next._head.txt +3 -3
  9. package/out/__next._index.txt +6 -5
  10. package/out/__next._tree.txt +4 -2
  11. package/out/_next/static/chunks/003jnm.v5tzw5.js +1 -0
  12. package/out/_next/static/chunks/00re8v.gbcywn.js +1 -0
  13. package/out/_next/static/chunks/00s106sbq8t9v.js +1 -0
  14. package/out/_next/static/chunks/012hi627qrdnn.js +1 -0
  15. package/out/_next/static/chunks/0174xh3wfsjm1.js +2 -0
  16. package/out/_next/static/chunks/01xlw8hd842-c.js +1 -0
  17. package/out/_next/static/chunks/02~o2nmo5pmy1.js +1 -0
  18. package/out/_next/static/chunks/07t.dhhokszz5.css +1 -0
  19. package/out/_next/static/chunks/0_wia9ofmsi1c.css +2 -0
  20. package/out/_next/static/chunks/0ah8fihozo2_u.js +5 -0
  21. package/out/_next/static/chunks/0bzupvr5gt3k9.js +31 -0
  22. package/out/_next/static/chunks/0d3shmwh5_nmn.js +1 -0
  23. package/out/_next/static/chunks/0e_h0d3ekzks8.css +1 -0
  24. package/out/_next/static/chunks/0gdluj423gso1.js +1 -0
  25. package/out/_next/static/chunks/0gmoiq06srjay.css +1 -0
  26. package/out/_next/static/chunks/0ho~log~~-jwp.css +1 -0
  27. package/out/_next/static/chunks/0imkasy7kb67u.js +1 -0
  28. package/out/_next/static/chunks/0jjc_b9q_ldi2.js +1 -0
  29. package/out/_next/static/chunks/0jl~j62iz2uvr.js +1 -0
  30. package/out/_next/static/chunks/0lqslm813wk_h.js +1 -0
  31. package/out/_next/static/chunks/{0bogtdbh.dcu1.js → 0n~dq4kpx9xxx.js} +1 -1
  32. package/out/_next/static/chunks/0pqt~8bl3ukh4.js +4 -0
  33. package/out/_next/static/chunks/0q782fxxd0lx~.js +1 -0
  34. package/out/_next/static/chunks/0qub_r0x_r-e9.css +1 -0
  35. package/out/_next/static/chunks/0slwj0c46k5cu.js +1 -0
  36. package/out/_next/static/chunks/0sorqk.oc6b7j.css +1 -0
  37. package/out/_next/static/chunks/0tapzqc6hgvx-.js +1 -0
  38. package/out/_next/static/chunks/0xsc7z5x8n7wg.js +1 -0
  39. package/out/_next/static/chunks/0zm~gys2jwl0g.js +1 -0
  40. package/out/_next/static/chunks/turbopack-0a_g3u0ud~jb8.js +1 -0
  41. package/out/_not-found/__next._full.txt +19 -15
  42. package/out/_not-found/__next._head.txt +3 -3
  43. package/out/_not-found/__next._index.txt +6 -5
  44. package/out/_not-found/__next._not-found.__PAGE__.txt +9 -0
  45. package/out/_not-found/__next._not-found.txt +3 -3
  46. package/out/_not-found/__next._tree.txt +2 -2
  47. package/out/_not-found/index.html +2 -2
  48. package/out/_not-found/index.txt +19 -15
  49. package/out/app/__next._full.txt +20 -0
  50. package/out/app/__next._head.txt +5 -0
  51. package/out/app/__next._index.txt +7 -0
  52. package/out/app/__next._tree.txt +2 -0
  53. package/out/app/__next.app.__PAGE__.txt +9 -0
  54. package/out/app/__next.app.txt +5 -0
  55. package/out/app/index.html +15 -0
  56. package/out/app/index.txt +20 -0
  57. package/out/changelog/__next._full.txt +22 -0
  58. package/out/changelog/__next._head.txt +5 -0
  59. package/out/changelog/__next._index.txt +7 -0
  60. package/out/changelog/__next._tree.txt +3 -0
  61. package/out/changelog/__next.changelog.__PAGE__.txt +10 -0
  62. package/out/changelog/__next.changelog.txt +5 -0
  63. package/out/changelog/index.html +15 -0
  64. package/out/changelog/index.txt +22 -0
  65. package/out/chat/__next._full.txt +20 -18
  66. package/out/chat/__next._head.txt +3 -3
  67. package/out/chat/__next._index.txt +6 -5
  68. package/out/chat/__next._tree.txt +3 -3
  69. package/out/chat/__next.chat.__PAGE__.txt +9 -0
  70. package/out/chat/__next.chat.txt +5 -4
  71. package/out/chat/index.html +2 -2
  72. package/out/chat/index.txt +20 -18
  73. package/out/docs/__next._full.txt +22 -0
  74. package/out/docs/__next._head.txt +5 -0
  75. package/out/docs/__next._index.txt +7 -0
  76. package/out/docs/__next._tree.txt +3 -0
  77. package/out/docs/__next.docs.__PAGE__.txt +10 -0
  78. package/out/docs/__next.docs.txt +5 -0
  79. package/out/docs/getting-started/__next._full.txt +22 -0
  80. package/out/docs/getting-started/__next._head.txt +5 -0
  81. package/out/docs/getting-started/__next._index.txt +7 -0
  82. package/out/docs/getting-started/__next._tree.txt +3 -0
  83. package/out/docs/getting-started/__next.docs.getting-started.__PAGE__.txt +10 -0
  84. package/out/docs/getting-started/__next.docs.getting-started.txt +5 -0
  85. package/out/docs/getting-started/__next.docs.txt +5 -0
  86. package/out/docs/getting-started/index.html +15 -0
  87. package/out/docs/getting-started/index.txt +22 -0
  88. package/out/docs/index.html +15 -0
  89. package/out/docs/index.txt +22 -0
  90. package/out/download/__next._full.txt +34 -0
  91. package/out/download/__next._head.txt +5 -0
  92. package/out/download/__next._index.txt +7 -0
  93. package/out/download/__next._tree.txt +4 -0
  94. package/out/download/__next.download.__PAGE__.txt +16 -0
  95. package/out/download/__next.download.txt +5 -0
  96. package/out/download/index.html +15 -0
  97. package/out/download/index.txt +34 -0
  98. package/out/fonts/jetbrains-mono-latin-400-normal.woff2 +0 -0
  99. package/out/fonts/jetbrains-mono-latin-500-normal.woff2 +0 -0
  100. package/out/fonts/jetbrains-mono-latin-600-normal.woff2 +0 -0
  101. package/out/fonts/jetbrains-mono-latin-700-normal.woff2 +0 -0
  102. package/out/index.html +2 -2
  103. package/out/index.txt +20 -17
  104. package/out/ping/__next._full.txt +21 -0
  105. package/out/ping/__next._head.txt +5 -0
  106. package/out/ping/__next._index.txt +7 -0
  107. package/out/ping/__next._tree.txt +4 -0
  108. package/out/ping/__next.ping.__PAGE__.txt +10 -0
  109. package/out/ping/__next.ping.txt +5 -0
  110. package/out/ping/index.html +15 -0
  111. package/out/ping/index.txt +21 -0
  112. package/out/pwa-512x512.png +0 -0
  113. package/out/web3/__next._full.txt +21 -0
  114. package/out/web3/__next._head.txt +5 -0
  115. package/out/web3/__next._index.txt +7 -0
  116. package/out/web3/__next._tree.txt +3 -0
  117. package/out/web3/__next.web3.__PAGE__.txt +9 -0
  118. package/out/web3/__next.web3.txt +6 -0
  119. package/out/web3/ed25519/__next._full.txt +20 -0
  120. package/out/web3/ed25519/__next._head.txt +5 -0
  121. package/out/web3/ed25519/__next._index.txt +7 -0
  122. package/out/web3/ed25519/__next._tree.txt +3 -0
  123. package/out/web3/ed25519/__next.web3.ed25519.__PAGE__.txt +6 -0
  124. package/out/web3/ed25519/__next.web3.ed25519.txt +5 -0
  125. package/out/web3/ed25519/__next.web3.txt +6 -0
  126. package/out/web3/ed25519/index.html +1 -0
  127. package/out/web3/ed25519/index.txt +20 -0
  128. package/out/web3/index.html +15 -0
  129. package/out/web3/index.txt +21 -0
  130. package/out/web3/tools/__next._full.txt +20 -0
  131. package/out/web3/tools/__next._head.txt +5 -0
  132. package/out/web3/tools/__next._index.txt +7 -0
  133. package/out/web3/tools/__next._tree.txt +3 -0
  134. package/out/web3/tools/__next.web3.tools.__PAGE__.txt +6 -0
  135. package/out/web3/tools/__next.web3.tools.txt +5 -0
  136. package/out/web3/tools/__next.web3.txt +6 -0
  137. package/out/web3/tools/index.html +1 -0
  138. package/out/web3/tools/index.txt +20 -0
  139. package/package.json +162 -53
  140. package/public/fonts/jetbrains-mono-latin-400-normal.woff2 +0 -0
  141. package/public/fonts/jetbrains-mono-latin-500-normal.woff2 +0 -0
  142. package/public/fonts/jetbrains-mono-latin-600-normal.woff2 +0 -0
  143. package/public/fonts/jetbrains-mono-latin-700-normal.woff2 +0 -0
  144. package/public/pwa-512x512.png +0 -0
  145. package/server/cli.js +3 -0
  146. package/server/index.js +995 -0
  147. package/{src → server/src}/config.js +51 -50
  148. package/{src → server/src}/core/cid.js +157 -150
  149. package/{src → server/src}/index.js +1952 -1669
  150. package/server/src/utils/api.js +68 -0
  151. package/server/src/utils/avatar.js +11 -0
  152. package/{src → server/src}/utils/errors.js +70 -66
  153. package/server/src/utils/mostWallet.js +42 -0
  154. package/server/src/utils/mp.js +105 -0
  155. package/{src → server/src}/utils/security.js +173 -169
  156. package/server/src/utils/userIdentity.js +93 -0
  157. package/cli.js +0 -2
  158. package/out/_next/static/chunks/00l-yd3t8dvwz.js +0 -5
  159. package/out/_next/static/chunks/03k8t3tgym~8~.js +0 -1
  160. package/out/_next/static/chunks/09vfh8lfuacc0.css +0 -1
  161. package/out/_next/static/chunks/0dbhjjzl8qfwv.js +0 -1
  162. package/out/_next/static/chunks/0f73psqhr8dre.css +0 -1
  163. package/out/_next/static/chunks/0fbi7z4_.4j1j.js +0 -1
  164. package/out/_next/static/chunks/0ht900cau6_ur.js +0 -31
  165. package/out/_next/static/chunks/0ohm.ia-4ec60.js +0 -1
  166. package/out/_next/static/chunks/0u5ydb-f0.vxl.js +0 -1
  167. package/out/_next/static/chunks/14t2m1on-s5v~.js +0 -1
  168. package/out/_next/static/chunks/turbopack-076ce9exut_h3.js +0 -1
  169. package/out/_not-found/__next._not-found/__PAGE__.txt +0 -5
  170. package/out/app.css +0 -1535
  171. package/out/bundle.js +0 -107
  172. package/out/bundle.js.map +0 -7
  173. package/out/chat/__next.chat/__PAGE__.txt +0 -9
  174. package/out/chat-page.js +0 -112
  175. package/out/chat.css +0 -378
  176. package/out/index.js +0 -148
  177. package/public/app.css +0 -1535
  178. package/public/bundle.js +0 -107
  179. package/public/bundle.js.map +0 -7
  180. package/public/chat-page.js +0 -112
  181. package/public/chat.css +0 -378
  182. package/public/index.js +0 -148
  183. package/server.js +0 -880
  184. package/src/utils/api.js +0 -6
  185. /package/out/_next/static/{0h4f4QFk_KC9FlSRfQACk → sV38nXrv3xVVO6wvSdFlZ}/_buildManifest.js +0 -0
  186. /package/out/_next/static/{0h4f4QFk_KC9FlSRfQACk → sV38nXrv3xVVO6wvSdFlZ}/_clientMiddlewareManifest.js +0 -0
  187. /package/out/_next/static/{0h4f4QFk_KC9FlSRfQACk → sV38nXrv3xVVO6wvSdFlZ}/_ssgManifest.js +0 -0
@@ -1,1669 +1,1952 @@
1
- /**
2
- * MostBoxEngine - 核心 P2P 引擎
3
- * 基于 Hyperswarm/Hyperdrive 的跨平台 P2P 文件共享引擎
4
- *
5
- * 架构设计:
6
- * - Hyperdrive: 只负责存储文件内容,key 使用 CID(解耦存储与目录结构)
7
- * - published-files.json: 维护文件元数据和显示路径(用户看到的文件夹结构)
8
- * - 移动/重命名只需更新 JSON,零成本,不修改 Hyperdrive
9
- */
10
-
11
- import EventEmitter from 'eventemitter3'
12
- import Hyperswarm from 'hyperswarm'
13
- import Corestore from 'corestore'
14
- import Hyperdrive from 'hyperdrive'
15
- import Hypercore from 'hypercore'
16
- import b4a from 'b4a'
17
- import crypto from 'node:crypto'
18
- import { CID } from 'multiformats/cid'
19
- import fs from 'node:fs'
20
- import path from 'node:path'
21
-
22
- import { calculateCid, parseMostLink } from './core/cid.js'
23
- import { sanitizeFilename, validateAndSanitizePath, validateFileSize, checkDirectoryWritable, formatFileSize } from './utils/security.js'
24
- import { ValidationError, PathSecurityError, FileSizeError, PeerNotFoundError, IntegrityError, PermissionError, EngineNotInitializedError } from './utils/errors.js'
25
- import { GLOBAL_SHARED_SEED_STRING, MAX_FILE_SIZE, CONNECTION_TIMEOUT, DOWNLOAD_TIMEOUT, SWARM_BOOTSTRAP, MAX_PEERS, SWARM_KEEP_ALIVE_INTERVAL, SWARM_RANDOM_PUNCH_INTERVAL, DRIVE_ENTRY_TIMEOUT, DRIVE_SYNC_TIMEOUT, STREAM_READ_TIMEOUT, DOWNLOAD_POLL_INTERVAL, PROGRESS_THROTTLE, DEFAULT_READ_LIMIT, CHANNEL_NAME_MIN_LENGTH, CHANNEL_NAME_MAX_LENGTH, CHANNEL_NAME_REGEX, CHANNEL_NAME_PREFIX, CHANNEL_TOPIC_STRING, CHANNEL_MESSAGE_LIMIT, MAX_MESSAGE_LENGTH } from './config.js'
26
-
27
- export class MostBoxEngine extends EventEmitter {
28
- #store = null
29
- #swarm = null
30
- #drives = new Map()
31
- #publishedFiles = []
32
- #trashFiles = []
33
- #initialized = false
34
- #options = null
35
- #activeDownloads = new Map()
36
- #drivePromises = new Map()
37
-
38
- #channels = []
39
- #channelCores = new Map()
40
- #channelDiscoveries = new Map()
41
- #channelPeers = new Map()
42
-
43
- /**
44
- * 创建新的 MostBoxEngine 实例
45
- * @param {object} options - 配置选项
46
- * @param {string} options.dataPath - 存储 P2P 数据的路径(必填)
47
- * @param {string} [options.downloadPath] - 默认下载路径(可选,默认为 dataPath/downloads)
48
- * @param {number} [options.maxFileSize] - 最大文件大小(字节)(默认:100GB)
49
- */
50
- constructor(options) {
51
- super()
52
-
53
- if (!options || !options.dataPath) {
54
- throw new Error('dataPath is required')
55
- }
56
-
57
- this.#options = {
58
- dataPath: options.dataPath,
59
- downloadPath: options.downloadPath || path.join(options.dataPath, 'downloads'),
60
- maxFileSize: options.maxFileSize || MAX_FILE_SIZE
61
- }
62
- }
63
-
64
- /**
65
- * 初始化引擎 — 必须在调用其他方法之前调用
66
- */
67
- async start() {
68
- if (this.#initialized) {
69
- return
70
- }
71
-
72
- const { dataPath } = this.#options
73
-
74
- console.log(`[MostBox] Initializing engine...`)
75
- console.log(`[MostBox] Storage path: ${dataPath}`)
76
-
77
- if (!fs.existsSync(dataPath)) {
78
- fs.mkdirSync(dataPath, { recursive: true })
79
- }
80
-
81
- const GLOBAL_SHARED_SEED = b4a.alloc(32).fill(GLOBAL_SHARED_SEED_STRING)
82
- this.#store = new Corestore(dataPath, { primaryKey: GLOBAL_SHARED_SEED, unsafe: true })
83
-
84
- try {
85
- await this.#store.ready()
86
- console.log(`[MostBox] Corestore ready`)
87
- } catch (err) {
88
- if (err.message && err.message.includes('Another corestore is stored here')) {
89
- console.log(`[MostBox] Resetting corrupt storage...`)
90
- fs.rmSync(dataPath, { recursive: true, force: true })
91
- fs.mkdirSync(dataPath, { recursive: true })
92
- this.#store = new Corestore(dataPath, { primaryKey: GLOBAL_SHARED_SEED, unsafe: true })
93
- await this.#store.ready()
94
- console.log(`[MostBox] Corestore reset and ready`)
95
- } else if (err.message && err.message.includes('Invalid device file')) {
96
- throw new Error(`存储文件损坏,请关闭其他访问 ${dataPath} 的程序后重试`)
97
- } else if (err.message && err.message.includes('File descriptor could not be locked')) {
98
- throw new Error(`存储文件被锁定,请关闭其他访问 ${dataPath} 的程序后重试`)
99
- } else {
100
- throw err
101
- }
102
- }
103
-
104
- console.log(`[MostBox] Initializing Hyperswarm...`)
105
- this.#swarm = new Hyperswarm({
106
- maxPeers: MAX_PEERS,
107
- bootstrap: SWARM_BOOTSTRAP,
108
- firewall: () => false,
109
- connectionKeepAlive: SWARM_KEEP_ALIVE_INTERVAL,
110
- randomPunchInterval: SWARM_RANDOM_PUNCH_INTERVAL,
111
- handshakeTimeout: CONNECTION_TIMEOUT
112
- })
113
-
114
- this.#swarm.on('error', (err) => {
115
- if (err.code === 'SSL_ERROR' || err.message?.includes('handshake') || err.message?.includes('ECONNRESET')) {
116
- console.warn('[MostBox] Network warning (non-critical):', err.message)
117
- return
118
- }
119
- console.error('[MostBox] Swarm error:', err.message)
120
- this.emit('error', err)
121
- })
122
-
123
- this.#swarm.on('connection', (conn, info) => {
124
- conn.on('error', (err) => {
125
- if (err.code === 'SSL_ERROR' || err.message?.includes('handshake')) {
126
- return
127
- }
128
- })
129
-
130
- this.#store.replicate(conn)
131
- this.#handleChannelConnection(conn).catch(() => {})
132
- this.emit('connection', conn)
133
- })
134
-
135
- this.#publishedFiles = this.#loadPublishedMetadata()
136
- console.log(`[MostBox] Loaded ${this.#publishedFiles.length} published files`)
137
-
138
- this.#trashFiles = this.#loadTrashMetadata()
139
- console.log(`[MostBox] Loaded ${this.#trashFiles.length} trash files`)
140
-
141
- this.#channels = this.#loadChannelsMetadata()
142
- console.log(`[MostBox] Loaded ${this.#channels.length} channels`)
143
-
144
- for (const channel of this.#channels) {
145
- try {
146
- const ns = this.#store.namespace(`channel-${channel.name}`)
147
- const core = ns.get({ key: b4a.from(channel.coreKey, 'hex'), valueEncoding: 'json' })
148
- await core.ready()
149
- this.#channelCores.set(channel.name, core)
150
- this.#channelPeers.set(channel.name, new Map())
151
-
152
- let lastCoreLength = core.length
153
- core.on('append', async () => {
154
- if (core.length > lastCoreLength) {
155
- for (let i = lastCoreLength; i < core.length; i++) {
156
- try {
157
- const entry = await core.get(i)
158
- if (entry && entry.type === 'message') {
159
- this.emit('channel:message', { channel: channel.name, message: entry })
160
- }
161
- } catch {}
162
- }
163
- lastCoreLength = core.length
164
- }
165
- })
166
-
167
- const discoveryKey = b4a.from(channel.discoveryKey, 'hex')
168
- const discovery = this.#swarm.join(discoveryKey, { server: true, client: true })
169
- this.#channelDiscoveries.set(channel.name, discovery)
170
- console.log(`[MostBox] Rejoined channel: ${channel.name}`)
171
- } catch (err) {
172
- console.warn(`[MostBox] Failed to rejoin channel ${channel.name}:`, err.message)
173
- }
174
- }
175
-
176
- this.#initialized = true
177
- console.log(`[MostBox] Engine initialized successfully`)
178
- this.emit('ready')
179
-
180
- return this
181
- }
182
-
183
- /**
184
- * 停止引擎并清理资源
185
- */
186
- async stop() {
187
- if (!this.#initialized) {
188
- return
189
- }
190
-
191
- for (const task of this.#activeDownloads.values()) {
192
- task.aborted = true
193
- if (task.readStream) task.readStream.destroy()
194
- if (task.writeStream) task.writeStream.destroy()
195
- }
196
- this.#activeDownloads.clear()
197
-
198
- await Promise.allSettled([...this.#drives.values()].map(d => d.close()))
199
- this.#drives.clear()
200
-
201
- for (const core of this.#channelCores.values()) {
202
- try { await core.close() } catch {}
203
- }
204
- this.#channelCores.clear()
205
- this.#channelDiscoveries.clear()
206
- this.#channelPeers.clear()
207
- this.#channels = []
208
-
209
- if (this.#swarm) {
210
- await this.#swarm.destroy()
211
- this.#swarm = null
212
- }
213
-
214
- if (this.#store) {
215
- await this.#store.close()
216
- this.#store = null
217
- }
218
-
219
- this.#initialized = false
220
- this.emit('stopped')
221
- }
222
-
223
- /**
224
- * 获取节点的公钥
225
- * @returns {string} 节点 ID(十六进制字符串)
226
- */
227
- getNodeId() {
228
- this.#ensureInitialized()
229
- return b4a.toString(this.#swarm.keyPair.publicKey, 'hex')
230
- }
231
-
232
- /**
233
- * 获取当前网络状态
234
- * @returns {{ peers: number, status: string }}
235
- */
236
- getNetworkStatus() {
237
- this.#ensureInitialized()
238
- const connections = this.#swarm.connections.size
239
- return {
240
- peers: connections,
241
- status: connections > 0 ? 'connected' : 'waiting'
242
- }
243
- }
244
-
245
- /**
246
- * 将内容发布到 P2P 网络
247
- * Hyperdrive 中存储 key 为 '/' + cid,metadata 中存储 displayName(用户看到的路径)
248
- * @param {string|Buffer} content - 文件路径(字符串)或内容(Buffer)
249
- * @param {string} [fileName] - 文件名(Buffer 输入时必填)
250
- * @returns {Promise<{ cid: string, link: string, fileName: string }>}
251
- */
252
- async publishFile(content, fileName) {
253
- this.#ensureInitialized()
254
-
255
- let cleanPath = null
256
- let safeFileName
257
- let fileSize
258
-
259
- if (Buffer.isBuffer(content)) {
260
- if (!fileName) {
261
- throw new Error('fileName is required when publishing Buffer content')
262
- }
263
- safeFileName = sanitizeFilename(fileName)
264
- fileSize = content.length
265
- } else {
266
- cleanPath = content
267
- const pathValidation = validateAndSanitizePath(cleanPath)
268
- if (pathValidation.error) {
269
- throw new PathSecurityError(pathValidation.error)
270
- }
271
- cleanPath = pathValidation.cleanPath
272
-
273
- const sizeValidation = await validateFileSize(cleanPath, this.#options.maxFileSize)
274
- if (!sizeValidation.valid) {
275
- throw new FileSizeError(sizeValidation.error, sizeValidation.size)
276
- }
277
- fileSize = sizeValidation.size
278
-
279
- safeFileName = sanitizeFilename(fileName || path.basename(cleanPath))
280
- }
281
-
282
- if (fileSize > this.#options.maxFileSize) {
283
- const maxGB = Math.round(this.#options.maxFileSize / (1024 * 1024 * 1024))
284
- throw new FileSizeError(`File size exceeds limit of ${maxGB} GB`, fileSize)
285
- }
286
-
287
- this.emit('publish:progress', { stage: 'calculating-cid', file: safeFileName })
288
-
289
- const { cid: rootCid } = await calculateCid(content)
290
- const cidString = rootCid.toString()
291
-
292
- // 检查相同内容是否已存在
293
- const existingIndex = this.#publishedFiles.findIndex(f => f.cid === cidString)
294
- if (existingIndex !== -1) {
295
- const existing = this.#publishedFiles[existingIndex]
296
- return {
297
- cid: cidString,
298
- link: `most://${cidString}`,
299
- fileName: existing.fileName,
300
- alreadyExists: true
301
- }
302
- }
303
-
304
- // 获取或创建该 CID 对应的 drive
305
- const hashHex = b4a.toString(rootCid.multihash.digest, 'hex')
306
- const name = `drive-${hashHex}`
307
- let drive = this.#drives.get(name)
308
-
309
- if (!drive) {
310
- drive = await this.#getOrCreateDrive(name, { server: true, client: false })
311
- const discovery = this.#swarm.join(drive.discoveryKey, { server: true, client: false })
312
- await discovery.flushed()
313
- }
314
-
315
- this.emit('publish:progress', { stage: 'uploading', file: safeFileName })
316
-
317
- // Hyperdrive 中用 CID 作为 key 存储(解耦目录结构)
318
- const driveKey = '/' + cidString
319
-
320
- const ws = drive.createWriteStream(driveKey)
321
-
322
- if (Buffer.isBuffer(content)) {
323
- const CHUNK_SIZE = 64 * 1024
324
- let offset = 0
325
- const waitForDrain = () => new Promise(resolve => ws.once('drain', resolve))
326
-
327
- try {
328
- while (offset < content.length) {
329
- const chunk = content.slice(offset, offset + CHUNK_SIZE)
330
- const canContinue = ws.write(chunk)
331
- offset += chunk.length
332
- if (!canContinue && offset < content.length) {
333
- await waitForDrain()
334
- }
335
- }
336
- ws.end()
337
- await new Promise((resolve, reject) => {
338
- ws.on('finish', resolve)
339
- ws.on('error', reject)
340
- })
341
- } catch (err) {
342
- ws.destroy()
343
- throw err
344
- }
345
- } else {
346
- const rs = fs.createReadStream(cleanPath)
347
- await new Promise((resolve, reject) => {
348
- rs.pipe(ws)
349
- ws.on('finish', resolve)
350
- ws.on('error', reject)
351
- rs.on('error', reject)
352
- })
353
- }
354
-
355
- // 存储 displayName(用户看到的文件夹路径),不存储 drivePath
356
- this.#publishedFiles.push({
357
- fileName: safeFileName,
358
- cid: cidString,
359
- driveName: name,
360
- publishedAt: new Date().toISOString(),
361
- starred: false
362
- })
363
- this.#savePublishedMetadata()
364
-
365
- const result = {
366
- cid: cidString,
367
- link: `most://${cidString}?filename=${encodeURIComponent(safeFileName)}`,
368
- fileName: safeFileName
369
- }
370
-
371
- this.emit('publish:success', result)
372
- return result
373
- }
374
-
375
- /**
376
- * P2P 网络下载文件
377
- * @param {string} link - most:// 链接
378
- * @param {string} [taskId] - 用于取消的任务 ID
379
- * @returns {Promise<{ taskId: string, fileName: string, savedPath: string, alreadyExists?: boolean }>}
380
- */
381
- async downloadFile(link, taskId = null) {
382
- this.#ensureInitialized()
383
-
384
- taskId = taskId || `dl_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
385
-
386
- console.log(`[MostBox] Starting download for link: ${link} (taskId: ${taskId})`)
387
-
388
- const taskState = { aborted: false, readStream: null, writeStream: null }
389
- this.#activeDownloads.set(taskId, taskState)
390
-
391
- try {
392
- const parsed = parseMostLink(link)
393
- if (parsed.error) {
394
- throw new ValidationError(parsed.error)
395
- }
396
- const cidString = parsed.cid
397
- console.log(`[MostBox] Parsed CID: ${cidString}`)
398
-
399
- const existingFile = this.#publishedFiles.find(f => f.cid === cidString)
400
- if (existingFile) {
401
- console.log(`[MostBox] File already exists: ${existingFile.fileName}`)
402
- return {
403
- taskId,
404
- fileName: existingFile.fileName,
405
- alreadyExists: true
406
- }
407
- }
408
-
409
- const linkFileName = parsed.fileName
410
-
411
- const parsedCid = CID.parse(cidString)
412
- const hashHex = b4a.toString(parsedCid.multihash.digest, 'hex')
413
-
414
- if (taskState.aborted) throw new Error('Download cancelled')
415
-
416
- const name = `drive-${hashHex}`
417
- let drive = this.#drives.get(name)
418
-
419
- if (!drive) {
420
- console.log(`[MostBox] Creating new drive: ${name}`)
421
- drive = await this.#getOrCreateDrive(name, { server: true, client: true })
422
-
423
- this.emit('download:status', { taskId, status: 'connecting' })
424
-
425
- console.log(`[MostBox] Joining swarm for drive discovery...`)
426
- await this.#swarm.join(drive.discoveryKey, { server: true, client: true }).flushed()
427
- console.log(`[MostBox] Swarm join flushed`)
428
- } else {
429
- console.log(`[MostBox] Using existing drive: ${name}`)
430
- }
431
-
432
- if (taskState.aborted) throw new Error('Download cancelled')
433
-
434
- this.emit('download:status', { taskId, status: 'finding-peers' })
435
-
436
- console.log(`[MostBox] Waiting for drive content (timeout: ${DOWNLOAD_TIMEOUT / 1000}s)...`)
437
- const entries = await this.#waitForDriveContent(drive, DOWNLOAD_TIMEOUT, taskId, taskState)
438
-
439
- if (entries.length === 0) {
440
- console.log(`[MostBox] No entries found after timeout`)
441
-
442
- const peerCount = this.#swarm.connections.size
443
- let errorMessage = 'No files found in drive. '
444
-
445
- if (peerCount === 0) {
446
- errorMessage += 'Could not connect to any peers. This may be due to:\n'
447
- errorMessage += '1. Network firewall blocking P2P connections\n'
448
- errorMessage += '2. DHT bootstrap nodes unreachable\n'
449
- errorMessage += '3. NAT traversal failed (try port forwarding)\n'
450
- errorMessage += '4. No peers are currently sharing this file'
451
- } else {
452
- errorMessage += `Connected to ${peerCount} peers but no file data was found. This may be due to:\n`
453
- errorMessage += '1. Publisher node offline\n'
454
- errorMessage += '2. File may have been removed by publisher\n'
455
- errorMessage += '3. File link may be invalid or corrupted'
456
- }
457
-
458
- throw new PeerNotFoundError(errorMessage)
459
- }
460
-
461
- if (taskState.aborted) throw new Error('Download cancelled')
462
-
463
- console.log(`[MostBox] Found ${entries.length} entries, starting download...`)
464
-
465
- const targetDir = this.#options.dataPath
466
-
467
- const writableCheck = await checkDirectoryWritable(targetDir)
468
- if (!writableCheck.writable) {
469
- throw new PermissionError(writableCheck.error)
470
- }
471
-
472
- // 下载文件
473
- for (const entry of entries) {
474
- const cleanKey = entry.key.replace(/^[\/\\]/, '')
475
- const sanitizedFileName = linkFileName
476
- ? sanitizeFilename(linkFileName)
477
- : sanitizeFilename(cleanKey)
478
-
479
- let totalBytes = 0
480
- try {
481
- const stat = await drive.entry(entry.key)
482
- if (stat && stat.value && stat.value.blob) {
483
- totalBytes = stat.value.blob.byteLength || 0
484
- }
485
- } catch {
486
- // 忽略
487
- }
488
-
489
- const savePath = path.join(targetDir, sanitizedFileName)
490
-
491
- this.emit('download:status', {
492
- taskId,
493
- status: 'downloading',
494
- file: sanitizedFileName,
495
- size: totalBytes ? formatFileSize(totalBytes) : null
496
- })
497
-
498
- const rs = drive.createReadStream(entry.key)
499
- const ws = fs.createWriteStream(savePath)
500
-
501
- taskState.readStream = rs
502
- taskState.writeStream = ws
503
-
504
- let loadedBytes = 0
505
- let lastProgressUpdate = 0
506
-
507
- await new Promise((resolve, reject) => {
508
- rs.on('data', (chunk) => {
509
- if (taskState.aborted) {
510
- rs.destroy()
511
- ws.destroy()
512
- fs.unlink(savePath, () => { })
513
- reject(new Error('Download cancelled'))
514
- return
515
- }
516
- loadedBytes += chunk.length
517
- const now = Date.now()
518
- if (totalBytes > 0 && now - lastProgressUpdate > PROGRESS_THROTTLE) {
519
- lastProgressUpdate = now
520
- const percent = Math.round((loadedBytes / totalBytes) * 100)
521
- this.emit('download:progress', { taskId, loaded: loadedBytes, total: totalBytes, percent })
522
- }
523
- })
524
-
525
- rs.pipe(ws)
526
- ws.on('finish', resolve)
527
- ws.on('error', reject)
528
- rs.on('error', reject)
529
- })
530
-
531
- if (taskState.aborted) throw new Error('Download cancelled')
532
-
533
- this.emit('download:status', { taskId, status: 'verifying' })
534
-
535
- const { cid: downloadedCid } = await calculateCid(savePath)
536
- const expectedHash = b4a.toString(parsedCid.multihash.digest, 'hex')
537
- const actualHash = b4a.toString(downloadedCid.multihash.digest, 'hex')
538
-
539
- if (expectedHash !== actualHash) {
540
- fs.unlinkSync(savePath)
541
- throw new IntegrityError(`File content CID mismatch. File may be corrupted or tampered.`)
542
- }
543
-
544
- // Write file content to Hyperdrive so it can be served for preview
545
- const driveKey = '/' + cidString
546
- const readStream = fs.createReadStream(savePath)
547
- const writeStream = drive.createWriteStream(driveKey)
548
- await new Promise((resolve, reject) => {
549
- readStream.pipe(writeStream)
550
- writeStream.on('finish', resolve)
551
- writeStream.on('error', reject)
552
- readStream.on('error', reject)
553
- })
554
-
555
- const result = {
556
- taskId,
557
- fileName: sanitizedFileName,
558
- savedPath: savePath
559
- }
560
-
561
- // 将下载的文件添加到已发布文件列表(displayName 用原始文件名)
562
- const existingIndex = this.#publishedFiles.findIndex(f => f.cid === cidString)
563
- if (existingIndex !== -1) {
564
- const existing = this.#publishedFiles[existingIndex]
565
- if (existing.fileName !== sanitizedFileName) {
566
- throw new Error(`文件已存在: ${existing.fileName}`)
567
- }
568
- existing.publishedAt = new Date().toISOString()
569
- } else {
570
- this.#publishedFiles.push({
571
- fileName: sanitizedFileName,
572
- cid: cidString,
573
- driveName: name,
574
- publishedAt: new Date().toISOString(),
575
- starred: false
576
- })
577
- }
578
- this.#savePublishedMetadata()
579
-
580
- this.emit('download:success', result)
581
- return result
582
- }
583
- } finally {
584
- this.#activeDownloads.delete(taskId)
585
- }
586
- }
587
-
588
- /**
589
- * 列出所有已发布文件
590
- * @param {object} [options] - 筛选选项
591
- * @param {boolean} [options.starred] - 按收藏状态筛选
592
- * @returns {Array<{ fileName: string, cid: string, link: string, publishedAt: string, starred: boolean }>}
593
- */
594
- listPublishedFiles(options = {}) {
595
- this.#ensureInitialized()
596
- let files = this.#publishedFiles
597
-
598
- if (options.starred === true) {
599
- files = files.filter(f => f.starred === true)
600
- }
601
-
602
- return files.map(f => ({
603
- fileName: f.fileName,
604
- cid: f.cid,
605
- link: `most://${f.cid}`,
606
- publishedAt: f.publishedAt,
607
- starred: f.starred || false
608
- }))
609
- }
610
-
611
- /**
612
- * 切换文件的收藏状态
613
- * @param {string} cid - 文件的 CID
614
- * @returns {object} 更新后的文件信息
615
- */
616
- toggleStarred(cid) {
617
- this.#ensureInitialized()
618
- const index = this.#publishedFiles.findIndex(f => f.cid === cid)
619
- if (index === -1) {
620
- throw new Error('File not found')
621
- }
622
- this.#publishedFiles[index].starred = !this.#publishedFiles[index].starred
623
- this.#savePublishedMetadata()
624
- return {
625
- cid,
626
- starred: this.#publishedFiles[index].starred
627
- }
628
- }
629
-
630
- /**
631
- * 删除已发布文件 — 移至回收站而非永久删除
632
- * @param {string} cid - 要删除文件的 CID
633
- * @returns {Promise<Array>} 更新后的已发布文件列表
634
- */
635
- async deletePublishedFile(cid) {
636
- this.#ensureInitialized()
637
- const index = this.#publishedFiles.findIndex(f => f.cid === cid)
638
- if (index !== -1) {
639
- const fileRecord = this.#publishedFiles[index]
640
-
641
- this.#trashFiles.push({
642
- fileName: fileRecord.fileName,
643
- cid: fileRecord.cid,
644
- driveName: fileRecord.driveName,
645
- publishedAt: fileRecord.publishedAt,
646
- starred: fileRecord.starred || false,
647
- deletedAt: new Date().toISOString()
648
- })
649
- this.#saveTrashMetadata()
650
-
651
- this.#publishedFiles.splice(index, 1)
652
- this.#savePublishedMetadata()
653
- }
654
- return this.listPublishedFiles()
655
- }
656
-
657
- /**
658
- * 列出回收站中的所有文件
659
- * @returns {Array} 回收站文件
660
- */
661
- listTrashFiles() {
662
- this.#ensureInitialized()
663
- return this.#trashFiles.map(f => ({
664
- fileName: f.fileName,
665
- cid: f.cid,
666
- link: `most://${f.cid}`,
667
- publishedAt: f.publishedAt,
668
- starred: f.starred || false,
669
- deletedAt: f.deletedAt
670
- }))
671
- }
672
-
673
- /**
674
- * 从回收站恢复文件
675
- * @param {string} cid - 要恢复文件的 CID
676
- * @returns {Array} 更新后的已发布文件列表
677
- */
678
- restoreTrashFile(cid) {
679
- this.#ensureInitialized()
680
- const index = this.#trashFiles.findIndex(f => f.cid === cid)
681
- if (index === -1) {
682
- throw new Error('File not found in trash')
683
- }
684
-
685
- const fileRecord = this.#trashFiles[index]
686
-
687
- const parsedCid = CID.parse(fileRecord.cid)
688
- const hashHex = b4a.toString(parsedCid.multihash.digest, 'hex')
689
- const driveName = `drive-${hashHex}`
690
-
691
- this.#publishedFiles.push({
692
- fileName: fileRecord.fileName,
693
- cid: fileRecord.cid,
694
- driveName,
695
- publishedAt: fileRecord.publishedAt,
696
- starred: fileRecord.starred || false
697
- })
698
- this.#savePublishedMetadata()
699
-
700
- this.#trashFiles.splice(index, 1)
701
- this.#saveTrashMetadata()
702
-
703
- return this.listPublishedFiles()
704
- }
705
-
706
- /**
707
- * 永久删除回收站中的文件
708
- * @param {string} cid - 要永久删除文件的 CID
709
- * @returns {Promise<Array>} 更新后的回收站列表
710
- */
711
- async permanentDeleteTrashFile(cid) {
712
- this.#ensureInitialized()
713
- const index = this.#trashFiles.findIndex(f => f.cid === cid)
714
- if (index !== -1) {
715
- const fileRecord = this.#trashFiles[index]
716
- const driveName = fileRecord.driveName
717
-
718
- const drive = this.#drives.get(driveName)
719
- if (drive) {
720
- try {
721
- await drive.del('/' + fileRecord.cid)
722
- } catch (err) {
723
- // 文件可能不存在于驱动器中
724
- }
725
-
726
- await this.#swarm.leave(drive.discoveryKey)
727
- await drive.close()
728
- this.#drives.delete(driveName)
729
- }
730
-
731
- this.#trashFiles.splice(index, 1)
732
- this.#saveTrashMetadata()
733
- }
734
- return this.listTrashFiles()
735
- }
736
-
737
- /**
738
- * 清空回收站 — 永久删除所有回收站文件
739
- * @returns {Promise<Array>} 清空后的回收站列表
740
- */
741
- async emptyTrash() {
742
- this.#ensureInitialized()
743
-
744
- for (const fileRecord of this.#trashFiles) {
745
- const driveName = fileRecord.driveName
746
-
747
- const drive = this.#drives.get(driveName)
748
- if (drive) {
749
- try {
750
- await drive.del('/' + fileRecord.cid)
751
- } catch (err) {
752
- // 文件可能不存在
753
- }
754
-
755
- await this.#swarm.leave(drive.discoveryKey)
756
- await drive.close()
757
- this.#drives.delete(driveName)
758
- }
759
- }
760
-
761
- this.#trashFiles = []
762
- this.#saveTrashMetadata()
763
-
764
- return []
765
- }
766
-
767
- /**
768
- * 获取存储统计信息
769
- * @returns {Promise<{ total: number, used: number, free: number, fileCount: number, trashCount: number }>}
770
- */
771
- async getStorageStats() {
772
- this.#ensureInitialized()
773
-
774
- let totalSize = 0
775
- let freeSize = 0
776
- const { dataPath } = this.#options
777
-
778
- try {
779
- const stats = fs.statfsSync(dataPath)
780
- totalSize = stats.bsize * stats.blocks
781
- freeSize = stats.bsize * stats.bfree
782
- } catch (err) {
783
- try {
784
- const stats = fs.statSync(dataPath)
785
- totalSize = 0
786
- freeSize = 0
787
- } catch {
788
- totalSize = 0
789
- freeSize = 0
790
- }
791
- }
792
-
793
- let usedSize = 0
794
- const calculateDirSize = (dirPath) => {
795
- try {
796
- const entries = fs.readdirSync(dirPath, { withFileTypes: true })
797
- for (const entry of entries) {
798
- const fullPath = path.join(dirPath, entry.name)
799
- if (entry.isDirectory()) {
800
- if (entry.name !== 'db') {
801
- calculateDirSize(fullPath)
802
- }
803
- } else {
804
- try {
805
- const stat = fs.statSync(fullPath)
806
- usedSize += stat.size
807
- } catch {
808
- // 跳过无法访问的文件
809
- }
810
- }
811
- }
812
- } catch {
813
- // 跳过无法访问的目录
814
- }
815
- }
816
-
817
- calculateDirSize(dataPath)
818
-
819
- return {
820
- total: totalSize,
821
- used: usedSize,
822
- free: freeSize,
823
- fileCount: this.#publishedFiles.length,
824
- trashCount: this.#trashFiles.length
825
- }
826
- }
827
-
828
- /**
829
- * 移动/重命名已发布文件
830
- * 只更新 metadata 中的 displayName,不修改 Hyperdrive
831
- * @param {string} cid - 要移动文件的 CID
832
- * @param {string} newFileName - 新文件路径
833
- * @returns {object} 更新后的文件信息
834
- */
835
- moveFile(cid, newFileName) {
836
- this.#ensureInitialized()
837
- const index = this.#publishedFiles.findIndex(f => f.cid === cid)
838
- if (index === -1) {
839
- throw new Error('File not found')
840
- }
841
- const safeFileName = sanitizeFilename(newFileName)
842
- this.#publishedFiles[index].fileName = safeFileName
843
- this.#publishedFiles[index].publishedAt = new Date().toISOString()
844
- this.#savePublishedMetadata()
845
- return {
846
- cid,
847
- fileName: safeFileName,
848
- link: `most://${cid}`
849
- }
850
- }
851
-
852
- /**
853
- * 重命名文件夹(重命名文件夹内的所有文件 displayName)
854
- * 只更新 metadata,不修改 Hyperdrive
855
- * @param {string} oldPath - 当前文件夹路径
856
- * @param {string} newPath - 新文件夹路径
857
- * @returns {object} 更新后的文件信息
858
- */
859
- renameFolder(oldPath, newPath) {
860
- this.#ensureInitialized()
861
- const prefix = oldPath + '/'
862
- const updatedFiles = []
863
-
864
- for (const file of this.#publishedFiles) {
865
- if (file.fileName.startsWith(prefix)) {
866
- const remainder = file.fileName.substring(prefix.length)
867
- const newFileName = sanitizeFilename(remainder ? newPath + '/' + remainder : newPath)
868
- file.fileName = newFileName
869
- file.publishedAt = new Date().toISOString()
870
- updatedFiles.push({
871
- cid: file.cid,
872
- fileName: file.fileName,
873
- link: `most://${file.cid}`
874
- })
875
- }
876
- }
877
-
878
- if (updatedFiles.length > 0) {
879
- this.#savePublishedMetadata()
880
- }
881
-
882
- return { files: updatedFiles }
883
- }
884
-
885
- /**
886
- * 取消正在进行的下载
887
- * @param {string} taskId - 要取消下载的任务 ID
888
- */
889
- cancelDownload(taskId) {
890
- const task = this.#activeDownloads.get(taskId)
891
- if (task) {
892
- task.aborted = true
893
- if (task.readStream) task.readStream.destroy()
894
- if (task.writeStream) task.writeStream.destroy()
895
- }
896
- }
897
-
898
- getPublishedFiles() {
899
- return this.#publishedFiles
900
- }
901
-
902
- /**
903
- * 读取已发布文件的内容(用于预览)
904
- * Hyperdrive 中用 CID 作为 key 存储
905
- * @param {string} cid - 文件的 CID
906
- * @param {number} [offset=0] - 读取起始位置
907
- * @param {number} [limit=10000] - 最大读取字节数
908
- */
909
- async readFileContent(cid, offset = 0, limit = DEFAULT_READ_LIMIT) {
910
- this.#ensureInitialized()
911
-
912
- const fileRecord = this.#publishedFiles.find(f => f.cid === cid)
913
- if (!fileRecord) {
914
- throw new Error('File not found')
915
- }
916
-
917
- const drive = await this.#getDriveForFile(fileRecord)
918
-
919
- // Hyperdrive 中 key 为 '/' + cid
920
- const driveKey = '/' + cid
921
- const entry = await drive.entry(driveKey, { wait: true, timeout: DRIVE_ENTRY_TIMEOUT })
922
- if (!entry || !entry.value) {
923
- throw new Error('File content not available')
924
- }
925
-
926
- const chunks = []
927
- const stream = drive.createReadStream(driveKey, { start: offset, end: offset + limit - 1 })
928
-
929
- const timeoutPromise = new Promise((_, reject) => {
930
- setTimeout(() => reject(new Error('Stream read timeout')), STREAM_READ_TIMEOUT)
931
- })
932
-
933
- const readPromise = (async () => {
934
- for await (const chunk of stream) {
935
- chunks.push(chunk)
936
- }
937
- })()
938
-
939
- await Promise.race([readPromise, timeoutPromise])
940
-
941
- const content = Buffer.concat(chunks).toString('utf8')
942
- const hasMore = chunks.length > 0 && chunks[chunks.length - 1].length === limit
943
-
944
- return { content, hasMore }
945
- }
946
-
947
- /**
948
- * 读取已发布文件的原始内容(用于预览/下载)
949
- * Hyperdrive 中用 CID 作为 key 存储
950
- * @param {string} cid - 文件的 CID
951
- * @param {object} [options] - 选项
952
- * @param {number} [options.offset=0] - 读取起始位置
953
- * @param {number} [options.limit] - 最大读取字节数,不指定则读取到末尾
954
- * @param {number} [options.timeout=10000] - 流读取超时(毫秒)
955
- * @returns {Promise<{buffer: Buffer, fileName: string, totalSize: number}>}
956
- */
957
- async readFileRaw(cid, options = {}) {
958
- this.#ensureInitialized()
959
-
960
- const fileRecord = this.#publishedFiles.find(f => f.cid === cid)
961
- if (!fileRecord) {
962
- throw new Error('File not found')
963
- }
964
-
965
- const drive = await this.#getDriveForFile(fileRecord)
966
-
967
- const driveKey = '/' + cid
968
- const entry = await drive.entry(driveKey, { wait: true, timeout: DRIVE_ENTRY_TIMEOUT })
969
- if (!entry || !entry.value || !entry.value.blob) {
970
- throw new Error('File content not available')
971
- }
972
-
973
- const totalSize = entry.value.blob.byteLength || 0
974
-
975
- const { offset = 0, limit, timeout = STREAM_READ_TIMEOUT } = options
976
- const effectiveLimit = (limit === undefined || limit === null)
977
- ? totalSize - offset
978
- : Math.min(limit, totalSize - offset)
979
-
980
- if (effectiveLimit <= 0) {
981
- return { buffer: Buffer.alloc(0), fileName: fileRecord.fileName, totalSize }
982
- }
983
-
984
- const chunks = []
985
- const stream = drive.createReadStream(driveKey, {
986
- start: offset,
987
- end: offset + effectiveLimit - 1
988
- })
989
-
990
- const timeoutPromise = new Promise((_, reject) => {
991
- setTimeout(() => reject(new Error('Stream read timeout')), timeout)
992
- })
993
-
994
- const readPromise = (async () => {
995
- try {
996
- for await (const chunk of stream) {
997
- chunks.push(chunk)
998
- }
999
- } catch (err) {
1000
- if (err.message !== 'Stream read timeout') {
1001
- throw err
1002
- }
1003
- }
1004
- })()
1005
-
1006
- await Promise.race([readPromise, timeoutPromise])
1007
- await readPromise.catch(() => { })
1008
-
1009
- const buffer = Buffer.concat(chunks)
1010
- return { buffer, fileName: fileRecord.fileName, totalSize }
1011
- }
1012
-
1013
- /**
1014
- * 获取文件对应的 drive,如果不存在则创建并同步
1015
- */
1016
- async #getDriveForFile(fileRecord) {
1017
- let drive = this.#drives.get(fileRecord.driveName)
1018
- if (!drive) {
1019
- drive = await this.#getOrCreateDrive(fileRecord.driveName, { server: true, client: true })
1020
- }
1021
- await this.#syncDrive(drive)
1022
- return drive
1023
- }
1024
-
1025
- // --- 频道管理 ---
1026
-
1027
- /**
1028
- * 创建或加入频道
1029
- * @param {string} name - 频道名
1030
- * @param {string} [type='personal'] - 频道类型
1031
- * @returns {Promise<{ name: string, key: string }>}
1032
- */
1033
- async createChannel(name, type = 'personal') {
1034
- this.#ensureInitialized()
1035
-
1036
- if (!CHANNEL_NAME_REGEX.test(name)) {
1037
- throw new Error('频道名只能包含字母、数字、下划线和连字符')
1038
- }
1039
- if (name.length < CHANNEL_NAME_MIN_LENGTH) {
1040
- throw new Error(`频道名至少 ${CHANNEL_NAME_MIN_LENGTH} 个字符`)
1041
- }
1042
- if (name.length > CHANNEL_NAME_MAX_LENGTH) {
1043
- throw new Error(`频道名最多 ${CHANNEL_NAME_MAX_LENGTH} 个字符`)
1044
- }
1045
-
1046
- const existing = this.#channels.find(c => c.name === name)
1047
- if (existing) {
1048
- return { name: existing.name, key: existing.coreKey }
1049
- }
1050
-
1051
- const ns = this.#store.namespace(`channel-${name}`)
1052
- const core = ns.get({ name: 'messages', valueEncoding: 'json' })
1053
- await core.ready()
1054
-
1055
- const discoveryKey = this.#generateChannelDiscoveryKey(name)
1056
- const discovery = this.#swarm.join(discoveryKey, { server: true, client: true })
1057
- await discovery.flushed()
1058
-
1059
- let lastCoreLength = core.length
1060
- core.on('append', async () => {
1061
- if (core.length > lastCoreLength) {
1062
-
1063
- for (let i = lastCoreLength; i < core.length; i++) {
1064
- try {
1065
- const entry = await core.get(i)
1066
- if (entry && entry.type === 'message') {
1067
-
1068
- this.emit('channel:message', { channel: name, message: entry })
1069
- }
1070
- } catch (err) {
1071
-
1072
- }
1073
- }
1074
- lastCoreLength = core.length
1075
- }
1076
- })
1077
-
1078
- const channelInfo = {
1079
- name,
1080
- discoveryKey: b4a.toString(discoveryKey, 'hex'),
1081
- coreKey: b4a.toString(core.key, 'hex'),
1082
- createdAt: new Date().toISOString(),
1083
- type
1084
- }
1085
-
1086
- this.#channels.push(channelInfo)
1087
- this.#channelCores.set(name, core)
1088
- this.#channelPeers.set(name, new Map())
1089
- this.#saveChannelsMetadata()
1090
-
1091
- console.log(`[MostBox] Channel created: ${name}`)
1092
- this.emit('channel:joined', { name, key: channelInfo.coreKey })
1093
-
1094
- return { name, key: channelInfo.coreKey }
1095
- }
1096
-
1097
- /**
1098
- * 加入已有频道(通过频道名和 coreKey)
1099
- * @param {string} name - 频道名
1100
- * @param {string} [coreKey] - 频道的 coreKey(加入他人创建的频道时必填)
1101
- * @returns {Promise<{ name: string, key: string }>}
1102
- */
1103
- async joinChannel(name, coreKey = null) {
1104
- this.#ensureInitialized()
1105
-
1106
- const existing = this.#channels.find(c => c.name === name)
1107
- if (existing) {
1108
- return { name: existing.name, key: existing.coreKey }
1109
- }
1110
-
1111
- if (!coreKey) {
1112
- throw new Error('加入已有频道需要提供 coreKey')
1113
- }
1114
-
1115
- const ns = this.#store.namespace(`channel-${name}`)
1116
- const core = ns.get({ key: b4a.from(coreKey, 'hex'), valueEncoding: 'json' })
1117
- await core.ready()
1118
-
1119
- const discoveryKey = this.#generateChannelDiscoveryKey(name)
1120
- const discovery = this.#swarm.join(discoveryKey, { server: true, client: true })
1121
- await discovery.flushed()
1122
-
1123
- let lastCoreLength = core.length
1124
- core.on('append', async () => {
1125
- if (core.length > lastCoreLength) {
1126
-
1127
- for (let i = lastCoreLength; i < core.length; i++) {
1128
- try {
1129
- const entry = await core.get(i)
1130
- if (entry && entry.type === 'message') {
1131
-
1132
- this.emit('channel:message', { channel: name, message: entry })
1133
- }
1134
- } catch (err) {
1135
-
1136
- }
1137
- }
1138
- lastCoreLength = core.length
1139
- }
1140
- })
1141
-
1142
- const channelInfo = {
1143
- name,
1144
- discoveryKey: b4a.toString(discoveryKey, 'hex'),
1145
- coreKey,
1146
- createdAt: new Date().toISOString(),
1147
- type: 'group'
1148
- }
1149
-
1150
- this.#channels.push(channelInfo)
1151
- this.#channelCores.set(name, core)
1152
- this.#channelPeers.set(name, new Map())
1153
- this.#saveChannelsMetadata()
1154
-
1155
- console.log(`[MostBox] Joined channel: ${name}`)
1156
- this.emit('channel:joined', { name, key: coreKey })
1157
-
1158
- return { name, key: coreKey }
1159
- }
1160
-
1161
- /**
1162
- * 离开频道
1163
- * @param {string} name - 频道名
1164
- * @returns {Promise<string[]>} 剩余频道列表
1165
- */
1166
- async leaveChannel(name) {
1167
- this.#ensureInitialized()
1168
-
1169
- const index = this.#channels.findIndex(c => c.name === name)
1170
- if (index === -1) {
1171
- throw new Error('频道不存在')
1172
- }
1173
-
1174
- const channel = this.#channels[index]
1175
-
1176
- const discovery = this.#channelDiscoveries.get(name)
1177
- if (discovery) {
1178
- try {
1179
- await this.#swarm.leave(b4a.from(channel.discoveryKey, 'hex'))
1180
- } catch {}
1181
- this.#channelDiscoveries.delete(name)
1182
- }
1183
-
1184
- const core = this.#channelCores.get(name)
1185
- if (core) {
1186
- try {
1187
- await core.close()
1188
- } catch {}
1189
- this.#channelCores.delete(name)
1190
- }
1191
-
1192
- this.#channelPeers.delete(name)
1193
- this.#channels.splice(index, 1)
1194
- this.#saveChannelsMetadata()
1195
-
1196
- console.log(`[MostBox] Left channel: ${name}`)
1197
- this.emit('channel:left', { name })
1198
-
1199
- return this.listChannels()
1200
- }
1201
-
1202
- /**
1203
- * 列出所有频道
1204
- * @returns {Array<{ name: string, coreKey: string, createdAt: string, type: string, peerCount: number }>}
1205
- */
1206
- listChannels() {
1207
- this.#ensureInitialized()
1208
-
1209
- return this.#channels.map(c => ({
1210
- name: c.name,
1211
- coreKey: c.coreKey,
1212
- createdAt: c.createdAt,
1213
- type: c.type,
1214
- peerCount: (this.#channelPeers.get(c.name) || new Map()).size
1215
- }))
1216
- }
1217
-
1218
- /**
1219
- * 获取频道消息
1220
- * @param {string} name - 频道名
1221
- * @param {object} [options] - 选项
1222
- * @param {number} [options.limit=100] - 消息数量
1223
- * @param {number} [options.offset=0] - 偏移量
1224
- * @returns {Promise<Array>}
1225
- */
1226
- async getChannelMessages(name, options = {}) {
1227
- this.#ensureInitialized()
1228
-
1229
- const { limit = CHANNEL_MESSAGE_LIMIT, offset = 0 } = options
1230
-
1231
- const core = this.#channelCores.get(name)
1232
- if (!core) {
1233
- throw new Error('频道未初始化')
1234
- }
1235
-
1236
- const messages = []
1237
- const total = core.length
1238
- const start = Math.max(0, total - offset - limit)
1239
- const end = total - offset
1240
-
1241
- for (let i = start; i < end; i++) {
1242
- try {
1243
- const entry = await core.get(i)
1244
- messages.push(entry)
1245
- } catch {
1246
- break
1247
- }
1248
- }
1249
-
1250
- return messages
1251
- }
1252
-
1253
- /**
1254
- * 发送消息到频道
1255
- * @param {string} name - 频道名
1256
- * @param {string} content - 消息内容
1257
- * @param {string} [authorName] - 作者名(可选,默认使用 displayName)
1258
- * @returns {Promise<object>}
1259
- */
1260
- async sendMessage(name, content, authorName = null) {
1261
- this.#ensureInitialized()
1262
-
1263
- const core = this.#channelCores.get(name)
1264
- if (!core) {
1265
- throw new Error('频道未初始化')
1266
- }
1267
-
1268
- if (!content || !content.trim()) {
1269
- throw new Error('消息内容不能为空')
1270
- }
1271
-
1272
- const trimmed = content.trim()
1273
- if (trimmed.length > MAX_MESSAGE_LENGTH) {
1274
- throw new Error(`消息内容不能超过 ${MAX_MESSAGE_LENGTH} 字符`)
1275
- }
1276
-
1277
- const displayName = this.getNodeId().slice(0, 4)
1278
-
1279
- const message = {
1280
- type: 'message',
1281
- author: this.getNodeId(),
1282
- authorName: displayName,
1283
- content: trimmed,
1284
- timestamp: Date.now()
1285
- }
1286
-
1287
- await core.append(message)
1288
-
1289
- this.emit('channel:message', { channel: name, message })
1290
-
1291
- return message
1292
- }
1293
-
1294
- /**
1295
- * 获取频道内在线用户
1296
- * @param {string} name - 频道名
1297
- * @returns {Array<{ peerId: string, authorName: string, lastSeen: number }>}
1298
- */
1299
- getChannelPeers(name) {
1300
- this.#ensureInitialized()
1301
-
1302
- const peers = this.#channelPeers.get(name)
1303
- if (!peers) {
1304
- throw new Error('频道未初始化')
1305
- }
1306
-
1307
- return [...peers.values()].map(p => ({
1308
- peerId: p.peerId,
1309
- authorName: p.authorName,
1310
- lastSeen: p.lastSeen
1311
- }))
1312
- }
1313
-
1314
- /**
1315
- * 获取显示名
1316
- * @returns {string|null}
1317
- */
1318
- getDisplayName() {
1319
- try {
1320
- const configPath = this.#getConfigPath()
1321
- if (fs.existsSync(configPath)) {
1322
- const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'))
1323
- return config.displayName || null
1324
- }
1325
- } catch {}
1326
- return null
1327
- }
1328
-
1329
- /**
1330
- * 设置显示名
1331
- * @param {string} name - 显示名
1332
- */
1333
- setDisplayName(name) {
1334
- try {
1335
- const configPath = this.#getConfigPath()
1336
- const config = fs.existsSync(configPath) ? JSON.parse(fs.readFileSync(configPath, 'utf-8')) : {}
1337
- config.displayName = name.trim()
1338
- const tmpPath = configPath + '.tmp'
1339
- fs.writeFileSync(tmpPath, JSON.stringify(config, null, 2), 'utf-8')
1340
- fs.renameSync(tmpPath, configPath)
1341
- return true
1342
- } catch {
1343
- return false
1344
- }
1345
- }
1346
-
1347
- // --- 私有方法 ---
1348
-
1349
- #ensureInitialized() {
1350
- if (!this.#initialized) {
1351
- throw new EngineNotInitializedError()
1352
- }
1353
- }
1354
-
1355
- async #getOrCreateDrive(name, options = { server: true, client: false }) {
1356
- if (this.#drives.has(name)) return this.#drives.get(name)
1357
- if (this.#drivePromises.has(name)) return this.#drivePromises.get(name)
1358
-
1359
- const promise = (async () => {
1360
- const drive = new Hyperdrive(this.#store.namespace(name))
1361
- await drive.ready()
1362
- this.#drives.set(name, drive)
1363
- return drive
1364
- })()
1365
-
1366
- this.#drivePromises.set(name, promise)
1367
-
1368
- try {
1369
- const drive = await promise
1370
- return drive
1371
- } finally {
1372
- this.#drivePromises.delete(name)
1373
- }
1374
- }
1375
-
1376
- async #syncDrive(drive, timeout = DRIVE_SYNC_TIMEOUT) {
1377
- const done = drive.findingPeers()
1378
- this.#swarm.join(drive.discoveryKey, { server: true, client: true }).flushed().then(done, done)
1379
- try {
1380
- const updated = await Promise.race([
1381
- drive.update(),
1382
- new Promise((_, reject) => setTimeout(() => reject(new Error('Sync timeout')), timeout))
1383
- ])
1384
- return updated
1385
- } catch {
1386
- return false
1387
- }
1388
- }
1389
-
1390
- #getMetadataPath() {
1391
- return path.join(this.#options.dataPath, 'published-files.json')
1392
- }
1393
-
1394
- #getTrashMetadataPath() {
1395
- return path.join(this.#options.dataPath, 'trash-files.json')
1396
- }
1397
-
1398
- #atomicWrite(filePath, data) {
1399
- const tmpPath = filePath + '.tmp'
1400
- fs.writeFileSync(tmpPath, data, 'utf-8')
1401
- fs.renameSync(tmpPath, filePath)
1402
- }
1403
-
1404
- #loadPublishedMetadata() {
1405
- try {
1406
- const metadataPath = this.#getMetadataPath()
1407
- if (fs.existsSync(metadataPath)) {
1408
- const data = fs.readFileSync(metadataPath, 'utf-8')
1409
- const parsed = JSON.parse(data)
1410
- return parsed.map(f => ({ ...f, starred: f.starred || false }))
1411
- }
1412
- } catch (err) {
1413
- console.warn('Failed to load published metadata, using empty list:', err.message)
1414
- }
1415
- return []
1416
- }
1417
-
1418
- #savePublishedMetadata() {
1419
- try {
1420
- const metadataPath = this.#getMetadataPath()
1421
- this.#atomicWrite(metadataPath, JSON.stringify(this.#publishedFiles, null, 2))
1422
- } catch (err) {
1423
- console.error('Failed to save published metadata:', err.message)
1424
- }
1425
- }
1426
-
1427
- #loadTrashMetadata() {
1428
- try {
1429
- const metadataPath = this.#getTrashMetadataPath()
1430
- if (fs.existsSync(metadataPath)) {
1431
- const data = fs.readFileSync(metadataPath, 'utf-8')
1432
- return JSON.parse(data)
1433
- }
1434
- } catch (err) {
1435
- console.warn('Failed to load trash metadata, using empty list:', err.message)
1436
- }
1437
- return []
1438
- }
1439
-
1440
- #saveTrashMetadata() {
1441
- try {
1442
- const metadataPath = this.#getTrashMetadataPath()
1443
- this.#atomicWrite(metadataPath, JSON.stringify(this.#trashFiles, null, 2))
1444
- } catch (err) {
1445
- console.error('Failed to save trash metadata:', err.message)
1446
- }
1447
- }
1448
-
1449
- #getChannelsMetadataPath() {
1450
- return path.join(this.#options.dataPath, 'channels.json')
1451
- }
1452
-
1453
- #getConfigPath() {
1454
- return path.join(this.#options.dataPath, 'channel-config.json')
1455
- }
1456
-
1457
- #loadChannelsMetadata() {
1458
- try {
1459
- const metadataPath = this.#getChannelsMetadataPath()
1460
- if (fs.existsSync(metadataPath)) {
1461
- const data = fs.readFileSync(metadataPath, 'utf-8')
1462
- return JSON.parse(data)
1463
- }
1464
- } catch (err) {
1465
- console.warn('Failed to load channels metadata, using empty list:', err.message)
1466
- }
1467
- return []
1468
- }
1469
-
1470
- #saveChannelsMetadata() {
1471
- try {
1472
- const metadataPath = this.#getChannelsMetadataPath()
1473
- this.#atomicWrite(metadataPath, JSON.stringify(this.#channels, null, 2))
1474
- } catch (err) {
1475
- console.error('Failed to save channels metadata:', err.message)
1476
- }
1477
- }
1478
-
1479
- #generateChannelDiscoveryKey(name) {
1480
- const hash = crypto.createHash('sha256')
1481
- .update(`${CHANNEL_NAME_PREFIX}${name}`)
1482
- .digest()
1483
- return hash
1484
- }
1485
-
1486
- async #handleChannelConnection(conn) {
1487
- const stream = conn
1488
- let connectedPeerId = null
1489
- let connectedAuthorName = null
1490
-
1491
- const helloMessage = JSON.stringify({
1492
- type: 'channel-hello',
1493
- peerId: this.getNodeId(),
1494
- authorName: this.getNodeId().slice(0, 4),
1495
- channels: this.#channels.map(c => c.name)
1496
- })
1497
-
1498
- try {
1499
- stream.write(helloMessage)
1500
- } catch {
1501
- return
1502
- }
1503
-
1504
- stream.on('data', async (data) => {
1505
- try {
1506
- const msg = JSON.parse(data.toString())
1507
- if (msg.type === 'channel-hello') {
1508
- connectedPeerId = msg.peerId
1509
- connectedAuthorName = msg.authorName
1510
-
1511
- const theirChannels = new Set(msg.channels || [])
1512
- for (const [name, peers] of this.#channelPeers) {
1513
- if (theirChannels.has(name)) {
1514
- peers.set(msg.peerId, {
1515
- peerId: msg.peerId,
1516
- authorName: msg.authorName,
1517
- lastSeen: Date.now()
1518
- })
1519
- const core = this.#channelCores.get(name)
1520
- if (core) {
1521
- const ns = this.#store.namespace(`channel-${name}`)
1522
- ns.replicate(conn).catch(() => {})
1523
- }
1524
- }
1525
- }
1526
- this.emit('channel:peer:online', { peerId: msg.peerId, authorName: msg.authorName })
1527
- }
1528
- } catch {}
1529
- })
1530
-
1531
- stream.on('close', () => {
1532
- if (connectedPeerId) {
1533
- for (const [name, peers] of this.#channelPeers) {
1534
- if (peers.has(connectedPeerId)) {
1535
- const peer = peers.get(connectedPeerId)
1536
- peers.delete(connectedPeerId)
1537
- this.emit('channel:peer:offline', { peerId: connectedPeerId, authorName: peer?.authorName })
1538
- }
1539
- }
1540
- }
1541
- })
1542
- }
1543
-
1544
- /**
1545
- * 等待驱动器内容从对等节点或本地可用
1546
- * @param {Hyperdrive} drive - 要检查的驱动器
1547
- * @param {number} timeout - 最大等待时间(毫秒)
1548
- * @param {string} [taskId] - 用于取消的任务 ID
1549
- * @param {object} [taskState] - 任务状态对象
1550
- * @returns {Promise<Array>} - 条目列表
1551
- */
1552
- async #waitForDriveContent(drive, timeout, taskId = null, taskState = null) {
1553
- const startTime = Date.now()
1554
- const checkInterval = DOWNLOAD_POLL_INTERVAL
1555
- let lastPeerCount = 0
1556
- let lastStatus = ''
1557
- let bootstrapNodesChecked = false
1558
-
1559
- const localEntries = []
1560
- try {
1561
- for await (const entry of drive.list()) {
1562
- localEntries.push(entry)
1563
- }
1564
- if (localEntries.length > 0) {
1565
- console.log(`[MostBox] Found ${localEntries.length} entries locally`)
1566
- this.emit('download:status', { taskId, status: 'syncing' })
1567
- return localEntries
1568
- }
1569
- } catch (err) {
1570
- // 继续进行节点发现
1571
- }
1572
-
1573
- while (Date.now() - startTime < timeout) {
1574
- if (taskState && taskState.aborted) {
1575
- throw new Error('Download cancelled')
1576
- }
1577
-
1578
- const currentTime = Date.now()
1579
- const elapsed = Math.round((currentTime - startTime) / 1000)
1580
-
1581
- const currentPeerCount = this.#swarm.connections.size
1582
- const hasPeers = currentPeerCount > 0
1583
-
1584
- if (currentPeerCount !== lastPeerCount) {
1585
- console.log(`[MostBox] Peer count changed: ${lastPeerCount} -> ${currentPeerCount} (elapsed: ${elapsed}s)`)
1586
- lastPeerCount = currentPeerCount
1587
- }
1588
-
1589
- const entries = []
1590
- try {
1591
- for await (const entry of drive.list()) {
1592
- entries.push(entry)
1593
- }
1594
- } catch (err) {
1595
- // 驱动器可能尚未就绪
1596
- }
1597
-
1598
- if (entries.length > 0) {
1599
- console.log(`[MostBox] Found ${entries.length} entries after ${elapsed}s`)
1600
- this.emit('download:status', { taskId, status: 'syncing' })
1601
- return entries
1602
- }
1603
-
1604
- if (hasPeers) {
1605
- const newStatus = 'syncing'
1606
- if (lastStatus !== newStatus) {
1607
- this.emit('download:status', { taskId, status: newStatus })
1608
- lastStatus = newStatus
1609
- }
1610
- } else {
1611
- const newStatus = 'finding-peers'
1612
- if (lastStatus !== newStatus) {
1613
- this.emit('download:status', { taskId, status: newStatus })
1614
- lastStatus = newStatus
1615
- }
1616
-
1617
- if (elapsed % 30 === 0 && elapsed > 0) {
1618
- console.log(`[MostBox] Still waiting for peers... (${elapsed}s elapsed, timeout: ${timeout / 1000}s)`)
1619
-
1620
- if (!bootstrapNodesChecked && elapsed >= 60) {
1621
- bootstrapNodesChecked = true
1622
- console.log(`[MostBox] No peers found after 60s. This may indicate:`)
1623
- console.log(`[MostBox] 1. Network/firewall blocking P2P connections`)
1624
- console.log(`[MostBox] 2. DHT bootstrap nodes unreachable`)
1625
- console.log(`[MostBox] 3. Publisher node offline`)
1626
- console.log(`[MostBox] 4. NAT traversal failed`)
1627
- }
1628
- }
1629
- }
1630
-
1631
- await new Promise(resolve => setTimeout(resolve, checkInterval))
1632
- }
1633
-
1634
- console.log(`[MostBox] Timeout reached after ${timeout / 1000}s, making final attempt...`)
1635
-
1636
- const entries = []
1637
- try {
1638
- for await (const entry of drive.list()) {
1639
- entries.push(entry)
1640
- }
1641
- } catch (err) {
1642
- console.log(`[MostBox] Final attempt failed: ${err.message}`)
1643
- }
1644
-
1645
- console.log(`[MostBox] Final entry count: ${entries.length}`)
1646
-
1647
- if (entries.length === 0) {
1648
- const peerCount = this.#swarm.connections.size
1649
- console.log(`[MostBox] Diagnostic information:`)
1650
- console.log(`[MostBox] - Peer count: ${peerCount}`)
1651
- console.log(`[MostBox] - Bootstrap nodes: ${SWARM_BOOTSTRAP.length}`)
1652
- console.log(`[MostBox] - Timeout: ${timeout / 1000}s`)
1653
-
1654
- if (peerCount === 0) {
1655
- console.log(`[MostBox] Suggestion: Check network connectivity and firewall settings`)
1656
- } else {
1657
- console.log(`[MostBox] Suggestion: Publisher may be offline or file may have been removed`)
1658
- }
1659
- }
1660
-
1661
- return entries
1662
- }
1663
- }
1664
-
1665
- // 重新导出工具函数
1666
- export * from './config.js'
1667
- export * from './core/cid.js'
1668
- export * from './utils/errors.js'
1669
- export * from './utils/security.js'
1
+ /**
2
+ * MostBoxEngine - 核心 P2P 引擎
3
+ * 基于 Hyperswarm/Hyperdrive 的跨平台 P2P 文件共享引擎
4
+ *
5
+ * 架构设计:
6
+ * - Hyperdrive: 只负责存储文件内容,key 使用 CID(解耦存储与目录结构)
7
+ * - published-files.json: 维护文件元数据和显示路径(用户看到的文件夹结构)
8
+ * - 移动/重命名只需更新 JSON,零成本,不修改 Hyperdrive
9
+ */
10
+
11
+ import EventEmitter from 'eventemitter3'
12
+ import Hyperswarm from 'hyperswarm'
13
+ import Corestore from 'corestore'
14
+ import Hyperdrive from 'hyperdrive'
15
+ import b4a from 'b4a'
16
+ import crypto from 'node:crypto'
17
+ import { CID } from 'multiformats/cid'
18
+ import fs from 'node:fs'
19
+ import path from 'node:path'
20
+
21
+ import { calculateCid, parseMostLink } from './core/cid.js'
22
+ import {
23
+ sanitizeFilename,
24
+ validateAndSanitizePath,
25
+ validateFileSize,
26
+ checkDirectoryWritable,
27
+ formatFileSize,
28
+ } from './utils/security.js'
29
+ import {
30
+ ValidationError,
31
+ PathSecurityError,
32
+ FileSizeError,
33
+ PeerNotFoundError,
34
+ IntegrityError,
35
+ PermissionError,
36
+ EngineNotInitializedError,
37
+ } from './utils/errors.js'
38
+ import {
39
+ GLOBAL_SHARED_SEED_STRING,
40
+ MAX_FILE_SIZE,
41
+ CONNECTION_TIMEOUT,
42
+ DOWNLOAD_TIMEOUT,
43
+ SWARM_BOOTSTRAP,
44
+ MAX_PEERS,
45
+ SWARM_KEEP_ALIVE_INTERVAL,
46
+ SWARM_RANDOM_PUNCH_INTERVAL,
47
+ DRIVE_ENTRY_TIMEOUT,
48
+ DRIVE_SYNC_TIMEOUT,
49
+ STREAM_READ_TIMEOUT,
50
+ FILE_WRITE_CHUNK_SIZE,
51
+ DOWNLOAD_POLL_INTERVAL_MIN,
52
+ DOWNLOAD_POLL_INTERVAL_MAX,
53
+ DRIVE_UPDATE_INTERVAL,
54
+ PROGRESS_THROTTLE,
55
+ DEFAULT_READ_LIMIT,
56
+ CHANNEL_NAME_MIN_LENGTH,
57
+ CHANNEL_NAME_MAX_LENGTH,
58
+ CHANNEL_NAME_REGEX,
59
+ CHANNEL_NAME_PREFIX,
60
+ CHANNEL_MESSAGE_LIMIT,
61
+ MAX_MESSAGE_LENGTH,
62
+ } from './config.js'
63
+
64
+ export class MostBoxEngine extends EventEmitter {
65
+ #store = null
66
+ #swarm = null
67
+ #drives = new Map()
68
+ #publishedFiles = []
69
+ #trashFiles = []
70
+ #initialized = false
71
+ #options = null
72
+ #activeDownloads = new Map()
73
+ #drivePromises = new Map()
74
+
75
+ #channels = []
76
+ #channelCores = new Map()
77
+ #channelDiscoveries = new Map()
78
+ #channelChatDiscoveries = new Map()
79
+ #channelPeers = new Map()
80
+
81
+ #chatSwarm = null
82
+
83
+ /**
84
+ * 创建新的 MostBoxEngine 实例
85
+ * @param {object} options - 配置选项
86
+ * @param {string} options.dataPath - 存储 P2P 数据的路径(必填)
87
+ * @param {string} [options.downloadPath] - 默认下载路径(可选,默认为 dataPath/downloads)
88
+ * @param {number} [options.maxFileSize] - 最大文件大小(字节)(默认:100GB)
89
+ */
90
+ constructor(options) {
91
+ super()
92
+
93
+ if (!options || !options.dataPath) {
94
+ throw new Error('dataPath is required')
95
+ }
96
+
97
+ this.#options = {
98
+ dataPath: options.dataPath,
99
+ downloadPath:
100
+ options.downloadPath || path.join(options.dataPath, 'downloads'),
101
+ maxFileSize: options.maxFileSize || MAX_FILE_SIZE,
102
+ }
103
+ }
104
+
105
+ /**
106
+ * 初始化引擎 — 必须在调用其他方法之前调用
107
+ */
108
+ async start() {
109
+ if (this.#initialized) {
110
+ return
111
+ }
112
+
113
+ const { dataPath } = this.#options
114
+
115
+ console.log(`[MostBox] Initializing engine...`)
116
+ console.log(`[MostBox] Storage path: ${dataPath}`)
117
+
118
+ if (!fs.existsSync(dataPath)) {
119
+ fs.mkdirSync(dataPath, { recursive: true })
120
+ }
121
+
122
+ const GLOBAL_SHARED_SEED = b4a.alloc(32).fill(GLOBAL_SHARED_SEED_STRING)
123
+ this.#store = new Corestore(dataPath, {
124
+ primaryKey: GLOBAL_SHARED_SEED,
125
+ unsafe: true,
126
+ })
127
+
128
+ try {
129
+ await this.#store.ready()
130
+ console.log(`[MostBox] Corestore ready`)
131
+ } catch (err) {
132
+ if (
133
+ err.message &&
134
+ err.message.includes('Another corestore is stored here')
135
+ ) {
136
+ console.log(`[MostBox] Resetting corrupt storage...`)
137
+ fs.rmSync(dataPath, { recursive: true, force: true })
138
+ fs.mkdirSync(dataPath, { recursive: true })
139
+ this.#store = new Corestore(dataPath, {
140
+ primaryKey: GLOBAL_SHARED_SEED,
141
+ unsafe: true,
142
+ })
143
+ await this.#store.ready()
144
+ console.log(`[MostBox] Corestore reset and ready`)
145
+ } else if (err.message && err.message.includes('Invalid device file')) {
146
+ throw new Error(`存储文件损坏,请关闭其他访问 ${dataPath} 的程序后重试`)
147
+ } else if (
148
+ err.message &&
149
+ err.message.includes('File descriptor could not be locked')
150
+ ) {
151
+ throw new Error(
152
+ `存储文件被锁定,请关闭其他访问 ${dataPath} 的程序后重试`
153
+ )
154
+ } else {
155
+ throw err
156
+ }
157
+ }
158
+
159
+ console.log(`[MostBox] Initializing Hyperswarm...`)
160
+ this.#swarm = new Hyperswarm({
161
+ maxPeers: MAX_PEERS,
162
+ bootstrap: SWARM_BOOTSTRAP,
163
+ firewall: () => false,
164
+ connectionKeepAlive: SWARM_KEEP_ALIVE_INTERVAL,
165
+ randomPunchInterval: SWARM_RANDOM_PUNCH_INTERVAL,
166
+ handshakeTimeout: CONNECTION_TIMEOUT,
167
+ })
168
+
169
+ this.#swarm.on('error', err => {
170
+ if (
171
+ err.code === 'SSL_ERROR' ||
172
+ err.message?.includes('handshake') ||
173
+ err.message?.includes('ECONNRESET')
174
+ ) {
175
+ console.warn('[MostBox] Network warning (non-critical):', err.message)
176
+ return
177
+ }
178
+ console.error('[MostBox] Swarm error:', err.message)
179
+ this.emit('error', err)
180
+ })
181
+
182
+ this.#swarm.on('connection', (conn, _info) => {
183
+ conn.on('error', err => {
184
+ if (err.code === 'SSL_ERROR' || err.message?.includes('handshake')) {
185
+ return
186
+ }
187
+ })
188
+
189
+ this.#store.replicate(conn)
190
+ this.emit('connection', conn)
191
+ })
192
+
193
+ this.#chatSwarm = new Hyperswarm({
194
+ maxPeers: MAX_PEERS,
195
+ bootstrap: SWARM_BOOTSTRAP,
196
+ firewall: () => false,
197
+ connectionKeepAlive: SWARM_KEEP_ALIVE_INTERVAL,
198
+ randomPunchInterval: SWARM_RANDOM_PUNCH_INTERVAL,
199
+ handshakeTimeout: CONNECTION_TIMEOUT,
200
+ })
201
+
202
+ this.#chatSwarm.on('error', err => {
203
+ if (
204
+ err.code === 'SSL_ERROR' ||
205
+ err.message?.includes('handshake') ||
206
+ err.message?.includes('ECONNRESET')
207
+ ) {
208
+ console.warn(
209
+ '[MostBox] Chat swarm warning (non-critical):',
210
+ err.message
211
+ )
212
+ return
213
+ }
214
+ console.error('[MostBox] Chat swarm error:', err.message)
215
+ this.emit('error', err)
216
+ })
217
+
218
+ this.#chatSwarm.on('connection', (conn, _info) => {
219
+ conn.on('error', err => {
220
+ if (err.code === 'SSL_ERROR' || err.message?.includes('handshake')) {
221
+ return
222
+ }
223
+ })
224
+
225
+ this.#handleChannelConnection(conn).catch(() => {})
226
+ })
227
+
228
+ this.#publishedFiles = this.#loadPublishedMetadata()
229
+ console.log(
230
+ `[MostBox] Loaded ${this.#publishedFiles.length} published files`
231
+ )
232
+
233
+ this.#trashFiles = this.#loadTrashMetadata()
234
+ console.log(`[MostBox] Loaded ${this.#trashFiles.length} trash files`)
235
+
236
+ this.#channels = this.#loadChannelsMetadata()
237
+ console.log(`[MostBox] Loaded ${this.#channels.length} channels`)
238
+
239
+ for (const channel of this.#channels) {
240
+ try {
241
+ const ns = this.#store.namespace(`channel-${channel.name}`)
242
+ const core = ns.get({
243
+ key: b4a.from(channel.coreKey, 'hex'),
244
+ valueEncoding: 'json',
245
+ })
246
+ await core.ready()
247
+ this.#channelCores.set(channel.name, core)
248
+ this.#channelPeers.set(channel.name, new Map())
249
+ this.#setupChannelAppendListener(core, channel.name)
250
+
251
+ const discoveryKey = b4a.from(channel.discoveryKey, 'hex')
252
+ const chatDiscoveryKey = this.#generateChannelChatDiscoveryKey(
253
+ channel.name
254
+ )
255
+ const appDiscovery = this.#swarm.join(discoveryKey, {
256
+ server: true,
257
+ client: true,
258
+ })
259
+ this.#channelDiscoveries.set(channel.name, appDiscovery)
260
+ const chatDiscovery = this.#chatSwarm.join(chatDiscoveryKey, {
261
+ server: true,
262
+ client: true,
263
+ })
264
+ this.#channelChatDiscoveries.set(channel.name, chatDiscovery)
265
+ console.log(`[MostBox] Rejoined channel: ${channel.name}`)
266
+ } catch (err) {
267
+ console.warn(
268
+ `[MostBox] Failed to rejoin channel ${channel.name}:`,
269
+ err.message
270
+ )
271
+ }
272
+ }
273
+
274
+ this.#initialized = true
275
+ console.log(`[MostBox] Engine initialized successfully`)
276
+ this.emit('ready')
277
+
278
+ return this
279
+ }
280
+
281
+ /**
282
+ * 停止引擎并清理资源
283
+ */
284
+ async stop() {
285
+ if (!this.#initialized) {
286
+ return
287
+ }
288
+
289
+ for (const task of this.#activeDownloads.values()) {
290
+ task.aborted = true
291
+ if (task.readStream) task.readStream.destroy()
292
+ if (task.writeStream) task.writeStream.destroy()
293
+ }
294
+ this.#activeDownloads.clear()
295
+
296
+ await Promise.allSettled([...this.#drives.values()].map(d => d.close()))
297
+ this.#drives.clear()
298
+
299
+ for (const core of this.#channelCores.values()) {
300
+ try {
301
+ await core.close()
302
+ } catch (err) {
303
+ console.warn('[MostBox] Failed to close channel core:', err.message)
304
+ }
305
+ }
306
+ this.#channelCores.clear()
307
+ this.#channelDiscoveries.clear()
308
+ this.#channelChatDiscoveries.clear()
309
+ this.#channelPeers.clear()
310
+ this.#channels = []
311
+
312
+ if (this.#swarm) {
313
+ await this.#swarm.destroy()
314
+ this.#swarm = null
315
+ }
316
+
317
+ if (this.#chatSwarm) {
318
+ await this.#chatSwarm.destroy()
319
+ this.#chatSwarm = null
320
+ }
321
+
322
+ if (this.#store) {
323
+ await this.#store.close()
324
+ this.#store = null
325
+ }
326
+
327
+ this.#initialized = false
328
+ this.emit('stopped')
329
+ }
330
+
331
+ /**
332
+ * 获取节点的公钥
333
+ * @returns {string} 节点 ID(十六进制字符串)
334
+ */
335
+ getNodeId() {
336
+ this.#ensureInitialized()
337
+ return b4a.toString(this.#swarm.keyPair.publicKey, 'hex')
338
+ }
339
+
340
+ /**
341
+ * 获取当前网络状态
342
+ * @returns {{ peers: number, status: string }}
343
+ */
344
+ getNetworkStatus() {
345
+ this.#ensureInitialized()
346
+ const appConnections = this.#swarm.connections.size
347
+ const chatConnections = this.#chatSwarm.connections.size
348
+ const total = appConnections + chatConnections
349
+ return {
350
+ peers: total,
351
+ appPeers: appConnections,
352
+ chatPeers: chatConnections,
353
+ status: total > 0 ? 'connected' : 'waiting',
354
+ }
355
+ }
356
+
357
+ /**
358
+ * 将内容发布到 P2P 网络
359
+ * Hyperdrive 中存储 key 为 '/' + cid,metadata 中存储 displayName(用户看到的路径)
360
+ * @param {string|Buffer} content - 文件路径(字符串)或内容(Buffer)
361
+ * @param {string} [fileName] - 文件名(Buffer 输入时必填)
362
+ * @returns {Promise<{ cid: string, link: string, fileName: string }>}
363
+ */
364
+ async publishFile(content, fileName) {
365
+ this.#ensureInitialized()
366
+
367
+ let cleanPath = null
368
+ let safeFileName
369
+ let fileSize
370
+
371
+ if (Buffer.isBuffer(content)) {
372
+ if (!fileName) {
373
+ throw new Error('fileName is required when publishing Buffer content')
374
+ }
375
+ safeFileName = sanitizeFilename(fileName)
376
+ fileSize = content.length
377
+ } else {
378
+ cleanPath = content
379
+ const pathValidation = validateAndSanitizePath(cleanPath)
380
+ if (pathValidation.error) {
381
+ throw new PathSecurityError(pathValidation.error)
382
+ }
383
+ cleanPath = pathValidation.cleanPath
384
+
385
+ const sizeValidation = await validateFileSize(
386
+ cleanPath,
387
+ this.#options.maxFileSize
388
+ )
389
+ if (!sizeValidation.valid) {
390
+ throw new FileSizeError(sizeValidation.error, sizeValidation.size)
391
+ }
392
+ fileSize = sizeValidation.size
393
+
394
+ safeFileName = sanitizeFilename(fileName || path.basename(cleanPath))
395
+ }
396
+
397
+ if (fileSize > this.#options.maxFileSize) {
398
+ const maxGB = Math.round(this.#options.maxFileSize / (1024 * 1024 * 1024))
399
+ throw new FileSizeError(
400
+ `File size exceeds limit of ${maxGB} GB`,
401
+ fileSize
402
+ )
403
+ }
404
+
405
+ this.emit('publish:progress', {
406
+ stage: 'calculating-cid',
407
+ file: safeFileName,
408
+ })
409
+
410
+ const { cid: rootCid } = await calculateCid(content)
411
+ const cidString = rootCid.toString()
412
+
413
+ // 检查相同内容是否已存在
414
+ const existingIndex = this.#publishedFiles.findIndex(
415
+ f => f.cid === cidString
416
+ )
417
+ if (existingIndex !== -1) {
418
+ const existing = this.#publishedFiles[existingIndex]
419
+ return {
420
+ cid: cidString,
421
+ link: `most://${cidString}`,
422
+ fileName: existing.fileName,
423
+ alreadyExists: true,
424
+ }
425
+ }
426
+
427
+ // 获取或创建该 CID 对应的 drive
428
+ const hashHex = b4a.toString(rootCid.multihash.digest, 'hex')
429
+ const name = `drive-${hashHex}`
430
+ let drive = this.#drives.get(name)
431
+
432
+ if (!drive) {
433
+ drive = await this.#getOrCreateDrive(name, {
434
+ server: true,
435
+ client: false,
436
+ })
437
+ const discovery = this.#swarm.join(drive.discoveryKey, {
438
+ server: true,
439
+ client: false,
440
+ })
441
+ await discovery.flushed()
442
+ }
443
+
444
+ this.emit('publish:progress', { stage: 'uploading', file: safeFileName })
445
+
446
+ // Hyperdrive 中用 CID 作为 key 存储(解耦目录结构)
447
+ const driveKey = '/' + cidString
448
+
449
+ const ws = drive.createWriteStream(driveKey)
450
+
451
+ if (Buffer.isBuffer(content)) {
452
+ let offset = 0
453
+ const waitForDrain = () =>
454
+ new Promise(resolve => ws.once('drain', resolve))
455
+
456
+ try {
457
+ while (offset < content.length) {
458
+ const chunk = content.slice(offset, offset + FILE_WRITE_CHUNK_SIZE)
459
+ const canContinue = ws.write(chunk)
460
+ offset += chunk.length
461
+ if (!canContinue && offset < content.length) {
462
+ await waitForDrain()
463
+ }
464
+ }
465
+ ws.end()
466
+ await new Promise((resolve, reject) => {
467
+ ws.on('finish', resolve)
468
+ ws.on('error', reject)
469
+ })
470
+ } catch (err) {
471
+ ws.destroy()
472
+ throw err
473
+ }
474
+ } else {
475
+ const rs = fs.createReadStream(cleanPath)
476
+ await new Promise((resolve, reject) => {
477
+ rs.pipe(ws)
478
+ ws.on('finish', resolve)
479
+ ws.on('error', reject)
480
+ rs.on('error', reject)
481
+ })
482
+ }
483
+
484
+ // 存储 displayName(用户看到的文件夹路径),不存储 drivePath
485
+ this.#publishedFiles.push({
486
+ fileName: safeFileName,
487
+ cid: cidString,
488
+ driveName: name,
489
+ publishedAt: new Date().toISOString(),
490
+ starred: false,
491
+ })
492
+ this.#savePublishedMetadata()
493
+
494
+ const result = {
495
+ cid: cidString,
496
+ link: `most://${cidString}?filename=${encodeURIComponent(safeFileName)}`,
497
+ fileName: safeFileName,
498
+ }
499
+
500
+ this.emit('publish:success', result)
501
+ return result
502
+ }
503
+
504
+ /**
505
+ * P2P 网络下载文件
506
+ * @param {string} link - most:// 链接
507
+ * @param {string} [taskId] - 用于取消的任务 ID
508
+ * @returns {Promise<{ taskId: string, fileName: string, savedPath: string, alreadyExists?: boolean }>}
509
+ */
510
+ async downloadFile(link, taskId = null) {
511
+ this.#ensureInitialized()
512
+
513
+ taskId =
514
+ taskId || `dl_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
515
+
516
+ console.log(
517
+ `[MostBox] Starting download for link: ${link} (taskId: ${taskId})`
518
+ )
519
+
520
+ const taskState = { aborted: false, readStream: null, writeStream: null }
521
+ this.#activeDownloads.set(taskId, taskState)
522
+
523
+ try {
524
+ const parsed = parseMostLink(link)
525
+ if (parsed.error) {
526
+ throw new ValidationError(parsed.error)
527
+ }
528
+ const cidString = parsed.cid
529
+ console.log(`[MostBox] Parsed CID: ${cidString}`)
530
+
531
+ const existingFile = this.#publishedFiles.find(f => f.cid === cidString)
532
+ if (existingFile) {
533
+ console.log(`[MostBox] File already exists: ${existingFile.fileName}`)
534
+ return {
535
+ taskId,
536
+ fileName: existingFile.fileName,
537
+ alreadyExists: true,
538
+ }
539
+ }
540
+
541
+ const linkFileName = parsed.fileName
542
+
543
+ const parsedCid = CID.parse(cidString)
544
+ const hashHex = b4a.toString(parsedCid.multihash.digest, 'hex')
545
+
546
+ if (taskState.aborted) throw new Error('Download cancelled')
547
+
548
+ const name = `drive-${hashHex}`
549
+ let drive = this.#drives.get(name)
550
+
551
+ if (!drive) {
552
+ console.log(`[MostBox] Creating new drive: ${name}`)
553
+ drive = await this.#getOrCreateDrive(name, {
554
+ server: true,
555
+ client: true,
556
+ })
557
+
558
+ this.emit('download:status', { taskId, status: 'connecting' })
559
+
560
+ console.log(`[MostBox] Joining swarm for drive discovery...`)
561
+ await this.#swarm
562
+ .join(drive.discoveryKey, { server: true, client: true })
563
+ .flushed()
564
+ console.log(`[MostBox] Swarm join flushed`)
565
+ } else {
566
+ console.log(`[MostBox] Using existing drive: ${name}`)
567
+ }
568
+
569
+ if (taskState.aborted) throw new Error('Download cancelled')
570
+
571
+ this.emit('download:status', { taskId, status: 'finding-peers' })
572
+
573
+ console.log(
574
+ `[MostBox] Waiting for drive content (timeout: ${DOWNLOAD_TIMEOUT / 1000}s)...`
575
+ )
576
+ const entries = await this.#waitForDriveContent(
577
+ drive,
578
+ DOWNLOAD_TIMEOUT,
579
+ taskId,
580
+ taskState
581
+ )
582
+
583
+ if (entries.length === 0) {
584
+ console.log(`[MostBox] No entries found after timeout`)
585
+
586
+ const peerCount = this.#swarm.connections.size
587
+ let errorMessage = 'No files found in drive. '
588
+
589
+ if (peerCount === 0) {
590
+ errorMessage +=
591
+ 'Could not connect to any peers. This may be due to:\n'
592
+ errorMessage += '1. Network firewall blocking P2P connections\n'
593
+ errorMessage += '2. DHT bootstrap nodes unreachable\n'
594
+ errorMessage += '3. NAT traversal failed (try port forwarding)\n'
595
+ errorMessage += '4. No peers are currently sharing this file'
596
+ } else {
597
+ errorMessage += `Connected to ${peerCount} peers but no file data was found. This may be due to:\n`
598
+ errorMessage += '1. Publisher node offline\n'
599
+ errorMessage += '2. File may have been removed by publisher\n'
600
+ errorMessage += '3. File link may be invalid or corrupted'
601
+ }
602
+
603
+ throw new PeerNotFoundError(errorMessage)
604
+ }
605
+
606
+ if (taskState.aborted) throw new Error('Download cancelled')
607
+
608
+ console.log(
609
+ `[MostBox] Found ${entries.length} entries, starting download...`
610
+ )
611
+
612
+ const targetDir = this.#options.dataPath
613
+
614
+ const writableCheck = await checkDirectoryWritable(targetDir)
615
+ if (!writableCheck.writable) {
616
+ throw new PermissionError(writableCheck.error)
617
+ }
618
+
619
+ // 下载文件
620
+ for (const entry of entries) {
621
+ const cleanKey = entry.key.replace(/^[\/\\]/, '')
622
+ const sanitizedFileName = linkFileName
623
+ ? sanitizeFilename(linkFileName)
624
+ : sanitizeFilename(cleanKey)
625
+
626
+ let totalBytes = 0
627
+ try {
628
+ const stat = await drive.entry(entry.key)
629
+ if (stat && stat.value && stat.value.blob) {
630
+ totalBytes = stat.value.blob.byteLength || 0
631
+ }
632
+ } catch {
633
+ // 忽略
634
+ }
635
+
636
+ const savePath = path.join(targetDir, sanitizedFileName)
637
+
638
+ this.emit('download:status', {
639
+ taskId,
640
+ status: 'downloading',
641
+ file: sanitizedFileName,
642
+ size: totalBytes ? formatFileSize(totalBytes) : null,
643
+ })
644
+
645
+ const rs = drive.createReadStream(entry.key)
646
+ const ws = fs.createWriteStream(savePath)
647
+
648
+ taskState.readStream = rs
649
+ taskState.writeStream = ws
650
+
651
+ let loadedBytes = 0
652
+ let lastProgressUpdate = 0
653
+
654
+ await new Promise((resolve, reject) => {
655
+ rs.on('data', chunk => {
656
+ if (taskState.aborted) {
657
+ rs.destroy()
658
+ ws.destroy()
659
+ fs.unlink(savePath, () => {})
660
+ reject(new Error('Download cancelled'))
661
+ return
662
+ }
663
+ loadedBytes += chunk.length
664
+ const now = Date.now()
665
+ if (
666
+ totalBytes > 0 &&
667
+ now - lastProgressUpdate > PROGRESS_THROTTLE
668
+ ) {
669
+ lastProgressUpdate = now
670
+ const percent = Math.round((loadedBytes / totalBytes) * 100)
671
+ this.emit('download:progress', {
672
+ taskId,
673
+ loaded: loadedBytes,
674
+ total: totalBytes,
675
+ percent,
676
+ })
677
+ }
678
+ })
679
+
680
+ rs.pipe(ws)
681
+ ws.on('finish', resolve)
682
+ ws.on('error', reject)
683
+ rs.on('error', reject)
684
+ })
685
+
686
+ if (taskState.aborted) throw new Error('Download cancelled')
687
+
688
+ this.emit('download:status', { taskId, status: 'verifying' })
689
+
690
+ const { cid: downloadedCid } = await calculateCid(savePath)
691
+ const expectedHash = b4a.toString(parsedCid.multihash.digest, 'hex')
692
+ const actualHash = b4a.toString(downloadedCid.multihash.digest, 'hex')
693
+
694
+ if (expectedHash !== actualHash) {
695
+ fs.unlinkSync(savePath)
696
+ throw new IntegrityError(
697
+ `File content CID mismatch. File may be corrupted or tampered.`
698
+ )
699
+ }
700
+
701
+ // Write file content to Hyperdrive for seeding to other peers
702
+ const driveKey = '/' + cidString
703
+ const existingEntry = await drive.entry(driveKey)
704
+ if (!existingEntry) {
705
+ const readStream = fs.createReadStream(savePath)
706
+ const writeStream = drive.createWriteStream(driveKey)
707
+ await new Promise((resolve, reject) => {
708
+ readStream.pipe(writeStream)
709
+ writeStream.on('finish', resolve)
710
+ writeStream.on('error', reject)
711
+ readStream.on('error', reject)
712
+ })
713
+ }
714
+
715
+ const result = {
716
+ taskId,
717
+ fileName: sanitizedFileName,
718
+ savedPath: savePath,
719
+ }
720
+
721
+ // 将下载的文件添加到已发布文件列表(displayName 用原始文件名)
722
+ const existingIndex = this.#publishedFiles.findIndex(
723
+ f => f.cid === cidString
724
+ )
725
+ if (existingIndex !== -1) {
726
+ const existing = this.#publishedFiles[existingIndex]
727
+ if (existing.fileName !== sanitizedFileName) {
728
+ throw new Error(`文件已存在: ${existing.fileName}`)
729
+ }
730
+ existing.publishedAt = new Date().toISOString()
731
+ } else {
732
+ this.#publishedFiles.push({
733
+ fileName: sanitizedFileName,
734
+ cid: cidString,
735
+ driveName: name,
736
+ publishedAt: new Date().toISOString(),
737
+ starred: false,
738
+ })
739
+ }
740
+ this.#savePublishedMetadata()
741
+
742
+ this.emit('download:success', result)
743
+ return result
744
+ }
745
+ } finally {
746
+ this.#activeDownloads.delete(taskId)
747
+ }
748
+ }
749
+
750
+ /**
751
+ * 列出所有已发布文件
752
+ * @param {object} [options] - 筛选选项
753
+ * @param {boolean} [options.starred] - 按收藏状态筛选
754
+ * @returns {Array<{ fileName: string, cid: string, link: string, publishedAt: string, starred: boolean }>}
755
+ */
756
+ listPublishedFiles(options = {}) {
757
+ this.#ensureInitialized()
758
+ let files = this.#publishedFiles
759
+
760
+ if (options.starred === true) {
761
+ files = files.filter(f => f.starred === true)
762
+ }
763
+
764
+ return files.map(f => ({
765
+ fileName: f.fileName,
766
+ cid: f.cid,
767
+ link: `most://${f.cid}`,
768
+ publishedAt: f.publishedAt,
769
+ starred: f.starred || false,
770
+ }))
771
+ }
772
+
773
+ /**
774
+ * 切换文件的收藏状态
775
+ * @param {string} cid - 文件的 CID
776
+ * @returns {object} 更新后的文件信息
777
+ */
778
+ toggleStarred(cid) {
779
+ this.#ensureInitialized()
780
+ const index = this.#publishedFiles.findIndex(f => f.cid === cid)
781
+ if (index === -1) {
782
+ throw new Error('File not found')
783
+ }
784
+ this.#publishedFiles[index].starred = !this.#publishedFiles[index].starred
785
+ this.#savePublishedMetadata()
786
+ return {
787
+ cid,
788
+ starred: this.#publishedFiles[index].starred,
789
+ }
790
+ }
791
+
792
+ /**
793
+ * 删除已发布文件 移至回收站而非永久删除
794
+ * @param {string} cid - 要删除文件的 CID
795
+ * @returns {Promise<Array>} 更新后的已发布文件列表
796
+ */
797
+ async deletePublishedFile(cid) {
798
+ this.#ensureInitialized()
799
+ const index = this.#publishedFiles.findIndex(f => f.cid === cid)
800
+ if (index !== -1) {
801
+ const fileRecord = this.#publishedFiles[index]
802
+
803
+ this.#trashFiles.push({
804
+ fileName: fileRecord.fileName,
805
+ cid: fileRecord.cid,
806
+ driveName: fileRecord.driveName,
807
+ publishedAt: fileRecord.publishedAt,
808
+ starred: fileRecord.starred || false,
809
+ deletedAt: new Date().toISOString(),
810
+ })
811
+ this.#saveTrashMetadata()
812
+
813
+ this.#publishedFiles.splice(index, 1)
814
+ this.#savePublishedMetadata()
815
+ }
816
+ return this.listPublishedFiles()
817
+ }
818
+
819
+ /**
820
+ * 列出回收站中的所有文件
821
+ * @returns {Array} 回收站文件
822
+ */
823
+ listTrashFiles() {
824
+ this.#ensureInitialized()
825
+ return this.#trashFiles.map(f => ({
826
+ fileName: f.fileName,
827
+ cid: f.cid,
828
+ link: `most://${f.cid}`,
829
+ publishedAt: f.publishedAt,
830
+ starred: f.starred || false,
831
+ deletedAt: f.deletedAt,
832
+ }))
833
+ }
834
+
835
+ /**
836
+ * 从回收站恢复文件
837
+ * @param {string} cid - 要恢复文件的 CID
838
+ * @returns {Array} 更新后的已发布文件列表
839
+ */
840
+ restoreTrashFile(cid) {
841
+ this.#ensureInitialized()
842
+ const index = this.#trashFiles.findIndex(f => f.cid === cid)
843
+ if (index === -1) {
844
+ throw new Error('File not found in trash')
845
+ }
846
+
847
+ const fileRecord = this.#trashFiles[index]
848
+
849
+ const parsedCid = CID.parse(fileRecord.cid)
850
+ const hashHex = b4a.toString(parsedCid.multihash.digest, 'hex')
851
+ const driveName = `drive-${hashHex}`
852
+
853
+ this.#publishedFiles.push({
854
+ fileName: fileRecord.fileName,
855
+ cid: fileRecord.cid,
856
+ driveName,
857
+ publishedAt: fileRecord.publishedAt,
858
+ starred: fileRecord.starred || false,
859
+ })
860
+ this.#savePublishedMetadata()
861
+
862
+ this.#trashFiles.splice(index, 1)
863
+ this.#saveTrashMetadata()
864
+
865
+ return this.listPublishedFiles()
866
+ }
867
+
868
+ /**
869
+ * 永久删除回收站中的文件
870
+ * @param {string} cid - 要永久删除文件的 CID
871
+ * @returns {Promise<Array>} 更新后的回收站列表
872
+ */
873
+ async permanentDeleteTrashFile(cid) {
874
+ this.#ensureInitialized()
875
+ const index = this.#trashFiles.findIndex(f => f.cid === cid)
876
+ if (index !== -1) {
877
+ const fileRecord = this.#trashFiles[index]
878
+ const driveName = fileRecord.driveName
879
+
880
+ const drive = this.#drives.get(driveName)
881
+ if (drive) {
882
+ try {
883
+ await drive.del('/' + fileRecord.cid)
884
+ } catch {
885
+ // 文件可能不存在于驱动器中
886
+ }
887
+
888
+ await this.#swarm.leave(drive.discoveryKey)
889
+ await drive.close()
890
+ this.#drives.delete(driveName)
891
+ }
892
+
893
+ this.#trashFiles.splice(index, 1)
894
+ this.#saveTrashMetadata()
895
+ }
896
+ return this.listTrashFiles()
897
+ }
898
+
899
+ /**
900
+ * 清空回收站 — 永久删除所有回收站文件
901
+ * @returns {Promise<Array>} 清空后的回收站列表
902
+ */
903
+ async emptyTrash() {
904
+ this.#ensureInitialized()
905
+
906
+ for (const fileRecord of this.#trashFiles) {
907
+ const driveName = fileRecord.driveName
908
+
909
+ const drive = this.#drives.get(driveName)
910
+ if (drive) {
911
+ try {
912
+ await drive.del('/' + fileRecord.cid)
913
+ } catch {
914
+ // 文件可能不存在
915
+ }
916
+
917
+ await this.#swarm.leave(drive.discoveryKey)
918
+ await drive.close()
919
+ this.#drives.delete(driveName)
920
+ }
921
+ }
922
+
923
+ this.#trashFiles = []
924
+ this.#saveTrashMetadata()
925
+
926
+ return []
927
+ }
928
+
929
+ /**
930
+ * 获取存储统计信息
931
+ * @returns {Promise<{ total: number, used: number, free: number, fileCount: number, trashCount: number }>}
932
+ */
933
+ async getStorageStats() {
934
+ this.#ensureInitialized()
935
+
936
+ let totalSize = 0
937
+ let freeSize = 0
938
+ const { dataPath } = this.#options
939
+
940
+ try {
941
+ const stats = fs.statfsSync(dataPath)
942
+ totalSize = stats.bsize * stats.blocks
943
+ freeSize = stats.bsize * stats.bfree
944
+ } catch {
945
+ try {
946
+ fs.statSync(dataPath)
947
+ totalSize = 0
948
+ freeSize = 0
949
+ } catch {
950
+ totalSize = 0
951
+ freeSize = 0
952
+ }
953
+ }
954
+
955
+ let usedSize = 0
956
+ const calculateDirSize = dirPath => {
957
+ try {
958
+ const entries = fs.readdirSync(dirPath, { withFileTypes: true })
959
+ for (const entry of entries) {
960
+ const fullPath = path.join(dirPath, entry.name)
961
+ if (entry.isDirectory()) {
962
+ if (entry.name !== 'db') {
963
+ calculateDirSize(fullPath)
964
+ }
965
+ } else {
966
+ try {
967
+ const stat = fs.statSync(fullPath)
968
+ usedSize += stat.size
969
+ } catch {
970
+ // 跳过无法访问的文件
971
+ }
972
+ }
973
+ }
974
+ } catch {
975
+ // 跳过无法访问的目录
976
+ }
977
+ }
978
+
979
+ calculateDirSize(dataPath)
980
+
981
+ return {
982
+ total: totalSize,
983
+ used: usedSize,
984
+ free: freeSize,
985
+ fileCount: this.#publishedFiles.length,
986
+ trashCount: this.#trashFiles.length,
987
+ }
988
+ }
989
+
990
+ /**
991
+ * 移动/重命名已发布文件
992
+ * 只更新 metadata 中的 displayName,不修改 Hyperdrive
993
+ * @param {string} cid - 要移动文件的 CID
994
+ * @param {string} newFileName - 新文件路径
995
+ * @returns {object} 更新后的文件信息
996
+ */
997
+ moveFile(cid, newFileName) {
998
+ this.#ensureInitialized()
999
+ const index = this.#publishedFiles.findIndex(f => f.cid === cid)
1000
+ if (index === -1) {
1001
+ throw new Error('File not found')
1002
+ }
1003
+ const safeFileName = sanitizeFilename(newFileName)
1004
+ this.#publishedFiles[index].fileName = safeFileName
1005
+ this.#publishedFiles[index].publishedAt = new Date().toISOString()
1006
+ this.#savePublishedMetadata()
1007
+ return {
1008
+ cid,
1009
+ fileName: safeFileName,
1010
+ link: `most://${cid}`,
1011
+ }
1012
+ }
1013
+
1014
+ /**
1015
+ * 重命名文件夹(重命名文件夹内的所有文件 displayName)
1016
+ * 只更新 metadata,不修改 Hyperdrive
1017
+ * @param {string} oldPath - 当前文件夹路径
1018
+ * @param {string} newPath - 新文件夹路径
1019
+ * @returns {object} 更新后的文件信息
1020
+ */
1021
+ renameFolder(oldPath, newPath) {
1022
+ this.#ensureInitialized()
1023
+ const prefix = oldPath + '/'
1024
+ const updatedFiles = []
1025
+
1026
+ for (const file of this.#publishedFiles) {
1027
+ if (file.fileName.startsWith(prefix)) {
1028
+ const remainder = file.fileName.substring(prefix.length)
1029
+ const newFileName = sanitizeFilename(
1030
+ remainder ? newPath + '/' + remainder : newPath
1031
+ )
1032
+ file.fileName = newFileName
1033
+ file.publishedAt = new Date().toISOString()
1034
+ updatedFiles.push({
1035
+ cid: file.cid,
1036
+ fileName: file.fileName,
1037
+ link: `most://${file.cid}`,
1038
+ })
1039
+ }
1040
+ }
1041
+
1042
+ if (updatedFiles.length > 0) {
1043
+ this.#savePublishedMetadata()
1044
+ }
1045
+
1046
+ return { files: updatedFiles }
1047
+ }
1048
+
1049
+ /**
1050
+ * 取消正在进行的下载
1051
+ * @param {string} taskId - 要取消下载的任务 ID
1052
+ */
1053
+ cancelDownload(taskId) {
1054
+ const task = this.#activeDownloads.get(taskId)
1055
+ if (task) {
1056
+ task.aborted = true
1057
+ if (task.readStream) task.readStream.destroy()
1058
+ if (task.writeStream) task.writeStream.destroy()
1059
+ }
1060
+ }
1061
+
1062
+ getPublishedFiles() {
1063
+ return this.#publishedFiles
1064
+ }
1065
+
1066
+ /**
1067
+ * 读取已发布文件的内容(用于预览)
1068
+ * Hyperdrive 中用 CID 作为 key 存储
1069
+ * @param {string} cid - 文件的 CID
1070
+ * @param {number} [offset=0] - 读取起始位置
1071
+ * @param {number} [limit=10000] - 最大读取字节数
1072
+ */
1073
+ async readFileContent(cid, offset = 0, limit = DEFAULT_READ_LIMIT) {
1074
+ this.#ensureInitialized()
1075
+
1076
+ const fileRecord = this.#publishedFiles.find(f => f.cid === cid)
1077
+ if (!fileRecord) {
1078
+ throw new Error('File not found')
1079
+ }
1080
+
1081
+ const drive = await this.#getDriveForFile(fileRecord)
1082
+
1083
+ // Hyperdrive 中 key 为 '/' + cid
1084
+ const driveKey = '/' + cid
1085
+ const entry = await drive.entry(driveKey, {
1086
+ wait: true,
1087
+ timeout: DRIVE_ENTRY_TIMEOUT,
1088
+ })
1089
+ if (!entry || !entry.value) {
1090
+ throw new Error('File content not available')
1091
+ }
1092
+
1093
+ const chunks = []
1094
+ const stream = drive.createReadStream(driveKey, {
1095
+ start: offset,
1096
+ end: offset + limit - 1,
1097
+ })
1098
+
1099
+ const timeoutPromise = new Promise((_, reject) => {
1100
+ setTimeout(
1101
+ () => reject(new Error('Stream read timeout')),
1102
+ STREAM_READ_TIMEOUT
1103
+ )
1104
+ })
1105
+
1106
+ const readPromise = (async () => {
1107
+ for await (const chunk of stream) {
1108
+ chunks.push(chunk)
1109
+ }
1110
+ })()
1111
+
1112
+ await Promise.race([readPromise, timeoutPromise])
1113
+
1114
+ const content = Buffer.concat(chunks).toString('utf8')
1115
+ const hasMore =
1116
+ chunks.length > 0 && chunks[chunks.length - 1].length === limit
1117
+
1118
+ return { content, hasMore }
1119
+ }
1120
+
1121
+ /**
1122
+ * 读取已发布文件的原始内容(用于预览/下载)
1123
+ * Hyperdrive 中用 CID 作为 key 存储
1124
+ * @param {string} cid - 文件的 CID
1125
+ * @param {object} [options] - 选项
1126
+ * @param {number} [options.offset=0] - 读取起始位置
1127
+ * @param {number} [options.limit] - 最大读取字节数,不指定则读取到末尾
1128
+ * @param {number} [options.timeout=10000] - 流读取超时(毫秒)
1129
+ * @returns {Promise<{buffer: Buffer, fileName: string, totalSize: number}>}
1130
+ */
1131
+ async readFileRaw(cid, options = {}) {
1132
+ this.#ensureInitialized()
1133
+
1134
+ const fileRecord = this.#publishedFiles.find(f => f.cid === cid)
1135
+ if (!fileRecord) {
1136
+ throw new Error('File not found')
1137
+ }
1138
+
1139
+ const drive = await this.#getDriveForFile(fileRecord)
1140
+
1141
+ const driveKey = '/' + cid
1142
+ const entry = await drive.entry(driveKey, {
1143
+ wait: true,
1144
+ timeout: DRIVE_ENTRY_TIMEOUT,
1145
+ })
1146
+ if (!entry || !entry.value || !entry.value.blob) {
1147
+ throw new Error('File content not available')
1148
+ }
1149
+
1150
+ const totalSize = entry.value.blob.byteLength || 0
1151
+
1152
+ const { offset = 0, limit, timeout = STREAM_READ_TIMEOUT } = options
1153
+ const effectiveLimit =
1154
+ limit === undefined || limit === null
1155
+ ? totalSize - offset
1156
+ : Math.min(limit, totalSize - offset)
1157
+
1158
+ if (effectiveLimit <= 0) {
1159
+ return {
1160
+ buffer: Buffer.alloc(0),
1161
+ fileName: fileRecord.fileName,
1162
+ totalSize,
1163
+ }
1164
+ }
1165
+
1166
+ const chunks = []
1167
+ const stream = drive.createReadStream(driveKey, {
1168
+ start: offset,
1169
+ end: offset + effectiveLimit - 1,
1170
+ })
1171
+
1172
+ const timeoutPromise = new Promise((_, reject) => {
1173
+ setTimeout(() => reject(new Error('Stream read timeout')), timeout)
1174
+ })
1175
+
1176
+ const readPromise = (async () => {
1177
+ try {
1178
+ for await (const chunk of stream) {
1179
+ chunks.push(chunk)
1180
+ }
1181
+ } catch (err) {
1182
+ if (err.message !== 'Stream read timeout') {
1183
+ throw err
1184
+ }
1185
+ }
1186
+ })()
1187
+
1188
+ await Promise.race([readPromise, timeoutPromise])
1189
+ await readPromise.catch(() => {})
1190
+
1191
+ const buffer = Buffer.concat(chunks)
1192
+ return { buffer, fileName: fileRecord.fileName, totalSize }
1193
+ }
1194
+
1195
+ /**
1196
+ * 获取文件对应的 drive,如果不存在则创建并同步
1197
+ */
1198
+ async #getDriveForFile(fileRecord) {
1199
+ let drive = this.#drives.get(fileRecord.driveName)
1200
+ if (!drive) {
1201
+ drive = await this.#getOrCreateDrive(fileRecord.driveName, {
1202
+ server: true,
1203
+ client: true,
1204
+ })
1205
+ }
1206
+ await this.#syncDrive(drive)
1207
+ return drive
1208
+ }
1209
+
1210
+ // --- 频道管理 ---
1211
+
1212
+ /**
1213
+ * 创建或加入频道
1214
+ * @param {string} name - 频道名
1215
+ * @param {string} [type='personal'] - 频道类型
1216
+ * @returns {Promise<{ name: string, key: string }>}
1217
+ */
1218
+ async createChannel(name, type = 'personal') {
1219
+ this.#ensureInitialized()
1220
+
1221
+ if (!CHANNEL_NAME_REGEX.test(name)) {
1222
+ throw new Error('频道名只能包含字母、数字、下划线和连字符')
1223
+ }
1224
+ if (name.length < CHANNEL_NAME_MIN_LENGTH) {
1225
+ throw new Error(`频道名至少 ${CHANNEL_NAME_MIN_LENGTH} 个字符`)
1226
+ }
1227
+ if (name.length > CHANNEL_NAME_MAX_LENGTH) {
1228
+ throw new Error(`频道名最多 ${CHANNEL_NAME_MAX_LENGTH} 个字符`)
1229
+ }
1230
+
1231
+ const existing = this.#channels.find(c => c.name === name)
1232
+ if (existing) {
1233
+ return { name: existing.name, key: existing.coreKey }
1234
+ }
1235
+
1236
+ const ns = this.#store.namespace(`channel-${name}`)
1237
+ const core = ns.get({ name: 'messages', valueEncoding: 'json' })
1238
+ await core.ready()
1239
+
1240
+ const discoveryKey = this.#generateChannelDiscoveryKey(name)
1241
+ const chatDiscoveryKey = this.#generateChannelChatDiscoveryKey(name)
1242
+ const appDiscovery = this.#swarm.join(discoveryKey, {
1243
+ server: true,
1244
+ client: true,
1245
+ })
1246
+ await appDiscovery.flushed()
1247
+ const chatDiscovery = this.#chatSwarm.join(chatDiscoveryKey, {
1248
+ server: true,
1249
+ client: true,
1250
+ })
1251
+ await chatDiscovery.flushed()
1252
+
1253
+ this.#setupChannelAppendListener(core, name)
1254
+
1255
+ const channelInfo = {
1256
+ name,
1257
+ discoveryKey: b4a.toString(discoveryKey, 'hex'),
1258
+ coreKey: b4a.toString(core.key, 'hex'),
1259
+ createdAt: new Date().toISOString(),
1260
+ type,
1261
+ }
1262
+
1263
+ this.#channels.push(channelInfo)
1264
+ this.#channelCores.set(name, core)
1265
+ this.#channelPeers.set(name, new Map())
1266
+ this.#channelDiscoveries.set(name, appDiscovery)
1267
+ this.#channelChatDiscoveries.set(name, chatDiscovery)
1268
+ this.#saveChannelsMetadata()
1269
+
1270
+ console.log(`[MostBox] Channel created: ${name}`)
1271
+ this.emit('channel:joined', { name, key: channelInfo.coreKey })
1272
+
1273
+ return { name, key: channelInfo.coreKey }
1274
+ }
1275
+
1276
+ /**
1277
+ * 加入已有频道(通过频道名和 coreKey)
1278
+ * @param {string} name - 频道名
1279
+ * @param {string} [coreKey] - 频道的 coreKey(加入他人创建的频道时必填)
1280
+ * @returns {Promise<{ name: string, key: string }>}
1281
+ */
1282
+ async joinChannel(name, coreKey = null) {
1283
+ this.#ensureInitialized()
1284
+
1285
+ const existing = this.#channels.find(c => c.name === name)
1286
+ if (existing) {
1287
+ return { name: existing.name, key: existing.coreKey }
1288
+ }
1289
+
1290
+ if (!coreKey) {
1291
+ throw new Error('加入已有频道需要提供 coreKey')
1292
+ }
1293
+
1294
+ const ns = this.#store.namespace(`channel-${name}`)
1295
+ const core = ns.get({
1296
+ key: b4a.from(coreKey, 'hex'),
1297
+ valueEncoding: 'json',
1298
+ })
1299
+ await core.ready()
1300
+
1301
+ const discoveryKey = this.#generateChannelDiscoveryKey(name)
1302
+ const chatDiscoveryKey = this.#generateChannelChatDiscoveryKey(name)
1303
+ const appDiscovery = this.#swarm.join(discoveryKey, {
1304
+ server: true,
1305
+ client: true,
1306
+ })
1307
+ await appDiscovery.flushed()
1308
+ const chatDiscovery = this.#chatSwarm.join(chatDiscoveryKey, {
1309
+ server: true,
1310
+ client: true,
1311
+ })
1312
+ await chatDiscovery.flushed()
1313
+
1314
+ this.#setupChannelAppendListener(core, name)
1315
+
1316
+ const channelInfo = {
1317
+ name,
1318
+ discoveryKey: b4a.toString(discoveryKey, 'hex'),
1319
+ coreKey,
1320
+ createdAt: new Date().toISOString(),
1321
+ type: 'group',
1322
+ }
1323
+
1324
+ this.#channels.push(channelInfo)
1325
+ this.#channelCores.set(name, core)
1326
+ this.#channelPeers.set(name, new Map())
1327
+ this.#channelDiscoveries.set(name, appDiscovery)
1328
+ this.#channelChatDiscoveries.set(name, chatDiscovery)
1329
+ this.#saveChannelsMetadata()
1330
+
1331
+ console.log(`[MostBox] Joined channel: ${name}`)
1332
+ this.emit('channel:joined', { name, key: coreKey })
1333
+
1334
+ return { name, key: coreKey }
1335
+ }
1336
+
1337
+ /**
1338
+ * 离开频道
1339
+ * @param {string} name - 频道名
1340
+ * @returns {Promise<string[]>} 剩余频道列表
1341
+ */
1342
+ async leaveChannel(name) {
1343
+ this.#ensureInitialized()
1344
+
1345
+ const index = this.#channels.findIndex(c => c.name === name)
1346
+ if (index === -1) {
1347
+ throw new Error('频道不存在')
1348
+ }
1349
+
1350
+ const channel = this.#channels[index]
1351
+
1352
+ const appDiscovery = this.#channelDiscoveries.get(name)
1353
+ if (appDiscovery) {
1354
+ try {
1355
+ await this.#swarm.leave(b4a.from(channel.discoveryKey, 'hex'))
1356
+ } catch (err) {
1357
+ console.warn(
1358
+ `[MostBox] Failed to leave app swarm for ${name}:`,
1359
+ err.message
1360
+ )
1361
+ }
1362
+ this.#channelDiscoveries.delete(name)
1363
+ }
1364
+
1365
+ const chatDiscovery = this.#channelChatDiscoveries.get(name)
1366
+ if (chatDiscovery) {
1367
+ try {
1368
+ const chatDiscoveryKey = this.#generateChannelChatDiscoveryKey(name)
1369
+ await this.#chatSwarm.leave(chatDiscoveryKey)
1370
+ } catch (err) {
1371
+ console.warn(
1372
+ `[MostBox] Failed to leave chat swarm for ${name}:`,
1373
+ err.message
1374
+ )
1375
+ }
1376
+ this.#channelChatDiscoveries.delete(name)
1377
+ }
1378
+
1379
+ const core = this.#channelCores.get(name)
1380
+ if (core) {
1381
+ try {
1382
+ await core.close()
1383
+ } catch (err) {
1384
+ console.warn(
1385
+ `[MostBox] Failed to close channel core for ${name}:`,
1386
+ err.message
1387
+ )
1388
+ }
1389
+ this.#channelCores.delete(name)
1390
+ }
1391
+
1392
+ this.#channelPeers.delete(name)
1393
+ this.#channels.splice(index, 1)
1394
+ this.#saveChannelsMetadata()
1395
+
1396
+ console.log(`[MostBox] Left channel: ${name}`)
1397
+ this.emit('channel:left', { name })
1398
+
1399
+ return this.listChannels()
1400
+ }
1401
+
1402
+ /**
1403
+ * 列出所有频道
1404
+ * @returns {Array<{ name: string, coreKey: string, createdAt: string, type: string, peerCount: number }>}
1405
+ */
1406
+ listChannels() {
1407
+ this.#ensureInitialized()
1408
+
1409
+ return this.#channels.map(c => ({
1410
+ name: c.name,
1411
+ coreKey: c.coreKey,
1412
+ createdAt: c.createdAt,
1413
+ type: c.type,
1414
+ peerCount: (this.#channelPeers.get(c.name) || new Map()).size,
1415
+ }))
1416
+ }
1417
+
1418
+ /**
1419
+ * 获取频道消息
1420
+ * @param {string} name - 频道名
1421
+ * @param {object} [options] - 选项
1422
+ * @param {number} [options.limit=100] - 消息数量
1423
+ * @param {number} [options.offset=0] - 偏移量
1424
+ * @returns {Promise<Array>}
1425
+ */
1426
+ async getChannelMessages(name, options = {}) {
1427
+ this.#ensureInitialized()
1428
+
1429
+ const { limit = CHANNEL_MESSAGE_LIMIT, offset = 0 } = options
1430
+
1431
+ const core = this.#channelCores.get(name)
1432
+ if (!core) {
1433
+ throw new Error('频道未初始化')
1434
+ }
1435
+
1436
+ const messages = []
1437
+ const total = core.length
1438
+ const start = Math.max(0, total - offset - limit)
1439
+ const end = total - offset
1440
+
1441
+ for (let i = start; i < end; i++) {
1442
+ try {
1443
+ const entry = await core.get(i)
1444
+ messages.push(entry)
1445
+ } catch {
1446
+ break
1447
+ }
1448
+ }
1449
+
1450
+ return messages
1451
+ }
1452
+
1453
+ /**
1454
+ * 发送消息到频道
1455
+ * @param {string} name - 频道名
1456
+ * @param {string} content - 消息内容
1457
+ * @param {string} author - 作者 address
1458
+ * @param {string} authorName - 作者显示名
1459
+ * @returns {Promise<object>}
1460
+ */
1461
+ async sendMessage(name, content, author, authorName) {
1462
+ this.#ensureInitialized()
1463
+
1464
+ const core = this.#channelCores.get(name)
1465
+ if (!core) {
1466
+ throw new Error('频道未初始化')
1467
+ }
1468
+
1469
+ if (!content || !content.trim()) {
1470
+ throw new Error('消息内容不能为空')
1471
+ }
1472
+
1473
+ const trimmed = content.trim()
1474
+ if (trimmed.length > MAX_MESSAGE_LENGTH) {
1475
+ throw new Error(`消息内容不能超过 ${MAX_MESSAGE_LENGTH} 字符`)
1476
+ }
1477
+
1478
+ const message = {
1479
+ type: 'message',
1480
+ author,
1481
+ authorName,
1482
+ content: trimmed,
1483
+ timestamp: Date.now(),
1484
+ }
1485
+
1486
+ await core.append(message)
1487
+
1488
+ this.emit('channel:message', { channel: name, message })
1489
+
1490
+ return message
1491
+ }
1492
+
1493
+ /**
1494
+ * 获取频道内在线用户
1495
+ * @param {string} name - 频道名
1496
+ * @returns {Array<{ peerId: string, authorName: string, lastSeen: number }>}
1497
+ */
1498
+ getChannelPeers(name) {
1499
+ this.#ensureInitialized()
1500
+
1501
+ const peers = this.#channelPeers.get(name)
1502
+ if (!peers) {
1503
+ return []
1504
+ }
1505
+
1506
+ return [...peers.values()].map(p => ({
1507
+ peerId: p.peerId,
1508
+ authorName: p.authorName,
1509
+ lastSeen: p.lastSeen,
1510
+ }))
1511
+ }
1512
+
1513
+ /**
1514
+ * 获取显示名
1515
+ * @returns {string|null}
1516
+ */
1517
+ getDisplayName() {
1518
+ try {
1519
+ const configPath = this.#getConfigPath()
1520
+ if (fs.existsSync(configPath)) {
1521
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'))
1522
+ return config.displayName || null
1523
+ }
1524
+ } catch {}
1525
+ return null
1526
+ }
1527
+
1528
+ /**
1529
+ * 设置显示名
1530
+ * @param {string} name - 显示名
1531
+ */
1532
+ setDisplayName(name) {
1533
+ try {
1534
+ const configPath = this.#getConfigPath()
1535
+ const config = fs.existsSync(configPath)
1536
+ ? JSON.parse(fs.readFileSync(configPath, 'utf-8'))
1537
+ : {}
1538
+ config.displayName = name.trim()
1539
+ const tmpPath = configPath + '.tmp'
1540
+ fs.writeFileSync(tmpPath, JSON.stringify(config, null, 2), 'utf-8')
1541
+ fs.renameSync(tmpPath, configPath)
1542
+ return true
1543
+ } catch {
1544
+ return false
1545
+ }
1546
+ }
1547
+
1548
+ // --- 私有方法 ---
1549
+
1550
+ #ensureInitialized() {
1551
+ if (!this.#initialized) {
1552
+ throw new EngineNotInitializedError()
1553
+ }
1554
+ }
1555
+
1556
+ async #getOrCreateDrive(name, _options = { server: true, client: false }) {
1557
+ if (this.#drives.has(name)) return this.#drives.get(name)
1558
+ if (this.#drivePromises.has(name)) return this.#drivePromises.get(name)
1559
+
1560
+ const promise = (async () => {
1561
+ const drive = new Hyperdrive(this.#store.namespace(name))
1562
+ await drive.ready()
1563
+ this.#drives.set(name, drive)
1564
+ return drive
1565
+ })()
1566
+
1567
+ this.#drivePromises.set(name, promise)
1568
+
1569
+ try {
1570
+ const drive = await promise
1571
+ return drive
1572
+ } finally {
1573
+ this.#drivePromises.delete(name)
1574
+ }
1575
+ }
1576
+
1577
+ async #syncDrive(drive, timeout = DRIVE_SYNC_TIMEOUT) {
1578
+ const done = drive.findingPeers()
1579
+ this.#swarm
1580
+ .join(drive.discoveryKey, { server: true, client: true })
1581
+ .flushed()
1582
+ .then(done, done)
1583
+ try {
1584
+ const updated = await Promise.race([
1585
+ drive.update(),
1586
+ new Promise((_, reject) =>
1587
+ setTimeout(() => reject(new Error('Sync timeout')), timeout)
1588
+ ),
1589
+ ])
1590
+ return updated
1591
+ } catch {
1592
+ return false
1593
+ }
1594
+ }
1595
+
1596
+ #getMetadataPath() {
1597
+ return path.join(this.#options.dataPath, 'published-files.json')
1598
+ }
1599
+
1600
+ #getTrashMetadataPath() {
1601
+ return path.join(this.#options.dataPath, 'trash-files.json')
1602
+ }
1603
+
1604
+ #atomicWrite(filePath, data) {
1605
+ const tmpPath = filePath + '.tmp'
1606
+ fs.writeFileSync(tmpPath, data, 'utf-8')
1607
+ fs.renameSync(tmpPath, filePath)
1608
+ }
1609
+
1610
+ #loadPublishedMetadata() {
1611
+ try {
1612
+ const metadataPath = this.#getMetadataPath()
1613
+ if (fs.existsSync(metadataPath)) {
1614
+ const data = fs.readFileSync(metadataPath, 'utf-8')
1615
+ const parsed = JSON.parse(data)
1616
+ return parsed.map(f => ({ ...f, starred: f.starred || false }))
1617
+ }
1618
+ } catch (err) {
1619
+ console.warn(
1620
+ 'Failed to load published metadata, using empty list:',
1621
+ err.message
1622
+ )
1623
+ }
1624
+ return []
1625
+ }
1626
+
1627
+ #savePublishedMetadata() {
1628
+ try {
1629
+ const metadataPath = this.#getMetadataPath()
1630
+ this.#atomicWrite(
1631
+ metadataPath,
1632
+ JSON.stringify(this.#publishedFiles, null, 2)
1633
+ )
1634
+ } catch (err) {
1635
+ console.error('Failed to save published metadata:', err.message)
1636
+ }
1637
+ }
1638
+
1639
+ #loadTrashMetadata() {
1640
+ try {
1641
+ const metadataPath = this.#getTrashMetadataPath()
1642
+ if (fs.existsSync(metadataPath)) {
1643
+ const data = fs.readFileSync(metadataPath, 'utf-8')
1644
+ return JSON.parse(data)
1645
+ }
1646
+ } catch (err) {
1647
+ console.warn(
1648
+ 'Failed to load trash metadata, using empty list:',
1649
+ err.message
1650
+ )
1651
+ }
1652
+ return []
1653
+ }
1654
+
1655
+ #saveTrashMetadata() {
1656
+ try {
1657
+ const metadataPath = this.#getTrashMetadataPath()
1658
+ this.#atomicWrite(metadataPath, JSON.stringify(this.#trashFiles, null, 2))
1659
+ } catch (err) {
1660
+ console.error('Failed to save trash metadata:', err.message)
1661
+ }
1662
+ }
1663
+
1664
+ #getChannelsMetadataPath() {
1665
+ return path.join(this.#options.dataPath, 'channels.json')
1666
+ }
1667
+
1668
+ #getConfigPath() {
1669
+ return path.join(this.#options.dataPath, 'channel-config.json')
1670
+ }
1671
+
1672
+ #loadChannelsMetadata() {
1673
+ try {
1674
+ const metadataPath = this.#getChannelsMetadataPath()
1675
+ if (fs.existsSync(metadataPath)) {
1676
+ const data = fs.readFileSync(metadataPath, 'utf-8')
1677
+ return JSON.parse(data)
1678
+ }
1679
+ } catch (err) {
1680
+ console.warn(
1681
+ 'Failed to load channels metadata, using empty list:',
1682
+ err.message
1683
+ )
1684
+ }
1685
+ return []
1686
+ }
1687
+
1688
+ #saveChannelsMetadata() {
1689
+ try {
1690
+ const metadataPath = this.#getChannelsMetadataPath()
1691
+ this.#atomicWrite(metadataPath, JSON.stringify(this.#channels, null, 2))
1692
+ } catch (err) {
1693
+ console.error('Failed to save channels metadata:', err.message)
1694
+ }
1695
+ }
1696
+
1697
+ #generateChannelDiscoveryKey(name) {
1698
+ const hash = crypto
1699
+ .createHash('sha256')
1700
+ .update(`${CHANNEL_NAME_PREFIX}${name}`)
1701
+ .digest()
1702
+ return hash
1703
+ }
1704
+
1705
+ #generateChannelChatDiscoveryKey(name) {
1706
+ const hash = crypto
1707
+ .createHash('sha256')
1708
+ .update(`${CHANNEL_NAME_PREFIX}${name}:chat`)
1709
+ .digest()
1710
+ return hash
1711
+ }
1712
+
1713
+ #setupChannelAppendListener(core, channelName) {
1714
+ let lastCoreLength = core.length
1715
+ core.on('append', async () => {
1716
+ if (core.length > lastCoreLength) {
1717
+ for (let i = lastCoreLength; i < core.length; i++) {
1718
+ try {
1719
+ const entry = await core.get(i)
1720
+ if (entry && entry.type === 'message') {
1721
+ this.emit('channel:message', {
1722
+ channel: channelName,
1723
+ message: entry,
1724
+ })
1725
+ }
1726
+ } catch (err) {
1727
+ console.error(
1728
+ `[MostBox] Failed to read channel message from ${channelName}:`,
1729
+ err.message
1730
+ )
1731
+ continue
1732
+ }
1733
+ }
1734
+ lastCoreLength = core.length
1735
+ }
1736
+ })
1737
+ }
1738
+
1739
+ async #handleChannelConnection(conn) {
1740
+ const stream = conn
1741
+ let connectedPeerId = null
1742
+
1743
+ const helloMessage = JSON.stringify({
1744
+ type: 'channel-hello',
1745
+ peerId: this.getNodeId(),
1746
+ authorName: this.getNodeId().slice(0, 4),
1747
+ channels: this.#channels.map(c => c.name),
1748
+ })
1749
+
1750
+ try {
1751
+ stream.write(helloMessage)
1752
+ } catch {
1753
+ return
1754
+ }
1755
+
1756
+ stream.on('data', async data => {
1757
+ try {
1758
+ const msg = JSON.parse(data.toString())
1759
+ if (msg.type === 'channel-hello') {
1760
+ connectedPeerId = msg.peerId
1761
+
1762
+ const theirChannels = new Set(msg.channels || [])
1763
+ for (const [name, peers] of this.#channelPeers) {
1764
+ if (theirChannels.has(name)) {
1765
+ peers.set(msg.peerId, {
1766
+ peerId: msg.peerId,
1767
+ authorName: msg.authorName,
1768
+ lastSeen: Date.now(),
1769
+ })
1770
+ }
1771
+ }
1772
+ this.emit('channel:peer:online', {
1773
+ peerId: msg.peerId,
1774
+ authorName: msg.authorName,
1775
+ })
1776
+ }
1777
+ } catch (err) {
1778
+ console.warn(`[MostBox] Failed to process channel data:`, err.message)
1779
+ }
1780
+ })
1781
+
1782
+ stream.on('close', () => {
1783
+ if (connectedPeerId) {
1784
+ for (const [, peers] of this.#channelPeers) {
1785
+ if (peers.has(connectedPeerId)) {
1786
+ const peer = peers.get(connectedPeerId)
1787
+ peers.delete(connectedPeerId)
1788
+ this.emit('channel:peer:offline', {
1789
+ peerId: connectedPeerId,
1790
+ authorName: peer?.authorName,
1791
+ })
1792
+ }
1793
+ }
1794
+ }
1795
+ })
1796
+ }
1797
+
1798
+ /**
1799
+ * 等待驱动器内容从对等节点或本地可用
1800
+ * @param {Hyperdrive} drive - 要检查的驱动器
1801
+ * @param {number} timeout - 最大等待时间(毫秒)
1802
+ * @param {string} [taskId] - 用于取消的任务 ID
1803
+ * @param {object} [taskState] - 任务状态对象
1804
+ * @returns {Promise<Array>} - 条目列表
1805
+ */
1806
+ async #waitForDriveContent(drive, timeout, taskId = null, taskState = null) {
1807
+ const startTime = Date.now()
1808
+ let pollInterval = DOWNLOAD_POLL_INTERVAL_MIN
1809
+ let lastPeerCount = 0
1810
+ let lastStatus = ''
1811
+ let bootstrapNodesChecked = false
1812
+ let lastUpdateTime = 0
1813
+
1814
+ const localEntries = []
1815
+ try {
1816
+ for await (const entry of drive.list()) {
1817
+ localEntries.push(entry)
1818
+ }
1819
+ if (localEntries.length > 0) {
1820
+ console.log(`[MostBox] Found ${localEntries.length} entries locally`)
1821
+ this.emit('download:status', { taskId, status: 'syncing' })
1822
+ return localEntries
1823
+ }
1824
+ } catch {}
1825
+
1826
+ const tryUpdateDrive = async () => {
1827
+ const now = Date.now()
1828
+ if (now - lastUpdateTime > DRIVE_UPDATE_INTERVAL) {
1829
+ lastUpdateTime = now
1830
+ try {
1831
+ await drive.update()
1832
+ } catch {}
1833
+ }
1834
+ }
1835
+
1836
+ while (Date.now() - startTime < timeout) {
1837
+ if (taskState && taskState.aborted) {
1838
+ throw new Error('Download cancelled')
1839
+ }
1840
+
1841
+ const currentTime = Date.now()
1842
+ const elapsed = Math.round((currentTime - startTime) / 1000)
1843
+
1844
+ const currentPeerCount = this.#swarm.connections.size
1845
+ const hasPeers = currentPeerCount > 0
1846
+
1847
+ if (currentPeerCount !== lastPeerCount) {
1848
+ console.log(
1849
+ `[MostBox] Peer count changed: ${lastPeerCount} -> ${currentPeerCount} (elapsed: ${elapsed}s)`
1850
+ )
1851
+ lastPeerCount = currentPeerCount
1852
+ }
1853
+
1854
+ await tryUpdateDrive()
1855
+
1856
+ const entries = []
1857
+ try {
1858
+ for await (const entry of drive.list()) {
1859
+ entries.push(entry)
1860
+ }
1861
+ } catch {}
1862
+
1863
+ if (entries.length > 0) {
1864
+ console.log(
1865
+ `[MostBox] Found ${entries.length} entries after ${elapsed}s`
1866
+ )
1867
+ this.emit('download:status', { taskId, status: 'syncing' })
1868
+ return entries
1869
+ }
1870
+
1871
+ if (hasPeers) {
1872
+ const newStatus = 'syncing'
1873
+ if (lastStatus !== newStatus) {
1874
+ this.emit('download:status', { taskId, status: newStatus })
1875
+ lastStatus = newStatus
1876
+ }
1877
+ pollInterval = Math.min(pollInterval + 200, DOWNLOAD_POLL_INTERVAL_MAX)
1878
+ } else {
1879
+ const newStatus = 'finding-peers'
1880
+ if (lastStatus !== newStatus) {
1881
+ this.emit('download:status', { taskId, status: newStatus })
1882
+ lastStatus = newStatus
1883
+ }
1884
+ pollInterval = DOWNLOAD_POLL_INTERVAL_MIN
1885
+
1886
+ if (elapsed % 30 === 0 && elapsed > 0) {
1887
+ console.log(
1888
+ `[MostBox] Still waiting for peers... (elapsed: ${elapsed}s, timeout: ${timeout / 1000}s)`
1889
+ )
1890
+
1891
+ if (!bootstrapNodesChecked && elapsed >= 60) {
1892
+ bootstrapNodesChecked = true
1893
+ console.log(
1894
+ `[MostBox] No peers found after 60s. This may indicate:`
1895
+ )
1896
+ console.log(
1897
+ `[MostBox] 1. Network/firewall blocking P2P connections`
1898
+ )
1899
+ console.log(`[MostBox] 2. DHT bootstrap nodes unreachable`)
1900
+ console.log(`[MostBox] 3. Publisher node offline`)
1901
+ console.log(`[MostBox] 4. NAT traversal failed`)
1902
+ }
1903
+ }
1904
+ }
1905
+
1906
+ await new Promise(resolve => setTimeout(resolve, pollInterval))
1907
+ }
1908
+
1909
+ console.log(
1910
+ `[MostBox] Timeout reached after ${timeout / 1000}s, making final attempt...`
1911
+ )
1912
+
1913
+ await tryUpdateDrive()
1914
+
1915
+ const entries = []
1916
+ try {
1917
+ for await (const entry of drive.list()) {
1918
+ entries.push(entry)
1919
+ }
1920
+ } catch (err) {
1921
+ console.log(`[MostBox] Final attempt failed: ${err.message}`)
1922
+ }
1923
+
1924
+ console.log(`[MostBox] Final entry count: ${entries.length}`)
1925
+
1926
+ if (entries.length === 0) {
1927
+ const peerCount = this.#swarm.connections.size
1928
+ console.log(`[MostBox] Diagnostic information:`)
1929
+ console.log(`[MostBox] - Peer count: ${peerCount}`)
1930
+ console.log(`[MostBox] - Bootstrap nodes: ${SWARM_BOOTSTRAP.length}`)
1931
+ console.log(`[MostBox] - Timeout: ${timeout / 1000}s`)
1932
+
1933
+ if (peerCount === 0) {
1934
+ console.log(
1935
+ `[MostBox] Suggestion: Check network connectivity and firewall settings`
1936
+ )
1937
+ } else {
1938
+ console.log(
1939
+ `[MostBox] Suggestion: Publisher may be offline or file may have been removed`
1940
+ )
1941
+ }
1942
+ }
1943
+
1944
+ return entries
1945
+ }
1946
+ }
1947
+
1948
+ // 重新导出工具函数
1949
+ export * from './config.js'
1950
+ export * from './core/cid.js'
1951
+ export * from './utils/errors.js'
1952
+ export * from './utils/security.js'