diff --git a/client/ScreenCapture.h b/client/ScreenCapture.h index 149f844..e32f1fc 100644 --- a/client/ScreenCapture.h +++ b/client/ScreenCapture.h @@ -14,6 +14,7 @@ #include #include #include +#include #include // SSE2 #include "common/config.h" #include "VideoEncoderBase.h" @@ -149,6 +150,9 @@ public: int m_FrameID; // 帧序号 int m_GOP; // 关键帧间隔 bool m_SendKeyFrame; // 发送关键帧 + // Web 端请求下一帧强制 IDR(COMMAND_SCREEN_KEYFRAME_REQ)。只对 H.264 生效; + // 必须在成功编码出 IDR 后清除,否则 ScanNextScreen 失败/硬编 EAGAIN 时请求会丢。 + std::atomic m_bForceKeyFrame{false}; std::unique_ptr m_encoder; // 编码器,ensureEncoder() lazy 创建,走 EncoderFactory 探测 bool m_bEncoderPrimed = false; // encoder 是否已成功产出过一个包; // false 时禁止 skip——避免单显示器路径 @@ -257,6 +261,11 @@ public: m_BlockBuffers[blockY] = new BYTE[m_ulFullWidth * m_ulFullHeight * 4 * 2 / m_BlockNum + 12]; } } + + // 请求下一帧强制 IDR(可跨线程调用)。只影响 H.264 编码路径; + // 实际清除发生在成功编码出 IDR 之后。 + void RequestKeyFrame() { m_bForceKeyFrame.store(true, std::memory_order_relaxed); } + virtual ~ScreenCapture() { if (m_BitmapInfor_Full != NULL) { @@ -876,7 +885,12 @@ public: { BYTE algo = m_bAlgorithm; int frameID = m_FrameID + 1; - bool keyFrame = (frameID % m_GOP == 0); + // Web 端可请求强制 IDR(COMMAND_SCREEN_KEYFRAME_REQ),只对 H.264 有意义。 + // 标志在成功编码出 IDR 后才清除(见下方 H.264 keyframe 分支), + // 保证 ScanNextScreen 失败或硬编 EAGAIN 时请求不会丢失。 + bool forceKeyFrame = (algo == ALGORITHM_H264) && + m_bForceKeyFrame.load(std::memory_order_relaxed); + bool keyFrame = forceKeyFrame || (frameID % m_GOP == 0); m_RectBuffer[0] = keyFrame ? TOKEN_KEYFRAME : TOKEN_NEXTSCREEN; LPBYTE data = m_RectBuffer + 1; // 写入使用了哪种算法 @@ -967,6 +981,8 @@ public: *ulNextSendLength = 1 + offset + encoded_size; memcpy(data + offset, encoded_data, encoded_size); m_bEncoderPrimed = true; // 与下方 FirstBuffer 同步:自此 skip 安全 + // IDR 已成功产出,满足(可能的)强制关键帧请求。 + m_bForceKeyFrame.store(false, std::memory_order_relaxed); break; } default: diff --git a/client/ScreenManager.cpp b/client/ScreenManager.cpp index fe87d18..b3f0d9e 100644 --- a/client/ScreenManager.cpp +++ b/client/ScreenManager.cpp @@ -930,6 +930,13 @@ VOID CScreenManager::OnReceive(PBYTE szBuffer, ULONG ulLength) SwitchScreen(); break; } + case COMMAND_SCREEN_KEYFRAME_REQ: { + // Web 屏幕墙恢复/新加入者请求:下一帧强制 IDR,立即重建参考链。 + if (m_ScreenSpyObject) { + m_ScreenSpyObject->RequestKeyFrame(); + } + break; + } case CMD_FULL_SCREEN: { int fullScreen = szBuffer[1]; iniFile cfg(CLIENT_PATH); diff --git a/common/commands.h b/common/commands.h index 4941450..1ac7281 100644 --- a/common/commands.h +++ b/common/commands.h @@ -328,6 +328,7 @@ enum { COMMAND_TLS_ENABLE = 166, // 服务端 → 客户端:启用 TLS 传输(写注册表,下次启动生效) COMMAND_TLS_DISABLE = 167, // 服务端 → 客户端:停用 TLS 传输(写注册表,下次启动生效) COMMAND_REGEN_CLIENT_ID = 168, // 服务端 → 客户端:撞 ID 的克隆机重生成 client_id(掺随机 UUID)后重连 + COMMAND_SCREEN_KEYFRAME_REQ = 169, // 服务端 → 客户端:下一次屏幕编码强制输出 IDR(Web 屏幕墙恢复用) TOKEN_DECRYPT = 199, TOKEN_REGEDIT = 200, // 注册表 diff --git a/server/2015Remote/WebService.cpp b/server/2015Remote/WebService.cpp index 1238f4f..8049b0c 100644 --- a/server/2015Remote/WebService.cpp +++ b/server/2015Remote/WebService.cpp @@ -45,6 +45,27 @@ static void ReplaceAll(std::string& s, const std::string& from, const std::strin } } +// 设备 ID 在 JSON 里以字符串传输(避免 64 位精度丢失),但兼容数字写法。 +static uint64_t ParseDeviceIdValue(const Json::Value& v) { + if (v.isString()) return strtoull(v.asString().c_str(), nullptr, 10); + if (v.isUInt64()) return v.asUInt64(); + if (v.isInt64()) return (uint64_t)v.asInt64(); + return 0; +} + +static uint64_t ParseDeviceIdField(const Json::Value& root, const char* key) { + return ParseDeviceIdValue(root[key]); +} + +// req 是前端生成的关联号;字符串/数字都接受,原样回显。 +static std::string ParseReqField(const Json::Value& root) { + const Json::Value& v = root["req"]; + if (v.isString()) return v.asString(); + if (v.isUInt64()) return std::to_string(v.asUInt64()); + if (v.isInt64()) return std::to_string(v.asInt64()); + return ""; +} + // Challenge-response nonce storage (prevents replay attacks) static std::map s_ClientNonces; static std::mutex s_NonceMutex; @@ -337,6 +358,10 @@ void CWebService::ServerThread(int port) { Json::Value challenge; challenge["cmd"] = "challenge"; challenge["nonce"] = nonce; + // 能力协商:新前端据此启用屏幕墙(多设备订阅)与 keyframe 请求。 + // 缺少这些字段的服务端(Go / 旧 C++ 二进制)会让新前端自动降级。 + challenge["caps"]["multiWatch"] = true; + challenge["caps"]["keyframeRequest"] = true; Json::StreamWriterBuilder builder; builder["indentation"] = ""; SendText(ws_ptr, Json::writeString(builder, challenge)); @@ -379,14 +404,13 @@ void CWebService::ServerThread(int port) { } else if (cmd == "get_devices") { HandleGetDevices(ws_ptr, token); } else if (cmd == "connect") { - // Support both string and number ID formats - std::string id_str = root.get("id", "").asString(); - uint64_t device_id = id_str.empty() ? 0 : strtoull(id_str.c_str(), nullptr, 10); - HandleConnect(ws_ptr, token, device_id); + HandleConnect(ws_ptr, token, ParseDeviceIdField(root, "id"), + ParseReqField(root), root.get("multiWatch", false).asBool()); } else if (cmd == "disconnect") { - std::string disc_id_str = root.get("id", "").asString(); - uint64_t disc_device_id = disc_id_str.empty() ? 0 : strtoull(disc_id_str.c_str(), nullptr, 10); - HandleDisconnect(ws_ptr, token, disc_device_id); + HandleDisconnect(ws_ptr, token, ParseDeviceIdField(root, "id"), + ParseReqField(root), root.get("multiWatch", false).asBool()); + } else if (cmd == "keyframe") { + HandleKeyframe(ws_ptr, token, ParseDeviceIdField(root, "id"), ParseReqField(root)); } else if (cmd == "ping") { HandlePing(ws_ptr, token); } else if (cmd == "mouse") { @@ -396,11 +420,11 @@ void CWebService::ServerThread(int port) { } else if (cmd == "clipboard_set") { HandleClipboardSet(ws_ptr, msg); } else if (cmd == "clipboard_get") { - HandleClipboardGet(ws_ptr, token); + HandleClipboardGet(ws_ptr, token, ParseDeviceIdField(root, "id")); } else if (cmd == "rdp_reset") { - HandleRdpReset(ws_ptr, token); + HandleRdpReset(ws_ptr, token, ParseDeviceIdField(root, "id")); } else if (cmd == "audio_toggle") { - HandleAudioToggle(ws_ptr, token); + HandleAudioToggle(ws_ptr, token, ParseDeviceIdField(root, "id")); } else if (cmd == "get_salt") { HandleGetSalt(ws_ptr, msg); } else if (cmd == "create_user") { @@ -464,7 +488,7 @@ void CWebService::HeartbeatThread() { std::lock_guard lock(m_ClientsMutex); for (const auto& [ws_ptr, client] : m_Clients) { // Only check clients that are watching a device (streaming video) - if (client.watch_device_id > 0 && client.last_activity > 0) { + if (!client.watch_device_ids.empty() && client.last_activity > 0) { uint64_t elapsed = now - client.last_activity; if (elapsed > HEARTBEAT_TIMEOUT_SEC) { timedOutClients.push_back(ws_ptr); @@ -625,22 +649,36 @@ void CWebService::HandleGetDevices(void* ws_ptr, const std::string& token) { SendText(ws_ptr, BuildDeviceListJson(username)); } -void CWebService::HandleConnect(void* ws_ptr, const std::string& token, uint64_t device_id) { +void CWebService::HandleConnect(void* ws_ptr, const std::string& token, uint64_t device_id, + const std::string& req, bool multi_watch) { + // 所有响应都带 id/req:屏幕墙有多个并发 connect,前端必须能按设备/请求号路由。 + auto send_error = [&](const std::string& err) { + Json::Value res; + res["cmd"] = "connect_result"; + res["ok"] = false; + res["msg"] = err; + res["id"] = std::to_string(device_id); + if (!req.empty()) res["req"] = req; + Json::StreamWriterBuilder builder; + builder["indentation"] = ""; + SendText(ws_ptr, Json::writeString(builder, res)); + }; + std::string username, role; if (!ValidateToken(token, username, role)) { - SendText(ws_ptr, BuildJsonResponse("connect_result", false, "Invalid token")); + send_error("Invalid token"); return; } // Check device exists and is online if (!m_pParentDlg) { - SendText(ws_ptr, BuildJsonResponse("connect_result", false, "Service not ready")); + send_error("Service not ready"); return; } context* ctx = m_pParentDlg->FindHost(device_id); if (!ctx) { - SendText(ws_ptr, BuildJsonResponse("connect_result", false, "Device not online")); + send_error("Device not online"); return; } @@ -665,7 +703,7 @@ void CWebService::HandleConnect(void* ws_ptr, const std::string& token, uint64_t } } if (!hasAccess) { - SendText(ws_ptr, BuildJsonResponse("connect_result", false, "Permission denied")); + send_error("Permission denied"); return; } } @@ -675,30 +713,76 @@ void CWebService::HandleConnect(void* ws_ptr, const std::string& token, uint64_t // remote_open, cleared on session teardown), so AI input cannot be injected into a // picture a human is watching/controlling. if (IsMcpTriggered(device_id)) { - SendText(ws_ptr, BuildJsonResponse("connect_result", false, "Device busy: remote control session active")); + send_error("Device busy: remote control session active"); return; } - // Check max clients per device - int current_count = GetWebClientCount(device_id); - if (current_count >= m_nMaxClientsPerDevice) { - SendText(ws_ptr, BuildJsonResponse("connect_result", false, "Too many viewers")); - return; - } - - // Update client state + // 该 ws 是否已经订阅这台设备(屏幕墙点击 tile 放大时会重复 connect 以拿 + // 分辨率/关键帧)。重复 connect 必须跳过每设备 viewer 上限检查。 + bool already = false; { std::lock_guard lock(m_ClientsMutex); auto it = m_Clients.find(ws_ptr); if (it != m_Clients.end()) { - it->second.watch_device_id = device_id; + already = it->second.watch_device_ids.count(device_id) > 0; } } - // Start remote desktop session if this is the first web viewer - if (current_count == 0) { + // Check max clients per device + int current_count = GetWebClientCount(device_id); + if (!already && current_count >= m_nMaxClientsPerDevice) { + send_error("Too many viewers"); + return; + } + + // Update client state. 新前端(multiWatch=true)向集合里插入;旧前端保持 + // 单设备语义:connect 替换整个集合并停掉其它设备,避免集合无限增长。 + std::vector to_stop; + bool too_many_watch = false; + { + std::lock_guard lock(m_ClientsMutex); + auto it = m_Clients.find(ws_ptr); + if (it != m_Clients.end()) { + auto& watch = it->second.watch_device_ids; + if (!multi_watch) { + for (uint64_t id : watch) { + if (id != device_id) to_stop.push_back(id); + } + watch.clear(); + } + if (already) { + watch.insert(device_id); // already true 时必然在集合内,这里只是兜底 + } else if (watch.size() >= kMaxWatchPerClient) { + too_many_watch = true; + } else { + watch.insert(device_id); + } + } + } + if (too_many_watch) { + send_error("Too many devices on one connection"); + return; + } + // StopRemoteDesktop 会再次锁 m_ClientsMutex,必须在锁外调用。 + for (uint64_t id : to_stop) { + StopRemoteDesktop(id); + } + + // Start remote desktop session if this is the first web viewer. 若之前已订阅 + // 但捕获已意外结束(例如 MFC 对话框关闭),补一次冷启动。 + bool need_start = (current_count == 0) || + (already && GetScreenContext(device_id) == nullptr); + if (need_start) { if (!StartRemoteDesktop(device_id)) { - SendText(ws_ptr, BuildJsonResponse("connect_result", false, "Failed to start remote desktop")); + // 回滚本次插入,避免一个没有屏幕会话的设备长期占着订阅/计数。 + { + std::lock_guard lock(m_ClientsMutex); + auto it = m_Clients.find(ws_ptr); + if (it != m_Clients.end()) { + it->second.watch_device_ids.erase(device_id); + } + } + send_error("Failed to start remote desktop"); return; } } @@ -724,6 +808,8 @@ void CWebService::HandleConnect(void* ws_ptr, const std::string& token, uint64_t Json::Value res; res["cmd"] = "connect_result"; res["ok"] = true; + res["id"] = std::to_string(device_id); + if (!req.empty()) res["req"] = req; // Only include dimensions if we have valid cached values // Otherwise, client will wait for resolution_changed message if (width > 0 && height > 0) { @@ -741,48 +827,72 @@ void CWebService::HandleConnect(void* ws_ptr, const std::string& token, uint64_t builder["indentation"] = ""; SendText(ws_ptr, Json::writeString(builder, res)); - // Send cached keyframe if available + // Send cached keyframe if available. 缓存里是 raw H.264 payload(见 + // BroadcastH264Frame),这里统一用 BuildFramePacket 封装。 + std::vector cached; { std::lock_guard lock(m_DeviceCacheMutex); auto it = m_DeviceCache.find(device_id); - if (it != m_DeviceCache.end() && !it->second->keyframe_cache.empty()) { + if (it != m_DeviceCache.end()) { std::lock_guard cache_lock(it->second->cache_mutex); - auto packet = BuildFramePacket(device_id, true, - it->second->keyframe_cache.data(), - it->second->keyframe_cache.size()); - SendBinary(ws_ptr, packet.data(), packet.size()); + cached = it->second->keyframe_cache; } } + if (!cached.empty()) { + auto packet = BuildFramePacket(device_id, true, cached.data(), cached.size()); + SendBinary(ws_ptr, packet.data(), packet.size()); + } } -void CWebService::HandleDisconnect(void* ws_ptr, const std::string& token, uint64_t requested_device_id) { +void CWebService::HandleDisconnect(void* ws_ptr, const std::string& token, uint64_t requested_device_id, + const std::string& req, bool multi_watch) { + auto send_reply = [&](bool ok, const std::string& err) { + Json::Value res; + res["cmd"] = "disconnect_result"; + res["ok"] = ok; + if (!err.empty()) res["msg"] = err; + if (requested_device_id > 0) res["id"] = std::to_string(requested_device_id); + if (!req.empty()) res["req"] = req; + Json::StreamWriterBuilder builder; + builder["indentation"] = ""; + SendText(ws_ptr, Json::writeString(builder, res)); + }; + std::string username, role; if (!ValidateToken(token, username, role)) { - SendText(ws_ptr, BuildJsonResponse("disconnect_result", false, "Invalid token")); + send_reply(false, "Invalid token"); return; } - // Get the device_id before clearing watch state - uint64_t device_id = 0; + std::vector to_stop; { std::lock_guard lock(m_ClientsMutex); auto it = m_Clients.find(ws_ptr); if (it != m_Clients.end()) { - // Only disconnect if no specific device requested, or if it matches current device - // This prevents race condition when quickly switching devices - if (requested_device_id == 0 || it->second.watch_device_id == requested_device_id) { - device_id = it->second.watch_device_id; - it->second.watch_device_id = 0; + auto& watch = it->second.watch_device_ids; + if (multi_watch && requested_device_id != 0) { + // 屏幕墙:只取消这一路订阅,其它设备继续。 + if (watch.erase(requested_device_id) > 0) { + it->second.keyframe_req_at.erase(requested_device_id); + to_stop.push_back(requested_device_id); + } + } else if (requested_device_id == 0 || watch.count(requested_device_id) > 0) { + // 旧前端 / 全量断开:保持原来的“请求 id 必须匹配”的竞态保护, + // 快速切换设备时不会误清刚建立的新订阅。 + to_stop.assign(watch.begin(), watch.end()); + watch.clear(); + it->second.keyframe_req_at.clear(); } } } - // Close remote desktop if this was the last viewer - if (device_id > 0) { - StopRemoteDesktop(device_id); + // Close remote desktop if this was the last viewer. StopRemoteDesktop 会再次 + // 锁 m_ClientsMutex,必须在锁外调用。 + for (uint64_t id : to_stop) { + StopRemoteDesktop(id); } - SendText(ws_ptr, BuildJsonResponse("disconnect_result", true)); + send_reply(true, ""); } void CWebService::HandlePing(void* ws_ptr, const std::string& token) { @@ -795,6 +905,22 @@ void CWebService::HandlePing(void* ws_ptr, const std::string& token) { SendText(ws_ptr, Json::writeString(builder, res)); } +uint64_t CWebService::ResolveWatchDevice(void* ws_ptr, uint64_t requested_device_id) { + std::lock_guard lock(m_ClientsMutex); + auto it = m_Clients.find(ws_ptr); + if (it == m_Clients.end()) return 0; + const auto& watch = it->second.watch_device_ids; + + if (requested_device_id != 0) { + // 显式 id 必须属于本连接的订阅集合,防止越权向别的设备注入输入。 + return watch.count(requested_device_id) ? requested_device_id : 0; + } + // 旧前端不带 id:只有集合恰好一台设备时才回退。多设备时绝不猜, + // 否则会变成跨设备输入注入。 + if (watch.size() == 1) return *watch.begin(); + return 0; +} + void CWebService::HandleMouse(void* ws_ptr, const std::string& msg) { // Parse JSON Json::Value root; @@ -817,16 +943,7 @@ void CWebService::HandleMouse(void* ws_ptr, const std::string& msg) { } // Get device being watched - uint64_t device_id = 0; - { - std::lock_guard lock(m_ClientsMutex); - auto it = m_Clients.find(ws_ptr); - if (it != m_Clients.end()) { - device_id = it->second.watch_device_id; - it->second.last_activity = (uint64_t)time(nullptr); - } - } - + uint64_t device_id = ResolveWatchDevice(ws_ptr, ParseDeviceIdField(root, "id")); if (device_id == 0) return; // Get screen context (not main context!) @@ -904,16 +1021,7 @@ void CWebService::HandleKey(void* ws_ptr, const std::string& msg) { } // Get device being watched - uint64_t device_id = 0; - { - std::lock_guard lock(m_ClientsMutex); - auto it = m_Clients.find(ws_ptr); - if (it != m_Clients.end()) { - device_id = it->second.watch_device_id; - it->second.last_activity = (uint64_t)time(nullptr); - } - } - + uint64_t device_id = ResolveWatchDevice(ws_ptr, ParseDeviceIdField(root, "id")); if (device_id == 0) return; // Get screen context @@ -953,16 +1061,7 @@ void CWebService::HandleClipboardSet(void* ws_ptr, const std::string& msg) { } // Get device being watched - uint64_t device_id = 0; - { - std::lock_guard lock(m_ClientsMutex); - auto it = m_Clients.find(ws_ptr); - if (it != m_Clients.end()) { - device_id = it->second.watch_device_id; - it->second.last_activity = (uint64_t)time(nullptr); - } - } - + uint64_t device_id = ResolveWatchDevice(ws_ptr, ParseDeviceIdField(root, "id")); if (device_id == 0) return; // Get screen context @@ -989,24 +1088,15 @@ void CWebService::HandleClipboardSet(void* ws_ptr, const std::string& msg) { ctx->Send2Client(szData.data(), (ULONG)szData.size()); } -void CWebService::HandleClipboardGet(void* ws_ptr, const std::string& token) { +void CWebService::HandleClipboardGet(void* ws_ptr, const std::string& token, uint64_t requested_device_id) { // Validate token std::string username, role; if (!ValidateToken(token, username, role)) { return; } - // Get device being watched - uint64_t device_id = 0; - { - std::lock_guard lock(m_ClientsMutex); - auto it = m_Clients.find(ws_ptr); - if (it != m_Clients.end()) { - device_id = it->second.watch_device_id; - it->second.last_activity = (uint64_t)time(nullptr); - } - } - + // Get the device being watched + uint64_t device_id = ResolveWatchDevice(ws_ptr, requested_device_id); if (device_id == 0) return; // Get screen context @@ -1020,7 +1110,7 @@ void CWebService::HandleClipboardGet(void* ws_ptr, const std::string& token) { ctx->Send2Client(&bToken, sizeof(BYTE)); } -void CWebService::HandleRdpReset(void* ws_ptr, const std::string& token) { +void CWebService::HandleRdpReset(void* ws_ptr, const std::string& token, uint64_t requested_device_id) { std::string username, role; if (!ValidateToken(token, username, role)) { SendText(ws_ptr, BuildJsonResponse("rdp_reset_result", false, "Invalid token")); @@ -1028,15 +1118,7 @@ void CWebService::HandleRdpReset(void* ws_ptr, const std::string& token) { } // Get the device being watched by this client - uint64_t device_id = 0; - { - std::lock_guard lock(m_ClientsMutex); - auto it = m_Clients.find(ws_ptr); - if (it != m_Clients.end()) { - device_id = it->second.watch_device_id; - } - } - + uint64_t device_id = ResolveWatchDevice(ws_ptr, requested_device_id); if (device_id == 0) { SendText(ws_ptr, BuildJsonResponse("rdp_reset_result", false, "No device connected")); return; @@ -1060,19 +1142,14 @@ void CWebService::HandleRdpReset(void* ws_ptr, const std::string& token) { } } -void CWebService::HandleAudioToggle(void* ws_ptr, const std::string& token) { +void CWebService::HandleAudioToggle(void* ws_ptr, const std::string& token, uint64_t requested_device_id) { std::string username, role; if (!ValidateToken(token, username, role)) { SendText(ws_ptr, BuildJsonResponse("audio_toggle_result", false, "Invalid token")); return; } - uint64_t device_id = 0; - { - std::lock_guard lock(m_ClientsMutex); - auto it = m_Clients.find(ws_ptr); - if (it != m_Clients.end()) device_id = it->second.watch_device_id; - } + uint64_t device_id = ResolveWatchDevice(ws_ptr, requested_device_id); if (device_id == 0) { SendText(ws_ptr, BuildJsonResponse("audio_toggle_result", false, "No device connected")); return; @@ -1087,6 +1164,78 @@ void CWebService::HandleAudioToggle(void* ws_ptr, const std::string& token) { SendText(ws_ptr, BuildJsonResponse("audio_toggle_result", true)); } +void CWebService::HandleKeyframe(void* ws_ptr, const std::string& token, uint64_t device_id, + const std::string& req) { + Json::Value res; + res["cmd"] = "keyframe_result"; + res["id"] = std::to_string(device_id); + if (!req.empty()) res["req"] = req; + + auto send = [&](bool ok, const std::string& err) { + res["ok"] = ok; + if (!err.empty()) res["msg"] = err; + Json::StreamWriterBuilder builder; + builder["indentation"] = ""; + SendText(ws_ptr, Json::writeString(builder, res)); + }; + + std::string username, role; + if (!ValidateToken(token, username, role)) { + send(false, "Invalid token"); + return; + } + if (device_id == 0) { + send(false, "No device"); + return; + } + + // 校验该 ws 确实订阅了这台设备;同一设备最多 1 次/秒,前端重试不会打爆编码器。 + bool allowed = false; + { + std::lock_guard lock(m_ClientsMutex); + auto it = m_Clients.find(ws_ptr); + if (it != m_Clients.end() && it->second.watch_device_ids.count(device_id)) { + uint64_t now = (uint64_t)time(nullptr); + uint64_t& last = it->second.keyframe_req_at[device_id]; + if (last == 0 || now > last) { + last = now; + allowed = true; + } + } + } + if (!allowed) { + send(false, "Not watching device or rate limited"); + return; + } + + CONTEXT_OBJECT* ctx = GetScreenContext(device_id); + if (!ctx) { + send(false, "No active screen session"); + return; + } + + // 先把缓存里的 raw IDR 单独发给请求方,让它立刻有帧可画;随后再让客户端 + // 强制生成新的 IDR 重置参考链(缓存 IDR 无法保证后续 P 帧的引用正确)。 + std::vector cached; + { + std::lock_guard lock(m_DeviceCacheMutex); + auto it = m_DeviceCache.find(device_id); + if (it != m_DeviceCache.end()) { + std::lock_guard cache_lock(it->second->cache_mutex); + cached = it->second->keyframe_cache; + } + } + if (!cached.empty()) { + auto packet = BuildFramePacket(device_id, true, cached.data(), cached.size()); + SendBinary(ws_ptr, packet.data(), packet.size()); + } + + BYTE bToken = COMMAND_SCREEN_KEYFRAME_REQ; + ctx->Send2Client(&bToken, sizeof(BYTE)); + + send(true, ""); +} + ////////////////////////////////////////////////////////////////////////// // User Management Handlers ////////////////////////////////////////////////////////////////////////// @@ -1280,20 +1429,21 @@ void CWebService::RegisterClient(void* ws_ptr, const std::string& client_ip) { } void CWebService::UnregisterClient(void* ws_ptr) { - uint64_t device_id = 0; + std::set watched; - // Get device_id and remove client + // Copy the watch set and remove the client under the lock. { std::lock_guard lock(m_ClientsMutex); auto it = m_Clients.find(ws_ptr); if (it != m_Clients.end()) { - device_id = it->second.watch_device_id; + watched = std::move(it->second.watch_device_ids); m_Clients.erase(it); } } - // Close remote desktop if this was the last viewer - if (device_id > 0) { + // Close remote desktops whose last viewer just disconnected. + // StopRemoteDesktop re-locks m_ClientsMutex, so this must run outside the lock. + for (uint64_t device_id : watched) { StopRemoteDesktop(device_id); } @@ -1317,7 +1467,7 @@ int CWebService::GetWebClientCount(uint64_t device_id) { std::lock_guard lock(m_ClientsMutex); int count = 0; for (const auto& [ptr, client] : m_Clients) { - if (client.watch_device_id == device_id) { + if (client.watch_device_ids.count(device_id)) { count++; } } @@ -1664,7 +1814,7 @@ void CWebService::BroadcastFrame(uint64_t device_id, const uint8_t* data, size_t // Broadcast to all watching clients std::lock_guard lock(m_ClientsMutex); for (auto& [ws_ptr, client] : m_Clients) { - if (client.watch_device_id == device_id) { + if (client.watch_device_ids.count(device_id)) { SendBinary(ws_ptr, packet.data(), packet.size()); } } @@ -1689,15 +1839,19 @@ void CWebService::BroadcastH264Frame(uint64_t device_id, const uint8_t* data, si if (!data || len < 9 || m_bStopping) return; // Broadcast to all watching clients - std::lock_guard lock(m_ClientsMutex); - for (auto& [ws_ptr, client] : m_Clients) { - if (client.watch_device_id == device_id) { - SendBinary(ws_ptr, data, len); + { + std::lock_guard lock(m_ClientsMutex); + for (auto& [ws_ptr, client] : m_Clients) { + if (client.watch_device_ids.count(device_id)) { + SendBinary(ws_ptr, data, len); + } } } - // Cache keyframe (check FrameType byte at offset 4) + // Cache keyframe (check FrameType byte at offset 4). 只缓存 raw payload: + // HandleConnect/HandleKeyframe 发送时会统一用 BuildFramePacket 封装, + // 缓存完整包会导致双重封装(历史 bug)。 if (data[4] == 1) { // IDR frame - CacheKeyframe(device_id, data, len); + CacheKeyframe(device_id, data + 9, len - 9); } } @@ -1716,12 +1870,17 @@ void CWebService::NotifyResolutionChange(uint64_t device_id, int width, int heig it->second->screen_height = height; it->second->top_down = top_down; if (!client_type.empty()) it->second->client_type = client_type; + // 旧 IDR 对应旧分辨率/编码器,不能再发给新加入者。 + { + std::lock_guard cache_lock(it->second->cache_mutex); + it->second->keyframe_cache.clear(); + } } // Notify watching clients Json::Value res; res["cmd"] = "resolution_changed"; - res["id"] = device_id; + res["id"] = std::to_string(device_id); res["width"] = width; res["height"] = height; res["top_down"] = top_down; @@ -1733,7 +1892,7 @@ void CWebService::NotifyResolutionChange(uint64_t device_id, int width, int heig std::lock_guard lock(m_ClientsMutex); for (auto& [ws_ptr, client] : m_Clients) { - if (client.watch_device_id == device_id) { + if (client.watch_device_ids.count(device_id)) { SendText(ws_ptr, json); } } @@ -1766,7 +1925,7 @@ void CWebService::NotifyAudioState(uint64_t device_id, bool enabled) { Json::Value res; res["cmd"] = "audio_state"; - res["id"] = device_id; + res["id"] = std::to_string(device_id); res["enabled"] = enabled; Json::StreamWriterBuilder builder; @@ -1775,7 +1934,7 @@ void CWebService::NotifyAudioState(uint64_t device_id, bool enabled) { std::lock_guard lock(m_ClientsMutex); for (auto& [ws_ptr, client] : m_Clients) { - if (client.watch_device_id == device_id) { + if (client.watch_device_ids.count(device_id)) { SendText(ws_ptr, json); } } @@ -1787,6 +1946,7 @@ void CWebService::BroadcastCursor(uint64_t device_id, uint8_t cursor_index) { // Build JSON message Json::Value res; res["cmd"] = "cursor"; + res["id"] = std::to_string(device_id); res["index"] = cursor_index; Json::StreamWriterBuilder builder; @@ -1796,7 +1956,7 @@ void CWebService::BroadcastCursor(uint64_t device_id, uint8_t cursor_index) { // Send to all watching clients std::lock_guard lock(m_ClientsMutex); for (auto& [ws_ptr, client] : m_Clients) { - if (client.watch_device_id == device_id) { + if (client.watch_device_ids.count(device_id)) { SendText(ws_ptr, json); } } @@ -1807,7 +1967,7 @@ void CWebService::NotifyClipboardText(uint64_t device_id, const std::string& tex Json::Value res; res["cmd"] = "clipboard_text"; - res["id"] = device_id; + res["id"] = std::to_string(device_id); res["text"] = text; Json::StreamWriterBuilder builder; @@ -1816,7 +1976,7 @@ void CWebService::NotifyClipboardText(uint64_t device_id, const std::string& tex std::lock_guard lock(m_ClientsMutex); for (auto& [ws_ptr, client] : m_Clients) { - if (client.watch_device_id == device_id) { + if (client.watch_device_ids.count(device_id)) { SendText(ws_ptr, json); } } @@ -1855,6 +2015,18 @@ bool CWebService::StartRemoteDesktop(uint64_t device_id) { // Send COMMAND_SCREEN_SPY with H264 algorithm // If client is already capturing (MFC opened first), it will re-send TOKEN_BITMAPINFO // This creates a new hidden Web dialog while MFC dialog remains visible + + // Cold start: drop any stale IDR cached from a previous capture session, it + // may have a different resolution / encoder instance. + { + std::lock_guard lock(m_DeviceCacheMutex); + auto it = m_DeviceCache.find(device_id); + if (it != m_DeviceCache.end()) { + std::lock_guard cache_lock(it->second->cache_mutex); + it->second->keyframe_cache.clear(); + } + } + BYTE bToken[32] = { 0 }; bToken[0] = COMMAND_SCREEN_SPY; bToken[1] = 0; // DXGI mode: 0=GDI @@ -1872,7 +2044,7 @@ void CWebService::StopRemoteDesktop(uint64_t device_id) { { std::lock_guard lock(m_ClientsMutex); for (const auto& [ws_ptr, client] : m_Clients) { - if (client.watch_device_id == device_id) { + if (client.watch_device_ids.count(device_id)) { watchingCount++; } } @@ -1901,7 +2073,7 @@ void CWebService::StopRemoteDesktop(uint64_t device_id) { { std::lock_guard lock(m_ClientsMutex); for (const auto& [ws, client] : m_Clients) - if (client.watch_device_id == device_id) count++; + if (client.watch_device_ids.count(device_id)) count++; } if (count == 0 && m_pParentDlg) { ClearWebTriggered(device_id); @@ -2223,14 +2395,20 @@ void CWebService::FlushDeviceChanges() { if (!offline.empty()) { std::lock_guard lock(m_ClientsMutex); for (auto& [ws_ptr, client] : m_Clients) { - if (offline.count(client.watch_device_id)) { + // 一个 ws 可能同时看多台设备:只移除真正离线的那些,保留其它。 + std::vector gone; + for (uint64_t id : offline) { + if (client.watch_device_ids.count(id)) gone.push_back(id); + } + for (uint64_t id : gone) { Json::Value res; res["cmd"] = "device_offline"; - res["id"] = std::to_string(client.watch_device_id); + res["id"] = std::to_string(id); Json::StreamWriterBuilder builder; builder["indentation"] = ""; SendText(ws_ptr, Json::writeString(builder, res)); - client.watch_device_id = 0; + client.watch_device_ids.erase(id); + client.keyframe_req_at.erase(id); } } } diff --git a/server/2015Remote/WebService.h b/server/2015Remote/WebService.h index 4488767..c1fe072 100644 --- a/server/2015Remote/WebService.h +++ b/server/2015Remote/WebService.h @@ -68,12 +68,16 @@ struct WebClient { std::string token; std::string username; std::string role; // "admin" | "viewer" - uint64_t watch_device_id; // 0 = not watching any device + // 当前这个 ws 订阅的设备集合。屏幕墙允许多路复用;旧前端仍按单设备语义使用 + // (connect 替换整个集合)。 + std::set watch_device_ids; + // 每台设备的 keyframe 请求节流时间戳(秒),防止前端重试打爆服务端/客户端。 + std::map keyframe_req_at; uint64_t connected_at; uint64_t last_activity; // Last message/pong received (for heartbeat timeout) std::string client_ip; - WebClient() : watch_device_id(0), connected_at(0), last_activity(0) {} + WebClient() : connected_at(0), last_activity(0) {} }; // Login attempt tracking for rate limiting @@ -185,15 +189,24 @@ private: void HandleLogin(void* ws_ptr, const std::string& msg, const std::string& client_ip); void HandleGetSalt(void* ws_ptr, const std::string& msg); void HandleGetDevices(void* ws_ptr, const std::string& token); - void HandleConnect(void* ws_ptr, const std::string& token, uint64_t device_id); - void HandleDisconnect(void* ws_ptr, const std::string& token, uint64_t requested_device_id = 0); + void HandleConnect(void* ws_ptr, const std::string& token, uint64_t device_id, + const std::string& req = "", bool multi_watch = false); + void HandleDisconnect(void* ws_ptr, const std::string& token, uint64_t requested_device_id = 0, + const std::string& req = "", bool multi_watch = false); + void HandleKeyframe(void* ws_ptr, const std::string& token, uint64_t device_id, + const std::string& req); void HandlePing(void* ws_ptr, const std::string& token); void HandleMouse(void* ws_ptr, const std::string& msg); void HandleKey(void* ws_ptr, const std::string& msg); void HandleClipboardSet(void* ws_ptr, const std::string& msg); - void HandleClipboardGet(void* ws_ptr, const std::string& token); - void HandleRdpReset(void* ws_ptr, const std::string& token); - void HandleAudioToggle(void* ws_ptr, const std::string& token); + void HandleClipboardGet(void* ws_ptr, const std::string& token, uint64_t device_id = 0); + void HandleRdpReset(void* ws_ptr, const std::string& token, uint64_t device_id = 0); + void HandleAudioToggle(void* ws_ptr, const std::string& token, uint64_t device_id = 0); + + // 输入类命令的设备解析:优先用显式 id(必须在 watch 集合内);否则当集合恰好 + // 只有一台设备时回退到它(兼容不带 id 的旧前端)。多设备且无 id 时返回 0(丢弃), + // 绝不能回退到任意集合成员,否则会变成跨设备输入注入。 + uint64_t ResolveWatchDevice(void* ws_ptr, uint64_t requested_device_id); // Token management std::string GenerateToken(const std::string& username, const std::string& role); @@ -246,6 +259,10 @@ private: static const int HEARTBEAT_INTERVAL_SEC = 30; // Send ping every 30 seconds static const int HEARTBEAT_TIMEOUT_SEC = 90; // Disconnect if no activity for 90 seconds + // 每个 ws 最多订阅的设备数(屏幕墙最多 25 个 tile,留余量)。防止异常客户端 + // 订阅数百台设备拖慢每一帧的广播循环。 + static constexpr size_t kMaxWatchPerClient = 32; + // Heartbeat thread function void HeartbeatThread(); diff --git a/server/web/index.html b/server/web/index.html index 62eeb99..363d97e 100644 --- a/server/web/index.html +++ b/server/web/index.html @@ -1198,6 +1198,213 @@ .toolbar-btn-bar { display: none; } .toolbar-toggle { display: flex; } } + /* ===== Screen Wall ===== */ + #view-wall { display: none; } + #view-wall.available { display: inline-block; } + .wall-btn { border: 1px solid rgba(255, 255, 255, 0.1) !important; border-radius: 8px; } + + #wall-page { max-width: none; padding-bottom: 16px; } + .wall-header { + display: flex; justify-content: space-between; align-items: center; + gap: 12px; flex-wrap: wrap; margin-bottom: 12px; + } + .wall-title-wrap { display: flex; align-items: center; gap: 10px; min-width: 0; } + .wall-title-wrap h2 { color: #e94560; font-size: 22px; display: flex; align-items: center; gap: 10px; margin: 0; } + .wall-back-btn { + width: 36px; height: 36px; flex: 0 0 36px; border-radius: 8px; + border: 1px solid rgba(255, 255, 255, 0.1); + background: rgba(15, 52, 96, 0.8); color: #eee; + font-size: 18px; line-height: 1; cursor: pointer; + } + .wall-back-btn:hover { background: rgba(233, 69, 96, 0.3); color: #e94560; } + .wall-toolbar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } + .wall-layouts { + display: flex; border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 8px; overflow: hidden; + } + .wall-layouts .view-btn { border-right: 1px solid rgba(255, 255, 255, 0.08); border-radius: 0; } + .wall-layouts .view-btn:last-child { border-right: none; } + .wall-clear-btn { + padding: 8px 14px; border: 1px solid rgba(233, 69, 96, 0.35); + border-radius: 8px; background: rgba(233, 69, 96, 0.12); + color: #e94560; font-size: 13px; cursor: pointer; + } + .wall-clear-btn:hover { background: rgba(233, 69, 96, 0.25); } + .wall-drawer-btn { + display: none; width: 40px; height: 36px; border-radius: 8px; + border: 1px solid rgba(255, 255, 255, 0.1); + background: rgba(15, 52, 96, 0.8); color: #eee; font-size: 16px; cursor: pointer; + } + + .wall-body { + display: flex; gap: 12px; + height: calc(100vh - 120px - env(safe-area-inset-top) - env(safe-area-inset-bottom)); + min-height: 320px; + } + .wall-sidebar { + width: 236px; flex: 0 0 236px; display: flex; flex-direction: column; + background: rgba(15, 20, 40, 0.65); + border: 1px solid rgba(255, 255, 255, 0.08); border-radius: 12px; overflow: hidden; + } + .wall-sidebar-head { padding: 10px; border-bottom: 1px solid rgba(255, 255, 255, 0.08); } + .wall-search { width: 100%; } + .wall-search input { width: 100%; padding: 8px 28px 8px 10px; font-size: 13px; } + .wall-device-list { + flex: 1; overflow-y: auto; padding: 6px; + /* 深色主题滚动条:Firefox 用标准属性,Chrome/Edge 用 ::-webkit-scrollbar */ + scrollbar-width: thin; + scrollbar-color: rgba(233, 69, 96, 0.45) rgba(255, 255, 255, 0.04); + } + .wall-device-list::-webkit-scrollbar { width: 8px; } + .wall-device-list::-webkit-scrollbar-track { + background: rgba(255, 255, 255, 0.04); border-radius: 4px; + } + .wall-device-list::-webkit-scrollbar-thumb { + background: rgba(233, 69, 96, 0.45); + border-radius: 4px; + border: 2px solid transparent; + background-clip: padding-box; + } + .wall-device-list::-webkit-scrollbar-thumb:hover { + background: rgba(233, 69, 96, 0.7); background-clip: padding-box; + } + .wall-device-list::-webkit-scrollbar-corner { background: transparent; } + .wall-device-row { + display: flex; align-items: center; gap: 8px; padding: 8px 10px; + border-radius: 8px; cursor: pointer; user-select: none; + touch-action: manipulation; /* 触屏双击打开时不触发缩放 */ + } + .wall-device-row:hover { background: rgba(233, 69, 96, 0.12); } + .wall-device-row.offline { opacity: 0.45; cursor: not-allowed; } + .wall-dot { width: 8px; height: 8px; border-radius: 50%; background: #4caf50; flex: 0 0 8px; } + .wall-device-row.offline .wall-dot { background: #666; } + .wall-device-name { + flex: 1; min-width: 0; font-size: 13px; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + } + .wall-device-badge { + background: rgba(233, 69, 96, 0.2); color: #e94560; + border-radius: 10px; padding: 1px 7px; font-size: 11px; flex: 0 0 auto; + } + .wall-device-info { color: #777; font-size: 11px; } + .wall-sidebar-empty { color: #777; font-size: 12px; text-align: center; padding: 24px 8px; } + + /* 侧边栏设备悬浮提示:复用 Grid 设备卡片的内容结构(.info/.meta-row/.active-window), + 这里只覆盖浮层定位与外观,让提示信息与 Grid 模式完全一致。 */ + .wall-tooltip { + position: fixed; + z-index: 300; + width: 270px; + max-width: calc(100vw - 24px); + padding: 12px 14px; + background: rgba(22, 33, 62, 0.97); + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 10px; + box-shadow: 0 10px 28px rgba(0, 0, 0, 0.55); + cursor: default; + pointer-events: none; + display: none; + } + .wall-tooltip.visible { display: block; } + /* .device-card 的悬停位移与顶部渐变条不适用于浮层 */ + .wall-tooltip:hover { transform: none; border-color: rgba(255, 255, 255, 0.12); } + .wall-tooltip::before { display: none; } + .wall-tooltip h3 { font-size: 14px; margin-bottom: 8px; padding-right: 0; } + + .wall-grid { + flex: 1; min-width: 0; height: 100%; + display: grid; gap: 8px; grid-auto-rows: minmax(0, 1fr); + align-content: stretch; overflow: hidden; + } + .wall-empty { + grid-column: 1 / -1; grid-row: 1 / -1; + display: flex; flex-direction: column; align-items: center; justify-content: center; + gap: 10px; color: #667; font-size: 14px; text-align: center; + border: 1px dashed rgba(255, 255, 255, 0.1); border-radius: 12px; + } + .wall-empty-icon { font-size: 40px; opacity: 0.5; } + .wall-tile { + position: relative; display: flex; flex-direction: column; + min-width: 0; min-height: 0; background: #070b14; + border: 1px solid rgba(255, 255, 255, 0.09); border-radius: 10px; overflow: hidden; + } + .wall-tile:hover { border-color: rgba(233, 69, 96, 0.45); } + .wall-tile.focused { border-color: #e94560; box-shadow: 0 0 0 1px rgba(233, 69, 96, 0.4); } + .wall-tile-head { + display: flex; align-items: center; gap: 6px; padding: 5px 8px; + background: rgba(15, 20, 40, 0.85); font-size: 12px; flex: 0 0 auto; + } + .wall-tile-name { + flex: 1; min-width: 0; color: #ddd; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + } + .wall-tile-badge { + font-size: 10px; padding: 1px 6px; border-radius: 8px; + background: rgba(255, 255, 255, 0.08); color: #999; flex: 0 0 auto; + } + .wall-tile-badge.live { background: rgba(76, 175, 80, 0.18); color: #7bd88f; } + .wall-tile-badge.resuming { background: rgba(255, 193, 7, 0.18); color: #ffd54f; } + .wall-tile-badge.paused { background: rgba(255, 255, 255, 0.08); color: #aaa; } + .wall-tile-badge.offline { background: rgba(158, 158, 158, 0.18); color: #bbb; } + .wall-tile-badge.error { background: rgba(233, 69, 96, 0.22); color: #ff7b8d; } + .wall-tile-badge.waiting { background: rgba(33, 150, 243, 0.18); color: #64b5f6; } + .wall-tile-close { + border: none; background: transparent; color: #888; + font-size: 16px; line-height: 1; cursor: pointer; + padding: 2px 5px; border-radius: 4px; flex: 0 0 auto; + } + .wall-tile-close:hover { color: #e94560; background: rgba(233, 69, 96, 0.15); } + .wall-tile-body { + position: relative; flex: 1; min-height: 0; display: flex; + align-items: center; justify-content: center; + background: #000; cursor: pointer; overflow: hidden; + } + .wall-tile-body canvas { + max-width: 100%; max-height: 100%; width: auto; height: auto; + display: block; object-fit: contain; + } + .wall-tile-hover { + position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; + background: rgba(0, 0, 0, 0.35); opacity: 0; transition: opacity 0.15s; + color: #fff; font-size: 13px; pointer-events: none; text-align: center; padding: 8px; + } + .wall-tile-body:hover .wall-tile-hover { opacity: 1; } + .wall-tile-error { + position: absolute; inset: 0; display: none; align-items: center; justify-content: center; + background: rgba(20, 5, 10, 0.85); color: #ff7b8d; font-size: 12px; + text-align: center; padding: 12px; line-height: 1.5; + } + .wall-tile.has-error .wall-tile-error { display: flex; } + + #wall-sidebar-backdrop { + display: none; position: fixed; inset: 0; z-index: 55; + background: rgba(0, 0, 0, 0.5); + } + @media (max-width: 768px) { + #wall-page { + padding: calc(10px + env(safe-area-inset-top)) + calc(10px + env(safe-area-inset-right)) + calc(10px + env(safe-area-inset-bottom)) + calc(10px + env(safe-area-inset-left)); + } + .wall-title-wrap h2 { font-size: 18px; } + .wall-body { height: calc(100vh - 96px - env(safe-area-inset-top) - env(safe-area-inset-bottom)); gap: 0; } + .wall-sidebar { + position: fixed; left: 0; top: 0; bottom: 0; z-index: 60; + width: 78vw; max-width: 300px; flex: none; + border-radius: 0 12px 12px 0; background: #0d1226; + transform: translateX(-105%); transition: transform 0.2s ease; + } + .wall-sidebar.open { transform: translateX(0); box-shadow: 8px 0 30px rgba(0, 0, 0, 0.6); } + .wall-drawer-btn { display: inline-flex; align-items: center; justify-content: center; } + #wall-sidebar-backdrop.open { display: block; } + .wall-grid { gap: 5px; } + .wall-tile-head { padding: 3px 6px; font-size: 11px; } + .wall-tile-badge { font-size: 9px; padding: 0 5px; } + .wall-tile-close { font-size: 14px; padding: 1px 4px; } + .wall-layouts .view-btn { padding: 8px 9px; font-size: 12px; } + .wall-clear-btn { padding: 8px 10px; font-size: 12px; } + } @@ -1241,6 +1448,7 @@ + +

Screen Wall 0

+ +
+
+ + + + + +
+ + + +
+ +
+ +
+
+
+
🖥
+
Double-click a device on the left to add it to the wall
+
+
+
+
@@ -1433,6 +1685,41 @@ const canvas = document.getElementById('screen-canvas'); const ctx = canvas.getContext('2d'); + // ===================== Screen Wall (multi-device) ===================== + // 该常量必须在脚本顶部:wall 状态初始化立即用到它,后文声明会触发 TDZ。 + const isTouchDevice = ('ontouchstart' in window) || (navigator.maxTouchPoints > 0); + + const WALL_MAX_TILES = 25; + const WALL_LAYOUTS = [1, 4, 9, 16, 25]; + const WALL_KEYFRAME_RETRY_MS = 1500; + const WALL_KEYFRAME_MAX_RETRIES = 3; + const WALL_STORAGE_KEY = 'yama_wall_state_v1'; + // 解码预算:桌面 9 路,触屏设备硬件解码器少,降到 4 路。 + // 用函数延迟求值(isTouchDevice 的 TDZ 已解决,但保留函数形式便于调试)。 + const wallLiveBudget = () => (isTouchDevice ? 4 : 9); + // 二进制帧头只带设备 ID 的低 32 位(C++ BuildFramePacket 截断)。 + const low32 = (id) => { + try { return Number(BigInt(id) & 0xFFFFFFFFn); } catch (e) { return NaN; } + }; + + // 服务端能力协商:旧 C++ / Go 服务端没有 caps,前端隐藏 Wall。 + let serverCaps = { multiWatch: false, keyframeRequest: false }; + let wall = { + active: false, + layout: isTouchDevice ? 4 : 9, + tiles: [], // {uid, deviceId, low32, canvas, el} + sessions: new Map(), // low32 -> WallSession + search: '', + focusDeviceId: null, + fromWall: false, // 单画面页是否由 tile 放大进入 + uidSeq: 1 + }; + let wallRestored = false; + // 本地订阅引用计数:key 为 JSON 协议里的完整 id 字符串。 + // owners = Set<'focus' | 'wall:uid'> + let streams = new Map(); + let reqSeq = 1; + // ====== Audio & Video Implementation ====== // // - Video: H.264 / AV1 → VideoDecoder Web API → canvas @@ -1768,19 +2055,45 @@ startPingInterval(); // Auto-restore session if token exists if (token) { - // Check if we were on screen-page with an active device const screenPage = document.getElementById('screen-page'); + const wallPage = document.getElementById('wall-page'); if (screenPage.classList.contains('active') && currentDevice) { // Reconnect to current device updateScreenStatus('connecting'); - ws.send(JSON.stringify({ cmd: 'connect', id: String(currentDevice.id), token })); + needKeyframe = true; + const sid = String(currentDevice.id); + const st = streams.get(sid); + if (!st || st.refcount === 0) acquireStream(sid, 'focus'); + else wallSendConnect(sid); + } else if (wallPage.classList.contains('active')) { + // 服务端已丢失 watch 集合:按本地引用计数重新订阅, + // 然后按预算恢复墙内解码(暂停中的 session 会请求 keyframe)。 + resubscribeAll(); + wallAllocBudget(); + getDevices(); } else { showPage('devices-page'); getDevices(); } } }; - ws.onclose = () => { stopPingInterval(); updateWsStatus('disconnected'); stopAllAudio(); audioFormat = null; scheduleReconnect(); }; + ws.onclose = () => { + stopPingInterval(); + updateWsStatus('disconnected'); + stopAllAudio(); + audioFormat = null; + // WS 断开:关闭所有墙解码器释放 GPU 资源,保留 tile 布局。 + // streams 保留,重连时 resubscribeAll() 重发 connect。 + for (const s of wall.sessions.values()) { + s.pendingConnect = false; + s.needKeyframe = true; + const wasLive = s.live; + wallCloseDecoder(s); + s.live = false; + if (wasLive) wallSetStatus(s, 'paused'); + } + scheduleReconnect(); + }; ws.onerror = (e) => console.error('WS error:', e); ws.onmessage = (event) => { if (typeof event.data === 'string') handleSignaling(JSON.parse(event.data)); @@ -1804,7 +2117,13 @@ switch (msg.cmd) { case 'challenge': challengeNonce = msg.nonce || ''; - console.log('Received challenge nonce'); + // 能力协商:旧 C++ / Go 服务端没有 caps → 隐藏 Wall,保持单设备行为。 + serverCaps = { + multiWatch: !!(msg.caps && msg.caps.multiWatch), + keyframeRequest: !!(msg.caps && msg.caps.keyframeRequest) + }; + updateWallAvailability(); + console.log('Received challenge nonce; caps=', serverCaps); break; case 'salt': if (msg.ok) { @@ -1845,9 +2164,26 @@ devices = msg.devices || []; // Keep current page (renderDeviceList adjusts if out of range) renderDeviceList(); + renderWallSidebar(); + wallUpdateAllTileMeta(); break; - case 'connect_result': + case 'connect_result': { if (!token) break; + const resId = (msg.id !== undefined && msg.id !== null) ? String(msg.id) : null; + const focusId = currentDevice ? String(currentDevice.id) : null; + const onScreen = document.getElementById('screen-page').classList.contains('active'); + const isFocus = resId !== null + ? (onScreen && focusId === resId) + : (onScreen && !!currentDevice); + + // 墙 session:记录尺寸/状态并请求关键帧。即使同时是聚焦设备 + // (重复 connect)也更新,避免墙 tile 卡在 Waiting。 + if (resId !== null) { + const wsess = wall.sessions.get(low32(resId)); + if (wsess && wsess.fullId === resId) wallApplyConnectResult(wsess, msg); + } + if (!isFocus) break; + if (msg.ok) { // Resolution may not be available yet (first connection) if (msg.width && msg.height) { @@ -1866,14 +2202,32 @@ if (typeof msg.audio_enabled === 'boolean') { applyAudioState(msg.audio_enabled); } + // 用新 IDR 重置参考链(缓存 IDR 只用于第一帧立即出图)。 + requestFocusKeyframe(focusId); } else { updateScreenStatus('error', msg.msg); - setTimeout(() => showPage('devices-page'), 2000); + const failedId = resId || focusId; + setTimeout(() => { + // 连接失败:释放引用,避免 streams 残留导致后续重订阅泄漏。 + // 无论用户是否已切换都必须释放(释放的是失败设备的 owner)。 + if (failedId) releaseStream(failedId, 'focus'); + // 2 秒内用户可能已返回设备页、进入墙或新开了别的设备: + // 只有仍停留在这台失败设备上时才导航,避免打断新会话。 + if (!currentDevice || String(currentDevice.id) !== failedId) return; + currentDevice = null; + wall.fromWall = false; + showPage('devices-page'); + }, 2000); } break; - case 'audio_state': + } + case 'audio_state': { + // 只处理当前聚焦设备;墙静音。 + if (msg.id !== undefined && msg.id !== null && currentDevice && + String(msg.id) !== String(currentDevice.id)) break; applyAudioState(!!msg.enabled); break; + } case 'audio_toggle_result': if (!msg.ok) console.warn('[Audio] toggle failed:', msg.msg); break; @@ -1899,7 +2253,33 @@ // disconnect() already handles navigation, this is just server acknowledgment // No action needed - prevents race conditions when switching devices break; - case 'resolution_changed': + case 'resolution_changed': { + const rcId = (msg.id !== undefined && msg.id !== null) ? String(msg.id) : null; + const focusId = currentDevice ? String(currentDevice.id) : null; + const onScreen = document.getElementById('screen-page').classList.contains('active'); + const isFocus = rcId !== null + ? (onScreen && focusId === rcId) + : onScreen; + + if (rcId !== null) { + const wsess = wall.sessions.get(low32(rcId)); + if (wsess && wsess.fullId === rcId && + typeof msg.width === 'number' && typeof msg.height === 'number') { + const changed = (wsess.width !== msg.width || wsess.height !== msg.height); + wsess.width = msg.width; + wsess.height = msg.height; + wsess.topDown = msg.top_down === true; + wallPrepareCanvasDimensions(wsess); + if (changed) wallCloseDecoder(wsess); + if (wall.active && wsess.live) { + wsess.needKeyframe = true; + wallSetStatus(wsess, 'resuming'); + wallRequestKeyframe(wsess, true); + } + } + } + if (!isFocus) break; + updateScreenStatus('connected'); isTopDown = msg.top_down === true; if (msg.client_type !== undefined) { @@ -1912,7 +2292,11 @@ } initDecoder(msg.width, msg.height); break; - case 'cursor': + } + case 'cursor': { + // 墙 tile 不画远程光标;只处理聚焦设备。 + if (msg.id !== undefined && msg.id !== null && currentDevice && + String(msg.id) !== String(currentDevice.id)) break; // Update remote cursor visual: native CSS cursor on desktop, // overlay variant on touch. 254=custom (unsupported), 255=unsupported. currentCursorIndex = msg.index; @@ -1920,19 +2304,41 @@ applyRemoteCursor(msg.index); } break; - case 'clipboard_text': - // 远程剪贴板文本回传:写回浏览器(即本机)剪贴板 + } + case 'clipboard_text': { + // 远程剪贴板文本回传:写回浏览器(即本机)剪贴板。墙忽略。 + if (msg.id !== undefined && msg.id !== null && currentDevice && + String(msg.id) !== String(currentDevice.id)) break; if (msg.text) { try { navigator.clipboard.writeText(msg.text); } catch (e) {} } break; - case 'device_offline': - // Only handle if this is the device we're currently viewing + } + case 'device_offline': { if (!token) break; - if (!currentDevice || String(msg.id) !== String(currentDevice.id)) break; + const offId = String(msg.id); + // 服务端已从 watch 集合移除该设备:本地引用同步失效(无论墙 + // 内是否有 session),tile 保留,设备重新上线后点击 tile 可重连。 + // 若不删除,残留 refcount 会让重新加入墙时缺少 0→1 的 connect。 + streams.delete(offId); + const wsess = wall.sessions.get(low32(offId)); + if (wsess && wsess.fullId === offId) { + wallCloseDecoder(wsess); + wsess.live = false; + wallSetStatus(wsess, 'offline'); + wallAllocBudget(); + wallUpdateSidebarBadges(); + } + // 聚焦设备继续走原有提示并返回设备页。 + if (!currentDevice || offId !== String(currentDevice.id)) break; updateScreenStatus('error', 'Device offline'); setTimeout(() => { showPage('devices-page'); getDevices(); }, 2000); break; + } + case 'keyframe_result': + // 以实际收到 keyframe 为准;失败时等待自然 IDR。 + if (msg.ok === false) console.warn('[Wall] keyframe request failed:', msg.msg); + break; case 'device_update': // Only update if on devices page if (document.getElementById('devices-page').classList.contains('active')) { @@ -1941,7 +2347,8 @@ break; case 'devices_changed': // Refresh device list when devices come online/offline - if (document.getElementById('devices-page').classList.contains('active')) { + if (document.getElementById('devices-page').classList.contains('active') || + document.getElementById('wall-page').classList.contains('active')) { getDevices(); } break; @@ -2059,6 +2466,915 @@ return videoBytes[0] === 0x00 ? 'avc' : 'av1'; } + // ===================================================================== + // Screen Wall: multi-device monitoring + // + // 一个标签页仍然只有一条 WS。墙为每台设备维护一个 WallSession + // (一个 VideoDecoder + N 个 tile canvas),同一设备的重复 tile 共享 + // session。streams 是对服务端 watch 集合的本地引用计数: + // refresh 0→1 时发送 connect,1→0 时发送 disconnect。 + // 二进制帧只按 low32(deviceId) 路由(帧头截断),JSON 响应按完整 id。 + // ===================================================================== + + function updateWallAvailability() { + const btn = document.getElementById('view-wall'); + if (!btn) return; + const ok = !!(serverCaps.multiWatch && checkWebCodecs().supported); + btn.classList.toggle('available', ok); + // Go / 旧 C++ 服务端没有 multiWatch:墙不可用。 + if (!ok && wall.active) { + // 能力在登录后才到达,若已在墙页则退回设备页。 + showPage('devices-page'); + } + } + + function wallNextReq() { return String(reqSeq++); } + + // 单画面页请求一次新 IDR:晚加入 / 从墙放大的设备可立即重建参考链。 + // 服务端按 1 次/秒/设备限流,失败时自然 GOP 兜底。 + function requestFocusKeyframe(deviceId) { + if (!serverCaps.keyframeRequest || !deviceId) return; + if (!ws || ws.readyState !== WebSocket.OPEN || !token) return; + try { + ws.send(JSON.stringify({ + cmd: 'keyframe', id: String(deviceId), token, req: wallNextReq() + })); + } catch (e) {} + } + + function currentDeviceId() { + return currentDevice ? String(currentDevice.id) : undefined; + } + + // ---------------- 订阅引用计数 ---------------- + + function acquireStream(deviceId, owner) { + const id = String(deviceId); + let st = streams.get(id); + if (!st) { + st = { refcount: 0, owners: new Set(), connected: false }; + streams.set(id, st); + } + if (st.owners.has(owner)) return; + st.owners.add(owner); + st.refcount++; + if (st.refcount === 1) { + st.connected = false; + wallSendConnect(id); + } + } + + function releaseStream(deviceId, owner) { + const id = String(deviceId); + const st = streams.get(id); + if (!st || !st.owners.has(owner)) return; + st.owners.delete(owner); + st.refcount--; + if (st.refcount <= 0) { + streams.delete(id); + wallSendDisconnect(id); + } + } + + function wallSendConnect(id) { + if (!ws || ws.readyState !== WebSocket.OPEN || !token) return; + const sid = String(id); + const st = streams.get(sid); + if (st) st.connected = true; + const s = wall.sessions.get(low32(sid)); + if (s) s.pendingConnect = true; + ws.send(JSON.stringify({ + cmd: 'connect', id: sid, token, req: wallNextReq(), multiWatch: true + })); + } + + function wallSendDisconnect(id) { + if (!ws || ws.readyState !== WebSocket.OPEN || !token) return; + ws.send(JSON.stringify({ + cmd: 'disconnect', id: String(id), token, req: wallNextReq(), multiWatch: true + })); + } + + // WS 重连后,服务端已丢失所有 watch 状态,按本地引用计数重发 connect。 + function resubscribeAll() { + for (const [id, st] of streams) { + if (st.refcount > 0) wallSendConnect(id); + } + } + + // ---------------- WallSession / 解码器 ---------------- + + function wallCreateSession(fullId) { + const id = String(fullId); + const l32 = low32(id); + const s = { + fullId: id, low32: l32, + decoder: null, codec: '', + width: 0, height: 0, topDown: false, + needKeyframe: true, live: false, + status: 'waiting', error: '', + canvases: new Set(), + decodeTimestamp: 0, lastFrameAt: 0, + keyframeReqAt: 0, keyframeRetries: 0, keyframeTimer: null, + decodeFailures: 0, + pendingConnect: false + }; + wall.sessions.set(l32, s); + return s; + } + + function wallCloseDecoder(s) { + if (s.keyframeTimer) { clearTimeout(s.keyframeTimer); s.keyframeTimer = null; } + if (s.decoder) { try { s.decoder.close(); } catch (e) {} s.decoder = null; } + s.needKeyframe = true; + } + + function wallSetStatus(s, status, error) { + if (!s) return; + s.status = status; + s.error = error || ''; + for (const tile of wall.tiles) { + if (tile.low32 === s.low32) wallUpdateTileBadge(tile); + } + } + + function wallPrepareCanvasDimensions(s) { + if (!s.width || !s.height) return; + for (const c of s.canvases) { + if (c.width !== s.width) c.width = s.width; + if (c.height !== s.height) c.height = s.height; + const cx = c.getContext('2d'); + if (s.topDown) cx.setTransform(1, 0, 0, 1, 0, 0); + else cx.setTransform(1, 0, 0, -1, 0, c.height); + } + } + + function wallInitDecoder(s) { + wallCloseDecoder(s); + if (!s.live) return false; + const codecStr = s.codec === 'av1' ? 'av01.0.08M.08' : 'avc1.42E01E'; + try { + s.decoder = new VideoDecoder({ + output: (frame) => wallOnDecodedFrame(s, frame), + error: (e) => { + console.error('[Wall] decoder error', s.fullId, e); + // 已从墙移除的旧 session(被关闭后迟到的回调):不再操作状态或重试。 + if (wall.sessions.get(s.low32) !== s) return; + s.decodeFailures++; + s.needKeyframe = true; + wallCloseDecoder(s); + if (s.decodeFailures >= 3) { + s.live = false; + wallSetStatus(s, 'error', + 'Decoder failed: ' + ((e && e.message) ? e.message : e)); + wallAllocBudget(); + } else { + wallSetStatus(s, 'resuming'); + wallRequestKeyframe(s, true, 500); + } + } + }); + const cfg = { codec: codecStr, optimizeForLatency: true }; + // 码流参数(SPS)由首个 keyframe 携带;有 connect_result 尺寸时 + // 一并提供,便于解码器尽早分配输出缓冲。 + if (s.width > 0) cfg.codedWidth = s.width; + if (s.height > 0) cfg.codedHeight = s.height; + s.decoder.configure(cfg); + s.decodeTimestamp = 0; + s.needKeyframe = true; + return true; + } catch (e) { + console.error('[Wall] configure failed', s.fullId, e); + s.decoder = null; + s.decodeFailures++; + s.live = false; + wallSetStatus(s, 'error', + 'Cannot create decoder: ' + ((e && e.message) ? e.message : e)); + // 指数退避重试(硬件解码器耗尽等临时故障) + if (s.decodeFailures < 3) { + setTimeout(() => { + // session 可能已被移除、被移除后同 low32 重新创建,或已被其它路径恢复。 + // 必须比较对象身份:仅判断 key 存在会误操作已孤立的旧 session。 + if (s.live || wall.sessions.get(s.low32) !== s || s.status !== 'error') return; + s.live = true; + s.needKeyframe = true; + wallSetStatus(s, 'resuming'); + wallAllocBudget(); + wallRequestKeyframe(s, true); + }, 2000 * s.decodeFailures); + } else { + // 放弃该 session,把预算槽让给其它 tile。 + wallAllocBudget(); + } + return false; + } + } + + function wallOnDecodedFrame(s, frame) { + try { + if (!s.live) return; + s.lastFrameAt = performance.now(); + const fw = frame.displayWidth || s.width; + const fh = frame.displayHeight || s.height; + for (const c of s.canvases) { + if (!c.isConnected) continue; + if (fw && fh && (c.width !== fw || c.height !== fh)) { + c.width = fw; + c.height = fh; + if (fw !== s.width || fh !== s.height) { + s.width = fw; + s.height = fh; + } + } + const cx = c.getContext('2d'); + // Windows/Linux/macOS 客户端发的是 bottom-up,需要垂直翻转; + // Android MediaCodec 是 top-down。 + if (s.topDown) cx.setTransform(1, 0, 0, 1, 0, 0); + else cx.setTransform(1, 0, 0, -1, 0, c.height); + cx.drawImage(frame, 0, 0); + } + if (s.live && s.status !== 'live') wallSetStatus(s, 'live'); + } catch (e) { + console.warn('[Wall] draw failed', s.fullId, e); + } finally { + try { frame.close(); } catch (e) {} + } + } + + function wallHandleFrame(s, data, frameType, dataLen) { + if (!s || !s.live || s.status === 'error' || s.status === 'offline') return; + if (dataLen <= 0) return; + const isKey = frameType === 1; + const videoData = new Uint8Array(data, 9, dataLen); + const frameCodec = detectCodec(videoData); + + // codec 切换必须等 keyframe 才能重建解码器 + if (s.codec && frameCodec !== s.codec) { + if (!isKey) { + s.needKeyframe = true; + wallRequestKeyframe(s); + return; + } + s.codec = ''; + wallCloseDecoder(s); + } + + if (!s.decoder) { + if (!isKey) { + s.needKeyframe = true; + wallRequestKeyframe(s); + return; + } + s.codec = frameCodec; + if (!wallInitDecoder(s)) return; + } + if (!s.decoder || s.decoder.state !== 'configured') { + s.needKeyframe = true; + return; + } + if (s.needKeyframe && !isKey) { + wallRequestKeyframe(s); + return; + } + // 与单画面路径一致:解码队列积压时丢弃 P 帧并等待 IDR 重新同步 + if (!isKey && s.decoder.decodeQueueSize > 10) { + s.needKeyframe = true; + wallRequestKeyframe(s); + return; + } + try { + s.decoder.decode(new EncodedVideoChunk({ + type: isKey ? 'key' : 'delta', + timestamp: s.decodeTimestamp++, + data: videoData + })); + if (isKey) { + s.needKeyframe = false; + s.keyframeRetries = 0; + if (s.keyframeTimer) { + clearTimeout(s.keyframeTimer); + s.keyframeTimer = null; + } + } + } catch (e) { + console.warn('[Wall] decode failed', s.fullId, e); + s.needKeyframe = true; + wallRequestKeyframe(s); + } + } + + // 请求客户端下一帧强制 IDR。带节流 + 1.5s 重试(最多 3 次); + // 旧客户端不认识该命令时回退为等待自然 GOP,UI 显示 Resuming…。 + function wallRequestKeyframe(s, force, delay) { + if (!s || !s.live) return; + if (!serverCaps.keyframeRequest) return; + if (!ws || ws.readyState !== WebSocket.OPEN || !token) return; + const now = performance.now(); + if (!force && now - s.keyframeReqAt < 1000) return; + if (!force && s.keyframeRetries >= WALL_KEYFRAME_MAX_RETRIES) return; + if (s.keyframeTimer) { clearTimeout(s.keyframeTimer); s.keyframeTimer = null; } + + const send = () => { + if (!s.live) return; + if (!s.needKeyframe && s.status === 'live') return; + if (!ws || ws.readyState !== WebSocket.OPEN || !token) return; + s.keyframeReqAt = performance.now(); + if (!force) s.keyframeRetries++; + try { + ws.send(JSON.stringify({ + cmd: 'keyframe', id: s.fullId, token, req: wallNextReq() + })); + } catch (e) {} + if (s.status !== 'live') wallSetStatus(s, 'resuming'); + if (s.needKeyframe && s.keyframeRetries < WALL_KEYFRAME_MAX_RETRIES) { + s.keyframeTimer = setTimeout(() => { + s.keyframeTimer = null; + if (s.live && s.needKeyframe) wallRequestKeyframe(s); + }, WALL_KEYFRAME_RETRY_MS); + } + }; + if (delay) s.keyframeTimer = setTimeout(send, delay); + else send(); + } + + function wallResumeSession(s) { + if (!s || s.live) return; + if (s.status === 'error' || s.status === 'offline') return; + s.live = true; + s.needKeyframe = true; + s.keyframeRetries = 0; + s.decodeFailures = 0; + s.decodeTimestamp = 0; + // 解码器延迟到首个 keyframe 再创建:必须先用 detectCodec 嗅探 + // H.264/AV1,否则用错 codec 配置会直接解码失败。 + wallSetStatus(s, 'resuming'); + // connect 尚未完成时不要请求 keyframe:服务端还没开始抓屏,请求会被丢弃。 + // connect_result 到达后会再请求一次。 + if (!s.pendingConnect) wallRequestKeyframe(s, true); + } + + function wallPauseSession(s) { + if (!s || !s.live) return; + s.live = false; + wallCloseDecoder(s); + if (s.status === 'live' || s.status === 'resuming' || s.status === 'waiting') { + wallSetStatus(s, 'paused'); + } + } + + function wallPauseAll() { + for (const s of wall.sessions.values()) wallPauseSession(s); + } + + // 按当前布局/在线状态计算可见 session,并分配解码预算。 + // 超出预算的 tile 暂停解码但保留末帧;已有 live session 优先保留(LRU)。 + function wallAllocBudget() { + if (!wall.active) { wallPauseAll(); return; } + const budget = wallLiveBudget(); + const visible = []; + const seen = new Set(); + wall.tiles.forEach((tile, idx) => { + if (idx >= wall.layout) return; + const s = wall.sessions.get(tile.low32); + if (!s || seen.has(s.low32)) return; + seen.add(s.low32); + if (s.status === 'offline' || s.status === 'error') return; + visible.push(s); + }); + + let keep = visible.filter(s => s.live); + const rest = visible.filter(s => !s.live); + if (keep.length > budget) { + keep.sort((a, b) => (b.lastFrameAt || 0) - (a.lastFrameAt || 0)); + keep.slice(budget).forEach(wallPauseSession); + keep = keep.slice(0, budget); + } + const winners = keep.concat(rest.slice(0, Math.max(0, budget - keep.length))); + const winnerIds = new Set(winners.map(s => s.low32)); + for (const s of wall.sessions.values()) { + if (s.live && !winnerIds.has(s.low32)) wallPauseSession(s); + } + winners.forEach(wallResumeSession); + } + + // ---------------- tile / 布局 / 侧边栏 ---------------- + + function wallUpdateTileMeta(tile) { + if (!tile.el) return; + const dev = devices.find(d => String(d.id) === tile.deviceId); + const name = dev ? displayName(dev) : ('Device ' + tile.deviceId); + const nameEl = tile.el.querySelector('.wall-tile-name'); + if (nameEl) { + nameEl.textContent = name; + nameEl.title = name; + } + wallUpdateTileBadge(tile); + } + + function wallUpdateTileBadge(tile) { + if (!tile.el) return; + const s = wall.sessions.get(tile.low32); + let status = 'waiting', label = 'Waiting', error = ''; + if (s) { + status = s.status; + error = s.error || ''; + if (status === 'live') label = 'Live'; + else if (status === 'resuming') label = 'Resuming…'; + else if (status === 'paused') label = 'Paused'; + else if (status === 'offline') label = 'Offline'; + else if (status === 'error') label = 'Error'; + else label = 'Waiting'; + } else { + status = 'error'; + label = 'Error'; + error = 'Session unavailable'; + } + const badge = tile.el.querySelector('.wall-tile-badge'); + if (badge) { + badge.className = 'wall-tile-badge ' + status; + badge.textContent = label; + } + tile.el.classList.toggle('has-error', status === 'error'); + const errEl = tile.el.querySelector('.wall-tile-error'); + if (errEl) errEl.textContent = error; + } + + function wallCreateTileEl(tile) { + const el = document.createElement('div'); + el.className = 'wall-tile'; + el.dataset.uid = tile.uid; + el.innerHTML = + '
' + + '' + + 'Waiting' + + '' + + '
' + + '
' + + '
' + + '
Click to control
' + + '
'; + const body = el.querySelector('.wall-tile-body'); + // canvas 放在最前,保证 error / hover 覆盖层绘制在它上面。 + body.insertBefore(tile.canvas, body.firstChild); + el.querySelector('.wall-tile-close').addEventListener('click', (ev) => { + ev.stopPropagation(); + removeWallTile(tile.uid); + }); + body.addEventListener('click', () => focusWallTile(tile.uid)); + return el; + } + + function wallRenderGrid() { + const grid = document.getElementById('wall-grid'); + if (!grid) return; + const n = Math.max(1, Math.round(Math.sqrt(wall.layout)) || 1); + grid.style.gridTemplateColumns = 'repeat(' + n + ', minmax(0, 1fr))'; + grid.style.gridTemplateRows = 'repeat(' + n + ', minmax(0, 1fr))'; + const empty = document.getElementById('wall-empty'); + if (empty) empty.style.display = wall.tiles.length ? 'none' : 'flex'; + // 移除已删除的 tile 节点 + Array.from(grid.querySelectorAll('.wall-tile')).forEach(el => { + if (!wall.tiles.some(t => t.uid === el.dataset.uid)) el.remove(); + }); + wall.tiles.forEach((tile, idx) => { + if (tile.el.parentNode !== grid) grid.appendChild(tile.el); + tile.el.style.display = idx < wall.layout ? '' : 'none'; + wallUpdateTileMeta(tile); + }); + const count = document.getElementById('wall-tile-count'); + if (count) count.textContent = String(wall.tiles.length); + } + + function wallAddTileInternal(deviceId, restoring) { + const id = String(deviceId); + const l32 = low32(id); + if (!Number.isFinite(l32)) return null; + if (wall.tiles.length >= WALL_MAX_TILES) return null; + const existing = wall.sessions.get(l32); + if (existing && existing.fullId !== id) { + // 两台设备的低 32 位相同:绝不能共用一个解码器。 + if (!restoring) alert('Device ID collision (low 32 bits). Cannot open both on the same wall.'); + return null; + } + const uid = 'w' + (wall.uidSeq++); + const tile = { + uid, deviceId: id, low32: l32, + canvas: document.createElement('canvas'), + el: null + }; + tile.el = wallCreateTileEl(tile); + wall.tiles.push(tile); + + let s = existing; + if (!s) s = wallCreateSession(id); + s.canvases.add(tile.canvas); + if (s.width > 0 && s.height > 0) wallPrepareCanvasDimensions(s); + wallUpdateTileMeta(tile); + return tile; + } + + function addWallTile(deviceId) { + if (!serverCaps.multiWatch) { + alert('Screen Wall is not supported by this server'); + return; + } + if (wall.tiles.length >= WALL_MAX_TILES) { + alert('Wall is full (max ' + WALL_MAX_TILES + ' tiles)'); + return; + } + const dev = devices.find(d => String(d.id) === String(deviceId)); + const tile = wallAddTileInternal(deviceId, false); + if (!tile) return; + + if (dev && !dev.online) { + wallSetStatus(wall.sessions.get(tile.low32), 'offline'); + } else { + acquireStream(tile.deviceId, 'wall:' + tile.uid); + wallAllocBudget(); + } + wallRenderGrid(); + wallSaveState(); + wallUpdateSidebarBadges(); + if (isTouchDevice) toggleWallSidebar(false); + } + + function removeWallTile(uid) { + const idx = wall.tiles.findIndex(t => t.uid === uid); + if (idx < 0) return; + const tile = wall.tiles[idx]; + wall.tiles.splice(idx, 1); + releaseStream(tile.deviceId, 'wall:' + tile.uid); + const s = wall.sessions.get(tile.low32); + if (s) { + s.canvases.delete(tile.canvas); + if (s.canvases.size === 0) { + wallCloseDecoder(s); + wall.sessions.delete(s.low32); + } + } + if (tile.el && tile.el.parentNode) tile.el.remove(); + wallRenderGrid(); + wallSaveState(); + wallAllocBudget(); + wallUpdateSidebarBadges(); + } + + function clearWall() { + if (!wall.tiles.length) return; + if (!confirm('Close all tiles on the wall?')) return; + const tiles = wall.tiles.slice(); + wall.tiles = []; + for (const tile of tiles) { + releaseStream(tile.deviceId, 'wall:' + tile.uid); + if (tile.el && tile.el.parentNode) tile.el.remove(); + } + for (const s of wall.sessions.values()) wallCloseDecoder(s); + wall.sessions.clear(); + wallRenderGrid(); + wallSaveState(); + wallUpdateSidebarBadges(); + } + + function wallIsKnownOffline(id) { + const d = devices.find(x => String(x.id) === String(id)); + return !!d && !d.online; + } + + function wallSyncTileSubscriptions() { + wall.tiles.forEach((tile, idx) => { + const owner = 'wall:' + tile.uid; + if (idx < wall.layout && !wallIsKnownOffline(tile.deviceId)) { + acquireStream(tile.deviceId, owner); + } else { + releaseStream(tile.deviceId, owner); + } + }); + } + + function wallSyncLayoutButtons() { + document.querySelectorAll('#wall-layouts .view-btn').forEach(b => { + b.classList.toggle('active', parseInt(b.dataset.layout, 10) === wall.layout); + }); + } + + function setWallLayout(n) { + if (!WALL_LAYOUTS.includes(n)) return; + wall.layout = n; + wallSyncLayoutButtons(); + wallRenderGrid(); + wallSyncTileSubscriptions(); + if (wall.active) wallAllocBudget(); + wallSaveState(); + } + + function wallFilteredDevices() { + const q = (wall.search || '').toLowerCase(); + if (!q) return devices; + return devices.filter(d => + (d.name && d.name.toLowerCase().includes(q)) || + (d.remark && d.remark.toLowerCase().includes(q)) || + (d.ip && d.ip.toLowerCase().includes(q)) || + (d.os && d.os.toLowerCase().includes(q)) || + (d.location && d.location.toLowerCase().includes(q)) + ); + } + + // 侧边栏行的双击判定间隔。用 click 计时而不是原生 dblclick: + // 桌面双击与触屏双击(double-tap)都会产生两次 click,行为一致。 + const WALL_ROW_DBLCLICK_MS = 400; + let wallLastRowClick = null; + let wallTooltipEl = null; + + function wallFindDevice(id) { + return devices.find(d => String(d.id) === id) || null; + } + + function wallTooltipHtml(d) { + // 与 Grid 模式 device-card 的字段/顺序保持一致。 + const screenInfo = escapeHtml(d.screen || '-'); + const rtt = d.rtt || '-'; + const activeWin = d.activeWindow || ''; + return '

' + escapeHtml(displayName(d)) + '

' + + '
' + + '
IP: ' + escapeHtml(d.ip || '-') + '
' + + '
Loc: ' + escapeHtml(d.location || '-') + '
' + + '
' + + '
OS: ' + escapeHtml(d.os || '-') + '
' + + '
' + + 'RTT: ' + escapeHtml(rtt) + '' + + 'Ver: ' + escapeHtml(d.version || '-') + '' + + '' + screenInfo + '' + + '
' + + (activeWin ? '
' + + escapeHtml(activeWin) + '
' : ''); + } + + function wallShowTooltip(row) { + // 只在真正支持悬停的指针设备上显示:触屏 tap 会顺带触发 mouseenter, + // 浮层会挡住双击目标;触屏笔记本外接鼠标时 (hover: hover) 仍为真。 + const canHover = window.matchMedia && window.matchMedia('(hover: hover)').matches; + if (!canHover) return; + const d = wallFindDevice(row.dataset.deviceId); + if (!d) return; + if (!wallTooltipEl) { + wallTooltipEl = document.createElement('div'); + wallTooltipEl.className = 'device-card wall-tooltip'; + wallTooltipEl.setAttribute('role', 'tooltip'); + document.body.appendChild(wallTooltipEl); + } + wallTooltipEl.innerHTML = wallTooltipHtml(d); + wallTooltipEl.classList.add('visible'); + const rect = row.getBoundingClientRect(); + const tipRect = wallTooltipEl.getBoundingClientRect(); + const gap = 10; + // 优先放在行右侧;右侧放不下(窄屏/抽屉)就夹在视口内。 + let left = rect.right + gap; + if (left + tipRect.width > window.innerWidth - 8) { + left = Math.max(8, window.innerWidth - tipRect.width - 8); + } + let top = rect.top + rect.height / 2 - tipRect.height / 2; + top = Math.max(8, Math.min(top, window.innerHeight - tipRect.height - 8)); + wallTooltipEl.style.left = left + 'px'; + wallTooltipEl.style.top = top + 'px'; + } + + function wallHideTooltip() { + if (wallTooltipEl) wallTooltipEl.classList.remove('visible'); + } + + function renderWallSidebar() { + const host = document.getElementById('wall-device-list'); + if (!host) return; + // 列表重渲染后旧行已销毁,必须先收起浮层,否则会悬在页面上不消失。 + // 注意不要清空 wallLastRowClick:重渲染可能发生在双击的两下之间, + // 保留计时才能让第二次点击仍然生效。 + wallHideTooltip(); + if (!devices.length) { + host.innerHTML = '
Loading devices...
'; + return; + } + const list = wallFilteredDevices(); + if (!list.length) { + host.innerHTML = '
No matching devices
'; + return; + } + const counts = new Map(); + for (const t of wall.tiles) counts.set(t.deviceId, (counts.get(t.deviceId) || 0) + 1); + host.innerHTML = list.map(d => { + const id = String(d.id); + const n = counts.get(id) || 0; + const name = displayName(d); + return '
' + + '' + + '' + escapeHtml(name) + '' + + (n ? '' + n + '' : '') + + '
'; + }).join(''); + host.onscroll = wallHideTooltip; + host.querySelectorAll('.wall-device-row').forEach(row => { + // 单击不做任何事,避免误触新开画面;双击才加入墙。 + row.addEventListener('click', () => { + if (row.classList.contains('offline')) return; + const id = row.dataset.deviceId; + const now = Date.now(); + if (wallLastRowClick && wallLastRowClick.id === id && + now - wallLastRowClick.at <= WALL_ROW_DBLCLICK_MS) { + wallLastRowClick = null; + addWallTile(id); + return; + } + wallLastRowClick = { id, at: now }; + }); + row.addEventListener('mouseenter', () => wallShowTooltip(row)); + row.addEventListener('mouseleave', wallHideTooltip); + }); + } + + function wallUpdateSidebarBadges() { + if (wall.active) renderWallSidebar(); + } + + function wallUpdateAllTileMeta() { + for (const tile of wall.tiles) wallUpdateTileMeta(tile); + } + + function onWallSearchChange() { + wall.search = document.getElementById('wall-search-input').value || ''; + const clear = document.getElementById('wall-search-clear'); + if (clear) clear.style.display = wall.search ? 'block' : 'none'; + renderWallSidebar(); + } + + function clearWallSearch() { + const input = document.getElementById('wall-search-input'); + if (input) input.value = ''; + onWallSearchChange(); + } + + function toggleWallSidebar(force) { + const sb = document.getElementById('wall-sidebar'); + const bd = document.getElementById('wall-sidebar-backdrop'); + if (!sb) return; + const open = (typeof force === 'boolean') ? force : !sb.classList.contains('open'); + sb.classList.toggle('open', open); + if (bd) bd.classList.toggle('open', open); + if (!open) { + wallHideTooltip(); + wallLastRowClick = null; + } + } + + function focusWallTile(uid) { + const tile = wall.tiles.find(t => t.uid === uid); + if (!tile) return; + const dev = devices.find(d => String(d.id) === tile.deviceId); + if (!dev) { alert('Device not found'); return; } + if (!dev.online) { alert('Device is offline'); return; } + const s = wall.sessions.get(tile.low32); + if (s && s.status === 'offline') { + // 设备重新上线:离线时服务端已移除 watch,且 streams 条目被整体删除, + // 因此需为所有属于该设备的 tile 重新建立 owner(只补当前 tile 会让 + // 同设备的其它 tile 在后续 release 时计数不平衡)。 + wallSetStatus(s, 'waiting'); + s.needKeyframe = true; + s.live = false; + wallSyncTileSubscriptions(); + wallAllocBudget(); + return; + } + connectDevice(tile.deviceId, true); + } + + // ---------------- 墙页生命周期 ---------------- + + function wallEnterPage() { + if (!wallRestored) wallRestoreState(); + // 视口变化后提示层的位置会失效,直接收起(同一回调重复注册会被去重)。 + window.addEventListener('resize', wallHideTooltip, { passive: true }); + renderWallSidebar(); + wallRenderGrid(); + wallSyncTileSubscriptions(); + wallAllocBudget(); + } + + function wallLeavePage() { + // 释放墙订阅:进入单画面时 focus 引用已在 connectDevice 中先建立, + // 因此聚焦设备的流不会 1→0 断开。 + for (const tile of wall.tiles) releaseStream(tile.deviceId, 'wall:' + tile.uid); + wallPauseAll(); + wallSaveState(); + toggleWallSidebar(false); + } + + function showWall() { + if (!serverCaps.multiWatch) { + alert('Screen Wall requires a newer server with multi-view support.'); + return; + } + const compat = checkWebCodecs(); + if (!compat.supported) { + alert('Browser does not support H.264: ' + compat.reason); + return; + } + if (!wallRestored) wallRestoreState(); + showPage('wall-page'); + } + + function closeWall() { + showPage('devices-page'); + getDevices(); + } + + function wallSaveState() { + try { + localStorage.setItem(WALL_STORAGE_KEY, JSON.stringify({ + layout: wall.layout, + tiles: wall.tiles.map(t => t.deviceId) + })); + } catch (e) {} + } + + function wallRestoreState() { + wallRestored = true; + try { + const raw = localStorage.getItem(WALL_STORAGE_KEY); + if (raw) { + const data = JSON.parse(raw); + if (WALL_LAYOUTS.includes(data.layout)) wall.layout = data.layout; + if (Array.isArray(data.tiles)) { + data.tiles.slice(0, WALL_MAX_TILES).forEach(id => { + wallAddTileInternal(String(id), true); + }); + } + } + } catch (e) { + console.warn('[Wall] restore failed', e); + } + wallSyncLayoutButtons(); + wallRenderGrid(); + } + + // 登出 / 关闭页面时彻底清理(不发送 disconnect:WS 即将关闭, + // 服务端 UnregisterClient 会清理 watch 集合)。 + function wallTeardown(clearStorage) { + wallHideTooltip(); + for (const tile of wall.tiles) { + if (tile.el && tile.el.parentNode) tile.el.remove(); + } + wall.tiles = []; + for (const s of wall.sessions.values()) wallCloseDecoder(s); + wall.sessions.clear(); + streams.clear(); + wall.fromWall = false; + wall.focusDeviceId = null; + if (clearStorage) { + try { localStorage.removeItem(WALL_STORAGE_KEY); } catch (e) {} + } + wallRenderGrid(); + wallUpdateSidebarBadges(); + } + + // connect_result / resolution_changed 的墙侧处理。 + function wallApplyConnectResult(s, msg) { + if (!s) return; + s.pendingConnect = false; + if (msg.ok === false) { + wallCloseDecoder(s); + s.live = false; + wallSetStatus(s, 'error', msg.msg || 'Connection failed'); + wallAllocBudget(); + return; + } + if (msg.width && msg.height) { + const changed = (s.width !== msg.width || s.height !== msg.height); + s.width = msg.width; + s.height = msg.height; + wallPrepareCanvasDimensions(s); + if (changed && s.decoder) wallCloseDecoder(s); + } + if (typeof msg.top_down === 'boolean') s.topDown = msg.top_down === true; + if (s.width && s.height) wallPrepareCanvasDimensions(s); + + if (s.live) { + // 服务端随 connect_result 会先发一帧缓存的 IDR(立即出图), + // 再请求一个新 IDR 重置参考链。 + s.needKeyframe = true; + s.keyframeRetries = 0; + if (s.keyframeTimer) { clearTimeout(s.keyframeTimer); s.keyframeTimer = null; } + wallSetStatus(s, 'resuming'); + wallRequestKeyframe(s, true); + } else { + // 之前失败/离线的 session 现在连接成功:解除 Error/Offline, + // 否则 wallAllocBudget 会永久跳过它,tile 再也无法恢复。 + if (s.status === 'waiting' || s.status === 'error' || s.status === 'offline') { + wallSetStatus(s, 'paused'); + if (wall.active) wallAllocBudget(); + } + } + } + + // ============================================================ // Minimal WebM-Opus muxer: wraps each Opus packet in a one-block // Cluster so it can be fed to a SourceBuffer of type @@ -2353,18 +3669,34 @@ // Audio frame: frameType byte at offset 4 indicates audio (96 = TOKEN_SCREEN_AUDIO) // Full frame format: [deviceID:4][frameType:1][dataLen:4][hasFormat:1][AudioFormat?][audio_data...] - if (u8.length > 4 && u8[4] === 96) { - // Skip frame header (9 bytes) and pass audio payload to handler + if (u8.length < 9) return; + const view = new DataView(data); + const frameDeviceId = view.getUint32(0, true); // 低 32 位 + const frameType = view.getUint8(4); + const dataLen = view.getUint32(5, true); + + const onScreenPage = document.getElementById('screen-page').classList.contains('active'); + const focusedLow = (onScreenPage && currentDevice) ? low32(currentDevice.id) : NaN; + + if (frameType === 96) { + // 墙是静音监控墙:只播放当前聚焦设备的音频。 + if (!onScreenPage || !currentDevice || focusedLow !== frameDeviceId) return; const audioPayload = data.slice(9); handleAudioFrame(audioPayload); return; } + if (dataLen < 0 || 9 + dataLen > data.byteLength) return; + + // 墙路由:二进制帧头只有低 32 位,必须用 low32 查 session。 + if (!onScreenPage || focusedLow !== frameDeviceId) { + const session = wall.sessions.get(frameDeviceId); + if (session && wall.active) { + wallHandleFrame(session, data, frameType, dataLen); + } + return; + } // Video frame: [deviceID:4][frameType:1][dataLen:4][videoData...] - const view = new DataView(data); - const deviceId = view.getUint32(0, true); - const frameType = view.getUint8(4); - const dataLen = view.getUint32(5, true); const isKeyframe = frameType === 1; const videoData = new Uint8Array(data, 9, dataLen); const frameCodec = dataLen > 0 ? detectCodec(videoData) : currentCodec; @@ -2624,6 +3956,16 @@ } document.querySelectorAll('.page').forEach(p => p.classList.remove('active')); document.getElementById(pageId).classList.add('active'); + + // 屏幕墙生命周期:进入时恢复 tile/订阅,离开时释放墙订阅并暂停解码。 + const wasWall = wall.active; + const willWall = pageId === 'wall-page'; + wall.active = willWall; + if (!wasWall && willWall) { + wallEnterPage(); + } else if (wasWall && !willWall) { + wallLeavePage(); + } } function updateScreenStatus(status, msg) { @@ -2683,6 +4025,8 @@ } function confirmLogout() { document.getElementById('logout-confirm-modal').classList.remove('active'); + // 彻底清理屏幕墙(tile/session/订阅引用)并清除持久化布局。 + wallTeardown(true); // Close and reconnect WebSocket to get new challenge if (ws) { ws.onclose = null; // Prevent auto-reconnect delay @@ -2806,7 +4150,7 @@ ws.send(JSON.stringify({ cmd: 'get_devices', token })); } - function connectDevice(id) { + function connectDevice(id, fromWall) { const dev = devices.find(d => d.id === id || d.id === String(id)); if (!dev || !dev.online) { alert('Device is offline'); @@ -2822,7 +4166,13 @@ } const compat = checkWebCodecs(); if (!compat.supported) { alert('Browser does not support H264: ' + compat.reason); return; } + const sid = String(id); + // 同页直接切设备(正常 UI 不会发生):先释放旧 focus 引用。 + if (currentDevice && String(currentDevice.id) !== sid) { + releaseStream(currentDevice.id, 'focus'); + } currentDevice = dev; + wall.fromWall = !!fromWall; document.getElementById('device-name').textContent = displayName(currentDevice); document.getElementById('frame-info').textContent = ''; // Reset throughput / resolution read-outs for the new session @@ -2835,8 +4185,16 @@ // Default the audio button to "on" optimistically; server will // correct via connect_result.audio_enabled or audio_state event. applyAudioState(true); + + // 先建立 focus 引用,再 showPage(离开墙时会释放墙订阅), + // 这样墙里正在观看的这条流不会 1→0 被断开。 + const st = streams.get(sid); + const hadStream = !!st && st.refcount > 0; + acquireStream(sid, 'focus'); + // 已由墙订阅时显式重复 connect,以获取最新分辨率/关键帧; + // 服务端对已 watch 的同一设备放行。 + if (hadStream) wallSendConnect(sid); showPage('screen-page'); - ws.send(JSON.stringify({ cmd: 'connect', id: String(id), token })); } // ====== Web 终端(xterm.js)====== @@ -3313,7 +4671,7 @@ function sendRdpReset() { if (ws && ws.readyState === WebSocket.OPEN && token) { - ws.send(JSON.stringify({ cmd: 'rdp_reset', token })); + ws.send(JSON.stringify({ cmd: 'rdp_reset', token, id: currentDeviceId() })); // Visual feedback - rotate icon const btn = event.target; btn.style.transform = 'rotate(360deg)'; @@ -3345,12 +4703,12 @@ function toggleAudio() { if (ws && ws.readyState === WebSocket.OPEN && token) { - ws.send(JSON.stringify({ cmd: 'audio_toggle', token })); + ws.send(JSON.stringify({ cmd: 'audio_toggle', token, id: currentDeviceId() })); } } // Detect touch device (mobile/tablet) - const isTouchDevice = ('ontouchstart' in window) || (navigator.maxTouchPoints > 0); + // 注意:该常量在屏幕墙状态初始化时就要用到,因此必须在脚本顶部声明(见 wall 状态块)。 function toggleControl() { controlEnabled = !controlEnabled; @@ -3751,7 +5109,7 @@ // Update cursor overlay position updateCursorOverlay(x, y); if (ws && ws.readyState === WebSocket.OPEN && token) { - const msg = { cmd: 'mouse', token, type, x, y, button: button || 0 }; + const msg = { cmd: 'mouse', token, id: currentDeviceId(), type, x, y, button: button || 0 }; if (delta !== undefined) msg.delta = delta; ws.send(JSON.stringify(msg)); } @@ -3773,7 +5131,7 @@ // Filter Windows keys (handled locally, not sent to remote) if (keyCode === 91 || keyCode === 92) return; // VK_LWIN, VK_RWIN if (ws && ws.readyState === WebSocket.OPEN && token) { - ws.send(JSON.stringify({ cmd: 'key', token, keyCode, down: isDown, alt: !!altKey })); + ws.send(JSON.stringify({ cmd: 'key', token, id: currentDeviceId(), keyCode, down: isDown, alt: !!altKey })); } } @@ -3783,7 +5141,7 @@ const press = () => { sendKey(86, true, false); sendKey(86, false, false); }; navigator.clipboard.readText().then(function(text) { if (text && ws && ws.readyState === WebSocket.OPEN && token) { - ws.send(JSON.stringify({ cmd: 'clipboard_set', token, text })); + ws.send(JSON.stringify({ cmd: 'clipboard_set', token, id: currentDeviceId(), text })); } press(); }).catch(function() { press(); }); // 读剪贴板被拒/为空:仍执行一次普通远程粘贴 @@ -3794,7 +5152,7 @@ function scheduleCopyFromRemote() { setTimeout(function() { if (ws && ws.readyState === WebSocket.OPEN && token) { - ws.send(JSON.stringify({ cmd: 'clipboard_get', token })); + ws.send(JSON.stringify({ cmd: 'clipboard_get', token, id: currentDeviceId() })); } }, 250); } @@ -3803,7 +5161,7 @@ // "I" 组合框和 1/2/3…/中 同属输入辅助栏,不应要求先开鼠标控制。 function sendKeyRaw(keyCode, isDown) { if (ws && ws.readyState === WebSocket.OPEN && token) { - ws.send(JSON.stringify({ cmd: 'key', token, keyCode, down: isDown, alt: false })); + ws.send(JSON.stringify({ cmd: 'key', token, id: currentDeviceId(), keyCode, down: isDown, alt: false })); } } @@ -3812,7 +5170,7 @@ function sendTextToRemote(text) { if (!text) return; if (ws && ws.readyState === WebSocket.OPEN && token) { - ws.send(JSON.stringify({ cmd: 'clipboard_set', token, text })); + ws.send(JSON.stringify({ cmd: 'clipboard_set', token, id: currentDeviceId(), text })); } sendKeyRaw(17, true); // Ctrl down sendKeyRaw(86, true); // V down @@ -4532,15 +5890,27 @@ canvas.style.transformOrigin = ''; if (decoder) { try { decoder.close(); } catch(e) {} decoder = null; } - if (ws && ws.readyState === WebSocket.OPEN && token && currentDevice) { - ws.send(JSON.stringify({ cmd: 'disconnect', token, id: String(currentDevice.id) })); - } + // 墙内设备仍可能被订阅(tile 还在),音频必须显式停止。 + stopAllAudio(); + audioFormat = null; + const focusId = currentDevice ? String(currentDevice.id) : null; + const backToWall = wall.fromWall; currentDevice = null; // Clear current device + wall.fromWall = false; isAndroidRemote = false; updateAndroidNavbar(); document.getElementById('screen-page').classList.remove('android-portrait'); - showPage('devices-page'); - getDevices(); + if (backToWall) { + // 先进入墙页(重新建立墙订阅),再释放 focus 引用: + // 该设备还有 tile 时 refcount 保持 ≥1,流不断,tile 立即可恢复。 + showPage('wall-page'); + if (focusId) releaseStream(focusId, 'focus'); + getDevices(); + } else { + if (focusId) releaseStream(focusId, 'focus'); + showPage('devices-page'); + getDevices(); + } } function escapeHtml(text) { @@ -4578,6 +5948,8 @@ function doBackgroundDisconnect() { cancelReconnect(); stopPingInterval(); + // 关闭墙解码器,避免留下失效的 VideoDecoder;tile 布局保留。 + wallPauseAll(); if (ws) { ws.onclose = null; ws.close(); @@ -4592,11 +5964,18 @@ // Page going to background const screenPage = document.getElementById('screen-page'); const termPage = document.getElementById('term-page'); + const wallPage = document.getElementById('wall-page'); const onScreenPage = screenPage && screenPage.classList.contains('active') && currentDevice; const onTermPage = termPage && termPage.classList.contains('active') && termState.deviceId; + const onWallPage = wallPage && wallPage.classList.contains('active'); - if (onScreenPage || onTermPage) { - // 屏幕预览 / 终端:移动端给 30 秒宽限,期间切回应用就无缝继续; + if (onWallPage) { + // 墙:关闭所有解码器省电/释放 GPU,订阅保留,回前台按预算恢复。 + wallPauseAll(); + } + + if (onScreenPage || onTermPage || onWallPage) { + // 屏幕预览 / 终端 / 墙:移动端给 30 秒宽限,期间切回应用就无缝继续; // 桌面:保持长连,靠 ping 心跳 if (isTouchDevice) { backgroundDisconnectTimer = setTimeout(doBackgroundDisconnect, BACKGROUND_TIMEOUT_MOBILE); @@ -4612,7 +5991,9 @@ backgroundDisconnectTimer = null; } const screenPage = document.getElementById('screen-page'); + const wallPage = document.getElementById('wall-page'); const onScreenPage = screenPage && screenPage.classList.contains('active') && currentDevice; + const onWallPage = wallPage && wallPage.classList.contains('active'); if (!ws || ws.readyState !== WebSocket.OPEN) { // WS was dropped — reset decoder before reconnect. @@ -4637,6 +6018,16 @@ ws.onclose = null; ws.close(); connectWebSocket(); + } else if (onWallPage && isTouchDevice) { + // 移动端同样重建连接(decoder 已在 hidden 时关闭), + // onopen 会按本地引用计数重新订阅墙内设备。 + ws.onclose = null; + ws.close(); + connectWebSocket(); + } else if (onWallPage) { + // 桌面:连接还在,直接恢复解码预算(暂停的 session 会请求 keyframe)。 + wallAllocBudget(); + if (token) ws.send(JSON.stringify({ cmd: 'ping', token })); } else if (token) { ws.send(JSON.stringify({ cmd: 'ping', token })); } @@ -4679,7 +6070,7 @@ // Shortcuts only visible when keyboard is open, so no extra check needed function sendShortcutKey(keyCode, isDown) { if (ws && ws.readyState === WebSocket.OPEN && token) { - ws.send(JSON.stringify({ cmd: 'key', token, keyCode, down: isDown, alt: false })); + ws.send(JSON.stringify({ cmd: 'key', token, id: currentDeviceId(), keyCode, down: isDown, alt: false })); } } const shortcutBtns = document.querySelectorAll('.shortcut-btn');