Feature: Add live thumbnail preview column to online host list

This commit is contained in:
yuanyuanxiang
2026-05-13 15:14:51 +02:00
parent 6c32b478af
commit e762e3cbd1
9 changed files with 647 additions and 36 deletions

View File

@@ -89,6 +89,7 @@
#define TIMER_STATUSBAR_INIT 7
#define TIMER_PREVIEW_ARRIVAL 8 // 屏幕预览到达超时4 秒未收到则提示"预览不可用"
#define TIMER_PREVIEW_LOOP 9 // "播放快照"循环拉取(间隔由 LOOP_INTERVAL_MS 决定)
#define TIMER_THUMBNAIL_REFRESH 10 // 主机列表缩略图后台刷新(间隔取自 m_ThumbnailCfg
#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"
@@ -129,9 +130,12 @@ typedef struct {
int nWidth; //列表的宽度
} COLUMNSTRUCT;
const int g_Column_Count_Online = ONLINELIST_MAX; // 报表的列数
// 列表列布局:第 0 列固定为"预览"(缩略图,无数据槽),后续列对应 ONLINELIST_* 偏移 1。
// 即 listCol == 0 → 缩略图listCol >= 1 → ONLINELIST_(listCol - 1)。
const int g_Column_Count_Online = ONLINELIST_MAX + 1; // 1 缩略图 + 既有数据列
COLUMNSTRUCT g_Column_Data_Online[g_Column_Count_Online] = {
{"预览", 60 },
{"IP", 130 },
{"端口", 60 },
{"地理位置", 130 },
@@ -146,6 +150,12 @@ COLUMNSTRUCT g_Column_Data_Online[g_Column_Count_Online] = {
{"类型", 50 },
};
// 缩略图列号常量 + 列号→数据槽映射。负返回值代表"该列无数据槽"。
static const int COL_THUMBNAIL = 0;
static inline int ColumnToDataSlot(int listCol) {
return listCol <= COL_THUMBNAIL ? -1 : (listCol - 1);
}
// 用于应用程序“关于”菜单项的 CAboutDlg 对话框
const int g_Column_Count_Message = 3; // 列表的个数
@@ -833,6 +843,7 @@ BEGIN_MESSAGE_MAP(CMy2015RemoteDlg, CDialogEx)
ON_COMMAND(ID_WHAT_IS_THIS, &CMy2015RemoteDlg::OnWhatIsThis)
ON_COMMAND(ID_ONLINE_AUTHORIZE, &CMy2015RemoteDlg::OnOnlineAuthorize)
ON_NOTIFY(NM_DBLCLK, IDC_ONLINE, &CMy2015RemoteDlg::OnListClick)
ON_NOTIFY(NM_CLICK, IDC_ONLINE, &CMy2015RemoteDlg::OnListSingleClick)
ON_COMMAND(ID_ONLINE_UNAUTHORIZE, &CMy2015RemoteDlg::OnOnlineUnauthorize)
ON_COMMAND(ID_TOOL_REQUEST_AUTH, &CMy2015RemoteDlg::OnToolRequestAuth)
ON_COMMAND(ID_TOOL_INPUT_PASSWORD, &CMy2015RemoteDlg::OnToolInputPassword)
@@ -877,6 +888,7 @@ BEGIN_MESSAGE_MAP(CMy2015RemoteDlg, CDialogEx)
ON_COMMAND(ID_PARAM_ENABLE_LOG, &CMy2015RemoteDlg::OnParamEnableLog)
ON_COMMAND(ID_PARAM_PRIVACY_WALLPAPER, &CMy2015RemoteDlg::OnParamPrivacyWallpaper)
ON_COMMAND(ID_PARAM_FILE_V2, &CMy2015RemoteDlg::OnParamFileV2)
ON_COMMAND(ID_PARAM_THUMBNAIL_PREVIEW, &CMy2015RemoteDlg::OnParamThumbnailPreview)
ON_COMMAND(ID_PARAM_RUN_AS_USER, &CMy2015RemoteDlg::OnParamRunAsUser)
ON_COMMAND(ID_PROXY_PORT, &CMy2015RemoteDlg::OnProxyPort)
ON_COMMAND(ID_HOOK_WIN, &CMy2015RemoteDlg::OnHookWin)
@@ -1317,6 +1329,9 @@ VOID CMy2015RemoteDlg::InitControl()
m_CList_Message.ModifyStyle(0, LVS_SHOWSELALWAYS);
m_CList_Message.SetExtendedStyle(style);
m_CList_Message.ModifyStyle(WS_HSCROLL, 0);
// 不在这里调 ApplyThumbnailSettings —— 调用方在 LoadThumbnailSettingsFromCfg
// 之后统一 Apply避免"先用默认值 Apply 一次,再读 INI 后再 Apply 一次"的双绘)。
}
@@ -2011,6 +2026,12 @@ BOOL CMy2015RemoteDlg::OnInitDialog()
strcpy(m_settings.RequestAuthUrl, THIS_CFG.GetStr("settings", "RequestAuthUrl", BRAND_URL_REQUEST_AUTH).c_str());
strcpy(m_settings.GetPluginUrl, THIS_CFG.GetStr("settings", "GetPluginUrl", BRAND_URL_GET_PLUGIN).c_str());
m_bEnableFileV2 = THIS_CFG.GetInt("settings", "EnableFileV2", 0) != 0;
// 缩略图配置:从 [thumbnail] 节读取(独立于 MasterSettings纯主控端 UI 偏好)。
// 注意InitControl 末尾已经先调过一次 Apply用默认值那时 INI 还没读 → 状态是错的。
// 这里读完 INI 后再 Apply 一次纠正过来。
LoadThumbnailSettingsFromCfg();
ApplyThumbnailSettings();
m_runNormal = THIS_CFG.GetInt("settings", "RunNormal", 0);
CMenu* SubMenu = m_MainMenu.GetSubMenu(2);
@@ -2020,6 +2041,7 @@ BOOL CMy2015RemoteDlg::OnInitDialog()
SubMenu->CheckMenuItem(ID_PARAM_LOGIN_NOTIFY, m_needNotify ? MF_CHECKED : MF_UNCHECKED);
SubMenu->CheckMenuItem(ID_PARAM_ENABLE_LOG, m_settings.EnableLog ? MF_CHECKED : MF_UNCHECKED);
SubMenu->CheckMenuItem(ID_PARAM_FILE_V2, m_bEnableFileV2 ? MF_CHECKED : MF_UNCHECKED);
SubMenu->CheckMenuItem(ID_PARAM_THUMBNAIL_PREVIEW, m_ThumbnailCfg.Enabled ? MF_CHECKED : MF_UNCHECKED);
// 互斥逻辑:三种模式 (RunNormal: 0=服务+SYSTEM, 1=普通模式, 2=服务+User)
if (m_runNormal == 0) {
@@ -2068,7 +2090,7 @@ BOOL CMy2015RemoteDlg::OnInitDialog()
memset(&lvColumn, 0, sizeof(LVCOLUMN));
lvColumn.mask = LVCF_TEXT;
lvColumn.pszText = (char*)str.data();
m_CList_Online.SetColumn(ONLINELIST_VIDEO, &lvColumn);
m_CList_Online.SetColumn(ONLINELIST_VIDEO + 1, &lvColumn); // +1缩略图列占据列 0
timeBeginPeriod(1);
if (IsFunctionReallyHooked("user32.dll","SetTimer") || IsFunctionReallyHooked("user32.dll", "KillTimer")) {
THIS_APP->MessageBox(_TR("FUCK!!! 请勿HOOK此程序!"), _TR("提示"), MB_ICONERROR);
@@ -3066,6 +3088,9 @@ void CMy2015RemoteDlg::OnTimer(UINT_PTR nIDEvent)
if (nIDEvent == TIMER_PREVIEW_LOOP) {
TickLoopTips();
}
if (nIDEvent == TIMER_THUMBNAIL_REFRESH) {
TickThumbnailRefresh();
}
if (nIDEvent == TIMER_CLEAR_BALLOON) {
KillTimer(TIMER_CLEAR_BALLOON);
@@ -3413,7 +3438,16 @@ void CMy2015RemoteDlg::Release()
UninitFileUpload();
DeletePopupWindow(TRUE);
CloseAllLoopTips(); // 关闭所有"播放快照"循环窗口
CloseAllLoopTips(); // 关闭所有"播放快照"循环窗口
KillTimer(TIMER_THUMBNAIL_REFRESH);
ClearAllThumbnailCache(); // 释放所有缓存 HBITMAP
// 先解绑再销毁行高 ImageList避免 listview HWND 在 m_thumbRowHeightImgList
// 之后销毁的极端时序里短暂持有悬空 HIMAGELIST。
if (m_thumbRowHeightImgList.GetSafeHandle()) {
m_CList_Online.SetImageList(NULL, LVSIL_SMALL);
m_thumbRowHeightImgList.DeleteImageList();
m_thumbRowHeightApplied = 0;
}
isClosed = TRUE;
ShowWindow(SW_HIDE);
@@ -3480,6 +3514,9 @@ int CALLBACK CMy2015RemoteDlg::CompareFunction(LPARAM lParam1, LPARAM lParam2, L
void CMy2015RemoteDlg::SortByColumn(int nColumn)
{
// 缩略图列点击表头不参与排序 —— 没有可比较的数据维度,避免误触发
if (ColumnToDataSlot(nColumn) < 0) return;
static int m_nSortColumn = 0;
static bool m_bSortAscending = false;
if (nColumn == m_nSortColumn) {
@@ -3491,14 +3528,14 @@ void CMy2015RemoteDlg::SortByColumn(int nColumn)
m_bSortAscending = true;
}
// 虚拟列表:对数据源进行排序
// 虚拟列表:对数据源进行排序(列号映射到底层数据槽)
EnterCriticalSection(&m_cs);
int col = m_nSortColumn;
int slot = ColumnToDataSlot(m_nSortColumn);
bool asc = m_bSortAscending;
std::sort(m_HostList.begin(), m_HostList.end(),
[col, asc](context* a, context* b) {
CString s1 = a ? a->GetClientData(col) : "";
CString s2 = b ? b->GetClientData(col) : "";
[slot, asc](context* a, context* b) {
CString s1 = a ? a->GetClientData(slot) : "";
CString s2 = b ? b->GetClientData(slot) : "";
int result = s1.Compare(s2);
return asc ? (result < 0) : (result > 0);
});
@@ -3564,19 +3601,22 @@ void CMy2015RemoteDlg::OnGetDispInfo(NMHDR* pNMHDR, LRESULT* pResult)
// 提供文本数据
if (pItem->mask & LVIF_TEXT) {
CString text;
int nCol = pItem->iSubItem;
// 备注列特殊处理
if (nCol == ONLINELIST_COMPUTER_NAME) {
CString note = m_ClientMap->GetClientMapData(ctx->GetClientID(), MAP_NOTE);
text = !note.IsEmpty() ? note : ctx->GetClientData(nCol);
int slot = ColumnToDataSlot(nCol);
if (slot < 0) {
// 缩略图列:自绘渲染,文本置空
if (pItem->pszText && pItem->cchTextMax > 0) pItem->pszText[0] = '\0';
} else {
text = ctx->GetClientData(nCol);
if (text.IsEmpty()) text = "?";
CString text;
if (slot == ONLINELIST_COMPUTER_NAME) {
CString note = m_ClientMap->GetClientMapData(ctx->GetClientID(), MAP_NOTE);
text = !note.IsEmpty() ? note : ctx->GetClientData(slot);
} else {
text = ctx->GetClientData(slot);
if (text.IsEmpty()) text = "?";
}
lstrcpyn(pItem->pszText, text, pItem->cchTextMax);
}
lstrcpyn(pItem->pszText, text, pItem->cchTextMax);
}
*pResult = 0;
@@ -3611,6 +3651,15 @@ void CMy2015RemoteDlg::OnGetDispInfoW(NMHDR* pNMHDR, LRESULT* pResult)
if ((pItem->mask & LVIF_TEXT) && pItem->pszText && pItem->cchTextMax > 0) {
std::wstring wtext;
int nCol = pItem->iSubItem;
int slot = ColumnToDataSlot(nCol);
if (slot < 0) {
// 缩略图列:自绘渲染,文本置空
pItem->pszText[0] = L'\0';
*pResult = 0;
return;
}
// 下面的逻辑沿用旧版,但用 slot 替代 nCol 访问数据槽
nCol = slot;
if (nCol == ONLINELIST_LOGINTIME) {
// "活动窗口"列:心跳到达后用旁路表里的宽字符串(已正确解码);
@@ -4371,7 +4420,7 @@ void CMy2015RemoteDlg::OnMainSet()
CLock L(m_cs);
m_settings.ReportInterval = m;
m_settings.DetectSoftware = n;
m_CList_Online.SetColumn(ONLINELIST_VIDEO, &lvColumn);
m_CList_Online.SetColumn(ONLINELIST_VIDEO + 1, &lvColumn); // +1缩略图列占据列 0
SendMasterSettings(nullptr, m_settings);
}
@@ -5742,6 +5791,11 @@ LRESULT CMy2015RemoteDlg::OnUserToOnlineList(WPARAM wParam, LPARAM lParam)
AddList(strIP,strAddr,strPCName,strOS,strCPU,strVideo,strPing,LoginInfor->moduleVersion,LoginInfor->szStartTime, v, ContextObject);
delete LoginInfor;
// 缩略图调度"急播":新上线主机立刻入队,下次 tick 就发请求(含三道门判定)
if (m_ThumbnailCfg.Enabled) {
m_ThumbNextDueTick[ContextObject->GetClientID()] = ::GetTickCount();
}
// 执行主机上线触发器
ExecuteOnlineTrigger(ContextObject);
@@ -5906,6 +5960,10 @@ LRESULT CMy2015RemoteDlg::OnUserOfflineMsg(WPARAM wParam, LPARAM lParam)
// 关闭对应客户端的循环快照浮窗如有。CloseLoopTip 内部 find 找不到会静默返回。
if (info->clientId != 0) {
CloseLoopTip(info->clientId);
// 清理缩略图相关状态(缓存 + 调度 + 在飞标记)。主机已不在列表,重绘不必要。
ClearThumbnailCacheEntry(info->clientId);
m_ThumbNextDueTick.erase(info->clientId);
m_ThumbnailPending.erase(info->clientId);
}
// Close child dialog window
@@ -7601,6 +7659,13 @@ void CMy2015RemoteDlg::OnListClick(NMHDR* pNMHDR, LRESULT* pResult)
LPNMITEMACTIVATE pNMItem = (LPNMITEMACTIVATE)pNMHDR;
int iItem = pNMItem->iItem;
// 双击命中缩略图列:单击已触发 OnListSingleClick 打开循环窗口,这里不再叠加单发 tooltip
// 避免两个窗口同时出现。命中其他列时保持原有"双击 → 单发预览"行为。
if (pNMItem->iSubItem == COL_THUMBNAIL && m_ThumbnailCfg.Enabled) {
*pResult = 0;
return;
}
CLock lock(m_cs);
context* ctx = GetContextByListIndex(iItem);
if (ctx) {
@@ -7709,6 +7774,47 @@ void CMy2015RemoteDlg::OnListClick(NMHDR* pNMHDR, LRESULT* pResult)
*pResult = 0;
}
// 单击列表:仅对缩略图列起效 —— 打开循环监视窗口(等价于右键菜单"播放快照")。
// 其余列保持系统默认(选中),不抢用户体验。
void CMy2015RemoteDlg::OnListSingleClick(NMHDR* pNMHDR, LRESULT* pResult)
{
*pResult = 0;
if (!m_ThumbnailCfg.Enabled) return;
LPNMITEMACTIVATE p = (LPNMITEMACTIVATE)pNMHDR;
if (p->iItem < 0) return;
if (p->iSubItem != COL_THUMBNAIL) return;
context* ctx = nullptr;
uint64_t cid = 0;
{
CLock L(m_cs);
ctx = GetContextByListIndex(p->iItem);
if (ctx) cid = ctx->GetClientID();
}
if (!ctx || cid == 0) return;
// 已经开着循环窗 → 不重复开,让现有窗口前置
auto it = m_LoopTips.find(cid);
if (it != m_LoopTips.end() && it->second.tip && ::IsWindow(it->second.tip->GetSafeHwnd())) {
it->second.tip->ShowWindow(SW_SHOWNORMAL);
it->second.tip->BringWindowToTop();
return;
}
// 没有缓存的缩略图(即单元格显示的是 "…" 或 "N/A")→ 不弹窗。
// 用户语义:能看到画面才允许点开放大;占位态下点击不做任何动作。
auto cacheIt = m_HostThumbnails.find(cid);
bool hasBmp = (cacheIt != m_HostThumbnails.end() && cacheIt->second.bmp);
if (!hasBmp) return;
// 锚点:点击位置附近(列表客户区坐标 → 屏幕坐标)
CPoint anchor = p->ptAction;
m_CList_Online.ClientToScreen(&anchor);
anchor.Offset(20, 10);
OpenLoopTip(ctx, anchor);
}
// 屏幕预览参数挑选:复用既有 GetTargetQualityLevel + GetScreenPreviewProfile
// RTT 阈值表与屏幕共享共用FRP 模式阈值更宽松超清档Ultra/High+ 4K/超宽屏
// 时按 min(screenWidth/4, 1280) 自适应放大,避免高分屏被压成无信息缩略图
@@ -7783,14 +7889,24 @@ LRESULT CMy2015RemoteDlg::OnPreviewResponse(WPARAM /*wParam*/, LPARAM lParam)
// ---------- 路径 1循环快照按 clientId 命中)----------
auto it = m_LoopTips.find(msg->clientId);
if (it != m_LoopTips.end()) {
if (it != m_LoopTips.end() &&
it->second.expectedReqId != 0 && hdr->reqId == it->second.expectedReqId)
{
// reqId 不命中时不直接 return让流程继续走到缩略图分支 —— 兼容"刚打开循环窗
// 时还有上一笔缩略图请求在飞"的情况,防止 m_ThumbnailPending 条目被永久挂死。
LoopTipEntry& entry = it->second;
// 校验 reqId每条目独立计数过滤过期/串扰响应
if (entry.expectedReqId == 0 || hdr->reqId != entry.expectedReqId) return 0;
if (!entry.tip || !::IsWindow(entry.tip->GetSafeHwnd())) return 0;
if (dataOk) {
entry.tip->SetImageFromJpeg(jpeg, hdr->bytes);
// 顺手刷新列表缩略图(开循环窗的主机不再单独发请求,省一半流量)
if (m_ThumbnailCfg.Enabled) {
CacheThumbnail(msg->clientId, jpeg, hdr->bytes);
InvalidateHostRow(msg->clientId);
// 重置该主机的 due 时间,避免循环窗关掉后立刻又有缩略图请求
DWORD now = ::GetTickCount();
m_ThumbNextDueTick[msg->clientId] = now + (DWORD)m_ThumbnailCfg.RefreshIntervalSec * 1000;
}
} else {
// 单帧失败不直接关窗,标"不可用",下一轮定时器再尝试
entry.tip->MarkPreviewUnavailable();
@@ -7798,7 +7914,18 @@ LRESULT CMy2015RemoteDlg::OnPreviewResponse(WPARAM /*wParam*/, LPARAM lParam)
return 0;
}
// ---------- 路径 2既有单发流程 ----------
// ---------- 路径 2列表缩略图(按 clientId 命中 in-flight 集合)----------
if (m_ThumbnailPending.count(msg->clientId)) {
m_ThumbnailPending.erase(msg->clientId);
if (dataOk) {
CacheThumbnail(msg->clientId, jpeg, hdr->bytes);
InvalidateHostRow(msg->clientId);
}
// 数据非 OK 也不重试,等下个周期;保留旧缩略(如有)
return 0;
}
// ---------- 路径 3既有单发流程 ----------
// 序号校验:和当前期待的 reqId 不符 → 过期响应,丢弃
if (m_PreviewReqId == 0 || hdr->reqId != m_PreviewReqId) return 0;
if (!m_pPreviewTip || !::IsWindow(m_pPreviewTip->GetSafeHwnd())) return 0;
@@ -7991,6 +8118,302 @@ LRESULT CMy2015RemoteDlg::OnLoopTipDestroyed(WPARAM /*wParam*/, LPARAM lParam)
return 0;
}
// ========== 主机列表缩略图:实现 ==========
//
// 线程模型:与循环模式一致 —— m_HostThumbnails / m_ThumbNextDueTick /
// m_ThumbnailPending 全部仅 UI 线程访问。响应 PostMessage 经消息泵串行化派发。
//
// 缓存策略:响应到达即解码 + 等比缩放到 ThumbWidth × ThumbHeight 的 HBITMAP
// 这样自绘时只需 BitBlt 一次,无 GDI+ 开销。设置变更(尺寸)会清空所有 HBITMAP
// 下一轮请求自然按新尺寸重建。
void CMy2015RemoteDlg::LoadThumbnailSettingsFromCfg()
{
m_ThumbnailCfg.Enabled = THIS_CFG.GetInt("thumbnail", "Enabled", 1) != 0;
m_ThumbnailCfg.RefreshIntervalSec = THIS_CFG.GetInt("thumbnail", "RefreshIntervalSec", 30);
m_ThumbnailCfg.ThumbWidth = THIS_CFG.GetInt("thumbnail", "ThumbWidth", 60);
m_ThumbnailCfg.NetReqWidth = THIS_CFG.GetInt("thumbnail", "NetReqWidth", 120);
m_ThumbnailCfg.JpegQuality = THIS_CFG.GetInt("thumbnail", "JpegQuality", 60);
// Clamp 到安全范围(防 INI 被手工编错)
if (m_ThumbnailCfg.RefreshIntervalSec < 5) m_ThumbnailCfg.RefreshIntervalSec = 5;
if (m_ThumbnailCfg.RefreshIntervalSec > 300) m_ThumbnailCfg.RefreshIntervalSec = 300;
if (m_ThumbnailCfg.ThumbWidth < 40) m_ThumbnailCfg.ThumbWidth = 40;
if (m_ThumbnailCfg.ThumbWidth > 120) m_ThumbnailCfg.ThumbWidth = 120;
if (m_ThumbnailCfg.NetReqWidth < m_ThumbnailCfg.ThumbWidth)
m_ThumbnailCfg.NetReqWidth = m_ThumbnailCfg.ThumbWidth * 2;
if (m_ThumbnailCfg.NetReqWidth > 240) m_ThumbnailCfg.NetReqWidth = 240;
if (m_ThumbnailCfg.JpegQuality < 30) m_ThumbnailCfg.JpegQuality = 30;
if (m_ThumbnailCfg.JpegQuality > 85) m_ThumbnailCfg.JpegQuality = 85;
}
void CMy2015RemoteDlg::SaveThumbnailSettingsToCfg() const
{
THIS_CFG.SetInt("thumbnail", "Enabled", m_ThumbnailCfg.Enabled ? 1 : 0);
THIS_CFG.SetInt("thumbnail", "RefreshIntervalSec", m_ThumbnailCfg.RefreshIntervalSec);
THIS_CFG.SetInt("thumbnail", "ThumbWidth", m_ThumbnailCfg.ThumbWidth);
THIS_CFG.SetInt("thumbnail", "NetReqWidth", m_ThumbnailCfg.NetReqWidth);
THIS_CFG.SetInt("thumbnail", "JpegQuality", m_ThumbnailCfg.JpegQuality);
}
void CMy2015RemoteDlg::ApplyThumbnailSettings()
{
// 任何已缓存的 HBITMAP 都是按"旧 ThumbWidth"渲染的,尺寸不再可信 —— 一律清空。
// 下一次响应到达时按新尺寸重建。带来的代价是切换尺寸后会有一个空窗期。
ClearAllThumbnailCache();
// 列宽 + 行高 跟着 ThumbWidth 走(高度 = 9/16 比例 + 上下各 2px 留白)
int thumbW = m_ThumbnailCfg.ThumbWidth;
int thumbH = thumbW * 9 / 16;
int rowH = thumbH + 4;
if (rowH < 20) rowH = 20;
if (m_CList_Online.GetSafeHwnd()) {
// 用 SetColumnVisible 控制 col 0 的存在感 —— 仅 SetColumn(width=0) 不够:
// CListCtrlEx::AdjustColumnWidths 会在窗口拉伸时按 m_Columns[i].Percent
// 重新分配宽度,让"已关闭"的列 0 再次得到约 5% 的可见宽度。
// SetColumnVisible 会把 m_Columns[0].Visible 置为 falseAdjustColumnWidths
// 看到不可见列直接给 0 且不计入 visiblePercent把空间让给其它列。
m_CList_Online.SetColumnVisible(COL_THUMBNAIL, m_ThumbnailCfg.Enabled ? TRUE : FALSE);
if (m_ThumbnailCfg.Enabled) {
// 启用时,把宽度强制设回我们想要的 thumbW + 10
// SetColumnVisible 内部 AdjustColumnWidths 是按 percent 算出来的可能不一样
LVCOLUMN col = {};
col.mask = LVCF_WIDTH;
col.cx = thumbW + 10;
m_CList_Online.SetColumn(COL_THUMBNAIL, &col);
}
// 行高 ImageList 管理:
// - 开启缩略图 → 装 1×rowH 的 dummy ImageListrowH ≈ 38 px 撑高行)
// - 关闭缩略图 → 装 1×1 的 dummy ImageList行高 = max(字体高, 1) ≈ 字体原生高)
// 之所以不是 SetImageList(NULL) "完全拆掉"Win32 ListView 在 ImageList 由 X→NULL
// 时**不重测行高**(行高被锁死在最后一次有 ImageList 时的值WM_SETFONT 等踢一脚
// 的招数也都不可靠。统一始终装一张 ImageList只让"高度数值"变化 → ListView 每次都
// 重测行高。1×1 的 ImageList 用户感知不到(视觉上等同于无 ImageList
int targetRowH = m_ThumbnailCfg.Enabled ? rowH : 1;
if (targetRowH != m_thumbRowHeightApplied) {
m_CList_Online.SetImageList(NULL, LVSIL_SMALL);
if (m_thumbRowHeightImgList.GetSafeHandle()) {
m_thumbRowHeightImgList.DeleteImageList();
}
m_thumbRowHeightImgList.Create(1, targetRowH, ILC_COLOR | ILC_MASK, 1, 1);
m_CList_Online.SetImageList(&m_thumbRowHeightImgList, LVSIL_SMALL);
m_thumbRowHeightApplied = targetRowH;
}
}
// 计时器:启用时按 1 秒固定 tick 节奏轮询per-host 各自的 nextDueTick 决定真发送)
KillTimer(TIMER_THUMBNAIL_REFRESH);
if (m_ThumbnailCfg.Enabled) {
SetTimer(TIMER_THUMBNAIL_REFRESH, 1000, nullptr);
} else {
// 关闭时连"在飞"和"待发"也清掉,避免之后误派发
m_ThumbnailPending.clear();
m_ThumbNextDueTick.clear();
}
// 触发一次列表重绘,让列宽变化生效
if (m_CList_Online.GetSafeHwnd()) {
m_CList_Online.Invalidate(FALSE);
}
}
void CMy2015RemoteDlg::ClearThumbnailCacheEntry(uint64_t clientID)
{
auto it = m_HostThumbnails.find(clientID);
if (it == m_HostThumbnails.end()) return;
if (it->second.bmp) ::DeleteObject(it->second.bmp);
m_HostThumbnails.erase(it);
}
void CMy2015RemoteDlg::ClearAllThumbnailCache()
{
for (auto& kv : m_HostThumbnails) {
if (kv.second.bmp) ::DeleteObject(kv.second.bmp);
}
m_HostThumbnails.clear();
}
void CMy2015RemoteDlg::InvalidateHostRow(uint64_t clientID)
{
if (!m_CList_Online.GetSafeHwnd()) return;
// 查 clientID -> m_HostList 索引 -> 当前过滤后的可见行
CLock L(m_cs);
auto it = m_ClientIndex.find(clientID);
if (it == m_ClientIndex.end()) return;
size_t hostIdx = it->second;
for (size_t fi = 0; fi < m_FilteredIndices.size(); ++fi) {
if (m_FilteredIndices[fi] == hostIdx) {
m_CList_Online.RedrawItems((int)fi, (int)fi);
break;
}
}
}
// 把收到的 JPEG 解码 + 等比缩到 ThumbWidth × thumbH存为 24bpp DIB。
// 失败时静默丢弃(保留旧缓存或保持空),下一轮重试。
void CMy2015RemoteDlg::CacheThumbnail(uint64_t clientID, const BYTE* jpeg, size_t bytes)
{
if (!jpeg || bytes == 0) return;
// GDI+ 解码(与 CPreviewTipWnd::SetImageFromJpeg 同模式)
HGLOBAL hMem = ::GlobalAlloc(GMEM_MOVEABLE, bytes);
if (!hMem) return;
void* p = ::GlobalLock(hMem);
if (!p) { ::GlobalFree(hMem); return; }
memcpy(p, jpeg, bytes);
::GlobalUnlock(hMem);
IStream* stream = nullptr;
if (FAILED(::CreateStreamOnHGlobal(hMem, TRUE, &stream))) {
::GlobalFree(hMem);
return;
}
// 注意:本 TU 顶部 `#define new DEBUG_NEW`MFC 模式),而 Gdiplus::GdiplusBase::operator new
// 不接受 DEBUG_NEW 的 (size, file, line) 三参形式 → 局部撤宏,构造完再恢复。
#pragma push_macro("new")
#undef new
std::unique_ptr<Gdiplus::Bitmap> bmp(new Gdiplus::Bitmap(stream, FALSE));
#pragma pop_macro("new")
stream->Release(); // 接管 hMem 的释放
if (!bmp || bmp->GetLastStatus() != Gdiplus::Ok) return;
int dstW = m_ThumbnailCfg.ThumbWidth;
int dstH = dstW * 9 / 16;
if (dstW <= 0 || dstH <= 0) return;
// 渲染到一张 24bpp HBITMAPDC 兼容 + DIB 友好)
HDC hScreen = ::GetDC(nullptr);
HDC hMemDC = ::CreateCompatibleDC(hScreen);
HBITMAP hbm = ::CreateCompatibleBitmap(hScreen, dstW, dstH);
::ReleaseDC(nullptr, hScreen);
if (!hMemDC || !hbm) {
if (hMemDC) ::DeleteDC(hMemDC);
if (hbm) ::DeleteObject(hbm);
return;
}
HBITMAP hbmOld = (HBITMAP)::SelectObject(hMemDC, hbm);
// 底色:让等比 letterbox 留白显眼一点(深灰)
RECT rcAll = { 0, 0, dstW, dstH };
HBRUSH hBrush = ::CreateSolidBrush(RGB(50, 50, 50));
::FillRect(hMemDC, &rcAll, hBrush);
::DeleteObject(hBrush);
// 等比缩放图像居中放置letterbox
{
Gdiplus::Graphics g(hMemDC);
g.SetInterpolationMode(Gdiplus::InterpolationModeHighQualityBicubic);
g.SetSmoothingMode(Gdiplus::SmoothingModeHighQuality);
double iw = (double)bmp->GetWidth();
double ih = (double)bmp->GetHeight();
double sx = dstW / iw;
double sy = dstH / ih;
double s = sx < sy ? sx : sy;
int dw = (int)(iw * s + 0.5);
int dh = (int)(ih * s + 0.5);
int dx = (dstW - dw) / 2;
int dy = (dstH - dh) / 2;
g.DrawImage(bmp.get(), dx, dy, dw, dh);
}
::SelectObject(hMemDC, hbmOld);
::DeleteDC(hMemDC);
// 替换/插入缓存
ClearThumbnailCacheEntry(clientID);
ThumbCacheEntry e;
e.bmp = hbm; e.w = dstW; e.h = dstH;
m_HostThumbnails[clientID] = e;
}
void CMy2015RemoteDlg::SendThumbnailRequest(context* ctx)
{
if (!ctx) return;
if (++m_ThumbnailReqId == 0) m_ThumbnailReqId = 1;
SendScreenPreviewRequest(ctx, m_ThumbnailReqId,
(WORD)m_ThumbnailCfg.NetReqWidth,
(BYTE)m_ThumbnailCfg.JpegQuality);
m_ThumbnailPending.insert(ctx->GetClientID());
}
// 主循环(每秒 tick 一次):扫描在线主机,触发到期 + 活跃的请求
void CMy2015RemoteDlg::TickThumbnailRefresh()
{
if (!m_ThumbnailCfg.Enabled) return;
DWORD now = ::GetTickCount();
DWORD intervalMs = (DWORD)m_ThumbnailCfg.RefreshIntervalSec * 1000;
// 拷一份 clientId 列表,避免在 m_cs 内长时间持锁
std::vector<uint64_t> ids;
std::vector<uint64_t> loopOpen;
{
CLock L(m_cs);
ids.reserve(m_HostList.size());
for (auto* c : m_HostList) {
if (c) ids.push_back(c->GetClientID());
}
}
// 同步:开着循环窗口的主机本轮跳过 —— 循环窗的响应会顺手填缓存OnPreviewResponse
for (const auto& kv : m_LoopTips) loopOpen.push_back(kv.first);
std::set<uint64_t> loopSet(loopOpen.begin(), loopOpen.end());
// 限速:每跳最多发 8 台请求,避免初次铺满时瞬时拥挤
const int kMaxPerTick = 8;
int sent = 0;
for (uint64_t cid : ids) {
if (sent >= kMaxPerTick) break;
// 已在飞,不重复
if (m_ThumbnailPending.count(cid)) continue;
// 开着循环窗,跳过
if (loopSet.count(cid)) continue;
// 到期判定(首次出现时也算到期:插入 due=now
auto itDue = m_ThumbNextDueTick.find(cid);
if (itDue == m_ThumbNextDueTick.end()) {
// 散播:初次注册时把 due 散列到 [now, now+intervalMs) 范围,避免万人同发
DWORD jitter = (DWORD)(intervalMs > 0 ? (cid % intervalMs) : 0);
m_ThumbNextDueTick[cid] = now + jitter;
continue;
}
if ((LONG)(itDue->second - now) > 0) continue; // 未到期
// 三道门:支持能力位 + 非 Locked/Inactive
bool ok = false;
context* ctx = nullptr;
{
CLock L(m_cs);
ctx = FindHostNoLock(cid);
if (ctx && ctx->SupportsScreenPreview()) {
CString a = ctx->GetClientData(ONLINELIST_LOGINTIME);
bool isIdleOrLocked =
a.Find(_T("Locked")) == 0 || a.Find(_T("Inactive")) == 0;
if (!isIdleOrLocked) ok = true;
}
}
if (!ok) {
// 不活跃 / 不支持:仍把 due 推到下一周期,避免每 tick 都判一次
itDue->second = now + intervalMs;
continue;
}
// 发送(再次在锁内拿 ctx因为 ok 判定后到此处可能被下线)
{
CLock L(m_cs);
ctx = FindHostNoLock(cid);
if (ctx) {
SendThumbnailRequest(ctx);
++sent;
}
}
itDue->second = now + intervalMs;
}
}
void CMy2015RemoteDlg::OnOnlineUnauthorize()
{
@@ -8390,11 +8813,135 @@ void CMy2015RemoteDlg::OnNMCustomdrawOnline(NMHDR* pNMHDR, LRESULT* pResult)
}
}
LeaveCriticalSection(&m_cs);
if (!ctx) return;
int r = m_ClientMap->GetClientMapInteger(ctx->GetClientID(), MAP_LEVEL);
if (r >= 1) pLVCD->clrText = RGB(0, 0, 255); // 字体蓝
if (r >= 2) pLVCD->clrText = RGB(255, 0, 0); // 字体红
if (r >= 3) pLVCD->clrTextBk = RGB(255, 160, 160); // 背景红
if (ctx) {
int r = m_ClientMap->GetClientMapInteger(ctx->GetClientID(), MAP_LEVEL);
if (r >= 1) pLVCD->clrText = RGB(0, 0, 255); // 字体蓝
if (r >= 2) pLVCD->clrText = RGB(255, 0, 0); // 字体红
if (r >= 3) pLVCD->clrTextBk = RGB(255, 160, 160); // 背景红
}
// 启用缩略图时:
// 1) 请求 NOTIFYSUBITEMDRAW —— 拿到对每个 subitem 的回调权
// 在 col 0 上返回 CDRF_SKIPDEFAULT 让系统**不画** col 0消除空白闪烁源头
// 2) 请求 NOTIFYPOSTPAINT —— 在 POSTPAINT 阶段用格内 DB 把缩略图画进去
// 双管齐下:如果 SUBITEM 在 col 0 不触发POSTPAINT 仍能兜底(退回旧行为);
// 如果 SUBITEM 在 col 0 触发了,系统从此不再写 col 0闪烁源被掐死。
*pResult = m_ThumbnailCfg.Enabled
? (CDRF_NOTIFYSUBITEMDRAW | CDRF_NOTIFYPOSTPAINT)
: 0;
return;
}
case CDDS_ITEMPREPAINT | CDDS_SUBITEM: {
if (!m_ThumbnailCfg.Enabled) { *pResult = CDRF_DODEFAULT; return; }
// 仅当系统真的回调了 col 0 的 subitem 阶段时生效。CDRF_SKIPDEFAULT 让系统
// 跳过自己对 col 0 的所有绘制label / 选中条延伸 / 焦点框等),后续完全
// 由 POSTPAINT 接管,从而消除"空白 col 0 一闪"的视觉污染。
if (pLVCD->iSubItem == COL_THUMBNAIL) {
*pResult = CDRF_SKIPDEFAULT;
return;
}
*pResult = CDRF_DODEFAULT;
return;
}
case CDDS_ITEMPOSTPAINT: {
if (!m_ThumbnailCfg.Enabled) return;
int nRow = static_cast<int>(pLVCD->nmcd.dwItemSpec);
uint64_t clientID = 0;
bool supportsPreview = false; // 区分"暂未到帧"和"协议不支持"两种占位
{
CLock L(m_cs);
if (nRow >= 0 && nRow < (int)m_FilteredIndices.size()) {
size_t realIdx = m_FilteredIndices[nRow];
if (realIdx < m_HostList.size() && m_HostList[realIdx]) {
clientID = m_HostList[realIdx]->GetClientID();
supportsPreview = m_HostList[realIdx]->SupportsScreenPreview();
}
}
}
if (clientID == 0) return;
bool isSelected = (m_CList_Online.GetItemState(nRow, LVIS_SELECTED) & LVIS_SELECTED) != 0;
// 用 GetItemRect + GetColumnWidth(0) 算 col 0 的矩形 —— 比 pLVCD->nmcd.rc 更
// 可靠:后者在 POSTPAINT 阶段对虚拟列表有时返回空 / 行片段。
RECT rcRow = {};
if (!m_CList_Online.GetItemRect(nRow, &rcRow, LVIR_BOUNDS)) return;
int col0w = m_CList_Online.GetColumnWidth(0);
if (col0w <= 0) return;
int cellW = col0w;
int cellH = rcRow.bottom - rcRow.top;
if (cellW <= 0 || cellH <= 0) return;
RECT rcCell = { rcRow.left, rcRow.top, rcRow.left + cellW, rcRow.bottom };
HDC hdcDst = pLVCD->nmcd.hdc;
// 格内双缓冲:把整格(底色 + 占位 / 缩略图)全部画到离屏 HBITMAP 上,
// 最后一次 BitBlt 落地,保证从用户视角看 col 0 只有"旧内容 → 新内容"两种态。
// 解决 LVS_OWNERDATA + LVS_EX_DOUBLEBUFFER + CDDS_ITEMPOSTPAINT 这个组合下
// POSTPAINT 偶发绕开 back buffer 写屏导致的占位"一闪"。
HDC hMemDC = ::CreateCompatibleDC(hdcDst);
HBITMAP hbmCell = ::CreateCompatibleBitmap(hdcDst, cellW, cellH);
if (!hMemDC || !hbmCell) {
if (hMemDC) ::DeleteDC(hMemDC);
if (hbmCell) ::DeleteObject(hbmCell);
return;
}
HBITMAP hbmOld = (HBITMAP)::SelectObject(hMemDC, hbmCell);
// 全部画在 (0,0)-(cellW,cellH) 局部坐标
RECT rcLocal = { 0, 0, cellW, cellH };
HBRUSH hBg = ::GetSysColorBrush(isSelected ? COLOR_HIGHLIGHT : COLOR_WINDOW);
::FillRect(hMemDC, &rcLocal, hBg);
auto it = m_HostThumbnails.find(clientID);
bool hasBmp = (it != m_HostThumbnails.end() && it->second.bmp);
if (!hasBmp) {
// 占位文本直接浮在 cell 底色上,不再画内嵌灰框 —— 与表格背景融合。
// - 支持预览但还没收到帧 → "…"
// - 不支持预览的老客户端 → "N/A"
// - 选中态:用系统 HIGHLIGHTTEXT 保证白底/蓝底下都清晰可读
HFONT hListFont = (HFONT)m_CList_Online.SendMessage(WM_GETFONT, 0, 0);
HFONT hOldFont = hListFont ? (HFONT)::SelectObject(hMemDC, hListFont) : nullptr;
::SetBkMode(hMemDC, TRANSPARENT);
::SetTextColor(hMemDC, ::GetSysColor(
isSelected ? COLOR_HIGHLIGHTTEXT : COLOR_WINDOWTEXT));
const wchar_t* placeholder = supportsPreview ? L"" : L"N/A";
::DrawTextW(hMemDC, placeholder, -1, &rcLocal,
DT_CENTER | DT_VCENTER | DT_SINGLELINE);
if (hOldFont) ::SelectObject(hMemDC, hOldFont);
} else {
const ThumbCacheEntry& te = it->second;
int maxW = cellW - 2, maxH = cellH - 2;
if (maxW < 4) maxW = 4;
if (maxH < 4) maxH = 4;
double sx = (double)maxW / te.w;
double sy = (double)maxH / te.h;
double s = sx < sy ? sx : sy;
int dw = (int)(te.w * s + 0.5);
int dh = (int)(te.h * s + 0.5);
int dx = (cellW - dw) / 2;
int dy = (cellH - dh) / 2;
HDC hThumbDC = ::CreateCompatibleDC(hMemDC);
HBITMAP hThumbOld = (HBITMAP)::SelectObject(hThumbDC, te.bmp);
::SetStretchBltMode(hMemDC, HALFTONE);
::SetBrushOrgEx(hMemDC, 0, 0, nullptr);
::StretchBlt(hMemDC, dx, dy, dw, dh, hThumbDC, 0, 0, te.w, te.h, SRCCOPY);
::SelectObject(hThumbDC, hThumbOld);
::DeleteDC(hThumbDC);
}
// 一次性原子刷屏
::BitBlt(hdcDst, rcCell.left, rcCell.top, cellW, cellH, hMemDC, 0, 0, SRCCOPY);
::SelectObject(hMemDC, hbmOld);
::DeleteObject(hbmCell);
::DeleteDC(hMemDC);
*pResult = 0;
return;
}
}
}
@@ -9434,10 +9981,22 @@ void CMy2015RemoteDlg::OnParamFileV2()
SubMenu->CheckMenuItem(ID_PARAM_FILE_V2, m_bEnableFileV2 ? MF_CHECKED : MF_UNCHECKED);
THIS_CFG.SetInt("settings", "EnableFileV2", m_bEnableFileV2 ? 1 : 0);
Mprintf("文件传输V2: %s\n", m_bEnableFileV2 ? "启用" : "禁用");
MessageBoxA(m_bEnableFileV2 ? _TR("已启用文件传输协议V2支持断点续传和C2C传输。") : _TR("已关闭文件传输协议V2。"),
MessageBoxA(m_bEnableFileV2 ? _TR("已启用文件传输协议V2支持断点续传和C2C传输。") : _TR("已关闭文件传输协议V2。"),
_TR("提示"), MB_ICONINFORMATION);
}
void CMy2015RemoteDlg::OnParamThumbnailPreview()
{
m_ThumbnailCfg.Enabled = !m_ThumbnailCfg.Enabled;
CMenu* SubMenu = m_MainMenu.GetSubMenu(2);
if (SubMenu)
SubMenu->CheckMenuItem(ID_PARAM_THUMBNAIL_PREVIEW,
m_ThumbnailCfg.Enabled ? MF_CHECKED : MF_UNCHECKED);
SaveThumbnailSettingsToCfg();
ApplyThumbnailSettings(); // 内部处理列宽/行高/定时器/缓存切换
Mprintf("主机列表预览图: %s\n", m_ThumbnailCfg.Enabled ? "启用" : "禁用");
}
void CMy2015RemoteDlg::OnParamRunAsUser()
{
CMenu* SubMenu = m_MainMenu.GetSubMenu(2);