原生数据面协议层:桌面 engine 进度节流 / pion 日志 + iOS 数据面路由

- 桌面 engine(pion 数据面,承 ab57afd 之后的精修):
  - 新增 logging.go——把 pion 内部日志路由到宿主 OnLog(真机无 stderr,连接失败无从查);仅 ice/mdns 作用域放 Debug、余 Info+、Trace 丢弃,按 session 聚合
  - wire.go / session.go / engine.go:进度回调 ~10Hz 节流(progressEmitThrottleMs,高吞吐下每片一回调打满宿主主线程;终态由 emitProgressNow 强发最终值),与 web store push 同量级;engine_test.go 跟进
- web 数据面路由:p2p.ts 翻 IOS_NATIVE=true(iOS 走原生 libwebrtc 引擎)+ 新增 p2pIos.ts(iOS p2p 后端)+ p2pNative.ts / net/ios.ts 跟进
- 数据面设计文档 NATIVE-TRANSFER.md:U1(gomobile+pion)真机证伪 → 翻案 U2(libwebrtc)的依据与实测证据(§7/§8)
- 线协议三端一致(cdrop-file ordered / meta+chunk(64KB)+done+ack / 16MB-4MB 水位 / ack 追平完成),桌面 pion ↔ iOS libwebrtc ↔ web JS 互通
This commit is contained in:
2026-06-28 19:32:37 +08:00
parent ab57afdec2
commit f1a00d128e
11 changed files with 587 additions and 22 deletions
+30 -1
View File
@@ -1,6 +1,7 @@
import { apiFetch } from "../../net/api";
import { getICEServers } from "./iceServers";
import { isDesktop } from "../../net/desktop";
import { isIOSShell } from "../../net/ios";
import { useAppStore, type CandidateBreakdown, type IceStats, type TransferPhase } from "../../store";
import { deliverIncoming, openIncomingSink, type IncomingSink } from "./incomingSink";
import {
@@ -10,6 +11,13 @@ import {
nativeStartIncoming,
nativeStartOutgoing,
} from "./p2pNative";
import {
nativeIosCleanup,
nativeIosHandleSignal,
nativeIosHasPeer,
nativeIosStartIncoming,
nativeIosStartOutgoing,
} from "./p2pIos";
import type { FileSource } from "./source";
// WebRTC client wired to the brief §2 invariants:
@@ -45,6 +53,13 @@ const ACK_INTERVAL_MS = 200;
// SSE 也会独立完成),避免 ack 丢失 / 接收端异常致发送端悬挂在"完成中"。
const ACK_COMPLETE_TIMEOUT_MS = 30_000;
// iOS 原生数据面总开关。Swift 侧引擎已从 gomobile/pion 换成 libwebrtcSafari 同款 RTCPeerConnection
// 与 iOS Network framework 原生集成)——逃离 WKWebView 写死的 256KB SCTP 接收窗 + 渲染器节流。JS 路由 /
// 桥协议 / 线协议一字不变(见 p2pIos.ts、net/ios.ts),仅原生后端实现替换(见 ios 端 LibWebRtcEngine.swift)。
// 早先 pion-on-iOS 因 Go raw socket 不与 iOS 网络栈集成而真机不可靠(连接时好时坏 / 中途断流),已废弃。
// 桌面原生数据面仍走 pionisDesktop 分支,P1 稳定),不受此开关影响。
const IOS_NATIVE = true;
export interface FileMeta
{
name: string;
@@ -897,8 +912,9 @@ const p2pSessions = new Map<string, Session>();
export function p2pHandleSignal(from: string, payload: SignalPayload): void
{
// 桌面:该对端的会话在原生后端(Go/pion)→ 把入站信令转给它。
// 桌面 / iOS:该对端的会话在原生后端(Go/pion)→ 把入站信令转给它。
if (nativeHasPeer(from)) { nativeHandleSignal(from, payload); return; }
if (IOS_NATIVE && nativeIosHasPeer(from)) { nativeIosHandleSignal(from, payload); return; }
const sess = p2pSessions.get(from);
if (!sess)
{
@@ -912,6 +928,7 @@ export function p2pHandleSignal(from: string, payload: SignalPayload): void
export function p2pCleanup(peerName: string): void
{
if (nativeHasPeer(peerName)) { nativeCleanup(peerName); return; }
if (IOS_NATIVE && nativeIosHasPeer(peerName)) { nativeIosCleanup(peerName); return; }
const sess = p2pSessions.get(peerName);
if (sess) { sess.cancel(); }
p2pSessions.delete(peerName);
@@ -954,6 +971,12 @@ export function p2pStartOutgoing(
{
return nativeStartOutgoing(sessionId, receiverName, src.path, src.size);
}
// iOS 无头壳:源带 cdrop-file://<id> 引用(path)即走 libwebrtc 原生数据面(Swift 侧解析回沙盒
// 绝对路径直接读盘)。IOS_NATIVE 关闭时回退下方 JS SessionWebKit WebRTC)。
if (IOS_NATIVE && isIOSShell() && src.path)
{
return nativeIosStartOutgoing(sessionId, receiverName, src.path, src.size);
}
const sess = new Session(sessionId, receiverName, "sender");
p2pSessions.set(receiverName, sess);
sess.stateListeners.add((s) => updateStoreState(sessionId, s));
@@ -977,6 +1000,12 @@ export function p2pStartIncoming(sessionId: string, senderName: string): P2PSess
{
return nativeStartIncoming(sessionId, senderName);
}
// iOS 无头壳:接收端走 libwebrtc 原生数据面(Swift 直接写盘,无 base64 桥 / 无 OPFS)。
// IOS_NATIVE 关闭时回退下方 JS SessionWebKit WebRTC,接收端流式 OPFS)。
if (IOS_NATIVE && isIOSShell())
{
return nativeIosStartIncoming(sessionId, senderName);
}
const sess = new Session(sessionId, senderName, "receiver");
p2pSessions.set(senderName, sess);
sess.stateListeners.add((s) => updateStoreState(sessionId, s));
+324
View File
@@ -0,0 +1,324 @@
// p2pIos —— iOS 无头壳的原生数据面后端。实现与 p2p.ts 同形的 P2PSession 接口与四个入口,
// 把 WebRTC 收发委派给 gomobile/pion 引擎(经 cdropEngine 桥),逃离 WKWebView 的 256KB rwnd
// 与渲染器节流(见 desktop/NATIVE-TRANSFER.md §7——iOS 与桌面共用同一份 Go engine 包)。
//
// 与桌面 p2pNative.ts 逐行对称,差别仅在桥:桌面经 Wailswindow.go + EventsOn),iOS 经
// net/ios.ts 的 callNativeRPC+ onNativeEvent(事件)。分工一致:Go 引擎只跑纯 WebRTC +
// 文件 I/O;一切 HTTP 在此发——出站信令 POST /api/hub/signal、状态机 POST/p2p 于 connected·
// 发送端、/done 于 completed·接收端、/fail 于 failed)。引擎经 p2p* 事件把进度 / 状态 / 出站
// 信令 / 落盘路径反向回来。
//
// p2p.ts 在 iOS 壳按对端把这些会话登记到本后端(见其四个入口的分支),故 transfer.ts /
// hub.ts 一字不改。
import { apiFetch } from "../../net/api";
import {
nativeP2PCancel,
nativeP2PHandleSignal,
nativeP2PStartIncoming,
nativeP2PStartOutgoing,
subscribeNativeP2P,
} from "../../net/ios";
import { useAppStore, type TransferPhase } from "../../store";
import { getICEServers } from "./iceServers";
import type { P2PProgressEvent, P2PSession, P2PState } from "./p2p";
interface NativeSess
{
sessionId: string;
peerName: string;
role: "sender" | "receiver";
state: P2PState;
bytes: number;
total: number;
stateListeners: Set<(s: P2PState) => void>;
progressListeners: Set<(e: P2PProgressEvent) => void>;
p2pPosted: boolean;
// 信令串行链:seed 为 StartOutgoing/Incoming 的桥调用(确保 Go 侧会话已建),其后每条入站
// 信令串到链尾、按到达顺序投给 Go——pion 在 SetRemoteDescription 前 AddICECandidate 会报错,
// 故 offer/answer 必须先于其后 trickle 的 ICE 候选。等价于桌面 p2pNative 的 chain。
chain: Promise<void>;
}
const sessionsById = new Map<string, NativeSess>();
const idByPeer = new Map<string, string>();
let subscribed = false;
// ensureSubscribed 懒注册全局 p2p* 事件监听(仅一次),据 sessionId 分发到各会话。
function ensureSubscribed(): void
{
if (subscribed) { return; }
subscribed = true;
subscribeNativeP2P({
onProgress: (sid, bytes) =>
{
const s = sessionsById.get(sid);
if (!s) { return; }
s.bytes = bytes;
const total = s.total || sessionFileSize(sid);
for (const cb of s.progressListeners) { cb({ bytes, total }); }
pushStoreBytes(sid, bytes);
},
onState: (sid, state) =>
{
const s = sessionsById.get(sid);
if (!s) { return; }
applyState(s, state as P2PState);
},
onSignal: (_sid, toPeer, payloadJSON) =>
{
// 各信令独立 POST、不串行——串行会让一条慢 POST 头阻塞其后所有 trickle 候选,在弱网
// 下致 ICE 无法在 30s 内连通 → 退中继(桌面实测「等待对方接受」回退的同款坑)。乱序到达
// 对端只触发对端已 swallow 的 addIceCandidate 告警,无害。
let payload: unknown;
try { payload = JSON.parse(payloadJSON); }
catch { return; }
void apiFetch("/api/hub/signal", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ to: toPeer, payload }),
}).then((r) =>
{
if (!r.ok && r.status !== 410)
{
// eslint-disable-next-line no-console
console.warn("ios native p2p signal HTTP", r.status);
}
}).catch(() => { /* 信令尽力而为;丢失由 ICE 超时兜底 */ });
},
onSaved: (_sid, _path) =>
{
// iOS 落盘成功由原生侧(DownloadManager + transferDone)告知用户,此处无需额外提示。
},
onIcePair: (sid, local, remote) =>
{
setIcePair(sid, local, remote);
},
});
}
// setIcePair 把 pion 选中的候选对填进记录的 iceStats,使调试面板能展示「实际走哪条路径」。
// 候选分类计数 pion 未细分,留空(同桌面 p2pNative)。
function setIcePair(sessionId: string, local: string, remote: string): void
{
const store = useAppStore.getState();
const cur = store.activeTransfers[sessionId];
if (!cur) { return; }
const empty = { host: 0, mdns: 0, srflx: 0, prflx: 0, relay: 0 };
store.upsertTransfer({
...cur,
iceStats: {
gathering: "complete",
connection: "connected",
candidates: { local: { ...empty }, remote: { ...empty } },
selectedPair: { local, remote },
},
});
}
function sessionFileSize(sessionId: string): number
{
return useAppStore.getState().activeTransfers[sessionId]?.fileSize ?? 0;
}
// applyState 把引擎状态落到监听者 + store,并发起对应的状态机 POSTHTTP 留 JS)。
function applyState(s: NativeSess, state: P2PState): void
{
if (s.state === state) { return; }
s.state = state;
for (const cb of s.stateListeners) { cb(state); }
updateStoreState(s.sessionId, state);
if (state === "connected")
{
// 推进 phase(否则 UI 状态停在初始 phase、且 bytes/速度文案被抑制)。
setPhase(s.sessionId, "ice_connected");
// 发送端在通道就绪时把传输标记为 P2P_ACTIVE,使随后接收端的 /done 合法。
if (s.role === "sender" && !s.p2pPosted)
{
s.p2pPosted = true;
markP2PActive(s.sessionId);
}
}
else if (state === "completed")
{
// /done 由接收端权威发出;发送端只落本地终态(updateStoreState 已置 DONE)。
if (s.role === "receiver") { markServerDone(s.sessionId, s.bytes); }
}
else if (state === "failed")
{
markServerFail(s.sessionId, "native_p2p_failed");
}
}
// updateStoreState 与 p2p.ts 同名函数等价(此处复制以免与 p2p.ts 形成运行时循环依赖)。
function updateStoreState(sessionId: string, p2pState: P2PState): void
{
const store = useAppStore.getState();
const cur = store.activeTransfers[sessionId];
if (!cur) { return; }
if (p2pState === "connected") { store.upsertTransfer({ ...cur, state: "P2P_ACTIVE" }); }
else if (p2pState === "completed") { store.completeTransfer(sessionId, "DONE"); }
else if (p2pState === "failed") { store.completeTransfer(sessionId, "FAILED"); }
// closed 不主动移 history:交由上层 / 中继流程决定终态(同 p2p.ts)。
}
function pushStoreBytes(sessionId: string, bytes: number): void
{
const store = useAppStore.getState();
const cur = store.activeTransfers[sessionId];
if (!cur) { return; }
const grew = bytes > (cur.bytesTransferred ?? 0);
store.upsertTransfer({
...cur,
// 字节流动即「传输中」:FLOW_PHASES 据此放开 bytes/速度文案,状态文案也随之正确。
phase: cur.phase === "completing" ? cur.phase : "transferring",
bytesTransferred: bytes,
lastProgressAt: grew ? Date.now() : cur.lastProgressAt,
});
}
// setPhase 更新记录的 phase(驱动 UI 状态文案 + bytes/速度文案的可见性);同值跳过。
function setPhase(sessionId: string, phase: TransferPhase): void
{
const store = useAppStore.getState();
const cur = store.activeTransfers[sessionId];
if (!cur || cur.phase === phase) { return; }
store.upsertTransfer({ ...cur, phase });
}
function markP2PActive(sessionId: string): void
{
void apiFetch(`/api/transfer/${sessionId}/p2p`, { method: "POST" }).catch(() => { /* 409 无碍 */ });
}
function markServerDone(sessionId: string, bytes: number): void
{
void apiFetch(`/api/transfer/${sessionId}/done`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ bytes_transferred: bytes }),
}).catch(() => { /* 接收端权威 /done;失败由 SSE 终态兜底 */ });
}
function markServerFail(sessionId: string, reason: string): void
{
void apiFetch(`/api/transfer/${sessionId}/fail`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ reason }),
}).catch(() => { /* best-effort */ });
}
function makeSession(s: NativeSess): P2PSession
{
return {
sessionId: s.sessionId,
peerName: s.peerName,
get state() { return s.state; },
onState: (cb) =>
{
s.stateListeners.add(cb);
return () => s.stateListeners.delete(cb);
},
onProgress: (cb) =>
{
s.progressListeners.add(cb);
return () => s.progressListeners.delete(cb);
},
cancel: () => { nativeIosCleanup(s.peerName); },
};
}
function register(s: NativeSess): void
{
sessionsById.set(s.sessionId, s);
idByPeer.set(s.peerName, s.sessionId);
}
// nativeIosStartOutgoing 经 Go 引擎发送 filePathcdrop-file://<id> 引用,原生解析回沙盒绝对
// 路径)到 peerName。total 用于进度事件的分母(store 的 bytesTransferred 仍是 UI 真值源)。
export function nativeIosStartOutgoing(
sessionId: string, peerName: string, filePath: string, total: number,
): P2PSession
{
ensureSubscribed();
const s: NativeSess = {
sessionId, peerName, role: "sender", state: "connecting", bytes: 0, total,
stateListeners: new Set(), progressListeners: new Set(), p2pPosted: false,
chain: Promise.resolve(),
};
register(s);
// 原生引擎即刻开始收集候选 + 连通性检查;显式推进 phase,避免 UI 停在 initiate 设的
// 「等待对端接受」(waiting_accept)误导——原生后端不像 JS Session 那样发中间 phase。
setPhase(sessionId, "ice_checking");
const ice = JSON.stringify(getICEServers());
// 用 StartOutgoing 的桥调用作信令链 seed:入站 answer / ICE 须等会话建好再投。
s.chain = nativeP2PStartOutgoing(sessionId, peerName, filePath, ice).catch((e) =>
{
// eslint-disable-next-line no-console
console.error("ios native start outgoing failed", e);
applyState(s, "failed");
});
return makeSession(s);
}
export function nativeIosStartIncoming(sessionId: string, senderName: string): P2PSession
{
ensureSubscribed();
const s: NativeSess = {
sessionId, peerName: senderName, role: "receiver", state: "connecting", bytes: 0, total: 0,
stateListeners: new Set(), progressListeners: new Set(), p2pPosted: false,
chain: Promise.resolve(),
};
register(s);
setPhase(sessionId, "ice_checking");
const ice = JSON.stringify(getICEServers());
// 用 StartIncoming 的桥调用作信令链 seed:入站 offer / ICE 须等会话建好再投。
s.chain = nativeP2PStartIncoming(sessionId, senderName, ice).catch((e) =>
{
// eslint-disable-next-line no-console
console.error("ios native start incoming failed", e);
applyState(s, "failed");
});
return makeSession(s);
}
export function nativeIosHandleSignal(fromPeer: string, payload: unknown): void
{
const payloadJSON = JSON.stringify(payload);
const sid = idByPeer.get(fromPeer);
const s = sid ? sessionsById.get(sid) : undefined;
if (!s)
{
// 无登记会话(极少见):直接尽力转发。
void nativeP2PHandleSignal(fromPeer, payloadJSON).catch(() => { /* Go 会报无会话 */ });
return;
}
// 串到会话信令链尾:等会话建好、且按到达顺序逐条投给 Go(offer/answer 先于 ICE 候选)。
s.chain = s.chain.then(() => nativeP2PHandleSignal(fromPeer, payloadJSON)).catch((e) =>
{
// eslint-disable-next-line no-console
console.warn("ios native handle signal failed", e);
});
}
export function nativeIosCleanup(peerName: string): void
{
const sid = idByPeer.get(peerName);
if (!sid) { return; }
idByPeer.delete(peerName);
const s = sessionsById.get(sid);
sessionsById.delete(sid);
void nativeP2PCancel(sid);
if (s && s.state !== "completed" && s.state !== "failed")
{
s.state = "closed";
for (const cb of s.stateListeners) { cb("closed"); }
}
}
// nativeIosHasPeer 供 p2p.ts 判断某对端的信令 / 清理该路由到 iOS 原生后端还是 JS 后端。
export function nativeIosHasPeer(peerName: string): boolean
{
return idByPeer.has(peerName);
}
+4
View File
@@ -248,6 +248,9 @@ export function nativeStartOutgoing(
chain: Promise.resolve(),
};
register(s);
// 原生引擎即刻收集候选 + 连通性检查;显式推进 phase,避免 UI 停在 initiate 设的
// 「等待对端接受」——原生后端不像 JS Session 那样发中间 phase。
setPhase(sessionId, "ice_checking");
const ice = JSON.stringify(getICEServers());
// 用 StartOutgoing 的桥调用作信令链 seed:入站 answer / ICE 须等会话建好再投。
s.chain = nativeP2PStartOutgoing(sessionId, peerName, filePath, ice).catch((e) =>
@@ -268,6 +271,7 @@ export function nativeStartIncoming(sessionId: string, senderName: string): P2PS
chain: Promise.resolve(),
};
register(s);
setPhase(sessionId, "ice_checking");
const ice = JSON.stringify(getICEServers());
// 用 StartIncoming 的桥调用作信令链 seed:入站 offer / ICE 须等会话建好再投。
s.chain = nativeP2PStartIncoming(sessionId, senderName, ice).catch((e) =>
+84
View File
@@ -214,6 +214,10 @@ export function bridgeFileSource(url: string, name: string, size: number, type:
name,
size,
type: type || "application/octet-stream",
// path 带 cdrop-file://<id> 引用:iOS 原生数据面(gomobile/pion)据此让原生侧解析回沙盒
// 绝对路径、由 Go 直接读盘发送(见 p2pIos.ts / EngineController.p2pStartOutgoing)。JS 收发
// 路径不读 path,故对回退到 JS 引擎的传输无影响(与桌面 nativeFileSource 同范式)。
path: url,
async slice(start, end)
{
const total = end - start;
@@ -233,3 +237,83 @@ export function bridgeFileSource(url: string, name: string, size: number, type:
},
};
}
// ── 原生 P2P 数据面桥(仅 iOS 无头壳) ────────────────────────────────────────
//
// 与桌面 desktop.ts 的 P2P 段同形,把 WebRTC 收发委派给 gomobile/pion 引擎(经 cdropEngine
// 桥),逃离 WKWebView 的 256KB rwnd 与渲染器节流(见 desktop/NATIVE-TRANSFER.md §7)。分工同桌面:
// Go 引擎只跑纯 WebRTC + 文件 I/O;一切 HTTP(出站信令 / 状态机 POST)留 JS(见 p2pIos.ts)。
// 四个入口走 callNativeRPC,原生回 resolve);进度 / 状态 / 出站信令 / 落盘 / 候选对经
// onNativeEvent 反向回来。原生侧契约见 ios/CDrop/Sources/Engine/EngineController.swift。
// nativeP2PStartOutgoing:发起原生发送。filePath 为 cdrop-file://<id> 引用,原生解析回沙盒
// 绝对路径后交 Go 直接读盘(不经 base64 桥 / 不整文件进内存)。
export async function nativeP2PStartOutgoing(
sessionId: string, peerName: string, filePath: string, iceServersJSON: string,
): Promise<void>
{
await callNative<void>("p2pStartOutgoing", { sessionId, peerName, filePath, iceServersJSON });
}
export async function nativeP2PStartIncoming(
sessionId: string, peerName: string, iceServersJSON: string,
): Promise<void>
{
await callNative<void>("p2pStartIncoming", { sessionId, peerName, iceServersJSON });
}
export async function nativeP2PHandleSignal(fromPeer: string, payloadJSON: string): Promise<void>
{
await callNative<void>("p2pHandleSignal", { fromPeer, payloadJSON });
}
export async function nativeP2PCancel(sessionId: string): Promise<void>
{
if (!handler()) { return; }
try { await callNative<void>("p2pCancel", { sessionId }); }
catch { /* 取消路径吞错 */ }
}
// 原生 P2P 事件回调集合(引擎经 __cdropEngineEvent 反向通知,与桌面 NativeP2PHandlers 同形)。
export interface NativeP2PHandlers
{
onProgress: (sessionId: string, bytes: number) => void;
onState: (sessionId: string, state: string) => void;
onSignal: (sessionId: string, toPeer: string, payloadJSON: string) => void;
onSaved: (sessionId: string, path: string) => void;
onIcePair: (sessionId: string, local: string, remote: string) => void;
}
// subscribeNativeP2P 订阅 p2p* 事件并分发;返回取消订阅函数。非 iOS 壳里 onNativeEvent 仍可
// 调用(只是原生永不派发),保持调用点无需分支。
export function subscribeNativeP2P(h: NativeP2PHandlers): () => void
{
const offs = [
onNativeEvent("p2pProgress", (pl) =>
{
const p = pl as { sessionId: string; bytes: number };
h.onProgress(p.sessionId, p.bytes);
}),
onNativeEvent("p2pState", (pl) =>
{
const p = pl as { sessionId: string; state: string };
h.onState(p.sessionId, p.state);
}),
onNativeEvent("p2pSignal", (pl) =>
{
const p = pl as { sessionId: string; to: string; payload: string };
h.onSignal(p.sessionId, p.to, p.payload);
}),
onNativeEvent("p2pSaved", (pl) =>
{
const p = pl as { sessionId: string; path: string };
h.onSaved(p.sessionId, p.path);
}),
onNativeEvent("p2pIcePair", (pl) =>
{
const p = pl as { sessionId: string; local: string; remote: string };
h.onIcePair(p.sessionId, p.local, p.remote);
}),
];
return () => { for (const off of offs) { off(); } };
}