diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 83a774c..5baecac 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -17,13 +17,21 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Setup .NET - uses: actions/setup-dotnet@v4 + uses: actions/setup-dotnet@v6 with: dotnet-version: ${{ env.DOTNET_VERSION }} + - name: Setup MSBuild + uses: microsoft/setup-msbuild@v3 + + - name: Build native bootstrapper + run: | + msbuild src/WpfVisualTreeMcp.Bootstrapper/WpfVisualTreeMcp.Bootstrapper.vcxproj /m /p:Configuration=Release /p:Platform=x64 + msbuild src/WpfVisualTreeMcp.Bootstrapper/WpfVisualTreeMcp.Bootstrapper.vcxproj /m /p:Configuration=Release /p:Platform=Win32 + - name: Restore dependencies run: dotnet restore WpfVisualTreeMcp.sln @@ -33,8 +41,23 @@ jobs: - name: Test run: dotnet test WpfVisualTreeMcp.sln --no-build --configuration Release --verbosity normal --logger "trx;LogFileName=test-results.trx" + - name: Install x86 .NET runtime + shell: pwsh + run: | + $dotnetRoot = Join-Path $env:RUNNER_TEMP 'dotnet-x86' + $installScript = Join-Path $env:RUNNER_TEMP 'dotnet-install.ps1' + Invoke-WebRequest https://dot.net/v1/dotnet-install.ps1 -OutFile $installScript + & $installScript -Channel 8.0 -Runtime dotnet -Architecture x86 -InstallDir $dotnetRoot -NoPath + & $installScript -Channel 8.0 -Runtime windowsdesktop -Architecture x86 -InstallDir $dotnetRoot -NoPath + if (-not (Test-Path -LiteralPath (Join-Path $dotnetRoot 'dotnet.exe'))) { throw 'x86 .NET runtime installation failed.' } + "DOTNET_ROOT_X86=$dotnetRoot" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + + - name: Test WPF inspection matrix + shell: pwsh + run: ./tests/run-integration-tests.ps1 -SkipNativeBuild + - name: Upload test results - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 if: always() with: name: test-results @@ -43,8 +66,67 @@ jobs: - name: Publish MCP Server run: dotnet publish src/WpfVisualTreeMcp.Server/WpfVisualTreeMcp.Server.csproj --no-build --configuration Release --output ./publish + - name: Verify auto-injection payload + shell: pwsh + run: | + $netFxInspectorDlls = Get-ChildItem ./src/WpfVisualTreeMcp.Inspector/bin/Release/net48/*.dll + $requiredPayloadFiles = @( + './publish/native/x64/WpfInspectorBootstrapper.dll' + './publish/native/x86/WpfInspectorBootstrapper.dll' + './publish/native/x64/coreclr/WpfVisualTreeMcp.Inspector.runtimeconfig.json' + './publish/native/x86/coreclr/WpfVisualTreeMcp.Inspector.runtimeconfig.json' + foreach ($architecture in @('x64', 'x86')) { + foreach ($dll in $netFxInspectorDlls) { + "./publish/native/$architecture/$($dll.Name)" + } + } + ) + $missingPayloadFiles = @($requiredPayloadFiles | Where-Object { -not (Test-Path -LiteralPath $_) }) + if ($missingPayloadFiles.Count -gt 0) { + throw "Missing auto-injection payload files: $($missingPayloadFiles -join ', ')" + } + + - name: Pack NuGet package + run: dotnet pack src/WpfVisualTreeMcp.Server/WpfVisualTreeMcp.Server.csproj --no-build --configuration Release --output ./artifacts + + - name: Verify NuGet auto-injection payload + shell: pwsh + run: | + $package = Get-ChildItem ./artifacts/*.nupkg | Select-Object -First 1 + if ($null -eq $package) { throw 'NuGet package was not created.' } + + $netFxInspectorDllNames = Get-ChildItem ./src/WpfVisualTreeMcp.Inspector/bin/Release/net48/*.dll | + Select-Object -ExpandProperty Name + $requiredEntrySuffixes = @( + foreach ($architecture in @('x64', 'x86')) { + "/native/$architecture/WpfInspectorBootstrapper.dll" + "/native/$architecture/coreclr/WpfVisualTreeMcp.Inspector.runtimeconfig.json" + foreach ($dllName in $netFxInspectorDllNames) { + "/native/$architecture/$dllName" + } + } + ) + + Add-Type -AssemblyName System.IO.Compression.FileSystem + $archive = [System.IO.Compression.ZipFile]::OpenRead($package.FullName) + try { + $entryNames = @($archive.Entries.FullName) + $missingEntrySuffixes = @($requiredEntrySuffixes | Where-Object { + $requiredSuffix = $_ + -not ($entryNames | Where-Object { + $_.EndsWith($requiredSuffix, [StringComparison]::OrdinalIgnoreCase) + }) + }) + if ($missingEntrySuffixes.Count -gt 0) { + throw "NuGet package is missing auto-injection payload entries: $($missingEntrySuffixes -join ', ')" + } + } + finally { + $archive.Dispose() + } + - name: Upload artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: wpf-visual-tree-mcp path: ./publish @@ -54,20 +136,22 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Setup .NET - uses: actions/setup-dotnet@v4 + uses: actions/setup-dotnet@v6 with: dotnet-version: ${{ env.DOTNET_VERSION }} - # Server transitively references the WPF (net8.0-windows) Inspector, so a + # Server has a build-only reference to the WPF Inspector payload, so a # restore/format on Linux needs Windows targeting enabled. - name: Restore dependencies run: dotnet restore src/WpfVisualTreeMcp.Server/WpfVisualTreeMcp.Server.csproj -p:EnableWindowsTargeting=true - name: Check formatting run: dotnet format src/WpfVisualTreeMcp.Server/WpfVisualTreeMcp.Server.csproj --verify-no-changes --verbosity diagnostic + env: + EnableWindowsTargeting: true continue-on-error: true # Build verification for .NET Framework projects (Windows only) @@ -76,21 +160,22 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Setup .NET - uses: actions/setup-dotnet@v4 + uses: actions/setup-dotnet@v6 with: dotnet-version: ${{ env.DOTNET_VERSION }} - name: Setup MSBuild - uses: microsoft/setup-msbuild@v1.3 + uses: microsoft/setup-msbuild@v3 - name: Restore NuGet packages run: dotnet restore WpfVisualTreeMcp.sln - - name: Build .NET Framework projects + - name: Build .NET Framework targets run: | - dotnet build src/WpfVisualTreeMcp.Inspector/WpfVisualTreeMcp.Inspector.csproj --configuration Release - dotnet build src/WpfVisualTreeMcp.Injector/WpfVisualTreeMcp.Injector.csproj --configuration Release - dotnet build samples/SampleWpfApp/SampleWpfApp.csproj --configuration Release + dotnet build src/WpfVisualTreeMcp.Inspector/WpfVisualTreeMcp.Inspector.csproj --configuration Release --framework net472 + dotnet build src/WpfVisualTreeMcp.Inspector/WpfVisualTreeMcp.Inspector.csproj --configuration Release --framework net48 + dotnet build samples/SampleWpfApp/SampleWpfApp.csproj --configuration Release --framework net472 + dotnet build samples/SampleWpfApp/SampleWpfApp.csproj --configuration Release --framework net48 diff --git a/.github/workflows/publish-mcp-registry.yml b/.github/workflows/publish-mcp-registry.yml index 94de03c..19c02c8 100644 --- a/.github/workflows/publish-mcp-registry.yml +++ b/.github/workflows/publish-mcp-registry.yml @@ -20,7 +20,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Download mcp-publisher run: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3a68594..2465b0b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -22,15 +22,20 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Setup .NET - uses: actions/setup-dotnet@v4 + uses: actions/setup-dotnet@v6 with: dotnet-version: ${{ env.DOTNET_VERSION }} - name: Setup MSBuild - uses: microsoft/setup-msbuild@v1.3 + uses: microsoft/setup-msbuild@v3 + + - name: Build native bootstrapper + run: | + msbuild src/WpfVisualTreeMcp.Bootstrapper/WpfVisualTreeMcp.Bootstrapper.vcxproj /m /p:Configuration=Release /p:Platform=x64 + msbuild src/WpfVisualTreeMcp.Bootstrapper/WpfVisualTreeMcp.Bootstrapper.vcxproj /m /p:Configuration=Release /p:Platform=Win32 - name: Restore dependencies run: dotnet restore WpfVisualTreeMcp.sln @@ -44,6 +49,26 @@ jobs: - name: Publish MCP Server run: dotnet publish src/WpfVisualTreeMcp.Server/WpfVisualTreeMcp.Server.csproj --no-build --configuration Release --output ./publish/server + - name: Verify auto-injection payload + shell: pwsh + run: | + $netFxInspectorDlls = Get-ChildItem ./src/WpfVisualTreeMcp.Inspector/bin/Release/net48/*.dll + $requiredPayloadFiles = @( + './publish/server/native/x64/WpfInspectorBootstrapper.dll' + './publish/server/native/x86/WpfInspectorBootstrapper.dll' + './publish/server/native/x64/coreclr/WpfVisualTreeMcp.Inspector.runtimeconfig.json' + './publish/server/native/x86/coreclr/WpfVisualTreeMcp.Inspector.runtimeconfig.json' + foreach ($architecture in @('x64', 'x86')) { + foreach ($dll in $netFxInspectorDlls) { + "./publish/server/native/$architecture/$($dll.Name)" + } + } + ) + $missingPayloadFiles = @($requiredPayloadFiles | Where-Object { -not (Test-Path -LiteralPath $_) }) + if ($missingPayloadFiles.Count -gt 0) { + throw "Missing auto-injection payload files: $($missingPayloadFiles -join ', ')" + } + - name: Publish Inspector DLL run: dotnet publish src/WpfVisualTreeMcp.Inspector/WpfVisualTreeMcp.Inspector.csproj --configuration Release --framework net8.0-windows --output ./publish/inspector @@ -54,6 +79,42 @@ jobs: - name: Pack NuGet package run: dotnet pack src/WpfVisualTreeMcp.Server/WpfVisualTreeMcp.Server.csproj --configuration Release --output ./artifacts + - name: Verify NuGet auto-injection payload + shell: pwsh + run: | + $package = Get-ChildItem ./artifacts/*.nupkg | Select-Object -First 1 + if ($null -eq $package) { throw 'NuGet package was not created.' } + + $netFxInspectorDllNames = Get-ChildItem ./src/WpfVisualTreeMcp.Inspector/bin/Release/net48/*.dll | + Select-Object -ExpandProperty Name + $requiredEntrySuffixes = @( + foreach ($architecture in @('x64', 'x86')) { + "/native/$architecture/WpfInspectorBootstrapper.dll" + "/native/$architecture/coreclr/WpfVisualTreeMcp.Inspector.runtimeconfig.json" + foreach ($dllName in $netFxInspectorDllNames) { + "/native/$architecture/$dllName" + } + } + ) + + Add-Type -AssemblyName System.IO.Compression.FileSystem + $archive = [System.IO.Compression.ZipFile]::OpenRead($package.FullName) + try { + $entryNames = @($archive.Entries.FullName) + $missingEntrySuffixes = @($requiredEntrySuffixes | Where-Object { + $requiredSuffix = $_ + -not ($entryNames | Where-Object { + $_.EndsWith($requiredSuffix, [StringComparison]::OrdinalIgnoreCase) + }) + }) + if ($missingEntrySuffixes.Count -gt 0) { + throw "NuGet package is missing auto-injection payload entries: $($missingEntrySuffixes -join ', ')" + } + } + finally { + $archive.Dispose() + } + # Trusted publishing (OIDC): requires a policy at nuget.org/account/trustedpublishing # for repo faze79/WPFVisualTreeMcp, workflow release.yml, package WpfVisualTreeMcp, # plus a repo secret NUGET_USER = the nuget.org profile name. Skipped with a notice @@ -94,7 +155,7 @@ jobs: } - name: Create GitHub Release - uses: softprops/action-gh-release@v1 + uses: softprops/action-gh-release@v3 with: body_path: release-notes.md files: | diff --git a/CHANGELOG.md b/CHANGELOG.md index f28ff8c..1351674 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,17 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Fixed + +- Build and package the x64 and x86 native bootstrappers so Auto-injection works from the + NuGet tool and release archive. +- Package the complete .NET Framework Inspector dependency closure and resolve its + co-located private assemblies without relying on the target application's binding redirects. +- Use the standard CoreCLR runtimeconfig filename so it remains accessible from the NuGet + tool's deeply nested installation directory. + ## [0.12.0] - 2026-07-24 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 783f9d4..7bcd3e0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,8 +18,11 @@ dotnet build -c Release WpfVisualTreeMcp.sln # Run tests dotnet test WpfVisualTreeMcp.sln +# Run the full self-hosted/auto-injection matrix (Windows PowerShell) +./tests/run-integration-tests.ps1 + # Run sample WPF app for testing -dotnet run --project samples/SampleWpfApp +dotnet run --project samples/SampleWpfApp --framework net8.0-windows # Publish MCP Server executable (same exe also runs as the CLI) dotnet publish src/WpfVisualTreeMcp.Server/WpfVisualTreeMcp.Server.csproj -c Release -o ./publish @@ -129,6 +132,8 @@ The Inspector strips UTF-8 BOM (0xEF 0xBB 0xBF) before JSON parsing to prevent d dropdowns, context menus and tooltips — requires the window visible and unobstructed) - DPI-aware via `PresentationSource.FromVisual` - Downscales if exceeding `max_width`/`max_height` (default 1920x1080) +- Captures the element's current arranged bounds; it does not scroll and stitch + off-screen content, and virtualized items that have not been realized are absent - Returns MCP `ImageContentBlock` (base64 PNG) — Claude sees the image directly ### Logging @@ -140,9 +145,9 @@ The Inspector strips UTF-8 BOM (0xEF 0xBB 0xBF) before JSON parsing to prevent d ## WPF App Inspection Modes ### Auto-Injection Mode -Use `wpf_attach(process_id=, auto_inject=true)` to inject the Inspector into any running .NET Framework WPF app. Requires: +Use `wpf_attach(process_id=, auto_inject=true)` to inject the Inspector into a running .NET Framework or .NET 8 WPF app. Requires: - Native bootstrapper DLL in `publish/native/x64/` (or x86) -- Target process must be .NET Framework (CLR hosting) +- A supported CLR and matching Inspector payload (`net48` for .NET Framework or `net8.0-windows` for CoreCLR) - Architecture detection is automatic (x64 vs x86) ### Self-Hosted Mode @@ -177,7 +182,7 @@ The server uses the official Microsoft/Anthropic MCP SDK. Configure in `.mcp.jso The server executable doubles as a command-line tool. `WpfVisualTreeMcp.Server.exe` with **no arguments** runs the MCP stdio server; with **any recognised subcommand** it runs a single one-shot CLI command instead (`Program.cs` checks `args[0]` via -`CliRunner.IsCliCommand`). This gives the same 21 capabilities without an MCP +`CliRunner.IsCliCommand`). This gives the same 28 capabilities without an MCP connection — useful when the MCP server is not connected, for scripting, or for verifying the pipeline manually. diff --git a/README.md b/README.md index 5f02ff6..3fb3d4a 100644 --- a/README.md +++ b/README.md @@ -107,7 +107,7 @@ command list. Output is JSON on stdout; diagnostics go to stderr. | Interaction surface | click (UIA + physical, double/right), set-text w/ read-back, send-keys, **select-item (virtualized)** | click / type / select | usually invoke-only | | Wait for element conditions | ✅ `wpf_wait_for` | ✅ | ❌ | | Popup / dropdown / context-menu screenshots | ✅ screen mode | partial | screenshot only | -| Cross-architecture injection (x64 ⇄ x86) | ✅ | n/a | ❌ | +| Cross-architecture injection (x64 server → x86 target) | ✅ | n/a | ❌ | | Dual-mode: MCP server **and** one-shot CLI | ✅ | ❌ | ❌ | | Distribution | NuGet (`dnx`/tool) + official MCP registry | varies | varies | @@ -120,6 +120,8 @@ In short: UIA-based tools see what accessibility exposes, and computer-use agent - Windows 10/11 - [.NET 8.0 SDK](https://dotnet.microsoft.com/download/dotnet/8.0) or later - A WPF application to inspect +- Visual Studio 2022 C++ build tools when building the auto-injection payload from source +- x86 .NET 8 Windows Desktop runtime when running the cross-bitness integration tests ### Installation @@ -226,7 +228,36 @@ Add to your Cursor settings (`.cursor/mcp.json`): } ``` -### Self-Hosted Mode (Recommended) +### Auto-Injection Mode + +Auto-injection loads the Inspector into an already-running WPF process without +source changes. Set `auto_inject=true` when calling `wpf_attach`, or run: + +```powershell +wpfinspect attach --pid --auto-inject +``` + +Auto-injection has these constraints: + +- It requires permission to open the target process, write memory, and create a + remote thread. Elevated, protected, sandboxed, or security-hardened processes + may reject it, and endpoint security may block it as DLL injection. +- The matching x64 or x86 native bootstrapper and the complete Inspector + dependency set must be present. A 64-bit server additionally needs the + bundled x86 helper and the x86 .NET 8 runtime to inject into a 32-bit target. + Native ARM64 targets are not supported. +- Injection occurs after process startup, so it cannot recover earlier binding + errors or initialization activity. A restarted application must be injected + again under its new process ID. +- The target must have an initialized WPF `Application` and a responsive UI + dispatcher. The injected Inspector remains loaded until the target exits. +- Loading native and managed code into the target can conflict with its runtime, + assembly versions, or process-hardening policy. + +See the [injector documentation](src/WpfVisualTreeMcp.Injector/README.md) for the +implementation, runtime requirements, diagnostics, and detailed limitations. + +### Self-Hosted Mode For your WPF application to be inspectable, add a reference to the Inspector DLL and initialize it on startup: @@ -258,6 +289,8 @@ public partial class App : Application ``` This enables the MCP server to connect to your application via named pipes for real-time inspection. +The Inspector multi-targets .NET Framework 4.7.2, .NET Framework 4.8, and +.NET 8 for Windows, so project references select a compatible build. ## Usage Examples @@ -357,7 +390,13 @@ For detailed architecture documentation, see [docs/ARCHITECTURE.md](docs/ARCHITE | `wpf_get_layout_info` | Get layout information | | `wpf_export_tree` | Export visual tree to XAML or JSON | -For complete tool documentation, see [docs/TOOLS_REFERENCE.md](docs/TOOLS_REFERENCE.md). +Screenshot capture covers the element's current arranged bounds. It does not +scroll and stitch off-screen content, and virtualized items that have not been +realized are not available to capture. + +For detailed examples of the original inspection tools, see +[docs/TOOLS_REFERENCE.md](docs/TOOLS_REFERENCE.md); run `wpfinspect help` for +complete CLI documentation. ## Roadmap @@ -450,13 +489,16 @@ Contributions are welcome! Please feel free to submit a Pull Request. 3. Build and run tests: ```bash - dotnet build - dotnet test + dotnet build WpfVisualTreeMcp.sln -c Release + dotnet test WpfVisualTreeMcp.sln -c Release + + # Full 12-case self-hosted/auto-injection matrix (Windows PowerShell) + ./tests/run-integration-tests.ps1 ``` 4. Run the sample WPF app for testing: ```bash - dotnet run --project samples/SampleWpfApp + dotnet run --project samples/SampleWpfApp --framework net8.0-windows ``` ### Project Structure @@ -466,18 +508,20 @@ WpfVisualTreeMcp/ ├── src/ │ ├── WpfVisualTreeMcp.Server/ # MCP Server (.NET 8) - Uses official MCP SDK │ │ ├── Program.cs # Server initialization with MCP SDK -│ │ ├── WpfTools.cs # 20 WPF tools (17 inspection + click/set-text/send-keys) +│ │ ├── WpfTools.cs # 28 WPF tools (22 inspection + 6 state-changing) │ │ ├── Cli/CliRunner.cs # One-shot CLI front-end (v0.4.0) │ │ └── Services/ # Process & IPC management -│ ├── WpfVisualTreeMcp.Inspector/ # Injected DLL (.NET Framework 4.8) -│ ├── WpfVisualTreeMcp.Injector/ # Managed injection logic (CreateRemoteThread; net48 + net8.0) +│ ├── WpfVisualTreeMcp.Inspector/ # Injected DLL (.NET Framework 4.7.2/4.8 and .NET 8) +│ ├── WpfVisualTreeMcp.Injector/ # Managed injection logic (CreateRemoteThread; .NET 8) │ ├── WpfVisualTreeMcp.InjectorHelper/# x86 .NET 8 helper exe for cross-arch injection (v0.6.0) │ ├── WpfVisualTreeMcp.Bootstrapper/ # Native C++ DLL for CLR hosting │ └── WpfVisualTreeMcp.Shared/ # Shared models & IPC contracts ├── samples/ │ └── SampleWpfApp/ # Test application ├── tests/ -│ └── WpfVisualTreeMcp.Tests/ # Unit tests (48 tests) +│ ├── WpfVisualTreeMcp.Tests/ # Server, CLI and injector unit tests +│ ├── WpfVisualTreeMcp.Shared.Tests/ # Shared contract tests across all target frameworks +│ └── WpfVisualTreeMcp.IntegrationTests/ # 3 frameworks × 2 architectures × 2 modes ├── publish/ # Published server + native DLLs │ └── native/{x64,x86}/ # Architecture-specific bootstrapper └── docs/ # Documentation @@ -487,7 +531,7 @@ WpfVisualTreeMcp/ - **MCP SDK**: Built with the [official C# MCP SDK](https://github.com/modelcontextprotocol/csharp-sdk) from Microsoft/Anthropic - **Protocol**: JSON-RPC 2.0 over stdio transport -- **Target Framework**: .NET 8.0 (Server) / .NET Framework 4.8 + .NET 8.0-windows (Inspector, dual-target) +- **Target Framework**: .NET 8.0 (Server/Injector) / .NET Framework 4.7.2, 4.8 + .NET 8.0-windows (Inspector) - **IPC**: Named Pipes for server-to-application communication - **Tools**: 28 tools auto-discovered via `[McpServerTool]` attributes (22 read-only inspection incl. `wpf_wait_for`, `wpf_snapshot`, `wpf_diff`, `wpf_evaluate_binding`, `wpf_explain_triggers` + 6 state-changing: `wpf_click_element`, `wpf_select_item`, `wpf_set_text`, `wpf_send_keys`, `wpf_set_property`, `wpf_revert_property`) - **CLI**: same executable runs as one-shot CLI when given a subcommand (`Program.cs` routes via `CliRunner.IsCliCommand`) diff --git a/WpfVisualTreeMcp.sln b/WpfVisualTreeMcp.sln index f7d05a8..d60563b 100644 --- a/WpfVisualTreeMcp.sln +++ b/WpfVisualTreeMcp.sln @@ -31,6 +31,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution mcp.json = mcp.json EndProjectSection EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "WpfVisualTreeMcp.Shared.Tests", "tests\WpfVisualTreeMcp.Shared.Tests\WpfVisualTreeMcp.Shared.Tests.csproj", "{17056590-7FA9-414F-9772-6C60C56C1A5D}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "WpfVisualTreeMcp.IntegrationTests", "tests\WpfVisualTreeMcp.IntegrationTests\WpfVisualTreeMcp.IntegrationTests.csproj", "{A8E70F1C-2D31-4CF1-B589-1A290781D415}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -65,6 +69,14 @@ Global {F6A7B8C9-D0E1-2345-F012-456789012345}.Debug|Any CPU.Build.0 = Debug|Any CPU {F6A7B8C9-D0E1-2345-F012-456789012345}.Release|Any CPU.ActiveCfg = Release|Any CPU {F6A7B8C9-D0E1-2345-F012-456789012345}.Release|Any CPU.Build.0 = Release|Any CPU + {17056590-7FA9-414F-9772-6C60C56C1A5D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {17056590-7FA9-414F-9772-6C60C56C1A5D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {17056590-7FA9-414F-9772-6C60C56C1A5D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {17056590-7FA9-414F-9772-6C60C56C1A5D}.Release|Any CPU.Build.0 = Release|Any CPU + {A8E70F1C-2D31-4CF1-B589-1A290781D415}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A8E70F1C-2D31-4CF1-B589-1A290781D415}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A8E70F1C-2D31-4CF1-B589-1A290781D415}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A8E70F1C-2D31-4CF1-B589-1A290781D415}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -77,6 +89,8 @@ Global {B0A1C2D3-E4F5-6789-0123-456789ABCDEF} = {1A2B3C4D-5E6F-7890-1234-567890ABCDEF} {E5F6A7B8-C9D0-1234-EF01-345678901234} = {2B3C4D5E-6F78-9012-3456-7890ABCDEF01} {F6A7B8C9-D0E1-2345-F012-456789012345} = {3C4D5E6F-7890-1234-5678-90ABCDEF0123} + {17056590-7FA9-414F-9772-6C60C56C1A5D} = {3C4D5E6F-7890-1234-5678-90ABCDEF0123} + {A8E70F1C-2D31-4CF1-B589-1A290781D415} = {3C4D5E6F-7890-1234-5678-90ABCDEF0123} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {12345678-90AB-CDEF-1234-567890ABCDEF} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 088fdb8..56a9553 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -27,9 +27,9 @@ The MCP Server is the main entry point that communicates with AI agents via the ### 2. Inspector DLL (`WpfVisualTreeMcp.Inspector`) -The Inspector is a .NET Framework library that gets loaded into target WPF applications to perform inspection operations. +The Inspector is loaded into target WPF applications to perform inspection and interaction operations. -**Technology:** .NET Framework 4.8 (for maximum compatibility with WPF apps) +**Technology:** .NET Framework 4.7.2, .NET Framework 4.8, and .NET 8 for Windows **Responsibilities:** - Access `VisualTreeHelper` and `LogicalTreeHelper` @@ -48,7 +48,7 @@ The Inspector is a .NET Framework library that gets loaded into target WPF appli The Injector is responsible for loading the Inspector DLL into target WPF processes. -**Technology:** C++/CLI or managed code +**Technology:** .NET 8 managed injection logic, native C++ bootstrapper, and an x86 .NET 8 helper **Responsibilities:** - Enumerate running WPF processes @@ -170,18 +170,17 @@ public async Task GetVisualTreeAsync(ElementHandle root) ### Process Isolation - The MCP Server runs in a separate process from target applications -- Inspector DLL has read-only access to the visual tree -- No modification of application state is possible through inspection +- The Inspector runs in-process and can inspect the visual tree +- Interaction and live-property tools intentionally modify application state ### Named Pipe Security -- Pipes are created with appropriate ACLs -- Only the MCP Server process can connect -- Pipe names include process IDs to prevent collisions +- Pipes are local to the machine and their names include process IDs +- The protocol does not authenticate the connecting client ### Injection Safety -- Only .NET Framework WPF applications can be inspected -- Injection uses safe managed code techniques -- Target application stability is preserved +- .NET Framework and .NET 8 WPF applications are supported +- Auto-injection loads native and managed code by creating a remote thread +- Process permissions, endpoint security, runtime conflicts, or blocked UI threads can prevent injection or affect the target ## Error Handling diff --git a/docs/GETTING_STARTED.md b/docs/GETTING_STARTED.md index e28b781..1071baa 100644 --- a/docs/GETTING_STARTED.md +++ b/docs/GETTING_STARTED.md @@ -31,7 +31,7 @@ Before you begin, ensure you have: src/WpfVisualTreeMcp.Server/bin/Debug/net8.0/WpfVisualTreeMcp.Server.exe ``` -### Option 2: .NET Tool Installation (Coming Soon) +### Option 2: .NET Tool Installation ```bash dotnet tool install -g WpfVisualTreeMcp @@ -134,6 +134,9 @@ Add a reference to `WpfVisualTreeMcp.Inspector` in your WPF project: ``` +The Inspector supports WPF applications targeting .NET Framework 4.7.2, +.NET Framework 4.8, and .NET 8 for Windows. + ### Step 2: Initialize the Inspector In your `App.xaml.cs`, initialize the inspector on startup: @@ -175,7 +178,7 @@ Either start your own WPF application (with the Inspector set up as above) or us ```bash cd WpfVisualTreeMcp -dotnet run --project samples/SampleWpfApp +dotnet run --project samples/SampleWpfApp --framework net8.0-windows ``` The sample app already has the Inspector configured. @@ -255,7 +258,7 @@ What styles are defined in this application? ### "No WPF applications found" - Ensure the target application is running -- Check that it's a .NET Framework WPF application +- Check that it is a supported .NET Framework or .NET 8 WPF application - The application must have a main window visible ### "Failed to attach to process" or "Inspector not loaded" diff --git a/docs/TOOLS_REFERENCE.md b/docs/TOOLS_REFERENCE.md index 288e5b1..3a8147a 100644 --- a/docs/TOOLS_REFERENCE.md +++ b/docs/TOOLS_REFERENCE.md @@ -1,6 +1,8 @@ # Tools Reference -Complete reference for all MCP tools provided by WpfVisualTreeMcp. +Detailed reference for the original inspection tools provided by +WpfVisualTreeMcp. For the current list of all 28 tools, see the +[README](../README.md#available-tools) or run `wpfinspect help`. ## Process Management diff --git a/samples/SampleWpfApp/App.xaml.cs b/samples/SampleWpfApp/App.xaml.cs index 0a34c3e..d382903 100644 --- a/samples/SampleWpfApp/App.xaml.cs +++ b/samples/SampleWpfApp/App.xaml.cs @@ -1,6 +1,5 @@ -using System.Diagnostics; +using System; using System.Windows; -using WpfVisualTreeMcp.Inspector; namespace SampleWpfApp; @@ -9,19 +8,32 @@ namespace SampleWpfApp; /// public partial class App : Application { + private bool _inspectorStarted; + protected override void OnStartup(StartupEventArgs e) { base.OnStartup(e); - // Initialize the WPF Visual Tree Inspector - // This enables the MCP server to inspect this application - InspectorService.Initialize(Process.GetCurrentProcess().Id); + _inspectorStarted = !string.Equals( + Environment.GetEnvironmentVariable("WPF_VISUAL_TREE_MCP_SELF_HOSTED"), + "false", + StringComparison.OrdinalIgnoreCase); + + if (_inspectorStarted) + { + // Initialize the WPF Visual Tree Inspector + // This enables the MCP server to inspect this application + SelfHostedInspector.Start(); + } } protected override void OnExit(ExitEventArgs e) { - // Clean up the inspector service - InspectorService.Instance?.Dispose(); + if (_inspectorStarted) + { + // Clean up the inspector service + SelfHostedInspector.Stop(); + } base.OnExit(e); } diff --git a/samples/SampleWpfApp/SampleWpfApp.csproj b/samples/SampleWpfApp/SampleWpfApp.csproj index 147936a..2728eb2 100644 --- a/samples/SampleWpfApp/SampleWpfApp.csproj +++ b/samples/SampleWpfApp/SampleWpfApp.csproj @@ -2,12 +2,13 @@ WinExe - net48 + net472;net48;net8.0-windows true SampleWpfApp SampleWpfApp 12.0 enable + AnyCPU;x86;x64 diff --git a/samples/SampleWpfApp/SelfHostedInspector.cs b/samples/SampleWpfApp/SelfHostedInspector.cs new file mode 100644 index 0000000..dd7921e --- /dev/null +++ b/samples/SampleWpfApp/SelfHostedInspector.cs @@ -0,0 +1,20 @@ +using System.Diagnostics; +using System.Runtime.CompilerServices; +using WpfVisualTreeMcp.Inspector; + +namespace SampleWpfApp; + +internal static class SelfHostedInspector +{ + [MethodImpl(MethodImplOptions.NoInlining)] + public static void Start() + { + InspectorService.Initialize(Process.GetCurrentProcess().Id); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + public static void Stop() + { + InspectorService.Instance?.Dispose(); + } +} diff --git a/src/WpfVisualTreeMcp.Injector/ProcessInjector.cs b/src/WpfVisualTreeMcp.Injector/ProcessInjector.cs index e203f97..53a1fda 100644 --- a/src/WpfVisualTreeMcp.Injector/ProcessInjector.cs +++ b/src/WpfVisualTreeMcp.Injector/ProcessInjector.cs @@ -363,8 +363,22 @@ public bool IsInspectorLoaded(Process process) public string GetInspectorDllPath() { var assemblyLocation = typeof(ProcessInjector).Assembly.Location; - var directory = Path.GetDirectoryName(assemblyLocation); - return Path.Combine(directory!, "WpfVisualTreeMcp.Inspector.dll"); + var directory = Path.GetDirectoryName(assemblyLocation)!; + var fileName = "WpfVisualTreeMcp.Inspector.dll"; + var candidates = new[] + { + Path.Combine(directory, fileName), + Path.Combine(directory, "native", "x64", fileName), + Path.Combine(directory, "native", "x86", fileName), + }; + + foreach (var candidate in candidates) + { + if (File.Exists(candidate)) + return candidate; + } + + return candidates[1]; } /// diff --git a/src/WpfVisualTreeMcp.Injector/README.md b/src/WpfVisualTreeMcp.Injector/README.md index cca4e56..4aa2878 100644 --- a/src/WpfVisualTreeMcp.Injector/README.md +++ b/src/WpfVisualTreeMcp.Injector/README.md @@ -1,16 +1,60 @@ # WpfVisualTreeMcp.Injector -This project handles injection of the Inspector DLL into target WPF processes. - -## Current Status - -The injector is currently a **stub implementation**. Full DLL injection into external .NET processes requires advanced techniques that are beyond the scope of the initial implementation. - -## Injection Approaches - -### Option 1: Self-Hosted Mode (Recommended for Development) - -For development and testing, the recommended approach is to have your WPF application directly reference the Inspector DLL: +This project injects the WPF Inspector into an already-running WPF process. It +is used by both the MCP server and the one-shot CLI when auto-injection is +requested. + +## How Auto-Injection Works + +1. `ProcessInjector` verifies that the target is managed and detects its + architecture. +2. It selects the matching x64 or x86 `WpfInspectorBootstrapper.dll`. +3. For a same-bitness target, it uses `OpenProcess`, `VirtualAllocEx`, + `WriteProcessMemory`, and `CreateRemoteThread` to call `LoadLibraryW` in the + target. A 64-bit server launches the bundled 32-bit `WpfInjectorHelper.exe` + for an x86 target. +4. The native bootstrapper detects the loaded CLR. It uses + `ExecuteInDefaultAppDomain` for .NET Framework and `hostfxr` for CoreCLR. +5. The bootstrapper loads `WpfVisualTreeMcp.Inspector.dll`, which starts the + named-pipe endpoint used by the server. + +Auto-injection does not require source changes to the target application. + +## Requirements and Limitations + +- **Windows and architecture:** Native bootstrappers are provided for x64 and + x86. Native ARM64 targets are not supported. Cross-bitness injection from the + normal 64-bit server into an x86 target requires `WpfInjectorHelper.exe` and + the x86 .NET 8 runtime. +- **Process access:** The injector needs process-query, remote-thread, and + virtual-memory access. Integrity-level differences, protected or sandboxed + processes, endpoint security, and process-hardening policies can deny these + operations. +- **Runtime compatibility:** .NET Framework targets load the `net48` Inspector + in the default AppDomain and require the .NET Framework 4.8 runtime. CoreCLR + targets load the `net8.0-windows` Inspector through `hostfxr` and require a + compatible Windows Desktop runtime. Custom CLR hosts and incompatible loaded + runtimes may reject the Inspector. +- **Complete payload:** The architecture directory must contain the matching + native bootstrapper, Inspector assembly, and managed dependency closure. + CoreCLR injection also requires the Inspector runtime configuration. + `LoadLibraryW` or managed initialization fails when the payload is incomplete. +- **Application state:** Injection starts the pipe server immediately, but WPF + operations require `Application.Current` and a responsive UI dispatcher. + Requests time out while the UI thread is blocked. +- **Timing and lifetime:** Auto-injection cannot observe binding errors or UI + initialization that occurred before attachment. A restarted process must be + injected again. Detaching the external client does not unload the Inspector; + it remains in the target until process exit. +- **Target stability:** Injection loads native and managed code, starts threads, + registers listeners, and resolves private dependencies inside the target. + Applications with conflicting assemblies or unusual hosting arrangements may + be destabilized. + +## Self-Hosted Alternative + +When the application source can be changed, self-hosting avoids remote-process +injection and gives the application control over startup and disposal: ```csharp // In your WPF application's App.xaml.cs @@ -25,49 +69,26 @@ protected override void OnStartup(StartupEventArgs e) This avoids the complexity of injection and provides the most reliable experience. -### Option 2: Native Injection (Future) - -For production scenarios where you need to attach to arbitrary WPF applications, the following approaches can be considered: - -1. **CreateRemoteThread with LoadLibrary** - - Classic DLL injection technique - - Requires a native C++ helper for bootstrapping managed code +Self-hosting is appropriate when policy blocks injection, diagnostics must start +with the application, or injection conflicts with the target's runtime. It +requires a target-framework-compatible Inspector reference. -2. **CLR Debugging APIs (ICorDebug)** - - Uses the .NET debugging infrastructure - - Can create threads and load assemblies in the target process +## Diagnostics -3. **EasyHook or Similar Libraries** - - Third-party libraries that simplify managed injection - - Handle the complexity of cross-process managed code loading +Run the smallest attachment attempt with verbose logging: -4. **AppDomain Injection via Profiling API** - - Uses the CLR profiling infrastructure - - Most invasive but most capable option - -## Why Injection is Complex - -Injecting managed code (.NET) into another managed process is significantly more complex than native DLL injection because: - -1. The CLR must be properly initialized in the target process -2. The injected assembly must be loaded into the correct AppDomain -3. The injected code must run on the correct thread (usually the UI thread for WPF) -4. .NET Core/5+ has different hosting requirements than .NET Framework - -## Recommended Development Workflow - -1. **During Development** - - Use self-hosted mode in your test applications - - Reference the Inspector DLL directly - -2. **For Testing with External Apps** - - Build a test harness that loads Inspector on startup - - Use this for integration testing +```powershell +wpfinspect attach --pid --auto-inject --verbose +``` -3. **Future Production** - - Implement proper injection using one of the approaches above - - Or consider a different architecture (e.g., a Visual Studio extension) +The native bootstrapper writes initialization details to +`%TEMP%\WpfInspectorBootstrapper.log`. When running as an MCP server, logs are +stored under `%LOCALAPPDATA%\WpfVisualTreeMcp\logs`. ## Files -- `ProcessInjector.cs` - Stub implementation with P/Invoke declarations for future use +- `ProcessInjector.cs` - Target discovery, architecture selection, and remote + `LoadLibraryW` injection +- `../WpfVisualTreeMcp.Bootstrapper/WpfInspectorBootstrapper.cpp` - Native CLR + bootstrapper +- `../WpfVisualTreeMcp.InjectorHelper/Program.cs` - x86 cross-bitness helper diff --git a/src/WpfVisualTreeMcp.Injector/WpfVisualTreeMcp.Injector.csproj b/src/WpfVisualTreeMcp.Injector/WpfVisualTreeMcp.Injector.csproj index 68746ba..60e69b3 100644 --- a/src/WpfVisualTreeMcp.Injector/WpfVisualTreeMcp.Injector.csproj +++ b/src/WpfVisualTreeMcp.Injector/WpfVisualTreeMcp.Injector.csproj @@ -1,12 +1,7 @@ - - net48;net8.0 + net8.0 Library WpfVisualTreeMcp.Injector WpfVisualTreeMcp.Injector @@ -15,8 +10,4 @@ true - - - - diff --git a/src/WpfVisualTreeMcp.InjectorHelper/WpfVisualTreeMcp.InjectorHelper.csproj b/src/WpfVisualTreeMcp.InjectorHelper/WpfVisualTreeMcp.InjectorHelper.csproj index ca64b74..8fd4496 100644 --- a/src/WpfVisualTreeMcp.InjectorHelper/WpfVisualTreeMcp.InjectorHelper.csproj +++ b/src/WpfVisualTreeMcp.InjectorHelper/WpfVisualTreeMcp.InjectorHelper.csproj @@ -17,7 +17,6 @@ WpfVisualTreeMcp.InjectorHelper enable enable - $(NoWarn);NU1903 diff --git a/src/WpfVisualTreeMcp.Inspector/InspectorService.cs b/src/WpfVisualTreeMcp.Inspector/InspectorService.cs index c6c85fe..be25111 100644 --- a/src/WpfVisualTreeMcp.Inspector/InspectorService.cs +++ b/src/WpfVisualTreeMcp.Inspector/InspectorService.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.IO; +using System.Reflection; using System.Text.Json; using System.Threading.Tasks; using System.Windows; @@ -28,6 +29,21 @@ public class InspectorService : IDisposable private bool _disposed; private static readonly object _initLock = new(); +#if NET48 + private static readonly HashSet _privateDependencyNames = new(StringComparer.OrdinalIgnoreCase) + { + "Microsoft.Bcl.AsyncInterfaces", + "System.Buffers", + "System.Memory", + "System.Numerics.Vectors", + "System.Runtime.CompilerServices.Unsafe", + "System.Text.Encodings.Web", + "System.Text.Json", + "System.Threading.Tasks.Extensions", + "System.ValueTuple", + }; + private static bool _dependencyResolverRegistered; +#endif public static InspectorService? Instance { get; private set; } /// @@ -105,6 +121,9 @@ public static void Initialize(int processId) try { DebugLog($"Inspector.Initialize called for PID={processId}"); +#if NET48 + RegisterDependencyResolver(); +#endif Instance = new InspectorService(processId); DebugLog("Inspector instance created, calling Start()"); Instance.Start(); @@ -119,6 +138,27 @@ public static void Initialize(int processId) } } +#if NET48 + private static void RegisterDependencyResolver() + { + if (_dependencyResolverRegistered) return; + + var inspectorDirectory = Path.GetDirectoryName(typeof(InspectorService).Assembly.Location); + if (string.IsNullOrEmpty(inspectorDirectory)) return; + + AppDomain.CurrentDomain.AssemblyResolve += (_, args) => + { + var assemblyName = new AssemblyName(args.Name).Name; + if (string.IsNullOrEmpty(assemblyName) || !_privateDependencyNames.Contains(assemblyName)) + return null; + + var assemblyPath = Path.Combine(inspectorDirectory, assemblyName + ".dll"); + return File.Exists(assemblyPath) ? Assembly.LoadFrom(assemblyPath) : null; + }; + _dependencyResolverRegistered = true; + } +#endif + private InspectorService(int processId) { _treeWalker = new TreeWalker(); diff --git a/src/WpfVisualTreeMcp.Inspector/WpfVisualTreeMcp.Inspector.csproj b/src/WpfVisualTreeMcp.Inspector/WpfVisualTreeMcp.Inspector.csproj index 87ab961..8437e6c 100644 --- a/src/WpfVisualTreeMcp.Inspector/WpfVisualTreeMcp.Inspector.csproj +++ b/src/WpfVisualTreeMcp.Inspector/WpfVisualTreeMcp.Inspector.csproj @@ -1,7 +1,7 @@ - net48;net8.0-windows + net472;net48;net8.0-windows Library WpfVisualTreeMcp.Inspector WpfVisualTreeMcp.Inspector @@ -10,9 +10,9 @@ true - + - + diff --git a/src/WpfVisualTreeMcp.Server/Services/ProcessManager.cs b/src/WpfVisualTreeMcp.Server/Services/ProcessManager.cs index 607ffcb..4c9e8c6 100644 --- a/src/WpfVisualTreeMcp.Server/Services/ProcessManager.cs +++ b/src/WpfVisualTreeMcp.Server/Services/ProcessManager.cs @@ -129,7 +129,8 @@ public async Task AttachToProcessAsync(int? processId, string targetProcess.Id, targetProcess.ProcessName); // Check if Inspector is already loaded (self-hosted mode) - var inspectorLoaded = IsInspectorLoaded(targetProcess); + var inspectorLoaded = IsInspectorLoaded(targetProcess) || + await WaitForInspectorPipeAsync(targetProcess.Id, TimeSpan.FromMilliseconds(700)); if (inspectorLoaded) { _logger.LogInformation("Inspector DLL already loaded in target process (self-hosted mode)"); diff --git a/src/WpfVisualTreeMcp.Server/WpfVisualTreeMcp.Server.csproj b/src/WpfVisualTreeMcp.Server/WpfVisualTreeMcp.Server.csproj index 7e50c7d..003a277 100644 --- a/src/WpfVisualTreeMcp.Server/WpfVisualTreeMcp.Server.csproj +++ b/src/WpfVisualTreeMcp.Server/WpfVisualTreeMcp.Server.csproj @@ -50,6 +50,12 @@ + + - - PreserveNewest - PreserveNewest - - + + PreserveNewest PreserveNewest @@ -92,16 +91,9 @@ PreserveNewest PreserveNewest - - - PreserveNewest - PreserveNewest - - + + PreserveNewest PreserveNewest @@ -152,7 +144,7 @@ PreserveNewest + Link="native\x64\coreclr\WpfVisualTreeMcp.Inspector.runtimeconfig.json"> PreserveNewest PreserveNewest @@ -170,7 +162,7 @@ PreserveNewest + Link="native\x86\coreclr\WpfVisualTreeMcp.Inspector.runtimeconfig.json"> PreserveNewest PreserveNewest @@ -182,4 +174,15 @@ + + + + + + + diff --git a/src/WpfVisualTreeMcp.Shared/WpfVisualTreeMcp.Shared.csproj b/src/WpfVisualTreeMcp.Shared/WpfVisualTreeMcp.Shared.csproj index 36134d1..30b3e48 100644 --- a/src/WpfVisualTreeMcp.Shared/WpfVisualTreeMcp.Shared.csproj +++ b/src/WpfVisualTreeMcp.Shared/WpfVisualTreeMcp.Shared.csproj @@ -1,7 +1,7 @@ - net8.0;net48 + net8.0;net472;net48 enable enable 12.0 @@ -9,7 +9,7 @@ - + diff --git a/tests/WpfVisualTreeMcp.IntegrationTests/AssemblyInfo.cs b/tests/WpfVisualTreeMcp.IntegrationTests/AssemblyInfo.cs new file mode 100644 index 0000000..2171200 --- /dev/null +++ b/tests/WpfVisualTreeMcp.IntegrationTests/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using Xunit; + +[assembly: CollectionBehavior(DisableTestParallelization = true)] diff --git a/tests/WpfVisualTreeMcp.IntegrationTests/InspectionModeMatrixTests.cs b/tests/WpfVisualTreeMcp.IntegrationTests/InspectionModeMatrixTests.cs new file mode 100644 index 0000000..355bc9a --- /dev/null +++ b/tests/WpfVisualTreeMcp.IntegrationTests/InspectionModeMatrixTests.cs @@ -0,0 +1,355 @@ +using System.ComponentModel; +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Text.Json; +using FluentAssertions; +using Xunit; +using Xunit.Sdk; + +namespace WpfVisualTreeMcp.IntegrationTests; + +public class InspectionModeMatrixTests +{ + private const uint Th32csSnapModule = 0x00000008; + private const uint Th32csSnapModule32 = 0x00000010; + private static readonly IntPtr InvalidHandleValue = new(-1); + + public static TheoryData Cases => new() + { + { "net472", "x86", "SelfHosted" }, + { "net472", "x86", "AutoInjection" }, + { "net472", "x64", "SelfHosted" }, + { "net472", "x64", "AutoInjection" }, + { "net48", "x86", "SelfHosted" }, + { "net48", "x86", "AutoInjection" }, + { "net48", "x64", "SelfHosted" }, + { "net48", "x64", "AutoInjection" }, + { "net8.0-windows", "x86", "SelfHosted" }, + { "net8.0-windows", "x86", "AutoInjection" }, + { "net8.0-windows", "x64", "SelfHosted" }, + { "net8.0-windows", "x64", "AutoInjection" }, + }; + + [IntegrationTheory] + [MemberData(nameof(Cases))] + [Trait("Category", "Integration")] + public async Task Cli_inspects_sample_for_target_architecture_and_mode( + string targetFramework, + string architecture, + string mode) + { + var samplePath = Path.Combine( + GetRequiredEnvironmentVariable("WPF_VISUAL_TREE_MCP_INTEGRATION_SAMPLES"), + targetFramework, + architecture, + "SampleWpfApp.exe"); + File.Exists(samplePath).Should().BeTrue("the integration runner should publish every sample matrix variant"); + + using var sample = StartSample(samplePath, mode); + try + { + await WaitForMainWindowAsync(sample, TimeSpan.FromSeconds(15)); + + GetProcessArchitecture(sample).Should().Be(architecture); + var runtimeModule = targetFramework == "net8.0-windows" ? "coreclr.dll" : "clr.dll"; + await WaitForModuleAsync(sample.Id, runtimeModule, present: true, TimeSpan.FromSeconds(10)); + + var autoInject = mode == "AutoInjection"; + if (autoInject) + { + var beforeInjection = await RunCliAsync( + new[] + { + "find", + "--pid", + sample.Id.ToString(), + "--name", + "SubmitButton", + "--compact", + }, + TimeSpan.FromSeconds(8)); + beforeInjection.ExitCode.Should().NotBe( + 0, + "the auto-injection case must not be inspectable before attach --auto-inject"); + } + + var attachArguments = new List + { + "attach", + "--pid", + sample.Id.ToString(), + "--compact", + }; + if (autoInject) + attachArguments.Add("--auto-inject"); + + var attach = await RunCliAsync(attachArguments, TimeSpan.FromSeconds(20)); + attach.ExitCode.Should().Be(0, FormatCommandFailure("attach", attach)); + + using (var attachJson = JsonDocument.Parse(attach.StandardOutput)) + { + attachJson.RootElement.GetProperty("success").GetBoolean().Should().BeTrue(); + attachJson.RootElement.GetProperty("processId").GetInt32().Should().Be(sample.Id); + attachJson.RootElement.GetProperty("inspectorStatus").GetString() + .Should().Be(autoInject ? "Loaded (injected)" : "Loaded (self-hosted)"); + } + + if (autoInject) + { + var secondAttach = await RunCliAsync(attachArguments, TimeSpan.FromSeconds(20)); + secondAttach.ExitCode.Should().Be(0, FormatCommandFailure("second attach", secondAttach)); + + using var secondAttachJson = JsonDocument.Parse(secondAttach.StandardOutput); + secondAttachJson.RootElement.GetProperty("success").GetBoolean().Should().BeTrue(); + secondAttachJson.RootElement.GetProperty("processId").GetInt32().Should().Be(sample.Id); + secondAttachJson.RootElement.GetProperty("inspectorStatus").GetString() + .Should().Be("Loaded (self-hosted)", + "the second attach should reuse the loaded Inspector without reinjecting it"); + } + + var find = await FindSubmitButtonAsync(sample.Id, TimeSpan.FromSeconds(15)); + find.ExitCode.Should().Be(0, FormatCommandFailure("find", find)); + + using var findJson = JsonDocument.Parse(find.StandardOutput); + var elements = findJson.RootElement.GetProperty("elements"); + elements.GetArrayLength().Should().Be(1); + var submitButton = elements[0]; + submitButton.GetProperty("name").GetString().Should().Be("SubmitButton"); + submitButton.GetProperty("typeName").GetString().Should().Be("System.Windows.Controls.Button"); + submitButton.GetProperty("handle").GetString().Should().StartWith("elem_"); + findJson.RootElement.GetProperty("count").GetInt32().Should().Be(1); + } + finally + { + await StopProcessAsync(sample); + } + } + + private static Process StartSample(string samplePath, string mode) + { + var startInfo = new ProcessStartInfo + { + FileName = samplePath, + UseShellExecute = false, + }; + startInfo.Environment["WPF_VISUAL_TREE_MCP_SELF_HOSTED"] = + mode == "SelfHosted" ? "true" : "false"; + + return Process.Start(startInfo) ?? throw new XunitException($"Failed to start {samplePath}."); + } + + private static async Task WaitForMainWindowAsync(Process process, TimeSpan timeout) + { + var deadline = DateTime.UtcNow + timeout; + while (DateTime.UtcNow < deadline) + { + process.Refresh(); + if (process.HasExited) + throw new XunitException($"Sample process exited with code {process.ExitCode} before creating a window."); + if (process.MainWindowHandle != IntPtr.Zero) + return; + await Task.Delay(100); + } + + throw new XunitException($"Sample process {process.Id} did not create a main window within {timeout}."); + } + + private static async Task WaitForModuleAsync(int processId, string moduleName, bool present, TimeSpan timeout) + { + var deadline = DateTime.UtcNow + timeout; + while (DateTime.UtcNow < deadline) + { + if (GetModuleNames(processId).Contains(moduleName) == present) + return; + await Task.Delay(100); + } + + var expectation = present ? "load" : "unload"; + throw new XunitException($"Process {processId} did not {expectation} {moduleName} within {timeout}."); + } + + private static string GetProcessArchitecture(Process process) + { + if (!Environment.Is64BitOperatingSystem) + return "x86"; + if (!IsWow64Process(process.Handle, out var isWow64)) + throw new Win32Exception(Marshal.GetLastWin32Error()); + return isWow64 ? "x86" : "x64"; + } + + private static HashSet GetModuleNames(int processId) + { + for (var attempt = 0; attempt < 5; attempt++) + { + var snapshot = CreateToolhelp32Snapshot(Th32csSnapModule | Th32csSnapModule32, (uint)processId); + if (snapshot == InvalidHandleValue) + { + if (Marshal.GetLastWin32Error() == 24) + continue; + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + + try + { + var modules = new HashSet(StringComparer.OrdinalIgnoreCase); + var entry = new ModuleEntry32 { Size = (uint)Marshal.SizeOf() }; + if (!Module32First(snapshot, ref entry)) + throw new Win32Exception(Marshal.GetLastWin32Error()); + + do + { + modules.Add(entry.Module); + } + while (Module32Next(snapshot, ref entry)); + + return modules; + } + finally + { + CloseHandle(snapshot); + } + } + + throw new Win32Exception(24); + } + + private static async Task FindSubmitButtonAsync(int processId, TimeSpan timeout) + { + var deadline = DateTime.UtcNow + timeout; + CommandResult? lastResult = null; + while (DateTime.UtcNow < deadline) + { + lastResult = await RunCliAsync( + new[] + { + "find", + "--pid", + processId.ToString(), + "--name", + "SubmitButton", + "--compact", + }, + TimeSpan.FromSeconds(10)); + + if (lastResult.ExitCode == 0 && ContainsSubmitButton(lastResult.StandardOutput)) + return lastResult; + await Task.Delay(250); + } + + return lastResult ?? throw new XunitException("The CLI find command was not attempted."); + } + + private static bool ContainsSubmitButton(string json) + { + try + { + using var document = JsonDocument.Parse(json); + return document.RootElement.GetProperty("elements").EnumerateArray().Any(element => + element.GetProperty("name").GetString() == "SubmitButton"); + } + catch (JsonException) + { + return false; + } + } + + private static async Task RunCliAsync(IEnumerable arguments, TimeSpan timeout) + { + var startInfo = new ProcessStartInfo + { + FileName = GetRequiredEnvironmentVariable("WPF_VISUAL_TREE_MCP_INTEGRATION_SERVER"), + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + }; + foreach (var argument in arguments) + startInfo.ArgumentList.Add(argument); + + using var process = Process.Start(startInfo) ?? throw new XunitException("Failed to start the CLI."); + var standardOutput = process.StandardOutput.ReadToEndAsync(); + var standardError = process.StandardError.ReadToEndAsync(); + using var cancellation = new CancellationTokenSource(timeout); + try + { + await process.WaitForExitAsync(cancellation.Token); + } + catch (OperationCanceledException) + { + process.Kill(entireProcessTree: true); + throw new XunitException($"CLI process timed out after {timeout}."); + } + + return new CommandResult( + process.ExitCode, + await standardOutput, + await standardError); + } + + private static async Task StopProcessAsync(Process process) + { + if (process.HasExited) + return; + + process.CloseMainWindow(); + using var cancellation = new CancellationTokenSource(TimeSpan.FromSeconds(3)); + try + { + await process.WaitForExitAsync(cancellation.Token); + } + catch (OperationCanceledException) + { + process.Kill(entireProcessTree: true); + await process.WaitForExitAsync(); + } + } + + private static string GetRequiredEnvironmentVariable(string name) + { + return Environment.GetEnvironmentVariable(name) ?? + throw new XunitException($"Environment variable {name} is required."); + } + + private static string FormatCommandFailure(string command, CommandResult result) + { + return $"{command} failed.{Environment.NewLine}" + + $"stdout: {result.StandardOutput}{Environment.NewLine}" + + $"stderr: {result.StandardError}"; + } + + private sealed record CommandResult(int ExitCode, string StandardOutput, string StandardError); + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + private struct ModuleEntry32 + { + public uint Size; + public uint ModuleId; + public uint ProcessId; + public uint GlobalUsageCount; + public uint ProcessUsageCount; + public IntPtr BaseAddress; + public uint BaseSize; + public IntPtr ModuleHandle; + + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 256)] + public string Module; + + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 260)] + public string ExePath; + } + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool IsWow64Process(IntPtr processHandle, out bool wow64Process); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern IntPtr CreateToolhelp32Snapshot(uint flags, uint processId); + + [DllImport("kernel32.dll", EntryPoint = "Module32FirstW", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern bool Module32First(IntPtr snapshot, ref ModuleEntry32 entry); + + [DllImport("kernel32.dll", EntryPoint = "Module32NextW", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern bool Module32Next(IntPtr snapshot, ref ModuleEntry32 entry); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool CloseHandle(IntPtr handle); +} diff --git a/tests/WpfVisualTreeMcp.IntegrationTests/IntegrationTheoryAttribute.cs b/tests/WpfVisualTreeMcp.IntegrationTests/IntegrationTheoryAttribute.cs new file mode 100644 index 0000000..aa4df86 --- /dev/null +++ b/tests/WpfVisualTreeMcp.IntegrationTests/IntegrationTheoryAttribute.cs @@ -0,0 +1,15 @@ +using Xunit; + +namespace WpfVisualTreeMcp.IntegrationTests; + +internal sealed class IntegrationTheoryAttribute : TheoryAttribute +{ + public IntegrationTheoryAttribute() + { + if (string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("WPF_VISUAL_TREE_MCP_INTEGRATION_SERVER")) || + string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("WPF_VISUAL_TREE_MCP_INTEGRATION_SAMPLES"))) + { + Skip = "Run tests/run-integration-tests.ps1 to prepare the native payload and sample matrix."; + } + } +} diff --git a/tests/WpfVisualTreeMcp.IntegrationTests/WpfVisualTreeMcp.IntegrationTests.csproj b/tests/WpfVisualTreeMcp.IntegrationTests/WpfVisualTreeMcp.IntegrationTests.csproj new file mode 100644 index 0000000..7897268 --- /dev/null +++ b/tests/WpfVisualTreeMcp.IntegrationTests/WpfVisualTreeMcp.IntegrationTests.csproj @@ -0,0 +1,21 @@ + + + + net8.0-windows + enable + enable + false + true + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + diff --git a/tests/WpfVisualTreeMcp.Tests/IpcSerializerTests.cs b/tests/WpfVisualTreeMcp.Shared.Tests/IpcSerializerTests.cs similarity index 100% rename from tests/WpfVisualTreeMcp.Tests/IpcSerializerTests.cs rename to tests/WpfVisualTreeMcp.Shared.Tests/IpcSerializerTests.cs diff --git a/tests/WpfVisualTreeMcp.Tests/SharedModelsTests.cs b/tests/WpfVisualTreeMcp.Shared.Tests/SharedModelsTests.cs similarity index 100% rename from tests/WpfVisualTreeMcp.Tests/SharedModelsTests.cs rename to tests/WpfVisualTreeMcp.Shared.Tests/SharedModelsTests.cs diff --git a/tests/WpfVisualTreeMcp.Shared.Tests/WpfVisualTreeMcp.Shared.Tests.csproj b/tests/WpfVisualTreeMcp.Shared.Tests/WpfVisualTreeMcp.Shared.Tests.csproj new file mode 100644 index 0000000..325fad8 --- /dev/null +++ b/tests/WpfVisualTreeMcp.Shared.Tests/WpfVisualTreeMcp.Shared.Tests.csproj @@ -0,0 +1,30 @@ + + + + net8.0;net472;net48 + enable + enable + 12.0 + false + true + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/tests/WpfVisualTreeMcp.Tests/ProcessInjectorTests.cs b/tests/WpfVisualTreeMcp.Tests/ProcessInjectorTests.cs index 438b437..0f82292 100644 --- a/tests/WpfVisualTreeMcp.Tests/ProcessInjectorTests.cs +++ b/tests/WpfVisualTreeMcp.Tests/ProcessInjectorTests.cs @@ -68,7 +68,7 @@ public void InjectIntoProcess_WithInvalidProcessId_ThrowsInvalidOperationExcepti { // Arrange var invalidProcessId = int.MaxValue - 1; - var dllPath = _injector.GetInspectorDllPath(); + var dllPath = typeof(ProcessInjector).Assembly.Location; // Act & Assert var act = () => _injector.InjectIntoProcess(invalidProcessId, dllPath); diff --git a/tests/run-integration-tests.ps1 b/tests/run-integration-tests.ps1 new file mode 100644 index 0000000..354bc4c --- /dev/null +++ b/tests/run-integration-tests.ps1 @@ -0,0 +1,169 @@ +[CmdletBinding()] +param( + [string]$PlatformToolset, + [switch]$SkipNativeBuild +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$repoRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..')) +$artifactsRoot = [IO.Path]::GetFullPath((Join-Path $repoRoot 'artifacts\integration')) +$pathPrefix = $repoRoot.TrimEnd([IO.Path]::DirectorySeparatorChar) + [IO.Path]::DirectorySeparatorChar +if (-not $artifactsRoot.StartsWith($pathPrefix, [StringComparison]::OrdinalIgnoreCase)) { + throw "Integration artifact path is outside the repository: $artifactsRoot" +} + +function Invoke-ExternalCommand { + param( + [Parameter(Mandatory)] + [string]$FilePath, + [Parameter(Mandatory)] + [string[]]$ArgumentList + ) + + & $FilePath @ArgumentList + if ($LASTEXITCODE -ne 0) { + throw "Command failed with exit code ${LASTEXITCODE}: $FilePath $($ArgumentList -join ' ')" + } +} + +function Get-MSBuildPath { + $command = Get-Command msbuild.exe -ErrorAction SilentlyContinue + if ($null -ne $command) { + return $command.Source + } + + $vswhere = Join-Path ${env:ProgramFiles(x86)} 'Microsoft Visual Studio\Installer\vswhere.exe' + if (-not (Test-Path -LiteralPath $vswhere)) { + throw 'MSBuild was not found. Install Visual Studio Build Tools with Desktop development with C++.' + } + + $path = & $vswhere -latest -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -find 'MSBuild\**\Bin\MSBuild.exe' | + Select-Object -First 1 + if ([string]::IsNullOrWhiteSpace($path)) { + throw 'MSBuild was not found. Install Visual Studio Build Tools with Desktop development with C++.' + } + return $path +} + +function Assert-X86DotNetRuntime { + $dotnetRoot = $env:DOTNET_ROOT_X86 + if ([string]::IsNullOrWhiteSpace($dotnetRoot)) { + $dotnetRoot = Join-Path ${env:ProgramFiles(x86)} 'dotnet' + } + $env:DOTNET_ROOT_X86 = $dotnetRoot + + $dotnet = Join-Path $dotnetRoot 'dotnet.exe' + if (-not (Test-Path -LiteralPath $dotnet)) { + throw "The x86 .NET 8 runtime is required by WpfInjectorHelper. Install it or set DOTNET_ROOT_X86 (checked $dotnet)." + } + + $runtimes = & $dotnet --list-runtimes + if ($LASTEXITCODE -ne 0 -or + -not ($runtimes -match '^Microsoft\.NETCore\.App 8\.') -or + -not ($runtimes -match '^Microsoft\.WindowsDesktop\.App 8\.')) { + throw "The x86 .NET 8 and Windows Desktop runtimes are required by WpfInjectorHelper and the sample matrix (checked $dotnet)." + } +} + +Assert-X86DotNetRuntime + +if (Test-Path -LiteralPath $artifactsRoot) { + Remove-Item -LiteralPath $artifactsRoot -Recurse -Force +} +New-Item -ItemType Directory -Path $artifactsRoot | Out-Null + +if (-not $SkipNativeBuild) { + $msbuild = Get-MSBuildPath + $bootstrapper = Join-Path $repoRoot 'src\WpfVisualTreeMcp.Bootstrapper\WpfVisualTreeMcp.Bootstrapper.vcxproj' + foreach ($platform in @('x64', 'Win32')) { + $arguments = @( + $bootstrapper, + '/m', + '/p:Configuration=Release', + "/p:Platform=$platform" + ) + if (-not [string]::IsNullOrWhiteSpace($PlatformToolset)) { + $arguments += "/p:PlatformToolset=$PlatformToolset" + } + Invoke-ExternalCommand $msbuild $arguments + } +} + +$inspector = Join-Path $repoRoot 'src\WpfVisualTreeMcp.Inspector\WpfVisualTreeMcp.Inspector.csproj' +$injectorHelper = Join-Path $repoRoot 'src\WpfVisualTreeMcp.InjectorHelper\WpfVisualTreeMcp.InjectorHelper.csproj' +$server = Join-Path $repoRoot 'src\WpfVisualTreeMcp.Server\WpfVisualTreeMcp.Server.csproj' +$sample = Join-Path $repoRoot 'samples\SampleWpfApp\SampleWpfApp.csproj' +$integrationTests = Join-Path $repoRoot 'tests\WpfVisualTreeMcp.IntegrationTests\WpfVisualTreeMcp.IntegrationTests.csproj' +$serverOutput = Join-Path $artifactsRoot 'server' +$samplesOutput = Join-Path $artifactsRoot 'samples' +$sampleIntermediateOutput = [IO.Path]::GetFullPath((Join-Path (Split-Path $sample) 'obj')) +$samplePathPrefix = [IO.Path]::GetFullPath((Split-Path $sample)).TrimEnd([IO.Path]::DirectorySeparatorChar) + [IO.Path]::DirectorySeparatorChar +if (-not $sampleIntermediateOutput.StartsWith($samplePathPrefix, [StringComparison]::OrdinalIgnoreCase)) { + throw "Sample intermediate output path is outside the sample project: $sampleIntermediateOutput" +} + +Invoke-ExternalCommand dotnet @('build', $inspector, '--configuration', 'Release', '--no-incremental') +Invoke-ExternalCommand dotnet @('build', $injectorHelper, '--configuration', 'Release', '--no-incremental') +Invoke-ExternalCommand dotnet @( + 'publish', + $server, + '--configuration', + 'Release', + '--output', + $serverOutput +) + +foreach ($targetFramework in @('net472', 'net48', 'net8.0-windows')) { + foreach ($architecture in @('x86', 'x64')) { + $output = Join-Path $samplesOutput "$targetFramework\$architecture" + if (Test-Path -LiteralPath $sampleIntermediateOutput) { + Remove-Item -LiteralPath $sampleIntermediateOutput -Recurse -Force + } + $arguments = @( + 'publish', + $sample, + '--configuration', + 'Release', + '--framework', + $targetFramework, + '--runtime', + "win-$architecture", + '--output', + $output, + "-p:PlatformTarget=$architecture" + ) + if ($targetFramework -eq 'net8.0-windows') { + $arguments += @('--self-contained', 'false') + } + Invoke-ExternalCommand dotnet $arguments + } +} + +# Leave the shared intermediate output in its normal, non-RID-specific state. +Invoke-ExternalCommand dotnet @('restore', $sample) + +Invoke-ExternalCommand dotnet @('build', $integrationTests, '--configuration', 'Release') + +$previousServer = $env:WPF_VISUAL_TREE_MCP_INTEGRATION_SERVER +$previousSamples = $env:WPF_VISUAL_TREE_MCP_INTEGRATION_SAMPLES +try { + $env:WPF_VISUAL_TREE_MCP_INTEGRATION_SERVER = Join-Path $serverOutput 'WpfVisualTreeMcp.Server.exe' + $env:WPF_VISUAL_TREE_MCP_INTEGRATION_SAMPLES = $samplesOutput + Invoke-ExternalCommand dotnet @( + 'test', + $integrationTests, + '--configuration', + 'Release', + '--no-build', + '--filter', + 'Category=Integration', + '--verbosity', + 'normal' + ) +} +finally { + $env:WPF_VISUAL_TREE_MCP_INTEGRATION_SERVER = $previousServer + $env:WPF_VISUAL_TREE_MCP_INTEGRATION_SAMPLES = $previousSamples +}