Files
Commilitia-Drop/internal/httpapi/sse.go
T
admin 2caf1ab921 传输等待前台:后台 / 墓碑 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 的三返回值。
2026-06-27 18:16:23 +08:00

111 lines
3.3 KiB
Go

package httpapi
import (
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"time"
"commilitia.net/cdrop/internal/db"
"commilitia.net/cdrop/internal/hub"
"commilitia.net/cdrop/internal/jwtauth"
)
const (
keepaliveInterval = 15 * time.Second
probeFrame = ": hi\n\n"
)
// handleEvents serves the long-lived SSE channel mandated by brief §5.
// Sets X-Accel-Buffering / Cache-Control / flush to defeat reverse-proxy buffering.
// Sends a probe frame immediately so the client can detect first-byte latency.
func (s *Server) handleEvents(w http.ResponseWriter, r *http.Request) {
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "streaming unsupported", http.StatusInternalServerError)
return
}
claims, ok := jwtauth.ClaimsFromContext(r.Context())
if !ok {
http.Error(w, "missing claims", http.StatusUnauthorized)
return
}
deviceName, _ := jwtauth.DeviceNameFromContext(r.Context())
if deviceName == "" {
http.Error(w, "missing device", http.StatusBadRequest)
return
}
deviceType := jwtauth.DeviceTypeFromContext(r.Context())
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("X-Accel-Buffering", "no")
w.Header().Set("Connection", "keep-alive")
w.WriteHeader(http.StatusOK)
if _, err := io.WriteString(w, probeFrame); err != nil {
return
}
flusher.Flush()
// Key the hub entry by the stable device_id (X-Auth-Meta) when present, so a rename never
// orphans this connection; deviceName rides along for presence fallback + name addressing.
// A code-less browser (no device_id) falls back to name keying inside Connect.
client := s.hub.Connect(r.Context(), claims.UserID, claims.DeviceID, deviceName, deviceType)
defer s.hub.Disconnect(client)
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()
for {
select {
case ev, ok := <-client.Events():
if !ok {
return
}
if err := writeSSEEvent(w, ev); err != nil {
return
}
flusher.Flush()
case <-ping.C:
if _, err := io.WriteString(w, "event: ping\ndata: {}\n\n"); err != nil {
return
}
flusher.Flush()
// Refresh last_seen so a connection-only client (no other API calls)
// keeps its managed device row alive past the sweeper TTL while connected.
if claims.DeviceID != "" {
_ = s.queries.TouchDevice(r.Context(), db.TouchDeviceParams{
LastSeen: time.Now().Unix(),
Tier: claims.Tier(),
DeviceID: claims.DeviceID,
UserID: claims.UserID,
})
}
case <-r.Context().Done():
return
}
}
}
func writeSSEEvent(w io.Writer, ev hub.Event) error {
payload, err := json.Marshal(ev.Data)
if err != nil {
return fmt.Errorf("marshal sse data: %w", err)
}
// SSE frame: "event: <type>\ndata: <json>\n\n".
// JSON is single-line via json.Marshal so no `data:` continuation needed.
_, err = fmt.Fprintf(w, "event: %s\ndata: %s\n\n", ev.Type, payload)
return err
}