传输等待前台:后台 / 墓碑 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
+54 -6
View File
@@ -6,6 +6,7 @@ import (
"encoding/hex"
"errors"
"fmt"
"log/slog"
"time"
"commilitia.net/cdrop/internal/apns"
@@ -93,17 +94,20 @@ type InitParams struct {
// Init creates a new PENDING session and pushes transfer:incoming to the receiver.
// Returns the new session ID. Receiver offline does not fail Init — the receiver
// will see the session next time it connects via /api/devices or a fresh init.
func (s *Service) Init(ctx context.Context, p InitParams) (string, error) {
// Init returns (sessionID, receiverOnline, error). receiverOnline is whether the receiver had a
// live SSE at init time — false means the receiver is offline and was sent a wake push, so the
// sender should wait for it to come online before offering (rather than fail immediately).
func (s *Service) Init(ctx context.Context, p InitParams) (string, bool, error) {
if p.SenderName == "" || p.ReceiverName == "" || p.FileName == "" {
return "", errors.New("init: sender_name, receiver_name, file_name required")
return "", false, errors.New("init: sender_name, receiver_name, file_name required")
}
if p.SenderName == p.ReceiverName {
return "", errors.New("init: sender and receiver must differ")
return "", false, errors.New("init: sender and receiver must differ")
}
// brief §2 explicitly says "不限文件大小"; 0-byte is legal (empty file
// transfer still needs control frames). Negatives are nonsense.
if p.FileSize < 0 {
return "", errors.New("init: file_size cannot be negative")
return "", false, errors.New("init: file_size cannot be negative")
}
id := s.newID()
@@ -125,7 +129,7 @@ func (s *Service) Init(ctx context.Context, p InitParams) (string, error) {
FileSha256: sha,
CreatedAt: createdAt,
}); err != nil {
return "", fmt.Errorf("create session: %w", err)
return "", false, fmt.Errorf("create session: %w", err)
}
view := SessionView{
@@ -162,7 +166,51 @@ func (s *Service) Init(ctx context.Context, p InitParams) (string, error) {
}
}
return id, nil
return id, delivered, nil
}
// pendingTransferWindowSec bounds how far back RedeliverPendingTo looks: a transfer older than
// this (the sender's wait window plus margin) the sender has long since given up on.
const pendingTransferWindowSec = 180
// RedeliverPendingTo re-sends transfer:incoming for the receiver's still-PENDING sessions when it
// (re)connects — e.g. an iOS device that was offline at Init and is now woken by the incoming
// push. Without this the woken receiver never arms its P2P answerer, so the waiting sender's offer
// would be dropped (p2pHandleSignal ignores a signal from an unknown peer). Best-effort, bounded
// to recent sessions; called from the SSE handler right after a device connects.
func (s *Service) RedeliverPendingTo(ctx context.Context, userID, deviceName string) {
if deviceName == "" {
return
}
rows, err := s.queries.ListPendingTransfersByReceiver(ctx, db.ListPendingTransfersByReceiverParams{
UserID: userID,
ReceiverName: deviceName,
CreatedAt: s.now().Unix() - pendingTransferWindowSec,
})
if err != nil {
slog.Error("redeliver pending transfers failed", "err", err, "user", userID, "device", deviceName)
return
}
for _, row := range rows {
sha := ""
if row.FileSha256 != nil {
sha = *row.FileSha256
}
view := SessionView{
ID: row.ID,
SenderName: row.SenderName,
FileName: row.FileName,
FileSize: row.FileSize,
FileSHA256: sha,
}
s.hub.SendTo(userID, deviceName, hub.Event{
Type: "transfer:incoming",
Data: map[string]any{
"session": view,
"auto_accept": row.FileSize <= AutoAcceptThreshold,
},
})
}
}
// Transition validates and applies a state change, then broadcasts transfer:state