桌面原生数据面:pion 数据通道取代 WebView WebRTC,逃离 256KB rwnd 与渲染器节流
把桌面 P2P 数据面从 WebView 内 JS WebRTC 移到 Go 进程的 pion,逃离 WebKit/WebView2 写死的 256KB SCTP 接收窗与隐藏窗口的渲染器节流。后端零改动、与现有客户端互通(线协议逐字节对齐)。真机实测原生↔原生 / 原生↔iOS(经有线)稳定 7-10MB/s,对比旧 JS 路径的 1.5MB/s 还塌缩是质变。设计与阶段见 desktop/NATIVE-TRANSFER.md。
- engine 包(纯 Go,仅 pion+stdlib、零 Wails 依赖,供将来 gomobile 复用于 iOS):pion 收发,逐字节对齐 web 引擎线协议(DataChannel cdrop-file ordered、文本帧 meta/done/ack、二进制分片、信令 {type,sdp?,candidate?});SetSCTPMaxReceiveBufferSize 顶到 4MB;mDNS QueryOnly 解析对端 .local 候选实现同内网 host↔host 直连;接收端直接写盘(去 base64 桥 / OPFS);进程内端到端测试 + 诊断日志(候选/ICE 态/选中对/发送停滞)。
- app.go:Bind P2PStartOutgoing / StartIncoming / HandleSignal / Cancel + 原生取文件 PickFileForSend + 中继回退读盘片 ReadFileSlice;引擎回调经 EventsEmit p2p:* 反向通知(进度/状态/信令/落盘路径/候选对)。
- JS p2pBackend 抽象(p2pNative.ts):桌面按对端把会话委派给 Go 引擎,transfer.ts / hub.ts 零改动;HTTP 全留 JS(出站信令 POST、状态机 /p2p·接收端 /done·/fail);监听 p2p:* 事件驱动 store、phase 与 iceStats。
- 发送取文件改原生路径:selectedFile 模型 File→FileSource(桌面带绝对 path → Go 直接读盘走原生;浏览器拖放无 path → 回退 JS)。Composer dropzone 在桌面打开原生文件对话框。
- 完成语义修复:接收端完成不立即拆连接(续发最终 ack、留 SSE 终态 Cancel 拆、60s 兜底、终态不可被后续 close 翻转)——修「iOS→mac 实际成功却被误标失败」。
- 剪贴板暂存路径修复:富文本经 Universal Clipboard 暂存为 .rtfd,pasteboard 纯文本表示有时取到该暂存路径——uploadClipboard 共享守卫拦截 + mac 原生读改从 RTF / RTFD 派生纯文本。
This commit is contained in:
@@ -81,8 +81,20 @@ export function utf8ByteLength(text: string): number
|
||||
|
||||
// uploadClipboard 把一段纯文本作为剪贴板写入推到云端(PUT /api/clipboard)。
|
||||
// 字节超限 / 空字符串 / 网络错误统一抛 Error,调用点显示给用户。
|
||||
// 富文本经 Universal Clipboard / Handoff 会被暂存为 Group Containers 下的 .rtfd 文件,原生侧
|
||||
// 读 pasteboard 字符串有时取到的是该暂存文件路径而非文本(实测:mac 复制富文本 → 同步出
|
||||
// 一条 .../shared-pasteboard/items/.../xxx.rtfd 路径)。这类暂存路径绝不能当剪贴板内容同步——
|
||||
// 在共享上传口拦掉,对三端(mac NSPasteboard / iOS UIPasteboard)一致生效。标记串极特异,
|
||||
// 几乎不可能出现在用户真实复制的文本里。
|
||||
function isPasteboardStagingPath(s: string): boolean
|
||||
{
|
||||
return s.includes("/shared-pasteboard/")
|
||||
|| s.includes("com.apple.coreservices.useractivityd");
|
||||
}
|
||||
|
||||
export async function uploadClipboard(content: string): Promise<void>
|
||||
{
|
||||
if (isPasteboardStagingPath(content)) { return; }
|
||||
if (utf8ByteLength(content) > CLIPBOARD_MAX_BYTES)
|
||||
{
|
||||
throw new Error(t("errors.clipboardOverflow", { max: CLIPBOARD_MAX_BYTES }));
|
||||
|
||||
@@ -4,6 +4,8 @@ import { Button, DynText, Panel, Tabs, TextArea } from "../../ui/primitives";
|
||||
import { toast } from "../../ui/feedback";
|
||||
import { t } from "../../i18n";
|
||||
import { formatBytes } from "../../utils/format";
|
||||
import { isDesktop, nativeFileSource, pickFileForSend } from "../../net/desktop";
|
||||
import { fileSource, type FileSource } from "./source";
|
||||
import s from "./Composer.module.css";
|
||||
|
||||
export type ComposerMode = "file" | "message";
|
||||
@@ -11,8 +13,8 @@ export type ComposerMode = "file" | "message";
|
||||
export interface ComposerProps
|
||||
{
|
||||
targetDevice: string | null;
|
||||
selectedFile: File | null;
|
||||
onFileChange: (f: File | null) => void;
|
||||
selectedFile: FileSource | null;
|
||||
onFileChange: (f: FileSource | null) => void;
|
||||
onSendFile: () => Promise<void> | void;
|
||||
onSendMessage: (text: string) => Promise<void> | void;
|
||||
/** 默认 'file'。 */
|
||||
@@ -148,8 +150,8 @@ export function Composer(props: ComposerProps)
|
||||
|
||||
interface FilePaneProps
|
||||
{
|
||||
selectedFile: File | null;
|
||||
onFileChange: (f: File | null) => void;
|
||||
selectedFile: FileSource | null;
|
||||
onFileChange: (f: FileSource | null) => void;
|
||||
fileInputRef: React.RefObject<HTMLInputElement>;
|
||||
}
|
||||
|
||||
@@ -170,8 +172,23 @@ function FilePane(props: FilePaneProps)
|
||||
{
|
||||
e.preventDefault();
|
||||
setDragOver(false);
|
||||
// 拖放给的是浏览器 File(桌面亦无绝对路径)→ 包成 FileSource。桌面此路无 path、
|
||||
// 回退 JS 数据面;走原生数据面需经下方 handlePick 的原生文件对话框取路径。
|
||||
const f = e.dataTransfer.files[0];
|
||||
if (f) { onFileChange(f); }
|
||||
if (f) { onFileChange(fileSource(f)); }
|
||||
};
|
||||
|
||||
// 取文件:桌面走原生文件对话框(Go 返回绝对路径 → nativeFileSource,发送走原生数据面,
|
||||
// 逃离 256KB rwnd 与渲染器节流);浏览器 / iOS 走隐藏的 <input type=file>。
|
||||
const handlePick = async () =>
|
||||
{
|
||||
if (isDesktop())
|
||||
{
|
||||
const picked = await pickFileForSend(t("home.file.dropzone"));
|
||||
if (picked) { onFileChange(nativeFileSource(picked, "")); }
|
||||
return;
|
||||
}
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const cls = [
|
||||
@@ -184,7 +201,7 @@ function FilePane(props: FilePaneProps)
|
||||
<>
|
||||
<div
|
||||
className={cls}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
onClick={() => void handlePick()}
|
||||
onDragOver={onDragOver}
|
||||
onDragLeave={() => setDragOver(false)}
|
||||
onDrop={onDrop}
|
||||
@@ -194,7 +211,7 @@ function FilePane(props: FilePaneProps)
|
||||
{
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
fileInputRef.current?.click();
|
||||
void handlePick();
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -227,7 +244,7 @@ function FilePane(props: FilePaneProps)
|
||||
onChange={(e) =>
|
||||
{
|
||||
const f = e.currentTarget.files?.[0];
|
||||
if (f) { onFileChange(f); }
|
||||
if (f) { onFileChange(fileSource(f)); }
|
||||
e.currentTarget.value = "";
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import { apiFetch } from "../../net/api";
|
||||
import { getICEServers } from "./iceServers";
|
||||
import { isDesktop } from "../../net/desktop";
|
||||
import { useAppStore, type CandidateBreakdown, type IceStats, type TransferPhase } from "../../store";
|
||||
import { deliverIncoming, openIncomingSink, type IncomingSink } from "./incomingSink";
|
||||
import {
|
||||
nativeCleanup,
|
||||
nativeHandleSignal,
|
||||
nativeHasPeer,
|
||||
nativeStartIncoming,
|
||||
nativeStartOutgoing,
|
||||
} from "./p2pNative";
|
||||
import type { FileSource } from "./source";
|
||||
|
||||
// WebRTC client wired to the brief §2 invariants:
|
||||
@@ -889,6 +897,8 @@ const p2pSessions = new Map<string, Session>();
|
||||
|
||||
export function p2pHandleSignal(from: string, payload: SignalPayload): void
|
||||
{
|
||||
// 桌面:该对端的会话在原生后端(Go/pion)→ 把入站信令转给它。
|
||||
if (nativeHasPeer(from)) { nativeHandleSignal(from, payload); return; }
|
||||
const sess = p2pSessions.get(from);
|
||||
if (!sess)
|
||||
{
|
||||
@@ -901,6 +911,7 @@ export function p2pHandleSignal(from: string, payload: SignalPayload): void
|
||||
|
||||
export function p2pCleanup(peerName: string): void
|
||||
{
|
||||
if (nativeHasPeer(peerName)) { nativeCleanup(peerName); return; }
|
||||
const sess = p2pSessions.get(peerName);
|
||||
if (sess) { sess.cancel(); }
|
||||
p2pSessions.delete(peerName);
|
||||
@@ -937,6 +948,12 @@ export function p2pStartOutgoing(
|
||||
src: FileSource,
|
||||
): P2PSession
|
||||
{
|
||||
// 桌面且源带绝对路径(经原生文件选择):走原生数据面(Go/pion 直接读盘发送),逃离
|
||||
// WebView 的 256KB rwnd 与渲染器节流。无 path 的源(浏览器 File 拖放)仍走 JS 引擎。
|
||||
if (isDesktop() && src.path)
|
||||
{
|
||||
return nativeStartOutgoing(sessionId, receiverName, src.path, src.size);
|
||||
}
|
||||
const sess = new Session(sessionId, receiverName, "sender");
|
||||
p2pSessions.set(receiverName, sess);
|
||||
sess.stateListeners.add((s) => updateStoreState(sessionId, s));
|
||||
@@ -955,6 +972,11 @@ export function p2pStartOutgoing(
|
||||
|
||||
export function p2pStartIncoming(sessionId: string, senderName: string): P2PSession
|
||||
{
|
||||
// 桌面:接收端一律走原生数据面(Go 直接写盘,无 base64 桥 / 无 OPFS),逃离 256KB rwnd。
|
||||
if (isDesktop())
|
||||
{
|
||||
return nativeStartIncoming(sessionId, senderName);
|
||||
}
|
||||
const sess = new Session(sessionId, senderName, "receiver");
|
||||
p2pSessions.set(senderName, sess);
|
||||
sess.stateListeners.add((s) => updateStoreState(sessionId, s));
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
// p2pNative —— 桌面原生数据面后端。实现与 p2p.ts 同形的 P2PSession 接口与四个入口,
|
||||
// 把 WebRTC 收发委派给 Go/pion 引擎(经 Wails 桥),逃离 WebView 的 256KB rwnd 与渲染器
|
||||
// 节流(见 desktop/NATIVE-TRANSFER.md)。
|
||||
//
|
||||
// 分工:Go 引擎只跑纯 WebRTC + 文件 I/O;一切 HTTP 在此发——出站信令 POST /api/hub/signal、
|
||||
// 状态机 POST(/p2p 于 connected·发送端、/done 于 completed·接收端、/fail 于 failed)。引擎
|
||||
// 经 p2p:* 事件把进度 / 状态 / 出站信令 / 落盘路径反向回来。
|
||||
//
|
||||
// p2p.ts 在桌面按对端把这些会话登记到原生后端(见其四个入口的分支),故 transfer.ts /
|
||||
// hub.ts 一字不改。
|
||||
import { apiFetch } from "../../net/api";
|
||||
import { t } from "../../i18n";
|
||||
import {
|
||||
nativeP2PCancel,
|
||||
nativeP2PHandleSignal,
|
||||
nativeP2PStartIncoming,
|
||||
nativeP2PStartOutgoing,
|
||||
subscribeNativeP2P,
|
||||
} from "../../net/desktop";
|
||||
import { useAppStore, type TransferPhase } from "../../store";
|
||||
import { toast } from "../../ui/feedback";
|
||||
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 候选。等价于 Go 端测试里的 sigQueue。
|
||||
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("native p2p signal HTTP", r.status);
|
||||
}
|
||||
}).catch(() => { /* 信令尽力而为;丢失由 ICE 超时兜底 */ });
|
||||
},
|
||||
onSaved: (_sid, path) =>
|
||||
{
|
||||
toast.ok(t("transfer.savedTo", { path }));
|
||||
},
|
||||
onIcePair: (sid, local, remote) =>
|
||||
{
|
||||
setIcePair(sid, local, remote);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// setIcePair 把 pion 选中的候选对填进记录的 iceStats,使调试面板能展示「实际走哪条路径」
|
||||
// (原生后端此前不填,致进入传输后调试面板候选信息空白)。候选分类计数 pion 未细分,留空。
|
||||
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,并发起对应的状态机 POST(HTTP 留 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: () => { nativeCleanup(s.peerName); },
|
||||
};
|
||||
}
|
||||
|
||||
function register(s: NativeSess): void
|
||||
{
|
||||
sessionsById.set(s.sessionId, s);
|
||||
idByPeer.set(s.peerName, s.sessionId);
|
||||
}
|
||||
|
||||
// nativeStartOutgoing 经 Go 引擎发送 filePath(绝对路径)到 peerName。total 用于进度事件
|
||||
// 的分母(store 的 bytesTransferred 仍是 UI 真值源)。
|
||||
export function nativeStartOutgoing(
|
||||
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);
|
||||
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("native start outgoing failed", e);
|
||||
applyState(s, "failed");
|
||||
});
|
||||
return makeSession(s);
|
||||
}
|
||||
|
||||
export function nativeStartIncoming(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);
|
||||
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("native start incoming failed", e);
|
||||
applyState(s, "failed");
|
||||
});
|
||||
return makeSession(s);
|
||||
}
|
||||
|
||||
export function nativeHandleSignal(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("native handle signal failed", e);
|
||||
});
|
||||
}
|
||||
|
||||
export function nativeCleanup(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"); }
|
||||
}
|
||||
}
|
||||
|
||||
// nativeHasPeer 供 p2p.ts 判断某对端的信令 / 清理该路由到原生后端还是 JS 后端。
|
||||
export function nativeHasPeer(peerName: string): boolean
|
||||
{
|
||||
return idByPeer.has(peerName);
|
||||
}
|
||||
@@ -8,6 +8,10 @@ export interface FileSource
|
||||
readonly name: string;
|
||||
readonly size: number;
|
||||
readonly type: string;
|
||||
// path 仅在桌面原生数据面存在:经原生文件选择获得的绝对路径,Go 引擎据此直接读盘做
|
||||
// P2P(不走 slice)。浏览器 / iOS 的 File / Range 来源无此字段。slice 仍保留,供中继
|
||||
// 回退读取(桌面经 Go 桥读盘片,见 net/desktop.nativeFileSource)。
|
||||
readonly path?: string;
|
||||
// 读取 [start, end) 字节区间(end 独占),返回该区间的副本。底层 buffer 固定为
|
||||
// ArrayBuffer(非 SharedArrayBuffer),以满足 RTCDataChannel.send / fetch body 的类型约束。
|
||||
slice(start: number, end: number): Promise<Uint8Array<ArrayBuffer>>;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Wails 桌面壳的桥接层:仅在桌面 WebView 中可用。浏览器里 isDesktop() 返回
|
||||
// false,所有调用点都回退到既有的 Web 行为,因此本模块对纯 Web 构建零影响
|
||||
// ——同一份代码同时部署到浏览器与桌面。
|
||||
import type { FileSource } from "../features/transfer/source";
|
||||
import { useAppStore, type User } from "../store";
|
||||
|
||||
// 桌面会话视图:不含 refresh_token——长寿命密钥只在 Go 进程,绝不进 JS(凭据策略
|
||||
@@ -52,6 +53,25 @@ interface DesktopBridge
|
||||
// 弹原生系统通知(macOS UNUserNotificationCenter / Windows toast)。标题、正文
|
||||
// 已由前端本地化。
|
||||
ShowNotification: (title: string, body: string) => Promise<void>;
|
||||
// 原生 P2P 数据面(pion in Go):发起 / 预备接收 / 喂入站信令 / 取消。进度 / 状态 /
|
||||
// 出站信令 / 落盘路径经 p2p:* 事件反向回来(见 subscribeNativeP2P)。HTTP 全留 JS。
|
||||
// 见 desktop/NATIVE-TRANSFER.md。
|
||||
P2PStartOutgoing: (sessionId: string, peerName: string, filePath: string, iceServersJSON: string) => Promise<void>;
|
||||
P2PStartIncoming: (sessionId: string, peerName: string, iceServersJSON: string) => Promise<void>;
|
||||
P2PHandleSignal: (fromPeer: string, payloadJSON: string) => Promise<void>;
|
||||
P2PCancel: (sessionId: string) => Promise<void>;
|
||||
// 原生文件选择(发送端走原生数据面:Go 按返回的绝对路径直接读盘)。用户取消返回 null。
|
||||
PickFileForSend: (title: string) => Promise<PickedFile | null>;
|
||||
// 中继回退时 WebView 读原生文件片(base64);P2P 主路径不经此桥。
|
||||
ReadFileSlice: (path: string, start: number, end: number) => Promise<string>;
|
||||
}
|
||||
|
||||
// 原生文件选择结果,字段与 Go PickedFile 的 json tag 一致。
|
||||
export interface PickedFile
|
||||
{
|
||||
path: string;
|
||||
name: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
// 桌面剪贴板自动同步开关,默认开;由设置页通过 setClipboardSyncEnabled 调整。
|
||||
@@ -343,3 +363,121 @@ export async function effectiveDownloadDir(): Promise<string>
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
// base64ToBytes 是 bytesToBase64 的逆:把 base64 串解回字节(ArrayBuffer 背衬,满足
|
||||
// RTCDataChannel.send / fetch body 的类型约束)。中继回退读原生文件片时用。
|
||||
function base64ToBytes(b64: string): Uint8Array<ArrayBuffer>
|
||||
{
|
||||
const bin = atob(b64);
|
||||
const out = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i += 1) { out[i] = bin.charCodeAt(i); }
|
||||
return out;
|
||||
}
|
||||
|
||||
// --- 原生 P2P 数据面桥(仅桌面) ---
|
||||
|
||||
// pickFileForSend 打开原生文件对话框,返回选中文件(绝对路径 + 名 + 大小);取消 / 非桌面
|
||||
// 返回 null。供发送端走原生数据面(Go 按路径直接读盘)。
|
||||
export async function pickFileForSend(title: string): Promise<PickedFile | null>
|
||||
{
|
||||
const app = bridge();
|
||||
if (!app) { return null; }
|
||||
try { return (await app.PickFileForSend(title)) ?? null; }
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
// nativeFileSource 把原生选中的文件包成 FileSource:path 让 Go 引擎直接读盘做 P2P;slice
|
||||
// 仅在中继回退时用(经 Go ReadFileSlice 读盘片、base64 过桥——罕见路径,不影响 P2P 主路径)。
|
||||
export function nativeFileSource(picked: PickedFile, type: string): FileSource
|
||||
{
|
||||
return {
|
||||
name: picked.name,
|
||||
size: picked.size,
|
||||
type: type || "application/octet-stream",
|
||||
path: picked.path,
|
||||
async slice(start, end)
|
||||
{
|
||||
const app = bridge();
|
||||
if (!app) { throw new Error("desktop bridge unavailable"); }
|
||||
return base64ToBytes(await app.ReadFileSlice(picked.path, start, end));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function nativeP2PStartOutgoing(
|
||||
sessionId: string, peerName: string, filePath: string, iceServersJSON: string,
|
||||
): Promise<void>
|
||||
{
|
||||
const app = bridge();
|
||||
if (!app) { throw new Error("desktop bridge unavailable"); }
|
||||
await app.P2PStartOutgoing(sessionId, peerName, filePath, iceServersJSON);
|
||||
}
|
||||
|
||||
export async function nativeP2PStartIncoming(
|
||||
sessionId: string, peerName: string, iceServersJSON: string,
|
||||
): Promise<void>
|
||||
{
|
||||
const app = bridge();
|
||||
if (!app) { throw new Error("desktop bridge unavailable"); }
|
||||
await app.P2PStartIncoming(sessionId, peerName, iceServersJSON);
|
||||
}
|
||||
|
||||
export async function nativeP2PHandleSignal(fromPeer: string, payloadJSON: string): Promise<void>
|
||||
{
|
||||
const app = bridge();
|
||||
if (!app) { throw new Error("desktop bridge unavailable"); }
|
||||
await app.P2PHandleSignal(fromPeer, payloadJSON);
|
||||
}
|
||||
|
||||
export async function nativeP2PCancel(sessionId: string): Promise<void>
|
||||
{
|
||||
const app = bridge();
|
||||
if (!app) { return; }
|
||||
try { await app.P2PCancel(sessionId); }
|
||||
catch { /* 取消路径吞错 */ }
|
||||
}
|
||||
|
||||
// 原生 P2P 事件回调集合(引擎经 Wails 事件反向通知)。
|
||||
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:* 事件并分发;返回取消订阅函数。非桌面为 no-op。
|
||||
export function subscribeNativeP2P(h: NativeP2PHandlers): () => void
|
||||
{
|
||||
const rt = runtime();
|
||||
if (!rt) { return () => { /* no-op */ }; }
|
||||
const offs = [
|
||||
rt.EventsOn("p2p:progress", (...d) =>
|
||||
{
|
||||
const p = d[0] as { sessionId: string; bytes: number };
|
||||
h.onProgress(p.sessionId, p.bytes);
|
||||
}),
|
||||
rt.EventsOn("p2p:state", (...d) =>
|
||||
{
|
||||
const p = d[0] as { sessionId: string; state: string };
|
||||
h.onState(p.sessionId, p.state);
|
||||
}),
|
||||
rt.EventsOn("p2p:signal", (...d) =>
|
||||
{
|
||||
const p = d[0] as { sessionId: string; to: string; payload: string };
|
||||
h.onSignal(p.sessionId, p.to, p.payload);
|
||||
}),
|
||||
rt.EventsOn("p2p:saved", (...d) =>
|
||||
{
|
||||
const p = d[0] as { sessionId: string; path: string };
|
||||
h.onSaved(p.sessionId, p.path);
|
||||
}),
|
||||
rt.EventsOn("p2p:icepair", (...d) =>
|
||||
{
|
||||
const p = d[0] as { sessionId: string; local: string; remote: string };
|
||||
h.onIcePair(p.sessionId, p.local, p.remote);
|
||||
}),
|
||||
];
|
||||
return () => { for (const off of offs) { off(); } };
|
||||
}
|
||||
|
||||
@@ -61,7 +61,9 @@ function HomePage()
|
||||
if (!selectedDevice || !selectedFile) { return; }
|
||||
try
|
||||
{
|
||||
await startOutgoingTransfer(selectedDevice, fileSource(selectedFile));
|
||||
// selectedFile 已是 FileSource(桌面原生选择带 path → 走原生数据面;浏览器
|
||||
// File 包装无 path → 走 JS 引擎)。见 Composer 的取文件分支。
|
||||
await startOutgoingTransfer(selectedDevice, selectedFile);
|
||||
setSelectedFile(null);
|
||||
}
|
||||
catch (e)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { FileSource } from "../features/transfer/source";
|
||||
import type { Locale } from "../i18n";
|
||||
|
||||
// Auth shape — see FRONTEND_DESIGN.md §6.
|
||||
@@ -174,9 +175,11 @@ export interface AppState
|
||||
|
||||
// ---- ui slice ----
|
||||
selectedDevice: string | null;
|
||||
selectedFile: File | null;
|
||||
// 选中待发文件统一为 FileSource:浏览器 / iOS 是 File 包装,桌面原生选择带绝对 path
|
||||
// (Go 引擎据此直接读盘做原生 P2P)。发送端据有无 path 选原生 / JS 数据面。
|
||||
selectedFile: FileSource | null;
|
||||
setSelectedDevice: (n: string | null) => void;
|
||||
setSelectedFile: (f: File | null) => void;
|
||||
setSelectedFile: (f: FileSource | null) => void;
|
||||
|
||||
// ---- theme slice ----
|
||||
// 'system' 时移除 data-theme 让 @media (prefers-color-scheme) 接管;
|
||||
|
||||
Reference in New Issue
Block a user