Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 18 additions & 6 deletions docs/chromium-plugin.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@ lvt.exe → plugin DLL → named pipe → native host → Chrome extension → c
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
4. The **plugin** can request the extension's tab list and select a tab by URL/title before the DOM walk
5. The **extension** uses the `chrome.debugger` API (Chrome DevTools Protocol) to walk the DOM tree of the selected tab (or the active tab when no tab selector is provided)
6. The DOM tree is returned as an lvt element tree with bounds, properties, and text content

## Prerequisites

Expand Down Expand Up @@ -52,8 +53,20 @@ lvt --name chrome --format xml

# Capture screenshot with element annotations
lvt --name chrome --screenshot page.png

# Select a Chromium tab by URL or title substring while targeting the browser
lvt --name chrome --title "github.com/asklar/lvt"

# Prefix with url: or title: to restrict the match; * and ? wildcards are supported
lvt --name msedge --title "title:Dashboard"
```

When `--title` is the only target selector, it still selects a top-level window by
title. When it is combined with `--name`, `--pid`, or `--hwnd` for a Chromium
browser, the Chromium plugin also uses it to select the tab whose URL or title
matches the provided substring/pattern. If no tab matches, lvt prints a clear
`lvt-chromium: No Chromium tab matches '...'` error.

## Manual live E2E check

From the build output directory:
Expand Down Expand Up @@ -135,7 +148,7 @@ Framework name is reported as `"chromium (Chrome)"` or `"chromium (Edge)"`.

### 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
- **Service worker** (`service-worker.js`): Connects to the native messaging host, dispatches tab-list and 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`
Expand All @@ -150,7 +163,7 @@ Framework name is reported as `"chromium (Chrome)"` or `"chromium (Edge)"`.

- 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
- Enrichment: connects to the named pipe, optionally sends `listTabs` to select a tab by URL/title, sends a `getDOM` request, and parses the response

## Troubleshooting

Expand Down Expand Up @@ -178,14 +191,13 @@ lvt --name chrome

## Limitations

- Only inspects the **active tab** (tab selection by URL/title is planned)
- Inspects the **active tab** by default; pass `--title` together with `--name`, `--pid`, or `--hwnd` to select a tab by URL/title substring or wildcard pattern
- `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
Expand Down
10 changes: 7 additions & 3 deletions src/main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,8 @@ static void print_usage() {
" --hwnd <handle> Target window by HWND (hex, e.g. 0x1A0B3C)\n"
" --pid <pid> Target process by PID (finds main window)\n"
" --name <exe> Target by process name (e.g. notepad.exe)\n"
" --title <text> Target by window title substring\n"
" --title <text> Target by window title substring; with Chromium and\n"
" --name/--pid/--hwnd, select tab by URL/title substring\n"
" --output <file> Write output to file instead of stdout\n"
" --format <fmt> Output format: json (default) or xml\n"
" --screenshot <file> Capture annotated screenshot to PNG\n"
Expand Down Expand Up @@ -78,6 +79,7 @@ struct Args {
std::string elementId;
std::string queryId;
std::string queryProperty;
std::string pluginOption;
int depth = -1;
int intervalMs = 500;
bool frameworksOnly = false;
Expand Down Expand Up @@ -222,7 +224,7 @@ static bool write_output(const std::string& outputFile, const std::string& conte
static bool build_output_tree(const lvt::TargetInfo& target, const Args& args,
lvt::Element& outputTree) {
auto frameworks = lvt::detect_frameworks(target.hwnd, target.pid);
auto tree = lvt::build_tree(target.hwnd, target.pid, frameworks);
auto tree = lvt::build_tree(target.hwnd, target.pid, frameworks, -1, args.pluginOption);

lvt::Element* outputRoot = &tree;
if (!args.elementId.empty()) {
Expand Down Expand Up @@ -286,6 +288,8 @@ int main(int argc, char* argv[]) {
}

auto args = parse_args(argc, argv);
bool hasNonTitleTarget = args.hwnd || args.pid || !args.processName.empty();
args.pluginOption = hasNonTitleTarget ? args.windowTitle : "";

// Load plugins from %USERPROFILE%/.lvt/plugins/
lvt::load_plugins();
Expand Down Expand Up @@ -399,7 +403,7 @@ int main(int argc, char* argv[]) {
}

// Build full tree (no depth limit) so element IDs are stable
auto tree = lvt::build_tree(target.hwnd, target.pid, frameworks);
auto tree = lvt::build_tree(target.hwnd, target.pid, frameworks, -1, args.pluginOption);

// Scope to element if requested
lvt::Element* outputRoot = &tree;
Expand Down
80 changes: 59 additions & 21 deletions src/plugin_chromium/extension/service-worker.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,18 @@ async function handleHostMessage(message) {

if (message.type === "getDOM") {
try {
const result = await getActiveTabDOM(message);
const result = await getTabDOM(message);
nativePort?.postMessage(result);
} catch (e) {
nativePort?.postMessage({
type: "error",
requestId: message.requestId,
message: e.message || String(e)
});
}
} else if (message.type === "listTabs") {
try {
const result = await listInspectableTabs(message);
nativePort?.postMessage(result);
} catch (e) {
nativePort?.postMessage({
Expand All @@ -42,30 +53,57 @@ async function handleHostMessage(message) {
}
}

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://"));
}
function isDebuggableTab(tab) {
return tab?.url && !tab.url.startsWith("chrome://") &&
!tab.url.startsWith("edge://") && !tab.url.startsWith("about:") &&
!tab.url.startsWith("chrome-extension://");
}

async function listInspectableTabs(request) {
const tabs = (await chrome.tabs.query({})).filter(isDebuggableTab);
return {
type: "tabs",
requestId: request.requestId,
tabs: tabs.map(t => ({
id: t.id,
url: t.url || "",
title: t.title || "",
active: !!t.active,
windowId: t.windowId
}))
};
}

async function getTabDOM(request) {
let tab = null;

if (typeof request.tabId === "number") {
tab = await chrome.tabs.get(request.tabId);
if (!isDebuggableTab(tab)) {
throw new Error(`Tab ${request.tabId} is not debuggable (chrome:// and edge:// pages cannot be inspected)`);
}
} else {
// 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(isDebuggableTab);

// 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(isDebuggableTab);
}

if (!tabs.length) {
throw new Error("No debuggable tab found (chrome:// and edge:// pages cannot be inspected)");
}

if (!tabs.length) {
throw new Error("No debuggable tab found (chrome:// and edge:// pages cannot be inspected)");
tab = tabs[0];
}

const tab = tabs[0];
if (!tab.id) {
throw new Error("Active tab has no ID");
throw new Error("Selected tab has no ID");
}

// Attach the debugger to the tab
Expand Down
54 changes: 51 additions & 3 deletions src/plugin_chromium/lvt_chromium_plugin.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
// relay to retrieve the DOM tree.

#include "plugin.h"
#include "tab_selection.h"

#include <nlohmann/json.hpp>

Expand Down Expand Up @@ -233,7 +234,7 @@ __declspec(dllexport) int lvt_detect_framework(DWORD pid, HWND /*hwnd*/, LvtFram
}

__declspec(dllexport) int lvt_enrich_tree(HWND /*hwnd*/, DWORD /*pid*/,
const char* /*element_class_filter*/,
const char* element_class_filter,
char** json_out)
{
if (!json_out) return 0;
Expand All @@ -260,9 +261,56 @@ __declspec(dllexport) int lvt_enrich_tree(HWND /*hwnd*/, DWORD /*pid*/,

DebugLog("connected to native messaging host pipe");

auto selector = normalize_chromium_tab_selector(element_class_filter);
json request = {{"type", "getDOM"}, {"requestId", "1"}, {"tabId", "active"}};

if (!selector.empty()) {
json listRequest = {{"type", "listTabs"}, {"requestId", "1"}};
if (!write_pipe_message(pipe, listRequest.dump())) {
DebugLog("failed to send listTabs request");
CloseHandle(pipe);
return 0;
}

std::string listResponse;
if (!read_pipe_message(pipe, listResponse, 30000)) {
DebugLog("failed to read listTabs response");
CloseHandle(pipe);
return 0;
}

try {
auto tabsEnvelope = json::parse(listResponse);
if (tabsEnvelope.contains("type") && tabsEnvelope["type"] == "error") {
auto msg = tabsEnvelope.value("message", "unknown error");
DebugLog("extension returned error while listing tabs: %s", msg.c_str());
fprintf(stderr, "lvt-chromium: %s\n", msg.c_str());
CloseHandle(pipe);
return 0;
}

std::string error;
auto selected = select_chromium_tab_target(tabsEnvelope, selector, error);
if (!selected) {
DebugLog("%s", error.c_str());
fprintf(stderr, "lvt-chromium: %s\n", error.c_str());
CloseHandle(pipe);
return 0;
}
DebugLog("selected tab %d: %s (%s)",
selected->tab_id, selected->title.c_str(), selected->url.c_str());
request["requestId"] = "2";
request["tabId"] = selected->tab_id;
} catch (const json::parse_error& e) {
DebugLog("failed to parse listTabs response JSON: %s", e.what());
CloseHandle(pipe);
return 0;
}
}

// Send getDOM request
std::string request = "{\"type\":\"getDOM\",\"requestId\":\"1\",\"tabId\":\"active\"}";
if (!write_pipe_message(pipe, request)) {
std::string requestString = request.dump();
if (!write_pipe_message(pipe, requestString)) {
DebugLog("failed to send getDOM request");
CloseHandle(pipe);
return 0;
Expand Down
Loading
Loading