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) <noreply@anthropic.com>
This commit is contained in:
yuanyuanxiang
2026-05-07 18:17:28 +02:00
parent 70a6b0128e
commit 566f5b8d42
15 changed files with 785 additions and 22 deletions

View File

@@ -0,0 +1,243 @@
// PreviewTipWnd.cpp
#include "stdafx.h"
#include "PreviewTipWnd.h"
#include <objidl.h> // IUnknown / IStream — gdiplus.h 依赖它们已声明
#include <gdiplus.h>
#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<Bitmap> 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);
}