127070e226
设备名与令牌解耦(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)
326 lines
11 KiB
Go
326 lines
11 KiB
Go
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 是
|
|
// 设置页展示「上次活跃」需要的扩展字段。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"`
|
|
LastSeen int64 `json:"last_seen"`
|
|
}
|
|
|
|
const clientBuffer = 32
|
|
|
|
// Client is a single live SSE connection.
|
|
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
|
|
// 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 }
|
|
|
|
// 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.
|
|
// 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),
|
|
}
|
|
|
|
h.mu.Lock()
|
|
if h.users[userID] == nil {
|
|
h.users[userID] = map[string]*Client{}
|
|
}
|
|
if old, ok := h.users[userID][key]; ok {
|
|
close(old.ch)
|
|
}
|
|
h.users[userID][key] = 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.key]; ok && active == c {
|
|
delete(h.users[c.UserID], c.key)
|
|
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.key]
|
|
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 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, target string, ev Event) bool {
|
|
h.mu.RLock()
|
|
defer h.mu.RUnlock()
|
|
c := h.clientFor(userID, target)
|
|
if c == nil {
|
|
return false
|
|
}
|
|
select {
|
|
case c.ch <- ev:
|
|
return true
|
|
default:
|
|
slog.Warn("client buffer full; dropping event",
|
|
"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) {
|
|
h.mu.RLock()
|
|
defer h.mu.RUnlock()
|
|
for _, c := range h.users[userID] {
|
|
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 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()
|
|
_, ok := h.users[userID][deviceID]
|
|
return ok
|
|
}
|
|
|
|
// 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 {
|
|
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)
|
|
}
|
|
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) publishPresence(ctx context.Context, userID string) {
|
|
devs, err := h.devices.ListDevicesByUser(ctx, userID)
|
|
if err != nil {
|
|
// Log but continue with an empty managed-device set: a transient DB error must
|
|
// not blank out the presence of live, unmanaged devices that need no row.
|
|
slog.Error("presence: list devices failed", "user", userID, "err", err)
|
|
}
|
|
|
|
now := time.Now().Unix()
|
|
// Hold the read lock across the build AND the sends: the non-blocking sends below must
|
|
// be mutually exclusive with any channel close (Kick / Connect-replace / Close hold the
|
|
// write lock), or a send could hit a closed channel and panic.
|
|
h.mu.RLock()
|
|
defer h.mu.RUnlock()
|
|
live := h.users[userID]
|
|
seen := make(map[string]bool, len(devs))
|
|
items := make([]PresenceDevice, 0, len(devs)+len(live))
|
|
for _, d := range devs {
|
|
// 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{
|
|
DeviceID: d.DeviceID, Name: d.Name, Type: d.Type, Online: online, LastSeen: d.LastSeen,
|
|
})
|
|
seen[d.DeviceID] = true
|
|
}
|
|
// 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: c.Name, Type: c.Type, Online: true, LastSeen: now,
|
|
})
|
|
}
|
|
|
|
ev := Event{
|
|
Type: "presence",
|
|
Data: map[string]any{"devices": items},
|
|
}
|
|
for _, c := range live {
|
|
select {
|
|
case c.ch <- ev:
|
|
default:
|
|
slog.Warn("presence: client buffer full; dropping",
|
|
"user", c.UserID, "device", c.DeviceID)
|
|
}
|
|
}
|
|
}
|