@bolloon/bolloon-agent 0.4.23 → 0.4.24
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/ios/index.html +74 -17
- package/dist/ios/mobile-agent.js +49 -4
- package/dist/ios/mobile-chain.js +481 -0
- package/dist/ios/mobile-core.js +373 -13
- package/dist/ios/mobile-helia.js +683 -0
- package/dist/ios/mobile-ipfs.js +680 -0
- package/dist/ios/mobile-orbit.js +268 -0
- package/dist/ios/mobile-p2p.js +130 -1
- package/dist/ios/mobile-social.js +624 -0
- package/dist/ios/mobile-sync.js +193 -0
- package/dist/ios/mobile-trade.js +492 -0
- package/dist/ios/mobile-wallet.js +11 -0
- package/dist/ios/mobile.css +24 -0
- package/dist/ios/mobile.html +74 -17
- package/dist/ios/mobile.js +911 -55
- package/dist/ios/routes-x402-info.js +194 -0
- package/dist/ios/server.js +303 -9
- package/dist/ios/sw.js +26 -2
- package/dist/web/agent-delegate-server.js +58 -12
- package/dist/web/mobile-agent.js +195 -1
- package/dist/web/mobile-core.js +170 -1
- package/dist/web/server.js +51 -4
- package/package.json +1 -1
|
@@ -0,0 +1,683 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mobile-helia.ts — 手机端「真 IPFS 节点」模块 (Helia + js-libp2p) (2026-09-11)
|
|
3
|
+
*
|
|
4
|
+
* 目标: 让手机 App (Capacitor iOS WebView) 里**真的跑一个 IPFS 节点** ——
|
|
5
|
+
* 有 PeerID、有 blockstore、有 bitswap。不是只调网关。
|
|
6
|
+
*
|
|
7
|
+
* ─────────────────── 真节点来源: @diap/sdk/helia (2026-09-11 重接) ───────────────────
|
|
8
|
+
* 上次直接 import `@diap/sdk` 在 WebView 挂了并回滚 (HeliaIpfsClient 在 `await helia.start()`
|
|
9
|
+
* 之前读 `helia.libp2p` getter → NotStartedError)。SDK 0.2.7 已修, 并新增 `@diap/sdk/helia`
|
|
10
|
+
* 子路径, 所以真节点现在一律经它创建:
|
|
11
|
+
* - 无种子 → `HeliaIpfsClient.newPublicOnly()`; 有种子 → `newWithRemoteNode(seedAddrs)`
|
|
12
|
+
* (工厂自带 `addresses.listen=['/p2p-circuit']` + `circuitRelayTransport()` → 中继能力已保)。
|
|
13
|
+
* - 传了 `dhtClient` / `libp2pOptions` 的高级路径 → 自建 helia 配置后 `fromHelia()` 包一层,
|
|
14
|
+
* 再 `await client.start()` (SDK 内部先 `await helia.start()` 再读 getter, 顺序有保证)。
|
|
15
|
+
* - **唯一成功判据**: `client.getStartResult().ok === true` (SDK 内部已校验 peerId 非空 +
|
|
16
|
+
* `libp2p.status==='started'`)。
|
|
17
|
+
* - `heliaAddJson`/`heliaGetJson` 在有 SDK 客户端时走 `upload(JSON.stringify(...))` / `get(cid)`
|
|
18
|
+
* (SDK 的 upload **只收字符串**; get 回来的 `content` 可能是 JSON 字符串 → 解析回对象),
|
|
19
|
+
* 没有客户端 (注入假节点 / 真节点起不来) 时退回本地 blockstore + computeCid, 保住本地块能力。
|
|
20
|
+
*
|
|
21
|
+
* ───────────────────────── 设计要点 ─────────────────────────
|
|
22
|
+
* 1) 复用 mobile-ipfs.computeCid() 算 CID (硬要求):
|
|
23
|
+
* 内容 → jsonClean → @ipld/dag-cbor encode → sha2-256 → CIDv1(0x71)
|
|
24
|
+
* dag-cbor 编码字节与 CID 完全对应, 所以 heliaAddJson 写进 blockstore 的块
|
|
25
|
+
* 就是「能重新哈希出同一个 CID」的那份字节 —— 手机与桌面 contentToCid() 逐字一致。
|
|
26
|
+
* 2) blockstore 默认 **内存** (helia 内部默认 MemoryBlockstore)。
|
|
27
|
+
* 持久化需要额外包 (blockstore-fs / IndexedDB), 本轮不引入 —— 前台重启即清空。
|
|
28
|
+
*
|
|
29
|
+
* ───────────────────────── iOS 后台限制 (务必知悉) ─────────────────────────
|
|
30
|
+
* - iOS 会挂起后台 WebView → 节点**只在前台在线**, 不做 24/7。回到前台需重新
|
|
31
|
+
* startMobileHelia()。别把手机当常驻节点, 只当「前台参与 + 能服务块」的对等端。
|
|
32
|
+
* - 手机 (WebView) **不能 listen** 公网地址, 只能拨出 (webSockets dial)。
|
|
33
|
+
* 拨出的连接是**双向**的, 所以手机仍能通过它 bitswap 提供/接收块 —— 能服务块。
|
|
34
|
+
* - 传输只有 webSockets + circuit-relay (浏览器无 tcp/udp)。必须 /ws 或 /wss 地址。
|
|
35
|
+
*
|
|
36
|
+
* ───────────────────────── 打包 / 加载 ─────────────────────────
|
|
37
|
+
* 所有重依赖 (helia / libp2p 各插件) 都用**动态 import()** 惰性加载 —— 只有真正
|
|
38
|
+
* 建节点时才拉进来 (顶层只保留 `import type` 静态类型)。esbuild --platform=browser
|
|
39
|
+
* 实测 0 个 node 内置引用 (见交付说明)。
|
|
40
|
+
* 测试用 createMobileHeliaNode({ node: fakeNode }) 注入假节点, **不会** import helia。
|
|
41
|
+
*
|
|
42
|
+
* ────────────────── Helia 7 启动语义 (2026-09-11 修, 血的教训) ──────────────────
|
|
43
|
+
* `createHelia()` 在 Helia 7 里是**同步函数**: 它同步返回一个 status='stopped' 的
|
|
44
|
+
* 节点 —— **libp2p 还没建, 更没 start**。此时读 `helia.libp2p` 会抛 NotStartedError
|
|
45
|
+
* ('Not started')。必须先 `await helia.start()`: libp2p 的 mixin 在 helia.start()
|
|
46
|
+
* 内部才 `createLibp2p(opts)` 并 `libp2p.start()`。
|
|
47
|
+
* 旧代码只 `await createHelia({libp2p:{...}})` (await 一个同步值等于没 await) 就返回
|
|
48
|
+
* ok:true → peerId 为空、status 报 'Not started'、底层错误被吞。
|
|
49
|
+
* 本模块现在: 建 → **await helia.start()** → 校验 `libp2p.status === 'started'` 且
|
|
50
|
+
* peerId 非空 → 才算 ok:true; 任一步失败返回真实 message/stack。
|
|
51
|
+
* (实测: 把已建好的 libp2p **实例**塞给 createHelia({libp2p: instance}) 会抛
|
|
52
|
+
* `TypeError: createService is not a function` —— Helia 7 只接受 libp2p **配置**,
|
|
53
|
+
* 不接受实例。所以走「配置 + helia.start()」这条路。)
|
|
54
|
+
*
|
|
55
|
+
* ───────────────────────── 已知限制 (WebView 环境) ─────────────────────────
|
|
56
|
+
* 1) WebView 里 libp2p 若起不来 (例如 WKWebView 缺 WebSocket/缺 crypto 随机源、
|
|
57
|
+
* circuit-relay 传输初始化失败), start 返回 **ok:false + 真实 error**, 但节点仍
|
|
58
|
+
* 会被装上 (currentNode 非空) —— **本地块能力 (heliaAddJson/heliaGetJson 的
|
|
59
|
+
* blockstore 路径) 不受影响, 继续可用**。状态里看 `libp2pStatus` / `lastError`。
|
|
60
|
+
* 2) 手机只 listen `/p2p-circuit` (需公网中继才能拿到地址); 没有中继时 getMultiaddrs
|
|
61
|
+
* 为空是**正常**的, 不代表失败 —— 失败以 peerId 为空为准。
|
|
62
|
+
* 3) iOS 挂起后台 WebView 会掐断连接; 回前台重新 startMobileHelia()。
|
|
63
|
+
*
|
|
64
|
+
* 全部导出函数**失败一律返回 { ok:false, error }**, 永不抛。
|
|
65
|
+
*/
|
|
66
|
+
import { CID } from 'multiformats/cid';
|
|
67
|
+
import * as dagCbor from '@ipld/dag-cbor';
|
|
68
|
+
import { computeCid, jsonClean, normalizeCid, defaultStorage } from './mobile-ipfs.js';
|
|
69
|
+
// ─────────────────────────── 类型 ───────────────────────────
|
|
70
|
+
const errMsg = (err) => {
|
|
71
|
+
if (err instanceof Error)
|
|
72
|
+
return err.message;
|
|
73
|
+
if (typeof err === 'string')
|
|
74
|
+
return err;
|
|
75
|
+
try {
|
|
76
|
+
return JSON.stringify(err);
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return String(err);
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
/**
|
|
83
|
+
* 比 errMsg 更"真"的错误串: `Name: message @ frame1 <- frame2 <- frame3`。
|
|
84
|
+
* libp2p/helia 的启动错误常常 message 为空或只有一句, stack 才有信息量 —— 透出到 UI/探针。
|
|
85
|
+
*/
|
|
86
|
+
const errDetail = (err) => {
|
|
87
|
+
if (err instanceof Error) {
|
|
88
|
+
const head = err.name && err.message ? `${err.name}: ${err.message}` : err.name || err.message || 'Error';
|
|
89
|
+
const frames = (err.stack || '')
|
|
90
|
+
.split('\n')
|
|
91
|
+
.slice(1, 4)
|
|
92
|
+
.map((s) => s.trim())
|
|
93
|
+
.filter(Boolean);
|
|
94
|
+
return frames.length ? `${head} @ ${frames.join(' <- ')}` : head;
|
|
95
|
+
}
|
|
96
|
+
return errMsg(err);
|
|
97
|
+
};
|
|
98
|
+
/** 运行环境摘要 (只读、不抛) —— 启动失败时附在 error 里, 便于在 WebView 日志里定位环境差异 */
|
|
99
|
+
const envHint = () => {
|
|
100
|
+
try {
|
|
101
|
+
const g = globalThis;
|
|
102
|
+
return `env{WebSocket:${typeof g.WebSocket},protocol:${g.location?.protocol ?? 'n/a'},crypto:${typeof g.crypto?.getRandomValues === 'function'}}`;
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
return 'env{unknown}';
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
// ─────────────────────────── 常量 ───────────────────────────
|
|
109
|
+
/** localStorage 开关 key (供设置页 UI 控) */
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
// 运行时垫片: iOS WebKit 缺失的 ES2024+ API (必须在 import('helia'/'@libp2p/*') 之前跑)
|
|
112
|
+
// 实测证据 (模拟器 iPhone 15, 2026-09-11): libp2p 启动直接抛
|
|
113
|
+
// TypeError: Promise.withResolvers is not a function
|
|
114
|
+
// → libp2p 根本起不来 (status: not-created)。Helia 7 的 createHelia 是同步的,
|
|
115
|
+
// 必须 await helia.start(), 而 start() 内部就走到了这个 API。
|
|
116
|
+
// 本模块在 bundle 里是顶层语句, 加载即执行 → 保证早于任何动态 import。
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
(() => {
|
|
119
|
+
const P = Promise;
|
|
120
|
+
if (typeof P.withResolvers !== 'function') {
|
|
121
|
+
P.withResolvers = function withResolvers() {
|
|
122
|
+
let resolve;
|
|
123
|
+
let reject;
|
|
124
|
+
const promise = new Promise((res, rej) => { resolve = res; reject = rej; });
|
|
125
|
+
return { promise, resolve, reject };
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
if (typeof P.try !== 'function') {
|
|
129
|
+
P.try = function promiseTry(fn, ...args) {
|
|
130
|
+
return new Promise((res, rej) => { try {
|
|
131
|
+
res(fn(...args));
|
|
132
|
+
}
|
|
133
|
+
catch (e) {
|
|
134
|
+
rej(e);
|
|
135
|
+
} });
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
})();
|
|
139
|
+
export const HELIA_ENABLED_KEY = 'bolloon_helia_enabled';
|
|
140
|
+
/** 手机只支持 websockets 传输: 地址必须带 /ws 或 /wss */
|
|
141
|
+
export const isWsCapableAddr = (addr) => typeof addr === 'string' && /\/ws(s)?(\/|$)/.test(addr.trim());
|
|
142
|
+
// ─────────────────────────── 模块级单例状态 ───────────────────────────
|
|
143
|
+
let currentNode = null;
|
|
144
|
+
let currentPeerId = null;
|
|
145
|
+
/** 真节点 → SDK HeliaIpfsClient (WeakMap: 节点释放即回收; 不用全局可变客户端, 避免和注入节点串味) */
|
|
146
|
+
const nodeClients = new WeakMap();
|
|
147
|
+
/** 传给 SDK 工厂的超时 (秒) */
|
|
148
|
+
const SDK_TIMEOUT_SEC = 30;
|
|
149
|
+
/** 最近一次启动失败的真实原因 (给 UI/探针看) */
|
|
150
|
+
let lastStartError = null;
|
|
151
|
+
/** 并发 start 去重 (两次 startMobileHelia 同时进来只建一次) */
|
|
152
|
+
let startPromise = null;
|
|
153
|
+
/** 从节点上读 PeerID 字符串 (失败 undefined)。真 helia 未 start 时读 libp2p 会抛 NotStartedError → undefined */
|
|
154
|
+
function peerIdOf(node) {
|
|
155
|
+
try {
|
|
156
|
+
const s = node?.libp2p?.peerId?.toString?.();
|
|
157
|
+
return typeof s === 'string' && s ? s : undefined;
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
return undefined;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* 安全取 libp2p: 真 helia 在 helia.start() 之前 `helia.libp2p` 的 getter 会抛
|
|
165
|
+
* NotStartedError('Not started') —— 这里吞成 error 串而不是让整个流程炸掉。
|
|
166
|
+
*/
|
|
167
|
+
function libp2pOf(node) {
|
|
168
|
+
try {
|
|
169
|
+
const l = node?.libp2p;
|
|
170
|
+
return l ? { libp2p: l } : { error: 'libp2p 不存在' };
|
|
171
|
+
}
|
|
172
|
+
catch (err) {
|
|
173
|
+
return { error: errMsg(err) };
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
/** libp2p 状态字符串 (永不抛) —— 供 heliaStatus / 启动校验使用 */
|
|
177
|
+
function libp2pStatusOf(node) {
|
|
178
|
+
const { libp2p, error } = libp2pOf(node);
|
|
179
|
+
if (!libp2p)
|
|
180
|
+
return error ? `not-created(${error})` : 'none';
|
|
181
|
+
try {
|
|
182
|
+
if (typeof libp2p.status === 'string')
|
|
183
|
+
return libp2p.status;
|
|
184
|
+
if (typeof libp2p.isStarted === 'function')
|
|
185
|
+
return libp2p.isStarted() ? 'started' : 'stopped';
|
|
186
|
+
return 'unknown';
|
|
187
|
+
}
|
|
188
|
+
catch (err) {
|
|
189
|
+
return `error:${errMsg(err)}`;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
/** 从 /p2p-circuit 地址里解析出中继 peerId (地址形如 <relayAddr>/p2p/<relay>/p2p-circuit/p2p/<self>) */
|
|
193
|
+
function relaysFromCircuitAddrs(circuitAddrs) {
|
|
194
|
+
const out = [];
|
|
195
|
+
for (const a of circuitAddrs) {
|
|
196
|
+
const head = a.split('/p2p-circuit')[0] || '';
|
|
197
|
+
const ids = head.match(/\/p2p\/([^/]+)/g) || [];
|
|
198
|
+
const last = ids[ids.length - 1];
|
|
199
|
+
const relay = last ? last.slice('/p2p/'.length) : '';
|
|
200
|
+
if (relay && !out.includes(relay))
|
|
201
|
+
out.push(relay);
|
|
202
|
+
}
|
|
203
|
+
return out;
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* 本机 libp2p 的可拨入地址 (`/p2p-circuit`) + 已预约中继。永不抛。
|
|
207
|
+
* 手机只 listen /p2p-circuit → 没有可用中继时必然为空, 这是正常状态 (不是错误)。
|
|
208
|
+
* ws/wss 地址排前面 (WebView 只能拨 ws/wss, 拨不了裸 tcp)。
|
|
209
|
+
*/
|
|
210
|
+
function circuitInfoOf(libp2p) {
|
|
211
|
+
try {
|
|
212
|
+
const all = (libp2p?.getMultiaddrs?.() || []).map((a) => a && typeof a.toString === 'function' ? a.toString() : String(a));
|
|
213
|
+
const isWs = (a) => {
|
|
214
|
+
const parts = a.split('/');
|
|
215
|
+
return parts.includes('ws') || parts.includes('wss');
|
|
216
|
+
};
|
|
217
|
+
const circuitAddrs = all
|
|
218
|
+
.filter((a) => a.includes('/p2p-circuit'))
|
|
219
|
+
.sort((a, b) => (isWs(a) ? 0 : 1) - (isWs(b) ? 0 : 1));
|
|
220
|
+
return { circuitAddrs, relays: relaysFromCircuitAddrs(circuitAddrs) };
|
|
221
|
+
}
|
|
222
|
+
catch {
|
|
223
|
+
return { circuitAddrs: [], relays: [] };
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* 启动后校验 (唯一"算成功"的标准): peerId 非空 **且** libp2p 真的 started。
|
|
228
|
+
* 不满足 → 返回真实原因 (message/stack + libp2p 状态), 交给调用方返回 ok:false。
|
|
229
|
+
*/
|
|
230
|
+
function verifyStarted(node) {
|
|
231
|
+
const peerId = peerIdOf(node);
|
|
232
|
+
const lps = libp2pStatusOf(node);
|
|
233
|
+
if (!peerId) {
|
|
234
|
+
const { error } = libp2pOf(node);
|
|
235
|
+
return {
|
|
236
|
+
error: `libp2p 未就绪: PeerID 为空 (libp2pStatus=${lps}${error ? `, ${error}` : ''}, heliaStatus=${String(node.status ?? 'n/a')})`,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
if (lps !== 'started' && lps !== 'unknown' && lps !== 'none') {
|
|
240
|
+
return { error: `libp2p 未启动: status=${lps} (PeerID=${peerId})` };
|
|
241
|
+
}
|
|
242
|
+
return { peerId };
|
|
243
|
+
}
|
|
244
|
+
/** 从 mobile-p2p 读已保存地址 (惰性 import, 避免无谓加载 libp2p); 失败返回 [] */
|
|
245
|
+
async function readSavedPeerAddrs() {
|
|
246
|
+
try {
|
|
247
|
+
const mod = await import('./mobile-p2p.js');
|
|
248
|
+
const addrs = mod.listMobilePeerAddrs();
|
|
249
|
+
return Array.isArray(addrs) ? addrs.filter((a) => typeof a === 'string') : [];
|
|
250
|
+
}
|
|
251
|
+
catch {
|
|
252
|
+
return [];
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
/** 拨种子地址 (逐个, 单个失败跳过)。假节点/未启动的 libp2p 没有可用的 dial → 直接跳过。 */
|
|
256
|
+
async function dialSeeds(node, addrs) {
|
|
257
|
+
if (!addrs.length)
|
|
258
|
+
return;
|
|
259
|
+
// 用安全 getter: 真 helia 未 start 时读 node.libp2p 会抛 NotStartedError (旧代码就是在这里炸的)
|
|
260
|
+
const { libp2p } = libp2pOf(node);
|
|
261
|
+
if (!libp2p || typeof libp2p.dial !== 'function')
|
|
262
|
+
return;
|
|
263
|
+
let multiaddr = null;
|
|
264
|
+
try {
|
|
265
|
+
const mod = await import('@multiformats/multiaddr');
|
|
266
|
+
multiaddr = mod.multiaddr;
|
|
267
|
+
}
|
|
268
|
+
catch {
|
|
269
|
+
return; // 拿不到 multiaddr 构造器 → 静默跳过拨号
|
|
270
|
+
}
|
|
271
|
+
for (const addr of addrs) {
|
|
272
|
+
try {
|
|
273
|
+
await libp2p.dial.call(libp2p, multiaddr(addr));
|
|
274
|
+
}
|
|
275
|
+
catch {
|
|
276
|
+
/* 单个种子失败不影响整体启动 */
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
// ─────────────────────────── 建节点 ───────────────────────────
|
|
281
|
+
/**
|
|
282
|
+
* 高级路径专用: 自建一个 Helia 节点 (带 dhtClient / 自定义 libp2pOptions)。
|
|
283
|
+
* 浏览器无 tcp → 只挂 webSockets + circuit-relay; 手机不能 listen 公网地址 → 只 listen
|
|
284
|
+
* /p2p-circuit (有中继才有地址, 没中继就是空 —— 正常)。blockstore 不传 → helia 默认内存。
|
|
285
|
+
* **只创建, 不 start、不读任何 getter** —— 启动/校验交给 SDK 的 HeliaIpfsClient。
|
|
286
|
+
*/
|
|
287
|
+
async function buildCustomHelia(opts) {
|
|
288
|
+
const heliaMod = await import('helia');
|
|
289
|
+
const { webSockets } = await import('@libp2p/websockets');
|
|
290
|
+
const { circuitRelayTransport } = await import('@libp2p/circuit-relay-v2');
|
|
291
|
+
const { noise } = await import('@chainsafe/libp2p-noise');
|
|
292
|
+
const { yamux } = await import('@chainsafe/libp2p-yamux');
|
|
293
|
+
const { identify } = await import('@libp2p/identify');
|
|
294
|
+
const { ping } = await import('@libp2p/ping');
|
|
295
|
+
const services = { identify: identify(), ping: ping() };
|
|
296
|
+
if (opts.dhtClient) {
|
|
297
|
+
const { kadDHT } = await import('@libp2p/kad-dht');
|
|
298
|
+
services.dht = kadDHT({ clientMode: true });
|
|
299
|
+
}
|
|
300
|
+
const libp2pOptions = {
|
|
301
|
+
addresses: { listen: ['/p2p-circuit'] },
|
|
302
|
+
transports: [webSockets(), circuitRelayTransport()],
|
|
303
|
+
connectionEncrypters: [noise()],
|
|
304
|
+
streamMuxers: [yamux()],
|
|
305
|
+
services,
|
|
306
|
+
...(opts.libp2pOptions || {}),
|
|
307
|
+
};
|
|
308
|
+
// Helia 7: createHelia 同步返回 status='stopped' 的节点 (libp2p 尚未创建)。兼容旧版返回 Promise。
|
|
309
|
+
return await heliaMod.createHelia({ libp2p: libp2pOptions });
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* 创建并 start 一个 Helia 节点 (transports: webSockets + circuitRelay; 无 tcp)。
|
|
313
|
+
* 传 opts.node → 用注入节点 (测试); 否则动态 import('helia') 建真节点。
|
|
314
|
+
* 失败**不抛**, 返回 { ok:false, error }。
|
|
315
|
+
*
|
|
316
|
+
* 成功标准 (严格): 节点真的 start 了 **且** peerId 非空。注意 Helia 7 的
|
|
317
|
+
* createHelia() 是同步的、返回未启动节点 —— 必须显式 `await helia.start()`,
|
|
318
|
+
* 否则 libp2p 根本不会被创建 (读 node.libp2p 直接抛 NotStartedError)。
|
|
319
|
+
* libp2p 起不来时仍把 node 返回给调用方 (本地块能力不受影响), 但 ok:false。
|
|
320
|
+
*/
|
|
321
|
+
export async function createMobileHeliaNode(opts = {}) {
|
|
322
|
+
try {
|
|
323
|
+
// 1) 注入节点 (测试 / 外部已建好的节点)
|
|
324
|
+
if (opts.node) {
|
|
325
|
+
const node = opts.node;
|
|
326
|
+
// 单独 try: start 失败也要把 node 交回去, 保住本地块能力
|
|
327
|
+
let startErr;
|
|
328
|
+
if (typeof node.start === 'function' && node.status !== 'started') {
|
|
329
|
+
try {
|
|
330
|
+
await node.start();
|
|
331
|
+
}
|
|
332
|
+
catch (err) {
|
|
333
|
+
startErr = err;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
// 即使 peerId 缺失也先拨种子 (拿不到 dial 会跳过), 再把真实结论返回
|
|
337
|
+
await dialSeeds(node, (opts.seedAddrs || []).filter(isWsCapableAddr));
|
|
338
|
+
if (startErr !== undefined) {
|
|
339
|
+
return {
|
|
340
|
+
ok: false,
|
|
341
|
+
node,
|
|
342
|
+
error: `节点 start() 失败: ${errDetail(startErr)} (libp2pStatus=${libp2pStatusOf(node)})`,
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
const v = verifyStarted(node);
|
|
346
|
+
if (v.error)
|
|
347
|
+
return { ok: false, node, error: v.error };
|
|
348
|
+
return { ok: true, node, peerId: v.peerId };
|
|
349
|
+
}
|
|
350
|
+
// 2) 真节点: 经 @diap/sdk/helia 的 HeliaIpfsClient 创建 (0.2.7 已修 "未 start 就读 getter" 的坑)
|
|
351
|
+
// 必须用**动态** import —— iOS 的 ES2024 垫片是本模块顶层语句; 静态 import 会在模块体
|
|
352
|
+
// 之前求值, 垫片就晚了 (上次 WebView 挂掉的根因之一)。
|
|
353
|
+
const sdk = await import('@diap/sdk/helia');
|
|
354
|
+
const seedAddrs = (opts.seedAddrs || []).filter(isWsCapableAddr);
|
|
355
|
+
let client;
|
|
356
|
+
if (opts.dhtClient || opts.libp2pOptions) {
|
|
357
|
+
// 高级路径: 自建 helia 配置, 用 fromHelia 包成 SDK 客户端。
|
|
358
|
+
// fromHelia 不读任何 libp2p getter; 随后的 start() 内部先 await helia.start() 再校验。
|
|
359
|
+
const helia = await buildCustomHelia(opts);
|
|
360
|
+
client = sdk.HeliaIpfsClient.fromHelia(helia);
|
|
361
|
+
}
|
|
362
|
+
else if (seedAddrs.length > 0) {
|
|
363
|
+
// 有种子 → SDK 工厂: 自带 circuitRelayTransport + listen /p2p-circuit, start 成功后才拨种子
|
|
364
|
+
client = await sdk.HeliaIpfsClient.newWithRemoteNode(seedAddrs, null, SDK_TIMEOUT_SEC);
|
|
365
|
+
}
|
|
366
|
+
else {
|
|
367
|
+
client = await sdk.HeliaIpfsClient.newPublicOnly(SDK_TIMEOUT_SEC);
|
|
368
|
+
}
|
|
369
|
+
// ★ 唯一成功判据: SDK 硬化校验 `getStartResult().ok === true`
|
|
370
|
+
// (SDK 内部已确认 peerId 非空 **且** libp2p.status === 'started', 且读 getter 全在 start 之后)
|
|
371
|
+
const started = client.getStartResult() ?? (await client.start());
|
|
372
|
+
const node = client.getHelia() ?? undefined;
|
|
373
|
+
if (!started.ok) {
|
|
374
|
+
return {
|
|
375
|
+
ok: false,
|
|
376
|
+
node,
|
|
377
|
+
error: `${started.error ?? 'libp2p 未就绪'} (libp2pStatus=${String(started.libp2pStatus)}) ${envHint()}`,
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
if (!node) {
|
|
381
|
+
return {
|
|
382
|
+
ok: false,
|
|
383
|
+
error: `Helia 已启动但没有节点实例 (libp2pStatus=${String(started.libp2pStatus)}) ${envHint()}`,
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
// 记下 节点 → SDK 客户端, 供 heliaAddJson / heliaGetJson 走 SDK 的 upload/get
|
|
387
|
+
nodeClients.set(node, client);
|
|
388
|
+
return { ok: true, node, peerId: started.peerId ?? peerIdOf(node) };
|
|
389
|
+
}
|
|
390
|
+
catch (err) {
|
|
391
|
+
// 建节点/加载依赖阶段的真实错误 (message + stack)
|
|
392
|
+
return { ok: false, error: `建 Helia 节点失败: ${errDetail(err)} ${envHint()}` };
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
// ─────────────────────────── 生命周期 (幂等) ───────────────────────────
|
|
396
|
+
async function doStart(cfg) {
|
|
397
|
+
// 上一次没起来的半成品节点先清掉, 避免重复 start 时泄漏一个 live libp2p/datastore。
|
|
398
|
+
// (注入节点路径不清 —— 由调用方/测试自己管)
|
|
399
|
+
if (currentNode && !cfg.node) {
|
|
400
|
+
try {
|
|
401
|
+
await currentNode.stop?.();
|
|
402
|
+
}
|
|
403
|
+
catch {
|
|
404
|
+
/* 半成品停不掉也不影响重建 */
|
|
405
|
+
}
|
|
406
|
+
currentNode = null;
|
|
407
|
+
currentPeerId = null;
|
|
408
|
+
}
|
|
409
|
+
let seedAddrs = cfg.seedAddrs;
|
|
410
|
+
if (seedAddrs === undefined) {
|
|
411
|
+
// 没给种子 → 从 mobile-p2p 读已保存节点地址, 过滤掉 /ws 不支持的项
|
|
412
|
+
const saved = await readSavedPeerAddrs();
|
|
413
|
+
seedAddrs = saved.filter(isWsCapableAddr);
|
|
414
|
+
}
|
|
415
|
+
else {
|
|
416
|
+
seedAddrs = seedAddrs.filter(isWsCapableAddr);
|
|
417
|
+
}
|
|
418
|
+
const res = await createMobileHeliaNode({
|
|
419
|
+
seedAddrs,
|
|
420
|
+
libp2pOptions: cfg.libp2pOptions,
|
|
421
|
+
node: cfg.node,
|
|
422
|
+
dhtClient: cfg.dhtClient,
|
|
423
|
+
});
|
|
424
|
+
// ★ 只要拿到了节点就先装上 —— libp2p 起不来也要保住本地块能力 (add/get 走 blockstore)。
|
|
425
|
+
if (res.node) {
|
|
426
|
+
currentNode = res.node;
|
|
427
|
+
currentPeerId = res.peerId ?? peerIdOf(res.node) ?? null;
|
|
428
|
+
}
|
|
429
|
+
// ★ 但只有「peerId 非空 + libp2p 真 started」才算成功, 否则透出真实错误。
|
|
430
|
+
if (!res.ok || !res.peerId) {
|
|
431
|
+
const error = res.error ??
|
|
432
|
+
`libp2p 未就绪: PeerID 为空 (libp2pStatus=${libp2pStatusOf(res.node ?? currentNode)}) ${envHint()}`;
|
|
433
|
+
lastStartError = error;
|
|
434
|
+
return { ok: false, node: res.node, error };
|
|
435
|
+
}
|
|
436
|
+
lastStartError = null;
|
|
437
|
+
return { ok: true, node: res.node, peerId: res.peerId };
|
|
438
|
+
}
|
|
439
|
+
/**
|
|
440
|
+
* 启动手机 Helia 节点。**幂等**: 已「真起来」(peerId 非空) 直接返回现有节点, 不重建。
|
|
441
|
+
* 半启动 (有节点没 peerId) 时会重试一次启动 (先停掉半成品)。
|
|
442
|
+
* 无 seedAddrs → 自动用 mobile-p2p 已保存地址 (过滤非 /ws); 允许空列表启动
|
|
443
|
+
* (能启动, 只是暂时没有对端)。
|
|
444
|
+
*/
|
|
445
|
+
export async function startMobileHelia(cfg = {}) {
|
|
446
|
+
if (currentNode && currentPeerId) {
|
|
447
|
+
return { ok: true, node: currentNode, peerId: currentPeerId };
|
|
448
|
+
}
|
|
449
|
+
if (startPromise)
|
|
450
|
+
return startPromise;
|
|
451
|
+
startPromise = doStart(cfg);
|
|
452
|
+
try {
|
|
453
|
+
return await startPromise;
|
|
454
|
+
}
|
|
455
|
+
finally {
|
|
456
|
+
startPromise = null;
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
/** 停止并清空节点。**幂等**: 未启动时返回 { ok:true }。 */
|
|
460
|
+
export async function stopMobileHelia() {
|
|
461
|
+
const node = currentNode;
|
|
462
|
+
currentNode = null;
|
|
463
|
+
currentPeerId = null;
|
|
464
|
+
lastStartError = null;
|
|
465
|
+
if (!node)
|
|
466
|
+
return { ok: true };
|
|
467
|
+
try {
|
|
468
|
+
if (typeof node.stop === 'function')
|
|
469
|
+
await node.stop();
|
|
470
|
+
return { ok: true };
|
|
471
|
+
}
|
|
472
|
+
catch (err) {
|
|
473
|
+
return { ok: false, error: errMsg(err) };
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
// ─────────────────────────── 读写 (CID 与 computeCid 一致) ───────────────────────────
|
|
477
|
+
/**
|
|
478
|
+
* 把 JSON 对象写入手机节点 blockstore。
|
|
479
|
+
* 真节点 (有 SDK 客户端) → 走 `HeliaIpfsClient.upload(JSON.stringify(...))`:
|
|
480
|
+
* SDK 的 upload **只接受字符串**, 且内部会把字符串 JSON.parse 回对象再做 dag-cbor 编码,
|
|
481
|
+
* CID 与 computeCid() 完全一致 (已实测同值)。SDK 失败(不抛)时退回本地块路径。
|
|
482
|
+
* 注入假节点 / 真节点起不来 → 走本地: CID 用 mobile-ipfs.computeCid() 算
|
|
483
|
+
* (dag-cbor + sha2-256 + CIDv1, 与桌面端逐字一致), 写入的字节就是同一份 dag-cbor 编码。
|
|
484
|
+
* 失败不抛。
|
|
485
|
+
*/
|
|
486
|
+
export async function heliaAddJson(obj) {
|
|
487
|
+
try {
|
|
488
|
+
const node = currentNode;
|
|
489
|
+
if (!node)
|
|
490
|
+
return { ok: false, error: 'Helia 节点未启动 (请先 startMobileHelia)' };
|
|
491
|
+
const client = nodeClients.get(node);
|
|
492
|
+
if (client) {
|
|
493
|
+
const r = await client.upload(JSON.stringify(jsonClean(obj)));
|
|
494
|
+
if (r.ok && r.cid)
|
|
495
|
+
return { ok: true, cid: r.cid };
|
|
496
|
+
// 不 ok 也不抛 —— 落到本地块路径, 保住"本地块能力不受 libp2p 影响"的不变式
|
|
497
|
+
}
|
|
498
|
+
const cidStr = await computeCid(obj); // 跨端一致的 CID
|
|
499
|
+
const bytes = dagCbor.encode(jsonClean(obj));
|
|
500
|
+
await node.blockstore.put(CID.parse(cidStr), bytes);
|
|
501
|
+
return { ok: true, cid: cidStr };
|
|
502
|
+
}
|
|
503
|
+
catch (err) {
|
|
504
|
+
return { ok: false, error: errMsg(err) };
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
/** 把 blockstore.get 的返回 (async generator / Promise) 归一成字节 */
|
|
508
|
+
async function collectBytes(src) {
|
|
509
|
+
if (src == null)
|
|
510
|
+
throw new Error('blockstore.get 无返回');
|
|
511
|
+
const maybePromise = src;
|
|
512
|
+
const unwrapped = typeof maybePromise.then === 'function' ? await maybePromise : src;
|
|
513
|
+
if (unwrapped instanceof Uint8Array)
|
|
514
|
+
return unwrapped;
|
|
515
|
+
const iterable = unwrapped;
|
|
516
|
+
if (iterable && (typeof iterable[Symbol.asyncIterator] === 'function' || typeof iterable[Symbol.iterator] === 'function')) {
|
|
517
|
+
const chunks = [];
|
|
518
|
+
for await (const c of iterable) {
|
|
519
|
+
chunks.push(c instanceof Uint8Array ? c : new Uint8Array(c));
|
|
520
|
+
}
|
|
521
|
+
if (chunks.length === 0)
|
|
522
|
+
throw new Error('块不可用 (本地 blockstore 与网络均未命中)');
|
|
523
|
+
if (chunks.length === 1)
|
|
524
|
+
return chunks[0];
|
|
525
|
+
const total = chunks.reduce((n, c) => n + c.length, 0);
|
|
526
|
+
const out = new Uint8Array(total);
|
|
527
|
+
let off = 0;
|
|
528
|
+
for (const c of chunks) {
|
|
529
|
+
out.set(c, off);
|
|
530
|
+
off += c.length;
|
|
531
|
+
}
|
|
532
|
+
return out;
|
|
533
|
+
}
|
|
534
|
+
throw new Error('无法从 blockstore.get 读取字节');
|
|
535
|
+
}
|
|
536
|
+
/**
|
|
537
|
+
* 读取 JSON 块: 先本地 blockstore, 再走网络 (helia 的 blockstore.get 会经 bitswap 取块),
|
|
538
|
+
* 拿到字节用 dag-cbor 解码。from 标出命中来源。失败不抛。
|
|
539
|
+
*/
|
|
540
|
+
export async function heliaGetJson(cid) {
|
|
541
|
+
const node = currentNode;
|
|
542
|
+
if (!node)
|
|
543
|
+
return { ok: false, from: 'network', error: 'Helia 节点未启动 (请先 startMobileHelia)' };
|
|
544
|
+
let parsed;
|
|
545
|
+
try {
|
|
546
|
+
const key = normalizeCid(cid);
|
|
547
|
+
if (!key)
|
|
548
|
+
return { ok: false, from: 'network', error: 'cid 不能为空' };
|
|
549
|
+
parsed = CID.parse(key);
|
|
550
|
+
}
|
|
551
|
+
catch (err) {
|
|
552
|
+
return { ok: false, from: 'network', error: `非法 cid: ${errMsg(err)}` };
|
|
553
|
+
}
|
|
554
|
+
// 本地是否命中 (决定 from)。has 缺失时按未命中处理 → 交给 get 走网络。
|
|
555
|
+
let local = false;
|
|
556
|
+
try {
|
|
557
|
+
local = node.blockstore.has ? await node.blockstore.has(parsed) : false;
|
|
558
|
+
}
|
|
559
|
+
catch {
|
|
560
|
+
local = false;
|
|
561
|
+
}
|
|
562
|
+
const from = local ? 'local' : 'network';
|
|
563
|
+
// 真节点: 走 SDK 客户端的 get (blockstore → 网络 → 网关, 且网关字节会做 multihash 校验)。
|
|
564
|
+
// SDK 的 content 对非字符串值会 JSON.stringify → 这里尝试解析回对象, 解析不了就当字符串。
|
|
565
|
+
const client = nodeClients.get(node);
|
|
566
|
+
if (client) {
|
|
567
|
+
const r = await client.get(parsed.toString());
|
|
568
|
+
if (!r.ok)
|
|
569
|
+
return { ok: false, from, error: r.error ?? '读取失败' };
|
|
570
|
+
let value = r.content;
|
|
571
|
+
try {
|
|
572
|
+
value = JSON.parse(r.content);
|
|
573
|
+
}
|
|
574
|
+
catch {
|
|
575
|
+
value = r.content;
|
|
576
|
+
}
|
|
577
|
+
return { ok: true, value, from: local || r.source === 'blockstore' ? 'local' : 'network' };
|
|
578
|
+
}
|
|
579
|
+
try {
|
|
580
|
+
const bytes = await collectBytes(node.blockstore.get(parsed));
|
|
581
|
+
const value = dagCbor.decode(bytes);
|
|
582
|
+
return { ok: true, value, from };
|
|
583
|
+
}
|
|
584
|
+
catch (err) {
|
|
585
|
+
return { ok: false, from, error: errMsg(err) };
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
// ─────────────────────────── 状态 ───────────────────────────
|
|
589
|
+
/**
|
|
590
|
+
* 节点状态: running / PeerID / 已连接 peers / blockstore 块数 / libp2p 真实状态 / 最近启动错误。
|
|
591
|
+
* 失败不抛。`running` 以 libp2p 真实状态为准 (有 libp2p 就必须 started); 没有 libp2p 的
|
|
592
|
+
* 纯本地块节点才退回看 node.status。
|
|
593
|
+
*/
|
|
594
|
+
export async function heliaStatus() {
|
|
595
|
+
const node = currentNode;
|
|
596
|
+
if (!node) {
|
|
597
|
+
const out = {
|
|
598
|
+
ok: true,
|
|
599
|
+
running: false,
|
|
600
|
+
peers: [],
|
|
601
|
+
libp2pStatus: 'not-created',
|
|
602
|
+
circuitAddrs: [],
|
|
603
|
+
relays: [],
|
|
604
|
+
};
|
|
605
|
+
if (lastStartError)
|
|
606
|
+
out.lastError = lastStartError;
|
|
607
|
+
return out;
|
|
608
|
+
}
|
|
609
|
+
try {
|
|
610
|
+
const { libp2p, error: libp2pError } = libp2pOf(node);
|
|
611
|
+
const libp2pStatus = libp2pStatusOf(node);
|
|
612
|
+
const peerId = currentPeerId ?? peerIdOf(node);
|
|
613
|
+
// 有 libp2p → 以它的状态为准; 没有 libp2p (纯本地块/假节点) → 看 node.status
|
|
614
|
+
const running = libp2pError ? false : libp2p ? libp2pStatus === 'started' : node.status === 'started';
|
|
615
|
+
// 2026-09-11: 可拨入地址 (/p2p-circuit) + 已预约中继 —— 手机能不能被拨入就看这两个
|
|
616
|
+
const { circuitAddrs, relays } = circuitInfoOf(libp2p);
|
|
617
|
+
let peers = [];
|
|
618
|
+
try {
|
|
619
|
+
const list = libp2p?.getPeers?.() || [];
|
|
620
|
+
peers = list.map((p) => (p && typeof p.toString === 'function' ? p.toString() : String(p)));
|
|
621
|
+
}
|
|
622
|
+
catch {
|
|
623
|
+
peers = [];
|
|
624
|
+
}
|
|
625
|
+
let blockCount;
|
|
626
|
+
if (typeof node.blockstore.getAll === 'function') {
|
|
627
|
+
try {
|
|
628
|
+
let n = 0;
|
|
629
|
+
for await (const _ of node.blockstore.getAll())
|
|
630
|
+
n++;
|
|
631
|
+
blockCount = n;
|
|
632
|
+
}
|
|
633
|
+
catch {
|
|
634
|
+
blockCount = undefined;
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
const out = { ok: true, running, peers, libp2pStatus, circuitAddrs, relays };
|
|
638
|
+
if (peerId)
|
|
639
|
+
out.peerId = peerId;
|
|
640
|
+
if (blockCount !== undefined)
|
|
641
|
+
out.blockCount = blockCount;
|
|
642
|
+
if (lastStartError)
|
|
643
|
+
out.lastError = lastStartError;
|
|
644
|
+
// 能拿到 libp2p 但状态异常时, 也把原因写进 error 供 UI 直接显示
|
|
645
|
+
if (!peerId && libp2pError)
|
|
646
|
+
out.error = `libp2p 未就绪: ${libp2pError}`;
|
|
647
|
+
return out;
|
|
648
|
+
}
|
|
649
|
+
catch (err) {
|
|
650
|
+
const out = {
|
|
651
|
+
ok: false,
|
|
652
|
+
running: false,
|
|
653
|
+
peers: [],
|
|
654
|
+
libp2pStatus: libp2pStatusOf(node),
|
|
655
|
+
circuitAddrs: [],
|
|
656
|
+
relays: [],
|
|
657
|
+
error: errDetail(err),
|
|
658
|
+
};
|
|
659
|
+
if (lastStartError)
|
|
660
|
+
out.lastError = lastStartError;
|
|
661
|
+
return out;
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
// ─────────────────────────── 开关 (UI 控) ───────────────────────────
|
|
665
|
+
/** 读开关 (默认关; 存储损坏一律当关)。 */
|
|
666
|
+
export function heliaEnabled(storage = defaultStorage()) {
|
|
667
|
+
try {
|
|
668
|
+
return storage.getItem(HELIA_ENABLED_KEY) === '1';
|
|
669
|
+
}
|
|
670
|
+
catch {
|
|
671
|
+
return false;
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
/** 写开关到 localStorage (key = bolloon_helia_enabled)。失败不抛。 */
|
|
675
|
+
export function setHeliaEnabled(enabled, storage = defaultStorage()) {
|
|
676
|
+
try {
|
|
677
|
+
storage.setItem(HELIA_ENABLED_KEY, enabled ? '1' : '0');
|
|
678
|
+
return { ok: true, enabled: !!enabled };
|
|
679
|
+
}
|
|
680
|
+
catch (err) {
|
|
681
|
+
return { ok: false, error: errMsg(err) };
|
|
682
|
+
}
|
|
683
|
+
}
|