设备名与令牌解耦 + iOS Broker 应用内登录 / 消息 / 图库 / 记录持久化 + 三端 bug 修复

设备名与令牌解耦(presence 按 device_id 键控):
- hub 主键改稳定 device_id(presence / online / kick 用之),peer 路由仍按设备名经 hub 内 clientFor 解析——不动传输核心;Client 加 Name + 连接键,PresenceDevice 加 device_id,sse 传 claims.DeviceID,publishPresence 按 device_id 判在线、name 取自 devices 表
- 改名走已有 PATCH /api/devices/{device_id}(不重铸会话、不换 token、不中断 SSE)+ hub.Rename 更新活跃连接名 + 重广播 presence,全端即时无重复项;sessions 列表 name 用 devices 表覆盖 broker Label;web 改名从重铸迁到 PATCH,iOS 设置页即时改名;DeviceItem / DeviceInfo 按 device_id 键控判本机
- 移除「需移除两次」:publishPresence 不再把「有 device_id 但无 devices 行」的 live 连接当在线(吊销后用未过期 token 重连的僵尸),只显示 code-less 连接;合法重登新建行故正常
- revoke 对无 broker 会话的本地行也清(清掉合成测试 / 幽灵设备);跨端吊销 / 断连一律按 device_id Kick

iOS Broker 应用内登录(免扫码):
- BrokerLogin.swift:ASWebAuthenticationSession 跑 broker /device/authorize + /device/token 的 PKCE 设备授权流(对齐桌面 loopback,自定义 scheme cdrop://auth-callback 回调)→ /api/auth/device-session 代铸出带真名 + 稳定 device_id 的设备会话;DeviceIDStore 跨登录复用 device_id 杜绝重登重复
- LoginView 主登录改 broker 账号登录、扫码降级为可展开备选

iOS 功能补全:
- 设备间消息(引擎 main.ts 单条增量过桥 + onNativeEvent sendMessage;EngineController 消息状态 + 新 MessagesView 会话 UI;后端 /api/message 已就绪)
- 图库发送(PhotosPicker 文件 / 图库菜单 → stagePhotoData → 复用 cdrop-file 发送链路;NSPhotoLibraryUsageDescription)
- 传输历史 / 消息记录持久化(RecordsStore 落 Application Support JSON,跨重启存活)+ 长按删除 + 清空(二次确认)
- 进行中传输主动取消 / 立即切中继(详情页两按钮,复用引擎桥命令)
- 身份愈合(引擎 /api/me 取真实显示名经 identityUpdated 回报 Keychain,修扫码登录「用户显示 UUID」)
- 触发本地网络权限(NWBrowser 浏览 _cdrop._tcp)使 WKWebView WebRTC 取 host 候选

iOS 发送 / 启动修复:
- 发送在读文件即失败:引擎在 https 源下 fetch("cdrop-file://") 跨源 + Range 头触发 CORS 预检被拦 → 改走原生桥 bridgeFileSource / readFileSlice(512 KiB 分块 base64),绕开 CORS、整文件仍不整体进内存
- 冷启重开「missing injected session」:EngineWebView.makeUIView 在 boot 注入前确定性接好 auth,消除注入竞态

桌面 bug 修复:
- 登出静默失败:window.confirm 在 Wails WebView 失效(返回 falsy 致动作被静默跳过)→ 跨平台 DOM 确认弹窗(utils/confirm 的 Promise 式 confirmDialog + ui/ConfirmHost 的 Mantine Modal)取代之;登出改 await 先吊销 broker 会话再清本地(桌面 clearDesktopSession 会丢 Go 侧 refresh 的竞态)
- 扫 iOS 登录二维码被拒「不是本站的码」:wails:// 源永不等 https 站点源 → 桌面放宽 parseLinkApprovalUrl 的 origin 等值校验(仍留 /link 路径 + r/c;批准 r/c 一律发往本端后端、外站码只 404,无开放重定向)
- macOS 本地网络权限触发(localnetwork_darwin NWBrowser + Info.plist 键);相机修复(NSCameraUsageDescription)

web / 跨端:
- presence 透传 device_id + 离线检测按 device_id;会话列表随 presence 防抖重拉(已登出设备自动从列表消失,不再需手动刷新);移除设备 / 清剪贴板 / 撤销令牌 / 登出均改 DOM 确认弹窗

分发 / 签名:
- 真机签名从手动 scaffold 改自动 provisioning(删 Signing.xcconfig,project.yml 去 profile specifier,just ios-device 用 -allowProvisioningUpdates + ASC 团队 key);App Group 改名 group.net.commilitia.Commilitia-Drop(含 entitlements)
- hub 测试加 device_id 键控 + 改名解耦回归(-race)
This commit is contained in:
2026-06-27 03:50:04 +08:00
parent 44096069a7
commit 127070e226
51 changed files with 1655 additions and 227 deletions
+7 -1
View File
@@ -39,7 +39,7 @@ func (s *Server) handleDevices(w http.ResponseWriter, r *http.Request) {
Name: d.Name,
Type: d.Type,
Tier: d.Tier,
Online: s.hub.Online(claims.UserID, d.Name),
Online: s.hub.Online(claims.UserID, d.DeviceID),
LastSeen: d.LastSeen,
})
}
@@ -105,5 +105,11 @@ func (s *Server) handleRenameDevice(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "device not found"})
return
}
// Decouple: the broker session (token) is untouched — only the device's display name
// changes. Update the live hub entry's name (kept keyed by the stable device_id) and
// re-broadcast presence so every peer sees the new name at once, with no re-login, no
// session re-mint, and no duplicate device row.
s.hub.Rename(claims.UserID, deviceID, name)
s.hub.PublishPresence(r.Context(), claims.UserID)
w.WriteHeader(http.StatusNoContent)
}
+1 -1
View File
@@ -258,7 +258,7 @@ func (s *Server) handleDisconnect(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing device"})
return
}
s.hub.Kick(claims.UserID, deviceName)
s.hub.Kick(claims.UserID, claims.DeviceID, deviceName)
s.hub.PublishPresence(r.Context(), claims.UserID)
w.WriteHeader(http.StatusNoContent)
}
+34 -7
View File
@@ -69,10 +69,16 @@ func (s *Server) handleSessionsList(w http.ResponseWriter, r *http.Request) {
// logged-out device's row is reaped there, not on this read path — this GET stays
// side-effect-free, and the session list itself is always R1-authoritative regardless of
// any stale cache row (the row only ever supplies a type for a device that is in R1).
// device_id -> cached type + live name. The name overlay is what keeps the session list
// consistent after a decoupled rename: the broker Label is only set at mint time and does
// not follow a PATCH /api/devices rename, so we prefer the devices-table name (the same
// source presence uses) and fall back to the broker Label only for a row-less session.
typeByID := map[string]string{}
nameByID := map[string]string{}
if devs, err := s.queries.ListDevicesByUser(r.Context(), claims.UserID); err == nil {
for _, d := range devs {
typeByID[d.DeviceID] = d.Type
nameByID[d.DeviceID] = d.Name
}
}
@@ -88,6 +94,10 @@ func (s *Server) handleSessionsList(w http.ResponseWriter, r *http.Request) {
if typ == "" {
typ = "browser"
}
name := nameByID[sess.Meta]
if name == "" {
name = sess.Label
}
scope := "full"
if jwtauth.ScopeTier(sess.Scope) == "guest" {
scope = "guest"
@@ -95,11 +105,11 @@ func (s *Server) handleSessionsList(w http.ResponseWriter, r *http.Request) {
out = append(out, sessionView{
ID: sess.Meta,
DeviceID: sess.Meta,
DeviceName: sess.Label,
DeviceName: name,
Kind: typ,
Scope: scope,
Current: sess.Meta == claims.DeviceID,
Online: s.hub.Online(claims.UserID, sess.Label),
Online: s.hub.Online(claims.UserID, sess.Meta),
CreatedAt: sess.CreatedAt,
LastUsedAt: sess.LastUsedAt,
})
@@ -133,9 +143,11 @@ func (s *Server) handleSessionRevoke(w http.ResponseWriter, r *http.Request) {
// pruned cache row still revokes correctly and stays authorized to this user.
func (s *Server) revokeDevice(r *http.Request, userID, deviceID string) (int, bool) {
sid, name := "", ""
localRowOwned := false
if dev, err := s.queries.GetDevice(r.Context(), deviceID); err == nil && dev.UserID == userID {
sid = dev.BrokerSid
name = dev.Name
localRowOwned = true
}
if sid == "" {
sessions, err := s.broker.ListSessions(r.Context(), userID)
@@ -152,8 +164,22 @@ func (s *Server) revokeDevice(r *http.Request, userID, deviceID string) (int, bo
}
}
if sid == "" {
// Not this user's device, or already gone from both the cache and the broker.
return http.StatusNotFound, false
// No broker session for this device_id. If we still own a local cache row, it is a stale /
// phantom entry — a synthetic test device (the Diag residue) or a row whose broker session
// is long gone. Drop the local row + kick + republish so the user can always clear such a
// device from their list; only a device_id we own nothing for is a genuine 404.
if !localRowOwned {
return http.StatusNotFound, false
}
if _, err := s.queries.DeleteDevice(r.Context(), db.DeleteDeviceParams{
DeviceID: deviceID,
UserID: userID,
}); err != nil {
slog.Warn("delete phantom device cache failed", "err", err, "user", userID, "device", deviceID)
}
s.hub.Kick(userID, deviceID, name)
s.hub.PublishPresence(r.Context(), userID)
return http.StatusNoContent, true
}
// Revoke the broker session first so the device can't refresh; a 404 (already gone) is
// idempotent success inside RevokeSession.
@@ -169,9 +195,10 @@ func (s *Server) revokeDevice(r *http.Request, userID, deviceID string) (int, bo
// and the next list-prune clean it). Report success so the client sees the logout.
slog.Warn("delete device cache failed", "err", err, "user", userID, "device", deviceID)
}
if name != "" {
s.hub.Kick(userID, name)
}
// Kick by the stable device_id (the hub key); name rides along for the code-less fallback
// path inside Kick. This makes cross-device revoke land reliably (the prior name-keyed Kick
// could miss a renamed device, leaving it able to keep refreshing — the "移除失败" symptom).
s.hub.Kick(userID, deviceID, name)
s.hub.PublishPresence(r.Context(), userID)
return http.StatusNoContent, true
}
+4 -1
View File
@@ -51,7 +51,10 @@ func (s *Server) handleEvents(w http.ResponseWriter, r *http.Request) {
}
flusher.Flush()
client := s.hub.Connect(r.Context(), claims.UserID, deviceName, deviceType)
// 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)
+101 -35
View File
@@ -23,8 +23,10 @@ type Event struct {
// PresenceDevice is the per-device entry in a `presence` event payload.
// Field names align with brief §5: { name, type, online }; last_seen 是
// 设置页展示「上次活跃」需要的扩展字段。
// 设置页展示「上次活跃」需要的扩展字段。device_id 是稳定标识:name 与令牌解耦后,
// 前端按它去重 / 判本机 / 寻址,改名只换 name 字段而 device_id 不变(避免改名生重复项)。
type PresenceDevice struct {
DeviceID string `json:"device_id"`
Name string `json:"name"`
Type string `json:"type"`
Online bool `json:"online"`
@@ -37,11 +39,28 @@ const clientBuffer = 32
type Client struct {
UserID string
DeviceID string
// Name 是连接声明的设备名(X-Device-Name)。presence 的权威 name 来自 devices 表(按
// device_id 查,改名即时反映);本字段仅用于 (1) 无 devices 行的 live 连接(代铸前的
// 全局 SSO 浏览器)的 presence 兜底标签,(2) 按 name 寻址时的解析(见 clientFor)。
Name string
// Type is the client-declared device type (browser / macos / windows / linux / ios).
// It lets presence label a live device that has no devices-table row (a global-SSO
// browser or a broker-authenticated desktop).
Type string
ch chan Event
// key 是 users[userID] 这一级 map 的键:有 device_id 用 device_id(稳定,改名不变),
// 无(代铸前浏览器)回落 "name:"+name。Disconnect 据此精确摘除自身。
key string
ch chan Event
}
// connKey 计算一个 live 连接在 hub map 里的键:device_id 优先(稳定标识,改名不变,故改名
// 不再产生孤儿键 / 重复项 —— 这是「设备名与令牌解耦」的服务端命门);无 device_id(代铸前
// 的全局 SSO 浏览器)回落 "name:"+name,与 device_id 命名空间不冲突。
func connKey(deviceID, name string) string {
if deviceID != "" {
return deviceID
}
return "name:" + name
}
func (c *Client) Events() <-chan Event { return c.ch }
@@ -70,14 +89,18 @@ func New(devices DeviceLister) *Hub {
}
// 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.
// deviceType is the caller's declared device type, surfaced in presence for a live device
// that has no devices-table row.
func (h *Hub) Connect(ctx context.Context, userID, deviceID, deviceType string) *Client {
// Keyed by the connection key (device_id when present): a reconnect of the SAME device — even
// after a rename, since device_id is stable — replaces its own entry (its channel is closed)
// rather than orphaning the old one. deviceName is the connection-declared name (presence
// fallback + name addressing); deviceType is surfaced for a live device with no devices-table row.
func (h *Hub) Connect(ctx context.Context, userID, deviceID, deviceName, deviceType string) *Client {
key := connKey(deviceID, deviceName)
c := &Client{
UserID: userID,
DeviceID: deviceID,
Name: deviceName,
Type: deviceType,
key: key,
ch: make(chan Event, clientBuffer),
}
@@ -85,10 +108,10 @@ func (h *Hub) Connect(ctx context.Context, userID, deviceID, deviceType string)
if h.users[userID] == nil {
h.users[userID] = map[string]*Client{}
}
if old, ok := h.users[userID][deviceID]; ok {
if old, ok := h.users[userID][key]; ok {
close(old.ch)
}
h.users[userID][deviceID] = c
h.users[userID][key] = c
h.mu.Unlock()
go h.publishPresence(ctx, userID)
@@ -99,8 +122,8 @@ func (h *Hub) Connect(ctx context.Context, userID, deviceID, deviceType string)
// 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 active, ok := h.users[c.UserID][c.key]; ok && active == c {
delete(h.users[c.UserID], c.key)
if len(h.users[c.UserID]) == 0 {
delete(h.users, c.UserID)
}
@@ -109,7 +132,7 @@ func (h *Hub) Disconnect(c *Client) {
time.AfterFunc(h.grace, func() {
h.mu.RLock()
_, stillOnline := h.users[c.UserID][c.DeviceID]
_, stillOnline := h.users[c.UserID][c.key]
h.mu.RUnlock()
if stillOnline {
return
@@ -120,18 +143,21 @@ func (h *Hub) Disconnect(c *Client) {
})
}
// SendTo routes an event to a specific (userID, deviceID). Reports whether
// the target was online and the event was queued.
// SendTo routes an event to one of the user's live clients addressed by `target`. The
// target may be a stable device_id (direct map hit) OR a device name — peer addressing
// across cdrop (signaling / messages / transfers) is by name, so this resolves a name to
// its live client via clientFor while presence/online/kick key by the stable device_id.
// Reports whether the target was online and the event was queued.
//
// The send happens while still holding the read lock so it can never race a Kick / Connect-
// replace / Close that closes the channel (those hold the write lock): a send on a closed
// channel panics even inside a select, so close-vs-send must be mutually exclusive. The send
// is non-blocking (select default), so holding the read lock across it is brief.
func (h *Hub) SendTo(userID, deviceID string, ev Event) bool {
func (h *Hub) SendTo(userID, target string, ev Event) bool {
h.mu.RLock()
defer h.mu.RUnlock()
c, ok := h.users[userID][deviceID]
if !ok {
c := h.clientFor(userID, target)
if c == nil {
return false
}
select {
@@ -139,11 +165,27 @@ func (h *Hub) SendTo(userID, deviceID string, ev Event) bool {
return true
default:
slog.Warn("client buffer full; dropping event",
"user", userID, "device", deviceID, "type", ev.Type)
"user", userID, "device", target, "type", ev.Type)
return false
}
}
// clientFor resolves `target` (device_id or device name) to a live client. Caller holds at
// least the read lock. A direct map hit handles device_id (and the "name:"+name fallback key
// of a code-less browser is never addressed directly); otherwise a scan matches the current
// display name — so a renamed device is reachable by its NEW name as soon as Rename lands.
func (h *Hub) clientFor(userID, target string) *Client {
if c, ok := h.users[userID][target]; ok {
return c
}
for _, c := range h.users[userID] {
if c.Name == target {
return c
}
}
return nil
}
// Broadcast fans an event out to every live client of a user. The non-blocking sends run
// under the read lock so they can't race a concurrent channel close (see SendTo).
func (h *Hub) Broadcast(userID string, ev Event) {
@@ -159,7 +201,7 @@ func (h *Hub) Broadcast(userID string, ev Event) {
}
}
// Online reports whether a (userID, deviceID) currently has a live SSE.
// Online reports whether a device (by stable device_id) currently has a live SSE.
func (h *Hub) Online(userID, deviceID string) bool {
h.mu.RLock()
defer h.mu.RUnlock()
@@ -167,17 +209,34 @@ func (h *Hub) Online(userID, deviceID string) bool {
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. The close happens UNDER the write lock — the
// same discipline as Connect-replace and Close — so it can never race a send from
// publishPresence / Broadcast / SendTo (those hold the read lock), which would otherwise
// panic on a send to a closed channel. revokeDevice now Kicks on every logout / device
// delete / session revoke, so this path is hot, not rare.
func (h *Hub) Kick(userID, deviceID string) {
// Rename updates a live client's display name in place (keyed by stable device_id), so a
// device renamed via PATCH /api/devices/{device_id} stays the SAME hub entry — no reconnect,
// no token re-mint, no duplicate — and becomes reachable by its new name (clientFor) at once.
// No-op if the device has no live connection (its next connect carries the new name anyway).
func (h *Hub) Rename(userID, deviceID, name string) {
if deviceID == "" {
return
}
h.mu.Lock()
defer h.mu.Unlock()
if c, ok := h.users[userID][deviceID]; ok {
delete(h.users[userID], deviceID)
c.Name = name
}
}
// Kick force-removes a device's live entry from the hub and closes its event channel; the SSE
// handler exits on the next iteration. Keyed by the connection key (device_id when present,
// else "name:"+name) so it摘除 the exact entry Connect inserted. The close happens UNDER the
// write lock — the same discipline as Connect-replace and Close — so it can never race a send
// from publishPresence / Broadcast / SendTo (those hold the read lock), which would otherwise
// panic on a send to a closed channel. revokeDevice now Kicks on every logout / device delete /
// session revoke, so this path is hot, not rare.
func (h *Hub) Kick(userID, deviceID, name string) {
key := connKey(deviceID, name)
h.mu.Lock()
defer h.mu.Unlock()
if c, ok := h.users[userID][key]; ok {
delete(h.users[userID], key)
if len(h.users[userID]) == 0 {
delete(h.users, userID)
}
@@ -226,21 +285,28 @@ func (h *Hub) publishPresence(ctx context.Context, userID string) {
seen := make(map[string]bool, len(devs))
items := make([]PresenceDevice, 0, len(devs)+len(live))
for _, d := range devs {
_, online := live[d.Name]
// Online keyed by the stable device_id (not the name) — so a renamed device stays
// online through the rename, and the authoritative name is the (just-updated) DB row.
_, online := live[d.DeviceID]
items = append(items, PresenceDevice{
Name: d.Name, Type: d.Type, Online: online, LastSeen: d.LastSeen,
DeviceID: d.DeviceID, Name: d.Name, Type: d.Type, Online: online, LastSeen: d.LastSeen,
})
seen[d.Name] = true
seen[d.DeviceID] = true
}
// Live connections without a devices-table row — a global-SSO browser before 代铸, or a
// device whose cache row hasn't landed yet. They are reachable on the hub (clipboard /
// signaling already route to them), so they must appear as online peers too.
for name, c := range live {
if seen[name] {
// Live connections without a devices-table row. Only a code-less connection (DeviceID=="" —
// a global-SSO browser before 代铸, keyed by name) is surfaced here, by its declared name.
//
// A connection WITH a device_id but no row is deliberately NOT surfaced: its row is either
// deleted (a device that was just revoked but reconnected on a still-valid access token —
// the "需要移除两次" zombie) or a 代铸 row that hasn't landed yet. Hiding it stops the revoked
// device from reappearing as a phantom online peer; a legitimate re-login mints a fresh
// devices row and shows normally via the loop above.
for _, c := range live {
if c.DeviceID != "" {
continue
}
items = append(items, PresenceDevice{
Name: name, Type: c.Type, Online: true, LastSeen: now,
Name: c.Name, Type: c.Type, Online: true, LastSeen: now,
})
}
+98 -28
View File
@@ -41,18 +41,18 @@ func waitForEvent(t *testing.T, c *Client, want string, timeout time.Duration) E
func TestConnectFiresPresenceEvent(t *testing.T) {
lister := &fakeLister{
devices: []db.Device{{UserID: "alice", Name: "tab-1", Type: "browser", LastSeen: 1}},
devices: []db.Device{{UserID: "alice", DeviceID: "dev_1", Name: "tab-1", Type: "browser", LastSeen: 1}},
}
h := New(lister)
defer h.Close()
c := h.Connect(context.Background(), "alice", "tab-1", "browser")
c := h.Connect(context.Background(), "alice", "dev_1", "tab-1", "browser")
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 {
if len(devs) != 1 || devs[0].Name != "tab-1" || devs[0].DeviceID != "dev_1" || !devs[0].Online {
t.Errorf("presence devices: %+v", devs)
}
}
@@ -60,18 +60,18 @@ func TestConnectFiresPresenceEvent(t *testing.T) {
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},
{UserID: "alice", DeviceID: "dev_1", Name: "tab-1", Type: "browser", LastSeen: 1},
{UserID: "alice", DeviceID: "dev_2", Name: "tab-2", Type: "browser", LastSeen: 2},
},
}
h := New(lister)
defer h.Close()
c1 := h.Connect(context.Background(), "alice", "tab-1", "browser")
c1 := h.Connect(context.Background(), "alice", "dev_1", "tab-1", "browser")
defer h.Disconnect(c1)
_ = waitForEvent(t, c1, "presence", time.Second)
c2 := h.Connect(context.Background(), "alice", "tab-2", "browser")
c2 := h.Connect(context.Background(), "alice", "dev_2", "tab-2", "browser")
defer h.Disconnect(c2)
// c2 receives its own presence (announced on Connect).
@@ -96,13 +96,13 @@ func TestSecondClientSeesFirstAsOnline(t *testing.T) {
}
}
// A live device with no devices-table row (global-SSO browser / broker-auth desktop)
// must still appear in presence as online, labelled by its declared type.
// A live device with no devices-table row (global-SSO browser / broker-auth desktop) and no
// device_id must still appear in presence as online, labelled by its declared name + type.
func TestLiveOnlyDeviceAppearsInPresence(t *testing.T) {
h := New(&fakeLister{}) // empty managed-device set
defer h.Close()
c := h.Connect(context.Background(), "alice", "macbook", "macos")
c := h.Connect(context.Background(), "alice", "", "macbook", "macos")
defer h.Disconnect(c)
ev := waitForEvent(t, c, "presence", time.Second)
@@ -112,22 +112,31 @@ func TestLiveOnlyDeviceAppearsInPresence(t *testing.T) {
}
}
func TestSendToRoutesEvent(t *testing.T) {
lister := &fakeLister{}
func TestSendToRoutesEventByName(t *testing.T) {
lister := &fakeLister{
devices: []db.Device{{UserID: "alice", DeviceID: "dev_1", Name: "tab-1", Type: "browser"}},
}
h := New(lister)
defer h.Close()
c := h.Connect(context.Background(), "alice", "tab-1", "browser")
c := h.Connect(context.Background(), "alice", "dev_1", "tab-1", "browser")
defer h.Disconnect(c)
_ = waitForEvent(t, c, "presence", time.Second)
// Peer addressing is by name; the hub resolves it to the device_id-keyed client.
if !h.SendTo("alice", "tab-1", Event{Type: "signal", Data: map[string]any{"x": 1}}) {
t.Fatal("SendTo to live client should report true")
t.Fatal("SendTo by name to a 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)
}
// Addressing by the stable device_id also works (direct map hit).
if !h.SendTo("alice", "dev_1", Event{Type: "signal"}) {
t.Fatal("SendTo by device_id should report true")
}
_ = waitForEvent(t, c, "signal", time.Second)
}
func TestSendToOfflineDeviceReportsFalse(t *testing.T) {
@@ -138,20 +147,79 @@ func TestSendToOfflineDeviceReportsFalse(t *testing.T) {
}
}
// TestRenameKeepsEntryAndUpdatesRouting is the decoupling guarantee: a renamed device keeps
// its single hub entry (keyed by the stable device_id), so presence shows the new name with no
// duplicate and the device stays online and reachable by its NEW name — no reconnect, no token.
func TestRenameKeepsEntryAndUpdatesRouting(t *testing.T) {
lister := &fakeLister{
devices: []db.Device{{UserID: "alice", DeviceID: "dev_1", Name: "iPhone", Type: "ios"}},
}
h := New(lister)
defer h.Close()
c := h.Connect(context.Background(), "alice", "dev_1", "iPhone", "ios")
defer h.Disconnect(c)
_ = waitForEvent(t, c, "presence", time.Second)
// Simulate the rename: the DB row's name changes, then the hub is told.
lister.devices[0].Name = "My iPhone"
h.Rename("alice", "dev_1", "My iPhone")
h.PublishPresence(context.Background(), "alice")
ev := waitForEvent(t, c, "presence", time.Second)
devs := ev.Data.(map[string]any)["devices"].([]PresenceDevice)
if len(devs) != 1 {
t.Fatalf("rename must not duplicate the device: got %+v", devs)
}
if devs[0].Name != "My iPhone" || devs[0].DeviceID != "dev_1" || !devs[0].Online {
t.Errorf("presence after rename: %+v", devs[0])
}
// Reachable by the new name; the old name no longer resolves.
if !h.SendTo("alice", "My iPhone", Event{Type: "signal"}) {
t.Error("renamed device should be reachable by its new name")
}
_ = waitForEvent(t, c, "signal", time.Second)
if h.SendTo("alice", "iPhone", Event{Type: "signal"}) {
t.Error("old name should no longer resolve after rename")
}
}
func TestReconnectClosesOldChannel(t *testing.T) {
h := New(&fakeLister{})
defer h.Close()
old := h.Connect(context.Background(), "alice", "tab-1", "browser")
old := h.Connect(context.Background(), "alice", "dev_1", "tab-1", "browser")
// drain the initial presence so we can detect close
<-old.Events()
_ = h.Connect(context.Background(), "alice", "tab-1", "browser")
_ = h.Connect(context.Background(), "alice", "dev_1", "tab-1", "browser")
select {
case _, ok := <-old.Events():
if ok {
t.Error("old channel should be closed on reconnect from same (user, device)")
t.Error("old channel should be closed on reconnect from same device_id")
}
case <-time.After(time.Second):
t.Fatal("timeout waiting for old channel close")
}
}
// A reconnect of the SAME device under a DIFFERENT name (e.g. mid-rename) still replaces its
// own entry rather than orphaning the old one — because the key is the stable device_id.
func TestReconnectAfterRenameReplacesSameEntry(t *testing.T) {
h := New(&fakeLister{})
defer h.Close()
old := h.Connect(context.Background(), "alice", "dev_1", "iPhone", "ios")
<-old.Events()
_ = h.Connect(context.Background(), "alice", "dev_1", "My iPhone", "ios")
select {
case _, ok := <-old.Events():
if ok {
t.Error("old channel should be closed even when the name changed (same device_id)")
}
case <-time.After(time.Second):
t.Fatal("timeout waiting for old channel close")
@@ -160,16 +228,16 @@ func TestReconnectClosesOldChannel(t *testing.T) {
func TestDisconnectRemovesFromOnlineSet(t *testing.T) {
h := New(&fakeLister{
devices: []db.Device{{UserID: "alice", Name: "tab-1", Type: "browser"}},
devices: []db.Device{{UserID: "alice", DeviceID: "dev_1", Name: "tab-1", Type: "browser"}},
})
defer h.Close()
c := h.Connect(context.Background(), "alice", "tab-1", "browser")
if !h.Online("alice", "tab-1") {
c := h.Connect(context.Background(), "alice", "dev_1", "tab-1", "browser")
if !h.Online("alice", "dev_1") {
t.Fatal("client should be online after Connect")
}
h.Disconnect(c)
if h.Online("alice", "tab-1") {
if h.Online("alice", "dev_1") {
t.Error("client should not be online after Disconnect")
}
}
@@ -180,7 +248,7 @@ func TestDisconnectRemovesFromOnlineSet(t *testing.T) {
// the whole process; now close and send are mutually exclusive. Run with -race.
func TestConcurrentKickAndPresenceNoPanic(t *testing.T) {
h := New(&fakeLister{
devices: []db.Device{{UserID: "u", Name: "d", Type: "browser"}},
devices: []db.Device{{UserID: "u", DeviceID: "dev_0", Name: "d", Type: "browser"}},
})
defer h.Close()
@@ -191,13 +259,15 @@ func TestConcurrentKickAndPresenceNoPanic(t *testing.T) {
wg.Add(1)
go func(n int) {
defer wg.Done()
id := fmt.Sprintf("dev_%d", n)
name := fmt.Sprintf("d%d", n)
for j := 0; j < iters; j += 1 {
h.Connect(context.Background(), "u", name, "browser")
h.Connect(context.Background(), "u", id, name, "browser")
h.PublishPresence(context.Background(), "u")
h.Broadcast("u", Event{Type: "x"})
h.SendTo("u", name, Event{Type: "y"})
h.Kick("u", name)
h.Rename("u", id, name+"-r")
h.Kick("u", id, name)
}
}(i)
}
@@ -208,16 +278,16 @@ func TestConcurrentKickAndPresenceNoPanic(t *testing.T) {
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"},
{UserID: "alice", DeviceID: "dev_1", Name: "tab-1", Type: "browser"},
{UserID: "alice", DeviceID: "dev_2", Name: "tab-2", Type: "browser"},
},
})
h.grace = 100 * time.Millisecond
defer h.Close()
c1 := h.Connect(context.Background(), "alice", "tab-1", "browser")
c1 := h.Connect(context.Background(), "alice", "dev_1", "tab-1", "browser")
defer h.Disconnect(c1)
c2 := h.Connect(context.Background(), "alice", "tab-2", "browser")
c2 := h.Connect(context.Background(), "alice", "dev_2", "tab-2", "browser")
// Drain initial presence frames
_ = waitForEvent(t, c1, "presence", time.Second)
_ = waitForEvent(t, c1, "presence", time.Second)