44096069a7
- 发送端流式(R-iOS-4):新增 FileSource 抽象(web/src/features/transfer/source.ts),iOS 经 HTTP Range 惰性拉取(原生 WKURLSchemeHandler 认 Range 头 seek 回 206),整文件不进 WebView 内存;web/桌面经 fileSource 包装字节不变。p2p/relay/transfer 改吃 FileSource - 后台续传:iOS 26 BGContinuedProcessingTask(BackgroundTaskManager + AppDelegate 注册 + BGTaskSchedulerPermittedIdentifiers),引擎传输进度驱动系统进度 UI - APNs:后端 internal/apns(ES256 .p8 JWT + HTTP/2,仿 internal/push)+ push_subscriptions.platform 列 + POST /api/push/apns/register + transfer/message 未投递分支分流;原生 PushRegistry 授权 + 设备令牌经引擎桥 registerPush 上报;aps-environment + remote-notification 后台模式 - Share Extension:CDropShare appex 抓文件 → App Group 收件箱 → cdrop://share 深链唤主程序选设备发送(只交接、不跑引擎) - 控制中心剪贴板两控件:CDropWidgets widgetkit appex(ControlWidget + AppIntent + 纯原生 ClipboardClient REST);用专用 broker 设备会话(主程序经引擎 provisionWidgetSession 代铸独立 device_id 会话存 App Group,控件自刷不与引擎抢 refresh 轮换) - 真机分发预备:committed 签名改 ad-hoc 使模拟器 entitlement 生效;真机手动签名 scaffold(Signing.xcconfig 仅 device SDK 套 Manual + gitignore 的 Local.xcconfig 填 Team/profile)+ just ios-device / ios-devices 全 CLI 装机;包名改 net.commilitia.Commilitia-Drop(含 .share/.widgets,BG task 前缀同改);REALDEVICE.md 重写为门户 + CLI 装机手册 - I18n.swift 移 Shared/ 供三 target 共用;i18n 加 ios.bg/share/control.* 键 - ultracode 多 agent 审查修复(0 HIGH,2 MED + 7 LOW):分享 send 后留收件箱致重发→move 私有暂存;WidgetSession 锁屏文件保护→UntilFirstUserAuthentication;outgoing/安全作用域登出释放;控件 device_id 登出清理;并发控件 refresh CAS-before-clear;APNs 读 reason + prune bad token + bust 过期 JWT;APNSEnv 大小写归一;Share-ext completeRequest 挪进 open 回调
890 lines
34 KiB
TypeScript
890 lines
34 KiB
TypeScript
import { apiFetch } from "../../net/api";
|
||
import { getICEServers } from "./iceServers";
|
||
import { useAppStore, type CandidateBreakdown, type IceStats, type TransferPhase } from "../../store";
|
||
import { deliverIncoming, openIncomingSink, type IncomingSink } from "./incomingSink";
|
||
import type { FileSource } from "./source";
|
||
|
||
// WebRTC client wired to the brief §2 invariants:
|
||
// - iceServers: pulled from lib/iceServers (Cloudflare Realtime TURN over
|
||
// TLS when CDROP_CF_TURN_* is configured; STUN-only fallback otherwise).
|
||
// Refreshed on login by RootLayout.
|
||
// - DataChannel "cdrop-file", ordered:true
|
||
// - chunk size 64 KiB, backpressure thresholds 16 MiB / 4 MiB
|
||
// - 接收端流式写 OPFS(lib/incomingSink),避免 iOS Safari 内存压力
|
||
// - control frames as JSON strings (head meta + tail done), payload bytes binary
|
||
// - signaling via POST /api/hub/signal, peer routed by deviceName
|
||
//
|
||
// trickle ICE: each candidate ships in its own signal frame the moment the
|
||
// browser surfaces it.
|
||
//
|
||
// Same-LAN Restricted Cone NAT 排查依据:iceCandidatePoolSize=4 预热候选;
|
||
// 收集本/远端 host/mdns/srflx/prflx/relay 计数 + 选中候选对类型推到 store,
|
||
// 让 UI 暴露"实际通过哪条路径连上"(host:host = 真直连,relay:* = 走了 TURN)。
|
||
const CHANNEL_NAME = "cdrop-file";
|
||
const CHUNK_SIZE = 64 * 1024;
|
||
const HIGH_WATERMARK = 16 * 1024 * 1024;
|
||
const LOW_WATERMARK = 4 * 1024 * 1024;
|
||
// 发送端「读写解耦」:一次读 4 MiB 进内存,再从内存切 CHUNK_SIZE 子片 dc.send。原先
|
||
// 每 64 KiB 都 await file.slice().arrayBuffer(),逐片异步文件读在 iOS Safari 上极慢(实测
|
||
// 直连 P2P 仅 ~250 KB/s,远低于中继 3 MB/s)。块读把异步文件读次数降 ~64 倍,SCTP 消息
|
||
// 大小与流控(HIGH/LOW watermark)完全不变,接收端无感。
|
||
const READ_BLOCK_SIZE = 4 * 1024 * 1024;
|
||
const ICE_CANDIDATE_POOL = 4;
|
||
|
||
export interface FileMeta
|
||
{
|
||
name: string;
|
||
size: number;
|
||
sha256?: string;
|
||
}
|
||
|
||
export type P2PState = "connecting" | "connected" | "completed" | "failed" | "closed";
|
||
|
||
export interface P2PProgressEvent
|
||
{
|
||
bytes: number;
|
||
total: number;
|
||
}
|
||
|
||
export interface P2PSession
|
||
{
|
||
sessionId: string;
|
||
peerName: string;
|
||
state: P2PState;
|
||
onState: (cb: (s: P2PState) => void) => () => void;
|
||
onProgress: (cb: (e: P2PProgressEvent) => void) => () => void;
|
||
cancel: () => void;
|
||
}
|
||
|
||
interface SignalPayload
|
||
{
|
||
type: "offer" | "answer" | "ice";
|
||
sdp?: RTCSessionDescriptionInit;
|
||
candidate?: RTCIceCandidateInit;
|
||
}
|
||
|
||
const EMPTY_BREAKDOWN: CandidateBreakdown =
|
||
{ host: 0, mdns: 0, srflx: 0, prflx: 0, relay: 0 };
|
||
|
||
class Session
|
||
{
|
||
public state: P2PState = "connecting";
|
||
public stateListeners = new Set<(s: P2PState) => void>();
|
||
public progressListeners = new Set<(p: P2PProgressEvent) => void>();
|
||
public pc: RTCPeerConnection;
|
||
public dc: RTCDataChannel | null = null;
|
||
public canceled = false;
|
||
|
||
private receivedMeta: FileMeta | null = null;
|
||
private receivedBytes = 0;
|
||
// 接收端字节槽:onmessage 是同步回调,但 sink.write 是 async;用一根
|
||
// promise 链保证 chunk 顺序写入,链尾的 then 处理 close。
|
||
private receiveQueue: Promise<IncomingSink | null> = Promise.resolve(null);
|
||
// Track total only for sender progress reporting; receiver learns it
|
||
// from the meta frame.
|
||
private outgoingTotal = 0;
|
||
private bytesSent = 0;
|
||
|
||
private phase: TransferPhase = "initializing";
|
||
private localCandidates: CandidateBreakdown = { ...EMPTY_BREAKDOWN };
|
||
private remoteCandidates: CandidateBreakdown = { ...EMPTY_BREAKDOWN };
|
||
private selectedPair: { local: string; remote: string } | undefined;
|
||
private firstByteSent = false;
|
||
private firstByteReceived = false;
|
||
// 节流:emitProgress 一秒可达数百次,每次 store.upsertTransfer 都触发
|
||
// 一次 React 重渲染(含 DebugPanel 的 IceStats)。在 384 次 / 24 MB 的尺度
|
||
// 上能把主线程拖到吃掉 dc.onmessage 的程度,使接收方 SCTP 缓冲填满 → rwnd=0
|
||
// → sender 的 bufferedAmount 永远卡在 HIGH。把 store push 限到 10 Hz。
|
||
private storePushScheduled: number | null = null;
|
||
private lastMessageAt = 0;
|
||
// 发送进度轮询:streamFile 期间每 250ms 刷一次,让"已交付字节"在 backpressure
|
||
// 等待期间也能平滑爬升、不误报 stalled(见 streamFile / senderDeliveredBytes)。
|
||
private sendProgressPollId: number | null = null;
|
||
|
||
constructor(public sessionId: string, public peerName: string, public role: "sender" | "receiver")
|
||
{
|
||
this.pc = new RTCPeerConnection({
|
||
iceServers: getICEServers(),
|
||
iceCandidatePoolSize: ICE_CANDIDATE_POOL,
|
||
});
|
||
|
||
this.pc.onicecandidate = (ev) =>
|
||
{
|
||
if (!ev.candidate)
|
||
{
|
||
// gathering 完成;推一次包含最终计数的快照
|
||
this.flushPushUpdate();
|
||
return;
|
||
}
|
||
this.classifyAndCount(ev.candidate, this.localCandidates);
|
||
this.flushPushUpdate();
|
||
void this.sendSignal({ type: "ice", candidate: ev.candidate.toJSON() });
|
||
};
|
||
|
||
this.pc.onicegatheringstatechange = () =>
|
||
{
|
||
if (this.pc.iceGatheringState === "gathering" && this.phase === "initializing")
|
||
{
|
||
this.setPhase("ice_gathering");
|
||
}
|
||
else
|
||
{
|
||
this.flushPushUpdate();
|
||
}
|
||
};
|
||
|
||
this.pc.oniceconnectionstatechange = () =>
|
||
{
|
||
if (this.canceled) { return; }
|
||
const s = this.pc.iceConnectionState;
|
||
if (s === "checking")
|
||
{
|
||
this.setPhase("ice_checking");
|
||
}
|
||
else if (s === "connected" || s === "completed")
|
||
{
|
||
this.setState("connected");
|
||
if (this.phase === "ice_checking" || this.phase === "ice_gathering"
|
||
|| this.phase === "initializing")
|
||
{
|
||
this.setPhase("ice_connected");
|
||
}
|
||
void this.refreshSelectedPair();
|
||
// ICE 报 connected 时 nominated pair 经常还没决定,一次 getStats
|
||
// 拿不到就要轮询,否则 sender 调试面板永远显示"尚未选定"。
|
||
this.startSelectedPairPolling();
|
||
}
|
||
else if (s === "failed")
|
||
{
|
||
this.setState("failed");
|
||
void this.markServerFail("ice_failed");
|
||
}
|
||
this.flushPushUpdate();
|
||
};
|
||
|
||
if (role === "receiver")
|
||
{
|
||
this.pc.ondatachannel = (ev) =>
|
||
{
|
||
this.bindDataChannel(ev.channel);
|
||
this.startReceiverWatchdog();
|
||
};
|
||
}
|
||
}
|
||
|
||
setState(s: P2PState): void
|
||
{
|
||
if (this.state === s) { return; }
|
||
this.state = s;
|
||
for (const cb of this.stateListeners) { cb(s); }
|
||
}
|
||
|
||
private setPhase(p: TransferPhase): void
|
||
{
|
||
if (this.phase === p) { return; }
|
||
this.phase = p;
|
||
// phase 变化是稀疏事件(< 10 次 / 整个传输),同步 push 不会拥塞渲染
|
||
this.flushPushUpdate();
|
||
}
|
||
|
||
/** 立即 push(绕过节流),并取消任何 pending 节流 push */
|
||
private flushPushUpdate(): void
|
||
{
|
||
if (this.storePushScheduled !== null)
|
||
{
|
||
window.clearTimeout(this.storePushScheduled);
|
||
this.storePushScheduled = null;
|
||
}
|
||
this.pushUpdate();
|
||
}
|
||
|
||
/** 节流到 ≤10 Hz;高频 emitProgress 走这条 */
|
||
private throttledPushUpdate(): void
|
||
{
|
||
if (this.storePushScheduled !== null) { return; }
|
||
this.storePushScheduled = window.setTimeout(() =>
|
||
{
|
||
this.storePushScheduled = null;
|
||
this.pushUpdate();
|
||
}, 100);
|
||
}
|
||
|
||
private pushUpdate(): void
|
||
{
|
||
const store = useAppStore.getState();
|
||
const cur = store.activeTransfers[this.sessionId];
|
||
if (!cur) { return; }
|
||
|
||
const isReceiver = this.role === "receiver";
|
||
const bytes = isReceiver ? this.receivedBytes : this.senderDeliveredBytes();
|
||
const lastProgressAt = bytes > (cur.bytesTransferred ?? 0)
|
||
? Date.now()
|
||
: cur.lastProgressAt;
|
||
|
||
const iceStats: IceStats = {
|
||
gathering: this.pc.iceGatheringState,
|
||
connection: this.pc.iceConnectionState,
|
||
candidates: {
|
||
local: { ...this.localCandidates },
|
||
remote: { ...this.remoteCandidates },
|
||
},
|
||
selectedPair: this.selectedPair,
|
||
dataChannel: this.dc
|
||
? { readyState: this.dc.readyState, bufferedAmount: this.dc.bufferedAmount }
|
||
: undefined,
|
||
};
|
||
|
||
store.upsertTransfer({
|
||
...cur,
|
||
phase: this.phase,
|
||
bytesTransferred: bytes,
|
||
lastProgressAt,
|
||
iceStats,
|
||
});
|
||
}
|
||
|
||
private emitProgress(): void
|
||
{
|
||
const total = this.role === "sender" ? this.outgoingTotal : (this.receivedMeta?.size ?? 0);
|
||
const bytes = this.role === "sender" ? this.senderDeliveredBytes() : this.receivedBytes;
|
||
for (const cb of this.progressListeners) { cb({ bytes, total }); }
|
||
this.throttledPushUpdate();
|
||
}
|
||
|
||
/**
|
||
* 发送端"已交付字节"=已离开本地 buffer 的量(bytesSent − bufferedAmount)。
|
||
* bytesSent 只是"已塞进 dc 的本地 16MiB buffer",会被 64KB 一片秒满到 16MiB 后
|
||
* 定格;真正送达网络的是离开 buffer 的部分。用它做进度,进度随接收端实际收程
|
||
* 平滑爬升,而非跳到 buffer 满就冻住,也避免 backpressure 等待期被误判 stalled。
|
||
* 单调非降:send 时 bytesSent 与 bufferedAmount 同增(delivered 持平),其余时刻
|
||
* buffer 抽干使 bufferedAmount 降(delivered 升)。
|
||
*/
|
||
private senderDeliveredBytes(): number
|
||
{
|
||
const buffered = this.dc?.bufferedAmount ?? 0;
|
||
return Math.max(0, this.bytesSent - buffered);
|
||
}
|
||
|
||
private startSendProgressPoll(): void
|
||
{
|
||
if (this.sendProgressPollId !== null) { return; }
|
||
this.sendProgressPollId = window.setInterval(() =>
|
||
{
|
||
if (this.canceled) { this.stopSendProgressPoll(); return; }
|
||
this.emitProgress();
|
||
}, 250);
|
||
}
|
||
|
||
private stopSendProgressPoll(): void
|
||
{
|
||
if (this.sendProgressPollId !== null)
|
||
{
|
||
window.clearInterval(this.sendProgressPollId);
|
||
this.sendProgressPollId = null;
|
||
}
|
||
}
|
||
|
||
private classifyAndCount(c: RTCIceCandidate | RTCIceCandidateInit, into: CandidateBreakdown): void
|
||
{
|
||
const sdp = (c as RTCIceCandidate).candidate ?? (c as RTCIceCandidateInit).candidate ?? "";
|
||
if (!sdp) { return; }
|
||
const typeMatch = sdp.match(/typ (host|srflx|prflx|relay)/);
|
||
if (!typeMatch) { return; }
|
||
const t = typeMatch[1] as "host" | "srflx" | "prflx" | "relay";
|
||
if (t === "host")
|
||
{
|
||
// candidate:<foundation> <component> <protocol> <priority> <ip> <port> typ host …
|
||
const addr = sdp.match(/^candidate:\S+ \d+ \S+ \d+ (\S+) /);
|
||
if (addr && addr[1].endsWith(".local")) { into.mdns += 1; return; }
|
||
into.host += 1;
|
||
return;
|
||
}
|
||
into[t] += 1;
|
||
}
|
||
|
||
bindDataChannel(dc: RTCDataChannel): void
|
||
{
|
||
this.dc = dc;
|
||
dc.binaryType = "arraybuffer";
|
||
dc.bufferedAmountLowThreshold = LOW_WATERMARK;
|
||
|
||
dc.onopen = () =>
|
||
{
|
||
// 接收端的 dc.onopen 与发送端被分别绑定(发送端在 startOutgoing 里覆盖
|
||
// 这个 handler 来 streamFile)。这里覆盖掉的概率仅在 receiver。
|
||
if (this.role === "receiver" && this.phase !== "transferring")
|
||
{
|
||
this.setPhase("dc_open");
|
||
}
|
||
};
|
||
dc.onmessage = (ev) => { this.handleMessage(ev.data); };
|
||
dc.onerror = () => { this.setState("failed"); };
|
||
dc.onclose = () =>
|
||
{
|
||
if (this.state !== "completed" && this.state !== "failed")
|
||
{
|
||
this.setState("closed");
|
||
}
|
||
};
|
||
}
|
||
|
||
private handleMessage(data: string | ArrayBuffer | Blob): void
|
||
{
|
||
this.lastMessageAt = Date.now();
|
||
if (typeof data === "string")
|
||
{
|
||
try
|
||
{
|
||
const msg = JSON.parse(data);
|
||
if (msg.type === "meta")
|
||
{
|
||
this.receivedMeta = { name: msg.name, size: msg.size, sha256: msg.sha256 };
|
||
// 提前打开 sink;后续 chunk 入队等 sink 就绪。带上文件名,桌面大文件
|
||
// spill 落盘后据此命名。
|
||
this.receiveQueue = openIncomingSink(this.sessionId, msg.name);
|
||
this.emitProgress();
|
||
}
|
||
else if (msg.type === "done")
|
||
{
|
||
this.setPhase("completing");
|
||
void this.finalizeIncoming();
|
||
}
|
||
}
|
||
catch
|
||
{
|
||
// ignore malformed control frame
|
||
}
|
||
return;
|
||
}
|
||
if (data instanceof ArrayBuffer)
|
||
{
|
||
if (!this.firstByteReceived) { this.firstByteReceived = true; this.setPhase("transferring"); }
|
||
this.receivedBytes += data.byteLength;
|
||
// 串到 promise 链尾;sink.write 完成才会处理下一个 chunk,与 SCTP
|
||
// rwnd 共同提供 backpressure。链上失败会沿着 finalize 抛出来。
|
||
this.receiveQueue = this.receiveQueue.then(async (sink) =>
|
||
{
|
||
if (sink) { await sink.write(data); }
|
||
return sink;
|
||
});
|
||
this.emitProgress();
|
||
}
|
||
}
|
||
|
||
/** 收方看门狗:超过 5s 没收到任何消息(onmessage 没 fire)即 console.warn,
|
||
* 方便诊断主线程被微任务堵住的情形。仅 receiver 角色起作用。 */
|
||
private startReceiverWatchdog(): void
|
||
{
|
||
if (this.role !== "receiver") { return; }
|
||
this.lastMessageAt = Date.now();
|
||
const id = window.setInterval(() =>
|
||
{
|
||
if (this.canceled || this.state === "completed" || this.state === "failed"
|
||
|| this.state === "closed")
|
||
{
|
||
window.clearInterval(id);
|
||
return;
|
||
}
|
||
if (!this.firstByteReceived) { return; }
|
||
const since = Date.now() - this.lastMessageAt;
|
||
if (since > 5_000)
|
||
{
|
||
// eslint-disable-next-line no-console
|
||
console.warn("p2p receiver: no onmessage for >5s", {
|
||
sinceMs: since,
|
||
bytesReceived: this.receivedBytes,
|
||
bufferedAmount: this.dc?.bufferedAmount,
|
||
readyState: this.dc?.readyState,
|
||
});
|
||
}
|
||
}, 5_000);
|
||
}
|
||
|
||
private async finalizeIncoming(): Promise<void>
|
||
{
|
||
if (!this.receivedMeta) { return; }
|
||
try
|
||
{
|
||
const sink = await this.receiveQueue;
|
||
if (!sink) { throw new Error("receive sink missing"); }
|
||
const result = await sink.close();
|
||
deliverIncoming(result, this.receivedMeta.name, this.sessionId);
|
||
this.setState("completed");
|
||
void this.markServerDone(this.receivedBytes);
|
||
}
|
||
catch (e)
|
||
{
|
||
// eslint-disable-next-line no-console
|
||
console.error("finalize incoming failed", e);
|
||
this.setState("failed");
|
||
void this.markServerFail((e as Error).message ?? "finalize_failed");
|
||
}
|
||
}
|
||
|
||
async sendSignal(payload: SignalPayload): Promise<void>
|
||
{
|
||
const r = await apiFetch("/api/hub/signal", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ to: this.peerName, payload }),
|
||
});
|
||
if (!r.ok && r.status !== 410)
|
||
{
|
||
// 410 means peer offline; signaling fails silently (caller will time out)
|
||
// eslint-disable-next-line no-console
|
||
console.warn("p2p signal HTTP", r.status, await r.text().catch(() => ""));
|
||
}
|
||
}
|
||
|
||
async handleSignal(payload: SignalPayload): Promise<void>
|
||
{
|
||
if (payload.type === "offer" && payload.sdp)
|
||
{
|
||
await this.pc.setRemoteDescription(payload.sdp);
|
||
const answer = await this.pc.createAnswer();
|
||
await this.pc.setLocalDescription(answer);
|
||
await this.sendSignal({ type: "answer", sdp: answer });
|
||
}
|
||
else if (payload.type === "answer" && payload.sdp)
|
||
{
|
||
await this.pc.setRemoteDescription(payload.sdp);
|
||
}
|
||
else if (payload.type === "ice" && payload.candidate)
|
||
{
|
||
this.classifyAndCount(payload.candidate, this.remoteCandidates);
|
||
this.pushUpdate();
|
||
try { await this.pc.addIceCandidate(payload.candidate); }
|
||
catch (e)
|
||
{
|
||
// eslint-disable-next-line no-console
|
||
console.warn("p2p addIceCandidate", e);
|
||
}
|
||
}
|
||
}
|
||
|
||
async startOutgoing(src: FileSource): Promise<void>
|
||
{
|
||
this.outgoingTotal = src.size;
|
||
const dc = this.pc.createDataChannel(CHANNEL_NAME, { ordered: true });
|
||
this.bindDataChannel(dc);
|
||
// bindDataChannel 给 receiver 绑了 onopen;sender 这里覆盖一下,等
|
||
// ICE 通道一就绪立刻开始 streamFile(receiver 不会走这条分支)。
|
||
dc.onopen = () =>
|
||
{
|
||
this.setPhase("dc_open");
|
||
void this.streamFile(src).catch((e) =>
|
||
{
|
||
// eslint-disable-next-line no-console
|
||
console.error("p2p send failed", e);
|
||
this.stopSendProgressPoll();
|
||
this.setState("failed");
|
||
});
|
||
};
|
||
|
||
const offer = await this.pc.createOffer();
|
||
await this.pc.setLocalDescription(offer);
|
||
await this.sendSignal({ type: "offer", sdp: offer });
|
||
}
|
||
|
||
private async streamFile(src: FileSource): Promise<void>
|
||
{
|
||
if (!this.dc) { throw new Error("no data channel"); }
|
||
const dc = this.dc;
|
||
|
||
// Tell the backend that the channel is live so /done is later legal:
|
||
// the state machine demands ACCEPTED → P2P_ACTIVE → DONE.
|
||
await this.markP2PActive();
|
||
|
||
// Header.
|
||
const meta: FileMeta = { name: src.name, size: src.size };
|
||
dc.send(JSON.stringify({ type: "meta", ...meta }));
|
||
|
||
// buffer 会被 64KB 一片秒满到 16MiB,之后发送循环长时间阻塞在 waitForBufferLow
|
||
// 等抽干,期间没有 send、也没有 emitProgress。这个轮询在等待期持续刷新进度,让
|
||
// "已交付字节"随接收端实际收程平滑爬升、lastProgressAt 保持新鲜,不误报"疑似卡住"。
|
||
this.startSendProgressPoll();
|
||
|
||
// 发送块大小固定 64 KiB:实测放大到 256 KiB 在 iOS Safari 上反而把吞吐打到
|
||
// <100 KB/s(SCTP 大消息分片/重组代价),故维持 CHUNK_SIZE。读写解耦(块读)保留。
|
||
const sendChunk = CHUNK_SIZE;
|
||
|
||
let offset = 0;
|
||
while (offset < src.size)
|
||
{
|
||
// 一次读一大块进内存(异步文件读从每 64 KiB 一次降到每 4 MiB 一次)。iOS 下
|
||
// src.slice 经 Range 向原生拉取该块,整文件不进 WebView 内存(R-iOS-4)。
|
||
const blockEnd = Math.min(offset + READ_BLOCK_SIZE, src.size);
|
||
const block = await src.slice(offset, blockEnd);
|
||
|
||
// 从内存块按 sendChunk 子片发送;流控(bufferedAmount/水位)逻辑不变。
|
||
let inBlock = 0;
|
||
while (inBlock < block.byteLength)
|
||
{
|
||
// 预测式检查:在 send 之前看"加了这一片之后会不会撞上 HIGH"。
|
||
// Chrome WebRTC 的 RTCDataChannel.send 在 bufferedAmount + size 超过
|
||
// kMaxQueuedSendDataBytes(16 MiB)时直接抛 OperationError;我们 HIGH
|
||
// 也设 16 MiB,原本的 `bufferedAmount > HIGH` 检查在 buffer = HIGH - 1
|
||
// 时不触发,下一片就会跨过硬上限挂掉。
|
||
if (dc.bufferedAmount + sendChunk > HIGH_WATERMARK)
|
||
{
|
||
await waitForBufferLow(dc, LOW_WATERMARK);
|
||
}
|
||
const end = Math.min(inBlock + sendChunk, block.byteLength);
|
||
// subarray 是零拷贝视图;dc.send 接受 ArrayBufferView,只发该视图字节范围。
|
||
const chunk = block.subarray(inBlock, end);
|
||
try
|
||
{
|
||
dc.send(chunk);
|
||
}
|
||
catch (e)
|
||
{
|
||
// 兜底:极端情况下 bufferedAmount 在 await 与 send 之间漂移,仍可能
|
||
// 撞到 OperationError。退一步等 buffer 抽干到 LOW 再重试一次;仍失
|
||
// 败说明通道本身有问题,让外层 .catch 走 markServerFail。
|
||
if (e instanceof DOMException && e.name === "OperationError")
|
||
{
|
||
// eslint-disable-next-line no-console
|
||
console.warn("p2p send queue full, backing off then retrying", {
|
||
bufferedAmount: dc.bufferedAmount,
|
||
readyState: dc.readyState,
|
||
bytesSent: this.bytesSent,
|
||
});
|
||
await waitForBufferLow(dc, LOW_WATERMARK);
|
||
dc.send(chunk);
|
||
}
|
||
else
|
||
{
|
||
throw e;
|
||
}
|
||
}
|
||
const sent = end - inBlock;
|
||
inBlock += sent;
|
||
offset += sent;
|
||
this.bytesSent = offset;
|
||
if (!this.firstByteSent) { this.firstByteSent = true; this.setPhase("transferring"); }
|
||
this.emitProgress();
|
||
}
|
||
}
|
||
|
||
dc.send(JSON.stringify({ type: "done" }));
|
||
this.setPhase("completing");
|
||
// 抽干整个 buffer(数据 + done 帧)再宣告完成:dc.send 只是入本地 buffer,真正
|
||
// 送达要等 SCTP 发出。早先送完入 buffer 即 setState("completed"),会在还有多达
|
||
// 16MiB 在途时就标记完成,与接收端"仍在下载"不一致。等 bufferedAmount 落到 0=
|
||
// 字节已全部交付网络,接收端随后 finalize 并 POST /done(那条权威 /done 仍由接收
|
||
// 端发,这里只在抽干后驱动发送端自身 UI 收尾)。
|
||
await waitForBufferLow(dc, 0);
|
||
this.stopSendProgressPoll();
|
||
this.bytesSent = src.size;
|
||
this.emitProgress();
|
||
this.setState("completed");
|
||
// /done is intentionally posted by the *receiver* only — they are the
|
||
// authoritative "got it" signal. Doubling it up here just produces
|
||
// 409 console noise once the receiver's call lands first.
|
||
}
|
||
|
||
private selectedPairPollId: number | null = null;
|
||
|
||
private async refreshSelectedPair(): Promise<boolean>
|
||
{
|
||
try
|
||
{
|
||
const stats = await this.pc.getStats();
|
||
const cands = new Map<string, RTCStats & { candidateType?: string; protocol?: string }>();
|
||
let pairLocalId: string | undefined;
|
||
let pairRemoteId: string | undefined;
|
||
|
||
stats.forEach((report) =>
|
||
{
|
||
const r = report as RTCStats & {
|
||
candidateType?: string; protocol?: string;
|
||
state?: string; nominated?: boolean;
|
||
localCandidateId?: string; remoteCandidateId?: string;
|
||
};
|
||
if (r.type === "local-candidate" || r.type === "remote-candidate")
|
||
{
|
||
cands.set(r.id, r);
|
||
}
|
||
if (r.type === "candidate-pair" && r.state === "succeeded" && r.nominated)
|
||
{
|
||
pairLocalId = r.localCandidateId;
|
||
pairRemoteId = r.remoteCandidateId;
|
||
}
|
||
});
|
||
if (pairLocalId && pairRemoteId)
|
||
{
|
||
this.selectedPair = {
|
||
local: formatCandidateLabel(cands.get(pairLocalId)),
|
||
remote: formatCandidateLabel(cands.get(pairRemoteId)),
|
||
};
|
||
this.flushPushUpdate();
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
catch
|
||
{
|
||
// 老浏览器或权限问题,吞掉,调试面板就显示不出 selectedPair
|
||
return true; // 不再重试
|
||
}
|
||
}
|
||
|
||
/** 一次 refreshSelectedPair 经常拿不到(连接刚 connected,nominated pair
|
||
* 还没确定)。每 2s 轮询一次直到拿到,或 30s 超时,或会话进入终态。 */
|
||
private startSelectedPairPolling(): void
|
||
{
|
||
if (this.selectedPairPollId !== null) { return; }
|
||
const start = Date.now();
|
||
this.selectedPairPollId = window.setInterval(async () =>
|
||
{
|
||
if (this.canceled
|
||
|| this.selectedPair
|
||
|| this.state === "completed"
|
||
|| this.state === "failed"
|
||
|| this.state === "closed"
|
||
|| Date.now() - start > 30_000)
|
||
{
|
||
if (this.selectedPairPollId !== null)
|
||
{
|
||
window.clearInterval(this.selectedPairPollId);
|
||
this.selectedPairPollId = null;
|
||
}
|
||
return;
|
||
}
|
||
await this.refreshSelectedPair();
|
||
}, 2_000);
|
||
}
|
||
|
||
private async markP2PActive(): Promise<void>
|
||
{
|
||
try
|
||
{
|
||
// 409 is fine — receiver may have already moved the state, or the
|
||
// session might be in a state ineligible for P2P_ACTIVE.
|
||
await apiFetch(`/api/transfer/${this.sessionId}/p2p`, { method: "POST" });
|
||
}
|
||
catch (e)
|
||
{
|
||
// eslint-disable-next-line no-console
|
||
console.warn("p2p mark p2p_active", e);
|
||
}
|
||
}
|
||
|
||
private async markServerDone(bytes: number): Promise<void>
|
||
{
|
||
// 重试到服务端真的接到为止:发完文件之后这条 POST 不到,整个状态机
|
||
// 会停在 P2P_ACTIVE,UI 永远不进入 history。但 409 表示状态已经不允许
|
||
// 转 DONE(多半是已 DONE / FAILED / CANCELLED 在终态),直接放弃。
|
||
for (let attempt = 0; attempt < 3; attempt += 1)
|
||
{
|
||
try
|
||
{
|
||
const r = await apiFetch(`/api/transfer/${this.sessionId}/done`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ bytes_transferred: bytes }),
|
||
});
|
||
if (r.ok) { return; }
|
||
if (r.status === 409) { return; }
|
||
const text = await r.text().catch(() => r.statusText);
|
||
// eslint-disable-next-line no-console
|
||
console.warn(`p2p mark done attempt ${attempt + 1} → ${r.status}: ${text}`);
|
||
}
|
||
catch (e)
|
||
{
|
||
// eslint-disable-next-line no-console
|
||
console.warn(`p2p mark done attempt ${attempt + 1} error`, e);
|
||
}
|
||
if (attempt < 2)
|
||
{
|
||
await new Promise((r) => setTimeout(r, 500 * (attempt + 1)));
|
||
}
|
||
}
|
||
}
|
||
|
||
async markServerFail(reason: string): Promise<void>
|
||
{
|
||
try
|
||
{
|
||
await apiFetch(`/api/transfer/${this.sessionId}/fail`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ reason }),
|
||
});
|
||
}
|
||
catch (e)
|
||
{
|
||
// eslint-disable-next-line no-console
|
||
console.warn("p2p mark fail", e);
|
||
}
|
||
}
|
||
|
||
cancel(): void
|
||
{
|
||
this.canceled = true;
|
||
if (this.storePushScheduled !== null)
|
||
{
|
||
window.clearTimeout(this.storePushScheduled);
|
||
this.storePushScheduled = null;
|
||
}
|
||
if (this.selectedPairPollId !== null)
|
||
{
|
||
window.clearInterval(this.selectedPairPollId);
|
||
this.selectedPairPollId = null;
|
||
}
|
||
this.stopSendProgressPoll();
|
||
this.dc?.close();
|
||
this.pc.close();
|
||
this.setState("closed");
|
||
// 把可能开着的 OPFS 临时文件删干净
|
||
this.receiveQueue = this.receiveQueue.then(async (sink) =>
|
||
{
|
||
try { await sink?.cancel(); } catch { /* swallow */ }
|
||
return null;
|
||
});
|
||
}
|
||
}
|
||
|
||
function formatCandidateLabel(c: undefined | (RTCStats & { candidateType?: string; protocol?: string })): string
|
||
{
|
||
if (!c) { return "?"; }
|
||
return `${c.candidateType ?? "?"}/${c.protocol ?? "?"}`;
|
||
}
|
||
|
||
// waitForBufferLow 三重保险等 dc.bufferedAmount 落到 threshold 以下:
|
||
// 1. bufferedamountlow 事件(标准路径)
|
||
// 2. addEventListener 之后再 re-check(防 attach-before-fire 竞态)
|
||
// 3. 100ms 轮询(兜底某些 Chromium 版本事件不触发的历史 bug)
|
||
// 30s 没下来则 console.warn 但不中断(同时由 polling 接力,最终一定会
|
||
// 等到);这条日志能定位"sender 静默卡住"是不是这条 await。
|
||
function waitForBufferLow(dc: RTCDataChannel, threshold: number): Promise<void>
|
||
{
|
||
if (dc.bufferedAmount <= threshold) { return Promise.resolve(); }
|
||
return new Promise<void>((resolve) =>
|
||
{
|
||
let resolved = false;
|
||
const start = Date.now();
|
||
const cleanup = () =>
|
||
{
|
||
window.clearInterval(pollId);
|
||
window.clearTimeout(watchdog);
|
||
dc.removeEventListener("bufferedamountlow", onLow);
|
||
};
|
||
const finish = () =>
|
||
{
|
||
if (resolved) { return; }
|
||
resolved = true;
|
||
cleanup();
|
||
resolve();
|
||
};
|
||
const onLow = () => { finish(); };
|
||
const pollId = window.setInterval(() =>
|
||
{
|
||
if (dc.bufferedAmount <= threshold) { finish(); }
|
||
}, 100);
|
||
const watchdog = window.setTimeout(() =>
|
||
{
|
||
// eslint-disable-next-line no-console
|
||
console.warn("p2p waitForBufferLow stuck", {
|
||
bufferedAmount: dc.bufferedAmount,
|
||
threshold,
|
||
readyState: dc.readyState,
|
||
elapsedMs: Date.now() - start,
|
||
});
|
||
}, 30_000);
|
||
dc.addEventListener("bufferedamountlow", onLow);
|
||
// re-check: addEventListener 之后 bufferedAmount 可能已经在 attach
|
||
// 前的最近 task 里跨过 threshold(事件已 fire 但我们没有监听),
|
||
// 此时 polling 会兜底但有 100ms 延迟;这里同步再看一眼短路掉。
|
||
if (dc.bufferedAmount <= threshold) { finish(); }
|
||
});
|
||
}
|
||
|
||
const p2pSessions = new Map<string, Session>();
|
||
|
||
export function p2pHandleSignal(from: string, payload: SignalPayload): void
|
||
{
|
||
const sess = p2pSessions.get(from);
|
||
if (!sess)
|
||
{
|
||
// eslint-disable-next-line no-console
|
||
console.warn("p2p: signal from unknown peer", from, payload?.type);
|
||
return;
|
||
}
|
||
void sess.handleSignal(payload);
|
||
}
|
||
|
||
export function p2pCleanup(peerName: string): void
|
||
{
|
||
const sess = p2pSessions.get(peerName);
|
||
if (sess) { sess.cancel(); }
|
||
p2pSessions.delete(peerName);
|
||
}
|
||
|
||
function buildPublicSession(sess: Session): P2PSession
|
||
{
|
||
return {
|
||
sessionId: sess.sessionId,
|
||
peerName: sess.peerName,
|
||
get state() { return sess.state; },
|
||
onState: (cb) =>
|
||
{
|
||
sess.stateListeners.add(cb);
|
||
return () => sess.stateListeners.delete(cb);
|
||
},
|
||
onProgress: (cb) =>
|
||
{
|
||
sess.progressListeners.add(cb);
|
||
return () => sess.progressListeners.delete(cb);
|
||
},
|
||
cancel: () => { p2pCleanup(sess.peerName); },
|
||
};
|
||
}
|
||
|
||
// p2pStartOutgoing returns *synchronously* — startOutgoing's createOffer /
|
||
// setLocalDescription / signal POST are all kicked off as a background promise.
|
||
// This is critical for the 30s ICE-fallback watchdog (transfer.ts) to actually
|
||
// arm: previously an `await p2pStartOutgoing` could hang forever if e.g.
|
||
// createOffer was monkey-patched to never resolve (M11 fallback test scenario).
|
||
export function p2pStartOutgoing(
|
||
sessionId: string,
|
||
receiverName: string,
|
||
src: FileSource,
|
||
): P2PSession
|
||
{
|
||
const sess = new Session(sessionId, receiverName, "sender");
|
||
p2pSessions.set(receiverName, sess);
|
||
sess.stateListeners.add((s) => updateStoreState(sessionId, s));
|
||
void sess.startOutgoing(src).catch((e) =>
|
||
{
|
||
// Cancellation (fallback path closes pc → pending createOffer rejects)
|
||
// is expected, not a transport failure — let the relay flow drive state.
|
||
if (sess.canceled) { return; }
|
||
// eslint-disable-next-line no-console
|
||
console.error("p2p start outgoing failed", e);
|
||
sess.setState("failed");
|
||
void sess.markServerFail((e as Error).message ?? "p2p_start_failed");
|
||
});
|
||
return buildPublicSession(sess);
|
||
}
|
||
|
||
export function p2pStartIncoming(sessionId: string, senderName: string): P2PSession
|
||
{
|
||
const sess = new Session(sessionId, senderName, "receiver");
|
||
p2pSessions.set(senderName, sess);
|
||
sess.stateListeners.add((s) => updateStoreState(sessionId, s));
|
||
return buildPublicSession(sess);
|
||
}
|
||
|
||
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:可能是 fallback 路径主动 cancel,让上层
|
||
// /fallback / /fail 流程决定终态。
|
||
}
|