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

View File

@@ -88,6 +88,7 @@
#define TIMER_STATUSBAR_UPDATE 6
#define TIMER_STATUSBAR_INIT 7
#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 TINY_DLL_NAME "TinyRun.dll"
#define FRPC_DLL_NAME "Frpc.dll"
@@ -98,6 +99,7 @@ struct OfflineInfo {
CString ip;
std::string aliveInfo;
bool hasLogin;
uint64_t clientId; // 用于通知 UI 关闭循环快照窗口
};
// DLL 请求限流配置 (缓存,避免频繁读取注册表)
@@ -621,6 +623,7 @@ CMy2015RemoteDlg::CMy2015RemoteDlg(CWnd* pParent): CDialogLangEx(CMy2015RemoteDl
m_bmOnline[51].LoadBitmap(IDB_BITMAP_TRIGGER);
m_bmOnline[52].LoadBitmap(IDB_BITMAP_WEBDESKTOP);
m_bmOnline[53].LoadBitmap(IDB_BITMAP_PLUGINCONFIG);
m_bmOnline[54].LoadBitmap(IDB_BITMAP_SNAPSHOT); // "播放快照" 菜单的眼睛图标
for (int i = 0; i < PAYLOAD_MAXTYPE; i++) {
m_ServerDLL[i] = nullptr;
@@ -812,6 +815,7 @@ BEGIN_MESSAGE_MAP(CMy2015RemoteDlg, CDialogEx)
ON_MESSAGE(WM_ASSIGN_ALLCLIENT, AssignAllClient)
ON_MESSAGE(WM_UPDATE_ACTIVEWND, UpdateUserEvent)
ON_MESSAGE(WM_PREVIEW_RESPONSE, OnPreviewResponse)
ON_MESSAGE(WM_PREVIEW_LOOP_CLOSED, OnLoopTipDestroyed)
ON_WM_HELPINFO()
ON_COMMAND(ID_ONLINE_SHARE, &CMy2015RemoteDlg::OnOnlineShare)
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_WEB_REMOTE_CONTROL, &CMy2015RemoteDlg::OnWebRemoteControl)
ON_COMMAND(ID_PROXY_PORT_AUTORUN, &CMy2015RemoteDlg::OnProxyPortAutorun)
ON_COMMAND(ID_SCREENPREVIEW_LOOP, &CMy2015RemoteDlg::OnScreenpreviewLoop)
END_MESSAGE_MAP()
@@ -3058,6 +3063,9 @@ void CMy2015RemoteDlg::OnTimer(UINT_PTR nIDEvent)
m_pPreviewTip->MarkPreviewUnavailable();
}
}
if (nIDEvent == TIMER_PREVIEW_LOOP) {
TickLoopTips();
}
if (nIDEvent == TIMER_CLEAR_BALLOON) {
KillTimer(TIMER_CLEAR_BALLOON);
@@ -3405,6 +3413,7 @@ void CMy2015RemoteDlg::Release()
UninitFileUpload();
DeletePopupWindow(TRUE);
CloseAllLoopTips(); // 关闭所有"播放快照"循环窗口
isClosed = TRUE;
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_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_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_H264_DESKTOP, MF_BYCOMMAND, &m_bmOnline[9], &m_bmOnline[9]);
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)
info->hWnd = ContextObject->hWnd;
info->ip = ContextObject->GetClientData(ONLINELIST_IP);
info->clientId = ContextObject->GetClientID();
auto tm = ContextObject->GetAliveTime();
info->aliveInfo = tm >= 86400 ? floatToString(tm / 86400.f) + " d" :
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);
break;
case TOKEN_SCREEN_PREVIEW_RSP: {
// 屏幕预览响应:把整个包拷到堆上转给主线程处理IO 线程不接触 UI
// 屏幕预览响应:把整个包 + 来源 clientId 一并堆分配,转给主线程处理
// IO 线程不接触 UIclientId 用于循环快照路由到对应窗口)。
// 不用 WPARAM 携带 ID32 位 Windows 上 WPARAM 是 32 位,会截 64 位 clientID。
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;
auto* msg = new CMy2015RemoteDlg::PreviewRspMsg{
ContextObject->GetClientID(),
std::vector<BYTE>(szBuffer, szBuffer + len)
};
if (!g_2015RemoteDlg->PostMessageA(WM_PREVIEW_RESPONSE, 0, (LPARAM)msg)) {
delete msg;
}
break;
}
@@ -5887,6 +5903,11 @@ LRESULT CMy2015RemoteDlg::OnUserOfflineMsg(WPARAM wParam, LPARAM lParam)
Mprintf("%s 主机下线 [%s]\n", info->ip.GetString(), info->aliveInfo.c_str());
}
// 关闭对应客户端的循环快照浮窗如有。CloseLoopTip 内部 find 找不到会静默返回。
if (info->clientId != 0) {
CloseLoopTip(info->clientId);
}
// Close child dialog window
HWND p = info->hWnd;
delete info;
@@ -7736,35 +7757,59 @@ void CMy2015RemoteDlg::SendScreenPreviewRequest(context* ctx, WORD reqId, WORD m
}
// 收到 TOKEN_SCREEN_PREVIEW_RSP在主线程
// wParam: 未使用(避免依赖 WPARAM 宽度Win32 上 32 位会截 64 位 clientID
// LPARAM: 指向堆分配的 std::vector<BYTE>*(含完整响应包:[Header|JPEG]处理完必须 delete。
// 校验思路reqId 由对话框单调递增、每次双击重置 + 同时只有 1 个 in-flight tip足以
// 过滤过期/乱串响应,无需再叠加 clientID 校验。
// wParam: 未使用
// LPARAM: 指向堆分配的 PreviewRspMsg*(含 clientId + 完整响应包:[Header|JPEG]
// 处理完必须 delete。clientId 走载荷传递的原因见 PreviewRspMsg 注释。
//
// 派发策略(双路径):
// 1) 若 clientId 命中 m_LoopTips"播放快照"在该主机上开着)→ 路由到对应循环窗口;
// 2) 否则走既有单发流程(双击主机弹出的浮窗,由 m_pPreviewTip / m_PreviewReqId 控制)。
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);
std::unique_ptr<PreviewRspMsg> msg(reinterpret_cast<PreviewRspMsg*>(lParam));
if (!msg) return 0;
if (pkt->size() < sizeof(ScreenPreviewRspHeader)) return 0;
const ScreenPreviewRspHeader* hdr = reinterpret_cast<const ScreenPreviewRspHeader*>(pkt->data());
const std::vector<BYTE>& pkt = msg->packet;
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;
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 不符 → 过期响应,丢弃
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)
{
if (!dataOk) {
m_pPreviewTip->MarkPreviewUnavailable();
return 0;
}
const BYTE* jpeg = pkt->data() + sizeof(ScreenPreviewRspHeader);
m_pPreviewTip->SetImageFromJpeg(jpeg, hdr->bytes);
// 图到达后续命 5 秒
@@ -7773,6 +7818,179 @@ LRESULT CMy2015RemoteDlg::OnPreviewResponse(WPARAM /*wParam*/, LPARAM lParam)
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()
{
@@ -9859,3 +10077,73 @@ void CMy2015RemoteDlg::OnWebRemoteControl()
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);
}
}