@ckylinmc/mp-sdk 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/ckylmp.js ADDED
@@ -0,0 +1,842 @@
1
+ /**
2
+ * CKyLyn 小程序桥 TS SDK(单文件,零依赖)。
3
+ *
4
+ * 双通道(宿主侧实现见 `app/mp-lynx/src/main/java/in/ckyl/superapp/mplynx/bridge/`,
5
+ * 规范见 `app/docs/NATIVE_MODULES.md`):
6
+ *
7
+ * 1. **事件桥**(兼容/交互,永久保留):
8
+ * - JS→宿主:`NativeModules.CkylMpBridge.invoke(msgid, event, payloadJson)`
9
+ * - 宿主→JS 回执:`ckylinmp::reply` → `[msgid, event, ok, dataJson, errorJson]`
10
+ * - 宿主→JS 推送:`ckylinmp::event` → `[event, dataJson]`(下载进度等)
11
+ * 回执按 msgid 关联(对齐旧版 apibridge.js 语义),默认 10s 超时。
12
+ * 2. **原生模块**(二进制/高频;byte[] ↔ ArrayBuffer 直达,无 base64):
13
+ * - `NativeModules.CkylMpFile.{readBytes,saveBytes,scopeReadBytes,scopeWriteBytes}`
14
+ * - `NativeModules.CkylMpWebRtc.{sendText,sendBytes,setMicrophone,setSpeaker}`
15
+ * - `NativeModules.CkylMpShare.shareFile(spec)`
16
+ * 回执统一 `(errorCode|null, errorMessage|null, meta|null, bytes|null)`;
17
+ * 缺模块(旧宿主)时按 API 降级(写入/文本分享回退事件桥;读取/文件分享明确失败)。
18
+ *
19
+ * ⚠️ 约定:GlobalEventEmitter 监听必须注册于**后台线程**(Lynx 4.0.0 行为),
20
+ * 本 SDK 在首次调用时惰性注册,请勿在渲染关键路径上延迟首次调用时机。
21
+ */
22
+ /** 桥错误:宿主结构化错误码(见 docs/MP_PERMS.md 错误码表) */
23
+ export class CkylMpError extends Error {
24
+ constructor(code, message, extra = {}) {
25
+ super(message);
26
+ this.name = "CkylMpError";
27
+ this.code = code;
28
+ this.extra = extra;
29
+ }
30
+ }
31
+ // ── 内部:传输层 ─────────────────────────────────────────────
32
+ const REPLY_EVENT = "ckylinmp::reply";
33
+ const PUSH_EVENT = "ckylinmp::event";
34
+ const DEFAULT_TIMEOUT_MS = 10000;
35
+ let seq = 0;
36
+ let listenerInstalled = false;
37
+ const pending = new Map();
38
+ /** 推送订阅:handler 可选第二参 [bytes](`ckylinmp::event` 三元组 `[event, dataJson, binary?]`) */
39
+ const pushHandlers = new Map();
40
+ function installListeners() {
41
+ if (listenerInstalled)
42
+ return;
43
+ // 环境守卫:GlobalEventEmitter 缺失(裸 JS/Node/单测)时跳过注册,
44
+ // invoke 会在「无桥」分支快速失败(NOT_IMPLEMENTED),避免 ReferenceError。
45
+ let emitter;
46
+ try {
47
+ emitter = typeof GlobalEventEmitter !== "undefined" ? GlobalEventEmitter : undefined;
48
+ }
49
+ catch {
50
+ emitter = undefined;
51
+ }
52
+ if (!emitter)
53
+ return;
54
+ listenerInstalled = true;
55
+ emitter.addListener(REPLY_EVENT, (...args) => {
56
+ const [msgid, , ok, dataJson, errorJson] = args;
57
+ const call = pending.get(msgid);
58
+ if (!call)
59
+ return; // 已超时/未知回执
60
+ pending.delete(msgid);
61
+ clearTimeout(call.timer);
62
+ if (ok) {
63
+ call.resolve(dataJson || "{}");
64
+ }
65
+ else {
66
+ let parsed = { code: "INTERNAL", message: errorJson };
67
+ try {
68
+ parsed = { ...parsed, ...JSON.parse(errorJson) };
69
+ }
70
+ catch {
71
+ /* 保留原始文本作 message */
72
+ }
73
+ const { code, message, ...extra } = parsed;
74
+ call.reject(new CkylMpError(code || "INTERNAL", message || "未知错误", extra));
75
+ }
76
+ });
77
+ emitter.addListener(PUSH_EVENT, (...args) => {
78
+ const [event, dataJson, bytes] = args;
79
+ const handlers = pushHandlers.get(event);
80
+ if (!handlers || handlers.size === 0)
81
+ return;
82
+ let data = dataJson;
83
+ try {
84
+ data = JSON.parse(dataJson);
85
+ }
86
+ catch {
87
+ /* 非 JSON 载荷按原文分发 */
88
+ }
89
+ handlers.forEach((h) => {
90
+ try {
91
+ h(data, bytes);
92
+ }
93
+ catch {
94
+ /* 单个 handler 异常不影响其他订阅者 */
95
+ }
96
+ });
97
+ });
98
+ }
99
+ function invoke(event, payload = {}, timeoutMs = DEFAULT_TIMEOUT_MS) {
100
+ return new Promise((resolve, reject) => {
101
+ const bridge = safeBridge();
102
+ if (!bridge) {
103
+ reject(new CkylMpError("NOT_IMPLEMENTED", "当前宿主不支持小程序桥"));
104
+ return;
105
+ }
106
+ installListeners();
107
+ const msgid = `msg_${Date.now()}_${++seq}`;
108
+ const timer = setTimeout(() => {
109
+ pending.delete(msgid);
110
+ reject(new CkylMpError("TIMEOUT", `等待 ${event} 回执超时(${timeoutMs}ms)`));
111
+ }, timeoutMs);
112
+ pending.set(msgid, {
113
+ resolve: (dataJson) => {
114
+ try {
115
+ resolve(JSON.parse(dataJson));
116
+ }
117
+ catch {
118
+ resolve({});
119
+ }
120
+ },
121
+ reject,
122
+ timer,
123
+ });
124
+ bridge.invoke(msgid, event, JSON.stringify(payload));
125
+ });
126
+ }
127
+ // ── 原生模块通道(二进制/高频路径,docs/NATIVE_MODULES.md)────
128
+ // 与事件桥的关系:类型化原生模块(@LynxMethod + byte[] 直达)承载
129
+ // 文件字节/WebRTC 实时/非文本分享;事件桥保持为兼容与交互通道。
130
+ // 缺模块(旧宿主)时按 API 降级:二进制读取/文件分享明确失败(NOT_IMPLEMENTED),
131
+ // 写入/文本分享自动回退 base64 事件桥路径。
132
+ /** 探测某原生模块是否可用(缺模块 = 旧宿主) */
133
+ export function hasNativeCapability(module) {
134
+ try {
135
+ return typeof NativeModules !== "undefined" && NativeModules[module] != null;
136
+ }
137
+ catch {
138
+ return false;
139
+ }
140
+ }
141
+ /**
142
+ * 调用类型化原生模块方法(Callback 契约
143
+ * `(errorCode|null, errorMessage|null, meta|null, bytes|null)`)。
144
+ * 模块缺失 → NOT_IMPLEMENTED(由调用方决定降级或明确失败)。
145
+ */
146
+ function callNative(module, method, args, timeoutMs = DEFAULT_TIMEOUT_MS) {
147
+ return new Promise((resolve, reject) => {
148
+ const mod = safeNative(module);
149
+ const fn = mod?.[method];
150
+ if (typeof fn !== "function") {
151
+ reject(new CkylMpError("NOT_IMPLEMENTED", `当前宿主不支持原生模块 ${module}.${method}`));
152
+ return;
153
+ }
154
+ let settled = false;
155
+ const timer = setTimeout(() => {
156
+ if (settled)
157
+ return;
158
+ settled = true;
159
+ reject(new CkylMpError("TIMEOUT", `等待 ${module}.${method} 回执超时(${timeoutMs}ms)`));
160
+ }, timeoutMs);
161
+ const cb = (errorCode, errorMessage, meta, bytes) => {
162
+ if (settled)
163
+ return;
164
+ settled = true;
165
+ clearTimeout(timer);
166
+ if (errorCode) {
167
+ reject(new CkylMpError(errorCode, errorMessage ?? "未知错误", meta ?? {}));
168
+ return;
169
+ }
170
+ resolve({ meta: meta ?? {}, bytes });
171
+ };
172
+ try {
173
+ fn.apply(mod, [...args, cb]);
174
+ }
175
+ catch (e) {
176
+ if (settled)
177
+ return;
178
+ settled = true;
179
+ clearTimeout(timer);
180
+ reject(new CkylMpError("INTERNAL", e?.message ?? "原生模块调用失败"));
181
+ }
182
+ });
183
+ }
184
+ /** 安全读取 NativeModules(环境中不存在时返回 undefined) */
185
+ function safeNative(module) {
186
+ try {
187
+ return NativeModules[module];
188
+ }
189
+ catch {
190
+ return undefined;
191
+ }
192
+ }
193
+ /** 安全读取事件桥模块(NativeModules / NativeModules.CkylMpBridge 缺失时返回 undefined) */
194
+ function safeBridge() {
195
+ try {
196
+ return typeof NativeModules !== "undefined"
197
+ ? NativeModules.CkylMpBridge
198
+ : undefined;
199
+ }
200
+ catch {
201
+ return undefined;
202
+ }
203
+ }
204
+ /** Blob/ArrayBuffer/视图 → ArrayBuffer(原生模块 byte[] 输入) */
205
+ function toArrayBuffer(data) {
206
+ if (data instanceof ArrayBuffer)
207
+ return Promise.resolve(data);
208
+ if (ArrayBuffer.isView(data)) {
209
+ const view = data;
210
+ return Promise.resolve(view.buffer.slice(view.byteOffset, view.byteOffset + view.byteLength));
211
+ }
212
+ return data.arrayBuffer();
213
+ }
214
+ /** 二进制输入判定(Blob / ArrayBuffer / TypedArray / DataView) */
215
+ function isBinaryLike(data) {
216
+ return (typeof Blob !== "undefined" && data instanceof Blob ||
217
+ data instanceof ArrayBuffer ||
218
+ ArrayBuffer.isView(data));
219
+ }
220
+ /** WebRTC 数据通道单条消息上限(原生模块 byte[] 直达;与宿主常量一致) */
221
+ const WEBRTC_MAX_MSG_BYTES = 256 * 1024;
222
+ /** WebRTC 调用超时(数据通道 send 即刻回执,放宽少许) */
223
+ const WEBRTC_CALL_TIMEOUT_MS = 30000;
224
+ /** 文件/二进制类交互超时(与事件桥文件类一致的长交互放宽) */
225
+ const FILE_ACTION_TIMEOUT_MS = 300000;
226
+ /** 二进制读取超时(8–16MB 一次性读取,逐设备 IO 放宽) */
227
+ const READ_BINARY_TIMEOUT_MS = 120000;
228
+ // ── 推送事件订阅 ─────────────────────────────────────────────
229
+ /** 订阅宿主推送事件(如 download_progress、webrtc_binary);返回取消函数 */
230
+ export function on(event, handler) {
231
+ installListeners();
232
+ let set = pushHandlers.get(event);
233
+ if (!set) {
234
+ set = new Set();
235
+ pushHandlers.set(event, set);
236
+ }
237
+ set.add(handler);
238
+ return () => off(event, handler);
239
+ }
240
+ /** 取消订阅 */
241
+ export function off(event, handler) {
242
+ pushHandlers.get(event)?.delete(handler);
243
+ }
244
+ /**
245
+ * 冷启动 deeplink 参数(宿主 globalProps 注入,同步可读;无参数 = 空数组)。
246
+ * 必须在启动早期调用(globalProps 渲染前即曝光,任意时点读取均含启动值)。
247
+ */
248
+ export function getLaunchOptions() {
249
+ const gp = (() => {
250
+ try {
251
+ return typeof lynx !== "undefined" ? lynx.__globalProps : undefined;
252
+ }
253
+ catch {
254
+ return undefined;
255
+ }
256
+ })();
257
+ const launch = gp?.launchOptions;
258
+ if (launch && Array.isArray(launch.query)) {
259
+ const query = launch.query.filter((item) => Array.isArray(item) &&
260
+ item.length === 2 &&
261
+ typeof item[0] === "string" &&
262
+ typeof item[1] === "string");
263
+ return { query };
264
+ }
265
+ return { query: [] };
266
+ }
267
+ /**
268
+ * 订阅「小程序已启动时」的带参 deeplink 推送(`deeplink_called`);
269
+ * 无参数的 deeplink 不推送。返回取消函数。
270
+ */
271
+ export function onDeeplinkCalled(handler) {
272
+ return on("deeplink_called", (raw) => {
273
+ const query = (raw?.query ?? []);
274
+ try {
275
+ handler({ query: Array.isArray(query) ? query : [] });
276
+ }
277
+ catch {
278
+ /* 单 handler 异常不影响其他订阅者 */
279
+ }
280
+ });
281
+ }
282
+ // 返回监听:handler 集合 + 一次性注册(back.on/off 幂等;宿主端失败静默忽略)
283
+ const backHandlers = new Set();
284
+ let backEventInstalled = false;
285
+ let backRegistered = false;
286
+ /**
287
+ * 返回监听:注册后宿主**单次返回**推送 `back_press` 给本小程序(宿主不再双击退出),
288
+ * 小程序可借 `exitApp()` 在「返回到达最顶层」时退出。返回取消函数;全部取消后自动注销。
289
+ */
290
+ export function onBackPress(handler) {
291
+ if (!backEventInstalled) {
292
+ backEventInstalled = true;
293
+ on("back_press", () => {
294
+ backHandlers.forEach((h) => {
295
+ try {
296
+ h();
297
+ }
298
+ catch {
299
+ /* 单 handler 异常不影响其他订阅者 */
300
+ }
301
+ });
302
+ });
303
+ }
304
+ if (!backRegistered) {
305
+ backRegistered = true;
306
+ invoke("back.on", {}, 5000).catch(() => {
307
+ /* 宿主不支持返回监听时静默(事件订阅同失效) */
308
+ });
309
+ }
310
+ backHandlers.add(handler);
311
+ return () => {
312
+ backHandlers.delete(handler);
313
+ if (backHandlers.size === 0 && backRegistered) {
314
+ backRegistered = false;
315
+ invoke("back.off", {}, 5000).catch(() => { });
316
+ }
317
+ };
318
+ }
319
+ /** 退出本小程序(宿主关闭小程序任务;返回监听模式下「返回到底」的关闭出口) */
320
+ export async function exitApp() {
321
+ await invoke("app.exit");
322
+ }
323
+ // ── 权限 ─────────────────────────────────────────────────────
324
+ /**
325
+ * 批量申请权限(必须已在小程序 `perms` 字段声明)。
326
+ *
327
+ * - 全部已裁决:立即返回已允许列表,不弹窗;
328
+ * - 含待定项:宿主弹出一次性授权弹窗(仅一次/保持允许/永久拒绝);
329
+ * - 返回本次调用后生效允许的权限 key 列表(含历史已允许项)。
330
+ */
331
+ export async function requestPerms(perms) {
332
+ const res = await invoke("requestPerms", { perms });
333
+ return res.perms;
334
+ }
335
+ /**
336
+ * 获取作用域 token(需已授权 token 权限;未授权返回 PERM_DENIED,
337
+ * 请先 `requestPerms(["token"])`)。
338
+ */
339
+ export async function getToken() {
340
+ const res = await invoke("getToken");
341
+ return res.token;
342
+ }
343
+ /**
344
+ * 获取宿主当前用户的昵称/头像/UserId(需已授权 `profile` 权限;
345
+ * 未授权抛 `PERM_DENIED`,请先 `requestPerms(["profile"])`——首次弹授权窗,
346
+ * 文案「获取您的头像、昵称」;宿主未登录抛 `AUTH_REQUIRED`)。
347
+ */
348
+ export async function getProfile() {
349
+ return invoke("getProfile");
350
+ }
351
+ // ── store(隔离 KV)──────────────────────────────────────────
352
+ export const store = {
353
+ async get(key) {
354
+ const res = await invoke("store.get", { key });
355
+ return res.value;
356
+ },
357
+ async set(key, value) {
358
+ await invoke("store.set", { key, value });
359
+ },
360
+ async remove(key) {
361
+ await invoke("store.remove", { key });
362
+ },
363
+ async keys() {
364
+ const res = await invoke("store.keys");
365
+ return res.keys;
366
+ },
367
+ async clear() {
368
+ await invoke("store.clear");
369
+ },
370
+ };
371
+ // ── 能力 ─────────────────────────────────────────────────────
372
+ /**
373
+ * 系统分享面板:文本(事件桥)或 文本+图片/文件附件(原生模块通道)。
374
+ * `file` 提供时经 `CkylMpShare.shareFile`(bytes 落宿主临时文件,FileProvider
375
+ * 授权);仅文本时走事件桥 `share`(旧宿主兼容)。`text`/`file` 至少其一。
376
+ */
377
+ export async function share(options) {
378
+ if (options.file !== undefined) {
379
+ const spec = options.file;
380
+ if (!hasNativeCapability("CkylMpShare")) {
381
+ throw new CkylMpError("NOT_IMPLEMENTED", "当前宿主不支持文件分享");
382
+ }
383
+ if (spec.uri === undefined && spec.data === undefined) {
384
+ throw new CkylMpError("BAD_REQUEST", "share: file.uri / file.data 至少其一");
385
+ }
386
+ const data = spec.data !== undefined ? await toArrayBuffer(spec.data) : undefined;
387
+ await callNative("CkylMpShare", "shareFile", [
388
+ options.text ?? null,
389
+ options.title ?? null,
390
+ spec.uri ?? null,
391
+ data ?? null,
392
+ spec.filename ?? null,
393
+ spec.mime ?? null,
394
+ ], FILE_ACTION_TIMEOUT_MS);
395
+ return;
396
+ }
397
+ if (!options.text)
398
+ throw new CkylMpError("BAD_REQUEST", "share: 需要 text 或 file");
399
+ await invoke("share", { text: options.text, title: options.title });
400
+ }
401
+ /**
402
+ * 系统 DownloadManager 下载,返回下载 id 与文件名;
403
+ * 进度/完成/失败经推送事件分发(见 [onDownloadProgress]/[onDownloadSettled])。
404
+ * `openAfterComplete` = 完成后由宿主直接打开文件。
405
+ */
406
+ export async function download(options) {
407
+ return invoke("download", { ...options });
408
+ }
409
+ /** 订阅下载进度推送;返回取消函数 */
410
+ export function onDownloadProgress(handler) {
411
+ return on("download_progress", (data) => handler(data));
412
+ }
413
+ /** 订阅下载完成/失败推送 */
414
+ export function onDownloadSettled(handler) {
415
+ const offComplete = on("download_complete", (d) => handler("download_complete", d));
416
+ const offFailed = on("download_failed", (d) => handler("download_failed", d));
417
+ return () => {
418
+ offComplete();
419
+ offFailed();
420
+ };
421
+ }
422
+ /** 拉起宿主扫码页,返回二维码原文(用户取消抛 USER_CANCELLED) */
423
+ export async function scanQR() {
424
+ const res = await invoke("scanQR", {}, 120000);
425
+ return res.result;
426
+ }
427
+ /** 获取当前位置(宿主运行时权限弹窗;超时抛 INTERNAL) */
428
+ export async function getLocation(options = {}) {
429
+ return invoke("getLocation", { ...options }, Math.max((options.timeoutMs ?? 15000) + 5000, DEFAULT_TIMEOUT_MS));
430
+ }
431
+ /** 经宿主转发 Intent(结构性消毒:仅 action/data/categories/package/string extras) */
432
+ export async function sendIntent(spec) {
433
+ await invoke("sendIntent", { ...spec });
434
+ }
435
+ // ── 文件能力(writefile/selectfolder/selectfile/readfile/selectmedia)─────────
436
+ // 统一经宿主 SAF 系统选择器;用户取消抛 USER_CANCELLED;需先 requestPerms 对应权限。
437
+ // 超时放宽:选择器/对话框为长交互,按 5 分钟兜底。
438
+ /** 保存对话框:文件名可改、选择保存位置、保存或取消(需 writefile 权限) */
439
+ export async function writeFile(options) {
440
+ // 二进制快路径:Blob/ArrayBuffer/视图 → byte[] 直达宿主(无 base64、无 JSON 字符串);
441
+ // 旧宿主(无 CkylMpFile)自动回退 base64 事件桥路径。
442
+ if (isBinaryLike(options.data) && hasNativeCapability("CkylMpFile")) {
443
+ const bytes = await toArrayBuffer(options.data);
444
+ const { meta } = await callNative("CkylMpFile", "saveBytes", [
445
+ bytes,
446
+ options.filename ?? null,
447
+ options.mime ?? null,
448
+ ], FILE_ACTION_TIMEOUT_MS);
449
+ return {
450
+ uri: String(meta.uri ?? ""),
451
+ name: String(meta.name ?? ""),
452
+ size: Number(meta.size ?? 0),
453
+ };
454
+ }
455
+ const normalized = await normalizeData(options.data, options.encoding);
456
+ return invoke("writeFile", {
457
+ filename: options.filename,
458
+ data: normalized.data,
459
+ encoding: normalized.encoding,
460
+ mime: options.mime,
461
+ }, FILE_ACTION_TIMEOUT_MS);
462
+ }
463
+ /**
464
+ * 二进制读取(需 readfile 权限;**原生模块通道**,byte[] 直达):
465
+ * 返回字节而非 base64/文本,默认上限 8MB(maxBytes 上限 16MB,超限 FILE_TOO_LARGE)。
466
+ * 旧宿主不支持时抛 NOT_IMPLEMENTED(明确失败,不回退——1MB base64 不是等价能力)。
467
+ */
468
+ export async function readFileBytes(options) {
469
+ const { meta, bytes } = await callNative("CkylMpFile", "readBytes", [
470
+ options.uri,
471
+ options.maxBytes ?? 0,
472
+ ], READ_BINARY_TIMEOUT_MS);
473
+ if (!bytes)
474
+ throw new CkylMpError("INTERNAL", "宿主未返回字节数据");
475
+ return {
476
+ uri: String(meta.uri ?? options.uri),
477
+ name: String(meta.name ?? ""),
478
+ size: Number(meta.size ?? bytes.byteLength),
479
+ mime: meta.mime != null ? String(meta.mime) : undefined,
480
+ bytes: new Uint8Array(bytes),
481
+ };
482
+ }
483
+ /** 目录选择:返回 tree URI + 展示名 + primary 真实路径(需 selectfolder 权限) */
484
+ export async function selectFolder(options = {}) {
485
+ return invoke("selectFolder", { persist: options.persist ?? true }, FILE_ACTION_TIMEOUT_MS);
486
+ }
487
+ /**
488
+ * 文件选择:单/多选 + 类型过滤(MIME/扩展名)+ 默认打开目录(需 selectfile 权限)。
489
+ * `initialUri` 传 selectFolder 返回的 uri;`types` 为空 = 不限类型。
490
+ */
491
+ export async function selectFiles(options = {}) {
492
+ return invoke("selectFiles", { ...options }, FILE_ACTION_TIMEOUT_MS);
493
+ }
494
+ /**
495
+ * 读取指定 URI 文件内容(需 readfile 权限;文件 ≤1MB,超限抛 FILE_TOO_LARGE)。
496
+ * encoding: auto(默认,按内容判定文本/二进制)| utf8 | base64。
497
+ */
498
+ export async function readFile(options) {
499
+ return invoke("readFile", { ...options }, FILE_ACTION_TIMEOUT_MS);
500
+ }
501
+ /**
502
+ * 系统相册选择(需 selectmedia 权限):
503
+ * - 系统 Photo Picker(API 33+ 原生 / 30-32 backport / 更老设备降级 OpenDocument);
504
+ * - `mediaType` 图片/视频/全部;`multiple` 多选(maxCount 上限 20);
505
+ * - Photo Picker 不支持自定义类型过滤:`filters`/`maxSizeBytes` 为宿主**后置过滤**,
506
+ * 未命中项出现在 `skipped`。
507
+ */
508
+ export async function selectMedia(options = {}) {
509
+ return invoke("selectMedia", { ...options }, FILE_ACTION_TIMEOUT_MS);
510
+ }
511
+ // ── controlfolder(scope 逐项授权 + 目录完全控制)────────────────────────────
512
+ // 每个 scope 前缀必须单独授权(controlFolder.request 弹说明对话框 + 用户选择目录);
513
+ // 操作路径必须为绝对路径且落在已授权 scope 前缀下(目录穿越一律 SCOPE_NOT_GRANTED)。
514
+ /** 授权一个目录范围(需 controlfolder 权限;用户取消抛 USER_CANCELLED) */
515
+ export async function requestFolderScope(scope) {
516
+ return invoke("controlFolder.request", { scope }, FILE_ACTION_TIMEOUT_MS);
517
+ }
518
+ /** 列出已授权范围 */
519
+ export async function folderScopes() {
520
+ return invoke("controlFolder.scopes", {});
521
+ }
522
+ /** controlfolder 操作集(path 均为绝对路径且必须在已授权 scope 前缀下) */
523
+ export const controlFolder = {
524
+ createDir(path) {
525
+ return invoke("controlFolder.createDir", { path });
526
+ },
527
+ createFile(path, options = {}) {
528
+ return buildDataInvoke("controlFolder.createFile", path, options, options.data !== undefined);
529
+ },
530
+ writeFile(path, options) {
531
+ // 二进制快路径:byte[] 直达(无 base64);旧宿主自动回退事件桥 base64。
532
+ if (isBinaryLike(options.data) && hasNativeCapability("CkylMpFile")) {
533
+ return writeScopeBytes(path, options.data);
534
+ }
535
+ return buildDataInvoke("controlFolder.writeFile", path, options, true);
536
+ },
537
+ /**
538
+ * 二进制读取(controlfolder 字节态;默认 8MB,上限 16MB,超限 FILE_TOO_LARGE)。
539
+ * 旧宿主不支持时抛 NOT_IMPLEMENTED。
540
+ */
541
+ readFileBytes(path, options = {}) {
542
+ return readScopeBytes(path, options.maxBytes);
543
+ },
544
+ /**
545
+ * 二进制写入(controlfolder 字节态,覆盖写,≤32MB;显式快路径,旧宿主 NOT_IMPLEMENTED)。
546
+ * 说明:`writeFile` 自动按输入走本路径或事件桥,正常情况下无需直接用本方法。
547
+ */
548
+ writeFileBytes(path, data, options = {}) {
549
+ if (!hasNativeCapability("CkylMpFile")) {
550
+ return Promise.reject(new CkylMpError("NOT_IMPLEMENTED", "当前宿主不支持二进制写入"));
551
+ }
552
+ return writeScopeBytes(path, data);
553
+ },
554
+ readFile(path, options = {}) {
555
+ return invoke("controlFolder.readFile", { path, ...options }, FILE_ACTION_TIMEOUT_MS);
556
+ },
557
+ list(path, options = {}) {
558
+ return invoke("controlFolder.list", { ...(path !== undefined ? { path } : {}), recursive: options.recursive }, FILE_ACTION_TIMEOUT_MS);
559
+ },
560
+ rename(path, newName) {
561
+ return invoke("controlFolder.rename", { path, newName });
562
+ },
563
+ delete(path) {
564
+ return invoke("controlFolder.delete", { path });
565
+ },
566
+ copy(src, dest) {
567
+ return invoke("controlFolder.copy", { src, dest }, FILE_ACTION_TIMEOUT_MS);
568
+ },
569
+ move(src, dest) {
570
+ return invoke("controlFolder.move", { src, dest }, FILE_ACTION_TIMEOUT_MS);
571
+ },
572
+ };
573
+ /** controlfolder 二进制写入(scopeWriteBytes;字节直达,路径须命中已授权 scope) */
574
+ async function writeScopeBytes(path, data) {
575
+ const bytes = await toArrayBuffer(data);
576
+ const { meta } = await callNative("CkylMpFile", "scopeWriteBytes", [
577
+ path, bytes, null,
578
+ ], FILE_ACTION_TIMEOUT_MS);
579
+ return { path: String(meta.path ?? path) };
580
+ }
581
+ /** controlfolder 二进制读取(scopeReadBytes;≤16MB) */
582
+ async function readScopeBytes(path, maxBytes) {
583
+ const { meta, bytes } = await callNative("CkylMpFile", "scopeReadBytes", [
584
+ path, maxBytes ?? 0,
585
+ ], READ_BINARY_TIMEOUT_MS);
586
+ if (!bytes)
587
+ throw new CkylMpError("INTERNAL", "宿主未返回字节数据");
588
+ return {
589
+ path: String(meta.path ?? path),
590
+ name: String(meta.name ?? ""),
591
+ size: Number(meta.size ?? bytes.byteLength),
592
+ mime: meta.mime != null ? String(meta.mime) : undefined,
593
+ bytes: new Uint8Array(bytes),
594
+ };
595
+ }
596
+ async function buildDataInvoke(event, path, options, withData) {
597
+ const payload = { path };
598
+ if (withData && options.data !== undefined) {
599
+ const normalized = await normalizeData(options.data, options.encoding);
600
+ payload.data = normalized.data;
601
+ payload.encoding = normalized.encoding;
602
+ }
603
+ return invoke(event, payload, FILE_ACTION_TIMEOUT_MS);
604
+ }
605
+ /** 载荷归一:文本 utf8 原样;Blob/ArrayBuffer/Uint8Array 转 base64;允许显式指定编码 */
606
+ async function normalizeData(data, encoding) {
607
+ if (data instanceof Blob) {
608
+ const base64 = await blobToBase64(data);
609
+ return { data: base64, encoding: "base64" };
610
+ }
611
+ if (data instanceof ArrayBuffer || ArrayBuffer.isView(data)) {
612
+ return { data: toBase64(data), encoding: "base64" };
613
+ }
614
+ return { data, encoding: encoding ?? "utf8" };
615
+ }
616
+ function blobToBase64(blob) {
617
+ return new Promise((resolve, reject) => {
618
+ const reader = new FileReader();
619
+ reader.onload = () => {
620
+ const result = reader.result;
621
+ if (typeof result === "string") {
622
+ resolve(result.slice(result.indexOf(",") + 1));
623
+ }
624
+ else {
625
+ reject(new Error("无法读取 Blob"));
626
+ }
627
+ };
628
+ reader.onerror = () => reject(reader.error ?? new Error("Blob 读取失败"));
629
+ reader.readAsDataURL(blob);
630
+ });
631
+ }
632
+ function toBase64(input) {
633
+ const bytes = input instanceof ArrayBuffer ? new Uint8Array(input) : new Uint8Array(input.buffer, input.byteOffset, input.byteLength);
634
+ let binary = "";
635
+ for (let i = 0; i < bytes.length; i++)
636
+ binary += String.fromCharCode(bytes[i]);
637
+ return btoa(binary);
638
+ }
639
+ export class MpDataChannel {
640
+ constructor(pcId, dcId, label) {
641
+ this.messageHandlers = new Set();
642
+ this.pcId = pcId;
643
+ this.dcId = dcId;
644
+ this.label = label;
645
+ }
646
+ /** 发送文本(UTF-8);单条上限 64KB(事件桥路径) */
647
+ send(data) {
648
+ return invoke("webrtc.dataChannel.send", { pcId: this.pcId, dcId: this.dcId, data, binary: false });
649
+ }
650
+ /**
651
+ * 发送二进制:**原生模块路径**(byte[] 直达,≤256KB;应用层自行分帧)。
652
+ * 旧宿主(无 CkylMpWebRtc)自动回退 base64 事件桥(≤48KB 实际字节)。
653
+ */
654
+ async sendBytes(data) {
655
+ const buf = await toArrayBuffer(data);
656
+ if (buf.byteLength > WEBRTC_MAX_MSG_BYTES) {
657
+ throw new CkylMpError("BAD_REQUEST", `单条消息超过 ${WEBRTC_MAX_MSG_BYTES / 1024}KB 上限`);
658
+ }
659
+ if (hasNativeCapability("CkylMpWebRtc")) {
660
+ await callNative("CkylMpWebRtc", "sendBytes", [this.pcId, this.dcId, buf], WEBRTC_CALL_TIMEOUT_MS);
661
+ return;
662
+ }
663
+ await invoke("webrtc.dataChannel.send", { pcId: this.pcId, dcId: this.dcId, data: toBase64(buf), binary: true }, WEBRTC_CALL_TIMEOUT_MS);
664
+ }
665
+ /** 发送二进制(base64 文本,legacy);解码后单条上限 64KB */
666
+ sendBinary(base64) {
667
+ return invoke("webrtc.dataChannel.send", { pcId: this.pcId, dcId: this.dcId, data: base64, binary: true });
668
+ }
669
+ /** 订阅消息;返回取消函数 */
670
+ onMessage(handler) {
671
+ this.messageHandlers.add(handler);
672
+ return () => {
673
+ this.messageHandlers.delete(handler);
674
+ };
675
+ }
676
+ /** 内部分发入口([MpPeerConnection] 路由 webrtc_event / webrtc_binary 用) */
677
+ dispatch(data, binary, bytes) {
678
+ this.messageHandlers.forEach((h) => {
679
+ try {
680
+ h({ data, bytes, binary });
681
+ }
682
+ catch {
683
+ /* 单个 handler 异常不影响其他订阅者 */
684
+ }
685
+ });
686
+ }
687
+ }
688
+ /**
689
+ * P2P 连接(仅音频 + 数据通道)。回调属性按需赋值;
690
+ * [close] 后事件订阅自动解除。
691
+ */
692
+ export class MpPeerConnection {
693
+ constructor(pcId) {
694
+ this.channels = new Map();
695
+ this.pcId = pcId;
696
+ const offEvent = on("webrtc_event", (raw) => {
697
+ const e = raw;
698
+ if (e.pcId !== this.pcId)
699
+ return;
700
+ switch (e.type) {
701
+ case "ice_candidate":
702
+ this.onIceCandidate?.({
703
+ candidate: e.candidate,
704
+ sdpMid: e.sdpMid,
705
+ sdpMLineIndex: e.sdpMLineIndex,
706
+ });
707
+ break;
708
+ case "ice_gathering_state":
709
+ this.onIceGatheringStateChange?.(e.state);
710
+ break;
711
+ case "connection_state":
712
+ this.onConnectionStateChange?.(e.state);
713
+ break;
714
+ case "remote_audio":
715
+ this.onRemoteAudio?.(e.state);
716
+ break;
717
+ case "data_channel": {
718
+ const dc = new MpDataChannel(this.pcId, e.dcId, e.label ?? "");
719
+ this.channels.set(dc.dcId, dc);
720
+ this.onDataChannel?.(dc);
721
+ break;
722
+ }
723
+ case "data_channel_message":
724
+ this.channels.get(e.dcId)?.dispatch(e.data ?? null, e.binary ?? false);
725
+ break;
726
+ }
727
+ });
728
+ // 二进制消息(字节直达,无 base64):`webrtc_binary` 推送三元组 [event, dataJson, bytes]
729
+ const offBinary = on("webrtc_binary", (raw, bytes) => {
730
+ const e = raw;
731
+ if (e.pcId !== this.pcId)
732
+ return;
733
+ this.channels.get(e.dcId)?.dispatch(null, true, bytes ?? new ArrayBuffer(0));
734
+ });
735
+ this.unsubscribe = () => {
736
+ offEvent();
737
+ offBinary();
738
+ };
739
+ }
740
+ createOffer() {
741
+ return invoke("webrtc.createOffer", { pcId: this.pcId });
742
+ }
743
+ createAnswer() {
744
+ return invoke("webrtc.createAnswer", { pcId: this.pcId });
745
+ }
746
+ setLocalDescription(desc) {
747
+ return invoke("webrtc.setLocalDescription", { pcId: this.pcId, ...desc });
748
+ }
749
+ setRemoteDescription(desc) {
750
+ return invoke("webrtc.setRemoteDescription", { pcId: this.pcId, ...desc });
751
+ }
752
+ addIceCandidate(candidate) {
753
+ return invoke("webrtc.addIceCandidate", { pcId: this.pcId, ...candidate });
754
+ }
755
+ /** 新建本地数据通道(需先完成 offer/answer 协商) */
756
+ async createDataChannel(label) {
757
+ const res = await invoke("webrtc.createDataChannel", { pcId: this.pcId, label });
758
+ const dc = new MpDataChannel(this.pcId, res.dcId, label);
759
+ this.channels.set(dc.dcId, dc);
760
+ return dc;
761
+ }
762
+ /** 麦克风开关:首次开启触发宿主运行时权限弹窗(超时放宽至 60s) */
763
+ setMicrophone(enabled) {
764
+ return invoke("webrtc.setMicrophone", { pcId: this.pcId, enabled }, 60000);
765
+ }
766
+ /** 关闭并释放(幂等;同时解除事件订阅) */
767
+ close() {
768
+ this.unsubscribe();
769
+ return invoke("webrtc.close", { pcId: this.pcId });
770
+ }
771
+ }
772
+ /**
773
+ * 新建 P2P 连接(需已授权 webrtc 权限,否则 PERM_DENIED)。
774
+ * 典型流程:createPeerConnection → createOffer/setLocalDescription →
775
+ * 信令交换 → setRemoteDescription/addIceCandidate → 建数据通道或开麦。
776
+ */
777
+ export async function createPeerConnection(config = {}) {
778
+ const res = await invoke("webrtc.createPeerConnection", {
779
+ iceServers: config.iceServers ?? [],
780
+ });
781
+ return new MpPeerConnection(res.pcId);
782
+ }
783
+ /** 音频输出路由:`true` 扬声器,`false` 听筒 */
784
+ export function setSpeaker(enabled) {
785
+ return invoke("webrtc.setSpeaker", { enabled });
786
+ }
787
+ // ── HTTP 流式桥(宿主 SSE 代理)───────────────────────────
788
+ // 宿主 OkHttp 逐行读取 + 宿主侧 UTF-8 解码,经推送事件(http_stream)逐行回传,
789
+ // 规避 JS 运行时缺 ReadableStream/TextDecoder(真逐字流式)。ungated,与 fetch 同列。
790
+ const EVENT_HTTP_STREAM = "http_stream";
791
+ /** 宿主桥是否可用(纯读全局,供探针判定 bridgeStreamOk;不触发监听注册) */
792
+ export function isBridgeAvailable() {
793
+ try {
794
+ return typeof NativeModules !== "undefined" && !!NativeModules.CkylMpBridge;
795
+ }
796
+ catch {
797
+ return false;
798
+ }
799
+ }
800
+ /**
801
+ * 发起宿主 SSE 流式请求。先订阅 http_stream 再 invoke(宿主快速 ack),避免丢首行;
802
+ * end/error 到达即自动退订。返回句柄供主动 cancel。
803
+ */
804
+ export async function streamHttp(req, handlers) {
805
+ const streamId = `hs_${Date.now()}_${++seq}`;
806
+ const off = on(EVENT_HTTP_STREAM, (raw) => {
807
+ const e = raw;
808
+ if (e.streamId !== streamId)
809
+ return;
810
+ if (e.type === "line") {
811
+ handlers.onLine(e.data ?? "");
812
+ }
813
+ else if (e.type === "end") {
814
+ off();
815
+ handlers.onEnd();
816
+ }
817
+ else if (e.type === "error") {
818
+ off();
819
+ handlers.onError(e.status ?? 0, e.message ?? "");
820
+ }
821
+ });
822
+ try {
823
+ await invoke("http.stream", {
824
+ streamId,
825
+ url: req.url,
826
+ method: req.method ?? "POST",
827
+ headers: req.headers ?? {},
828
+ body: req.body ?? "",
829
+ });
830
+ }
831
+ catch (e) {
832
+ off();
833
+ throw e;
834
+ }
835
+ return {
836
+ streamId,
837
+ cancel: () => {
838
+ off();
839
+ return invoke("http.stream.cancel", { streamId });
840
+ },
841
+ };
842
+ }