diff --git a/Ladybird/BrowserWindow.cpp b/Ladybird/BrowserWindow.cpp index e4e78e24ed7..cca366632ac 100644 --- a/Ladybird/BrowserWindow.cpp +++ b/Ladybird/BrowserWindow.cpp @@ -52,6 +52,16 @@ BrowserWindow::BrowserWindow(Core::EventLoop& event_loop) } }); + auto* js_console_action = new QAction("Show &JS Console"); + js_console_action->setIcon(QIcon(QString("%1/res/icons/16x16/filetype-javascript.png").arg(s_serenity_resource_root.characters()))); + js_console_action->setShortcut(QKeySequence(Qt::CTRL | Qt::SHIFT | Qt::Key_J)); + inspect_menu->addAction(js_console_action); + QObject::connect(js_console_action, &QAction::triggered, this, [this] { + if (m_current_tab) { + m_current_tab->view().show_js_console(); + } + }); + auto* debug_menu = menuBar()->addMenu("&Debug"); auto* dump_dom_tree_action = new QAction("Dump DOM Tree"); diff --git a/Ladybird/CMakeLists.txt b/Ladybird/CMakeLists.txt index 242fc7e604f..aa1db9bf15f 100644 --- a/Ladybird/CMakeLists.txt +++ b/Ladybird/CMakeLists.txt @@ -35,6 +35,8 @@ find_package(Qt6 REQUIRED COMPONENTS Core Widgets Network) set(SOURCES BrowserWindow.cpp + ConsoleClient.cpp + ConsoleGlobalObject.cpp CookieJar.cpp RequestManagerQt.cpp main.cpp diff --git a/Ladybird/ConsoleClient.cpp b/Ladybird/ConsoleClient.cpp new file mode 100644 index 00000000000..68d30e950f8 --- /dev/null +++ b/Ladybird/ConsoleClient.cpp @@ -0,0 +1,197 @@ +/* + * Copyright (c) 2021, Brandon Scott + * Copyright (c) 2020, Hunter Salyer + * Copyright (c) 2021, Sam Atkins + * Copyright (c) 2022, Andreas Kling + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#include "ConsoleClient.h" +#include "ConsoleGlobalObject.h" +#include "WebView.h" +#include +#include +#include +#include +#include + +namespace Ladybird { + +ConsoleClient::ConsoleClient(JS::Console& console, WeakPtr interpreter, WebView& view) + : JS::ConsoleClient(console) + , m_view(view) + , m_interpreter(interpreter) +{ + JS::DeferGC defer_gc(m_interpreter->heap()); + + auto& vm = m_interpreter->vm(); + auto& global_object = m_interpreter->global_object(); + + auto console_global_object = m_interpreter->heap().allocate_without_global_object(static_cast(global_object)); + + // NOTE: We need to push an execution context here for NativeFunction::create() to succeed during global object initialization. + // It gets removed immediately after creating the interpreter in Document::interpreter(). + auto& eso = verify_cast(*m_interpreter->realm().host_defined()); + vm.push_execution_context(eso.realm_execution_context()); + console_global_object->initialize_global_object(); + vm.pop_execution_context(); + + m_console_global_object = JS::make_handle(console_global_object); +} + +void ConsoleClient::handle_input(String const& js_source) +{ + auto& settings = verify_cast(*m_interpreter->realm().host_defined()); + auto script = Web::HTML::ClassicScript::create("(console)", js_source, settings, settings.api_base_url()); + + // FIXME: Add parse error printouts back once ClassicScript can report parse errors. + + auto result = script->run(); + + StringBuilder output_html; + + if (result.is_abrupt()) { + output_html.append("Uncaught exception: "sv); + auto error = *result.release_error().value(); + if (error.is_object()) + output_html.append(JS::MarkupGenerator::html_from_error(error.as_object())); + else + output_html.append(JS::MarkupGenerator::html_from_value(error)); + print_html(output_html.string_view()); + return; + } + + if (result.value().has_value()) + print_html(JS::MarkupGenerator::html_from_value(*result.value())); +} + +void ConsoleClient::print_html(String const& line) +{ + m_message_log.append({ .type = ConsoleOutput::Type::HTML, .data = line }); + m_view.did_output_js_console_message(m_message_log.size() - 1); +} + +void ConsoleClient::clear_output() +{ + m_message_log.append({ .type = ConsoleOutput::Type::Clear, .data = "" }); + m_view.did_output_js_console_message(m_message_log.size() - 1); +} + +void ConsoleClient::begin_group(String const& label, bool start_expanded) +{ + m_message_log.append({ .type = start_expanded ? ConsoleOutput::Type::BeginGroup : ConsoleOutput::Type::BeginGroupCollapsed, .data = label }); + m_view.did_output_js_console_message(m_message_log.size() - 1); +} + +void ConsoleClient::end_group() +{ + m_message_log.append({ .type = ConsoleOutput::Type::EndGroup, .data = "" }); + m_view.did_output_js_console_message(m_message_log.size() - 1); +} + +void ConsoleClient::send_messages(i32 start_index) +{ + // FIXME: Cap the number of messages we send at once? + auto messages_to_send = m_message_log.size() - start_index; + if (messages_to_send < 1) { + // When the console is first created, it requests any messages that happened before + // then, by requesting with start_index=0. If we don't have any messages at all, that + // is still a valid request, and we can just ignore it. + dbgln("Requested non-existent console message index."); + return; + } + + // FIXME: Replace with a single Vector of message structs + Vector message_types; + Vector messages; + message_types.ensure_capacity(messages_to_send); + messages.ensure_capacity(messages_to_send); + + for (size_t i = start_index; i < m_message_log.size(); i++) { + auto& message = m_message_log[i]; + switch (message.type) { + case ConsoleOutput::Type::HTML: + message_types.append("html"sv); + break; + case ConsoleOutput::Type::Clear: + message_types.append("clear"sv); + break; + case ConsoleOutput::Type::BeginGroup: + message_types.append("group"sv); + break; + case ConsoleOutput::Type::BeginGroupCollapsed: + message_types.append("groupCollapsed"sv); + break; + case ConsoleOutput::Type::EndGroup: + message_types.append("groupEnd"sv); + break; + } + + messages.append(message.data); + } + + m_view.did_get_js_console_messages(start_index, message_types, messages); +} + +void ConsoleClient::clear() +{ + clear_output(); +} + +// 2.3. Printer(logLevel, args[, options]), https://console.spec.whatwg.org/#printer +JS::ThrowCompletionOr ConsoleClient::printer(JS::Console::LogLevel log_level, PrinterArguments arguments) +{ + if (log_level == JS::Console::LogLevel::Trace) { + auto trace = arguments.get(); + StringBuilder html; + if (!trace.label.is_empty()) + html.appendff("{}
", escape_html_entities(trace.label)); + + html.append(""sv); + for (auto& function_name : trace.stack) + html.appendff("-> {}
", escape_html_entities(function_name)); + html.append("
"sv); + + print_html(html.string_view()); + return JS::js_undefined(); + } + + if (log_level == JS::Console::LogLevel::Group || log_level == JS::Console::LogLevel::GroupCollapsed) { + auto group = arguments.get(); + begin_group(group.label, log_level == JS::Console::LogLevel::Group); + return JS::js_undefined(); + } + + auto output = String::join(' ', arguments.get>()); + m_console.output_debug_message(log_level, output); + + StringBuilder html; + switch (log_level) { + case JS::Console::LogLevel::Debug: + html.append("(d) "sv); + break; + case JS::Console::LogLevel::Error: + html.append("(e) "sv); + break; + case JS::Console::LogLevel::Info: + html.append("(i) "sv); + break; + case JS::Console::LogLevel::Log: + html.append(" "sv); + break; + case JS::Console::LogLevel::Warn: + case JS::Console::LogLevel::CountReset: + html.append("(w) "sv); + break; + default: + html.append(""sv); + break; + } + + html.append(escape_html_entities(output)); + html.append(""sv); + print_html(html.string_view()); + return JS::js_undefined(); +} +} diff --git a/Ladybird/ConsoleGlobalObject.cpp b/Ladybird/ConsoleGlobalObject.cpp new file mode 100644 index 00000000000..3abb32d1713 --- /dev/null +++ b/Ladybird/ConsoleGlobalObject.cpp @@ -0,0 +1,114 @@ +/* + * Copyright (c) 2021, Sam Atkins + * Copyright (c) 2022, Andreas Kling + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#include "ConsoleGlobalObject.h" +#include +#include +#include +#include +#include +#include + +namespace Ladybird { + +ConsoleGlobalObject::ConsoleGlobalObject(Web::Bindings::WindowObject& parent_object) + : m_window_object(&parent_object) +{ +} + +void ConsoleGlobalObject::initialize_global_object() +{ + Base::initialize_global_object(); + + // $0 magic variable + define_native_accessor("$0", inspected_node_getter, nullptr, 0); +} + +void ConsoleGlobalObject::visit_edges(Visitor& visitor) +{ + Base::visit_edges(visitor); + visitor.visit(m_window_object); +} + +JS::ThrowCompletionOr ConsoleGlobalObject::internal_get_prototype_of() const +{ + return m_window_object->internal_get_prototype_of(); +} + +JS::ThrowCompletionOr ConsoleGlobalObject::internal_set_prototype_of(JS::Object* prototype) +{ + return m_window_object->internal_set_prototype_of(prototype); +} + +JS::ThrowCompletionOr ConsoleGlobalObject::internal_is_extensible() const +{ + return m_window_object->internal_is_extensible(); +} + +JS::ThrowCompletionOr ConsoleGlobalObject::internal_prevent_extensions() +{ + return m_window_object->internal_prevent_extensions(); +} + +JS::ThrowCompletionOr> ConsoleGlobalObject::internal_get_own_property(JS::PropertyKey const& property_name) const +{ + if (auto result = TRY(m_window_object->internal_get_own_property(property_name)); result.has_value()) + return result; + + return Base::internal_get_own_property(property_name); +} + +JS::ThrowCompletionOr ConsoleGlobalObject::internal_define_own_property(JS::PropertyKey const& property_name, JS::PropertyDescriptor const& descriptor) +{ + return m_window_object->internal_define_own_property(property_name, descriptor); +} + +JS::ThrowCompletionOr ConsoleGlobalObject::internal_has_property(JS::PropertyKey const& property_name) const +{ + return TRY(Object::internal_has_property(property_name)) || TRY(m_window_object->internal_has_property(property_name)); +} + +JS::ThrowCompletionOr ConsoleGlobalObject::internal_get(JS::PropertyKey const& property_name, JS::Value receiver) const +{ + if (TRY(m_window_object->has_own_property(property_name))) + return m_window_object->internal_get(property_name, (receiver == this) ? m_window_object : receiver); + + return Base::internal_get(property_name, receiver); +} + +JS::ThrowCompletionOr ConsoleGlobalObject::internal_set(JS::PropertyKey const& property_name, JS::Value value, JS::Value receiver) +{ + return m_window_object->internal_set(property_name, value, (receiver == this) ? m_window_object : receiver); +} + +JS::ThrowCompletionOr ConsoleGlobalObject::internal_delete(JS::PropertyKey const& property_name) +{ + return m_window_object->internal_delete(property_name); +} + +JS::ThrowCompletionOr> ConsoleGlobalObject::internal_own_property_keys() const +{ + return m_window_object->internal_own_property_keys(); +} + +JS_DEFINE_NATIVE_FUNCTION(ConsoleGlobalObject::inspected_node_getter) +{ + auto* this_object = TRY(vm.this_value(global_object).to_object(global_object)); + + if (!is(this_object)) + return vm.throw_completion(global_object, JS::ErrorType::NotAnObjectOfType, "ConsoleGlobalObject"); + + auto console_global_object = static_cast(this_object); + auto& window = console_global_object->m_window_object->impl(); + auto* inspected_node = window.associated_document().inspected_node(); + if (!inspected_node) + return JS::js_undefined(); + + return Web::Bindings::wrap(global_object, *inspected_node); +} + +} diff --git a/Ladybird/WebView.cpp b/Ladybird/WebView.cpp index b9fc499325b..107b7c5fdfd 100644 --- a/Ladybird/WebView.cpp +++ b/Ladybird/WebView.cpp @@ -8,6 +8,7 @@ #define AK_DONT_REPLACE_STD #include "WebView.h" +#include "ConsoleClient.h" #include "CookieJar.h" #include "RequestManagerQt.h" #include @@ -30,12 +31,14 @@ #include #include #include +#include #include #include #include #include #include #include +#include #include #include #include @@ -50,12 +53,26 @@ #include #include #include +#include +#include #include #include #include #include +#include +#include #include +AK::String akstring_from_qstring(QString const& qstring) +{ + return AK::String(qstring.toUtf8().data()); +} + +QString qstring_from_akstring(AK::String const& akstring) +{ + return QString::fromUtf8(akstring.characters(), akstring.length()); +} + String s_serenity_resource_root = [] { auto const* source_dir = getenv("SERENITY_SOURCE_DIR"); if (source_dir) { @@ -162,6 +179,20 @@ public: virtual void page_did_finish_loading(AK::URL const&) override { + initialize_js_console(); + m_console_client->send_messages(0); + } + + void initialize_js_console() + { + auto* document = page().top_level_browsing_context().active_document(); + auto interpreter = document->interpreter().make_weak_ptr(); + if (m_interpreter.ptr() == interpreter.ptr()) + return; + + m_interpreter = interpreter; + m_console_client = make(interpreter->global_object().console(), interpreter, m_view); + interpreter->global_object().console().set_client(*m_console_client.ptr()); } virtual void page_did_change_selection() override @@ -290,7 +321,6 @@ public: void set_should_show_line_box_borders(bool state) { m_should_show_line_box_borders = state; } -private: HeadlessBrowserPageClient(WebView& view) : m_view(view) , m_page(make(*this)) @@ -301,6 +331,8 @@ private: NonnullOwnPtr m_page; Browser::CookieJar m_cookie_jar; + OwnPtr m_console_client; + WeakPtr m_interpreter; RefPtr m_palette_impl; Gfx::IntRect m_viewport_rect { 0, 0, 800, 600 }; Web::CSS::PreferredColorScheme m_preferred_color_scheme { Web::CSS::PreferredColorScheme::Auto }; @@ -675,3 +707,69 @@ String WebView::source() const return String::empty(); return document->source(); } + +void WebView::run_javascript(String const& js_source) const +{ + auto* active_document = const_cast(m_page_client->page().top_level_browsing_context().active_document()); + + if (!active_document) + return; + + // This is partially based on "execute a javascript: URL request" https://html.spec.whatwg.org/multipage/browsing-the-web.html#javascript-protocol + + // Let settings be browsingContext's active document's relevant settings object. + auto& settings = active_document->relevant_settings_object(); + + // Let baseURL be settings's API base URL. + auto base_url = settings.api_base_url(); + + // Let script be the result of creating a classic script given scriptSource, settings, baseURL, and the default classic script fetch options. + // FIXME: This doesn't pass in "default classic script fetch options" + // FIXME: What should the filename be here? + auto script = Web::HTML::ClassicScript::create("(client connection run_javascript)", js_source, settings, base_url); + + // Let evaluationStatus be the result of running the classic script script. + auto evaluation_status = script->run(); + + if (evaluation_status.is_error()) + dbgln("Exception :("); +} + +void WebView::did_output_js_console_message(i32 message_index) +{ + m_page_client->m_console_client->send_messages(message_index); +} + +void WebView::did_get_js_console_messages(i32, Vector, Vector messages) +{ + for (auto& message : messages) { + m_js_console_output_edit->append(qstring_from_akstring(message).trimmed()); + } +} + +void WebView::show_js_console() +{ + if (!m_js_console_widget) { + m_js_console_widget = new QWidget; + m_js_console_widget->setWindowTitle("JS Console"); + auto* layout = new QVBoxLayout; + m_js_console_widget->setLayout(layout); + m_js_console_output_edit = new QTextEdit; + m_js_console_output_edit->setReadOnly(true); + m_js_console_input_edit = new QLineEdit; + layout->addWidget(m_js_console_output_edit); + layout->addWidget(m_js_console_input_edit); + m_js_console_widget->resize(640, 480); + + QObject::connect(m_js_console_input_edit, &QLineEdit::returnPressed, [this] { + auto code = m_js_console_input_edit->text().trimmed(); + m_js_console_input_edit->clear(); + + m_js_console_output_edit->append(QString("> %1").arg(code)); + + m_page_client->m_console_client->handle_input(akstring_from_qstring(code)); + }); + } + m_js_console_widget->show(); + m_js_console_input_edit->setFocus(); +} diff --git a/Ladybird/WebView.h b/Ladybird/WebView.h index b2d185c4fc6..86932e2297e 100644 --- a/Ladybird/WebView.h +++ b/Ladybird/WebView.h @@ -12,6 +12,10 @@ #include #include #include +#include + +class QTextEdit; +class QLineEdit; class HeadlessBrowserPageClient; @@ -34,6 +38,13 @@ public: String source() const; + void run_javascript(String const& js_source) const; + + void did_output_js_console_message(i32 message_index); + void did_get_js_console_messages(i32 start_index, Vector message_types, Vector messages); + + void show_js_console(); + signals: void linkHovered(QString, int timeout = 0); void linkUnhovered(); @@ -48,4 +59,8 @@ private: qreal m_inverse_pixel_scaling_ratio { 1.0 }; bool m_should_show_line_box_borders { false }; + + QPointer m_js_console_widget; + QTextEdit* m_js_console_output_edit { nullptr }; + QLineEdit* m_js_console_input_edit { nullptr }; };