From 2810071a9c7a3c421ee73d7d5cca07243d68040b Mon Sep 17 00:00:00 2001 From: Timothy Flynn Date: Fri, 4 Apr 2025 18:12:32 -0400 Subject: [PATCH] LibWebView: Support custom search engines This allows the user to store custom search engines via about:settings. Custom engines will be displayed below the builtin engines in the drop- down to select the default engine. A couple of edge cases here: 1. We currently reject a custom engine if one with the same name already exists. In the future, we should allow editing custom engines. 2. If a custom engine which was the default engine is removed, we will disable search rather than falling back to any other engine. --- Base/res/ladybird/about-pages/settings.html | 206 ++++++++++++++++++-- Libraries/LibWebView/SearchEngine.cpp | 14 +- Libraries/LibWebView/SearchEngine.h | 3 +- Libraries/LibWebView/Settings.cpp | 89 ++++++++- Libraries/LibWebView/Settings.h | 7 + Libraries/LibWebView/WebUI/SettingsUI.cpp | 24 ++- Libraries/LibWebView/WebUI/SettingsUI.h | 2 + 7 files changed, 317 insertions(+), 28 deletions(-) diff --git a/Base/res/ladybird/about-pages/settings.html b/Base/res/ladybird/about-pages/settings.html index 76cdf863034..336e205a84c 100644 --- a/Base/res/ladybird/about-pages/settings.html +++ b/Base/res/ladybird/about-pages/settings.html @@ -185,6 +185,19 @@ margin: 8px 4px 10px 4px; } + dialog .dialog-form { + border-top: 1px solid var(--border-color); + + display: flex; + flex-direction: column; + + padding: 15px 20px; + } + + dialog .form-group { + margin-bottom: 20px; + } + dialog .dialog-footer { display: flex; justify-content: flex-end; @@ -318,10 +331,13 @@
@@ -386,6 +402,35 @@ + +
+

Search Settings

+ +
+
+

Manage custom search engines

+
+
+
+
+ + +
+
+ +

Use %s as a placeholder for the search query

+ +
+
+ +
+
+
+

@@ -421,10 +466,17 @@ const languagesList = document.querySelector("#languages-list"); const languagesSelect = document.querySelector("#languages-select"); const languagesSettings = document.querySelector("#languages-settings"); - const searchToggle = document.querySelector("#search-toggle"); + const searchClose = document.querySelector("#search-close"); + const searchCustomAdd = document.querySelector("#search-custom-add"); + const searchCustomName = document.querySelector("#search-custom-name"); + const searchCustomURL = document.querySelector("#search-custom-url"); + const searchDialog = document.querySelector("#search-dialog"); const searchEngine = document.querySelector("#search-engine"); - const autocompleteToggle = document.querySelector("#autocomplete-toggle"); + const searchList = document.querySelector("#search-list"); + const searchSettings = document.querySelector("#search-settings"); + const searchToggle = document.querySelector("#search-toggle"); const autocompleteEngine = document.querySelector("#autocomplete-engine"); + const autocompleteToggle = document.querySelector("#autocomplete-toggle"); const autoplaySettings = document.querySelector("#autoplay-settings"); const siteSettings = document.querySelector("#site-settings"); const siteSettingsAdd = document.querySelector("#site-settings-add"); @@ -445,6 +497,7 @@ ''; window.settings = {}; + window.nativeSearchEngineCount = 0; const loadSettings = settings => { window.settings = settings; @@ -472,9 +525,14 @@ renderEngine(type); }; + loadCustomSearchEngines(); renderEngineSettings(Engine.search, window.settings.searchEngine); renderEngineSettings(Engine.autocomplete, window.settings.autocompleteEngine); + if (searchDialog.open) { + showSearchEngineSettings(); + } + const siteSetting = currentSiteSetting(); if (siteSetting === "autoplay") { @@ -484,11 +542,15 @@ doNotTrackToggle.checked = window.settings.doNotTrack; }; + const containsValidURL = input => { + return input.value.length !== 0 && input.checkValidity(); + }; + newTabPageURL.addEventListener("change", () => { newTabPageURL.classList.remove("success"); newTabPageURL.classList.remove("error"); - if (!newTabPageURL.checkValidity()) { + if (!containsValidURL(newTabPageURL)) { newTabPageURL.classList.add("error"); return; } @@ -657,15 +719,20 @@ engine.add(option); } + + if (type === Engine.search) { + window.nativeSearchEngineCount = engine.length; + engine.appendChild(document.createElement("hr")); + } }; const renderEngine = type => { const [name, toggle, engine] = engineForType(type); if (toggle.checked) { - engine.parentElement.classList.remove("hidden"); + engine.closest(".card-group").classList.remove("hidden"); } else { - engine.parentElement.classList.add("hidden"); + engine.closest(".card-group").classList.add("hidden"); } if (toggle.checked && engine.selectedIndex !== 0) { @@ -702,6 +769,124 @@ setSaveEngineListeners(Engine.search); setSaveEngineListeners(Engine.autocomplete); + const loadCustomSearchEngines = () => { + while (searchEngine.length > window.nativeSearchEngineCount) { + searchEngine.remove(window.nativeSearchEngineCount); + } + + const custom = window.settings.searchEngine?.custom || []; + + custom.forEach(custom => { + const option = document.createElement("option"); + option.text = custom.name; + option.value = custom.name; + + searchEngine.add(option); + }); + }; + + const showSearchEngineSettings = () => { + searchCustomName.classList.remove("error"); + searchCustomURL.classList.remove("error"); + searchList.innerHTML = ""; + + const custom = window.settings.searchEngine?.custom || []; + + if (custom.length === 0) { + const placeholder = document.createElement("div"); + placeholder.className = "dialog-list-item-placeholder"; + placeholder.textContent = "No custom search engines added"; + + searchList.appendChild(placeholder); + } + + custom.forEach(custom => { + const name = document.createElement("span"); + name.textContent = custom.name; + + const url = document.createElement("span"); + url.className = "dialog-list-item-placeholder"; + url.style = "padding-left: 0"; + url.textContent = ` — ${custom.url}`; + + const engine = document.createElement("span"); + engine.className = "dialog-list-item-label"; + engine.appendChild(name); + engine.appendChild(url); + + const remove = document.createElement("button"); + remove.className = "dialog-button"; + remove.innerHTML = "×"; + remove.title = `Remove ${custom.name}`; + + remove.addEventListener("click", () => { + ladybird.sendMessage("removeCustomSearchEngine", custom); + }); + + const item = document.createElement("div"); + item.className = "dialog-list-item"; + item.appendChild(engine); + item.appendChild(remove); + + searchList.appendChild(item); + }); + + if (!searchDialog.open) { + setTimeout(() => searchCustomName.focus()); + searchDialog.showModal(); + } + }; + + const addCustomSearchEngine = () => { + searchCustomName.classList.remove("error"); + searchCustomURL.classList.remove("error"); + + for (const i = 0; i < searchEngine.length; ++i) { + if (searchCustomName.value === searchEngine.item(i).value) { + searchCustomName.classList.add("error"); + return; + } + } + + if (!containsValidURL(searchCustomURL)) { + searchCustomURL.classList.add("error"); + return; + } + + ladybird.sendMessage("addCustomSearchEngine", { + name: searchCustomName.value, + url: searchCustomURL.value, + }); + + searchCustomName.value = ""; + searchCustomURL.value = ""; + + setTimeout(() => searchCustomName.focus()); + }; + + searchCustomAdd.addEventListener("click", addCustomSearchEngine); + + searchCustomName.addEventListener("keydown", event => { + if (event.key === "Enter") { + addCustomSearchEngine(); + } + }); + + searchCustomURL.addEventListener("keydown", event => { + if (event.key === "Enter") { + addCustomSearchEngine(); + } + }); + + searchClose.addEventListener("click", () => { + searchDialog.close(); + }); + + searchSettings.addEventListener("click", event => { + showSearchEngineSettings(); + event.stopPropagation(); + }); + const forciblyEnableSiteSettings = settings => { settings.forEach(setting => { const label = document.querySelector(`#${setting}-forcibly-enabled`); @@ -837,8 +1022,7 @@ } }; - close(languagesDialog); - close(siteSettings); + document.querySelectorAll("dialog").forEach(close); }); document.addEventListener("WebUILoaded", () => { diff --git a/Libraries/LibWebView/SearchEngine.cpp b/Libraries/LibWebView/SearchEngine.cpp index 1c42bddb092..7f5d0d053d5 100644 --- a/Libraries/LibWebView/SearchEngine.cpp +++ b/Libraries/LibWebView/SearchEngine.cpp @@ -4,13 +4,12 @@ * SPDX-License-Identifier: BSD-2-Clause */ -#include #include #include namespace WebView { -static auto builtin_search_engines = to_array({ +static auto s_builtin_search_engines = to_array({ { "Bing"_string, "https://www.bing.com/search?q=%s"_string }, { "Brave"_string, "https://search.brave.com/search?q=%s"_string }, { "DuckDuckGo"_string, "https://duckduckgo.com/?q=%s"_string }, @@ -23,16 +22,9 @@ static auto builtin_search_engines = to_array({ { "Yandex"_string, "https://yandex.com/search/?text=%s"_string }, }); -ReadonlySpan search_engines() +ReadonlySpan builtin_search_engines() { - return builtin_search_engines; -} - -Optional find_search_engine_by_name(StringView name) -{ - return find_value(builtin_search_engines, [&](auto const& engine) { - return engine.name == name; - }); + return s_builtin_search_engines; } String SearchEngine::format_search_query_for_display(StringView query) const diff --git a/Libraries/LibWebView/SearchEngine.h b/Libraries/LibWebView/SearchEngine.h index e29ec04f34f..d526e796f00 100644 --- a/Libraries/LibWebView/SearchEngine.h +++ b/Libraries/LibWebView/SearchEngine.h @@ -20,7 +20,6 @@ struct SearchEngine { String query_url; }; -ReadonlySpan search_engines(); -Optional find_search_engine_by_name(StringView name); +ReadonlySpan builtin_search_engines(); } diff --git a/Libraries/LibWebView/Settings.cpp b/Libraries/LibWebView/Settings.cpp index cc2fe8f03d6..aab10c43b68 100644 --- a/Libraries/LibWebView/Settings.cpp +++ b/Libraries/LibWebView/Settings.cpp @@ -5,6 +5,7 @@ */ #include +#include #include #include #include @@ -26,7 +27,9 @@ static constexpr auto languages_key = "languages"sv; static auto default_language = "en"_string; static constexpr auto search_engine_key = "searchEngine"sv; +static constexpr auto search_engine_custom_key = "custom"sv; static constexpr auto search_engine_name_key = "name"sv; +static constexpr auto search_engine_url_key = "url"sv; static constexpr auto autocomplete_engine_key = "autocompleteEngine"sv; static constexpr auto autocomplete_engine_name_key = "name"sv; @@ -89,8 +92,18 @@ Settings Settings::create(Badge) settings.m_languages = parse_json_languages(*languages); if (auto search_engine = settings_json.value().get_object(search_engine_key); search_engine.has_value()) { + if (auto custom_engines = search_engine->get_array(search_engine_custom_key); custom_engines.has_value()) { + custom_engines->for_each([&](JsonValue const& engine) { + auto custom_engine = parse_custom_search_engine(engine); + if (!custom_engine.has_value() || settings.find_search_engine_by_name(custom_engine->name).has_value()) + return; + + settings.m_custom_search_engines.append(custom_engine.release_value()); + }); + } + if (auto search_engine_name = search_engine->get_string(search_engine_name_key); search_engine_name.has_value()) - settings.m_search_engine = find_search_engine_by_name(*search_engine_name); + settings.m_search_engine = settings.find_search_engine_by_name(*search_engine_name); } if (auto autocomplete_engine = settings_json.value().get_object(autocomplete_engine_key); autocomplete_engine.has_value()) { @@ -144,12 +157,25 @@ JsonValue Settings::serialize_json() const settings.set(languages_key, move(languages)); - if (m_search_engine.has_value()) { + JsonArray custom_search_engines; + custom_search_engines.ensure_capacity(m_custom_search_engines.size()); + + for (auto const& engine : m_custom_search_engines) { JsonObject search_engine; + search_engine.set(search_engine_name_key, engine.name); + search_engine.set(search_engine_url_key, engine.query_url); + + custom_search_engines.must_append(move(search_engine)); + } + + JsonObject search_engine; + if (!custom_search_engines.is_empty()) + search_engine.set(search_engine_custom_key, move(custom_search_engines)); + if (m_search_engine.has_value()) search_engine.set(search_engine_name_key, m_search_engine->name); + if (!search_engine.is_empty()) settings.set(search_engine_key, move(search_engine)); - } if (m_autocomplete_engine.has_value()) { JsonObject autocomplete_engine; @@ -184,6 +210,7 @@ void Settings::restore_defaults() m_new_tab_page_url = URL::about_newtab(); m_languages = { default_language }; m_search_engine.clear(); + m_custom_search_engines.clear(); m_autocomplete_engine.clear(); m_autoplay = SiteSetting {}; m_do_not_track = DoNotTrack::No; @@ -250,6 +277,62 @@ void Settings::set_search_engine(Optional search_engine_name) observer.search_engine_changed(); } +Optional Settings::parse_custom_search_engine(JsonValue const& search_engine) +{ + if (!search_engine.is_object()) + return {}; + + auto name = search_engine.as_object().get_string(search_engine_name_key); + auto url = search_engine.as_object().get_string(search_engine_url_key); + if (!name.has_value() || !url.has_value()) + return {}; + + auto parsed_url = URL::Parser::basic_parse(*url); + if (!parsed_url.has_value()) + return {}; + + return SearchEngine { .name = name.release_value(), .query_url = url.release_value() }; +} + +void Settings::add_custom_search_engine(SearchEngine search_engine) +{ + if (find_search_engine_by_name(search_engine.name).has_value()) + return; + + m_custom_search_engines.append(move(search_engine)); + persist_settings(); +} + +void Settings::remove_custom_search_engine(SearchEngine const& search_engine) +{ + auto reset_default_search_engine = m_search_engine.has_value() && m_search_engine->name == search_engine.name; + if (reset_default_search_engine) + m_search_engine.clear(); + + m_custom_search_engines.remove_all_matching([&](auto const& engine) { + return engine.name == search_engine.name; + }); + + persist_settings(); + + if (reset_default_search_engine) { + for (auto& observer : m_observers) + observer.search_engine_changed(); + } +} + +Optional Settings::find_search_engine_by_name(StringView name) +{ + auto comparator = [&](auto const& engine) { return engine.name == name; }; + + if (auto result = find_value(builtin_search_engines(), comparator); result.has_value()) + return result.copy(); + if (auto result = find_value(m_custom_search_engines, comparator); result.has_value()) + return result.copy(); + + return {}; +} + void Settings::set_autocomplete_engine(Optional autocomplete_engine_name) { if (autocomplete_engine_name.has_value()) diff --git a/Libraries/LibWebView/Settings.h b/Libraries/LibWebView/Settings.h index f5ceef03913..5a4138f124e 100644 --- a/Libraries/LibWebView/Settings.h +++ b/Libraries/LibWebView/Settings.h @@ -60,6 +60,10 @@ public: Optional const& search_engine() const { return m_search_engine; } void set_search_engine(Optional search_engine_name); + static Optional parse_custom_search_engine(JsonValue const&); + void add_custom_search_engine(SearchEngine); + void remove_custom_search_engine(SearchEngine const&); + Optional const& autocomplete_engine() const { return m_autocomplete_engine; } void set_autocomplete_engine(Optional autocomplete_engine_name); @@ -80,11 +84,14 @@ private: void persist_settings(); + Optional find_search_engine_by_name(StringView name); + ByteString m_settings_path; URL::URL m_new_tab_page_url; Vector m_languages; Optional m_search_engine; + Vector m_custom_search_engines; Optional m_autocomplete_engine; SiteSetting m_autoplay; DoNotTrack m_do_not_track { DoNotTrack::No }; diff --git a/Libraries/LibWebView/WebUI/SettingsUI.cpp b/Libraries/LibWebView/WebUI/SettingsUI.cpp index d496c6039f9..ec2269a4833 100644 --- a/Libraries/LibWebView/WebUI/SettingsUI.cpp +++ b/Libraries/LibWebView/WebUI/SettingsUI.cpp @@ -34,6 +34,12 @@ void SettingsUI::register_interfaces() register_interface("setSearchEngine"sv, [this](auto const& data) { set_search_engine(data); }); + register_interface("addCustomSearchEngine"sv, [this](auto const& data) { + add_custom_search_engine(data); + }); + register_interface("removeCustomSearchEngine"sv, [this](auto const& data) { + remove_custom_search_engine(data); + }); register_interface("setAutocompleteEngine"sv, [this](auto const& data) { set_autocomplete_engine(data); }); @@ -94,7 +100,7 @@ void SettingsUI::set_languages(JsonValue const& languages) void SettingsUI::load_available_engines() { JsonArray search_engines; - for (auto const& engine : WebView::search_engines()) + for (auto const& engine : WebView::builtin_search_engines()) search_engines.must_append(engine.name); JsonArray autocomplete_engines; @@ -116,6 +122,22 @@ void SettingsUI::set_search_engine(JsonValue const& search_engine) WebView::Application::settings().set_search_engine(search_engine.as_string()); } +void SettingsUI::add_custom_search_engine(JsonValue const& search_engine) +{ + if (auto custom_engine = Settings::parse_custom_search_engine(search_engine); custom_engine.has_value()) + WebView::Application::settings().add_custom_search_engine(custom_engine.release_value()); + + load_current_settings(); +} + +void SettingsUI::remove_custom_search_engine(JsonValue const& search_engine) +{ + if (auto custom_engine = Settings::parse_custom_search_engine(search_engine); custom_engine.has_value()) + WebView::Application::settings().remove_custom_search_engine(*custom_engine); + + load_current_settings(); +} + void SettingsUI::set_autocomplete_engine(JsonValue const& autocomplete_engine) { if (autocomplete_engine.is_null()) diff --git a/Libraries/LibWebView/WebUI/SettingsUI.h b/Libraries/LibWebView/WebUI/SettingsUI.h index 8e6651684bf..b7202ea2b8a 100644 --- a/Libraries/LibWebView/WebUI/SettingsUI.h +++ b/Libraries/LibWebView/WebUI/SettingsUI.h @@ -24,6 +24,8 @@ private: void load_available_engines(); void set_search_engine(JsonValue const&); + void add_custom_search_engine(JsonValue const&); + void remove_custom_search_engine(JsonValue const&); void set_autocomplete_engine(JsonValue const&); void load_forcibly_enabled_site_settings();