传输等待前台:后台 / 墓碑 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
+13 -13
View File
@@ -83,7 +83,7 @@ func newTestServiceWithRelay(t *testing.T, r RelayController) *Service {
func TestInit_AllowsZeroByteFile(t *testing.T) {
svc := newTestService(t)
if _, err := svc.Init(context.Background(), InitParams{
if _, _, err := svc.Init(context.Background(), InitParams{
UserID: "u", SenderName: "s", ReceiverName: "r",
FileName: "empty.txt", FileSize: 0,
}); err != nil {
@@ -93,7 +93,7 @@ func TestInit_AllowsZeroByteFile(t *testing.T) {
func TestInit_PersistsPending(t *testing.T) {
svc := newTestService(t)
id, err := svc.Init(context.Background(), InitParams{
id, _, err := svc.Init(context.Background(), InitParams{
UserID: "alice",
SenderName: "tab-1",
ReceiverName: "tab-2",
@@ -129,7 +129,7 @@ func TestInit_RejectsBadInput(t *testing.T) {
{UserID: "u", SenderName: "s", ReceiverName: "r", FileName: "f", FileSize: -1},
}
for i, p := range bad {
if _, err := svc.Init(context.Background(), p); err == nil {
if _, _, err := svc.Init(context.Background(), p); err == nil {
t.Errorf("case %d should error: %+v", i, p)
}
}
@@ -138,7 +138,7 @@ func TestInit_RejectsBadInput(t *testing.T) {
func TestTransition_HappyPath_PendingToDone(t *testing.T) {
svc := newTestService(t)
ctx := context.Background()
id, err := svc.Init(ctx, InitParams{
id, _, err := svc.Init(ctx, InitParams{
UserID: "u", SenderName: "s", ReceiverName: "r",
FileName: "f", FileSize: 1024,
})
@@ -173,7 +173,7 @@ func TestTransition_HappyPath_PendingToDone(t *testing.T) {
func TestTransition_FallbackToRelayThenDone(t *testing.T) {
svc := newTestService(t)
ctx := context.Background()
id, _ := svc.Init(ctx, InitParams{
id, _, _ := svc.Init(ctx, InitParams{
UserID: "u", SenderName: "s", ReceiverName: "r",
FileName: "f", FileSize: 10,
})
@@ -197,7 +197,7 @@ func TestTransition_RegistersRelayOnFallback(t *testing.T) {
fr := &fakeRelay{}
svc := newTestServiceWithRelay(t, fr)
ctx := context.Background()
id, _ := svc.Init(ctx, InitParams{
id, _, _ := svc.Init(ctx, InitParams{
UserID: "u", SenderName: "laptop", ReceiverName: "phone",
FileName: "f", FileSize: 10,
})
@@ -222,7 +222,7 @@ func TestTransition_RelayFullSurfacesUnavailable(t *testing.T) {
fr := &fakeRelay{registerErr: errors.New("too many")}
svc := newTestServiceWithRelay(t, fr)
ctx := context.Background()
id, _ := svc.Init(ctx, InitParams{
id, _, _ := svc.Init(ctx, InitParams{
UserID: "u", SenderName: "s", ReceiverName: "r",
FileName: "f", FileSize: 10,
})
@@ -242,7 +242,7 @@ func TestTransition_RelayFullSurfacesUnavailable(t *testing.T) {
func TestTransition_RejectsInvalidJump(t *testing.T) {
svc := newTestService(t)
ctx := context.Background()
id, _ := svc.Init(ctx, InitParams{
id, _, _ := svc.Init(ctx, InitParams{
UserID: "u", SenderName: "s", ReceiverName: "r",
FileName: "f", FileSize: 10,
})
@@ -256,7 +256,7 @@ func TestTransition_PendingToActiveAllowed(t *testing.T) {
// Race tolerance: sender may call /p2p before receiver's /accept commits.
svc := newTestService(t)
ctx := context.Background()
id, _ := svc.Init(ctx, InitParams{
id, _, _ := svc.Init(ctx, InitParams{
UserID: "u", SenderName: "s", ReceiverName: "r",
FileName: "f", FileSize: 10,
})
@@ -269,7 +269,7 @@ func TestTransition_AcceptedToDoneAllowed(t *testing.T) {
// Race tolerance: receiver may finish receiving (call /done) before /p2p ever fired.
svc := newTestService(t)
ctx := context.Background()
id, _ := svc.Init(ctx, InitParams{
id, _, _ := svc.Init(ctx, InitParams{
UserID: "u", SenderName: "s", ReceiverName: "r",
FileName: "f", FileSize: 10,
})
@@ -284,7 +284,7 @@ func TestTransition_AcceptedToDoneAllowed(t *testing.T) {
func TestTransition_RejectsForeignUser(t *testing.T) {
svc := newTestService(t)
ctx := context.Background()
id, _ := svc.Init(ctx, InitParams{
id, _, _ := svc.Init(ctx, InitParams{
UserID: "alice", SenderName: "s", ReceiverName: "r",
FileName: "f", FileSize: 10,
})
@@ -307,7 +307,7 @@ func TestExpirePending_CancelsOldSessions(t *testing.T) {
old := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
svc.now = func() time.Time { return old }
id, _ := svc.Init(ctx, InitParams{
id, _, _ := svc.Init(ctx, InitParams{
UserID: "u", SenderName: "s", ReceiverName: "r",
FileName: "f", FileSize: 10,
})
@@ -333,7 +333,7 @@ func TestExpirePending_CancelsOldSessions(t *testing.T) {
func TestExpirePending_LeavesFreshAlone(t *testing.T) {
svc := newTestService(t)
ctx := context.Background()
id, _ := svc.Init(ctx, InitParams{
id, _, _ := svc.Init(ctx, InitParams{
UserID: "u", SenderName: "s", ReceiverName: "r",
FileName: "f", FileSize: 10,
})