#include #include #include #include #include #include #include #include #include #include #include #include #include #include #include // ───────────────────────────────────────────────────────────────────────────── // Global mouse-hook state // ───────────────────────────────────────────────────────────────────────────── static HHOOK g_mouseHook = nullptr; static DWORD g_mainThreadId = 0; static std::atomic g_leftDownCount{0}; static std::atomic g_leftUpCount{0}; static std::atomic g_stop{false}; static std::mutex g_stdoutMtx; static LRESULT CALLBACK LowLevelMouseProc(int nCode, WPARAM wParam, LPARAM lParam) { if (nCode >= 0) { if (wParam == WM_LBUTTONDOWN) g_leftDownCount.fetch_add(1, std::memory_order_relaxed); else if (wParam == WM_LBUTTONUP) g_leftUpCount.fetch_add(1, std::memory_order_relaxed); } return CallNextHookEx(g_mouseHook, nCode, wParam, lParam); } // ───────────────────────────────────────────────────────────────────────────── // Utilities // ───────────────────────────────────────────────────────────────────────────── static int64_t nowMs() { return static_cast( std::chrono::duration_cast( std::chrono::system_clock::now().time_since_epoch()) .count()); } static void writeJsonLine(const std::string& json) { std::lock_guard lock(g_stdoutMtx); std::cout << json << '\n'; std::cout.flush(); } static std::string jsonEscape(const std::string& s) { std::string r; r.reserve(s.size()); for (unsigned char c : s) { switch (c) { case '"': r += "\\\""; break; case '\\': r += "\\\\"; break; case '\n': r += "\\n"; break; case '\r': r += "\\r"; break; case '\t': r += "\\t"; break; default: r.push_back(static_cast(c)); break; } } return r; } static const char kBase64Chars[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; static std::string base64Encode(const uint8_t* data, size_t len) { std::string out; out.reserve(((len + 2) / 3) * 4); for (size_t i = 0; i < len; i += 3) { const uint32_t b = (static_cast(data[i]) << 16) | (i + 1 < len ? static_cast(data[i + 1]) << 8 : 0u) | (i + 2 < len ? static_cast(data[i + 2]) : 0u); out.push_back(kBase64Chars[(b >> 18) & 0x3F]); out.push_back(kBase64Chars[(b >> 12) & 0x3F]); out.push_back(i + 1 < len ? kBase64Chars[(b >> 6) & 0x3F] : '='); out.push_back(i + 2 < len ? kBase64Chars[(b ) & 0x3F] : '='); } return out; } // ───────────────────────────────────────────────────────────────────────────── // GDI+ PNG encoder CLSID // ───────────────────────────────────────────────────────────────────────────── static bool getPngClsid(CLSID& out) { UINT num = 0, sz = 0; if (Gdiplus::GetImageEncodersSize(&num, &sz) != Gdiplus::Ok || sz == 0) return false; std::vector buf(sz); auto* enc = reinterpret_cast(buf.data()); if (Gdiplus::GetImageEncoders(num, sz, enc) != Gdiplus::Ok) return false; for (UINT i = 0; i < num; ++i) { if (std::wstring(enc[i].MimeType) == L"image/png") { out = enc[i].Clsid; return true; } } return false; } // ───────────────────────────────────────────────────────────────────────────── // Standard cursor-type lookup // ───────────────────────────────────────────────────────────────────────────── static const char* standardCursorType(HCURSOR hc) { if (!hc) return nullptr; static const struct { WORD id; const char* name; } kMap[] = { {32512, "arrow"}, {32513, "text"}, {32514, "wait"}, {32515, "crosshair"}, {32516, "up-arrow"}, {32642, "resize-nwse"}, {32643, "resize-nesw"}, {32644, "resize-ew"}, {32645, "resize-ns"}, {32646, "move"}, {32648, "not-allowed"}, {32649, "pointer"}, {32650, "app-starting"}, {32651, "help"}, }; static constexpr int N = static_cast(sizeof(kMap) / sizeof(kMap[0])); static HCURSOR g_handles[N] = {}; static bool g_init = false; if (!g_init) { for (int i = 0; i < N; ++i) g_handles[i] = LoadCursor(nullptr, MAKEINTRESOURCE(kMap[i].id)); g_init = true; } for (int i = 0; i < N; ++i) if (g_handles[i] && g_handles[i] == hc) return kMap[i].name; return nullptr; } // ───────────────────────────────────────────────────────────────────────────── // Custom cursor-type detection (replicates the PowerShell heuristic) // ───────────────────────────────────────────────────────────────────────────── static const char* detectCustomCursorType( const uint32_t* pixels, int w, int h, int hotX, int hotY) { if (w < 24 || h < 24 || w > 64 || h > 64) return nullptr; if (hotX < w * 0.25 || hotX > w * 0.75) return nullptr; if (hotY < h * 0.15 || hotY > h * 0.55) return nullptr; int opaque = 0, topHalf = 0; int left = w, top = h, right = -1, bottom = -1; for (int y = 0; y < h; ++y) { for (int x = 0; x < w; ++x) { const uint8_t a = static_cast(pixels[y * w + x] >> 24); if (a <= 32) continue; ++opaque; if (y < h / 2) ++topHalf; if (x < left) left = x; if (x > right) right = x; if (y < top) top = y; if (y > bottom) bottom = y; } } if (opaque < 90 || right < left || bottom < top) return nullptr; const int ow = right - left + 1; const int oh = bottom - top + 1; if (ow < w * 0.35 || ow > w * 0.9) return nullptr; if (oh < h * 0.45 || oh > static_cast(h)) return nullptr; if (top > h * 0.45 || bottom < h * 0.65) return nullptr; return topHalf > opaque * 0.55 ? "closed-hand" : "open-hand"; } // ───────────────────────────────────────────────────────────────────────────── // Build asset JSON for the given cursor (returns empty string on failure) // // Renders the cursor via GDI DrawIconEx onto a 32-bpp transparent DIB section // and then encodes to PNG — matching the PowerShell approach of // Graphics.Clear(Transparent) + Graphics.DrawIcon(). This correctly preserves // per-pixel alpha for 32-bit cursors, unlike Gdiplus::Bitmap::FromHICON which // can produce incorrect alpha for cursor handles. // ───────────────────────────────────────────────────────────────────────────── static std::string buildAssetJson( HCURSOR hCursor, const std::string& handleStr, const CLSID& pngClsid, const char** outCustomType) { *outCustomType = nullptr; // Get hotspot and cursor dimensions from the icon info. // For color cursors hbmColor gives the size; for monochrome cursors the // mask bitmap is twice the cursor height (AND mask stacked on XOR mask). ICONINFO ii{}; if (!GetIconInfo(hCursor, &ii)) return {}; const int hotX = static_cast(ii.xHotspot); const int hotY = static_cast(ii.yHotspot); int w = 0, h = 0; if (ii.hbmColor) { BITMAP bm{}; if (GetObject(ii.hbmColor, sizeof(bm), &bm)) { w = bm.bmWidth; h = bm.bmHeight; } } if (ii.hbmMask && (w == 0 || h == 0)) { BITMAP bm{}; if (GetObject(ii.hbmMask, sizeof(bm), &bm)) { w = bm.bmWidth; h = ii.hbmColor ? bm.bmHeight : bm.bmHeight / 2; } } if (ii.hbmMask) DeleteObject(ii.hbmMask); if (ii.hbmColor) DeleteObject(ii.hbmColor); if (w <= 0 || h <= 0) return {}; // Copy the cursor handle so DrawIconEx cannot affect the live system cursor. const HICON hCopy = CopyIcon(hCursor); if (!hCopy) return {}; // Allocate a 32-bpp top-down DIB section and clear it to transparent black, // then draw the cursor with DI_NORMAL. For 32-bit alpha cursors Windows // writes correct per-pixel alpha into the high byte of each BGRA pixel. const int stride = w * 4; BITMAPINFOHEADER bih{}; bih.biSize = sizeof(bih); bih.biWidth = w; bih.biHeight = -h; // negative = top-down scanline order bih.biPlanes = 1; bih.biBitCount = 32; bih.biCompression = BI_RGB; void* pBits = nullptr; HDC hDC = CreateCompatibleDC(nullptr); HBITMAP hBmp = hDC ? CreateDIBSection(hDC, reinterpret_cast(&bih), DIB_RGB_COLORS, &pBits, nullptr, 0) : nullptr; if (!hBmp || !pBits) { if (hBmp) DeleteObject(hBmp); if (hDC) DeleteDC(hDC); DestroyIcon(hCopy); return {}; } HGDIOBJ hOld = SelectObject(hDC, hBmp); std::memset(pBits, 0, static_cast(stride * h)); // transparent black DrawIconEx(hDC, 0, 0, hCopy, w, h, 0, nullptr, DI_NORMAL); GdiFlush(); SelectObject(hDC, hOld); DeleteDC(hDC); DestroyIcon(hCopy); // GDI's 32-bit DIB stores pixels as BGRA in memory. GDI+'s // PixelFormat32bppARGB interprets each 32-bit word as 0xAARRGGBB which is // identical to BGRA on little-endian, so the alpha byte is always >> 24. { const auto* px = static_cast(pBits); *outCustomType = detectCustomCursorType(px, w, h, hotX, hotY); } // Wrap the DIB pixels in a GDI+ Bitmap (zero-copy) and save to PNG. // Keep hBmp alive until after gBmp is destroyed so pBits remains valid. std::vector pngData; { Gdiplus::Bitmap gBmp(w, h, stride, PixelFormat32bppARGB, static_cast(pBits)); if (gBmp.GetLastStatus() == Gdiplus::Ok) { IStream* pStream = nullptr; if (SUCCEEDED(CreateStreamOnHGlobal(nullptr, TRUE, &pStream))) { if (gBmp.Save(pStream, &pngClsid) == Gdiplus::Ok) { ULARGE_INTEGER sz{}; LARGE_INTEGER zero{}; pStream->Seek(zero, STREAM_SEEK_END, &sz); pStream->Seek(zero, STREAM_SEEK_SET, nullptr); pngData.resize(static_cast(sz.QuadPart)); ULONG n = 0; pStream->Read(pngData.data(), static_cast(pngData.size()), &n); pngData.resize(n); } pStream->Release(); } } } // gBmp destroyed here; pBits (owned by hBmp) still valid DeleteObject(hBmp); if (pngData.empty()) return {}; const std::string dataUrl = "data:image/png;base64," + base64Encode(pngData.data(), pngData.size()); std::string json; json.reserve(dataUrl.size() + 128); json = "{\"id\":\"" + handleStr + "\""; json += ",\"imageDataUrl\":\"" + jsonEscape(dataUrl) + "\""; json += ",\"width\":" + std::to_string(w); json += ",\"height\":" + std::to_string(h); json += ",\"hotspotX\":" + std::to_string(hotX); json += ",\"hotspotY\":" + std::to_string(hotY); if (*outCustomType) { json += ",\"cursorType\":\""; json += *outCustomType; json += "\""; } else { json += ",\"cursorType\":null"; } json += "}"; return json; } // ───────────────────────────────────────────────────────────────────────────── // Sampling loop (background thread) // ───────────────────────────────────────────────────────────────────────────── static void runSamplingLoop(int intervalMs, HWND targetWindow, const CLSID& pngClsid) { HCURSOR lastCursor = nullptr; while (!g_stop.load(std::memory_order_relaxed)) { const int downCount = g_leftDownCount.exchange(0, std::memory_order_relaxed); const int upCount = g_leftUpCount.exchange(0, std::memory_order_relaxed); CURSORINFO ci{}; ci.cbSize = sizeof(ci); if (!GetCursorInfo(&ci)) { char buf[160]; std::snprintf(buf, sizeof(buf), "{\"type\":\"error\",\"timestampMs\":%" PRId64 ",\"message\":\"GetCursorInfo failed\"}", nowMs()); writeJsonLine(buf); std::this_thread::sleep_for(std::chrono::milliseconds(intervalMs)); continue; } const bool visible = (ci.flags & CURSOR_SHOWING) != 0; const HCURSOR hc = ci.hCursor; // Handle string ("0xHEX" or empty for null cursor) char handleBuf[32] = {}; if (hc) std::snprintf(handleBuf, sizeof(handleBuf), "0x%" PRIX64, static_cast(reinterpret_cast(hc))); const std::string handleStr = hc ? handleBuf : ""; // Standard cursor type const char* cursorType = standardCursorType(hc); // Mouse button state const SHORT ks = GetAsyncKeyState(VK_LBUTTON); const bool leftDown = (ks & 0x8000) != 0; const bool leftPressed = downCount > 0 || (ks & 0x0001) != 0; const bool leftReleased = upCount > 0; // Asset — only when the cursor handle changes std::string assetJson; if (visible && hc && hc != lastCursor) { const char* customType = nullptr; assetJson = buildAssetJson(hc, handleStr, pngClsid, &customType); if (!assetJson.empty() && !cursorType && customType) cursorType = customType; lastCursor = hc; } // Window bounds std::string boundsJson = "null"; if (targetWindow && IsWindow(targetWindow)) { RECT r{}; if (GetWindowRect(targetWindow, &r)) { const int bw = r.right - r.left; const int bh = r.bottom - r.top; if (bw > 0 && bh > 0) { char buf[128]; std::snprintf(buf, sizeof(buf), "{\"x\":%ld,\"y\":%ld,\"width\":%d,\"height\":%d}", r.left, r.top, bw, bh); boundsJson = buf; } } } // Emit sample JSON std::string out; out.reserve(256); out += "{\"type\":\"sample\""; out += ",\"timestampMs\":"; out += std::to_string(nowMs()); out += ",\"x\":"; out += std::to_string(ci.ptScreenPos.x); out += ",\"y\":"; out += std::to_string(ci.ptScreenPos.y); out += ",\"visible\":"; out += visible ? "true" : "false"; out += ",\"handle\":"; out += hc ? ("\"" + handleStr + "\"") : "null"; out += ",\"cursorType\":"; out += cursorType ? ("\"" + std::string(cursorType) + "\"") : "null"; out += ",\"leftButtonDown\":"; out += leftDown ? "true" : "false"; out += ",\"leftButtonPressed\":"; out += leftPressed ? "true" : "false"; out += ",\"leftButtonReleased\":"; out += leftReleased ? "true" : "false"; out += ",\"bounds\":"; out += boundsJson; out += ",\"asset\":"; out += assetJson.empty() ? "null" : assetJson; out += "}"; writeJsonLine(out); // Exit if stdout pipe is broken (parent process died) if (std::cout.fail()) { PostThreadMessage(g_mainThreadId, WM_QUIT, 0, 0); break; } std::this_thread::sleep_for(std::chrono::milliseconds(intervalMs)); } } // ───────────────────────────────────────────────────────────────────────────── // main // ───────────────────────────────────────────────────────────────────────────── int main(int argc, char* argv[]) { if (argc < 2) { std::cerr << "Usage: cursor-sampler [windowHandle]" << std::endl; return 1; } const int intervalMs = std::max(1, std::atoi(argv[1])); HWND targetWindow = nullptr; if (argc >= 3) { const std::string arg = argv[2]; if (!arg.empty() && arg != "null") { try { const int base = (arg.rfind("0x", 0) == 0 || arg.rfind("0X", 0) == 0) ? 16 : 10; const uint64_t v = std::stoull(arg, nullptr, base); if (v) targetWindow = reinterpret_cast(static_cast(v)); } catch (...) {} } } // Initialize GDI+ Gdiplus::GdiplusStartupInput gdipInput{}; ULONG_PTR gdipToken = 0; if (Gdiplus::GdiplusStartup(&gdipToken, &gdipInput, nullptr) != Gdiplus::Ok) { std::cerr << "GDI+ init failed" << std::endl; return 1; } CLSID pngClsid{}; if (!getPngClsid(pngClsid)) { std::cerr << "PNG encoder not found" << std::endl; Gdiplus::GdiplusShutdown(gdipToken); return 1; } // Install global low-level mouse hook on this thread g_mouseHook = SetWindowsHookEx(WH_MOUSE_LL, LowLevelMouseProc, GetModuleHandle(nullptr), 0); if (!g_mouseHook) { std::cerr << "SetWindowsHookEx failed" << std::endl; Gdiplus::GdiplusShutdown(gdipToken); return 1; } // Prime GetAsyncKeyState so the first poll doesn't return stale "since-last-call" bits GetAsyncKeyState(VK_LBUTTON); // Signal readiness g_mainThreadId = GetCurrentThreadId(); { char buf[80]; std::snprintf(buf, sizeof(buf), "{\"type\":\"ready\",\"timestampMs\":%" PRId64 "}", nowMs()); writeJsonLine(buf); } // Start sampling on a background thread std::thread sampler(runSamplingLoop, intervalMs, targetWindow, std::cref(pngClsid)); // Run the message pump on the main thread — required for WH_MOUSE_LL callbacks MSG msg; while (GetMessage(&msg, nullptr, 0, 0) > 0) { TranslateMessage(&msg); DispatchMessage(&msg); } g_stop.store(true, std::memory_order_relaxed); if (sampler.joinable()) sampler.join(); UnhookWindowsHookEx(g_mouseHook); Gdiplus::GdiplusShutdown(gdipToken); return 0; }