2caf1ab921
- 根因: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 的三返回值。
99 lines
3.2 KiB
Go
99 lines
3.2 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
"commilitia.net/cdrop/internal/jwtauth"
|
|
"commilitia.net/cdrop/internal/transfer"
|
|
)
|
|
|
|
type initReq struct {
|
|
ReceiverName string `json:"receiver_name"`
|
|
FileName string `json:"file_name"`
|
|
FileSize int64 `json:"file_size"`
|
|
FileSHA256 string `json:"file_sha256,omitempty"`
|
|
}
|
|
|
|
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) {
|
|
claims, _ := jwtauth.ClaimsFromContext(r.Context())
|
|
sender, _ := jwtauth.DeviceNameFromContext(r.Context())
|
|
|
|
var req initReq
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
|
|
return
|
|
}
|
|
|
|
id, receiverOnline, err := s.transfers.Init(r.Context(), transfer.InitParams{
|
|
UserID: claims.UserID,
|
|
SenderName: sender,
|
|
ReceiverName: req.ReceiverName,
|
|
FileName: req.FileName,
|
|
FileSize: req.FileSize,
|
|
FileSHA256: req.FileSHA256,
|
|
})
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, initResp{SessionID: id, ReceiverOnline: receiverOnline})
|
|
}
|
|
|
|
// handleTransferTransition is the shared body for accept / cancel / fallback /
|
|
// done / fail. The target state and any side-fields are bound by the route.
|
|
type transitionReq struct {
|
|
Reason string `json:"reason,omitempty"`
|
|
BytesTransferred int64 `json:"bytes_transferred,omitempty"`
|
|
}
|
|
|
|
func (s *Server) transitionHandler(target, mode string) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
claims, _ := jwtauth.ClaimsFromContext(r.Context())
|
|
id := chi.URLParam(r, "id")
|
|
if id == "" {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing id"})
|
|
return
|
|
}
|
|
|
|
var req transitionReq
|
|
if r.ContentLength > 0 {
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
|
|
return
|
|
}
|
|
}
|
|
|
|
err := s.transfers.Transition(
|
|
r.Context(), claims.UserID, id, target,
|
|
mode, req.Reason, req.BytesTransferred,
|
|
)
|
|
switch {
|
|
case err == nil:
|
|
w.WriteHeader(http.StatusNoContent)
|
|
case errors.Is(err, transfer.ErrNotFound):
|
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
|
|
case errors.Is(err, transfer.ErrForbidden):
|
|
writeJSON(w, http.StatusForbidden, map[string]string{"error": "forbidden"})
|
|
case errors.Is(err, transfer.ErrInvalidTransition):
|
|
writeJSON(w, http.StatusConflict, map[string]string{"error": err.Error()})
|
|
case errors.Is(err, transfer.ErrRelayUnavailable):
|
|
writeJSON(w, http.StatusServiceUnavailable,
|
|
map[string]string{"error": "relay unavailable, retry later"})
|
|
default:
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
}
|
|
}
|
|
}
|