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 = "";
}}
/>
diff --git a/web/src/features/transfer/p2p.ts b/web/src/features/transfer/p2p.ts
index 4e8b066..4980b28 100644
--- a/web/src/features/transfer/p2p.ts
+++ b/web/src/features/transfer/p2p.ts
@@ -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
();
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));
diff --git a/web/src/features/transfer/p2pNative.ts b/web/src/features/transfer/p2pNative.ts
new file mode 100644
index 0000000..006bff6
--- /dev/null
+++ b/web/src/features/transfer/p2pNative.ts
@@ -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;
+}
+
+const sessionsById = new Map();
+const idByPeer = new Map();
+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);
+}
diff --git a/web/src/features/transfer/source.ts b/web/src/features/transfer/source.ts
index b8bf091..0471c71 100644
--- a/web/src/features/transfer/source.ts
+++ b/web/src/features/transfer/source.ts
@@ -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>;
diff --git a/web/src/net/desktop.ts b/web/src/net/desktop.ts
index 7e4aa6f..b51e31b 100644
--- a/web/src/net/desktop.ts
+++ b/web/src/net/desktop.ts
@@ -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;
+ // 原生 P2P 数据面(pion in Go):发起 / 预备接收 / 喂入站信令 / 取消。进度 / 状态 /
+ // 出站信令 / 落盘路径经 p2p:* 事件反向回来(见 subscribeNativeP2P)。HTTP 全留 JS。
+ // 见 desktop/NATIVE-TRANSFER.md。
+ P2PStartOutgoing: (sessionId: string, peerName: string, filePath: string, iceServersJSON: string) => Promise;
+ P2PStartIncoming: (sessionId: string, peerName: string, iceServersJSON: string) => Promise;
+ P2PHandleSignal: (fromPeer: string, payloadJSON: string) => Promise;
+ P2PCancel: (sessionId: string) => Promise;
+ // 原生文件选择(发送端走原生数据面:Go 按返回的绝对路径直接读盘)。用户取消返回 null。
+ PickFileForSend: (title: string) => Promise;
+ // 中继回退时 WebView 读原生文件片(base64);P2P 主路径不经此桥。
+ ReadFileSlice: (path: string, start: number, end: number) => Promise;
+}
+
+// 原生文件选择结果,字段与 Go PickedFile 的 json tag 一致。
+export interface PickedFile
+{
+ path: string;
+ name: string;
+ size: number;
}
// 桌面剪贴板自动同步开关,默认开;由设置页通过 setClipboardSyncEnabled 调整。
@@ -343,3 +363,121 @@ export async function effectiveDownloadDir(): Promise
return "";
}
}
+
+// base64ToBytes 是 bytesToBase64 的逆:把 base64 串解回字节(ArrayBuffer 背衬,满足
+// RTCDataChannel.send / fetch body 的类型约束)。中继回退读原生文件片时用。
+function base64ToBytes(b64: string): Uint8Array
+{
+ 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
+{
+ 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
+{
+ 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
+{
+ 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
+{
+ const app = bridge();
+ if (!app) { throw new Error("desktop bridge unavailable"); }
+ await app.P2PHandleSignal(fromPeer, payloadJSON);
+}
+
+export async function nativeP2PCancel(sessionId: string): Promise
+{
+ 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(); } };
+}
diff --git a/web/src/routes/index.tsx b/web/src/routes/index.tsx
index 6641bc3..a23bd13 100644
--- a/web/src/routes/index.tsx
+++ b/web/src/routes/index.tsx
@@ -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)
diff --git a/web/src/store/types.ts b/web/src/store/types.ts
index 6b0400f..0f2ce9d 100644
--- a/web/src/store/types.ts
+++ b/web/src/store/types.ts
@@ -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) 接管;