From 1fe486cebe91d21a4f6d4943b51ecb0b7947f1be Mon Sep 17 00:00:00 2001 From: Timothy Flynn Date: Thu, 23 Nov 2023 12:26:38 -0500 Subject: [PATCH] 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. --- .../Userland/Libraries/LibWebView/BUILD.gn | 1 + Userland/Libraries/LibWebView/CMakeLists.txt | 1 + Userland/Libraries/LibWebView/Forward.h | 1 + .../Libraries/LibWebView/InspectorClient.cpp | 523 ++++++++++++++++++ .../Libraries/LibWebView/InspectorClient.h | 49 ++ .../Libraries/LibWebView/SourceHighlighter.h | 6 + 6 files changed, 581 insertions(+) create mode 100644 Userland/Libraries/LibWebView/InspectorClient.cpp create mode 100644 Userland/Libraries/LibWebView/InspectorClient.h diff --git a/Meta/gn/secondary/Userland/Libraries/LibWebView/BUILD.gn b/Meta/gn/secondary/Userland/Libraries/LibWebView/BUILD.gn index 1523681adb..548e3b4c94 100644 --- a/Meta/gn/secondary/Userland/Libraries/LibWebView/BUILD.gn +++ b/Meta/gn/secondary/Userland/Libraries/LibWebView/BUILD.gn @@ -120,6 +120,7 @@ shared_library("LibWebView") { "CookieJar.cpp", "Database.cpp", "History.cpp", + "InspectorClient.cpp", "PropertyTableModel.cpp", "RequestServerAdapter.cpp", "SearchEngine.cpp", diff --git a/Userland/Libraries/LibWebView/CMakeLists.txt b/Userland/Libraries/LibWebView/CMakeLists.txt index 870ffda21a..0fc65abd7f 100644 --- a/Userland/Libraries/LibWebView/CMakeLists.txt +++ b/Userland/Libraries/LibWebView/CMakeLists.txt @@ -5,6 +5,7 @@ set(SOURCES CookieJar.cpp Database.cpp History.cpp + InspectorClient.cpp PropertyTableModel.cpp RequestServerAdapter.cpp SearchEngine.cpp diff --git a/Userland/Libraries/LibWebView/Forward.h b/Userland/Libraries/LibWebView/Forward.h index 062237509c..11a56d27c3 100644 --- a/Userland/Libraries/LibWebView/Forward.h +++ b/Userland/Libraries/LibWebView/Forward.h @@ -14,6 +14,7 @@ class ConsoleClient; class CookieJar; class Database; class History; +class InspectorClient; class OutOfProcessWebView; class ViewImplementation; class WebContentClient; diff --git a/Userland/Libraries/LibWebView/InspectorClient.cpp b/Userland/Libraries/LibWebView/InspectorClient.cpp new file mode 100644 index 0000000000..5eda8c8273 --- /dev/null +++ b/Userland/Libraries/LibWebView/InspectorClient.cpp @@ -0,0 +1,523 @@ +/* + * Copyright (c) 2023, Tim Flynn + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#include +#include +#include +#include +#include +#include + +namespace WebView { + +static ErrorOr 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"~~~( + + + + + + + +
+
+ + +
+
+ +
+
+)~~~"sv); + + generate_dom_tree(builder); + + builder.append(R"~~~( +
+
+)~~~"sv); + + generate_accessibility_tree(builder); + + builder.append(R"~~~( +
+
+ + + + +)~~~"sv); + + m_inspector_web_view.load_html(builder.string_view()); +} + +template +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("
"sv); + + builder.append(""sv); + generator(node); + builder.append(""sv); + + children->for_each([&](auto const& child) { + builder.append("
"sv); + generate_tree(builder, child.as_object(), generator); + builder.append("
"sv); + }); + + builder.append("
"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("pseudo-element"sv); pseudo_element.has_value()) { + append_data_attribute("id"sv, node.get_integer("parent-id"sv).value()); + append_data_attribute("pseudo-element"sv, *pseudo_element); + } else { + append_data_attribute("id"sv, node.get_integer("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("", data_attributes.string_view()); + builder.append(name); + builder.append(""sv); + } else { + builder.appendff("", data_attributes.string_view()); + builder.append(text); + builder.append(""sv); + } + + return; + } + + if (type == "comment"sv) { + auto comment = node.get_deprecated_string("data"sv).release_value(); + comment = escape_html_entities(comment); + + builder.appendff("", data_attributes.string_view()); + builder.appendff("<!--{}-->", comment); + builder.append(""sv); + return; + } + + if (type == "shadow-root"sv) { + auto mode = node.get_deprecated_string("mode"sv).release_value(); + + builder.appendff("", data_attributes.string_view()); + builder.appendff("{} ({})", name, mode); + builder.append(""sv); + return; + } + + if (type != "element"sv) { + builder.appendff("", data_attributes.string_view()); + builder.appendff(name); + builder.append(""sv); + return; + } + + if (name.equals_ignoring_ascii_case("BODY"sv)) + m_body_node_id = node.get_integer("id"sv).value(); + + builder.appendff("", data_attributes.string_view()); + builder.append("<"sv); + builder.appendff("{}", 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(" "sv); + builder.appendff("{}", name); + builder.append('='); + builder.appendff("\"{}\"", value); + }); + } + + builder.append(">"sv); + builder.append(""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(""); + builder.append(MUST(Web::Infra::strip_and_collapse_whitespace(text))); + builder.append(""sv); + return; + } + + if (type != "element"sv) { + builder.appendff(""); + builder.appendff(role.to_lowercase()); + builder.append(""sv); + return; + } + + auto name = node.get_deprecated_string("name"sv).value_or({}); + auto description = node.get_deprecated_string("description"sv).value_or({}); + + builder.appendff(""); + builder.append(role.to_lowercase()); + builder.appendff(" name: \"{}\", description: \"{}\"", name, description); + builder.append(""sv); + }); +} + +} diff --git a/Userland/Libraries/LibWebView/InspectorClient.h b/Userland/Libraries/LibWebView/InspectorClient.h new file mode 100644 index 0000000000..d62c367e1b --- /dev/null +++ b/Userland/Libraries/LibWebView/InspectorClient.h @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2023, Tim Flynn + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#include +#include +#include +#include + +#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)> 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 m_dom_tree; + Optional m_accessibility_tree; + + Optional m_body_node_id; + Optional m_pending_selection; + + bool m_inspector_loaded { false }; +}; + +} diff --git a/Userland/Libraries/LibWebView/SourceHighlighter.h b/Userland/Libraries/LibWebView/SourceHighlighter.h index 2d4f204947..12d1c0852f 100644 --- a/Userland/Libraries/LibWebView/SourceHighlighter.h +++ b/Userland/Libraries/LibWebView/SourceHighlighter.h @@ -41,6 +41,9 @@ constexpr inline StringView HTML_HIGHLIGHTER_STYLE = R"~~~( .attribute-value { color: deepskyblue; } + .internal { + color: darkgrey; + } } @media (prefers-color-scheme: light) { @@ -56,6 +59,9 @@ constexpr inline StringView HTML_HIGHLIGHTER_STYLE = R"~~~( .attribute-value { color: blue; } + .internal { + color: dimgray; + } } )~~~"sv;