diff --git a/AK/Debug.h.in b/AK/Debug.h.in index f4855eec87..2f306ce684 100644 --- a/AK/Debug.h.in +++ b/AK/Debug.h.in @@ -494,6 +494,10 @@ #cmakedefine01 WASM_VALIDATOR_DEBUG #endif +#ifndef WEBDRIVER_DEBUG +#cmakedefine01 WEBDRIVER_DEBUG +#endif + #ifndef WEBGL_CONTEXT_DEBUG #cmakedefine01 WEBGL_CONTEXT_DEBUG #endif diff --git a/Meta/CMake/all_the_debug_macros.cmake b/Meta/CMake/all_the_debug_macros.cmake index 5c96e08d3e..7b05ed522f 100644 --- a/Meta/CMake/all_the_debug_macros.cmake +++ b/Meta/CMake/all_the_debug_macros.cmake @@ -211,6 +211,7 @@ set(WAITQUEUE_DEBUG ON) set(WASM_BINPARSER_DEBUG ON) set(WASM_TRACE_DEBUG ON) set(WASM_VALIDATOR_DEBUG ON) +set(WEBDRIVER_DEBUG ON) set(WEBGL_CONTEXT_DEBUG ON) set(WEBSERVER_DEBUG ON) set(WEB_WORKER_DEBUG ON) diff --git a/Userland/Services/CMakeLists.txt b/Userland/Services/CMakeLists.txt index f81dffcb94..f617787235 100644 --- a/Userland/Services/CMakeLists.txt +++ b/Userland/Services/CMakeLists.txt @@ -26,5 +26,6 @@ if (SERENITYOS) add_subdirectory(Taskbar) add_subdirectory(TelnetServer) add_subdirectory(WebContent) + add_subdirectory(WebDriver) add_subdirectory(WindowServer) endif() diff --git a/Userland/Services/WebDriver/BrowserConnection.cpp b/Userland/Services/WebDriver/BrowserConnection.cpp new file mode 100644 index 0000000000..889c7d4095 --- /dev/null +++ b/Userland/Services/WebDriver/BrowserConnection.cpp @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2022, Florent Castelli + * Copyright (c) 2022, Sam Atkins + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#include "BrowserConnection.h" +#include "Client.h" + +namespace WebDriver { + +BrowserConnection::BrowserConnection(NonnullOwnPtr socket, NonnullRefPtr client, unsigned session_id) + : IPC::ConnectionFromClient(*this, move(socket), 1) + , m_client(move(client)) + , m_session_id(session_id) +{ +} + +void BrowserConnection::die() +{ + dbgln_if(WEBDRIVER_DEBUG, "Session {} was closed remotely. Shutting down...", m_session_id); + m_client->close_session(m_session_id); +} + +} diff --git a/Userland/Services/WebDriver/BrowserConnection.h b/Userland/Services/WebDriver/BrowserConnection.h new file mode 100644 index 0000000000..4dad6588dd --- /dev/null +++ b/Userland/Services/WebDriver/BrowserConnection.h @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2022, Florent Castelli + * Copyright (c) 2022, Sam Atkins + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#pragma once + +#include +#include +#include +#include +#include +#include + +namespace WebDriver { + +class Client; + +class BrowserConnection + : public IPC::ConnectionFromClient { + C_OBJECT_ABSTRACT(BrowserConnection) +public: + BrowserConnection(NonnullOwnPtr socket, NonnullRefPtr client, unsigned session_id); + + virtual void die() override; + +private: + NonnullRefPtr m_client; + unsigned m_session_id { 0 }; +}; + +} diff --git a/Userland/Services/WebDriver/CMakeLists.txt b/Userland/Services/WebDriver/CMakeLists.txt new file mode 100644 index 0000000000..c8365b127f --- /dev/null +++ b/Userland/Services/WebDriver/CMakeLists.txt @@ -0,0 +1,19 @@ +serenity_component( + WebDriver + TARGETS WebDriver +) + +set(SOURCES + BrowserConnection.cpp + Client.cpp + Session.cpp + main.cpp +) + +set(GENERATED_SOURCES + ../../Applications/Browser/WebDriverSessionClientEndpoint.h + ../../Applications/Browser/WebDriverSessionServerEndpoint.h +) + +serenity_bin(WebDriver) +target_link_libraries(WebDriver LibCore LibHTTP LibMain LibIPC) diff --git a/Userland/Services/WebDriver/Client.cpp b/Userland/Services/WebDriver/Client.cpp new file mode 100644 index 0000000000..31b9b01748 --- /dev/null +++ b/Userland/Services/WebDriver/Client.cpp @@ -0,0 +1,442 @@ +/* + * Copyright (c) 2022, Florent Castelli + * Copyright (c) 2022, Sam Atkins + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#include "Client.h" +#include "Session.h" +#include +#include +#include +#include +#include +#include + +namespace WebDriver { + +Atomic Client::s_next_session_id; +NonnullOwnPtrVector Client::s_sessions; +Vector Client::s_routes = { + { HTTP::HttpRequest::Method::POST, { "session" }, &Client::handle_post_session }, + { HTTP::HttpRequest::Method::DELETE, { "session", ":session_id" }, &Client::handle_delete_session }, + { HTTP::HttpRequest::Method::GET, { "status" }, &Client::handle_get_status }, + { HTTP::HttpRequest::Method::POST, { "session", ":session_id", "url" }, &Client::handle_post_url }, + { HTTP::HttpRequest::Method::GET, { "session", ":session_id", "url" }, &Client::handle_get_url }, + { HTTP::HttpRequest::Method::GET, { "session", ":session_id", "title" }, &Client::handle_get_title }, + { HTTP::HttpRequest::Method::DELETE, { "session", ":session_id", "window" }, &Client::handle_delete_window }, +}; + +Client::Client(NonnullOwnPtr socket, Core::Object* parent) + : Core::Object(parent) + , m_socket(move(socket)) +{ +} + +void Client::die() +{ + m_socket->close(); + deferred_invoke([this] { remove_from_parent(); }); +} + +void Client::start() +{ + m_socket->on_ready_to_read = [this] { + StringBuilder builder; + + // FIXME: All this should be moved to LibHTTP and be made spec compliant + auto maybe_buffer = ByteBuffer::create_uninitialized(m_socket->buffer_size()); + if (maybe_buffer.is_error()) { + warnln("Could not create buffer for client: {}", maybe_buffer.error()); + die(); + return; + } + + auto buffer = maybe_buffer.release_value(); + for (;;) { + auto maybe_can_read = m_socket->can_read_without_blocking(); + if (maybe_can_read.is_error()) { + warnln("Failed to get the blocking status for the socket: {}", maybe_can_read.error()); + die(); + return; + } + + if (!maybe_can_read.value()) + break; + + auto maybe_data = m_socket->read(buffer); + if (maybe_data.is_error()) { + warnln("Failed to read data from the request: {}", maybe_data.error()); + die(); + return; + } + + if (m_socket->is_eof()) { + die(); + break; + } + + builder.append(StringView(maybe_data.value())); + } + + auto request = builder.to_byte_buffer(); + auto http_request_or_error = HTTP::HttpRequest::from_raw_request(request); + if (!http_request_or_error.has_value()) + return; + + auto http_request = http_request_or_error.release_value(); + + auto body_or_error = read_body_as_json(http_request); + if (body_or_error.is_error()) { + warnln("Failed to read the request body: {}", body_or_error.error()); + die(); + return; + } + + auto maybe_did_handle = handle_request(http_request, body_or_error.value()); + if (maybe_did_handle.is_error()) { + warnln("Failed to handle the request: {}", maybe_did_handle.error()); + } + + die(); + }; +} + +ErrorOr Client::read_body_as_json(HTTP::HttpRequest const& request) +{ + // If we received a multipart body here, this would fail badly. + unsigned content_length = 0; + for (auto const& header : request.headers()) { + if (header.name.equals_ignoring_case("Content-Length"sv)) { + content_length = header.value.to_int(TrimWhitespace::Yes).value_or(0); + break; + } + } + + if (!content_length) + return JsonValue(); + + // FIXME: Check the Content-Type is actually application/json + JsonParser json_parser(request.body()); + return json_parser.parse(); +} + +ErrorOr Client::handle_request(HTTP::HttpRequest const& request, JsonValue const& body) +{ + if constexpr (WEBDRIVER_DEBUG) { + dbgln("Got HTTP request: {} {}", request.method_name(), request.resource()); + if (!body.is_null()) + dbgln("Body: {}", body.to_string()); + } + + auto routing_result_match = match_route(request.method(), request.resource()); + if (routing_result_match.is_error()) { + auto error = routing_result_match.release_error(); + dbgln_if(WEBDRIVER_DEBUG, "Failed to match route: {}", error); + TRY(send_error_response(error, request)); + return false; + } + + auto routing_result = routing_result_match.release_value(); + auto result = (this->*routing_result.handler)(routing_result.parameters, body); + if (result.is_error()) { + dbgln_if(WEBDRIVER_DEBUG, "Error in calling route handler: {}", result.error()); + TRY(send_error_response(result.release_error(), request)); + return false; + } + + auto object = result.release_value(); + TRY(send_response(object.to_string(), request)); + + return true; +} + +// https://w3c.github.io/webdriver/#dfn-send-a-response +ErrorOr Client::send_response(StringView content, HTTP::HttpRequest const& request) +{ + // FIXME: Implement to spec. + + StringBuilder builder; + builder.append("HTTP/1.0 200 OK\r\n"sv); + builder.append("Server: WebDriver (SerenityOS)\r\n"sv); + builder.append("X-Frame-Options: SAMEORIGIN\r\n"sv); + builder.append("X-Content-Type-Options: nosniff\r\n"sv); + builder.append("Pragma: no-cache\r\n"sv); + builder.append("Content-Type: application/json; charset=utf-8\r\n"sv); + builder.appendff("Content-Length: {}\r\n", content.length()); + builder.append("\r\n"sv); + + auto builder_contents = builder.to_byte_buffer(); + TRY(m_socket->write(builder_contents)); + TRY(m_socket->write(content.bytes())); + log_response(200, request); + + auto keep_alive = false; + if (auto it = request.headers().find_if([](auto& header) { return header.name.equals_ignoring_case("Connection"sv); }); !it.is_end()) { + if (it->value.trim_whitespace().equals_ignoring_case("keep-alive"sv)) + keep_alive = true; + } + if (!keep_alive) + m_socket->close(); + + return {}; +} + +// https://w3c.github.io/webdriver/#dfn-send-an-error +ErrorOr Client::send_error_response(HttpError const& error, HTTP::HttpRequest const& request) +{ + // FIXME: Implement to spec. + + dbgln("send_error_response: {} {}: {}", error.http_status, error.error, error.message); + auto reason_phrase = HTTP::HttpResponse::reason_phrase_for_code(error.http_status); + + auto result = JsonObject(); + result.set("error", error.error); + result.set("message", error.message); + result.set("stacktrace", ""); + + StringBuilder content_builder; + result.serialize(content_builder); + + StringBuilder header_builder; + header_builder.appendff("HTTP/1.0 {} ", error.http_status); + header_builder.append(reason_phrase); + header_builder.append("\r\n"sv); + header_builder.append("Content-Type: application/json; charset=UTF-8\r\n"sv); + header_builder.appendff("Content-Length: {}\r\n", content_builder.length()); + header_builder.append("\r\n"sv); + TRY(m_socket->write(header_builder.to_byte_buffer())); + TRY(m_socket->write(content_builder.to_byte_buffer())); + + log_response(error.http_status, request); + return {}; +} + +void Client::log_response(unsigned code, HTTP::HttpRequest const& request) +{ + outln("{} :: {:03d} :: {} {}", Core::DateTime::now().to_string(), code, request.method_name(), request.resource()); +} + +// https://w3c.github.io/webdriver/#dfn-match-a-request +ErrorOr Client::match_route(HTTP::HttpRequest::Method method, String resource) +{ + // FIXME: Implement to spec. + + dbgln_if(WEBDRIVER_DEBUG, "match_route({}, {})", HTTP::to_string(method), resource); + + // https://w3c.github.io/webdriver/webdriver-spec.html#routing-requests + if (!resource.starts_with(m_prefix)) + return HttpError { 404, "unknown command", "The resource doesn't start with the prefix." }; + + Vector resource_split = resource.substring_view(m_prefix.length()).split_view('/', true); + Vector parameters; + + bool matched_path = false; + + for (auto const& route : Client::s_routes) { + dbgln_if(WEBDRIVER_DEBUG, "- Checking {} {}", HTTP::to_string(route.method), String::join("/"sv, route.path)); + if (resource_split.size() != route.path.size()) { + dbgln_if(WEBDRIVER_DEBUG, "-> Discarding: Wrong length"); + continue; + } + + bool match = true; + for (size_t i = 0; i < route.path.size(); ++i) { + if (route.path[i].starts_with(':')) { + parameters.append(resource_split[i]); + continue; + } + + if (route.path[i] != resource_split[i]) { + match = false; + parameters.clear(); + dbgln_if(WEBDRIVER_DEBUG, "-> Discarding: Part `{}` does not match `{}`", route.path[i], resource_split[i]); + break; + } + } + + if (match && route.method == method) { + dbgln_if(WEBDRIVER_DEBUG, "-> Matched! :^)"); + return RoutingResult { route.handler, parameters }; + } + matched_path = true; + } + + // Matched a path, but didn't match a known method + if (matched_path) { + dbgln_if(WEBDRIVER_DEBUG, "- A path matched, but method didn't. :^("); + return HttpError { 405, "unknown method", "The command matched a known URL but did not match a method for that URL." }; + } + + // Didn't have any match + dbgln_if(WEBDRIVER_DEBUG, "- No matches. :^("); + return HttpError { 404, "unknown command", "The command was not recognized." }; +} + +ErrorOr Client::find_session_with_id(StringView session_id) +{ + auto session_id_or_error = session_id.to_uint<>(); + if (!session_id_or_error.has_value()) + return HttpError { 404, "invalid session id", "Invalid session id" }; + + for (auto& session : Client::s_sessions) { + if (session.session_id() == session_id_or_error.value()) + return &session; + } + return HttpError { 404, "invalid session id", "Invalid session id" }; +} + +void Client::close_session(unsigned session_id) +{ + bool found = Client::s_sessions.remove_first_matching([&](auto const& it) { + return it->session_id() == session_id; + }); + + if (found) + dbgln_if(WEBDRIVER_DEBUG, "Shut down session {}", session_id); + else + dbgln_if(WEBDRIVER_DEBUG, "Unable to shut down session {}: Not found", session_id); +} + +JsonValue Client::make_json_value(JsonValue const& value) +{ + JsonObject result; + result.set("value", value); + return result; +} + +// POST /session https://w3c.github.io/webdriver/#dfn-new-sessions +ErrorOr Client::handle_post_session(Vector, JsonValue const&) +{ + dbgln_if(WEBDRIVER_DEBUG, "Handling POST /session"); + + // FIXME: 1. If the maximum active sessions is equal to the length of the list of active sessions, + // return error with error code session not created. + + // FIXME: 2. If the remote end is an intermediary node, take implementation-defined steps that either + // result in returning an error with error code session not created, or in returning a + // success with data that is isomorphic to that returned by remote ends according to the + // rest of this algorithm. If an error is not returned, the intermediary node must retain a + // reference to the session created on the upstream node as the associated session such + // that commands may be forwarded to this associated session on subsequent commands. + + // FIXME: 3. If the maximum active sessions is equal to the length of the list of active sessions, + // return error with error code session not created. + + // FIXME: 4. Let capabilities be the result of trying to process capabilities with parameters as an argument. + + // FIXME: 5. If capabilities’s is null, return error with error code session not created. + + // 6. Let session id be the result of generating a UUID. + // FIXME: Actually create a UUID. + auto session_id = Client::s_next_session_id++; + + // 7. Let session be a new session with the session ID of session id. + NonnullOwnPtr session = make(session_id, *this); + auto start_result = session->start(); + if (start_result.is_error()) { + return HttpError { 500, "Failed to start session", start_result.error().string_literal() }; + } + + // FIXME: 8. Set the current session to session. + + // FIXME: 9. Run any WebDriver new session algorithm defined in external specifications, + // with arguments session and capabilities. + + // 10. Append session to active sessions. + Client::s_sessions.append(move(session)); + + // 11. Let body be a JSON Object initialized with: + JsonObject body; + // "sessionId" + // session id + body.set("sessionId", String::number(session_id)); + // FIXME: "capabilities" + // capabilities + + // FIXME: 12. Initialize the following from capabilities: + // NOTE: See spec for steps + + // FIXME: 13. Set the webdriver-active flag to true. + + // FIXME: 14. Set the current top-level browsing context for session with the top-level browsing context + // of the UA’s current browsing context. + + // FIXME: 15. Set the request queue to a new queue. + + // 16. Return success with data body. + return make_json_value(body); +} + +// DELETE /session/{session id} https://w3c.github.io/webdriver/#dfn-delete-session +ErrorOr Client::handle_delete_session(Vector parameters, JsonValue const&) +{ + dbgln_if(WEBDRIVER_DEBUG, "Handling DELETE /session/"); + + // 1. If the current session is an active session, try to close the session. + Session* session = TRY(find_session_with_id(parameters[0])); + + auto stop_result = session->stop(); + if (stop_result.is_error()) { + return HttpError { 500, "unsupported operation", stop_result.error().string_literal() }; + } + + // 2. Return success with data null. + return make_json_value(JsonValue()); +} + +// GET /status https://w3c.github.io/webdriver/#dfn-status +ErrorOr Client::handle_get_status(Vector, JsonValue const&) +{ + dbgln_if(WEBDRIVER_DEBUG, "Handling GET /status"); + + // FIXME: Implement the spec steps + return HttpError { 400, "", "" }; +} + +// POST /session/{session id}/url https://w3c.github.io/webdriver/#dfn-navigate-to +ErrorOr Client::handle_post_url(Vector parameters, JsonValue const& payload) +{ + dbgln_if(WEBDRIVER_DEBUG, "Handling POST /session//url"); + Session* session = TRY(find_session_with_id(parameters[0])); + + // NOTE: Spec steps handled in Session::post_url(). + auto result = TRY(session->post_url(payload)); + return make_json_value(result); +} + +// GET /session/{session id}/url https://w3c.github.io/webdriver/#dfn-get-current-url +ErrorOr Client::handle_get_url(Vector, JsonValue const&) +{ + dbgln_if(WEBDRIVER_DEBUG, "Handling GET /session//url"); + + // FIXME: Implement the spec steps + return HttpError { 400, "", "" }; +} + +// GET /session/{session id}/title https://w3c.github.io/webdriver/#dfn-get-title +ErrorOr Client::handle_get_title(Vector parameters, JsonValue const&) +{ + dbgln_if(WEBDRIVER_DEBUG, "Handling GET /session//title"); + Session* session = TRY(find_session_with_id(parameters[0])); + + // NOTE: Spec steps handled in Session::get_title(). + auto result = TRY(session->get_title()); + + return make_json_value(result); +} + +// DELETE /session/{session id}/window https://w3c.github.io/webdriver/#dfn-close-window +ErrorOr Client::handle_delete_window(Vector parameters, JsonValue const&) +{ + dbgln_if(WEBDRIVER_DEBUG, "Handling DELETE /session//window"); + Session* session = TRY(find_session_with_id(parameters[0])); + + // NOTE: Spec steps handled in Session::delete_window(). + TRY(unwrap_result(session->delete_window())); + + return make_json_value(JsonValue()); +} + +} diff --git a/Userland/Services/WebDriver/Client.h b/Userland/Services/WebDriver/Client.h new file mode 100644 index 0000000000..5360fdd5e8 --- /dev/null +++ b/Userland/Services/WebDriver/Client.h @@ -0,0 +1,91 @@ +/* + * Copyright (c) 2022, Florent Castelli + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +namespace WebDriver { + +class Client final : public Core::Object { + C_OBJECT(Client); + +public: + void start(); + void close_session(unsigned session_id); + +private: + Client(NonnullOwnPtr, Core::Object* parent); + + ErrorOr read_body_as_json(HTTP::HttpRequest const&); + ErrorOr handle_request(HTTP::HttpRequest const&, JsonValue const& body); + ErrorOr send_response(StringView content, HTTP::HttpRequest const&); + ErrorOr send_error_response(HttpError const& error, HTTP::HttpRequest const&); + void die(); + void log_response(unsigned code, HTTP::HttpRequest const&); + + using RouteHandler = ErrorOr (Client::*)(Vector, JsonValue const&); + struct Route { + HTTP::HttpRequest::Method method; + Vector path; + RouteHandler handler; + }; + + struct RoutingResult { + RouteHandler handler; + Vector parameters; + }; + + ErrorOr match_route(HTTP::HttpRequest::Method method, String resource); + ErrorOr handle_post_session(Vector, JsonValue const& payload); + ErrorOr handle_delete_session(Vector, JsonValue const& payload); + ErrorOr handle_get_status(Vector, JsonValue const& payload); + ErrorOr handle_post_url(Vector, JsonValue const& payload); + ErrorOr handle_get_url(Vector, JsonValue const& payload); + ErrorOr handle_get_title(Vector, JsonValue const& payload); + ErrorOr handle_delete_window(Vector, JsonValue const& payload); + + ErrorOr find_session_with_id(StringView session_id); + JsonValue make_json_value(JsonValue const&); + + template + static ErrorOr unwrap_result(ErrorOr> result) + { + if (result.is_error()) { + Variant error = result.release_error(); + if (error.has()) + return error.get(); + return HttpError { 500, "unsupported operation", error.get().string_literal() }; + } + + return result.release_value(); + } + static ErrorOr unwrap_result(ErrorOr> result) + { + if (result.is_error()) { + Variant error = result.release_error(); + if (error.has()) + return error.get(); + return HttpError { 500, "unsupported operation", error.get().string_literal() }; + } + return {}; + } + + NonnullOwnPtr m_socket; + static Vector s_routes; + String m_prefix = "/"; + + static NonnullOwnPtrVector s_sessions; + static Atomic s_next_session_id; +}; + +} diff --git a/Userland/Services/WebDriver/HttpError.h b/Userland/Services/WebDriver/HttpError.h new file mode 100644 index 0000000000..9646f49b15 --- /dev/null +++ b/Userland/Services/WebDriver/HttpError.h @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2022, Florent Castelli + * Copyright (c) 2022, Sam Atkins + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#pragma once + +#include + +namespace WebDriver { + +struct HttpError { + unsigned http_status; + String error; + String message; +}; + +} + +template<> +struct AK::Formatter : Formatter { + ErrorOr format(FormatBuilder& builder, WebDriver::HttpError const& error) + { + return Formatter::format(builder, String::formatted("Error {}, {}: {}", error.http_status, error.error, error.message)); + } +}; diff --git a/Userland/Services/WebDriver/Session.cpp b/Userland/Services/WebDriver/Session.cpp new file mode 100644 index 0000000000..38eb5eb4b2 --- /dev/null +++ b/Userland/Services/WebDriver/Session.cpp @@ -0,0 +1,144 @@ +/* + * Copyright (c) 2022, Florent Castelli + * Copyright (c) 2022, Sam Atkins + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#include "Session.h" +#include "BrowserConnection.h" +#include "Client.h" +#include +#include +#include +#include + +namespace WebDriver { + +Session::Session(unsigned session_id, NonnullRefPtr client) + : m_client(move(client)) + , m_id(session_id) +{ +} + +Session::~Session() +{ + if (m_started) { + auto error = stop(); + if (error.is_error()) { + warnln("Failed to stop session {}: {}", m_id, error.error()); + } + } +} + +ErrorOr Session::start() +{ + auto socket_path = String::formatted("/tmp/browser_webdriver_{}_{}", getpid(), m_id); + dbgln("Listening for WebDriver connection on {}", socket_path); + + // FIXME: Use Core::LocalServer + struct sockaddr_un addr; + int listen_socket = TRY(Core::System::socket(AF_UNIX, SOCK_STREAM, 0)); + ::memset(&addr, 0, sizeof(struct sockaddr_un)); + addr.sun_family = AF_UNIX; + ::strncpy(addr.sun_path, socket_path.characters(), sizeof(addr.sun_path) - 1); + + TRY(Core::System::bind(listen_socket, (const struct sockaddr*)&addr, sizeof(struct sockaddr_un))); + TRY(Core::System::listen(listen_socket, 1)); + + char const* argv[] = { "/bin/Browser", "--webdriver", socket_path.characters(), nullptr }; + TRY(Core::System::posix_spawn("/bin/Browser"sv, nullptr, nullptr, const_cast(argv), environ)); + + int data_socket = TRY(Core::System::accept(listen_socket, nullptr, nullptr)); + auto socket = TRY(Core::Stream::LocalSocket::adopt_fd(data_socket)); + TRY(socket->set_blocking(true)); + m_browser_connection = TRY(adopt_nonnull_ref_or_enomem(new (nothrow) BrowserConnection(move(socket), m_client, session_id()))); + dbgln("Browser is connected"); + + m_started = true; + m_windows.set("main", make("main", true)); + m_current_window_handle = "main"; + + return {}; +} + +ErrorOr Session::stop() +{ + m_browser_connection->async_quit(); + return {}; +} + +// DELETE /session/{session id}/window https://w3c.github.io/webdriver/#dfn-close-window +ErrorOr> Session::delete_window() +{ + // 1. If the current top-level browsing context is no longer open, return error with error code no such window. + auto current_window = get_window_object(); + if (!current_window.has_value()) + return Variant(HttpError { 400, "no such window", "Window not found" }); + + // 2. Close the current top-level browsing context. + m_windows.remove(m_current_window_handle); + + // 3. If there are no more open top-level browsing contexts, then close the session. + if (m_windows.is_empty()) { + auto result = stop(); + if (result.is_error()) { + return Variant(result.release_error()); + } + } + + return {}; +} + +// POST /session/{session id}/url https://w3c.github.io/webdriver/#dfn-navigate-to +ErrorOr Session::post_url(JsonValue const& payload) +{ + // 1. If the current top-level browsing context is no longer open, return error with error code no such window. + auto current_window = get_window_object(); + if (!current_window.has_value()) + return HttpError { 400, "no such window", "Window not found" }; + + // FIXME 2. Handle any user prompts and return its value if it is an error. + + // 3. If the url property is missing from the parameters argument or it is not a string, return error with error code invalid argument. + if (!payload.is_object() || !payload.as_object().has_string("url"sv)) { + return HttpError { 400, "invalid argument", "Payload doesn't have a string url" }; + } + + // 4. Let url be the result of getting a property named url from the parameters argument. + URL url(payload.as_object().get_ptr("url"sv)->as_string()); + + // FIXME: 5. If url is not an absolute URL or an absolute URL with fragment, return error with error code invalid argument. [URL] + + // 6. Let url be the result of getting a property named url from the parameters argument. + // Duplicate step? + + // 7. Navigate the current top-level browsing context to url. + m_browser_connection->async_set_url(url); + + // FIXME: 8. Run the post-navigation checks and return its value if it is an error. + + // FIXME: 9. Wait for navigation to complete and return its value if it is an error. + + // FIXME: 10. Set the current browsing context to the current top-level browsing context. + + // 11. Return success with data null. + return JsonValue(); +} + +// GET /session/{session id}/title https://w3c.github.io/webdriver/#dfn-get-title +ErrorOr Session::get_title() +{ + // 1. If the current top-level browsing context is no longer open, return error with error code no such window. + auto current_window = get_window_object(); + if (!current_window.has_value()) + return HttpError { 400, "no such window", "Window not found" }; + + // FIXME: 2. Handle any user prompts and return its value if it is an error. + + // 3. Let title be the initial value of the title IDL attribute of the current top-level browsing context's active document. + // 4. Return success with data title. + return JsonValue(m_browser_connection->get_title()); +} + +} diff --git a/Userland/Services/WebDriver/Session.h b/Userland/Services/WebDriver/Session.h new file mode 100644 index 0000000000..2e3ba08885 --- /dev/null +++ b/Userland/Services/WebDriver/Session.h @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2022, Florent Castelli + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#pragma once + +#include +#include +#include +#include +#include +#include + +namespace WebDriver { + +class Session { +public: + Session(unsigned session_id, NonnullRefPtr client); + ~Session(); + + unsigned session_id() const { return m_id; } + + struct Window { + String handle; + bool is_open; + }; + + HashMap>& get_window_handles() { return m_windows; } + Optional get_window_object() { return m_windows.get(m_current_window_handle); } + String get_window() { return m_current_window_handle; } + + ErrorOr start(); + ErrorOr stop(); + ErrorOr> delete_window(); + ErrorOr post_url(JsonValue const& url); + ErrorOr get_title(); + +private: + NonnullRefPtr m_client; + bool m_started { false }; + unsigned m_id { 0 }; + HashMap> m_windows; + String m_current_window_handle; + RefPtr m_local_server; + RefPtr m_browser_connection; +}; + +} diff --git a/Userland/Services/WebDriver/main.cpp b/Userland/Services/WebDriver/main.cpp new file mode 100644 index 0000000000..c92e277754 --- /dev/null +++ b/Userland/Services/WebDriver/main.cpp @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2022, Florent Castelli + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#include +#include +#include +#include +#include +#include + +ErrorOr serenity_main(Main::Arguments arguments) +{ + String default_listen_address = "0.0.0.0"; + u16 default_port = 8000; + + String listen_address = default_listen_address; + int port = default_port; + + Core::ArgsParser args_parser; + args_parser.add_option(listen_address, "IP address to listen on", "listen-address", 'l', "listen_address"); + args_parser.add_option(port, "Port to listen on", "port", 'p', "port"); + args_parser.parse(arguments); + + auto ipv4_address = IPv4Address::from_string(listen_address); + if (!ipv4_address.has_value()) { + warnln("Invalid listen address: {}", listen_address); + return 1; + } + + if ((u16)port != port) { + warnln("Invalid port number: {}", port); + return 1; + } + + TRY(Core::System::pledge("stdio accept rpath inet unix proc exec fattr")); + + Core::EventLoop loop; + + auto server = TRY(Core::TCPServer::try_create()); + + server->on_ready_to_accept = [&] { + auto maybe_client_socket = server->accept(); + if (maybe_client_socket.is_error()) { + warnln("Failed to accept the client: {}", maybe_client_socket.error()); + return; + } + + auto maybe_buffered_socket = Core::Stream::BufferedTCPSocket::create(maybe_client_socket.release_value()); + if (maybe_buffered_socket.is_error()) { + warnln("Could not obtain a buffered socket for the client: {}", maybe_buffered_socket.error()); + return; + } + + // FIXME: Propagate errors + MUST(maybe_buffered_socket.value()->set_blocking(true)); + auto client = WebDriver::Client::construct(maybe_buffered_socket.release_value(), server); + client->start(); + }; + + TRY(server->listen(ipv4_address.value(), port)); + + outln("Listening on {}:{}", ipv4_address.value(), port); + + TRY(Core::System::unveil("/bin/Browser", "rx")); + TRY(Core::System::unveil("/etc/timezone", "r")); + TRY(Core::System::unveil("/res/icons", "r")); + TRY(Core::System::unveil("/tmp", "rwc")); + TRY(Core::System::unveil(nullptr, nullptr)); + + TRY(Core::System::pledge("stdio accept rpath unix proc exec fattr")); + return loop.exec(); +}