diff --git a/.github/skills/lvt/SKILL.md b/.github/skills/lvt/SKILL.md index 875dc66..8f33151 100644 --- a/.github/skills/lvt/SKILL.md +++ b/.github/skills/lvt/SKILL.md @@ -263,7 +263,10 @@ Pattern state is only emitted where the pattern is supported, so the presence of 2. **Run `lvt --name --format xml`** to get a quick overview of the UI tree 3. **Take a screenshot** with `lvt screenshot --name --output ui.png` to see the visual layout with element IDs 4. **Drill into a subtree** with `--element --depth ` if the tree is large -5. **Use element IDs and bounds** to plan any UI interactions (clicks, keyboard input) +5. **Add `--fast`** on a rich XAML/WinUI3 app if `dump`/`watch` feels slow — it + skips the full property-chain walk in favor of cheap bounds/Text/Content/ + basic-state reads, at the cost of not reporting arbitrary custom properties +6. **Use element IDs and bounds** to plan any UI interactions (clicks, keyboard input) ## MCP server mode diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8045702..b6e8521 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -26,18 +26,21 @@ jobs: preset: default build_dir: build build_tests: 'ON' + build_viewer: 'ON' rust_target: x86_64-pc-windows-msvc - arch: x86 msvc_arch: amd64_x86 preset: x86 build_dir: build-x86 build_tests: 'OFF' + build_viewer: 'OFF' rust_target: i686-pc-windows-msvc - arch: arm64 msvc_arch: amd64_arm64 preset: arm64 build_dir: build-arm64 build_tests: 'OFF' + build_viewer: 'OFF' rust_target: aarch64-pc-windows-msvc steps: @@ -64,6 +67,12 @@ jobs: - name: Set up Rust (for the MCP server) run: rustup target add ${{ matrix.rust_target }} + - name: Set up .NET 10 (for the x64 viewer) + if: matrix.build_viewer == 'ON' + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + # Tests only run on x64, and gtest now sits behind a vcpkg manifest # feature, so the other legs can skip building it entirely. - name: Configure @@ -71,6 +80,7 @@ jobs: cmake --preset ${{ matrix.preset }} -DCMAKE_BUILD_TYPE=Release -DLVT_BUILD_TESTS=${{ matrix.build_tests }} + -DLVT_BUILD_VIEWER=${{ matrix.build_viewer }} -DLVT_ENABLE_MCP=ON - name: Build managed WPF assembly @@ -128,12 +138,40 @@ jobs: Copy-Item .github\skills\lvt\SKILL.md release\skills\lvt\ Compress-Archive -Path release\* -DestinationPath lvt-${{ env.RELEASE_TAG }}-${{ matrix.arch }}.zip + # Keep the normal CLI archive lean. The viewer has a .NET Desktop + # Runtime dependency and is published as a separate, version-matched + # x64 asset. It still includes the complete matching lvt runtime so + # users never have to combine two archives by hand. + - name: Package viewer + if: matrix.build_viewer == 'ON' + run: | + if (!(Test-Path ${{ matrix.build_dir }}\viewer\LvtViewer.exe)) { + throw "The viewer build output was not produced." + } + New-Item -ItemType Directory -Path viewer-release -Force + Copy-Item -Recurse ${{ matrix.build_dir }}\viewer\* viewer-release\ + Copy-Item src\viewer\RELEASE-README.txt viewer-release\README.txt + Copy-Item LICENSE viewer-release\ + # Debug symbols and incremental-link files are useful CI artifacts, + # but not runtime dependencies and add substantial release weight. + Get-ChildItem viewer-release -Recurse -File -Include *.pdb,*.ilk,*.locked* | + Remove-Item -Force + Compress-Archive -Path viewer-release\* ` + -DestinationPath lvt-viewer-${{ env.RELEASE_TAG }}-x64.zip + - name: Upload release artifact uses: actions/upload-artifact@v4 with: name: release-${{ matrix.arch }} path: lvt-${{ env.RELEASE_TAG }}-${{ matrix.arch }}.zip + - name: Upload viewer release artifact + if: matrix.build_viewer == 'ON' + uses: actions/upload-artifact@v4 + with: + name: release-viewer-x64 + path: lvt-viewer-${{ env.RELEASE_TAG }}-x64.zip + publish: runs-on: ubuntu-latest needs: release diff --git a/CMakeLists.txt b/CMakeLists.txt index fdfde54..13f300d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -48,6 +48,11 @@ option(LVT_ENABLE_MCP "MCP server mode (lvt mcp); requires a Rust toolchain option(LVT_BUILD_TOOL "Build the lvt command-line tool" ON) option(LVT_BUILD_MANAGED "Build managed .NET assemblies (requires .NET SDK)" ON) +# Off by default: it's a separate WPF app built by its own `dotnet build`, not +# something most contributors touching lvt_core need. See src/viewer/README.md +# for the architecture (it drives lvt.exe as a subprocess; it never links +# lvt_core) and why it is not part of the default build. +option(LVT_BUILD_VIEWER "Build the lvt Viewer WPF app (requires .NET SDK)" OFF) # LVT_BUILD_TESTS is declared above project(), before the vcpkg toolchain runs. if(LVT_BUILD_MANAGED AND NOT (LVT_ENABLE_WPF OR LVT_ENABLE_WINFORMS OR LVT_ENABLE_AVALONIA)) @@ -107,6 +112,12 @@ set(LVT_PUBLIC_HEADERS src/wil_diagnostics.h "${CMAKE_CURRENT_BINARY_DIR}/include/lvt/lvt_config.h" ) +# tree_builder.h exposes ConnectionLookup in its public API and includes this +# header using its source-tree-relative providers/ path. Preserve that layout +# in the install tree rather than flattening the provider header beside it. +set(LVT_PUBLIC_PROVIDER_HEADERS + src/providers/framework_connection.h +) # --- lvt_core: the reusable library behind the CLI --- set(LVT_CORE_SOURCES @@ -124,6 +135,7 @@ set(LVT_CORE_SOURCES src/wil_diagnostics.cpp src/providers/win32_provider.cpp src/providers/comctl_provider.cpp + src/providers/connection_registry.cpp ) # xaml_diag_common backs both the XAML and WinUI 3 providers. @@ -196,6 +208,50 @@ if(LVT_BUILD_TOOL) "lvt - Live Visual Tree CLI" "lvt" "lvt.exe" VFT_APP) endif() +# --- lvt Viewer (WPF, .NET) -------------------------------------------------- +# A separate desktop GUI, built by its own `dotnet build` rather than CMake +# compiling anything: it drives lvt.exe as a subprocess (dump/watch/action +# verbs) instead of linking lvt_core, so nothing here needs a C++/.NET +# interop seam. See src/viewer/README.md for the architecture. +if(LVT_BUILD_VIEWER) + find_program(LVT_DOTNET_EXE NAMES dotnet) + if(NOT LVT_DOTNET_EXE) + message(FATAL_ERROR + "LVT_BUILD_VIEWER is ON but dotnet was not found. Install the .NET SDK, " + "or configure with -DLVT_BUILD_VIEWER=OFF to build without the viewer.") + endif() + + set(LVT_VIEWER_PROJECT "${CMAKE_CURRENT_SOURCE_DIR}/src/viewer/LvtViewer/LvtViewer.csproj") + set(LVT_VIEWER_OUTPUT_DIR "${CMAKE_BINARY_DIR}/viewer") + add_custom_target(lvt_viewer ALL + COMMAND "${LVT_DOTNET_EXE}" build "${LVT_VIEWER_PROJECT}" -c Release -o "${LVT_VIEWER_OUTPUT_DIR}" + BYPRODUCTS "${LVT_VIEWER_OUTPUT_DIR}/LvtViewer.exe" + WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}" + COMMENT "Building lvt Viewer (WPF)" + ) + if(LVT_BUILD_TOOL) + # A copy of lvt.exe with none of its TAP DLLs/plugins next to it is + # actively worse than no copy: it resolves (get_tap_directory() finds + # this directory first) but every framework-specific enrichment it + # would have provided silently stops working, indistinguishable from + # the target simply not using that framework - exactly the failure + # mode a user hit the first time this copied everything but the TAP + # DLLs. The rest of that copying (mirroring the release packaging + # step in .github/workflows/release.yml and the install() rules + # below) lives further down this file, after the TAP DLL and plugin + # targets it references are actually defined - if(TARGET ...) can + # only see targets CMake has already processed by that point in the + # file, so doing it here (before those add_library() calls) would + # silently no-op every one of those checks. + add_custom_command(TARGET lvt_viewer POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "$" "${LVT_VIEWER_OUTPUT_DIR}/lvt.exe" + COMMENT "Copying lvt.exe next to the viewer" + ) + add_dependencies(lvt_viewer lvt) + endif() +endif() + # --- MCP server (Rust) ----------------------------------------------------- # The MCP protocol layer is a Rust staticlib linked into lvt.exe, so `lvt mcp` # is served by the same binary the rest of the CLI is — no second executable and @@ -332,13 +388,39 @@ if(LVT_ENABLE_WINUI3) # There is no vcpkg port for the Windows App SDK, so this always comes from # a NuGet package: the vcpkg port downloads it and passes the directory in, # otherwise fall back to the NuGet cache or a local packages/ dir. + # + # Two different NuGet layouts exist and both need checking: the older, + # unified "Microsoft.WindowsAppSDK" package (winmds under lib/uap10.0), + # and the newer split packaging (Microsoft.WindowsAppSDK.WinUI, + # .Foundation, .Base, ...) where each capability is its own package and + # the winmds live under metadata/ instead - only the latter was found + # installed on this machine (confirmed via the NuGet cache directly), + # so a build here would otherwise silently fall back to no WinUI3 + # projection at all: CollectPositionsAndText's WinUI3-specific casts + # compile out, meaning every genuine WinUI3 element's bounds/position/ + # text collection could only ever fail (this is what turned out to be + # the entire root cause of the "bounds/text not captured for WinUI3 + # elements in File Explorer" bug - not a code defect). if(NOT LVT_WASDK_WINMD_DIR) file(GLOB _wasdk_candidates "$ENV{USERPROFILE}/.nuget/packages/microsoft.windowsappsdk/*/lib/uap10.0" "$ENV{NUGET_PACKAGES}/microsoft.windowsappsdk/*/lib/uap10.0" "${CMAKE_SOURCE_DIR}/packages/Microsoft.WindowsAppSDK.*/lib/uap10.0" + "$ENV{USERPROFILE}/.nuget/packages/microsoft.windowsappsdk.winui/*/metadata" + "$ENV{NUGET_PACKAGES}/microsoft.windowsappsdk.winui/*/metadata" ) - foreach(_candidate ${_wasdk_candidates}) + # Prefer the newest STABLE version when several are cached: plain + # descending string sort would put "2.0.250930000-experimental" + # ahead of "1.8.251222000" (lexicographically "2" > "1"), and an + # experimental/preview SDK's winmd can reference types (seen live: + # Microsoft.Web.WebView2.Core.CoreWebView2) that don't resolve + # against the stable siblings selected below, failing the whole + # projection generation. Only fall back to a pre-release version if + # truly nothing stable is cached. + list(SORT _wasdk_candidates ORDER DESCENDING) + set(_wasdk_stable_candidates "${_wasdk_candidates}") + list(FILTER _wasdk_stable_candidates EXCLUDE REGEX "-(experimental|preview|alpha|beta)[0-9.]*[/\\\\]") + foreach(_candidate ${_wasdk_stable_candidates} ${_wasdk_candidates}) if(EXISTS "${_candidate}/Microsoft.UI.Xaml.winmd") set(LVT_WASDK_WINMD_DIR "${_candidate}" CACHE PATH "" FORCE) break() @@ -386,10 +468,72 @@ if(LVT_ENABLE_WINUI3) message(STATUS " using ${LVT_CPPWINRT_EXE}") file(GLOB WASDK_ALL_WINMDS "${LVT_WASDK_WINMD_DIR}/*.winmd") - # Also include other winmd directories in the package - get_filename_component(_wasdk_root "${LVT_WASDK_WINMD_DIR}/../.." ABSOLUTE) - file(GLOB_RECURSE _extra_winmds "${_wasdk_root}/*.winmd") - list(APPEND WASDK_ALL_WINMDS ${_extra_winmds}) + # Also include other winmd directories that belong with this + # one - which ones depends on which of the two NuGet layouts + # LVT_WASDK_WINMD_DIR matched above. + if(LVT_WASDK_WINMD_DIR MATCHES "/metadata$") + # Split-package layout (Microsoft.WindowsAppSDK.WinUI, + # .Foundation, .Base, ...): LVT_WASDK_WINMD_DIR is + # .../microsoft.windowsappsdk.winui//metadata. + # Sibling packages define types Microsoft.UI.Xaml.winmd can + # reference, so pull in the latest STABLE version of every + # other microsoft.windowsappsdk.* package cached alongside + # this one - explicitly not a second, different version of + # THIS SAME package: mixing versions of one package (e.g. + # both a cached 2.0-experimental and 1.8 stable .winui) fed + # cppwinrt conflicting type definitions and broke the whole + # projection ("Type ... could not be found" from a database + # mismatch) - this is exactly the failure being guarded + # against here. + get_filename_component(_wasdk_version_dir "${LVT_WASDK_WINMD_DIR}/.." ABSOLUTE) + get_filename_component(_wasdk_this_pkg_dir "${_wasdk_version_dir}/.." ABSOLUTE) + get_filename_component(_wasdk_packages_dir "${_wasdk_this_pkg_dir}/.." ABSOLUTE) + file(GLOB _sibling_wasdk_pkg_dirs "${_wasdk_packages_dir}/microsoft.windowsappsdk.*") + foreach(_sibling_pkg ${_sibling_wasdk_pkg_dirs}) + if(NOT _sibling_pkg STREQUAL _wasdk_this_pkg_dir AND IS_DIRECTORY "${_sibling_pkg}") + file(GLOB _sibling_versions "${_sibling_pkg}/*") + list(SORT _sibling_versions ORDER DESCENDING) + set(_sibling_stable_versions "${_sibling_versions}") + list(FILTER _sibling_stable_versions EXCLUDE REGEX "-(experimental|preview|alpha|beta)[0-9.]*$") + foreach(_sibling_version ${_sibling_stable_versions} ${_sibling_versions}) + file(GLOB_RECURSE _sibling_winmds "${_sibling_version}/*.winmd") + if(_sibling_winmds) + list(APPEND WASDK_ALL_WINMDS ${_sibling_winmds}) + break() # only the one (best) version of this sibling + endif() + endforeach() + endif() + endforeach() + else() + # Unified layout (Microsoft.WindowsAppSDK): LVT_WASDK_WINMD_DIR + # is .../microsoft.windowsappsdk//lib/uap10.0 - + # other TFM directories under that same version's lib/ can + # hold winmds too. + get_filename_component(_wasdk_root "${LVT_WASDK_WINMD_DIR}/../.." ABSOLUTE) + file(GLOB_RECURSE _extra_winmds "${_wasdk_root}/*.winmd") + list(APPEND WASDK_ALL_WINMDS ${_extra_winmds}) + endif() + + # Microsoft.UI.Xaml.winmd references WebView2 types (WinUI3's + # WebView2 control), which live in an entirely separate NuGet + # package (Microsoft.Web.WebView2, not part of WindowsAppSDK at + # all) - missing this reference is what actually broke + # generation here ("Type 'Microsoft.Web.WebView2.Core.CoreWebView2' + # could not be found") once the WindowsAppSDK winmds themselves + # were correctly found above. + file(GLOB _webview2_version_dirs + "$ENV{USERPROFILE}/.nuget/packages/microsoft.web.webview2/*" + "$ENV{NUGET_PACKAGES}/microsoft.web.webview2/*") + list(SORT _webview2_version_dirs ORDER DESCENDING) + set(_webview2_stable_dirs "${_webview2_version_dirs}") + list(FILTER _webview2_stable_dirs EXCLUDE REGEX "-(experimental|preview|prerelease|alpha|beta)[0-9.]*$") + foreach(_webview2_dir ${_webview2_stable_dirs} ${_webview2_version_dirs}) + if(EXISTS "${_webview2_dir}/lib/Microsoft.Web.WebView2.Core.winmd") + list(APPEND WASDK_ALL_WINMDS "${_webview2_dir}/lib/Microsoft.Web.WebView2.Core.winmd") + break() + endif() + endforeach() + list(REMOVE_DUPLICATES WASDK_ALL_WINMDS) # A stale projection must not be left behind on failure. @@ -618,6 +762,89 @@ add_custom_command(TARGET lvt_chromium_plugin POST_BUILD ) endif() +# lvt Viewer: copy the TAP DLLs and plugins lvt.exe needs, alongside it. +# Deliberately placed here rather than in the LVT_BUILD_VIEWER block above: +# if(TARGET lvt_tap) and friends can only see targets CMake has already +# processed by this point in the file, so this has to come after every +# add_library()/add_executable() call it references. +if(LVT_BUILD_VIEWER AND LVT_BUILD_TOOL) + foreach(_tap lvt_tap lvt_wpf_tap lvt_winforms_tap) + if(TARGET ${_tap}) + add_custom_command(TARGET lvt_viewer POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "$" "${LVT_VIEWER_OUTPUT_DIR}/" + COMMENT "Copying ${_tap} next to the viewer" + ) + add_dependencies(lvt_viewer ${_tap}) + endif() + endforeach() + unset(_tap) + + if(TARGET lvt_wpf_tap AND LVT_BUILD_MANAGED) + add_custom_command(TARGET lvt_viewer POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "${LVT_RUNTIME_OUTPUT_DIR}/LvtWpfTap.dll" "${LVT_VIEWER_OUTPUT_DIR}/LvtWpfTap.dll" + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "${LVT_RUNTIME_OUTPUT_DIR}/LvtWpfTap.runtimeconfig.json" + "${LVT_VIEWER_OUTPUT_DIR}/LvtWpfTap.runtimeconfig.json" + COMMENT "Copying the managed WPF tree walker assembly next to the viewer" + ) + endif() + + if(TARGET lvt_winforms_tap AND LVT_BUILD_MANAGED) + add_custom_command(TARGET lvt_viewer POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "${LVT_RUNTIME_OUTPUT_DIR}/LvtWinFormsTap.dll" "${LVT_VIEWER_OUTPUT_DIR}/LvtWinFormsTap.dll" + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "${LVT_RUNTIME_OUTPUT_DIR}/LvtWinFormsTap.runtimeconfig.json" + "${LVT_VIEWER_OUTPUT_DIR}/LvtWinFormsTap.runtimeconfig.json" + COMMENT "Copying the managed WinForms tree walker assembly next to the viewer" + ) + endif() + + foreach(_plugin lvt_avalonia_plugin lvt_chromium_plugin) + if(TARGET ${_plugin}) + add_custom_command(TARGET lvt_viewer POST_BUILD + COMMAND ${CMAKE_COMMAND} -E make_directory "${LVT_VIEWER_OUTPUT_DIR}/plugins" + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "$" "${LVT_VIEWER_OUTPUT_DIR}/plugins/" + COMMENT "Copying ${_plugin} next to the viewer" + ) + add_dependencies(lvt_viewer ${_plugin}) + endif() + endforeach() + unset(_plugin) + + if(TARGET lvt_avalonia_tap) + add_custom_command(TARGET lvt_viewer POST_BUILD + COMMAND ${CMAKE_COMMAND} -E make_directory "${LVT_VIEWER_OUTPUT_DIR}/plugins/avalonia" + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "$" "${LVT_VIEWER_OUTPUT_DIR}/plugins/avalonia/" + COMMENT "Copying the Avalonia TAP DLL next to the viewer" + ) + add_dependencies(lvt_viewer lvt_avalonia_tap) + if(LVT_BUILD_MANAGED) + add_custom_command(TARGET lvt_viewer POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "${LVT_RUNTIME_OUTPUT_DIR}/plugins/avalonia/LvtAvaloniaTreeWalker.dll" + "${LVT_VIEWER_OUTPUT_DIR}/plugins/avalonia/LvtAvaloniaTreeWalker.dll" + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "${LVT_RUNTIME_OUTPUT_DIR}/plugins/avalonia/LvtAvaloniaTreeWalker.runtimeconfig.json" + "${LVT_VIEWER_OUTPUT_DIR}/plugins/avalonia/LvtAvaloniaTreeWalker.runtimeconfig.json" + COMMENT "Copying the managed Avalonia tree walker assembly next to the viewer" + ) + endif() + endif() + + if(TARGET lvt_chromium_plugin) + add_custom_command(TARGET lvt_viewer POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_directory + "${LVT_RUNTIME_OUTPUT_DIR}/plugins/chromium" "${LVT_VIEWER_OUTPUT_DIR}/plugins/chromium" + COMMENT "Copying the Chromium native host + extension next to the viewer" + ) + endif() +endif() + # --- Tests --- if(LVT_BUILD_TESTS) enable_testing() @@ -803,6 +1030,8 @@ install(TARGETS lvt_core ARCHIVE DESTINATION "${CMAKE_INSTALL_LIBDIR}" ) install(FILES ${LVT_PUBLIC_HEADERS} DESTINATION "${CMAKE_INSTALL_INCLUDEDIR}/lvt") +install(FILES ${LVT_PUBLIC_PROVIDER_HEADERS} + DESTINATION "${CMAKE_INSTALL_INCLUDEDIR}/lvt/providers") if(LVT_BUILD_TOOL) install(TARGETS lvt RUNTIME DESTINATION "${LVT_INSTALL_TOOLSDIR}") diff --git a/README.md b/README.md index 7bc6083..43c6d12 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,10 @@ A Windows CLI tool that inspects the visual tree of running applications. Design ### Download -Grab the latest release from **[GitHub Releases](https://github.com/asklar/lvt/releases/latest)** — extract the zip and run `lvt.exe` from any terminal. +Grab the latest release from **[GitHub Releases](https://github.com/asklar/lvt/releases/latest)**. +The `lvt-vX.Y.Z-.zip` assets are the lean command-line packages; extract +one and run `lvt.exe` from any terminal. The graphical viewer is published +separately as `lvt-viewer-vX.Y.Z-x64.zip`. ### Install the Copilot skill @@ -186,7 +189,7 @@ lvt frameworks --hwnd 0x1A0B3C lvt dump --name myapp --element e5 --depth 3 # Query an element by durable key or eN id -lvt query "win32|Window|MyWindow/win32|Button|Button|Name:OK" text --name myapp +lvt query "win32|MyWindow/win32|Button|Name:OK" text --name myapp # Watch for live tree changes as JSON diff events lvt watch --name notepad --interval 250 @@ -222,6 +225,7 @@ lvt wait-for e9 --wait-prop IsEnabled=true --name myapp | `--output ` | Write to a file instead of stdout, or the PNG path for `screenshot` | | `--format ` | `json` (default) or `xml` | | `--interval ` | Polling interval for `watch` (default: 500) | +| `--fast` | Skip the XAML/WinUI3 property-chain walk (`GetPropertyValuesChain`) in favor of cheap direct property reads. Much faster on a rich tree — still reports bounds, `Text`, `Content`, and basic state, but not arbitrary custom properties. Default is off (today's exhaustive collection) | | `--element ` | Scope to a specific element subtree by positional `eN` id, durable key, or `uia:` | | `--uia` | Use the UI Automation tree instead of the visual tree | | `--uia-view ` | UIA tree view: `control` (default), `raw`, or `content` | @@ -400,6 +404,33 @@ for the full tool reference and the security model. Building it from source needs a Rust toolchain and is opt-in (`-DLVT_ENABLE_MCP=ON`); released binaries have it built in. +## lvt Viewer + +A graphical, live element-tree browser for Windows — think Visual Studio's +Live Visual Tree or the Windows SDK's Inspect.exe. Drag a crosshair onto a +window to target it; a tree on one side and a property panel on the other +both update live as the target's UI changes. + +Download `lvt-viewer-vX.Y.Z-x64.zip` from the matching +[GitHub release](https://github.com/asklar/lvt/releases/latest), extract the +whole archive, and run `LvtViewer.exe`. The archive contains the matching x64 +CLI, TAP DLLs, managed walkers, and plugins; it requires the +[.NET 10 Desktop Runtime](https://dotnet.microsoft.com/download/dotnet/10.0). + +To build it from source instead: + +```powershell +cmake --preset default -DLVT_BUILD_VIEWER=ON +cmake --build build +.\build\viewer\LvtViewer.exe +``` + +It's a separate WPF (.NET) app that drives `lvt.exe` as a subprocess (`watch` +for live updates, `toggle`/`set-value` for editing) rather than linking +`lvt_core`. See **[src/viewer/README.md](src/viewer/README.md)** for the +architecture, why `watch` was chosen over MCP for live updates, and how to +build/run it. + ## Output format ### Watch mode @@ -411,6 +442,11 @@ Element matching uses stable framework/type/class/path-derived keys instead of the positional `e0`, `e1`, ... ids, so unique moved elements are reported as `changed` events with a `path` field change. +`--fast` applies to `watch` too: every tick collects the cheaper property set +instead of the full XAML/WinUI3 property chain, so `changed` events on an +arbitrary custom property outside bounds/Text/Content/basic state won't be +reported — only those properties are tracked and diffed in fast mode. + ### JSON ```json diff --git a/docs/architecture.md b/docs/architecture.md index dc60eb2..d58cd70 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -61,7 +61,13 @@ flowchart BT 2. **ComCtlProvider** walks the existing tree and enriches known ComCtl controls. For example, a `SysListView32` element gets child elements for its items, columns, and headers via control-specific messages (`LVM_GETITEMCOUNT`, `LVM_GETITEMTEXT`, etc.). -3. **XamlProvider / WinUI3Provider** inject the TAP DLL into the target process, receive the XAML visual tree as JSON via named pipe, and graft XAML subtrees into matching `DesktopChildSiteBridge` elements in the Win32 tree. +3. **XamlProvider / WinUI3Provider** inject the TAP DLL into the target process, receive the XAML visual tree as JSON over a persistent named pipe, and graft XAML subtrees into matching `DesktopChildSiteBridge` elements in the Win32 tree. + +### Reusable connections (`providers/framework_connection.h`, `connection_registry.h`) + +Injecting the TAP DLL and calling `AdviseVisualTreeChange` is meant to happen **once** per debugging session, not on every tree refresh — see `docs/tap-dll-design.md`'s connection lifecycle section. `IFrameworkConnection` is the generic interface a provider can implement to expose that as "connect once, `get_tree()` many times"; `ConnectionRegistry` is a per-process, refcounted registry (keyed by `pid` + framework label) that lets a long-running consumer — `watch`'s tick loop, an MCP session — acquire one via a move-only `ConnectionHandle` and reuse it for its own lifetime, instead of each tree refresh re-injecting from scratch. `tree_builder.h`'s `build_tree` takes an optional `ConnectionLookup` callback for this; a caller that doesn't supply one (a one-shot `dump`/`query`/`screenshot`) sees no behavior change — providers fall back to their original one-shot `enrich()`. + +Only XamlProvider and WinUI3Provider implement this today (they are the only frameworks with a real `AdviseVisualTreeChange`-equivalent API); other providers/plugins can adopt the same interface later without changing how callers acquire or use it. ### Element ID assignment diff --git a/docs/avalonia-plugin.md b/docs/avalonia-plugin.md index 344fa52..17f18a6 100644 --- a/docs/avalonia-plugin.md +++ b/docs/avalonia-plugin.md @@ -96,6 +96,20 @@ $ lvt --name AvaloniaTestApp --format xml --depth 3 - Target process must match lvt's architecture (x64 or ARM64) - The .NET runtime (`hostfxr.dll`) must be installed on the system +## Persistent connections (optional) + +`src/plugin.h`'s plugin ABI (v2) has a persistent lifetime group: +`lvt_connection_open`, `lvt_connection_get_tree`, and +`lvt_connection_close` (plus the existing `lvt_plugin_free`) must all be +implemented before lvt enables the connection path. The optional +`lvt_connection_poll_events`/`lvt_connection_events_free` pair adds push +event draining. These functions let +`watch` and MCP sessions reuse one connection across many tree refreshes instead of +re-injecting every time — see that header's "Persistent connections" section and +`docs/tap-dll-design.md`'s connection lifecycle for the pattern the built-in XAML/WinUI3 +providers already follow. This plugin does not implement them yet; it still uses the +original one-shot `lvt_enrich_tree` path, which continues to work unchanged either way. + ## Test app A simple Avalonia test application is included in `tests/avalonia_test_app/`: diff --git a/docs/chromium-plugin.md b/docs/chromium-plugin.md index 711679e..b78a2dd 100644 --- a/docs/chromium-plugin.md +++ b/docs/chromium-plugin.md @@ -202,3 +202,14 @@ lvt --name chrome - WebView2 support (Chrome embedded in Win32 apps) - Lazy loading for very large DOM trees - Chrome Web Store / Edge Add-ons publication +- Persistent connections: `src/plugin.h`'s plugin ABI (v2) enables this path + only when `lvt_connection_open`, `lvt_connection_get_tree`, + `lvt_connection_close`, and the existing `lvt_plugin_free` are all + implemented. The optional `lvt_connection_poll_events`/ + `lvt_connection_events_free` pair adds event draining. These let `watch` and MCP + sessions reuse one connection across many refreshes instead of + re-establishing the extension/native-messaging channel every time — see + that header and `docs/tap-dll-design.md`'s connection lifecycle for the + pattern the built-in XAML/WinUI3 providers already follow. This plugin + does not implement them yet; it still uses the one-shot `lvt_enrich_tree` + path, which continues to work unchanged either way. diff --git a/docs/mcp-server.md b/docs/mcp-server.md index 9478203..e0cbbc0 100644 --- a/docs/mcp-server.md +++ b/docs/mcp-server.md @@ -118,6 +118,17 @@ input at where an element is. Because it works by injecting into the target it needs lvt and the target to share an architecture; when they do not, it says so and names the right binary. +On a rich XAML/WinUI3 tree, `get_visual_tree` walks every element's entire +property inheritance chain by default (`IVisualTreeService:: +GetPropertyValuesChain`) — measured at ~4.5ms/element on a real app, which adds +up on a tree of hundreds or thousands of elements. Pass `fast: true` to skip +that walk and collect bounds/`Text`/`Content`/basic state the cheaper way +instead (a few direct property reads per element, no property-chain walk). +This is enough to browse or search a tree by, and to hit-test/highlight +elements, but it will not report arbitrary custom properties the way the +default (`fast: false`) walk does — use `get_element_properties` for a single +element's exhaustive property set regardless of which mode built the tree. + ## Addressing elements Every tool that takes an element accepts these forms: @@ -127,8 +138,10 @@ Every tool that takes an element accepts these forms: so it can be checked rather than assumed. - **`e12`** — the element's position in the tree you fetched, read against the session's own tree. -- **A durable key** — a path-based identifier that survives more change. Also - self-describing: it names the framework that produced it. +- **A durable key** — a framework-native identifier that survives more change. + XAML/WinUI3 use compact diagnostics handles (`xaml:0x…`, `winui3:0x…`); + providers without a process-wide handle use a structural path. Both forms are + self-describing. - **`uia:`** — the UIA runtime identifier. **A session only accepts references from its own tree.** The other tree's are @@ -287,8 +300,8 @@ the other's references** rather than guessing what you meant. If you want to work the other way round, open a second session — they are independent and cheap. Durable keys are self-describing — they name the framework that produced them -(`wpf|…`, `uia|…`) — so they need no qualifier, and they are refused by the -wrong session just as `eN` refs are. +(`winui3:0x…`, `wpf|…`, `uia|…`) — so they need no qualifier, and they are +refused by the wrong session just as `eN` refs are. ## Prefer patterns over synthetic input diff --git a/docs/tap-dll-design.md b/docs/tap-dll-design.md index 35e422f..d452a4d 100644 --- a/docs/tap-dll-design.md +++ b/docs/tap-dll-design.md @@ -4,29 +4,30 @@ The TAP DLL (`lvt_tap.dll`) is a COM in-process server that gets injected into the target process to walk XAML visual trees. It uses the same diagnostic infrastructure that Visual Studio's Live Visual Tree uses. ("TAP" comes from the `wszTAPDllName` parameter of `InitializeXamlDiagnosticsEx`.) -## Injection flow +`InitializeXamlDiagnosticsEx` and `AdviseVisualTreeChange` are a subscribe-and-react API: they are meant to be called **once** per debugging session, with `OnVisualTreeChange` then incrementally reporting Add/Remove mutations for as long as the subscription stays alive. The TAP DLL is built around that model — connect once, serve many tree refreshes over a persistent pipe, disconnect once when the session ends — not around reconnecting from scratch on every refresh. An earlier version of this file did the latter (calling `InitializeXamlDiagnosticsEx` fresh every `watch` tick); that caused a confirmed, unbounded resource leak (one message-only window created and never destroyed per tick) and was the root cause of a "tree refreshes/resets" bug reported against Microsoft Store. See `src/providers/framework_connection.h` and `connection_registry.h` for the caller-side half of this design. + +## Connection lifecycle ```mermaid sequenceDiagram - participant lvt as lvt.exe - participant target as Target Process + participant lvt as lvt.exe (XamlDiagConnection) + participant target as Target Process (LvtTap) - lvt->>lvt: LoadLibrary(initDllPath) - lvt->>lvt: GetProcAddress(InitializeXamlDiagnosticsEx) + lvt->>lvt: CreateNamedPipe(pipeName, PIPE_ACCESS_DUPLEX) lvt->>target: InitializeXamlDiagnosticsEx(connectionName, pid, pipe, tapDll, CLSID) - target->>target: LoadLibrary("lvt_tap.dll") - target->>target: DllGetClassObject(CLSID_LvtTap) - target->>target: IClassFactory::CreateInstance() - target->>target: SetSite(IXamlDiagnostics) - target->>target: QI → IVisualTreeService - target->>target: AdviseVisualTreeChange(callback) - loop Tree replay - target->>target: OnVisualTreeChange(node) + target->>target: LoadLibrary("lvt_tap.dll"), SetSite(IXamlDiagnostics) + target->>target: AdviseVisualTreeChange(callback) — ONCE + target->>lvt: connects to pipe, writes "READY" + lvt->>lvt: connect() returns a live connection + + loop Every tree refresh (a watch tick, an MCP tool call, ...) + lvt->>target: "GET_TREE" or "GET_TREE FAST" + target->>target: dispatch CollectBounds/CollectPositionsAndText to UI thread + target->>lvt: one JSON line (the current tree) end - target->>target: CollectBounds (UI thread dispatch) - lvt->>lvt: CreateNamedPipe(pipeName) - target->>lvt: SerializeAndSend() → JSON over pipe - lvt->>lvt: Parse JSON, graft into element tree + + lvt->>target: "DISCONNECT" (when the caller releases the connection) + target->>target: UnadviseVisualTreeChange, DestroyWindow, UnregisterClass, COM release ``` ### Connection names @@ -35,7 +36,11 @@ The XAML diagnostics API uses named connections. Each concurrent diagnostics ses - **System XAML (UWP):** `"VisualDiagConnection1"`, `"VisualDiagConnection2"`, … - **WinUI 3:** `"WinUIVisualDiagConnection1"`, `"WinUIVisualDiagConnection2"`, … -lvt tries connection names sequentially until one succeeds (doesn't return `ERROR_NOT_FOUND`). +lvt tries connection names sequentially until one succeeds (doesn't return +`ERROR_NOT_FOUND`). Identifiers are monotonically allocated by a XAML core and +can grow well beyond 10 in long-lived, multi-window apps, so lvt scans up to +10,000 names (matching UWPSpy's strategy) rather than assuming a small fixed +range. ### Init DLL paths @@ -56,18 +61,21 @@ flowchart TB Launch["Launch worker thread (AdviseThreadProc)"] OnVTC["OnVisualTreeChange(relation, element, mutation)"] - BuildMap["Build TreeNode map (m_nodes, m_roots)"] + BuildMap["Add/Remove into TreeNode map (m_nodes, m_roots)"] - WorkerThread["Worker thread"] - Advise["AdviseVisualTreeChange — replays existing tree"] - SendMsg["SendMessage(WM_COLLECT_BOUNDS) — dispatch to UI thread"] - Serialize["SerializeAndSend() — JSON → named pipe"] - Unadvise["UnadviseVisualTreeChange()"] + WorkerThread["Worker thread: AdviseThreadProcImpl"] + Advise["AdviseVisualTreeChange — ONCE, replays existing tree"] + Serve["ServeConnection: connect pipe, write READY"] + Loop["RunCommandLoop: read GET_TREE/DISCONNECT requests"] + HandleGetTree["HandleGetTree: SendMessage(WM_COLLECT_BOUNDS) dispatch, SerializeAndSend"] + Cleanup["CleanupUIResources: Unadvise, DestroyWindow, UnregisterClass, COM release"] LvtTap --> SetSite & OnVTC & WorkerThread SetSite --> QI & MsgWnd & Launch OnVTC --> BuildMap - WorkerThread --> Advise --> SendMsg --> Serialize --> Unadvise + WorkerThread --> Advise --> Serve --> Loop + Loop -->|GET_TREE, repeated| HandleGetTree + Loop -->|DISCONNECT or broken pipe| Cleanup ``` ## Threading model @@ -95,29 +103,35 @@ sequenceDiagram UI->>Worker: Launch worker thread UI->>UI: Return S_OK (UI thread is now free) - Worker->>Worker: AdviseVisualTreeChange(callback) + Worker->>Worker: AdviseVisualTreeChange(callback) — ONCE loop Tree replay Worker->>Worker: OnVisualTreeChange(node) → builds m_nodes map end - - Worker->>UI: SendMessage(WM_COLLECT_BOUNDS) - Note over Worker: blocks until UI thread responds - - UI->>UI: WndProc: WM_COLLECT_BOUNDS - loop For each node - UI->>UI: GetPropertyValuesChain() → ActualWidth, ActualHeight, ActualOffset + Worker->>Pipe: connect, write "READY" + + loop Every GET_TREE request + Worker->>UI: SendMessage(WM_COLLECT_BOUNDS) + Note over Worker: blocks until UI thread responds + UI->>UI: WndProc: WM_COLLECT_BOUNDS + loop For each node + UI->>UI: GetPropertyValuesChain() → ActualWidth, ActualHeight, ActualOffset + end + UI->>Worker: Return (unblocks SendMessage) + Worker->>Pipe: SerializeAndSend() → one JSON line end - UI->>Worker: Return (unblocks SendMessage) - Worker->>Pipe: SerializeAndSend() → JSON - Worker->>Worker: UnadviseVisualTreeChange() + Worker->>UI: SendMessageTimeout(WM_TAP_DESTROY) — on DISCONNECT/broken pipe + UI->>UI: DestroyWindow(hwnd) — runs on the owning thread + Worker->>Worker: UnadviseVisualTreeChange(), UnregisterClass, COM release ``` Key details: -- The message-only window is created on the UI thread in `SetSite()` via `CreateWindowExW(... HWND_MESSAGE ...)` +- The message-only window is created **once**, on the UI thread in `SetSite()`, via `CreateWindowExW(... HWND_MESSAGE ...)` — and destroyed exactly once, when the connection ends, not per request. - `SendMessage` from the worker thread blocks until the UI thread processes `WM_COLLECT_BOUNDS` - The UI thread is free at this point (SetSite has returned), so there's no deadlock +- `DestroyWindow` must run on the thread that created the window; the worker thread cannot call it directly, so cleanup dispatches `WM_TAP_DESTROY` to the UI thread via `SendMessageTimeoutW` (bounded, so a hung/gone UI thread cannot block cleanup forever) - SEH wrappers (`CollectBoundsForNodeSEH`) protect against crashes in individual node queries +- `m_nodes`/`m_roots`/`m_orderedHandles` are guarded by `m_nodesMutex`: once a connection stays open across many requests, `OnVisualTreeChange` can fire (adding or removing nodes) between — or even during — a `GET_TREE` request's own collection pass, which the old one-shot-per-tick design never had to account for ### Why COM marshaling doesn't work @@ -138,6 +152,8 @@ Each `TreeNode` stores: | `offsetX`, `offsetY` | `GetPropertyValuesChain` | `ActualOffset` (if available) | | `hasBounds` | Computed | `true` if both width and height were collected | +`OnVisualTreeChange` handles both `Add` (inserts into `m_nodes`/the parent's `childHandles`, or `m_roots` for a top-level element) and `Remove` (erases from all three) — Remove handling only matters once a connection's tree state persists across many requests instead of being torn down and rebuilt from scratch every time. + ### Bounds collection results Not all nodes return bounds: @@ -145,9 +161,9 @@ Not all nodes return bounds: - `ActualOffset` is often not available via `GetPropertyValuesChain` (it's a non-dependency-property in WinUI 3) - When offsets are missing, all XAML elements within a bridge share the bridge window's screen position -## JSON output format +## Wire protocol -The TAP DLL serializes the tree as a JSON array of root nodes: +Every message on the pipe is one line (UTF-8, `\n`-terminated). lvt.exe → TAP DLL commands are plain text; TAP DLL → lvt.exe responses are either a literal `READY`/`BYE`, or the tree itself as a JSON array of root nodes: ```json [ @@ -164,7 +180,14 @@ The TAP DLL serializes the tree as a JSON array of root nodes: ] ``` -This is sent as UTF-8 over the named pipe and parsed by `graft_json_node()` in `xaml_diag_common.cpp`. +| Direction | Message | Meaning | +|-----------|---------|---------| +| TAP → lvt | `READY` | Sent once, right after `AdviseVisualTreeChange` succeeds — before any bounds/property collection | +| lvt → TAP | `GET_TREE` / `GET_TREE FAST` | Request a refresh; `FAST` overrides the connection's default fast-mode setting for this one response | +| TAP → lvt | `[...]` | One JSON array of root nodes, in response to `GET_TREE` | +| lvt → TAP | `DISCONNECT` | End the connection; TAP DLL replies `BYE`, then runs its cleanup | + +Parsed and grafted by `graft_xaml_tree_json()` in `xaml_diag_common.cpp`. ## Static CRT @@ -175,6 +198,6 @@ The TAP DLL is built with `/MT` (static CRT). This is essential because: ## Debugging -- **Log file:** `%TEMP%\lvt_tap.log` — all TAP DLL operations are logged with thread IDs +- **Log file:** `%TEMP%\lvt_tap.log` for unpackaged targets, but `%LOCALAPPDATA%\Packages\\AC\Temp\lvt_tap.log` for AppContainer (UWP/MSIX-packaged) targets — the two are easy to confuse when debugging a packaged app. All TAP DLL operations are logged with millisecond timestamps and thread IDs. Because the TAP DLL never unloads (`DllCanUnloadNow` returns `S_FALSE`, see below), this file accumulates across every run against that target for as long as the target process lives, not just the most recent one. - **Debugger:** Use `C:\Debuggers\cdb.exe` to attach to the target process and debug injection issues -- **File lock:** `lvt_tap.dll` is locked by the target process after injection. Kill the target before rebuilding. +- **File lock:** `lvt_tap.dll` is locked by the target process after injection. Kill the target before rebuilding. For AppContainer targets, the staged copy at `%TEMP%\lvt_tap\` can also be held open by an unrelated, long-lived AppContainer host process from an earlier test run — if a rebuilt DLL isn't taking effect, check for and kill stale processes still holding that staged file before assuming the build itself is broken. diff --git a/mcp/src/server.rs b/mcp/src/server.rs index 78952b4..1478930 100644 --- a/mcp/src/server.rs +++ b/mcp/src/server.rs @@ -129,6 +129,11 @@ pub struct VisualTreeArgs { /// /// Costs a second walk, so it is off by default. pub correlate: Option, + /// Skip the XAML/WinUI3 full property-chain walk in favor of cheaper + /// direct property reads. Much faster on a rich tree, but only reports + /// bounds/Text/Content/basic state — not arbitrary custom properties. + /// Off by default (full properties, matching get_element_properties). + pub fast: Option, } #[derive(Debug, Deserialize, schemars::JsonSchema)] @@ -575,7 +580,10 @@ impl LvtServer { it shows implementation structure the UIA tree hides. Its elements are a \ different, finer-grained set than the UIA tree's, and its references only \ work in a session connected with mode 'visual'. Pass correlate:true to see \ - which of these elements UI Automation exposes and which it does not. \ + which of these elements UI Automation exposes and which it does not. Pass \ + fast:true on a large XAML/WinUI3 tree to trade the full per-element \ + property set for a much quicker walk (still reports bounds, Text, Content, \ + and basic state — enough to browse or search by, not exhaustive). \ Requires lvt and the target to share an architecture.", output_schema = crate::schema::visual_tree(), annotations(read_only_hint = true, open_world_hint = true) @@ -740,6 +748,7 @@ fn visual_tree_params(a: VisualTreeArgs) -> serde_json::Value { "properties": a.properties, "timeoutMs": a.timeout_ms, "correlate": a.correlate, + "fast": a.fast, })) } diff --git a/skills/lvt/SKILL.md b/skills/lvt/SKILL.md index 875dc66..8f33151 100644 --- a/skills/lvt/SKILL.md +++ b/skills/lvt/SKILL.md @@ -263,7 +263,10 @@ Pattern state is only emitted where the pattern is supported, so the presence of 2. **Run `lvt --name --format xml`** to get a quick overview of the UI tree 3. **Take a screenshot** with `lvt screenshot --name --output ui.png` to see the visual layout with element IDs 4. **Drill into a subtree** with `--element --depth ` if the tree is large -5. **Use element IDs and bounds** to plan any UI interactions (clicks, keyboard input) +5. **Add `--fast`** on a rich XAML/WinUI3 app if `dump`/`watch` feels slow — it + skips the full property-chain walk in favor of cheap bounds/Text/Content/ + basic-state reads, at the cost of not reporting arbitrary custom properties +6. **Use element IDs and bounds** to plan any UI interactions (clicks, keyboard input) ## MCP server mode diff --git a/src/element_key.cpp b/src/element_key.cpp index 975eb58..1bdeb26 100644 --- a/src/element_key.cpp +++ b/src/element_key.cpp @@ -18,68 +18,53 @@ std::string escape_key_part(const std::string& value) { } std::string base_identity_key(const Element& el) { - return escape_key_part(el.framework) + "|" + - escape_key_part(el.type) + "|" + - escape_key_part(el.className); + // Only one of type/className, not both: for every provider that sets + // both (xaml_diag_common.cpp, wpf_inject.cpp), `type` is derived as the + // substring of `className` after its last '.', so it never carries + // information className does not already have — including both here + // duplicated it for nothing. className is the more specific of the two + // when both exist (a raw win32/native class name, or a fully-qualified + // XAML type), so it wins; type is the fallback for providers where + // className can be legitimately empty (UIA elements often report no + // ClassName at all, see uia_provider.cpp), so the key never gets an + // empty identity segment. + // + // This matters far more than it looks: a key is built once per element + // but then repeated as a "/"-joined prefix in *every one* of that + // element's descendants (see assign_child_keys below), so halving one + // segment here roughly halves the key payload of an entire subtree, not + // just one element. Measured on a real ~1900-element WinUI3 tree + // (Microsoft Store), the full ancestor-chain "key" made up 40% of the + // dump's total JSON size before this change. + const std::string& identity = el.className.empty() ? el.type : el.className; + return escape_key_part(el.framework) + "|" + escape_key_part(identity); } -void collect_index(const Element& el, const std::string& path, - std::vector& out, - std::unordered_map& counts) { - IndexedElement indexed; - indexed.element = ⪙ - indexed.path = path; - indexed.baseKey = base_identity_key(el); - out.push_back(indexed); - counts[indexed.baseKey]++; - - for (size_t i = 0; i < el.children.size(); ++i) { - auto childPath = path.empty() ? std::to_string(i) : path + "." + std::to_string(i); - collect_index(el.children[i], childPath, out, counts); - } -} - -void assign_keys(std::vector& elements, - const std::unordered_map& counts) { - for (auto& indexed : elements) { - indexed.key = indexed.baseKey; - auto count = counts.find(indexed.baseKey); - if (count != counts.end() && count->second > 1) - indexed.key += "|@" + indexed.path; - } -} - -std::vector index_tree(const Element& root) { - std::vector elements; - std::unordered_map counts; - collect_index(root, "0", elements, counts); - assign_keys(elements, counts); - return elements; +static std::string hwnd_key(uintptr_t handle) { + std::ostringstream out; + out << "hwnd:0x" << std::hex << std::uppercase << handle; + return out.str(); } -void index_tree_pair(const Element& before, const Element& after, - std::vector& beforeElements, - std::vector& afterElements) { - std::unordered_map beforeCounts; - std::unordered_map afterCounts; - collect_index(before, "0", beforeElements, beforeCounts); - collect_index(after, "0", afterElements, afterCounts); +// XAML diagnostics InstanceHandles are already process-wide object +// identities. Unlike a sibling index/name path, they do not change when an +// element is reparented and do not need every ancestor repeated in every +// descendant's key. They have also been observed stable across independent +// diagnostics connections to the same live target, which the WinUI +// integration test guards by dumping twice and querying in a third process. +// Keep the structural algorithm as the fallback for providers/elements that +// do not expose such an identity. +static std::string compact_instance_key(const Element& el) { + if (el.nativeHandle == 0 || + (el.framework != "xaml" && el.framework != "winui3")) + return {}; - std::unordered_map combinedCounts = beforeCounts; - for (const auto& [key, count] : afterCounts) - combinedCounts[key] = std::max(combinedCounts[key], count); - - assign_keys(beforeElements, combinedCounts); - assign_keys(afterElements, combinedCounts); -} - -static std::string hwnd_key(uintptr_t handle) { std::ostringstream out; - out << "hwnd:0x" << std::hex << std::uppercase << handle; + out << el.framework << ":0x" << std::hex << std::uppercase << el.nativeHandle; return out.str(); } -static std::string stable_name_key(const Element& el) { +std::string stable_name_key(const Element& el) { for (const char* name : {"AutomationId", "x:Name", "Name", "automationId", "name"}) { auto it = el.properties.find(name); if (it != el.properties.end() && !it->second.empty()) @@ -88,6 +73,14 @@ static std::string stable_name_key(const Element& el) { return {}; } +// baseCounts/hwndCounts/nameCounts are all scoped to just `parent`'s own +// direct children (see assign_child_keys, which builds them fresh for each +// parent) — never counted across the whole tree. This locality is the +// entire point: a node's key can only ever be disturbed by a change among +// its OWN siblings, never by something elsewhere in an unrelated subtree. +// See assign_element_keys' doc comment for what used to go wrong when a +// different, GLOBALLY-scoped algorithm was used instead (in watch's +// diffing, before this). static std::string discriminator_for_child(const Element& child, size_t childIndex, const std::map& baseCounts, const std::map& hwndCounts, @@ -133,14 +126,21 @@ static void assign_child_keys(Element& parent, const std::string& parentKey) { for (size_t i = 0; i < parent.children.size(); ++i) { auto& child = parent.children[i]; - auto segment = discriminator_for_child(child, i, baseCounts, hwndCounts, nameCounts); - child.key = parentKey.empty() ? segment : parentKey + "/" + segment; + auto compact = compact_instance_key(child); + if (!compact.empty()) { + child.key = std::move(compact); + } else { + auto segment = discriminator_for_child(child, i, baseCounts, hwndCounts, nameCounts); + child.key = parentKey.empty() ? segment : parentKey + "/" + segment; + } assign_child_keys(child, child.key); } } void assign_element_keys(Element& root) { - root.key = base_identity_key(root); + root.key = compact_instance_key(root); + if (root.key.empty()) + root.key = base_identity_key(root); assign_child_keys(root, root.key); } diff --git a/src/element_key.h b/src/element_key.h index 184d083..240107d 100644 --- a/src/element_key.h +++ b/src/element_key.h @@ -1,31 +1,53 @@ #pragma once #include "element.h" #include -#include -#include namespace lvt { -struct IndexedElement { - const Element* element = nullptr; - std::string path; - std::string baseKey; - std::string key; -}; - std::string escape_key_part(const std::string& value); std::string base_identity_key(const Element& el); -void collect_index(const Element& el, const std::string& path, - std::vector& out, - std::unordered_map& counts); -void assign_keys(std::vector& elements, - const std::unordered_map& counts); -std::vector index_tree(const Element& root); -void index_tree_pair(const Element& before, const Element& after, - std::vector& beforeElements, - std::vector& afterElements); +// A stable, human-meaningful identifier for `el` drawn from the first of +// AutomationId / x:Name / Name (and their lowercase property-bag spellings) +// that is actually present, or empty if none are. Exposed (not file-local) +// so watch_diff.cpp's cross-tick reconciliation can use the exact same +// notion of "this child is identifiable independent of its position" that +// assign_element_keys' own per-sibling disambiguation already uses — +// having two different answers to "is this child otherwise identifiable" +// living in two different files is exactly the kind of divergence that +// caused this area's bugs before. +std::string stable_name_key(const Element& el); +// Assigns every element in `root` a durable, self-describing key. XAML and +// WinUI3 elements with an IXamlDiagnostics InstanceHandle use the compact, +// process-wide form "xaml:0x..." / "winui3:0x...". Other providers (and the +// rare XAML node without a handle) use "framework|className" path segments, +// "/"-joined from the nearest structural ancestor and disambiguated among +// siblings by native handle first, then a stable name property, then a local +// sibling index as a last resort. Used by dump/query/UIA output, and by +// watch_diff.cpp to give a *freshly discovered* element (the first tick, or +// a genuinely new node appearing later) its initial key. +// +// watch's diffing does NOT rely on recomputing this same key fresh on +// every tick to recognize a persisting element as "the same one" —a +// fallback structural key, with no memory between ticks, cannot do that +// robustly: it threads every ancestor's own disambiguating segment into +// each descendant's key, so if *any* ancestor's position among its own +// same-identity siblings shifts for any reason (extremely common in a +// live, animated UI — carousels, virtualized lists recycling items), every +// element beneath it gets a brand-new key on that tick even though nothing +// about it individually changed. Verified live: a passively-watched, +// completely untouched Microsoft Store home page (whose carousel +// auto-rotates) showed nearly its *entire* tree repeatedly flip-flopping +// between removed and re-added, purely from time passing. +// +// Instead, watch_diff.cpp's own reconciliation matches each tick's tree +// against the previous tick's node-by-node (by native handle, then +// stable_name_key, then relative position among remaining same-identity +// siblings) and has a matched node INHERIT its predecessor's key outright, +// never recomputing it. A key assigned by this function only "sticks" for +// as long as reconciliation keeps recognizing that same conceptual slot — +// which is indefinitely, unless the element is actually removed. void assign_element_keys(Element& root); } // namespace lvt diff --git a/src/framework_detector.cpp b/src/framework_detector.cpp index 636df43..c259eb9 100644 --- a/src/framework_detector.cpp +++ b/src/framework_detector.cpp @@ -179,9 +179,16 @@ std::vector detect_frameworks(HWND hwnd, DWORD pid) { bool detectedWpf = false; bool detectedWinForms = false; if (pid) { - auto winui = detect_module(pid, L"Microsoft.UI.Xaml.dll"); - if (winui.found) { - result.push_back({Framework::WinUI3, winui.version}); + auto winuiXaml = detect_module(pid, L"Microsoft.UI.Xaml.dll"); + auto frameworkUdk = detect_module(pid, L"Microsoft.Internal.FrameworkUdk.dll"); + // Microsoft.UI.Xaml.dll alone does not mean WinUI 3: WinUI 2 is a + // controls library hosted by system Windows.UI.Xaml, and apps such + // as Windows Terminal legitimately load both DLLs. WinUI 3's + // diagnostics endpoint is exported by the Windows App SDK's + // Microsoft.Internal.FrameworkUdk.dll, so require that runtime + // signal before selecting the WinUI3 provider. + if (winuiXaml.found && frameworkUdk.found) { + result.push_back({Framework::WinUI3, winuiXaml.version}); detectedWinUI3 = true; } auto xaml = detect_module(pid, L"Windows.UI.Xaml.dll"); diff --git a/src/lvt_api.cpp b/src/lvt_api.cpp index a4b9d7d..4dbc76b 100644 --- a/src/lvt_api.cpp +++ b/src/lvt_api.cpp @@ -10,12 +10,19 @@ #include "screenshot.h" #include "target.h" #include "tree_builder.h" +#include "providers/connection_registry.h" #ifdef LVT_ENABLE_UIA #include "providers/uia_actions.h" #include "providers/uia_props.h" #include "providers/uia_provider.h" #endif +#if LVT_ENABLE_XAML +#include "providers/xaml_provider.h" +#endif +#if LVT_ENABLE_WINUI3 +#include "providers/winui3_provider.h" +#endif #include #include @@ -133,6 +140,167 @@ bool find_session(const std::string& id, Session& out) { return true; } +bool session_is_active(const std::string& id) { + std::lock_guard lock(g_sessionsMutex); + return g_sessions.contains(id); +} + +// --- per-session persistent connections ---------------------------------- +// +// Session is copied out of g_sessions by find_session/require_session (a +// deliberate choice: methods do their real work without holding +// g_sessionsMutex for that whole time), so a persistent IFrameworkConnection +// - move-only by design, see connection_registry.h's ConnectionHandle - +// cannot live as a Session member; it is kept here instead, keyed by session +// id, and reused across every get_visual_tree/get_uia_tree/find_elements/ +// click/... call that session makes, the same way watch's loop reuses one +// across ticks (see main.cpp's acquire_watch_connections). Erased (dropping +// the ConnectionHandles, which disconnect cleanly) in method_disconnect. +std::mutex g_connectionsMutex; +std::map>> g_sessionConnections; + +// Builds a ConnectionLookup for build_tree, lazily acquiring (once per +// session, on whichever call first needs it) a persistent connection for +// each xaml/winui3 framework this session's target actually has. Returns an +// empty (falsy) ConnectionLookup when the target has neither, so build_tree +// falls back to its normal one-shot-per-call path with no behavior change. +lvt::ConnectionLookup connection_lookup_for_session(const Session& session, + const std::vector& frameworks) { + bool hasXaml = false, hasWinUI3 = false; + for (auto& fi : frameworks) { + if (fi.type == lvt::Framework::Xaml) hasXaml = true; + if (fi.type == lvt::Framework::WinUI3) hasWinUI3 = true; + } + if (!hasXaml && !hasWinUI3) + return {}; + + std::lock_guard lock(g_connectionsMutex); + auto& entry = g_sessionConnections[session.id]; + + // A connection can die mid-session (a transient timeout against an + // unusually large/busy tree, or the target recycling something XAML + // diagnostics-related - see main.cpp's refresh_dead_watch_connections + // for the live evidence and full reasoning, which applies identically + // here). Drop any dead entries first so the "what does this session + // still need" check below is based on current reality, not just + // whether a label was ever successfully connected once - otherwise a + // single transient failure would silently and permanently fall back to + // one-shot-per-call reinjection for the rest of the session. + for (auto it = entry.begin(); it != entry.end();) { + if (it->second && !it->second->is_alive()) { + it->second.reset(); + it = entry.erase(it); + } else { + ++it; + } + } + + const auto has_label = [&entry](const char* label) { + for (const auto& [existingLabel, handle] : entry) { + if (existingLabel == label && handle) + return true; + } + return false; + }; + + const bool needXaml = hasXaml && !has_label("xaml"); + const bool needWinUI3 = hasWinUI3 && !has_label("winui3"); + if (needXaml || needWinUI3) { + // A full, untrimmed probe tree, needed only to resolve which + // process/DLL a connection should target (XamlProvider needs to + // locate the CoreWindow) - discarded once that resolution is done. A + // session may already hold some other connection here (e.g. UIA, or + // only one of xaml/winui3 from an earlier partial success), so retry + // only whichever framework labels are still missing instead of treating + // "entry is non-empty" as "everything this session could ever need is + // already connected". + // Only the native CoreWindow HWND is needed to resolve the process + // for XAML injection. Do not run the detected framework providers + // here: that would perform a complete one-shot XAML collection + // immediately before opening the persistent connection. + lvt::Element probeTree = lvt::build_tree(session.hwnd, session.pid, {}); +#if LVT_ENABLE_XAML + if (needXaml) { + auto handle = lvt::ConnectionRegistry::instance().acquire( + session.pid, session.hwnd, "xaml", + [&probeTree](HWND hwnd, DWORD pid) -> std::shared_ptr { + lvt::XamlProvider xaml; + return xaml.open_connection(probeTree, hwnd, pid); + }); + if (handle) + entry.emplace_back("xaml", std::move(handle)); + } +#endif +#if LVT_ENABLE_WINUI3 + if (needWinUI3) { + auto handle = lvt::ConnectionRegistry::instance().acquire( + session.pid, session.hwnd, "winui3", + [](HWND hwnd, DWORD pid) -> std::shared_ptr { + lvt::WinUI3Provider winui3; + return winui3.open_connection(hwnd, pid); + }); + if (handle) + entry.emplace_back("winui3", std::move(handle)); + } +#endif + } + + // Do not return raw pointers into g_sessionConnections. `disconnect` + // can run concurrently on another MCP worker and erase this entry while + // build_tree is still using its lookup. A shared snapshot keeps every + // in-flight connection alive until this synchronous build finishes, + // independently of the session/registry handle being removed. + std::vector>> connections; + connections.reserve(entry.size()); + for (const auto& [label, handle] : entry) { + if (handle) + connections.emplace_back(label, handle.shared()); + } + return [connections = std::move(connections)]( + const std::string& label) -> lvt::IFrameworkConnection* { + for (const auto& [lbl, connection] : connections) { + if (lbl == label) + return connection.get(); + } + return nullptr; + }; +} + +#ifdef LVT_ENABLE_UIA +// UIA mode does not go through build_tree/ConnectionLookup, because the whole +// UIA tree replaces the visual tree rather than enriching it. It still benefits +// from the same "connect once, reuse many times" shape, though: a session can +// keep one client-side IUIAutomation object alive and re-walk through it on +// every request instead of CoCreateInstance + timeout setup on every call. +std::shared_ptr uia_connection_for_session(const Session& session) { + std::lock_guard lock(g_connectionsMutex); + auto& entry = g_sessionConnections[session.id]; + + for (auto it = entry.begin(); it != entry.end(); ++it) { + if (it->first != "uia") + continue; + if (it->second && it->second->is_alive()) + return std::dynamic_pointer_cast(it->second.shared()); + + it->second.reset(); + entry.erase(it); + break; + } + + auto handle = lvt::ConnectionRegistry::instance().acquire( + session.pid, session.hwnd, "uia", + [](HWND hwnd, DWORD) -> std::shared_ptr { + return lvt::UiaConnection::connect(hwnd); + }); + if (!handle) + return nullptr; + + auto connection = std::dynamic_pointer_cast(handle.shared()); + entry.emplace_back("uia", std::move(handle)); + return connection; +} +#endif + // --- per-target serialization ------------------------------------------- // // The MCP server dispatches every request on its own task, so several tool @@ -306,6 +474,8 @@ ParsedRef parse_ref(const std::string& ref) { // not. if (ref.rfind("uia|", 0) == 0) return {RefTree::uia, ref}; + if (ref.rfind("xaml:0x", 0) == 0 || ref.rfind("winui3:0x", 0) == 0) + return {RefTree::visual, ref}; if (ref.find('|') != std::string::npos) return {RefTree::visual, ref}; @@ -429,12 +599,26 @@ bool build_tree_for(const Session& session, const json& params, bool uia, *truncated = false; // One walk of a given window at a time; see the note on g_targetLocks. TargetGuard guard(session.hwnd); + // A request can copy its Session just before a concurrent disconnect + // removes it, then wait behind disconnect on this target lock. Refuse + // once it reaches the critical section rather than recreating a + // connection entry for a session that no longer exists. + if (!session_is_active(session.id)) { + error = "this session was disconnected while the request was waiting"; + return false; + } if (uia) { #ifdef LVT_ENABLE_UIA lvt::UiaProvider provider; const auto options = uia_options_from(params); - // Retry a failed walk rather than reporting the target unreadable. + // Prefer the session's persistent UIA client when one is available, so + // repeated MCP reads reuse one IUIAutomation object instead of + // CoCreateInstance + timeout setup on every call. Still retry a failed + // walk rather than reporting the target unreadable: external readers + // (screen readers, Inspect.exe, another lvt) can still collide with + // this process, and a failed/acquisition-reused path should degrade to + // the exact one-shot walk this code used before the connection work. // // A UIA walk is answered by the target's UI thread, which serves one // caller at a time, so a walk that overlaps another fails its @@ -449,7 +633,19 @@ bool build_tree_for(const Session& session, const json& params, bool uia, for (int attempt = 0; attempt < 3 && !result; ++attempt) { if (attempt > 0) Sleep(static_cast(120 * attempt)); - result = provider.build(session.hwnd, options, &wasTruncated); + + bool attemptTruncated = false; + lvt::Element connectedTree; + if (auto connection = uia_connection_for_session(session)) { + if (connection->get_tree_with_options(connectedTree, options, &attemptTruncated)) { + result = std::move(connectedTree); + wasTruncated = attemptTruncated; + break; + } + } + + result = provider.build(session.hwnd, options, &attemptTruncated); + wasTruncated = attemptTruncated; } if (!result) { error = "could not read the UI Automation tree for this window; it may be busy " @@ -480,7 +676,24 @@ bool build_tree_for(const Session& session, const json& params, bool uia, } auto frameworks = lvt::detect_frameworks(session.hwnd, session.pid); - tree = lvt::build_tree(session.hwnd, session.pid, frameworks, -1, {}); + const bool fastProperties = get_bool(params, "fast", false); + auto connectionLookup = connection_lookup_for_session(session, frameworks); + tree = lvt::build_tree(session.hwnd, session.pid, frameworks, -1, {}, fastProperties, connectionLookup); + + // Bounds each reused connection's internal pushed-event queue (see + // XamlDiagConnection::queue_change_event's cap - this drain just keeps + // it small in the common case rather than relying on the cap alone). + // Nothing consumes the events themselves yet; a future tool (e.g. a + // "wait for tree change" call) could. + { + std::lock_guard lock(g_connectionsMutex); + auto it = g_sessionConnections.find(session.id); + if (it != g_sessionConnections.end()) { + for (auto& [label, handle] : it->second) { + if (handle) (void)handle->poll_events(); + } + } + } return true; } @@ -625,6 +838,21 @@ json method_disconnect(const json& params) { for (const auto& [_, session] : g_sessions) stillReferenced = stillReferenced || session.hwnd == released; } + { + // Match the lock order used by tree/action reads: target first, + // connection map second. This waits for any operation that already + // entered the target critical section, while those operations hold + // their own shared connection snapshot so teardown cannot invalidate + // a raw pointer in flight. + TargetGuard guard(released); + // Dropping this session's ConnectionHandles here releases the + // registry's references (see connection_registry.h); if this was + // the last reference to a given (pid, framework) connection, its + // destructor sends a clean DISCONNECT rather than leaving it open + // until the whole MCP server process eventually exits. + std::lock_guard lock(g_connectionsMutex); + g_sessionConnections.erase(id); + } if (!stillReferenced) forget_target_lock(released); return json{{"disconnected", id}}; @@ -1139,6 +1367,11 @@ json method_hit_test(const json& params) { bool looks_like_visual_key(const std::string& ref) { if (ref.rfind("uia:", 0) == 0 || ref.rfind("uia|", 0) == 0) return false; + // XAML/WinUI3 use compact diagnostics-handle keys rather than structural + // paths. They are still visual-tree references and must be rejected by a + // UIA-mode session before attempting to resolve or act on them. + if (ref.rfind("xaml:0x", 0) == 0 || ref.rfind("winui3:0x", 0) == 0) + return true; // A durable key always has a framework prefix followed by '|'. const auto bar = ref.find('|'); return bar != std::string::npos && bar > 0; @@ -1524,6 +1757,8 @@ json method_action(const json& params, lvt::ActionKind kind, const char* actionN std::optional guard; if (!isWait) guard.emplace(session.hwnd); + if (!session_is_active(session.id)) + throw std::runtime_error("this session was disconnected while the request was waiting"); auto options = uia_options_from(params); if (isWait) { @@ -1537,7 +1772,8 @@ json method_action(const json& params, lvt::ActionKind kind, const char* actionN options.timeoutMs = 10000; } - const auto result = lvt::perform_action(session.hwnd, options, request); + auto connection = uia_connection_for_session(session); + const auto result = lvt::perform_action(session.hwnd, options, request, connection.get()); auto out = action_result_to_json(result, actionName, get_string(params, "element")); if (!result.ok) throw std::runtime_error(out.dump()); diff --git a/src/main.cpp b/src/main.cpp index ec123d1..1b5091a 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -12,6 +12,13 @@ #include "providers/uia_provider.h" #include "providers/uia_actions.h" #endif +#include "providers/connection_registry.h" +#if LVT_ENABLE_XAML +#include "providers/xaml_provider.h" +#endif +#if LVT_ENABLE_WINUI3 +#include "providers/winui3_provider.h" +#endif #include "element_key.h" #include @@ -25,6 +32,7 @@ #include #include #include +#include static std::atomic_bool g_watchStop = false; @@ -96,6 +104,10 @@ static void print_usage() { " --element Scope the tree to one element's subtree\n" " --depth Max tree traversal depth (default: unlimited)\n" " --interval Watch polling interval (default: 500)\n" + " --fast Skip the XAML/WinUI3 full property chain walk;\n" + " collect bounds/Text/Content the cheap way instead.\n" + " Much faster on rich trees; misses custom properties\n" + " outside Text/Content/bounds/basic state.\n" #ifndef NDEBUG " --annotations-json Write annotation rectangles as JSON (test hook)\n" #endif @@ -155,6 +167,12 @@ struct Args { int waitTimeoutMs = 5000; int depth = -1; int intervalMs = 500; + // Skips IVisualTreeService::GetPropertyValuesChain for XAML/WinUI3 + // elements (the dominant per-element cost of a rich tree) in favor of + // cheaper direct WinRT property reads — see build_tree's fastProperties + // parameter. Off by default to keep today's exhaustive property + // collection as the default behavior. + bool fastProperties = false; // MCP only: expose the tools that can change the target application. bool allowInput = false; }; @@ -368,6 +386,8 @@ static Args parse_args(int argc, char* argv[]) { args.depth = parse_non_negative_int(argv[++i], "--depth"); } else if (strcmp(arg, "--interval") == 0 && i + 1 < argc) { args.intervalMs = parse_non_negative_int(argv[++i], "--interval"); + } else if (strcmp(arg, "--fast") == 0) { + args.fastProperties = true; } else if (strcmp(arg, "--uia") == 0) { args.uia = true; } else if (strcmp(arg, "--allow-input") == 0) { @@ -535,6 +555,37 @@ static bool build_uia_tree(const lvt::TargetInfo& target, const Args& args, return true; } +static bool build_uia_tree_with_connection(const lvt::TargetInfo& target, const Args& args, + const lvt::ConnectionLookup& connectionLookup, + lvt::Element& tree) { + if (!connectionLookup) + return build_uia_tree(target, args, tree); + + lvt::UiaOptions options; + if (!lvt::parse_uia_view(args.uiaViewName, options.view)) { + fprintf(stderr, "lvt: --uia-view must be raw, control, or content\n"); + return false; + } + options.extraProperties = args.uiaProps; + options.timeoutMs = args.uiaTimeoutMs; + + // build_root_tree only knows about the generic IFrameworkConnection + // interface. UIA is the one caller that also needs per-call view/property/ + // timeout control, so when we know we are in --uia mode we intentionally + // recover the richer concrete type here. + if (auto* base = connectionLookup("uia")) { + if (auto* connection = dynamic_cast(base)) { + if (connection->is_alive() && connection->get_tree_with_options(tree, options)) { + lvt::assign_element_ids(tree); + lvt::assign_element_keys(tree); + return true; + } + } + } + + return build_uia_tree(target, args, tree); +} + static std::string uia_framework_label(const Args& args) { lvt::UiaView view = lvt::UiaView::control; lvt::parse_uia_view(args.uiaViewName, view); @@ -547,29 +598,41 @@ static bool build_uia_tree(const lvt::TargetInfo&, const Args&, lvt::Element&) { return false; } +static bool build_uia_tree_with_connection(const lvt::TargetInfo& target, const Args& args, + const lvt::ConnectionLookup&, lvt::Element& tree) { + return build_uia_tree(target, args, tree); +} + static std::string uia_framework_label(const Args&) { return "uia"; } #endif // Build the root tree for the requested mode. --uia replaces the visual tree // outright rather than enriching it: it is a different view of the same window, // produced without injecting anything into the target. +// +// `connectionLookup` is forwarded to build_tree unchanged - see its own doc +// comment in tree_builder.h. watch supplies one for both visual-tree and UIA +// sessions; every other caller (dump/query/screenshot) sees no behavior change. static bool build_root_tree(const lvt::TargetInfo& target, const Args& args, - lvt::Element& tree) { + lvt::Element& tree, + const lvt::ConnectionLookup& connectionLookup = {}) { if (args.uia) { - if (!build_uia_tree(target, args, tree)) + if (!build_uia_tree_with_connection(target, args, connectionLookup, tree)) return false; return true; } auto frameworks = lvt::detect_frameworks(target.hwnd, target.pid); - tree = lvt::build_tree(target.hwnd, target.pid, frameworks, -1, args.pluginOption); + tree = lvt::build_tree(target.hwnd, target.pid, frameworks, -1, args.pluginOption, + args.fastProperties, connectionLookup); return true; } static bool build_output_tree(const lvt::TargetInfo& target, const Args& args, - lvt::Element& outputTree) { + lvt::Element& outputTree, + const lvt::ConnectionLookup& connectionLookup = {}) { lvt::Element tree; - if (!build_root_tree(target, args, tree)) + if (!build_root_tree(target, args, tree, connectionLookup)) return false; lvt::Element* outputRoot = &tree; @@ -588,11 +651,250 @@ static bool build_output_tree(const lvt::TargetInfo& target, const Args& args, return true; } +static void collect_frameworks_present(const lvt::Element& el, std::set& out) { + if (!el.framework.empty()) + out.insert(el.framework); + for (const auto& child : el.children) + collect_frameworks_present(child, out); +} + +// True if `previous` had real content in an *injected* framework (xaml or +// winui3 — the ones that require InitializeXamlDiagnosticsEx and can fail; +// win32/comctl never inject and are not what this guards against) that +// `current` has none of at all. The target window itself being confirmed +// still open (see run_watch_loop's IsWindow check, which always runs +// before this) makes "the whole XAML tree just vanished" a symptom, not +// real news, in the rare case it still happens: the actual root cause this +// guarded against — xaml_diag_common.cpp's TAP DLL connect-back timeout +// being far shorter (15s) than a busy, actively animating tree can +// legitimately need (measured live at 40.8s for a real, successful +// collection) — is now fixed at its source (that timeout is 60s). This is +// kept as a secondary safety net for whatever is left over that: a +// genuinely hung target, or a walk slower even than the new timeout. +static bool lost_injected_framework_content(const lvt::Element& previous, const lvt::Element& current) { + std::set prevFrameworks, currFrameworks; + collect_frameworks_present(previous, prevFrameworks); + collect_frameworks_present(current, currFrameworks); + for (const auto& fw : prevFrameworks) { + if ((fw == "xaml" || fw == "winui3") && !currFrameworks.count(fw)) + return true; + } + return false; +} + +// Acquires the persistent connections a watch session can reuse across ticks. +// For visual-tree sessions that means one connection per injectable framework +// (xaml/winui3); for --uia it means one reusable UI Automation client. Held +// for the whole watch session; released automatically when run_watch_loop +// returns. +// +// The visual-tree path needs a fresh, UNTRIMMED probe walk of its own rather +// than reusing whatever the tick loop's `previous`/`current` holds: those may +// have been scoped by --element/--depth and could be missing the very node +// (e.g. a CoreWindow) XamlProvider::open_connection needs to resolve which +// process to inject into. UIA needs no such probe because it does not resolve +// a framework island before connecting. +static std::vector> acquire_watch_connections( + const lvt::TargetInfo& target, const Args& args) { + std::vector> connections; + +#ifdef LVT_ENABLE_UIA + if (args.uia) { + auto handle = lvt::ConnectionRegistry::instance().acquire( + target.pid, target.hwnd, "uia", + [](HWND hwnd, DWORD) -> std::shared_ptr { + return lvt::UiaConnection::connect(hwnd); + }); + if (handle) + connections.emplace_back("uia", std::move(handle)); + return connections; + } +#endif + + auto frameworks = lvt::detect_frameworks(target.hwnd, target.pid); + bool hasXaml = false, hasWinUI3 = false; + for (auto& fi : frameworks) { + if (fi.type == lvt::Framework::Xaml) hasXaml = true; + if (fi.type == lvt::Framework::WinUI3) hasWinUI3 = true; + } + if (!hasXaml && !hasWinUI3) + return connections; + + // XamlProvider::open_connection only needs the CoreWindow HWND from the + // native window skeleton so it can resolve the app process behind an + // ApplicationFrameHost window. Passing the detected framework list here + // used to run every enrichment provider too, including a complete + // one-shot XAML injection + full property walk immediately before + // opening the persistent connection. Besides contradicting the + // connection-reuse design, that redundant probe measured ~25 seconds + // against Microsoft Store. An empty framework list still builds the + // untrimmed Win32 base tree (build_tree always starts with Win32), which + // contains the CoreWindow and is all this probe actually needs. + lvt::Element probeTree = lvt::build_tree(target.hwnd, target.pid, {}); + +#if LVT_ENABLE_XAML + if (hasXaml) { + auto handle = lvt::ConnectionRegistry::instance().acquire( + target.pid, target.hwnd, "xaml", + [&probeTree](HWND hwnd, DWORD pid) -> std::shared_ptr { + lvt::XamlProvider xaml; + return xaml.open_connection(probeTree, hwnd, pid); + }); + // Always record a "xaml" entry once the framework is detected, even + // if this particular acquire attempt failed (handle is then empty/ + // falsy) - InitializeXamlDiagnosticsEx is known to fail transiently + // on a first try against a slow/busy target (observed live against + // Microsoft Store) even though a retry moments later succeeds. + // Without an entry here at all, refresh_dead_watch_connections has + // nothing to notice and retry: it can only detect and fix an + // existing (label, handle) pair going dead, not a framework whose + // very first acquisition never even produced one - which silently + // and permanently starved that framework of enrichment for the + // rest of the session once tree_builder.cpp stopped falling back + // to one-shot reinjection for a lookup-returns-nothing case (see + // that commit's own reasoning for why one-shot must not be the + // silent fallback there). + connections.emplace_back("xaml", std::move(handle)); + } +#endif +#if LVT_ENABLE_WINUI3 + if (hasWinUI3) { + auto handle = lvt::ConnectionRegistry::instance().acquire( + target.pid, target.hwnd, "winui3", + [](HWND hwnd, DWORD pid) -> std::shared_ptr { + lvt::WinUI3Provider winui3; + return winui3.open_connection(hwnd, pid); + }); + // See the matching comment in the Xaml case above. + connections.emplace_back("winui3", std::move(handle)); + } +#endif + return connections; +} + +// Builds a ConnectionLookup (see tree_builder.h) closing over `connections` +// so build_tree can find the right one by framework label without knowing +// anything about the registry or how it was acquired. +static lvt::ConnectionLookup make_lookup( + std::vector>& connections) { + return [&connections](const std::string& label) -> lvt::IFrameworkConnection* { + for (auto& [lbl, handle] : connections) { + if (lbl == label) + return handle.get(); + } + return nullptr; + }; +} + +// Called once per tick, before building the tree: if a held connection has +// died (a transient timeout against an unusually large/busy tree - observed +// live against Microsoft Store's home page, whose own collection can take +// several seconds even in --fast mode - or the target recycling something +// XAML-diagnostics-related), is_alive() goes false; and, separately, a +// framework can have been detected but never successfully acquired a +// connection in the first place (its very first InitializeXamlDiagnosticsEx +// attempt failed - also observed live against Microsoft Store, transient +// but common against a slow/busy target). Since tree_builder.cpp no longer +// silently falls back to one-shot reinjection when a supplied +// ConnectionLookup returns nothing for a framework (see the commit that +// tightened that), *both* cases need this function to actively retry them - +// otherwise either one would silently and permanently skip that +// framework's enrichment for the rest of the watch session, with nothing +// left to notice or recover. Observed live: once Microsoft Store's XAML +// connection timed out once, every following tick logged +// "InitializeXamlDiagnosticsEx failed" - the connection endpoints were still +// consumed/settling from the connection that had just died, so immediately +// retrying via the one-shot path on every single tick kept losing that race +// too, compounding rather than recovering. +// +// Releasing a dead ConnectionHandle before calling acquire() again for the +// same key is required, not just tidy: ConnectionRegistry::release() only +// looks up its map by (pid, label), not by matching the specific connection +// instance a caller's handle refers to. Reacquiring first (while still +// holding the old, dead handle) would create a new entry in the map under +// that same key; the old handle's *later* release would then decrement the +// brand new entry's refcount instead of the dead one's, since release() has +// no way to tell them apart - risking the fresh connection being torn down +// prematurely. Resetting first ensures the dead entry is fully erased +// before any new one for the same key can exist. +static void refresh_dead_watch_connections( + const lvt::TargetInfo& target, const Args& args, + std::vector>& connections) { + bool anyDead = false; + for (auto& [label, handle] : connections) { + // A missing/never-acquired handle (acquire_watch_connections still + // records an entry even when its own acquire() attempt failed - see + // that function's comment) is treated exactly like a dead one here: + // both mean "this framework has no working connection right now", + // and both are worth retrying rather than leaving as a permanent + // gap for the rest of the session. + if (!handle || !handle->is_alive()) { + if (lvt::g_debug) + fprintf(stderr, "lvt: %s connection %s; will attempt to (re)connect\n", + label.c_str(), handle ? "died" : "was never established"); + handle.reset(); + anyDead = true; + } + } + if (!anyDead) + return; + + // Re-derive fresh connections via the same logic used at startup rather + // than duplicating it, then take only the ones this call actually + // needed (still-alive entries above were left alone and must not be + // touched here - see this function's own comment on why acquiring twice + // for the same live key, even transiently, is safe: it is just a + // temporary extra refcount that `fresh` going out of scope drops again, + // never releasing anything this function did not itself acquire). + auto fresh = acquire_watch_connections(target, args); + for (auto& [label, handle] : connections) { + if (handle) + continue; + for (auto& [freshLabel, freshHandle] : fresh) { + if (freshLabel == label && freshHandle) { + handle = std::move(freshHandle); + break; + } + } + } +} + static int run_watch_loop(const lvt::TargetInfo& target, const Args& args) { SetConsoleCtrlHandler(console_ctrl_handler, TRUE); + // Acquired at the start of the session and refreshed whenever a held + // connection dies (see refresh_dead_watch_connections) - not + // re-acquired from scratch every tick, which is the entire point of + // this mechanism. + std::vector> connections; + connections = acquire_watch_connections(target, args); + lvt::ConnectionLookup lookup = make_lookup(connections); + + // The very first tree build did not get the same tolerance the tick loop + // below already has for a transient failure — it would fail outright + // and exit this whole process on one bad injection attempt. Observed + // live: a viewer connecting to a XAML/WinUI3 app can hit + // "InitializeXamlDiagnosticsEx failed" on the very first try even + // though a retry moments later succeeds (the same transient-connection + // flakiness the tick loop's own comment already describes), and from + // the viewer's side that looked exactly like "the crosshair picker + // sometimes goes disabled for no reason" — this process exiting + // immediately at startup is indistinguishable, from IsConnected's point + // of view, from a deliberate disconnect. A few retries here costs + // nothing on the ordinary path (the first attempt still succeeds + // almost always) and turns a transient hiccup into a normal, silent + // recovery instead of a dead connection. lvt::Element previous; - if (!build_output_tree(target, args, previous)) + bool built = false; + for (int attempt = 0; attempt < 5 && !built; ++attempt) { + if (attempt > 0) { + if (lvt::g_debug) + fprintf(stderr, "lvt: retrying initial watch connection (attempt %d)\n", attempt + 1); + Sleep(static_cast(300 * attempt)); + } + built = build_output_tree(target, args, previous, lookup); + } + if (!built) return 1; for (const auto& event : lvt::snapshot_added_events(previous)) @@ -613,8 +915,10 @@ static int run_watch_loop(const lvt::TargetInfo& target, const Args& args) { break; } + refresh_dead_watch_connections(target, args, connections); + lvt::Element current; - if (!build_output_tree(target, args, current)) { + if (!build_output_tree(target, args, current, lookup)) { // A tick can fail transiently — most easily in UIA mode, where a // momentarily busy target trips the transaction timeout. That is // precisely the condition --watch exists to observe, so skip the @@ -624,6 +928,50 @@ static int run_watch_loop(const lvt::TargetInfo& target, const Args& args) { continue; } + // Drain whatever incremental Add/Remove notifications a connection + // pushed since the last tick (see PushChangeEvent in lvt_tap.cpp + // and IFrameworkConnection::poll_events). watch's own change events + // (emitted below via diff_trees) already come from comparing two + // full GET_TREE snapshots, so these are not fed into that today - + // this just bounds each connection's internal event queue (see + // XamlDiagConnection::queue_change_event's cap) and surfaces them + // for debugging. A future phase could drive watch's ticks from + // these directly instead of polling on a fixed interval. + if (lvt::g_debug) { + for (auto& [label, handle] : connections) { + if (!handle) continue; + auto events = handle->poll_events(); + if (!events.empty()) + fprintf(stderr, "lvt: %s connection reported %zu pushed change event(s) this tick\n", + label.c_str(), events.size()); + } + } else { + for (auto& [label, handle] : connections) { + if (handle) (void)handle->poll_events(); + } + } + + // See lost_injected_framework_content's doc comment: this is now a + // secondary safety net (the actual root cause is fixed at its + // source, in xaml_diag_common.cpp's connect-back timeout), so a + // small, conservative retry here is enough — and, per + // inject_and_collect_xaml_tree's own comment, retrying *too* + // eagerly risks starting another walk that competes with a + // still-running straggler from the attempt that is timing out, + // rather than helping it finish. Bounded so a *genuine* loss (the + // app really did close its XAML view) still gets reported, just + // not on the very first affected tick. + if (lost_injected_framework_content(previous, current)) { + for (int extra = 0; extra < 2 && lost_injected_framework_content(previous, current); extra++) { + if (lvt::g_debug) + fprintf(stderr, "lvt: XAML/WinUI3 content vanished this tick; retrying (attempt %d)\n", extra + 1); + Sleep(1000); + lvt::Element retryTree; + if (build_output_tree(target, args, retryTree, lookup)) + current = std::move(retryTree); + } + } + for (const auto& event : lvt::diff_trees(previous, current)) printf("%s\n", lvt::serialize_change_event(event).c_str()); fflush(stdout); diff --git a/src/plugin.h b/src/plugin.h index 49b020c..51fedc9 100644 --- a/src/plugin.h +++ b/src/plugin.h @@ -11,13 +11,21 @@ extern "C" { #endif -#define LVT_PLUGIN_API_VERSION 1 +// Bumped from 1 to 2 to add the OPTIONAL persistent-connection functions +// below (see "Persistent connections"). This is additive, not a breaking +// change: lvt_loader.cpp accepts any api_version from 1 up to this value +// (not just an exact match), and every v2 function is probed individually +// via GetProcAddress — a v1-only plugin that has never been rebuilt simply +// doesn't export them, and lvt core falls back to the same one-shot +// lvt_enrich_tree path it always used. A plugin only needs to bump its own +// reported api_version once it actually implements the v2 functions. +#define LVT_PLUGIN_API_VERSION 2 // ---------- Plugin metadata ---------- struct LvtPluginInfo { uint32_t struct_size; // sizeof(LvtPluginInfo), for versioning - uint32_t api_version; // must be LVT_PLUGIN_API_VERSION + uint32_t api_version; // the highest LVT_PLUGIN_API_VERSION this plugin actually implements const char* name; // short identifier, e.g. "myframework" const char* description; // human-readable, e.g. "Custom framework support" }; @@ -73,17 +81,85 @@ typedef int (*LvtDetectFrameworkFn)(DWORD pid, HWND hwnd, LvtFrameworkDetection* // `hwnd_filter` is the HWND of a specific host window to scope enrichment to, // or NULL for all. // Returns nonzero on success. +// +// This one-shot path always works and is what every v1 plugin implements. +// A plugin that also implements the v2 functions below still needs this one: +// it is the fallback lvt core uses whenever no persistent connection is +// available (a lvt_connection_open failure, or a caller — a one-shot CLI +// command — that never asked the registry for one at all). typedef int (*LvtEnrichTreeFn)(HWND hwnd, DWORD pid, const char* element_class_filter, char** json_out); // Free memory allocated by the plugin (e.g. json_out from LvtEnrichTreeFn). typedef void (*LvtPluginFreeFn)(void* ptr); +// ---------- Persistent connections (optional, API v2) ---------- +// +// Mirrors src/providers/framework_connection.h's IFrameworkConnection: a +// plugin that implements these lets lvt core (see connection_registry.h) +// establish a connection ONCE and reuse it for many tree refreshes across a +// watch session or an MCP session, instead of calling lvt_enrich_tree fresh +// every single time - the same problem this whole mechanism exists to avoid +// for XAML/WinUI3 (see docs/tap-dll-design.md's connection lifecycle). +// +// The persistent tree path is enabled only when lvt_connection_open, +// lvt_connection_get_tree, lvt_connection_close, and the existing +// lvt_plugin_free export are all present: every successful open must be +// closeable, and every JSON result must be releasable by its allocating +// module. Polling is a separate optional pair: +// lvt_connection_poll_events is used only with +// lvt_connection_events_free. An incomplete v2 group is treated as v1 and +// keeps working through lvt_enrich_tree. + +struct LvtConnectionEvent { + uint32_t struct_size; + const char* mutation; // "add" | "remove" + uintptr_t handle; + uintptr_t parent_handle; + int32_t child_index; + const char* element_type; // only meaningful for "add" + const char* name; // only meaningful for "add" +}; + +// Establishes a persistent connection to (hwnd, pid). Returns an opaque, +// plugin-owned handle, or NULL if unsupported or the connection could not +// be established. lvt core treats NULL exactly like a v1 plugin that has no +// lvt_connection_open at all: it falls back to lvt_enrich_tree. +typedef void* (*LvtConnectionOpenFn)(HWND hwnd, DWORD pid); + +// Re-collects the current tree over `conn` (no re-injection) and yields it +// the same way lvt_enrich_tree does: a malloc'd JSON string in `json_out`, +// freed by the caller via lvt_plugin_free. +typedef int (*LvtConnectionGetTreeFn)(void* conn, const char* element_class_filter, char** json_out); + +// Non-blocking: fills `events_out`/`count_out` with whatever incremental +// Add/Remove notifications the plugin has observed since the last call. +// Returns nonzero on success (including "success, zero events"); a plugin +// that never implements real incremental tracking can simply always report +// zero events here - callers always have lvt_connection_get_tree as a full +// refresh fallback, so this is never the only way to get current data. +typedef int (*LvtConnectionPollEventsFn)(void* conn, LvtConnectionEvent** events_out, uint32_t* count_out); + +// Frees an array returned by lvt_connection_poll_events. +typedef void (*LvtConnectionEventsFreeFn)(LvtConnectionEvent* events, uint32_t count); + +// Closes a connection opened by lvt_connection_open - the plugin's chance +// to do whatever clean teardown its underlying mechanism needs, exactly +// once, when the connection actually ends (not per refresh). +typedef void (*LvtConnectionCloseFn)(void* conn); + // Exported function names (for GetProcAddress) #define LVT_PLUGIN_INFO_FUNC "lvt_plugin_info" #define LVT_PLUGIN_DETECT_FUNC "lvt_detect_framework" #define LVT_PLUGIN_ENRICH_FUNC "lvt_enrich_tree" #define LVT_PLUGIN_FREE_FUNC "lvt_plugin_free" +// v2, all optional - see "Persistent connections" above. +#define LVT_PLUGIN_CONNECTION_OPEN_FUNC "lvt_connection_open" +#define LVT_PLUGIN_CONNECTION_GET_TREE_FUNC "lvt_connection_get_tree" +#define LVT_PLUGIN_CONNECTION_POLL_EVENTS_FUNC "lvt_connection_poll_events" +#define LVT_PLUGIN_CONNECTION_EVENTS_FREE_FUNC "lvt_connection_events_free" +#define LVT_PLUGIN_CONNECTION_CLOSE_FUNC "lvt_connection_close" + #ifdef __cplusplus } #endif diff --git a/src/plugin_loader.cpp b/src/plugin_loader.cpp index 9382384..d6e9fe0 100644 --- a/src/plugin_loader.cpp +++ b/src/plugin_loader.cpp @@ -84,8 +84,14 @@ static void load_plugins_from(const std::wstring& dir, std::set& s } LvtPluginInfo* info = infoFn(); + // Accept any version from 1 up to what this core supports, not + // just an exact match: a plugin reporting api_version=1 (built + // before the optional v2 connection functions existed - see + // plugin.h) must keep loading and working via lvt_enrich_tree, + // exactly as it always did. Only reject something newer than this + // core understands, or malformed metadata. if (!info || info->struct_size < sizeof(LvtPluginInfo) || - info->api_version != LVT_PLUGIN_API_VERSION) { + info->api_version < 1 || info->api_version > LVT_PLUGIN_API_VERSION) { if (g_debug) fprintf(stderr, "lvt: %ls has incompatible plugin API version\n", fd.cFileName); @@ -101,11 +107,32 @@ static void load_plugins_from(const std::wstring& dir, std::set& s GetProcAddress(lp.module.get(), LVT_PLUGIN_ENRICH_FUNC)); lp.free_fn = reinterpret_cast( GetProcAddress(lp.module.get(), LVT_PLUGIN_FREE_FUNC)); - + // Every one of these is independently optional - see plugin.h's + // "Persistent connections". A plugin still on v1 simply doesn't + // export them, GetProcAddress returns nullptr, and every consumer + // of LoadedPlugin already treats a null function pointer as "this + // plugin doesn't support that" (open_plugin_connection below + // requires connection_open specifically to be non-null before + // trying to use any of the rest). + lp.connection_open = reinterpret_cast( + GetProcAddress(lp.module.get(), LVT_PLUGIN_CONNECTION_OPEN_FUNC)); + lp.connection_get_tree = reinterpret_cast( + GetProcAddress(lp.module.get(), LVT_PLUGIN_CONNECTION_GET_TREE_FUNC)); + lp.connection_poll_events = reinterpret_cast( + GetProcAddress(lp.module.get(), LVT_PLUGIN_CONNECTION_POLL_EVENTS_FUNC)); + lp.connection_events_free = reinterpret_cast( + GetProcAddress(lp.module.get(), LVT_PLUGIN_CONNECTION_EVENTS_FREE_FUNC)); + lp.connection_close = reinterpret_cast( + GetProcAddress(lp.module.get(), LVT_PLUGIN_CONNECTION_CLOSE_FUNC)); + + const bool supportsPersistentConnections = + lp.connection_open && lp.connection_get_tree && + lp.connection_close && lp.free_fn; if (g_debug) - fprintf(stderr, "lvt: loaded plugin '%s' (%s)\n", + fprintf(stderr, "lvt: loaded plugin '%s' (%s)%s\n", info->name ? info->name : "?", - info->description ? info->description : ""); + info->description ? info->description : "", + supportsPersistentConnections ? ", supports persistent connections" : ""); s_plugins.push_back(std::move(lp)); } while (FindNextFileW(hFind.get(), &fd)); @@ -207,32 +234,12 @@ static void graft_json_node(const json& j, Element& parent, const std::string& f parent.children.push_back(std::move(el)); } -bool enrich_with_plugin(Element& root, HWND hwnd, DWORD pid, - const PluginFrameworkInfo& pluginFw, - const std::string& pluginOption) { - if (!pluginFw.plugin || !pluginFw.plugin->enrich) return false; - - char* jsonOut = nullptr; - int ok = pluginFw.plugin->enrich(hwnd, pid, - pluginOption.empty() ? nullptr : pluginOption.c_str(), - &jsonOut); - if (!ok || !jsonOut) return false; - - json treeJson; - try { - treeJson = json::parse(jsonOut); - } catch (const json::parse_error& e) { - fprintf(stderr, "lvt: failed to parse plugin JSON: %s\n", e.what()); - if (pluginFw.plugin->free_fn) pluginFw.plugin->free_fn(jsonOut); - return false; - } - - if (g_debug) - fprintf(stderr, "lvt: plugin '%s' returned %zu bytes of tree data\n", - pluginFw.name.c_str(), strlen(jsonOut)); - - if (pluginFw.plugin->free_fn) pluginFw.plugin->free_fn(jsonOut); - +// Grafts an already-parsed plugin tree JSON payload into `root` - shared by +// the one-shot path (enrich_with_plugin) and a reused PluginConnection's +// repeated get_tree() calls, so both share exactly one implementation of +// this logic (mirrors xaml_diag_common.cpp's graft_xaml_tree_json split for +// the same reason). +static void graft_plugin_tree_json(const json& treeJson, Element& root, const std::string& frameworkName) { // The plugin JSON is an array of tree roots. Each root has a "target_hwnd" // field (hex HWND string) indicating which existing element to graft under. // We walk the tree fresh for each root to find the matching host element by @@ -262,22 +269,151 @@ bool enrich_with_plugin(Element& root, HWND hwnd, DWORD pid, double baseY = host->bounds.y; if (node.contains("children") && node["children"].is_array()) { for (auto& child : node["children"]) { - graft_json_node(child, *host, pluginFw.name, baseX, baseY); + graft_json_node(child, *host, frameworkName, baseX, baseY); } } else { - graft_json_node(node, *host, pluginFw.name, baseX, baseY); + graft_json_node(node, *host, frameworkName, baseX, baseY); } } else { // No matching host — graft under root - graft_json_node(node, root, pluginFw.name, + graft_json_node(node, root, frameworkName, root.bounds.x, root.bounds.y); } } } else if (treeJson.is_object()) { - graft_json_node(treeJson, root, pluginFw.name); + graft_json_node(treeJson, root, frameworkName); } +} + +bool enrich_with_plugin(Element& root, HWND hwnd, DWORD pid, + const PluginFrameworkInfo& pluginFw, + const std::string& pluginOption) { + if (!pluginFw.plugin || !pluginFw.plugin->enrich) return false; + char* jsonOut = nullptr; + int ok = pluginFw.plugin->enrich(hwnd, pid, + pluginOption.empty() ? nullptr : pluginOption.c_str(), + &jsonOut); + if (!ok || !jsonOut) return false; + + json treeJson; + try { + treeJson = json::parse(jsonOut); + } catch (const json::parse_error& e) { + fprintf(stderr, "lvt: failed to parse plugin JSON: %s\n", e.what()); + if (pluginFw.plugin->free_fn) pluginFw.plugin->free_fn(jsonOut); + return false; + } + + if (g_debug) + fprintf(stderr, "lvt: plugin '%s' returned %zu bytes of tree data\n", + pluginFw.name.c_str(), strlen(jsonOut)); + + if (pluginFw.plugin->free_fn) pluginFw.plugin->free_fn(jsonOut); + + graft_plugin_tree_json(treeJson, root, pluginFw.name); return true; } +// IFrameworkConnection adapter over a plugin's optional v2 connection +// functions (see plugin.h). Lets connection_registry.h's ConnectionRegistry +// treat a plugin-provided connection identically to XamlDiagConnection - +// callers (watch's loop, an MCP session) don't need to know or care which +// one they got. +class PluginConnection : public IFrameworkConnection { +public: + PluginConnection(const LoadedPlugin* plugin, void* handle, std::string frameworkName) + : m_plugin(plugin), m_handle(handle), m_frameworkName(std::move(frameworkName)) { + } + + ~PluginConnection() override { + if (m_handle && m_plugin->connection_close) + m_plugin->connection_close(m_handle); + } + + bool get_tree(Element& root, bool /*fastProperties*/, + const std::string& providerOption = {}) override { + // Plugins have no fast/full distinction today - see plugin.h's + // LvtConnectionGetTreeFn. Accepting and ignoring the parameter here + // (rather than omitting it) keeps this a drop-in IFrameworkConnection, + // consistent with XamlDiagConnection's signature. + if (!m_handle || !m_plugin->connection_get_tree) + return false; + + char* jsonOut = nullptr; + const char* filter = providerOption.empty() ? nullptr : providerOption.c_str(); + int ok = m_plugin->connection_get_tree(m_handle, filter, &jsonOut); + if (!ok || !jsonOut) { + m_alive = false; + return false; + } + + json treeJson; + try { + treeJson = json::parse(jsonOut); + } catch (const json::parse_error& e) { + fprintf(stderr, "lvt: failed to parse plugin connection JSON: %s\n", e.what()); + if (m_plugin->free_fn) m_plugin->free_fn(jsonOut); + return false; + } + if (m_plugin->free_fn) m_plugin->free_fn(jsonOut); + + graft_plugin_tree_json(treeJson, root, m_frameworkName); + return true; + } + + std::vector poll_events() override { + std::vector result; + if (!m_handle || !m_plugin->connection_poll_events || + !m_plugin->connection_events_free) + return result; + + LvtConnectionEvent* events = nullptr; + uint32_t count = 0; + if (!m_plugin->connection_poll_events(m_handle, &events, &count) || !events) + return result; + + result.reserve(count); + for (uint32_t i = 0; i < count; i++) { + ConnectionEvent ev; + ev.mutation = (events[i].mutation && std::string(events[i].mutation) == "remove") + ? ConnectionEvent::Mutation::removed + : ConnectionEvent::Mutation::added; + ev.handle = events[i].handle; + ev.parentHandle = events[i].parent_handle; + ev.childIndex = events[i].child_index; + ev.elementType = events[i].element_type ? events[i].element_type : ""; + ev.name = events[i].name ? events[i].name : ""; + result.push_back(std::move(ev)); + } + if (m_plugin->connection_events_free) + m_plugin->connection_events_free(events, count); + return result; + } + + bool is_alive() const override { return m_alive && m_handle != nullptr; } + +private: + const LoadedPlugin* m_plugin; + void* m_handle; + std::string m_frameworkName; + bool m_alive = true; +}; + +std::shared_ptr open_plugin_connection( + const PluginFrameworkInfo& pluginFw, HWND hwnd, DWORD pid) { + if (!pluginFw.plugin || + !pluginFw.plugin->connection_open || + !pluginFw.plugin->connection_get_tree || + !pluginFw.plugin->connection_close || + !pluginFw.plugin->free_fn) + return nullptr; + + void* handle = pluginFw.plugin->connection_open(hwnd, pid); + if (!handle) + return nullptr; + + return std::make_shared(pluginFw.plugin, handle, pluginFw.name); +} + } // namespace lvt diff --git a/src/plugin_loader.h b/src/plugin_loader.h index 25b66c4..27a55f0 100644 --- a/src/plugin_loader.h +++ b/src/plugin_loader.h @@ -1,6 +1,8 @@ #pragma once #include "plugin.h" #include "element.h" +#include "providers/framework_connection.h" +#include #include #include #include @@ -14,6 +16,14 @@ struct LoadedPlugin { LvtDetectFrameworkFn detect; LvtEnrichTreeFn enrich; LvtPluginFreeFn free_fn; + // v2 exports (see plugin.h's "Persistent connections"). The core only + // enables persistence when open/get_tree/close and the v1 free_fn are + // all present; polling additionally requires its matching free export. + LvtConnectionOpenFn connection_open = nullptr; + LvtConnectionGetTreeFn connection_get_tree = nullptr; + LvtConnectionPollEventsFn connection_poll_events = nullptr; + LvtConnectionEventsFreeFn connection_events_free = nullptr; + LvtConnectionCloseFn connection_close = nullptr; }; // Discover and load plugins from %USERPROFILE%/.lvt/plugins/ @@ -40,4 +50,14 @@ bool enrich_with_plugin(Element& root, HWND hwnd, DWORD pid, const PluginFrameworkInfo& pluginFw, const std::string& pluginOption = {}); +// Establishes a persistent connection (see framework_connection.h) via the +// plugin's optional v2 functions, for reuse across many refreshes the same +// way make_xaml_diag_connection is for XAML/WinUI3 - see +// connection_registry.h for how a caller acquires/reuses/releases one. +// Returns nullptr if the plugin doesn't implement the complete required v2 +// lifetime group (open/get_tree/close plus lvt_plugin_free), or connection +// establishment failed. +std::shared_ptr open_plugin_connection( + const PluginFrameworkInfo& pluginFw, HWND hwnd, DWORD pid); + } // namespace lvt diff --git a/src/providers/connection_registry.cpp b/src/providers/connection_registry.cpp new file mode 100644 index 0000000..9cd2184 --- /dev/null +++ b/src/providers/connection_registry.cpp @@ -0,0 +1,93 @@ +// connection_registry.cpp — per-process refcounted registry of live +// IFrameworkConnections. See connection_registry.h for the rationale. + +#include "connection_registry.h" + +namespace lvt { + +ConnectionHandle::ConnectionHandle(DWORD pid, std::string frameworkLabel, + std::shared_ptr connection) + : m_pid(pid), m_frameworkLabel(std::move(frameworkLabel)), m_connection(std::move(connection)) { +} + +ConnectionHandle::~ConnectionHandle() { + release_if_held(); +} + +ConnectionHandle::ConnectionHandle(ConnectionHandle&& other) noexcept + : m_pid(other.m_pid), + m_frameworkLabel(std::move(other.m_frameworkLabel)), + m_connection(std::move(other.m_connection)) { + other.m_connection.reset(); +} + +ConnectionHandle& ConnectionHandle::operator=(ConnectionHandle&& other) noexcept { + if (this != &other) { + release_if_held(); + m_pid = other.m_pid; + m_frameworkLabel = std::move(other.m_frameworkLabel); + m_connection = std::move(other.m_connection); + other.m_connection.reset(); + } + return *this; +} + +void ConnectionHandle::reset() { + release_if_held(); + m_connection.reset(); +} + +void ConnectionHandle::release_if_held() { + if (m_connection) { + ConnectionRegistry::instance().release(m_pid, m_frameworkLabel); + } +} + +ConnectionRegistry& ConnectionRegistry::instance() { + static ConnectionRegistry registry; + return registry; +} + +ConnectionHandle ConnectionRegistry::acquire(DWORD pid, HWND hwnd, const std::string& frameworkLabel, + const Factory& factory) { + std::lock_guard lock(m_mutex); + auto key = std::make_pair(pid, frameworkLabel); + auto it = m_entries.find(key); + if (it != m_entries.end() && it->second.connection && it->second.connection->is_alive()) { + it->second.refCount++; + return ConnectionHandle(pid, frameworkLabel, it->second.connection); + } + + // No live entry (or a stale one whose connection died) - (re)create it. + // The factory call happens with the registry lock held: connecting is + // relatively rare (once per watch session / MCP session, not once per + // tick) and serializing it avoids two racing acquirers both trying to + // inject into the same target at once. + auto connection = factory(hwnd, pid); + if (!connection) { + m_entries.erase(key); + return ConnectionHandle(); + } + + Entry entry; + entry.connection = connection; + entry.refCount = 1; + m_entries[key] = entry; + return ConnectionHandle(pid, frameworkLabel, connection); +} + +void ConnectionRegistry::release(DWORD pid, const std::string& frameworkLabel) { + std::lock_guard lock(m_mutex); + auto key = std::make_pair(pid, frameworkLabel); + auto it = m_entries.find(key); + if (it == m_entries.end()) + return; + if (--it->second.refCount <= 0) { + // Dropping the shared_ptr here runs the connection's destructor, + // which is where each provider performs its clean disconnect + // (DISCONNECT + UnadviseVisualTreeChange + DestroyWindow, etc). + m_entries.erase(it); + } +} + +} // namespace lvt diff --git a/src/providers/connection_registry.h b/src/providers/connection_registry.h new file mode 100644 index 0000000..bf6427e --- /dev/null +++ b/src/providers/connection_registry.h @@ -0,0 +1,96 @@ +#pragma once +#include "framework_connection.h" +#include +#include +#include +#include +#include +#include +#include + +namespace lvt { + +// RAII handle to an acquired IFrameworkConnection. Move-only; releasing the +// registry's reference happens automatically in the destructor. +// +// This exists specifically so "acquire a connection, forget to release it" +// cannot happen by omission at a call site - the exact failure mode +// (something is created once and never explicitly torn down) that produced +// the confirmed, unbounded per-tick window leak this whole mechanism +// replaces. A default-constructed/empty handle is valid and falsy. +class ConnectionHandle { +public: + ConnectionHandle() = default; + ConnectionHandle(DWORD pid, std::string frameworkLabel, + std::shared_ptr connection); + ~ConnectionHandle(); + + ConnectionHandle(ConnectionHandle&& other) noexcept; + ConnectionHandle& operator=(ConnectionHandle&& other) noexcept; + ConnectionHandle(const ConnectionHandle&) = delete; + ConnectionHandle& operator=(const ConnectionHandle&) = delete; + + IFrameworkConnection* operator->() const { return m_connection.get(); } + IFrameworkConnection* get() const { return m_connection.get(); } + // Keeps an in-flight operation alive independently of the registry + // handle. MCP disconnect can erase a session concurrently; callers that + // cross that boundary must hold this shared snapshot rather than a raw + // pointer into g_sessionConnections. + std::shared_ptr shared() const { return m_connection; } + explicit operator bool() const { return m_connection != nullptr; } + + // Drop this handle's reference early, before it would otherwise go out + // of scope (e.g. because is_alive() went false and the caller wants a + // fresh acquire() on the next attempt rather than holding a dead one). + void reset(); + +private: + void release_if_held(); + + DWORD m_pid = 0; + std::string m_frameworkLabel; + std::shared_ptr m_connection; +}; + +// Per-process (i.e. per lvt_core instance - a one-shot CLI command links +// this fresh each run, while lvt_core stays loaded for the whole life of an +// `lvt watch` process or an `lvt mcp` server process) registry of live +// IFrameworkConnections, keyed by (pid, framework label, e.g. "xaml" / +// "winui3"). Refcounted: multiple acquirers within the SAME process share +// one underlying connection; it is only torn down once the last holder +// releases (or lets its ConnectionHandle go out of scope). +// +// Deliberately per-process only - a connection acquired by one lvt.exe +// invocation is not visible to another separately-running lvt.exe process +// targeting the same window. Broader cross-process sharing was considered +// and explicitly deferred; see docs/architecture.md. +class ConnectionRegistry { +public: + static ConnectionRegistry& instance(); + + // Attempts to establish a NEW connection for (hwnd, pid). Returning + // nullptr means "could not connect"; the caller falls back to whatever + // one-shot path it used before this registry existed. + using Factory = std::function(HWND hwnd, DWORD pid)>; + + // Returns the existing live connection for (pid, frameworkLabel) if one + // is registered and still is_alive(); otherwise invokes `factory` to + // create one and registers it. The returned handle is empty (falsy) if + // no live connection exists and `factory` also failed. + ConnectionHandle acquire(DWORD pid, HWND hwnd, const std::string& frameworkLabel, + const Factory& factory); + +private: + friend class ConnectionHandle; + void release(DWORD pid, const std::string& frameworkLabel); + + struct Entry { + std::shared_ptr connection; + int refCount = 0; + }; + + std::mutex m_mutex; + std::map, Entry> m_entries; +}; + +} // namespace lvt diff --git a/src/providers/framework_connection.h b/src/providers/framework_connection.h new file mode 100644 index 0000000..3a8f8c6 --- /dev/null +++ b/src/providers/framework_connection.h @@ -0,0 +1,88 @@ +#pragma once +#include "../element.h" +#include +#include +#include +#include + +namespace lvt { + +// A live, reusable connection to one framework "island" (e.g. one XAML or +// WinUI3 diagnostics session) inside a target process. +// +// This exists so a provider that supports it (today: XamlProvider and +// WinUI3Provider via xaml_diag_common.cpp) can be injected/subscribed ONCE +// and then reused for many subsequent tree refreshes, instead of the old +// per-call model of re-running InitializeXamlDiagnosticsEx from scratch +// every time. That old model is what caused a confirmed, unbounded resource +// leak (one message-only window created and never destroyed per call) and +// the "tree refreshes/resets" bug live-diagnosed against Microsoft Store: +// repeated reconnect/re-advise/re-walk cycles compete for the target's UI +// thread and can occasionally make a whole tick's collection take far +// longer than expected. +// +// A provider that does NOT support this (e.g. Win32Provider/ComCtlProvider, +// which just re-enumerate cheap native HWNDs, or any plugin that hasn't +// implemented the optional plugin ABI v2 connection functions) is simply +// never asked for one — callers fall back to their existing one-shot +// enrich()-per-call path unchanged. See connection_registry.h for how a +// caller (watch's loop, an MCP session) acquires/reuses/releases these. +class IFrameworkConnection { +public: + virtual ~IFrameworkConnection() = default; + + // Re-walk bounds/properties over the ALREADY established connection and + // graft the result into `root`. This should be cheap relative to the old + // one-shot inject_and_collect_xaml_tree: no (re)injection, no fresh + // AdviseVisualTreeChange, no new message-only window — just a bounds/ + // property refresh dispatched over the connection that is already open. + // Returns false if the refresh failed (e.g. the pipe broke); callers + // should treat that the same as is_alive() having gone false and give + // up on this connection (via the registry) rather than keep retrying it. + // `providerOption` carries provider-specific filtering/configuration + // through the generic connection path. Built-in providers ignore it; + // plugin connections forward it as element_class_filter so persistent + // and one-shot plugin collection have identical behavior. + virtual bool get_tree(Element& root, bool fastProperties, + const std::string& providerOption = {}) = 0; + + // Non-blocking: returns whatever incremental Add/Remove change + // notifications have arrived since the last call, without triggering a + // full tree walk. Empty until the connection's provider implements + // incremental push (see ConnectionEvent below) - safe to call, and + // safe to ignore the result, even for a provider that never populates + // it, since a caller can always fall back to get_tree() for a full + // refresh. + virtual std::vector poll_events() = 0; + + // False once the underlying connection is known to be gone (pipe + // closed/broken, target process exited, etc). A caller holding a + // reference via the registry should release it once this goes false; + // the registry will not hand out a dead connection to a new acquirer. + virtual bool is_alive() const = 0; +}; + +// One incremental structural change reported by a provider that supports +// push notifications (see IFrameworkConnection::poll_events). Mirrors the +// shape of the Add/Remove mutations XAML diagnostics' own +// IVisualTreeServiceCallback::OnVisualTreeChange already reports — this +// struct is the framework-agnostic version of that, so `watch`'s loop and +// MCP sessions don't need to know which underlying API produced it. +struct ConnectionEvent { + enum class Mutation { added, removed }; + Mutation mutation = Mutation::added; + + // Native handle identity (e.g. XAML InstanceHandle) — the same value + // Element::nativeHandle carries once grafted, so this can be matched + // directly against an already-built tree without heuristics. + uintptr_t handle = 0; + uintptr_t parentHandle = 0; + int childIndex = 0; + + // Present for `added`; empty for `removed` (nothing more than the + // handle is needed to remove a node that already exists in the tree). + std::string elementType; + std::string name; +}; + +} // namespace lvt diff --git a/src/providers/uia_actions.cpp b/src/providers/uia_actions.cpp index ee25f47..2d10285 100644 --- a/src/providers/uia_actions.cpp +++ b/src/providers/uia_actions.cpp @@ -59,6 +59,22 @@ HRESULT create_automation(const UiaOptions& options, IUIAutomation** out) { return S_OK; } +std::optional build_tree_for_action(HWND hwnd, const UiaOptions& options, + UiaConnection* connection) { + // The live Invoke/Value/SendInput path below still uses its own automation + // object because that code is not modelled as an IFrameworkConnection. The + // surrounding tree reads, however, are ordinary UIA walks and can reuse a + // session/watch connection when one exists. + if (connection && connection->is_alive()) { + Element tree; + if (connection->get_tree_with_options(tree, options)) + return tree; + } + + UiaProvider provider; + return provider.build(hwnd, options); +} + // Compare a RuntimeId SAFEARRAY against the components we are looking for. bool runtime_id_matches(SAFEARRAY* array, const std::vector& runtimeId) { if (!array) @@ -581,7 +597,8 @@ const char* action_kind_name(ActionKind kind) { } ActionResult perform_action(HWND hwnd, const UiaOptions& options, - const ActionRequest& request) { + const ActionRequest& request, + UiaConnection* connection) { ActionResult result; // Waiting is not an action on a live element: it re-walks until the tree @@ -613,8 +630,7 @@ ActionResult perform_action(HWND hwnd, const UiaOptions& options, return result; } - UiaProvider provider; - if (auto tree = provider.build(hwnd, options)) { + if (auto tree = build_tree_for_action(hwnd, options, connection)) { assign_element_ids(*tree); assign_element_keys(*tree); const Element* found = find_element_by_ref(*tree, request.elementRef); @@ -666,8 +682,7 @@ ActionResult perform_action(HWND hwnd, const UiaOptions& options, Element target; std::vector runtimeId; if (needsElement) { - UiaProvider provider; - auto tree = provider.build(hwnd, options); + auto tree = build_tree_for_action(hwnd, options, connection); if (!tree) { result.message = "could not read the UI Automation tree for this window"; return result; @@ -935,8 +950,7 @@ ActionResult perform_action(HWND hwnd, const UiaOptions& options, // effect without a second walk. Re-found by RuntimeId because ids shift // whenever the tree changes, which an action may well have caused. if (needsElement) { - UiaProvider provider; - if (auto after = provider.build(hwnd, options)) { + if (auto after = build_tree_for_action(hwnd, options, connection)) { lvt::assign_element_ids(*after); lvt::assign_element_keys(*after); const auto it = target.properties.find("RuntimeId"); diff --git a/src/providers/uia_actions.h b/src/providers/uia_actions.h index e701cdf..93e7292 100644 --- a/src/providers/uia_actions.h +++ b/src/providers/uia_actions.h @@ -69,15 +69,18 @@ struct ActionResult { bool hasElement = false; }; -// Resolve the reference against a fresh UIA walk of the target and perform the -// action. Everything happens on the provider's MTA thread. +// Resolve the reference against a UIA walk of the target and perform the +// action. When `connection` is supplied, the resolve/readback/wait walks reuse +// that persistent client; the live-element action itself still runs on the +// action path's own automation object. Everything happens on an MTA thread. // // Pattern-based paths are preferred throughout: they do not steal focus, do not // move the cursor, and work when the window is not on top. SendInput is the // fallback for elements that expose no suitable pattern, and it requires // bringing the window forward. ActionResult perform_action(HWND hwnd, const UiaOptions& options, - const ActionRequest& request); + const ActionRequest& request, + UiaConnection* connection = nullptr); // Parse an action name as accepted on the command line. bool parse_action_kind(const std::string& name, ActionKind& out); diff --git a/src/providers/uia_provider.cpp b/src/providers/uia_provider.cpp index 5047674..0b62ad7 100644 --- a/src/providers/uia_provider.cpp +++ b/src/providers/uia_provider.cpp @@ -11,6 +11,7 @@ #include #include #include +#include #include #include #include @@ -20,6 +21,8 @@ namespace lvt { namespace { using clock_type = std::chrono::steady_clock; +static constexpr DWORD kUiaDefaultTransactionTimeoutMs = 20000; +static constexpr DWORD kUiaConnectionTimeoutCapMs = 2000; std::string narrow(BSTR bstr) { if (!bstr) @@ -359,6 +362,32 @@ std::vector resolve_properties(const UiaOptions& options, return properties; } +void apply_automation_timeouts(IUIAutomation* automation, const UiaOptions& options) { + if (!automation) + return; + + // A fresh one-shot client can get "UIA's default 20s transaction timeout" + // by simply never calling put_TransactionTimeout at all. A REUSED client + // cannot: once one walk tightened or widened the timeout, that value sticks + // to the same automation object for the next walk unless lvt actively puts + // it back. Making the effective defaults explicit here keeps the persistent + // path behaviorally aligned with the one-shot path instead of letting one + // call's timeout leak into the next. + const DWORD transactionTimeoutMs = options.timeoutMs > 0 + ? static_cast(options.timeoutMs) + : kUiaDefaultTransactionTimeoutMs; + + wil::com_ptr automationRef; + automationRef = automation; + if (auto automation2 = automationRef.try_query()) { + LOG_IF_FAILED(automation2->put_TransactionTimeout(transactionTimeoutMs)); + // Connecting should never need the whole budget; cap it so an + // unreachable provider fails fast instead of consuming the deadline. + LOG_IF_FAILED(automation2->put_ConnectionTimeout( + (std::min)(transactionTimeoutMs, kUiaConnectionTimeoutCapMs))); + } +} + HRESULT create_automation(const UiaOptions& options, IUIAutomation** out) { // CUIAutomation8 gives the IUIAutomation6 generation, which supports // per-call timeouts and connection-recovery behaviour. Fall back to the @@ -377,30 +406,15 @@ HRESULT create_automation(const UiaOptions& options, IUIAutomation** out) { // elements later only limits the cheap in-process walk of the materialised // cache. Driving the transaction timeout from --uia-timeout is what makes // that flag mean what it says. - // - // 0 means "no lvt-imposed deadline", which is expressed by leaving UIA's - // own default (20s) in place rather than passing 0 through: the meaning of - // 0 for put_TransactionTimeout is not documented. - if (options.timeoutMs > 0) { - if (auto automation2 = automation.try_query()) { - LOG_IF_FAILED(automation2->put_TransactionTimeout( - static_cast(options.timeoutMs))); - // Connecting should never need the whole budget; cap it so an - // unreachable provider fails fast instead of consuming the deadline. - LOG_IF_FAILED(automation2->put_ConnectionTimeout( - static_cast((std::min)(options.timeoutMs, 2000)))); - } - } + apply_automation_timeouts(automation.get(), options); *out = automation.detach(); return S_OK; } -HRESULT build_tree_on_mta(HWND hwnd, const UiaOptions& options, - Element& out, bool& truncated) { - wil::com_ptr automation; - RETURN_IF_FAILED(create_automation(options, &automation)); - +HRESULT build_tree_with_automation(IUIAutomation* automation, HWND hwnd, + const UiaOptions& options, + Element& out, bool& truncated) { wil::com_ptr root; RETURN_IF_FAILED(automation->ElementFromHandle(hwnd, &root)); RETURN_HR_IF_NULL(E_FAIL, root.get()); @@ -409,7 +423,7 @@ HRESULT build_tree_on_mta(HWND hwnd, const UiaOptions& options, auto properties = resolve_properties(options, &requested); wil::com_ptr request; - RETURN_IF_FAILED(make_cache_request(automation.get(), options, properties, &request)); + RETURN_IF_FAILED(make_cache_request(automation, options, properties, &request)); wil::com_ptr cachedRoot; RETURN_IF_FAILED(root->BuildUpdatedCache(request.get(), &cachedRoot)); @@ -437,9 +451,22 @@ HRESULT build_tree_on_mta(HWND hwnd, const UiaOptions& options, return S_OK; } +HRESULT build_tree_on_mta(HWND hwnd, const UiaOptions& options, + Element& out, bool& truncated) { + wil::com_ptr automation; + RETURN_IF_FAILED(create_automation(options, &automation)); + return build_tree_with_automation(automation.get(), hwnd, options, out, truncated); +} + // UIA clients belong in an MTA. screenshot.cpp initializes an STA on the calling // thread, and a thread cannot be in both, so all UIA work is marshalled onto a -// dedicated MTA thread. This also serialises access to the client. +// dedicated MTA thread. +// +// One-shot callers still create and release their COM objects inside `fn`. +// UiaConnection is the deliberate exception: it creates IUIAutomation once on +// one run_on_mta call and reuses that pointer on later run_on_mta calls. That +// is still the SAME apartment because COINIT_MULTITHREADED joins the single, +// process-wide MTA rather than inventing a per-thread apartment. // // The body is wrapped in CATCH_RETURN because an exception escaping a thread // function calls std::terminate: building the Element tree allocates freely, so @@ -452,8 +479,6 @@ HRESULT run_on_mta(Fn&& fn) { result = [&]() -> HRESULT { try { auto uninit = wil::CoInitializeEx_failfast(COINIT_MULTITHREADED); - // COM objects are created and released inside fn, so they are - // gone before this thread leaves the apartment. return fn(); } CATCH_RETURN(); @@ -529,4 +554,132 @@ std::optional UiaProvider::build(HWND hwnd, const UiaOptions& options, return root; } +struct UiaConnection::State { + std::mutex mutex; + wil::com_ptr automation; + CO_MTA_USAGE_COOKIE mtaCookie = nullptr; +}; + +UiaConnection::UiaConnection(HWND hwnd) + : m_hwnd(hwnd), m_state(std::make_unique()) { +} + +std::shared_ptr UiaConnection::connect(HWND hwnd) { + auto connection = std::shared_ptr(new UiaConnection(hwnd)); + const HRESULT hr = run_on_mta([&]() -> HRESULT { + CO_MTA_USAGE_COOKIE cookie = nullptr; + RETURN_IF_FAILED(CoIncrementMTAUsage(&cookie)); + + wil::com_ptr automation; + UiaOptions connectOptions; + // connect() is not itself a tree walk; the very next get_tree call + // re-applies that walk's own timeout anyway, so create the client with + // the default-effective setting rather than baking in an arbitrary + // caller-specific budget up front. + connectOptions.timeoutMs = 0; + const HRESULT createHr = create_automation(connectOptions, &automation); + if (FAILED(createHr)) { + CoDecrementMTAUsage(cookie); + RETURN_HR(createHr); + } + + std::lock_guard lock(connection->m_state->mutex); + connection->m_state->automation = std::move(automation); + connection->m_state->mtaCookie = cookie; + return S_OK; + }); + + if (FAILED(hr)) { + LOG_IF_FAILED(hr); + return {}; + } + return connection; +} + +UiaConnection::~UiaConnection() { + wil::com_ptr automation; + CO_MTA_USAGE_COOKIE cookie = nullptr; + { + std::lock_guard lock(m_state->mutex); + automation = std::move(m_state->automation); + cookie = m_state->mtaCookie; + m_state->mtaCookie = nullptr; + } + if (automation) { + // connect() took an MTA-usage cookie specifically so the process-wide + // MTA stays alive even between our short-lived worker threads. Release + // the automation object before dropping that cookie, otherwise the last + // CoDecrementMTAUsage could tear the apartment down while this pointer + // still belongs to it. + (void)run_on_mta([&]() -> HRESULT { + automation.reset(); + return S_OK; + }); + } + if (cookie) + CoDecrementMTAUsage(cookie); +} + +bool UiaConnection::get_tree(Element& root, bool fastProperties, + const std::string& /*providerOption*/) { + (void)fastProperties; + return get_tree_with_options(root, UiaOptions{}); +} + +bool UiaConnection::get_tree_with_options(Element& root, const UiaOptions& options, + bool* truncated) { + if (truncated) + *truncated = false; + + // One live walk at a time against the reused client. Parallel reads buy + // nothing here — the target's UI thread still answers them serially — and + // holding the lock across the whole run also keeps teardown from releasing + // the shared automation object while this call is still using it. Read + // only the raw pointer here; even com_ptr's AddRef would execute on the + // caller's thread, which may be STA or not in COM at all. + std::unique_lock lock(m_state->mutex); + IUIAutomation* automation = m_state->automation.get(); + if (!automation) + return false; + + Element built; + bool wasTruncated = false; + const HWND hwnd = m_hwnd; + const HRESULT hr = run_on_mta([automation, hwnd, &options, &built, &wasTruncated]() -> HRESULT { + // connect() created this automation object on one thread that had + // joined the process-wide MTA. Every get_tree_with_options call joins + // that same single MTA before touching it, so the raw pointer stays in + // one apartment throughout its whole life and needs no marshal/proxy. + // connect() also took an MTA-usage cookie, so that apartment survives + // between our short-lived worker threads instead of being torn down + // when the creating thread exits. + apply_automation_timeouts(automation, options); + return build_tree_with_automation(automation, hwnd, options, built, wasTruncated); + }); + lock.unlock(); + + if (truncated) + *truncated = wasTruncated; + + if (FAILED(hr)) { + LOG_IF_FAILED(hr); + return false; + } + if (wasTruncated) { + fprintf(stderr, "lvt: UIA walk hit the %d ms deadline; tree is partial\n", + options.timeoutMs); + } + root = std::move(built); + return true; +} + +std::vector UiaConnection::poll_events() { + return {}; +} + +bool UiaConnection::is_alive() const { + std::lock_guard lock(m_state->mutex); + return m_state->automation != nullptr; +} + } // namespace lvt diff --git a/src/providers/uia_provider.h b/src/providers/uia_provider.h index ef6b34b..e642ba6 100644 --- a/src/providers/uia_provider.h +++ b/src/providers/uia_provider.h @@ -1,7 +1,9 @@ #pragma once +#include "framework_connection.h" #include "provider.h" #include "uia_props.h" +#include #include #include #include @@ -50,6 +52,31 @@ class UiaProvider : public IProvider { std::optional build(HWND hwnd, const UiaOptions& options, bool* truncated = nullptr); }; +// Reusable UIA client for callers that read the same target repeatedly (watch, +// MCP sessions). Unlike the visual-tree connections this never injects into the +// target; it simply amortizes CoCreateInstance(CUIAutomation[8]) across many +// walks while keeping each walk's own view/property/timeout options. +class UiaConnection : public IFrameworkConnection { +public: + static std::shared_ptr connect(HWND hwnd); + ~UiaConnection() override; + + bool get_tree(Element& root, bool fastProperties, + const std::string& providerOption = {}) override; + bool get_tree_with_options(Element& root, const UiaOptions& options, + bool* truncated = nullptr); + std::vector poll_events() override; + bool is_alive() const override; + +private: + explicit UiaConnection(HWND hwnd); + + struct State; + + HWND m_hwnd = nullptr; + std::unique_ptr m_state; +}; + // Format a UIA RuntimeId as the dotted string lvt emits, e.g. "42.1234.0". std::string format_runtime_id(const std::vector& runtimeId); diff --git a/src/providers/winui3_provider.cpp b/src/providers/winui3_provider.cpp index acf49d2..c779495 100644 --- a/src/providers/winui3_provider.cpp +++ b/src/providers/winui3_provider.cpp @@ -49,24 +49,34 @@ static std::wstring find_framework_udk(DWORD pid) { return {}; } -void WinUI3Provider::enrich(Element& root, HWND hwnd, DWORD pid) { +void WinUI3Provider::enrich(Element& root, HWND hwnd, DWORD pid, bool fastProperties) { label_winui3_windows(root); // Try XAML diagnostics injection for the full visual tree // WinUI3 registers "WinUIVisualDiagConnection" endpoints - // InitializeXamlDiagnosticsEx can be loaded from FrameworkUdk.dll (WinAppSDK) - // or from Windows.UI.Xaml.dll (System32) + // InitializeXamlDiagnosticsEx for WinUI 3 is exported by the Windows App + // SDK's FrameworkUdk. Microsoft.UI.Xaml.dll can also be the WinUI 2 + // controls library hosted by system XAML, so falling back to + // Windows.UI.Xaml.dll here would use the wrong endpoint flavor. std::wstring frameworkUdk = find_framework_udk(pid); - std::wstring initDll; - if (!frameworkUdk.empty()) { - initDll = frameworkUdk; - } else { - // Fall back to system XAML - initDll = L"Windows.UI.Xaml.dll"; - } + if (frameworkUdk.empty()) + return; + + inject_and_collect_xaml_tree(root, hwnd, pid, L"", frameworkUdk, "winui3", + L"WinUIVisualDiagConnection", fastProperties); +} - inject_and_collect_xaml_tree(root, hwnd, pid, L"", initDll, "winui3", - L"WinUIVisualDiagConnection"); +std::shared_ptr WinUI3Provider::open_connection(HWND hwnd, DWORD pid) { + std::wstring frameworkUdk = find_framework_udk(pid); + if (frameworkUdk.empty()) + return nullptr; + return make_xaml_diag_connection(hwnd, pid, L"", frameworkUdk, "winui3", + L"WinUIVisualDiagConnection"); +} + +void WinUI3Provider::enrich_with_connection(Element& root, IFrameworkConnection& connection, bool fastProperties) { + label_winui3_windows(root); + connection.get_tree(root, fastProperties); } } // namespace lvt diff --git a/src/providers/winui3_provider.h b/src/providers/winui3_provider.h index ae14c72..ca3a283 100644 --- a/src/providers/winui3_provider.h +++ b/src/providers/winui3_provider.h @@ -1,5 +1,7 @@ #pragma once #include "provider.h" +#include "framework_connection.h" +#include namespace lvt { @@ -8,7 +10,19 @@ class WinUI3Provider : public IProvider { // Enrich the element tree with WinUI 3 visual tree information. // Injects lvt_tap.dll via InitializeXamlDiagnosticsEx targeting // Microsoft.UI.Xaml.dll in the target process. - void enrich(Element& root, HWND hwnd, DWORD pid); + // `fastProperties` — see xaml_diag_common.h's inject_and_collect_xaml_tree. + void enrich(Element& root, HWND hwnd, DWORD pid, bool fastProperties = false); + + // Establishes a persistent connection (see framework_connection.h) for + // reuse across many refreshes, e.g. by watch's loop or an MCP session — + // see connection_registry.h. Returns nullptr if the connection could + // not be established. + std::shared_ptr open_connection(HWND hwnd, DWORD pid); + + // Refreshes `root` over the already-open `connection` instead of + // re-injecting - the bridge-matching/grafting is unchanged, it just + // reads from an existing connection rather than a fresh one-shot inject. + void enrich_with_connection(Element& root, IFrameworkConnection& connection, bool fastProperties = false); }; } // namespace lvt diff --git a/src/providers/xaml_diag_common.cpp b/src/providers/xaml_diag_common.cpp index 36f1660..70821a0 100644 --- a/src/providers/xaml_diag_common.cpp +++ b/src/providers/xaml_diag_common.cpp @@ -2,6 +2,7 @@ // Used by both XamlProvider and WinUI3Provider. #include "xaml_diag_common.h" +#include "framework_connection.h" #include "../tap/tap_clsid.h" #include "../debug.h" #include "../bounds_util.h" @@ -17,6 +18,9 @@ #include #include #include +#include +#include +#include #include #include @@ -26,6 +30,11 @@ using json = nlohmann::json; namespace lvt { +// How long to wait for the TAP DLL to finish walking the target's tree and +// connect back with the result — see its use below for the measured, +// evidence-based reason this is 60s, not the 15s it used to be. +static constexpr DWORD kXamlCollectionTimeoutMs = 60000; + static std::wstring make_pipe_name() { GUID guid; CoCreateGuid(&guid); @@ -118,7 +127,8 @@ static std::string sanitize(const std::string& s) { // Collect all DesktopChildSiteBridge elements in tree order static void collect_bridges(Element& el, std::vector& bridges) { - if (el.className == "Microsoft.UI.Content.DesktopChildSiteBridge") { + if (el.className == "Microsoft.UI.Content.DesktopChildSiteBridge" || + el.className == "Windows.UI.Composition.DesktopWindowContentBridge") { bridges.push_back(&el); } for (auto& child : el.children) { @@ -140,6 +150,13 @@ static void graft_json_node(const json& j, Element& parent, const std::string& f parent.children.emplace_back(); Element& el = parent.children.back(); el.framework = framework; + // IXamlDiagnostics supplies an InstanceHandle for every live XAML + // object. Preserve it as the provider-native identity: watch_diff can + // reconcile directly by handle, compact element keys can avoid + // repeating a multi-kilobyte ancestor path, and future property-edit + // commands can address the exact object expected by + // IVisualTreeService::SetProperty. + el.nativeHandle = static_cast(j.value("handle", 0ULL)); el.className = sanitize(j.value("type", "")); // x:Name is a developer identifier, not user-visible text — store as property @@ -208,20 +225,389 @@ static void graft_json_node(const json& j, Element& parent, const std::string& f } -bool inject_and_collect_xaml_tree( - Element& root, - HWND /*hwnd*/, - DWORD pid, +// Grafts an already-parsed XAML tree JSON payload (as produced by the TAP +// DLL's SerializeAndSend, or a duplex connection's GET_TREE response - same +// shape either way) into `root`. Split out from the connect+collect flow so +// both the one-shot path (inject_and_collect_xaml_tree) and a reused, +// persistent connection's repeated get_tree() calls (see XamlDiagConnection +// below) share exactly one implementation of this logic instead of two +// copies that could drift apart. +static void graft_xaml_tree_json(const json& treeJson, Element& root, const std::string& frameworkLabel) { + // Graft XAML elements into corresponding bridge windows. + // Each DesktopWindowXamlSource root maps 1:1 to a DesktopChildSiteBridge HWND. + // We match by best-fit size: the XAML root's first child dimensions are compared + // against each bridge's window bounds to find the most compatible match. + // This is more robust than order-based matching since Win32 HWND enumeration order + // may differ from XAML tree root enumeration order. + if (treeJson.is_array()) { + std::set usedBridges; + + // InitializeXamlDiagnosticsEx targets a whole process, not a single + // HWND: when several top-level windows of the same app share one + // process (multiple Notepad or File Explorer windows are common — + // and were exactly how this was found), every window's XAML content + // comes back in one combined stream, and more than one + // DesktopWindowXamlSource root here means there is real ambiguity + // about which root belongs to *this* window's bridges. Only then is + // it worth rejecting a low-confidence match (see the tolerance + // check below): with a single root there is nothing else it could + // be, no matter how poorly its content size happens to match, so + // the legacy graft-under-root fallback stays exactly as + // conservative as before for the overwhelmingly common case. + size_t xamlSourceRootCount = 0; + for (auto& node : treeJson) { + if (sanitize(node.value("type", "")).find("DesktopWindowXamlSource") != std::string::npos) + xamlSourceRootCount++; + } + const bool multipleRootsAmbiguous = xamlSourceRootCount > 1; + + for (auto& node : treeJson) { + std::string typeName = sanitize(node.value("type", "")); + if (typeName.find("DesktopWindowXamlSource") == std::string::npos) { + graft_json_node(node, root, frameworkLabel, root.bounds.x, root.bounds.y); + continue; + } + + // Get the XAML content dimensions from descendants with bounds + double contentW = 0, contentH = 0; + std::function findContentSize = [&](const json& n) { + double w = n.value("width", 0.0); + double h = n.value("height", 0.0); + if (w > contentW) contentW = w; + if (h > contentH) contentH = h; + if (n.contains("children") && n["children"].is_array()) { + for (auto& child : n["children"]) { + findContentSize(child); + if (contentW > 0 && contentH > 0) return; // found, stop early + } + } + }; + if (node.contains("children") && node["children"].is_array()) { + for (auto& child : node["children"]) { + findContentSize(child); + } + } + + // Skip strict bridge matching for roots with no measurable + // content when there is no ambiguity to resolve: fall back to + // the legacy graft-under-root behavior, same as before this + // window's-worth-of-contamination fix existed, since with a + // single root there is no sibling window's content it could be + // confused with — dropping it here would only lose real + // structure (e.g. a not-yet-laid-out tab strip) for no safety + // benefit. Only drop outright when multiple roots are actually + // competing for the same bridges. + if (contentW <= 0 && contentH <= 0) { + if (!multipleRootsAmbiguous) + graft_json_node(node, root, frameworkLabel, root.bounds.x, root.bounds.y); + continue; + } + + if (g_debug) { + fprintf(stderr, "lvt: XAML root contentW=%.0f contentH=%.0f\n", + contentW, contentH); + } + + // Find the best-matching bridge by size similarity + std::vector bridges; + collect_bridges(root, bridges); + int bestIdx = -1; + double bestScore = 1e18; + for (size_t i = 0; i < bridges.size(); i++) { + auto identity = bridge_identity(*bridges[i]); + if (!identity.empty() && usedBridges.count(identity)) continue; + double bw = bridges[i]->bounds.width; + double bh = bridges[i]->bounds.height; + // Score: prefer bridges whose dimensions best accommodate the content + double wDiff = std::abs(bw - contentW); + double hDiff = std::abs(bh - contentH); + double score = wDiff + hDiff; + if (score < bestScore) { + bestScore = score; + bestIdx = static_cast(i); + } + } + + // Reject a "best" match that still isn't actually close — but + // only when multiple roots are genuinely competing (see + // multipleRootsAmbiguous above). Without a tolerance, the loop + // above always finds *some* bridge — including bridges + // belonging to *this* window that just happen to be the + // least-bad leftover for a completely different window's root — + // silently grafting one window's content (and its Text/bounds) + // onto a sibling window's tree. Requiring the winning candidate + // to be within a size-relative tolerance of its own bridge is + // what tells "this genuinely is that bridge's content" apart + // from "this is a foreign root that merely didn't lose by + // much"; anything else is dropped instead of misattached. When + // there is only one root, skip this check entirely and keep the + // legacy behavior of accepting whatever the single bridge is, + // however poor the size match — there is no other candidate it + // could rightfully belong to. + if (multipleRootsAmbiguous && bestIdx >= 0) { + double bw = bridges[bestIdx]->bounds.width; + double bh = bridges[bestIdx]->bounds.height; + constexpr double kMinAbsoluteToleragePx = 40.0; + constexpr double kRelativeTolerance = 0.25; + double tolerance = std::max(kMinAbsoluteToleragePx, (bw + bh) * kRelativeTolerance); + if (bestScore > tolerance) { + if (g_debug) { + fprintf(stderr, "lvt: rejecting XAML root match (score=%.0f > tolerance=%.0f); " + "likely belongs to a different window sharing this process\n", + bestScore, tolerance); + } + bestIdx = -1; + } + } + + if (bestIdx >= 0) { + auto* bridge = bridges[bestIdx]; + auto identity = bridge_identity(*bridge); + if (!identity.empty()) + usedBridges.insert(identity); + double baseX = bridge->bounds.x; + double baseY = bridge->bounds.y; + graft_json_node(node, *bridge, frameworkLabel, baseX, baseY); + } else if (!multipleRootsAmbiguous) { + // No DesktopChildSiteBridge matched at all — including the + // case where this window has none to begin with (classic + // system XAML doesn't use the WinUI3 Islands bridge model, + // so `bridges` is always empty there). With only one root in + // play there is no sibling window's content to confuse this + // with, so fall back to the legacy graft-under-root + // behavior exactly as before this fix. + graft_json_node(node, root, frameworkLabel, root.bounds.x, root.bounds.y); + } + // Otherwise: multiple roots were genuinely competing and none + // matched confidently enough — drop this root rather than + // misattach it to the wrong window. + } + } else if (treeJson.is_object()) { + graft_json_node(treeJson, root, frameworkLabel); + } +} + +// Buffered line I/O over the persistent duplex pipe lvt.exe creates and the +// TAP DLL connects back to. Every read/write must pass an OVERLAPPED +// structure - the pipe handle is created with FILE_FLAG_OVERLAPPED (needed +// so the initial "wait for the TAP DLL to connect" step can be bounded by a +// timeout) and mixing overlapped and non-overlapped calls on the same +// handle is unsupported. +class DuplexPipeLineIO { +public: + explicit DuplexPipeLineIO(HANDLE pipe) : m_pipe(pipe) { + m_readEvent.reset(CreateEventW(nullptr, TRUE, FALSE, nullptr)); + m_writeEvent.reset(CreateEventW(nullptr, TRUE, FALSE, nullptr)); + } + + // Reads one '\n'-terminated line (returned without the newline). + // Returns false on timeout, a broken pipe, or EOF - all of which mean + // this connection is no longer usable. + bool read_line(DWORD timeoutMs, std::string& outLine) { + for (;;) { + auto nl = m_buffer.find('\n'); + if (nl != std::string::npos) { + outLine = m_buffer.substr(0, nl); + m_buffer.erase(0, nl + 1); + if (!outLine.empty() && outLine.back() == '\r') outLine.pop_back(); + return true; + } + char chunk[8192]; + DWORD bytesRead = 0; + OVERLAPPED ov = {}; + ResetEvent(m_readEvent.get()); + ov.hEvent = m_readEvent.get(); + BOOL ok = ReadFile(m_pipe, chunk, sizeof(chunk), &bytesRead, &ov); + if (!ok) { + DWORD err = GetLastError(); + if (err == ERROR_IO_PENDING) { + if (WaitForSingleObject(m_readEvent.get(), timeoutMs) != WAIT_OBJECT_0) { + CancelIo(m_pipe); + return false; + } + if (!GetOverlappedResult(m_pipe, &ov, &bytesRead, FALSE) || bytesRead == 0) + return false; + } else { + return false; + } + } else if (bytesRead == 0) { + return false; + } + m_buffer.append(chunk, bytesRead); + } + } + + // Writes one line (message + '\n'). lvt.exe only ever sends short text + // commands, so a generous fixed default timeout is plenty. + bool write_line(const std::string& line, DWORD timeoutMs = 5000) { + std::string withNewline = line + "\n"; + OVERLAPPED ov = {}; + ResetEvent(m_writeEvent.get()); + ov.hEvent = m_writeEvent.get(); + DWORD written = 0; + BOOL ok = WriteFile(m_pipe, withNewline.data(), + static_cast(withNewline.size()), &written, &ov); + if (!ok) { + DWORD err = GetLastError(); + if (err != ERROR_IO_PENDING) return false; + if (WaitForSingleObject(m_writeEvent.get(), timeoutMs) != WAIT_OBJECT_0) { + CancelIo(m_pipe); + return false; + } + if (!GetOverlappedResult(m_pipe, &ov, &written, FALSE)) return false; + } + return true; + } + +private: + HANDLE m_pipe; + wil::unique_event m_readEvent; + wil::unique_event m_writeEvent; + std::string m_buffer; +}; + +// A live, persistent connection to one XAML/WinUI3 diagnostics session in a +// target process - the concrete IFrameworkConnection this file provides. +// See framework_connection.h for why this exists: InitializeXamlDiagnosticsEx +// and AdviseVisualTreeChange are meant to be called ONCE per session, not +// re-run from scratch on every tree refresh (the old design, and the +// confirmed source of an unbounded per-tick resource leak in the TAP DLL - +// one message-only window created and never destroyed per refresh). +// +// connect() performs the injection exactly once; get_tree() then reuses the +// same pipe for as many refreshes as the caller needs, and the destructor +// sends a clean DISCONNECT so the TAP DLL's own teardown +// (UnadviseVisualTreeChange, DestroyWindow, COM release - see lvt_tap.cpp's +// CleanupUIResources) runs exactly once, when this connection actually ends, +// instead of never running at all. +class XamlDiagConnection : public IFrameworkConnection { +public: + static std::shared_ptr connect( + HWND hwnd, DWORD pid, + const std::wstring& xamlDiagDll, + const std::wstring& initDllPath, + std::string frameworkLabel, + const std::wstring& connPrefix); + + ~XamlDiagConnection() override { + if (m_alive && m_io) { + // Best-effort: tell the TAP DLL we're done so it runs its clean + // teardown instead of just noticing a broken pipe later. A + // short timeout is fine - we are tearing down either way. + m_io->write_line("DISCONNECT", 2000); + } + } + + bool get_tree(Element& root, bool fastProperties, + const std::string& /*providerOption*/ = {}) override { + if (!m_alive) return false; + std::string cmd = fastProperties ? "GET_TREE FAST" : "GET_TREE"; + if (!m_io->write_line(cmd)) { + m_alive = false; + return false; + } + // A pushed CHANGE event (see lvt_tap.cpp's OnVisualTreeChange/ + // PushChangeEvent) can arrive on this same stream at any time, + // interleaved with the response to this specific request - drain + // and queue any of those (they start with '{') before the actual + // tree response (a JSON array, starts with '[') turns up. + for (;;) { + std::string line; + if (!m_io->read_line(kXamlCollectionTimeoutMs, line)) { + fprintf(stderr, "lvt: %s: no response from TAP DLL (timeout or broken connection)\n", + m_frameworkLabel.c_str()); + m_alive = false; + return false; + } + if (!line.empty() && line[0] == '{') { + queue_change_event(line); + continue; + } + json treeJson; + try { + treeJson = json::parse(line); + } catch (const json::parse_error& e) { + fprintf(stderr, "lvt: failed to parse XAML tree JSON: %s\n", e.what()); + return false; + } + graft_xaml_tree_json(treeJson, root, m_frameworkLabel); + return true; + } + } + + std::vector poll_events() override { + std::lock_guard lock(m_eventsMutex); + return std::move(m_pendingEvents); + } + + bool is_alive() const override { return m_alive; } + +private: + XamlDiagConnection(wil::unique_hfile pipe, std::unique_ptr io, + std::string frameworkLabel) + : m_pipe(std::move(pipe)), m_io(std::move(io)), m_frameworkLabel(std::move(frameworkLabel)) { + m_alive = true; + } + + // Parses one {"type":"CHANGE",...} line (see lvt_tap.cpp's + // PushChangeEvent for the exact shape) and queues it for poll_events(). + // Malformed/unrecognized lines are dropped rather than treated as an + // error - a push event is best-effort by design (see PushChangeEvent's + // comment), and get_tree()'s own response is never affected by this. + void queue_change_event(const std::string& line) { + json ev; + try { + ev = json::parse(line); + } catch (const json::parse_error&) { + return; + } + if (ev.value("type", "") != "CHANGE") + return; + + ConnectionEvent ce; + ce.mutation = (ev.value("mutation", "") == "remove") + ? ConnectionEvent::Mutation::removed + : ConnectionEvent::Mutation::added; + ce.handle = static_cast(ev.value("handle", 0ULL)); + ce.parentHandle = static_cast(ev.value("parent", 0ULL)); + ce.childIndex = ev.value("childIndex", 0); + ce.elementType = ev.value("elementType", ""); + ce.name = ev.value("name", ""); + + std::lock_guard lock(m_eventsMutex); + // A caller that never calls poll_events() at all (e.g. a one-shot + // CLI command that happened to acquire a connection but never asked + // for events) must not turn this into an unbounded leak of its own. + // Capping and dropping the oldest is safe: nothing currently + // depends on poll_events() for correctness (get_tree() is always a + // complete, independent refresh), only as an optional efficiency + // gain for a caller that does drain regularly. + constexpr size_t kMaxPendingEvents = 10000; + if (m_pendingEvents.size() >= kMaxPendingEvents) + m_pendingEvents.erase(m_pendingEvents.begin()); + m_pendingEvents.push_back(std::move(ce)); + } + + wil::unique_hfile m_pipe; + std::unique_ptr m_io; + std::string m_frameworkLabel; + bool m_alive = false; + std::mutex m_eventsMutex; + std::vector m_pendingEvents; +}; + +std::shared_ptr XamlDiagConnection::connect( + HWND /*hwnd*/, DWORD pid, const std::wstring& xamlDiagDll, const std::wstring& initDllPath, - const std::string& frameworkLabel, + std::string frameworkLabel, const std::wstring& connPrefix) { std::wstring tapDll = tap_dll_path(L"lvt_tap"); if (GetFileAttributesW(tapDll.c_str()) == INVALID_FILE_ATTRIBUTES) { fprintf(stderr, "lvt: TAP DLL not found: %ls\n", tapDll.c_str()); - return false; + return nullptr; } // AppContainer (UWP) processes can't load DLLs from arbitrary paths. @@ -246,15 +632,18 @@ bool inject_and_collect_xaml_tree( wil::unique_hlocal pipeSecurityDescriptor(rawPipeSd); sa.lpSecurityDescriptor = pipeSecurityDescriptor.get(); + // PIPE_ACCESS_DUPLEX (not PIPE_ACCESS_INBOUND): lvt.exe now sends + // GET_TREE/DISCONNECT requests over this same pipe for as long as the + // connection lives, not just receiving one write-once blob. wil::unique_hfile pipe(CreateNamedPipeW( pipeName.c_str(), - PIPE_ACCESS_INBOUND | FILE_FLAG_OVERLAPPED, + PIPE_ACCESS_DUPLEX | FILE_FLAG_OVERLAPPED, PIPE_TYPE_BYTE | PIPE_READMODE_BYTE | PIPE_WAIT, - 1, 0, 1024 * 1024, 10000, &sa)); + 1, 1024 * 1024, 1024 * 1024, 10000, &sa)); if (!pipe) { fprintf(stderr, "lvt: failed to create named pipe (error %lu)\n", GetLastError()); - return false; + return nullptr; } // Load InitializeXamlDiagnosticsEx from the specified DLL. @@ -266,7 +655,7 @@ bool inject_and_collect_xaml_tree( } if (!hXaml) { fprintf(stderr, "lvt: failed to load %ls (error %lu)\n", initDllPath.c_str(), GetLastError()); - return false; + return nullptr; } using FnInit = HRESULT(WINAPI*)(LPCWSTR, DWORD, LPCWSTR, LPCWSTR, CLSID, LPCWSTR); @@ -274,7 +663,7 @@ bool inject_and_collect_xaml_tree( GetProcAddress(hXaml.get(), "InitializeXamlDiagnosticsEx")); if (!pInit) { fprintf(stderr, "lvt: InitializeXamlDiagnosticsEx not found in %ls\n", initDllPath.c_str()); - return false; + return nullptr; } // Try connection endpoint names: prefix + "1", prefix + "2", ... @@ -289,7 +678,21 @@ bool inject_and_collect_xaml_tree( DWORD connectErr = GetLastError(); HRESULT hr = E_FAIL; - for (int i = 0; i < 10; i++) { + // The TAP DLL parses its GetInitializationData() BSTR as "pipe_name" or + // "pipe_name|FAST" (see lvt_tap.cpp's SetSiteImpl) — this only sets the + // connection-wide *default* fast mode now; get_tree() overrides it per + // request (see HandleGetTree in lvt_tap.cpp), so a single persistent + // connection can still mix fast live-tree polls with an occasional full + // request the way the old per-call model did. + std::wstring initData = pipeName; + // Connection identifiers are monotonically allocated by each XAML core + // and can grow well beyond 10 in a long-lived, multi-window process. + // Windows Terminal was observed with no endpoint in slots 1..10 despite + // an active WinUI tree. UWPSpy uses the same 10,000-attempt ceiling, + // citing DXamlCore's own allocation behavior; keep a high finite bound so + // a framework-detection false positive cannot loop forever. + constexpr int kMaxConnectionIdentifiers = 10000; + for (int i = 0; i < kMaxConnectionIdentifiers; i++) { wchar_t endPoint[64]; swprintf_s(endPoint, L"%s%d", connPrefix.c_str(), i + 1); @@ -299,7 +702,7 @@ bool inject_and_collect_xaml_tree( xamlDiagDll.c_str(), tapDll.c_str(), CLSID_LvtTap, - pipeName.c_str()); + initData.c_str()); if (g_debug) fprintf(stderr, "lvt: %ls pid=%lu -> 0x%08lX\n", endPoint, pid, hr); @@ -313,153 +716,111 @@ bool inject_and_collect_xaml_tree( if (FAILED(hr)) { fprintf(stderr, "lvt: InitializeXamlDiagnosticsEx failed (0x%08lX)\n", hr); CancelIo(pipe.get()); - return false; + return nullptr; } if (g_debug) - fprintf(stderr, "lvt: injection succeeded, waiting for XAML tree data...\n"); - - // Wait for the TAP DLL to connect + fprintf(stderr, "lvt: injection succeeded, waiting for TAP DLL to connect...\n"); + + // Wait for the TAP DLL to connect and subscribe. Unlike the old + // one-shot model, this is now a real handshake wait, not a "wait for + // the whole collection" wait: the TAP DLL sends READY as soon as it has + // subscribed (AdviseVisualTreeChange), *before* doing any bounds/ + // property collection (see lvt_tap.cpp's ServeConnection) - the actual + // per-request collection cost (measured live at up to 40+ seconds for a + // large, actively animating tree - see kXamlCollectionTimeoutMs's own + // comment for that measurement) is now bounded by get_tree()'s own + // read_line() timeout instead of this connect step. if (connectErr == ERROR_IO_PENDING) { - DWORD waitResult = WaitForSingleObject(ov.hEvent, 15000); + DWORD waitResult = WaitForSingleObject(ov.hEvent, kXamlCollectionTimeoutMs); if (waitResult != WAIT_OBJECT_0) { fprintf(stderr, "lvt: TAP DLL did not connect (timeout)\n"); CancelIo(pipe.get()); - return false; + return nullptr; } } else if (connectErr != ERROR_PIPE_CONNECTED) { fprintf(stderr, "lvt: ConnectNamedPipe failed (error %lu)\n", connectErr); - return false; + return nullptr; } - // Read all data from pipe (overlapped with timeout) - std::string data; - char buf[4096]; - DWORD bytesRead = 0; - OVERLAPPED readOv = {}; - wil::unique_event readEvent(CreateEventW(nullptr, TRUE, FALSE, nullptr)); - readOv.hEvent = readEvent.get(); - for (;;) { - ResetEvent(readOv.hEvent); - BOOL ok = ReadFile(pipe.get(), 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.get()); - break; - } - if (!GetOverlappedResult(pipe.get(), &readOv, &bytesRead, FALSE) || bytesRead == 0) - break; - } else { - break; - } - } else if (bytesRead == 0) { - break; - } - data.append(buf, bytesRead); + auto io = std::make_unique(pipe.get()); + std::string readyLine; + if (!io->read_line(kXamlCollectionTimeoutMs, readyLine) || readyLine != "READY") { + fprintf(stderr, "lvt: TAP DLL did not send READY (got '%s')\n", readyLine.c_str()); + return nullptr; } if (g_debug) - fprintf(stderr, "lvt: received %zu bytes of XAML tree data\n", data.size()); - - if (data.empty()) { - fprintf(stderr, "lvt: no XAML tree data received from target process\n"); - return false; - } - - json treeJson; - try { - treeJson = json::parse(data); - } catch (const json::parse_error& e) { - fprintf(stderr, "lvt: failed to parse XAML tree JSON: %s\n", e.what()); - return false; - } - - // Graft XAML elements into corresponding bridge windows. - // Each DesktopWindowXamlSource root maps 1:1 to a DesktopChildSiteBridge HWND. - // We match by best-fit size: the XAML root's first child dimensions are compared - // against each bridge's window bounds to find the most compatible match. - // This is more robust than order-based matching since Win32 HWND enumeration order - // may differ from XAML tree root enumeration order. - if (treeJson.is_array()) { - std::set usedBridges; - - for (auto& node : treeJson) { - std::string typeName = sanitize(node.value("type", "")); - if (typeName.find("DesktopWindowXamlSource") == std::string::npos) { - graft_json_node(node, root, frameworkLabel, root.bounds.x, root.bounds.y); - continue; - } - - // Get the XAML content dimensions from descendants with bounds - double contentW = 0, contentH = 0; - std::function findContentSize = [&](const json& n) { - double w = n.value("width", 0.0); - double h = n.value("height", 0.0); - if (w > contentW) contentW = w; - if (h > contentH) contentH = h; - if (n.contains("children") && n["children"].is_array()) { - for (auto& child : n["children"]) { - findContentSize(child); - if (contentW > 0 && contentH > 0) return; // found, stop early - } - } - }; - if (node.contains("children") && node["children"].is_array()) { - for (auto& child : node["children"]) { - findContentSize(child); - } - } + fprintf(stderr, "lvt: TAP DLL connected and ready\n"); - // Skip bridge matching for roots with no measurable content - if (contentW <= 0 && contentH <= 0) { - graft_json_node(node, root, frameworkLabel, root.bounds.x, root.bounds.y); - continue; - } - - if (g_debug) { - fprintf(stderr, "lvt: XAML root contentW=%.0f contentH=%.0f\n", - contentW, contentH); - } + // std::shared_ptr with a private constructor: std::make_shared can't + // call it directly, so construct with new and wrap. + return std::shared_ptr( + new XamlDiagConnection(std::move(pipe), std::move(io), std::move(frameworkLabel))); +} - // Find the best-matching bridge by size similarity - std::vector bridges; - collect_bridges(root, bridges); - int bestIdx = -1; - double bestScore = 1e18; - for (size_t i = 0; i < bridges.size(); i++) { - auto identity = bridge_identity(*bridges[i]); - if (!identity.empty() && usedBridges.count(identity)) continue; - double bw = bridges[i]->bounds.width; - double bh = bridges[i]->bounds.height; - // Score: prefer bridges whose dimensions best accommodate the content - double wDiff = std::abs(bw - contentW); - double hDiff = std::abs(bh - contentH); - double score = wDiff + hDiff; - if (score < bestScore) { - bestScore = score; - bestIdx = static_cast(i); - } - } +// Establishes a persistent XAML/WinUI3 diagnostics connection for reuse +// across many tree refreshes - see framework_connection.h and +// connection_registry.h for how a caller (watch's loop, an MCP session) +// acquires/reuses/releases one instead of re-injecting per refresh. +std::shared_ptr make_xaml_diag_connection( + HWND hwnd, DWORD pid, + const std::wstring& xamlDiagDll, + const std::wstring& initDllPath, + const std::string& frameworkLabel, + const std::wstring& connPrefix) +{ + return XamlDiagConnection::connect(hwnd, pid, xamlDiagDll, initDllPath, frameworkLabel, connPrefix); +} - if (bestIdx >= 0) { - auto* bridge = bridges[bestIdx]; - auto identity = bridge_identity(*bridge); - if (!identity.empty()) - usedBridges.insert(identity); - double baseX = bridge->bounds.x; - double baseY = bridge->bounds.y; - graft_json_node(node, *bridge, frameworkLabel, baseX, baseY); - } else { - graft_json_node(node, root, frameworkLabel, root.bounds.x, root.bounds.y); - } +// Injects and collects the XAML tree, retrying a small, bounded number of +// times if the single attempt genuinely fails (a hard error, not merely +// "this is taking a while" — kXamlCollectionTimeoutMs above already gives a +// slow-but-progressing collection the room it needs, based on measured +// real-world timing, so a failure that gets here is a rarer case: the +// target closing mid-walk, a one-off COM error, and similar). +// +// Deliberately few attempts, with a real gap between them, rather than +// many with a short one: if a previous attempt failed after actually +// starting a walk inside the target (as opposed to failing before that, +// e.g. at InitializeXamlDiagnosticsEx itself), the TAP DLL's own worker +// thread for that attempt keeps running in the target to completion +// regardless of what lvt.exe decides to do — there is no way to cancel it +// from here. Retrying too eagerly would start a *second*, fully +// independent walk competing with that still-running one for the same +// target UI thread via SendMessage, which can only make a target that is +// already struggling to keep up slower still, not faster — the opposite +// of what a retry is supposed to achieve. A short pause before the one +// retry this makes gives a stray straggler more of a chance to finish +// first. +bool inject_and_collect_xaml_tree( + Element& root, + HWND hwnd, + DWORD pid, + const std::wstring& xamlDiagDll, + const std::wstring& initDllPath, + const std::string& frameworkLabel, + const std::wstring& connPrefix, + bool fastProperties) +{ + for (int attempt = 0; attempt < 2; ++attempt) { + if (attempt > 0) { + if (g_debug) + fprintf(stderr, "lvt: retrying XAML injection (attempt %d)\n", attempt + 1); + Sleep(1000); } - } else if (treeJson.is_object()) { - graft_json_node(treeJson, root, frameworkLabel); + // A one-shot caller (dump/query/screenshot) has no reason to keep + // this connection open once it has its tree - unlike watch's loop + // or an MCP session (see connection_registry.h), which acquire one + // and reuse it across many refreshes instead of reconnecting every + // time. `connection` going out of scope at the end of this + // iteration sends a clean DISCONNECT (see ~XamlDiagConnection). + auto connection = XamlDiagConnection::connect(hwnd, pid, xamlDiagDll, initDllPath, + frameworkLabel, connPrefix); + if (connection && connection->get_tree(root, fastProperties)) + return true; } - - return true; + return false; } } // namespace lvt diff --git a/src/providers/xaml_diag_common.h b/src/providers/xaml_diag_common.h index a4d18a9..e077d31 100644 --- a/src/providers/xaml_diag_common.h +++ b/src/providers/xaml_diag_common.h @@ -1,6 +1,8 @@ #pragma once #include "../element.h" +#include "framework_connection.h" #include +#include #include namespace lvt { @@ -12,6 +14,21 @@ namespace lvt { // (e.g. L"Windows.UI.Xaml.dll" or full path to FrameworkUdk.dll). // `connPrefix` is the connection endpoint name prefix to use // (e.g. L"VisualDiagConnection" for system XAML, L"WinUIVisualDiagConnection" for WinUI3). +// `fastProperties`, when true, tells the TAP DLL to skip +// IVisualTreeService::GetPropertyValuesChain (the dominant per-element cost +// of a rich tree, ~4.5ms/element measured live) and collect bounds/Text/ +// Content the cheaper way CollectPositionsAndText already used for +// position: direct WinRT property reads on an already-obtained +// IInspectable. Trades the exhaustive per-element property set (custom +// DPs, anything outside Text/Content/bounds) for speed — see lvt_tap.cpp's +// CollectBounds/CollectPositionsAndText for exactly what is and isn't +// captured in this mode. +// +// One-shot convenience: connects, collects exactly one tree, and disconnects +// again before returning. Fine for a single CLI command (dump/query/ +// screenshot), but wasteful for a caller that will ask for the tree +// repeatedly (watch's loop, an MCP session) - see make_xaml_diag_connection +// below for a connection that can be reused across many such calls instead. // Returns true if the tree was successfully enriched. bool inject_and_collect_xaml_tree( Element& root, @@ -20,6 +37,20 @@ bool inject_and_collect_xaml_tree( const std::wstring& xamlDiagDll, const std::wstring& initDllPath, const std::string& frameworkLabel, + const std::wstring& connPrefix = L"VisualDiagConnection", + bool fastProperties = false); + +// Establishes a persistent connection (see framework_connection.h) that can +// serve many subsequent get_tree() calls without re-injecting or +// re-subscribing. Intended to be handed to ConnectionRegistry::acquire as +// its Factory, so a caller like watch's loop or an MCP session can acquire +// once and reuse for its own lifetime. Returns nullptr if the connection +// could not be established. +std::shared_ptr make_xaml_diag_connection( + HWND hwnd, DWORD pid, + const std::wstring& xamlDiagDll, + const std::wstring& initDllPath, + const std::string& frameworkLabel, const std::wstring& connPrefix = L"VisualDiagConnection"); } // namespace lvt diff --git a/src/providers/xaml_provider.cpp b/src/providers/xaml_provider.cpp index ebca81b..d76fb24 100644 --- a/src/providers/xaml_provider.cpp +++ b/src/providers/xaml_provider.cpp @@ -6,7 +6,11 @@ namespace lvt { -void XamlProvider::enrich(Element& root, HWND hwnd, DWORD pid) { +// Shared by enrich(), open_connection() and enrich_with_connection() so all +// three resolve "which element is the CoreWindow" identically - a second, +// drifted implementation is exactly the kind of divergence this codebase +// has been bitten by before (see element_key.cpp's history). +static Element* find_core_window(Element& root) { Element* coreWindow = nullptr; std::function findCoreWindow = [&](Element& el) { if (el.className == "Windows.UI.Core.CoreWindow") { @@ -17,8 +21,33 @@ void XamlProvider::enrich(Element& root, HWND hwnd, DWORD pid) { for (auto& child : el.children) findCoreWindow(child); }; findCoreWindow(root); + return coreWindow; +} - if (!coreWindow) return; +static bool has_desktop_xaml_bridge(const Element& root) { + if (root.className == "Windows.UI.Composition.DesktopWindowContentBridge") + return true; + for (const auto& child : root.children) { + if (has_desktop_xaml_bridge(child)) + return true; + } + return false; +} + +void XamlProvider::enrich(Element& root, HWND hwnd, DWORD pid, bool fastProperties) { + Element* coreWindow = find_core_window(root); + if (!coreWindow) { + // Desktop system-XAML islands (for example Windows Terminal, which + // uses WinUI 2 controls hosted by Windows.UI.Xaml) have no CoreWindow. + // Their DesktopWindowXamlSource belongs to the target process and is + // hosted under this native bridge, so collect against that process + // and graft the returned island beneath the matching bridge. + if (!has_desktop_xaml_bridge(root)) + return; + inject_and_collect_xaml_tree(root, hwnd, pid, L"", L"Windows.UI.Xaml.dll", "xaml", + L"VisualDiagConnection", fastProperties); + return; + } // UWP apps: the CoreWindow belongs to the actual app process (e.g. CalculatorApp.exe), // not the ApplicationFrameHost.exe that owns the top-level window. @@ -29,7 +58,46 @@ void XamlProvider::enrich(Element& root, HWND hwnd, DWORD pid) { GetWindowThreadProcessId(coreHwnd, &corePid); } - inject_and_collect_xaml_tree(*coreWindow, hwnd, corePid, L"", L"Windows.UI.Xaml.dll", "xaml"); + inject_and_collect_xaml_tree(*coreWindow, hwnd, corePid, L"", L"Windows.UI.Xaml.dll", "xaml", + L"VisualDiagConnection", fastProperties); +} + +std::shared_ptr XamlProvider::open_connection(const Element& root, HWND hwnd, DWORD pid) { + // find_core_window mutates framework/type labels as a side effect (see + // its comment) - harmless here since `root` is only used to resolve + // corePid, but taking a non-const local copy of the pointer requires + // casting away const rather than duplicating the walk. Simpler: just + // walk read-only for the one thing this needs. + const Element* coreWindow = nullptr; + std::function find = [&](const Element& el) { + if (!coreWindow && el.className == "Windows.UI.Core.CoreWindow") coreWindow = ⪙ + for (auto& child : el.children) find(child); + }; + find(root); + if (!coreWindow) { + if (!has_desktop_xaml_bridge(root)) + return nullptr; + return make_xaml_diag_connection(hwnd, pid, L"", L"Windows.UI.Xaml.dll", "xaml", + L"VisualDiagConnection"); + } + + DWORD corePid = pid; + if (coreWindow->nativeHandle) { + HWND coreHwnd = reinterpret_cast(coreWindow->nativeHandle); + GetWindowThreadProcessId(coreHwnd, &corePid); + } + + return make_xaml_diag_connection(hwnd, corePid, L"", L"Windows.UI.Xaml.dll", "xaml", + L"VisualDiagConnection"); +} + +void XamlProvider::enrich_with_connection(Element& root, IFrameworkConnection& connection, bool fastProperties) { + Element* coreWindow = find_core_window(root); + if (coreWindow) { + connection.get_tree(*coreWindow, fastProperties); + } else if (has_desktop_xaml_bridge(root)) { + connection.get_tree(root, fastProperties); + } } } // namespace lvt diff --git a/src/providers/xaml_provider.h b/src/providers/xaml_provider.h index aab29cd..81996c6 100644 --- a/src/providers/xaml_provider.h +++ b/src/providers/xaml_provider.h @@ -1,5 +1,7 @@ #pragma once #include "provider.h" +#include "framework_connection.h" +#include namespace lvt { @@ -8,7 +10,24 @@ class XamlProvider : public IProvider { // Enrich the element tree with UWP XAML visual tree information. // Injects lvt_tap.dll into the target process via InitializeXamlDiagnosticsEx // and reads the XAML visual tree over a named pipe. - void enrich(Element& root, HWND hwnd, DWORD pid); + // `fastProperties` — see xaml_diag_common.h's inject_and_collect_xaml_tree. + void enrich(Element& root, HWND hwnd, DWORD pid, bool fastProperties = false); + + // Establishes a persistent connection (see framework_connection.h) for + // reuse across many refreshes, e.g. by watch's loop or an MCP session — + // see connection_registry.h. Uses the CoreWindow already present in + // `root` (UWP), or a Windows.UI.Composition.DesktopWindowContentBridge + // (desktop system-XAML island), to resolve which process owns the XAML + // content. This is the same resolution enrich() does internally, exposed + // so a caller only pays for it once instead of every refresh. Returns + // nullptr when neither supported host is present, or connection fails. + std::shared_ptr open_connection(const Element& root, HWND hwnd, DWORD pid); + + // Re-locates the CoreWindow in the CURRENT tick's `root` (a fresh Win32 + // walk happens every tick, so an Element pointer from a previous tick is + // never valid) and refreshes it over the already-open `connection` + // instead of re-injecting. No-op if `root` has no CoreWindow this tick. + void enrich_with_connection(Element& root, IFrameworkConnection& connection, bool fastProperties = false); }; } // namespace lvt diff --git a/src/tap/lvt_tap.cpp b/src/tap/lvt_tap.cpp index 13dec01..fa49d7c 100644 --- a/src/tap/lvt_tap.cpp +++ b/src/tap/lvt_tap.cpp @@ -12,8 +12,11 @@ #include #include #include +#include #include #include +#include +#include #include #include "xaml_property_filter.h" @@ -61,7 +64,7 @@ static void LogMsg(const char* fmt, ...) { logFile = _wfopen(tmp, L"a"); if (!logFile) return; } - fprintf(logFile, "[%lu] ", GetCurrentThreadId()); + fprintf(logFile, "[%llu][%lu] ", GetTickCount64(), GetCurrentThreadId()); va_list ap; va_start(ap, fmt); vfprintf(logFile, fmt, ap); @@ -78,6 +81,39 @@ static HMODULE GetCurrentModuleHandle() { return hm; } +// Buffered line reader over the persistent duplex pipe. Reading one byte per +// ReadFile call (fine for a single one-shot handshake) is far too slow once +// this pipe stays open for the whole connection and carries a full tree's +// JSON (potentially several MB) as one line per GET_TREE response — this +// reads in 8KB chunks and splits on '\n', keeping any partial trailing line +// buffered for the next call. +class PipeLineReader { +public: + explicit PipeLineReader(HANDLE pipe) : m_pipe(pipe) {} + + // Returns std::nullopt on EOF or a read error (the pipe is gone). + std::optional ReadLine() { + for (;;) { + auto nl = m_buffer.find('\n'); + if (nl != std::string::npos) { + std::string line = m_buffer.substr(0, nl); + m_buffer.erase(0, nl + 1); + if (!line.empty() && line.back() == '\r') line.pop_back(); + return line; + } + char chunk[8192]; + DWORD read = 0; + BOOL ok = ReadFile(m_pipe, chunk, sizeof(chunk), &read, nullptr); + if (!ok || read == 0) return std::nullopt; + m_buffer.append(chunk, read); + } + } + +private: + HANDLE m_pipe; + std::string m_buffer; +}; + struct TreeNode { InstanceHandle handle = 0; std::wstring type; @@ -94,6 +130,16 @@ struct TreeNode { class LvtTap; +// Describes one chunk of nodes to collect, passed via SendMessage's LPARAM. +// The struct lives on AdviseThreadProcImpl's stack: SendMessage is +// synchronous, so it stays valid for exactly as long as the receiving +// thread's WndProc needs it, with no lifetime management required. +struct BatchRequest { + LvtTap* self; + size_t start; + size_t count; +}; + // Forward declaration for WndProc static LRESULT CALLBACK LvtTapMsgWndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam); @@ -103,13 +149,52 @@ class LvtTap : public IObjectWithSite, public IVisualTreeServiceCallback2 { wil::com_ptr m_diag; wil::unique_hwnd m_msgWnd; // Message-only window for UI thread dispatch std::map m_nodes; + // Stable, flattened order over m_nodes' handles, built once per pass so + // both collection functions can be dispatched to the UI thread in small + // chunks (see AdviseThreadProcImpl) instead of one giant blocking call — + // a std::map has no efficient random-access range, hence flattening. + std::vector m_orderedHandles; std::vector m_roots; + // Guards all direct access to m_nodes/m_roots/m_orderedHandles. XAML's + // OnVisualTreeChange can fire on whichever UI thread owns the affected + // element(s) - which, for an app with more than one XAML "core"/window, + // need not be the same thread m_msgWnd (and therefore CollectBounds/ + // CollectPositionsAndText, dispatched there via SendMessage) is pinned + // to. This mattered far less under the old one-shot-per-tick design, + // which read this data exactly once, immediately after a single + // synchronous initial replay. A persistent connection reads it + // repeatedly across many GET_TREE requests over its whole life, so a + // concurrent Add/Remove from another thread needs an actual lock, not + // just favorable timing. + std::mutex m_nodesMutex; std::wstring m_pipeName; - bool m_collectProps = false; + // Parsed from the pipe-name suffix ("pipe_name|FAST") passed down from + // xaml_diag_common.cpp — this is only the connection-wide *default*; + // each GET_TREE request can override it for that one response (see + // HandleGetTree), so a single persistent connection can still mix fast + // live-tree polls with an occasional full-property request the way the + // old per-call model did. + bool m_fastMode = false; + // The persistent, duplex connection back to lvt.exe. Opened exactly + // once per connection lifetime (see ConnectPipeOnce) and kept open for + // as long as the command loop runs - this is the whole point of this + // redesign: one connect, many requests, instead of the old one-shot + // "collect once, write once, close" pipe. + wil::unique_hfile m_pipe; + // Guards every write to m_pipe. A GET_TREE response (written from the + // worker/command-loop thread) and a pushed CHANGE event (written from + // whichever thread XAML's OnVisualTreeChange happens to call back on) + // must never interleave their bytes on the wire. + std::mutex m_pipeWriteMutex; public: wil::com_ptr m_vts; static constexpr UINT WM_COLLECT_BOUNDS = WM_USER + 100; + // WM_COLLECT_BOUNDS + 1 is used for CollectPositionsAndText dispatch + // (see LvtTapMsgWndProc). This one asks the UI thread (the only thread + // allowed to destroy a window it owns) to destroy m_msgWnd during final + // cleanup - see CleanupUIResources. + static constexpr UINT WM_TAP_DESTROY = WM_USER + 102; public: HRESULT STDMETHODCALLTYPE QueryInterface(REFIID riid, void** ppv) override { @@ -181,16 +266,16 @@ class LvtTap : public IObjectWithSite, public IVisualTreeServiceCallback2 { wil::unique_bstr initData(rawInitData); if (initData) { std::wstring data(initData.get()); - // Format: "pipe_name" or "pipe_name|PROPS" + // Format: "pipe_name" or "pipe_name|FAST" auto sep = data.find(L'|'); if (sep != std::wstring::npos) { m_pipeName = data.substr(0, sep); std::wstring flags = data.substr(sep + 1); - m_collectProps = (flags.find(L"PROPS") != std::wstring::npos); + m_fastMode = (flags.find(L"FAST") != std::wstring::npos); } else { m_pipeName = data; } - LogMsg("Pipe name: %ls, collectProps: %d", m_pipeName.c_str(), m_collectProps); + LogMsg("Pipe name: %ls, fastMode: %d", m_pipeName.c_str(), m_fastMode); } hr = diag->QueryInterface(IID_PPV_ARGS(m_vts.put())); @@ -236,6 +321,72 @@ class LvtTap : public IObjectWithSite, public IVisualTreeServiceCallback2 { return self->AdviseThreadProcImpl(); } + // Connects the persistent duplex pipe back to lvt.exe. Unlike the old + // one-shot model (open, write once, close), this handle is kept open + // for the connection's whole life - see m_pipe's comment. + bool ConnectPipeOnce() { + if (m_pipeName.empty()) { + LogMsg("ConnectPipeOnce: pipe name is empty"); + return false; + } + m_pipe.reset(CreateFileW(m_pipeName.c_str(), GENERIC_READ | GENERIC_WRITE, 0, + nullptr, OPEN_EXISTING, 0, nullptr)); + if (!m_pipe) { + LogMsg("ConnectPipeOnce: failed to open pipe, error=%lu", GetLastError()); + return false; + } + LogMsg("ConnectPipeOnce: connected"); + return true; + } + + // Writes one line (message + '\n') to the pipe. Safe to call from any + // thread - guarded (see m_pipeWriteMutex) so a pushed CHANGE event can + // never interleave its bytes with a GET_TREE response. + bool WriteLine(const std::string& utf8Line) { + std::lock_guard lock(m_pipeWriteMutex); + if (!m_pipe) return false; + std::string withNewline = utf8Line; + withNewline += '\n'; + DWORD written = 0; + BOOL ok = WriteFile(m_pipe.get(), withNewline.data(), + static_cast(withNewline.size()), &written, nullptr); + if (ok) FlushFileBuffers(m_pipe.get()); + return ok != FALSE; + } + + // Writes one unsolicited {"type":"CHANGE",...} line - see + // OnVisualTreeChange, which calls this after releasing m_nodesMutex + // (never while holding it, to keep lock ordering simple: WriteLine only + // ever needs m_pipeWriteMutex). Safe to call before ServeConnection has + // run (no pipe yet - SetSiteImpl's synchronous initial replay happens + // first) or after the connection has ended: WriteLine just fails + // quietly in both cases, same as for any other caller, and a + // subsequent GET_TREE response always reflects current reality + // regardless of whether this push made it out. + void PushChangeEvent(bool added, InstanceHandle handle, InstanceHandle parent, + unsigned int childIndex, const std::wstring& type, const std::wstring& name) { + std::wstring json = L"{\"type\":\"CHANGE\",\"mutation\":\""; + json += added ? L"add" : L"remove"; + json += L"\",\"handle\":" + std::to_wstring(handle); + json += L",\"parent\":" + std::to_wstring(parent); + if (added) { + json += L",\"childIndex\":" + std::to_wstring(childIndex); + json += L",\"elementType\":\"" + Escape(type) + L"\""; + if (!name.empty()) + json += L",\"name\":\"" + Escape(name) + L"\""; + } + json += L"}"; + + int len = WideCharToMultiByte(CP_UTF8, 0, json.c_str(), (int)json.size(), + nullptr, 0, nullptr, nullptr); + std::string utf8(len, '\0'); + WideCharToMultiByte(CP_UTF8, 0, json.c_str(), (int)json.size(), + utf8.data(), len, nullptr, nullptr); + bool sent = WriteLine(utf8); + LogMsg("PushChangeEvent: %s handle=%llu parent=%llu sent=%d", + added ? "add" : "remove", (unsigned long long)handle, (unsigned long long)parent, sent); + } + DWORD AdviseThreadProcImpl() { LogMsg("AdviseThread starting"); @@ -244,6 +395,17 @@ class LvtTap : public IObjectWithSite, public IVisualTreeServiceCallback2 { static_cast( static_cast(this)); + // AdviseVisualTreeChange is called exactly ONCE per connection + // lifetime, here, and stays registered for as long as the + // command loop below runs - it is a subscribe-and-react API + // (OnVisualTreeChange keeps incrementally maintaining m_nodes/ + // m_roots for the whole connection), not something meant to be + // re-established on every tree refresh. Re-subscribing from + // scratch every poll (the old design) is what caused a + // confirmed, unbounded per-tick resource leak - see + // CleanupUIResources's comment for how this now cleans up + // exactly once, when the connection actually ends, instead. + LogMsg("Calling AdviseVisualTreeChange"); HRESULT hr = m_vts->AdviseVisualTreeChange(cb); LogMsg("AdviseVisualTreeChange returned 0x%08X, nodes=%zu, roots=%zu", hr, m_nodes.size(), m_roots.size()); @@ -253,31 +415,176 @@ class LvtTap : public IObjectWithSite, public IVisualTreeServiceCallback2 { Sleep(500); LogMsg("After sleep: nodes=%zu", m_nodes.size()); } - // Dispatch GetPropertyValuesChain to UI thread via message window. - // SendMessage blocks until the UI thread processes the message. - if (m_msgWnd) { - LogMsg("Dispatching CollectBounds to UI thread via SendMessage"); - SendMessageW(m_msgWnd.get(), WM_COLLECT_BOUNDS, 0, - reinterpret_cast(this)); - } - // Get element positions via TransformToVisual (works around broken - // ActualOffset serialization in WinUI3). Must run on the UI thread. -#if LVT_HAS_XAML_PROJECTION - if (m_msgWnd) { - SendMessageW(m_msgWnd.get(), WM_COLLECT_BOUNDS + 1, 0, - reinterpret_cast(this)); - } -#endif - SerializeAndSend(); + + ServeConnection(); + m_vts->UnadviseVisualTreeChange(cb); } } __except(EXCEPTION_EXECUTE_HANDLER) { LogMsg("AdviseThread crashed: 0x%08X", GetExceptionCode()); } + CleanupUIResources(); + LogMsg("AdviseThread exiting"); return 0; } + // Connects the persistent pipe and, if that succeeds, serves requests + // until the connection ends. Split out of AdviseThreadProcImpl (rather + // than inlined into its __try block) because MSVC's SEH rejects any + // C++ temporary requiring unwind cleanup - such as the std::string + // WriteLine("READY") would otherwise construct - directly inside a + // function that also contains __try (error C2712); a plain function + // call like this one has no such temporary at the __try call site. + void ServeConnection() { + if (ConnectPipeOnce()) { + WriteLine("READY"); + LogMsg("Sent READY, entering command loop"); + RunCommandLoop(); + } else { + LogMsg("Failed to connect pipe; cannot serve requests this connection"); + } + } + + // Persistent request/response loop, run for as long as lvt.exe keeps + // this connection open. Each request re-walks bounds/properties over + // the ALREADY-subscribed tree (no re-injection, no new AdviseVisualTreeChange, + // no new message window) - this is the entire point of this redesign. + void RunCommandLoop() { + PipeLineReader reader(m_pipe.get()); + for (;;) { + auto line = reader.ReadLine(); + if (!line) { + LogMsg("RunCommandLoop: pipe closed/error, exiting loop"); + break; + } + LogMsg("RunCommandLoop: received command '%s'", line->c_str()); + if (*line == "DISCONNECT") { + WriteLine("BYE"); + break; + } else if (line->rfind("GET_TREE", 0) == 0) { + bool fast = line->find("FAST") != std::string::npos; + HandleGetTree(fast); + } else { + LogMsg("RunCommandLoop: unknown command, ignoring"); + } + } + } + + // Re-collects bounds/properties for the tree already tracked in m_nodes + // (kept current by OnVisualTreeChange for the whole connection) and + // writes exactly one response line - every GET_TREE must get a + // response, even an empty "[]", since lvt.exe blocks waiting for one. + void HandleGetTree(bool fast) { + m_fastMode = fast; + + { + std::lock_guard lock(m_nodesMutex); + m_orderedHandles.clear(); + m_orderedHandles.reserve(m_nodes.size()); + for (auto& [handle, node] : m_nodes) { + m_orderedHandles.push_back(handle); + + // Properties and geometry are a per-request snapshot, not + // persistent tree identity. Leaving them in m_nodes made + // Text/Content entries append again on every watch tick, + // growing the serialized payload without bound. It also + // left hasBounds true forever, so fast mode stopped reading + // ActualWidth/ActualHeight after the first request and + // returned stale sizes after a resize. + node.properties.clear(); + node.width = 0; + node.height = 0; + node.offsetX = 0; + node.offsetY = 0; + node.hasBounds = false; + } + } + + // Dispatch GetPropertyValuesChain (and, below, TransformToVisual) to + // the UI thread in small chunks rather than one call covering every + // node. A single unbroken SendMessage call occupies the target's UI + // thread start to finish (several seconds for a rich tree) with no + // chance to service its own pending messages in between — including + // the modal loop DefWindowProc runs while the user is dragging the + // window, observed live as the target app feeling laggy/stuttery to + // move while `watch` was attached. Chunking with a short sleep + // between SendMessage calls lets that message queue drain between + // chunks; every node still gets collected, in the same order, every + // request. + constexpr size_t kBatchSize = 20; + if (m_msgWnd && !fast) { + LogMsg("Dispatching CollectBounds to UI thread via SendMessage, %zu nodes in batches of %zu", + m_orderedHandles.size(), kBatchSize); + for (size_t start = 0; start < m_orderedHandles.size(); start += kBatchSize) { + BatchRequest req{this, start, kBatchSize}; + SendMessageW(m_msgWnd.get(), WM_COLLECT_BOUNDS, 0, + reinterpret_cast(&req)); + Sleep(1); + } + LogMsg("Finished CollectBounds dispatch"); + } else if (m_msgWnd) { + // CollectBounds itself is intentionally a no-op in fast mode, + // but dispatching one SendMessage + Sleep per 20-node batch + // still cost ~1.9 seconds for Microsoft Store's ~2400-node + // tree. Skip the loop itself, not merely its per-node work. + LogMsg("Skipped CollectBounds dispatch entirely in fast mode"); + } + // Get element positions via TransformToVisual (works around broken + // ActualOffset serialization in WinUI3). Must run on the UI thread. +#if LVT_HAS_XAML_PROJECTION + if (m_msgWnd) { + for (size_t start = 0; start < m_orderedHandles.size(); start += kBatchSize) { + BatchRequest req{this, start, kBatchSize}; + SendMessageW(m_msgWnd.get(), WM_COLLECT_BOUNDS + 1, 0, + reinterpret_cast(&req)); + Sleep(1); + } + LogMsg("Finished CollectPositionsAndText dispatch"); + } +#endif + SerializeAndSend(); + } + + // Tears down everything SetSiteImpl/AdviseThreadProcImpl set up, exactly + // once, when the connection actually ends (DISCONNECT or a broken + // pipe) - not once per tree refresh. This is the direct fix for the + // confirmed leak: every earlier version of this file created a new + // message-only window per collection and never destroyed it. + void CleanupUIResources() { + if (m_msgWnd) { + HWND hwnd = m_msgWnd.get(); + LogMsg("Cleanup: requesting destroy of message window %p", hwnd); + // DestroyWindow must run on the thread that created the window + // (the UI thread SetSiteImpl ran on), not this worker thread - + // dispatch it there via the same SendMessage mechanism already + // used for bounds collection. A bounded timeout (rather than a + // bare blocking SendMessage) means a hung/gone UI thread cannot + // keep this worker thread - and therefore this whole cleanup - + // from ever completing. + DWORD_PTR result = 0; + LRESULT dispatched = SendMessageTimeoutW(hwnd, WM_TAP_DESTROY, 0, 0, + SMTO_ABORTIFHUNG, 2000, &result); + if (dispatched == 0) { + LogMsg("Cleanup: SendMessageTimeout for destroy failed/timed out, error=%lu", + GetLastError()); + } else if (IsWindow(hwnd)) { + LogMsg("Cleanup: message window still alive after destroy request"); + } else { + LogMsg("Cleanup: message window destroyed"); + // Already destroyed on the correct thread above; release + // ownership so wil::unique_hwnd's destructor does not also + // attempt DestroyWindow (which would run on THIS thread, + // the wrong one, and on an already-invalid handle). + (void)m_msgWnd.release(); + } + } + UnregisterClassW(L"LvtTapMsg", GetCurrentModuleHandle()); + m_vts.reset(); + m_diag.reset(); + m_pipe.reset(); + } + HRESULT STDMETHODCALLTYPE GetSite(REFIID riid, void** ppvSite) override { if (!m_site) { *ppvSite = nullptr; return E_FAIL; } return m_site->QueryInterface(riid, ppvSite); @@ -289,25 +596,62 @@ class LvtTap : public IObjectWithSite, public IVisualTreeServiceCallback2 { VisualElement element, VisualMutationType mutationType) override { - if (mutationType == VisualMutationType::Add) { - TreeNode node; - node.handle = element.Handle; - node.type = element.Type ? element.Type : L""; - node.name = element.Name ? element.Name : L""; - node.numChildren = element.NumChildren; - node.parent = relation.Parent; - node.childIndex = relation.ChildIndex; - m_nodes[element.Handle] = std::move(node); - - if (relation.Parent != 0) { - auto it = m_nodes.find(relation.Parent); + const bool isAdd = (mutationType == VisualMutationType::Add); + const bool isRemove = (mutationType == VisualMutationType::Remove); + std::wstring type, name; + { + std::lock_guard lock(m_nodesMutex); + if (isAdd) { + TreeNode node; + node.handle = element.Handle; + node.type = element.Type ? element.Type : L""; + node.name = element.Name ? element.Name : L""; + node.numChildren = element.NumChildren; + node.parent = relation.Parent; + node.childIndex = relation.ChildIndex; + type = node.type; + name = node.name; + m_nodes[element.Handle] = std::move(node); + + if (relation.Parent != 0) { + auto it = m_nodes.find(relation.Parent); + if (it != m_nodes.end()) { + it->second.childHandles.push_back(element.Handle); + } + } else { + m_roots.push_back(element.Handle); + } + } else if (isRemove) { + // Essential for a persistent connection, not optional: the + // old one-shot-per-tick design never needed this branch at + // all - a removed element simply would not appear in the + // *next fresh* replay, since m_nodes was rebuilt from + // scratch every time. A long-lived connection's m_nodes is + // never rebuilt, so without this, every element the target + // ever destroys would stay in the reported tree forever. + auto it = m_nodes.find(element.Handle); if (it != m_nodes.end()) { - it->second.childHandles.push_back(element.Handle); + InstanceHandle parent = it->second.parent; + m_nodes.erase(it); + if (parent != 0) { + auto pit = m_nodes.find(parent); + if (pit != m_nodes.end()) { + auto& kids = pit->second.childHandles; + kids.erase(std::remove(kids.begin(), kids.end(), element.Handle), kids.end()); + } + } else { + m_roots.erase(std::remove(m_roots.begin(), m_roots.end(), element.Handle), m_roots.end()); + } } - } else { - m_roots.push_back(element.Handle); } } + + // Pushed outside m_nodesMutex (see PushChangeEvent's comment on + // lock ordering). Lets a connected lvt.exe eventually react to real + // events instead of only ever polling via GET_TREE - see + // IFrameworkConnection::poll_events. + if (isAdd || isRemove) + PushChangeEvent(isAdd, element.Handle, relation.Parent, relation.ChildIndex, type, name); return S_OK; } @@ -423,12 +767,86 @@ class LvtTap : public IObjectWithSite, public IVisualTreeServiceCallback2 { } } - void CollectBounds(IVisualTreeService* vts) { - LogMsg("CollectBounds: collecting layout for %zu nodes on thread %lu", - m_nodes.size(), GetCurrentThreadId()); + // Every "lvt watch" tick re-injects and walks the *entire* tree from + // scratch (see run_watch_loop in main.cpp), and this loop runs on the + // target's UI thread via a blocking SendMessage (LvtTapMsgWndProc). + // + // An earlier version of this code capped how long a single pass could + // run (kUiThreadBudgetMs), on the theory that an unbounded walk over a + // rich tree could occupy the UI thread long enough to make unrelated + // work on that thread look hung. That budget was a mistake: measured + // live against a real, large WinUI3 app (Microsoft Store, ~1100 nodes, + // ~4ms/node), the wall-clock cutoff meant a different, non-deterministic + // subset of nodes got bounds each tick — not because the UI changed, but + // because ordinary timing jitter shifted exactly how many nodes fit in + // the budget window. Every unaffected element's bounds/properties then + // flip-flopped between "known" and "absent" every tick, forever, which + // `watch`'s diffing correctly (and unhelpfully) reported as constant + // "changed" events — flooding stdout (250MB+ observed in minutes) and + // burning CPU on serialization for output nobody asked for, which looked + // to a client (the lvt Viewer) exactly like a connection that was + // stuck, not one that was overcorrecting on interpreting real data. + // + // What still needed fixing after removing that budget: even at full, + // uninterrupted speed, a single SendMessage-dispatched pass over a rich + // tree (several seconds for 1000+ nodes) occupies the target's UI thread + // start to finish with no chance to service its own pending messages in + // between — which is exactly what a modal window-move loop (DefWindowProc + // handling WM_NCLBUTTONDOWN/SC_MOVE) *also* needs that same thread for, + // observed live as the target app feeling laggy/stuttery to drag while + // `watch` was attached. AdviseThreadProcImpl now dispatches this in small + // chunks (kBatchSize nodes per SendMessage call) with a short Sleep + // between chunks, so the target's UI thread gets to drain its own + // message queue between chunks instead of being monopolized for the + // whole pass — full correctness is unaffected (every node still gets + // collected, in the same order, every tick; only the dispatch is + // chunked), so this does not reintroduce the flapping the time budget + // caused. + // + // The protection against a pathologically slow *overall* collection is + // still one layer up: xaml_diag_common.cpp's TAP DLL only calls + // CreateFileW to connect to lvt.exe's pipe *after* + // CollectBounds/CollectPositionsAndText/SerializeAndSend all finish (see + // SerializeAndSend below), so lvt.exe's own "TAP DLL did not connect" + // timeout on the other end of that pipe already bounds the combined + // cost of this walk, chunked or not, and fails the whole tick cleanly + // (no partial data) rather than partially collecting. + // + // That timeout used to be 15 seconds, which was not a safety margin — + // it was the actual cause of real, reproducible tree data loss. Traced + // live against Microsoft Store's animated home page (~1936 elements): + // a single *successful* collection (every call below returned success) + // measured 40.8 seconds end to end, because chunking here specifically + // lets a busy/animating target's UI thread interleave its own work + // between chunks rather than being monopolized — exactly what an + // actively animating tree needs a lot of. At 15 seconds, lvt.exe + // routinely gave up and closed the pipe while this was still + // legitimately working, so the "fails cleanly" path above was firing + // for collections that would have succeeded if just given more time. + // See xaml_diag_common.cpp's kXamlCollectionTimeoutMs (now 60s) for + // where this is actually bounded today. + void CollectBounds(IVisualTreeService* vts, size_t start, size_t count) { + // Fast mode skips GetPropertyValuesChain entirely — the dominant + // per-node cost (~4.5ms/element, measured live against Microsoft + // Store and Calculator) of walking an element's *entire* property + // inheritance chain just to read ActualWidth/ActualHeight out of it. + // CollectPositionsAndText gets bounds a cheaper way instead (direct + // FrameworkElement.ActualWidth/ActualHeight via the same IInspectable + // it already fetches for position/text), so there is nothing for + // this function to do in fast mode. + if (m_fastMode) { + LogMsg("CollectBounds: skipped in fast mode, batch [%zu,%zu)", start, + std::min(start + count, m_orderedHandles.size())); + return; + } + size_t end = std::min(start + count, m_orderedHandles.size()); int collected = 0; - int idx = 0; - for (auto& [handle, node] : m_nodes) { + for (size_t i = start; i < end; i++) { + InstanceHandle handle = m_orderedHandles[i]; + std::lock_guard lock(m_nodesMutex); + auto it = m_nodes.find(handle); + if (it == m_nodes.end()) continue; + TreeNode& node = it->second; bool logDetail = false; int code = CollectBoundsForNodeSEH(vts, node, handle, logDetail); if (code != 0) { @@ -436,24 +854,61 @@ class LvtTap : public IObjectWithSite, public IVisualTreeServiceCallback2 { (unsigned long long)handle, code); } if (node.hasBounds) collected++; - idx++; } - LogMsg("CollectBounds: collected bounds for %d/%zu nodes", collected, m_nodes.size()); + LogMsg("CollectBounds: collected bounds for %d/%zu nodes in batch [%zu,%zu)", + collected, end - start, start, end); } #if LVT_HAS_XAML_PROJECTION // Use TransformToVisual to get each element's position relative to the XAML island root. // Also reads Text property from TextBlock elements. // Tries both WinUI3 (Microsoft.UI.Xaml) and system XAML (Windows.UI.Xaml) interfaces. - void CollectPositionsAndText() { + // Chunked the same way, and for the same reason, as CollectBounds above. + // Unboxes a Content/Header-style IInspectable to a string only when it is + // actually one — most ContentControls hold a nested UIElement subtree + // there instead (a StackPanel with an Image+TextBlock, say), and that has + // no meaningful flat string to show. IPropertyValue is how WinRT + // represents a boxed primitive regardless of which XAML projection + // produced it, so this one helper covers both WUX and Microsoft.UI.Xaml + // without a separate branch for each. + static bool TryUnboxString(const winrt::Windows::Foundation::IInspectable& value, + winrt::hstring& out) { + if (auto propValue = value.try_as()) { + if (propValue.Type() == winrt::Windows::Foundation::PropertyType::String) { + out = propValue.GetString(); + return !out.empty(); + } + } + return false; + } + + static void SetCollectedProperty(TreeNode& node, const wchar_t* name, + const winrt::hstring& value) { + if (value.empty()) + return; + auto existing = std::find_if( + node.properties.begin(), node.properties.end(), + [name](const auto& property) { return property.first == name; }); + if (existing != node.properties.end()) + existing->second = std::wstring(value); + else + node.properties.emplace_back(name, std::wstring(value)); + } + + void CollectPositionsAndText(size_t start, size_t count) { namespace WUX = winrt::Windows::UI::Xaml; namespace WUXC = winrt::Windows::UI::Xaml::Controls; if (!m_diag) return; - int positioned = 0, textsRead = 0; - for (auto& [handle, node] : m_nodes) { - if (!node.hasBounds) continue; + size_t end = std::min(start + count, m_orderedHandles.size()); + int positioned = 0, textsRead = 0, boundsFromFastPath = 0; + for (size_t i = start; i < end; i++) { + InstanceHandle handle = m_orderedHandles[i]; + std::lock_guard lock(m_nodesMutex); + auto it = m_nodes.find(handle); + if (it == m_nodes.end()) continue; + TreeNode& node = it->second; // Keep raw to preserve XAML diagnostics' existing ABI lifetime behavior. ::IInspectable* raw = nullptr; @@ -465,6 +920,41 @@ class LvtTap : public IObjectWithSite, public IVisualTreeServiceCallback2 { winrt::copy_from_abi(inspectable, raw); raw = nullptr; // ownership transferred + // Fast mode never ran GetPropertyValuesChain (see + // CollectBounds), so ActualWidth/ActualHeight have not been + // read yet — get them the same cheap way position/text + // already come from: a direct WinRT property read on the + // IInspectable this loop obtained anyway, no COM property- + // chain walk. Non-fast mode already has hasBounds from + // CollectBounds and skips this — it is not wrong to redo it, + // just pointless cost this path exists specifically to avoid. + if (m_fastMode && !node.hasBounds) { + bool gotBounds = false; +#if LVT_HAS_WINUI3_PROJECTION + if (auto fe = inspectable.try_as()) { + double w = fe.ActualWidth(), h = fe.ActualHeight(); + if (std::isfinite(w) && std::isfinite(h)) { + node.width = w; + node.height = h; + gotBounds = true; + } + } +#endif + if (!gotBounds) { + if (auto fe = inspectable.try_as()) { + double w = fe.ActualWidth(), h = fe.ActualHeight(); + if (std::isfinite(w) && std::isfinite(h)) { + node.width = w; + node.height = h; + gotBounds = true; + } + } + } + node.hasBounds = gotBounds; + if (gotBounds) boundsFromFastPath++; + } + if (!node.hasBounds) continue; + // Position via TransformToVisual — try WinUI3 first, then system XAML bool gotPosition = false; #if LVT_HAS_WINUI3_PROJECTION @@ -500,27 +990,50 @@ class LvtTap : public IObjectWithSite, public IVisualTreeServiceCallback2 { text = tb.Text(); } if (!text.empty()) { - node.properties.emplace_back(L"Text", std::wstring(text)); + SetCollectedProperty(node, L"Text", text); textsRead++; } + + // Content from ContentControl (Button, ListViewItem, ...) — + // only when it unboxes to a plain string (see TryUnboxString): + // most controls' Content is a nested element subtree, which + // has nothing flat to show here. This runs in both modes — + // GetPropertyValuesChain's own filter (xaml_property_filter.h) + // treats a reference-typed Content as a handle and drops it, + // so today this is new data even in the default/full path, + // not a duplicate of what GetPropertyValuesChain already + // reports. + winrt::hstring content; +#if LVT_HAS_WINUI3_PROJECTION + if (auto cc = inspectable.try_as()) + TryUnboxString(cc.Content(), content); +#endif + if (content.empty()) { + if (auto cc = inspectable.try_as()) + TryUnboxString(cc.Content(), content); + } + if (!content.empty()) + SetCollectedProperty(node, L"Content", content); } catch (...) { // Swallow WinRT exceptions — element may be in an invalid state } if (raw) raw->Release(); } - LogMsg("CollectPositionsAndText: %d positioned, %d texts", positioned, textsRead); + LogMsg("CollectPositionsAndText: %d positioned, %d texts, " + "%d bounds-from-fast-path in batch [%zu,%zu)", + positioned, textsRead, boundsFromFastPath, start, end); } #endif // Called on the UI thread via SendMessage from the worker thread public: - void CollectBoundsOnUIThread() { - CollectBounds(m_vts.get()); + void CollectBoundsOnUIThread(size_t start, size_t count) { + CollectBounds(m_vts.get(), start, count); } #if LVT_HAS_XAML_PROJECTION - void CollectPositionsOnUIThread() { - CollectPositionsAndText(); + void CollectPositionsOnUIThread(size_t start, size_t count) { + CollectPositionsAndText(start, count); } #endif private: @@ -590,17 +1103,32 @@ class LvtTap : public IObjectWithSite, public IVisualTreeServiceCallback2 { } void SerializeAndSend() { - LogMsg("SerializeAndSend: nodes=%zu, roots=%zu, pipe=%ls", - m_nodes.size(), m_roots.size(), m_pipeName.c_str()); - - if (m_pipeName.empty() || m_nodes.empty()) return; - - std::wstring json = L"["; - for (size_t i = 0; i < m_roots.size(); i++) { - if (i) json += L","; - json += SerializeNode(m_roots[i]); + // One lock for the whole recursive walk (SerializeNode is + // non-reentrant with respect to m_nodesMutex - it is only ever + // called from here) so the tree serialized is a single consistent + // snapshot, not a mix of before/after some concurrent Add/Remove + // that happened to land mid-walk. Released before the pipe write + // below, which can block on a slow/busy reader and has nothing to + // do with m_nodes. + std::wstring json; + { + std::lock_guard lock(m_nodesMutex); + LogMsg("SerializeAndSend: nodes=%zu, roots=%zu", m_nodes.size(), m_roots.size()); + + // Every GET_TREE request gets exactly one response line, even + // an empty "[]" - lvt.exe's connection object is blocked + // waiting for a reply (see xaml_diag_common.cpp's get_tree()), + // and silently returning here without writing anything would + // hang it until its own read timeout instead of completing + // quickly with "no data". + json = L"["; + for (size_t i = 0; i < m_roots.size(); i++) { + if (i) json += L","; + json += SerializeNode(m_roots[i]); + } + json += L"]"; } - json += L"]"; + LogMsg("SerializeAndSend: built JSON, %zu wchars", json.size()); int len = WideCharToMultiByte(CP_UTF8, 0, json.c_str(), (int)json.size(), nullptr, 0, nullptr, nullptr); @@ -608,15 +1136,10 @@ class LvtTap : public IObjectWithSite, public IVisualTreeServiceCallback2 { WideCharToMultiByte(CP_UTF8, 0, json.c_str(), (int)json.size(), utf8.data(), len, nullptr, nullptr); - wil::unique_hfile pipe(CreateFileW(m_pipeName.c_str(), GENERIC_WRITE, 0, - nullptr, OPEN_EXISTING, 0, nullptr)); - if (pipe) { - DWORD written = 0; - WriteFile(pipe.get(), utf8.data(), (DWORD)utf8.size(), &written, nullptr); - FlushFileBuffers(pipe.get()); - LogMsg("Wrote %lu bytes to pipe", written); + if (WriteLine(utf8)) { + LogMsg("SerializeAndSend: wrote %d bytes to the persistent pipe", len); } else { - LogMsg("Failed to open pipe: %lu", GetLastError()); + LogMsg("SerializeAndSend: failed to write to pipe, error=%lu", GetLastError()); } } }; @@ -624,21 +1147,28 @@ class LvtTap : public IObjectWithSite, public IVisualTreeServiceCallback2 { // Window procedure for dispatching GetPropertyValuesChain to UI thread static LRESULT CALLBACK LvtTapMsgWndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) { if (msg == LvtTap::WM_COLLECT_BOUNDS) { - auto* self = reinterpret_cast(lParam); - if (self) { - self->CollectBoundsOnUIThread(); + auto* req = reinterpret_cast(lParam); + if (req && req->self) { + req->self->CollectBoundsOnUIThread(req->start, req->count); } return 0; } if (msg == LvtTap::WM_COLLECT_BOUNDS + 1) { #if LVT_HAS_XAML_PROJECTION - auto* self = reinterpret_cast(lParam); - if (self) { - self->CollectPositionsOnUIThread(); + auto* req = reinterpret_cast(lParam); + if (req && req->self) { + req->self->CollectPositionsOnUIThread(req->start, req->count); } #endif return 0; } + if (msg == LvtTap::WM_TAP_DESTROY) { + // Runs on the thread that created hwnd (this window's owning UI + // thread) - see CleanupUIResources for why DestroyWindow cannot be + // called directly from the worker thread instead. + DestroyWindow(hwnd); + return 0; + } return DefWindowProcW(hwnd, msg, wParam, lParam); } diff --git a/src/tap/xaml_property_filter.h b/src/tap/xaml_property_filter.h index 09ee658..9384e95 100644 --- a/src/tap/xaml_property_filter.h +++ b/src/tap/xaml_property_filter.h @@ -9,19 +9,11 @@ namespace lvt { -// Free-text properties lvt captures when their value looks like a real string -// (see xaml_looks_like_handle / xaml_is_string_value_type). -inline bool xaml_is_text_property(const std::wstring& name) { - return name == L"Text" || name == L"Content" || name == L"Header" || - name == L"PlaceholderText" || name == L"Description" || - name == L"Title" || name == L"Glyph"; -} - // State/identity properties lvt captures regardless of value shape: their // value being "0", or a long numeric string that would otherwise look like a // handle, is legitimate data, not noise. xaml_should_capture_property skips // the handle heuristic entirely for names in this list, rather than relying -// on ValueType the way text properties do — see its comment for why. +// on ValueType the way every other property does — see its comment for why. inline bool xaml_is_state_property(const std::wstring& name) { return name == L"AutomationProperties.Name" || name == L"AutomationProperties.AutomationId" || @@ -102,24 +94,37 @@ inline bool xaml_should_capture_property(const std::wstring& name, const std::ws const std::wstring& valueType) { if (value.empty()) return false; - // The handle heuristic only ever applies to text properties. State - // properties (Tag, Source, AutomationProperties.*, Visibility, ...) are - // captured regardless of value shape, per xaml_is_state_property's - // contract: AutomationProperties.Name/AutomationId/HelpText are always - // genuinely string-typed in XAML, so a long numeric AutomationId (a - // very plausible generated id) must not be mistaken for a handle. Tag - // and Source can hold arbitrary reference-typed values too, but this - // function has no ValueType-independent way to tell "Tag holds a real - // long numeric string" from "Tag holds a handle" once the pattern-backed - // exemption above does not apply — capturing it as-is (possibly a raw - // handle string) is the lesser failure next to silently dropping an + // State properties (Tag, Source, AutomationProperties.*, Visibility, + // ...) are captured regardless of value shape, per + // xaml_is_state_property's contract: AutomationProperties.Name/ + // AutomationId/HelpText are always genuinely string-typed in XAML, so a + // long numeric AutomationId (a very plausible generated id) must not be + // mistaken for a handle. Tag and Source can hold arbitrary + // reference-typed values too, but this function has no + // ValueType-independent way to tell "Tag holds a real long numeric + // string" from "Tag holds a handle" once the pattern-backed exemption + // above does not apply — capturing it as-is (possibly a raw handle + // string) is the lesser failure next to silently dropping an // AutomationId, which is the property this list exists to protect. - if (xaml_is_text_property(name)) { - if (xaml_looks_like_handle(value, valueType)) - return false; - return xaml_is_string_value_type(valueType); - } - return xaml_is_state_property(name); + if (xaml_is_state_property(name)) + return true; + // Every other property — not just the curated text-property list + // (Text/Content/Header/...), but *any* named property XAML diagnostics + // reports — goes through the same check: this is what makes the + // property panel show a control's full set of properties (FontSize, + // Opacity, Margin, a custom DP, ...) rather than a handful of + // hand-picked names, while still guarding against XAML's opaque + // reference-typed handle IDs the same way a text property always has. + // A confirmed primitive ValueType (String/Boolean/Int32/Double/Enum, or + // empty/unconfirmed) is accepted outright unless the value's shape + // looks like a handle; anything with a ValueType naming an actual + // reference type (a control, a brush, a transform, ...) is excluded by + // xaml_is_string_value_type returning false for it — there is no + // meaningful flat string to show for those anyway. + if (xaml_looks_like_handle(value, valueType)) + return false; + return xaml_is_string_value_type(valueType); } } // namespace lvt + diff --git a/src/tree_builder.cpp b/src/tree_builder.cpp index 3786aa6..1231a1c 100644 --- a/src/tree_builder.cpp +++ b/src/tree_builder.cpp @@ -179,7 +179,8 @@ void trim_to_depth(Element& root, int maxDepth) { } Element build_tree(HWND hwnd, DWORD pid, const std::vector& frameworks, - int maxDepth, const std::string& pluginOption) { + int maxDepth, const std::string& pluginOption, bool fastProperties, + const ConnectionLookup& connectionLookup) { // Start with the Win32 provider as the base — it always applies Win32Provider win32; Element root = win32.build(hwnd, maxDepth); @@ -195,14 +196,45 @@ Element build_tree(HWND hwnd, DWORD pid, const std::vector& frame case Framework::Xaml: { #if LVT_ENABLE_XAML XamlProvider xaml; - xaml.enrich(root, hwnd, pid); + auto connection = connectionLookup ? connectionLookup("xaml") : nullptr; + if (connection && connection->is_alive()) { + xaml.enrich_with_connection(root, *connection, fastProperties); + } else if (!connectionLookup) { + // No ConnectionLookup at all means this is a one-shot CLI + // call (dump/query/screenshot) that never acquired a + // persistent connection by design - a single inject-collect- + // disconnect is the correct, minimal-footprint behavior here. + xaml.enrich(root, hwnd, pid, fastProperties); + } + // else: a ConnectionLookup was supplied (watch/MCP) but has no + // alive connection for "xaml" right now. Skip enrichment for + // this call rather than silently falling back to the one-shot + // path above - that fallback is what silently reintroduced + // permanent per-tick reinjection once a connection died, since + // it looks identical to a slow-but-working tick from the + // caller's side. The connection owner (refresh_dead_watch_ + // connections for watch, the MCP session's reconnect logic) + // is responsible for re-acquiring a fresh persistent connection + // before the next tick/call; this one just comes back with + // whatever the tree already had for this framework. #endif break; } case Framework::WinUI3: { #if LVT_ENABLE_WINUI3 WinUI3Provider winui3; - winui3.enrich(root, hwnd, pid); + auto connection = connectionLookup ? connectionLookup("winui3") : nullptr; + if (connection && connection->is_alive()) { + winui3.enrich_with_connection(root, *connection, fastProperties); + } else if (!connectionLookup) { + // See the matching comment in the Xaml case above: no + // lookup at all means a one-shot CLI call, where a single + // inject-collect-disconnect is correct by design. + winui3.enrich(root, hwnd, pid, fastProperties); + } + // else: lookup was supplied (watch/MCP) but returned no alive + // connection - skip rather than silently reinject; see the + // Xaml case above for the full rationale. #endif break; } @@ -228,7 +260,17 @@ Element build_tree(HWND hwnd, DWORD pid, const std::vector& frame pf.name = fi.name; pf.version = fi.version; pf.plugin = &p; - enrich_with_plugin(root, hwnd, pid, pf, pluginOption); + // A plugin's persistent connection (see plugin.h's + // optional v2 functions) is looked up under its own + // detected name, same as "xaml"/"winui3" - a caller + // that acquired one via open_plugin_connection supplies + // it through the same ConnectionLookup mechanism. + auto connection = connectionLookup ? connectionLookup(fi.name) : nullptr; + if (connection && connection->is_alive()) { + connection->get_tree(root, fastProperties, pluginOption); + } else { + enrich_with_plugin(root, hwnd, pid, pf, pluginOption); + } break; } } diff --git a/src/tree_builder.h b/src/tree_builder.h index 6612f52..82eed16 100644 --- a/src/tree_builder.h +++ b/src/tree_builder.h @@ -1,15 +1,43 @@ #pragma once #include "element.h" #include "framework_detector.h" +#include "providers/framework_connection.h" +#include +#include #include #include #include namespace lvt { +// Looks up an already-established, reusable connection for a given +// framework label ("xaml"/"winui3"), instead of build_tree re-injecting a +// fresh one-shot connection every call. Returns nullptr (or is left unset +// entirely) to keep today's one-shot-per-call behavior - this is how a +// one-shot CLI command (dump/query/screenshot) still works unchanged; only +// a caller that holds a connection across many build_tree calls (watch's +// loop, an MCP session - see connection_registry.h) supplies one. +// +// Returns a raw pointer, not a shared_ptr: the callback is only ever used +// synchronously within one build_tree call, and the caller supplying it +// already holds the real, refcounted ownership via a ConnectionHandle for +// as long as its own loop/session runs - returning a shared_ptr here would +// invite a stray copy to outlive that handle and the registry's own +// bookkeeping, which is exactly the kind of "forgot to release" bug this +// whole mechanism exists to avoid. +using ConnectionLookup = std::function; + // Build a unified visual tree from the given HWND using detected frameworks. +// `fastProperties` skips IVisualTreeService::GetPropertyValuesChain for +// XAML/WinUI3 elements (the dominant per-element cost of a rich tree) in +// favor of cheaper direct WinRT property reads — see +// xaml_diag_common.h's inject_and_collect_xaml_tree for exactly what that +// trades away. Defaults to false (today's exhaustive property collection), +// unaffected for every non-XAML/WinUI3 provider. Element build_tree(HWND hwnd, DWORD pid, const std::vector& frameworks, - int maxDepth = -1, const std::string& pluginOption = {}); + int maxDepth = -1, const std::string& pluginOption = {}, + bool fastProperties = false, + const ConnectionLookup& connectionLookup = {}); // Assign deterministic element IDs (e0, e1, ...) in depth-first order. void assign_element_ids(Element& root); diff --git a/src/viewer/LvtViewer/App.xaml b/src/viewer/LvtViewer/App.xaml new file mode 100644 index 0000000..a09cd14 --- /dev/null +++ b/src/viewer/LvtViewer/App.xaml @@ -0,0 +1,9 @@ + + + + + diff --git a/src/viewer/LvtViewer/App.xaml.cs b/src/viewer/LvtViewer/App.xaml.cs new file mode 100644 index 0000000..68d0509 --- /dev/null +++ b/src/viewer/LvtViewer/App.xaml.cs @@ -0,0 +1,43 @@ +using System; +using System.Configuration; +using System.Data; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Threading; +using LvtViewer.Services; + +namespace LvtViewer; + +/// +/// Interaction logic for App.xaml +/// +public partial class App : Application +{ + public App() + { + // No logging existed anywhere in the viewer before this — a crash or + // a live-only bug (a tree rebuild that disrupts navigation, the + // crosshair picker going unexpectedly disabled) had no trail to + // diagnose from afterward. These three handlers are what let an + // otherwise-silent crash leave a record instead of just vanishing. + DispatcherUnhandledException += (_, e) => + { + Logger.LogException("app", "Unhandled UI-thread exception", e.Exception); + // Intentionally not marking e.Handled = true: swallowing it would + // hide the crash's real cause from the user too, and the log + // entry above already preserves it either way. + }; + AppDomain.CurrentDomain.UnhandledException += (_, e) => + { + if (e.ExceptionObject is Exception ex) + Logger.LogException("app", "Unhandled non-UI-thread exception", ex); + }; + TaskScheduler.UnobservedTaskException += (_, e) => + { + Logger.LogException("app", "Unobserved task exception", e.Exception); + e.SetObserved(); + }; + Logger.Log("app", $"Starting, log file: {Logger.Path_}"); + } +} + diff --git a/src/viewer/LvtViewer/AssemblyInfo.cs b/src/viewer/LvtViewer/AssemblyInfo.cs new file mode 100644 index 0000000..cc29e7f --- /dev/null +++ b/src/viewer/LvtViewer/AssemblyInfo.cs @@ -0,0 +1,10 @@ +using System.Windows; + +[assembly:ThemeInfo( + ResourceDictionaryLocation.None, //where theme specific resource dictionaries are located + //(used if a resource is not found in the page, + // or application resource dictionaries) + ResourceDictionaryLocation.SourceAssembly //where the generic resource dictionary is located + //(used if a resource is not found in the page, + // app, or any theme specific resource dictionaries) +)] diff --git a/src/viewer/LvtViewer/Assets/LvtViewer.ico b/src/viewer/LvtViewer/Assets/LvtViewer.ico new file mode 100644 index 0000000..ce02029 Binary files /dev/null and b/src/viewer/LvtViewer/Assets/LvtViewer.ico differ diff --git a/src/viewer/LvtViewer/Assets/LvtViewer.svg b/src/viewer/LvtViewer/Assets/LvtViewer.svg new file mode 100644 index 0000000..37e49cb --- /dev/null +++ b/src/viewer/LvtViewer/Assets/LvtViewer.svg @@ -0,0 +1,29 @@ + + + lvt Viewer application icon + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/viewer/LvtViewer/Converters/EditKindToVisibilityConverter.cs b/src/viewer/LvtViewer/Converters/EditKindToVisibilityConverter.cs new file mode 100644 index 0000000..fde4575 --- /dev/null +++ b/src/viewer/LvtViewer/Converters/EditKindToVisibilityConverter.cs @@ -0,0 +1,26 @@ +using System; +using System.Globalization; +using System.Windows; +using System.Windows.Data; +using LvtViewer.ViewModels; + +namespace LvtViewer.Converters; + +/// +/// Shows an element in the property panel's row template only when the +/// row's matches the converter parameter +/// ("Toggle" or "TextValue"), so one DataTemplate can host all three +/// row shapes without a DataTemplateSelector. +/// +public sealed class EditKindToVisibilityConverter : IValueConverter +{ + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + if (value is not PropertyEditKind kind || parameter is not string wanted) + return Visibility.Collapsed; + return kind.ToString() == wanted ? Visibility.Visible : Visibility.Collapsed; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + => throw new NotSupportedException(); +} diff --git a/src/viewer/LvtViewer/Converters/FrameworkToBrushConverter.cs b/src/viewer/LvtViewer/Converters/FrameworkToBrushConverter.cs new file mode 100644 index 0000000..68dc3f4 --- /dev/null +++ b/src/viewer/LvtViewer/Converters/FrameworkToBrushConverter.cs @@ -0,0 +1,33 @@ +using System; +using System.Globalization; +using System.Windows.Data; +using System.Windows.Media; + +namespace LvtViewer.Converters; + +/// Maps an lvt Element's "framework" string to a small color swatch in the tree view. +public sealed class FrameworkToBrushConverter : IValueConverter +{ + private static readonly Brush Default = Brushes.Gray; + + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + var framework = (value as string ?? "").ToLowerInvariant(); + return framework switch + { + "win32" => Brushes.SlateGray, + "comctl" => Brushes.SteelBlue, + "uia" => Brushes.MediumPurple, + "xaml" => Brushes.DarkOrange, + "winui3" => Brushes.OrangeRed, + "wpf" => Brushes.MediumSeaGreen, + "winforms" => Brushes.Goldenrod, + "avalonia" => Brushes.DeepPink, + "chromium" => Brushes.DodgerBlue, + _ => Default, + }; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + => throw new NotSupportedException(); +} diff --git a/src/viewer/LvtViewer/Converters/InverseBooleanToVisibilityConverter.cs b/src/viewer/LvtViewer/Converters/InverseBooleanToVisibilityConverter.cs new file mode 100644 index 0000000..1b5b0f0 --- /dev/null +++ b/src/viewer/LvtViewer/Converters/InverseBooleanToVisibilityConverter.cs @@ -0,0 +1,20 @@ +using System; +using System.Globalization; +using System.Windows; +using System.Windows.Data; + +namespace LvtViewer.Converters; + +/// +/// Inverse of the standard BooleanToVisibilityConverter: true -> Collapsed, +/// false -> Visible. Used for the "editing needs UI Automation tree" hint +/// (item 3), which should show only when NOT in UIA mode. +/// +public sealed class InverseBooleanToVisibilityConverter : IValueConverter +{ + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + => value is bool b && b ? Visibility.Collapsed : Visibility.Visible; + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + => throw new NotSupportedException(); +} diff --git a/src/viewer/LvtViewer/Interop/CrosshairPicker.cs b/src/viewer/LvtViewer/Interop/CrosshairPicker.cs new file mode 100644 index 0000000..c2d0393 --- /dev/null +++ b/src/viewer/LvtViewer/Interop/CrosshairPicker.cs @@ -0,0 +1,195 @@ +using System; +using System.Windows; +using System.Windows.Input; +using System.Windows.Interop; + +namespace LvtViewer.Interop; + +/// +/// Implements Inspect.exe-style "viewfinder" targeting: press-and-drag a +/// crosshair handle; while dragging, whatever top-level window is under the +/// cursor is highlighted; on release, that window is resolved to a PID/HWND +/// and reported via . +/// +public sealed class CrosshairPicker +{ + private readonly FrameworkElement _handle; + private readonly Window _ownerWindow; + private readonly HighlightOverlay _overlay = new(); + private bool _dragging; + private IntPtr _lastHighlighted = IntPtr.Zero; + + public event Action? TargetPicked; + + /// Fires with a short hint whenever dragging starts/stops, for a status-bar cue. + public event Action? HintChanged; + + public CrosshairPicker(FrameworkElement handle, Window ownerWindow) + { + _handle = handle; + _ownerWindow = ownerWindow; + _handle.MouseLeftButtonDown += OnMouseDown; + _handle.MouseMove += OnMouseMove; + _handle.MouseLeftButtonUp += OnMouseUp; + _handle.LostMouseCapture += OnLostCapture; + // Mouse capture does not affect keyboard focus, so Escape has to be + // caught at the window level rather than on _handle itself. + _ownerWindow.PreviewKeyDown += OnPreviewKeyDown; + } + + private void OnMouseDown(object sender, MouseButtonEventArgs e) + { + _dragging = true; + _handle.CaptureMouse(); + _handle.Cursor = Cursors.Cross; + HintChanged?.Invoke("Release over a window to inspect it… (Esc to cancel)"); + e.Handled = true; + } + + private void OnMouseMove(object sender, MouseEventArgs e) + { + if (!_dragging) + return; + UpdateHighlight(); + } + + private void OnMouseUp(object sender, MouseButtonEventArgs e) + { + if (!_dragging) + return; + _dragging = false; + _handle.ReleaseMouseCapture(); + var hwnd = ResolveWindowUnderCursor(); + HideHighlight(); + if (hwnd != IntPtr.Zero) + TargetPicked?.Invoke(hwnd); + else + HintChanged?.Invoke("No window was under the cursor on release. Drag the crosshair onto a window to inspect it."); + } + + private void OnLostCapture(object sender, MouseEventArgs e) + { + _dragging = false; + _handle.Cursor = null; + HideHighlight(); + } + + private void OnPreviewKeyDown(object sender, KeyEventArgs e) + { + if (!_dragging || e.Key != Key.Escape) + return; + e.Handled = true; + HintChanged?.Invoke("Cancelled. Drag the crosshair onto a window to inspect it."); + // Releasing capture routes through OnLostCapture, which already + // does the rest of the cancel (clear _dragging, hide the highlight, + // restore the cursor) — no need to duplicate that here. + _handle.ReleaseMouseCapture(); + } + + private void UpdateHighlight() + { + var hwnd = ResolveWindowUnderCursor(); + if (hwnd == _lastHighlighted) + return; + _lastHighlighted = hwnd; + + if (hwnd == IntPtr.Zero) + { + HideHighlight(); + return; + } + + // Tracks whichever window is now under the cursor — Track() owns + // it, positions it, and starts polling for occlusion/minimize so + // the preview correctly disappears if something covers the + // candidate window mid-drag (see HighlightOverlay's class comment). + var rect = NativeMethods.GetVisibleFrame(hwnd); + _overlay.Track(hwnd, rect); + } + + private void HideHighlight() + { + _lastHighlighted = IntPtr.Zero; + if (_overlay.IsVisible) + _overlay.Hide(); + } + + /// + /// Finds the topmost window actually visible at the cursor — not just + /// "whatever WindowFromPoint returns", which only reports Z-order among + /// windows WindowFromPoint itself considers, and does not know about + /// DWM cloaking (a UWP app on another virtual desktop, or one DWM is + /// mid-transition on, is still cloaked-but-"there" and can report a + /// completely stale rect — this was the direct cause of a highlight + /// landing nowhere near any real window). + /// + /// EnumWindows visits top-level windows in top-to-bottom Z-order, so + /// the first one that (a) is not our own toolbar/overlay, (b) is + /// visible, not minimized, and not cloaked, and (c) actually contains + /// the point is exactly the topmost visible window there — anything + /// occluded by it, however large, is correctly never reached, and a + /// minimized window (parked off-screen or not) is never a candidate at + /// all rather than incidentally excluded by its rect missing the point. + /// + private IntPtr ResolveWindowUnderCursor() + { + if (!NativeMethods.GetCursorPos(out var pt)) + return IntPtr.Zero; + + var ownHwnd = new WindowInteropHelper(_ownerWindow).Handle; + var overlayHwnd = new WindowInteropHelper(_overlay).Handle; + + var found = IntPtr.Zero; + NativeMethods.EnumWindows((hwnd, _) => + { + // The overlay is deliberately click-through and only visual + // feedback for the candidate beneath it. Once it is shown it + // covers the cursor by definition, so treating it as an + // occluder makes the next mouse move (and mouse-up) resolve to + // no target: the rectangle flashes once, then disappears and + // nothing can be selected. Always skip it. + if (hwnd == overlayHwnd) + return true; + + if (hwnd == ownHwnd) + { + // Our own UI can genuinely be the topmost thing at this + // exact point — most commonly the cursor is still over the + // crosshair handle itself right at drag-start. If so, the + // search must stop here rather than skip past us and keep + // looking further down the Z-order: continuing could + // otherwise "find" some unrelated, far-lower window whose + // rect merely happens to also span this same screen point + // (e.g. some other large/maximized app elsewhere in the + // Z-order) even though it is not actually visible here at + // all — it is covered by our own window, which the + // unconditional skip below used to ignore entirely. + // Observed live: dragging the crosshair from directly over + // the viewer's own button picked a large, fully unrelated, + // and actually-hidden-behind-the-viewer window instead of + // correctly finding nothing (or whatever genuinely was + // topmost) at that point. + var ownRect = NativeMethods.GetVisibleFrame(hwnd); + if (pt.X >= ownRect.Left && pt.X < ownRect.Right && + pt.Y >= ownRect.Top && pt.Y < ownRect.Bottom) + return false; // stop — our own UI occupies this point + return true; // not at this point; keep looking + } + + if (!NativeMethods.IsWindowVisible(hwnd) || NativeMethods.IsIconic(hwnd) || + NativeMethods.IsCloaked(hwnd)) + return true; + + var rect = NativeMethods.GetVisibleFrame(hwnd); + if (rect.Width <= 0 || rect.Height <= 0) + return true; + if (pt.X < rect.Left || pt.X >= rect.Right || pt.Y < rect.Top || pt.Y >= rect.Bottom) + return true; + + found = hwnd; + return false; // stop — found the topmost visible window at this point + }, IntPtr.Zero); + + return found; + } +} diff --git a/src/viewer/LvtViewer/Interop/ElementPicker.cs b/src/viewer/LvtViewer/Interop/ElementPicker.cs new file mode 100644 index 0000000..872a34e --- /dev/null +++ b/src/viewer/LvtViewer/Interop/ElementPicker.cs @@ -0,0 +1,87 @@ +using System; +using System.Windows; +using System.Windows.Input; + +namespace LvtViewer.Interop; + +/// +/// Press-and-drag gesture with the same interaction shape as CrosshairPicker +/// (item 2's point-to-select), but for picking an *element* within the +/// already-connected target rather than a top-level window to connect to. +/// +/// Unlike CrosshairPicker, this class reports only raw screen points: it has +/// no idea what an "element" is, and hit-testing against the live element +/// tree is lvt-specific data this class deliberately stays decoupled from +/// (see MainWindow.FindDeepestElementAt / SelectElementInTree, which own +/// that logic instead). +/// +public sealed class ElementPicker +{ + private readonly FrameworkElement _handle; + private bool _dragging; + + /// Fires continuously while dragging, with the current cursor position. + public event Action? Dragging; + + /// Fires once on release; null only if the cursor position could not be read. + public event Action? Picked; + + /// Fires with a short hint whenever dragging starts/stops, for a status-bar cue. + public event Action? HintChanged; + + public ElementPicker(FrameworkElement handle, Window ownerWindow) + { + _handle = handle; + _handle.MouseLeftButtonDown += OnMouseDown; + _handle.MouseMove += OnMouseMove; + _handle.MouseLeftButtonUp += OnMouseUp; + _handle.LostMouseCapture += OnLostCapture; + // Mouse capture does not affect keyboard focus, so Escape has to be + // caught at the window level rather than on _handle itself. + ownerWindow.PreviewKeyDown += OnPreviewKeyDown; + } + + private void OnMouseDown(object sender, MouseButtonEventArgs e) + { + _dragging = true; + _handle.CaptureMouse(); + _handle.Cursor = Cursors.Cross; + HintChanged?.Invoke("Release over the target's UI to select that element in the tree… (Esc to cancel)"); + e.Handled = true; + } + + private void OnMouseMove(object sender, MouseEventArgs e) + { + if (!_dragging) + return; + if (NativeMethods.GetCursorPos(out var pt)) + Dragging?.Invoke(pt); + } + + private void OnMouseUp(object sender, MouseButtonEventArgs e) + { + if (!_dragging) + return; + _dragging = false; + _handle.ReleaseMouseCapture(); + POINT? result = NativeMethods.GetCursorPos(out var pt) ? pt : null; + Picked?.Invoke(result); + } + + private void OnLostCapture(object sender, MouseEventArgs e) + { + _dragging = false; + _handle.Cursor = null; + } + + private void OnPreviewKeyDown(object sender, KeyEventArgs e) + { + if (!_dragging || e.Key != Key.Escape) + return; + e.Handled = true; + HintChanged?.Invoke("Cancelled."); + // Releasing capture routes through OnLostCapture, which already + // clears _dragging and restores the cursor — nothing else to do. + _handle.ReleaseMouseCapture(); + } +} diff --git a/src/viewer/LvtViewer/Interop/HighlightOverlay.xaml b/src/viewer/LvtViewer/Interop/HighlightOverlay.xaml new file mode 100644 index 0000000..295bc99 --- /dev/null +++ b/src/viewer/LvtViewer/Interop/HighlightOverlay.xaml @@ -0,0 +1,13 @@ + + + diff --git a/src/viewer/LvtViewer/Interop/HighlightOverlay.xaml.cs b/src/viewer/LvtViewer/Interop/HighlightOverlay.xaml.cs new file mode 100644 index 0000000..1e785ee --- /dev/null +++ b/src/viewer/LvtViewer/Interop/HighlightOverlay.xaml.cs @@ -0,0 +1,269 @@ +using System; +using System.Runtime.InteropServices; +using System.Windows; +using System.Windows.Interop; +using System.Windows.Media; +using System.Windows.Threading; + +namespace LvtViewer.Interop; + +/// +/// A borderless, click-through window that draws a highlight rectangle +/// around whatever top-level window the crosshair drag is currently +/// hovering — the same visual feedback Inspect.exe gives while its +/// viewfinder is being dragged. +/// +/// Deliberately NOT WS_EX_TOPMOST/Topmost="True": an earlier version used +/// that, which kept the highlight visible on top of *everything* even when +/// the window it was supposedly highlighting was itself minimized or +/// covered by some unrelated window — Topmost places a window in its own +/// always-on-top band, entirely independent of whatever it is meant to be +/// annotating. Instead, SetOwner makes this window a native Win32-owned +/// window of whichever HWND it is currently highlighting, which gives two +/// things for free: Windows hides an owned window automatically when its +/// owner minimizes, and it never lets the owner get activated *above* its +/// owned windows. +/// +/// It does NOT, however, guarantee staying *below* whatever unrelated +/// window already happens to be above the owner — Win32 only promises +/// "above the owner", not "sandwiched directly between the owner and +/// whatever covers it" (confirmed against Microsoft's own SetWindowPos +/// docs and observed live: the highlight kept showing through a covering +/// app because any subsequent z-order recalculation re-snapped it directly +/// above its owner, regardless of what unrelated window had been on top a +/// moment before). So Track() below also runs an actual occlusion check +/// (NativeMethods.IsOccludedAt) on a timer and hides the window whenever +/// something real is genuinely covering the target, instead of trusting +/// z-order alone to keep it out of sight. +/// +public partial class HighlightOverlay : Window +{ + private const int GWL_EXSTYLE = -20; + private const int GWLP_HWNDPARENT = -8; + private const int WS_EX_TRANSPARENT = 0x00000020; + private const int WS_EX_LAYERED = 0x00080000; + private const int WS_EX_TOOLWINDOW = 0x00000080; + private const int WS_EX_NOACTIVATE = 0x08000000; + + private const uint SWP_NOSIZE = 0x0001; + private const uint SWP_NOMOVE = 0x0002; + private const uint SWP_NOACTIVATE = 0x0010; + private const uint SWP_NOZORDER = 0x0004; + // Without this, SetWindowPos on an *owned* window is free to also + // reposition its *owner* in the z-order as a side effect (documented + // Win32 behavior) — observed live as other apps' windows visibly + // shuffling z-order every time the crosshair drag moved to a new + // candidate window, since each move called SetOwner + SetWindowPos + // again. This flag pins the change to the overlay alone. + private const uint SWP_NOOWNERZORDER = 0x0200; + + // How often Track() re-checks whether the target is still actually + // visible at its last-known rect (not minimized, not covered by some + // other app) while the highlight is active. Cheap (one EnumWindows + // walk) and short enough that covering/uncovering the target reads as + // immediate, not laggy. + private static readonly TimeSpan PollInterval = TimeSpan.FromMilliseconds(200); + + [DllImport("user32.dll")] + private static extern int GetWindowLong(IntPtr hwnd, int index); + + [DllImport("user32.dll")] + private static extern int SetWindowLong(IntPtr hwnd, int index, int value); + + // GWLP_HWNDPARENT stores a window handle, which is pointer-sized (64-bit + // on x64 — this whole project is x64-only, but the distinction still + // matters here specifically): the plain 32-bit SetWindowLong/GetWindowLong + // pair above is fine for GWL_EXSTYLE (a genuinely 32-bit style bitmask) + // but would silently truncate an HWND passed through it, so the owner + // relationship needs its own, pointer-width pair. + [DllImport("user32.dll", EntryPoint = "SetWindowLongPtr")] + private static extern IntPtr SetWindowLongPtr(IntPtr hwnd, int index, IntPtr value); + + [DllImport("user32.dll")] + private static extern bool SetWindowPos(IntPtr hWnd, IntPtr hWndInsertAfter, + int x, int y, int cx, int cy, uint uFlags); + + private IntPtr _owner = IntPtr.Zero; + private IntPtr _lastTarget = IntPtr.Zero; + private RECT _lastFrame; + private DispatcherTimer? _pollTimer; + + public HighlightOverlay() + { + InitializeComponent(); + SourceInitialized += OnSourceInitialized; + } + + private void OnSourceInitialized(object? sender, EventArgs e) + { + var hwnd = new WindowInteropHelper(this).Handle; + var style = GetWindowLong(hwnd, GWL_EXSTYLE); + SetWindowLong(hwnd, GWL_EXSTYLE, + style | WS_EX_TRANSPARENT | WS_EX_LAYERED | WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE); + } + + /// + /// Makes this window a native owned window of + /// — see the class comment for why this replaces Topmost. Called from + /// Track() below, not meant to be called directly by either caller + /// anymore. + /// + private void SetOwner(IntPtr targetHwnd) + { + if (targetHwnd == IntPtr.Zero || targetHwnd == _owner) + return; + _owner = targetHwnd; + + var hwnd = new WindowInteropHelper(this).EnsureHandle(); + SetWindowLongPtr(hwnd, GWLP_HWNDPARENT, targetHwnd); + + // Changing GWLP_HWNDPARENT only updates which window this one is now + // considered owned by; the OS re-enforces "an owned window stays + // directly above its owner" the next time the *owner's* own + // z-position changes, not necessarily the instant ownership itself + // is reassigned. Without this explicit repositioning, retargeting + // the highlight onto a new window could leave it showing at + // whatever z-position it last held — e.g. still on top of some + // unrelated window that happens to cover the new target — until + // something else nudges the target's z-order. SWP_NOOWNERZORDER + // keeps this from also moving targetHwnd itself in the z-order as + // a side effect (see its own comment above). + SetWindowPos(hwnd, targetHwnd, 0, 0, 0, 0, + SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE | SWP_NOOWNERZORDER); + } + + /// + /// Points the highlight at / — the single entry point both callers + /// (CrosshairPicker, which retargets on every dragged-over window, and + /// MainWindow, which retargets whenever the connected target or the + /// selected element's host changes) use instead of calling SetOwner/ + /// MoveTo/Show individually. Owning + positioning alone is not enough + /// (see the class comment on why z-order can't guarantee staying below + /// a covering app), so this also starts a poll timer that re-checks + /// occlusion on its own — needed because nothing else tells this class + /// when some *unrelated* app gets brought to the front over the target + /// in between calls here. + /// + public void Track(IntPtr targetHwnd, RECT physicalFrame) + { + _lastTarget = targetHwnd; + _lastFrame = physicalFrame; + SetOwner(targetHwnd); + + if (_pollTimer == null) + { + _pollTimer = new DispatcherTimer { Interval = PollInterval }; + _pollTimer.Tick += (_, _) => Reevaluate(); + } + _pollTimer.Start(); + + Reevaluate(); + } + + /// + /// Shows/repositions the highlight if the target is still actually + /// visible at its last-known rect, or hides it if the target was + /// minimized, destroyed, or is now covered by some other real window — + /// see the class comment for why this check can't be replaced by + /// ownership/z-order alone. + /// + private void Reevaluate() + { + if (_lastTarget == IntPtr.Zero) + return; + + if (!NativeMethods.IsWindow(_lastTarget) || NativeMethods.IsIconic(_lastTarget)) + { + base.Hide(); + return; + } + + var center = new POINT + { + X = _lastFrame.Left + _lastFrame.Width / 2, + Y = _lastFrame.Top + _lastFrame.Height / 2, + }; + if (NativeMethods.IsOccludedAt(_lastTarget, center)) + { + base.Hide(); + return; + } + + MoveTo(_lastFrame); + if (Visibility != Visibility.Visible) + Show(); + } + + /// + /// Stops tracking entirely — shadows Window.Hide() (both call sites + /// use the declared HighlightOverlay type, so this is what actually + /// runs) so that deliberately hiding the highlight (drag ended, no + /// element under the cursor, selection cleared, ...) also stops the + /// poll timer instead of leaving it running and re-showing the + /// highlight on the next tick. + /// + public new void Hide() + { + _lastTarget = IntPtr.Zero; + _pollTimer?.Stop(); + base.Hide(); + } + + /// + /// Repositions the highlight over an absolute-screen rectangle that is + /// already in true physical pixels — this class does no DPI conversion + /// of its own and must not, because its two callers' rects do not start + /// out in the same coordinate space: + /// + /// - CrosshairPicker calls NativeMethods.GetVisibleFrame(hwnd) directly, + /// from this (Per-Monitor-V2 DPI aware, the .NET default) process, so + /// that rect is already true physical pixels with no conversion + /// needed. + /// - MainWindow builds a rect from ElementNodeViewModel's Bounds* + /// fields, which ultimately came from lvt.exe — a plain console app + /// with no DPI-awareness declaration, so Windows silently virtualizes + /// every Win32 coordinate query it makes down to a 96-DPI-equivalent + /// space. That rect needs scaling up to physical pixels *before* it + /// reaches this method (see MainWindow.ToPhysicalRect). + /// + /// An earlier version of this method applied that lvt-specific scaling + /// unconditionally, which was correct for the second caller and broke + /// the first: it double-scaled CrosshairPicker's already-physical rect, + /// observed live as the crosshair-drag preview highlight landing + /// nowhere near the actual window boundary. Converting at each call + /// site instead, rather than here, is what lets this method make a + /// single unconditional assumption (true physical pixels in) instead of + /// somehow needing to know which caller it is being invoked from. + /// + public void MoveTo(RECT physicalFrame) + { + var hwnd = new WindowInteropHelper(this).EnsureHandle(); + + int width = Math.Max(0, physicalFrame.Width); + int height = Math.Max(0, physicalFrame.Height); + SetWindowPos(hwnd, IntPtr.Zero, physicalFrame.Left, physicalFrame.Top, width, height, + SWP_NOACTIVATE | SWP_NOZORDER); + + // SetWindowPos moves the raw HWND, but WPF's own composition/render + // pipeline tracks position and size through this Window's *own* + // Left/Top/Width/Height DPs, entirely independent of the HWND's + // actual Win32 position — SetWindowPos alone leaves that WPF-side + // state stale. Observed live: the highlight stopped visually + // following the target window as it moved, and only caught up once + // something else (refocusing the viewer) forced WPF to redraw from + // scratch. Syncing Left/Top/Width/Height here, right after the + // move, keeps WPF's own understanding of where it is consistent + // with reality, which is what makes it keep rendering continuously + // on its own. GetDpi is queried *after* SetWindowPos specifically + // so it reflects whichever monitor the window is on *now* — before + // the move, it would still reflect the old one, reintroducing the + // cross-monitor mismatch the physical-pixel-first design here + // exists to avoid. + var dpi = VisualTreeHelper.GetDpi(this); + Left = physicalFrame.Left / dpi.DpiScaleX; + Top = physicalFrame.Top / dpi.DpiScaleY; + Width = width / dpi.DpiScaleX; + Height = height / dpi.DpiScaleY; + } +} diff --git a/src/viewer/LvtViewer/Interop/NativeMethods.cs b/src/viewer/LvtViewer/Interop/NativeMethods.cs new file mode 100644 index 0000000..d0bb241 --- /dev/null +++ b/src/viewer/LvtViewer/Interop/NativeMethods.cs @@ -0,0 +1,204 @@ +using System; +using System.Runtime.InteropServices; + +namespace LvtViewer.Interop; + +[StructLayout(LayoutKind.Sequential)] +public struct POINT +{ + public int X; + public int Y; +} + +[StructLayout(LayoutKind.Sequential)] +public struct RECT +{ + public int Left; + public int Top; + public int Right; + public int Bottom; + + public int Width => Right - Left; + public int Height => Bottom - Top; +} + +/// +/// P/Invoke declarations backing the crosshair-drag window picker +/// (Interop/CrosshairPicker.cs), which resolves a screen point to the +/// topmost actually-visible top-level window there via EnumWindows (Z-order) +/// filtered by IsWindowVisible/IsIconic/IsCloaked and a rect hit test — see +/// CrosshairPicker.ResolveWindowUnderCursor for why WindowFromPoint alone +/// is not enough. +/// +public static class NativeMethods +{ + public const uint GA_ROOT = 2; + + // DWMWA_EXTENDED_FRAME_BOUNDS gives the visible window rectangle + // (excluding the invisible resize-border padding Windows 10/11 add + // around top-level windows), which is what should be highlighted — + // GetWindowRect alone would draw the highlight noticeably outside the + // window's visible edge. + public const int DWMWA_EXTENDED_FRAME_BOUNDS = 9; + + // A cloaked window (DWM hides it — a UWP app on another virtual desktop, + // or one DWM is mid-transition on) is still a perfectly valid HWND that + // WindowFromPoint/EnumWindows will happily return, but it is not what + // the user can actually see on screen, and its rect can be stale + // garbage from whenever it was last actually shown. Skipping cloaked + // windows is what keeps the crosshair from ever picking one. + public const int DWMWA_CLOAKED = 14; + + [DllImport("user32.dll")] + public static extern bool GetCursorPos(out POINT point); + + [DllImport("user32.dll")] + public static extern IntPtr WindowFromPoint(POINT point); + + [DllImport("user32.dll")] + public static extern IntPtr GetAncestor(IntPtr hwnd, uint flags); + + [DllImport("user32.dll")] + public static extern uint GetWindowThreadProcessId(IntPtr hwnd, out uint processId); + + [DllImport("user32.dll")] + public static extern bool GetWindowRect(IntPtr hwnd, out RECT rect); + + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + public static extern int GetWindowText(IntPtr hwnd, System.Text.StringBuilder text, int maxCount); + + [DllImport("user32.dll")] + public static extern bool IsWindow(IntPtr hwnd); + + [DllImport("user32.dll")] + public static extern bool IsIconic(IntPtr hwnd); + + [DllImport("user32.dll")] + public static extern bool IsWindowVisible(IntPtr hwnd); + + [DllImport("kernel32.dll")] + public static extern uint GetCurrentProcessId(); + + public delegate bool EnumWindowsProc(IntPtr hwnd, IntPtr lParam); + + // Enumerates top-level windows in top-to-bottom Z-order — exactly the + // order a hit test needs to try them in, so the first one whose rect + // contains the point (after skipping minimized/invisible/cloaked ones) + // is correctly the topmost *visible* window at that point, not merely + // the topmost window of any kind. + [DllImport("user32.dll")] + public static extern bool EnumWindows(EnumWindowsProc callback, IntPtr lParam); + + [DllImport("dwmapi.dll")] + public static extern int DwmGetWindowAttribute(IntPtr hwnd, int attribute, out RECT value, int size); + + [DllImport("dwmapi.dll", EntryPoint = "DwmGetWindowAttribute")] + public static extern int DwmGetWindowAttributeInt(IntPtr hwnd, int attribute, out int value, int size); + + [DllImport("user32.dll")] + public static extern uint GetDpiForSystem(); + + /// + /// lvt.exe is a plain console app with no DPI-awareness declaration, so + /// Windows silently virtualizes every Win32 coordinate query it makes + /// (GetWindowRect and friends) down to a 96-DPI-equivalent space — + /// verified live on a 150%-scaled system: lvt.exe reported a window's + /// rect scaled down by exactly 1/1.5 from its true physical rect. Every + /// bounds value the viewer gets *from lvt* (ElementNodeViewModel's + /// Bounds* fields, ultimately from a `lvt watch`/`dump` JSON payload) is + /// in that same virtualized space, and must be scaled by this factor + /// before it can be compared against or used to position anything this + /// (Per-Monitor-V2 DPI aware, the .NET default) process gets directly + /// from Win32 itself — e.g. GetCursorPos, or another window's + /// GetVisibleFrame — which are already true physical pixels needing no + /// conversion at all. Two real bugs came from conflating these: the + /// selection highlight landing nowhere near the actual element (lvt's + /// virtualized bounds used as if already physical), and — after a first + /// attempt fixed that by scaling unconditionally inside HighlightOverlay + /// — the crosshair-drag preview highlight breaking instead (it was + /// already-physical, and got double-scaled). + /// + public static double LvtToPhysicalDpiScale => GetDpiForSystem() / 96.0; + + /// The visible frame of , preferring DWM's extended frame bounds. + public static RECT GetVisibleFrame(IntPtr hwnd) + { + if (DwmGetWindowAttribute(hwnd, DWMWA_EXTENDED_FRAME_BOUNDS, out var dwmRect, + Marshal.SizeOf()) == 0) + { + return dwmRect; + } + GetWindowRect(hwnd, out var rect); + return rect; + } + + public static string GetWindowTitle(IntPtr hwnd) + { + var sb = new System.Text.StringBuilder(512); + GetWindowText(hwnd, sb, sb.Capacity); + return sb.ToString(); + } + + /// Whether DWM is currently hiding this window — see DWMWA_CLOAKED's comment. + public static bool IsCloaked(IntPtr hwnd) => + DwmGetWindowAttributeInt(hwnd, DWMWA_CLOAKED, out int cloaked, sizeof(int)) == 0 && cloaked != 0; + + /// + /// True if some other, actually-visible top-level window — not + /// belonging to this process, and not + /// itself — is stacked above at + /// , i.e. targetHwnd is not what the user would + /// actually see there right now. + /// + /// This exists because Win32's owned-window z-order rule ("an owned + /// window always stays above its owner") only guarantees that one + /// direction: it does not guarantee staying *below* whatever unrelated + /// window already happens to be above the owner. Any subsequent z-order + /// recalculation re-snaps an owned window directly above its owner + /// regardless of what unrelated window was on top a moment + /// before — so HighlightOverlay cannot rely on ownership/SetWindowPos + /// alone to stay hidden behind a covering app; it must actually check. + /// Mirrors CrosshairPicker.ResolveWindowUnderCursor's top-to-bottom + /// EnumWindows technique so both share one source of truth for "is my + /// target actually visible here". + /// + public static bool IsOccludedAt(IntPtr targetHwnd, POINT point) + { + uint ownPid = GetCurrentProcessId(); + bool occluded = false; + bool reachedTarget = false; + + EnumWindows((hwnd, _) => + { + if (hwnd == targetHwnd) + { + reachedTarget = true; + return false; // stop — nothing above targetHwnd covered the point + } + + // Never let our own viewer/overlay windows count as "occluding" + // the target, even if one is visually positioned over it. + GetWindowThreadProcessId(hwnd, out var pid); + if (pid == ownPid) + return true; + + if (!IsWindowVisible(hwnd) || IsIconic(hwnd) || IsCloaked(hwnd)) + return true; + + var rect = GetVisibleFrame(hwnd); + if (rect.Width <= 0 || rect.Height <= 0) + return true; + if (point.X < rect.Left || point.X >= rect.Right || + point.Y < rect.Top || point.Y >= rect.Bottom) + return true; + + occluded = true; + return false; + }, IntPtr.Zero); + + // If targetHwnd was never reached (e.g. it has since been + // destroyed, or is no longer a top-level window), treat it as + // occluded/not-visible rather than assuming it is fine to show. + return occluded || !reachedTarget; + } +} diff --git a/src/viewer/LvtViewer/LvtViewer.csproj b/src/viewer/LvtViewer/LvtViewer.csproj new file mode 100644 index 0000000..8a5e6d7 --- /dev/null +++ b/src/viewer/LvtViewer/LvtViewer.csproj @@ -0,0 +1,16 @@ + + + + WinExe + net10.0-windows + enable + enable + true + Assets\LvtViewer.ico + + + + + + + diff --git a/src/viewer/LvtViewer/MainWindow.xaml b/src/viewer/LvtViewer/MainWindow.xaml new file mode 100644 index 0000000..838b0d9 --- /dev/null +++ b/src/viewer/LvtViewer/MainWindow.xaml @@ -0,0 +1,236 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +