Files
openscreen/electron/native/wgc-capture/src/cursor-sampler.cpp
T
2026-05-20 11:53:50 +02:00

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;
}