diff --git a/internal/db/queries/transfers.sql b/internal/db/queries/transfers.sql index 6b9db66..48e5bea 100644 --- a/internal/db/queries/transfers.sql +++ b/internal/db/queries/transfers.sql @@ -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', diff --git a/internal/db/transfers.sql.go b/internal/db/transfers.sql.go index 89cac3b..27bc3f3 100644 --- a/internal/db/transfers.sql.go +++ b/internal/db/transfers.sql.go @@ -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, diff --git a/internal/httpapi/sse.go b/internal/httpapi/sse.go index dbd9683..58362e2 100644 --- a/internal/httpapi/sse.go +++ b/internal/httpapi/sse.go @@ -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() diff --git a/internal/httpapi/transfer.go b/internal/httpapi/transfer.go index 1a4ce41..9f0d7a4 100644 --- a/internal/httpapi/transfer.go +++ b/internal/httpapi/transfer.go @@ -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 / diff --git a/internal/transfer/service.go b/internal/transfer/service.go index ea65091..54c702f 100644 --- a/internal/transfer/service.go +++ b/internal/transfer/service.go @@ -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 diff --git a/internal/transfer/service_test.go b/internal/transfer/service_test.go index 72d1e3b..aac25cb 100644 --- a/internal/transfer/service_test.go +++ b/internal/transfer/service_test.go @@ -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, }) diff --git a/ios/CDrop/Sources/Engine/EngineController.swift b/ios/CDrop/Sources/Engine/EngineController.swift index 49adfe5..0c5cf95 100644 --- a/ios/CDrop/Sources/Engine/EngineController.swift +++ b/ios/CDrop/Sources/Engine/EngineController.swift @@ -334,10 +334,25 @@ final class EngineController: NSObject return files.first { $0.lastPathComponent.hasPrefix(base) } } - // 可发送目标:在线、且非本机(不发给自己)。发送 / 转发两处入口共用此过滤。 + // 可发送目标:在线设备 + 离线但可唤醒(iOS,有 APNs)的设备,且非本机。给离线可唤醒设备发送时, + // 后端推送唤醒它、发送端等其回前台 / 上线后再发起实时传输(见 web transfer.ts waitForPeerThenBegin)。 func sendableDevices() -> [DeviceItem] { - return devices.filter { $0.online && $0.name != deviceName } + return devices.filter { $0.name != deviceName && ($0.online || Self.isWakeable($0)) } + } + + // 离线但可经推送唤醒(目前=iOS App):可作为传输目标。 + static func isWakeable(_ dev: DeviceItem) -> Bool + { + if dev.online { return false } + let k = dev.type.lowercased() + return k.contains("ios") || k.contains("iphone") || k.contains("ipad") + } + + // 发送选择器里的设备标签:可唤醒(离线)设备附「可唤醒」提示,区别于在线设备。 + func sendLabel(_ dev: DeviceItem) -> String + { + return Self.isWakeable(dev) ? "\(dev.name) · \(t("home.device.wakeable"))" : dev.name } // 剪贴板上行:读本机 UIPasteboard 文本交引擎上传云剪贴板(用户手势触发,前台读取合规)。 diff --git a/ios/CDrop/Sources/Resources/i18n/en-US.json b/ios/CDrop/Sources/Resources/i18n/en-US.json index 13e2c17..dc08aac 100644 --- a/ios/CDrop/Sources/Resources/i18n/en-US.json +++ b/ios/CDrop/Sources/Resources/i18n/en-US.json @@ -25,6 +25,7 @@ "nav.themeToggle": "Toggle theme", "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", @@ -256,6 +257,8 @@ "transfer.mode.relay": "Relay", "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", diff --git a/ios/CDrop/Sources/Resources/i18n/zh-CN.json b/ios/CDrop/Sources/Resources/i18n/zh-CN.json index decba54..a631759 100644 --- a/ios/CDrop/Sources/Resources/i18n/zh-CN.json +++ b/ios/CDrop/Sources/Resources/i18n/zh-CN.json @@ -25,6 +25,7 @@ "nav.themeToggle": "切换主题", "home.greeting": "你好,{{name}}", "home.deviceList.title": "设备", + "home.device.wakeable": "可唤醒", "home.deviceList.onlineSuffix": "({{count}} 台在线)", "home.deviceList.showOffline": "显示离线设备({{count}})", "home.deviceList.hideOffline": "隐藏离线设备", @@ -256,6 +257,8 @@ "transfer.mode.relay": "中继", "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": "通道已连接", diff --git a/ios/CDrop/Sources/Resources/i18n/zh-TW.json b/ios/CDrop/Sources/Resources/i18n/zh-TW.json index 2daa73f..a29a323 100644 --- a/ios/CDrop/Sources/Resources/i18n/zh-TW.json +++ b/ios/CDrop/Sources/Resources/i18n/zh-TW.json @@ -25,6 +25,7 @@ "nav.themeToggle": "切換主題", "home.greeting": "你好,{{name}}", "home.deviceList.title": "裝置", + "home.device.wakeable": "可喚醒", "home.deviceList.onlineSuffix": "({{count}} 部線上)", "home.deviceList.showOffline": "顯示離線裝置({{count}})", "home.deviceList.hideOffline": "隱藏離線裝置", @@ -256,6 +257,8 @@ "transfer.mode.relay": "中繼", "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": "通道已連線", diff --git a/ios/CDrop/Sources/RootView.swift b/ios/CDrop/Sources/RootView.swift index cae7139..f430614 100644 --- a/ios/CDrop/Sources/RootView.swift +++ b/ios/CDrop/Sources/RootView.swift @@ -89,7 +89,7 @@ struct RootView: View { ForEach(engine.sendableDevices()) { dev in - Button(dev.name) { engine.sendShares(to: dev.name) } + Button(engine.sendLabel(dev)) { engine.sendShares(to: dev.name) } } Button(t("common.cancel"), role: .cancel) { engine.dismissShares() } } @@ -183,7 +183,7 @@ struct TransferListView: View { ForEach(sendableDevices) { dev in - Button(dev.name) { startSend(to: dev.name) } + Button(engine.sendLabel(dev)) { startSend(to: dev.name) } } Button(t("common.cancel"), role: .cancel) { } } @@ -777,7 +777,7 @@ struct ReceivedFilesView: View { ForEach(engine.sendableDevices()) { dev in - Button(dev.name) { send(to: dev.name) } + Button(engine.sendLabel(dev)) { send(to: dev.name) } } Button(t("common.cancel"), role: .cancel) { } } diff --git a/web/src/features/devices/DeviceStrip.tsx b/web/src/features/devices/DeviceStrip.tsx index f2d519d..27cd36f 100644 --- a/web/src/features/devices/DeviceStrip.tsx +++ b/web/src/features/devices/DeviceStrip.tsx @@ -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) => { @@ -154,7 +156,9 @@ function DevicePort(props: DevicePortProps) {device.online ? t("settings.online") - : t("settings.lastSeen", { time: formatRelative(device.lastSeen) })} + : wakeable + ? t("home.device.wakeable") + : t("settings.lastSeen", { time: formatRelative(device.lastSeen) })} @@ -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 { diff --git a/web/src/features/transfer/transfer.ts b/web/src/features/transfer/transfer.ts index 87d3a34..b703aa3 100644 --- a/web/src/features/transfer/transfer.ts +++ b/web/src/features/transfer/transfer.ts @@ -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 @@ -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); } diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index 72a44c1..e8e36d5 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -37,6 +37,7 @@ export const enUS: Partial = { // ---- 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 = { // ---- 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", diff --git a/web/src/i18n/locales/zh-CN.ts b/web/src/i18n/locales/zh-CN.ts index 08fa37c..456776e 100644 --- a/web/src/i18n/locales/zh-CN.ts +++ b/web/src/i18n/locales/zh-CN.ts @@ -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": "通道已连接", diff --git a/web/src/i18n/locales/zh-TW.ts b/web/src/i18n/locales/zh-TW.ts index d58ef6c..c6304d6 100644 --- a/web/src/i18n/locales/zh-TW.ts +++ b/web/src/i18n/locales/zh-TW.ts @@ -38,6 +38,7 @@ export const zhTW: Partial = { // ---- 首頁 ------------------------------------------------------------ "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 = { // ---- 客戶端 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": "通道已連線", diff --git a/web/src/store/types.ts b/web/src/store/types.ts index e138b73..6b0400f 100644 --- a/web/src/store/types.ts +++ b/web/src/store/types.ts @@ -38,6 +38,8 @@ export type ThemeMode = "light" | "dark" | "system"; export type TransferPhase = | "initializing" // 刚 /initiate / 收到 transfer:incoming,未起 WebRTC | "waiting_accept" // sender 等 /accept,receiver 等自己 /accept 回执 + | "waiting_peer" // 接收端离线/后台:已推送唤醒,发送端等其上线再发起 P2P + | "peer_offline" // 等待窗口内对端未上线,传输失败 | "ice_gathering" // RTCPeerConnection 正在收集候选 | "ice_checking" // 候选已交换,连通性检查中 | "ice_connected" // ICE 通过,DC 即将打开