cdrop — 跨 OS 剪贴板与文件传输服务
Commilitia Drop:自托管的跨设备剪贴板同步与点对点文件传输。 - 后端 Go(chi / SQLite WAL / SSE Hub / WebRTC signaling + 状态机 / Relay ring buffer),编译进单个 distroless 镜像(前端 go:embed)。 - 前端 React + TanStack Router + Zustand,自实现 SSE + WebRTC P2P,NAT 受阻时回退服务端中继;聚珍(Juzhen)CJK 综合排版。 - 桌面端 Wails v2(macOS / Windows),瘦客户端复用 web。 - 鉴权 OIDC PKCE(自建 Casdoor 等),refresh_token 信封加密存系统密钥库;iOS Shortcut 用 HS256 scoped token。 架构文档与变更记录见 docs 分支(PROJECT_BRIEF / FRONTEND_DESIGN / CHANGELOG)。 本次为公开发布初始提交:完整开发历史(含部署细节)留存于私有归档,公开仓库自此干净起步。
This commit is contained in:
@@ -0,0 +1,242 @@
|
||||
package hub
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"commilitia.net/cdrop/internal/db"
|
||||
)
|
||||
|
||||
// DeviceLister is the subset of *db.Queries the hub needs.
|
||||
// Declared as interface so tests can inject a fake.
|
||||
type DeviceLister interface {
|
||||
ListDevicesByUser(ctx context.Context, userID string) ([]db.Device, error)
|
||||
}
|
||||
|
||||
// Event is what the hub fan-outs to SSE clients.
|
||||
type Event struct {
|
||||
Type string `json:"-"`
|
||||
Data any `json:"-"`
|
||||
}
|
||||
|
||||
// PresenceDevice is the per-device entry in a `presence` event payload.
|
||||
// Field names align with brief §5: { name, type, online }; last_seen 是
|
||||
// 设置页展示「上次活跃」需要的扩展字段。
|
||||
type PresenceDevice struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Online bool `json:"online"`
|
||||
LastSeen int64 `json:"last_seen"`
|
||||
}
|
||||
|
||||
const clientBuffer = 32
|
||||
|
||||
// Client is a single live SSE connection.
|
||||
type Client struct {
|
||||
UserID string
|
||||
DeviceID string
|
||||
ch chan Event
|
||||
}
|
||||
|
||||
func (c *Client) Events() <-chan Event { return c.ch }
|
||||
|
||||
// Hub is the in-memory presence + signaling fan-out.
|
||||
//
|
||||
// Concurrency: a single sync.RWMutex protects the user→device map.
|
||||
// Sends to client channels are non-blocking — slow consumers drop events
|
||||
// rather than stall the broadcaster.
|
||||
type Hub struct {
|
||||
mu sync.RWMutex
|
||||
users map[string]map[string]*Client
|
||||
|
||||
devices DeviceLister
|
||||
grace time.Duration
|
||||
|
||||
closed bool
|
||||
}
|
||||
|
||||
func New(devices DeviceLister) *Hub {
|
||||
return &Hub{
|
||||
users: map[string]map[string]*Client{},
|
||||
devices: devices,
|
||||
grace: 30 * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
// Connect registers a new SSE client and announces presence to the user's other devices.
|
||||
// If a client for (userID, deviceID) already exists (e.g., a tab refresh), its channel is closed.
|
||||
func (h *Hub) Connect(ctx context.Context, userID, deviceID string) *Client {
|
||||
c := &Client{
|
||||
UserID: userID,
|
||||
DeviceID: deviceID,
|
||||
ch: make(chan Event, clientBuffer),
|
||||
}
|
||||
|
||||
h.mu.Lock()
|
||||
if h.users[userID] == nil {
|
||||
h.users[userID] = map[string]*Client{}
|
||||
}
|
||||
if old, ok := h.users[userID][deviceID]; ok {
|
||||
close(old.ch)
|
||||
}
|
||||
h.users[userID][deviceID] = c
|
||||
h.mu.Unlock()
|
||||
|
||||
go h.publishPresence(ctx, userID)
|
||||
return c
|
||||
}
|
||||
|
||||
// Disconnect removes a client and, after a grace period, re-publishes presence
|
||||
// to peers if the client has not reconnected.
|
||||
func (h *Hub) Disconnect(c *Client) {
|
||||
h.mu.Lock()
|
||||
if active, ok := h.users[c.UserID][c.DeviceID]; ok && active == c {
|
||||
delete(h.users[c.UserID], c.DeviceID)
|
||||
if len(h.users[c.UserID]) == 0 {
|
||||
delete(h.users, c.UserID)
|
||||
}
|
||||
}
|
||||
h.mu.Unlock()
|
||||
|
||||
time.AfterFunc(h.grace, func() {
|
||||
h.mu.RLock()
|
||||
_, stillOnline := h.users[c.UserID][c.DeviceID]
|
||||
h.mu.RUnlock()
|
||||
if stillOnline {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
h.publishPresence(ctx, c.UserID)
|
||||
})
|
||||
}
|
||||
|
||||
// SendTo routes an event to a specific (userID, deviceID). Reports whether
|
||||
// the target was online and the event was queued.
|
||||
func (h *Hub) SendTo(userID, deviceID string, ev Event) bool {
|
||||
h.mu.RLock()
|
||||
c, ok := h.users[userID][deviceID]
|
||||
h.mu.RUnlock()
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
select {
|
||||
case c.ch <- ev:
|
||||
return true
|
||||
default:
|
||||
slog.Warn("client buffer full; dropping event",
|
||||
"user", userID, "device", deviceID, "type", ev.Type)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Broadcast fans an event out to every live client of a user.
|
||||
func (h *Hub) Broadcast(userID string, ev Event) {
|
||||
clients := h.snapshotClients(userID)
|
||||
for _, c := range clients {
|
||||
select {
|
||||
case c.ch <- ev:
|
||||
default:
|
||||
slog.Warn("client buffer full; dropping event",
|
||||
"user", c.UserID, "device", c.DeviceID, "type", ev.Type)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Online reports whether a (userID, deviceID) currently has a live SSE.
|
||||
func (h *Hub) Online(userID, deviceID string) bool {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
_, ok := h.users[userID][deviceID]
|
||||
return ok
|
||||
}
|
||||
|
||||
// Kick force-removes a (userID, deviceID) entry from the hub and closes its
|
||||
// event channel; the SSE handler exits on the next iteration. 与 Connect 的
|
||||
// 旧通道关闭模式一致(与并发 SendTo 之间存在极窄竞态,但 Kick 罕用,可接受)。
|
||||
func (h *Hub) Kick(userID, deviceID string) {
|
||||
h.mu.Lock()
|
||||
c, ok := h.users[userID][deviceID]
|
||||
if ok {
|
||||
delete(h.users[userID], deviceID)
|
||||
if len(h.users[userID]) == 0 {
|
||||
delete(h.users, userID)
|
||||
}
|
||||
}
|
||||
h.mu.Unlock()
|
||||
if ok {
|
||||
close(c.ch)
|
||||
}
|
||||
}
|
||||
|
||||
// PublishPresence broadcasts the user's current device list to all live
|
||||
// clients. 用于在变更设备集合后(如 DELETE /api/devices/{name})立刻广播,
|
||||
// 而不是等待 Disconnect 的宽限期。
|
||||
func (h *Hub) PublishPresence(ctx context.Context, userID string) {
|
||||
h.publishPresence(ctx, userID)
|
||||
}
|
||||
|
||||
// Close drops every connected client. Used on graceful shutdown.
|
||||
func (h *Hub) Close() {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if h.closed {
|
||||
return
|
||||
}
|
||||
h.closed = true
|
||||
for _, devices := range h.users {
|
||||
for _, c := range devices {
|
||||
close(c.ch)
|
||||
}
|
||||
}
|
||||
h.users = map[string]map[string]*Client{}
|
||||
}
|
||||
|
||||
func (h *Hub) snapshotClients(userID string) []*Client {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
src := h.users[userID]
|
||||
out := make([]*Client, 0, len(src))
|
||||
for _, c := range src {
|
||||
out = append(out, c)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (h *Hub) publishPresence(ctx context.Context, userID string) {
|
||||
devs, err := h.devices.ListDevicesByUser(ctx, userID)
|
||||
if err != nil {
|
||||
slog.Error("presence: list devices failed", "user", userID, "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
h.mu.RLock()
|
||||
live := h.users[userID]
|
||||
items := make([]PresenceDevice, 0, len(devs))
|
||||
for _, d := range devs {
|
||||
_, online := live[d.Name]
|
||||
items = append(items, PresenceDevice{
|
||||
Name: d.Name, Type: d.Type, Online: online, LastSeen: d.LastSeen,
|
||||
})
|
||||
}
|
||||
clients := make([]*Client, 0, len(live))
|
||||
for _, c := range live {
|
||||
clients = append(clients, c)
|
||||
}
|
||||
h.mu.RUnlock()
|
||||
|
||||
ev := Event{
|
||||
Type: "presence",
|
||||
Data: map[string]any{"devices": items},
|
||||
}
|
||||
for _, c := range clients {
|
||||
select {
|
||||
case c.ch <- ev:
|
||||
default:
|
||||
slog.Warn("presence: client buffer full; dropping",
|
||||
"user", c.UserID, "device", c.DeviceID)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
package hub
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"commilitia.net/cdrop/internal/db"
|
||||
)
|
||||
|
||||
// fakeLister returns a fixed device list and counts calls.
|
||||
type fakeLister struct {
|
||||
devices []db.Device
|
||||
calls atomic.Int32
|
||||
}
|
||||
|
||||
func (f *fakeLister) ListDevicesByUser(_ context.Context, _ string) ([]db.Device, error) {
|
||||
f.calls.Add(1)
|
||||
return f.devices, nil
|
||||
}
|
||||
|
||||
func waitForEvent(t *testing.T, c *Client, want string, timeout time.Duration) Event {
|
||||
t.Helper()
|
||||
select {
|
||||
case ev, ok := <-c.Events():
|
||||
if !ok {
|
||||
t.Fatalf("event channel closed; wanted type=%q", want)
|
||||
}
|
||||
if want != "" && ev.Type != want {
|
||||
t.Fatalf("event type: got %q, want %q", ev.Type, want)
|
||||
}
|
||||
return ev
|
||||
case <-time.After(timeout):
|
||||
t.Fatalf("timeout waiting for event type=%q", want)
|
||||
}
|
||||
return Event{}
|
||||
}
|
||||
|
||||
func TestConnectFiresPresenceEvent(t *testing.T) {
|
||||
lister := &fakeLister{
|
||||
devices: []db.Device{{UserID: "alice", Name: "tab-1", Type: "browser", LastSeen: 1}},
|
||||
}
|
||||
h := New(lister)
|
||||
defer h.Close()
|
||||
|
||||
c := h.Connect(context.Background(), "alice", "tab-1")
|
||||
defer h.Disconnect(c)
|
||||
|
||||
ev := waitForEvent(t, c, "presence", time.Second)
|
||||
data, _ := ev.Data.(map[string]any)
|
||||
devs, _ := data["devices"].([]PresenceDevice)
|
||||
if len(devs) != 1 || devs[0].Name != "tab-1" || !devs[0].Online {
|
||||
t.Errorf("presence devices: %+v", devs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecondClientSeesFirstAsOnline(t *testing.T) {
|
||||
lister := &fakeLister{
|
||||
devices: []db.Device{
|
||||
{UserID: "alice", Name: "tab-1", Type: "browser", LastSeen: 1},
|
||||
{UserID: "alice", Name: "tab-2", Type: "browser", LastSeen: 2},
|
||||
},
|
||||
}
|
||||
h := New(lister)
|
||||
defer h.Close()
|
||||
|
||||
c1 := h.Connect(context.Background(), "alice", "tab-1")
|
||||
defer h.Disconnect(c1)
|
||||
_ = waitForEvent(t, c1, "presence", time.Second)
|
||||
|
||||
c2 := h.Connect(context.Background(), "alice", "tab-2")
|
||||
defer h.Disconnect(c2)
|
||||
|
||||
// c2 receives its own presence (announced on Connect).
|
||||
ev := waitForEvent(t, c2, "presence", time.Second)
|
||||
devs, _ := ev.Data.(map[string]any)["devices"].([]PresenceDevice)
|
||||
if len(devs) != 2 {
|
||||
t.Fatalf("expected 2 devices in presence, got %+v", devs)
|
||||
}
|
||||
online := map[string]bool{}
|
||||
for _, d := range devs {
|
||||
online[d.Name] = d.Online
|
||||
}
|
||||
if !online["tab-1"] || !online["tab-2"] {
|
||||
t.Errorf("both tabs should be online: %+v", online)
|
||||
}
|
||||
|
||||
// c1 also gets presence diff for c2's arrival.
|
||||
ev = waitForEvent(t, c1, "presence", time.Second)
|
||||
devs, _ = ev.Data.(map[string]any)["devices"].([]PresenceDevice)
|
||||
if len(devs) != 2 {
|
||||
t.Errorf("c1 presence after tab-2 arrival: got %d devices, want 2", len(devs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendToRoutesEvent(t *testing.T) {
|
||||
lister := &fakeLister{}
|
||||
h := New(lister)
|
||||
defer h.Close()
|
||||
|
||||
c := h.Connect(context.Background(), "alice", "tab-1")
|
||||
defer h.Disconnect(c)
|
||||
_ = waitForEvent(t, c, "presence", time.Second)
|
||||
|
||||
if !h.SendTo("alice", "tab-1", Event{Type: "signal", Data: map[string]any{"x": 1}}) {
|
||||
t.Fatal("SendTo to live client should report true")
|
||||
}
|
||||
ev := waitForEvent(t, c, "signal", time.Second)
|
||||
if ev.Data.(map[string]any)["x"] != 1 {
|
||||
t.Errorf("payload: %+v", ev.Data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendToOfflineDeviceReportsFalse(t *testing.T) {
|
||||
h := New(&fakeLister{})
|
||||
defer h.Close()
|
||||
if h.SendTo("ghost-user", "ghost-device", Event{Type: "signal"}) {
|
||||
t.Error("SendTo to offline target should report false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconnectClosesOldChannel(t *testing.T) {
|
||||
h := New(&fakeLister{})
|
||||
defer h.Close()
|
||||
|
||||
old := h.Connect(context.Background(), "alice", "tab-1")
|
||||
// drain the initial presence so we can detect close
|
||||
<-old.Events()
|
||||
|
||||
_ = h.Connect(context.Background(), "alice", "tab-1")
|
||||
|
||||
select {
|
||||
case _, ok := <-old.Events():
|
||||
if ok {
|
||||
t.Error("old channel should be closed on reconnect from same (user, device)")
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("timeout waiting for old channel close")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisconnectRemovesFromOnlineSet(t *testing.T) {
|
||||
h := New(&fakeLister{
|
||||
devices: []db.Device{{UserID: "alice", Name: "tab-1", Type: "browser"}},
|
||||
})
|
||||
defer h.Close()
|
||||
|
||||
c := h.Connect(context.Background(), "alice", "tab-1")
|
||||
if !h.Online("alice", "tab-1") {
|
||||
t.Fatal("client should be online after Connect")
|
||||
}
|
||||
h.Disconnect(c)
|
||||
if h.Online("alice", "tab-1") {
|
||||
t.Error("client should not be online after Disconnect")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGracePeriod uses a tiny grace so the test runs fast.
|
||||
func TestGracePeriodDelaysOfflinePresence(t *testing.T) {
|
||||
h := New(&fakeLister{
|
||||
devices: []db.Device{
|
||||
{UserID: "alice", Name: "tab-1", Type: "browser"},
|
||||
{UserID: "alice", Name: "tab-2", Type: "browser"},
|
||||
},
|
||||
})
|
||||
h.grace = 100 * time.Millisecond
|
||||
defer h.Close()
|
||||
|
||||
c1 := h.Connect(context.Background(), "alice", "tab-1")
|
||||
defer h.Disconnect(c1)
|
||||
c2 := h.Connect(context.Background(), "alice", "tab-2")
|
||||
// Drain initial presence frames
|
||||
_ = waitForEvent(t, c1, "presence", time.Second)
|
||||
_ = waitForEvent(t, c1, "presence", time.Second)
|
||||
_ = waitForEvent(t, c2, "presence", time.Second)
|
||||
|
||||
h.Disconnect(c2)
|
||||
// Grace 100ms: c1 should receive presence after the grace window with tab-2 offline.
|
||||
select {
|
||||
case ev := <-c1.Events():
|
||||
if ev.Type != "presence" {
|
||||
t.Fatalf("expected presence after grace, got %q", ev.Type)
|
||||
}
|
||||
devs := ev.Data.(map[string]any)["devices"].([]PresenceDevice)
|
||||
var tab2Online bool
|
||||
for _, d := range devs {
|
||||
if d.Name == "tab-2" {
|
||||
tab2Online = d.Online
|
||||
}
|
||||
}
|
||||
if tab2Online {
|
||||
t.Error("tab-2 should be offline in post-grace presence")
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("timeout waiting for post-grace presence")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user