From 2abbdf74bc18dfc9b41810249cc3bea0aaca759f Mon Sep 17 00:00:00 2001 From: Alexander Sklar Date: Thu, 26 Feb 2026 13:43:06 -0800 Subject: [PATCH 1/2] Add Chromium plugin for Chrome/Edge DOM inspection Adds a browser extension + native messaging host architecture that lets lvt dump the DOM tree from Chrome and Edge tabs: - Browser extension (Manifest V3) uses chrome.debugger API (CDP) to walk the DOM tree, including shadow DOM and iframes - Native messaging host (lvt_chromium_host.exe) relays between Chrome's stdin/stdout protocol and a Win32 named pipe - Plugin DLL (lvt_chromium_plugin.dll) detects chrome.exe/msedge.exe and connects to the named pipe to request and receive the DOM tree - Extension uses a fixed key for deterministic extension ID across installs - Filters out non-debuggable tabs (chrome://, edge://, about: URLs) No --remote-debugging-port flag required. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CMakeLists.txt | 42 +++ README.md | 3 +- docs/chromium-plugin.md | 176 +++++++++ src/plugin_chromium/chromium_host.cpp | 344 ++++++++++++++++++ src/plugin_chromium/extension/manifest.json | 22 ++ .../extension/service-worker.js | 195 ++++++++++ src/plugin_chromium/lvt_chromium_plugin.cpp | 329 +++++++++++++++++ tests/chromium_tests.cpp | 195 ++++++++++ 8 files changed, 1305 insertions(+), 1 deletion(-) create mode 100644 docs/chromium-plugin.md create mode 100644 src/plugin_chromium/chromium_host.cpp create mode 100644 src/plugin_chromium/extension/manifest.json create mode 100644 src/plugin_chromium/extension/service-worker.js create mode 100644 src/plugin_chromium/lvt_chromium_plugin.cpp create mode 100644 tests/chromium_tests.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 299250a..98cfa7a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -132,6 +132,36 @@ add_custom_command(TARGET lvt_avalonia_tap POST_BUILD COMMENT "Publishing managed Avalonia tree walker assembly" ) +# Chromium plugin DLL — runtime-loaded plugin for Chrome/Edge DOM tree support +add_library(lvt_chromium_plugin SHARED + src/plugin_chromium/lvt_chromium_plugin.cpp +) +target_compile_definitions(lvt_chromium_plugin PRIVATE WIN32_LEAN_AND_MEAN NOMINMAX) +target_include_directories(lvt_chromium_plugin PRIVATE src) +target_link_libraries(lvt_chromium_plugin PRIVATE nlohmann_json::nlohmann_json ole32 version) +set_target_properties(lvt_chromium_plugin PROPERTIES + OUTPUT_NAME "lvt_chromium_plugin" + RUNTIME_OUTPUT_DIRECTORY $/plugins +) + +# Chromium native messaging host — relay between Chrome extension and lvt +add_executable(lvt_chromium_host + src/plugin_chromium/chromium_host.cpp +) +target_compile_definitions(lvt_chromium_host PRIVATE WIN32_LEAN_AND_MEAN NOMINMAX) +target_link_libraries(lvt_chromium_host PRIVATE advapi32) +set_target_properties(lvt_chromium_host PROPERTIES + RUNTIME_OUTPUT_DIRECTORY $/plugins/chromium +) + +# Copy Chromium extension files to output +add_custom_command(TARGET lvt_chromium_plugin POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_directory + "${CMAKE_SOURCE_DIR}/src/plugin_chromium/extension" + "$/plugins/chromium/extension" + COMMENT "Copying Chromium extension files" +) + # --- Tests --- enable_testing() find_package(GTest CONFIG REQUIRED) @@ -172,3 +202,15 @@ target_link_libraries(lvt_integration_tests PRIVATE WIL::WIL nlohmann_json::nlohmann_json ) add_test(NAME integration_tests COMMAND lvt_integration_tests) + +# Chromium plugin tests — DOM JSON format and native messaging protocol +add_executable(lvt_chromium_tests + tests/chromium_tests.cpp +) +target_include_directories(lvt_chromium_tests PRIVATE src) +target_compile_definitions(lvt_chromium_tests PRIVATE WIN32_LEAN_AND_MEAN NOMINMAX) +target_link_libraries(lvt_chromium_tests PRIVATE + GTest::gtest GTest::gtest_main + nlohmann_json::nlohmann_json +) +add_test(NAME chromium_tests COMMAND lvt_chromium_tests) diff --git a/README.md b/README.md index a7d8bc8..afc3f59 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ A Windows CLI tool that inspects the visual tree of running applications. Design ## What it does - Targets any running Windows app by HWND, PID, process name, or window title -- Detects UI frameworks in use: Win32, ComCtl, Windows XAML (UWP), WinUI 3, WPF, [Avalonia](docs/avalonia-plugin.md) +- Detects UI frameworks in use: Win32, ComCtl, Windows XAML (UWP), WinUI 3, WPF, [Avalonia](docs/avalonia-plugin.md), [Chrome/Edge](docs/chromium-plugin.md) - Outputs a unified element tree as JSON or XML markup - Captures annotated PNG screenshots with element IDs overlaid - Elements get stable IDs (`e0`, `e1`, …) so AI agents can reference specific parts of the UI @@ -180,6 +180,7 @@ See [src/plugin.h](src/plugin.h) for the plugin interface. | Plugin | Framework | Docs | |--------|-----------|------| | **Avalonia** | [Avalonia UI](https://avaloniaui.net/) desktop apps | [docs/avalonia-plugin.md](docs/avalonia-plugin.md) | +| **Chromium** | Chrome/Edge browser DOM trees | [docs/chromium-plugin.md](docs/chromium-plugin.md) | These plugins are built from source alongside lvt and deployed to `%USERPROFILE%\.lvt\plugins\`. See each plugin's documentation for installation and usage details. diff --git a/docs/chromium-plugin.md b/docs/chromium-plugin.md new file mode 100644 index 0000000..3917724 --- /dev/null +++ b/docs/chromium-plugin.md @@ -0,0 +1,176 @@ +# Chromium Plugin — Chrome/Edge DOM Inspection + +The Chromium plugin lets lvt inspect the DOM tree of web pages in Google Chrome and Microsoft Edge. It works via a browser extension that communicates with lvt through Chrome's [Native Messaging](https://developer.chrome.com/docs/extensions/develop/concepts/native-messaging) protocol. + +## How it works + +``` +lvt.exe → plugin DLL → named pipe → native host → Chrome extension → chrome.debugger (CDP) → DOM +``` + +1. **lvt** detects Chrome/Edge by checking for `chrome.dll` or `msedge.dll` in the target process +2. The **plugin** connects to a named pipe served by the native messaging host +3. The **native messaging host** relays the request to the browser extension +4. The **extension** uses the `chrome.debugger` API (Chrome DevTools Protocol) to walk the DOM tree of the active tab +5. The DOM tree is returned as an lvt element tree with bounds, properties, and text content + +## Prerequisites + +- Google Chrome 110+ or Microsoft Edge 110+ +- lvt built with the chromium plugin (included by default) + +## Installation + +### 1. Register the native messaging host + +```powershell +build\plugins\chromium\lvt_chromium_host.exe --register +``` + +This creates registry entries for both Chrome and Edge and writes a `com.lvt.chromium.json` manifest file. + +### 2. Load the browser extension + +1. Open `chrome://extensions` (Chrome) or `edge://extensions` (Edge) +2. Enable **Developer mode** (toggle in top-right) +3. Click **Load unpacked** +4. Select the `build/plugins/chromium/extension/` directory + +The extension icon should appear in the toolbar. The extension will automatically connect to the native messaging host. + +## Usage + +```powershell +# Inspect Chrome +lvt --name chrome + +# Inspect Edge +lvt --name msedge + +# Output as XML +lvt --name chrome --format xml + +# Capture screenshot with element annotations +lvt --name chrome --screenshot page.png +``` + +## What you get + +The DOM tree is mapped to lvt elements: + +| DOM concept | lvt element field | +|-------------|-------------------| +| Tag name (`DIV`, `SPAN`) | `type` | +| Tag name (lowercase) | `className` | +| Text content | `text` | +| HTML attributes | `properties` | +| `getBoundingClientRect()` | `bounds` | +| Child elements | `children` | + +Framework name is reported as `"chromium (Chrome)"` or `"chromium (Edge)"`. + +### Example output (JSON) + +```json +{ + "id": "e0", + "type": "Window", + "framework": "win32", + "children": [ + { + "id": "e1", + "type": "HTML", + "framework": "chromium (Chrome)", + "children": [ + { + "id": "e2", + "type": "BODY", + "framework": "chromium (Chrome)", + "bounds": { "x": 0, "y": 0, "width": 1920, "height": 3000 }, + "properties": { "class": "main-content" }, + "children": [ + { + "id": "e3", + "type": "DIV", + "properties": { "id": "app", "class": "container" }, + "text": "Hello World" + } + ] + } + ] + } + ] +} +``` + +### Example output (XML) + +```xml + + + +
+ + + +``` + +## Architecture + +### Browser Extension (Manifest V3) + +- **Service worker** (`service-worker.js`): Connects to the native messaging host, dispatches DOM requests, uses `chrome.debugger` API for DOM walking +- Works on both Chrome and Edge (same Chromium extension format) +- Uses `chrome.debugger.sendCommand("DOM.getDocument", {depth: -1, pierce: true})` for full DOM including shadow DOM +- Gets element bounding boxes via `DOM.getBoxModel` + +### Native Messaging Host (`lvt_chromium_host.exe`) + +- Tiny C++ relay process launched by Chrome when the extension connects +- Bridges Chrome's stdin/stdout native messaging protocol with a Win32 named pipe (`\\.\pipe\lvt_chromium`) +- Supports `--register` to set up Windows registry entries + +### Plugin DLL (`lvt_chromium_plugin.dll`) + +- Implements the standard lvt plugin interface ([plugin.h](../src/plugin.h)) +- Detection: checks for `chrome.dll` or `msedge.dll` loaded in the target process +- Enrichment: connects to the named pipe, sends a `getDOM` request, and parses the response + +## Troubleshooting + +### "Cannot connect to browser extension" + +- Ensure the extension is loaded and active in Chrome/Edge (`chrome://extensions`) +- Run `lvt_chromium_host.exe --register` to (re-)register the native messaging host +- Check that the extension shows "Service worker: active" in the extensions page +- Try reloading the extension + +### Empty DOM tree + +- The tab must have finished loading (no spinner in the tab) +- Some pages may block debugger attachment (e.g., `chrome://` pages) +- Check `chrome://extensions` for extension errors + +### Debug logging + +Set `LVT_DEBUG=1` environment variable for verbose plugin logging: + +```powershell +$env:LVT_DEBUG = "1" +lvt --name chrome +``` + +## Limitations + +- Only inspects the **active tab** (tab selection by URL/title is planned) +- `chrome://` and `edge://` internal pages cannot be inspected +- The browser extension must be installed and the native host registered +- Shadow DOM content is included when `pierce: true` is used (default) + +## Future work + +- Tab selection by URL or title pattern +- iframe support (separate DOM walks per frame) +- WebView2 support (Chrome embedded in Win32 apps) +- Lazy loading for very large DOM trees +- Chrome Web Store / Edge Add-ons publication diff --git a/src/plugin_chromium/chromium_host.cpp b/src/plugin_chromium/chromium_host.cpp new file mode 100644 index 0000000..1b568a1 --- /dev/null +++ b/src/plugin_chromium/chromium_host.cpp @@ -0,0 +1,344 @@ +// lvt_chromium_host.cpp — Native messaging host for the LVT Chromium extension. +// Relays JSON messages between Chrome's native messaging protocol (stdin/stdout) +// and a named pipe that lvt.exe connects to. +// +// Usage: +// lvt_chromium_host.exe — Run as native messaging host (Chrome spawns this) +// lvt_chromium_host.exe --register — Register native messaging host for Chrome + Edge + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +static const char* PIPE_NAME = "\\\\.\\pipe\\lvt_chromium"; +static const char* HOST_NAME = "com.lvt.chromium"; + +static std::atomic g_running{true}; + +// ---------- Native messaging protocol ---------- +// Messages are length-prefixed: 4 bytes (uint32 LE) followed by JSON. + +static bool read_native_message(std::string& out) { + uint32_t len = 0; + DWORD bytesRead = 0; + HANDLE hStdin = GetStdHandle(STD_INPUT_HANDLE); + + if (!ReadFile(hStdin, &len, 4, &bytesRead, nullptr) || bytesRead != 4) + return false; + + if (len == 0 || len > 4 * 1024 * 1024) // 4MB max + return false; + + out.resize(len); + DWORD totalRead = 0; + while (totalRead < len) { + if (!ReadFile(hStdin, out.data() + totalRead, len - totalRead, &bytesRead, nullptr) || bytesRead == 0) + return false; + totalRead += bytesRead; + } + return true; +} + +static bool write_native_message(const std::string& msg) { + uint32_t len = static_cast(msg.size()); + DWORD bytesWritten = 0; + HANDLE hStdout = GetStdHandle(STD_OUTPUT_HANDLE); + + if (!WriteFile(hStdout, &len, 4, &bytesWritten, nullptr) || bytesWritten != 4) + return false; + if (!WriteFile(hStdout, msg.data(), len, &bytesWritten, nullptr) || bytesWritten != len) + return false; + FlushFileBuffers(hStdout); + return true; +} + +// ---------- Named pipe server ---------- + +static HANDLE create_pipe() { + SECURITY_ATTRIBUTES sa = {}; + sa.nLength = sizeof(sa); + sa.bInheritHandle = FALSE; + // Allow all users to connect (lvt.exe may run as different user context) + ConvertStringSecurityDescriptorToSecurityDescriptorA( + "D:(A;;GRGW;;;WD)(A;;GRGW;;;AC)", SDDL_REVISION_1, &sa.lpSecurityDescriptor, nullptr); + + HANDLE pipe = CreateNamedPipeA( + PIPE_NAME, + PIPE_ACCESS_DUPLEX | FILE_FLAG_OVERLAPPED, + PIPE_TYPE_BYTE | PIPE_READMODE_BYTE | PIPE_WAIT, + 1, // max instances + 64 * 1024, // out buffer + 4 * 1024 * 1024, // in buffer (DOM trees can be large) + 0, + &sa); + + LocalFree(sa.lpSecurityDescriptor); + return pipe; +} + +// Read a length-prefixed message from the named pipe +static bool read_pipe_message(HANDLE pipe, std::string& out) { + // Read a length-prefixed message from the named pipe + uint32_t len = 0; + DWORD bytesRead = 0; + OVERLAPPED ov = {}; + ov.hEvent = CreateEventW(nullptr, TRUE, FALSE, nullptr); + + BOOL ok = ReadFile(pipe, &len, 4, &bytesRead, &ov); + if (!ok && GetLastError() == ERROR_IO_PENDING) { + if (WaitForSingleObject(ov.hEvent, 30000) != WAIT_OBJECT_0) { + CancelIo(pipe); + CloseHandle(ov.hEvent); + return false; + } + if (!GetOverlappedResult(pipe, &ov, &bytesRead, FALSE)) { + CloseHandle(ov.hEvent); + return false; + } + } + CloseHandle(ov.hEvent); + if (bytesRead != 4 || len == 0 || len > 4 * 1024 * 1024) + return false; + + out.resize(len); + DWORD totalRead = 0; + while (totalRead < len) { + ov = {}; + ov.hEvent = CreateEventW(nullptr, TRUE, FALSE, nullptr); + ok = ReadFile(pipe, out.data() + totalRead, len - totalRead, &bytesRead, &ov); + if (!ok && GetLastError() == ERROR_IO_PENDING) { + if (WaitForSingleObject(ov.hEvent, 30000) != WAIT_OBJECT_0) { + CancelIo(pipe); + CloseHandle(ov.hEvent); + return false; + } + if (!GetOverlappedResult(pipe, &ov, &bytesRead, FALSE)) { + CloseHandle(ov.hEvent); + return false; + } + } + CloseHandle(ov.hEvent); + if (bytesRead == 0) return false; + totalRead += bytesRead; + } + return true; +} + +// Write a length-prefixed message to the named pipe +static bool write_pipe_message(HANDLE pipe, const std::string& msg) { + uint32_t len = static_cast(msg.size()); + DWORD bytesWritten = 0; + OVERLAPPED ov = {}; + ov.hEvent = CreateEventW(nullptr, TRUE, FALSE, nullptr); + + // Write length prefix + BOOL ok = WriteFile(pipe, &len, 4, &bytesWritten, &ov); + if (!ok && GetLastError() == ERROR_IO_PENDING) { + WaitForSingleObject(ov.hEvent, 5000); + if (!GetOverlappedResult(pipe, &ov, &bytesWritten, FALSE)) { + CloseHandle(ov.hEvent); + return false; + } + } + if (bytesWritten != 4) { CloseHandle(ov.hEvent); return false; } + + // Write message body + ResetEvent(ov.hEvent); + ok = WriteFile(pipe, msg.data(), len, &bytesWritten, &ov); + if (!ok && GetLastError() == ERROR_IO_PENDING) { + WaitForSingleObject(ov.hEvent, 30000); + if (!GetOverlappedResult(pipe, &ov, &bytesWritten, FALSE)) { + CloseHandle(ov.hEvent); + return false; + } + } + + CloseHandle(ov.hEvent); + return bytesWritten == len; +} + +// ---------- Registration ---------- + +static std::wstring get_exe_path() { + wchar_t path[MAX_PATH]; + GetModuleFileNameW(nullptr, path, MAX_PATH); + return path; +} + +static std::string wstring_to_utf8(const std::wstring& ws) { + if (ws.empty()) return {}; + int len = WideCharToMultiByte(CP_UTF8, 0, ws.c_str(), -1, nullptr, 0, nullptr, nullptr); + std::string s(len - 1, '\0'); + WideCharToMultiByte(CP_UTF8, 0, ws.c_str(), -1, s.data(), len, nullptr, nullptr); + return s; +} + +static bool register_host() { + auto exePath = get_exe_path(); + auto exeDir = exePath.substr(0, exePath.find_last_of(L"\\/")); + + // Create the native messaging host manifest JSON + // Path needs escaped backslashes for JSON + auto pathUtf8 = wstring_to_utf8(exePath); + std::string escapedPath; + for (char c : pathUtf8) { + if (c == '\\') escapedPath += "\\\\"; + else escapedPath += c; + } + + // The extension ID is deterministic because manifest.json contains a fixed "key". + // ID: pgknpnjnhiflafcaeafgpjonadhbpfok + std::string manifest = + "{\n" + " \"name\": \"" + std::string(HOST_NAME) + "\",\n" + " \"description\": \"LVT Chromium DOM inspector bridge\",\n" + " \"path\": \"" + escapedPath + "\",\n" + " \"type\": \"stdio\",\n" + " \"allowed_origins\": [\"chrome-extension://pgknpnjnhiflafcaeafgpjonadhbpfok/\"]\n" + "}\n"; + + // Write manifest file next to the exe + auto manifestPath = wstring_to_utf8(exeDir) + "\\com.lvt.chromium.json"; + { + std::ofstream f(manifestPath, std::ios::binary | std::ios::trunc); + if (!f) { + fprintf(stderr, "Failed to write manifest to %s\n", manifestPath.c_str()); + return false; + } + f.write(manifest.data(), manifest.size()); + } + + auto manifestPathW = exeDir + L"\\com.lvt.chromium.json"; + + // Register in Windows registry for both Chrome and Edge + const wchar_t* regPaths[] = { + L"Software\\Google\\Chrome\\NativeMessagingHosts\\com.lvt.chromium", + L"Software\\Microsoft\\Edge\\NativeMessagingHosts\\com.lvt.chromium", + }; + + bool ok = true; + for (auto regPath : regPaths) { + HKEY key; + LSTATUS status = RegCreateKeyExW(HKEY_CURRENT_USER, regPath, 0, nullptr, + 0, KEY_SET_VALUE, nullptr, &key, nullptr); + if (status != ERROR_SUCCESS) { + fprintf(stderr, "Failed to create registry key: %ls (error %ld)\n", regPath, status); + ok = false; + continue; + } + + status = RegSetValueExW(key, nullptr, 0, REG_SZ, + reinterpret_cast(manifestPathW.c_str()), + static_cast((manifestPathW.size() + 1) * sizeof(wchar_t))); + RegCloseKey(key); + + if (status != ERROR_SUCCESS) { + fprintf(stderr, "Failed to set registry value (error %ld)\n", status); + ok = false; + } else { + fprintf(stderr, "Registered: %ls\n", regPath); + } + } + + if (ok) { + fprintf(stderr, "Native messaging host registered successfully.\n"); + fprintf(stderr, "Manifest: %s\n", manifestPath.c_str()); + } + return ok; +} + +// ---------- Main relay loop ---------- + +static void run_relay() { + // Set stdin/stdout to binary mode + _setmode(_fileno(stdin), _O_BINARY); + _setmode(_fileno(stdout), _O_BINARY); + + HANDLE pipe = create_pipe(); + if (pipe == INVALID_HANDLE_VALUE) { + // Pipe may already exist from another host instance — not fatal, + // but we can't relay without it. + write_native_message("{\"type\":\"error\",\"message\":\"Failed to create named pipe\"}"); + return; + } + + // Tell the extension we're ready + write_native_message("{\"type\":\"ready\"}"); + + // Thread 1: Read from named pipe (lvt requests) → forward to extension (stdout) + std::thread pipeReader([&]() { + while (g_running) { + // Wait for lvt to connect + OVERLAPPED ov = {}; + ov.hEvent = CreateEventW(nullptr, TRUE, FALSE, nullptr); + ConnectNamedPipe(pipe, &ov); + DWORD err = GetLastError(); + + if (err == ERROR_IO_PENDING) { + // Wait up to 60s for lvt to connect, then loop to check g_running + while (g_running) { + if (WaitForSingleObject(ov.hEvent, 1000) == WAIT_OBJECT_0) + break; + } + if (!g_running) { + CancelIo(pipe); + CloseHandle(ov.hEvent); + break; + } + } else if (err != ERROR_PIPE_CONNECTED && err != 0) { + CloseHandle(ov.hEvent); + Sleep(1000); + continue; + } + CloseHandle(ov.hEvent); + + // lvt is connected — relay messages + while (g_running) { + std::string msg; + if (!read_pipe_message(pipe, msg)) + break; + // Forward lvt's request to the extension + if (!write_native_message(msg)) + break; + } + + DisconnectNamedPipe(pipe); + } + }); + + // Main thread: Read from extension (stdin) → forward to named pipe + while (g_running) { + std::string msg; + if (!read_native_message(msg)) { + g_running = false; + break; + } + // Forward extension's response to lvt via pipe + write_pipe_message(pipe, msg); + } + + g_running = false; + if (pipeReader.joinable()) + pipeReader.join(); + + CloseHandle(pipe); +} + +// ---------- Entry point ---------- + +int main(int argc, char* argv[]) { + if (argc > 1 && (strcmp(argv[1], "--register") == 0 || strcmp(argv[1], "-r") == 0)) { + return register_host() ? 0 : 1; + } + + run_relay(); + return 0; +} diff --git a/src/plugin_chromium/extension/manifest.json b/src/plugin_chromium/extension/manifest.json new file mode 100644 index 0000000..4f4a2e0 --- /dev/null +++ b/src/plugin_chromium/extension/manifest.json @@ -0,0 +1,22 @@ +{ + "manifest_version": 3, + "name": "LVT - Live Visual Tree", + "version": "1.0.0", + "description": "Exposes the DOM tree of browser tabs to the LVT CLI tool for AI-powered UI inspection.", + "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAg2amT6+n68/syIKGL+QwK0pkveIxgGX+9TTCFLr7RInxjsnh3vZGGIjaowfdPDfxruijJL8LMgHxASOBBjuUCWqBMPQOHw9LCLbULunNY8ahXxjZjocas1UhSQVwf7Hk990XdBzrwyqjE49F3wGR3Wbdvmg5FKHhuiLpDVzRKy6U5pKmyPb4bU6yrVgWefOSFfJdzPCir6vs6RyExy745+SUmwWz0eHCnPpNSdEInZ+LyzDGvkhPkfYXC2A2VfF+cK04cj9i+5+5K8Xnopt9g9/PkdSm+i257jhg5tlVUaekGzFtm8EyCAfETMUna4kYqKXH62u6Fui4nqpAXbH8AQIDAQAB", + "permissions": [ + "debugger", + "tabs", + "nativeMessaging", + "activeTab" + ], + "background": { + "service_worker": "service-worker.js" + }, + "icons": { + "16": "icons/icon16.png", + "48": "icons/icon48.png", + "128": "icons/icon128.png" + }, + "minimum_chrome_version": "110" +} diff --git a/src/plugin_chromium/extension/service-worker.js b/src/plugin_chromium/extension/service-worker.js new file mode 100644 index 0000000..454cecd --- /dev/null +++ b/src/plugin_chromium/extension/service-worker.js @@ -0,0 +1,195 @@ +// LVT Chromium Extension — Service Worker +// Connects to the lvt native messaging host and dispatches DOM tree requests. + +const NATIVE_HOST_NAME = "com.lvt.chromium"; + +let nativePort = null; + +function connectToHost() { + try { + nativePort = chrome.runtime.connectNative(NATIVE_HOST_NAME); + console.log("LVT: Connected to native messaging host"); + + nativePort.onMessage.addListener(handleHostMessage); + nativePort.onDisconnect.addListener(() => { + console.log("LVT: Native host disconnected", chrome.runtime.lastError?.message); + nativePort = null; + // Reconnect after a delay + setTimeout(connectToHost, 5000); + }); + } catch (e) { + console.error("LVT: Failed to connect to native host:", e); + setTimeout(connectToHost, 5000); + } +} + +async function handleHostMessage(message) { + console.log("LVT: Received message from host:", message.type); + + if (message.type === "getDOM") { + try { + const result = await getActiveTabDOM(message); + nativePort?.postMessage(result); + } catch (e) { + nativePort?.postMessage({ + type: "error", + requestId: message.requestId, + message: e.message || String(e) + }); + } + } else if (message.type === "ping") { + nativePort?.postMessage({ type: "pong" }); + } +} + +async function getActiveTabDOM(request) { + // Find the active tab in the last focused window + let tabs = await chrome.tabs.query({ active: true, lastFocusedWindow: true }); + + // Filter out non-debuggable tabs (chrome://, edge://, about:, etc.) + tabs = tabs.filter(t => t.url && !t.url.startsWith("chrome://") && + !t.url.startsWith("edge://") && !t.url.startsWith("about:") && + !t.url.startsWith("chrome-extension://")); + + // If no debuggable active tab, try any active tab across all windows + if (!tabs.length) { + tabs = await chrome.tabs.query({ active: true }); + tabs = tabs.filter(t => t.url && !t.url.startsWith("chrome://") && + !t.url.startsWith("edge://") && !t.url.startsWith("about:") && + !t.url.startsWith("chrome-extension://")); + } + + if (!tabs.length) { + throw new Error("No debuggable tab found (chrome:// and edge:// pages cannot be inspected)"); + } + + const tab = tabs[0]; + if (!tab.id) { + throw new Error("Active tab has no ID"); + } + + // Attach the debugger to the tab + const target = { tabId: tab.id }; + + try { + await chrome.debugger.attach(target, "1.3"); + } catch (e) { + // May already be attached + if (!e.message?.includes("Already attached")) { + throw new Error(`Failed to attach debugger: ${e.message}`); + } + } + + try { + // Enable DOM domain + await chrome.debugger.sendCommand(target, "DOM.enable"); + + // Get the full DOM tree + const docResult = await chrome.debugger.sendCommand(target, "DOM.getDocument", { + depth: -1, + pierce: true + }); + + // Get layout metrics for bounding boxes + const tree = await convertDOMTree(target, docResult.root); + + return { + type: "domTree", + requestId: request.requestId, + url: tab.url || "", + title: tab.title || "", + tree: tree ? [tree] : [] + }; + } finally { + try { + await chrome.debugger.detach(target); + } catch (e) { + // Ignore detach errors + } + } +} + +// Convert a CDP DOM.Node tree to lvt element format +async function convertDOMTree(target, node, depth = 0) { + // Skip non-element nodes (except document/document fragment) + const ELEMENT_NODE = 1; + const DOCUMENT_NODE = 9; + const DOCUMENT_FRAGMENT_NODE = 11; + + if (node.nodeType !== ELEMENT_NODE && + node.nodeType !== DOCUMENT_NODE && + node.nodeType !== DOCUMENT_FRAGMENT_NODE) { + return null; + } + + const element = { + type: node.nodeName || "", + }; + + // Parse attributes into properties + if (node.attributes && node.attributes.length > 0) { + const props = {}; + for (let i = 0; i < node.attributes.length; i += 2) { + props[node.attributes[i]] = node.attributes[i + 1] || ""; + } + element.properties = props; + } + + // Get text content for leaf elements + if (node.children) { + const textChildren = node.children.filter(c => c.nodeType === 3 /* TEXT_NODE */); + const text = textChildren.map(c => c.nodeValue?.trim()).filter(Boolean).join(" "); + if (text) { + element.text = text; + } + } + + // Try to get bounding box (only for element nodes, skip deep trees for perf) + if (node.nodeType === ELEMENT_NODE && depth < 50) { + try { + const boxModel = await chrome.debugger.sendCommand(target, "DOM.getBoxModel", { + nodeId: node.nodeId + }); + if (boxModel?.model?.content) { + const quad = boxModel.model.content; + // quad is [x1,y1, x2,y2, x3,y3, x4,y4] — use top-left and dimensions + element.offsetX = Math.round(quad[0]); + element.offsetY = Math.round(quad[1]); + element.width = Math.round(quad[2] - quad[0]); + element.height = Math.round(quad[5] - quad[1]); + } + } catch (e) { + // getBoxModel fails for invisible elements — that's fine + } + } + + // Recurse into children + const allChildren = [ + ...(node.children || []), + ...(node.shadowRoots || []), + ...(node.contentDocument ? [node.contentDocument] : []) + ]; + + if (allChildren.length > 0) { + const children = []; + for (const child of allChildren) { + const converted = await convertDOMTree(target, child, depth + 1); + if (converted) { + children.push(converted); + } + } + if (children.length > 0) { + element.children = children; + } + } + + return element; +} + +// Connect on startup and on install/update +connectToHost(); + +chrome.runtime.onInstalled.addListener(() => { + console.log("LVT: Extension installed/updated"); + if (!nativePort) connectToHost(); +}); diff --git a/src/plugin_chromium/lvt_chromium_plugin.cpp b/src/plugin_chromium/lvt_chromium_plugin.cpp new file mode 100644 index 0000000..52e2e9c --- /dev/null +++ b/src/plugin_chromium/lvt_chromium_plugin.cpp @@ -0,0 +1,329 @@ +// lvt_chromium_plugin.cpp — LVT plugin for Chrome/Edge DOM tree inspection. +// Detects Chromium-based browsers by checking for chrome.dll or msedge.dll, +// then communicates with the LVT Chromium extension via a native messaging host +// relay to retrieve the DOM tree. + +#include "plugin.h" + +#include + +#include +#include +#include +#include +#include +#include +#include + +#pragma comment(lib, "Psapi.lib") + +using json = nlohmann::json; + +// ---------- Logging ---------- + +static bool g_debug = false; + +static void DebugLog(const char* fmt, ...) { + if (!g_debug) return; + fprintf(stderr, "lvt-chromium: "); + va_list ap; + va_start(ap, fmt); + vfprintf(stderr, fmt, ap); + va_end(ap); + fprintf(stderr, "\n"); +} + +// ---------- Plugin metadata ---------- + +static LvtPluginInfo s_info = { + sizeof(LvtPluginInfo), + LVT_PLUGIN_API_VERSION, + "chromium", + "Chrome/Edge DOM tree support via browser extension" +}; + +// ---------- Module detection helpers ---------- + +static bool has_module(HANDLE proc, const wchar_t* moduleName) { + HMODULE modules[2048]; + DWORD needed = 0; + if (!EnumProcessModulesEx(proc, modules, sizeof(modules), &needed, LIST_MODULES_ALL)) + return false; + + for (DWORD i = 0; i < needed / sizeof(HMODULE); i++) { + wchar_t name[MAX_PATH]{}; + if (GetModuleBaseNameW(proc, modules[i], name, MAX_PATH)) { + if (_wcsicmp(name, moduleName) == 0) + return true; + } + } + return false; +} + +static std::string get_module_version(HANDLE proc, const wchar_t* moduleName) { + HMODULE modules[2048]; + DWORD needed = 0; + if (!EnumProcessModulesEx(proc, modules, sizeof(modules), &needed, LIST_MODULES_ALL)) + return {}; + + for (DWORD i = 0; i < needed / sizeof(HMODULE); i++) { + wchar_t name[MAX_PATH]{}; + if (!GetModuleBaseNameW(proc, modules[i], name, MAX_PATH)) + continue; + if (_wcsicmp(name, moduleName) != 0) + continue; + + wchar_t fullPath[MAX_PATH]{}; + if (!GetModuleFileNameExW(proc, modules[i], fullPath, MAX_PATH)) + return {}; + + DWORD verHandle = 0; + DWORD verSize = GetFileVersionInfoSizeW(fullPath, &verHandle); + if (verSize == 0) return {}; + + std::vector verData(verSize); + if (!GetFileVersionInfoW(fullPath, verHandle, verSize, verData.data())) + return {}; + + VS_FIXEDFILEINFO* fileInfo = nullptr; + UINT len = 0; + if (!VerQueryValueW(verData.data(), L"\\", reinterpret_cast(&fileInfo), &len)) + return {}; + + DWORD ms = fileInfo->dwProductVersionMS; + DWORD ls = fileInfo->dwProductVersionLS; + char buf[64]; + snprintf(buf, sizeof(buf), "%d.%d.%d.%d", + HIWORD(ms), LOWORD(ms), HIWORD(ls), LOWORD(ls)); + return buf; + } + return {}; +} + +// ---------- Named pipe communication ---------- + +static const char* PIPE_NAME = "\\\\.\\pipe\\lvt_chromium"; + +// Write a length-prefixed message to a pipe +static bool write_pipe_message(HANDLE pipe, const std::string& msg) { + uint32_t len = static_cast(msg.size()); + DWORD bytesWritten = 0; + OVERLAPPED ov = {}; + ov.hEvent = CreateEventW(nullptr, TRUE, FALSE, nullptr); + + BOOL ok = WriteFile(pipe, &len, 4, &bytesWritten, &ov); + if (!ok && GetLastError() == ERROR_IO_PENDING) { + WaitForSingleObject(ov.hEvent, 5000); + if (!GetOverlappedResult(pipe, &ov, &bytesWritten, FALSE)) { + CloseHandle(ov.hEvent); + return false; + } + } + if (bytesWritten != 4) { CloseHandle(ov.hEvent); return false; } + + ResetEvent(ov.hEvent); + ok = WriteFile(pipe, msg.data(), len, &bytesWritten, &ov); + if (!ok && GetLastError() == ERROR_IO_PENDING) { + WaitForSingleObject(ov.hEvent, 30000); + if (!GetOverlappedResult(pipe, &ov, &bytesWritten, FALSE)) { + CloseHandle(ov.hEvent); + return false; + } + } + + CloseHandle(ov.hEvent); + return bytesWritten == len; +} + +// Read a length-prefixed message from a pipe +static bool read_pipe_message(HANDLE pipe, std::string& out, DWORD timeoutMs = 30000) { + uint32_t len = 0; + DWORD bytesRead = 0; + OVERLAPPED ov = {}; + ov.hEvent = CreateEventW(nullptr, TRUE, FALSE, nullptr); + + BOOL ok = ReadFile(pipe, &len, 4, &bytesRead, &ov); + if (!ok && GetLastError() == ERROR_IO_PENDING) { + if (WaitForSingleObject(ov.hEvent, timeoutMs) != WAIT_OBJECT_0) { + CancelIo(pipe); + CloseHandle(ov.hEvent); + return false; + } + if (!GetOverlappedResult(pipe, &ov, &bytesRead, FALSE)) { + CloseHandle(ov.hEvent); + return false; + } + } + CloseHandle(ov.hEvent); + + if (bytesRead != 4 || len == 0 || len > 64 * 1024 * 1024) // 64MB max for large DOMs + return false; + + out.resize(len); + DWORD totalRead = 0; + while (totalRead < len) { + ov = {}; + ov.hEvent = CreateEventW(nullptr, TRUE, FALSE, nullptr); + ok = ReadFile(pipe, out.data() + totalRead, len - totalRead, &bytesRead, &ov); + if (!ok && GetLastError() == ERROR_IO_PENDING) { + if (WaitForSingleObject(ov.hEvent, timeoutMs) != WAIT_OBJECT_0) { + CancelIo(pipe); + CloseHandle(ov.hEvent); + return false; + } + if (!GetOverlappedResult(pipe, &ov, &bytesRead, FALSE)) { + CloseHandle(ov.hEvent); + return false; + } + } + CloseHandle(ov.hEvent); + if (bytesRead == 0) return false; + totalRead += bytesRead; + } + return true; +} + +// ---------- Version string storage ---------- +static char s_version_buf[64]; +static char s_browser_name[32]; + +// ---------- Plugin exports ---------- + +extern "C" { + +__declspec(dllexport) LvtPluginInfo* lvt_plugin_info(void) { + char dbg[8]{}; + if (GetEnvironmentVariableA("LVT_DEBUG", dbg, sizeof(dbg)) > 0) + g_debug = true; + return &s_info; +} + +__declspec(dllexport) int lvt_detect_framework(DWORD pid, HWND /*hwnd*/, LvtFrameworkDetection* out) { + if (!out) return 0; + + HANDLE proc = OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_VM_READ, FALSE, pid); + if (!proc) return 0; + + // Check for Chrome or Edge + bool isChrome = has_module(proc, L"chrome.dll"); + bool isEdge = has_module(proc, L"msedge.dll"); + + if (!isChrome && !isEdge) { + CloseHandle(proc); + return 0; + } + + const wchar_t* versionModule = isEdge ? L"msedge.dll" : L"chrome.dll"; + auto version = get_module_version(proc, versionModule); + CloseHandle(proc); + + if (!version.empty()) { + // Include browser name in version for display: "145.0.3800.70 (Edge)" + auto fullVersion = version + (isEdge ? " (Edge)" : " (Chrome)"); + strncpy_s(s_version_buf, fullVersion.c_str(), sizeof(s_version_buf) - 1); + out->version = s_version_buf; + } + + // Name must match plugin info name ("chromium") for tree builder lookup + out->struct_size = sizeof(LvtFrameworkDetection); + out->name = "chromium"; + + DebugLog("detected %s %s", isEdge ? "Edge" : "Chrome", version.c_str()); + return 1; +} + +__declspec(dllexport) int lvt_enrich_tree(HWND /*hwnd*/, DWORD /*pid*/, + const char* /*element_class_filter*/, + char** json_out) +{ + if (!json_out) return 0; + *json_out = nullptr; + + // Connect to the native messaging host's named pipe + HANDLE pipe = CreateFileA( + PIPE_NAME, + GENERIC_READ | GENERIC_WRITE, + 0, + nullptr, + OPEN_EXISTING, + FILE_FLAG_OVERLAPPED, + nullptr); + + if (pipe == INVALID_HANDLE_VALUE) { + DebugLog("failed to connect to native messaging host pipe (error %lu). " + "Is the LVT Chromium extension installed and active?", GetLastError()); + fprintf(stderr, "lvt-chromium: Cannot connect to browser extension.\n" + " Ensure the LVT extension is installed in Chrome/Edge and\n" + " the native messaging host is registered (lvt_chromium_host.exe --register).\n"); + return 0; + } + + DebugLog("connected to native messaging host pipe"); + + // Send getDOM request + std::string request = "{\"type\":\"getDOM\",\"requestId\":\"1\",\"tabId\":\"active\"}"; + if (!write_pipe_message(pipe, request)) { + DebugLog("failed to send getDOM request"); + CloseHandle(pipe); + return 0; + } + + DebugLog("sent getDOM request, waiting for response..."); + + // Read response (may be large — full DOM tree) + std::string response; + if (!read_pipe_message(pipe, response, 60000)) { + DebugLog("failed to read DOM response (timeout or error)"); + CloseHandle(pipe); + return 0; + } + + CloseHandle(pipe); + + DebugLog("received %zu bytes of DOM data", response.size()); + + if (response.empty()) { + DebugLog("empty DOM response"); + return 0; + } + + // Parse the response envelope and extract the "tree" field. + // The extension returns: {"type":"domTree","tree":[...],...} + // The plugin loader expects a JSON array of element nodes. + try { + auto envelope = json::parse(response); + + if (envelope.contains("type") && envelope["type"] == "error") { + auto msg = envelope.value("message", "unknown error"); + DebugLog("extension returned error: %s", msg.c_str()); + fprintf(stderr, "lvt-chromium: %s\n", msg.c_str()); + return 0; + } + + json tree; + if (envelope.contains("tree") && envelope["tree"].is_array()) { + tree = envelope["tree"]; + } else if (envelope.is_array()) { + tree = envelope; + } else { + DebugLog("unexpected response format"); + return 0; + } + + auto treeStr = tree.dump(); + char* result = static_cast(malloc(treeStr.size() + 1)); + if (!result) return 0; + memcpy(result, treeStr.c_str(), treeStr.size() + 1); + *json_out = result; + return 1; + } catch (const json::parse_error& e) { + DebugLog("failed to parse response JSON: %s", e.what()); + return 0; + } +} + +__declspec(dllexport) void lvt_plugin_free(void* ptr) { + free(ptr); +} + +} // extern "C" diff --git a/tests/chromium_tests.cpp b/tests/chromium_tests.cpp new file mode 100644 index 0000000..6b59c41 --- /dev/null +++ b/tests/chromium_tests.cpp @@ -0,0 +1,195 @@ +// Unit tests for the LVT Chromium plugin components. +// Tests the DOM JSON format compatibility with plugin_loader's graft_json_node, +// and the native messaging length-prefix protocol. + +#include +#include +#include +#include +#include + +using json = nlohmann::json; + +// ---- DOM JSON format tests ---- +// Verify that the JSON format produced by the extension is compatible +// with the plugin_loader's graft_json_node expectations. + +TEST(ChromiumDomJson, BasicElement) { + // Simulate what the extension produces for a
Hello
+ json element = { + {"type", "DIV"}, + {"text", "Hello"}, + {"offsetX", 10}, + {"offsetY", 20}, + {"width", 800}, + {"height", 600}, + {"properties", {{"id", "app"}, {"class", "container"}}} + }; + + // Verify fields match graft_json_node expectations + EXPECT_EQ(element.value("type", ""), "DIV"); + EXPECT_EQ(element.value("text", ""), "Hello"); + EXPECT_EQ(element.value("offsetX", 0.0), 10.0); + EXPECT_EQ(element.value("offsetY", 0.0), 20.0); + EXPECT_EQ(element.value("width", 0.0), 800.0); + EXPECT_EQ(element.value("height", 0.0), 600.0); + EXPECT_TRUE(element.contains("properties")); + EXPECT_TRUE(element["properties"].is_object()); + EXPECT_EQ(element["properties"]["id"], "app"); +} + +TEST(ChromiumDomJson, NestedTree) { + json tree = json::array({ + { + {"type", "HTML"}, + {"children", json::array({ + { + {"type", "HEAD"}, + {"children", json::array()} + }, + { + {"type", "BODY"}, + {"offsetX", 0}, + {"offsetY", 0}, + {"width", 1920}, + {"height", 1080}, + {"children", json::array({ + { + {"type", "DIV"}, + {"text", "Content"}, + {"properties", {{"class", "main"}}} + } + })} + } + })} + } + }); + + ASSERT_TRUE(tree.is_array()); + ASSERT_EQ(tree.size(), 1); + EXPECT_EQ(tree[0]["type"], "HTML"); + ASSERT_EQ(tree[0]["children"].size(), 2); + EXPECT_EQ(tree[0]["children"][1]["type"], "BODY"); + EXPECT_EQ(tree[0]["children"][1]["children"][0]["text"], "Content"); +} + +TEST(ChromiumDomJson, ResponseEnvelope) { + // The extension wraps the tree in an envelope + json response = { + {"type", "domTree"}, + {"requestId", "1"}, + {"url", "https://example.com"}, + {"title", "Example"}, + {"tree", json::array({ + {{"type", "HTML"}, {"children", json::array()}} + })} + }; + + // Plugin extracts the "tree" field + ASSERT_TRUE(response.contains("tree")); + ASSERT_TRUE(response["tree"].is_array()); + json tree = response["tree"]; + EXPECT_EQ(tree.size(), 1); + EXPECT_EQ(tree[0]["type"], "HTML"); +} + +TEST(ChromiumDomJson, ErrorResponse) { + json response = { + {"type", "error"}, + {"message", "No active tab found"} + }; + + EXPECT_EQ(response["type"], "error"); + EXPECT_EQ(response["message"], "No active tab found"); +} + +TEST(ChromiumDomJson, EmptyProperties) { + // Elements with no attributes should not have a properties field + json element = { + {"type", "DIV"}, + {"width", 100}, + {"height", 50} + }; + + EXPECT_FALSE(element.contains("properties")); + // graft_json_node checks: j.contains("properties") && j["properties"].is_object() + // So missing properties is fine +} + +TEST(ChromiumDomJson, ElementWithShadowRoot) { + // Shadow roots appear as document fragment nodes in the tree + json element = { + {"type", "DIV"}, + {"children", json::array({ + { + {"type", "#document-fragment"}, + {"children", json::array({ + {{"type", "SLOT"}} + })} + } + })} + }; + + ASSERT_TRUE(element["children"].is_array()); + EXPECT_EQ(element["children"][0]["type"], "#document-fragment"); +} + +// ---- Native messaging protocol tests ---- + +// Encode a native messaging frame: 4-byte LE length + JSON +static std::vector encode_native_message(const std::string& json_str) { + uint32_t len = static_cast(json_str.size()); + std::vector frame(4 + len); + memcpy(frame.data(), &len, 4); // Little-endian on x86/x64 + memcpy(frame.data() + 4, json_str.data(), len); + return frame; +} + +// Decode a native messaging frame +static std::string decode_native_message(const std::vector& frame) { + if (frame.size() < 4) return {}; + uint32_t len = 0; + memcpy(&len, frame.data(), 4); + if (frame.size() < 4 + len) return {}; + return std::string(reinterpret_cast(frame.data() + 4), len); +} + +TEST(NativeMessaging, EncodeSimple) { + auto frame = encode_native_message("{\"type\":\"ping\"}"); + ASSERT_GE(frame.size(), 4u); + uint32_t len = 0; + memcpy(&len, frame.data(), 4); + EXPECT_EQ(len, 15u); // strlen of {"type":"ping"} + EXPECT_EQ(frame.size(), 4u + 15u); +} + +TEST(NativeMessaging, RoundTrip) { + std::string original = "{\"type\":\"getDOM\",\"tabId\":\"active\"}"; + auto frame = encode_native_message(original); + auto decoded = decode_native_message(frame); + EXPECT_EQ(decoded, original); +} + +TEST(NativeMessaging, EmptyMessage) { + auto frame = encode_native_message(""); + uint32_t len = 0; + memcpy(&len, frame.data(), 4); + EXPECT_EQ(len, 0u); +} + +TEST(NativeMessaging, LargeMessage) { + // Simulate a large DOM tree (1MB) + std::string large(1024 * 1024, 'x'); + auto frame = encode_native_message(large); + uint32_t len = 0; + memcpy(&len, frame.data(), 4); + EXPECT_EQ(len, 1024u * 1024u); + auto decoded = decode_native_message(frame); + EXPECT_EQ(decoded.size(), large.size()); +} + +TEST(NativeMessaging, TruncatedFrame) { + std::vector frame = {0x0A, 0x00, 0x00, 0x00}; // claims 10 bytes, but no payload + auto decoded = decode_native_message(frame); + EXPECT_TRUE(decoded.empty()); +} From 53f5f4ebf0e72291edb364de26759d8de5a3b32a Mon Sep 17 00:00:00 2001 From: Alexander Sklar Date: Thu, 26 Feb 2026 13:48:01 -0800 Subject: [PATCH 2/2] Add Chromium tests to CI/release and update SKILL.md - Run lvt_chromium_tests.exe in both CI and release pipelines - Add chromium to framework list in SKILL.md - Document one-time Chrome/Edge extension setup for DOM inspection Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/ci.yml | 4 +++- .github/workflows/release.yml | 4 ++++ skills/lvt/SKILL.md | 23 +++++++++++++++++++++-- 3 files changed, 28 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 310db14..ad79ca3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,6 +38,9 @@ jobs: - name: Unit tests run: build\lvt_unit_tests.exe --gtest_output=xml:build\unit_test_results.xml + - name: Chromium plugin tests + run: build\lvt_chromium_tests.exe --gtest_output=xml:build\chromium_test_results.xml + - name: Integration tests run: build\lvt_integration_tests.exe --gtest_output=xml:build\integration_test_results.xml @@ -47,7 +50,6 @@ jobs: with: name: test-results path: build\*_test_results.xml - - name: Upload build artifacts uses: actions/upload-artifact@v4 if: always() diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0a9169a..31d2258 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -65,6 +65,10 @@ jobs: if: matrix.arch == 'x64' run: ${{ matrix.build_dir }}\lvt_unit_tests.exe + - name: Chromium plugin tests + if: matrix.arch == 'x64' + run: ${{ matrix.build_dir }}\lvt_chromium_tests.exe + - name: Integration tests if: matrix.arch == 'x64' run: ${{ matrix.build_dir }}\lvt_integration_tests.exe diff --git a/skills/lvt/SKILL.md b/skills/lvt/SKILL.md index 46e1189..a95fd20 100644 --- a/skills/lvt/SKILL.md +++ b/skills/lvt/SKILL.md @@ -16,7 +16,7 @@ Use `lvt` whenever you need to understand the visual content or structure of a r - **UI verification** — confirm that a UI change was applied correctly (e.g. a button label changed, a dialog appeared) - **Finding UI elements** — locate a specific control, menu item, or text field in an app's visual tree - **Screenshot capture** — take an annotated screenshot of an app with element IDs overlaid -- **Framework detection** — determine which UI frameworks an app uses (Win32, ComCtl, XAML, WinUI 3, WPF) +- **Framework detection** — determine which UI frameworks an app uses (Win32, ComCtl, XAML, WinUI 3, WPF, Chromium) - **Automated UI interaction planning** — get element IDs and bounds to plan mouse clicks or keyboard input ## Prerequisites @@ -118,7 +118,7 @@ Every element gets a stable ID like `e0`, `e1`, `e2`, etc., assigned in depth-fi |----------|-------------| | `id` | Stable element ID (e.g. `e0`) | | `type` | Element type name (e.g. `Window`, `Button`, `TextBlock`) | -| `framework` | Which framework owns this element (`win32`, `comctl`, `xaml`, `winui3`, `wpf`) | +| `framework` | Which framework owns this element (`win32`, `comctl`, `xaml`, `winui3`, `wpf`, `chromium`) | | `className` | Win32 window class name (Win32/ComCtl elements) | | `text` | Visible text content or window title | | `bounds` | Screen-relative bounding rectangle `{x, y, width, height}` | @@ -169,3 +169,22 @@ Every element gets a stable ID like `e0`, `e1`, `e2`, etc., assigned in depth-fi - For XAML/WinUI 3 apps, lvt injects a helper DLL into the target — this is safe and non-destructive but means `lvt_tap_{arch}.dll` must be next to `lvt.exe` - For WPF apps, lvt injects `lvt_wpf_tap_{arch}.dll` and the managed `LvtWpfTap.dll` — both must be next to `lvt.exe` - lvt.exe must match the target process architecture (x64, x86, or ARM64) — a clear error is shown on mismatch. Use `lvt-x86.exe` for 32-bit WPF apps. + +## Chrome/Edge DOM inspection (optional one-time setup) + +lvt can dump the DOM tree of web pages in Chrome and Edge. This requires a one-time setup: + +```powershell +$lvtDir = "$env:USERPROFILE\.lvt" + +# 1. Register the native messaging host for Chrome and Edge +& "$lvtDir\plugins\chromium\lvt_chromium_host.exe" --register + +# 2. Load the browser extension in Chrome or Edge: +# - Open chrome://extensions (or edge://extensions) +# - Enable "Developer mode" +# - Click "Load unpacked" → select the extension folder: +Start-Process "$lvtDir\plugins\chromium\extension" +``` + +After setup, `lvt --name chrome` or `lvt --name msedge` will include the DOM tree of the active tab. If the extension is not installed, lvt still works for all other frameworks — it just won't show web content.