From a4d6aa14a1afea3314647c58ef7c02e7020c1434 Mon Sep 17 00:00:00 2001 From: Alexander Sklar <22989529+asklar@users.noreply.github.com> Date: Wed, 8 Jul 2026 10:36:30 -0700 Subject: [PATCH] Chromium plugin: select tab by URL/title Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/chromium-plugin.md | 24 +++- src/main.cpp | 10 +- .../extension/service-worker.js | 80 ++++++++--- src/plugin_chromium/lvt_chromium_plugin.cpp | 54 +++++++- src/plugin_chromium/tab_selection.h | 126 ++++++++++++++++++ src/plugin_loader.cpp | 7 +- src/plugin_loader.h | 3 +- src/tree_builder.cpp | 5 +- src/tree_builder.h | 3 +- tests/chromium_tests.cpp | 66 +++++++++ 10 files changed, 339 insertions(+), 39 deletions(-) create mode 100644 src/plugin_chromium/tab_selection.h diff --git a/docs/chromium-plugin.md b/docs/chromium-plugin.md index 4464935..711679e 100644 --- a/docs/chromium-plugin.md +++ b/docs/chromium-plugin.md @@ -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 @@ -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: @@ -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` @@ -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 @@ -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 diff --git a/src/main.cpp b/src/main.cpp index 660f972..e14046c 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -45,7 +45,8 @@ static void print_usage() { " --hwnd Target window by HWND (hex, e.g. 0x1A0B3C)\n" " --pid Target process by PID (finds main window)\n" " --name Target by process name (e.g. notepad.exe)\n" - " --title Target by window title substring\n" + " --title Target by window title substring; with Chromium and\n" + " --name/--pid/--hwnd, select tab by URL/title substring\n" " --output Write output to file instead of stdout\n" " --format Output format: json (default) or xml\n" " --screenshot Capture annotated screenshot to PNG\n" @@ -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; @@ -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()) { @@ -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(); @@ -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; diff --git a/src/plugin_chromium/extension/service-worker.js b/src/plugin_chromium/extension/service-worker.js index 454cecd..536532f 100644 --- a/src/plugin_chromium/extension/service-worker.js +++ b/src/plugin_chromium/extension/service-worker.js @@ -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({ @@ -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 diff --git a/src/plugin_chromium/lvt_chromium_plugin.cpp b/src/plugin_chromium/lvt_chromium_plugin.cpp index 52e2e9c..d8034db 100644 --- a/src/plugin_chromium/lvt_chromium_plugin.cpp +++ b/src/plugin_chromium/lvt_chromium_plugin.cpp @@ -4,6 +4,7 @@ // relay to retrieve the DOM tree. #include "plugin.h" +#include "tab_selection.h" #include @@ -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; @@ -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; diff --git a/src/plugin_chromium/tab_selection.h b/src/plugin_chromium/tab_selection.h new file mode 100644 index 0000000..36efc67 --- /dev/null +++ b/src/plugin_chromium/tab_selection.h @@ -0,0 +1,126 @@ +#pragma once + +#include + +#include +#include +#include +#include + +struct ChromiumTabSelection { + int tab_id = -1; + std::string url; + std::string title; +}; + +static std::string chromium_trim(std::string s) { + auto is_space = [](unsigned char c) { return std::isspace(c) != 0; }; + s.erase(s.begin(), std::find_if_not(s.begin(), s.end(), is_space)); + s.erase(std::find_if_not(s.rbegin(), s.rend(), is_space).base(), s.end()); + return s; +} + +static std::string chromium_lower(std::string s) { + std::transform(s.begin(), s.end(), s.begin(), + [](unsigned char c) { return static_cast(std::tolower(c)); }); + return s; +} + +static std::string normalize_chromium_tab_selector(const char* filter) { + if (!filter) return {}; + std::string selector = chromium_trim(filter); + auto lower = chromium_lower(selector); + constexpr const char* prefixes[] = {"chromium.tab=", "tab="}; + for (auto* prefix : prefixes) { + std::string p(prefix); + if (lower.rfind(p, 0) == 0) + return chromium_trim(selector.substr(p.size())); + } + return selector; +} + +static bool chromium_is_debuggable_url(const std::string& url) { + auto lower = chromium_lower(url); + return !lower.empty() && + lower.rfind("chrome://", 0) != 0 && + lower.rfind("edge://", 0) != 0 && + lower.rfind("about:", 0) != 0 && + lower.rfind("chrome-extension://", 0) != 0; +} + +static bool chromium_wildcard_match(const std::string& pattern, const std::string& value) { + size_t p = 0, v = 0, star = std::string::npos, match = 0; + while (v < value.size()) { + if (p < pattern.size() && (pattern[p] == '?' || pattern[p] == value[v])) { + ++p; + ++v; + } else if (p < pattern.size() && pattern[p] == '*') { + star = p++; + match = v; + } else if (star != std::string::npos) { + p = star + 1; + v = ++match; + } else { + return false; + } + } + while (p < pattern.size() && pattern[p] == '*') + ++p; + return p == pattern.size(); +} + +static bool chromium_matches_selector(const std::string& selector, + const std::string& url, + const std::string& title) { + std::string pattern = chromium_trim(selector); + auto lowerPattern = chromium_lower(pattern); + enum class Field { Any, Url, Title }; + Field field = Field::Any; + if (lowerPattern.rfind("url:", 0) == 0) { + field = Field::Url; + pattern = chromium_trim(pattern.substr(4)); + } else if (lowerPattern.rfind("title:", 0) == 0) { + field = Field::Title; + pattern = chromium_trim(pattern.substr(6)); + } + + if (pattern.empty()) return false; + + auto p = chromium_lower(pattern); + auto u = chromium_lower(url); + auto t = chromium_lower(title); + bool wildcard = p.find_first_of("*?") != std::string::npos; + auto matches = [&](const std::string& value) { + return wildcard ? chromium_wildcard_match(p, value) : value.find(p) != std::string::npos; + }; + + if (field == Field::Url) return matches(u); + if (field == Field::Title) return matches(t); + return matches(u) || matches(t); +} + +static std::optional +select_chromium_tab_target(const nlohmann::json& targetList, + const std::string& selector, + std::string& error) { + const nlohmann::json* tabs = &targetList; + if (targetList.is_object() && targetList.contains("tabs")) + tabs = &targetList["tabs"]; + if (!tabs->is_array()) { + error = "Chromium tab list response did not contain a tabs array"; + return std::nullopt; + } + + for (const auto& tab : *tabs) { + int id = tab.value("id", tab.value("tabId", -1)); + std::string url = tab.value("url", ""); + std::string title = tab.value("title", ""); + if (id < 0 || !chromium_is_debuggable_url(url)) + continue; + if (chromium_matches_selector(selector, url, title)) + return ChromiumTabSelection{id, url, title}; + } + + error = "No Chromium tab matches '" + selector + "'"; + return std::nullopt; +} diff --git a/src/plugin_loader.cpp b/src/plugin_loader.cpp index 5c481d7..38a49c8 100644 --- a/src/plugin_loader.cpp +++ b/src/plugin_loader.cpp @@ -180,11 +180,14 @@ static void graft_json_node(const json& j, Element& parent, const std::string& f } bool enrich_with_plugin(Element& root, HWND hwnd, DWORD pid, - const PluginFrameworkInfo& pluginFw) { + const PluginFrameworkInfo& pluginFw, + const std::string& pluginOption) { if (!pluginFw.plugin || !pluginFw.plugin->enrich) return false; char* jsonOut = nullptr; - int ok = pluginFw.plugin->enrich(hwnd, pid, nullptr, &jsonOut); + int ok = pluginFw.plugin->enrich(hwnd, pid, + pluginOption.empty() ? nullptr : pluginOption.c_str(), + &jsonOut); if (!ok || !jsonOut) return false; json treeJson; diff --git a/src/plugin_loader.h b/src/plugin_loader.h index d87f36e..8913292 100644 --- a/src/plugin_loader.h +++ b/src/plugin_loader.h @@ -36,6 +36,7 @@ std::vector detect_plugin_frameworks(HWND hwnd, DWORD pid); // Ask the relevant plugin to enrich the tree for a plugin-detected framework. // Parses the JSON response and grafts elements under matching Win32 nodes. bool enrich_with_plugin(Element& root, HWND hwnd, DWORD pid, - const PluginFrameworkInfo& pluginFw); + const PluginFrameworkInfo& pluginFw, + const std::string& pluginOption = {}); } // namespace lvt diff --git a/src/tree_builder.cpp b/src/tree_builder.cpp index 19de733..a3b7da0 100644 --- a/src/tree_builder.cpp +++ b/src/tree_builder.cpp @@ -107,7 +107,8 @@ void trim_to_depth(Element& root, int maxDepth) { trim_to_depth_impl(root, 0, maxDepth); } -Element build_tree(HWND hwnd, DWORD pid, const std::vector& frameworks, int maxDepth) { +Element build_tree(HWND hwnd, DWORD pid, const std::vector& frameworks, + int maxDepth, const std::string& pluginOption) { // Start with the Win32 provider as the base — it always applies Win32Provider win32; Element root = win32.build(hwnd, maxDepth); @@ -143,7 +144,7 @@ Element build_tree(HWND hwnd, DWORD pid, const std::vector& frame pf.name = fi.name; pf.version = fi.version; pf.plugin = &p; - enrich_with_plugin(root, hwnd, pid, pf); + enrich_with_plugin(root, hwnd, pid, pf, pluginOption); break; } } diff --git a/src/tree_builder.h b/src/tree_builder.h index 8a65549..1fe9bd9 100644 --- a/src/tree_builder.h +++ b/src/tree_builder.h @@ -8,7 +8,8 @@ namespace lvt { // Build a unified visual tree from the given HWND using detected frameworks. -Element build_tree(HWND hwnd, DWORD pid, const std::vector& frameworks, int maxDepth = -1); +Element build_tree(HWND hwnd, DWORD pid, const std::vector& frameworks, + int maxDepth = -1, const std::string& pluginOption = {}); // Assign deterministic element IDs (e0, e1, ...) in depth-first order. void assign_element_ids(Element& root); diff --git a/tests/chromium_tests.cpp b/tests/chromium_tests.cpp index 852ef11..fe8e84f 100644 --- a/tests/chromium_tests.cpp +++ b/tests/chromium_tests.cpp @@ -18,6 +18,8 @@ #include #include +#include "plugin_chromium/tab_selection.h" + using json = nlohmann::json; namespace { @@ -400,3 +402,67 @@ TEST(NativeMessaging, TruncatedFrame) { auto decoded = decode_native_message(frame); EXPECT_TRUE(decoded.empty()); } + +TEST(ChromiumTabSelection, MatchByUrlSubstring) { + json targets = { + {"type", "tabs"}, + {"tabs", json::array({ + {{"id", 4}, {"url", "https://example.com/"}, {"title", "Example"}}, + {{"id", 7}, {"url", "https://github.com/asklar/lvt/pull/1"}, {"title", "Pull Request"}} + })} + }; + + std::string error; + auto selected = select_chromium_tab_target(targets, "asklar/lvt", error); + ASSERT_TRUE(selected.has_value()) << error; + EXPECT_EQ(selected->tab_id, 7); +} + +TEST(ChromiumTabSelection, MatchByTitleSubstring) { + json targets = json::array({ + {{"id", 1}, {"url", "https://example.com/"}, {"title", "Example"}}, + {{"id", 2}, {"url", "https://news.ycombinator.com/"}, {"title", "Hacker News"}} + }); + + std::string error; + auto selected = select_chromium_tab_target(targets, "title:hacker", error); + ASSERT_TRUE(selected.has_value()) << error; + EXPECT_EQ(selected->tab_id, 2); +} + +TEST(ChromiumTabSelection, WildcardPattern) { + json targets = json::array({ + {{"id", 11}, {"url", "https://learn.microsoft.com/windows/apps/"}, {"title", "Docs"}}, + {{"id", 12}, {"url", "https://github.com/"}, {"title", "GitHub"}} + }); + + std::string error; + auto selected = select_chromium_tab_target(targets, "url:*microsoft.com/windows*", error); + ASSERT_TRUE(selected.has_value()) << error; + EXPECT_EQ(selected->tab_id, 11); +} + +TEST(ChromiumTabSelection, NoMatchError) { + json targets = { + {"tabs", json::array({ + {{"id", 1}, {"url", "https://example.com/"}, {"title", "Example"}} + })} + }; + + std::string error; + auto selected = select_chromium_tab_target(targets, "not-present", error); + EXPECT_FALSE(selected.has_value()); + EXPECT_NE(error.find("No Chromium tab matches 'not-present'"), std::string::npos); +} + +TEST(ChromiumTabSelection, IgnoresNonDebuggableTabs) { + json targets = json::array({ + {{"id", 1}, {"url", "chrome://settings/"}, {"title", "Settings"}}, + {{"id", 2}, {"url", "https://example.com/settings"}, {"title", "Settings"}} + }); + + std::string error; + auto selected = select_chromium_tab_target(targets, "settings", error); + ASSERT_TRUE(selected.has_value()) << error; + EXPECT_EQ(selected->tab_id, 2); +}