From aa4a57b717f870cda65963d2f4f8bb39ab59e855 Mon Sep 17 00:00:00 2001 From: Alexander Sklar <22989529+asklar@users.noreply.github.com> Date: Wed, 8 Jul 2026 10:39:59 -0700 Subject: [PATCH 1/2] Add WinForms provider Adds WinForms detection, managed TAP-based HWND enrichment, a sample WinForms test app, and unit/integration coverage. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/ci.yml | 6 + CMakeLists.txt | 59 +++ build.cmd | 10 +- src/framework_detector.cpp | 17 + src/framework_detector.h | 1 + src/providers/winforms_inject.cpp | 309 +++++++++++++++ src/providers/winforms_inject.h | 11 + src/providers/winforms_provider.cpp | 21 + src/providers/winforms_provider.h | 11 + src/tap_winforms/.gitignore | 3 + src/tap_winforms/LvtWinFormsTap.csproj | 16 + .../LvtWinFormsTap.runtimeconfig.json | 9 + src/tap_winforms/WinFormsTreeWalker.cs | 146 +++++++ src/tap_winforms/winforms_tap_native.cpp | 371 ++++++++++++++++++ src/tap_winforms/winforms_tap_native.def | 2 + src/tree_builder.cpp | 6 + tests/apps/WinFormsSample/.gitignore | 3 + tests/apps/WinFormsSample/Program.cs | 67 ++++ .../apps/WinFormsSample/WinFormsSample.csproj | 11 + tests/integration_tests.cpp | 185 +++++++++ tests/unit_tests.cpp | 49 +++ 21 files changed, 1311 insertions(+), 2 deletions(-) create mode 100644 src/providers/winforms_inject.cpp create mode 100644 src/providers/winforms_inject.h create mode 100644 src/providers/winforms_provider.cpp create mode 100644 src/providers/winforms_provider.h create mode 100644 src/tap_winforms/.gitignore create mode 100644 src/tap_winforms/LvtWinFormsTap.csproj create mode 100644 src/tap_winforms/LvtWinFormsTap.runtimeconfig.json create mode 100644 src/tap_winforms/WinFormsTreeWalker.cs create mode 100644 src/tap_winforms/winforms_tap_native.cpp create mode 100644 src/tap_winforms/winforms_tap_native.def create mode 100644 tests/apps/WinFormsSample/.gitignore create mode 100644 tests/apps/WinFormsSample/Program.cs create mode 100644 tests/apps/WinFormsSample/WinFormsSample.csproj diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a79380c..84c47a6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,6 +35,9 @@ jobs: - name: Build managed Avalonia tree walker run: dotnet build src/plugin_avalonia/LvtAvaloniaTreeWalker/LvtAvaloniaTreeWalker.csproj -c Release + - name: Build managed WinForms TAP assembly + run: dotnet build src/tap_winforms/LvtWinFormsTap.csproj -c Release + - name: Build run: cmake --build build @@ -63,4 +66,7 @@ jobs: build/lvt_tap_x64.dll build/lvt_wpf_tap_x64.dll build/LvtWpfTap.dll + build/lvt_winforms_tap_x64.dll + build/LvtWinFormsTap.dll + build/LvtWinFormsTap.runtimeconfig.json build/plugins/ diff --git a/CMakeLists.txt b/CMakeLists.txt index a566f64..1ccdfd0 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -23,6 +23,8 @@ add_executable(lvt src/providers/winui3_provider.cpp src/providers/wpf_provider.cpp src/providers/wpf_inject.cpp + src/providers/winforms_provider.cpp + src/providers/winforms_inject.cpp src/providers/xaml_diag_common.cpp ) @@ -192,6 +194,46 @@ add_custom_command(TARGET lvt_wpf_tap POST_BUILD COMMENT "Copying managed WPF tree walker assembly" ) +# WinForms TAP DLL — native loader injected into WinForms target process +add_library(lvt_winforms_tap SHARED + src/tap_winforms/winforms_tap_native.cpp + src/tap_winforms/winforms_tap_native.def +) +target_compile_definitions(lvt_winforms_tap PRIVATE WIN32_LEAN_AND_MEAN NOMINMAX) +target_include_directories(lvt_winforms_tap PRIVATE src) +target_link_libraries(lvt_winforms_tap PRIVATE ole32) +set_property(TARGET lvt_winforms_tap PROPERTY MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>") + +set_target_properties(lvt_winforms_tap PROPERTIES + OUTPUT_NAME "lvt_winforms_tap_${TAP_ARCH_SUFFIX}" + RUNTIME_OUTPUT_DIRECTORY $ +) + +set(LVT_WINFORMS_MANAGED_DLL "${CMAKE_SOURCE_DIR}/src/tap_winforms/bin/Release/LvtWinFormsTap.dll") +add_custom_command( + OUTPUT "${LVT_WINFORMS_MANAGED_DLL}" + COMMAND dotnet build "${CMAKE_SOURCE_DIR}/src/tap_winforms/LvtWinFormsTap.csproj" -c Release --nologo + DEPENDS + "${CMAKE_SOURCE_DIR}/src/tap_winforms/LvtWinFormsTap.csproj" + "${CMAKE_SOURCE_DIR}/src/tap_winforms/WinFormsTreeWalker.cs" + WORKING_DIRECTORY "${CMAKE_SOURCE_DIR}" + COMMENT "Building managed WinForms tree walker assembly" + VERBATIM +) +add_custom_target(lvt_winforms_managed ALL + DEPENDS "${LVT_WINFORMS_MANAGED_DLL}" +) +add_dependencies(lvt_winforms_tap lvt_winforms_managed) +add_custom_command(TARGET lvt_winforms_tap POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "${LVT_WINFORMS_MANAGED_DLL}" + "$/LvtWinFormsTap.dll" + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "${CMAKE_SOURCE_DIR}/src/tap_winforms/LvtWinFormsTap.runtimeconfig.json" + "$/LvtWinFormsTap.runtimeconfig.json" + COMMENT "Copying managed WinForms tree walker assembly" +) + # Avalonia plugin DLL — runtime-loaded plugin for Avalonia UI framework support add_library(lvt_avalonia_plugin SHARED src/plugin_avalonia/lvt_avalonia_plugin.cpp @@ -291,6 +333,8 @@ add_executable(lvt_unit_tests src/providers/winui3_provider.cpp src/providers/wpf_provider.cpp src/providers/wpf_inject.cpp + src/providers/winforms_provider.cpp + src/providers/winforms_inject.cpp src/providers/xaml_diag_common.cpp ) target_include_directories(lvt_unit_tests PRIVATE src) @@ -352,6 +396,18 @@ add_custom_target(avalonia_test_app ALL file(TO_NATIVE_PATH "${AVALONIA_SAMPLE_OUTPUT_DIR}/AvaloniaTestApp.exe" AVALONIA_SAMPLE_EXE_NATIVE) string(REPLACE "\\" "\\\\" AVALONIA_SAMPLE_EXE_ESCAPED "${AVALONIA_SAMPLE_EXE_NATIVE}") +# WinForms sample app used by integration tests +set(WINFORMS_SAMPLE_PROJECT "${CMAKE_SOURCE_DIR}/tests/apps/WinFormsSample/WinFormsSample.csproj") +set(WINFORMS_SAMPLE_OUTPUT_DIR "${CMAKE_BINARY_DIR}/tests/apps/WinFormsSample") +add_custom_target(winforms_sample_app ALL + COMMAND dotnet build "${WINFORMS_SAMPLE_PROJECT}" -c Release -o "${WINFORMS_SAMPLE_OUTPUT_DIR}" --nologo + BYPRODUCTS "${WINFORMS_SAMPLE_OUTPUT_DIR}/WinFormsSample.exe" + WORKING_DIRECTORY "${CMAKE_SOURCE_DIR}" + COMMENT "Building WinForms sample app" +) +file(TO_NATIVE_PATH "${WINFORMS_SAMPLE_OUTPUT_DIR}/WinFormsSample.exe" WINFORMS_SAMPLE_EXE_NATIVE) +string(REPLACE "\\" "\\\\" WINFORMS_SAMPLE_EXE_ESCAPED "${WINFORMS_SAMPLE_EXE_NATIVE}") + # Integration tests — require a running Notepad instance add_executable(lvt_integration_tests tests/integration_tests.cpp @@ -363,6 +419,7 @@ target_compile_definitions(lvt_integration_tests PRIVATE WPF_SAMPLE_EXE_PATH="${WPF_SAMPLE_EXE_ESCAPED}" WINUI3_SAMPLE_EXE_PATH="${WINUI3_SAMPLE_EXE_ESCAPED}" AVALONIA_SAMPLE_EXE_PATH="${AVALONIA_SAMPLE_EXE_ESCAPED}" + WINFORMS_SAMPLE_EXE_PATH="${WINFORMS_SAMPLE_EXE_ESCAPED}" ) target_link_libraries(lvt_integration_tests PRIVATE GTest::gtest GTest::gtest_main @@ -372,12 +429,14 @@ target_link_libraries(lvt_integration_tests PRIVATE target_link_options(lvt_integration_tests PRIVATE "/MANIFEST:NO") add_dependencies(lvt_integration_tests lvt + lvt_winforms_tap lvt_avalonia_plugin lvt_avalonia_tap lvt_avalonia_tree_walker avalonia_test_app wpf_sample_app winui3_sample_app + winforms_sample_app ) add_test(NAME integration_tests COMMAND lvt_integration_tests) diff --git a/build.cmd b/build.cmd index 3f49a84..b0bd87a 100644 --- a/build.cmd +++ b/build.cmd @@ -23,19 +23,25 @@ REM --- .NET projects (must build before CMake) --- echo. echo === Building .NET projects === -echo [1/2] LvtWpfTap (net48)... +echo [1/3] LvtWpfTap (net48)... dotnet build src\tap_wpf\LvtWpfTap.csproj -c Release -v:q --nologo if errorlevel 1 ( echo WARNING: LvtWpfTap build failed (WPF TAP will be unavailable) ) -echo [2/2] LvtAvaloniaTreeWalker (net8.0)... +echo [2/3] LvtAvaloniaTreeWalker (net8.0)... dotnet restore src\plugin_avalonia\LvtAvaloniaTreeWalker\LvtAvaloniaTreeWalker.csproj -v:q --nologo dotnet publish src\plugin_avalonia\LvtAvaloniaTreeWalker\LvtAvaloniaTreeWalker.csproj -c Release -v:q --nologo if errorlevel 1 ( echo WARNING: LvtAvaloniaTreeWalker build failed (Avalonia plugin will be unavailable) ) +echo [3/3] LvtWinFormsTap (net48)... +dotnet build src\tap_winforms\LvtWinFormsTap.csproj -c Release -v:q --nologo +if errorlevel 1 ( + echo WARNING: LvtWinFormsTap build failed (WinForms enrichment will be unavailable) +) + REM --- CMake configure + build --- echo. diff --git a/src/framework_detector.cpp b/src/framework_detector.cpp index c73c7aa..636df43 100644 --- a/src/framework_detector.cpp +++ b/src/framework_detector.cpp @@ -14,6 +14,7 @@ std::string framework_to_string(Framework f) { case Framework::Xaml: return "xaml"; case Framework::WinUI3: return "winui3"; case Framework::Wpf: return "wpf"; + case Framework::WinForms: return "winforms"; case Framework::Plugin: return "plugin"; } return "unknown"; @@ -37,6 +38,7 @@ struct DetectData { bool hasWinUI3 = false; bool hasXaml = false; bool hasWpf = false; + bool hasWinForms = false; }; static BOOL CALLBACK detect_child_proc(HWND hwnd, LPARAM lParam) { @@ -66,6 +68,10 @@ static BOOL CALLBACK detect_child_proc(HWND hwnd, LPARAM lParam) { data->hasWpf = true; } + if (wcsstr(cls, L"WindowsForms10.")) { + data->hasWinForms = true; + } + return TRUE; } @@ -143,6 +149,9 @@ std::vector detect_frameworks(HWND hwnd, DWORD pid) { if (wcsstr(topCls, L"HwndWrapper[")) { data.hasWpf = true; } + if (wcsstr(topCls, L"WindowsForms10.")) { + data.hasWinForms = true; + } EnumChildWindows(hwnd, detect_child_proc, reinterpret_cast(&data)); if (data.hasComCtl) { @@ -168,6 +177,7 @@ std::vector detect_frameworks(HWND hwnd, DWORD pid) { bool detectedWinUI3 = false; bool detectedXaml = false; bool detectedWpf = false; + bool detectedWinForms = false; if (pid) { auto winui = detect_module(pid, L"Microsoft.UI.Xaml.dll"); if (winui.found) { @@ -188,6 +198,11 @@ std::vector detect_frameworks(HWND hwnd, DWORD pid) { result.push_back({Framework::Wpf, wpf.version}); detectedWpf = true; } + auto winforms = detect_module(pid, L"System.Windows.Forms.dll"); + if (winforms.found) { + result.push_back({Framework::WinForms, winforms.version}); + detectedWinForms = true; + } } // Class-name fallback (works when module enumeration fails) @@ -197,6 +212,8 @@ std::vector detect_frameworks(HWND hwnd, DWORD pid) { result.push_back({Framework::Xaml, {}}); if (!detectedWpf && data.hasWpf) result.push_back({Framework::Wpf, {}}); + if (!detectedWinForms && data.hasWinForms) + result.push_back({Framework::WinForms, {}}); // Plugin-provided framework detection auto pluginFws = detect_plugin_frameworks(hwnd, pid); diff --git a/src/framework_detector.h b/src/framework_detector.h index f41efac..f4b345b 100644 --- a/src/framework_detector.h +++ b/src/framework_detector.h @@ -11,6 +11,7 @@ enum class Framework { Xaml, WinUI3, Wpf, + WinForms, Plugin, // Plugin-provided framework (name in FrameworkInfo::name) }; diff --git a/src/providers/winforms_inject.cpp b/src/providers/winforms_inject.cpp new file mode 100644 index 0000000..7ee5602 --- /dev/null +++ b/src/providers/winforms_inject.cpp @@ -0,0 +1,309 @@ +#include "winforms_inject.h" +#include "../debug.h" +#include "../target.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using json = nlohmann::json; + +namespace lvt { + +static std::wstring make_pipe_name() { + GUID guid; + CoCreateGuid(&guid); + wchar_t buf[96]; + swprintf_s(buf, L"\\\\.\\pipe\\lvt_winforms_%08lX%04X%04X%02X%02X%02X%02X%02X%02X%02X%02X", + guid.Data1, guid.Data2, guid.Data3, + guid.Data4[0], guid.Data4[1], guid.Data4[2], guid.Data4[3], + guid.Data4[4], guid.Data4[5], guid.Data4[6], guid.Data4[7]); + return buf; +} + +static std::wstring get_exe_dir() { + wchar_t path[MAX_PATH]; + GetModuleFileNameW(nullptr, path, MAX_PATH); + std::wstring dir(path); + auto pos = dir.find_last_of(L"\\/"); + if (pos != std::wstring::npos) dir.resize(pos); + return dir; +} + +static std::string sanitize(const std::string& s) { + std::string r; + r.reserve(s.size()); + for (char c : s) { + if (static_cast(c) >= 0x20 || c == '\t') + r += c; + } + return r; +} + +static uintptr_t parse_hwnd_value(const std::string& text) { + if (text.empty()) return 0; + size_t index = 0; + int base = 16; + if (text.starts_with("0x") || text.starts_with("0X")) + index = 2; + try { + return static_cast(std::stoull(text.substr(index), nullptr, base)); + } catch (...) { + return 0; + } +} + +static std::string simple_type_name(const std::string& fullType) { + auto lastDot = fullType.rfind('.'); + return lastDot == std::string::npos ? fullType : fullType.substr(lastDot + 1); +} + +static void index_by_hwnd(Element& el, std::unordered_map& index) { + if (el.nativeHandle != 0) + index[el.nativeHandle] = ⪙ + auto it = el.properties.find("hwnd"); + if (it != el.properties.end()) { + auto hwnd = parse_hwnd_value(it->second); + if (hwnd != 0) + index[hwnd] = ⪙ + } + for (auto& child : el.children) + index_by_hwnd(child, index); +} + +static bool apply_control_node(const json& node, std::unordered_map& index) { + if (!node.is_object()) return false; + + bool applied = false; + auto hwnd = parse_hwnd_value(node.value("hwnd", "")); + auto found = index.find(hwnd); + if (found != index.end() && found->second) { + Element& el = *found->second; + auto fullType = sanitize(node.value("type", "")); + auto name = sanitize(node.value("name", "")); + auto text = sanitize(node.value("text", "")); + + el.framework = "winforms"; + if (!fullType.empty()) { + el.properties["winforms.type"] = fullType; + el.type = simple_type_name(fullType); + } + if (!name.empty()) { + el.properties["name"] = name; + el.properties["winforms.name"] = name; + } + if (!text.empty()) + el.properties["winforms.text"] = text; + if (node.contains("visible") && node["visible"].is_boolean()) + el.properties["winforms.visible"] = node["visible"].get() ? "true" : "false"; + if (node.contains("enabled") && node["enabled"].is_boolean()) + el.properties["winforms.enabled"] = node["enabled"].get() ? "true" : "false"; + if (node.contains("readOnly") && node["readOnly"].is_boolean()) + el.properties["readOnly"] = node["readOnly"].get() ? "true" : "false"; + if (node.contains("autoSize") && node["autoSize"].is_boolean()) + el.properties["autoSize"] = node["autoSize"].get() ? "true" : "false"; + applied = true; + } + + if (node.contains("children") && node["children"].is_array()) { + for (auto& child : node["children"]) + applied = apply_control_node(child, index) || applied; + } + return applied; +} + +bool apply_winforms_control_json(Element& root, const std::string& jsonText) { + if (jsonText.empty()) return false; + json treeJson; + try { + treeJson = json::parse(jsonText); + } catch (const json::parse_error&) { + return false; + } + + std::unordered_map index; + index_by_hwnd(root, index); + + bool applied = false; + if (treeJson.is_array()) { + for (auto& node : treeJson) + applied = apply_control_node(node, index) || applied; + } else if (treeJson.is_object()) { + applied = apply_control_node(treeJson, index); + } + return applied; +} + +static bool write_pipe_name_file(const std::wstring& dir, const std::wstring& pipeName) { + std::wstring path = dir + L"\\lvt_winforms_pipe.txt"; + int len = WideCharToMultiByte(CP_UTF8, 0, pipeName.c_str(), -1, nullptr, 0, nullptr, nullptr); + std::string utf8(len - 1, '\0'); + WideCharToMultiByte(CP_UTF8, 0, pipeName.c_str(), -1, utf8.data(), len, nullptr, nullptr); + + std::ofstream f(path, std::ios::binary | std::ios::trunc); + if (!f) return false; + f.write(utf8.data(), utf8.size()); + return true; +} + +static bool inject_dll(DWORD pid, const std::wstring& dllPath) { + wil::unique_handle proc(OpenProcess( + PROCESS_CREATE_THREAD | PROCESS_VM_OPERATION | PROCESS_VM_WRITE | PROCESS_QUERY_INFORMATION, + FALSE, pid)); + if (!proc) { + if (g_debug) fprintf(stderr, "lvt: failed to open WinForms target process %lu (error %lu)\n", pid, GetLastError()); + return false; + } + + size_t pathBytes = (dllPath.size() + 1) * sizeof(wchar_t); + void* remoteMem = VirtualAllocEx(proc.get(), nullptr, pathBytes, MEM_COMMIT, PAGE_READWRITE); + if (!remoteMem) { + if (g_debug) fprintf(stderr, "lvt: WinForms VirtualAllocEx failed (error %lu)\n", GetLastError()); + return false; + } + + if (!WriteProcessMemory(proc.get(), remoteMem, dllPath.c_str(), pathBytes, nullptr)) { + if (g_debug) fprintf(stderr, "lvt: WinForms WriteProcessMemory failed (error %lu)\n", GetLastError()); + VirtualFreeEx(proc.get(), remoteMem, 0, MEM_RELEASE); + return false; + } + + auto loadLibAddr = reinterpret_cast( + GetProcAddress(GetModuleHandleW(L"kernel32.dll"), "LoadLibraryW")); + + wil::unique_handle thread(CreateRemoteThread(proc.get(), nullptr, 0, loadLibAddr, remoteMem, 0, nullptr)); + if (!thread) { + if (g_debug) fprintf(stderr, "lvt: WinForms CreateRemoteThread failed (error %lu)\n", GetLastError()); + VirtualFreeEx(proc.get(), remoteMem, 0, MEM_RELEASE); + return false; + } + + WaitForSingleObject(thread.get(), 5000); + DWORD exitCode = 0; + GetExitCodeThread(thread.get(), &exitCode); + VirtualFreeEx(proc.get(), remoteMem, 0, MEM_RELEASE); + + if (exitCode == 0) { + if (g_debug) fprintf(stderr, "lvt: WinForms LoadLibraryW failed in target process\n"); + return false; + } + return true; +} + +bool inject_and_collect_winforms_tree(Element& root, HWND /*hwnd*/, DWORD pid) { + wil::unique_handle proc(OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, pid)); + if (proc) { + BOOL isWow64 = FALSE; + if (IsWow64Process(proc.get(), &isWow64) && isWow64) { +#if defined(_M_X64) || defined(_M_ARM64) + if (g_debug) fprintf(stderr, "lvt: WinForms target is 32-bit (WoW64); skipping managed enrichment\n"); + return false; +#endif + } + } + + std::wstring exeDir = get_exe_dir(); + const wchar_t* tapSuffix = (get_host_architecture() == Architecture::arm64) + ? L"\\lvt_winforms_tap_arm64.dll" : (sizeof(void*) == 4) + ? L"\\lvt_winforms_tap_x86.dll" : L"\\lvt_winforms_tap_x64.dll"; + std::wstring tapDll = exeDir + tapSuffix; + std::wstring managedDll = exeDir + L"\\LvtWinFormsTap.dll"; + + if (GetFileAttributesW(tapDll.c_str()) == INVALID_FILE_ATTRIBUTES || + GetFileAttributesW(managedDll.c_str()) == INVALID_FILE_ATTRIBUTES) { + if (g_debug) fprintf(stderr, "lvt: WinForms TAP binaries not found\n"); + return false; + } + + std::wstring pipeName = make_pipe_name(); + if (!write_pipe_name_file(exeDir, pipeName)) + return false; + + SECURITY_ATTRIBUTES sa = {}; + sa.nLength = sizeof(sa); + sa.bInheritHandle = FALSE; + ConvertStringSecurityDescriptorToSecurityDescriptorW( + L"D:(A;;GRGW;;;WD)(A;;GRGW;;;AC)", SDDL_REVISION_1, &sa.lpSecurityDescriptor, nullptr); + + HANDLE pipe = CreateNamedPipeW( + pipeName.c_str(), + PIPE_ACCESS_INBOUND | FILE_FLAG_OVERLAPPED, + PIPE_TYPE_BYTE | PIPE_READMODE_BYTE | PIPE_WAIT, + 1, 0, 1024 * 1024, 10000, &sa); + LocalFree(sa.lpSecurityDescriptor); + + if (pipe == INVALID_HANDLE_VALUE) { + DeleteFileW((exeDir + L"\\lvt_winforms_pipe.txt").c_str()); + return false; + } + + OVERLAPPED ov = {}; + ov.hEvent = CreateEventW(nullptr, TRUE, FALSE, nullptr); + ConnectNamedPipe(pipe, &ov); + DWORD connectErr = GetLastError(); + + if (!inject_dll(pid, tapDll)) { + CancelIo(pipe); + CloseHandle(ov.hEvent); + CloseHandle(pipe); + DeleteFileW((exeDir + L"\\lvt_winforms_pipe.txt").c_str()); + return false; + } + + if (connectErr == ERROR_IO_PENDING) { + if (WaitForSingleObject(ov.hEvent, 15000) != WAIT_OBJECT_0) { + CancelIo(pipe); + CloseHandle(ov.hEvent); + CloseHandle(pipe); + DeleteFileW((exeDir + L"\\lvt_winforms_pipe.txt").c_str()); + return false; + } + } else if (connectErr != ERROR_PIPE_CONNECTED) { + CloseHandle(ov.hEvent); + CloseHandle(pipe); + DeleteFileW((exeDir + L"\\lvt_winforms_pipe.txt").c_str()); + return false; + } + CloseHandle(ov.hEvent); + + std::string data; + char buf[4096]; + DWORD bytesRead = 0; + OVERLAPPED readOv = {}; + readOv.hEvent = CreateEventW(nullptr, TRUE, FALSE, nullptr); + for (;;) { + ResetEvent(readOv.hEvent); + BOOL ok = ReadFile(pipe, buf, sizeof(buf), &bytesRead, &readOv); + if (!ok) { + DWORD err = GetLastError(); + if (err == ERROR_IO_PENDING) { + if (WaitForSingleObject(readOv.hEvent, 15000) != WAIT_OBJECT_0) { + CancelIo(pipe); + break; + } + if (!GetOverlappedResult(pipe, &readOv, &bytesRead, FALSE) || bytesRead == 0) + break; + } else { + break; + } + } else if (bytesRead == 0) { + break; + } + data.append(buf, bytesRead); + } + CloseHandle(readOv.hEvent); + CloseHandle(pipe); + DeleteFileW((exeDir + L"\\lvt_winforms_pipe.txt").c_str()); + + if (data.empty()) + return false; + return apply_winforms_control_json(root, data); +} + +} // namespace lvt diff --git a/src/providers/winforms_inject.h b/src/providers/winforms_inject.h new file mode 100644 index 0000000..a3ced36 --- /dev/null +++ b/src/providers/winforms_inject.h @@ -0,0 +1,11 @@ +#pragma once +#include "../element.h" +#include +#include + +namespace lvt { + +bool apply_winforms_control_json(Element& root, const std::string& jsonText); +bool inject_and_collect_winforms_tree(Element& root, HWND hwnd, DWORD pid); + +} // namespace lvt diff --git a/src/providers/winforms_provider.cpp b/src/providers/winforms_provider.cpp new file mode 100644 index 0000000..90d7860 --- /dev/null +++ b/src/providers/winforms_provider.cpp @@ -0,0 +1,21 @@ +#include "winforms_provider.h" +#include "winforms_inject.h" + +namespace lvt { + +static void label_winforms_like_windows(Element& el) { + if (el.className.starts_with("WindowsForms10.")) { + el.framework = "winforms"; + el.properties["winforms.class"] = el.className; + } + for (auto& child : el.children) { + label_winforms_like_windows(child); + } +} + +void WinFormsProvider::enrich(Element& root, HWND hwnd, DWORD pid) { + label_winforms_like_windows(root); + inject_and_collect_winforms_tree(root, hwnd, pid); +} + +} // namespace lvt diff --git a/src/providers/winforms_provider.h b/src/providers/winforms_provider.h new file mode 100644 index 0000000..4c64cab --- /dev/null +++ b/src/providers/winforms_provider.h @@ -0,0 +1,11 @@ +#pragma once +#include "provider.h" + +namespace lvt { + +class WinFormsProvider : public IProvider { +public: + void enrich(Element& root, HWND hwnd, DWORD pid); +}; + +} // namespace lvt diff --git a/src/tap_winforms/.gitignore b/src/tap_winforms/.gitignore new file mode 100644 index 0000000..f6f63c7 --- /dev/null +++ b/src/tap_winforms/.gitignore @@ -0,0 +1,3 @@ +bin/ +obj/ + diff --git a/src/tap_winforms/LvtWinFormsTap.csproj b/src/tap_winforms/LvtWinFormsTap.csproj new file mode 100644 index 0000000..a1cc847 --- /dev/null +++ b/src/tap_winforms/LvtWinFormsTap.csproj @@ -0,0 +1,16 @@ + + + net48 + LvtWinFormsTap + Library + latest + false + false + AnyCPU + + + + + + + diff --git a/src/tap_winforms/LvtWinFormsTap.runtimeconfig.json b/src/tap_winforms/LvtWinFormsTap.runtimeconfig.json new file mode 100644 index 0000000..b723c2a --- /dev/null +++ b/src/tap_winforms/LvtWinFormsTap.runtimeconfig.json @@ -0,0 +1,9 @@ +{ + "runtimeOptions": { + "framework": { + "name": "Microsoft.WindowsDesktop.App", + "version": "8.0.0", + "rollForward": "LatestMajor" + } + } +} diff --git a/src/tap_winforms/WinFormsTreeWalker.cs b/src/tap_winforms/WinFormsTreeWalker.cs new file mode 100644 index 0000000..b4443f6 --- /dev/null +++ b/src/tap_winforms/WinFormsTreeWalker.cs @@ -0,0 +1,146 @@ +using System; +using System.IO.Pipes; +using System.Text; +using System.Windows.Forms; + +namespace LvtWinFormsTap +{ + public static class WinFormsTreeWalker + { + public delegate int CollectTreeDelegate(IntPtr pipeNamePtr, int pipeNameLength); + + public static int CollectTree(IntPtr pipeNamePtr, int pipeNameLength) + { + try + { + string pipeName = System.Runtime.InteropServices.Marshal.PtrToStringUni(pipeNamePtr); + return CollectTreeImpl(pipeName); + } + catch + { + return -1; + } + } + + public static int CollectTree(string pipeName) + { + return CollectTreeImpl(pipeName); + } + + private static int CollectTreeImpl(string pipeName) + { + try + { + string json = null; + if (Application.OpenForms.Count > 0 && Application.OpenForms[0].IsHandleCreated) + { + var first = Application.OpenForms[0]; + first.BeginInvoke(new MethodInvoker(() => json = WalkAllForms())); + + var deadline = DateTime.UtcNow.AddSeconds(5); + while (json == null && DateTime.UtcNow < deadline) + System.Threading.Thread.Sleep(10); + } + + if (json == null) + json = WalkAllForms(); + + string shortName = pipeName; + const string pipePrefix = @"\\.\pipe\"; + if (shortName.StartsWith(pipePrefix)) + shortName = shortName.Substring(pipePrefix.Length); + + using (var client = new NamedPipeClientStream(".", shortName, PipeDirection.Out)) + { + client.Connect(5000); + byte[] data = Encoding.UTF8.GetBytes(json); + client.Write(data, 0, data.Length); + client.Flush(); + } + + return 0; + } + catch + { + return -1; + } + } + + private static string WalkAllForms() + { + var sb = new StringBuilder(); + sb.Append('['); + bool first = true; + foreach (Form form in Application.OpenForms) + { + if (!first) sb.Append(','); + first = false; + SerializeControl(sb, form); + } + sb.Append(']'); + return sb.ToString(); + } + + private static void SerializeControl(StringBuilder sb, Control control) + { + sb.Append('{'); + sb.Append("\"hwnd\":\"").Append(((long)control.Handle).ToString("X")).Append('"'); + sb.Append(",\"type\":\"").Append(EscapeJson(control.GetType().FullName ?? control.GetType().Name)).Append('"'); + + if (!string.IsNullOrEmpty(control.Name)) + sb.Append(",\"name\":\"").Append(EscapeJson(control.Name)).Append('"'); + if (!string.IsNullOrEmpty(control.Text)) + sb.Append(",\"text\":\"").Append(EscapeJson(Trim(control.Text))).Append('"'); + if (!control.Visible) + sb.Append(",\"visible\":false"); + if (!control.Enabled) + sb.Append(",\"enabled\":false"); + if (control is TextBoxBase textBox) + sb.Append(",\"readOnly\":").Append(textBox.ReadOnly ? "true" : "false"); + if (control is ButtonBase button) + sb.Append(",\"autoSize\":").Append(button.AutoSize ? "true" : "false"); + + if (control.Controls.Count > 0) + { + sb.Append(",\"children\":["); + for (int i = 0; i < control.Controls.Count; i++) + { + if (i > 0) sb.Append(','); + SerializeControl(sb, control.Controls[i]); + } + sb.Append(']'); + } + + sb.Append('}'); + } + + private static string Trim(string value) + { + return value.Length > 200 ? value.Substring(0, 200) : value; + } + + private static string EscapeJson(string s) + { + if (s == null) return ""; + var sb = new StringBuilder(s.Length); + foreach (char c in s) + { + switch (c) + { + case '"': sb.Append("\\\""); break; + case '\\': sb.Append("\\\\"); break; + case '\n': sb.Append("\\n"); break; + case '\r': sb.Append("\\r"); break; + case '\t': sb.Append("\\t"); break; + default: + if (c < 0x20) + sb.AppendFormat("\\u{0:X4}", (int)c); + else + sb.Append(c); + break; + } + } + return sb.ToString(); + } + } +} diff --git a/src/tap_winforms/winforms_tap_native.cpp b/src/tap_winforms/winforms_tap_native.cpp new file mode 100644 index 0000000..d33ff81 --- /dev/null +++ b/src/tap_winforms/winforms_tap_native.cpp @@ -0,0 +1,371 @@ +// WinForms_tap_native.cpp — Native DLL injected into WinForms target process. +// Hosts the .NET CLR and calls managed WinFormsTreeWalker.CollectTree(). +// Supports both .NET Framework (ICLRMetaHost) and .NET Core (hostfxr). + +#include +#include +#include +#include + +static void LogMsg(const char* fmt, ...) { + static FILE* logFile = nullptr; + if (!logFile) { + wchar_t tmp[MAX_PATH]; + GetTempPathW(MAX_PATH, tmp); + wcscat_s(tmp, L"lvt_WinForms_tap.log"); + logFile = _wfopen(tmp, L"a"); + if (!logFile) return; + } + fprintf(logFile, "[%lu] ", GetCurrentThreadId()); + va_list ap; + va_start(ap, fmt); + vfprintf(logFile, fmt, ap); + va_end(ap); + fprintf(logFile, "\n"); + fflush(logFile); +} + +static DWORD WINAPI WorkerThread(LPVOID); + +static std::wstring GetDllDirectory() { + wchar_t path[MAX_PATH]; + HMODULE hm = nullptr; + GetModuleHandleExW( + GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT, + reinterpret_cast(&LogMsg), &hm); + GetModuleFileNameW(hm, path, MAX_PATH); + std::wstring dir(path); + auto pos = dir.find_last_of(L"\\/"); + if (pos != std::wstring::npos) dir.resize(pos); + return dir; +} + +// Read pipe name from a sidecar file written by lvt.exe before injection. +static std::wstring ReadPipeName() { + std::wstring dir = GetDllDirectory(); + std::wstring path = dir + L"\\lvt_WinForms_pipe.txt"; + + HANDLE hFile = CreateFileW(path.c_str(), GENERIC_READ, FILE_SHARE_READ, + nullptr, OPEN_EXISTING, 0, nullptr); + if (hFile == INVALID_HANDLE_VALUE) { + LogMsg("Failed to open pipe name file: %lu", GetLastError()); + return {}; + } + + char buf[256]{}; + DWORD bytesRead = 0; + ReadFile(hFile, buf, sizeof(buf) - 1, &bytesRead, nullptr); + CloseHandle(hFile); + + // Convert UTF-8 to wide + int wlen = MultiByteToWideChar(CP_UTF8, 0, buf, bytesRead, nullptr, 0); + std::wstring result(wlen, L'\0'); + MultiByteToWideChar(CP_UTF8, 0, buf, bytesRead, result.data(), wlen); + + // Trim whitespace + while (!result.empty() && (result.back() == L'\r' || result.back() == L'\n' || result.back() == L' ')) + result.pop_back(); + + LogMsg("Pipe name read: %ls", result.c_str()); + return result; +} + +// Try .NET Framework hosting via ICLRMetaHost +static bool TryNetFramework(const std::wstring& assemblyPath, const std::wstring& pipeName) { + HMODULE hMscoree = LoadLibraryW(L"mscoree.dll"); + if (!hMscoree) { + LogMsg("mscoree.dll not found"); + return false; + } + + using CLRCreateInstanceFn = HRESULT(WINAPI*)(REFCLSID, REFIID, LPVOID*); + auto pCLRCreateInstance = reinterpret_cast( + GetProcAddress(hMscoree, "CLRCreateInstance")); + if (!pCLRCreateInstance) { + LogMsg("CLRCreateInstance not found in mscoree.dll"); + FreeLibrary(hMscoree); + return false; + } + + ICLRMetaHost* metaHost = nullptr; + HRESULT hr = pCLRCreateInstance(CLSID_CLRMetaHost, IID_ICLRMetaHost, (void**)&metaHost); + if (FAILED(hr)) { + LogMsg("CLRCreateInstance failed: 0x%08X", hr); + FreeLibrary(hMscoree); + return false; + } + + // Enumerate loaded runtimes to find the one already running + IEnumUnknown* pEnum = nullptr; + hr = metaHost->EnumerateLoadedRuntimes(GetCurrentProcess(), &pEnum); + if (FAILED(hr)) { + LogMsg("EnumerateLoadedRuntimes failed: 0x%08X", hr); + metaHost->Release(); + return false; + } + + ICLRRuntimeInfo* runtimeInfo = nullptr; + IUnknown* pUnk = nullptr; + ULONG fetched = 0; + while (pEnum->Next(1, &pUnk, &fetched) == S_OK) { + hr = pUnk->QueryInterface(IID_ICLRRuntimeInfo, (void**)&runtimeInfo); + pUnk->Release(); + if (SUCCEEDED(hr)) break; + } + pEnum->Release(); + + if (!runtimeInfo) { + LogMsg("No loaded CLR runtime found"); + metaHost->Release(); + return false; + } + + wchar_t version[64]{}; + DWORD versionLen = 64; + runtimeInfo->GetVersionString(version, &versionLen); + LogMsg("Found CLR runtime: %ls", version); + + ICLRRuntimeHost* runtimeHost = nullptr; + hr = runtimeInfo->GetInterface(CLSID_CLRRuntimeHost, IID_ICLRRuntimeHost, (void**)&runtimeHost); + runtimeInfo->Release(); + metaHost->Release(); + + if (FAILED(hr) || !runtimeHost) { + LogMsg("GetInterface for ICLRRuntimeHost failed: 0x%08X", hr); + return false; + } + + // ExecuteInDefaultAppDomain calls a static method with signature: + // static int MethodName(string argument) + DWORD retVal = 0; + hr = runtimeHost->ExecuteInDefaultAppDomain( + assemblyPath.c_str(), + L"LvtWinFormsTap.WinFormsTreeWalker", + L"CollectTree", + pipeName.c_str(), + &retVal); + + runtimeHost->Release(); + + LogMsg("ExecuteInDefaultAppDomain returned 0x%08X, retVal=%lu", hr, retVal); + return SUCCEEDED(hr); +} + +// Try .NET Core hosting by finding the already-loaded coreclr.dll +// and calling managed code via the CLR hosting API. +typedef int (STDMETHODCALLTYPE *coreclr_create_delegate_fn)( + void* hostHandle, unsigned int domainId, + const char* entryPointAssemblyName, + const char* entryPointTypeName, + const char* entryPointMethodName, + void** delegate); + +typedef int (STDMETHODCALLTYPE *coreclr_execute_assembly_fn)( + void* hostHandle, unsigned int domainId, + int argc, const char** argv, + unsigned int* exitCode); + +// For .NET Core, we use a different approach: since the CLR is already running, +// we find coreclr.dll and use its exports to call into managed code. +// However, coreclr_create_delegate requires the host handle and domain ID, +// which we don't have from an injected DLL. +// +// Instead, we use the .NET hosting API (hostfxr) to load and call into +// a component assembly. Since the runtime is already initialized, hostfxr +// will reuse the existing runtime. +static bool TryNetCore(const std::wstring& assemblyPath, const std::wstring& pipeName) { + // Find hostfxr.dll - it should be loadable if .NET is installed + // First check if it's already loaded in the process + HMODULE hHostfxr = GetModuleHandleW(L"hostfxr.dll"); + if (!hHostfxr) { + // Try to load it from the .NET installation + // Use nethost to find it + LogMsg("hostfxr.dll not loaded, trying to find it"); + + // Search common .NET install locations + wchar_t progFiles[MAX_PATH]; + GetEnvironmentVariableW(L"ProgramFiles", progFiles, MAX_PATH); + std::wstring dotnetDir = std::wstring(progFiles) + L"\\dotnet\\host\\fxr"; + + WIN32_FIND_DATAW fd; + HANDLE hFind = FindFirstFileW((dotnetDir + L"\\*").c_str(), &fd); + std::wstring latestFxr; + if (hFind != INVALID_HANDLE_VALUE) { + do { + if (fd.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY && fd.cFileName[0] != L'.') { + latestFxr = dotnetDir + L"\\" + fd.cFileName + L"\\hostfxr.dll"; + } + } while (FindNextFileW(hFind, &fd)); + FindClose(hFind); + } + + if (!latestFxr.empty()) { + hHostfxr = LoadLibraryW(latestFxr.c_str()); + LogMsg("Loaded hostfxr from: %ls -> %p", latestFxr.c_str(), hHostfxr); + } + } + + if (!hHostfxr) { + LogMsg("Could not find hostfxr.dll"); + return false; + } + + // Get hostfxr exports + using hostfxr_initialize_fn = int(STDMETHODCALLTYPE*)( + const wchar_t* runtime_config_path, + const void* parameters, + void** host_context_handle); + using hostfxr_get_delegate_fn = int(STDMETHODCALLTYPE*)( + void* host_context_handle, + int type, + void** delegate); + using hostfxr_close_fn = int(STDMETHODCALLTYPE*)(void* host_context_handle); + + auto init_fn = reinterpret_cast( + GetProcAddress(hHostfxr, "hostfxr_initialize_for_runtime_config")); + auto get_delegate_fn = reinterpret_cast( + GetProcAddress(hHostfxr, "hostfxr_get_runtime_delegate")); + auto close_fn = reinterpret_cast( + GetProcAddress(hHostfxr, "hostfxr_close")); + + if (!init_fn || !get_delegate_fn || !close_fn) { + LogMsg("Failed to get hostfxr exports"); + return false; + } + + // Find the runtimeconfig.json next to the managed assembly + std::wstring configPath = assemblyPath; + auto dotPos = configPath.rfind(L'.'); + if (dotPos != std::wstring::npos) + configPath = configPath.substr(0, dotPos); + configPath += L".runtimeconfig.json"; + + // If no runtimeconfig.json exists, create a minimal one + if (GetFileAttributesW(configPath.c_str()) == INVALID_FILE_ATTRIBUTES) { + FILE* f = _wfopen(configPath.c_str(), L"w"); + if (f) { + fprintf(f, "{\n \"runtimeOptions\": {\n" + " \"framework\": {\n" + " \"name\": \"Microsoft.WindowsDesktop.App\",\n" + " \"version\": \"8.0.0\"\n" + " }\n }\n}\n"); + fclose(f); + LogMsg("Created runtimeconfig.json"); + } + } + + void* hostContext = nullptr; + int rc = init_fn(configPath.c_str(), nullptr, &hostContext); + LogMsg("hostfxr_initialize_for_runtime_config returned 0x%08X, context=%p", rc, hostContext); + + // rc == 0 means success, rc == 1 means "already initialized" (which is fine) + if (rc < 0 || !hostContext) { + LogMsg("hostfxr init failed"); + if (hostContext) close_fn(hostContext); + return false; + } + + // hdt_load_assembly_and_get_function_pointer = 5 + void* loadAndGet = nullptr; + rc = get_delegate_fn(hostContext, 5, &loadAndGet); + LogMsg("hostfxr_get_runtime_delegate(hdt_load_assembly_and_get_function_pointer) returned 0x%08X", rc); + + if (rc < 0 || !loadAndGet) { + close_fn(hostContext); + return false; + } + + // Signature for load_assembly_and_get_function_pointer: + // int fn(const wchar_t* assembly_path, const wchar_t* type_name, + // const wchar_t* method_name, const wchar_t* delegate_type_name, + // void* reserved, void** delegate) + using load_assembly_fn = int(STDMETHODCALLTYPE*)( + const wchar_t*, const wchar_t*, const wchar_t*, const wchar_t*, void*, void**); + auto loadAssembly = reinterpret_cast(loadAndGet); + + // Get function pointer to WinFormsTreeWalker.CollectTree + // Using UNMANAGEDCALLERSONLY_METHOD delegate type (-1) for simpler interop + // But since our method takes a string, we'll use the default delegate + using CollectTreeFn = int(STDMETHODCALLTYPE*)(const wchar_t*, int); + CollectTreeFn collectTree = nullptr; + + rc = loadAssembly( + assemblyPath.c_str(), + L"LvtWinFormsTap.WinFormsTreeWalker, LvtWinFormsTap", + L"CollectTree", + L"LvtWinFormsTap.WinFormsTreeWalker+CollectTreeDelegate, LvtWinFormsTap", + nullptr, + reinterpret_cast(&collectTree)); + + LogMsg("load_assembly_and_get_function_pointer returned 0x%08X, fn=%p", rc, collectTree); + + if (rc < 0 || !collectTree) { + close_fn(hostContext); + return false; + } + + int retVal = collectTree(pipeName.c_str(), static_cast(pipeName.size() * sizeof(wchar_t))); + LogMsg("CollectTree returned %d", retVal); + + close_fn(hostContext); + return retVal == 0; +} + +static DWORD WINAPI WorkerThread(LPVOID lpParameter) { + HMODULE hSelf = reinterpret_cast(lpParameter); + LogMsg("WorkerThread starting"); + + std::wstring pipeName = ReadPipeName(); + if (pipeName.empty()) { + LogMsg("No pipe name, exiting"); + if (hSelf) FreeLibraryAndExitThread(hSelf, 1); + return 1; + } + + std::wstring dir = GetDllDirectory(); + std::wstring assemblyPath = dir + L"\\LvtWinFormsTap.dll"; + + if (GetFileAttributesW(assemblyPath.c_str()) == INVALID_FILE_ATTRIBUTES) { + LogMsg("Managed assembly not found: %ls", assemblyPath.c_str()); + if (hSelf) FreeLibraryAndExitThread(hSelf, 1); + return 1; + } + + DWORD result = 1; + LogMsg("Attempting .NET Framework hosting..."); + if (TryNetFramework(assemblyPath, pipeName)) { + LogMsg("Tree collection succeeded via .NET Framework"); + result = 0; + } else { + LogMsg("Attempting .NET Core hosting..."); + if (TryNetCore(assemblyPath, pipeName)) { + LogMsg("Tree collection succeeded via .NET Core"); + result = 0; + } else { + LogMsg("All CLR hosting attempts failed"); + } + } + + // Unload ourselves from the target process. + // FreeLibraryAndExitThread atomically frees and exits so we don't + // return to unloaded code (Raymond Chen, Old New Thing 2013-11-05). + if (hSelf) FreeLibraryAndExitThread(hSelf, result); + return result; +} + +extern "C" { + +BOOL APIENTRY DllMain(HMODULE hMod, DWORD reason, LPVOID) { + if (reason == DLL_PROCESS_ATTACH) { + DisableThreadLibraryCalls(hMod); + LogMsg("DllMain: DLL_PROCESS_ATTACH"); + + // Spawn worker thread for the initial collection. + // The worker calls FreeLibraryAndExitThread when done to unload this DLL. + HANDLE hThread = CreateThread(nullptr, 0, WorkerThread, reinterpret_cast(hMod), 0, nullptr); + if (hThread) CloseHandle(hThread); + } + return TRUE; +} + +} // extern "C" diff --git a/src/tap_winforms/winforms_tap_native.def b/src/tap_winforms/winforms_tap_native.def new file mode 100644 index 0000000..ce0cf70 --- /dev/null +++ b/src/tap_winforms/winforms_tap_native.def @@ -0,0 +1,2 @@ +EXPORTS + DllMain diff --git a/src/tree_builder.cpp b/src/tree_builder.cpp index 19de733..80975c6 100644 --- a/src/tree_builder.cpp +++ b/src/tree_builder.cpp @@ -6,6 +6,7 @@ #include "providers/xaml_provider.h" #include "providers/winui3_provider.h" #include "providers/wpf_provider.h" +#include "providers/winforms_provider.h" #include "plugin_loader.h" #include #include @@ -135,6 +136,11 @@ Element build_tree(HWND hwnd, DWORD pid, const std::vector& frame wpf.enrich(root, hwnd, pid); break; } + case Framework::WinForms: { + WinFormsProvider winforms; + winforms.enrich(root, hwnd, pid); + break; + } case Framework::Plugin: { // Look up the plugin by name and enrich for (auto& p : get_plugins()) { diff --git a/tests/apps/WinFormsSample/.gitignore b/tests/apps/WinFormsSample/.gitignore new file mode 100644 index 0000000..f6f63c7 --- /dev/null +++ b/tests/apps/WinFormsSample/.gitignore @@ -0,0 +1,3 @@ +bin/ +obj/ + diff --git a/tests/apps/WinFormsSample/Program.cs b/tests/apps/WinFormsSample/Program.cs new file mode 100644 index 0000000..2db15f1 --- /dev/null +++ b/tests/apps/WinFormsSample/Program.cs @@ -0,0 +1,67 @@ +using System; +using System.Drawing; +using System.Windows.Forms; + +namespace WinFormsSample +{ + internal static class Program + { + [STAThread] + private static void Main() + { + Application.EnableVisualStyles(); + Application.SetCompatibleTextRenderingDefault(false); + Application.Run(new SampleForm()); + } + } + + internal sealed class SampleForm : Form + { + public SampleForm() + { + Name = "MainForm"; + Text = "LVT WinForms Sample"; + StartPosition = FormStartPosition.Manual; + Location = new Point(120, 120); + Size = new Size(420, 260); + + var label = new Label + { + Name = "messageLabel", + Text = "Sample label", + AutoSize = true, + Location = new Point(20, 20) + }; + + var textBox = new TextBox + { + Name = "inputTextBox", + Text = "Sample text", + Location = new Point(20, 55), + Width = 220 + }; + + var button = new Button + { + Name = "okButton", + Text = "OK", + Location = new Point(20, 95), + Size = new Size(90, 30) + }; + + var checkBox = new CheckBox + { + Name = "enabledCheckBox", + Text = "Enabled", + Checked = true, + Location = new Point(20, 135), + AutoSize = true + }; + + Controls.Add(label); + Controls.Add(textBox); + Controls.Add(button); + Controls.Add(checkBox); + } + } +} diff --git a/tests/apps/WinFormsSample/WinFormsSample.csproj b/tests/apps/WinFormsSample/WinFormsSample.csproj new file mode 100644 index 0000000..93b0797 --- /dev/null +++ b/tests/apps/WinFormsSample/WinFormsSample.csproj @@ -0,0 +1,11 @@ + + + WinExe + net10.0-windows + true + latest + false + false + x64 + + diff --git a/tests/integration_tests.cpp b/tests/integration_tests.cpp index d7ea938..beb98e4 100644 --- a/tests/integration_tests.cpp +++ b/tests/integration_tests.cpp @@ -422,6 +422,16 @@ static bool frameworks_contain_avalonia(const json& j) { return false; } +static bool frameworks_contain_winforms(const json& j) { + if (!j.contains("frameworks") || !j["frameworks"].is_array()) + return false; + for (auto& fw : j["frameworks"]) { + if (fw.is_string() && fw.get().starts_with("winforms")) + return true; + } + return false; +} + static bool has_winui3_descendant(const json& el) { if (el.value("framework", "") == "winui3") return true; @@ -737,6 +747,181 @@ TEST_F(AvaloniaFixture, DurableKeysAndQueryRoundTrip) { EXPECT_EQ(trim_crlf(queriedName), "ClickButton"); } +class WinFormsSampleFixture : public ::testing::Test { +protected: + static void SetUpTestSuite() { + s_sample_exe = WINFORMS_SAMPLE_EXE_PATH; + if (!fs::exists(s_sample_exe)) { + s_skip_reason = "WinForms sample app not found: " + s_sample_exe; + return; + } + + STARTUPINFOA si = {sizeof(si)}; + s_pi = {}; + auto workdir = fs::path(s_sample_exe).parent_path().string(); + std::string cmd = "\"" + s_sample_exe + "\""; + if (!CreateProcessA(nullptr, cmd.data(), nullptr, nullptr, FALSE, 0, + nullptr, workdir.c_str(), &si, &s_pi)) { + s_skip_reason = "Failed to launch WinForms sample app"; + return; + } + s_pid = s_pi.dwProcessId; + if (s_pi.hProcess) { + WaitForInputIdle(s_pi.hProcess, 5000); + } + + auto lvt = get_lvt_path(); + auto winformsReady = [](const json& j) { + return frameworks_contain_winforms(j) && + j.contains("root") && + json_tree_has_named_control(j["root"], "okButton") && + json_tree_has_named_control(j["root"], "inputTextBox") && + json_tree_has_named_control(j["root"], "messageLabel"); + }; + auto readyDump = dump_ready_tree(lvt, get_pid_arg(), winformsReady, 50); + if (winformsReady(readyDump)) { + s_ready = true; + return; + } + + s_skip_reason = "WinForms sample app never became ready with framework and named controls"; + } + + static void TearDownTestSuite() { + if (s_pi.hProcess) { + TerminateProcess(s_pi.hProcess, 0); + CloseHandle(s_pi.hProcess); + CloseHandle(s_pi.hThread); + } + } + + static void SkipIfNotReady() { + if (!s_ready) + GTEST_SKIP() << s_skip_reason; + } + + static std::string get_pid_arg() { + return "--pid " + std::to_string(s_pid); + } + + static PROCESS_INFORMATION s_pi; + static DWORD s_pid; + static bool s_ready; + static std::string s_sample_exe; + static std::string s_skip_reason; +}; + +PROCESS_INFORMATION WinFormsSampleFixture::s_pi = {}; +DWORD WinFormsSampleFixture::s_pid = 0; +bool WinFormsSampleFixture::s_ready = false; +std::string WinFormsSampleFixture::s_sample_exe; +std::string WinFormsSampleFixture::s_skip_reason; + +TEST_F(WinFormsSampleFixture, DetectsWinFormsFramework) { + SkipIfNotReady(); + + auto lvt = get_lvt_path(); + auto output = run_command(make_cmd(lvt, get_pid_arg() + " --frameworks")); + ASSERT_FALSE(output.empty()); + EXPECT_NE(output.find("winforms"), std::string::npos); +} + +TEST_F(WinFormsSampleFixture, EnrichesControlsWithManagedNameAndType) { + SkipIfNotReady(); + + auto lvt = get_lvt_path(); + auto winformsReady = [](const json& j) { + return frameworks_contain_winforms(j) && + j.contains("root") && + json_tree_has_named_control(j["root"], "okButton") && + json_tree_has_named_control(j["root"], "inputTextBox") && + json_tree_has_named_control(j["root"], "messageLabel"); + }; + auto j = dump_ready_tree(lvt, get_pid_arg(), winformsReady, 50); + ASSERT_TRUE(winformsReady(j)) << "WinForms tree never became ready"; + + auto* okButton = find_named_control(j["root"], "okButton"); + ASSERT_NE(okButton, nullptr); + EXPECT_EQ(okButton->value("framework", ""), "winforms"); + EXPECT_EQ(okButton->value("type", ""), "Button"); + EXPECT_EQ((*okButton)["properties"].value("winforms.type", ""), "System.Windows.Forms.Button"); + + auto* textBox = find_named_control(j["root"], "inputTextBox"); + ASSERT_NE(textBox, nullptr); + EXPECT_EQ(textBox->value("type", ""), "TextBox"); + EXPECT_EQ((*textBox)["properties"].value("winforms.type", ""), "System.Windows.Forms.TextBox"); + + auto* label = find_named_control(j["root"], "messageLabel"); + ASSERT_NE(label, nullptr); + EXPECT_EQ(label->value("type", ""), "Label"); + + auto okKey = okButton->value("key", ""); + ASSERT_FALSE(okKey.empty()); + auto queried = query_element_until(lvt, get_pid_arg(), okKey, okKey); + ASSERT_FALSE(queried.is_discarded()) << "query for okButton key never resolved"; + EXPECT_EQ(queried.value("framework", ""), "winforms"); + EXPECT_EQ(queried.value("type", ""), "Button"); + EXPECT_EQ(queried.value("name", ""), "okButton"); + + auto queriedType = query_prop_until(lvt, get_pid_arg(), okKey, "winforms.type", + "System.Windows.Forms.Button"); + EXPECT_EQ(queriedType, "System.Windows.Forms.Button"); +} + +TEST_F(WinFormsSampleFixture, DurableKeyContract) { + SkipIfNotReady(); + + auto lvt = get_lvt_path(); + auto winformsReady = [](const json& j) { + return frameworks_contain_winforms(j) && + j.contains("root") && + json_tree_has_named_control(j["root"], "okButton") && + json_tree_has_named_control(j["root"], "inputTextBox") && + json_tree_has_named_control(j["root"], "messageLabel"); + }; + auto j1 = dump_ready_tree(lvt, get_pid_arg(), winformsReady, 50); + auto j2 = dump_ready_tree(lvt, get_pid_arg(), winformsReady, 50); + ASSERT_TRUE(winformsReady(j1)) << "WinForms tree never became ready (dump 1)"; + ASSERT_TRUE(winformsReady(j2)) << "WinForms tree never became ready (dump 2)"; + ASSERT_TRUE(frameworks_contain_winforms(j1)); + + std::vector elements; + collect_json_elements(j1["root"], elements); + ASSERT_GT(elements.size(), 0u); + + std::set keys; + for (auto* el : elements) { + auto key = el->value("key", ""); + EXPECT_FALSE(key.empty()) << "Element " << el->value("id", "?") << " has empty durable key"; + EXPECT_TRUE(keys.insert(key).second) << "Duplicate durable key: " << key; + } + + std::map firstMap; + std::map secondMap; + collect_key_contract_map(j1["root"], "0", firstMap); + collect_key_contract_map(j2["root"], "0", secondMap); + EXPECT_EQ(firstMap, secondMap); + + auto* okButton = find_named_control(j1["root"], "okButton"); + ASSERT_NE(okButton, nullptr); + EXPECT_EQ(okButton->value("framework", ""), "winforms"); + EXPECT_EQ(okButton->value("type", ""), "Button"); + EXPECT_EQ((*okButton)["properties"].value("winforms.type", ""), "System.Windows.Forms.Button"); + + auto okKey = okButton->value("key", ""); + ASSERT_FALSE(okKey.empty()); + auto queried = query_element_until(lvt, get_pid_arg(), okKey, okKey); + ASSERT_FALSE(queried.is_discarded()) << "query for okButton key never resolved"; + EXPECT_EQ(queried.value("key", ""), okKey); + EXPECT_EQ(queried.value("type", ""), "Button"); + EXPECT_EQ(queried.value("framework", ""), "winforms"); + EXPECT_EQ(queried.value("name", ""), "okButton"); + + auto queriedType = query_prop_until(lvt, get_pid_arg(), okKey, "winforms.type", + "System.Windows.Forms.Button"); + EXPECT_EQ(queriedType, "System.Windows.Forms.Button"); +} + class WpfSampleFixture : public ::testing::Test { protected: static void SetUpTestSuite() { diff --git a/tests/unit_tests.cpp b/tests/unit_tests.cpp index 8110acf..d278dcd 100644 --- a/tests/unit_tests.cpp +++ b/tests/unit_tests.cpp @@ -8,6 +8,7 @@ #include "watch_diff.h" #include "framework_detector.h" #include "target.h" +#include "providers/winforms_inject.h" #include #include @@ -71,6 +72,7 @@ TEST(FrameworkToString, AllFrameworks) { EXPECT_EQ(framework_to_string(Framework::Xaml), "xaml"); EXPECT_EQ(framework_to_string(Framework::WinUI3), "winui3"); EXPECT_EQ(framework_to_string(Framework::Wpf), "wpf"); + EXPECT_EQ(framework_to_string(Framework::WinForms), "winforms"); EXPECT_EQ(framework_to_string(Framework::Plugin), "plugin"); } @@ -474,6 +476,53 @@ TEST(Element, TreeConstruction) { EXPECT_EQ(root.children[1].type, "Child2"); } +// ---- WinForms enrichment ---- + +TEST(WinFormsEnrichment, AppliesManagedPropertiesByHwnd) { + Element root; + root.type = "Window"; + root.framework = "win32"; + root.nativeHandle = 0x100; + + Element child; + child.type = "Button"; + child.framework = "win32"; + child.nativeHandle = 0x200; + child.properties["hwnd"] = "0x00000200"; + root.children.push_back(child); + + auto ok = apply_winforms_control_json(root, + R"([{"hwnd":"100","type":"System.Windows.Forms.Form","name":"MainForm","children":[)" + R"({"hwnd":"200","type":"System.Windows.Forms.Button","name":"okButton","text":"OK","enabled":true,"autoSize":false})" + R"(]}])"); + + ASSERT_TRUE(ok); + EXPECT_EQ(root.framework, "winforms"); + EXPECT_EQ(root.type, "Form"); + EXPECT_EQ(root.properties["winforms.type"], "System.Windows.Forms.Form"); + EXPECT_EQ(root.properties["name"], "MainForm"); + + auto& enriched = root.children[0]; + EXPECT_EQ(enriched.framework, "winforms"); + EXPECT_EQ(enriched.type, "Button"); + EXPECT_EQ(enriched.properties["winforms.type"], "System.Windows.Forms.Button"); + EXPECT_EQ(enriched.properties["name"], "okButton"); + EXPECT_EQ(enriched.properties["winforms.text"], "OK"); + EXPECT_EQ(enriched.properties["winforms.enabled"], "true"); + EXPECT_EQ(enriched.properties["autoSize"], "false"); +} + +TEST(WinFormsEnrichment, InvalidJsonDoesNotModifyTree) { + Element root; + root.type = "Window"; + root.framework = "win32"; + root.nativeHandle = 0x100; + + EXPECT_FALSE(apply_winforms_control_json(root, "{not json")); + EXPECT_EQ(root.framework, "win32"); + EXPECT_EQ(root.type, "Window"); +} + // ---- Watch diff ---- static Element diff_el(const std::string& type, const std::string& className, From 477d66278b1e2d8b4b95d151a0035e9a9bc6a612 Mon Sep 17 00:00:00 2001 From: Alexander Sklar <22989529+asklar@users.noreply.github.com> Date: Thu, 9 Jul 2026 01:11:25 -0700 Subject: [PATCH 2/2] Fix WinForms TAP managed output path under Platform=x64 (CI build) LvtWinFormsTap.csproj set PlatformTarget=AnyCPU but not an explicit OutputPath, so in a VS x64 developer environment (CI sets Platform=x64) MSBuild emitted the assembly to bin\x64\Release\ instead of bin\Release\. The native lvt_winforms_tap target copies from bin\Release\, so CI failed with "Error copying ... LvtWinFormsTap.dll: No such file or directory". Force a flat bin\\ output via explicit OutputPath so every invocation (ci.yml dotnet build, the CMake lvt_winforms_managed custom command, and plain local builds) lands the DLL where the copy expects it, regardless of the ambient Platform. Verified by reproducing Platform=x64 locally: the DLL now builds to bin\Release\ and lvt_winforms_tap's copy succeeds. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/tap_winforms/LvtWinFormsTap.csproj | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/tap_winforms/LvtWinFormsTap.csproj b/src/tap_winforms/LvtWinFormsTap.csproj index a1cc847..cd880d5 100644 --- a/src/tap_winforms/LvtWinFormsTap.csproj +++ b/src/tap_winforms/LvtWinFormsTap.csproj @@ -7,6 +7,10 @@ false false AnyCPU + + bin\$(Configuration)\