Feature: Web screen wall for multi-device live monitoring

Add a third view to the web UI, the screen wall, which shows several
devices at once in one tab with 1x1 / 2x2 / 3x3 / 4x4 / 5x5 layouts and a
device sidebar on the left. Tiles are monitoring-only; clicking one
enlarges that device into the existing single-screen page for control, and
Back returns to the wall. The same device can be added several times: all
tiles of one device share a single decoder and a single stream.

The wall decodes at most 9 streams on desktop and 4 on touch devices.
Tiles beyond the budget keep receiving frames but have their decoder
closed and show the last frame plus a Paused badge; they are promoted
automatically when a live tile goes away. Subscriptions are reference
counted on the client (streams map with owner sets), so a stream is only
connected on the 0 -> 1 transition and disconnected on 1 -> 0, and the
wall and the single-screen page can share one device without tearing the
stream down.

Server side, WebClient now holds a set of watched devices instead of a
single id. A new multiWatch command flag selects set semantics, while
clients without it keep the old replace-all behaviour, so an old frontend
against the new server is unchanged. connect and disconnect responses echo
id and req so the frontend can route them, input commands carry an id and
are resolved against the watch set (dropped when the set has several
devices and no id), and the per-client watch set is capped at 32.

For fast recovery after a pause or when joining late, the server caches
each device's raw IDR payload and replays it on connect and on a new
keyframe command, then asks the client to force a new IDR. The client gets
COMMAND_SCREEN_KEYFRAME_REQ = 169 and an atomic request flag that is only
cleared after an IDR actually came out of the encoder, so a failed capture
or EAGAIN does not lose the request. The frontend retries the keyframe
request up to three times; an old client that ignores the command simply
recovers on the next natural GOP.

Also fix the keyframe cache double wrapping: BroadcastH264Frame used to
cache the full packet header and HandleConnect wrapped it again. The cache
now stores data + 9, len - 9 and every sender wraps it through
BuildFramePacket, and the cache is cleared on cold start and on resolution
change. StopRemoteDesktop is always called outside m_ClientsMutex, with
the ids collected under the lock first.

Co-Authored-By: deepseek-flash
This commit is contained in:
yuanyuanxianganddeepseek-flash committed 2026-10-18 11:03:17 +02:00
1 parent 04f4eb9947
commit 8eab26c1f6
6 files changed
+1790 -180

No files matched your search

+310 -132
View File
@@ -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<void*, std::string> 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<std::mutex> 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<std::mutex> 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<uint64_t> to_stop;
bool too_many_watch = false;
{
std::lock_guard<std::mutex> 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<std::mutex> 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<uint8_t> cached;
{
std::lock_guard<std::mutex> 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<std::mutex> 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<uint64_t> to_stop;
{
std::lock_guard<std::mutex> 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<std::mutex> 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<std::mutex> 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<std::mutex> 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<std::mutex> 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<std::mutex> 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<std::mutex> 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<std::mutex> 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<std::mutex> 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<uint8_t> cached;
{
std::lock_guard<std::mutex> lock(m_DeviceCacheMutex);
auto it = m_DeviceCache.find(device_id);
if (it != m_DeviceCache.end()) {
std::lock_guard<std::mutex> 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<uint64_t> watched;
// Get device_id and remove client
// Copy the watch set and remove the client under the lock.
{
std::lock_guard<std::mutex> 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<std::mutex> 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<std::mutex> 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<std::mutex> 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<std::mutex> 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<std::mutex> 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<std::mutex> 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<std::mutex> 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<std::mutex> 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<std::mutex> 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<std::mutex> lock(m_DeviceCacheMutex);
auto it = m_DeviceCache.find(device_id);
if (it != m_DeviceCache.end()) {
std::lock_guard<std::mutex> 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<std::mutex> 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<std::mutex> 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<std::mutex> lock(m_ClientsMutex);
for (auto& [ws_ptr, client] : m_Clients) {
if (offline.count(client.watch_device_id)) {
// 一个 ws 可能同时看多台设备:只移除真正离线的那些,保留其它。
std::vector<uint64_t> 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);
}
}
}
+24 -7
View File
@@ -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<uint64_t> watch_device_ids;
// 每台设备的 keyframe 请求节流时间戳(秒),防止前端重试打爆服务端/客户端。
std::map<uint64_t, uint64_t> 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();
+1431 -40
View File
File diff suppressed because it is too large. Load diff