传输等待前台:后台 / 墓碑 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)
|
bytes_transferred = COALESCE(sqlc.narg('bytes_transferred'), bytes_transferred)
|
||||||
WHERE id = sqlc.arg('id');
|
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
|
-- name: ExpirePendingSessions :many
|
||||||
UPDATE transfer_sessions
|
UPDATE transfer_sessions
|
||||||
SET state = 'CANCELLED',
|
SET state = 'CANCELLED',
|
||||||
|
|||||||
@@ -120,6 +120,60 @@ func (q *Queries) GetTransferSession(ctx context.Context, id string) (TransferSe
|
|||||||
return i, err
|
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
|
const updateTransferState = `-- name: UpdateTransferState :execrows
|
||||||
UPDATE transfer_sessions
|
UPDATE transfer_sessions
|
||||||
SET state = ?1,
|
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)
|
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)
|
ping := time.NewTicker(keepaliveInterval)
|
||||||
defer ping.Stop()
|
defer ping.Stop()
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ type initReq struct {
|
|||||||
|
|
||||||
type initResp struct {
|
type initResp struct {
|
||||||
SessionID string `json:"session_id"`
|
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) {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
id, err := s.transfers.Init(r.Context(), transfer.InitParams{
|
id, receiverOnline, err := s.transfers.Init(r.Context(), transfer.InitParams{
|
||||||
UserID: claims.UserID,
|
UserID: claims.UserID,
|
||||||
SenderName: sender,
|
SenderName: sender,
|
||||||
ReceiverName: req.ReceiverName,
|
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()})
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
return
|
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 /
|
// handleTransferTransition is the shared body for accept / cancel / fallback /
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"commilitia.net/cdrop/internal/apns"
|
"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.
|
// 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
|
// 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.
|
// 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 == "" {
|
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 {
|
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
|
// brief §2 explicitly says "不限文件大小"; 0-byte is legal (empty file
|
||||||
// transfer still needs control frames). Negatives are nonsense.
|
// transfer still needs control frames). Negatives are nonsense.
|
||||||
if p.FileSize < 0 {
|
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()
|
id := s.newID()
|
||||||
@@ -125,7 +129,7 @@ func (s *Service) Init(ctx context.Context, p InitParams) (string, error) {
|
|||||||
FileSha256: sha,
|
FileSha256: sha,
|
||||||
CreatedAt: createdAt,
|
CreatedAt: createdAt,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
return "", fmt.Errorf("create session: %w", err)
|
return "", false, fmt.Errorf("create session: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
view := SessionView{
|
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
|
// 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) {
|
func TestInit_AllowsZeroByteFile(t *testing.T) {
|
||||||
svc := newTestService(t)
|
svc := newTestService(t)
|
||||||
if _, err := svc.Init(context.Background(), InitParams{
|
if _, _, err := svc.Init(context.Background(), InitParams{
|
||||||
UserID: "u", SenderName: "s", ReceiverName: "r",
|
UserID: "u", SenderName: "s", ReceiverName: "r",
|
||||||
FileName: "empty.txt", FileSize: 0,
|
FileName: "empty.txt", FileSize: 0,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
@@ -93,7 +93,7 @@ func TestInit_AllowsZeroByteFile(t *testing.T) {
|
|||||||
|
|
||||||
func TestInit_PersistsPending(t *testing.T) {
|
func TestInit_PersistsPending(t *testing.T) {
|
||||||
svc := newTestService(t)
|
svc := newTestService(t)
|
||||||
id, err := svc.Init(context.Background(), InitParams{
|
id, _, err := svc.Init(context.Background(), InitParams{
|
||||||
UserID: "alice",
|
UserID: "alice",
|
||||||
SenderName: "tab-1",
|
SenderName: "tab-1",
|
||||||
ReceiverName: "tab-2",
|
ReceiverName: "tab-2",
|
||||||
@@ -129,7 +129,7 @@ func TestInit_RejectsBadInput(t *testing.T) {
|
|||||||
{UserID: "u", SenderName: "s", ReceiverName: "r", FileName: "f", FileSize: -1},
|
{UserID: "u", SenderName: "s", ReceiverName: "r", FileName: "f", FileSize: -1},
|
||||||
}
|
}
|
||||||
for i, p := range bad {
|
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)
|
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) {
|
func TestTransition_HappyPath_PendingToDone(t *testing.T) {
|
||||||
svc := newTestService(t)
|
svc := newTestService(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
id, err := svc.Init(ctx, InitParams{
|
id, _, err := svc.Init(ctx, InitParams{
|
||||||
UserID: "u", SenderName: "s", ReceiverName: "r",
|
UserID: "u", SenderName: "s", ReceiverName: "r",
|
||||||
FileName: "f", FileSize: 1024,
|
FileName: "f", FileSize: 1024,
|
||||||
})
|
})
|
||||||
@@ -173,7 +173,7 @@ func TestTransition_HappyPath_PendingToDone(t *testing.T) {
|
|||||||
func TestTransition_FallbackToRelayThenDone(t *testing.T) {
|
func TestTransition_FallbackToRelayThenDone(t *testing.T) {
|
||||||
svc := newTestService(t)
|
svc := newTestService(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
id, _ := svc.Init(ctx, InitParams{
|
id, _, _ := svc.Init(ctx, InitParams{
|
||||||
UserID: "u", SenderName: "s", ReceiverName: "r",
|
UserID: "u", SenderName: "s", ReceiverName: "r",
|
||||||
FileName: "f", FileSize: 10,
|
FileName: "f", FileSize: 10,
|
||||||
})
|
})
|
||||||
@@ -197,7 +197,7 @@ func TestTransition_RegistersRelayOnFallback(t *testing.T) {
|
|||||||
fr := &fakeRelay{}
|
fr := &fakeRelay{}
|
||||||
svc := newTestServiceWithRelay(t, fr)
|
svc := newTestServiceWithRelay(t, fr)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
id, _ := svc.Init(ctx, InitParams{
|
id, _, _ := svc.Init(ctx, InitParams{
|
||||||
UserID: "u", SenderName: "laptop", ReceiverName: "phone",
|
UserID: "u", SenderName: "laptop", ReceiverName: "phone",
|
||||||
FileName: "f", FileSize: 10,
|
FileName: "f", FileSize: 10,
|
||||||
})
|
})
|
||||||
@@ -222,7 +222,7 @@ func TestTransition_RelayFullSurfacesUnavailable(t *testing.T) {
|
|||||||
fr := &fakeRelay{registerErr: errors.New("too many")}
|
fr := &fakeRelay{registerErr: errors.New("too many")}
|
||||||
svc := newTestServiceWithRelay(t, fr)
|
svc := newTestServiceWithRelay(t, fr)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
id, _ := svc.Init(ctx, InitParams{
|
id, _, _ := svc.Init(ctx, InitParams{
|
||||||
UserID: "u", SenderName: "s", ReceiverName: "r",
|
UserID: "u", SenderName: "s", ReceiverName: "r",
|
||||||
FileName: "f", FileSize: 10,
|
FileName: "f", FileSize: 10,
|
||||||
})
|
})
|
||||||
@@ -242,7 +242,7 @@ func TestTransition_RelayFullSurfacesUnavailable(t *testing.T) {
|
|||||||
func TestTransition_RejectsInvalidJump(t *testing.T) {
|
func TestTransition_RejectsInvalidJump(t *testing.T) {
|
||||||
svc := newTestService(t)
|
svc := newTestService(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
id, _ := svc.Init(ctx, InitParams{
|
id, _, _ := svc.Init(ctx, InitParams{
|
||||||
UserID: "u", SenderName: "s", ReceiverName: "r",
|
UserID: "u", SenderName: "s", ReceiverName: "r",
|
||||||
FileName: "f", FileSize: 10,
|
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.
|
// Race tolerance: sender may call /p2p before receiver's /accept commits.
|
||||||
svc := newTestService(t)
|
svc := newTestService(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
id, _ := svc.Init(ctx, InitParams{
|
id, _, _ := svc.Init(ctx, InitParams{
|
||||||
UserID: "u", SenderName: "s", ReceiverName: "r",
|
UserID: "u", SenderName: "s", ReceiverName: "r",
|
||||||
FileName: "f", FileSize: 10,
|
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.
|
// Race tolerance: receiver may finish receiving (call /done) before /p2p ever fired.
|
||||||
svc := newTestService(t)
|
svc := newTestService(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
id, _ := svc.Init(ctx, InitParams{
|
id, _, _ := svc.Init(ctx, InitParams{
|
||||||
UserID: "u", SenderName: "s", ReceiverName: "r",
|
UserID: "u", SenderName: "s", ReceiverName: "r",
|
||||||
FileName: "f", FileSize: 10,
|
FileName: "f", FileSize: 10,
|
||||||
})
|
})
|
||||||
@@ -284,7 +284,7 @@ func TestTransition_AcceptedToDoneAllowed(t *testing.T) {
|
|||||||
func TestTransition_RejectsForeignUser(t *testing.T) {
|
func TestTransition_RejectsForeignUser(t *testing.T) {
|
||||||
svc := newTestService(t)
|
svc := newTestService(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
id, _ := svc.Init(ctx, InitParams{
|
id, _, _ := svc.Init(ctx, InitParams{
|
||||||
UserID: "alice", SenderName: "s", ReceiverName: "r",
|
UserID: "alice", SenderName: "s", ReceiverName: "r",
|
||||||
FileName: "f", FileSize: 10,
|
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)
|
old := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||||
svc.now = func() time.Time { return old }
|
svc.now = func() time.Time { return old }
|
||||||
|
|
||||||
id, _ := svc.Init(ctx, InitParams{
|
id, _, _ := svc.Init(ctx, InitParams{
|
||||||
UserID: "u", SenderName: "s", ReceiverName: "r",
|
UserID: "u", SenderName: "s", ReceiverName: "r",
|
||||||
FileName: "f", FileSize: 10,
|
FileName: "f", FileSize: 10,
|
||||||
})
|
})
|
||||||
@@ -333,7 +333,7 @@ func TestExpirePending_CancelsOldSessions(t *testing.T) {
|
|||||||
func TestExpirePending_LeavesFreshAlone(t *testing.T) {
|
func TestExpirePending_LeavesFreshAlone(t *testing.T) {
|
||||||
svc := newTestService(t)
|
svc := newTestService(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
id, _ := svc.Init(ctx, InitParams{
|
id, _, _ := svc.Init(ctx, InitParams{
|
||||||
UserID: "u", SenderName: "s", ReceiverName: "r",
|
UserID: "u", SenderName: "s", ReceiverName: "r",
|
||||||
FileName: "f", FileSize: 10,
|
FileName: "f", FileSize: 10,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -334,10 +334,25 @@ final class EngineController: NSObject
|
|||||||
return files.first { $0.lastPathComponent.hasPrefix(base) }
|
return files.first { $0.lastPathComponent.hasPrefix(base) }
|
||||||
}
|
}
|
||||||
|
|
||||||
// 可发送目标:在线、且非本机(不发给自己)。发送 / 转发两处入口共用此过滤。
|
// 可发送目标:在线设备 + 离线但可唤醒(iOS,有 APNs)的设备,且非本机。给离线可唤醒设备发送时,
|
||||||
|
// 后端推送唤醒它、发送端等其回前台 / 上线后再发起实时传输(见 web transfer.ts waitForPeerThenBegin)。
|
||||||
func sendableDevices() -> [DeviceItem]
|
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 文本交引擎上传云剪贴板(用户手势触发,前台读取合规)。
|
// 剪贴板上行:读本机 UIPasteboard 文本交引擎上传云剪贴板(用户手势触发,前台读取合规)。
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
"nav.themeToggle": "Toggle theme",
|
"nav.themeToggle": "Toggle theme",
|
||||||
"home.greeting": "Hello, {{name}}",
|
"home.greeting": "Hello, {{name}}",
|
||||||
"home.deviceList.title": "Devices",
|
"home.deviceList.title": "Devices",
|
||||||
|
"home.device.wakeable": "Wakeable",
|
||||||
"home.deviceList.onlineSuffix": "({{count}} online)",
|
"home.deviceList.onlineSuffix": "({{count}} online)",
|
||||||
"home.deviceList.showOffline": "Show offline ({{count}})",
|
"home.deviceList.showOffline": "Show offline ({{count}})",
|
||||||
"home.deviceList.hideOffline": "Hide offline",
|
"home.deviceList.hideOffline": "Hide offline",
|
||||||
@@ -256,6 +257,8 @@
|
|||||||
"transfer.mode.relay": "Relay",
|
"transfer.mode.relay": "Relay",
|
||||||
"transfer.phase.initializing": "Initializing…",
|
"transfer.phase.initializing": "Initializing…",
|
||||||
"transfer.phase.waiting_accept": "Waiting for the other side to accept…",
|
"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_gathering": "Gathering network candidates…",
|
||||||
"transfer.phase.ice_checking": "Establishing direct tunnel…",
|
"transfer.phase.ice_checking": "Establishing direct tunnel…",
|
||||||
"transfer.phase.ice_connected": "Tunnel established",
|
"transfer.phase.ice_connected": "Tunnel established",
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
"nav.themeToggle": "切换主题",
|
"nav.themeToggle": "切换主题",
|
||||||
"home.greeting": "你好,{{name}}",
|
"home.greeting": "你好,{{name}}",
|
||||||
"home.deviceList.title": "设备",
|
"home.deviceList.title": "设备",
|
||||||
|
"home.device.wakeable": "可唤醒",
|
||||||
"home.deviceList.onlineSuffix": "({{count}} 台在线)",
|
"home.deviceList.onlineSuffix": "({{count}} 台在线)",
|
||||||
"home.deviceList.showOffline": "显示离线设备({{count}})",
|
"home.deviceList.showOffline": "显示离线设备({{count}})",
|
||||||
"home.deviceList.hideOffline": "隐藏离线设备",
|
"home.deviceList.hideOffline": "隐藏离线设备",
|
||||||
@@ -256,6 +257,8 @@
|
|||||||
"transfer.mode.relay": "中继",
|
"transfer.mode.relay": "中继",
|
||||||
"transfer.phase.initializing": "正在初始化…",
|
"transfer.phase.initializing": "正在初始化…",
|
||||||
"transfer.phase.waiting_accept": "等待对端接受…",
|
"transfer.phase.waiting_accept": "等待对端接受…",
|
||||||
|
"transfer.phase.waiting_peer": "等待对端上线…",
|
||||||
|
"transfer.phase.peer_offline": "对端未上线",
|
||||||
"transfer.phase.ice_gathering": "收集网络候选…",
|
"transfer.phase.ice_gathering": "收集网络候选…",
|
||||||
"transfer.phase.ice_checking": "建立直连通道…",
|
"transfer.phase.ice_checking": "建立直连通道…",
|
||||||
"transfer.phase.ice_connected": "通道已连接",
|
"transfer.phase.ice_connected": "通道已连接",
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
"nav.themeToggle": "切換主題",
|
"nav.themeToggle": "切換主題",
|
||||||
"home.greeting": "你好,{{name}}",
|
"home.greeting": "你好,{{name}}",
|
||||||
"home.deviceList.title": "裝置",
|
"home.deviceList.title": "裝置",
|
||||||
|
"home.device.wakeable": "可喚醒",
|
||||||
"home.deviceList.onlineSuffix": "({{count}} 部線上)",
|
"home.deviceList.onlineSuffix": "({{count}} 部線上)",
|
||||||
"home.deviceList.showOffline": "顯示離線裝置({{count}})",
|
"home.deviceList.showOffline": "顯示離線裝置({{count}})",
|
||||||
"home.deviceList.hideOffline": "隱藏離線裝置",
|
"home.deviceList.hideOffline": "隱藏離線裝置",
|
||||||
@@ -256,6 +257,8 @@
|
|||||||
"transfer.mode.relay": "中繼",
|
"transfer.mode.relay": "中繼",
|
||||||
"transfer.phase.initializing": "正在初始化…",
|
"transfer.phase.initializing": "正在初始化…",
|
||||||
"transfer.phase.waiting_accept": "等待對端接受…",
|
"transfer.phase.waiting_accept": "等待對端接受…",
|
||||||
|
"transfer.phase.waiting_peer": "等待對端上線…",
|
||||||
|
"transfer.phase.peer_offline": "對端未上線",
|
||||||
"transfer.phase.ice_gathering": "收集網路候選…",
|
"transfer.phase.ice_gathering": "收集網路候選…",
|
||||||
"transfer.phase.ice_checking": "建立直連通道…",
|
"transfer.phase.ice_checking": "建立直連通道…",
|
||||||
"transfer.phase.ice_connected": "通道已連線",
|
"transfer.phase.ice_connected": "通道已連線",
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ struct RootView: View
|
|||||||
{
|
{
|
||||||
ForEach(engine.sendableDevices())
|
ForEach(engine.sendableDevices())
|
||||||
{ dev in
|
{ 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() }
|
Button(t("common.cancel"), role: .cancel) { engine.dismissShares() }
|
||||||
}
|
}
|
||||||
@@ -183,7 +183,7 @@ struct TransferListView: View
|
|||||||
{
|
{
|
||||||
ForEach(sendableDevices)
|
ForEach(sendableDevices)
|
||||||
{ dev in
|
{ dev in
|
||||||
Button(dev.name) { startSend(to: dev.name) }
|
Button(engine.sendLabel(dev)) { startSend(to: dev.name) }
|
||||||
}
|
}
|
||||||
Button(t("common.cancel"), role: .cancel) { }
|
Button(t("common.cancel"), role: .cancel) { }
|
||||||
}
|
}
|
||||||
@@ -777,7 +777,7 @@ struct ReceivedFilesView: View
|
|||||||
{
|
{
|
||||||
ForEach(engine.sendableDevices())
|
ForEach(engine.sendableDevices())
|
||||||
{ dev in
|
{ dev in
|
||||||
Button(dev.name) { send(to: dev.name) }
|
Button(engine.sendLabel(dev)) { send(to: dev.name) }
|
||||||
}
|
}
|
||||||
Button(t("common.cancel"), role: .cancel) { }
|
Button(t("common.cancel"), role: .cancel) { }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export function DeviceStrip(props: DeviceStripProps)
|
|||||||
[ devices, selfDeviceName ],
|
[ devices, selfDeviceName ],
|
||||||
);
|
);
|
||||||
const visible = useMemo(
|
const visible = useMemo(
|
||||||
() => (showOffline ? peers : peers.filter((d) => d.online)),
|
() => (showOffline ? peers : peers.filter((d) => d.online || isWakeable(d))),
|
||||||
[ peers, showOffline ],
|
[ peers, showOffline ],
|
||||||
);
|
);
|
||||||
const onlineCount = peers.filter((d) => d.online).length;
|
const onlineCount = peers.filter((d) => d.online).length;
|
||||||
@@ -103,7 +103,9 @@ function DevicePort(props: DevicePortProps)
|
|||||||
{
|
{
|
||||||
const { device, selected, onClick, onDropFile } = props;
|
const { device, selected, onClick, onDropFile } = props;
|
||||||
const [ dragOver, setDragOver ] = useState(false);
|
const [ dragOver, setDragOver ] = useState(false);
|
||||||
const disabled = !device.online;
|
// 离线但可唤醒(iOS)的设备仍可选作目标:发送时后端推送唤醒、发送端等其上线再传。
|
||||||
|
const wakeable = isWakeable(device);
|
||||||
|
const disabled = !device.online && !wakeable;
|
||||||
|
|
||||||
const handleDragOver = (e: DragEvent<HTMLButtonElement>) =>
|
const handleDragOver = (e: DragEvent<HTMLButtonElement>) =>
|
||||||
{
|
{
|
||||||
@@ -154,7 +156,9 @@ function DevicePort(props: DevicePortProps)
|
|||||||
<DynText>
|
<DynText>
|
||||||
{device.online
|
{device.online
|
||||||
? t("settings.online")
|
? t("settings.online")
|
||||||
: t("settings.lastSeen", { time: formatRelative(device.lastSeen) })}
|
: wakeable
|
||||||
|
? t("home.device.wakeable")
|
||||||
|
: t("settings.lastSeen", { time: formatRelative(device.lastSeen) })}
|
||||||
</DynText>
|
</DynText>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -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/...
|
// 后端目前所有 web 接入都写 type="browser";客户端时代会扩展为 macos/windows/...
|
||||||
function mapKind(type: string): DeviceKind
|
function mapKind(type: string): DeviceKind
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ interface OutgoingState
|
|||||||
src: FileSource;
|
src: FileSource;
|
||||||
p2p: P2PSession | null;
|
p2p: P2PSession | null;
|
||||||
fallbackTimer: number | 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;
|
abort: AbortController;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,7 +48,11 @@ export async function startOutgoingTransfer(
|
|||||||
const text = await initR.text().catch(() => initR.statusText);
|
const text = await initR.text().catch(() => initR.statusText);
|
||||||
throw new Error(`initiate ${initR.status}: ${text}`);
|
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({
|
useAppStore.getState().upsertTransfer({
|
||||||
sessionId,
|
sessionId,
|
||||||
@@ -53,7 +62,7 @@ export async function startOutgoingTransfer(
|
|||||||
state: "PENDING",
|
state: "PENDING",
|
||||||
peerName: receiverName,
|
peerName: receiverName,
|
||||||
startedAt: Date.now(),
|
startedAt: Date.now(),
|
||||||
phase: "waiting_accept",
|
phase: receiverOffline ? "waiting_peer" : "waiting_accept",
|
||||||
bytesTransferred: 0,
|
bytesTransferred: 0,
|
||||||
lastProgressAt: Date.now(),
|
lastProgressAt: Date.now(),
|
||||||
});
|
});
|
||||||
@@ -65,10 +74,27 @@ export async function startOutgoingTransfer(
|
|||||||
src,
|
src,
|
||||||
p2p: null,
|
p2p: null,
|
||||||
fallbackTimer: null,
|
fallbackTimer: null,
|
||||||
|
waitTimer: null,
|
||||||
|
waitUnsub: null,
|
||||||
abort,
|
abort,
|
||||||
};
|
};
|
||||||
outgoing.set(sessionId, state);
|
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
|
// Arm the fallback watchdog *before* any P2P work. p2pStartOutgoing is
|
||||||
// intentionally synchronous: if createOffer / setLocalDescription hang
|
// intentionally synchronous: if createOffer / setLocalDescription hang
|
||||||
// (e.g. injected stub during fallback testing) the timer must still fire.
|
// (e.g. injected stub during fallback testing) the timer must still fire.
|
||||||
@@ -88,7 +114,7 @@ export async function startOutgoingTransfer(
|
|||||||
}
|
}
|
||||||
}, ICE_TIMEOUT_MS);
|
}, ICE_TIMEOUT_MS);
|
||||||
|
|
||||||
const p2p = p2pStartOutgoing(sessionId, receiverName, src);
|
const p2p = p2pStartOutgoing(sessionId, peerName, src);
|
||||||
state.p2p = p2p;
|
state.p2p = p2p;
|
||||||
|
|
||||||
p2p.onState((s) =>
|
p2p.onState((s) =>
|
||||||
@@ -98,8 +124,53 @@ export async function startOutgoingTransfer(
|
|||||||
if (state.fallbackTimer) { clearTimeout(state.fallbackTimer); state.fallbackTimer = null; }
|
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<void>
|
async function triggerFallback(sessionId: string): Promise<void>
|
||||||
@@ -241,6 +312,8 @@ function cleanupOutgoing(sessionId: string): void
|
|||||||
const s = outgoing.get(sessionId);
|
const s = outgoing.get(sessionId);
|
||||||
if (!s) { return; }
|
if (!s) { return; }
|
||||||
if (s.fallbackTimer) { clearTimeout(s.fallbackTimer); }
|
if (s.fallbackTimer) { clearTimeout(s.fallbackTimer); }
|
||||||
|
if (s.waitTimer !== null) { clearTimeout(s.waitTimer); }
|
||||||
|
if (s.waitUnsub) { s.waitUnsub(); }
|
||||||
s.abort.abort();
|
s.abort.abort();
|
||||||
outgoing.delete(sessionId);
|
outgoing.delete(sessionId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export const enUS: Partial<TranslationDict> = {
|
|||||||
// ---- home -----------------------------------------------------------
|
// ---- home -----------------------------------------------------------
|
||||||
"home.greeting": "Hello, {{name}}",
|
"home.greeting": "Hello, {{name}}",
|
||||||
"home.deviceList.title": "Devices",
|
"home.deviceList.title": "Devices",
|
||||||
|
"home.device.wakeable": "Wakeable",
|
||||||
"home.deviceList.onlineSuffix": "({{count}} online)",
|
"home.deviceList.onlineSuffix": "({{count}} online)",
|
||||||
"home.deviceList.showOffline": "Show offline ({{count}})",
|
"home.deviceList.showOffline": "Show offline ({{count}})",
|
||||||
"home.deviceList.hideOffline": "Hide offline",
|
"home.deviceList.hideOffline": "Hide offline",
|
||||||
@@ -298,6 +299,8 @@ export const enUS: Partial<TranslationDict> = {
|
|||||||
// ---- client phase (real-time telemetry; tells "waiting" from "stuck") ----
|
// ---- client phase (real-time telemetry; tells "waiting" from "stuck") ----
|
||||||
"transfer.phase.initializing": "Initializing…",
|
"transfer.phase.initializing": "Initializing…",
|
||||||
"transfer.phase.waiting_accept": "Waiting for the other side to accept…",
|
"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_gathering": "Gathering network candidates…",
|
||||||
"transfer.phase.ice_checking": "Establishing direct tunnel…",
|
"transfer.phase.ice_checking": "Establishing direct tunnel…",
|
||||||
"transfer.phase.ice_connected": "Tunnel established",
|
"transfer.phase.ice_connected": "Tunnel established",
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export const zhCN = {
|
|||||||
// ---- 主页 ------------------------------------------------------------
|
// ---- 主页 ------------------------------------------------------------
|
||||||
"home.greeting": "你好,{{name}}",
|
"home.greeting": "你好,{{name}}",
|
||||||
"home.deviceList.title": "设备",
|
"home.deviceList.title": "设备",
|
||||||
|
"home.device.wakeable": "可唤醒",
|
||||||
"home.deviceList.onlineSuffix": "({{count}} 台在线)",
|
"home.deviceList.onlineSuffix": "({{count}} 台在线)",
|
||||||
"home.deviceList.showOffline": "显示离线设备({{count}})",
|
"home.deviceList.showOffline": "显示离线设备({{count}})",
|
||||||
"home.deviceList.hideOffline": "隐藏离线设备",
|
"home.deviceList.hideOffline": "隐藏离线设备",
|
||||||
@@ -294,6 +295,8 @@ export const zhCN = {
|
|||||||
// ---- 客户端 phase(实时遥测,区分等待中和卡住)----------------------
|
// ---- 客户端 phase(实时遥测,区分等待中和卡住)----------------------
|
||||||
"transfer.phase.initializing": "正在初始化…",
|
"transfer.phase.initializing": "正在初始化…",
|
||||||
"transfer.phase.waiting_accept": "等待对端接受…",
|
"transfer.phase.waiting_accept": "等待对端接受…",
|
||||||
|
"transfer.phase.waiting_peer": "等待对端上线…",
|
||||||
|
"transfer.phase.peer_offline": "对端未上线",
|
||||||
"transfer.phase.ice_gathering": "收集网络候选…",
|
"transfer.phase.ice_gathering": "收集网络候选…",
|
||||||
"transfer.phase.ice_checking": "建立直连通道…",
|
"transfer.phase.ice_checking": "建立直连通道…",
|
||||||
"transfer.phase.ice_connected": "通道已连接",
|
"transfer.phase.ice_connected": "通道已连接",
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ export const zhTW: Partial<TranslationDict> = {
|
|||||||
// ---- 首頁 ------------------------------------------------------------
|
// ---- 首頁 ------------------------------------------------------------
|
||||||
"home.greeting": "你好,{{name}}",
|
"home.greeting": "你好,{{name}}",
|
||||||
"home.deviceList.title": "裝置",
|
"home.deviceList.title": "裝置",
|
||||||
|
"home.device.wakeable": "可喚醒",
|
||||||
"home.deviceList.onlineSuffix": "({{count}} 部線上)",
|
"home.deviceList.onlineSuffix": "({{count}} 部線上)",
|
||||||
"home.deviceList.showOffline": "顯示離線裝置({{count}})",
|
"home.deviceList.showOffline": "顯示離線裝置({{count}})",
|
||||||
"home.deviceList.hideOffline": "隱藏離線裝置",
|
"home.deviceList.hideOffline": "隱藏離線裝置",
|
||||||
@@ -298,6 +299,8 @@ export const zhTW: Partial<TranslationDict> = {
|
|||||||
// ---- 客戶端 phase(即時遙測,區分等待中和卡住)---------------------
|
// ---- 客戶端 phase(即時遙測,區分等待中和卡住)---------------------
|
||||||
"transfer.phase.initializing": "正在初始化…",
|
"transfer.phase.initializing": "正在初始化…",
|
||||||
"transfer.phase.waiting_accept": "等待對端接受…",
|
"transfer.phase.waiting_accept": "等待對端接受…",
|
||||||
|
"transfer.phase.waiting_peer": "等待對端上線…",
|
||||||
|
"transfer.phase.peer_offline": "對端未上線",
|
||||||
"transfer.phase.ice_gathering": "收集網路候選…",
|
"transfer.phase.ice_gathering": "收集網路候選…",
|
||||||
"transfer.phase.ice_checking": "建立直連通道…",
|
"transfer.phase.ice_checking": "建立直連通道…",
|
||||||
"transfer.phase.ice_connected": "通道已連線",
|
"transfer.phase.ice_connected": "通道已連線",
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ export type ThemeMode = "light" | "dark" | "system";
|
|||||||
export type TransferPhase =
|
export type TransferPhase =
|
||||||
| "initializing" // 刚 /initiate / 收到 transfer:incoming,未起 WebRTC
|
| "initializing" // 刚 /initiate / 收到 transfer:incoming,未起 WebRTC
|
||||||
| "waiting_accept" // sender 等 /accept,receiver 等自己 /accept 回执
|
| "waiting_accept" // sender 等 /accept,receiver 等自己 /accept 回执
|
||||||
|
| "waiting_peer" // 接收端离线/后台:已推送唤醒,发送端等其上线再发起 P2P
|
||||||
|
| "peer_offline" // 等待窗口内对端未上线,传输失败
|
||||||
| "ice_gathering" // RTCPeerConnection 正在收集候选
|
| "ice_gathering" // RTCPeerConnection 正在收集候选
|
||||||
| "ice_checking" // 候选已交换,连通性检查中
|
| "ice_checking" // 候选已交换,连通性检查中
|
||||||
| "ice_connected" // ICE 通过,DC 即将打开
|
| "ice_connected" // ICE 通过,DC 即将打开
|
||||||
|
|||||||
Reference in New Issue
Block a user