feat(windows): add native cursor type detection via cursor-monitor.exe

Build a standalone C++ helper (same pattern as wgc-capture) that polls
GetCursorInfo() every 50ms and emits STATE:<type> to stdout — identical
protocol to the macOS Swift helper. Windows recordings now capture
correct cursor types (text, pointer, resize, etc.) instead of always
falling back to arrow.
This commit is contained in:
tokenflow1m
2026-03-15 18:12:42 +09:00
parent c7b648f403
commit 8844111584
6 changed files with 217 additions and 32 deletions
+2 -1
View File
@@ -29,5 +29,6 @@ release/**
.tmp/
.history/
# WGC native capture build artifacts
# Native capture build artifacts
electron/native/wgc-capture/build/
electron/native/cursor-monitor/build/
+42 -30
View File
@@ -710,6 +710,10 @@ function getWgcCaptureExePath() {
return resolveUnpackedAppPath('electron', 'native', 'wgc-capture', 'build', 'Release', 'wgc-capture.exe')
}
function getCursorMonitorExePath() {
return resolveUnpackedAppPath('electron', 'native', 'cursor-monitor', 'build', 'Release', 'cursor-monitor.exe')
}
async function isWgcCaptureAvailable(): Promise<boolean> {
if (process.platform !== 'win32') return false
@@ -1067,50 +1071,58 @@ async function ensureNativeCursorMonitorBinary() {
)
}
function handleCursorMonitorStdout(chunk: Buffer) {
nativeCursorMonitorOutputBuffer += chunk.toString()
const lines = nativeCursorMonitorOutputBuffer.split(/\r?\n/)
nativeCursorMonitorOutputBuffer = lines.pop() ?? ''
for (const line of lines) {
const match = line.match(/^STATE:(.+)$/)
if (!match) continue
const next = match[1].trim() as CursorVisualType
if (
next === 'arrow'
|| next === 'text'
|| next === 'pointer'
|| next === 'crosshair'
|| next === 'open-hand'
|| next === 'closed-hand'
|| next === 'resize-ew'
|| next === 'resize-ns'
|| next === 'not-allowed'
) {
if (currentCursorVisualType !== next) {
currentCursorVisualType = next
sampleCursorStateChange(next)
emitCursorStateChanged(next)
}
}
}
}
async function startNativeCursorMonitor() {
stopNativeCursorMonitor()
if (process.platform !== 'darwin') {
if (process.platform !== 'darwin' && process.platform !== 'win32') {
currentCursorVisualType = undefined
return
}
try {
const helperPath = await ensureNativeCursorMonitorBinary()
let helperPath: string
if (process.platform === 'win32') {
helperPath = getCursorMonitorExePath()
} else {
helperPath = await ensureNativeCursorMonitorBinary()
}
nativeCursorMonitorOutputBuffer = ''
currentCursorVisualType = undefined
nativeCursorMonitorProcess = spawn(helperPath, [], {
stdio: ['pipe', 'pipe', 'pipe'],
})
nativeCursorMonitorProcess.stdout.on('data', (chunk: Buffer) => {
nativeCursorMonitorOutputBuffer += chunk.toString()
const lines = nativeCursorMonitorOutputBuffer.split(/\r?\n/)
nativeCursorMonitorOutputBuffer = lines.pop() ?? ''
for (const line of lines) {
const match = line.match(/^STATE:(.+)$/)
if (!match) continue
const next = match[1].trim() as CursorVisualType
if (
next === 'arrow'
|| next === 'text'
|| next === 'pointer'
|| next === 'crosshair'
|| next === 'open-hand'
|| next === 'closed-hand'
|| next === 'resize-ew'
|| next === 'resize-ns'
|| next === 'not-allowed'
) {
if (currentCursorVisualType !== next) {
currentCursorVisualType = next
sampleCursorStateChange(next)
emitCursorStateChanged(next)
}
}
}
})
nativeCursorMonitorProcess.stdout.on('data', handleCursorMonitorStdout)
nativeCursorMonitorProcess.once('close', () => {
nativeCursorMonitorProcess = null
@@ -0,0 +1,9 @@
cmake_minimum_required(VERSION 3.20)
project(cursor-monitor LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
add_executable(cursor-monitor src/main.cpp)
target_link_libraries(cursor-monitor PRIVATE user32)
@@ -0,0 +1,59 @@
#include <windows.h>
#include <iostream>
#include <string>
#include <thread>
#include <atomic>
#include <unordered_map>
static std::atomic<bool> g_running{true};
static void stdinListener() {
std::string line;
while (std::getline(std::cin, line)) {
if (line == "stop") {
g_running.store(false);
return;
}
}
g_running.store(false);
}
int main() {
std::setvbuf(stdout, nullptr, _IONBF, 0);
std::unordered_map<HCURSOR, std::string> cursorMap;
cursorMap[LoadCursor(NULL, IDC_ARROW)] = "arrow";
cursorMap[LoadCursor(NULL, IDC_IBEAM)] = "text";
cursorMap[LoadCursor(NULL, IDC_HAND)] = "pointer";
cursorMap[LoadCursor(NULL, IDC_CROSS)] = "crosshair";
cursorMap[LoadCursor(NULL, IDC_NO)] = "not-allowed";
cursorMap[LoadCursor(NULL, IDC_SIZEWE)] = "resize-ew";
cursorMap[LoadCursor(NULL, IDC_SIZENS)] = "resize-ns";
cursorMap[LoadCursor(NULL, IDC_SIZEALL)] = "open-hand";
cursorMap[LoadCursor(NULL, IDC_WAIT)] = "arrow";
cursorMap[LoadCursor(NULL, IDC_APPSTARTING)] = "arrow";
std::thread listener(stdinListener);
listener.detach();
std::string lastType;
while (g_running.load()) {
CURSORINFO ci = {};
ci.cbSize = sizeof(ci);
if (GetCursorInfo(&ci) && (ci.flags & CURSOR_SHOWING)) {
auto it = cursorMap.find(ci.hCursor);
std::string type = (it != cursorMap.end()) ? it->second : "arrow";
if (type != lastType) {
lastType = type;
std::cout << "STATE:" << type << "\n";
}
}
Sleep(50);
}
return 0;
}
+2 -1
View File
@@ -25,8 +25,9 @@
"rebuild:native": "electron-rebuild --force --only uiohook-napi",
"build:native-helpers": "node scripts/build-native-helpers.mjs",
"build:wgc-capture": "node scripts/build-wgc-capture.mjs",
"build:cursor-monitor": "node scripts/build-cursor-monitor.mjs",
"build:mac": "npm run build:native-helpers && tsc && vite build && electron-builder --mac",
"build:win": "npm run build:wgc-capture && tsc && vite build && electron-builder --win",
"build:win": "npm run build:wgc-capture && npm run build:cursor-monitor && tsc && vite build && electron-builder --win",
"build:linux": "tsc && vite build && electron-builder --linux",
"i18n:check": "node scripts/i18n-check.mjs",
"test": "vitest --run",
+103
View File
@@ -0,0 +1,103 @@
import { execSync } from "node:child_process";
import { existsSync, mkdirSync } from "node:fs";
import path from "node:path";
const projectRoot = process.cwd();
const sourceDir = path.join(projectRoot, "electron", "native", "cursor-monitor");
const buildDir = path.join(sourceDir, "build");
if (process.platform !== "win32") {
console.log("[build-cursor-monitor] Skipping: host platform is not Windows.");
process.exit(0);
}
if (!existsSync(path.join(sourceDir, "CMakeLists.txt"))) {
console.error("[build-cursor-monitor] CMakeLists.txt not found at", sourceDir);
process.exit(1);
}
function findCmake() {
// Check PATH first
try {
execSync("cmake --version", { stdio: "pipe" });
return "cmake";
} catch {
// not on PATH
}
// VS 2022 bundled CMake
const vsEditions = ["Community", "Professional", "Enterprise", "BuildTools"];
for (const edition of vsEditions) {
const cmakePath = path.join(
"C:",
"Program Files",
"Microsoft Visual Studio",
"2022",
edition,
"Common7",
"IDE",
"CommonExtensions",
"Microsoft",
"CMake",
"CMake",
"bin",
"cmake.exe",
);
if (existsSync(cmakePath)) {
return `"${cmakePath}"`;
}
}
return null;
}
const cmake = findCmake();
if (!cmake) {
console.error(
"[build-cursor-monitor] CMake not found. Install Visual Studio with C++ CMake tools or standalone CMake.",
);
process.exit(1);
}
mkdirSync(buildDir, { recursive: true });
console.log("[build-cursor-monitor] Configuring CMake...");
try {
execSync(`${cmake} .. -G "Visual Studio 17 2022" -A x64`, {
cwd: buildDir,
stdio: "inherit",
timeout: 120000,
});
} catch {
console.log("[build-cursor-monitor] VS 2022 generator not found, trying VS 2019...");
try {
execSync(`${cmake} .. -G "Visual Studio 16 2019" -A x64`, {
cwd: buildDir,
stdio: "inherit",
timeout: 120000,
});
} catch (innerError) {
console.error("[build-cursor-monitor] CMake configure failed:", innerError.message);
process.exit(1);
}
}
console.log("[build-cursor-monitor] Building...");
try {
execSync(`${cmake} --build . --config Release`, {
cwd: buildDir,
stdio: "inherit",
timeout: 300000,
});
} catch (error) {
console.error("[build-cursor-monitor] Build failed:", error.message);
process.exit(1);
}
const exePath = path.join(buildDir, "Release", "cursor-monitor.exe");
if (existsSync(exePath)) {
console.log(`[build-cursor-monitor] Built successfully: ${exePath}`);
} else {
console.error("[build-cursor-monitor] Expected exe not found at", exePath);
process.exit(1);
}