most-box 0.0.2 → 0.0.6

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