Feature: Add "Play Snapshot" loop preview windows for online hosts

This commit is contained in:
yuanyuanxiang
2026-05-13 13:05:31 +02:00
parent b813d94486
commit 6c32b478af
13 changed files with 603 additions and 50 deletions

Binary file not shown.

View File

@@ -88,6 +88,7 @@
#define TIMER_STATUSBAR_UPDATE 6 #define TIMER_STATUSBAR_UPDATE 6
#define TIMER_STATUSBAR_INIT 7 #define TIMER_STATUSBAR_INIT 7
#define TIMER_PREVIEW_ARRIVAL 8 // 屏幕预览到达超时4 秒未收到则提示"预览不可用" #define TIMER_PREVIEW_ARRIVAL 8 // 屏幕预览到达超时4 秒未收到则提示"预览不可用"
#define TIMER_PREVIEW_LOOP 9 // "播放快照"循环拉取(间隔由 LOOP_INTERVAL_MS 决定)
#define TODO_NOTICE MessageBoxL("This feature has not been implemented!\nPlease contact: 962914132@qq.com", "提示", MB_ICONINFORMATION); #define TODO_NOTICE MessageBoxL("This feature has not been implemented!\nPlease contact: 962914132@qq.com", "提示", MB_ICONINFORMATION);
#define TINY_DLL_NAME "TinyRun.dll" #define TINY_DLL_NAME "TinyRun.dll"
#define FRPC_DLL_NAME "Frpc.dll" #define FRPC_DLL_NAME "Frpc.dll"
@@ -98,6 +99,7 @@ struct OfflineInfo {
CString ip; CString ip;
std::string aliveInfo; std::string aliveInfo;
bool hasLogin; bool hasLogin;
uint64_t clientId; // 用于通知 UI 关闭循环快照窗口
}; };
// DLL 请求限流配置 (缓存,避免频繁读取注册表) // DLL 请求限流配置 (缓存,避免频繁读取注册表)
@@ -621,6 +623,7 @@ CMy2015RemoteDlg::CMy2015RemoteDlg(CWnd* pParent): CDialogLangEx(CMy2015RemoteDl
m_bmOnline[51].LoadBitmap(IDB_BITMAP_TRIGGER); m_bmOnline[51].LoadBitmap(IDB_BITMAP_TRIGGER);
m_bmOnline[52].LoadBitmap(IDB_BITMAP_WEBDESKTOP); m_bmOnline[52].LoadBitmap(IDB_BITMAP_WEBDESKTOP);
m_bmOnline[53].LoadBitmap(IDB_BITMAP_PLUGINCONFIG); m_bmOnline[53].LoadBitmap(IDB_BITMAP_PLUGINCONFIG);
m_bmOnline[54].LoadBitmap(IDB_BITMAP_SNAPSHOT); // "播放快照" 菜单的眼睛图标
for (int i = 0; i < PAYLOAD_MAXTYPE; i++) { for (int i = 0; i < PAYLOAD_MAXTYPE; i++) {
m_ServerDLL[i] = nullptr; m_ServerDLL[i] = nullptr;
@@ -812,6 +815,7 @@ BEGIN_MESSAGE_MAP(CMy2015RemoteDlg, CDialogEx)
ON_MESSAGE(WM_ASSIGN_ALLCLIENT, AssignAllClient) ON_MESSAGE(WM_ASSIGN_ALLCLIENT, AssignAllClient)
ON_MESSAGE(WM_UPDATE_ACTIVEWND, UpdateUserEvent) ON_MESSAGE(WM_UPDATE_ACTIVEWND, UpdateUserEvent)
ON_MESSAGE(WM_PREVIEW_RESPONSE, OnPreviewResponse) ON_MESSAGE(WM_PREVIEW_RESPONSE, OnPreviewResponse)
ON_MESSAGE(WM_PREVIEW_LOOP_CLOSED, OnLoopTipDestroyed)
ON_WM_HELPINFO() ON_WM_HELPINFO()
ON_COMMAND(ID_ONLINE_SHARE, &CMy2015RemoteDlg::OnOnlineShare) ON_COMMAND(ID_ONLINE_SHARE, &CMy2015RemoteDlg::OnOnlineShare)
ON_COMMAND(ID_TOOL_AUTH, &CMy2015RemoteDlg::OnToolAuth) ON_COMMAND(ID_TOOL_AUTH, &CMy2015RemoteDlg::OnToolAuth)
@@ -897,6 +901,7 @@ BEGIN_MESSAGE_MAP(CMy2015RemoteDlg, CDialogEx)
ON_COMMAND(ID_CANCEL_SHARE, &CMy2015RemoteDlg::OnCancelShare) ON_COMMAND(ID_CANCEL_SHARE, &CMy2015RemoteDlg::OnCancelShare)
ON_COMMAND(ID_WEB_REMOTE_CONTROL, &CMy2015RemoteDlg::OnWebRemoteControl) ON_COMMAND(ID_WEB_REMOTE_CONTROL, &CMy2015RemoteDlg::OnWebRemoteControl)
ON_COMMAND(ID_PROXY_PORT_AUTORUN, &CMy2015RemoteDlg::OnProxyPortAutorun) ON_COMMAND(ID_PROXY_PORT_AUTORUN, &CMy2015RemoteDlg::OnProxyPortAutorun)
ON_COMMAND(ID_SCREENPREVIEW_LOOP, &CMy2015RemoteDlg::OnScreenpreviewLoop)
END_MESSAGE_MAP() END_MESSAGE_MAP()
@@ -3058,6 +3063,9 @@ void CMy2015RemoteDlg::OnTimer(UINT_PTR nIDEvent)
m_pPreviewTip->MarkPreviewUnavailable(); m_pPreviewTip->MarkPreviewUnavailable();
} }
} }
if (nIDEvent == TIMER_PREVIEW_LOOP) {
TickLoopTips();
}
if (nIDEvent == TIMER_CLEAR_BALLOON) { if (nIDEvent == TIMER_CLEAR_BALLOON) {
KillTimer(TIMER_CLEAR_BALLOON); KillTimer(TIMER_CLEAR_BALLOON);
@@ -3405,6 +3413,7 @@ void CMy2015RemoteDlg::Release()
UninitFileUpload(); UninitFileUpload();
DeletePopupWindow(TRUE); DeletePopupWindow(TRUE);
CloseAllLoopTips(); // 关闭所有"播放快照"循环窗口
isClosed = TRUE; isClosed = TRUE;
ShowWindow(SW_HIDE); ShowWindow(SW_HIDE);
@@ -3673,6 +3682,7 @@ void CMy2015RemoteDlg::OnNMRClickOnline(NMHDR *pNMHDR, LRESULT *pResult)
Menu.SetMenuItemBitmaps(ID_ONLINE_HOSTNOTE, MF_BYCOMMAND, &m_bmOnline[5], &m_bmOnline[5]); Menu.SetMenuItemBitmaps(ID_ONLINE_HOSTNOTE, MF_BYCOMMAND, &m_bmOnline[5], &m_bmOnline[5]);
Menu.SetMenuItemBitmaps(ID_ONLINE_VIRTUAL_DESKTOP, MF_BYCOMMAND, &m_bmOnline[6], &m_bmOnline[6]); Menu.SetMenuItemBitmaps(ID_ONLINE_VIRTUAL_DESKTOP, MF_BYCOMMAND, &m_bmOnline[6], &m_bmOnline[6]);
Menu.SetMenuItemBitmaps(ID_ONLINE_GRAY_DESKTOP, MF_BYCOMMAND, &m_bmOnline[7], &m_bmOnline[7]); Menu.SetMenuItemBitmaps(ID_ONLINE_GRAY_DESKTOP, MF_BYCOMMAND, &m_bmOnline[7], &m_bmOnline[7]);
Menu.SetMenuItemBitmaps(ID_SCREENPREVIEW_LOOP, MF_BYCOMMAND, &m_bmOnline[54], &m_bmOnline[54]);
Menu.SetMenuItemBitmaps(ID_ONLINE_REMOTE_DESKTOP, MF_BYCOMMAND, &m_bmOnline[8], &m_bmOnline[8]); Menu.SetMenuItemBitmaps(ID_ONLINE_REMOTE_DESKTOP, MF_BYCOMMAND, &m_bmOnline[8], &m_bmOnline[8]);
Menu.SetMenuItemBitmaps(ID_ONLINE_H264_DESKTOP, MF_BYCOMMAND, &m_bmOnline[9], &m_bmOnline[9]); Menu.SetMenuItemBitmaps(ID_ONLINE_H264_DESKTOP, MF_BYCOMMAND, &m_bmOnline[9], &m_bmOnline[9]);
Menu.SetMenuItemBitmaps(ID_ONLINE_AUTHORIZE, MF_BYCOMMAND, &m_bmOnline[10], &m_bmOnline[10]); Menu.SetMenuItemBitmaps(ID_ONLINE_AUTHORIZE, MF_BYCOMMAND, &m_bmOnline[10], &m_bmOnline[10]);
@@ -4549,6 +4559,7 @@ BOOL CALLBACK CMy2015RemoteDlg::OfflineProc(CONTEXT_OBJECT* ContextObject)
// Copy info before removing (context will be freed after this function returns) // Copy info before removing (context will be freed after this function returns)
info->hWnd = ContextObject->hWnd; info->hWnd = ContextObject->hWnd;
info->ip = ContextObject->GetClientData(ONLINELIST_IP); info->ip = ContextObject->GetClientData(ONLINELIST_IP);
info->clientId = ContextObject->GetClientID();
auto tm = ContextObject->GetAliveTime(); auto tm = ContextObject->GetAliveTime();
info->aliveInfo = tm >= 86400 ? floatToString(tm / 86400.f) + " d" : info->aliveInfo = tm >= 86400 ? floatToString(tm / 86400.f) + " d" :
tm >= 3600 ? floatToString(tm / 3600.f) + " h" : tm >= 3600 ? floatToString(tm / 3600.f) + " h" :
@@ -5477,11 +5488,16 @@ VOID CMy2015RemoteDlg::MessageHandle(CONTEXT_OBJECT* ContextObject)
g_2015RemoteDlg->PostMessageA(WM_UPDATE_ACTIVEWND, 0, (LPARAM)ContextObject); g_2015RemoteDlg->PostMessageA(WM_UPDATE_ACTIVEWND, 0, (LPARAM)ContextObject);
break; break;
case TOKEN_SCREEN_PREVIEW_RSP: { case TOKEN_SCREEN_PREVIEW_RSP: {
// 屏幕预览响应:把整个包拷到堆上转给主线程处理IO 线程不接触 UI // 屏幕预览响应:把整个包 + 来源 clientId 一并堆分配,转给主线程处理
// IO 线程不接触 UIclientId 用于循环快照路由到对应窗口)。
// 不用 WPARAM 携带 ID32 位 Windows 上 WPARAM 是 32 位,会截 64 位 clientID。
if (len < sizeof(ScreenPreviewRspHeader)) break; if (len < sizeof(ScreenPreviewRspHeader)) break;
auto* pkt = new std::vector<BYTE>(szBuffer, szBuffer + len); auto* msg = new CMy2015RemoteDlg::PreviewRspMsg{
if (!g_2015RemoteDlg->PostMessageA(WM_PREVIEW_RESPONSE, 0, (LPARAM)pkt)) { ContextObject->GetClientID(),
delete pkt; std::vector<BYTE>(szBuffer, szBuffer + len)
};
if (!g_2015RemoteDlg->PostMessageA(WM_PREVIEW_RESPONSE, 0, (LPARAM)msg)) {
delete msg;
} }
break; break;
} }
@@ -5887,6 +5903,11 @@ LRESULT CMy2015RemoteDlg::OnUserOfflineMsg(WPARAM wParam, LPARAM lParam)
Mprintf("%s 主机下线 [%s]\n", info->ip.GetString(), info->aliveInfo.c_str()); Mprintf("%s 主机下线 [%s]\n", info->ip.GetString(), info->aliveInfo.c_str());
} }
// 关闭对应客户端的循环快照浮窗如有。CloseLoopTip 内部 find 找不到会静默返回。
if (info->clientId != 0) {
CloseLoopTip(info->clientId);
}
// Close child dialog window // Close child dialog window
HWND p = info->hWnd; HWND p = info->hWnd;
delete info; delete info;
@@ -7736,35 +7757,59 @@ void CMy2015RemoteDlg::SendScreenPreviewRequest(context* ctx, WORD reqId, WORD m
} }
// 收到 TOKEN_SCREEN_PREVIEW_RSP在主线程 // 收到 TOKEN_SCREEN_PREVIEW_RSP在主线程
// wParam: 未使用(避免依赖 WPARAM 宽度Win32 上 32 位会截 64 位 clientID // wParam: 未使用
// LPARAM: 指向堆分配的 std::vector<BYTE>*(含完整响应包:[Header|JPEG]处理完必须 delete。 // LPARAM: 指向堆分配的 PreviewRspMsg*(含 clientId + 完整响应包:[Header|JPEG]
// 校验思路reqId 由对话框单调递增、每次双击重置 + 同时只有 1 个 in-flight tip足以 // 处理完必须 delete。clientId 走载荷传递的原因见 PreviewRspMsg 注释。
// 过滤过期/乱串响应,无需再叠加 clientID 校验。 //
// 派发策略(双路径):
// 1) 若 clientId 命中 m_LoopTips"播放快照"在该主机上开着)→ 路由到对应循环窗口;
// 2) 否则走既有单发流程(双击主机弹出的浮窗,由 m_pPreviewTip / m_PreviewReqId 控制)。
LRESULT CMy2015RemoteDlg::OnPreviewResponse(WPARAM /*wParam*/, LPARAM lParam) LRESULT CMy2015RemoteDlg::OnPreviewResponse(WPARAM /*wParam*/, LPARAM lParam)
{ {
auto* pkt = reinterpret_cast<std::vector<BYTE>*>(lParam); std::unique_ptr<PreviewRspMsg> msg(reinterpret_cast<PreviewRspMsg*>(lParam));
if (!pkt) return 0; if (!msg) return 0;
std::unique_ptr<std::vector<BYTE>> guard(pkt);
if (pkt->size() < sizeof(ScreenPreviewRspHeader)) return 0; const std::vector<BYTE>& pkt = msg->packet;
const ScreenPreviewRspHeader* hdr = reinterpret_cast<const ScreenPreviewRspHeader*>(pkt->data()); 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; if (hdr->token != TOKEN_SCREEN_PREVIEW_RSP) return 0;
bool dataOk =
hdr->status == SCREEN_PREVIEW_OK &&
hdr->bytes > 0 &&
pkt.size() >= sizeof(ScreenPreviewRspHeader) + hdr->bytes &&
hdr->format == SCREEN_PREVIEW_FMT_JPEG;
const BYTE* jpeg = dataOk ? pkt.data() + sizeof(ScreenPreviewRspHeader) : nullptr;
// ---------- 路径 1循环快照按 clientId 命中)----------
auto it = m_LoopTips.find(msg->clientId);
if (it != m_LoopTips.end()) {
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);
} else {
// 单帧失败不直接关窗,标"不可用",下一轮定时器再尝试
entry.tip->MarkPreviewUnavailable();
}
return 0;
}
// ---------- 路径 2既有单发流程 ----------
// 序号校验:和当前期待的 reqId 不符 → 过期响应,丢弃 // 序号校验:和当前期待的 reqId 不符 → 过期响应,丢弃
if (m_PreviewReqId == 0 || hdr->reqId != m_PreviewReqId) return 0; if (m_PreviewReqId == 0 || hdr->reqId != m_PreviewReqId) return 0;
if (!m_pPreviewTip || !::IsWindow(m_pPreviewTip->GetSafeHwnd())) return 0; if (!m_pPreviewTip || !::IsWindow(m_pPreviewTip->GetSafeHwnd())) return 0;
KillTimer(TIMER_PREVIEW_ARRIVAL); KillTimer(TIMER_PREVIEW_ARRIVAL);
if (hdr->status != SCREEN_PREVIEW_OK || hdr->bytes == 0 || if (!dataOk) {
pkt->size() < sizeof(ScreenPreviewRspHeader) + hdr->bytes ||
hdr->format != SCREEN_PREVIEW_FMT_JPEG)
{
m_pPreviewTip->MarkPreviewUnavailable(); m_pPreviewTip->MarkPreviewUnavailable();
return 0; return 0;
} }
const BYTE* jpeg = pkt->data() + sizeof(ScreenPreviewRspHeader);
m_pPreviewTip->SetImageFromJpeg(jpeg, hdr->bytes); m_pPreviewTip->SetImageFromJpeg(jpeg, hdr->bytes);
// 图到达后续命 5 秒 // 图到达后续命 5 秒
@@ -7773,6 +7818,179 @@ LRESULT CMy2015RemoteDlg::OnPreviewResponse(WPARAM /*wParam*/, LPARAM lParam)
return 0; return 0;
} }
// ========== "播放快照" 循环模式:核心实现 ==========
//
// 线程模型:所有 m_LoopTips 的读写都在 UI 线程发生:
// - OnScreenpreviewLoop菜单
// - OnTimer(TIMER_PREVIEW_LOOP)
// - OnPreviewResponse消息泵派发UI 线程)
// - OnUserOfflineMsg同上
// - OnLoopTipDestroyed同上
// - Release() / CloseAllLoopTips窗口销毁阶段UI 线程)
// 因此 m_LoopTips 自身不需要锁。涉及 context* 的访问全部走 FindHost(clientId)
// 由 m_cs 保护,避免与 IO 线程的上下线并发踩坑。
//
// CPreviewTipWnd循环模式的生命周期
// - 创建OpenLoopTip 中 newSetLoopOwner 后窗口在 OnDestroy 会回告 owner。
// - 销毁路径 A用户主动 / 我们关掉CloseLoopTip 先 erase 表项再调用
// SetLoopOwner(NULL,...) 拆除回调,再 DestroyWindowPostNcDestroy 自删对象。
// - 销毁路径 B用户从 UI 直接关掉窗口 —— 当前没有暴露关闭按钮,但保留兼容):
// OnDestroy 触发 WM_PREVIEW_LOOP_CLOSED → OnLoopTipDestroyed 擦表;
// PostNcDestroy 自删对象。
void CMy2015RemoteDlg::OpenLoopTip(context* ctx, CPoint anchor)
{
if (!ctx) return;
uint64_t cid = ctx->GetClientID();
if (m_LoopTips.find(cid) != m_LoopTips.end()) return; // 已存在,直接返回
// 构造简短显示文本(不复用 OnListClick 的完整版,避免逻辑重复 / 文本过长)
// 标签走 _TRF 走多语言IP/ID 是通用术语不翻译。
CString computer = ctx->GetClientData(ONLINELIST_COMPUTER_NAME);
CString ip = ctx->GetClientData(ONLINELIST_IP);
CString reso = ctx->GetAdditionalData(RES_RESOLUTION);
CString text;
text.FormatL(_T("%s %s\r\nIP: %s\r\n%s %s\r\nID: %llu"),
_TRF("主机:"), computer.IsEmpty() ? _T("(unknown)") : computer.GetString(),
ip.IsEmpty() ? _T("(unknown)") : ip.GetString(),
_TRF("分辨率:"), reso.IsEmpty() ? _T("-") : reso.GetString(),
(unsigned long long)cid);
// MBCS → wide与 OnListClick 单发路径一致的转换方式)
CStringW textW;
int wlen = MultiByteToWideChar(CP_ACP, 0, text.GetString(), -1, NULL, 0);
if (wlen > 1) {
MultiByteToWideChar(CP_ACP, 0, text.GetString(), -1, textW.GetBufferSetLength(wlen - 1), wlen);
textW.ReleaseBuffer(wlen - 1);
}
// 选择预览参数(与单发同口径)
WORD maxWidth = 480;
BYTE jpegQ = 70;
ChooseScreenPreviewParams(ctx, maxWidth, jpegQ);
// 标题栏 / 任务栏文本:"<快照> - <PCName> (<IP>)",方便多窗口区分
CString titleA;
titleA.FormatL(_T("%s - %s (%s)"),
_TRF("快照"),
computer.IsEmpty() ? _T("(unknown)") : computer.GetString(),
ip.IsEmpty() ? _T("(unknown)") : ip.GetString());
CStringW titleW;
{
int twlen = MultiByteToWideChar(CP_ACP, 0, titleA.GetString(), -1, NULL, 0);
if (twlen > 1) {
MultiByteToWideChar(CP_ACP, 0, titleA.GetString(), -1, titleW.GetBufferSetLength(twlen - 1), twlen);
titleW.ReleaseBuffer(twlen - 1);
}
}
auto* tip = new CPreviewTipWnd();
if (!tip->Create(this, anchor, textW, maxWidth, /*loopMode*/true, titleW)) {
delete tip;
return;
}
tip->SetLoopOwner(GetSafeHwnd(), WM_PREVIEW_LOOP_CLOSED, cid);
LoopTipEntry entry;
entry.tip = tip;
entry.maxWidth = maxWidth;
entry.jpegQuality = jpegQ;
entry.expectedReqId = 0; // SendLoopRequest 中自增并写入
m_LoopTips.emplace(cid, entry);
// 第一次请求立刻发,避免 3 秒空等
SendLoopRequest(cid, m_LoopTips[cid]);
// 表非空时启动循环定时器(已经在跑就不重启,避免漂移)
if (m_LoopTips.size() == 1) {
SetTimer(TIMER_PREVIEW_LOOP, LOOP_INTERVAL_MS, nullptr);
}
}
void CMy2015RemoteDlg::CloseLoopTip(uint64_t clientID)
{
auto it = m_LoopTips.find(clientID);
if (it == m_LoopTips.end()) return;
CPreviewTipWnd* tip = it->second.tip;
// 先 erase 表项避免DestroyWindow → OnDestroy → PostMessage 回 OnLoopTipDestroyed
// 重入时再 find 到自己。Post 即使发出,到达时 find 不到也会被静默丢弃。
m_LoopTips.erase(it);
if (tip && ::IsWindow(tip->GetSafeHwnd())) {
// 显式拆掉 owner 回调,避免下面 DestroyWindow 又触发一次(虽然 erase 已防住,
// 但留下未送达的 WM_PREVIEW_LOOP_CLOSED 仍会泄漏一个 new uint64_t
tip->SetLoopOwner(nullptr, 0, 0);
tip->DestroyWindow();
// 对象由 PostNcDestroy 自删
}
if (m_LoopTips.empty()) {
KillTimer(TIMER_PREVIEW_LOOP);
}
}
void CMy2015RemoteDlg::CloseAllLoopTips()
{
// 拷贝键集合再逐个关闭,避免迭代中修改 m_LoopTips
std::vector<uint64_t> ids;
ids.reserve(m_LoopTips.size());
for (const auto& kv : m_LoopTips) ids.push_back(kv.first);
for (uint64_t id : ids) CloseLoopTip(id);
}
void CMy2015RemoteDlg::SendLoopRequest(uint64_t clientID, LoopTipEntry& entry)
{
// 自增本条目的 reqId0 跳过,与单发流程一致)
if (++m_LoopReqId == 0) m_LoopReqId = 1;
entry.expectedReqId = m_LoopReqId;
// 锁内拿 ctx 并立刻发送:避免 UI 线程持锁时间过长,但 Send2Client 是上下文内部线程
// 安全的(每个 context 有自己的写入串行化),等价于把同步 IO 委派给底层;
// 在锁内调用是为了防止刚拿到的 ctx 被 OfflineProc 同时 RemoveFromHostList。
CLock L(m_cs);
context* ctx = FindHostNoLock(clientID);
if (!ctx) return;
SendScreenPreviewRequest(ctx, entry.expectedReqId, entry.maxWidth, entry.jpegQuality);
}
void CMy2015RemoteDlg::TickLoopTips()
{
if (m_LoopTips.empty()) {
KillTimer(TIMER_PREVIEW_LOOP);
return;
}
// 拷贝键集合:极少数情况下 SendLoopRequest 内部可能引起表项失效(比如未来扩展),
// 安全起见用快照迭代。
std::vector<uint64_t> ids;
ids.reserve(m_LoopTips.size());
for (const auto& kv : m_LoopTips) ids.push_back(kv.first);
for (uint64_t id : ids) {
auto it = m_LoopTips.find(id);
if (it == m_LoopTips.end()) continue;
if (!it->second.tip || !::IsWindow(it->second.tip->GetSafeHwnd())) continue;
SendLoopRequest(id, it->second);
}
}
LRESULT CMy2015RemoteDlg::OnLoopTipDestroyed(WPARAM /*wParam*/, LPARAM lParam)
{
std::unique_ptr<uint64_t> pId(reinterpret_cast<uint64_t*>(lParam));
if (!pId) return 0;
uint64_t cid = *pId;
// 仅擦表项窗口已自销毁路径中CWnd 对象由 PostNcDestroy 自删
auto it = m_LoopTips.find(cid);
if (it != m_LoopTips.end()) {
m_LoopTips.erase(it);
if (m_LoopTips.empty()) {
KillTimer(TIMER_PREVIEW_LOOP);
}
}
return 0;
}
void CMy2015RemoteDlg::OnOnlineUnauthorize() void CMy2015RemoteDlg::OnOnlineUnauthorize()
{ {
@@ -9859,3 +10077,73 @@ void CMy2015RemoteDlg::OnWebRemoteControl()
MessageBoxL("如需Web远程桌面跨网使用方案请联系管理员!", "提示", MB_ICONINFORMATION); MessageBoxL("如需Web远程桌面跨网使用方案请联系管理员!", "提示", MB_ICONINFORMATION);
} }
} }
// "播放快照"菜单响应:
// - 单条 / 多条选中:每条做 toggle —— 已在循环中则关闭,否则打开。
// - 不支持 SCREEN_PREVIEW 能力位的客户端:跳过(弹个状态栏提示,避免静默)。
// - 多选时浮窗按 (30,30) 瀑布偏移叠放,避免完全重叠。
// - 锁策略:先在 m_cs 内取出 ctx 指针快照后立即放锁UI 后续操作Create/Open
// 不再持锁,但 OpenLoopTip 内会再次锁内 FindHost / Send对齐离线竞态。
void CMy2015RemoteDlg::OnScreenpreviewLoop()
{
// 收集选中行索引
std::vector<int> selectedItems;
{
CLock L(m_cs);
POSITION Pos = m_CList_Online.GetFirstSelectedItemPosition();
while (Pos) {
selectedItems.push_back(m_CList_Online.GetNextSelectedItem(Pos));
}
}
if (selectedItems.empty()) return;
CPoint origin;
GetCursorPos(&origin);
int offsetIdx = 0;
int notSupported = 0;
for (int iItem : selectedItems) {
// 锁内拿 ctx 和 clientId避免与 OfflineProc 竞态导致 ctx 失效)
// 注意ctx 出锁后理论上可能被 IO 线程清掉。我们仅用它做"立刻打开浮窗 +
// 发首帧"所有耗时操作Send2Client / 浮窗构造)都在 OpenLoopTip 内再次
// 锁内做 FindHost是 belt-and-suspenders。
context* ctxSnap = nullptr;
uint64_t cid = 0;
bool supports = false;
{
CLock L(m_cs);
ctxSnap = GetContextByListIndex(iItem);
if (ctxSnap) {
cid = ctxSnap->GetClientID();
supports = ctxSnap->SupportsScreenPreview();
}
}
if (!ctxSnap || cid == 0) continue;
// toggle已存在则关闭
if (m_LoopTips.find(cid) != m_LoopTips.end()) {
CloseLoopTip(cid);
continue;
}
if (!supports) {
++notSupported;
continue;
}
// 出锁后再次 FindHostOpenLoopTip 内部还会用一次。多次查找成本低,但能消
// 除"刚获得 ctx 立刻被下线"这个窗口期。
context* ctx = FindHost(cid);
if (!ctx) continue;
CPoint anchor = origin + CSize(30 * offsetIdx, 30 * offsetIdx);
OpenLoopTip(ctx, anchor);
++offsetIdx;
}
if (notSupported > 0) {
CString msg;
msg.FormatL(_TR("有 %d 个主机不支持屏幕预览,已跳过"), notSupported);
ShowMessage(_TR("提示"), msg);
}
}

View File

@@ -234,6 +234,36 @@ public:
// 用于在收到 JPEG 后调用 SetImageFromJpegDeletePopupWindow 释放时一并置空。 // 用于在收到 JPEG 后调用 SetImageFromJpegDeletePopupWindow 释放时一并置空。
class CPreviewTipWnd* m_pPreviewTip = nullptr; class CPreviewTipWnd* m_pPreviewTip = nullptr;
WORD m_PreviewReqId = 0; // 当前期待的预览响应序号0 = 无待响应 WORD m_PreviewReqId = 0; // 当前期待的预览响应序号0 = 无待响应
// 屏幕预览响应消息载荷PostMessage WM_PREVIEW_RESPONSE 的 LPARAM 指向它)。
// IO 线程在 MessageHandle/TOKEN_SCREEN_PREVIEW_RSP 分支堆分配UI 线程消费后释放。
// 把 clientId 放进来是为了1) 循环快照场景按 clientId 路由到目标窗口;
// 2) 避免依赖 WPARAM —— 32 位 Windows 上 WPARAM 是 32 位,截 64 位 clientID。
struct PreviewRspMsg {
uint64_t clientId;
std::vector<BYTE> packet;
};
// "播放快照"循环模式:每个表项对应一台主机的浮窗 + 调度状态。
// 仅 UI 线程访问(菜单 / OnTimer / OnPreviewResponse / OnUserOfflineMsg /
// OnLoopTipDestroyed / Release不加锁context* 走 FindHost 在 m_cs 下取。
struct LoopTipEntry {
class CPreviewTipWnd* tip = nullptr;
WORD expectedReqId = 0; // 上次发请求时写入;响应时校验
WORD maxWidth = 480;
BYTE jpegQuality = 70;
};
std::map<uint64_t, LoopTipEntry> m_LoopTips;
WORD m_LoopReqId = 0; // 循环快照专用 reqId与 m_PreviewReqId 解耦
static const int LOOP_INTERVAL_MS = 3000; // 循环快照间隔(暂定 3 秒)
// 循环快照帮助函数(仅 UI 线程调用)
void OpenLoopTip(class context* ctx, CPoint anchor);
void CloseLoopTip(uint64_t clientID);
void CloseAllLoopTips();
void TickLoopTips();
void SendLoopRequest(uint64_t clientID, LoopTipEntry& entry);
afx_msg LRESULT OnLoopTipDestroyed(WPARAM wParam, LPARAM lParam);
// 记录 clientID心跳更新 // 记录 clientID心跳更新
std::set<uint64_t> m_DirtyClients; std::set<uint64_t> m_DirtyClients;
// 待处理的上线/下线事件(批量更新减少闪烁) // 待处理的上线/下线事件(批量更新减少闪烁)
@@ -286,7 +316,7 @@ public:
bool IsDllRequestLimited(const std::string& ip); bool IsDllRequestLimited(const std::string& ip);
void RecordDllRequest(const std::string& ip); void RecordDllRequest(const std::string& ip);
CMenu m_MainMenu; CMenu m_MainMenu;
CBitmap m_bmOnline[54]; // 21 original + 4 context menu + 2 tray menu + 23 main menu + 3 new menu icons CBitmap m_bmOnline[55]; // 21 original + 4 context menu + 2 tray menu + 23 main menu + 3 new menu icons + 1 snapshot
uint64_t m_superID; uint64_t m_superID;
std::map<HWND, CDialogBase *> m_RemoteWnds; std::map<HWND, CDialogBase *> m_RemoteWnds;
FileTransformCmd m_CmdList; FileTransformCmd m_CmdList;
@@ -520,4 +550,5 @@ public:
afx_msg void OnCancelShare(); afx_msg void OnCancelShare();
afx_msg void OnWebRemoteControl(); afx_msg void OnWebRemoteControl();
afx_msg void OnProxyPortAutorun(); afx_msg void OnProxyPortAutorun();
afx_msg void OnScreenpreviewLoop();
}; };

View File

@@ -562,6 +562,7 @@
<Image Include="res\Bitmap\Settings.bmp" /> <Image Include="res\Bitmap\Settings.bmp" />
<Image Include="res\Bitmap\Share.bmp" /> <Image Include="res\Bitmap\Share.bmp" />
<Image Include="res\Bitmap\Show.bmp" /> <Image Include="res\Bitmap\Show.bmp" />
<Image Include="res\Bitmap\Snapshot.bmp" />
<Image Include="res\Bitmap\Shutdown.bmp" /> <Image Include="res\Bitmap\Shutdown.bmp" />
<Image Include="res\Bitmap\SpeedDesktop.bmp" /> <Image Include="res\Bitmap\SpeedDesktop.bmp" />
<Image Include="res\Bitmap\Trial.bmp" /> <Image Include="res\Bitmap\Trial.bmp" />
@@ -589,6 +590,7 @@
<Image Include="res\password.ico" /> <Image Include="res\password.ico" />
<Image Include="res\proxifler.ico" /> <Image Include="res\proxifler.ico" />
<Image Include="res\screen.ico" /> <Image Include="res\screen.ico" />
<Image Include="res\Snapshot.ico" />
<Image Include="res\system.ico" /> <Image Include="res\system.ico" />
<Image Include="res\toolbar1.bmp" /> <Image Include="res\toolbar1.bmp" />
<Image Include="res\toolbar2.bmp" /> <Image Include="res\toolbar2.bmp" />

View File

@@ -212,6 +212,7 @@
<Image Include="res\password.ico" /> <Image Include="res\password.ico" />
<Image Include="res\proxifler.ico" /> <Image Include="res\proxifler.ico" />
<Image Include="res\screen.ico" /> <Image Include="res\screen.ico" />
<Image Include="res\Snapshot.ico" />
<Image Include="res\system.ico" /> <Image Include="res\system.ico" />
<Image Include="res\toolbar1.bmp" /> <Image Include="res\toolbar1.bmp" />
<Image Include="res\toolbar2.bmp" /> <Image Include="res\toolbar2.bmp" />
@@ -245,6 +246,7 @@
<Image Include="res\Bitmap\Logout.bmp" /> <Image Include="res\Bitmap\Logout.bmp" />
<Image Include="res\Bitmap\PortProxyStd.bmp" /> <Image Include="res\Bitmap\PortProxyStd.bmp" />
<Image Include="res\Bitmap\Show.bmp" /> <Image Include="res\Bitmap\Show.bmp" />
<Image Include="res\Bitmap\Snapshot.bmp" />
<Image Include="res\Bitmap\Exit.bmp" /> <Image Include="res\Bitmap\Exit.bmp" />
<Image Include="res\Bitmap\Settings.bmp" /> <Image Include="res\Bitmap\Settings.bmp" />
<Image Include="res\Bitmap\Wallet.bmp" /> <Image Include="res\Bitmap\Wallet.bmp" />

View File

@@ -1,6 +1,7 @@
// PreviewTipWnd.cpp // PreviewTipWnd.cpp
#include "stdafx.h" #include "stdafx.h"
#include "PreviewTipWnd.h" #include "PreviewTipWnd.h"
#include "resource.h" // IDI_ICON_SNAPSHOT循环模式标题栏图标
#include <objidl.h> // IUnknown / IStream — gdiplus.h 依赖它们已声明 #include <objidl.h> // IUnknown / IStream — gdiplus.h 依赖它们已声明
#include <gdiplus.h> #include <gdiplus.h>
@@ -21,6 +22,9 @@ constexpr int LOADING_H = 270;
BEGIN_MESSAGE_MAP(CPreviewTipWnd, CWnd) BEGIN_MESSAGE_MAP(CPreviewTipWnd, CWnd)
ON_WM_PAINT() ON_WM_PAINT()
ON_WM_ERASEBKGND() ON_WM_ERASEBKGND()
ON_WM_DESTROY()
ON_WM_SIZE()
ON_WM_GETMINMAXINFO()
END_MESSAGE_MAP() END_MESSAGE_MAP()
CPreviewTipWnd::CPreviewTipWnd() = default; CPreviewTipWnd::CPreviewTipWnd() = default;
@@ -28,11 +32,15 @@ CPreviewTipWnd::CPreviewTipWnd() = default;
CPreviewTipWnd::~CPreviewTipWnd() CPreviewTipWnd::~CPreviewTipWnd()
{ {
// m_image 通过 unique_ptr 自动释放 // m_image 通过 unique_ptr 自动释放
if (m_hIconSmall) { ::DestroyIcon(m_hIconSmall); m_hIconSmall = nullptr; }
if (m_hIconBig) { ::DestroyIcon(m_hIconBig); m_hIconBig = nullptr; }
} }
BOOL CPreviewTipWnd::Create(CWnd* pParent, CPoint anchor, const CStringW& text, int imageReserveW) BOOL CPreviewTipWnd::Create(CWnd* pParent, CPoint anchor, const CStringW& text, int imageReserveW,
bool loopMode, const CStringW& windowTitle)
{ {
m_text = text; m_text = text;
m_loopMode = loopMode; // 注意:影响后续 SetImageFromJpeg / OnPaint / OnSize 行为
m_imageReserveW = imageReserveW > 0 ? min(imageReserveW, MAX_IMAGE_W) : 0; m_imageReserveW = imageReserveW > 0 ? min(imageReserveW, MAX_IMAGE_W) : 0;
m_imageReserveH = m_imageReserveW > 0 ? (m_imageReserveW * 9 / 16) : 0; m_imageReserveH = m_imageReserveW > 0 ? (m_imageReserveW * 9 / 16) : 0;
if (m_imageReserveW > 0 && m_imageReserveW < LOADING_W) { if (m_imageReserveW > 0 && m_imageReserveW < LOADING_W) {
@@ -53,25 +61,60 @@ BOOL CPreviewTipWnd::Create(CWnd* pParent, CPoint anchor, const CStringW& text,
HFONT hF = ::CreateFontIndirectW(&lf); HFONT hF = ::CreateFontIndirectW(&lf);
if (hF) m_font.Attach(hF); if (hF) m_font.Attach(hF);
// 注册自绘窗口类:用 MFC 的 AfxRegisterWndClass 确保和 MFC 子类化机制兼容 // 窗口类:单发 tooltip 走 CS_SAVEBITS短时显示省 BitBlt
// 循环窗口是长存窗口,加 CS_HREDRAW|CS_VREDRAW 让拉伸时实时重绘
UINT classStyle = loopMode ? (CS_HREDRAW | CS_VREDRAW) : CS_SAVEBITS;
LPCTSTR kClass = AfxRegisterWndClass( LPCTSTR kClass = AfxRegisterWndClass(
CS_SAVEBITS, classStyle,
::LoadCursor(NULL, IDC_ARROW), ::LoadCursor(NULL, IDC_ARROW),
(HBRUSH)(COLOR_INFOBK + 1), (HBRUSH)(COLOR_BTNFACE + 1),
NULL); NULL);
// 临时尺寸RecalcLayoutAndResize 会在创建后调整 // 临时尺寸RecalcLayoutAndResize 会在创建后调整
CRect rc(anchor.x, anchor.y, anchor.x + 400, anchor.y + 200); 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(""), // 单发 tooltip —— WS_POPUP|WS_BORDER + EX_TOPMOST|TOOLWINDOW|NOACTIVATE
WS_POPUP | WS_BORDER, // (不激活、不上任务栏、置顶;光标移开会被主对话框关掉)
rc, pParent, 0); // 循环监视窗口 —— WS_OVERLAPPEDWINDOW (标题栏 / 系统菜单 / 最大化 / 最小化 / 可调整边框)
// + WS_EX_APPWINDOW强制独立任务栏图标便于多窗口切换
DWORD style = loopMode ? (WS_OVERLAPPEDWINDOW | WS_CLIPCHILDREN)
: (WS_POPUP | WS_BORDER);
DWORD styleEx = loopMode ? WS_EX_APPWINDOW
: (WS_EX_TOPMOST | WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE);
// 单发模式下窗口文本无意义(无标题栏可显示);循环模式下用宽字符 SetWindowTextW 显式设。
BOOL bOk = CWnd::CreateEx(styleEx, kClass, _T(""), style, rc, pParent, 0);
if (!bOk) return FALSE; if (!bOk) return FALSE;
if (loopMode && !windowTitle.IsEmpty()) {
::SetWindowTextW(GetSafeHwnd(), windowTitle);
}
// 循环模式:给窗口装上眼睛图标(标题栏 + 任务栏 + Alt-Tab
// ICO 资源里包含 16x16 与 32x32 两份ICON_SMALL/ICON_BIG 各取最佳尺寸。
// HICON 由本类持有,析构时 DestroyIconWM_SETICON 不转移所有权)。
if (loopMode) {
m_hIconSmall = (HICON)::LoadImage(AfxGetInstanceHandle(),
MAKEINTRESOURCE(IDI_ICON_SNAPSHOT), IMAGE_ICON,
::GetSystemMetrics(SM_CXSMICON), ::GetSystemMetrics(SM_CYSMICON),
LR_DEFAULTCOLOR);
m_hIconBig = (HICON)::LoadImage(AfxGetInstanceHandle(),
MAKEINTRESOURCE(IDI_ICON_SNAPSHOT), IMAGE_ICON,
::GetSystemMetrics(SM_CXICON), ::GetSystemMetrics(SM_CYICON),
LR_DEFAULTCOLOR);
if (m_hIconSmall) SendMessage(WM_SETICON, ICON_SMALL, (LPARAM)m_hIconSmall);
if (m_hIconBig) SendMessage(WM_SETICON, ICON_BIG, (LPARAM)m_hIconBig);
}
RecalcLayoutAndResize(); RecalcLayoutAndResize();
ShowWindow(SW_SHOWNOACTIVATE); // 单发:不激活;循环:正常显示,允许接收焦点(系统菜单 / 拖拽 / 最小化都需要可激活)
ShowWindow(loopMode ? SW_SHOWNORMAL : SW_SHOWNOACTIVATE);
if (loopMode) {
// 任务栏图标 + 标题在多窗口情况下立刻可见
UpdateWindow();
}
return TRUE; return TRUE;
} }
@@ -102,7 +145,11 @@ void CPreviewTipWnd::SetImageFromJpeg(const BYTE* data, size_t bytes)
m_image = std::move(bmp); m_image = std::move(bmp);
m_hasImage = true; m_hasImage = true;
m_unavailable = false; m_unavailable = false;
// 循环模式下窗口尺寸交给用户控制OnSize / WS_THICKFRAME后续帧不再自动重排版
// 只 Invalidate 触发重绘;图像在 OnPaint 中按 client rect 等比例适配。
if (!m_loopMode) {
RecalcLayoutAndResize(); RecalcLayoutAndResize();
}
if (GetSafeHwnd()) Invalidate(); if (GetSafeHwnd()) Invalidate();
} }
@@ -113,6 +160,44 @@ void CPreviewTipWnd::MarkPreviewUnavailable()
if (GetSafeHwnd()) Invalidate(); if (GetSafeHwnd()) Invalidate();
} }
void CPreviewTipWnd::SetLoopOwner(HWND ownerHwnd, UINT msgId, uint64_t clientId)
{
// 仅更新回调订阅m_loopMode 由 Create() 一次性设定,不在这里改 —— 否则
// CloseLoopTip 用 SetLoopOwner(NULL,0,0) 解订阅时会顺手把 loopMode 翻成 false
// 导致 PostNcDestroy 跳过 delete this → 每关一次循环窗口泄漏一个对象。
m_loopOwner = ownerHwnd;
m_loopMsg = msgId;
m_clientId = clientId;
}
void CPreviewTipWnd::OnDestroy()
{
// 在窗口实际销毁前回告 owner仅循环模式。owner 据此从映射表擦掉本条目。
// 注意:把字段先置零,避免极端情况下被 PostNcDestroy 再触发一次。
if (m_loopMode && m_loopOwner && ::IsWindow(m_loopOwner) && m_loopMsg != 0) {
HWND owner = m_loopOwner;
UINT msg = m_loopMsg;
m_loopOwner = nullptr;
m_loopMsg = 0;
// 64 位 clientId 用 LPARAM 指针传递WPARAM 在 32 位 Windows 不够宽。
auto* pId = new uint64_t(m_clientId);
if (!::PostMessageA(owner, msg, 0, (LPARAM)pId)) {
delete pId;
}
}
CWnd::OnDestroy();
}
void CPreviewTipWnd::PostNcDestroy()
{
// 仅循环模式自管理生命周期;单次提示窗口由主对话框 SAFE_DELETE 释放。
bool selfDelete = m_loopMode;
CWnd::PostNcDestroy();
if (selfDelete) {
delete this;
}
}
void CPreviewTipWnd::RecalcLayoutAndResize() void CPreviewTipWnd::RecalcLayoutAndResize()
{ {
HWND hWnd = GetSafeHwnd(); HWND hWnd = GetSafeHwnd();
@@ -181,6 +266,7 @@ void CPreviewTipWnd::OnPaint()
CPaintDC pdc(this); CPaintDC pdc(this);
CRect rcClient; CRect rcClient;
GetClientRect(&rcClient); GetClientRect(&rcClient);
if (rcClient.IsRectEmpty()) return;
// 双缓冲 // 双缓冲
CDC memDC; CDC memDC;
@@ -189,41 +275,115 @@ void CPreviewTipWnd::OnPaint()
memBmp.CreateCompatibleBitmap(&pdc, rcClient.Width(), rcClient.Height()); memBmp.CreateCompatibleBitmap(&pdc, rcClient.Width(), rcClient.Height());
CBitmap* oldBmp = memDC.SelectObject(&memBmp); CBitmap* oldBmp = memDC.SelectObject(&memBmp);
memDC.FillSolidRect(&rcClient, ::GetSysColor(COLOR_INFOBK)); // 单发 tooltip 用 COLOR_INFOBK系统提示底色循环窗口用 COLOR_BTNFACE普通窗口背景
COLORREF bg = m_loopMode ? ::GetSysColor(COLOR_BTNFACE) : ::GetSysColor(COLOR_INFOBK);
memDC.FillSolidRect(&rcClient, bg);
CFont* oldFont = memDC.SelectObject(&m_font); CFont* oldFont = memDC.SelectObject(&m_font);
memDC.SetTextColor(::GetSysColor(COLOR_INFOTEXT)); memDC.SetTextColor(::GetSysColor(m_loopMode ? COLOR_WINDOWTEXT : COLOR_INFOTEXT));
memDC.SetBkMode(TRANSPARENT); memDC.SetBkMode(TRANSPARENT);
if (m_loopMode) {
// 循环模式:基于 client rect 动态分配;图像区按 client 大小自适应,文本固定在底部
CRect rcImg, rcText;
LayoutForLoopMode(rcClient, rcImg, rcText);
if (!rcImg.IsRectEmpty()) DrawImageArea(memDC, rcImg);
if (!rcText.IsRectEmpty()) DrawTextArea(memDC, rcText);
} else {
// 单发 tooltip保持原行为按 RecalcLayoutAndResize 算好的固定尺寸排版
int curY = PADDING; int curY = PADDING;
if (m_imageDrawW > 0 && m_imageDrawH > 0) { if (m_imageDrawW > 0 && m_imageDrawH > 0) {
CRect rcImg(PADDING, curY, PADDING + m_imageDrawW, curY + m_imageDrawH); CRect rcImg(PADDING, curY, PADDING + m_imageDrawW, curY + m_imageDrawH);
DrawImageArea(memDC, rcImg); DrawImageArea(memDC, rcImg);
curY += m_imageDrawH + IMAGE_TEXT_GAP; curY += m_imageDrawH + IMAGE_TEXT_GAP;
} }
CRect rcText(PADDING, curY, PADDING + m_textW, curY + m_textH); CRect rcText(PADDING, curY, PADDING + m_textW, curY + m_textH);
DrawTextArea(memDC, rcText); DrawTextArea(memDC, rcText);
}
memDC.SelectObject(oldFont); memDC.SelectObject(oldFont);
pdc.BitBlt(0, 0, rcClient.Width(), rcClient.Height(), &memDC, 0, 0, SRCCOPY); pdc.BitBlt(0, 0, rcClient.Width(), rcClient.Height(), &memDC, 0, 0, SRCCOPY);
memDC.SelectObject(oldBmp); memDC.SelectObject(oldBmp);
} }
void CPreviewTipWnd::LayoutForLoopMode(const CRect& client, CRect& outImg, CRect& outText)
{
outImg.SetRectEmpty();
outText.SetRectEmpty();
int innerW = client.Width() - 2 * PADDING;
if (innerW < 50) return; // 太窄,啥也别画,避免计算崩溃
// 计算文本以当前可用宽度换行后的高度(用一份临时 DC
int textH = 0;
{
CClientDC dc(this);
CFont* old = dc.SelectObject(&m_font);
CRect rcCalc(0, 0, innerW, 32767);
::DrawTextW(dc.GetSafeHdc(), m_text, m_text.GetLength(), &rcCalc,
DT_CALCRECT | DT_LEFT | DT_WORDBREAK | DT_NOPREFIX);
textH = rcCalc.Height();
dc.SelectObject(old);
}
// 文本固定在底部
outText.SetRect(client.left + PADDING,
client.bottom - PADDING - textH,
client.right - PADDING,
client.bottom - PADDING);
// 图像区占顶部剩余空间
outImg.SetRect(client.left + PADDING,
client.top + PADDING,
client.right - PADDING,
outText.top - IMAGE_TEXT_GAP);
// 高度不足以放图像(被压扁)→ 只画文本
if (outImg.Height() < 60) {
outImg.SetRectEmpty();
// 文本提到中间,避免压在底部边缘
outText.MoveToY(client.top + PADDING);
}
}
void CPreviewTipWnd::DrawImageArea(CDC& dc, const CRect& rc) void CPreviewTipWnd::DrawImageArea(CDC& dc, const CRect& rc)
{ {
if (rc.IsRectEmpty()) return;
// 边框 // 边框
dc.Draw3dRect(&rc, ::GetSysColor(COLOR_3DSHADOW), ::GetSysColor(COLOR_3DSHADOW)); dc.Draw3dRect(&rc, ::GetSysColor(COLOR_3DSHADOW), ::GetSysColor(COLOR_3DSHADOW));
CRect rcInner = rc;
rcInner.DeflateRect(1, 1);
if (m_hasImage && m_image) { if (m_hasImage && m_image) {
if (m_loopMode) {
// 循环模式:保留长宽比适配 rect黑色背景充当 letterbox 留白
dc.FillSolidRect(&rcInner, RGB(32, 32, 32));
double imgW = (double)m_image->GetWidth();
double imgH = (double)m_image->GetHeight();
if (imgW > 0 && imgH > 0 && rcInner.Width() > 0 && rcInner.Height() > 0) {
double sx = rcInner.Width() / imgW;
double sy = rcInner.Height() / imgH;
double scale = sx < sy ? sx : sy;
int drawW = (int)(imgW * scale + 0.5);
int drawH = (int)(imgH * scale + 0.5);
int x = rcInner.left + (rcInner.Width() - drawW) / 2;
int y = rcInner.top + (rcInner.Height() - drawH) / 2;
Graphics g(dc.GetSafeHdc());
g.SetInterpolationMode(InterpolationModeHighQualityBicubic);
g.SetSmoothingMode(SmoothingModeHighQuality);
g.DrawImage(m_image.get(), x, y, drawW, drawH);
}
} else {
// 单发:与原行为一致,铺满 rc
Graphics g(dc.GetSafeHdc()); Graphics g(dc.GetSafeHdc());
g.SetInterpolationMode(InterpolationModeHighQualityBicubic); g.SetInterpolationMode(InterpolationModeHighQualityBicubic);
g.SetSmoothingMode(SmoothingModeHighQuality); g.SetSmoothingMode(SmoothingModeHighQuality);
g.DrawImage(m_image.get(), rc.left + 1, rc.top + 1, rc.Width() - 2, rc.Height() - 2); g.DrawImage(m_image.get(), rc.left + 1, rc.top + 1, rc.Width() - 2, rc.Height() - 2);
}
} else { } else {
// 占位灰色背景 // 占位灰色背景
CRect rcInner = rc;
rcInner.DeflateRect(1, 1);
dc.FillSolidRect(&rcInner, RGB(245, 245, 245)); dc.FillSolidRect(&rcInner, RGB(245, 245, 245));
const wchar_t* placeholder = m_unavailable ? L"Preview Unavailable" : L"Loading Preview ..."; const wchar_t* placeholder = m_unavailable ? L"Preview Unavailable" : L"Loading Preview ...";
@@ -231,10 +391,29 @@ void CPreviewTipWnd::DrawImageArea(CDC& dc, const CRect& rc)
dc.SetTextColor(m_unavailable ? RGB(160, 80, 80) : RGB(120, 120, 120)); dc.SetTextColor(m_unavailable ? RGB(160, 80, 80) : RGB(120, 120, 120));
RECT rcInnerRaw = rcInner; RECT rcInnerRaw = rcInner;
::DrawTextW(dc.GetSafeHdc(), placeholder, -1, &rcInnerRaw, fmt); ::DrawTextW(dc.GetSafeHdc(), placeholder, -1, &rcInnerRaw, fmt);
dc.SetTextColor(::GetSysColor(COLOR_INFOTEXT)); dc.SetTextColor(::GetSysColor(m_loopMode ? COLOR_WINDOWTEXT : COLOR_INFOTEXT));
} }
} }
void CPreviewTipWnd::OnSize(UINT nType, int cx, int cy)
{
CWnd::OnSize(nType, cx, cy);
if (m_loopMode && GetSafeHwnd()) {
// 循环模式:所有排版交给 OnPaint 读取 client rect重画即可
Invalidate();
}
}
void CPreviewTipWnd::OnGetMinMaxInfo(MINMAXINFO* lpMMI)
{
if (m_loopMode && lpMMI) {
// 防止用户把窗口拽到肉眼不可见。240x200 大致能保证标题栏 + 一行文本 + 缩略图可见。
lpMMI->ptMinTrackSize.x = 240;
lpMMI->ptMinTrackSize.y = 200;
}
CWnd::OnGetMinMaxInfo(lpMMI);
}
void CPreviewTipWnd::DrawTextArea(CDC& dc, const CRect& rc) void CPreviewTipWnd::DrawTextArea(CDC& dc, const CRect& rc)
{ {
RECT r = rc; RECT r = rc;

View File

@@ -20,7 +20,12 @@ public:
// text 下方显示的主机详情文本(宽字符,确保跨语言系统正确渲染) // text 下方显示的主机详情文本(宽字符,确保跨语言系统正确渲染)
// imageReserveW 上方图像区域预留宽度(即将到来的预览最大宽度,仅作初始布局) // imageReserveW 上方图像区域预留宽度(即将到来的预览最大宽度,仅作初始布局)
// 为 0 表示不预留 — 与老 STATIC 路径行为一致(仅文本) // 为 0 表示不预留 — 与老 STATIC 路径行为一致(仅文本)
BOOL Create(CWnd* pParent, CPoint anchor, const CStringW& text, int imageReserveW); // loopMode true: 真正的可拖拽/缩放/最小化/最大化的独立窗口("播放快照"循环),
// 带标题栏 + 任务栏图标,关闭按钮走系统菜单;
// false: 单发 tooltip双击主机的预览无 chrome、不激活、置顶。
// windowTitle loopMode=true 时显示在标题栏与任务栏的文本false 时忽略。
BOOL Create(CWnd* pParent, CPoint anchor, const CStringW& text, int imageReserveW,
bool loopMode = false, const CStringW& windowTitle = L"");
// 收到 JPEG 后调用:解码并重画。线程安全前提是只在主 UI 线程调用。 // 收到 JPEG 后调用:解码并重画。线程安全前提是只在主 UI 线程调用。
void SetImageFromJpeg(const BYTE* data, size_t bytes); void SetImageFromJpeg(const BYTE* data, size_t bytes);
@@ -30,15 +35,28 @@ public:
WORD GetReqId() const { return m_reqId; } WORD GetReqId() const { return m_reqId; }
void SetReqId(WORD id) { m_reqId = id; } void SetReqId(WORD id) { m_reqId = id; }
// 循环快照模式:开启后窗口销毁时会向 ownerHwnd 发送 msgIdLPARAM 是堆上的
// uint64_t* 携带 clientId接收方负责 delete且 PostNcDestroy 会自动 delete this。
// 调用方需在自行销毁前用 SetLoopOwner(NULL, 0, 0) 解除回调,避免重复通知。
void SetLoopOwner(HWND ownerHwnd, UINT msgId, uint64_t clientId);
uint64_t GetClientID() const { return m_clientId; }
bool IsLoopMode() const { return m_loopMode; }
protected: protected:
afx_msg void OnPaint(); afx_msg void OnPaint();
afx_msg BOOL OnEraseBkgnd(CDC* pDC); afx_msg BOOL OnEraseBkgnd(CDC* pDC);
afx_msg void OnDestroy();
afx_msg void OnSize(UINT nType, int cx, int cy);
afx_msg void OnGetMinMaxInfo(MINMAXINFO* lpMMI);
virtual void PostNcDestroy();
DECLARE_MESSAGE_MAP() DECLARE_MESSAGE_MAP()
private: private:
void RecalcLayoutAndResize(); void RecalcLayoutAndResize();
void DrawImageArea(CDC& dc, const CRect& rc); void DrawImageArea(CDC& dc, const CRect& rc);
void DrawTextArea(CDC& dc, const CRect& rc); void DrawTextArea(CDC& dc, const CRect& rc);
// 循环模式:基于当前 client rect 重新分配图像区/文本区
void LayoutForLoopMode(const CRect& client, CRect& outImg, CRect& outText);
CStringW m_text; CStringW m_text;
int m_imageReserveW = 0; // 预留图像宽度(图像未到达时占位) int m_imageReserveW = 0; // 预留图像宽度(图像未到达时占位)
@@ -54,4 +72,14 @@ private:
CFont m_font; CFont m_font;
WORD m_reqId = 0; WORD m_reqId = 0;
// 循环快照模式相关
bool m_loopMode = false;
HWND m_loopOwner = nullptr;
UINT m_loopMsg = 0;
uint64_t m_clientId = 0;
// 标题栏 / 任务栏图标。WM_SETICON 不转移所有权MSDNoriginator 必须自己释放),
// 析构时 DestroyIcon不能在 OnDestroy 里销毁,因为系统在 WM_NCDESTROY 之前还在用它。
HICON m_hIconSmall = nullptr;
HICON m_hIconBig = nullptr;
}; };

View File

@@ -1836,3 +1836,12 @@ IOCP
推荐: 拷贝到 macOS 后运行 install.sh 安装 (脚本会自动重签)。=Recommended: Copy to macOS and run install.sh (the script re-signs automatically). 推荐: 拷贝到 macOS 后运行 install.sh 安装 (脚本会自动重签)。=Recommended: Copy to macOS and run install.sh (the script re-signs automatically).
或手动重签:=Or re-sign manually: 或手动重签:=Or re-sign manually:
<请输入文本用于替换远程剪切板>=<Please input text to replace remote clipboard> <请输入文本用于替换远程剪切板>=<Please input text to replace remote clipboard>
; Screen Preview Loop - English Translation
; Format: Simplified Chinese=English
主机:=Host:
分辨率:=Resolution:
有 %d 个主机不支持屏幕预览,已跳过=%d host(s) do not support screen preview, skipped
播放快照=Play Snapshot
快照=Snapshot

View File

@@ -1827,3 +1827,12 @@ IOCP
推荐: 拷贝到 macOS 后运行 install.sh 安装 (脚本会自动重签)。=推薦: 複製到 macOS 後執行 install.sh 安裝 (腳本會自動重新簽章)。 推荐: 拷贝到 macOS 后运行 install.sh 安装 (脚本会自动重签)。=推薦: 複製到 macOS 後執行 install.sh 安裝 (腳本會自動重新簽章)。
或手动重签:=或手動重新簽章: 或手动重签:=或手動重新簽章:
<请输入文本用于替换远程剪切板>=<请输入文本用于替换远程剪切板> <请输入文本用于替换远程剪切板>=<请输入文本用于替换远程剪切板>
; Screen Preview Loop - Traditional Chinese Translation
; Format: Simplified Chinese=Traditional Chinese
主机:=主機:
分辨率:=解析度:
有 %d 个主机不支持屏幕预览,已跳过=有 %d 個主機不支援螢幕預覽,已跳過
播放快照=播放快照
快照=快照

Binary file not shown.

After

Width:  |  Height:  |  Size: 822 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

View File

@@ -255,6 +255,8 @@
#define IDR_MACOS_GHOST 372 #define IDR_MACOS_GHOST 372
#define IDB_BITMAP_WEBDESKTOP 373 #define IDB_BITMAP_WEBDESKTOP 373
#define IDB_BITMAP_PLUGINCONFIG 374 #define IDB_BITMAP_PLUGINCONFIG 374
#define IDB_BITMAP_SNAPSHOT 375
#define IDI_ICON_SNAPSHOT 376
#define IDR_LANG_EN_US 380 #define IDR_LANG_EN_US 380
#define IDR_LANG_ZH_TW 381 #define IDR_LANG_ZH_TW 381
#define IDC_MESSAGE 1000 #define IDC_MESSAGE 1000
@@ -974,14 +976,16 @@
#define ID_33046 33046 #define ID_33046 33046
#define ID_PROXY_PORT_AUTORUN 33047 #define ID_PROXY_PORT_AUTORUN 33047
#define ID_TRIGGER_SETTINGS 33048 #define ID_TRIGGER_SETTINGS 33048
#define ID_33048 33048
#define ID_SCREENPREVIEW_LOOP 33049
#define ID_EXIT_FULLSCREEN 40001 #define ID_EXIT_FULLSCREEN 40001
// Next default values for new objects // Next default values for new objects
// //
#ifdef APSTUDIO_INVOKED #ifdef APSTUDIO_INVOKED
#ifndef APSTUDIO_READONLY_SYMBOLS #ifndef APSTUDIO_READONLY_SYMBOLS
#define _APS_NEXT_RESOURCE_VALUE 373 #define _APS_NEXT_RESOURCE_VALUE 377
#define _APS_NEXT_COMMAND_VALUE 33048 #define _APS_NEXT_COMMAND_VALUE 33050
#define _APS_NEXT_CONTROL_VALUE 2542 #define _APS_NEXT_CONTROL_VALUE 2542
#define _APS_NEXT_SYMED_VALUE 105 #define _APS_NEXT_SYMED_VALUE 105
#endif #endif

View File

@@ -102,6 +102,7 @@
#define WM_DISCONNECT WM_USER+3032 #define WM_DISCONNECT WM_USER+3032
#define WM_OPENTERMINALDIALOG WM_USER+3033 #define WM_OPENTERMINALDIALOG WM_USER+3033
#define WM_PREVIEW_RESPONSE WM_USER+3034 #define WM_PREVIEW_RESPONSE WM_USER+3034
#define WM_PREVIEW_LOOP_CLOSED WM_USER+3035
#ifdef _UNICODE #ifdef _UNICODE
#if defined _M_IX86 #if defined _M_IX86