传输等待前台:后台 / 墓碑 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:
@@ -21,6 +21,16 @@ SET state = sqlc.arg('state'),
|
||||
bytes_transferred = COALESCE(sqlc.narg('bytes_transferred'), bytes_transferred)
|
||||
WHERE id = sqlc.arg('id');
|
||||
|
||||
-- name: ListPendingTransfersByReceiver :many
|
||||
-- Still-PENDING transfers addressed to a receiver, bounded to recently created ones (the
|
||||
-- sender's wait window). Re-delivered as transfer:incoming when that receiver (re)connects -
|
||||
-- e.g. an offline iOS device just woken by the incoming-transfer push -so it arms its P2P
|
||||
-- answerer for the waiting sender's offer.
|
||||
SELECT id, sender_name, file_name, file_size, file_sha256
|
||||
FROM transfer_sessions
|
||||
WHERE user_id = ? AND receiver_name = ? AND state = 'PENDING' AND created_at > ?
|
||||
ORDER BY created_at ASC;
|
||||
|
||||
-- name: ExpirePendingSessions :many
|
||||
UPDATE transfer_sessions
|
||||
SET state = 'CANCELLED',
|
||||
|
||||
@@ -120,6 +120,60 @@ func (q *Queries) GetTransferSession(ctx context.Context, id string) (TransferSe
|
||||
return i, err
|
||||
}
|
||||
|
||||
const listPendingTransfersByReceiver = `-- name: ListPendingTransfersByReceiver :many
|
||||
SELECT id, sender_name, file_name, file_size, file_sha256
|
||||
FROM transfer_sessions
|
||||
WHERE user_id = ? AND receiver_name = ? AND state = 'PENDING' AND created_at > ?
|
||||
ORDER BY created_at ASC
|
||||
`
|
||||
|
||||
type ListPendingTransfersByReceiverParams struct {
|
||||
UserID string `json:"user_id"`
|
||||
ReceiverName string `json:"receiver_name"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
}
|
||||
|
||||
type ListPendingTransfersByReceiverRow struct {
|
||||
ID string `json:"id"`
|
||||
SenderName string `json:"sender_name"`
|
||||
FileName string `json:"file_name"`
|
||||
FileSize int64 `json:"file_size"`
|
||||
FileSha256 *string `json:"file_sha256"`
|
||||
}
|
||||
|
||||
// Still-PENDING transfers addressed to a receiver, bounded to recently created ones (the
|
||||
// sender's wait window). Re-delivered as transfer:incoming when that receiver (re)connects -
|
||||
// e.g. an offline iOS device just woken by the incoming-transfer push -so it arms its P2P
|
||||
// answerer for the waiting sender's offer.
|
||||
func (q *Queries) ListPendingTransfersByReceiver(ctx context.Context, arg ListPendingTransfersByReceiverParams) ([]ListPendingTransfersByReceiverRow, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listPendingTransfersByReceiver, arg.UserID, arg.ReceiverName, arg.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ListPendingTransfersByReceiverRow
|
||||
for rows.Next() {
|
||||
var i ListPendingTransfersByReceiverRow
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.SenderName,
|
||||
&i.FileName,
|
||||
&i.FileSize,
|
||||
&i.FileSha256,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const updateTransferState = `-- name: UpdateTransferState :execrows
|
||||
UPDATE transfer_sessions
|
||||
SET state = ?1,
|
||||
|
||||
@@ -59,6 +59,11 @@ func (s *Server) handleEvents(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
slog.Debug("sse connected", "user", claims.UserID, "device", deviceName)
|
||||
|
||||
// Re-deliver any still-PENDING transfers addressed to this device (e.g. an offline iOS device
|
||||
// just woken by an incoming-transfer push), so it arms its P2P answerer for the waiting
|
||||
// sender's offer. SendTo buffers into this client's channel, drained by the loop below.
|
||||
s.transfers.RedeliverPendingTo(r.Context(), claims.UserID, deviceName)
|
||||
|
||||
ping := time.NewTicker(keepaliveInterval)
|
||||
defer ping.Stop()
|
||||
|
||||
|
||||
@@ -20,6 +20,10 @@ type initReq struct {
|
||||
|
||||
type initResp struct {
|
||||
SessionID string `json:"session_id"`
|
||||
// ReceiverOnline is false when the receiver had no live SSE at init time (a wake push was
|
||||
// sent). The sender uses it to wait for the receiver to come online before offering, rather
|
||||
// than immediately failing/relaying to an absent peer.
|
||||
ReceiverOnline bool `json:"receiver_online"`
|
||||
}
|
||||
|
||||
func (s *Server) handleTransferInit(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -32,7 +36,7 @@ func (s *Server) handleTransferInit(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
id, err := s.transfers.Init(r.Context(), transfer.InitParams{
|
||||
id, receiverOnline, err := s.transfers.Init(r.Context(), transfer.InitParams{
|
||||
UserID: claims.UserID,
|
||||
SenderName: sender,
|
||||
ReceiverName: req.ReceiverName,
|
||||
@@ -44,7 +48,7 @@ func (s *Server) handleTransferInit(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, initResp{SessionID: id})
|
||||
writeJSON(w, http.StatusOK, initResp{SessionID: id, ReceiverOnline: receiverOnline})
|
||||
}
|
||||
|
||||
// handleTransferTransition is the shared body for accept / cancel / fallback /
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user