diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 7014222..f3049b1 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -27,6 +27,17 @@ jobs: run: npm run patch && npm i - name: Build run: npm run build:release + - name: Test Windows host support + if: runner.os == 'Windows' + run: | + cmake -S . -B build-wsl-tests -DLIBNUT_BUILD_ADDON=OFF -DLIBNUT_BUILD_WSL_TESTS=ON + if ($LASTEXITCODE) { exit $LASTEXITCODE } + cmake --build build-wsl-tests --config Release + if ($LASTEXITCODE) { exit $LASTEXITCODE } + ctest --test-dir build-wsl-tests -C Release --output-on-failure + - name: Test Windows host protocol + if: runner.os == 'Windows' + run: npm run test:wsl-host - name: Run tests uses: GabrielBB/xvfb-action@v1 with: diff --git a/.github/workflows/tagged_release.yaml b/.github/workflows/tagged_release.yaml index d4b9546..50c6ef8 100644 --- a/.github/workflows/tagged_release.yaml +++ b/.github/workflows/tagged_release.yaml @@ -25,6 +25,17 @@ jobs: run: npm run patch && npm i - name: Build run: npm run build:release + - name: Test Windows host support + if: runner.os == 'Windows' + run: | + cmake -S . -B build-wsl-tests -DLIBNUT_BUILD_ADDON=OFF -DLIBNUT_BUILD_WSL_TESTS=ON + if ($LASTEXITCODE) { exit $LASTEXITCODE } + cmake --build build-wsl-tests --config Release + if ($LASTEXITCODE) { exit $LASTEXITCODE } + ctest --test-dir build-wsl-tests -C Release --output-on-failure + - name: Test Windows host protocol + if: runner.os == 'Windows' + run: npm run test:wsl-host - name: Run tests uses: GabrielBB/xvfb-action@v1 with: diff --git a/.gitignore b/.gitignore index 7dffdeb..212373c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,9 @@ build/ +build-wsl-tests/ node_modules/ prebuilds/ .idea .vscode .cache cmake-build-debug -.DS_Store \ No newline at end of file +.DS_Store diff --git a/.npmignore b/.npmignore index 6f77a7e..bd10846 100644 --- a/.npmignore +++ b/.npmignore @@ -11,6 +11,11 @@ build/*.vcxproj* build/*.sln build/x64/ build/libnut.dir/ +build/libnut-wsl-host.dir/ +build/libnut-wsl-host-tests.dir/ +build/Release/libnut-wsl-host-tests.exe +build/Debug/libnut-wsl-host-tests.exe +build-wsl-tests/ CMakeFiles src test diff --git a/CMakeLists.txt b/CMakeLists.txt index 6488720..5804dc1 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -3,6 +3,32 @@ set(CMAKE_CXX_STANDARD 17) project(libnut) +option(LIBNUT_BUILD_ADDON "Build the Node-API addon" ON) +option(LIBNUT_BUILD_WSL_HOST "Build the persistent Windows host executable" ${WIN32}) + +if (LIBNUT_BUILD_WSL_HOST) + if (NOT WIN32) + message(FATAL_ERROR "The WSL host must be built with a Windows toolchain") + endif() + add_executable(libnut-wsl-host src/win32/wsl_host.cc) + target_link_libraries(libnut-wsl-host PRIVATE windowsapp user32 gdi32) + target_compile_options(libnut-wsl-host PRIVATE /W4 /EHsc /utf-8) + set_property(TARGET libnut-wsl-host PROPERTY MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>") + option(LIBNUT_BUILD_WSL_TESTS "Build Windows host support tests" OFF) + if (LIBNUT_BUILD_WSL_TESTS) + add_executable(libnut-wsl-host-tests test/wsl-host-unit.cc) + target_link_libraries(libnut-wsl-host-tests PRIVATE windowsapp user32) + target_compile_options(libnut-wsl-host-tests PRIVATE /W4 /EHsc /utf-8) + set_property(TARGET libnut-wsl-host-tests PROPERTY MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>") + enable_testing() + add_test(NAME wsl-host-support COMMAND libnut-wsl-host-tests) + endif() +endif() + +if (NOT LIBNUT_BUILD_ADDON) + return() +endif() + # Source set(SOURCE_FILES src/main.cc diff --git a/README.md b/README.md index 51f2ac0..214dde0 100644 --- a/README.md +++ b/README.md @@ -24,3 +24,70 @@ npm run build:release npm install npm run build:debug ``` + +## Windows host helper for WSL + +Windows builds also produce `build/Release/libnut-wsl-host.exe`. It is a C++17 +executable used by the WSL providers in +[nut.js](https://github.com/nut-tree/nut.js). It runs independently of Node.js on +Windows and uses Windows APIs for mouse, keyboard, primary-screen capture, +clipboard text and window operations. The executable is included in the +existing Windows native package. + +For a helper-only build, use a Windows SDK and Visual C++ toolchain: + +```powershell +cmake -S . -B build -A x64 -DLIBNUT_BUILD_ADDON=OFF +cmake --build build --config Release +node test/wsl-host-protocol.js +``` + +The last command tests the protocol without capturing the desktop or sending +input. Build the helper for the same processor architecture as the Windows +native package. `LIBNUT_BUILD_WSL_HOST=OFF` disables this target when it is not +needed. + +Add `-DLIBNUT_BUILD_WSL_TESTS=ON` to configure the native support tests, then run +`ctest --test-dir build -C Release --output-on-failure` after building. These +tests check key mappings, failed-release tracking, reply limits and bounded +window waits without sending input or capturing the desktop. + +The helper must run in the signed-in user's interactive Windows session. A +service or SSH session is not a substitute. Windows may block input into +elevated applications or the secure desktop. Coordinates are physical pixels; +screen capture covers the primary monitor, as the nut.js screen interface +requires. + +### Internal protocol + +The helper reads one UTF-8 JSON request per line from standard input: + +```json +{"id":1,"command":"hello","args":{}} +``` + +Each reply starts with a UTF-8 JSON line containing the matching `id`, either +`result` or `error`, and `byteLength`. A capture reply is followed immediately +by exactly `byteLength` raw, top-down BGRA pixel bytes. Other replies have a zero +byte length. The `hello` result reports protocol version 1; clients must check +it before sending desktop commands. + +Requests execute in order. Mouse clicks and keyboard clicks are single helper +commands. Requests and JSON reply lines are limited to 1 MiB; captured pixel +data is limited to 128 MiB. An oversized reply is replaced with a bounded error +for that request, so subsequent commands can still use the connection. +Diagnostic messages go to standard error, never into the reply stream. + +Key names map to the same fixed US keyboard codes as the native backend. Text +uses Unicode input instead. Window changes are requested asynchronously and +wait at most five seconds for each state change, returning false if it does not +complete. The waits also check whether the caller has disconnected. + +Closing the input pipe cancels long typing or highlight operations and attempts +to release keys and mouse buttons held by the helper. Failed releases remain +tracked for another attempt while the helper runs. The explicit `releaseInput` +command reports failure, and shutdown reports rejected releases on standard +error. Windows restrictions can prevent cleanup from succeeding. +Clients must not automatically retry an interrupted command: its input may +already have reached an application. +The helper opens no network listener and performs no runtime compilation. diff --git a/package.json b/package.json index 71371d0..3f3f07a 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "patch": "node ./patch-packagename.js", "build:debug": "cmake-js rebuild --CDCMAKE_OSX_ARCHITECTURES=\"arm64;x86_64\" --debug", "build:release": "cmake-js rebuild --CDCMAKE_OSX_ARCHITECTURES=\"arm64;x86_64\"", + "test:wsl-host": "node test/wsl-host-protocol.js", "prepublishOnly": "npm run build:release", "publish:next": "npm publish --tag next" }, diff --git a/src/win32/wsl_host.cc b/src/win32/wsl_host.cc new file mode 100644 index 0000000..d093cc1 --- /dev/null +++ b/src/win32/wsl_host.cc @@ -0,0 +1,447 @@ +#include "wsl_host_support.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace winrt; +using namespace Windows::Data::Json; + +namespace { +constexpr size_t maxRequestBytes = 1024 * 1024; +constexpr size_t maxImageBytes = 128 * 1024 * 1024; +constexpr int protocolVersion = 1; + +void require(bool condition, const char* message) { + if (!condition) throw std::runtime_error(message); +} + +void checked(BOOL result, const char* operation) { + if (!result) throw std::runtime_error(std::string(operation) + " failed (Windows error " + std::to_string(GetLastError()) + ")"); +} + +double number(const JsonObject& value, const wchar_t* name, double minimum, double maximum) { + double result = value.GetNamedNumber(name); + require(std::isfinite(result) && result >= minimum && result <= maximum, "Number outside supported range"); + return result; +} + +int integer(const JsonObject& value, const wchar_t* name, int minimum = INT_MIN, int maximum = INT_MAX) { + double result = number(value, name, minimum, maximum); + require(std::floor(result) == result, "Expected an integer"); + return static_cast(result); +} + +JsonObject point(int x, int y) { + JsonObject result; + result.SetNamedValue(L"x", JsonValue::CreateNumberValue(x)); + result.SetNamedValue(L"y", JsonValue::CreateNumberValue(y)); + return result; +} + +JsonObject rectangle(int x, int y, int width, int height) { + JsonObject result = point(x, y); + result.SetNamedValue(L"width", JsonValue::CreateNumberValue(width)); + result.SetNamedValue(L"height", JsonValue::CreateNumberValue(height)); + return result; +} + +void send(INPUT input) { + require(SendInput(1, &input, sizeof(input)) == 1, "SendInput failed; input may be blocked by an elevated application or a non-interactive desktop"); +} + +void checkConnection() { + HANDLE input = GetStdHandle(STD_INPUT_HANDLE); + if (GetFileType(input) == FILE_TYPE_PIPE) { + DWORD available = 0; + if (!PeekNamedPipe(input, nullptr, 0, nullptr, &available, nullptr)) + throw std::runtime_error("The caller closed the command pipe"); + } +} + +void wait(DWORD milliseconds) { + checkConnection(); + while (milliseconds > 0) { + DWORD chunk = std::min(milliseconds, 100); + Sleep(chunk); + milliseconds -= chunk; + checkConnection(); + } +} + +class Host { + HWND owner = nullptr; + std::set heldKeys; + std::set heldButtons; + + static DWORD buttonFlag(int button, bool down) { + static const DWORD flags[3][2] = { + {MOUSEEVENTF_LEFTUP, MOUSEEVENTF_LEFTDOWN}, + {MOUSEEVENTF_MIDDLEUP, MOUSEEVENTF_MIDDLEDOWN}, + {MOUSEEVENTF_RIGHTUP, MOUSEEVENTF_RIGHTDOWN} + }; + require(button >= 0 && button < 3, "Invalid mouse button"); + return flags[button][down ? 1 : 0]; + } + + void button(int value, bool down) { + INPUT input{}; + input.type = INPUT_MOUSE; + input.mi.dwFlags = buttonFlag(value, down); + // Track before sending, so shutdown can release input after uncertain failures. + if (down) heldButtons.insert(value); + send(input); + if (!down) heldButtons.erase(value); + } + + static INPUT keyboardInput(WORD code, bool down) { + INPUT input{}; + input.type = INPUT_KEYBOARD; + input.ki.wVk = code; + input.ki.wScan = static_cast(MapVirtualKeyW(code, MAPVK_VK_TO_VSC)); + switch (code) { + case VK_RCONTROL: case VK_RMENU: case VK_INSERT: case VK_DELETE: + case VK_HOME: case VK_END: case VK_PRIOR: case VK_NEXT: + case VK_LEFT: case VK_RIGHT: case VK_UP: case VK_DOWN: + case VK_LWIN: case VK_RWIN: case VK_APPS: case VK_DIVIDE: + input.ki.dwFlags |= KEYEVENTF_EXTENDEDKEY; + } + if (!down) input.ki.dwFlags |= KEYEVENTF_KEYUP; + return input; + } + + void key(WORD code, bool down) { + if (down) heldKeys.insert(code); + send(keyboardInput(code, down)); + if (!down) heldKeys.erase(code); + } + + static HWND window(const JsonObject& args) { + double handle = number(args, L"handle", 0, 9007199254740991.0); + require(std::floor(handle) == handle, "Invalid window handle"); + HWND result = reinterpret_cast(static_cast(handle)); + require(IsWindow(result), "Window handle is no longer valid"); + return result; + } + + static RECT windowRect(HWND hwnd) { + RECT rect{}; + checked(GetWindowRect(hwnd, &rect), "GetWindowRect"); + return rect; + } + + template + static bool waitForWindow(HWND hwnd, Ready ready) { + checkConnection(); + return wsl::waitForState([&] { + require(IsWindow(hwnd), "Window handle is no longer valid"); + return ready(); + }, [](DWORD milliseconds) { wait(milliseconds); }, [] { return GetTickCount64(); }); + } + + static bool showWindow(HWND hwnd, bool minimize) { + if (!ShowWindowAsync(hwnd, minimize ? SW_MINIMIZE : SW_RESTORE)) return false; + return waitForWindow(hwnd, [&] { return (IsIconic(hwnd) != FALSE) == minimize; }); + } + + JsonValue clipboard(const JsonObject& args, bool write) { + checked(OpenClipboard(owner), "OpenClipboard"); + struct Close { ~Close() { CloseClipboard(); } } close; + if (!write) { + if (!IsClipboardFormatAvailable(CF_UNICODETEXT)) return JsonValue::CreateStringValue(L""); + HANDLE data = GetClipboardData(CF_UNICODETEXT); + require(data != nullptr, "GetClipboardData failed"); + const wchar_t* text = static_cast(GlobalLock(data)); + require(text != nullptr, "GlobalLock failed"); + struct Unlock { HANDLE data; ~Unlock() { GlobalUnlock(data); } } unlock{data}; + size_t count = GlobalSize(data) / sizeof(wchar_t); + size_t length = 0; + while (length < count && text[length]) ++length; + require(length < count, "Clipboard text is not terminated"); + return JsonValue::CreateStringValue(hstring(text, static_cast(length))); + } + hstring text = args.GetNamedString(L"text"); + size_t size = (static_cast(text.size()) + 1) * sizeof(wchar_t); + HGLOBAL data = GlobalAlloc(GMEM_MOVEABLE, size); + require(data != nullptr, "GlobalAlloc failed"); + void* buffer = GlobalLock(data); + if (!buffer) { GlobalFree(data); throw std::runtime_error("GlobalLock failed"); } + memcpy(buffer, text.c_str(), size); + GlobalUnlock(data); + if (!EmptyClipboard() || !SetClipboardData(CF_UNICODETEXT, data)) { + GlobalFree(data); + throw std::runtime_error("Writing clipboard text failed"); + } + return JsonValue::CreateNullValue(); + } + + static void highlight(const JsonObject& args) { + int x = integer(args, L"x"), y = integer(args, L"y"); + int width = integer(args, L"width", 1), height = integer(args, L"height", 1); + int duration = integer(args, L"duration", 0); + double opacity = number(args, L"opacity", 0, 1); + HWND overlay = CreateWindowExW(WS_EX_LAYERED | WS_EX_TOPMOST | WS_EX_TRANSPARENT | WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE, + L"LibnutWslHost", L"", WS_POPUP, x, y, width, height, nullptr, nullptr, GetModuleHandleW(nullptr), nullptr); + require(overlay != nullptr, "Creating highlight overlay failed"); + struct Destroy { HWND hwnd; ~Destroy() { DestroyWindow(hwnd); } } destroy{overlay}; + checked(SetLayeredWindowAttributes(overlay, 0, static_cast(255 * opacity), LWA_ALPHA), "SetLayeredWindowAttributes"); + ShowWindow(overlay, SW_SHOWNOACTIVATE); + UpdateWindow(overlay); + ULONGLONG deadline = GetTickCount64() + duration; + while (GetTickCount64() < deadline) { + MSG message{}; + while (PeekMessageW(&message, overlay, 0, 0, PM_REMOVE)) { + TranslateMessage(&message); + DispatchMessageW(&message); + } + wait(static_cast(std::min(10, deadline - std::min(deadline, GetTickCount64())))); + } + } + + static JsonObject capture(const JsonObject& args, std::vector& pixels) { + int screenWidth = GetSystemMetrics(SM_CXSCREEN), screenHeight = GetSystemMetrics(SM_CYSCREEN); + int x = args.HasKey(L"x") ? integer(args, L"x", 0, screenWidth - 1) : 0; + int y = args.HasKey(L"y") ? integer(args, L"y", 0, screenHeight - 1) : 0; + int width = args.HasKey(L"width") ? integer(args, L"width", 1, screenWidth - x) : screenWidth; + int height = args.HasKey(L"height") ? integer(args, L"height", 1, screenHeight - y) : screenHeight; + require(width > 0 && height > 0, "No interactive primary display is available"); + size_t bytes = static_cast(width) * height * 4; + require(bytes <= maxImageBytes, "Screenshot exceeds the protocol image limit"); + HDC screen = GetDC(nullptr); + require(screen != nullptr, "GetDC failed"); + struct Release { HDC dc; ~Release() { ReleaseDC(nullptr, dc); } } release{screen}; + HDC memory = CreateCompatibleDC(screen); + require(memory != nullptr, "CreateCompatibleDC failed"); + struct DeleteDc { HDC dc; ~DeleteDc() { DeleteDC(dc); } } deleteDc{memory}; + BITMAPINFO info{}; + info.bmiHeader.biSize = sizeof(BITMAPINFOHEADER); + info.bmiHeader.biWidth = width; + info.bmiHeader.biHeight = -height; + info.bmiHeader.biPlanes = 1; + info.bmiHeader.biBitCount = 32; + info.bmiHeader.biCompression = BI_RGB; + void* data = nullptr; + HBITMAP bitmap = CreateDIBSection(screen, &info, DIB_RGB_COLORS, &data, nullptr, 0); + require(bitmap != nullptr && data != nullptr, "CreateDIBSection failed"); + struct DeleteBitmap { HBITMAP bitmap; ~DeleteBitmap() { DeleteObject(bitmap); } } deleteBitmap{bitmap}; + HGDIOBJ previous = SelectObject(memory, bitmap); + require(previous != nullptr && previous != HGDI_ERROR, "SelectObject failed"); + struct Restore { HDC dc; HGDIOBJ object; ~Restore() { SelectObject(dc, object); } } restore{memory, previous}; + checked(BitBlt(memory, 0, 0, width, height, screen, x, y, SRCCOPY | CAPTUREBLT), "BitBlt"); + checked(GdiFlush(), "GdiFlush"); + const uint8_t* start = static_cast(data); + pixels.assign(start, start + bytes); + for (size_t index = 3; index < bytes; index += 4) pixels[index] = 255; + JsonObject result = rectangle(0, 0, width, height); + result.SetNamedValue(L"byteWidth", JsonValue::CreateNumberValue(static_cast(width) * 4)); + return result; + } + +public: + Host() { + if (!SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2)) { + require(SetThreadDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2) != nullptr, + "Cannot configure physical-pixel desktop coordinates"); + } + WNDCLASSW type{}; + type.lpfnWndProc = DefWindowProcW; + type.hInstance = GetModuleHandleW(nullptr); + type.lpszClassName = L"LibnutWslHost"; + type.hbrBackground = reinterpret_cast(GetStockObject(BLACK_BRUSH)); + require(RegisterClassW(&type) != 0, "RegisterClass failed"); + owner = CreateWindowW(type.lpszClassName, L"", 0, 0, 0, 0, 0, HWND_MESSAGE, nullptr, type.hInstance, nullptr); + require(owner != nullptr, "Creating clipboard owner failed"); + } + + ~Host() { + if (!releaseInput()) std::cerr << "libnut Windows host: Windows rejected releasing held input during shutdown\n"; + if (owner) DestroyWindow(owner); + UnregisterClassW(L"LibnutWslHost", GetModuleHandleW(nullptr)); + } + + bool releaseInput() noexcept { + bool keysReleased = wsl::releaseHeldInputs(heldKeys, [](WORD code) { + INPUT input = keyboardInput(code, false); + return SendInput(1, &input, sizeof(input)) == 1; + }); + bool buttonsReleased = wsl::releaseHeldInputs(heldButtons, [](int value) { + INPUT input{}; input.type = INPUT_MOUSE; input.mi.dwFlags = buttonFlag(value, false); + return SendInput(1, &input, sizeof(input)) == 1; + }); + return keysReleased && buttonsReleased; + } + + IJsonValue execute(const hstring& command, const JsonObject& args, std::vector& pixels) { + int postDelay = args.HasKey(L"delay") ? integer(args, L"delay", 0, 60000) : 0; + if (command == L"hello") { + JsonObject result; + result.SetNamedValue(L"protocol", JsonValue::CreateNumberValue(protocolVersion)); + result.SetNamedValue(L"pid", JsonValue::CreateNumberValue(GetCurrentProcessId())); + return result; + } + if (command == L"screenSize") return rectangle(0, 0, GetSystemMetrics(SM_CXSCREEN), GetSystemMetrics(SM_CYSCREEN)); + if (command == L"capture") return capture(args, pixels); + if (command == L"highlight") { highlight(args); return JsonValue::CreateNullValue(); } + if (command == L"cursorPosition") { + POINT position{}; checked(GetCursorPos(&position), "GetCursorPos"); + return point(position.x, position.y); + } + if (command == L"moveMouse") { + checked(SetCursorPos(integer(args, L"x"), integer(args, L"y")), "SetCursorPos"); + } else if (command == L"mouseButton") { + button(integer(args, L"button", 0, 2), args.GetNamedBoolean(L"down")); + } else if (command == L"click") { + int value = integer(args, L"button", 0, 2); + int count = integer(args, L"count", 1, 2); + try { + for (int index = 0; index < count; ++index) { + button(value, true); button(value, false); + if (index + 1 < count) wait(std::min(50, GetDoubleClickTime() / 2)); + } + } catch (...) { releaseInput(); throw; } + } else if (command == L"scroll") { + int amount = integer(args, L"amount"); + INPUT input{}; input.type = INPUT_MOUSE; + input.mi.dwFlags = args.GetNamedBoolean(L"horizontal") ? MOUSEEVENTF_HWHEEL : MOUSEEVENTF_WHEEL; + input.mi.mouseData = static_cast(amount); + send(input); + } else if (command == L"keys" || command == L"keyClick") { + JsonArray names = args.GetNamedArray(L"keys"); + require(names.Size() > 0 && names.Size() <= 32, "Expected 1 to 32 keys"); + std::vector codes; + for (const auto& name : names) codes.push_back(wsl::keyCode(to_string(name.GetString()))); + bool down = command == L"keyClick" || args.GetNamedBoolean(L"down"); + if (!down) std::reverse(codes.begin(), codes.end()); + try { + for (WORD code : codes) key(code, down); + if (command == L"keyClick") { + std::reverse(codes.begin(), codes.end()); + for (WORD code : codes) key(code, false); + } + } + catch (...) { releaseInput(); throw; } + } else if (command == L"type") { + hstring text = args.GetNamedString(L"text"); + for (wchar_t character : text) { + checkConnection(); + INPUT input{}; input.type = INPUT_KEYBOARD; input.ki.wScan = character; + input.ki.dwFlags = KEYEVENTF_UNICODE; + send(input); + input.ki.dwFlags |= KEYEVENTF_KEYUP; + send(input); + if (postDelay) wait(postDelay); + } + return JsonValue::CreateNullValue(); + } else if (command == L"clipboardRead") return clipboard(args, false); + else if (command == L"clipboardWrite") return clipboard(args, true); + else if (command == L"clipboardHasText") return JsonValue::CreateBooleanValue(!clipboard(args, false).GetString().empty()); + else if (command == L"clipboardClear") { + checked(OpenClipboard(owner), "OpenClipboard"); + BOOL result = EmptyClipboard(); + CloseClipboard(); + return JsonValue::CreateBooleanValue(result != FALSE); + } + else if (command == L"windows") { + std::vector handles; + checked(EnumWindows([](HWND hwnd, LPARAM context) -> BOOL { + reinterpret_cast*>(context)->push_back(hwnd); return TRUE; + }, reinterpret_cast(&handles)), "EnumWindows"); + JsonArray result; + for (HWND hwnd : handles) result.Append(JsonValue::CreateNumberValue(static_cast(reinterpret_cast(hwnd)))); + return result; + } else if (command == L"activeWindow") return JsonValue::CreateNumberValue(static_cast(reinterpret_cast(GetForegroundWindow()))); + else if (command == L"windowTitle") { + HWND hwnd = window(args); + std::vector text(static_cast(GetWindowTextLengthW(hwnd)) + 1); + int size = GetWindowTextW(hwnd, text.data(), static_cast(text.size())); + return JsonValue::CreateStringValue(hstring(text.data(), size)); + } else if (command == L"windowRegion") { + RECT rect = windowRect(window(args)); + return rectangle(rect.left, rect.top, rect.right - rect.left, rect.bottom - rect.top); + } else if (command == L"focusWindow") { + HWND hwnd = window(args); + if (IsIconic(hwnd) && !showWindow(hwnd, false)) return JsonValue::CreateBooleanValue(false); + SetForegroundWindow(hwnd); + return JsonValue::CreateBooleanValue(waitForWindow(hwnd, [&] { return GetForegroundWindow() == hwnd; })); + } else if (command == L"moveWindow" || command == L"resizeWindow") { + HWND hwnd = window(args); RECT rect = windowRect(hwnd); + int x = command == L"moveWindow" ? integer(args, L"x") : rect.left; + int y = command == L"moveWindow" ? integer(args, L"y") : rect.top; + int width = command == L"resizeWindow" ? integer(args, L"width", 1) : rect.right - rect.left; + int height = command == L"resizeWindow" ? integer(args, L"height", 1) : rect.bottom - rect.top; + UINT flags = SWP_ASYNCWINDOWPOS | SWP_NOACTIVATE | SWP_NOZORDER | + (command == L"moveWindow" ? SWP_NOSIZE : SWP_NOMOVE); + if (!SetWindowPos(hwnd, nullptr, x, y, width, height, flags)) return JsonValue::CreateBooleanValue(false); + return JsonValue::CreateBooleanValue(waitForWindow(hwnd, [&] { + RECT current = windowRect(hwnd); + return command == L"moveWindow" ? current.left == x && current.top == y : + current.right - current.left == width && current.bottom - current.top == height; + })); + } else if (command == L"minimizeWindow" || command == L"restoreWindow") { + HWND hwnd = window(args); + return JsonValue::CreateBooleanValue(showWindow(hwnd, command == L"minimizeWindow")); + } else if (command == L"releaseInput") require(releaseInput(), "Windows rejected releasing held input; failed releases remain tracked for another attempt"); + else throw std::runtime_error("Unknown Windows host command"); + if (postDelay) wait(postDelay); + return JsonValue::CreateNullValue(); + } +}; + +bool readRequest(std::string& line) { + line.clear(); + char value; + while (std::cin.get(value)) { + if (value == '\n') return true; + require(line.size() < maxRequestBytes, "Request exceeds the protocol limit"); + line.push_back(value); + } + require(line.empty(), "Incomplete request at end of input"); + return false; +} +} + +int main() { + _setmode(_fileno(stdin), _O_BINARY); + _setmode(_fileno(stdout), _O_BINARY); + try { + init_apartment(apartment_type::single_threaded); + Host host; + std::string line; + while (readRequest(line)) { + JsonObject response; + std::vector pixels; + try { + JsonObject request = JsonObject::Parse(to_hstring(line)); + double id = number(request, L"id", 1, 9007199254740991.0); + require(std::floor(id) == id, "Invalid request ID"); + response.SetNamedValue(L"id", JsonValue::CreateNumberValue(id)); + response.SetNamedValue(L"result", host.execute(request.GetNamedString(L"command"), request.GetNamedObject(L"args"), pixels)); + } catch (const hresult_error& error) { + response.SetNamedValue(L"error", JsonValue::CreateStringValue(error.message())); + pixels.clear(); + } catch (const std::exception& error) { + response.SetNamedValue(L"error", JsonValue::CreateStringValue(to_hstring(error.what()))); + pixels.clear(); + } + std::cout << wsl::responseLine(response, pixels) << '\n'; + if (!pixels.empty()) std::cout.write(reinterpret_cast(pixels.data()), static_cast(pixels.size())); + std::cout.flush(); + require(std::cout.good(), "Writing the response failed"); + } + return 0; + } catch (const hresult_error& error) { + std::cerr << "libnut Windows host: " << to_string(error.message()) << '\n'; + } catch (const std::exception& error) { + std::cerr << "libnut Windows host: " << error.what() << '\n'; + } + return 1; +} diff --git a/src/win32/wsl_host_support.h b/src/win32/wsl_host_support.h new file mode 100644 index 0000000..ddb34bf --- /dev/null +++ b/src/win32/wsl_host_support.h @@ -0,0 +1,97 @@ +#pragma once +#define NOMINMAX +#include "../keycode.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace wsl { +constexpr size_t maxHeaderBytes = 1024 * 1024; + +// Key represents US keyboard keys, not characters in the active application +// layout. Text is sent separately using KEYEVENTF_UNICODE. +inline WORD keyCode(const std::string& name) { + static const std::map keys = { + {"escape", K_ESCAPE}, {"backspace", K_BACKSPACE}, {"tab", K_TAB}, + {"return", K_RETURN}, {"enter", K_ENTER}, {"space", K_SPACE}, + {"delete", K_DELETE}, {"insert", K_INSERT}, {"home", K_HOME}, + {"end", K_END}, {"pageup", K_PAGEUP}, {"pagedown", K_PAGEDOWN}, + {"left", K_LEFT}, {"right", K_RIGHT}, {"up", K_UP}, {"down", K_DOWN}, + {"shift", K_SHIFT}, {"right_shift", K_RIGHTSHIFT}, + {"control", K_CONTROL}, {"right_control", K_RIGHTCONTROL}, + {"alt", K_ALT}, {"right_alt", K_RIGHTALT}, + {"command", K_WIN}, {"win", K_WIN}, {"cmd", K_WIN}, {"meta", K_WIN}, + {"right_win", K_RIGHTWIN}, {"right_cmd", K_RIGHTWIN}, {"right_meta", K_RIGHTWIN}, + {"caps_lock", K_CAPSLOCK}, {"num_lock", K_NUMLOCK}, {"scroll_lock", K_SCROLL_LOCK}, + {"printscreen", K_PRINTSCREEN}, {"pause", K_PAUSE}, {"menu", K_MENU}, + {"clear", K_CLEAR}, {"add", K_ADD}, {"subtract", K_SUBTRACT}, + {"multiply", K_MULTIPLY}, {"divide", K_DIVIDE}, {"numpad_decimal", K_NUMPAD_DECIMAL}, + {"audio_mute", K_AUDIO_VOLUME_MUTE}, {"audio_vol_down", K_AUDIO_VOLUME_DOWN}, + {"audio_vol_up", K_AUDIO_VOLUME_UP}, {"audio_play", K_AUDIO_PLAY}, + {"audio_pause", K_AUDIO_PAUSE}, {"audio_stop", K_AUDIO_STOP}, + {"audio_prev", K_AUDIO_PREV}, {"audio_next", K_AUDIO_NEXT}, + {",", K_COMMA}, {".", K_PERIOD}, {"/", K_SLASH}, {";", K_SEMICOLON}, + {"'", K_QUOTE}, {"[", K_LEFTBRACKET}, {"]", K_RIGHTBRACKET}, + {"\\", K_BACKSLASH}, {"-", K_MINUS}, {"=", K_EQUAL}, {"`", K_GRAVE} + }; + auto found = keys.find(name); + if (found != keys.end()) return static_cast(found->second); + if (name.size() == 1 && name[0] >= 'a' && name[0] <= 'z') + return static_cast(VK_A + name[0] - 'a'); + if (name.size() == 1 && name[0] >= '0' && name[0] <= '9') + return static_cast(VK_0 + name[0] - '0'); + if (name.size() >= 2 && name[0] == 'f') { + size_t consumed = 0; + int value = std::stoi(name.substr(1), &consumed); + if (consumed == name.size() - 1 && value >= 1 && value <= 24) + return static_cast(VK_F1 + value - 1); + } + if (name.size() == 8 && name.substr(0, 7) == "numpad_" && name[7] >= '0' && name[7] <= '9') + return static_cast(VK_NUMPAD0 + name[7] - '0'); + throw std::runtime_error("Unsupported Windows key: " + name); +} + +// A failed release remains owned by the helper so a later attempt can retry it. +template +bool releaseHeldInputs(std::set& held, Release release) { + for (auto input = held.begin(); input != held.end();) { + if (release(*input)) input = held.erase(input); + else ++input; + } + return held.empty(); +} + +// Window requests must not wait on a foreign application's message thread. +// Poll only local window state, with a bounded, disconnect-aware wait. +template +bool waitForState(Ready ready, Pause pause, Clock now, DWORD timeout = 5000) { + ULONGLONG deadline = now() + timeout; + for (;;) { + if (ready()) return true; + ULONGLONG current = now(); + if (current >= deadline) return false; + pause(static_cast(std::min(10, deadline - current))); + } +} + +inline std::string responseLine(winrt::Windows::Data::Json::JsonObject response, std::vector& pixels) { + using namespace winrt::Windows::Data::Json; + response.SetNamedValue(L"byteLength", JsonValue::CreateNumberValue(static_cast(pixels.size()))); + std::string line = winrt::to_string(response.Stringify()); + if (line.size() > maxHeaderBytes) { + // Keep the request ID, but do not transmit a result the client cannot read. + if (response.HasKey(L"result")) response.Remove(L"result"); + response.SetNamedValue(L"error", JsonValue::CreateStringValue(L"Windows host response exceeds the 1 MiB JSON limit")); + response.SetNamedValue(L"byteLength", JsonValue::CreateNumberValue(0)); + pixels.clear(); + line = winrt::to_string(response.Stringify()); + } + return line; +} +} diff --git a/test/wsl-host-protocol.js b/test/wsl-host-protocol.js new file mode 100644 index 0000000..4823ff9 --- /dev/null +++ b/test/wsl-host-protocol.js @@ -0,0 +1,26 @@ +// Protocol-only smoke test: no desktop capture, clipboard changes or input. +const assert = require("node:assert/strict"); +const { spawnSync } = require("node:child_process"); +const { join } = require("node:path"); + +const helper = process.argv[2] || join(__dirname, "..", "build", "Release", "libnut-wsl-host.exe"); +const commands = [ + { id: 1, command: "hello", args: {} }, + { id: 2, command: "unknown", args: {} }, + { id: 3, command: "keys", args: { keys: ["unsupported-key"], down: true } }, + { id: 4, command: "moveMouse", args: { x: "not-a-number", y: 0 } }, + { id: 5, command: "hello", args: {} } +]; +const result = spawnSync(helper, [], { input: commands.map(value => JSON.stringify(value)).join("\n") + "\n", encoding: "utf8", timeout: 10000 }); +assert.ifError(result.error); +assert.equal(result.status, 0, result.stderr); +const responses = result.stdout.trim().split("\n").map(value => JSON.parse(value)); +assert.equal(responses.length, commands.length); +assert.deepEqual(responses.map(value => value.id), commands.map(value => value.id)); +assert.equal(responses[0].result.protocol, 1); +assert.equal(responses[4].result.protocol, 1); +assert.equal(responses[0].result.pid, responses[4].result.pid); +assert.ok(responses[0].result.pid > 0); +for (const response of responses) assert.equal(response.byteLength, 0); +for (const response of responses.slice(1, 4)) assert.equal(typeof response.error, "string"); +console.log("Windows host protocol smoke test passed (5 requests, one process)"); diff --git a/test/wsl-host-unit.cc b/test/wsl-host-unit.cc new file mode 100644 index 0000000..d7c5b35 --- /dev/null +++ b/test/wsl-host-unit.cc @@ -0,0 +1,113 @@ +// Deterministic support tests. No capture, keyboard/mouse input or window changes. +#include "../src/win32/wsl_host_support.h" +#include + +using namespace winrt; +using namespace Windows::Data::Json; + +void expect(bool condition, const char* message) { + if (!condition) throw std::runtime_error(message); +} + +void testKeys() { + const std::map punctuation = { + {"[", VK_OEM_4}, {"]", VK_OEM_6}, {";", VK_OEM_1}, {"'", VK_OEM_7}, + {",", VK_OEM_COMMA}, {".", VK_OEM_PERIOD}, {"/", VK_OEM_2}, + {"\\", VK_OEM_5}, {"-", VK_OEM_MINUS}, {"=", VK_OEM_PLUS}, {"`", VK_OEM_3} + }; + for (const auto& mapping : punctuation) expect(wsl::keyCode(mapping.first) == mapping.second, "Incorrect US punctuation key"); + for (char key = 'a'; key <= 'z'; ++key) expect(wsl::keyCode(std::string(1, key)) == VK_A + key - 'a', "Incorrect letter key"); + for (char key = '0'; key <= '9'; ++key) expect(wsl::keyCode(std::string(1, key)) == VK_0 + key - '0', "Incorrect number key"); + expect(wsl::keyCode("f24") == VK_F24, "Incorrect F24 key"); + expect(wsl::keyCode("numpad_9") == VK_NUMPAD9, "Incorrect keypad key"); + expect(wsl::keyCode("right_control") == VK_RCONTROL, "Incorrect right modifier"); + for (const std::string name : {"unsupported", "f25", "A", "!"}) { + bool rejected = false; + try { wsl::keyCode(name); } catch (const std::exception&) { rejected = true; } + expect(rejected, "Unsupported key was accepted"); + } +} + +void testReleases() { + std::set keys{VK_A, VK_LCONTROL}; + std::vector attempted; + bool released = wsl::releaseHeldInputs(keys, [&](WORD key) { + attempted.push_back(key); + return key == VK_A; + }); + expect(!released, "A failed release reported success"); + expect(keys == std::set{VK_LCONTROL}, "A failed key release was forgotten"); + expect(attempted.size() == 2, "Cleanup stopped after a failed key"); + expect(wsl::releaseHeldInputs(keys, [](WORD) { return true; }), "Retry did not clear the held key"); + expect(keys.empty(), "Successful retry retained the key"); + std::set buttons{0, 2}; + expect(!wsl::releaseHeldInputs(buttons, [](int) { return false; }), "Failed button releases reported success"); + expect(buttons == std::set({0, 2}), "Failed button releases were forgotten"); + expect(wsl::releaseHeldInputs(buttons, [](int) { return true; }), "Button retry failed"); +} + +void testResponses() { + auto response = [](const hstring& value) { + JsonObject result; + result.SetNamedValue(L"id", JsonValue::CreateNumberValue(7)); + result.SetNamedValue(L"result", JsonValue::CreateStringValue(value)); + return result; + }; + std::vector pixels; + JsonObject overhead = response(L""); + size_t base = wsl::responseLine(overhead, pixels).size(); + std::wstring boundary(wsl::maxHeaderBytes - base, L'a'); + std::string exact = wsl::responseLine(response(hstring(boundary)), pixels); + expect(exact.size() == wsl::maxHeaderBytes, "Exact header boundary was not accepted"); + expect(JsonObject::Parse(to_hstring(exact)).HasKey(L"result"), "Accepted result was replaced"); + boundary.push_back(L'a'); + JsonObject oversized = JsonObject::Parse(to_hstring(wsl::responseLine(response(hstring(boundary)), pixels))); + expect(oversized.GetNamedNumber(L"id") == 7 && oversized.HasKey(L"error"), "Oversized reply lost its bounded error or ID"); + expect(!oversized.HasKey(L"result") && oversized.GetNamedNumber(L"byteLength") == 0, "Oversized reply retained payload"); + // JSON escaping and UTF-8 bytes, not UTF-16 character counts, determine size. + for (const std::wstring text : {std::wstring(600000, L'"'), std::wstring(600000, L'\u00e9')}) { + pixels = {1, 2, 3, 4}; + std::string line = wsl::responseLine(response(hstring(text)), pixels); + expect(line.size() < wsl::maxHeaderBytes && pixels.empty(), "Oversized reply retained binary data"); + expect(JsonObject::Parse(to_hstring(line)).HasKey(L"error"), "Encoded header limit was not enforced"); + } + expect(JsonObject::Parse(to_hstring(wsl::responseLine(response(L"next command"), pixels))).GetNamedString(L"result") == L"next command", + "A later response failed after an oversized one"); + JsonObject error; + error.SetNamedValue(L"id", JsonValue::CreateNumberValue(8)); + error.SetNamedValue(L"error", JsonValue::CreateStringValue(hstring(std::wstring(wsl::maxHeaderBytes + 1, L'a')))); + std::string boundedError = wsl::responseLine(error, pixels); + expect(boundedError.size() < wsl::maxHeaderBytes && JsonObject::Parse(to_hstring(boundedError)).GetNamedNumber(L"id") == 8, + "An oversized error could not be replaced without a result field"); +} + +void testWindowWaits() { + ULONGLONG time = 0; + int polls = 0; + expect(!wsl::waitForState([&] { ++polls; return false; }, [&](DWORD duration) { time += duration; }, [&] { return time; }), + "A hung window reported completion"); + expect(time == 5000 && polls > 1, "Window wait was not bounded"); + time = 0; + expect(wsl::waitForState([&] { return time >= 20; }, [&](DWORD duration) { time += duration; }, [&] { return time; }), + "Completed asynchronous state was not observed"); + expect(time == 20, "Completed state waited until timeout"); + bool disconnected = false; + try { + wsl::waitForState([] { return false; }, [](DWORD) { throw std::runtime_error("disconnected"); }, [] { return ULONGLONG{0}; }); + } catch (const std::runtime_error&) { disconnected = true; } + expect(disconnected, "Window wait swallowed caller disconnection"); +} + +int main() { + try { + init_apartment(apartment_type::single_threaded); + testKeys(); testReleases(); testResponses(); testWindowWaits(); + std::cout << "Windows host support tests passed (keys, failed releases, response limits, bounded window waits)\n"; + return 0; + } catch (const hresult_error& error) { + std::cerr << to_string(error.message()) << '\n'; + } catch (const std::exception& error) { + std::cerr << error.what() << '\n'; + } + return 1; +}