1
Fork 0
mirror of https://github.com/RGBCube/serenity synced 2025-05-14 10:54:57 +00:00

LibWebView: Implement a WebView-based Inspector client

This is modeled after a similar implementation for the JS console.

This client takes over an inspector WebView (created by the chrome) to
create the inspector application. Currently, this application includes
the DOM tree and accessibility tree as a first pass. It can later be
extended to included the style tables, the JS console itself, etc.
This commit is contained in:
Timothy Flynn 2023-11-23 12:26:38 -05:00 committed by Andreas Kling
parent ffdc2d8add
commit 1fe486cebe
6 changed files with 581 additions and 0 deletions

View file

@ -120,6 +120,7 @@ shared_library("LibWebView") {
"CookieJar.cpp", "CookieJar.cpp",
"Database.cpp", "Database.cpp",
"History.cpp", "History.cpp",
"InspectorClient.cpp",
"PropertyTableModel.cpp", "PropertyTableModel.cpp",
"RequestServerAdapter.cpp", "RequestServerAdapter.cpp",
"SearchEngine.cpp", "SearchEngine.cpp",

View file

@ -5,6 +5,7 @@ set(SOURCES
CookieJar.cpp CookieJar.cpp
Database.cpp Database.cpp
History.cpp History.cpp
InspectorClient.cpp
PropertyTableModel.cpp PropertyTableModel.cpp
RequestServerAdapter.cpp RequestServerAdapter.cpp
SearchEngine.cpp SearchEngine.cpp

View file

@ -14,6 +14,7 @@ class ConsoleClient;
class CookieJar; class CookieJar;
class Database; class Database;
class History; class History;
class InspectorClient;
class OutOfProcessWebView; class OutOfProcessWebView;
class ViewImplementation; class ViewImplementation;
class WebContentClient; class WebContentClient;

View file

@ -0,0 +1,523 @@
/*
* Copyright (c) 2023, Tim Flynn <trflynn89@serenityos.org>
*
* SPDX-License-Identifier: BSD-2-Clause
*/
#include <AK/JsonArray.h>
#include <AK/JsonObject.h>
#include <AK/StringBuilder.h>
#include <LibWeb/Infra/Strings.h>
#include <LibWebView/InspectorClient.h>
#include <LibWebView/SourceHighlighter.h>
namespace WebView {
static ErrorOr<JsonValue> parse_json_tree(StringView json)
{
auto parsed_tree = TRY(JsonValue::from_string(json));
if (!parsed_tree.is_object())
return Error::from_string_literal("Expected tree to be a JSON object");
return parsed_tree;
}
InspectorClient::InspectorClient(ViewImplementation& content_web_view, ViewImplementation& inspector_web_view)
: m_content_web_view(content_web_view)
, m_inspector_web_view(inspector_web_view)
{
m_content_web_view.on_received_dom_tree = [this](auto const& dom_tree) {
if (auto result = parse_json_tree(dom_tree); result.is_error())
dbgln("Failed to load DOM tree: {}", result.error());
else
m_dom_tree = result.release_value();
maybe_load_inspector();
};
m_content_web_view.on_received_accessibility_tree = [this](auto const& dom_tree) {
if (auto result = parse_json_tree(dom_tree); result.is_error())
dbgln("Failed to load accessibility tree: {}", result.error());
else
m_accessibility_tree = result.release_value();
maybe_load_inspector();
};
m_inspector_web_view.enable_inspector_prototype();
m_inspector_web_view.use_native_user_style_sheet();
m_inspector_web_view.on_inspector_loaded = [this]() {
m_inspector_loaded = true;
if (m_pending_selection.has_value())
select_node(m_pending_selection.release_value());
else
select_default_node();
};
m_inspector_web_view.on_inspector_selected_dom_node = [this](auto node_id, auto const& pseudo_element) {
auto inspected_node_properties = m_content_web_view.inspect_dom_node(node_id, pseudo_element);
if (on_dom_node_properties_received)
on_dom_node_properties_received(move(inspected_node_properties));
};
inspect();
}
InspectorClient::~InspectorClient()
{
m_content_web_view.on_received_dom_tree = nullptr;
m_content_web_view.on_received_accessibility_tree = nullptr;
}
void InspectorClient::inspect()
{
m_content_web_view.inspect_dom_tree();
m_content_web_view.inspect_accessibility_tree();
}
void InspectorClient::reset()
{
m_dom_tree.clear();
m_accessibility_tree.clear();
m_body_node_id.clear();
m_pending_selection.clear();
m_inspector_loaded = false;
}
void InspectorClient::select_hovered_node()
{
auto hovered_node_id = m_content_web_view.get_hovered_node_id();
select_node(hovered_node_id);
}
void InspectorClient::select_default_node()
{
if (m_body_node_id.has_value())
select_node(*m_body_node_id);
}
void InspectorClient::clear_selection()
{
m_content_web_view.clear_inspected_dom_node();
static constexpr auto script = "inspector.clearInspectedDOMNode();"sv;
m_inspector_web_view.run_javascript(script);
}
void InspectorClient::select_node(i32 node_id)
{
if (!m_inspector_loaded) {
m_pending_selection = node_id;
return;
}
auto script = MUST(String::formatted("inspector.inspectDOMNodeID({});", node_id));
m_inspector_web_view.run_javascript(script);
}
void InspectorClient::maybe_load_inspector()
{
if (!m_dom_tree.has_value() || !m_accessibility_tree.has_value())
return;
StringBuilder builder;
builder.append(R"~~~(
<!DOCTYPE html>
<html>
<head>
<meta name="color-scheme" content="dark light">
<style type="text/css">
)~~~"sv);
builder.append(HTML_HIGHLIGHTER_STYLE);
builder.append(R"~~~(
body {
margin: 0;
}
.tab-controls-container {
position: fixed;
width: 100%;
top: 0;
padding: 4px;
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
}
.tab-controls {
overflow: hidden;
flex-shrink: 0;
}
.tab-controls button {
font-size: 12px;
font-weight: 600;
font-family: system-ui, sans-serif;
float: left;
border: none;
outline: none;
cursor: pointer;
padding: 4px 8px;
}
.tab-controls :first-child {
border-radius: 0.5rem 0 0 0.5rem;
}
.tab-controls :last-child {
border-radius: 0 0.5rem 0.5rem 0;
}
.tab-content-container {
margin-top: 30px;
}
.tab-content {
display: none;
border-radius: 0.5rem;
margin-top: 30px;
padding: 6px 12px 6px 12px;
}
@media (prefers-color-scheme: dark) {
html {
background-color: rgb(23, 23, 23);
}
.tab-controls-container {
background-color: rgb(57, 57, 57);
}
.tab-controls button {
color: white;
background-color: rgb(43, 42, 50);
}
.tab-controls button.active {
background-color: rgb(22 100 219);
}
.tab-controls button + button {
border-left: 1px solid rgb(96, 96, 96);
}
}
@media (prefers-color-scheme: light) {
.tab-controls-container {
background-color: rgb(229, 229, 229);
}
.tab-controls button {
color: black;
background-color: white;
}
.tab-controls button.active {
color: white;
background-color: rgb(28, 138, 255);
}
.tab-controls button + button {
border-left: 1px solid rgb(242, 242, 242);
}
}
details > :not(:first-child) {
display: list-item;
list-style: none inside;
margin-left: 1em;
}
.hoverable {
display: block;
padding: 1px;
}
@media (prefers-color-scheme: dark) {
.hoverable:hover {
background-color: #31383e;
}
.selected {
border: 1px dashed cyan;
padding: 0;
}
}
@media (prefers-color-scheme: light) {
.hoverable:hover {
background-color: rgb(236, 236, 236);
}
.selected {
border: 1px dashed blue;
padding: 0;
}
}
</style>
</head>
<body>
<div class="tab-controls-container">
<div class="tab-controls">
<button id="dom-tree-button" onclick="selectTab(this, 'dom-tree')">DOM Tree</button>
<button id="accessibility-tree-button" onclick="selectTab(this, 'accessibility-tree')">Accessibility Tree</button>
</div>
</div>
<div class="tab-content-container">
<div id="dom-tree" class="tab-content html">
)~~~"sv);
generate_dom_tree(builder);
builder.append(R"~~~(
</div>
<div id="accessibility-tree" class="tab-content">
)~~~"sv);
generate_accessibility_tree(builder);
builder.append(R"~~~(
</div>
</div>
<script type="text/javascript">
let selectedTab = null;
let selectedTabButton = null;
let selectedDOMNode = null;
const selectTab = (tabButton, tabID) => {
let tab = document.getElementById(tabID);
if (selectedTab === tab) {
return;
}
if (selectedTab !== null) {
selectedTab.style.display = "none";
selectedTabButton.classList.remove("active");
}
tab.style.display = "block";
selectedTab = tab;
tabButton.classList.add("active");
selectedTabButton = tabButton;
};
let initialTabButton = document.getElementById("dom-tree-button");
selectTab(initialTabButton, "dom-tree");
inspector.inspectDOMNodeID = nodeID => {
let domNodes = document.querySelectorAll(`[data-id="${nodeID}"]`);
if (domNodes.length !== 1) {
return;
}
for (let domNode = domNodes[0]; domNode; domNode = domNode.parentNode) {
if (domNode.tagName === "DETAILS") {
domNode.setAttribute("open", "");
}
}
inspectDOMNode(domNodes[0]);
selectedDOMNode.scrollIntoView({ block: "start", inline: "start" });
};
inspector.clearInspectedDOMNode = () => {
if (selectedDOMNode !== null) {
selectedDOMNode.classList.remove("selected");
selectedDOMNode = null;
}
};
const inspectDOMNode = domNode => {
if (selectedDOMNode === domNode) {
return;
}
inspector.clearInspectedDOMNode();
domNode.classList.add("selected");
selectedDOMNode = domNode;
inspector.inspectDOMNode(domNode.dataset.id, domNode.dataset.pseudoElement);
};
document.addEventListener("DOMContentLoaded", () => {
let domTree = document.getElementById("dom-tree");
let domNodes = domTree.getElementsByClassName("hoverable");
for (let domNode of domNodes) {
domNode.addEventListener("click", event => {
inspectDOMNode(domNode);
event.preventDefault();
});
}
inspector.inspectorLoaded();
});
</script>
</body>
</html>
)~~~"sv);
m_inspector_web_view.load_html(builder.string_view());
}
template<typename Generator>
static void generate_tree(StringBuilder& builder, JsonObject const& node, Generator&& generator)
{
if (auto children = node.get_array("children"sv); children.has_value() && !children->is_empty()) {
auto name = node.get_deprecated_string("name"sv).value_or({});
builder.append("<details>"sv);
builder.append("<summary>"sv);
generator(node);
builder.append("</summary>"sv);
children->for_each([&](auto const& child) {
builder.append("<div>"sv);
generate_tree(builder, child.as_object(), generator);
builder.append("</div>"sv);
});
builder.append("</details>"sv);
} else {
generator(node);
}
}
void InspectorClient::generate_dom_tree(StringBuilder& builder)
{
generate_tree(builder, m_dom_tree->as_object(), [&](JsonObject const& node) {
auto type = node.get_deprecated_string("type"sv).value_or("unknown"sv);
auto name = node.get_deprecated_string("name"sv).value_or({});
StringBuilder data_attributes;
auto append_data_attribute = [&](auto name, auto value) {
if (!data_attributes.is_empty())
data_attributes.append(' ');
data_attributes.appendff("data-{}=\"{}\"", name, value);
};
if (auto pseudo_element = node.get_integer<i32>("pseudo-element"sv); pseudo_element.has_value()) {
append_data_attribute("id"sv, node.get_integer<i32>("parent-id"sv).value());
append_data_attribute("pseudo-element"sv, *pseudo_element);
} else {
append_data_attribute("id"sv, node.get_integer<i32>("id"sv).value());
}
if (type == "text"sv) {
auto deprecated_text = node.get_deprecated_string("text"sv).release_value();
deprecated_text = escape_html_entities(deprecated_text);
if (auto text = MUST(Web::Infra::strip_and_collapse_whitespace(deprecated_text)); text.is_empty()) {
builder.appendff("<span class=\"hoverable internal\" {}>", data_attributes.string_view());
builder.append(name);
builder.append("</span>"sv);
} else {
builder.appendff("<span class=\"hoverable\" {}>", data_attributes.string_view());
builder.append(text);
builder.append("</span>"sv);
}
return;
}
if (type == "comment"sv) {
auto comment = node.get_deprecated_string("data"sv).release_value();
comment = escape_html_entities(comment);
builder.appendff("<span class=\"hoverable comment\" {}>", data_attributes.string_view());
builder.appendff("&lt;!--{}--&gt;", comment);
builder.append("</span>"sv);
return;
}
if (type == "shadow-root"sv) {
auto mode = node.get_deprecated_string("mode"sv).release_value();
builder.appendff("<span class=\"hoverable internal\" {}>", data_attributes.string_view());
builder.appendff("{} ({})", name, mode);
builder.append("</span>"sv);
return;
}
if (type != "element"sv) {
builder.appendff("<span class=\"hoverable internal\" {}>", data_attributes.string_view());
builder.appendff(name);
builder.append("</span>"sv);
return;
}
if (name.equals_ignoring_ascii_case("BODY"sv))
m_body_node_id = node.get_integer<i32>("id"sv).value();
builder.appendff("<span class=\"hoverable\" {}>", data_attributes.string_view());
builder.append("<span>&lt;</span>"sv);
builder.appendff("<span class=\"tag\">{}</span>", name.to_lowercase());
if (auto attributes = node.get_object("attributes"sv); attributes.has_value()) {
attributes->for_each_member([&builder](auto const& name, auto const& value) {
builder.append("&nbsp;"sv);
builder.appendff("<span class=\"attribute-name\">{}</span>", name);
builder.append('=');
builder.appendff("<span class=\"attribute-value\">\"{}\"</span>", value);
});
}
builder.append("<span>&gt;</span>"sv);
builder.append("</span>"sv);
});
}
void InspectorClient::generate_accessibility_tree(StringBuilder& builder)
{
generate_tree(builder, m_accessibility_tree->as_object(), [&](JsonObject const& node) {
auto type = node.get_deprecated_string("type"sv).value_or("unknown"sv);
auto role = node.get_deprecated_string("role"sv).value_or({});
if (type == "text"sv) {
auto text = node.get_deprecated_string("text"sv).release_value();
text = escape_html_entities(text);
builder.appendff("<span class=\"hoverable\">");
builder.append(MUST(Web::Infra::strip_and_collapse_whitespace(text)));
builder.append("</span>"sv);
return;
}
if (type != "element"sv) {
builder.appendff("<span class=\"hoverable internal\">");
builder.appendff(role.to_lowercase());
builder.append("</span>"sv);
return;
}
auto name = node.get_deprecated_string("name"sv).value_or({});
auto description = node.get_deprecated_string("description"sv).value_or({});
builder.appendff("<span class=\"hoverable\">");
builder.append(role.to_lowercase());
builder.appendff(" name: \"{}\", description: \"{}\"", name, description);
builder.append("</span>"sv);
});
}
}

View file

@ -0,0 +1,49 @@
/*
* Copyright (c) 2023, Tim Flynn <trflynn89@serenityos.org>
*
* SPDX-License-Identifier: BSD-2-Clause
*/
#include <AK/Function.h>
#include <AK/JsonValue.h>
#include <AK/StringView.h>
#include <LibWebView/ViewImplementation.h>
#pragma once
namespace WebView {
class InspectorClient {
public:
InspectorClient(ViewImplementation& content_web_view, ViewImplementation& inspector_web_view);
~InspectorClient();
void inspect();
void reset();
void select_hovered_node();
void select_default_node();
void clear_selection();
Function<void(ErrorOr<ViewImplementation::DOMNodeProperties>)> on_dom_node_properties_received;
private:
void maybe_load_inspector();
void generate_dom_tree(StringBuilder&);
void generate_accessibility_tree(StringBuilder&);
void select_node(i32 node_id);
ViewImplementation& m_content_web_view;
ViewImplementation& m_inspector_web_view;
Optional<JsonValue> m_dom_tree;
Optional<JsonValue> m_accessibility_tree;
Optional<i32> m_body_node_id;
Optional<i32> m_pending_selection;
bool m_inspector_loaded { false };
};
}

View file

@ -41,6 +41,9 @@ constexpr inline StringView HTML_HIGHLIGHTER_STYLE = R"~~~(
.attribute-value { .attribute-value {
color: deepskyblue; color: deepskyblue;
} }
.internal {
color: darkgrey;
}
} }
@media (prefers-color-scheme: light) { @media (prefers-color-scheme: light) {
@ -56,6 +59,9 @@ constexpr inline StringView HTML_HIGHLIGHTER_STYLE = R"~~~(
.attribute-value { .attribute-value {
color: blue; color: blue;
} }
.internal {
color: dimgray;
}
} }
)~~~"sv; )~~~"sv;