most-box 0.1.0 → 0.1.2

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 (390) hide show
  1. package/README.md +24 -16
  2. package/electron/main.js +72 -8
  3. package/electron/preload.js +6 -0
  4. package/out/404/index.html +2 -2
  5. package/out/404.html +2 -2
  6. package/out/__next.__PAGE__.txt +6 -6
  7. package/out/__next._full.txt +23 -20
  8. package/out/__next._head.txt +3 -3
  9. package/out/__next._index.txt +8 -6
  10. package/out/__next._tree.txt +6 -4
  11. package/out/_next/static/chunks/0-n3pg7th.zza.js +1 -0
  12. package/out/_next/static/chunks/0.4j.0k5a64vg.js +1 -0
  13. package/out/_next/static/chunks/0.e2avjgna_b2.js +1 -0
  14. package/out/_next/static/chunks/0.ozi1_x2.m.~.js +1 -0
  15. package/out/_next/static/chunks/0.t5wlt51zou5.js +1 -0
  16. package/out/_next/static/chunks/0.w4hkvap~bva.js +1 -0
  17. package/out/_next/static/chunks/00d9h1tddnnnd.js +1 -0
  18. package/out/_next/static/chunks/00tkdqwxch-3s.js +1 -0
  19. package/out/_next/static/chunks/01l3o90g~1z42.js +1 -0
  20. package/out/_next/static/chunks/01mfky9camw6i.js +1 -0
  21. package/out/_next/static/chunks/01r.v-pqs1vrm.js +1 -0
  22. package/out/_next/static/chunks/03edqrb4zdj~g.js +31 -0
  23. package/out/_next/static/chunks/03h_6oo-gqkhz.js +1 -0
  24. package/out/_next/static/chunks/{0ho~log~~-jwp.css → 03h~nhgj0hv3p.css} +1 -1
  25. package/out/_next/static/chunks/04hcgsanv1hhu.js +1 -0
  26. package/out/_next/static/chunks/05g2q0w5b34.g.js +1 -0
  27. package/out/_next/static/chunks/05of77xycbt8~.js +1 -0
  28. package/out/_next/static/chunks/05zwemzfjx3sh.js +1 -0
  29. package/out/_next/static/chunks/06dpc5df94.v1.js +1 -0
  30. package/out/_next/static/chunks/06e1~1-z_ic9a.js +1 -0
  31. package/out/_next/static/chunks/075s7sn.ns~u5.js +1 -0
  32. package/out/_next/static/chunks/07dynrbvd3.f4.js +1 -0
  33. package/out/_next/static/chunks/07p~uva5pwgwe.js +1 -0
  34. package/out/_next/static/chunks/07r9nn-pzlgg1.js +1 -0
  35. package/out/_next/static/chunks/07td.jq7xff84.css +1 -0
  36. package/out/_next/static/chunks/08.72abkgwy9g.js +1 -0
  37. package/out/_next/static/chunks/08576xhv~~jck.js +1 -0
  38. package/out/_next/static/chunks/08u211~k~qu52.js +1 -0
  39. package/out/_next/static/chunks/098.p.2-zm4p7.js +1 -0
  40. package/out/_next/static/chunks/09ngvtajm7e5y.js +1 -0
  41. package/out/_next/static/chunks/09ps~-43n5qyo.js +1 -0
  42. package/out/_next/static/chunks/09v7_0gclxr46.js +1 -0
  43. package/out/_next/static/chunks/09yql86dir9c4.js +1 -0
  44. package/out/_next/static/chunks/09zmlfljowj1~.js +1 -0
  45. package/out/_next/static/chunks/0_s~ebb-7b2hr.js +1 -0
  46. package/out/_next/static/chunks/0_w-0-2z5oqd_.js +1 -0
  47. package/out/_next/static/chunks/0adx~d-j05c9d.css +24 -0
  48. package/out/_next/static/chunks/0ao1lbi4b.sfa.js +1 -0
  49. package/out/_next/static/chunks/0aq.rc9woa2nz.js +1 -0
  50. package/out/_next/static/chunks/0bld2u_ld~va2.js +1 -0
  51. package/out/_next/static/chunks/0bliugh5lxw55.js +1 -0
  52. package/out/_next/static/chunks/0cn9a7aimbdzq.js +1 -0
  53. package/out/_next/static/chunks/0d3f-nk3c.2re.js +1 -0
  54. package/out/_next/static/chunks/0dtohpf7~3d12.js +1 -0
  55. package/out/_next/static/chunks/0e-3e8h7g99yf.js +1 -0
  56. package/out/_next/static/chunks/0e531nije_ln2.js +1 -0
  57. package/out/_next/static/chunks/0e5zvj_rh0z3m.js +1 -0
  58. package/out/_next/static/chunks/0etes81d_cihn.js +1 -0
  59. package/out/_next/static/chunks/0f4y~rkk-n81e.js +1 -0
  60. package/out/_next/static/chunks/0fk~0~p7ivfn1.js +1 -0
  61. package/out/_next/static/chunks/0fw6juc~lsj3z.js +1 -0
  62. package/out/_next/static/chunks/0g0u7785a73vo.js +1 -0
  63. package/out/_next/static/chunks/0g_a~e050bgzg.css +1 -0
  64. package/out/_next/static/chunks/0g_fpgh7drfda.js +1 -0
  65. package/out/_next/static/chunks/{0n~dq4kpx9xxx.js → 0gcsdf57gcm6h.js} +1 -1
  66. package/out/_next/static/chunks/0gwian.hp3-92.js +1 -0
  67. package/out/_next/static/chunks/0gze5uso1mbe9.js +1 -0
  68. package/out/_next/static/chunks/0h4r.qtmpa6eh.js +1 -0
  69. package/out/_next/static/chunks/0hf.aosc-7172.js +1 -0
  70. package/out/_next/static/chunks/0hgz35c1ejbs9.js +1 -0
  71. package/out/_next/static/chunks/0hpev4am9jpmu.css +1 -0
  72. package/out/_next/static/chunks/0hrw-r.xmvmsq.js +1 -0
  73. package/out/_next/static/chunks/0hzg4al.v~8~m.js +1 -0
  74. package/out/_next/static/chunks/0ip9xrols_83o.js +1 -0
  75. package/out/_next/static/chunks/0j4-d0qf.v~kn.js +1 -0
  76. package/out/_next/static/chunks/0jhdeq.j9_02m.js +1 -0
  77. package/out/_next/static/chunks/0jy63h3i-y69i.js +1 -0
  78. package/out/_next/static/chunks/0kdnx_u-60k9s.js +1 -0
  79. package/out/_next/static/chunks/0kq~edq42o1-c.js +1 -0
  80. package/out/_next/static/chunks/0l5_.uqb-uqb8.js +1 -0
  81. package/out/_next/static/chunks/0l682p362d-5w.js +1 -0
  82. package/out/_next/static/chunks/0m68p9txef5rs.js +1 -0
  83. package/out/_next/static/chunks/0mex8svsiv-2l.js +1 -0
  84. package/out/_next/static/chunks/0mme-fm5d2oz2.js +1 -0
  85. package/out/_next/static/chunks/0myp4sjagr~h0.js +1 -0
  86. package/out/_next/static/chunks/0myq9gs8szydh.js +1 -0
  87. package/out/_next/static/chunks/0n.qlfk~z7o.6.js +1 -0
  88. package/out/_next/static/chunks/0n4t80gjc3q5h.js +1 -0
  89. package/out/_next/static/chunks/0o9ce4cyf76by.js +736 -0
  90. package/out/_next/static/chunks/0oz3yl6_-716p.js +1 -0
  91. package/out/_next/static/chunks/0p0sv~fuddvgr.js +1 -0
  92. package/out/_next/static/chunks/0pt.5cg1t09qs.js +1 -0
  93. package/out/_next/static/chunks/0q0ksgxg98xgd.js +1 -0
  94. package/out/_next/static/chunks/0qgx9t4jx16ua.css +1 -0
  95. package/out/_next/static/chunks/0qqupeexg83u7.js +1 -0
  96. package/out/_next/static/chunks/0rb-ri481.kc9.js +1 -0
  97. package/out/_next/static/chunks/0rsnmahfd.59p.js +1 -0
  98. package/out/_next/static/chunks/0rt6rgnvr-s_p.js +1 -0
  99. package/out/_next/static/chunks/0runh28p_gg6..js +1 -0
  100. package/out/_next/static/chunks/0shy.t1fwqcev.js +1 -0
  101. package/out/_next/static/chunks/{0d3shmwh5_nmn.js → 0t2xr05rlu96l.js} +1 -1
  102. package/out/_next/static/chunks/0t6h56rhg1y5i.js +1 -0
  103. package/out/_next/static/chunks/0tdqd1zunusgk.js +1 -0
  104. package/out/_next/static/chunks/0ujbnp38x63ek.js +1 -0
  105. package/out/_next/static/chunks/0ukyg~tkm~h2m.css +1 -0
  106. package/out/_next/static/chunks/0v68pdrp54lb-.js +1 -0
  107. package/out/_next/static/chunks/0vsm0m5sxrb.3.js +1 -0
  108. package/out/_next/static/chunks/0vzlz.iboqo3c.js +1 -0
  109. package/out/_next/static/chunks/0w87vbpkf-ogd.js +1 -0
  110. package/out/_next/static/chunks/0wtf0xsiicxx6.js +1 -0
  111. package/out/_next/static/chunks/0xdwau5k2augv.css +4 -0
  112. package/out/_next/static/chunks/0xgg0~kmf3gd-.js +1 -0
  113. package/out/_next/static/chunks/0xj24-70ptdzp.js +1 -0
  114. package/out/_next/static/chunks/0xxlx772fr3x4.js +1 -0
  115. package/out/_next/static/chunks/0y.li-~3oybew.js +1 -0
  116. package/out/_next/static/chunks/0yl2t7cs-n_ng.js +1 -0
  117. package/out/_next/static/chunks/0yq3kh.hchtm_.js +1 -0
  118. package/out/_next/static/chunks/0ys0l5au.9c2c.js +1 -0
  119. package/out/_next/static/chunks/0z48pmi6buytt.js +1 -0
  120. package/out/_next/static/chunks/0zapnvgy89mg..js +1 -0
  121. package/out/_next/static/chunks/0~.-vxi5oc.r0.js +1 -0
  122. package/out/_next/static/chunks/0~3ik-hfp9s-7.js +1 -0
  123. package/out/_next/static/chunks/0~4f5p6tvn1lq.js +1 -0
  124. package/out/_next/static/chunks/0~_0ys.2whxbw.js +1 -0
  125. package/out/_next/static/chunks/0~_ui9l7.2sxf.js +1 -0
  126. package/out/_next/static/chunks/1037jlyw5~7ht.js +1 -0
  127. package/out/_next/static/chunks/1045hfzu533z0.js +1 -0
  128. package/out/_next/static/chunks/104e5nmc.c-pl.js +1 -0
  129. package/out/_next/static/chunks/109taw1pbh-0b.js +1 -0
  130. package/out/_next/static/chunks/10x7~onqwp338.js +1 -0
  131. package/out/_next/static/chunks/10ynz1dy483wf.js +1 -0
  132. package/out/_next/static/chunks/11hds.mg~4_r-.js +1 -0
  133. package/out/_next/static/chunks/11ibzaklcauw~.js +1 -0
  134. package/out/_next/static/chunks/11z.0s6.42b.p.js +1 -0
  135. package/out/_next/static/chunks/12-9n56l0y3yr.js +1 -0
  136. package/out/_next/static/chunks/126enaq~f7scl.js +1 -0
  137. package/out/_next/static/chunks/12nr19.nnn6s3.js +5 -0
  138. package/out/_next/static/chunks/{0qub_r0x_r-e9.css → 12pep-2t-qg4n.css} +1 -1
  139. package/out/_next/static/chunks/1380op_pfk.qo.js +1 -0
  140. package/out/_next/static/chunks/146oiw1bggtn4.js +1 -0
  141. package/out/_next/static/chunks/14_inksek_rth.js +2 -0
  142. package/out/_next/static/chunks/14_po2rb_arn4.js +1 -0
  143. package/out/_next/static/chunks/14a4fwbiq.l3z.js +1 -0
  144. package/out/_next/static/chunks/14cowsqn95m1k.js +1 -0
  145. package/out/_next/static/chunks/14dtd3l03v.kx.js +1 -0
  146. package/out/_next/static/chunks/14tm3qa-v9o-4.js +1 -0
  147. package/out/_next/static/chunks/15-o4kb-evqd7.js +1 -0
  148. package/out/_next/static/chunks/153-sz7s.qml2.js +1 -0
  149. package/out/_next/static/chunks/157z7bowux3xj.js +1 -0
  150. package/out/_next/static/chunks/15m1_677az2cm.js +1 -0
  151. package/out/_next/static/chunks/15v.~.ne6ogkk.js +1 -0
  152. package/out/_next/static/chunks/16i.qbk8t8gf_.js +1 -0
  153. package/out/_next/static/chunks/16u9f35gylw8l.js +1 -0
  154. package/out/_next/static/chunks/16xls5tt_68lx.js +1 -0
  155. package/out/_next/static/chunks/17ajyb5ogk5yj.js +1 -0
  156. package/out/_next/static/chunks/17dyfxbq8yz8n.js +1 -0
  157. package/out/_next/static/chunks/180zln9pcq9ih.js +1 -0
  158. package/out/_next/static/chunks/1814izi5gh.kp.js +1 -0
  159. package/out/_next/static/chunks/turbopack-0xta0kqwzkf28.js +1 -0
  160. package/out/_next/static/media/KaTeX_AMS-Regular.0b~8ki5y928w2.woff +0 -0
  161. package/out/_next/static/media/KaTeX_AMS-Regular.0p1vbqd84i2~o.woff2 +0 -0
  162. package/out/_next/static/media/KaTeX_AMS-Regular.173t6ktr7uf-w.ttf +0 -0
  163. package/out/_next/static/media/KaTeX_Caligraphic-Bold.01-pzluls4zgb.woff2 +0 -0
  164. package/out/_next/static/media/KaTeX_Caligraphic-Bold.0x2v1lwn~880f.woff +0 -0
  165. package/out/_next/static/media/KaTeX_Caligraphic-Bold.16zv5fax0h0ka.ttf +0 -0
  166. package/out/_next/static/media/KaTeX_Caligraphic-Regular.02i3z7wig438t.ttf +0 -0
  167. package/out/_next/static/media/KaTeX_Caligraphic-Regular.0rysu1t-ncjq8.woff2 +0 -0
  168. package/out/_next/static/media/KaTeX_Caligraphic-Regular.10927swgekwun.woff +0 -0
  169. package/out/_next/static/media/KaTeX_Fraktur-Bold.0e-16u10iuyyf.woff +0 -0
  170. package/out/_next/static/media/KaTeX_Fraktur-Bold.0et27v~3~4uhe.ttf +0 -0
  171. package/out/_next/static/media/KaTeX_Fraktur-Bold.0w23i72~hprpq.woff2 +0 -0
  172. package/out/_next/static/media/KaTeX_Fraktur-Regular.0b.riegzdfue2.woff +0 -0
  173. package/out/_next/static/media/KaTeX_Fraktur-Regular.0rekyoa-52fj_.woff2 +0 -0
  174. package/out/_next/static/media/KaTeX_Fraktur-Regular.0vjwa15znhk~4.ttf +0 -0
  175. package/out/_next/static/media/KaTeX_Main-Bold.09i7~607shf-h.ttf +0 -0
  176. package/out/_next/static/media/KaTeX_Main-Bold.09lmynrorhcbw.woff +0 -0
  177. package/out/_next/static/media/KaTeX_Main-Bold.16pfc63_du6mx.woff2 +0 -0
  178. package/out/_next/static/media/KaTeX_Main-BoldItalic.0cp37g7x1q8h6.woff +0 -0
  179. package/out/_next/static/media/KaTeX_Main-BoldItalic.0d54rk08rx11s.woff2 +0 -0
  180. package/out/_next/static/media/KaTeX_Main-BoldItalic.15j6k~hix2t_0.ttf +0 -0
  181. package/out/_next/static/media/KaTeX_Main-Italic.0382gqciexmbu.woff +0 -0
  182. package/out/_next/static/media/KaTeX_Main-Italic.06o5nq0_91v60.woff2 +0 -0
  183. package/out/_next/static/media/KaTeX_Main-Italic.0su4i6mm18-wo.ttf +0 -0
  184. package/out/_next/static/media/KaTeX_Main-Regular.08zh8z.7shijf.ttf +0 -0
  185. package/out/_next/static/media/KaTeX_Main-Regular.0diheg01zyoph.woff +0 -0
  186. package/out/_next/static/media/KaTeX_Main-Regular.0kaf-ag2_wkm-.woff2 +0 -0
  187. package/out/_next/static/media/KaTeX_Math-BoldItalic.0ajzxypnbx1h1.ttf +0 -0
  188. package/out/_next/static/media/KaTeX_Math-BoldItalic.0ck1myuerwyqw.woff +0 -0
  189. package/out/_next/static/media/KaTeX_Math-BoldItalic.0ja97dn.cpc87.woff2 +0 -0
  190. package/out/_next/static/media/KaTeX_Math-Italic.09xkhecjcn5r9.woff +0 -0
  191. package/out/_next/static/media/KaTeX_Math-Italic.0x23a-bmp-5tg.ttf +0 -0
  192. package/out/_next/static/media/KaTeX_Math-Italic.0zrha2c4sl2je.woff2 +0 -0
  193. package/out/_next/static/media/KaTeX_SansSerif-Bold.05a9.pc1j_zx9.woff2 +0 -0
  194. package/out/_next/static/media/KaTeX_SansSerif-Bold.0jcl-ayi1uun0.woff +0 -0
  195. package/out/_next/static/media/KaTeX_SansSerif-Bold.0re8y.dm7.mt5.ttf +0 -0
  196. package/out/_next/static/media/KaTeX_SansSerif-Italic.0a0234dc3s62j.woff2 +0 -0
  197. package/out/_next/static/media/KaTeX_SansSerif-Italic.0judofdln9731.woff +0 -0
  198. package/out/_next/static/media/KaTeX_SansSerif-Italic.10z1iap9pfus8.ttf +0 -0
  199. package/out/_next/static/media/KaTeX_SansSerif-Regular.0h9yjlugq4q_e.woff +0 -0
  200. package/out/_next/static/media/KaTeX_SansSerif-Regular.0v6gcj32-czft.woff2 +0 -0
  201. package/out/_next/static/media/KaTeX_SansSerif-Regular.0zm18kga42ebc.ttf +0 -0
  202. package/out/_next/static/media/KaTeX_Script-Regular.0c4.h-mer83d_.woff2 +0 -0
  203. package/out/_next/static/media/KaTeX_Script-Regular.0q14y6zkzlpob.ttf +0 -0
  204. package/out/_next/static/media/KaTeX_Script-Regular.0ze6v4r_-99oy.woff +0 -0
  205. package/out/_next/static/media/KaTeX_Size1-Regular.013x6a4ierotp.woff2 +0 -0
  206. package/out/_next/static/media/KaTeX_Size1-Regular.0kidw0oi.m68o.woff +0 -0
  207. package/out/_next/static/media/KaTeX_Size1-Regular.0m6y-i6wfokni.ttf +0 -0
  208. package/out/_next/static/media/KaTeX_Size2-Regular.0blpmluwilgbg.woff +0 -0
  209. package/out/_next/static/media/KaTeX_Size2-Regular.0d5inmyp-tyv3.woff2 +0 -0
  210. package/out/_next/static/media/KaTeX_Size2-Regular.0wnhnvj-.k9d5.ttf +0 -0
  211. package/out/_next/static/media/KaTeX_Size3-Regular.01h0xm_sfctj3.woff +0 -0
  212. package/out/_next/static/media/KaTeX_Size3-Regular.0iukctyhw5j56.woff2 +0 -0
  213. package/out/_next/static/media/KaTeX_Size3-Regular.0jl8mqyf4gzpn.ttf +0 -0
  214. package/out/_next/static/media/KaTeX_Size4-Regular.0w3.rb_c4stzk.woff2 +0 -0
  215. package/out/_next/static/media/KaTeX_Size4-Regular.0wr_9l81-mu06.ttf +0 -0
  216. package/out/_next/static/media/KaTeX_Size4-Regular.12tvaesf3.zl3.woff +0 -0
  217. package/out/_next/static/media/KaTeX_Typewriter-Regular.0c4zdxz~8frhm.woff2 +0 -0
  218. package/out/_next/static/media/KaTeX_Typewriter-Regular.0cgrzn5l3kao5.woff +0 -0
  219. package/out/_next/static/media/KaTeX_Typewriter-Regular.128~qc3858otl.ttf +0 -0
  220. package/out/_not-found/__next._full.txt +21 -19
  221. package/out/_not-found/__next._head.txt +3 -3
  222. package/out/_not-found/__next._index.txt +8 -6
  223. package/out/_not-found/__next._not-found.__PAGE__.txt +4 -4
  224. package/out/_not-found/__next._not-found.txt +3 -3
  225. package/out/_not-found/__next._tree.txt +3 -2
  226. package/out/_not-found/index.html +2 -2
  227. package/out/_not-found/index.txt +21 -19
  228. package/out/admin/__next._full.txt +23 -0
  229. package/out/admin/__next._head.txt +5 -0
  230. package/out/admin/__next._index.txt +9 -0
  231. package/out/admin/__next._tree.txt +5 -0
  232. package/out/admin/__next.admin.__PAGE__.txt +9 -0
  233. package/out/admin/__next.admin.txt +5 -0
  234. package/out/admin/index.html +15 -0
  235. package/out/admin/index.txt +23 -0
  236. package/out/app/__next._full.txt +15 -13
  237. package/out/app/__next._head.txt +3 -3
  238. package/out/app/__next._index.txt +8 -6
  239. package/out/app/__next._tree.txt +4 -2
  240. package/out/app/__next.app.__PAGE__.txt +4 -4
  241. package/out/app/__next.app.txt +3 -4
  242. package/out/app/index.html +2 -2
  243. package/out/app/index.txt +15 -13
  244. package/out/chat/__next._full.txt +16 -14
  245. package/out/chat/__next._head.txt +3 -3
  246. package/out/chat/__next._index.txt +8 -6
  247. package/out/chat/__next._tree.txt +5 -3
  248. package/out/chat/__next.chat.__PAGE__.txt +4 -4
  249. package/out/chat/__next.chat.txt +4 -5
  250. package/out/chat/index.html +2 -2
  251. package/out/chat/index.txt +16 -14
  252. package/out/chat/join/__next._full.txt +25 -0
  253. package/out/chat/join/__next._head.txt +5 -0
  254. package/out/chat/join/__next._index.txt +9 -0
  255. package/out/chat/join/__next._tree.txt +5 -0
  256. package/out/chat/join/__next.chat.join.__PAGE__.txt +9 -0
  257. package/out/chat/join/__next.chat.join.txt +5 -0
  258. package/out/chat/join/__next.chat.txt +5 -0
  259. package/out/chat/join/index.html +15 -0
  260. package/out/chat/join/index.txt +25 -0
  261. package/out/download/__next._full.txt +34 -33
  262. package/out/download/__next._head.txt +3 -3
  263. package/out/download/__next._index.txt +8 -6
  264. package/out/download/__next._tree.txt +6 -4
  265. package/out/download/__next.download.__PAGE__.txt +13 -14
  266. package/out/download/__next.download.txt +3 -3
  267. package/out/download/index.html +2 -2
  268. package/out/download/index.txt +34 -33
  269. package/out/index.html +2 -2
  270. package/out/index.txt +23 -20
  271. package/out/note/__next._full.txt +24 -0
  272. package/out/{changelog → note}/__next._head.txt +3 -3
  273. package/out/note/__next._index.txt +9 -0
  274. package/out/note/__next._tree.txt +4 -0
  275. package/out/note/__next.note.__PAGE__.txt +9 -0
  276. package/out/note/__next.note.txt +5 -0
  277. package/out/note/index.html +15 -0
  278. package/out/note/index.txt +24 -0
  279. package/out/ping/__next._full.txt +23 -20
  280. package/out/ping/__next._head.txt +3 -3
  281. package/out/ping/__next._index.txt +8 -6
  282. package/out/ping/__next._tree.txt +6 -4
  283. package/out/ping/__next.ping.__PAGE__.txt +5 -5
  284. package/out/ping/__next.ping.txt +4 -4
  285. package/out/ping/index.html +2 -2
  286. package/out/ping/index.txt +23 -20
  287. package/out/web3/__next._full.txt +16 -14
  288. package/out/web3/__next._head.txt +3 -3
  289. package/out/web3/__next._index.txt +8 -6
  290. package/out/web3/__next._tree.txt +5 -3
  291. package/out/web3/__next.web3.__PAGE__.txt +4 -4
  292. package/out/web3/__next.web3.txt +4 -5
  293. package/out/web3/ed25519/__next._full.txt +14 -12
  294. package/out/web3/ed25519/__next._head.txt +3 -3
  295. package/out/web3/ed25519/__next._index.txt +8 -6
  296. package/out/web3/ed25519/__next._tree.txt +5 -3
  297. package/out/web3/ed25519/__next.web3.ed25519.__PAGE__.txt +2 -2
  298. package/out/web3/ed25519/__next.web3.ed25519.txt +3 -3
  299. package/out/web3/ed25519/__next.web3.txt +4 -5
  300. package/out/web3/ed25519/index.html +1 -1
  301. package/out/web3/ed25519/index.txt +14 -12
  302. package/out/web3/index.html +2 -2
  303. package/out/web3/index.txt +16 -14
  304. package/out/web3/tools/__next._full.txt +14 -12
  305. package/out/web3/tools/__next._head.txt +3 -3
  306. package/out/web3/tools/__next._index.txt +8 -6
  307. package/out/web3/tools/__next._tree.txt +5 -3
  308. package/out/web3/tools/__next.web3.tools.__PAGE__.txt +2 -2
  309. package/out/web3/tools/__next.web3.tools.txt +3 -3
  310. package/out/web3/tools/__next.web3.txt +4 -5
  311. package/out/web3/tools/index.html +1 -1
  312. package/out/web3/tools/index.txt +14 -12
  313. package/package.json +30 -13
  314. package/server/index.js +188 -901
  315. package/server/src/config.js +5 -1
  316. package/server/src/core/channelAttachment.js +68 -0
  317. package/server/src/core/cid.js +6 -71
  318. package/server/src/core/cidTopic.js +29 -0
  319. package/server/src/core/mostLink.js +88 -0
  320. package/server/src/http/access.js +123 -0
  321. package/server/src/http/app.js +1095 -0
  322. package/server/src/http/errors.js +35 -0
  323. package/server/src/http/nodeLogs.js +53 -0
  324. package/server/src/http/nodeStatus.js +146 -0
  325. package/server/src/http/staticFiles.js +84 -0
  326. package/server/src/http/uploads.js +114 -0
  327. package/server/src/index.js +1539 -301
  328. package/server/src/node/config.js +191 -0
  329. package/server/src/node/logs.js +94 -0
  330. package/server/src/utils/api.js +359 -8
  331. package/server/src/utils/auth.js +63 -0
  332. package/server/src/utils/dateTime.js +30 -0
  333. package/server/src/utils/downloadMessages.js +89 -0
  334. package/server/src/utils/errors.js +14 -0
  335. package/server/src/utils/mostWallet.js +185 -1
  336. package/server/src/utils/mp.js +2 -26
  337. package/server/src/utils/noteBackup.js +116 -0
  338. package/server/src/utils/noteUtils.js +128 -0
  339. package/server/src/utils/userIdentity.js +8 -61
  340. package/out/_next/static/chunks/003jnm.v5tzw5.js +0 -1
  341. package/out/_next/static/chunks/00re8v.gbcywn.js +0 -1
  342. package/out/_next/static/chunks/00s106sbq8t9v.js +0 -1
  343. package/out/_next/static/chunks/012hi627qrdnn.js +0 -1
  344. package/out/_next/static/chunks/0174xh3wfsjm1.js +0 -2
  345. package/out/_next/static/chunks/02~o2nmo5pmy1.js +0 -1
  346. package/out/_next/static/chunks/07t.dhhokszz5.css +0 -1
  347. package/out/_next/static/chunks/0_wia9ofmsi1c.css +0 -2
  348. package/out/_next/static/chunks/0ah8fihozo2_u.js +0 -5
  349. package/out/_next/static/chunks/0bzupvr5gt3k9.js +0 -31
  350. package/out/_next/static/chunks/0e_h0d3ekzks8.css +0 -1
  351. package/out/_next/static/chunks/0gdluj423gso1.js +0 -1
  352. package/out/_next/static/chunks/0gmoiq06srjay.css +0 -1
  353. package/out/_next/static/chunks/0imkasy7kb67u.js +0 -1
  354. package/out/_next/static/chunks/0jjc_b9q_ldi2.js +0 -1
  355. package/out/_next/static/chunks/0jl~j62iz2uvr.js +0 -1
  356. package/out/_next/static/chunks/0lqslm813wk_h.js +0 -1
  357. package/out/_next/static/chunks/0q782fxxd0lx~.js +0 -1
  358. package/out/_next/static/chunks/0slwj0c46k5cu.js +0 -1
  359. package/out/_next/static/chunks/0sorqk.oc6b7j.css +0 -1
  360. package/out/_next/static/chunks/0tapzqc6hgvx-.js +0 -1
  361. package/out/_next/static/chunks/0xsc7z5x8n7wg.js +0 -1
  362. package/out/_next/static/chunks/0zm~gys2jwl0g.js +0 -1
  363. package/out/_next/static/chunks/turbopack-0a_g3u0ud~jb8.js +0 -1
  364. package/out/changelog/__next._full.txt +0 -22
  365. package/out/changelog/__next._index.txt +0 -7
  366. package/out/changelog/__next._tree.txt +0 -3
  367. package/out/changelog/__next.changelog.__PAGE__.txt +0 -10
  368. package/out/changelog/__next.changelog.txt +0 -5
  369. package/out/changelog/index.html +0 -15
  370. package/out/changelog/index.txt +0 -22
  371. package/out/docs/__next._full.txt +0 -22
  372. package/out/docs/__next._head.txt +0 -5
  373. package/out/docs/__next._index.txt +0 -7
  374. package/out/docs/__next._tree.txt +0 -3
  375. package/out/docs/__next.docs.__PAGE__.txt +0 -10
  376. package/out/docs/__next.docs.txt +0 -5
  377. package/out/docs/getting-started/__next._full.txt +0 -22
  378. package/out/docs/getting-started/__next._head.txt +0 -5
  379. package/out/docs/getting-started/__next._index.txt +0 -7
  380. package/out/docs/getting-started/__next._tree.txt +0 -3
  381. package/out/docs/getting-started/__next.docs.getting-started.__PAGE__.txt +0 -10
  382. package/out/docs/getting-started/__next.docs.getting-started.txt +0 -5
  383. package/out/docs/getting-started/__next.docs.txt +0 -5
  384. package/out/docs/getting-started/index.html +0 -15
  385. package/out/docs/getting-started/index.txt +0 -22
  386. package/out/docs/index.html +0 -15
  387. package/out/docs/index.txt +0 -22
  388. /package/out/_next/static/{iOB2EBwOGZ0iYW7Lbg9u_ → t7ZIeQpVvjz4a7-5Tt-VK}/_buildManifest.js +0 -0
  389. /package/out/_next/static/{iOB2EBwOGZ0iYW7Lbg9u_ → t7ZIeQpVvjz4a7-5Tt-VK}/_clientMiddlewareManifest.js +0 -0
  390. /package/out/_next/static/{iOB2EBwOGZ0iYW7Lbg9u_ → t7ZIeQpVvjz4a7-5Tt-VK}/_ssgManifest.js +0 -0
@@ -14,11 +14,12 @@ import Corestore from 'corestore'
14
14
  import Hyperdrive from 'hyperdrive'
15
15
  import b4a from 'b4a'
16
16
  import crypto from 'node:crypto'
17
- import { CID } from 'multiformats/cid'
18
17
  import fs from 'node:fs'
19
18
  import path from 'node:path'
20
19
 
21
20
  import { calculateCid, parseMostLink } from './core/cid.js'
21
+ import { normalizeChannelAttachment } from './core/channelAttachment.js'
22
+ import { getCidInfo } from './core/cidTopic.js'
22
23
  import {
23
24
  sanitizeFilename,
24
25
  validateAndSanitizePath,
@@ -33,6 +34,8 @@ import {
33
34
  PeerNotFoundError,
34
35
  IntegrityError,
35
36
  PermissionError,
37
+ ConflictError,
38
+ StorageCapacityError,
36
39
  EngineNotInitializedError,
37
40
  } from './utils/errors.js'
38
41
  import {
@@ -51,6 +54,8 @@ import {
51
54
  DOWNLOAD_POLL_INTERVAL_MIN,
52
55
  DOWNLOAD_POLL_INTERVAL_MAX,
53
56
  DRIVE_UPDATE_INTERVAL,
57
+ HOLDING_REJOIN_BATCH_SIZE,
58
+ HOLDING_REJOIN_BATCH_DELAY,
54
59
  PROGRESS_THROTTLE,
55
60
  DEFAULT_READ_LIMIT,
56
61
  CHANNEL_NAME_MIN_LENGTH,
@@ -61,19 +66,51 @@ import {
61
66
  MAX_MESSAGE_LENGTH,
62
67
  } from './config.js'
63
68
 
69
+ const sleep = ms => new Promise(resolve => setTimeout(resolve, ms))
70
+
71
+ function normalizeOwnerAddress(address) {
72
+ const value = String(address || '').trim()
73
+ return /^0x[a-fA-F0-9]{40}$/.test(value) ? value.toLowerCase() : ''
74
+ }
75
+
76
+ function createOfflineSwarm() {
77
+ return {
78
+ connections: new Set(),
79
+ keyPair: {
80
+ publicKey: crypto.randomBytes(32),
81
+ },
82
+ on() {},
83
+ join() {
84
+ return {}
85
+ },
86
+ leave() {
87
+ return Promise.resolve()
88
+ },
89
+ destroy() {
90
+ return Promise.resolve()
91
+ },
92
+ }
93
+ }
94
+
64
95
  export class MostBoxEngine extends EventEmitter {
65
96
  #store = null
66
97
  #swarm = null
67
98
  #drives = new Map()
68
99
  #publishedFiles = []
100
+ #holdings = []
69
101
  #trashFiles = []
70
102
  #initialized = false
71
103
  #options = null
72
104
  #activeDownloads = new Map()
73
105
  #drivePromises = new Map()
106
+ #fileDiscoveries = new Map()
107
+ #fileMonitors = new Map()
108
+ #seedStates = new Map()
109
+ #holdingResumeTask = null
74
110
 
75
111
  #channels = []
76
112
  #channelCores = new Map()
113
+ #channelLocalCoreKey = new Map()
77
114
  #channelDiscoveries = new Map()
78
115
  #channelChatDiscoveries = new Map()
79
116
  #channelPeers = new Map()
@@ -85,7 +122,9 @@ export class MostBoxEngine extends EventEmitter {
85
122
  * @param {object} options - 配置选项
86
123
  * @param {string} options.dataPath - 存储 P2P 数据的路径(必填)
87
124
  * @param {string} [options.downloadPath] - 默认下载路径(可选,默认为 dataPath/downloads)
88
- * @param {number} [options.maxFileSize] - 最大文件大小(字节)(默认:100GB
125
+ * @param {number} [options.maxFileSize] - 最大文件大小(字节)(默认:10GB
126
+ * @param {number} [options.capacityBytes] - 节点存储容量上限(字节)(默认:100GB)
127
+ * @param {boolean} [options.disableNetwork] - 测试用:跳过真实 Hyperswarm 网络
89
128
  */
90
129
  constructor(options) {
91
130
  super()
@@ -99,6 +138,9 @@ export class MostBoxEngine extends EventEmitter {
99
138
  downloadPath:
100
139
  options.downloadPath || path.join(options.dataPath, 'downloads'),
101
140
  maxFileSize: options.maxFileSize || MAX_FILE_SIZE,
141
+ capacityBytes: options.capacityBytes || 100 * 1024 * 1024 * 1024,
142
+ downloadTimeout: options.downloadTimeout || DOWNLOAD_TIMEOUT,
143
+ disableNetwork: options.disableNetwork === true,
102
144
  }
103
145
  }
104
146
 
@@ -157,14 +199,19 @@ export class MostBoxEngine extends EventEmitter {
157
199
  }
158
200
 
159
201
  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
- })
202
+ if (this.#options.disableNetwork) {
203
+ this.#swarm = createOfflineSwarm()
204
+ this.#chatSwarm = createOfflineSwarm()
205
+ } else {
206
+ this.#swarm = new Hyperswarm({
207
+ maxPeers: MAX_PEERS,
208
+ bootstrap: SWARM_BOOTSTRAP,
209
+ firewall: () => false,
210
+ connectionKeepAlive: SWARM_KEEP_ALIVE_INTERVAL,
211
+ randomPunchInterval: SWARM_RANDOM_PUNCH_INTERVAL,
212
+ handshakeTimeout: CONNECTION_TIMEOUT,
213
+ })
214
+ }
168
215
 
169
216
  this.#swarm.on('error', err => {
170
217
  if (
@@ -190,14 +237,16 @@ export class MostBoxEngine extends EventEmitter {
190
237
  this.emit('connection', conn)
191
238
  })
192
239
 
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
- })
240
+ if (!this.#options.disableNetwork) {
241
+ this.#chatSwarm = new Hyperswarm({
242
+ maxPeers: MAX_PEERS,
243
+ bootstrap: SWARM_BOOTSTRAP,
244
+ firewall: () => false,
245
+ connectionKeepAlive: SWARM_KEEP_ALIVE_INTERVAL,
246
+ randomPunchInterval: SWARM_RANDOM_PUNCH_INTERVAL,
247
+ handshakeTimeout: CONNECTION_TIMEOUT,
248
+ })
249
+ }
201
250
 
202
251
  this.#chatSwarm.on('error', err => {
203
252
  if (
@@ -230,6 +279,17 @@ export class MostBoxEngine extends EventEmitter {
230
279
  `[MostBox] Loaded ${this.#publishedFiles.length} published files`
231
280
  )
232
281
 
282
+ this.#holdings = this.#loadHoldingsMetadata()
283
+ console.log(`[MostBox] Loaded ${this.#holdings.length} node holdings`)
284
+
285
+ for (const holding of this.#holdings) {
286
+ this.#setSeedState(holding.cid, {
287
+ status: 'queued',
288
+ topic: holding.topic,
289
+ driveName: holding.driveName,
290
+ })
291
+ }
292
+
233
293
  this.#trashFiles = this.#loadTrashMetadata()
234
294
  console.log(`[MostBox] Loaded ${this.#trashFiles.length} trash files`)
235
295
 
@@ -244,9 +304,22 @@ export class MostBoxEngine extends EventEmitter {
244
304
  valueEncoding: 'json',
245
305
  })
246
306
  await core.ready()
247
- this.#channelCores.set(channel.name, core)
307
+ const coreKeyHex = b4a.toString(core.key, 'hex')
308
+ if (!this.#channelCores.has(channel.name)) {
309
+ this.#channelCores.set(channel.name, new Map())
310
+ }
311
+ this.#channelCores.get(channel.name).set(coreKeyHex, core)
312
+ this.#channelLocalCoreKey.set(channel.name, coreKeyHex)
248
313
  this.#channelPeers.set(channel.name, new Map())
249
314
  this.#setupChannelAppendListener(core, channel.name)
315
+ const remoteCoreKeys = Array.isArray(channel.remoteCoreKeys)
316
+ ? channel.remoteCoreKeys
317
+ : []
318
+ for (const remoteCoreKey of remoteCoreKeys) {
319
+ if (remoteCoreKey && remoteCoreKey !== coreKeyHex) {
320
+ await this.#openRemoteChannelCore(channel.name, remoteCoreKey)
321
+ }
322
+ }
250
323
 
251
324
  const discoveryKey = b4a.from(channel.discoveryKey, 'hex')
252
325
  const chatDiscoveryKey = this.#generateChannelChatDiscoveryKey(
@@ -274,6 +347,7 @@ export class MostBoxEngine extends EventEmitter {
274
347
  this.#initialized = true
275
348
  console.log(`[MostBox] Engine initialized successfully`)
276
349
  this.emit('ready')
350
+ this.#resumeHoldingsInBackground()
277
351
 
278
352
  return this
279
353
  }
@@ -293,21 +367,15 @@ export class MostBoxEngine extends EventEmitter {
293
367
  }
294
368
  this.#activeDownloads.clear()
295
369
 
370
+ await Promise.allSettled(
371
+ [...this.#fileMonitors.values()].map(item => this.#closeFileMonitor(item))
372
+ )
373
+ this.#fileMonitors.clear()
296
374
  await Promise.allSettled([...this.#drives.values()].map(d => d.close()))
297
375
  this.#drives.clear()
298
-
299
- for (const core of this.#channelCores.values()) {
300
- try {
301
- await core.close()
302
- } catch (err) {
303
- console.warn('[MostBox] Failed to close channel core:', err.message)
304
- }
305
- }
306
- this.#channelCores.clear()
307
- this.#channelDiscoveries.clear()
308
- this.#channelChatDiscoveries.clear()
309
- this.#channelPeers.clear()
310
- this.#channels = []
376
+ this.#fileDiscoveries.clear()
377
+ this.#seedStates.clear()
378
+ this.#holdingResumeTask = null
311
379
 
312
380
  if (this.#swarm) {
313
381
  await this.#swarm.destroy()
@@ -319,6 +387,22 @@ export class MostBoxEngine extends EventEmitter {
319
387
  this.#chatSwarm = null
320
388
  }
321
389
 
390
+ for (const [, coresMap] of this.#channelCores) {
391
+ for (const [, core] of coresMap) {
392
+ try {
393
+ await core.close()
394
+ } catch (err) {
395
+ console.warn('[MostBox] Failed to close channel core:', err.message)
396
+ }
397
+ }
398
+ }
399
+ this.#channelCores.clear()
400
+ this.#channelLocalCoreKey.clear()
401
+ this.#channelDiscoveries.clear()
402
+ this.#channelChatDiscoveries.clear()
403
+ this.#channelPeers.clear()
404
+ this.#channels = []
405
+
322
406
  if (this.#store) {
323
407
  await this.#store.close()
324
408
  this.#store = null
@@ -359,10 +443,13 @@ export class MostBoxEngine extends EventEmitter {
359
443
  * Hyperdrive 中存储 key 为 '/' + cid,metadata 中存储 displayName(用户看到的路径)
360
444
  * @param {string|Buffer} content - 文件路径(字符串)或内容(Buffer)
361
445
  * @param {string} [fileName] - 文件名(Buffer 输入时必填)
446
+ * @param {object} [options] - 发布选项
447
+ * @param {string|null} [options.localPath] - 持有记录中的本地路径
362
448
  * @returns {Promise<{ cid: string, link: string, fileName: string }>}
363
449
  */
364
- async publishFile(content, fileName) {
450
+ async publishFile(content, fileName, options = {}) {
365
451
  this.#ensureInitialized()
452
+ const ownerAddress = normalizeOwnerAddress(options.ownerAddress)
366
453
 
367
454
  let cleanPath = null
368
455
  let safeFileName
@@ -402,6 +489,8 @@ export class MostBoxEngine extends EventEmitter {
402
489
  )
403
490
  }
404
491
 
492
+ this.#checkCapacity(fileSize)
493
+
405
494
  this.emit('publish:progress', {
406
495
  stage: 'calculating-cid',
407
496
  file: safeFileName,
@@ -409,24 +498,37 @@ export class MostBoxEngine extends EventEmitter {
409
498
 
410
499
  const { cid: rootCid } = await calculateCid(content)
411
500
  const cidString = rootCid.toString()
501
+ const { driveName: name } = this.#getCidInfo(cidString)
502
+ const holdingLocalPath =
503
+ options.localPath === undefined ? cleanPath : options.localPath
412
504
 
413
505
  // 检查相同内容是否已存在
414
506
  const existingIndex = this.#publishedFiles.findIndex(
415
- f => f.cid === cidString
507
+ f => f.cid === cidString && this.#recordMatchesOwner(f, ownerAddress)
416
508
  )
417
509
  if (existingIndex !== -1) {
418
510
  const existing = this.#publishedFiles[existingIndex]
511
+ await this.#joinCidTopicInternal(cidString, {
512
+ server: true,
513
+ client: false,
514
+ })
515
+ this.#upsertHolding({
516
+ cid: cidString,
517
+ fileName: existing.fileName,
518
+ size: fileSize,
519
+ localPath: holdingLocalPath,
520
+ driveName: name,
521
+ source: 'published',
522
+ })
419
523
  return {
420
524
  cid: cidString,
421
- link: `most://${cidString}`,
525
+ link: `most://${cidString}?filename=${encodeURIComponent(existing.fileName)}`,
422
526
  fileName: existing.fileName,
423
527
  alreadyExists: true,
424
528
  }
425
529
  }
426
530
 
427
531
  // 获取或创建该 CID 对应的 drive
428
- const hashHex = b4a.toString(rootCid.multihash.digest, 'hex')
429
- const name = `drive-${hashHex}`
430
532
  let drive = this.#drives.get(name)
431
533
 
432
534
  if (!drive) {
@@ -434,12 +536,11 @@ export class MostBoxEngine extends EventEmitter {
434
536
  server: true,
435
537
  client: false,
436
538
  })
437
- const discovery = this.#swarm.join(drive.discoveryKey, {
438
- server: true,
439
- client: false,
440
- })
441
- await discovery.flushed()
442
539
  }
540
+ await this.#joinCidTopicInternal(cidString, {
541
+ server: true,
542
+ client: false,
543
+ })
443
544
 
444
545
  this.emit('publish:progress', { stage: 'uploading', file: safeFileName })
445
546
 
@@ -488,8 +589,17 @@ export class MostBoxEngine extends EventEmitter {
488
589
  driveName: name,
489
590
  publishedAt: new Date().toISOString(),
490
591
  starred: false,
592
+ ownerAddress,
491
593
  })
492
594
  this.#savePublishedMetadata()
595
+ this.#upsertHolding({
596
+ cid: cidString,
597
+ fileName: safeFileName,
598
+ size: fileSize,
599
+ localPath: holdingLocalPath,
600
+ driveName: name,
601
+ source: 'published',
602
+ })
493
603
 
494
604
  const result = {
495
605
  cid: cidString,
@@ -505,13 +615,19 @@ export class MostBoxEngine extends EventEmitter {
505
615
  * 从 P2P 网络下载文件
506
616
  * @param {string} link - most:// 链接
507
617
  * @param {string} [taskId] - 用于取消的任务 ID
618
+ * @param {object} [options] - 下载选项
619
+ * @param {number} [options.timeout] - 等待 P2P 内容的超时时间(毫秒)
620
+ * @param {number} [options.streamReadTimeout] - 下载流无进度超时时间(毫秒)
508
621
  * @returns {Promise<{ taskId: string, fileName: string, savedPath: string, alreadyExists?: boolean }>}
509
622
  */
510
- async downloadFile(link, taskId = null) {
623
+ async downloadFile(link, taskId = null, options = {}) {
511
624
  this.#ensureInitialized()
625
+ const ownerAddress = normalizeOwnerAddress(options.ownerAddress)
512
626
 
513
627
  taskId =
514
628
  taskId || `dl_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
629
+ const downloadTimeout = options.timeout || this.#options.downloadTimeout
630
+ const streamReadTimeout = options.streamReadTimeout ?? STREAM_READ_TIMEOUT
515
631
 
516
632
  console.log(
517
633
  `[MostBox] Starting download for link: ${link} (taskId: ${taskId})`
@@ -527,10 +643,32 @@ export class MostBoxEngine extends EventEmitter {
527
643
  }
528
644
  const cidString = parsed.cid
529
645
  console.log(`[MostBox] Parsed CID: ${cidString}`)
646
+ const { driveName: name } = this.#getCidInfo(cidString)
530
647
 
531
- const existingFile = this.#publishedFiles.find(f => f.cid === cidString)
648
+ const existingFile = this.#publishedFiles.find(
649
+ f => f.cid === cidString && this.#recordMatchesOwner(f, ownerAddress)
650
+ )
532
651
  if (existingFile) {
533
652
  console.log(`[MostBox] File already exists: ${existingFile.fileName}`)
653
+ const existingHolding = this.#holdings.find(
654
+ item => item.cid === cidString
655
+ )
656
+ const existingSize = Number(existingFile.size)
657
+ await this.#joinCidTopicInternal(cidString, {
658
+ server: true,
659
+ client: false,
660
+ })
661
+ this.#upsertHolding({
662
+ cid: cidString,
663
+ fileName: existingFile.fileName,
664
+ size:
665
+ existingHolding?.size ??
666
+ (Number.isFinite(existingSize) ? existingSize : 0),
667
+ localPath:
668
+ existingHolding?.localPath || existingFile.localPath || null,
669
+ driveName: existingFile.driveName || name,
670
+ source: existingHolding?.source || 'published',
671
+ })
534
672
  return {
535
673
  taskId,
536
674
  fileName: existingFile.fileName,
@@ -540,12 +678,8 @@ export class MostBoxEngine extends EventEmitter {
540
678
 
541
679
  const linkFileName = parsed.fileName
542
680
 
543
- const parsedCid = CID.parse(cidString)
544
- const hashHex = b4a.toString(parsedCid.multihash.digest, 'hex')
545
-
546
681
  if (taskState.aborted) throw new Error('Download cancelled')
547
682
 
548
- const name = `drive-${hashHex}`
549
683
  let drive = this.#drives.get(name)
550
684
 
551
685
  if (!drive) {
@@ -556,35 +690,35 @@ export class MostBoxEngine extends EventEmitter {
556
690
  })
557
691
 
558
692
  this.emit('download:status', { taskId, status: 'connecting' })
559
-
560
- console.log(`[MostBox] Joining swarm for drive discovery...`)
561
- await this.#swarm
562
- .join(drive.discoveryKey, { server: true, client: true })
563
- .flushed()
564
- console.log(`[MostBox] Swarm join flushed`)
565
693
  } else {
566
694
  console.log(`[MostBox] Using existing drive: ${name}`)
567
695
  }
696
+ await this.#joinCidTopicInternal(cidString, {
697
+ server: false,
698
+ client: true,
699
+ })
568
700
 
569
701
  if (taskState.aborted) throw new Error('Download cancelled')
570
702
 
571
703
  this.emit('download:status', { taskId, status: 'finding-peers' })
572
704
 
573
705
  console.log(
574
- `[MostBox] Waiting for drive content (timeout: ${DOWNLOAD_TIMEOUT / 1000}s)...`
706
+ `[MostBox] Waiting for drive entry /${cidString} (timeout: ${downloadTimeout / 1000}s)...`
575
707
  )
576
- const entries = await this.#waitForDriveContent(
708
+ const driveKey = '/' + cidString
709
+ const entry = await this.#waitForDriveEntry(
577
710
  drive,
578
- DOWNLOAD_TIMEOUT,
711
+ driveKey,
712
+ downloadTimeout,
579
713
  taskId,
580
714
  taskState
581
715
  )
582
716
 
583
- if (entries.length === 0) {
584
- console.log(`[MostBox] No entries found after timeout`)
717
+ if (!entry) {
718
+ console.log(`[MostBox] Expected drive entry ${driveKey} not found`)
585
719
 
586
720
  const peerCount = this.#swarm.connections.size
587
- let errorMessage = 'No files found in drive. '
721
+ let errorMessage = `Expected file ${driveKey} was not found. `
588
722
 
589
723
  if (peerCount === 0) {
590
724
  errorMessage +=
@@ -606,10 +740,10 @@ export class MostBoxEngine extends EventEmitter {
606
740
  if (taskState.aborted) throw new Error('Download cancelled')
607
741
 
608
742
  console.log(
609
- `[MostBox] Found ${entries.length} entries, starting download...`
743
+ `[MostBox] Found expected entry ${driveKey}, starting download...`
610
744
  )
611
745
 
612
- const targetDir = this.#options.dataPath
746
+ const targetDir = this.#options.downloadPath
613
747
 
614
748
  const writableCheck = await checkDirectoryWritable(targetDir)
615
749
  if (!writableCheck.writable) {
@@ -617,6 +751,7 @@ export class MostBoxEngine extends EventEmitter {
617
751
  }
618
752
 
619
753
  // 下载文件
754
+ const entries = [entry]
620
755
  for (const entry of entries) {
621
756
  const cleanKey = entry.key.replace(/^[\/\\]/, '')
622
757
  const sanitizedFileName = linkFileName
@@ -633,7 +768,15 @@ export class MostBoxEngine extends EventEmitter {
633
768
  // 忽略
634
769
  }
635
770
 
771
+ if (totalBytes > 0) {
772
+ this.#checkCapacity(totalBytes)
773
+ }
774
+
636
775
  const savePath = path.join(targetDir, sanitizedFileName)
776
+ fs.mkdirSync(path.dirname(savePath), { recursive: true })
777
+ if (fs.existsSync(savePath)) {
778
+ throw new ConflictError(`已有同名文件: ${sanitizedFileName}`)
779
+ }
637
780
 
638
781
  this.emit('download:status', {
639
782
  taskId,
@@ -652,14 +795,54 @@ export class MostBoxEngine extends EventEmitter {
652
795
  let lastProgressUpdate = 0
653
796
 
654
797
  await new Promise((resolve, reject) => {
798
+ let settled = false
799
+ let readTimer = null
800
+
801
+ const clearReadTimer = () => {
802
+ if (readTimer) {
803
+ clearTimeout(readTimer)
804
+ readTimer = null
805
+ }
806
+ }
807
+
808
+ const fail = err => {
809
+ if (settled) return
810
+ settled = true
811
+ clearReadTimer()
812
+ rs.destroy(err)
813
+ ws.destroy()
814
+ fs.unlink(savePath, () => {})
815
+ reject(err)
816
+ }
817
+
818
+ const complete = () => {
819
+ if (settled) return
820
+ settled = true
821
+ clearReadTimer()
822
+ resolve()
823
+ }
824
+
825
+ const resetReadTimer = () => {
826
+ clearReadTimer()
827
+ if (streamReadTimeout > 0) {
828
+ readTimer = setTimeout(() => {
829
+ fail(
830
+ new Error(
831
+ `Download stalled: no data received for ${streamReadTimeout / 1000}s`
832
+ )
833
+ )
834
+ }, streamReadTimeout)
835
+ }
836
+ }
837
+
838
+ resetReadTimer()
839
+
655
840
  rs.on('data', chunk => {
656
841
  if (taskState.aborted) {
657
- rs.destroy()
658
- ws.destroy()
659
- fs.unlink(savePath, () => {})
660
- reject(new Error('Download cancelled'))
842
+ fail(new Error('Download cancelled'))
661
843
  return
662
844
  }
845
+ resetReadTimer()
663
846
  loadedBytes += chunk.length
664
847
  const now = Date.now()
665
848
  if (
@@ -678,9 +861,19 @@ export class MostBoxEngine extends EventEmitter {
678
861
  })
679
862
 
680
863
  rs.pipe(ws)
681
- ws.on('finish', resolve)
682
- ws.on('error', reject)
683
- rs.on('error', reject)
864
+ ws.on('finish', complete)
865
+ ws.on('error', fail)
866
+ rs.on('error', fail)
867
+ rs.on('close', () => {
868
+ if (taskState.aborted) {
869
+ fail(new Error('Download cancelled'))
870
+ }
871
+ })
872
+ ws.on('close', () => {
873
+ if (taskState.aborted) {
874
+ fail(new Error('Download cancelled'))
875
+ }
876
+ })
684
877
  })
685
878
 
686
879
  if (taskState.aborted) throw new Error('Download cancelled')
@@ -688,13 +881,12 @@ export class MostBoxEngine extends EventEmitter {
688
881
  this.emit('download:status', { taskId, status: 'verifying' })
689
882
 
690
883
  const { cid: downloadedCid } = await calculateCid(savePath)
691
- const expectedHash = b4a.toString(parsedCid.multihash.digest, 'hex')
692
- const actualHash = b4a.toString(downloadedCid.multihash.digest, 'hex')
884
+ const downloadedCidString = downloadedCid.toString()
693
885
 
694
- if (expectedHash !== actualHash) {
886
+ if (downloadedCidString !== cidString) {
695
887
  fs.unlinkSync(savePath)
696
888
  throw new IntegrityError(
697
- `File content CID mismatch. File may be corrupted or tampered.`
889
+ `File content CID mismatch. Expected ${cidString}, got ${downloadedCidString}.`
698
890
  )
699
891
  }
700
892
 
@@ -710,7 +902,17 @@ export class MostBoxEngine extends EventEmitter {
710
902
  writeStream.on('error', reject)
711
903
  readStream.on('error', reject)
712
904
  })
905
+ const verifyEntry = await drive.entry(driveKey)
906
+ if (!verifyEntry || !verifyEntry.value || !verifyEntry.value.blob) {
907
+ throw new IntegrityError(
908
+ `Failed to write file to Hyperdrive for seeding: ${driveKey}`
909
+ )
910
+ }
713
911
  }
912
+ await this.#joinCidTopicInternal(cidString, {
913
+ server: true,
914
+ client: false,
915
+ })
714
916
 
715
917
  const result = {
716
918
  taskId,
@@ -720,7 +922,7 @@ export class MostBoxEngine extends EventEmitter {
720
922
 
721
923
  // 将下载的文件添加到已发布文件列表(displayName 用原始文件名)
722
924
  const existingIndex = this.#publishedFiles.findIndex(
723
- f => f.cid === cidString
925
+ f => f.cid === cidString && this.#recordMatchesOwner(f, ownerAddress)
724
926
  )
725
927
  if (existingIndex !== -1) {
726
928
  const existing = this.#publishedFiles[existingIndex]
@@ -735,9 +937,19 @@ export class MostBoxEngine extends EventEmitter {
735
937
  driveName: name,
736
938
  publishedAt: new Date().toISOString(),
737
939
  starred: false,
940
+ ownerAddress,
738
941
  })
739
942
  }
740
943
  this.#savePublishedMetadata()
944
+ const savedSize = totalBytes || fs.statSync(savePath).size
945
+ this.#upsertHolding({
946
+ cid: cidString,
947
+ fileName: sanitizedFileName,
948
+ size: savedSize,
949
+ localPath: savePath,
950
+ driveName: name,
951
+ source: 'downloaded',
952
+ })
741
953
 
742
954
  this.emit('download:success', result)
743
955
  return result
@@ -747,6 +959,84 @@ export class MostBoxEngine extends EventEmitter {
747
959
  }
748
960
  }
749
961
 
962
+ /**
963
+ * 检测 most:// 链接当前是否能找到可下载内容,但不读取文件内容。
964
+ * @param {string} link - most:// 链接
965
+ * @param {object} [options] - 检测选项
966
+ * @param {number} [options.timeout] - 等待 P2P 内容的超时时间(毫秒)
967
+ * @returns {Promise<{ available: boolean, cid: string, fileName: string, size: number|null, alreadyExists?: boolean }>}
968
+ */
969
+ async checkDownloadAvailability(link, options = {}) {
970
+ this.#ensureInitialized()
971
+ const ownerAddress = normalizeOwnerAddress(options.ownerAddress)
972
+
973
+ const timeout = options.timeout || DRIVE_ENTRY_TIMEOUT
974
+ const parsed = parseMostLink(link)
975
+ if (parsed.error) {
976
+ throw new ValidationError(parsed.error)
977
+ }
978
+
979
+ const cidString = parsed.cid
980
+ const { driveName: name } = this.#getCidInfo(cidString)
981
+ const existingFile = this.#publishedFiles.find(
982
+ f => f.cid === cidString && this.#recordMatchesOwner(f, ownerAddress)
983
+ )
984
+ if (existingFile) {
985
+ return {
986
+ available: true,
987
+ cid: cidString,
988
+ fileName: existingFile.fileName,
989
+ size: Number(existingFile.size) || null,
990
+ alreadyExists: true,
991
+ }
992
+ }
993
+
994
+ const writableCheck = await checkDirectoryWritable(
995
+ this.#options.downloadPath
996
+ )
997
+ if (!writableCheck.writable) {
998
+ throw new PermissionError(writableCheck.error)
999
+ }
1000
+
1001
+ let drive = this.#drives.get(name)
1002
+
1003
+ if (!drive) {
1004
+ drive = await this.#getOrCreateDrive(name, {
1005
+ server: true,
1006
+ client: true,
1007
+ })
1008
+ }
1009
+
1010
+ await this.#joinCidTopicInternal(cidString, {
1011
+ server: false,
1012
+ client: true,
1013
+ })
1014
+
1015
+ const driveKey = '/' + cidString
1016
+ const entry = await this.#waitForDriveEntry(drive, driveKey, timeout)
1017
+
1018
+ if (!entry) {
1019
+ throw new PeerNotFoundError(
1020
+ '当前没有发现可下载的在线种子,请稍后重试或确认发布者在线'
1021
+ )
1022
+ }
1023
+
1024
+ let size = null
1025
+ try {
1026
+ const stat = await drive.entry(entry.key)
1027
+ if (stat?.value?.blob) {
1028
+ size = stat.value.blob.byteLength || 0
1029
+ }
1030
+ } catch {}
1031
+
1032
+ return {
1033
+ available: true,
1034
+ cid: cidString,
1035
+ fileName: parsed.fileName,
1036
+ size,
1037
+ }
1038
+ }
1039
+
750
1040
  /**
751
1041
  * 列出所有已发布文件
752
1042
  * @param {object} [options] - 筛选选项
@@ -756,6 +1046,11 @@ export class MostBoxEngine extends EventEmitter {
756
1046
  listPublishedFiles(options = {}) {
757
1047
  this.#ensureInitialized()
758
1048
  let files = this.#publishedFiles
1049
+ const ownerAddress = normalizeOwnerAddress(options.ownerAddress)
1050
+
1051
+ if (ownerAddress) {
1052
+ files = files.filter(f => this.#recordMatchesOwner(f, ownerAddress))
1053
+ }
759
1054
 
760
1055
  if (options.starred === true) {
761
1056
  files = files.filter(f => f.starred === true)
@@ -764,9 +1059,10 @@ export class MostBoxEngine extends EventEmitter {
764
1059
  return files.map(f => ({
765
1060
  fileName: f.fileName,
766
1061
  cid: f.cid,
767
- link: `most://${f.cid}`,
1062
+ link: `most://${f.cid}?filename=${encodeURIComponent(f.fileName)}`,
768
1063
  publishedAt: f.publishedAt,
769
1064
  starred: f.starred || false,
1065
+ ownerAddress: f.ownerAddress || '',
770
1066
  }))
771
1067
  }
772
1068
 
@@ -775,9 +1071,12 @@ export class MostBoxEngine extends EventEmitter {
775
1071
  * @param {string} cid - 文件的 CID
776
1072
  * @returns {object} 更新后的文件信息
777
1073
  */
778
- toggleStarred(cid) {
1074
+ toggleStarred(cid, options = {}) {
779
1075
  this.#ensureInitialized()
780
- const index = this.#publishedFiles.findIndex(f => f.cid === cid)
1076
+ const ownerAddress = normalizeOwnerAddress(options.ownerAddress)
1077
+ const index = this.#publishedFiles.findIndex(
1078
+ f => f.cid === cid && this.#recordMatchesOwner(f, ownerAddress)
1079
+ )
781
1080
  if (index === -1) {
782
1081
  throw new Error('File not found')
783
1082
  }
@@ -794,40 +1093,62 @@ export class MostBoxEngine extends EventEmitter {
794
1093
  * @param {string} cid - 要删除文件的 CID
795
1094
  * @returns {Promise<Array>} 更新后的已发布文件列表
796
1095
  */
797
- async deletePublishedFile(cid) {
1096
+ async deletePublishedFile(cid, options = {}) {
798
1097
  this.#ensureInitialized()
799
- const index = this.#publishedFiles.findIndex(f => f.cid === cid)
1098
+ const ownerAddress = normalizeOwnerAddress(options.ownerAddress)
1099
+ const index = this.#publishedFiles.findIndex(
1100
+ f => f.cid === cid && this.#recordMatchesOwner(f, ownerAddress)
1101
+ )
800
1102
  if (index !== -1) {
801
1103
  const fileRecord = this.#publishedFiles[index]
1104
+ const holding = this.#holdings.find(item => item.cid === fileRecord.cid)
802
1105
 
803
1106
  this.#trashFiles.push({
804
1107
  fileName: fileRecord.fileName,
805
1108
  cid: fileRecord.cid,
806
- driveName: fileRecord.driveName,
1109
+ driveName:
1110
+ fileRecord.driveName || this.#getCidInfo(fileRecord.cid).driveName,
1111
+ size: holding?.size ?? fileRecord.size ?? 0,
1112
+ localPath: holding?.localPath || fileRecord.localPath || null,
1113
+ source: holding?.source || 'published',
807
1114
  publishedAt: fileRecord.publishedAt,
808
1115
  starred: fileRecord.starred || false,
1116
+ ownerAddress: fileRecord.ownerAddress || ownerAddress,
809
1117
  deletedAt: new Date().toISOString(),
810
1118
  })
811
1119
  this.#saveTrashMetadata()
812
1120
 
813
1121
  this.#publishedFiles.splice(index, 1)
814
1122
  this.#savePublishedMetadata()
1123
+
1124
+ if (!this.#hasPublishedReference(fileRecord.cid)) {
1125
+ await this.#leaveCidTopic(fileRecord.cid)
1126
+ await this.#closeDriveForSeed(
1127
+ fileRecord.driveName || this.#getCidInfo(fileRecord.cid).driveName
1128
+ )
1129
+ this.#removeHolding(fileRecord.cid)
1130
+ }
815
1131
  }
816
- return this.listPublishedFiles()
1132
+ return this.listPublishedFiles({ ownerAddress })
817
1133
  }
818
1134
 
819
1135
  /**
820
1136
  * 列出回收站中的所有文件
821
1137
  * @returns {Array} 回收站文件
822
1138
  */
823
- listTrashFiles() {
1139
+ listTrashFiles(options = {}) {
824
1140
  this.#ensureInitialized()
825
- return this.#trashFiles.map(f => ({
1141
+ const ownerAddress = normalizeOwnerAddress(options.ownerAddress)
1142
+ const files = ownerAddress
1143
+ ? this.#trashFiles.filter(f => this.#recordMatchesOwner(f, ownerAddress))
1144
+ : this.#trashFiles
1145
+ return files.map(f => ({
826
1146
  fileName: f.fileName,
827
1147
  cid: f.cid,
828
- link: `most://${f.cid}`,
1148
+ link: `most://${f.cid}?filename=${encodeURIComponent(f.fileName)}`,
829
1149
  publishedAt: f.publishedAt,
830
1150
  starred: f.starred || false,
1151
+ ownerAddress: f.ownerAddress || '',
831
1152
  deletedAt: f.deletedAt,
832
1153
  }))
833
1154
  }
@@ -835,20 +1156,21 @@ export class MostBoxEngine extends EventEmitter {
835
1156
  /**
836
1157
  * 从回收站恢复文件
837
1158
  * @param {string} cid - 要恢复文件的 CID
838
- * @returns {Array} 更新后的已发布文件列表
1159
+ * @returns {Promise<Array>} 更新后的已发布文件列表
839
1160
  */
840
- restoreTrashFile(cid) {
1161
+ async restoreTrashFile(cid, options = {}) {
841
1162
  this.#ensureInitialized()
842
- const index = this.#trashFiles.findIndex(f => f.cid === cid)
1163
+ const ownerAddress = normalizeOwnerAddress(options.ownerAddress)
1164
+ const index = this.#trashFiles.findIndex(
1165
+ f => f.cid === cid && this.#recordMatchesOwner(f, ownerAddress)
1166
+ )
843
1167
  if (index === -1) {
844
1168
  throw new Error('File not found in trash')
845
1169
  }
846
1170
 
847
1171
  const fileRecord = this.#trashFiles[index]
848
1172
 
849
- const parsedCid = CID.parse(fileRecord.cid)
850
- const hashHex = b4a.toString(parsedCid.multihash.digest, 'hex')
851
- const driveName = `drive-${hashHex}`
1173
+ const { driveName } = this.#getCidInfo(fileRecord.cid)
852
1174
 
853
1175
  this.#publishedFiles.push({
854
1176
  fileName: fileRecord.fileName,
@@ -856,13 +1178,27 @@ export class MostBoxEngine extends EventEmitter {
856
1178
  driveName,
857
1179
  publishedAt: fileRecord.publishedAt,
858
1180
  starred: fileRecord.starred || false,
1181
+ ownerAddress: fileRecord.ownerAddress || ownerAddress,
859
1182
  })
860
1183
  this.#savePublishedMetadata()
861
1184
 
862
1185
  this.#trashFiles.splice(index, 1)
863
1186
  this.#saveTrashMetadata()
864
1187
 
865
- return this.listPublishedFiles()
1188
+ await this.#joinCidTopicInternal(fileRecord.cid, {
1189
+ server: true,
1190
+ client: false,
1191
+ })
1192
+ this.#upsertHolding({
1193
+ cid: fileRecord.cid,
1194
+ fileName: fileRecord.fileName,
1195
+ size: Number(fileRecord.size) || 0,
1196
+ localPath: fileRecord.localPath || null,
1197
+ driveName,
1198
+ source: fileRecord.source || 'published',
1199
+ })
1200
+
1201
+ return this.listPublishedFiles({ ownerAddress })
866
1202
  }
867
1203
 
868
1204
  /**
@@ -870,68 +1206,81 @@ export class MostBoxEngine extends EventEmitter {
870
1206
  * @param {string} cid - 要永久删除文件的 CID
871
1207
  * @returns {Promise<Array>} 更新后的回收站列表
872
1208
  */
873
- async permanentDeleteTrashFile(cid) {
1209
+ async permanentDeleteTrashFile(cid, options = {}) {
874
1210
  this.#ensureInitialized()
875
- const index = this.#trashFiles.findIndex(f => f.cid === cid)
1211
+ const ownerAddress = normalizeOwnerAddress(options.ownerAddress)
1212
+ const index = this.#trashFiles.findIndex(
1213
+ f => f.cid === cid && this.#recordMatchesOwner(f, ownerAddress)
1214
+ )
876
1215
  if (index !== -1) {
877
1216
  const fileRecord = this.#trashFiles[index]
878
- const driveName = fileRecord.driveName
1217
+ const driveName =
1218
+ fileRecord.driveName || this.#getCidInfo(fileRecord.cid).driveName
1219
+
1220
+ this.#trashFiles.splice(index, 1)
1221
+ this.#saveTrashMetadata()
879
1222
 
880
- const drive = this.#drives.get(driveName)
881
- if (drive) {
1223
+ if (!this.#hasAnyUserReference(fileRecord.cid)) {
882
1224
  try {
1225
+ const drive = await this.#getOrCreateDrive(driveName)
883
1226
  await drive.del('/' + fileRecord.cid)
884
1227
  } catch {
885
1228
  // 文件可能不存在于驱动器中
886
1229
  }
887
-
888
- await this.#swarm.leave(drive.discoveryKey)
889
- await drive.close()
890
- this.#drives.delete(driveName)
1230
+ await this.#closeDriveForSeed(driveName)
1231
+ await this.#leaveCidTopic(fileRecord.cid)
1232
+ this.#removeHolding(fileRecord.cid)
891
1233
  }
892
-
893
- this.#trashFiles.splice(index, 1)
894
- this.#saveTrashMetadata()
895
1234
  }
896
- return this.listTrashFiles()
1235
+ return this.listTrashFiles({ ownerAddress })
897
1236
  }
898
1237
 
899
1238
  /**
900
1239
  * 清空回收站 — 永久删除所有回收站文件
901
1240
  * @returns {Promise<Array>} 清空后的回收站列表
902
1241
  */
903
- async emptyTrash() {
1242
+ async emptyTrash(options = {}) {
904
1243
  this.#ensureInitialized()
1244
+ const ownerAddress = normalizeOwnerAddress(options.ownerAddress)
1245
+ const remainingTrash = []
1246
+ const removedTrash = []
905
1247
 
906
1248
  for (const fileRecord of this.#trashFiles) {
907
- const driveName = fileRecord.driveName
908
-
909
- const drive = this.#drives.get(driveName)
910
- if (drive) {
911
- try {
912
- await drive.del('/' + fileRecord.cid)
913
- } catch {
914
- // 文件可能不存在
915
- }
916
-
917
- await this.#swarm.leave(drive.discoveryKey)
918
- await drive.close()
919
- this.#drives.delete(driveName)
1249
+ if (ownerAddress && !this.#recordMatchesOwner(fileRecord, ownerAddress)) {
1250
+ remainingTrash.push(fileRecord)
1251
+ continue
920
1252
  }
1253
+ removedTrash.push(fileRecord)
921
1254
  }
922
1255
 
923
- this.#trashFiles = []
1256
+ this.#trashFiles = remainingTrash
924
1257
  this.#saveTrashMetadata()
925
1258
 
926
- return []
1259
+ for (const fileRecord of removedTrash) {
1260
+ if (this.#hasAnyUserReference(fileRecord.cid)) continue
1261
+ const driveName =
1262
+ fileRecord.driveName || this.#getCidInfo(fileRecord.cid).driveName
1263
+ try {
1264
+ const drive = await this.#getOrCreateDrive(driveName)
1265
+ await drive.del('/' + fileRecord.cid)
1266
+ } catch {
1267
+ // 文件可能不存在
1268
+ }
1269
+ await this.#closeDriveForSeed(driveName)
1270
+ await this.#leaveCidTopic(fileRecord.cid)
1271
+ this.#removeHolding(fileRecord.cid)
1272
+ }
1273
+
1274
+ return this.listTrashFiles({ ownerAddress })
927
1275
  }
928
1276
 
929
1277
  /**
930
1278
  * 获取存储统计信息
931
1279
  * @returns {Promise<{ total: number, used: number, free: number, fileCount: number, trashCount: number }>}
932
1280
  */
933
- async getStorageStats() {
1281
+ async getStorageStats(options = {}) {
934
1282
  this.#ensureInitialized()
1283
+ const ownerAddress = normalizeOwnerAddress(options.ownerAddress)
935
1284
 
936
1285
  let totalSize = 0
937
1286
  let freeSize = 0
@@ -982,8 +1331,16 @@ export class MostBoxEngine extends EventEmitter {
982
1331
  total: totalSize,
983
1332
  used: usedSize,
984
1333
  free: freeSize,
985
- fileCount: this.#publishedFiles.length,
986
- trashCount: this.#trashFiles.length,
1334
+ fileCount: ownerAddress
1335
+ ? this.#publishedFiles.filter(f =>
1336
+ this.#recordMatchesOwner(f, ownerAddress)
1337
+ ).length
1338
+ : this.#publishedFiles.length,
1339
+ trashCount: ownerAddress
1340
+ ? this.#trashFiles.filter(f =>
1341
+ this.#recordMatchesOwner(f, ownerAddress)
1342
+ ).length
1343
+ : this.#trashFiles.length,
987
1344
  }
988
1345
  }
989
1346
 
@@ -994,9 +1351,12 @@ export class MostBoxEngine extends EventEmitter {
994
1351
  * @param {string} newFileName - 新文件路径
995
1352
  * @returns {object} 更新后的文件信息
996
1353
  */
997
- moveFile(cid, newFileName) {
1354
+ moveFile(cid, newFileName, options = {}) {
998
1355
  this.#ensureInitialized()
999
- const index = this.#publishedFiles.findIndex(f => f.cid === cid)
1356
+ const ownerAddress = normalizeOwnerAddress(options.ownerAddress)
1357
+ const index = this.#publishedFiles.findIndex(
1358
+ f => f.cid === cid && this.#recordMatchesOwner(f, ownerAddress)
1359
+ )
1000
1360
  if (index === -1) {
1001
1361
  throw new Error('File not found')
1002
1362
  }
@@ -1007,7 +1367,7 @@ export class MostBoxEngine extends EventEmitter {
1007
1367
  return {
1008
1368
  cid,
1009
1369
  fileName: safeFileName,
1010
- link: `most://${cid}`,
1370
+ link: `most://${cid}?filename=${encodeURIComponent(safeFileName)}`,
1011
1371
  }
1012
1372
  }
1013
1373
 
@@ -1018,13 +1378,17 @@ export class MostBoxEngine extends EventEmitter {
1018
1378
  * @param {string} newPath - 新文件夹路径
1019
1379
  * @returns {object} 更新后的文件信息
1020
1380
  */
1021
- renameFolder(oldPath, newPath) {
1381
+ renameFolder(oldPath, newPath, options = {}) {
1022
1382
  this.#ensureInitialized()
1383
+ const ownerAddress = normalizeOwnerAddress(options.ownerAddress)
1023
1384
  const prefix = oldPath + '/'
1024
1385
  const updatedFiles = []
1025
1386
 
1026
1387
  for (const file of this.#publishedFiles) {
1027
- if (file.fileName.startsWith(prefix)) {
1388
+ if (
1389
+ file.fileName.startsWith(prefix) &&
1390
+ this.#recordMatchesOwner(file, ownerAddress)
1391
+ ) {
1028
1392
  const remainder = file.fileName.substring(prefix.length)
1029
1393
  const newFileName = sanitizeFilename(
1030
1394
  remainder ? newPath + '/' + remainder : newPath
@@ -1034,7 +1398,7 @@ export class MostBoxEngine extends EventEmitter {
1034
1398
  updatedFiles.push({
1035
1399
  cid: file.cid,
1036
1400
  fileName: file.fileName,
1037
- link: `most://${file.cid}`,
1401
+ link: `most://${file.cid}?filename=${encodeURIComponent(file.fileName)}`,
1038
1402
  })
1039
1403
  }
1040
1404
  }
@@ -1054,85 +1418,337 @@ export class MostBoxEngine extends EventEmitter {
1054
1418
  const task = this.#activeDownloads.get(taskId)
1055
1419
  if (task) {
1056
1420
  task.aborted = true
1057
- if (task.readStream) task.readStream.destroy()
1421
+ const err = new Error('Download cancelled')
1422
+ if (task.readStream) task.readStream.destroy(err)
1058
1423
  if (task.writeStream) task.writeStream.destroy()
1059
1424
  }
1060
1425
  }
1061
1426
 
1062
- getPublishedFiles() {
1063
- return this.#publishedFiles
1427
+ hasDownloadNameConflict(fileName) {
1428
+ this.#ensureInitialized()
1429
+ const sanitizedFileName = sanitizeFilename(fileName)
1430
+ const savePath = path.join(this.#options.downloadPath, sanitizedFileName)
1431
+ return fs.existsSync(savePath)
1064
1432
  }
1065
1433
 
1066
- /**
1067
- * 读取已发布文件的内容(用于预览)
1068
- * Hyperdrive 中用 CID 作为 key 存储
1069
- * @param {string} cid - 文件的 CID
1070
- * @param {number} [offset=0] - 读取起始位置
1071
- * @param {number} [limit=10000] - 最大读取字节数
1072
- */
1073
- async readFileContent(cid, offset = 0, limit = DEFAULT_READ_LIMIT) {
1074
- this.#ensureInitialized()
1434
+ setMaxFileSize(maxFileSize) {
1435
+ const parsed = Number(maxFileSize)
1436
+ if (!Number.isFinite(parsed) || parsed < 0) {
1437
+ throw new ValidationError('maxFileSize must be a non-negative number')
1438
+ }
1439
+ this.#options.maxFileSize = Math.floor(parsed)
1440
+ }
1075
1441
 
1076
- const fileRecord = this.#publishedFiles.find(f => f.cid === cid)
1077
- if (!fileRecord) {
1078
- throw new Error('File not found')
1442
+ getPublishedFiles(options = {}) {
1443
+ const ownerAddress = normalizeOwnerAddress(options.ownerAddress)
1444
+ return ownerAddress
1445
+ ? this.#publishedFiles.filter(f =>
1446
+ this.#recordMatchesOwner(f, ownerAddress)
1447
+ )
1448
+ : this.#publishedFiles
1449
+ }
1450
+
1451
+ listUsers() {
1452
+ this.#ensureInitialized()
1453
+ const users = new Map()
1454
+ const ensure = address => {
1455
+ const ownerAddress = normalizeOwnerAddress(address)
1456
+ if (!ownerAddress) return null
1457
+ if (!users.has(ownerAddress)) {
1458
+ users.set(ownerAddress, {
1459
+ address: ownerAddress,
1460
+ fileCount: 0,
1461
+ trashCount: 0,
1462
+ cidCount: 0,
1463
+ cids: new Set(),
1464
+ })
1465
+ }
1466
+ return users.get(ownerAddress)
1079
1467
  }
1080
1468
 
1081
- const drive = await this.#getDriveForFile(fileRecord)
1469
+ for (const file of this.#publishedFiles) {
1470
+ const entry = ensure(file.ownerAddress)
1471
+ if (!entry) continue
1472
+ entry.fileCount += 1
1473
+ entry.cids.add(file.cid)
1474
+ }
1475
+ for (const file of this.#trashFiles) {
1476
+ const entry = ensure(file.ownerAddress)
1477
+ if (!entry) continue
1478
+ entry.trashCount += 1
1479
+ entry.cids.add(file.cid)
1480
+ }
1481
+
1482
+ return [...users.values()].map(user => ({
1483
+ address: user.address,
1484
+ fileCount: user.fileCount,
1485
+ trashCount: user.trashCount,
1486
+ cidCount: user.cids.size,
1487
+ }))
1488
+ }
1082
1489
 
1083
- // Hyperdrive 中 key 为 '/' + cid
1084
- const driveKey = '/' + cid
1085
- const entry = await drive.entry(driveKey, {
1086
- wait: true,
1087
- timeout: DRIVE_ENTRY_TIMEOUT,
1088
- })
1089
- if (!entry || !entry.value) {
1090
- throw new Error('File content not available')
1490
+ async clearUserData(ownerAddressInput) {
1491
+ this.#ensureInitialized()
1492
+ const ownerAddress = normalizeOwnerAddress(ownerAddressInput)
1493
+ if (!ownerAddress) {
1494
+ throw new ValidationError('valid owner address is required')
1091
1495
  }
1092
1496
 
1093
- const chunks = []
1094
- const stream = drive.createReadStream(driveKey, {
1095
- start: offset,
1096
- end: offset + limit - 1,
1097
- })
1497
+ const affectedCids = new Set()
1498
+ const beforeFiles = this.#publishedFiles.length
1499
+ const beforeTrash = this.#trashFiles.length
1098
1500
 
1099
- const timeoutPromise = new Promise((_, reject) => {
1100
- setTimeout(
1101
- () => reject(new Error('Stream read timeout')),
1102
- STREAM_READ_TIMEOUT
1103
- )
1501
+ this.#publishedFiles = this.#publishedFiles.filter(file => {
1502
+ if (this.#recordMatchesOwner(file, ownerAddress)) {
1503
+ affectedCids.add(file.cid)
1504
+ return false
1505
+ }
1506
+ return true
1104
1507
  })
1105
-
1106
- const readPromise = (async () => {
1107
- for await (const chunk of stream) {
1108
- chunks.push(chunk)
1508
+ this.#trashFiles = this.#trashFiles.filter(file => {
1509
+ if (this.#recordMatchesOwner(file, ownerAddress)) {
1510
+ affectedCids.add(file.cid)
1511
+ return false
1109
1512
  }
1110
- })()
1513
+ return true
1514
+ })
1515
+ this.#channels = this.#channels
1516
+ .map(channel => ({
1517
+ ...channel,
1518
+ members: Array.isArray(channel.members)
1519
+ ? channel.members.filter(
1520
+ member => normalizeOwnerAddress(member) !== ownerAddress
1521
+ )
1522
+ : [],
1523
+ }))
1524
+ .filter(channel => channel.members.length > 0)
1111
1525
 
1112
- await Promise.race([readPromise, timeoutPromise])
1526
+ this.#savePublishedMetadata()
1527
+ this.#saveTrashMetadata()
1528
+ this.#saveChannelsMetadata()
1113
1529
 
1114
- const content = Buffer.concat(chunks).toString('utf8')
1115
- const hasMore =
1116
- chunks.length > 0 && chunks[chunks.length - 1].length === limit
1530
+ let removedReplicas = 0
1531
+ for (const cid of affectedCids) {
1532
+ if (this.#hasAnyUserReference(cid)) continue
1533
+ const driveName = this.#getCidInfo(cid).driveName
1534
+ try {
1535
+ const drive = await this.#getOrCreateDrive(driveName)
1536
+ await drive.del('/' + cid)
1537
+ } catch {}
1538
+ await this.#closeDriveForSeed(driveName)
1539
+ await this.#leaveCidTopic(cid)
1540
+ this.#removeHolding(cid)
1541
+ removedReplicas += 1
1542
+ }
1117
1543
 
1118
- return { content, hasMore }
1544
+ return {
1545
+ ownerAddress,
1546
+ removedFiles: beforeFiles - this.#publishedFiles.length,
1547
+ removedTrashFiles: beforeTrash - this.#trashFiles.length,
1548
+ removedReplicas,
1549
+ }
1119
1550
  }
1120
1551
 
1121
1552
  /**
1122
- * 读取已发布文件的原始内容(用于预览/下载)
1123
- * Hyperdrive 中用 CID 作为 key 存储
1124
- * @param {string} cid - 文件的 CID
1125
- * @param {object} [options] - 选项
1126
- * @param {number} [options.offset=0] - 读取起始位置
1127
- * @param {number} [options.limit] - 最大读取字节数,不指定则读取到末尾
1128
- * @param {number} [options.timeout=10000] - 流读取超时(毫秒)
1129
- * @returns {Promise<{buffer: Buffer, fileName: string, totalSize: number}>}
1553
+ * 列出当前节点持有的可做种文件副本
1554
+ * @returns {Array}
1130
1555
  */
1131
- async readFileRaw(cid, options = {}) {
1556
+ listHoldings() {
1132
1557
  this.#ensureInitialized()
1133
-
1134
- const fileRecord = this.#publishedFiles.find(f => f.cid === cid)
1135
- if (!fileRecord) {
1558
+ return this.#holdings.map(holding => {
1559
+ const seedState = this.#seedStates.get(holding.cid)
1560
+ const status =
1561
+ seedState?.status ||
1562
+ (this.#fileDiscoveries.has(holding.cid) ? 'active' : 'queued')
1563
+ return {
1564
+ ...holding,
1565
+ joined: status === 'active' && this.#fileDiscoveries.has(holding.cid),
1566
+ seedStatus: status,
1567
+ seedError: seedState?.error,
1568
+ seedStatusUpdatedAt: seedState?.updatedAt,
1569
+ ...this.#getFileRuntimeStats(holding.cid),
1570
+ link: `most://${holding.cid}?filename=${encodeURIComponent(holding.fileName || holding.cid)}`,
1571
+ }
1572
+ })
1573
+ }
1574
+
1575
+ /**
1576
+ * 手动记录节点已持有的文件副本
1577
+ * @param {object} record - 持有记录
1578
+ */
1579
+ async addHolding(record) {
1580
+ this.#ensureInitialized()
1581
+ const holding = this.#normalizeHolding(record)
1582
+ await this.#joinCidTopicInternal(holding.cid, {
1583
+ server: true,
1584
+ client: false,
1585
+ })
1586
+ return this.#upsertHolding(holding)
1587
+ }
1588
+
1589
+ /**
1590
+ * 按 CID digest topic 拉取完整文件副本
1591
+ * @param {object} input - 拉取参数
1592
+ * @param {string} [input.link] - most:// 链接
1593
+ * @param {string} [input.cid] - 文件 CID
1594
+ * @param {string} [input.fileName] - 保存文件名
1595
+ * @param {string} [input.taskId] - 下载任务 ID
1596
+ * @param {number} [input.timeout] - 等待 P2P 内容的超时时间
1597
+ */
1598
+ async pullByCid(input = {}) {
1599
+ this.#ensureInitialized()
1600
+
1601
+ if (input.link) {
1602
+ const parsed = parseMostLink(input.link)
1603
+ if (parsed.error) {
1604
+ throw new ValidationError(parsed.error)
1605
+ }
1606
+ const result = await this.downloadFile(input.link, input.taskId || null, {
1607
+ timeout: input.timeout,
1608
+ ownerAddress: input.ownerAddress,
1609
+ })
1610
+ return {
1611
+ ...result,
1612
+ cid: parsed.cid,
1613
+ }
1614
+ }
1615
+
1616
+ const cid = input.cid
1617
+ if (!cid) {
1618
+ throw new ValidationError('cid is required')
1619
+ }
1620
+
1621
+ this.#getCidInfo(cid)
1622
+ const fileName = sanitizeFilename(input.fileName || `${cid}.bin`)
1623
+ const link = `most://${cid}?filename=${encodeURIComponent(fileName)}`
1624
+ const result = await this.downloadFile(link, input.taskId || null, {
1625
+ timeout: input.timeout,
1626
+ ownerAddress: input.ownerAddress,
1627
+ })
1628
+
1629
+ return {
1630
+ ...result,
1631
+ cid,
1632
+ }
1633
+ }
1634
+
1635
+ /**
1636
+ * 按 CID digest 加入文件 topic
1637
+ * @param {string} cid - 文件 CID
1638
+ * @param {object} [options] - Hyperswarm join 选项
1639
+ */
1640
+ async joinCidTopic(cid, options = {}) {
1641
+ this.#ensureInitialized()
1642
+ return this.#joinCidTopicInternal(cid, options)
1643
+ }
1644
+
1645
+ /**
1646
+ * 用内存复制流连接两个本地引擎,供本地集成测试和诊断使用。
1647
+ */
1648
+ replicateWith(peerEngine) {
1649
+ this.#ensureInitialized()
1650
+ peerEngine.#ensureInitialized()
1651
+
1652
+ const left = this.#store.replicate(true, { live: true })
1653
+ const right = peerEngine.#store.replicate(false, { live: true })
1654
+
1655
+ left.on('error', () => {})
1656
+ right.on('error', () => {})
1657
+ left.pipe(right).pipe(left)
1658
+
1659
+ return {
1660
+ close: () => {
1661
+ left.destroy()
1662
+ right.destroy()
1663
+ },
1664
+ }
1665
+ }
1666
+
1667
+ /**
1668
+ * 读取已发布文件的内容(用于预览)
1669
+ * Hyperdrive 中用 CID 作为 key 存储
1670
+ * @param {string} cid - 文件的 CID
1671
+ * @param {number} [offset=0] - 读取起始位置
1672
+ * @param {number} [limit=10000] - 最大读取字节数
1673
+ */
1674
+ async readFileContent(
1675
+ cid,
1676
+ offset = 0,
1677
+ limit = DEFAULT_READ_LIMIT,
1678
+ options = {}
1679
+ ) {
1680
+ this.#ensureInitialized()
1681
+ const ownerAddress = normalizeOwnerAddress(options.ownerAddress)
1682
+
1683
+ const fileRecord = this.#publishedFiles.find(
1684
+ f =>
1685
+ f.cid === cid &&
1686
+ (options.public || this.#recordMatchesOwner(f, ownerAddress))
1687
+ )
1688
+ if (!fileRecord) {
1689
+ throw new Error('File not found')
1690
+ }
1691
+
1692
+ const drive = await this.#getDriveForFile(fileRecord)
1693
+
1694
+ // Hyperdrive 中 key 为 '/' + cid
1695
+ const driveKey = '/' + cid
1696
+ const entry = await drive.entry(driveKey, {
1697
+ wait: true,
1698
+ timeout: DRIVE_ENTRY_TIMEOUT,
1699
+ })
1700
+ if (!entry || !entry.value) {
1701
+ throw new Error('File content not available')
1702
+ }
1703
+
1704
+ const chunks = []
1705
+ const stream = drive.createReadStream(driveKey, {
1706
+ start: offset,
1707
+ end: offset + limit - 1,
1708
+ })
1709
+
1710
+ const timeoutPromise = new Promise((_, reject) => {
1711
+ setTimeout(
1712
+ () => reject(new Error('Stream read timeout')),
1713
+ STREAM_READ_TIMEOUT
1714
+ )
1715
+ })
1716
+
1717
+ const readPromise = (async () => {
1718
+ for await (const chunk of stream) {
1719
+ chunks.push(chunk)
1720
+ }
1721
+ })()
1722
+
1723
+ await Promise.race([readPromise, timeoutPromise])
1724
+
1725
+ const content = Buffer.concat(chunks).toString('utf8')
1726
+ const hasMore =
1727
+ chunks.length > 0 && chunks[chunks.length - 1].length === limit
1728
+
1729
+ return { content, hasMore }
1730
+ }
1731
+
1732
+ /**
1733
+ * 读取已发布文件的原始内容(用于预览/下载)
1734
+ * Hyperdrive 中用 CID 作为 key 存储
1735
+ * @param {string} cid - 文件的 CID
1736
+ * @param {object} [options] - 选项
1737
+ * @param {number} [options.offset=0] - 读取起始位置
1738
+ * @param {number} [options.limit] - 最大读取字节数,不指定则读取到末尾
1739
+ * @param {number} [options.timeout=10000] - 流读取超时(毫秒)
1740
+ * @returns {Promise<{buffer: Buffer, fileName: string, totalSize: number}>}
1741
+ */
1742
+ async readFileRaw(cid, options = {}) {
1743
+ this.#ensureInitialized()
1744
+ const ownerAddress = normalizeOwnerAddress(options.ownerAddress)
1745
+
1746
+ const fileRecord = this.#publishedFiles.find(
1747
+ f =>
1748
+ f.cid === cid &&
1749
+ (options.public || this.#recordMatchesOwner(f, ownerAddress))
1750
+ )
1751
+ if (!fileRecord) {
1136
1752
  throw new Error('File not found')
1137
1753
  }
1138
1754
 
@@ -1215,8 +1831,9 @@ export class MostBoxEngine extends EventEmitter {
1215
1831
  * @param {string} [type='personal'] - 频道类型
1216
1832
  * @returns {Promise<{ name: string, key: string }>}
1217
1833
  */
1218
- async createChannel(name, type = 'personal') {
1834
+ async createChannel(name, type = 'personal', options = {}) {
1219
1835
  this.#ensureInitialized()
1836
+ const ownerAddress = normalizeOwnerAddress(options.ownerAddress)
1220
1837
 
1221
1838
  if (!CHANNEL_NAME_REGEX.test(name)) {
1222
1839
  throw new Error('频道名只能包含字母、数字、下划线和连字符')
@@ -1230,6 +1847,13 @@ export class MostBoxEngine extends EventEmitter {
1230
1847
 
1231
1848
  const existing = this.#channels.find(c => c.name === name)
1232
1849
  if (existing) {
1850
+ if (ownerAddress && !Array.isArray(existing.members)) {
1851
+ existing.members = []
1852
+ }
1853
+ if (ownerAddress && !existing.members.includes(ownerAddress)) {
1854
+ existing.members.push(ownerAddress)
1855
+ this.#saveChannelsMetadata()
1856
+ }
1233
1857
  return { name: existing.name, key: existing.coreKey }
1234
1858
  }
1235
1859
 
@@ -1243,12 +1867,10 @@ export class MostBoxEngine extends EventEmitter {
1243
1867
  server: true,
1244
1868
  client: true,
1245
1869
  })
1246
- await appDiscovery.flushed()
1247
1870
  const chatDiscovery = this.#chatSwarm.join(chatDiscoveryKey, {
1248
1871
  server: true,
1249
1872
  client: true,
1250
1873
  })
1251
- await chatDiscovery.flushed()
1252
1874
 
1253
1875
  this.#setupChannelAppendListener(core, name)
1254
1876
 
@@ -1258,10 +1880,18 @@ export class MostBoxEngine extends EventEmitter {
1258
1880
  coreKey: b4a.toString(core.key, 'hex'),
1259
1881
  createdAt: new Date().toISOString(),
1260
1882
  type,
1883
+ ownerAddress,
1884
+ members: ownerAddress ? [ownerAddress] : [],
1885
+ remoteCoreKeys: [],
1261
1886
  }
1262
1887
 
1263
1888
  this.#channels.push(channelInfo)
1264
- this.#channelCores.set(name, core)
1889
+ const coreKeyHex = b4a.toString(core.key, 'hex')
1890
+ if (!this.#channelCores.has(name)) {
1891
+ this.#channelCores.set(name, new Map())
1892
+ }
1893
+ this.#channelCores.get(name).set(coreKeyHex, core)
1894
+ this.#channelLocalCoreKey.set(name, coreKeyHex)
1265
1895
  this.#channelPeers.set(name, new Map())
1266
1896
  this.#channelDiscoveries.set(name, appDiscovery)
1267
1897
  this.#channelChatDiscoveries.set(name, chatDiscovery)
@@ -1279,11 +1909,29 @@ export class MostBoxEngine extends EventEmitter {
1279
1909
  * @param {string} [coreKey] - 频道的 coreKey(加入他人创建的频道时必填)
1280
1910
  * @returns {Promise<{ name: string, key: string }>}
1281
1911
  */
1282
- async joinChannel(name, coreKey = null) {
1912
+ async joinChannel(name, coreKey = null, options = {}) {
1283
1913
  this.#ensureInitialized()
1914
+ const ownerAddress = normalizeOwnerAddress(options.ownerAddress)
1284
1915
 
1285
1916
  const existing = this.#channels.find(c => c.name === name)
1286
1917
  if (existing) {
1918
+ if (ownerAddress && !Array.isArray(existing.members)) {
1919
+ existing.members = []
1920
+ }
1921
+ if (ownerAddress && !existing.members.includes(ownerAddress)) {
1922
+ existing.members.push(ownerAddress)
1923
+ this.#saveChannelsMetadata()
1924
+ }
1925
+ if (coreKey && coreKey !== existing.coreKey) {
1926
+ if (!Array.isArray(existing.remoteCoreKeys)) {
1927
+ existing.remoteCoreKeys = []
1928
+ }
1929
+ if (!existing.remoteCoreKeys.includes(coreKey)) {
1930
+ existing.remoteCoreKeys.push(coreKey)
1931
+ this.#saveChannelsMetadata()
1932
+ }
1933
+ await this.#openRemoteChannelCore(name, coreKey)
1934
+ }
1287
1935
  return { name: existing.name, key: existing.coreKey }
1288
1936
  }
1289
1937
 
@@ -1292,11 +1940,13 @@ export class MostBoxEngine extends EventEmitter {
1292
1940
  }
1293
1941
 
1294
1942
  const ns = this.#store.namespace(`channel-${name}`)
1295
- const core = ns.get({
1296
- key: b4a.from(coreKey, 'hex'),
1943
+ const remoteCoreKeyHex = b4a.toString(b4a.from(coreKey, 'hex'), 'hex')
1944
+ const localCore = ns.get({
1945
+ name: `messages-${this.getNodeId()}`,
1297
1946
  valueEncoding: 'json',
1298
1947
  })
1299
- await core.ready()
1948
+ await localCore.ready()
1949
+ const localCoreKeyHex = b4a.toString(localCore.key, 'hex')
1300
1950
 
1301
1951
  const discoveryKey = this.#generateChannelDiscoveryKey(name)
1302
1952
  const chatDiscoveryKey = this.#generateChannelChatDiscoveryKey(name)
@@ -1304,34 +1954,43 @@ export class MostBoxEngine extends EventEmitter {
1304
1954
  server: true,
1305
1955
  client: true,
1306
1956
  })
1307
- await appDiscovery.flushed()
1308
1957
  const chatDiscovery = this.#chatSwarm.join(chatDiscoveryKey, {
1309
1958
  server: true,
1310
1959
  client: true,
1311
1960
  })
1312
- await chatDiscovery.flushed()
1313
1961
 
1314
- this.#setupChannelAppendListener(core, name)
1962
+ this.#setupChannelAppendListener(localCore, name)
1315
1963
 
1316
1964
  const channelInfo = {
1317
1965
  name,
1318
1966
  discoveryKey: b4a.toString(discoveryKey, 'hex'),
1319
- coreKey,
1967
+ coreKey: localCoreKeyHex,
1320
1968
  createdAt: new Date().toISOString(),
1321
1969
  type: 'group',
1970
+ ownerAddress,
1971
+ members: ownerAddress ? [ownerAddress] : [],
1972
+ remoteCoreKeys:
1973
+ remoteCoreKeyHex === localCoreKeyHex ? [] : [remoteCoreKeyHex],
1322
1974
  }
1323
1975
 
1324
1976
  this.#channels.push(channelInfo)
1325
- this.#channelCores.set(name, core)
1977
+ if (!this.#channelCores.has(name)) {
1978
+ this.#channelCores.set(name, new Map())
1979
+ }
1980
+ this.#channelCores.get(name).set(localCoreKeyHex, localCore)
1981
+ this.#channelLocalCoreKey.set(name, localCoreKeyHex)
1326
1982
  this.#channelPeers.set(name, new Map())
1327
1983
  this.#channelDiscoveries.set(name, appDiscovery)
1328
1984
  this.#channelChatDiscoveries.set(name, chatDiscovery)
1329
1985
  this.#saveChannelsMetadata()
1986
+ if (remoteCoreKeyHex !== localCoreKeyHex) {
1987
+ await this.#openRemoteChannelCore(name, remoteCoreKeyHex)
1988
+ }
1330
1989
 
1331
1990
  console.log(`[MostBox] Joined channel: ${name}`)
1332
- this.emit('channel:joined', { name, key: coreKey })
1991
+ this.emit('channel:joined', { name, key: localCoreKeyHex })
1333
1992
 
1334
- return { name, key: coreKey }
1993
+ return { name, key: localCoreKeyHex }
1335
1994
  }
1336
1995
 
1337
1996
  /**
@@ -1339,8 +1998,9 @@ export class MostBoxEngine extends EventEmitter {
1339
1998
  * @param {string} name - 频道名
1340
1999
  * @returns {Promise<string[]>} 剩余频道列表
1341
2000
  */
1342
- async leaveChannel(name) {
2001
+ async leaveChannel(name, options = {}) {
1343
2002
  this.#ensureInitialized()
2003
+ const ownerAddress = normalizeOwnerAddress(options.ownerAddress)
1344
2004
 
1345
2005
  const index = this.#channels.findIndex(c => c.name === name)
1346
2006
  if (index === -1) {
@@ -1348,46 +2008,54 @@ export class MostBoxEngine extends EventEmitter {
1348
2008
  }
1349
2009
 
1350
2010
  const channel = this.#channels[index]
2011
+ if (ownerAddress && Array.isArray(channel.members)) {
2012
+ channel.members = channel.members.filter(
2013
+ member => normalizeOwnerAddress(member) !== ownerAddress
2014
+ )
2015
+ if (channel.members.length > 0) {
2016
+ this.#saveChannelsMetadata()
2017
+ return this.listChannels({ ownerAddress })
2018
+ }
2019
+ }
1351
2020
 
1352
2021
  const appDiscovery = this.#channelDiscoveries.get(name)
1353
2022
  if (appDiscovery && this.#swarm) {
1354
- try {
1355
- await this.#swarm.leave(b4a.from(channel.discoveryKey, 'hex'))
1356
- } catch (err) {
2023
+ this.#channelDiscoveries.delete(name)
2024
+ this.#swarm.leave(b4a.from(channel.discoveryKey, 'hex')).catch(err => {
1357
2025
  console.warn(
1358
2026
  `[MostBox] Failed to leave app swarm for ${name}:`,
1359
2027
  err.message
1360
2028
  )
1361
- }
1362
- this.#channelDiscoveries.delete(name)
2029
+ })
1363
2030
  }
1364
2031
 
1365
2032
  const chatDiscovery = this.#channelChatDiscoveries.get(name)
1366
2033
  if (chatDiscovery && this.#chatSwarm) {
1367
- try {
1368
- const chatDiscoveryKey = this.#generateChannelChatDiscoveryKey(name)
1369
- await this.#chatSwarm.leave(chatDiscoveryKey)
1370
- } catch (err) {
2034
+ this.#channelChatDiscoveries.delete(name)
2035
+ const chatDiscoveryKey = this.#generateChannelChatDiscoveryKey(name)
2036
+ this.#chatSwarm.leave(chatDiscoveryKey).catch(err => {
1371
2037
  console.warn(
1372
2038
  `[MostBox] Failed to leave chat swarm for ${name}:`,
1373
2039
  err.message
1374
2040
  )
1375
- }
1376
- this.#channelChatDiscoveries.delete(name)
2041
+ })
1377
2042
  }
1378
2043
 
1379
- const core = this.#channelCores.get(name)
1380
- if (core) {
1381
- try {
1382
- await core.close()
1383
- } catch (err) {
1384
- console.warn(
1385
- `[MostBox] Failed to close channel core for ${name}:`,
1386
- err.message
1387
- )
2044
+ const coresMap = this.#channelCores.get(name)
2045
+ if (coresMap) {
2046
+ for (const [, core] of coresMap) {
2047
+ try {
2048
+ await core.close()
2049
+ } catch (err) {
2050
+ console.warn(
2051
+ `[MostBox] Failed to close channel core for ${name}:`,
2052
+ err.message
2053
+ )
2054
+ }
1388
2055
  }
1389
2056
  this.#channelCores.delete(name)
1390
2057
  }
2058
+ this.#channelLocalCoreKey.delete(name)
1391
2059
 
1392
2060
  this.#channelPeers.delete(name)
1393
2061
  this.#channels.splice(index, 1)
@@ -1396,23 +2064,61 @@ export class MostBoxEngine extends EventEmitter {
1396
2064
  console.log(`[MostBox] Left channel: ${name}`)
1397
2065
  this.emit('channel:left', { name })
1398
2066
 
1399
- return this.listChannels()
2067
+ return this.listChannels({ ownerAddress })
2068
+ }
2069
+
2070
+ setChannelRemark(name, remark, options = {}) {
2071
+ this.#ensureInitialized()
2072
+ const ownerAddress = normalizeOwnerAddress(options.ownerAddress)
2073
+ if (!ownerAddress) {
2074
+ throw new Error('需要登录才能设置备注')
2075
+ }
2076
+
2077
+ const channel = this.#channels.find(c => c.name === name)
2078
+ if (!channel) {
2079
+ throw new Error('频道不存在')
2080
+ }
2081
+
2082
+ const trimmed = (remark || '').trim()
2083
+ if (trimmed.length > 50) {
2084
+ throw new Error('备注最多 50 个字符')
2085
+ }
2086
+
2087
+ if (!channel.remarks) {
2088
+ channel.remarks = {}
2089
+ }
2090
+
2091
+ if (trimmed) {
2092
+ channel.remarks[ownerAddress] = trimmed
2093
+ } else {
2094
+ delete channel.remarks[ownerAddress]
2095
+ }
2096
+
2097
+ this.#saveChannelsMetadata()
2098
+ return trimmed
1400
2099
  }
1401
2100
 
1402
2101
  /**
1403
2102
  * 列出所有频道
1404
- * @returns {Array<{ name: string, coreKey: string, createdAt: string, type: string, peerCount: number }>}
2103
+ * @returns {Array<{ name: string, coreKey: string, createdAt: string, type: string, peerCount: number, remark: string }>}
1405
2104
  */
1406
- listChannels() {
2105
+ listChannels(options = {}) {
1407
2106
  this.#ensureInitialized()
2107
+ const ownerAddress = normalizeOwnerAddress(options.ownerAddress)
1408
2108
 
1409
- return this.#channels.map(c => ({
1410
- name: c.name,
1411
- coreKey: c.coreKey,
1412
- createdAt: c.createdAt,
1413
- type: c.type,
1414
- peerCount: (this.#channelPeers.get(c.name) || new Map()).size,
1415
- }))
2109
+ return this.#channels
2110
+ .filter(c => {
2111
+ if (!ownerAddress) return true
2112
+ return Array.isArray(c.members) && c.members.includes(ownerAddress)
2113
+ })
2114
+ .map(c => ({
2115
+ name: c.name,
2116
+ coreKey: c.coreKey,
2117
+ createdAt: c.createdAt,
2118
+ type: c.type,
2119
+ peerCount: (this.#channelPeers.get(c.name) || new Map()).size,
2120
+ remark: ownerAddress && c.remarks ? c.remarks[ownerAddress] || '' : '',
2121
+ }))
1416
2122
  }
1417
2123
 
1418
2124
  /**
@@ -1425,29 +2131,48 @@ export class MostBoxEngine extends EventEmitter {
1425
2131
  */
1426
2132
  async getChannelMessages(name, options = {}) {
1427
2133
  this.#ensureInitialized()
2134
+ this.#assertChannelMember(name, options.ownerAddress)
1428
2135
 
1429
2136
  const { limit = CHANNEL_MESSAGE_LIMIT, offset = 0 } = options
1430
2137
 
1431
- const core = this.#channelCores.get(name)
1432
- if (!core) {
2138
+ const coresMap = this.#channelCores.get(name)
2139
+ if (!coresMap || coresMap.size === 0) {
1433
2140
  throw new Error('频道未初始化')
1434
2141
  }
1435
2142
 
1436
- const messages = []
1437
- const total = core.length
1438
- const start = Math.max(0, total - offset - limit)
1439
- const end = total - offset
1440
-
1441
- for (let i = start; i < end; i++) {
1442
- try {
1443
- const entry = await core.get(i)
1444
- messages.push(entry)
1445
- } catch {
1446
- break
2143
+ const allMessages = []
2144
+ for (const [coreKeyHex, core] of coresMap) {
2145
+ for (let i = 0; i < core.length; i++) {
2146
+ try {
2147
+ const entry = await core.get(i)
2148
+ if (entry && entry.type === 'message') {
2149
+ allMessages.push({
2150
+ ...entry,
2151
+ _coreKey: coreKeyHex,
2152
+ _index: i,
2153
+ })
2154
+ }
2155
+ } catch {
2156
+ break
2157
+ }
1447
2158
  }
1448
2159
  }
1449
2160
 
1450
- return messages
2161
+ const seen = new Set()
2162
+ const unique = allMessages.filter(m => {
2163
+ const key = `${m._coreKey}:${m.author}:${m.timestamp}:${m.content}`
2164
+ if (seen.has(key)) return false
2165
+ seen.add(key)
2166
+ return true
2167
+ })
2168
+
2169
+ unique.sort((a, b) => a.timestamp - b.timestamp)
2170
+
2171
+ const total = unique.length
2172
+ const start = Math.max(0, total - offset - limit)
2173
+ const end = total - offset
2174
+
2175
+ return unique.slice(start, end).map(({ _coreKey, _index, ...msg }) => msg)
1451
2176
  }
1452
2177
 
1453
2178
  /**
@@ -1456,14 +2181,18 @@ export class MostBoxEngine extends EventEmitter {
1456
2181
  * @param {string} content - 消息内容
1457
2182
  * @param {string} author - 作者 address
1458
2183
  * @param {string} authorName - 作者显示名
2184
+ * @param {object} [options.attachment] - 附件元数据
1459
2185
  * @returns {Promise<object>}
1460
2186
  */
1461
- async sendMessage(name, content, author, authorName) {
2187
+ async sendMessage(name, content, author, authorName, options = {}) {
1462
2188
  this.#ensureInitialized()
2189
+ this.#assertChannelMember(name, options.ownerAddress)
1463
2190
 
1464
- const core = this.#channelCores.get(name)
2191
+ const localKeyHex = this.#channelLocalCoreKey.get(name)
2192
+ const coresMap = this.#channelCores.get(name)
2193
+ const core = localKeyHex && coresMap ? coresMap.get(localKeyHex) : null
1465
2194
  if (!core) {
1466
- throw new Error('频道未初始化')
2195
+ throw new Error('频道未初始化或无可写 core')
1467
2196
  }
1468
2197
 
1469
2198
  if (!content || !content.trim()) {
@@ -1474,6 +2203,10 @@ export class MostBoxEngine extends EventEmitter {
1474
2203
  if (trimmed.length > MAX_MESSAGE_LENGTH) {
1475
2204
  throw new Error(`消息内容不能超过 ${MAX_MESSAGE_LENGTH} 字符`)
1476
2205
  }
2206
+ const attachment = normalizeChannelAttachment(options.attachment)
2207
+ if (attachment && trimmed !== attachment.link) {
2208
+ throw new ValidationError('attachment content must match link')
2209
+ }
1477
2210
 
1478
2211
  const message = {
1479
2212
  type: 'message',
@@ -1482,11 +2215,12 @@ export class MostBoxEngine extends EventEmitter {
1482
2215
  content: trimmed,
1483
2216
  timestamp: Date.now(),
1484
2217
  }
2218
+ if (attachment) {
2219
+ message.attachment = attachment
2220
+ }
1485
2221
 
1486
2222
  await core.append(message)
1487
2223
 
1488
- this.emit('channel:message', { channel: name, message })
1489
-
1490
2224
  return message
1491
2225
  }
1492
2226
 
@@ -1495,8 +2229,9 @@ export class MostBoxEngine extends EventEmitter {
1495
2229
  * @param {string} name - 频道名
1496
2230
  * @returns {Array<{ peerId: string, authorName: string, lastSeen: number }>}
1497
2231
  */
1498
- getChannelPeers(name) {
2232
+ getChannelPeers(name, options = {}) {
1499
2233
  this.#ensureInitialized()
2234
+ this.#assertChannelMember(name, options.ownerAddress)
1500
2235
 
1501
2236
  const peers = this.#channelPeers.get(name)
1502
2237
  if (!peers) {
@@ -1553,6 +2288,429 @@ export class MostBoxEngine extends EventEmitter {
1553
2288
  }
1554
2289
  }
1555
2290
 
2291
+ #assertChannelMember(name, ownerAddress) {
2292
+ const normalizedOwner = normalizeOwnerAddress(ownerAddress)
2293
+ if (!normalizedOwner) return
2294
+
2295
+ const channel = this.#channels.find(c => c.name === name)
2296
+ if (!channel) {
2297
+ throw new Error('频道不存在')
2298
+ }
2299
+ if (
2300
+ !Array.isArray(channel.members) ||
2301
+ !channel.members.includes(normalizedOwner)
2302
+ ) {
2303
+ throw new PermissionError('未加入该频道')
2304
+ }
2305
+ }
2306
+
2307
+ #getCidInfo(cid) {
2308
+ return getCidInfo(cid)
2309
+ }
2310
+
2311
+ #setSeedState(cid, patch = {}) {
2312
+ const previous = this.#seedStates.get(cid) || {}
2313
+ const next = {
2314
+ ...previous,
2315
+ cid,
2316
+ ...patch,
2317
+ updatedAt: new Date().toISOString(),
2318
+ }
2319
+ this.#seedStates.set(cid, next)
2320
+ this.emit('seed:state', next)
2321
+ return next
2322
+ }
2323
+
2324
+ #clearSeedState(cid) {
2325
+ if (this.#seedStates.delete(cid)) {
2326
+ this.emit('seed:state:removed', { cid })
2327
+ }
2328
+ }
2329
+
2330
+ #getFileRuntimeStats(cid) {
2331
+ const state = this.#fileMonitors.get(cid)
2332
+ if (!state) {
2333
+ return {
2334
+ peerCount: 0,
2335
+ lastServedAt: null,
2336
+ totalServedBytes: 0,
2337
+ }
2338
+ }
2339
+
2340
+ return {
2341
+ peerCount: state.peerCount || 0,
2342
+ lastServedAt: state.lastServedAt || null,
2343
+ totalServedBytes: state.totalServedBytes || 0,
2344
+ }
2345
+ }
2346
+
2347
+ async #ensureFileMonitor(cid, drive = null) {
2348
+ const existing = this.#fileMonitors.get(cid)
2349
+ if (existing) return existing
2350
+
2351
+ const { driveName } = this.#getCidInfo(cid)
2352
+ const monitoredDrive = drive || (await this.#getOrCreateDrive(driveName))
2353
+ const monitor = monitoredDrive.monitor('/' + cid)
2354
+ const state = {
2355
+ cid,
2356
+ monitor,
2357
+ peerCount: 0,
2358
+ lastServedAt: null,
2359
+ totalServedBytes: 0,
2360
+ uploadBytes: 0,
2361
+ uploadBlocks: 0,
2362
+ lastMetricsEmittedAt: 0,
2363
+ cleanup: null,
2364
+ }
2365
+ this.#fileMonitors.set(cid, state)
2366
+
2367
+ const emitMetrics = (force = false) => {
2368
+ const now = Date.now()
2369
+ if (!force && now - state.lastMetricsEmittedAt < 1000) return
2370
+ state.lastMetricsEmittedAt = now
2371
+ this.emit('seed:metrics', {
2372
+ cid,
2373
+ ...this.#getFileRuntimeStats(cid),
2374
+ })
2375
+ }
2376
+
2377
+ const updatePeerCount = () => {
2378
+ const nextPeerCount = Number(monitor.peers) || 0
2379
+ if (nextPeerCount !== state.peerCount) {
2380
+ state.peerCount = nextPeerCount
2381
+ emitMetrics(true)
2382
+ }
2383
+ }
2384
+
2385
+ const updateTransferStats = () => {
2386
+ updatePeerCount()
2387
+ const uploadStats = monitor.uploadStats || {}
2388
+ const uploadBytes = Number(uploadStats.monitoringBytes) || 0
2389
+ const uploadBlocks = Number(uploadStats.blocks) || 0
2390
+ const servedMore =
2391
+ uploadBytes > state.uploadBytes || uploadBlocks > state.uploadBlocks
2392
+
2393
+ if (servedMore) {
2394
+ state.lastServedAt = new Date().toISOString()
2395
+ state.totalServedBytes = uploadBytes
2396
+ }
2397
+
2398
+ state.uploadBytes = uploadBytes
2399
+ state.uploadBlocks = uploadBlocks
2400
+ if (servedMore) emitMetrics()
2401
+ }
2402
+
2403
+ monitor.on('update', updateTransferStats)
2404
+ try {
2405
+ await monitor.ready()
2406
+ const blobs = monitor.blobs
2407
+ const onPeerUpdate = () => {
2408
+ updatePeerCount()
2409
+ }
2410
+ blobs?.core?.on('peer-add', onPeerUpdate)
2411
+ blobs?.core?.on('peer-remove', onPeerUpdate)
2412
+ state.cleanup = () => {
2413
+ blobs?.core?.off('peer-add', onPeerUpdate)
2414
+ blobs?.core?.off('peer-remove', onPeerUpdate)
2415
+ }
2416
+ updateTransferStats()
2417
+ } catch (err) {
2418
+ this.#fileMonitors.delete(cid)
2419
+ monitor.off('update', updateTransferStats)
2420
+ await monitor.close().catch(() => {})
2421
+ throw err
2422
+ }
2423
+
2424
+ return state
2425
+ }
2426
+
2427
+ async #closeFileMonitor(state) {
2428
+ if (!state) return
2429
+ try {
2430
+ state.cleanup?.()
2431
+ await state.monitor.close()
2432
+ } catch {}
2433
+ }
2434
+
2435
+ #resumeHoldingsInBackground() {
2436
+ if (this.#holdingResumeTask || this.#holdings.length === 0) {
2437
+ return
2438
+ }
2439
+
2440
+ const holdings = [...this.#holdings]
2441
+ this.#holdingResumeTask = (async () => {
2442
+ for (
2443
+ let index = 0;
2444
+ index < holdings.length && this.#initialized;
2445
+ index += HOLDING_REJOIN_BATCH_SIZE
2446
+ ) {
2447
+ const batch = holdings.slice(index, index + HOLDING_REJOIN_BATCH_SIZE)
2448
+ await Promise.allSettled(
2449
+ batch.map(async holding => {
2450
+ if (!this.#holdings.some(current => current.cid === holding.cid)) {
2451
+ return
2452
+ }
2453
+ await this.#joinCidTopicInternal(holding.cid, {
2454
+ server: true,
2455
+ client: false,
2456
+ })
2457
+ console.log(`[MostBox] Rejoined CID topic: ${holding.cid}`)
2458
+ })
2459
+ )
2460
+
2461
+ if (
2462
+ index + HOLDING_REJOIN_BATCH_SIZE < holdings.length &&
2463
+ this.#initialized
2464
+ ) {
2465
+ await sleep(HOLDING_REJOIN_BATCH_DELAY)
2466
+ }
2467
+ }
2468
+ })()
2469
+ .catch(err => {
2470
+ console.warn('[MostBox] Failed to resume holdings:', err.message)
2471
+ })
2472
+ .finally(() => {
2473
+ this.#holdingResumeTask = null
2474
+ })
2475
+ }
2476
+
2477
+ #normalizeHolding(record = {}) {
2478
+ const cid = record.cid
2479
+ if (!cid) {
2480
+ throw new ValidationError('cid is required')
2481
+ }
2482
+
2483
+ const { topicHex, driveName } = this.#getCidInfo(cid)
2484
+ if (record.topic && record.topic !== topicHex) {
2485
+ throw new ValidationError('topic must match CID digest')
2486
+ }
2487
+
2488
+ const size = Number(record.size)
2489
+ if (!Number.isFinite(size) || size < 0) {
2490
+ throw new ValidationError('size must be a non-negative number')
2491
+ }
2492
+
2493
+ return {
2494
+ cid,
2495
+ fileName: record.fileName || cid,
2496
+ size,
2497
+ localPath: record.localPath || null,
2498
+ topic: topicHex,
2499
+ driveName: record.driveName || driveName,
2500
+ source: record.source || 'manual',
2501
+ }
2502
+ }
2503
+
2504
+ #upsertHolding(record) {
2505
+ const holding = this.#normalizeHolding(record)
2506
+ const now = new Date().toISOString()
2507
+ const index = this.#holdings.findIndex(f => f.cid === holding.cid)
2508
+ const next =
2509
+ index === -1
2510
+ ? { ...holding, createdAt: now, updatedAt: now }
2511
+ : { ...this.#holdings[index], ...holding, updatedAt: now }
2512
+
2513
+ if (index === -1) {
2514
+ this.#holdings.push(next)
2515
+ } else {
2516
+ this.#holdings[index] = next
2517
+ }
2518
+
2519
+ this.#saveHoldingsMetadata()
2520
+ this.emit('holding:updated', next)
2521
+ this.#ensureFileMonitor(next.cid).catch(err => {
2522
+ this.#setSeedState(next.cid, {
2523
+ status: 'error',
2524
+ error: err.message,
2525
+ })
2526
+ })
2527
+ const seedState = this.#seedStates.get(next.cid)
2528
+ return {
2529
+ ...next,
2530
+ joined: this.#fileDiscoveries.has(next.cid),
2531
+ seedStatus:
2532
+ seedState?.status ||
2533
+ (this.#fileDiscoveries.has(next.cid) ? 'active' : 'queued'),
2534
+ seedError: seedState?.error,
2535
+ seedStatusUpdatedAt: seedState?.updatedAt,
2536
+ ...this.#getFileRuntimeStats(next.cid),
2537
+ }
2538
+ }
2539
+
2540
+ #removeHolding(cid) {
2541
+ const before = this.#holdings.length
2542
+ this.#holdings = this.#holdings.filter(holding => holding.cid !== cid)
2543
+ if (this.#holdings.length !== before) {
2544
+ this.#saveHoldingsMetadata()
2545
+ this.emit('holding:removed', { cid })
2546
+ }
2547
+ this.#closeFileMonitor(this.#fileMonitors.get(cid))
2548
+ this.#fileMonitors.delete(cid)
2549
+ this.#clearSeedState(cid)
2550
+ }
2551
+
2552
+ async #joinCidTopicInternal(cid, options = {}) {
2553
+ const { topic, topicHex, driveName } = this.#getCidInfo(cid)
2554
+ const requestedServer = options.server !== false
2555
+ const requestedClient = options.client === true
2556
+ this.#setSeedState(cid, {
2557
+ status: 'joining',
2558
+ topic: topicHex,
2559
+ driveName,
2560
+ error: undefined,
2561
+ })
2562
+
2563
+ try {
2564
+ const drive = await this.#getOrCreateDrive(driveName)
2565
+
2566
+ const existing = this.#fileDiscoveries.get(cid)
2567
+ if (existing) {
2568
+ const nextServer = existing.server || requestedServer
2569
+ const nextClient = existing.client || requestedClient
2570
+ const needsRoleUpgrade =
2571
+ nextServer !== existing.server || nextClient !== existing.client
2572
+
2573
+ if (!needsRoleUpgrade) {
2574
+ if (this.#holdings.some(holding => holding.cid === cid)) {
2575
+ this.#ensureFileMonitor(cid, drive).catch(err => {
2576
+ this.#setSeedState(cid, {
2577
+ status: 'error',
2578
+ error: err.message,
2579
+ })
2580
+ })
2581
+ }
2582
+ this.#setSeedState(cid, {
2583
+ status: 'active',
2584
+ topic: topicHex,
2585
+ driveName,
2586
+ error: undefined,
2587
+ })
2588
+ return {
2589
+ cid,
2590
+ topic: topicHex,
2591
+ driveName,
2592
+ joined: true,
2593
+ }
2594
+ }
2595
+
2596
+ await this.#swarm.leave(topic).catch(err => {
2597
+ console.warn(
2598
+ `[MostBox] Failed to upgrade CID topic role for ${cid}:`,
2599
+ err.message
2600
+ )
2601
+ })
2602
+ this.#fileDiscoveries.delete(cid)
2603
+ }
2604
+
2605
+ const server = existing?.server || requestedServer
2606
+ const client = existing?.client || requestedClient
2607
+ const discovery = this.#swarm.join(topic, {
2608
+ server,
2609
+ client,
2610
+ })
2611
+
2612
+ this.#fileDiscoveries.set(cid, {
2613
+ discovery,
2614
+ topic: topicHex,
2615
+ driveName,
2616
+ server,
2617
+ client,
2618
+ })
2619
+ this.#setSeedState(cid, {
2620
+ status: 'active',
2621
+ topic: topicHex,
2622
+ driveName,
2623
+ error: undefined,
2624
+ })
2625
+ if (this.#holdings.some(holding => holding.cid === cid)) {
2626
+ this.#ensureFileMonitor(cid, drive).catch(err => {
2627
+ this.#setSeedState(cid, {
2628
+ status: 'error',
2629
+ error: err.message,
2630
+ })
2631
+ })
2632
+ }
2633
+ this.emit('file:topic:joined', { cid, topic: topicHex, driveName })
2634
+
2635
+ return {
2636
+ cid,
2637
+ topic: topicHex,
2638
+ driveName,
2639
+ joined: true,
2640
+ }
2641
+ } catch (err) {
2642
+ this.#setSeedState(cid, {
2643
+ status: 'error',
2644
+ topic: topicHex,
2645
+ driveName,
2646
+ error: err.message,
2647
+ })
2648
+ throw err
2649
+ }
2650
+ }
2651
+
2652
+ async #leaveCidTopic(cid) {
2653
+ const existing = this.#fileDiscoveries.get(cid)
2654
+ if (!existing || !this.#swarm) {
2655
+ this.#setSeedState(cid, { status: 'paused' })
2656
+ return
2657
+ }
2658
+
2659
+ this.#fileDiscoveries.delete(cid)
2660
+ this.#swarm.leave(b4a.from(existing.topic, 'hex')).catch(err => {
2661
+ console.warn(`[MostBox] Failed to leave CID topic ${cid}:`, err.message)
2662
+ })
2663
+ this.#setSeedState(cid, {
2664
+ status: 'paused',
2665
+ topic: existing.topic,
2666
+ driveName: existing.driveName,
2667
+ })
2668
+ }
2669
+
2670
+ async #closeDriveForSeed(driveName) {
2671
+ const drive = this.#drives.get(driveName)
2672
+ if (!drive) {
2673
+ return null
2674
+ }
2675
+
2676
+ await drive.close()
2677
+ this.#drives.delete(driveName)
2678
+ return drive
2679
+ }
2680
+
2681
+ #recordMatchesOwner(record, ownerAddress) {
2682
+ const normalizedOwner = normalizeOwnerAddress(ownerAddress)
2683
+ if (!normalizedOwner) return !record.ownerAddress
2684
+ return normalizeOwnerAddress(record.ownerAddress) === normalizedOwner
2685
+ }
2686
+
2687
+ #hasPublishedReference(cid) {
2688
+ return this.#publishedFiles.some(file => file.cid === cid)
2689
+ }
2690
+
2691
+ #hasAnyUserReference(cid) {
2692
+ return (
2693
+ this.#publishedFiles.some(file => file.cid === cid) ||
2694
+ this.#trashFiles.some(file => file.cid === cid)
2695
+ )
2696
+ }
2697
+
2698
+ #getUsedBytes() {
2699
+ return this.#holdings.reduce((sum, h) => sum + (h.size || 0), 0)
2700
+ }
2701
+
2702
+ #checkCapacity(additionalBytes) {
2703
+ const used = this.#getUsedBytes()
2704
+ const capacity = this.#options.capacityBytes
2705
+ if (used + additionalBytes > capacity) {
2706
+ const usedGB = (used / (1024 * 1024 * 1024)).toFixed(2)
2707
+ const capacityGB = (capacity / (1024 * 1024 * 1024)).toFixed(2)
2708
+ throw new StorageCapacityError(
2709
+ `Storage capacity exceeded: used ${usedGB} GB, capacity ${capacityGB} GB`
2710
+ )
2711
+ }
2712
+ }
2713
+
1556
2714
  async #getOrCreateDrive(name, _options = { server: true, client: false }) {
1557
2715
  if (this.#drives.has(name)) return this.#drives.get(name)
1558
2716
  if (this.#drivePromises.has(name)) return this.#drivePromises.get(name)
@@ -1575,11 +2733,6 @@ export class MostBoxEngine extends EventEmitter {
1575
2733
  }
1576
2734
 
1577
2735
  async #syncDrive(drive, timeout = DRIVE_SYNC_TIMEOUT) {
1578
- const done = drive.findingPeers()
1579
- this.#swarm
1580
- .join(drive.discoveryKey, { server: true, client: true })
1581
- .flushed()
1582
- .then(done, done)
1583
2736
  try {
1584
2737
  const updated = await Promise.race([
1585
2738
  drive.update(),
@@ -1597,6 +2750,10 @@ export class MostBoxEngine extends EventEmitter {
1597
2750
  return path.join(this.#options.dataPath, 'published-files.json')
1598
2751
  }
1599
2752
 
2753
+ #getHoldingsMetadataPath() {
2754
+ return path.join(this.#options.dataPath, 'node-holdings.json')
2755
+ }
2756
+
1600
2757
  #getTrashMetadataPath() {
1601
2758
  return path.join(this.#options.dataPath, 'trash-files.json')
1602
2759
  }
@@ -1636,6 +2793,32 @@ export class MostBoxEngine extends EventEmitter {
1636
2793
  }
1637
2794
  }
1638
2795
 
2796
+ #loadHoldingsMetadata() {
2797
+ try {
2798
+ const metadataPath = this.#getHoldingsMetadataPath()
2799
+ if (fs.existsSync(metadataPath)) {
2800
+ const data = fs.readFileSync(metadataPath, 'utf-8')
2801
+ const parsed = JSON.parse(data)
2802
+ return parsed.map(record => this.#normalizeHolding(record))
2803
+ }
2804
+ } catch (err) {
2805
+ console.warn(
2806
+ 'Failed to load node holdings metadata, using empty list:',
2807
+ err.message
2808
+ )
2809
+ }
2810
+ return []
2811
+ }
2812
+
2813
+ #saveHoldingsMetadata() {
2814
+ try {
2815
+ const metadataPath = this.#getHoldingsMetadataPath()
2816
+ this.#atomicWrite(metadataPath, JSON.stringify(this.#holdings, null, 2))
2817
+ } catch (err) {
2818
+ console.error('Failed to save node holdings metadata:', err.message)
2819
+ }
2820
+ }
2821
+
1639
2822
  #loadTrashMetadata() {
1640
2823
  try {
1641
2824
  const metadataPath = this.#getTrashMetadataPath()
@@ -1736,15 +2919,57 @@ export class MostBoxEngine extends EventEmitter {
1736
2919
  })
1737
2920
  }
1738
2921
 
2922
+ async #openRemoteChannelCore(channelName, coreKeyHex) {
2923
+ const coresMap = this.#channelCores.get(channelName)
2924
+ if (!coresMap) return
2925
+ if (coresMap.has(coreKeyHex)) return
2926
+
2927
+ try {
2928
+ const ns = this.#store.namespace(`channel-${channelName}`)
2929
+ const core = ns.get({
2930
+ key: b4a.from(coreKeyHex, 'hex'),
2931
+ valueEncoding: 'json',
2932
+ })
2933
+ await core.ready()
2934
+ const normalizedCoreKey = b4a.toString(core.key, 'hex')
2935
+ coresMap.set(normalizedCoreKey, core)
2936
+ this.#setupChannelAppendListener(core, channelName)
2937
+ const channel = this.#channels.find(c => c.name === channelName)
2938
+ if (channel && normalizedCoreKey !== channel.coreKey) {
2939
+ if (!Array.isArray(channel.remoteCoreKeys)) {
2940
+ channel.remoteCoreKeys = []
2941
+ }
2942
+ if (!channel.remoteCoreKeys.includes(normalizedCoreKey)) {
2943
+ channel.remoteCoreKeys.push(normalizedCoreKey)
2944
+ this.#saveChannelsMetadata()
2945
+ }
2946
+ }
2947
+ console.log(
2948
+ `[MostBox] Opened remote channel core ${normalizedCoreKey.slice(0, 8)}... for ${channelName}`
2949
+ )
2950
+ } catch (err) {
2951
+ console.warn(
2952
+ `[MostBox] Failed to open remote channel core for ${channelName}:`,
2953
+ err.message
2954
+ )
2955
+ }
2956
+ }
2957
+
1739
2958
  async #handleChannelConnection(conn) {
1740
2959
  const stream = conn
1741
2960
  let connectedPeerId = null
1742
2961
 
2962
+ const coreKeys = {}
2963
+ for (const [name, localKeyHex] of this.#channelLocalCoreKey) {
2964
+ coreKeys[name] = localKeyHex
2965
+ }
2966
+
1743
2967
  const helloMessage = JSON.stringify({
1744
2968
  type: 'channel-hello',
1745
2969
  peerId: this.getNodeId(),
1746
2970
  authorName: this.getNodeId().slice(0, 4),
1747
2971
  channels: this.#channels.map(c => c.name),
2972
+ coreKeys,
1748
2973
  })
1749
2974
 
1750
2975
  try {
@@ -1769,6 +2994,17 @@ export class MostBoxEngine extends EventEmitter {
1769
2994
  })
1770
2995
  }
1771
2996
  }
2997
+
2998
+ if (msg.coreKeys && typeof msg.coreKeys === 'object') {
2999
+ for (const [channelName, coreKeyHex] of Object.entries(
3000
+ msg.coreKeys
3001
+ )) {
3002
+ if (this.#channelCores.has(channelName) && coreKeyHex) {
3003
+ await this.#openRemoteChannelCore(channelName, coreKeyHex)
3004
+ }
3005
+ }
3006
+ }
3007
+
1772
3008
  this.emit('channel:peer:online', {
1773
3009
  peerId: msg.peerId,
1774
3010
  authorName: msg.authorName,
@@ -1796,14 +3032,21 @@ export class MostBoxEngine extends EventEmitter {
1796
3032
  }
1797
3033
 
1798
3034
  /**
1799
- * 等待驱动器内容从对等节点或本地可用
3035
+ * 等待指定 Hyperdrive key 从对等节点或本地可用。
1800
3036
  * @param {Hyperdrive} drive - 要检查的驱动器
3037
+ * @param {string} key - 期望的 Hyperdrive key,固定为 /<cid>
1801
3038
  * @param {number} timeout - 最大等待时间(毫秒)
1802
3039
  * @param {string} [taskId] - 用于取消的任务 ID
1803
3040
  * @param {object} [taskState] - 任务状态对象
1804
- * @returns {Promise<Array>} - 条目列表
3041
+ * @returns {Promise<object|null>} - Hyperdrive entry
1805
3042
  */
1806
- async #waitForDriveContent(drive, timeout, taskId = null, taskState = null) {
3043
+ async #waitForDriveEntry(
3044
+ drive,
3045
+ key,
3046
+ timeout,
3047
+ taskId = null,
3048
+ taskState = null
3049
+ ) {
1807
3050
  const startTime = Date.now()
1808
3051
  let pollInterval = DOWNLOAD_POLL_INTERVAL_MIN
1809
3052
  let lastPeerCount = 0
@@ -1811,15 +3054,12 @@ export class MostBoxEngine extends EventEmitter {
1811
3054
  let bootstrapNodesChecked = false
1812
3055
  let lastUpdateTime = 0
1813
3056
 
1814
- const localEntries = []
1815
3057
  try {
1816
- for await (const entry of drive.list()) {
1817
- localEntries.push(entry)
1818
- }
1819
- if (localEntries.length > 0) {
1820
- console.log(`[MostBox] Found ${localEntries.length} entries locally`)
1821
- this.emit('download:status', { taskId, status: 'syncing' })
1822
- return localEntries
3058
+ const localEntry = await drive.entry(key)
3059
+ if (localEntry) {
3060
+ console.log(`[MostBox] Found expected entry ${key} locally`)
3061
+ if (taskId) this.emit('download:status', { taskId, status: 'syncing' })
3062
+ return localEntry
1823
3063
  }
1824
3064
  } catch {}
1825
3065
 
@@ -1853,32 +3093,32 @@ export class MostBoxEngine extends EventEmitter {
1853
3093
 
1854
3094
  await tryUpdateDrive()
1855
3095
 
1856
- const entries = []
1857
3096
  try {
1858
- for await (const entry of drive.list()) {
1859
- entries.push(entry)
3097
+ const entry = await drive.entry(key)
3098
+ if (entry) {
3099
+ console.log(`[MostBox] Found ${key} after ${elapsed}s`)
3100
+ if (taskId) {
3101
+ this.emit('download:status', { taskId, status: 'syncing' })
3102
+ }
3103
+ return entry
1860
3104
  }
1861
3105
  } catch {}
1862
3106
 
1863
- if (entries.length > 0) {
1864
- console.log(
1865
- `[MostBox] Found ${entries.length} entries after ${elapsed}s`
1866
- )
1867
- this.emit('download:status', { taskId, status: 'syncing' })
1868
- return entries
1869
- }
1870
-
1871
3107
  if (hasPeers) {
1872
3108
  const newStatus = 'syncing'
1873
3109
  if (lastStatus !== newStatus) {
1874
- this.emit('download:status', { taskId, status: newStatus })
3110
+ if (taskId) {
3111
+ this.emit('download:status', { taskId, status: newStatus })
3112
+ }
1875
3113
  lastStatus = newStatus
1876
3114
  }
1877
3115
  pollInterval = Math.min(pollInterval + 200, DOWNLOAD_POLL_INTERVAL_MAX)
1878
3116
  } else {
1879
3117
  const newStatus = 'finding-peers'
1880
3118
  if (lastStatus !== newStatus) {
1881
- this.emit('download:status', { taskId, status: newStatus })
3119
+ if (taskId) {
3120
+ this.emit('download:status', { taskId, status: newStatus })
3121
+ }
1882
3122
  lastStatus = newStatus
1883
3123
  }
1884
3124
  pollInterval = DOWNLOAD_POLL_INTERVAL_MIN
@@ -1912,36 +3152,34 @@ export class MostBoxEngine extends EventEmitter {
1912
3152
 
1913
3153
  await tryUpdateDrive()
1914
3154
 
1915
- const entries = []
1916
3155
  try {
1917
- for await (const entry of drive.list()) {
1918
- entries.push(entry)
3156
+ const entry = await drive.entry(key)
3157
+ if (entry) {
3158
+ console.log(`[MostBox] Found ${key} on final attempt`)
3159
+ return entry
1919
3160
  }
1920
3161
  } catch (err) {
1921
3162
  console.log(`[MostBox] Final attempt failed: ${err.message}`)
1922
3163
  }
1923
3164
 
1924
- console.log(`[MostBox] Final entry count: ${entries.length}`)
1925
-
1926
- if (entries.length === 0) {
1927
- const peerCount = this.#swarm.connections.size
1928
- console.log(`[MostBox] Diagnostic information:`)
1929
- console.log(`[MostBox] - Peer count: ${peerCount}`)
1930
- console.log(`[MostBox] - Bootstrap nodes: ${SWARM_BOOTSTRAP.length}`)
1931
- console.log(`[MostBox] - Timeout: ${timeout / 1000}s`)
3165
+ const peerCount = this.#swarm.connections.size
3166
+ console.log(`[MostBox] Diagnostic information:`)
3167
+ console.log(`[MostBox] - Expected key: ${key}`)
3168
+ console.log(`[MostBox] - Peer count: ${peerCount}`)
3169
+ console.log(`[MostBox] - Bootstrap nodes: ${SWARM_BOOTSTRAP.length}`)
3170
+ console.log(`[MostBox] - Timeout: ${timeout / 1000}s`)
1932
3171
 
1933
- if (peerCount === 0) {
1934
- console.log(
1935
- `[MostBox] Suggestion: Check network connectivity and firewall settings`
1936
- )
1937
- } else {
1938
- console.log(
1939
- `[MostBox] Suggestion: Publisher may be offline or file may have been removed`
1940
- )
1941
- }
3172
+ if (peerCount === 0) {
3173
+ console.log(
3174
+ `[MostBox] Suggestion: Check network connectivity and firewall settings`
3175
+ )
3176
+ } else {
3177
+ console.log(
3178
+ `[MostBox] Suggestion: Publisher may be offline or file may have been removed`
3179
+ )
1942
3180
  }
1943
3181
 
1944
- return entries
3182
+ return null
1945
3183
  }
1946
3184
  }
1947
3185