From 82c4c9de2b331e4bdd7a835cd4cbad56404c0e6b Mon Sep 17 00:00:00 2001 From: Inaya Yousfi Date: Wed, 30 Sep 2026 22:27:39 +0200 Subject: [PATCH 1/2] Add a persistent Windows host helper for WSL Build a standalone C++17 helper for mouse, keyboard, primary-screen capture, clipboard and window operations over standard input/output. Add a helper-only build option, protocol smoke tests and Windows workflow checks. Document the protocol and interactive-session requirement. --- .github/workflows/ci.yaml | 3 + .github/workflows/tagged_release.yaml | 3 + CMakeLists.txt | 17 + README.md | 51 +++ package.json | 1 + src/win32/wsl_host.cc | 469 ++++++++++++++++++++++++++ test/wsl-host-protocol.js | 26 ++ 7 files changed, 570 insertions(+) create mode 100644 src/win32/wsl_host.cc create mode 100644 test/wsl-host-protocol.js diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 7014222..00b7dca 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -27,6 +27,9 @@ jobs: run: npm run patch && npm i - name: Build run: npm run build:release + - 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..4b5cc27 100644 --- a/.github/workflows/tagged_release.yaml +++ b/.github/workflows/tagged_release.yaml @@ -25,6 +25,9 @@ jobs: run: npm run patch && npm i - name: Build run: npm run build:release + - 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/CMakeLists.txt b/CMakeLists.txt index 6488720..0fb710b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -3,6 +3,23 @@ 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>") +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..35cd60f 100644 --- a/README.md +++ b/README.md @@ -24,3 +24,54 @@ 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. + +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 are limited to 1 MiB and captured pixel data to 128 MiB. +Diagnostic messages go to standard error, never into the reply stream. + +Closing the input pipe cancels long typing or highlight operations and releases +keys and mouse buttons held by the helper. 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..4bf8234 --- /dev/null +++ b/src/win32/wsl_host.cc @@ -0,0 +1,469 @@ +#define NOMINMAX +#include "../keycode.h" +#include +#include +#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 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} + }; + auto found = keys.find(name); + if (found != keys.end() && found->second != K_NOT_A_KEY) return static_cast(found->second); + if (name.size() == 1) { + SHORT code = VkKeyScanExA(name[0], GetKeyboardLayout(0)); + require(code != -1, "Key is unavailable in the active Windows keyboard layout"); + return static_cast(code & 0xff); + } + if (name.size() >= 2 && name[0] == 'f') { + size_t consumed = 0; + int value = std::stoi(name.substr(1), &consumed); + require(consumed == name.size() - 1 && value >= 1 && value <= 24, "Invalid function key"); + 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); + } + + 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; + } + + 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"); + 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() { + releaseInput(); + if (owner) DestroyWindow(owner); + UnregisterClassW(L"LibnutWslHost", GetModuleHandleW(nullptr)); + } + + void releaseInput() noexcept { + for (WORD code : heldKeys) { + INPUT input = keyboardInput(code, false); + SendInput(1, &input, sizeof(input)); + } + for (int value : heldButtons) { + INPUT input{}; input.type = INPUT_MOUSE; input.mi.dwFlags = buttonFlag(value, false); + SendInput(1, &input, sizeof(input)); + } + heldKeys.clear(); heldButtons.clear(); + } + + 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(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, SW_RESTORE); + SetForegroundWindow(hwnd); + return JsonValue::CreateBooleanValue(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; + return JsonValue::CreateBooleanValue(MoveWindow(hwnd, x, y, width, height, TRUE) != FALSE); + } else if (command == L"minimizeWindow" || command == L"restoreWindow") { + HWND hwnd = window(args); + ShowWindow(hwnd, command == L"minimizeWindow" ? SW_MINIMIZE : SW_RESTORE); + return JsonValue::CreateBooleanValue(command == L"minimizeWindow" ? IsIconic(hwnd) != FALSE : IsIconic(hwnd) == FALSE); + } else if (command == L"releaseInput") releaseInput(); + 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(); + } + response.SetNamedValue(L"byteLength", JsonValue::CreateNumberValue(static_cast(pixels.size()))); + std::cout << to_string(response.Stringify()) << '\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/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)"); From 30a1c33b598a045d10b6cda51df327c723a7ecb9 Mon Sep 17 00:00:00 2001 From: Inaya Yousfi Date: Wed, 30 Sep 2026 22:59:02 +0200 Subject: [PATCH 2/2] Fix Windows helper capture and failure handling Synchronize screenshot reads and use fixed US key mappings. Retain failed input releases, bound JSON replies and use asynchronous window changes with bounded waits. Add native regression tests and workflow checks, exclude test build artifacts from packages, and document failure behavior. --- .github/workflows/ci.yaml | 8 ++ .github/workflows/tagged_release.yaml | 8 ++ .gitignore | 3 +- .npmignore | 5 ++ CMakeLists.txt | 9 ++ README.md | 24 +++++- src/win32/wsl_host.cc | 100 +++++++++-------------- src/win32/wsl_host_support.h | 97 ++++++++++++++++++++++ test/wsl-host-unit.cc | 113 ++++++++++++++++++++++++++ 9 files changed, 301 insertions(+), 66 deletions(-) create mode 100644 src/win32/wsl_host_support.h create mode 100644 test/wsl-host-unit.cc diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 00b7dca..f3049b1 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -27,6 +27,14 @@ 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 diff --git a/.github/workflows/tagged_release.yaml b/.github/workflows/tagged_release.yaml index 4b5cc27..50c6ef8 100644 --- a/.github/workflows/tagged_release.yaml +++ b/.github/workflows/tagged_release.yaml @@ -25,6 +25,14 @@ 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 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 0fb710b..5804dc1 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -14,6 +14,15 @@ if (LIBNUT_BUILD_WSL_HOST) 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) diff --git a/README.md b/README.md index 35cd60f..214dde0 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,11 @@ 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; @@ -68,10 +73,21 @@ 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 are limited to 1 MiB and captured pixel data to 128 MiB. +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. -Closing the input pipe cancels long typing or highlight operations and releases -keys and mouse buttons held by the helper. Clients must not automatically retry -an interrupted command: its input may already have reached an application. +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/src/win32/wsl_host.cc b/src/win32/wsl_host.cc index 4bf8234..d093cc1 100644 --- a/src/win32/wsl_host.cc +++ b/src/win32/wsl_host.cc @@ -1,7 +1,4 @@ -#define NOMINMAX -#include "../keycode.h" -#include -#include +#include "wsl_host_support.h" #include #include #include @@ -106,45 +103,6 @@ class Host { if (!down) heldButtons.erase(value); } - static 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} - }; - auto found = keys.find(name); - if (found != keys.end() && found->second != K_NOT_A_KEY) return static_cast(found->second); - if (name.size() == 1) { - SHORT code = VkKeyScanExA(name[0], GetKeyboardLayout(0)); - require(code != -1, "Key is unavailable in the active Windows keyboard layout"); - return static_cast(code & 0xff); - } - if (name.size() >= 2 && name[0] == 'f') { - size_t consumed = 0; - int value = std::stoi(name.substr(1), &consumed); - require(consumed == name.size() - 1 && value >= 1 && value <= 24, "Invalid function key"); - 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); - } - static INPUT keyboardInput(WORD code, bool down) { INPUT input{}; input.type = INPUT_KEYBOARD; @@ -181,6 +139,20 @@ class Host { 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; @@ -265,6 +237,7 @@ class Host { 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; @@ -290,21 +263,21 @@ class Host { } ~Host() { - releaseInput(); + if (!releaseInput()) std::cerr << "libnut Windows host: Windows rejected releasing held input during shutdown\n"; if (owner) DestroyWindow(owner); UnregisterClassW(L"LibnutWslHost", GetModuleHandleW(nullptr)); } - void releaseInput() noexcept { - for (WORD code : heldKeys) { + bool releaseInput() noexcept { + bool keysReleased = wsl::releaseHeldInputs(heldKeys, [](WORD code) { INPUT input = keyboardInput(code, false); - SendInput(1, &input, sizeof(input)); - } - for (int value : heldButtons) { + 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); - SendInput(1, &input, sizeof(input)); - } - heldKeys.clear(); heldButtons.clear(); + return SendInput(1, &input, sizeof(input)) == 1; + }); + return keysReleased && buttonsReleased; } IJsonValue execute(const hstring& command, const JsonObject& args, std::vector& pixels) { @@ -345,7 +318,7 @@ class Host { 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(keyCode(to_string(name.GetString()))); + 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 { @@ -396,21 +369,27 @@ class Host { 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, SW_RESTORE); + if (IsIconic(hwnd) && !showWindow(hwnd, false)) return JsonValue::CreateBooleanValue(false); SetForegroundWindow(hwnd); - return JsonValue::CreateBooleanValue(GetForegroundWindow() == 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; - return JsonValue::CreateBooleanValue(MoveWindow(hwnd, x, y, width, height, TRUE) != FALSE); + 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); - ShowWindow(hwnd, command == L"minimizeWindow" ? SW_MINIMIZE : SW_RESTORE); - return JsonValue::CreateBooleanValue(command == L"minimizeWindow" ? IsIconic(hwnd) != FALSE : IsIconic(hwnd) == FALSE); - } else if (command == L"releaseInput") releaseInput(); + 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(); @@ -453,8 +432,7 @@ int main() { response.SetNamedValue(L"error", JsonValue::CreateStringValue(to_hstring(error.what()))); pixels.clear(); } - response.SetNamedValue(L"byteLength", JsonValue::CreateNumberValue(static_cast(pixels.size()))); - std::cout << to_string(response.Stringify()) << '\n'; + 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"); 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-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; +}