传输等待前台:后台 / 墓碑 iOS 可作可唤醒传输目标 + 发送端等其回前台再传

- 根因:presence 的 online 仅表示有活 SSE,故后台 / 墓碑态 iOS 被判离线、不进传输目标,使前面的后台响应(推送唤醒 + 离线队列)实际无从触发。诉求模型:离线但可唤醒的设备可选作目标;发送端推送唤醒对端、等其回前台上线后再走实时传输(非服务端暂存转发)。
- 后端:transfer.Init 返回 receiverOnline(=SendTo delivered);handleTransferInit 回 receiver_online。新增 ListPendingTransfersByReceiver 查询 + Service.RedeliverPendingTo——接收端重连即补发其仍 PENDING 的 transfer:incoming(窗口内),使被唤醒的 iOS 重新 arm P2P answerer(否则发送端 offer 因 p2pHandleSignal 不识未知 peer 而被丢);sse.go handleEvents 在 hub.Connect 后调用它。
- 发送端 web/transfer.ts:startOutgoingTransfer 按 receiver_online 分流——在线即 beginP2P(原 P2P + fallback 逻辑抽出);离线则 waitForPeerThenBegin:标 phase=waiting_peer、订阅 presence、对端上线即延 1.2s(待其 arm)再 beginP2P;60s 窗口内不上线则 cancel + FAILED phase=peer_offline。cleanupOutgoing 清理 waitTimer / waitUnsub。TransferPhase 加 waiting_peer / peer_offline + 三语 i18n。
- 目标纳入 + 标记:离线但可唤醒(type 含 ios)的设备可选作传输目标、标「可唤醒」。web DeviceStrip 加 isWakeable(纳入 visible、放开 disabled、标签);iOS EngineController.sendableDevices 纳入 + isWakeable + sendLabel,三处发送选择器(传输 / 分享 / 收到的文件转发)改用 sendLabel。i18n 加 home.device.wakeable。
- 机制说明(留档):content-available 静默唤醒不会让挂起的引擎重连 SSE;真正上线=用户点推送回前台 → 引擎恢复 → SSE 重连 → 后端补发 incoming → arm → 发送端 offer。若 offer 与 arm 抢拍掉了,30s 后 relay 兜底(此时对端已在线)。可唤醒判定用类型启发式(离线 + ios),未加后端 push-sub 查询。
- 测试:service_test 适配 Init 的三返回值。
This commit is contained in:
2026-06-27 18:16:23 +08:00
parent a2cad11224
commit 2caf1ab921
17 changed files with 275 additions and 33 deletions
+16 -3
View File
@@ -30,7 +30,7 @@ export function DeviceStrip(props: DeviceStripProps)
[ devices, selfDeviceName ],
);
const visible = useMemo(
() => (showOffline ? peers : peers.filter((d) => d.online)),
() => (showOffline ? peers : peers.filter((d) => d.online || isWakeable(d))),
[ peers, showOffline ],
);
const onlineCount = peers.filter((d) => d.online).length;
@@ -103,7 +103,9 @@ function DevicePort(props: DevicePortProps)
{
const { device, selected, onClick, onDropFile } = props;
const [ dragOver, setDragOver ] = useState(false);
const disabled = !device.online;
// 离线但可唤醒(iOS)的设备仍可选作目标:发送时后端推送唤醒、发送端等其上线再传。
const wakeable = isWakeable(device);
const disabled = !device.online && !wakeable;
const handleDragOver = (e: DragEvent<HTMLButtonElement>) =>
{
@@ -154,7 +156,9 @@ function DevicePort(props: DevicePortProps)
<DynText>
{device.online
? t("settings.online")
: t("settings.lastSeen", { time: formatRelative(device.lastSeen) })}
: wakeable
? t("home.device.wakeable")
: t("settings.lastSeen", { time: formatRelative(device.lastSeen) })}
</DynText>
</span>
</div>
@@ -164,6 +168,15 @@ function DevicePort(props: DevicePortProps)
);
}
// isWakeable:离线但可经推送唤醒的设备(目前=iOS App,有 APNs)。可作为传输目标——发送时后端推送
// 唤醒它,发送端等其回前台 / 上线后再发起实时传输(见 transfer.ts waitForPeerThenBegin)。
function isWakeable(d: DeviceInfo): boolean
{
if (d.online) { return false; }
const k = d.type.toLowerCase();
return k.includes("ios") || k.includes("iphone") || k.includes("ipad");
}
// 后端目前所有 web 接入都写 type="browser";客户端时代会扩展为 macos/windows/...
function mapKind(type: string): DeviceKind
{
+77 -4
View File
@@ -16,6 +16,11 @@ interface OutgoingState
src: FileSource;
p2p: P2PSession | null;
fallbackTimer: number | null;
// waitTimer / waitUnsub back the "wait for an offline receiver to wake" path: the timeout that
// fails the transfer if the peer never comes online, and the presence subscription that fires
// P2P once it does.
waitTimer: number | null;
waitUnsub: (() => void) | null;
abort: AbortController;
}
@@ -43,7 +48,11 @@ export async function startOutgoingTransfer(
const text = await initR.text().catch(() => initR.statusText);
throw new Error(`initiate ${initR.status}: ${text}`);
}
const { session_id: sessionId } = (await initR.json()) as { session_id: string };
const data = (await initR.json()) as { session_id: string; receiver_online?: boolean };
const sessionId = data.session_id;
// 接收端在 initiate 时无活 SSE(离线 / 后台 / 墓碑):后端已发唤醒推送。发送端不立即发 P2P offer
// (对端尚未 arm answerer,offer 会被丢),而是等对端被唤醒上线后再发起;窗口内不上线则失败。
const receiverOffline = data.receiver_online === false;
useAppStore.getState().upsertTransfer({
sessionId,
@@ -53,7 +62,7 @@ export async function startOutgoingTransfer(
state: "PENDING",
peerName: receiverName,
startedAt: Date.now(),
phase: "waiting_accept",
phase: receiverOffline ? "waiting_peer" : "waiting_accept",
bytesTransferred: 0,
lastProgressAt: Date.now(),
});
@@ -65,10 +74,27 @@ export async function startOutgoingTransfer(
src,
p2p: null,
fallbackTimer: null,
waitTimer: null,
waitUnsub: null,
abort,
};
outgoing.set(sessionId, state);
if (receiverOffline) { waitForPeerThenBegin(state); }
else { beginP2P(state); }
return sessionId;
}
// beginP2P arms the relay-fallback watchdog and starts the P2P offer. Extracted from
// startOutgoingTransfer so the offline-receiver path can defer it until the peer wakes.
function beginP2P(state: OutgoingState): void
{
const { sessionId, peerName, src } = state;
const store0 = useAppStore.getState();
const r0 = store0.activeTransfers[sessionId];
if (r0 && r0.phase === "waiting_peer") { store0.upsertTransfer({ ...r0, phase: "waiting_accept" }); }
// Arm the fallback watchdog *before* any P2P work. p2pStartOutgoing is
// intentionally synchronous: if createOffer / setLocalDescription hang
// (e.g. injected stub during fallback testing) the timer must still fire.
@@ -88,7 +114,7 @@ export async function startOutgoingTransfer(
}
}, ICE_TIMEOUT_MS);
const p2p = p2pStartOutgoing(sessionId, receiverName, src);
const p2p = p2pStartOutgoing(sessionId, peerName, src);
state.p2p = p2p;
p2p.onState((s) =>
@@ -98,8 +124,53 @@ export async function startOutgoingTransfer(
if (state.fallbackTimer) { clearTimeout(state.fallbackTimer); state.fallbackTimer = null; }
}
});
}
return sessionId;
// PEER_WAIT_MS bounds how long the sender waits for an offline receiver to wake + reconnect.
const PEER_WAIT_MS = 60_000;
// PEER_ARM_DELAY_MS gives the woken receiver a beat to arm its P2P answerer (from the backend's
// reconnect re-delivery of transfer:incoming) before the sender's offer arrives.
const PEER_ARM_DELAY_MS = 1_200;
// waitForPeerThenBegin defers the P2P offer until an offline receiver comes online (woken by the
// incoming-transfer push). Watches presence; on the peer appearing online it waits a beat for the
// receiver to arm its answerer, then offers. Times out to a FAILED transfer if the peer never wakes.
function waitForPeerThenBegin(state: OutgoingState): void
{
const { sessionId, peerName } = state;
const peerOnline = (): boolean =>
useAppStore.getState().devices.some((d) => d.name === peerName && d.online);
const proceed = (): void =>
{
if (state.waitUnsub) { state.waitUnsub(); state.waitUnsub = null; }
if (state.waitTimer !== null) { clearTimeout(state.waitTimer); state.waitTimer = null; }
if (state.abort.signal.aborted) { return; }
window.setTimeout(() => { if (!state.abort.signal.aborted) { beginP2P(state); } }, PEER_ARM_DELAY_MS);
};
if (peerOnline()) { proceed(); return; }
state.waitUnsub = useAppStore.subscribe(() =>
{
if (state.abort.signal.aborted) { state.waitUnsub?.(); state.waitUnsub = null; return; }
if (peerOnline()) { proceed(); }
});
state.waitTimer = window.setTimeout(() =>
{
if (state.waitUnsub) { state.waitUnsub(); state.waitUnsub = null; }
state.waitTimer = null;
if (state.abort.signal.aborted) { return; }
const store = useAppStore.getState();
const r = store.activeTransfers[sessionId];
if (r && r.state === "PENDING")
{
void apiFetch(`/api/transfer/${sessionId}/cancel`, { method: "POST" }).catch(() => { /* best-effort */ });
store.upsertTransfer({ ...r, state: "FAILED", phase: "peer_offline" });
}
outgoing.delete(sessionId);
}, PEER_WAIT_MS);
}
async function triggerFallback(sessionId: string): Promise<void>
@@ -241,6 +312,8 @@ function cleanupOutgoing(sessionId: string): void
const s = outgoing.get(sessionId);
if (!s) { return; }
if (s.fallbackTimer) { clearTimeout(s.fallbackTimer); }
if (s.waitTimer !== null) { clearTimeout(s.waitTimer); }
if (s.waitUnsub) { s.waitUnsub(); }
s.abort.abort();
outgoing.delete(sessionId);
}
+3
View File
@@ -37,6 +37,7 @@ export const enUS: Partial<TranslationDict> = {
// ---- home -----------------------------------------------------------
"home.greeting": "Hello, {{name}}",
"home.deviceList.title": "Devices",
"home.device.wakeable": "Wakeable",
"home.deviceList.onlineSuffix": "({{count}} online)",
"home.deviceList.showOffline": "Show offline ({{count}})",
"home.deviceList.hideOffline": "Hide offline",
@@ -298,6 +299,8 @@ export const enUS: Partial<TranslationDict> = {
// ---- client phase (real-time telemetry; tells "waiting" from "stuck") ----
"transfer.phase.initializing": "Initializing…",
"transfer.phase.waiting_accept": "Waiting for the other side to accept…",
"transfer.phase.waiting_peer": "Waiting for the device to come online…",
"transfer.phase.peer_offline": "Device didn't come online",
"transfer.phase.ice_gathering": "Gathering network candidates…",
"transfer.phase.ice_checking": "Establishing direct tunnel…",
"transfer.phase.ice_connected": "Tunnel established",
+3
View File
@@ -34,6 +34,7 @@ export const zhCN = {
// ---- 主页 ------------------------------------------------------------
"home.greeting": "你好,{{name}}",
"home.deviceList.title": "设备",
"home.device.wakeable": "可唤醒",
"home.deviceList.onlineSuffix": "{{count}} 台在线)",
"home.deviceList.showOffline": "显示离线设备({{count}}",
"home.deviceList.hideOffline": "隐藏离线设备",
@@ -294,6 +295,8 @@ export const zhCN = {
// ---- 客户端 phase(实时遥测,区分等待中和卡住)----------------------
"transfer.phase.initializing": "正在初始化…",
"transfer.phase.waiting_accept": "等待对端接受…",
"transfer.phase.waiting_peer": "等待对端上线…",
"transfer.phase.peer_offline": "对端未上线",
"transfer.phase.ice_gathering": "收集网络候选…",
"transfer.phase.ice_checking": "建立直连通道…",
"transfer.phase.ice_connected": "通道已连接",
+3
View File
@@ -38,6 +38,7 @@ export const zhTW: Partial<TranslationDict> = {
// ---- 首頁 ------------------------------------------------------------
"home.greeting": "你好,{{name}}",
"home.deviceList.title": "裝置",
"home.device.wakeable": "可喚醒",
"home.deviceList.onlineSuffix": "{{count}} 部線上)",
"home.deviceList.showOffline": "顯示離線裝置({{count}}",
"home.deviceList.hideOffline": "隱藏離線裝置",
@@ -298,6 +299,8 @@ export const zhTW: Partial<TranslationDict> = {
// ---- 客戶端 phase(即時遙測,區分等待中和卡住)---------------------
"transfer.phase.initializing": "正在初始化…",
"transfer.phase.waiting_accept": "等待對端接受…",
"transfer.phase.waiting_peer": "等待對端上線…",
"transfer.phase.peer_offline": "對端未上線",
"transfer.phase.ice_gathering": "收集網路候選…",
"transfer.phase.ice_checking": "建立直連通道…",
"transfer.phase.ice_connected": "通道已連線",
+2
View File
@@ -38,6 +38,8 @@ export type ThemeMode = "light" | "dark" | "system";
export type TransferPhase =
| "initializing" // 刚 /initiate / 收到 transfer:incoming,未起 WebRTC
| "waiting_accept" // sender 等 /acceptreceiver 等自己 /accept 回执
| "waiting_peer" // 接收端离线/后台:已推送唤醒,发送端等其上线再发起 P2P
| "peer_offline" // 等待窗口内对端未上线,传输失败
| "ice_gathering" // RTCPeerConnection 正在收集候选
| "ice_checking" // 候选已交换,连通性检查中
| "ice_connected" // ICE 通过,DC 即将打开