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
2516 lines
90 KiB
C++
2516 lines
90 KiB
C++
#include "stdafx.h"
|
||
#include "2015Remote.h"
|
||
#include "resource.h" // IDR_WEB_XTERM_* (xterm.js/css 静态资源 ID)
|
||
#include "WebService.h"
|
||
#include "WebServiceAuth.h"
|
||
#include "2015RemoteDlg.h"
|
||
#include "Server.h" // For CONTEXT_OBJECT
|
||
#include "jsoncpp/json.h"
|
||
#include "HostJson.h" // BuildHostJson(单台主机序列化公共函数)
|
||
#include "WebPage.h"
|
||
#include "SimpleWebSocket.h"
|
||
#include "common/commands.h"
|
||
#include <filesystem>
|
||
#include <fstream>
|
||
#include <shlobj.h>
|
||
#include <set>
|
||
|
||
// Algorithm constants (same as ScreenSpyDlg.cpp)
|
||
#define ALGORITHM_H264 2
|
||
|
||
#pragma comment(lib, "ws2_32.lib")
|
||
|
||
// Load a Win32 BINARY resource by ID as std::string (raw bytes).
|
||
// Returns empty string on failure. The std::string is OK to hold binary data
|
||
// (we only treat it as bytes; size is from .length()).
|
||
static std::string LoadBinaryResourceAsString(int resourceId) {
|
||
HRSRC hRes = FindResourceA(NULL, MAKEINTRESOURCEA(resourceId), "BINARY");
|
||
if (!hRes) return {};
|
||
DWORD size = SizeofResource(NULL, hRes);
|
||
if (!size) return {};
|
||
HGLOBAL hData = LoadResource(NULL, hRes);
|
||
if (!hData) return {};
|
||
LPVOID p = LockResource(hData);
|
||
if (!p) return {};
|
||
return std::string(static_cast<const char*>(p), size);
|
||
}
|
||
|
||
// Replace all occurrences of `from` with `to` in-place.
|
||
static void ReplaceAll(std::string& s, const std::string& from, const std::string& to) {
|
||
if (from.empty()) return;
|
||
size_t pos = 0;
|
||
while ((pos = s.find(from, pos)) != std::string::npos) {
|
||
s.replace(pos, from.length(), to);
|
||
pos += to.length();
|
||
}
|
||
}
|
||
|
||
// 设备 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;
|
||
static std::atomic<bool> s_bShuttingDown{false}; // Prevents access during static destruction
|
||
|
||
// Generate random salt (16 hex chars) - thread-safe
|
||
static std::string GenerateSalt() {
|
||
static std::random_device rd;
|
||
static std::mt19937_64 gen(rd());
|
||
std::uniform_int_distribution<uint64_t> dis;
|
||
char buf[17];
|
||
snprintf(buf, sizeof(buf), "%016llX", dis(gen));
|
||
return std::string(buf);
|
||
}
|
||
|
||
// Generate random nonce (32 hex chars) - thread-safe
|
||
static std::string GenerateNonce() {
|
||
if (s_bShuttingDown) return "";
|
||
static std::random_device rd;
|
||
static std::mt19937_64 gen(rd());
|
||
std::uniform_int_distribution<uint64_t> dis;
|
||
char buf[33];
|
||
{
|
||
std::lock_guard<std::mutex> lock(s_NonceMutex);
|
||
sprintf_s(buf, "%016llx%016llx", dis(gen), dis(gen));
|
||
}
|
||
return buf;
|
||
}
|
||
|
||
// Store nonce for client
|
||
static void StoreNonce(void* ws_ptr, const std::string& nonce) {
|
||
if (s_bShuttingDown) return;
|
||
std::lock_guard<std::mutex> lock(s_NonceMutex);
|
||
s_ClientNonces[ws_ptr] = nonce;
|
||
}
|
||
|
||
// Get and clear nonce for client (one-time use)
|
||
static std::string ConsumeNonce(void* ws_ptr) {
|
||
if (s_bShuttingDown) return "";
|
||
std::lock_guard<std::mutex> lock(s_NonceMutex);
|
||
auto it = s_ClientNonces.find(ws_ptr);
|
||
if (it == s_ClientNonces.end()) return "";
|
||
std::string nonce = it->second;
|
||
s_ClientNonces.erase(it);
|
||
return nonce;
|
||
}
|
||
|
||
// Clear nonce when client disconnects
|
||
static void ClearNonce(void* ws_ptr) {
|
||
if (s_bShuttingDown) return;
|
||
std::lock_guard<std::mutex> lock(s_NonceMutex);
|
||
s_ClientNonces.erase(ws_ptr);
|
||
}
|
||
|
||
//////////////////////////////////////////////////////////////////////////
|
||
// CWebService Implementation
|
||
//////////////////////////////////////////////////////////////////////////
|
||
|
||
CWebService& CWebService::Instance() {
|
||
static CWebService instance;
|
||
return instance;
|
||
}
|
||
|
||
CWebService::CWebService()
|
||
: m_bRunning(false)
|
||
, m_bStopping(false)
|
||
, m_pServer(nullptr)
|
||
, m_pParentDlg(nullptr)
|
||
, m_nMaxClientsPerDevice(10)
|
||
, m_nTokenExpireSeconds(86400) // 24 hours
|
||
, m_bHideWebSessions(true) // Hide web-triggered dialogs by default
|
||
{
|
||
// Secret key will be initialized in Start() via WSAuth::Init()
|
||
// Admin user will be configured via SetAdminPassword()
|
||
|
||
// Initialize payloads directory
|
||
char exe_path[MAX_PATH];
|
||
GetModuleFileNameA(NULL, exe_path, MAX_PATH);
|
||
std::filesystem::path exeDir = std::filesystem::path(exe_path).parent_path();
|
||
m_PayloadsDir = (exeDir / "Payloads").string();
|
||
std::error_code ec;
|
||
std::filesystem::create_directories(m_PayloadsDir, ec);
|
||
|
||
// Initialize config directory (same as YAMA.db location)
|
||
char appdata_path[MAX_PATH];
|
||
if (SUCCEEDED(SHGetFolderPathA(NULL, CSIDL_APPDATA, NULL, 0, appdata_path))) {
|
||
m_ConfigDir = std::string(appdata_path) + "\\" BRAND_DATA_FOLDER "\\";
|
||
} else {
|
||
m_ConfigDir = ".\\";
|
||
}
|
||
std::filesystem::create_directories(m_ConfigDir, ec);
|
||
}
|
||
|
||
void CWebService::SetAdminPassword(const std::string& password) {
|
||
std::lock_guard<std::mutex> lock(m_UsersMutex);
|
||
m_Users.clear();
|
||
|
||
// Admin user is built-in, always first
|
||
WebUser admin;
|
||
admin.username = "admin";
|
||
admin.salt = ""; // Not used with challenge-response auth
|
||
admin.password_hash = WSAuth::ComputeSHA256(password); // Simple SHA256
|
||
admin.role = "admin";
|
||
m_Users.push_back(admin);
|
||
|
||
Mprintf("[WebService] Admin password configured\n");
|
||
|
||
// Load additional users from file (non-admin users)
|
||
LoadUsers();
|
||
}
|
||
|
||
CWebService::~CWebService() {
|
||
Stop();
|
||
}
|
||
|
||
bool CWebService::Start(int port) {
|
||
if (m_bRunning) return true;
|
||
|
||
// Initialize authorization and get secret key
|
||
// Pass raw authorization string, verified internally by private library
|
||
WSAuthContext authCtx;
|
||
std::string authStr = THIS_CFG.GetStr("settings", "Authorization", "");
|
||
if (!WSAuth::Init(authCtx, authStr)) {
|
||
Mprintf("[WebService] Authorization check failed, service disabled\n");
|
||
return false;
|
||
}
|
||
|
||
m_SecretKey = authCtx.secretKey;
|
||
m_nTokenExpireSeconds = authCtx.tokenExpireSec;
|
||
m_nMaxClientsPerDevice = authCtx.maxClientsPerDevice;
|
||
|
||
m_bStopping = false;
|
||
m_ServerThread = std::thread(&CWebService::ServerThread, this, port);
|
||
|
||
// Wait for server to start
|
||
for (int i = 0; i < 50 && !m_bRunning; i++) {
|
||
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
||
}
|
||
|
||
// Start heartbeat thread
|
||
if (m_bRunning) {
|
||
m_HeartbeatThread = std::thread(&CWebService::HeartbeatThread, this);
|
||
}
|
||
|
||
return m_bRunning;
|
||
}
|
||
|
||
void CWebService::Stop() {
|
||
if (!m_bRunning) return;
|
||
|
||
// Set flags FIRST to prevent new operations from starting
|
||
m_bRunning = false;
|
||
m_bStopping = true;
|
||
s_bShuttingDown = true; // Prevent access to static variables
|
||
|
||
// Stop heartbeat thread first
|
||
if (m_HeartbeatThread.joinable()) {
|
||
m_HeartbeatThread.join();
|
||
}
|
||
|
||
if (m_ServerThread.joinable()) {
|
||
m_ServerThread.join();
|
||
}
|
||
|
||
// Clear all clients after server stopped
|
||
{
|
||
std::lock_guard<std::mutex> lock(m_ClientsMutex);
|
||
m_Clients.clear();
|
||
}
|
||
|
||
// Clear screen contexts to prevent dangling pointers
|
||
{
|
||
std::lock_guard<std::mutex> lock(m_ScreenContextsMutex);
|
||
m_ScreenContexts.clear();
|
||
}
|
||
|
||
// Clear device cache
|
||
{
|
||
std::lock_guard<std::mutex> lock(m_DeviceCacheMutex);
|
||
m_DeviceCache.clear();
|
||
}
|
||
|
||
m_pServer = nullptr;
|
||
m_pParentDlg = nullptr; // Clear to prevent access to destroyed dialog
|
||
}
|
||
|
||
bool CWebService::IsRunning() const {
|
||
return m_bRunning;
|
||
}
|
||
|
||
void CWebService::ServerThread(int port) {
|
||
ws::Server wsServer;
|
||
m_pServer = &wsServer;
|
||
|
||
// Serve static HTML page and file downloads
|
||
static std::string cachedHtml = GetWebPageHTML();
|
||
// server/web/index.html carries a __BASE_PATH__ placeholder for the Go
|
||
// server's subpath deployment. The MFC server serves from the domain root,
|
||
// so strip it (→ "") — otherwise the WebSocket/CSS/JS URLs break.
|
||
ReplaceAll(cachedHtml, "__BASE_PATH__", "");
|
||
// server/web/index.html also carries a __BRAND_NAME__ placeholder for the
|
||
// white-label title. The Go server swaps it at serve time (defaulting to
|
||
// "SimpleRemoter"); the MFC server does the same here, otherwise the login
|
||
// page shows the literal "__BRAND_NAME__" token as <title>/<h1>.
|
||
ReplaceAll(cachedHtml, "__BRAND_NAME__", "SimpleRemoter");
|
||
// Log loaded size; zero bytes means the RC BINARY resource is missing or
|
||
// server/web/index.html was not embedded — check 2015Remote.rc and rebuild.
|
||
Mprintf("[WebService] index.html loaded: %zu bytes\n", cachedHtml.size());
|
||
std::string payloadsDir = m_PayloadsDir; // Capture for lambda
|
||
|
||
// 静态资源缓存:xterm.js / xterm.css / fit-addon。RC binary 资源加载一次缓存到内存,
|
||
// 避免每个浏览器请求都去 LockResource。Cache-Control 给浏览器侧缓存友好。
|
||
static std::string cachedXtermJs = LoadBinaryResourceAsString(IDR_WEB_XTERM_JS);
|
||
static std::string cachedXtermCss = LoadBinaryResourceAsString(IDR_WEB_XTERM_CSS);
|
||
static std::string cachedXtermFitJs = LoadBinaryResourceAsString(IDR_WEB_XTERM_FIT_JS);
|
||
static std::string cachedOpusDecoderJs = LoadBinaryResourceAsString(IDR_WEB_OPUS_DECODER_JS);
|
||
|
||
auto buildStatic = [](const std::string& body, const std::string& mime) {
|
||
ws::HttpResponse r = ws::HttpResponse::OK(body, mime);
|
||
r.headers["Cache-Control"] = "public, max-age=86400"; // 1 day
|
||
return r;
|
||
};
|
||
|
||
wsServer.onHttp([payloadsDir, buildStatic](const std::string& path) -> ws::HttpResponse {
|
||
if (path == "/" || path == "/index.html") {
|
||
return ws::HttpResponse::OK(cachedHtml);
|
||
} else if (path == "/static/xterm.js") {
|
||
if (cachedXtermJs.empty()) return ws::HttpResponse::NotFound();
|
||
return buildStatic(cachedXtermJs, "application/javascript; charset=utf-8");
|
||
} else if (path == "/static/xterm.css") {
|
||
if (cachedXtermCss.empty()) return ws::HttpResponse::NotFound();
|
||
return buildStatic(cachedXtermCss, "text/css; charset=utf-8");
|
||
} else if (path == "/static/xterm-fit.js") {
|
||
if (cachedXtermFitJs.empty()) return ws::HttpResponse::NotFound();
|
||
return buildStatic(cachedXtermFitJs, "application/javascript; charset=utf-8");
|
||
} else if (path == "/static/opus-decoder.min.js") {
|
||
if (cachedOpusDecoderJs.empty()) return ws::HttpResponse::NotFound();
|
||
return buildStatic(cachedOpusDecoderJs, "application/javascript; charset=utf-8");
|
||
} else if (path == "/health") {
|
||
return ws::HttpResponse::OK("{\"status\":\"ok\"}", "application/json");
|
||
} else if (path == "/manifest.json") {
|
||
// PWA manifest for iOS standalone app support
|
||
static const char* manifest = R"({
|
||
"name": "SimpleRemoter",
|
||
"short_name": "Remoter",
|
||
"start_url": "/",
|
||
"display": "standalone",
|
||
"orientation": "any",
|
||
"background_color": "#1a1a2e",
|
||
"theme_color": "#1a1a2e"
|
||
})";
|
||
return ws::HttpResponse::OK(manifest, "application/manifest+json");
|
||
} else if (path.rfind("/payloads/", 0) == 0) {
|
||
// File download: /payloads/filename
|
||
std::string filename = path.substr(10); // Remove "/payloads/"
|
||
|
||
// Security check: no path traversal or absolute paths
|
||
if (filename.empty() ||
|
||
filename.find("..") != std::string::npos || // Path traversal
|
||
filename[0] == '/' || filename[0] == '\\' || // Absolute path (Unix/Windows)
|
||
(filename.length() > 1 && filename[1] == ':')) // Windows drive letter (C:)
|
||
{
|
||
return ws::HttpResponse::Forbidden();
|
||
}
|
||
|
||
std::filesystem::path filepath = std::filesystem::path(payloadsDir) / filename;
|
||
std::error_code ec;
|
||
if (!std::filesystem::exists(filepath, ec) || !std::filesystem::is_regular_file(filepath, ec)) {
|
||
return ws::HttpResponse::NotFound();
|
||
}
|
||
|
||
return ws::HttpResponse::File(filepath.string(), filename);
|
||
}
|
||
return ws::HttpResponse::NotFound();
|
||
});
|
||
|
||
// WebSocket connect
|
||
wsServer.onConnect([this](std::shared_ptr<ws::Connection> conn) {
|
||
// Skip if server is stopping
|
||
if (m_bStopping) return;
|
||
|
||
void* ws_ptr = conn.get();
|
||
RegisterClient(ws_ptr, conn->clientIP());
|
||
|
||
// Generate and send challenge nonce
|
||
std::string nonce = GenerateNonce();
|
||
if (nonce.empty()) return; // Shutting down
|
||
StoreNonce(ws_ptr, nonce);
|
||
|
||
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));
|
||
});
|
||
|
||
// WebSocket disconnect
|
||
wsServer.onDisconnect([this](std::shared_ptr<ws::Connection> conn) {
|
||
// Skip cleanup if server is stopping (destructor will clean up)
|
||
if (m_bStopping) return;
|
||
void* ws_ptr = conn.get();
|
||
ClearNonce(ws_ptr);
|
||
UnregisterClient(ws_ptr);
|
||
});
|
||
|
||
// WebSocket message
|
||
wsServer.onMessage([this](std::shared_ptr<ws::Connection> conn, const std::string& msg) {
|
||
// Skip if server is stopping
|
||
if (m_bStopping) return;
|
||
|
||
void* ws_ptr = conn.get();
|
||
|
||
// Update last activity time for heartbeat
|
||
{
|
||
std::lock_guard<std::mutex> lock(m_ClientsMutex);
|
||
auto it = m_Clients.find(ws_ptr);
|
||
if (it != m_Clients.end()) {
|
||
it->second.last_activity = (uint64_t)time(nullptr);
|
||
}
|
||
}
|
||
|
||
// Parse JSON signaling
|
||
Json::Value root;
|
||
Json::Reader reader;
|
||
if (reader.parse(msg, root)) {
|
||
std::string cmd = root.get("cmd", "").asString();
|
||
std::string token = root.get("token", "").asString();
|
||
|
||
if (cmd == "login") {
|
||
HandleLogin(ws_ptr, msg, conn->clientIP());
|
||
} else if (cmd == "get_devices") {
|
||
HandleGetDevices(ws_ptr, token);
|
||
} else if (cmd == "connect") {
|
||
HandleConnect(ws_ptr, token, ParseDeviceIdField(root, "id"),
|
||
ParseReqField(root), root.get("multiWatch", false).asBool());
|
||
} else if (cmd == "disconnect") {
|
||
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") {
|
||
HandleMouse(ws_ptr, msg);
|
||
} else if (cmd == "key") {
|
||
HandleKey(ws_ptr, msg);
|
||
} else if (cmd == "clipboard_set") {
|
||
HandleClipboardSet(ws_ptr, msg);
|
||
} else if (cmd == "clipboard_get") {
|
||
HandleClipboardGet(ws_ptr, token, ParseDeviceIdField(root, "id"));
|
||
} else if (cmd == "rdp_reset") {
|
||
HandleRdpReset(ws_ptr, token, ParseDeviceIdField(root, "id"));
|
||
} else if (cmd == "audio_toggle") {
|
||
HandleAudioToggle(ws_ptr, token, ParseDeviceIdField(root, "id"));
|
||
} else if (cmd == "get_salt") {
|
||
HandleGetSalt(ws_ptr, msg);
|
||
} else if (cmd == "create_user") {
|
||
HandleCreateUser(ws_ptr, msg);
|
||
} else if (cmd == "delete_user") {
|
||
HandleDeleteUser(ws_ptr, msg);
|
||
} else if (cmd == "list_users") {
|
||
HandleListUsers(ws_ptr, token);
|
||
} else if (cmd == "get_groups") {
|
||
HandleGetGroups(ws_ptr, token);
|
||
} else if (cmd == "term_open") {
|
||
HandleTermOpen(ws_ptr, msg);
|
||
} else if (cmd == "term_input") {
|
||
HandleTermInput(ws_ptr, msg);
|
||
} else if (cmd == "term_resize") {
|
||
HandleTermResize(ws_ptr, msg);
|
||
} else if (cmd == "term_close") {
|
||
HandleTermClose(ws_ptr, msg);
|
||
}
|
||
}
|
||
});
|
||
|
||
// Start listening
|
||
if (wsServer.start(port)) {
|
||
m_bRunning = true;
|
||
// Wait until stop is requested
|
||
while (!m_bStopping && wsServer.isRunning()) {
|
||
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
||
}
|
||
wsServer.stop();
|
||
}
|
||
m_bRunning = false;
|
||
}
|
||
|
||
void CWebService::HeartbeatThread() {
|
||
Mprintf("[WebService] Heartbeat thread started (interval=%ds, timeout=%ds)\n",
|
||
HEARTBEAT_INTERVAL_SEC, HEARTBEAT_TIMEOUT_SEC);
|
||
|
||
while (!m_bStopping) {
|
||
// Sleep in small increments to respond quickly to stop
|
||
for (int i = 0; i < HEARTBEAT_INTERVAL_SEC * 10 && !m_bStopping; i++) {
|
||
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
||
}
|
||
if (m_bStopping) break;
|
||
|
||
// Send ping to all WebSocket connections
|
||
// Copy pointer first to avoid race with Stop()
|
||
void* pServer = m_pServer;
|
||
if (pServer && !m_bStopping) {
|
||
auto* wsServer = static_cast<ws::Server*>(pServer);
|
||
if (wsServer->isRunning()) {
|
||
wsServer->pingAll();
|
||
}
|
||
}
|
||
|
||
// Check for timed out clients
|
||
uint64_t now = (uint64_t)time(nullptr);
|
||
std::vector<void*> timedOutClients;
|
||
|
||
{
|
||
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_ids.empty() && client.last_activity > 0) {
|
||
uint64_t elapsed = now - client.last_activity;
|
||
if (elapsed > HEARTBEAT_TIMEOUT_SEC) {
|
||
timedOutClients.push_back(ws_ptr);
|
||
Mprintf("[WebService] Client %s timed out (no activity for %llu seconds)\n",
|
||
client.client_ip.c_str(), elapsed);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Disconnect timed out clients (outside lock to avoid deadlock)
|
||
for (void* ws_ptr : timedOutClients) {
|
||
UnregisterClient(ws_ptr);
|
||
}
|
||
}
|
||
|
||
Mprintf("[WebService] Heartbeat thread stopped\n");
|
||
}
|
||
|
||
//////////////////////////////////////////////////////////////////////////
|
||
// Signaling Handlers
|
||
//////////////////////////////////////////////////////////////////////////
|
||
|
||
void CWebService::HandleLogin(void* ws_ptr, const std::string& msg, const std::string& client_ip) {
|
||
Json::Value root;
|
||
Json::Reader reader;
|
||
if (!reader.parse(msg, root)) {
|
||
SendText(ws_ptr, BuildJsonResponse("login_result", false, "Invalid JSON"));
|
||
return;
|
||
}
|
||
|
||
std::string username = root.get("username", "").asString();
|
||
std::string response = root.get("response", "").asString();
|
||
std::string clientNonce = root.get("nonce", "").asString();
|
||
|
||
// Verify nonce matches (prevents replay)
|
||
std::string storedNonce = ConsumeNonce(ws_ptr);
|
||
if (storedNonce.empty() || storedNonce != clientNonce) {
|
||
SendText(ws_ptr, BuildJsonResponse("login_result", false, "Invalid or expired challenge"));
|
||
return;
|
||
}
|
||
|
||
// Check rate limit
|
||
if (!CheckRateLimit(client_ip)) {
|
||
SendText(ws_ptr, BuildJsonResponse("login_result", false, "Too many failed attempts. Try again later."));
|
||
return;
|
||
}
|
||
|
||
// Check if password is configured
|
||
if (m_Users.empty()) {
|
||
SendText(ws_ptr, BuildJsonResponse("login_result", false, "Server password not configured"));
|
||
return;
|
||
}
|
||
|
||
// Find user
|
||
WebUser* user = nullptr;
|
||
for (auto& u : m_Users) {
|
||
if (u.username == username) {
|
||
user = &u;
|
||
break;
|
||
}
|
||
}
|
||
|
||
if (!user) {
|
||
RecordFailedLogin(client_ip);
|
||
SendText(ws_ptr, BuildJsonResponse("login_result", false, "Invalid credentials"));
|
||
return;
|
||
}
|
||
|
||
// Verify challenge response: response = SHA256(passwordHash + nonce)
|
||
std::string expectedResponse = WSAuth::ComputeSHA256(user->password_hash + clientNonce);
|
||
if (response != expectedResponse) {
|
||
RecordFailedLogin(client_ip);
|
||
SendText(ws_ptr, BuildJsonResponse("login_result", false, "Invalid credentials"));
|
||
return;
|
||
}
|
||
|
||
// Success - generate token
|
||
RecordSuccessLogin(client_ip);
|
||
std::string token = GenerateToken(username, user->role);
|
||
|
||
// Update client state
|
||
{
|
||
std::lock_guard<std::mutex> lock(m_ClientsMutex);
|
||
auto it = m_Clients.find(ws_ptr);
|
||
if (it != m_Clients.end()) {
|
||
it->second.token = token;
|
||
it->second.username = username;
|
||
it->second.role = user->role;
|
||
}
|
||
}
|
||
|
||
// Build response
|
||
Json::Value res;
|
||
res["cmd"] = "login_result";
|
||
res["ok"] = true;
|
||
res["token"] = token;
|
||
res["role"] = user->role;
|
||
res["expires"] = m_nTokenExpireSeconds;
|
||
|
||
Json::StreamWriterBuilder builder;
|
||
builder["indentation"] = "";
|
||
SendText(ws_ptr, Json::writeString(builder, res));
|
||
}
|
||
|
||
void CWebService::HandleGetSalt(void* ws_ptr, const std::string& msg) {
|
||
Json::Value root;
|
||
Json::Reader reader;
|
||
if (!reader.parse(msg, root)) {
|
||
SendText(ws_ptr, BuildJsonResponse("salt", false, "Invalid JSON"));
|
||
return;
|
||
}
|
||
|
||
std::string username = root.get("username", "").asString();
|
||
if (username.empty()) {
|
||
SendText(ws_ptr, BuildJsonResponse("salt", false, "Username required"));
|
||
return;
|
||
}
|
||
|
||
// Find user and get salt
|
||
std::string salt = "";
|
||
bool userFound = false;
|
||
{
|
||
std::lock_guard<std::mutex> lock(m_UsersMutex);
|
||
for (const auto& u : m_Users) {
|
||
if (u.username == username) {
|
||
salt = u.salt;
|
||
userFound = true;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
// For security: if user doesn't exist, generate a fake deterministic salt
|
||
// This prevents username enumeration attacks
|
||
// Note: Admin has empty salt, so we must check userFound, not salt.empty()
|
||
if (!userFound) {
|
||
// Generate deterministic fake salt from username (won't match any real password)
|
||
salt = WSAuth::ComputeSHA256("fake_salt_prefix_" + username).substr(0, 16);
|
||
}
|
||
|
||
Json::Value res;
|
||
res["cmd"] = "salt";
|
||
res["ok"] = true;
|
||
res["salt"] = salt;
|
||
Json::StreamWriterBuilder builder;
|
||
builder["indentation"] = "";
|
||
SendText(ws_ptr, Json::writeString(builder, res));
|
||
}
|
||
|
||
void CWebService::HandleGetDevices(void* ws_ptr, const std::string& token) {
|
||
std::string username, role;
|
||
if (!ValidateToken(token, username, role)) {
|
||
SendText(ws_ptr, BuildJsonResponse("device_list", false, "Invalid token"));
|
||
return;
|
||
}
|
||
|
||
SendText(ws_ptr, BuildDeviceListJson(username));
|
||
}
|
||
|
||
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)) {
|
||
send_error("Invalid token");
|
||
return;
|
||
}
|
||
|
||
// Check device exists and is online
|
||
if (!m_pParentDlg) {
|
||
send_error("Service not ready");
|
||
return;
|
||
}
|
||
|
||
context* ctx = m_pParentDlg->FindHost(device_id);
|
||
if (!ctx) {
|
||
send_error("Device not online");
|
||
return;
|
||
}
|
||
|
||
// Check group permission (admin can access all devices)
|
||
if (username != "admin") {
|
||
std::string deviceGroup = ctx->GetGroupName();
|
||
if (deviceGroup.empty()) deviceGroup = "default";
|
||
|
||
bool hasAccess = false;
|
||
{
|
||
std::lock_guard<std::mutex> lock(m_UsersMutex);
|
||
for (const auto& u : m_Users) {
|
||
if (u.username == username) {
|
||
for (const auto& g : u.allowed_groups) {
|
||
if (g == deviceGroup) {
|
||
hasAccess = true;
|
||
break;
|
||
}
|
||
}
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
if (!hasAccess) {
|
||
send_error("Permission denied");
|
||
return;
|
||
}
|
||
}
|
||
|
||
// Bidirectional mutex (direction 2, §8.2): reject human Web viewing while an MCP
|
||
// remote control session owns this host's screen sub-connection (SetMcpTriggered on
|
||
// remote_open, cleared on session teardown), so AI input cannot be injected into a
|
||
// picture a human is watching/controlling.
|
||
if (IsMcpTriggered(device_id)) {
|
||
send_error("Device busy: remote control session active");
|
||
return;
|
||
}
|
||
|
||
// 该 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()) {
|
||
already = it->second.watch_device_ids.count(device_id) > 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)) {
|
||
// 回滚本次插入,避免一个没有屏幕会话的设备长期占着订阅/计数。
|
||
{
|
||
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;
|
||
}
|
||
}
|
||
|
||
// Get screen dimensions + audio state from device info cache (may not be ready)
|
||
int width = 0, height = 0;
|
||
int audio_enabled = -1; // -1 = unknown yet (前端走 audio_state 事件兜底)
|
||
bool top_down = false;
|
||
std::string client_type;
|
||
{
|
||
std::lock_guard<std::mutex> lock(m_DeviceCacheMutex);
|
||
auto it = m_DeviceCache.find(device_id);
|
||
if (it != m_DeviceCache.end()) {
|
||
width = it->second->screen_width;
|
||
height = it->second->screen_height;
|
||
audio_enabled = it->second->audio_enabled;
|
||
top_down = it->second->top_down;
|
||
client_type = it->second->client_type;
|
||
}
|
||
}
|
||
|
||
// Build success response
|
||
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) {
|
||
res["width"] = width;
|
||
res["height"] = height;
|
||
res["top_down"] = top_down;
|
||
if (!client_type.empty()) res["client_type"] = client_type;
|
||
}
|
||
if (audio_enabled >= 0) {
|
||
res["audio_enabled"] = (audio_enabled != 0);
|
||
}
|
||
res["algorithm"] = "h264";
|
||
|
||
Json::StreamWriterBuilder builder;
|
||
builder["indentation"] = "";
|
||
SendText(ws_ptr, Json::writeString(builder, res));
|
||
|
||
// 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()) {
|
||
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());
|
||
}
|
||
}
|
||
|
||
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)) {
|
||
send_reply(false, "Invalid token");
|
||
return;
|
||
}
|
||
|
||
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()) {
|
||
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. StopRemoteDesktop 会再次
|
||
// 锁 m_ClientsMutex,必须在锁外调用。
|
||
for (uint64_t id : to_stop) {
|
||
StopRemoteDesktop(id);
|
||
}
|
||
|
||
send_reply(true, "");
|
||
}
|
||
|
||
void CWebService::HandlePing(void* ws_ptr, const std::string& token) {
|
||
Json::Value res;
|
||
res["cmd"] = "pong";
|
||
res["time"] = (Json::Int64)time(nullptr);
|
||
|
||
Json::StreamWriterBuilder builder;
|
||
builder["indentation"] = "";
|
||
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;
|
||
Json::Reader reader;
|
||
if (!reader.parse(msg, root)) {
|
||
return;
|
||
}
|
||
|
||
std::string token = root.get("token", "").asString();
|
||
std::string type = root.get("type", "").asString();
|
||
int x = root.get("x", 0).asInt();
|
||
int y = root.get("y", 0).asInt();
|
||
int button = root.get("button", 0).asInt();
|
||
int delta = root.get("delta", 0).asInt();
|
||
|
||
// Validate token
|
||
std::string username, role;
|
||
if (!ValidateToken(token, username, role)) {
|
||
return;
|
||
}
|
||
|
||
// Get device being watched
|
||
uint64_t device_id = ResolveWatchDevice(ws_ptr, ParseDeviceIdField(root, "id"));
|
||
if (device_id == 0) return;
|
||
|
||
// Get screen context (not main context!)
|
||
CONTEXT_OBJECT* ctx = GetScreenContext(device_id);
|
||
if (!ctx) {
|
||
return;
|
||
}
|
||
|
||
// Build MSG64 structure(共享 helper,见 WebService.h;与 MCP remote_mouse 复用)
|
||
MSG64 msg64 = BuildMouseMsg64(x, y, 0, 0); // 默认 WM_NULL;未知 button 时仍发送(保持原行为)
|
||
|
||
// Map type and button to Windows message
|
||
if (type == "down") {
|
||
if (button == 0) { msg64.message = WM_LBUTTONDOWN; msg64.wParam = MK_LBUTTON; }
|
||
else if (button == 1) { msg64.message = WM_MBUTTONDOWN; msg64.wParam = MK_MBUTTON; }
|
||
else if (button == 2) { msg64.message = WM_RBUTTONDOWN; msg64.wParam = MK_RBUTTON; }
|
||
} else if (type == "up") {
|
||
if (button == 0) { msg64.message = WM_LBUTTONUP; }
|
||
else if (button == 1) { msg64.message = WM_MBUTTONUP; }
|
||
else if (button == 2) { msg64.message = WM_RBUTTONUP; }
|
||
} else if (type == "move") {
|
||
msg64.message = WM_MOUSEMOVE;
|
||
} else if (type == "wheel") {
|
||
msg64.message = WM_MOUSEWHEEL;
|
||
// WM_MOUSEWHEEL: HIWORD(wParam) = wheel delta, LOWORD(wParam) = key flags
|
||
// Normalize: browser delta is usually ±100+, Windows expects ±120
|
||
short wheelDelta = (short)(delta > 0 ? -120 : (delta < 0 ? 120 : 0));
|
||
msg64.wParam = MAKEWPARAM(0, wheelDelta);
|
||
} else if (type == "dblclick") {
|
||
// dblclick is only needed for macOS clients
|
||
// Windows detects double-click from rapid mousedown/mouseup sequence
|
||
context* mainCtx = m_pParentDlg->FindHost(device_id);
|
||
if (!mainCtx) return;
|
||
CString clientType = mainCtx->GetAdditionalData(RES_CLIENT_TYPE);
|
||
// Check for both "MAC" (new) and "macOS" (legacy) for compatibility
|
||
if (clientType != GetClientType(CLIENT_TYPE_MACOS) && clientType != "macOS") {
|
||
return; // Skip dblclick for non-macOS clients
|
||
}
|
||
if (button == 0) { msg64.message = WM_LBUTTONDBLCLK; msg64.wParam = MK_LBUTTON; }
|
||
else if (button == 2) { msg64.message = WM_RBUTTONDBLCLK; msg64.wParam = MK_RBUTTON; }
|
||
} else {
|
||
return; // Unknown type
|
||
}
|
||
|
||
// Send command to device
|
||
const int length = sizeof(MSG64) + 1;
|
||
BYTE szData[length + 4];
|
||
szData[0] = COMMAND_SCREEN_CONTROL;
|
||
memcpy(szData + 1, &msg64, sizeof(MSG64));
|
||
ctx->Send2Client(szData, length);
|
||
}
|
||
|
||
void CWebService::HandleKey(void* ws_ptr, const std::string& msg) {
|
||
// Parse JSON
|
||
Json::Value root;
|
||
Json::Reader reader;
|
||
if (!reader.parse(msg, root)) {
|
||
return;
|
||
}
|
||
|
||
std::string token = root.get("token", "").asString();
|
||
int keyCode = root.get("keyCode", 0).asInt();
|
||
bool isDown = root.get("down", false).asBool();
|
||
bool altKey = root.get("alt", false).asBool();
|
||
|
||
// Filter Windows keys (same as MFC version)
|
||
if (keyCode == VK_LWIN || keyCode == VK_RWIN) {
|
||
return;
|
||
}
|
||
|
||
// Validate token
|
||
std::string username, role;
|
||
if (!ValidateToken(token, username, role)) {
|
||
return;
|
||
}
|
||
|
||
// Get device being watched
|
||
uint64_t device_id = ResolveWatchDevice(ws_ptr, ParseDeviceIdField(root, "id"));
|
||
if (device_id == 0) return;
|
||
|
||
// Get screen context
|
||
CONTEXT_OBJECT* ctx = GetScreenContext(device_id);
|
||
if (!ctx) {
|
||
return;
|
||
}
|
||
|
||
// Build MSG64 structure(共享 helper,见 WebService.h;与 MCP remote_keyboard 复用)
|
||
MSG64 msg64 = BuildKeyMsg64(keyCode, isDown, altKey);
|
||
|
||
// Send command to device
|
||
const int length = sizeof(MSG64) + 1;
|
||
BYTE szData[length + 4];
|
||
szData[0] = COMMAND_SCREEN_CONTROL;
|
||
memcpy(szData + 1, &msg64, sizeof(MSG64));
|
||
ctx->Send2Client(szData, length);
|
||
}
|
||
|
||
void CWebService::HandleClipboardSet(void* ws_ptr, const std::string& msg) {
|
||
// Parse JSON
|
||
Json::Value root;
|
||
Json::Reader reader;
|
||
if (!reader.parse(msg, root)) {
|
||
return;
|
||
}
|
||
|
||
std::string token = root.get("token", "").asString();
|
||
std::string text = root.get("text", "").asString();
|
||
|
||
if (text.empty()) return;
|
||
|
||
// Validate token
|
||
std::string username, role;
|
||
if (!ValidateToken(token, username, role)) {
|
||
return;
|
||
}
|
||
|
||
// Get device being watched
|
||
uint64_t device_id = ResolveWatchDevice(ws_ptr, ParseDeviceIdField(root, "id"));
|
||
if (device_id == 0) return;
|
||
|
||
// Get screen context
|
||
CONTEXT_OBJECT* ctx = GetScreenContext(device_id);
|
||
if (!ctx) {
|
||
return;
|
||
}
|
||
|
||
// UTF-8 → UTF-16 → GBK(936)。客户端 UpdateClientClipboard 以 CF_TEXT(ANSI/GBK) 落剪贴板,
|
||
// 故显式用 936 而非 CP_ACP,避免服务端 locale 非中文时出错。
|
||
int wlen = MultiByteToWideChar(CP_UTF8, 0, text.c_str(), (int)text.size(), nullptr, 0);
|
||
if (wlen <= 0) return;
|
||
std::wstring wide(wlen, L'\0');
|
||
MultiByteToWideChar(CP_UTF8, 0, text.c_str(), (int)text.size(), &wide[0], wlen);
|
||
|
||
int glen = WideCharToMultiByte(936, 0, wide.c_str(), wlen, nullptr, 0, nullptr, nullptr);
|
||
if (glen <= 0) return;
|
||
|
||
// 命令字节 + GBK 文本
|
||
std::vector<BYTE> szData(1 + glen);
|
||
szData[0] = COMMAND_SCREEN_SET_CLIPBOARD;
|
||
WideCharToMultiByte(936, 0, wide.c_str(), wlen, (char*)szData.data() + 1, glen, nullptr, nullptr);
|
||
|
||
ctx->Send2Client(szData.data(), (ULONG)szData.size());
|
||
}
|
||
|
||
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 the device being watched
|
||
uint64_t device_id = ResolveWatchDevice(ws_ptr, requested_device_id);
|
||
if (device_id == 0) return;
|
||
|
||
// Get screen context
|
||
CONTEXT_OBJECT* ctx = GetScreenContext(device_id);
|
||
if (!ctx) {
|
||
return;
|
||
}
|
||
|
||
// 单字节命令,客户端以 SendClientClipboard → TOKEN_CLIPBOARD_TEXT 回包
|
||
BYTE bToken = COMMAND_SCREEN_GET_CLIPBOARD;
|
||
ctx->Send2Client(&bToken, sizeof(BYTE));
|
||
}
|
||
|
||
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"));
|
||
return;
|
||
}
|
||
|
||
// Get the device being watched by this client
|
||
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;
|
||
}
|
||
|
||
// Get screen context (not main context!)
|
||
CONTEXT_OBJECT* ctx = GetScreenContext(device_id);
|
||
if (!ctx) {
|
||
Mprintf("[WebService] HandleRdpReset: No screen context for device %llu\n", device_id);
|
||
SendText(ws_ptr, BuildJsonResponse("rdp_reset_result", false, "No active screen session"));
|
||
return;
|
||
}
|
||
|
||
// Send CMD_RESTORE_CONSOLE command to client
|
||
BYTE bToken = CMD_RESTORE_CONSOLE;
|
||
if (ctx->Send2Client(&bToken, 1)) {
|
||
Mprintf("[WebService] Sent RDP reset command to device %llu\n", device_id);
|
||
SendText(ws_ptr, BuildJsonResponse("rdp_reset_result", true));
|
||
} else {
|
||
SendText(ws_ptr, BuildJsonResponse("rdp_reset_result", false, "Failed to send command"));
|
||
}
|
||
}
|
||
|
||
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 = ResolveWatchDevice(ws_ptr, requested_device_id);
|
||
if (device_id == 0) {
|
||
SendText(ws_ptr, BuildJsonResponse("audio_toggle_result", false, "No device connected"));
|
||
return;
|
||
}
|
||
|
||
// 投递到 ScreenSpyDlg 的 UI 线程;那边会调用 Enable/DisableAudio 并通过
|
||
// NotifyAudioState 把新状态广播给所有 watching 的 web 客户端
|
||
if (!m_pParentDlg || !m_pParentDlg->PostWebAudioToggle(device_id)) {
|
||
SendText(ws_ptr, BuildJsonResponse("audio_toggle_result", false, "No active screen session"));
|
||
return;
|
||
}
|
||
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
|
||
//////////////////////////////////////////////////////////////////////////
|
||
|
||
void CWebService::HandleCreateUser(void* ws_ptr, const std::string& msg) {
|
||
Json::Value root;
|
||
Json::Reader reader;
|
||
if (!reader.parse(msg, root)) {
|
||
SendText(ws_ptr, BuildJsonResponse("create_user_result", false, "Invalid JSON"));
|
||
return;
|
||
}
|
||
|
||
std::string token = root.get("token", "").asString();
|
||
std::string username, role;
|
||
if (!ValidateToken(token, username, role)) {
|
||
SendText(ws_ptr, BuildJsonResponse("create_user_result", false, "Invalid token"));
|
||
return;
|
||
}
|
||
|
||
// Only admin can create users
|
||
if (role != "admin") {
|
||
SendText(ws_ptr, BuildJsonResponse("create_user_result", false, "Permission denied"));
|
||
return;
|
||
}
|
||
|
||
std::string newUsername = root.get("username", "").asString();
|
||
std::string newPassword = root.get("password", "").asString();
|
||
std::string newRole = root.get("role", "viewer").asString();
|
||
|
||
// Parse allowed_groups array
|
||
std::vector<std::string> allowedGroups;
|
||
const Json::Value& groups = root["allowed_groups"];
|
||
if (groups.isArray()) {
|
||
for (const auto& g : groups) {
|
||
if (g.isString() && !g.asString().empty()) {
|
||
allowedGroups.push_back(g.asString());
|
||
}
|
||
}
|
||
}
|
||
|
||
if (newUsername.empty() || newPassword.empty()) {
|
||
SendText(ws_ptr, BuildJsonResponse("create_user_result", false, "Username and password required"));
|
||
return;
|
||
}
|
||
|
||
if (CreateUser(newUsername, newPassword, newRole, allowedGroups)) {
|
||
SendText(ws_ptr, BuildJsonResponse("create_user_result", true));
|
||
} else {
|
||
SendText(ws_ptr, BuildJsonResponse("create_user_result", false, "Failed to create user (may already exist)"));
|
||
}
|
||
}
|
||
|
||
void CWebService::HandleDeleteUser(void* ws_ptr, const std::string& msg) {
|
||
Json::Value root;
|
||
Json::Reader reader;
|
||
if (!reader.parse(msg, root)) {
|
||
SendText(ws_ptr, BuildJsonResponse("delete_user_result", false, "Invalid JSON"));
|
||
return;
|
||
}
|
||
|
||
std::string token = root.get("token", "").asString();
|
||
std::string username, role;
|
||
if (!ValidateToken(token, username, role)) {
|
||
SendText(ws_ptr, BuildJsonResponse("delete_user_result", false, "Invalid token"));
|
||
return;
|
||
}
|
||
|
||
// Only admin can delete users
|
||
if (role != "admin") {
|
||
SendText(ws_ptr, BuildJsonResponse("delete_user_result", false, "Permission denied"));
|
||
return;
|
||
}
|
||
|
||
std::string targetUsername = root.get("username", "").asString();
|
||
|
||
if (DeleteUser(targetUsername)) {
|
||
SendText(ws_ptr, BuildJsonResponse("delete_user_result", true));
|
||
} else {
|
||
SendText(ws_ptr, BuildJsonResponse("delete_user_result", false, "Failed to delete user"));
|
||
}
|
||
}
|
||
|
||
void CWebService::HandleListUsers(void* ws_ptr, const std::string& token) {
|
||
std::string username, role;
|
||
if (!ValidateToken(token, username, role)) {
|
||
SendText(ws_ptr, BuildJsonResponse("list_users_result", false, "Invalid token"));
|
||
return;
|
||
}
|
||
|
||
// Only admin can list users
|
||
if (role != "admin") {
|
||
SendText(ws_ptr, BuildJsonResponse("list_users_result", false, "Permission denied"));
|
||
return;
|
||
}
|
||
|
||
Json::Value res;
|
||
res["cmd"] = "list_users_result";
|
||
res["ok"] = true;
|
||
|
||
Json::Value usersArray(Json::arrayValue);
|
||
{
|
||
std::lock_guard<std::mutex> lock(m_UsersMutex);
|
||
for (const auto& u : m_Users) {
|
||
Json::Value user;
|
||
user["username"] = u.username;
|
||
user["role"] = u.role;
|
||
|
||
// Include allowed_groups
|
||
Json::Value groups(Json::arrayValue);
|
||
for (const auto& g : u.allowed_groups) {
|
||
groups.append(g);
|
||
}
|
||
user["allowed_groups"] = groups;
|
||
|
||
usersArray.append(user);
|
||
}
|
||
}
|
||
res["users"] = usersArray;
|
||
|
||
Json::StreamWriterBuilder builder;
|
||
builder["indentation"] = "";
|
||
std::string json = Json::writeString(builder, res);
|
||
SendText(ws_ptr, json);
|
||
}
|
||
|
||
void CWebService::HandleGetGroups(void* ws_ptr, const std::string& token) {
|
||
std::string username, role;
|
||
if (!ValidateToken(token, username, role)) {
|
||
SendText(ws_ptr, BuildJsonResponse("groups", false, "Invalid token"));
|
||
return;
|
||
}
|
||
|
||
// Only admin can get groups list (for user management)
|
||
if (role != "admin") {
|
||
SendText(ws_ptr, BuildJsonResponse("groups", false, "Permission denied"));
|
||
return;
|
||
}
|
||
|
||
// Collect all unique groups from online devices
|
||
std::set<std::string> groups;
|
||
groups.insert("default"); // Always include default group
|
||
|
||
if (m_pParentDlg) {
|
||
EnterCriticalSection(&m_pParentDlg->m_cs);
|
||
for (context* ctx : m_pParentDlg->m_HostList) {
|
||
if (!ctx || !ctx->IsLogin()) continue;
|
||
std::string g = ctx->GetGroupName();
|
||
groups.insert(g.empty() ? "default" : g);
|
||
}
|
||
LeaveCriticalSection(&m_pParentDlg->m_cs);
|
||
}
|
||
|
||
// Build response
|
||
Json::Value res;
|
||
res["cmd"] = "groups";
|
||
res["ok"] = true;
|
||
res["groups"] = Json::Value(Json::arrayValue);
|
||
for (const auto& g : groups) {
|
||
res["groups"].append(g);
|
||
}
|
||
|
||
Json::StreamWriterBuilder builder;
|
||
builder["indentation"] = "";
|
||
std::string json = Json::writeString(builder, res);
|
||
SendText(ws_ptr, json);
|
||
}
|
||
|
||
//////////////////////////////////////////////////////////////////////////
|
||
// Token Management (delegated to WebServiceAuth module)
|
||
//////////////////////////////////////////////////////////////////////////
|
||
|
||
std::string CWebService::GenerateToken(const std::string& username, const std::string& role) {
|
||
return WSAuth::GenerateToken(username, role, m_nTokenExpireSeconds);
|
||
}
|
||
|
||
bool CWebService::ValidateToken(const std::string& token, std::string& username, std::string& role) {
|
||
return WSAuth::ValidateToken(token, username, role);
|
||
}
|
||
|
||
//////////////////////////////////////////////////////////////////////////
|
||
// Client Management
|
||
//////////////////////////////////////////////////////////////////////////
|
||
|
||
void CWebService::RegisterClient(void* ws_ptr, const std::string& client_ip) {
|
||
std::lock_guard<std::mutex> lock(m_ClientsMutex);
|
||
WebClient client;
|
||
client.client_ip = client_ip;
|
||
client.connected_at = (uint64_t)time(nullptr);
|
||
client.last_activity = client.connected_at; // Initialize for heartbeat
|
||
m_Clients[ws_ptr] = client;
|
||
}
|
||
|
||
void CWebService::UnregisterClient(void* ws_ptr) {
|
||
std::set<uint64_t> watched;
|
||
|
||
// 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()) {
|
||
watched = std::move(it->second.watch_device_ids);
|
||
m_Clients.erase(it);
|
||
}
|
||
}
|
||
|
||
// 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);
|
||
}
|
||
|
||
// 关闭这个 web client 持有的所有终端会话(MVP 一个用户一台主机一个,但兜底全扫描)
|
||
{
|
||
std::lock_guard<std::mutex> lk(m_TermMutex);
|
||
std::vector<uint64_t> to_close;
|
||
for (auto& kv : m_TermSessions) {
|
||
if (kv.second.ws_ptr == ws_ptr) to_close.push_back(kv.first);
|
||
}
|
||
for (uint64_t did : to_close) CloseTermSessionLocked(did);
|
||
}
|
||
}
|
||
|
||
WebClient* CWebService::FindClient(void* ws_ptr) {
|
||
auto it = m_Clients.find(ws_ptr);
|
||
return (it != m_Clients.end()) ? &it->second : nullptr;
|
||
}
|
||
|
||
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_ids.count(device_id)) {
|
||
count++;
|
||
}
|
||
}
|
||
return count;
|
||
}
|
||
|
||
//////////////////////////////////////////////////////////////////////////
|
||
// Rate Limiting
|
||
//////////////////////////////////////////////////////////////////////////
|
||
|
||
bool CWebService::CheckRateLimit(const std::string& ip) {
|
||
std::lock_guard<std::mutex> lock(m_LoginMutex);
|
||
auto it = m_LoginAttempts.find(ip);
|
||
if (it == m_LoginAttempts.end()) return true;
|
||
return time(nullptr) >= it->second.locked_until;
|
||
}
|
||
|
||
void CWebService::RecordFailedLogin(const std::string& ip) {
|
||
std::lock_guard<std::mutex> lock(m_LoginMutex);
|
||
auto& attempt = m_LoginAttempts[ip];
|
||
attempt.failed_count++;
|
||
if (attempt.failed_count >= 3) {
|
||
attempt.locked_until = time(nullptr) + 3600; // Lock for 1 hour
|
||
attempt.failed_count = 0;
|
||
Mprintf("[WebService] IP %s locked for 1 hour due to failed login attempts\n", ip.c_str());
|
||
}
|
||
}
|
||
|
||
void CWebService::RecordSuccessLogin(const std::string& ip) {
|
||
std::lock_guard<std::mutex> lock(m_LoginMutex);
|
||
m_LoginAttempts.erase(ip);
|
||
}
|
||
|
||
//////////////////////////////////////////////////////////////////////////
|
||
// Password Verification (delegated to WebServiceAuth module)
|
||
//////////////////////////////////////////////////////////////////////////
|
||
|
||
bool CWebService::VerifyPassword(const std::string& input, const WebUser& user) {
|
||
return WSAuth::VerifyPassword(input, user.password_hash, user.salt);
|
||
}
|
||
|
||
std::string CWebService::ComputeHash(const std::string& input) {
|
||
return WSAuth::ComputeSHA256(input);
|
||
}
|
||
|
||
//////////////////////////////////////////////////////////////////////////
|
||
// User Management
|
||
//////////////////////////////////////////////////////////////////////////
|
||
|
||
std::string CWebService::GetUsersFilePath() {
|
||
return m_ConfigDir + "users.json";
|
||
}
|
||
|
||
void CWebService::LoadUsers() {
|
||
// Note: m_UsersMutex should already be held by caller (SetAdminPassword)
|
||
// Load additional users from users.json (admin user is already in m_Users)
|
||
|
||
std::string path = GetUsersFilePath();
|
||
std::ifstream file(path);
|
||
if (!file.is_open()) {
|
||
Mprintf("[WebService] No users.json found, using admin only\n");
|
||
return;
|
||
}
|
||
|
||
try {
|
||
Json::Value root;
|
||
Json::CharReaderBuilder builder;
|
||
std::string errors;
|
||
if (!Json::parseFromStream(builder, file, &root, &errors)) {
|
||
Mprintf("[WebService] Failed to parse users.json: %s\n", errors.c_str());
|
||
return;
|
||
}
|
||
|
||
const Json::Value& users = root["users"];
|
||
int loaded = 0;
|
||
for (const auto& u : users) {
|
||
std::string username = u.get("username", "").asString();
|
||
// Skip admin user (it's built-in with master password)
|
||
if (username.empty() || username == "admin") continue;
|
||
|
||
WebUser user;
|
||
user.username = username;
|
||
user.password_hash = u.get("password_hash", "").asString();
|
||
user.salt = u.get("salt", "").asString();
|
||
user.role = u.get("role", "viewer").asString();
|
||
|
||
// Load allowed_groups
|
||
const Json::Value& groups = u["allowed_groups"];
|
||
if (groups.isArray()) {
|
||
for (const auto& g : groups) {
|
||
user.allowed_groups.push_back(g.asString());
|
||
}
|
||
}
|
||
|
||
if (!user.password_hash.empty()) {
|
||
m_Users.push_back(user);
|
||
loaded++;
|
||
}
|
||
}
|
||
Mprintf("[WebService] Loaded %d additional users from users.json\n", loaded);
|
||
} catch (const std::exception& e) {
|
||
Mprintf("[WebService] Error loading users.json: %s\n", e.what());
|
||
}
|
||
}
|
||
|
||
void CWebService::SaveUsers() {
|
||
// Save non-admin users to users.json
|
||
std::lock_guard<std::mutex> lock(m_UsersMutex);
|
||
|
||
Json::Value root;
|
||
Json::Value users(Json::arrayValue);
|
||
|
||
for (const auto& u : m_Users) {
|
||
// Skip admin user (it uses master password, not stored in file)
|
||
if (u.username == "admin") continue;
|
||
|
||
Json::Value user;
|
||
user["username"] = u.username;
|
||
user["password_hash"] = u.password_hash;
|
||
user["salt"] = u.salt;
|
||
user["role"] = u.role;
|
||
|
||
// Save allowed_groups
|
||
Json::Value groups(Json::arrayValue);
|
||
for (const auto& g : u.allowed_groups) {
|
||
groups.append(g);
|
||
}
|
||
user["allowed_groups"] = groups;
|
||
|
||
users.append(user);
|
||
}
|
||
|
||
root["users"] = users;
|
||
|
||
std::string path = GetUsersFilePath();
|
||
std::ofstream file(path);
|
||
if (!file.is_open()) {
|
||
Mprintf("[WebService] Failed to open users.json for writing\n");
|
||
return;
|
||
}
|
||
|
||
Json::StreamWriterBuilder builder;
|
||
builder["indentation"] = " ";
|
||
std::unique_ptr<Json::StreamWriter> writer(builder.newStreamWriter());
|
||
writer->write(root, &file);
|
||
|
||
Mprintf("[WebService] Saved %d users to users.json\n", (int)users.size());
|
||
}
|
||
|
||
bool CWebService::CreateUser(const std::string& username, const std::string& password, const std::string& role,
|
||
const std::vector<std::string>& allowed_groups) {
|
||
if (username.empty() || password.empty()) return false;
|
||
if (username == "admin") return false; // Cannot create user named "admin"
|
||
if (role != "admin" && role != "viewer") return false;
|
||
|
||
{
|
||
std::lock_guard<std::mutex> lock(m_UsersMutex);
|
||
|
||
// Check if user already exists
|
||
for (const auto& u : m_Users) {
|
||
if (u.username == username) return false;
|
||
}
|
||
|
||
// Generate salt and hash password with salt
|
||
WebUser user;
|
||
user.username = username;
|
||
user.salt = GenerateSalt();
|
||
user.password_hash = WSAuth::ComputeSHA256(password + user.salt);
|
||
user.role = role;
|
||
user.allowed_groups = allowed_groups;
|
||
|
||
m_Users.push_back(user);
|
||
Mprintf("[WebService] Created user: %s (role: %s, groups: %d)\n",
|
||
username.c_str(), role.c_str(), (int)allowed_groups.size());
|
||
}
|
||
|
||
// Save to file (outside lock scope since SaveUsers acquires its own lock)
|
||
SaveUsers();
|
||
return true;
|
||
}
|
||
|
||
bool CWebService::DeleteUser(const std::string& username) {
|
||
if (username.empty() || username == "admin") return false;
|
||
|
||
bool deleted = false;
|
||
{
|
||
std::lock_guard<std::mutex> lock(m_UsersMutex);
|
||
|
||
for (auto it = m_Users.begin(); it != m_Users.end(); ++it) {
|
||
if (it->username == username) {
|
||
m_Users.erase(it);
|
||
Mprintf("[WebService] Deleted user: %s\n", username.c_str());
|
||
deleted = true;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (deleted) {
|
||
SaveUsers();
|
||
}
|
||
return deleted;
|
||
}
|
||
|
||
std::vector<std::pair<std::string, std::string>> CWebService::ListUsers() {
|
||
std::lock_guard<std::mutex> lock(m_UsersMutex);
|
||
std::vector<std::pair<std::string, std::string>> result;
|
||
for (const auto& u : m_Users) {
|
||
result.push_back({u.username, u.role});
|
||
}
|
||
return result;
|
||
}
|
||
|
||
//////////////////////////////////////////////////////////////////////////
|
||
// JSON Helpers
|
||
//////////////////////////////////////////////////////////////////////////
|
||
|
||
std::string CWebService::BuildJsonResponse(const std::string& cmd, bool ok, const std::string& msg) {
|
||
Json::Value res;
|
||
res["cmd"] = cmd;
|
||
res["ok"] = ok;
|
||
if (!msg.empty()) {
|
||
res["msg"] = msg;
|
||
}
|
||
|
||
Json::StreamWriterBuilder builder;
|
||
builder["indentation"] = "";
|
||
return Json::writeString(builder, res);
|
||
}
|
||
|
||
std::string CWebService::BuildDeviceListJson(const std::string& username) {
|
||
Json::Value res;
|
||
res["cmd"] = "device_list";
|
||
res["devices"] = Json::Value(Json::arrayValue);
|
||
|
||
// Get user's allowed groups for filtering (skip for admin or empty username)
|
||
std::vector<std::string> allowedGroups;
|
||
bool filterByGroup = false;
|
||
if (!username.empty() && username != "admin") {
|
||
std::lock_guard<std::mutex> lock(m_UsersMutex);
|
||
for (const auto& u : m_Users) {
|
||
if (u.username == username) {
|
||
allowedGroups = u.allowed_groups;
|
||
filterByGroup = true;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (m_pParentDlg) {
|
||
// Access device list with lock
|
||
EnterCriticalSection(&m_pParentDlg->m_cs);
|
||
for (context* ctx : m_pParentDlg->m_HostList) {
|
||
if (!ctx || !ctx->IsLogin()) continue;
|
||
|
||
// Get device group (empty = "default")
|
||
std::string deviceGroup = ctx->GetGroupName();
|
||
if (deviceGroup.empty()) deviceGroup = "default";
|
||
|
||
// Filter by allowed groups if user is not admin
|
||
if (filterByGroup) {
|
||
bool allowed = false;
|
||
for (const auto& g : allowedGroups) {
|
||
if (g == deviceGroup) {
|
||
allowed = true;
|
||
break;
|
||
}
|
||
}
|
||
if (!allowed) continue; // Skip device not in allowed groups
|
||
}
|
||
|
||
// 单台主机序列化委托给公共函数 BuildHostJson(与 MCP 侧复用,
|
||
// 字段与编码转换逻辑保持一致,见 docs/Mcp_Design.md §3.4 方案 C)。
|
||
Json::Value device = BuildHostJson(ctx, m_pParentDlg->m_ClientMap);
|
||
res["devices"].append(device);
|
||
}
|
||
LeaveCriticalSection(&m_pParentDlg->m_cs);
|
||
}
|
||
|
||
Json::StreamWriterBuilder builder;
|
||
builder["indentation"] = "";
|
||
return Json::writeString(builder, res);
|
||
}
|
||
|
||
//////////////////////////////////////////////////////////////////////////
|
||
// WebSocket Send Helpers
|
||
//////////////////////////////////////////////////////////////////////////
|
||
|
||
void CWebService::SendText(void* ws_ptr, const std::string& text) {
|
||
if (!ws_ptr || m_bStopping) return;
|
||
ws::Connection* conn = (ws::Connection*)ws_ptr;
|
||
if (!conn->isClosed()) {
|
||
conn->send(text);
|
||
}
|
||
}
|
||
|
||
void CWebService::SendBinary(void* ws_ptr, const uint8_t* data, size_t len) {
|
||
if (!ws_ptr || !data || len == 0 || m_bStopping) return;
|
||
ws::Connection* conn = (ws::Connection*)ws_ptr;
|
||
if (!conn->isClosed()) {
|
||
conn->sendBinary(data, len);
|
||
}
|
||
}
|
||
|
||
//////////////////////////////////////////////////////////////////////////
|
||
// Frame Broadcasting
|
||
//////////////////////////////////////////////////////////////////////////
|
||
|
||
std::vector<uint8_t> CWebService::BuildFramePacket(uint64_t device_id, bool is_keyframe,
|
||
const uint8_t* data, size_t len) {
|
||
// Packet format: [DeviceID:4][FrameType:1][DataLen:4][H264Data:N]
|
||
std::vector<uint8_t> packet;
|
||
packet.reserve(9 + len);
|
||
|
||
// DeviceID (4 bytes, little-endian, truncated to 32-bit for JS compatibility)
|
||
uint32_t id32 = (uint32_t)(device_id & 0xFFFFFFFF);
|
||
packet.push_back((id32 >> 0) & 0xFF);
|
||
packet.push_back((id32 >> 8) & 0xFF);
|
||
packet.push_back((id32 >> 16) & 0xFF);
|
||
packet.push_back((id32 >> 24) & 0xFF);
|
||
|
||
// FrameType (1 byte): 0=P, 1=IDR
|
||
packet.push_back(is_keyframe ? 1 : 0);
|
||
|
||
// DataLen (4 bytes, little-endian)
|
||
uint32_t data_len = (uint32_t)len;
|
||
packet.push_back((data_len >> 0) & 0xFF);
|
||
packet.push_back((data_len >> 8) & 0xFF);
|
||
packet.push_back((data_len >> 16) & 0xFF);
|
||
packet.push_back((data_len >> 24) & 0xFF);
|
||
|
||
// H264 Data
|
||
packet.insert(packet.end(), data, data + len);
|
||
|
||
return packet;
|
||
}
|
||
|
||
void CWebService::BroadcastFrame(uint64_t device_id, const uint8_t* data, size_t len, bool is_keyframe) {
|
||
if (!data || len == 0 || m_bStopping) return;
|
||
|
||
// Build packet once
|
||
auto packet = BuildFramePacket(device_id, is_keyframe, data, len);
|
||
|
||
// Broadcast to all watching clients
|
||
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, packet.data(), packet.size());
|
||
}
|
||
}
|
||
}
|
||
|
||
void CWebService::CacheKeyframe(uint64_t device_id, const uint8_t* data, size_t len) {
|
||
if (!data || len == 0 || m_bStopping) return;
|
||
|
||
std::lock_guard<std::mutex> lock(m_DeviceCacheMutex);
|
||
auto it = m_DeviceCache.find(device_id);
|
||
if (it == m_DeviceCache.end()) {
|
||
m_DeviceCache[device_id] = std::make_shared<WebDeviceInfo>();
|
||
it = m_DeviceCache.find(device_id);
|
||
}
|
||
|
||
std::lock_guard<std::mutex> cache_lock(it->second->cache_mutex);
|
||
it->second->keyframe_cache.assign(data, data + len);
|
||
}
|
||
|
||
void CWebService::BroadcastH264Frame(uint64_t device_id, const uint8_t* data, size_t len) {
|
||
// The data is already a complete packet: [DeviceID:4][FrameType:1][DataLen:4][H264Data:N]
|
||
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_ids.count(device_id)) {
|
||
SendBinary(ws_ptr, data, len);
|
||
}
|
||
}
|
||
}
|
||
// Cache keyframe (check FrameType byte at offset 4). 只缓存 raw payload:
|
||
// HandleConnect/HandleKeyframe 发送时会统一用 BuildFramePacket 封装,
|
||
// 缓存完整包会导致双重封装(历史 bug)。
|
||
if (data[4] == 1) { // IDR frame
|
||
CacheKeyframe(device_id, data + 9, len - 9);
|
||
}
|
||
}
|
||
|
||
void CWebService::NotifyResolutionChange(uint64_t device_id, int width, int height, bool top_down, const std::string& client_type) {
|
||
if (m_bStopping) return;
|
||
|
||
// Update cache
|
||
{
|
||
std::lock_guard<std::mutex> lock(m_DeviceCacheMutex);
|
||
auto it = m_DeviceCache.find(device_id);
|
||
if (it == m_DeviceCache.end()) {
|
||
m_DeviceCache[device_id] = std::make_shared<WebDeviceInfo>();
|
||
it = m_DeviceCache.find(device_id);
|
||
}
|
||
it->second->screen_width = width;
|
||
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"] = std::to_string(device_id);
|
||
res["width"] = width;
|
||
res["height"] = height;
|
||
res["top_down"] = top_down;
|
||
if (!client_type.empty()) res["client_type"] = client_type;
|
||
|
||
Json::StreamWriterBuilder builder;
|
||
builder["indentation"] = "";
|
||
std::string json = Json::writeString(builder, res);
|
||
|
||
std::lock_guard<std::mutex> lock(m_ClientsMutex);
|
||
for (auto& [ws_ptr, client] : m_Clients) {
|
||
if (client.watch_device_ids.count(device_id)) {
|
||
SendText(ws_ptr, json);
|
||
}
|
||
}
|
||
}
|
||
|
||
bool CWebService::GetScreenSize(uint64_t device_id, int& width, int& height) {
|
||
width = 0;
|
||
height = 0;
|
||
std::lock_guard<std::mutex> lock(m_DeviceCacheMutex);
|
||
auto it = m_DeviceCache.find(device_id);
|
||
if (it == m_DeviceCache.end()) return false;
|
||
width = it->second->screen_width;
|
||
height = it->second->screen_height;
|
||
return width > 0 && height > 0;
|
||
}
|
||
|
||
void CWebService::NotifyAudioState(uint64_t device_id, bool enabled) {
|
||
if (m_bStopping) return;
|
||
|
||
// 缓存最新状态,新加入的 web 客户端通过 connect_result 取到初值
|
||
{
|
||
std::lock_guard<std::mutex> lock(m_DeviceCacheMutex);
|
||
auto it = m_DeviceCache.find(device_id);
|
||
if (it == m_DeviceCache.end()) {
|
||
m_DeviceCache[device_id] = std::make_shared<WebDeviceInfo>();
|
||
it = m_DeviceCache.find(device_id);
|
||
}
|
||
it->second->audio_enabled = enabled ? 1 : 0;
|
||
}
|
||
|
||
Json::Value res;
|
||
res["cmd"] = "audio_state";
|
||
res["id"] = std::to_string(device_id);
|
||
res["enabled"] = enabled;
|
||
|
||
Json::StreamWriterBuilder builder;
|
||
builder["indentation"] = "";
|
||
std::string json = Json::writeString(builder, res);
|
||
|
||
std::lock_guard<std::mutex> lock(m_ClientsMutex);
|
||
for (auto& [ws_ptr, client] : m_Clients) {
|
||
if (client.watch_device_ids.count(device_id)) {
|
||
SendText(ws_ptr, json);
|
||
}
|
||
}
|
||
}
|
||
|
||
void CWebService::BroadcastCursor(uint64_t device_id, uint8_t cursor_index) {
|
||
if (m_bStopping) return;
|
||
|
||
// Build JSON message
|
||
Json::Value res;
|
||
res["cmd"] = "cursor";
|
||
res["id"] = std::to_string(device_id);
|
||
res["index"] = cursor_index;
|
||
|
||
Json::StreamWriterBuilder builder;
|
||
builder["indentation"] = "";
|
||
std::string json = Json::writeString(builder, res);
|
||
|
||
// Send to all watching clients
|
||
std::lock_guard<std::mutex> lock(m_ClientsMutex);
|
||
for (auto& [ws_ptr, client] : m_Clients) {
|
||
if (client.watch_device_ids.count(device_id)) {
|
||
SendText(ws_ptr, json);
|
||
}
|
||
}
|
||
}
|
||
|
||
void CWebService::NotifyClipboardText(uint64_t device_id, const std::string& text) {
|
||
if (m_bStopping) return;
|
||
|
||
Json::Value res;
|
||
res["cmd"] = "clipboard_text";
|
||
res["id"] = std::to_string(device_id);
|
||
res["text"] = text;
|
||
|
||
Json::StreamWriterBuilder builder;
|
||
builder["indentation"] = "";
|
||
std::string json = Json::writeString(builder, res);
|
||
|
||
std::lock_guard<std::mutex> lock(m_ClientsMutex);
|
||
for (auto& [ws_ptr, client] : m_Clients) {
|
||
if (client.watch_device_ids.count(device_id)) {
|
||
SendText(ws_ptr, json);
|
||
}
|
||
}
|
||
}
|
||
|
||
bool CWebService::StartRemoteDesktop(uint64_t device_id) {
|
||
if (!m_pParentDlg) return false;
|
||
|
||
context* ctx = m_pParentDlg->FindHost(device_id);
|
||
if (!ctx) return false;
|
||
|
||
// Cancel any pending grace-period close so the existing session stays alive.
|
||
{
|
||
std::lock_guard<std::mutex> lk(m_PendingCloseMutex);
|
||
auto it = m_PendingCloseTimers.find(device_id);
|
||
if (it != m_PendingCloseTimers.end()) {
|
||
it->second->store(true);
|
||
m_PendingCloseTimers.erase(it);
|
||
}
|
||
}
|
||
|
||
// Check if there's already a Web session for this device
|
||
// Only reuse if Web has already triggered AND a Web dialog exists
|
||
// This ensures MFC and Web have independent dialogs
|
||
if (IsWebTriggered(device_id) && HasActiveSession(device_id)) {
|
||
Mprintf("[WebService] Reusing existing Web session for device %llu\n", device_id);
|
||
return true; // Web session exists, new web user joins watching
|
||
}
|
||
|
||
// Mark as web-triggered (dialog should be hidden)
|
||
{
|
||
std::lock_guard<std::mutex> lock(m_WebTriggeredMutex);
|
||
m_WebTriggeredDevices.insert(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
|
||
bToken[2] = ALGORITHM_H264; // H264 algorithm
|
||
bToken[3] = 1; // Multi-screen: true
|
||
|
||
return ctx->Send2Client(bToken, sizeof(bToken)) != FALSE;
|
||
}
|
||
|
||
void CWebService::StopRemoteDesktop(uint64_t device_id) {
|
||
if (!m_pParentDlg) return;
|
||
|
||
// Check if any other web clients are watching this device
|
||
int watchingCount = 0;
|
||
{
|
||
std::lock_guard<std::mutex> lock(m_ClientsMutex);
|
||
for (const auto& [ws_ptr, client] : m_Clients) {
|
||
if (client.watch_device_ids.count(device_id)) {
|
||
watchingCount++;
|
||
}
|
||
}
|
||
}
|
||
|
||
// If no more web clients watching, defer close by 30 s so a quick
|
||
// mobile reconnect (e.g. switching to a TOTP app) reuses the existing
|
||
// CScreenSpyDlg without a full cold-start.
|
||
if (watchingCount == 0) {
|
||
auto cancelled = std::make_shared<std::atomic<bool>>(false);
|
||
{
|
||
std::lock_guard<std::mutex> lk(m_PendingCloseMutex);
|
||
auto it = m_PendingCloseTimers.find(device_id);
|
||
if (it != m_PendingCloseTimers.end())
|
||
it->second->store(true);
|
||
m_PendingCloseTimers[device_id] = cancelled;
|
||
}
|
||
std::thread([this, device_id, cancelled]() {
|
||
std::this_thread::sleep_for(std::chrono::seconds(30));
|
||
if (cancelled->load()) return;
|
||
{
|
||
std::lock_guard<std::mutex> lk(m_PendingCloseMutex);
|
||
m_PendingCloseTimers.erase(device_id);
|
||
}
|
||
int count = 0;
|
||
{
|
||
std::lock_guard<std::mutex> lock(m_ClientsMutex);
|
||
for (const auto& [ws, client] : m_Clients)
|
||
if (client.watch_device_ids.count(device_id)) count++;
|
||
}
|
||
if (count == 0 && m_pParentDlg) {
|
||
ClearWebTriggered(device_id);
|
||
m_pParentDlg->CloseWebRemoteDesktopByClientID(device_id);
|
||
}
|
||
}).detach();
|
||
}
|
||
}
|
||
|
||
//////////////////////////////////////////////////////////////////////////
|
||
// Web Terminal Session
|
||
//
|
||
// 数据流向:
|
||
// 浏览器 ── term_open ──► HandleTermOpen ── COMMAND_SHELL ──► 客户端
|
||
// 客户端 ── shell 子上下文 ──► MessageHandle TOKEN_TERMINAL_START
|
||
// ── RegisterTerminalContext ──► WebService
|
||
// 客户端 shell 输出 ── TOKEN_TERMINAL_DATA ──► MessageHandle ──► OnTerminalData
|
||
// ──► term_output 给浏览器
|
||
// 浏览器 keystrokes ── term_input ──► HandleTermInput ──► shell_ctx->Send2Client
|
||
//
|
||
// 一台主机最多一个 web 终端会话(MVP)。
|
||
//////////////////////////////////////////////////////////////////////////
|
||
|
||
static std::string BuildTermJson(const std::string& cmd, std::initializer_list<std::pair<const char*, std::string>> kv) {
|
||
Json::Value v;
|
||
v["cmd"] = cmd;
|
||
for (auto& p : kv) v[p.first] = p.second;
|
||
Json::StreamWriterBuilder b; b["indentation"] = "";
|
||
return Json::writeString(b, v);
|
||
}
|
||
|
||
void CWebService::HandleTermOpen(void* ws_ptr, const std::string& msg) {
|
||
Json::Value root; Json::Reader rdr;
|
||
if (!rdr.parse(msg, root)) return;
|
||
|
||
std::string token = root.get("token", "").asString();
|
||
std::string username, role;
|
||
if (!ValidateToken(token, username, role)) {
|
||
SendText(ws_ptr, BuildJsonResponse("term_closed", false, "Invalid token"));
|
||
return;
|
||
}
|
||
std::string id_str = root.get("id", "").asString();
|
||
uint64_t device_id = id_str.empty() ? 0 : strtoull(id_str.c_str(), nullptr, 10);
|
||
if (!device_id || !m_pParentDlg) {
|
||
SendText(ws_ptr, BuildJsonResponse("term_closed", false, "Bad request"));
|
||
return;
|
||
}
|
||
context* ctx = m_pParentDlg->FindHost(device_id);
|
||
if (!ctx) {
|
||
SendText(ws_ptr, BuildJsonResponse("term_closed", false, "Device offline"));
|
||
return;
|
||
}
|
||
|
||
// Group permission check (admin 全部可见)
|
||
if (username != "admin") {
|
||
std::string g = ctx->GetGroupName(); if (g.empty()) g = "default";
|
||
bool ok = false;
|
||
{
|
||
std::lock_guard<std::mutex> lk(m_UsersMutex);
|
||
for (auto& u : m_Users) if (u.username == username) {
|
||
for (auto& ag : u.allowed_groups) if (ag == g) { ok = true; break; }
|
||
break;
|
||
}
|
||
}
|
||
if (!ok) {
|
||
SendText(ws_ptr, BuildJsonResponse("term_closed", false, "Permission denied"));
|
||
return;
|
||
}
|
||
}
|
||
|
||
// 占用:MVP 阶段单设备单 web 终端
|
||
{
|
||
std::lock_guard<std::mutex> lk(m_TermMutex);
|
||
if (m_TermSessions.find(device_id) != m_TermSessions.end() ||
|
||
m_TermPending.find(device_id) != m_TermPending.end()) {
|
||
SendText(ws_ptr, BuildJsonResponse("term_closed", false,
|
||
"Terminal already open by another viewer"));
|
||
return;
|
||
}
|
||
WebTermSession s; s.ws_ptr = ws_ptr; s.device_id = device_id;
|
||
s.shell_ctx = nullptr; s.is_pty = false;
|
||
m_TermSessions[device_id] = s;
|
||
m_TermPending.insert(device_id);
|
||
}
|
||
|
||
// 触发客户端:发 COMMAND_SHELL
|
||
BYTE cmd = COMMAND_SHELL;
|
||
if (!ctx->Send2Client(&cmd, 1)) {
|
||
std::lock_guard<std::mutex> lk(m_TermMutex);
|
||
m_TermSessions.erase(device_id);
|
||
m_TermPending.erase(device_id);
|
||
SendText(ws_ptr, BuildJsonResponse("term_closed", false, "Send failed"));
|
||
return;
|
||
}
|
||
Mprintf("[WebService] term_open device=%llu user=%s\n", device_id, username.c_str());
|
||
}
|
||
|
||
void CWebService::HandleTermInput(void* ws_ptr, const std::string& msg) {
|
||
Json::Value root; Json::Reader rdr;
|
||
if (!rdr.parse(msg, root)) return;
|
||
std::string token = root.get("token", "").asString();
|
||
std::string username, role;
|
||
if (!ValidateToken(token, username, role)) return;
|
||
std::string id_str = root.get("id", "").asString();
|
||
uint64_t device_id = id_str.empty() ? 0 : strtoull(id_str.c_str(), nullptr, 10);
|
||
std::string data = root.get("data", "").asString();
|
||
if (!device_id || data.empty()) return;
|
||
|
||
CONTEXT_OBJECT* shellCtx = nullptr;
|
||
{
|
||
std::lock_guard<std::mutex> lk(m_TermMutex);
|
||
auto it = m_TermSessions.find(device_id);
|
||
if (it == m_TermSessions.end() || it->second.ws_ptr != ws_ptr) return;
|
||
shellCtx = it->second.shell_ctx;
|
||
}
|
||
if (!shellCtx) return; // shell 子上下文还没就绪
|
||
shellCtx->Send2Client((BYTE*)data.data(), (ULONG)data.size());
|
||
}
|
||
|
||
void CWebService::HandleTermResize(void* ws_ptr, const std::string& msg) {
|
||
Json::Value root; Json::Reader rdr;
|
||
if (!rdr.parse(msg, root)) return;
|
||
std::string token = root.get("token", "").asString();
|
||
std::string username, role;
|
||
if (!ValidateToken(token, username, role)) return;
|
||
std::string id_str = root.get("id", "").asString();
|
||
uint64_t device_id = id_str.empty() ? 0 : strtoull(id_str.c_str(), nullptr, 10);
|
||
int cols = root.get("cols", 0).asInt();
|
||
int rows = root.get("rows", 0).asInt();
|
||
if (!device_id || cols <= 0 || rows <= 0) return;
|
||
|
||
CONTEXT_OBJECT* shellCtx = nullptr;
|
||
bool isPty = false;
|
||
{
|
||
std::lock_guard<std::mutex> lk(m_TermMutex);
|
||
auto it = m_TermSessions.find(device_id);
|
||
if (it == m_TermSessions.end() || it->second.ws_ptr != ws_ptr) return;
|
||
shellCtx = it->second.shell_ctx;
|
||
isPty = it->second.is_pty;
|
||
}
|
||
if (!shellCtx || !isPty) return; // 老 cmd 模式不支持 resize
|
||
|
||
BYTE buf[5];
|
||
buf[0] = CMD_TERMINAL_RESIZE;
|
||
*(short*)(buf + 1) = (short)cols;
|
||
*(short*)(buf + 3) = (short)rows;
|
||
shellCtx->Send2Client(buf, 5);
|
||
}
|
||
|
||
void CWebService::HandleTermClose(void* ws_ptr, const std::string& msg) {
|
||
Json::Value root; Json::Reader rdr;
|
||
if (!rdr.parse(msg, root)) return;
|
||
std::string id_str = root.get("id", "").asString();
|
||
uint64_t device_id = id_str.empty() ? 0 : strtoull(id_str.c_str(), nullptr, 10);
|
||
if (!device_id) return;
|
||
|
||
std::lock_guard<std::mutex> lk(m_TermMutex);
|
||
auto it = m_TermSessions.find(device_id);
|
||
if (it == m_TermSessions.end() || it->second.ws_ptr != ws_ptr) return;
|
||
CloseTermSessionLocked(device_id);
|
||
}
|
||
|
||
void CWebService::CloseTermSessionLocked(uint64_t device_id) {
|
||
auto it = m_TermSessions.find(device_id);
|
||
if (it == m_TermSessions.end()) return;
|
||
|
||
CONTEXT_OBJECT* shellCtx = it->second.shell_ctx;
|
||
void* ws_ptr = it->second.ws_ptr;
|
||
m_TermSessions.erase(it);
|
||
m_TermPending.erase(device_id);
|
||
if (shellCtx) {
|
||
m_TermContextToDevice.erase(shellCtx);
|
||
// 触发客户端 shell 退出:直接断该子上下文
|
||
// (老 ShellDlg 是直接 Send TOKEN_BYE 之类,但断开更可靠)
|
||
shellCtx->CancelIO();
|
||
}
|
||
// 通知前端
|
||
SendText(ws_ptr, BuildJsonResponse("term_closed", true, "closed"));
|
||
Mprintf("[WebService] term_closed device=%llu\n", device_id);
|
||
}
|
||
|
||
bool CWebService::IsTermPending(uint64_t device_id) {
|
||
std::lock_guard<std::mutex> lk(m_TermMutex);
|
||
return m_TermPending.find(device_id) != m_TermPending.end();
|
||
}
|
||
|
||
void CWebService::RegisterTerminalContext(uint64_t device_id, CONTEXT_OBJECT* ctx, bool isPty) {
|
||
void* ws_ptr_to_notify = nullptr;
|
||
{
|
||
std::lock_guard<std::mutex> lk(m_TermMutex);
|
||
auto it = m_TermSessions.find(device_id);
|
||
if (it == m_TermSessions.end()) return; // 没有等的 web session 了
|
||
it->second.shell_ctx = ctx;
|
||
it->second.is_pty = isPty;
|
||
m_TermContextToDevice[ctx] = device_id;
|
||
m_TermPending.erase(device_id);
|
||
ws_ptr_to_notify = it->second.ws_ptr;
|
||
}
|
||
|
||
// 关键步骤:告知客户端"启动 shell 输出回流"。
|
||
// PTY 模式:客户端 PTYHandler 已 fork 了 shell 子进程,但读线程要靠 COMMAND_NEXT 才启动
|
||
// (参考 TerminalDlg::OnTerminalReady)。漏发会导致 shell 在跑但输出永远不送回。
|
||
// PTY 还要先告知初始 cols/rows(默认 80x24),否则 shell 会按 PTY 默认尺寸渲染,
|
||
// vim 等 TUI 在浏览器侧的 fit 调整前会乱。后续浏览器 term_resize 会再调整。
|
||
if (isPty) {
|
||
BYTE resizeBuf[5];
|
||
resizeBuf[0] = CMD_TERMINAL_RESIZE;
|
||
*(short*)(resizeBuf + 1) = (short)80;
|
||
*(short*)(resizeBuf + 3) = (short)24;
|
||
ctx->Send2Client(resizeBuf, 5);
|
||
}
|
||
BYTE startCmd = COMMAND_NEXT;
|
||
ctx->Send2Client(&startCmd, 1);
|
||
|
||
// 通知前端 ready,告知模式(pty / legacy)
|
||
if (ws_ptr_to_notify) {
|
||
SendText(ws_ptr_to_notify, BuildTermJson("term_ready", {{"mode", isPty ? "pty" : "legacy"}}));
|
||
}
|
||
Mprintf("[WebService] term_ready device=%llu mode=%s\n",
|
||
device_id, isPty ? "pty" : "legacy");
|
||
}
|
||
|
||
bool CWebService::IsTerminalContext(CONTEXT_OBJECT* ctx) {
|
||
std::lock_guard<std::mutex> lk(m_TermMutex);
|
||
return m_TermContextToDevice.find(ctx) != m_TermContextToDevice.end();
|
||
}
|
||
|
||
void CWebService::OnTerminalData(CONTEXT_OBJECT* ctx, const BYTE* data, ULONG len) {
|
||
void* ws_ptr = nullptr;
|
||
{
|
||
std::lock_guard<std::mutex> lk(m_TermMutex);
|
||
auto it = m_TermContextToDevice.find(ctx);
|
||
if (it == m_TermContextToDevice.end()) return;
|
||
auto sit = m_TermSessions.find(it->second);
|
||
if (sit == m_TermSessions.end()) return;
|
||
ws_ptr = sit->second.ws_ptr;
|
||
}
|
||
if (!ws_ptr || !data || !len) return;
|
||
|
||
// 用 binary frame 透传字节流(避免 JSON 二进制不可见字符 / 编码膨胀)。
|
||
// 帧格式:[4B magic 'TRM1'][N=payload]
|
||
// 4 字节 magic = 0x54 0x52 0x4D 0x31 —— 视频帧首 4 字节是 deviceID(uint32 LE),
|
||
// 撞这个具体值 (0x314D5254) 的概率极低,浏览器侧据此安全分流。
|
||
std::vector<uint8_t> packet;
|
||
packet.reserve(len + 4);
|
||
packet.push_back('T'); packet.push_back('R'); packet.push_back('M'); packet.push_back('1');
|
||
packet.insert(packet.end(), data, data + len);
|
||
SendBinary(ws_ptr, packet.data(), packet.size());
|
||
}
|
||
|
||
void CWebService::OnTerminalClosed(CONTEXT_OBJECT* ctx) {
|
||
std::lock_guard<std::mutex> lk(m_TermMutex);
|
||
auto it = m_TermContextToDevice.find(ctx);
|
||
if (it == m_TermContextToDevice.end()) return;
|
||
uint64_t device_id = it->second;
|
||
CloseTermSessionLocked(device_id);
|
||
}
|
||
|
||
//////////////////////////////////////////////////////////////////////////
|
||
// Screen Context Registry (for mouse/keyboard control)
|
||
//////////////////////////////////////////////////////////////////////////
|
||
|
||
void CWebService::RegisterScreenContext(uint64_t device_id, CONTEXT_OBJECT* ctx) {
|
||
if (!m_bRunning) return;
|
||
std::lock_guard<std::mutex> lock(m_ScreenContextsMutex);
|
||
m_ScreenContexts[device_id] = ctx;
|
||
Mprintf("[WebService] Registered screen context for device %llu\n", device_id);
|
||
}
|
||
|
||
void CWebService::UnregisterScreenContext(uint64_t device_id) {
|
||
// Always clean up, even if WebService is stopping
|
||
// This prevents stale pointers in m_ScreenContexts
|
||
std::lock_guard<std::mutex> lock(m_ScreenContextsMutex);
|
||
m_ScreenContexts.erase(device_id);
|
||
if (m_bRunning) {
|
||
Mprintf("[WebService] Unregistered screen context for device %llu\n", device_id);
|
||
}
|
||
}
|
||
|
||
CONTEXT_OBJECT* CWebService::GetScreenContext(uint64_t device_id) {
|
||
std::lock_guard<std::mutex> lock(m_ScreenContextsMutex);
|
||
auto it = m_ScreenContexts.find(device_id);
|
||
return (it != m_ScreenContexts.end()) ? it->second : nullptr;
|
||
}
|
||
|
||
//////////////////////////////////////////////////////////////////////////
|
||
// Device Events
|
||
//////////////////////////////////////////////////////////////////////////
|
||
|
||
void CWebService::MarkDeviceOnline(uint64_t device_id) {
|
||
std::lock_guard<std::mutex> lock(m_DirtyDevicesMutex);
|
||
m_OnlineDevices.insert(device_id);
|
||
m_OfflineDevices.erase(device_id); // Cancel pending offline if any
|
||
}
|
||
|
||
void CWebService::MarkDeviceOffline(uint64_t device_id) {
|
||
std::lock_guard<std::mutex> lock(m_DirtyDevicesMutex);
|
||
m_OfflineDevices.insert(device_id);
|
||
m_OnlineDevices.erase(device_id); // Cancel pending online if any
|
||
}
|
||
|
||
void CWebService::FlushDeviceChanges() {
|
||
if (!m_bRunning || m_bStopping) return;
|
||
|
||
// Collect changes under lock
|
||
std::set<uint64_t> online, offline;
|
||
{
|
||
std::lock_guard<std::mutex> lock(m_DirtyDevicesMutex);
|
||
if (m_OnlineDevices.empty() && m_OfflineDevices.empty()) {
|
||
return; // No changes
|
||
}
|
||
online = std::move(m_OnlineDevices);
|
||
offline = std::move(m_OfflineDevices);
|
||
m_OnlineDevices.clear();
|
||
m_OfflineDevices.clear();
|
||
}
|
||
|
||
// Notify clients watching offline devices
|
||
if (!offline.empty()) {
|
||
std::lock_guard<std::mutex> lock(m_ClientsMutex);
|
||
for (auto& [ws_ptr, client] : m_Clients) {
|
||
// 一个 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(id);
|
||
Json::StreamWriterBuilder builder;
|
||
builder["indentation"] = "";
|
||
SendText(ws_ptr, Json::writeString(builder, res));
|
||
client.watch_device_ids.erase(id);
|
||
client.keyframe_req_at.erase(id);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Clear offline devices from cache
|
||
{
|
||
std::lock_guard<std::mutex> lock(m_DeviceCacheMutex);
|
||
for (uint64_t id : offline) {
|
||
m_DeviceCache.erase(id);
|
||
}
|
||
}
|
||
|
||
// Build and broadcast devices_changed message
|
||
Json::Value msg;
|
||
msg["cmd"] = "devices_changed";
|
||
msg["online"] = Json::Value(Json::arrayValue);
|
||
msg["offline"] = Json::Value(Json::arrayValue);
|
||
for (uint64_t id : online) {
|
||
msg["online"].append(std::to_string(id));
|
||
}
|
||
for (uint64_t id : offline) {
|
||
msg["offline"].append(std::to_string(id));
|
||
}
|
||
|
||
Json::StreamWriterBuilder builder;
|
||
builder["indentation"] = "";
|
||
std::string json = Json::writeString(builder, msg);
|
||
|
||
// Broadcast to all authenticated clients
|
||
std::lock_guard<std::mutex> lock(m_ClientsMutex);
|
||
for (const auto& [ws_ptr, client] : m_Clients) {
|
||
if (!client.token.empty()) {
|
||
SendText(ws_ptr, json);
|
||
}
|
||
}
|
||
}
|
||
|
||
bool CWebService::IsWebTriggered(uint64_t device_id) {
|
||
std::lock_guard<std::mutex> lock(m_WebTriggeredMutex);
|
||
return m_WebTriggeredDevices.find(device_id) != m_WebTriggeredDevices.end();
|
||
}
|
||
|
||
void CWebService::ClearWebTriggered(uint64_t device_id) {
|
||
std::lock_guard<std::mutex> lock(m_WebTriggeredMutex);
|
||
m_WebTriggeredDevices.erase(device_id);
|
||
}
|
||
|
||
void CWebService::SetMfcTriggered(uint64_t device_id) {
|
||
std::lock_guard<std::mutex> lock(m_MfcTriggeredMutex);
|
||
m_MfcTriggeredDevices.insert(device_id);
|
||
}
|
||
|
||
bool CWebService::IsMfcTriggered(uint64_t device_id) {
|
||
std::lock_guard<std::mutex> lock(m_MfcTriggeredMutex);
|
||
return m_MfcTriggeredDevices.find(device_id) != m_MfcTriggeredDevices.end();
|
||
}
|
||
|
||
void CWebService::ClearMfcTriggered(uint64_t device_id) {
|
||
std::lock_guard<std::mutex> lock(m_MfcTriggeredMutex);
|
||
m_MfcTriggeredDevices.erase(device_id);
|
||
}
|
||
|
||
void CWebService::SetMcpTriggered(uint64_t device_id) {
|
||
std::lock_guard<std::mutex> lock(m_McpTriggeredMutex);
|
||
m_McpTriggeredDevices.insert(device_id);
|
||
}
|
||
|
||
bool CWebService::IsMcpTriggered(uint64_t device_id) {
|
||
std::lock_guard<std::mutex> lock(m_McpTriggeredMutex);
|
||
return m_McpTriggeredDevices.find(device_id) != m_McpTriggeredDevices.end();
|
||
}
|
||
|
||
void CWebService::ClearMcpTriggered(uint64_t device_id) {
|
||
std::lock_guard<std::mutex> lock(m_McpTriggeredMutex);
|
||
m_McpTriggeredDevices.erase(device_id);
|
||
}
|
||
|
||
bool CWebService::HasActiveSession(uint64_t device_id) {
|
||
std::lock_guard<std::mutex> lock(m_ScreenContextsMutex);
|
||
return m_ScreenContexts.find(device_id) != m_ScreenContexts.end();
|
||
}
|
||
|
||
void CWebService::NotifyDeviceUpdate(uint64_t device_id, const std::string& rtt, const std::string& activeWindow) {
|
||
if (!m_bRunning || m_bStopping) return;
|
||
|
||
// Build update message
|
||
Json::Value msg;
|
||
msg["cmd"] = "device_update";
|
||
msg["id"] = std::to_string(device_id);
|
||
msg["rtt"] = rtt;
|
||
msg["activeWindow"] = activeWindow;
|
||
|
||
Json::StreamWriterBuilder builder;
|
||
builder["indentation"] = "";
|
||
std::string json_str = Json::writeString(builder, msg);
|
||
|
||
// Broadcast to all authenticated clients
|
||
std::lock_guard<std::mutex> lock(m_ClientsMutex);
|
||
for (const auto& [ws_ptr, client] : m_Clients) {
|
||
if (!client.token.empty()) {
|
||
SendText(ws_ptr, json_str);
|
||
}
|
||
}
|
||
}
|