483 lines
20 KiB
C++
483 lines
20 KiB
C++
#include <windows.h>
|
|
#include <gdiplus.h>
|
|
#include <objbase.h>
|
|
|
|
#include <atomic>
|
|
#include <algorithm>
|
|
#include <chrono>
|
|
#include <cinttypes>
|
|
#include <cstdint>
|
|
#include <cstdio>
|
|
#include <cstring>
|
|
#include <iostream>
|
|
#include <mutex>
|
|
#include <string>
|
|
#include <thread>
|
|
#include <vector>
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Global mouse-hook state
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
static HHOOK g_mouseHook = nullptr;
|
|
static DWORD g_mainThreadId = 0;
|
|
static std::atomic<int> g_leftDownCount{0};
|
|
static std::atomic<int> g_leftUpCount{0};
|
|
static std::atomic<bool> 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<int64_t>(
|
|
std::chrono::duration_cast<std::chrono::milliseconds>(
|
|
std::chrono::system_clock::now().time_since_epoch())
|
|
.count());
|
|
}
|
|
|
|
static void writeJsonLine(const std::string& json) {
|
|
std::lock_guard<std::mutex> 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<char>(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<uint32_t>(data[i]) << 16) |
|
|
(i + 1 < len ? static_cast<uint32_t>(data[i + 1]) << 8 : 0u) |
|
|
(i + 2 < len ? static_cast<uint32_t>(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<uint8_t> buf(sz);
|
|
auto* enc = reinterpret_cast<Gdiplus::ImageCodecInfo*>(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<int>(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<uint8_t>(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<double>(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<int>(ii.xHotspot);
|
|
const int hotY = static_cast<int>(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<const BITMAPINFO*>(&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<size_t>(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<const uint32_t*>(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<uint8_t> pngData;
|
|
{
|
|
Gdiplus::Bitmap gBmp(w, h, stride, PixelFormat32bppARGB,
|
|
static_cast<BYTE*>(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<size_t>(sz.QuadPart));
|
|
ULONG n = 0;
|
|
pStream->Read(pngData.data(), static_cast<ULONG>(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<uint64_t>(reinterpret_cast<uintptr_t>(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 <intervalMs> [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<HWND>(static_cast<uintptr_t>(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;
|
|
}
|