package realtime import ( "crypto/rand" "encoding/hex" "sort" "strconv" "strings" "time" ) // 注册表容量控制:离线实例保留一段时间便于排查,过期即清理; // 没有 displayKey 的客户端每次重连都会生成新实例,必须有上限兜底。 const ( instanceTTL = 10 * time.Minute maxInstances = 200 ) // Instance 是大屏实例的对外快照,供管理端查询「谁在线、在哪一页」。 type Instance struct { ID string `json:"id"` Online bool `json:"online"` Page string `json:"page,omitempty"` LastHeartbeat time.Time `json:"lastHeartbeat"` ConnectedAt time.Time `json:"connectedAt"` RemoteAddr string `json:"remoteAddr,omitempty"` AckVersion int64 `json:"ackVersion"` Slow bool `json:"slow"` // Screen 是 Console 分配的屏号(URL 里的 screen=), // 管理端靠它把「本机显示器」与「在线实例」对上;局域网浏览器直接开 /display 时为空串。 // 用字符串而不是数字:显示器的 id 由 Electron 给,不保证是小的正整数。 Screen string `json:"screen"` // Device 是浏览器报上来的设备标签(`安卓 13 · Chrome 120 · 1080×2400 @3x · 手机`), // DeviceRaw 是原始 UA。老客户端不报时**整个键不出现**(omitempty),管理端列表退回显示 id。 Device string `json:"device,omitempty"` DeviceRaw string `json:"deviceRaw,omitempty"` // Alias 是操作员手动起的名字。它**不住在注册表里**:注册表是内存的、实例过期即清理, // 而别名是机器级的、必须活过重启与实例过期,所以由 httpapi 从系统库设置里读出来, // 在返回列表时按实例 id 填进来(见 httpapi 的 loadInstanceAliases)。 Alias string `json:"alias,omitempty"` } // instanceState 是注册表的内部记录;同一 instanceId 的多条连接合并成一个实例。 type instanceState struct { id string page string screen string device string deviceRaw string connectedAt time.Time lastHeartbeat time.Time remoteAddr string ackVersion int64 slow bool conns int } // displayRegistration 是登记一个实例要用的 hello 字段。 // 单独一个结构体而不是直接传 helloPayload:注册表只关心「在哪一页、哪块屏、什么设备」, // role / lastVersion 那些与它无关。 type displayRegistration struct { page string screen string device string deviceRaw string } // registerDisplay 把连接挂到大屏实例上。 // // id 为空(局域网浏览器直接开 /display,URL 上没有 instance / screen)时**优先复用这条连接 // 已经在用的实例 id**,只有从没用过才现生成一个:客户端翻页(setPage)与补齐设备标签都会 // 再发一次 hello,而每次 hello 都现生成 `display-<随机>` 的话,老实例会永远留在注册表里 // 变成一条「离线条目」(TTL 十分钟才清理),管理端「在线大屏」底下就凭空多出一条同一台 // 设备的离线记录。刷新页面等新连接仍然各自登记,不会因此查不到。 func (h *Hub) registerDisplay(c *client, id string, reg displayRegistration) string { c.mu.Lock() if id == "" { id = c.instanceID } if id == "" { id = "display-" + randomID() } prev := c.instanceID c.instanceID = id c.mu.Unlock() // 同一连接重复 hello 或换了 instanceId 时,先归还旧实例的引用,避免计数虚高。 // 复用同一条 id 时 prev == id,引用计数不动——这正是「重复 hello 不许产生新实例」的一半。 if prev != "" && prev != id { h.releaseDisplayID(prev, c) } device := sanitizeDeviceText(reg.device, maxDeviceChars) deviceRaw := sanitizeDeviceText(reg.deviceRaw, maxDeviceRawChars) now := time.Now() h.mu.Lock() defer h.mu.Unlock() h.pruneInstancesLocked(now) st := h.instances[id] if st == nil { st = &instanceState{id: id, connectedAt: now} h.instances[id] = st } if prev != id { st.conns++ } if reg.page != "" { st.page = reg.page } if reg.screen != "" { st.screen = reg.screen } // 设备标签只在这次报了非空值时覆盖:老客户端(以及 localStorage 不可用的客户端) // 不带这两个字段,不该把上一次 hello 报上来的标签抹掉。 if device != "" { st.device = device } if deviceRaw != "" { st.deviceRaw = deviceRaw } st.lastHeartbeat = now st.remoteAddr = c.remoteAddr return id } // releaseDisplay 断开时只减少引用计数:实例保留在注册表里并标记离线,方便事后排查; // 离线超过 instanceTTL 后由 pruneInstancesLocked 清理。 func (h *Hub) releaseDisplay(c *client) { c.mu.Lock() id := c.instanceID c.mu.Unlock() if id == "" { return } h.releaseDisplayID(id, c) } func (h *Hub) releaseDisplayID(id string, c *client) { h.mu.Lock() defer h.mu.Unlock() st := h.instances[id] if st == nil { return } if st.conns > 0 { st.conns-- } st.lastHeartbeat = time.Now() st.ackVersion = c.ackVersion.Load() st.slow = c.slow.Load() } // touchInstance 在收到客户端消息或探测到 pong 时刷新「最后活跃」。 // now 必须是**收到对端帧的时间**,不能是服务端自己想盖的章:这个字段既决定注册表 // 里的「最后心跳」,也是离线实例清理的计时起点(见 pruneInstancesLocked)。 func (h *Hub) touchInstance(c *client, now time.Time) { c.mu.Lock() id := c.instanceID c.mu.Unlock() if id == "" { return } h.mu.Lock() defer h.mu.Unlock() if st := h.instances[id]; st != nil { st.lastHeartbeat = now st.ackVersion = c.ackVersion.Load() st.slow = c.slow.Load() } } // syncInstance 只把连接的 ack / 慢标记同步到注册表,**不碰 lastHeartbeat**。 // 心跳循环用它:服务端自己发过心跳不能算「对端还活着」的证据, // 否则一块拔了网线的屏会一直被刷成「刚刚活跃」。 func (h *Hub) syncInstance(c *client) { c.mu.Lock() id := c.instanceID c.mu.Unlock() if id == "" { return } h.mu.Lock() defer h.mu.Unlock() if st := h.instances[id]; st != nil { st.ackVersion = c.ackVersion.Load() st.slow = c.slow.Load() } } // setInstancePage 记录服务端刚下发的目标页,让注册表里的「当前页」跟得上 goto 指令。 // targets 为空表示广播(所有在线实例),否则只更新被点名的那几个实例——定向切页后 // 每块屏的「当前页」必须各算各的。 // // targets 在这里再 TrimSpace 一次是防御性的:调用方(SendCommandToInstances)已经 // 归一化过,但只要有第二个调用方忘了,注册表里就会出现「id 带空格」这类永远匹配不上的 // 死条目,而症状是「指令发出去了、界面上的当前页不变」——很难查。 func (h *Hub) setInstancePage(page string, targets []string) { if page == "" { return } var picked map[string]bool if len(targets) > 0 { picked = make(map[string]bool, len(targets)) for _, id := range targets { if id = strings.TrimSpace(id); id != "" { picked[id] = true } } } h.mu.Lock() defer h.mu.Unlock() for _, st := range h.instances { if st.conns == 0 { continue } if picked != nil && !picked[st.id] { continue } st.page = page } } // pruneInstancesLocked 清理过期离线实例并把注册表封顶到 maxInstances; // 调用方必须持有 h.mu。淘汰只针对离线实例,保证在线屏幕不会被误删。 func (h *Hub) pruneInstancesLocked(now time.Time) { for id, st := range h.instances { if st.conns == 0 && now.Sub(st.lastHeartbeat) > instanceTTL { delete(h.instances, id) } } if len(h.instances) <= maxInstances { return } offline := make([]*instanceState, 0, len(h.instances)) for _, st := range h.instances { if st.conns == 0 { offline = append(offline, st) } } sort.Slice(offline, func(i, j int) bool { return offline[i].lastHeartbeat.Before(offline[j].lastHeartbeat) }) for _, st := range offline { if len(h.instances) <= maxInstances { break } delete(h.instances, st.id) } } // Instances 返回注册表快照,按 instanceId 排序,便于管理端稳定展示。 // // online = 「还有连接挂着」。这里刻意不叠加 TTL 判断:只要连接还在,客户端就是那条 // 连接上的证据——而连接本身已经被 livenessLoop 用 ping/pong 求证过(不回 pong 的连接 // 会先被断开、引用计数归零)。反过来,若在这里按 lastHeartbeat 再判一次, // 一块正常但不发上行帧的大屏(数据没变时它只在收心跳)就会被误判成离线。 func (h *Hub) Instances() []Instance { h.mu.Lock() defer h.mu.Unlock() h.pruneInstancesLocked(time.Now()) out := make([]Instance, 0, len(h.instances)) for _, st := range h.instances { out = append(out, Instance{ ID: st.id, Online: st.conns > 0, Page: st.page, LastHeartbeat: st.lastHeartbeat, ConnectedAt: st.connectedAt, RemoteAddr: st.remoteAddr, AckVersion: st.ackVersion, Slow: st.slow, Screen: st.screen, Device: st.device, DeviceRaw: st.deviceRaw, }) } sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID }) return out } func randomID() string { var b [8]byte if _, err := rand.Read(b[:]); err != nil { // 随机源不可用时退回时间戳,实例 id 只需唯一,不承担安全职责。 return strconv.FormatInt(time.Now().UnixNano(), 16) } return hex.EncodeToString(b[:]) }