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

@@ -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<BYTE>(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();
// 项目是 MBCSCP_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<BYTE>*(含完整响应包:[Header|JPEG]),处理完必须 delete。
// 校验思路reqId 由对话框单调递增、每次双击重置 + 同时只有 1 个 in-flight tip足以
// 过滤过期/乱串响应,无需再叠加 clientID 校验。
LRESULT CMy2015RemoteDlg::OnPreviewResponse(WPARAM /*wParam*/, LPARAM lParam)
{
auto* pkt = reinterpret_cast<std::vector<BYTE>*>(lParam);
if (!pkt) return 0;
std::unique_ptr<std::vector<BYTE>> guard(pkt);
if (pkt->size() < sizeof(ScreenPreviewRspHeader)) return 0;
const ScreenPreviewRspHeader* hdr = reinterpret_cast<const ScreenPreviewRspHeader*>(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()
{