LibWebView: Add autocomplete settings to about:settings

This implements an autocomplete engine inside LibWebView, to replace the
engine currently used by Qt. Whereas Qt uses the Qt Network framework to
perform autocomplete requests, LibWebView uses RequestServer. This moves
downloading this untrusted data out of the browser process.

This patch only implements the persisted settings and their UI. It does
not integrate this engine into the browser UI.
This commit is contained in:
Timothy Flynn 2025-03-30 16:21:24 -04:00
parent 0cb506277f
commit 127a3d6f79
9 changed files with 432 additions and 66 deletions

View file

@ -52,6 +52,12 @@
float: left;
}
hr {
height: 1px;
background-color: var(--border-color);
border: none;
}
.card {
background-color: var(--card-background-color);
@ -72,6 +78,10 @@
padding: 20px;
}
.card-body > hr {
margin: 0 8px 16px 8px;
}
.card-group {
margin-bottom: 20px;
}
@ -165,10 +175,6 @@
}
dialog .dialog-body hr {
height: 1px;
background-color: var(--border-color);
border: none;
margin: 8px 4px 10px 4px;
}
@ -283,17 +289,36 @@
<div class="card-body">
<div class="card-group">
<div class="toggle-container">
<label for="search-enabled">Enable Search</label>
<label for="search-toggle">Enable Search</label>
<label class="toggle">
<input id="search-enabled" type="checkbox" />
<input id="search-toggle" type="checkbox" />
<span class="toggle-button"></span>
</label>
</div>
</div>
<div id="search-engine-list" class="card-group hidden">
<div class="card-group hidden">
<label for="search-engine">Default Search Engine</label>
<select id="search-engine">
<option value="">Please Select a Search Engine</option>
<option value="">Please select a search engine</option>
<hr />
</select>
</div>
<hr />
<div class="card-group">
<div class="toggle-container">
<label for="autocomplete-toggle">Enable Autocomplete</label>
<label class="toggle">
<input id="autocomplete-toggle" type="checkbox" />
<span class="toggle-button"></span>
</label>
</div>
</div>
<div class="card-group hidden">
<label for="autocomplete-engine">Default Autocomplete Engine</label>
<select id="autocomplete-engine">
<option value="">Please select an autocomplete engine</option>
<hr />
</select>
</div>
@ -347,9 +372,10 @@
<script>
const newTabPageURL = document.querySelector("#new-tab-page-url");
const searchEngineList = document.querySelector("#search-engine-list");
const searchEnabled = document.querySelector("#search-enabled");
const searchToggle = document.querySelector("#search-toggle");
const searchEngine = document.querySelector("#search-engine");
const autocompleteToggle = document.querySelector("#autocomplete-toggle");
const autocompleteEngine = document.querySelector("#autocomplete-engine");
const autoplaySettings = document.querySelector("#autoplay-settings");
const siteSettings = document.querySelector("#site-settings");
const siteSettingsAdd = document.querySelector("#site-settings-add");
@ -372,16 +398,21 @@
newTabPageURL.classList.remove("error");
newTabPageURL.value = window.settings.newTabPageURL;
const searchEngineName = window.settings.searchEngine?.name;
const renderEngineSettings = (type, setting) => {
const [name, toggle, engine] = engineForType(type);
if (searchEngineName) {
searchEnabled.checked = true;
searchEngine.value = searchEngineName;
} else {
searchEnabled.checked = false;
}
if (setting?.name) {
toggle.checked = true;
engine.value = setting?.name;
} else {
toggle.checked = false;
}
renderSearchEngine();
renderEngine(type);
};
renderEngineSettings(Engine.search, window.settings.searchEngine);
renderEngineSettings(Engine.autocomplete, window.settings.autocompleteEngine);
const siteSetting = currentSiteSetting();
@ -390,41 +421,6 @@
}
};
const loadSearchEngines = engines => {
for (const engine of engines) {
const option = document.createElement("option");
option.text = engine;
option.value = engine;
searchEngine.add(option);
}
};
const renderSearchEngine = () => {
if (searchEnabled.checked) {
searchEngineList.classList.remove("hidden");
} else {
searchEngineList.classList.add("hidden");
}
if (searchEnabled.checked && searchEngine.selectedIndex !== 0) {
searchEngine.item(0).disabled = true;
} else if (!searchEnabled.checked) {
searchEngine.item(0).disabled = false;
searchEngine.selectedIndex = 0;
}
};
const saveSearchEngine = () => {
if (searchEnabled.checked && searchEngine.selectedIndex !== 0) {
ladybird.sendMessage("setSearchEngine", searchEngine.value);
} else if (!searchEnabled.checked) {
ladybird.sendMessage("setSearchEngine", null);
}
renderSearchEngine();
};
newTabPageURL.addEventListener("change", () => {
newTabPageURL.classList.remove("success");
newTabPageURL.classList.remove("error");
@ -442,8 +438,75 @@
}, 1000);
});
searchEnabled.addEventListener("change", saveSearchEngine);
searchEngine.addEventListener("change", saveSearchEngine);
const Engine = Object.freeze({
search: 1,
autocomplete: 2,
});
const engineForType = engine => {
if (engine === Engine.search) {
return ["Search", searchToggle, searchEngine];
}
if (engine === Engine.autocomplete) {
return ["Autocomplete", autocompleteToggle, autocompleteEngine];
}
throw Error(`Unrecognized engine type ${engine}`);
};
const loadEngines = (type, engines) => {
const [name, toggle, engine] = engineForType(type);
for (const engineName of engines) {
const option = document.createElement("option");
option.text = engineName;
option.value = engineName;
engine.add(option);
}
};
const renderEngine = type => {
const [name, toggle, engine] = engineForType(type);
if (toggle.checked) {
engine.parentElement.classList.remove("hidden");
} else {
engine.parentElement.classList.add("hidden");
}
if (toggle.checked && engine.selectedIndex !== 0) {
engine.item(0).disabled = true;
} else if (!toggle.checked) {
engine.item(0).disabled = false;
engine.selectedIndex = 0;
}
};
const saveEngine = type => {
const [name, toggle, engine] = engineForType(type);
if (toggle.checked && engine.selectedIndex !== 0) {
ladybird.sendMessage(`set${name}Engine`, engine.value);
} else if (!toggle.checked) {
ladybird.sendMessage(`set${name}Engine`, null);
}
renderEngine(type);
};
const setSaveEngineListeners = type => {
const [name, toggle, engine] = engineForType(type);
toggle.addEventListener("change", () => {
saveEngine(type);
});
engine.addEventListener("change", () => {
saveEngine(type);
});
};
setSaveEngineListeners(Engine.search);
setSaveEngineListeners(Engine.autocomplete);
const forciblyEnableSiteSettings = settings => {
settings.forEach(setting => {
@ -576,7 +639,7 @@
});
document.addEventListener("WebUILoaded", () => {
ladybird.sendMessage("loadAvailableSearchEngines");
ladybird.sendMessage("loadAvailableEngines");
ladybird.sendMessage("loadCurrentSettings");
ladybird.sendMessage("loadForciblyEnabledSiteSettings");
});
@ -584,8 +647,9 @@
document.addEventListener("WebUIMessage", event => {
if (event.detail.name === "loadSettings") {
loadSettings(event.detail.data);
} else if (event.detail.name === "loadSearchEngines") {
loadSearchEngines(event.detail.data);
} else if (event.detail.name === "loadEngines") {
loadEngines(Engine.search, event.detail.data.search);
loadEngines(Engine.autocomplete, event.detail.data.autocomplete);
} else if (event.detail.name === "forciblyEnableSiteSettings") {
forciblyEnableSiteSettings(event.detail.data);
}

View file

@ -0,0 +1,194 @@
/*
* Copyright (c) 2023, Cameron Youell <cameronyouell@gmail.com>
* Copyright (c) 2025, Tim Flynn <trflynn89@ladybird.org>
*
* SPDX-License-Identifier: BSD-2-Clause
*/
#include <AK/Find.h>
#include <LibCore/EventLoop.h>
#include <LibRequests/Request.h>
#include <LibRequests/RequestClient.h>
#include <LibURL/Parser.h>
#include <LibURL/URL.h>
#include <LibWebView/Application.h>
#include <LibWebView/Autocomplete.h>
namespace WebView {
static constexpr auto builtin_autocomplete_engines = to_array<AutocompleteEngine>({
{ "DuckDuckGo"sv, "https://duckduckgo.com/ac/?q={}"sv },
{ "Google"sv, "https://www.google.com/complete/search?client=chrome&q={}"sv },
{ "Yahoo"sv, "https://search.yahoo.com/sugg/gossip/gossip-us-ura/?output=sd1&command={}"sv },
});
ReadonlySpan<AutocompleteEngine> autocomplete_engines()
{
return builtin_autocomplete_engines;
}
Optional<AutocompleteEngine const&> find_autocomplete_engine_by_name(StringView name)
{
auto it = AK::find_if(builtin_autocomplete_engines.begin(), builtin_autocomplete_engines.end(),
[&](auto const& engine) {
return engine.name == name;
});
if (it == builtin_autocomplete_engines.end())
return {};
return *it;
}
Autocomplete::Autocomplete() = default;
Autocomplete::~Autocomplete() = default;
void Autocomplete::query_autocomplete_engine(String query)
{
if (m_request) {
m_request->stop();
m_request.clear();
}
if (query.bytes_as_string_view().trim_whitespace().is_empty()) {
invoke_autocomplete_query_complete({});
return;
}
auto engine = Application::settings().autocomplete_engine();
if (!engine.has_value()) {
invoke_autocomplete_query_complete({});
return;
}
auto url_string = MUST(String::formatted(engine->query_url, URL::percent_encode(query)));
auto url = URL::Parser::basic_parse(url_string);
if (!url.has_value()) {
invoke_autocomplete_query_complete({});
return;
}
m_request = Application::request_server_client().start_request("GET"sv, *url);
m_query = move(query);
m_request->set_buffered_request_finished_callback(
[this, engine = engine.release_value()](u64, Requests::RequestTimingInfo const&, Optional<Requests::NetworkError> const& network_error, HTTP::HeaderMap const&, Optional<u32> response_code, Optional<String> const& reason_phrase, ReadonlyBytes payload) {
Core::deferred_invoke([this]() { m_request.clear(); });
if (network_error.has_value()) {
warnln("Unable to fetch autocomplete suggestions: {}", Requests::network_error_to_string(*network_error));
invoke_autocomplete_query_complete({});
return;
}
if (response_code.has_value() && *response_code >= 400) {
warnln("Received error response code {} from autocomplete engine: {}", *response_code, reason_phrase);
invoke_autocomplete_query_complete({});
return;
}
if (auto result = received_autocomplete_respsonse(engine, payload); result.is_error()) {
warnln("Unable to handle autocomplete response: {}", result.error());
invoke_autocomplete_query_complete({});
} else {
invoke_autocomplete_query_complete(result.release_value());
}
});
}
static ErrorOr<Vector<String>> parse_duckduckgo_autocomplete(JsonValue const& json)
{
if (!json.is_array())
return Error::from_string_literal("Expected DuckDuckGo autocomplete response to be a JSON array");
Vector<String> results;
results.ensure_capacity(json.as_array().size());
TRY(json.as_array().try_for_each([&](JsonValue const& suggestion) -> ErrorOr<void> {
if (!suggestion.is_object())
return Error::from_string_literal("Invalid DuckDuckGo autocomplete response, expected value to be an object");
if (auto value = suggestion.as_object().get_string("phrase"sv); value.has_value())
results.unchecked_append(*value);
return {};
}));
return results;
}
static ErrorOr<Vector<String>> parse_google_autocomplete(JsonValue const& json)
{
if (!json.is_array())
return Error::from_string_literal("Expected Google autocomplete response to be a JSON array");
auto const& values = json.as_array();
if (values.size() != 5)
return Error::from_string_literal("Invalid Google autocomplete response, expected 5 elements in array");
if (!values[1].is_array())
return Error::from_string_literal("Invalid Google autocomplete response, expected second element to be an array");
auto const& suggestions = values[1].as_array();
Vector<String> results;
results.ensure_capacity(suggestions.size());
TRY(suggestions.try_for_each([&](JsonValue const& suggestion) -> ErrorOr<void> {
if (!suggestion.is_string())
return Error::from_string_literal("Invalid Google autocomplete response, expected value to be a string");
results.unchecked_append(suggestion.as_string());
return {};
}));
return results;
}
static ErrorOr<Vector<String>> parse_yahoo_autocomplete(JsonValue const& json)
{
if (!json.is_object())
return Error::from_string_literal("Expected Yahoo autocomplete response to be a JSON array");
auto suggestions = json.as_object().get_array("r"sv);
if (!suggestions.has_value())
return Error::from_string_literal("Invalid Yahoo autocomplete response, expected \"r\" to be an object");
Vector<String> results;
results.ensure_capacity(suggestions->size());
TRY(suggestions->try_for_each([&](JsonValue const& suggestion) -> ErrorOr<void> {
if (!suggestion.is_object())
return Error::from_string_literal("Invalid Yahoo autocomplete response, expected value to be an object");
auto result = suggestion.as_object().get_string("k"sv);
if (!result.has_value())
return Error::from_string_literal("Invalid Yahoo autocomplete response, expected \"k\" to be a string");
results.unchecked_append(*result);
return {};
}));
return results;
}
ErrorOr<Vector<String>> Autocomplete::received_autocomplete_respsonse(AutocompleteEngine const& engine, StringView response)
{
auto json = TRY(JsonValue::from_string(response));
if (engine.name == "DuckDuckGo")
return parse_duckduckgo_autocomplete(json);
if (engine.name == "Google")
return parse_google_autocomplete(json);
if (engine.name == "Yahoo")
return parse_yahoo_autocomplete(json);
return Error::from_string_literal("Invalid engine name");
}
void Autocomplete::invoke_autocomplete_query_complete(Vector<String> suggestions) const
{
if (on_autocomplete_query_complete)
on_autocomplete_query_complete(move(suggestions));
}
}

View file

@ -0,0 +1,44 @@
/*
* Copyright (c) 2023, Cameron Youell <cameronyouell@gmail.com>
* Copyright (c) 2025, Tim Flynn <trflynn89@ladybird.org>
*
* SPDX-License-Identifier: BSD-2-Clause
*/
#pragma once
#include <AK/Error.h>
#include <AK/Function.h>
#include <AK/RefPtr.h>
#include <AK/String.h>
#include <AK/Vector.h>
#include <LibRequests/Forward.h>
namespace WebView {
struct AutocompleteEngine {
StringView name;
StringView query_url;
};
ReadonlySpan<AutocompleteEngine> autocomplete_engines();
Optional<AutocompleteEngine const&> find_autocomplete_engine_by_name(StringView name);
class Autocomplete {
public:
Autocomplete();
~Autocomplete();
Function<void(Vector<String>)> on_autocomplete_query_complete;
void query_autocomplete_engine(String);
private:
static ErrorOr<Vector<String>> received_autocomplete_respsonse(AutocompleteEngine const&, StringView response);
void invoke_autocomplete_query_complete(Vector<String> suggestions) const;
String m_query;
RefPtr<Requests::Request> m_request;
};
}

View file

@ -3,6 +3,7 @@ include(fontconfig)
set(SOURCES
Application.cpp
Attribute.cpp
Autocomplete.cpp
BrowserProcess.cpp
ConsoleOutput.cpp
CookieJar.cpp

View file

@ -11,6 +11,7 @@
namespace WebView {
class Application;
class Autocomplete;
class CookieJar;
class Database;
class OutOfProcessWebView;
@ -21,6 +22,7 @@ class WebContentClient;
class WebUI;
struct Attribute;
struct AutocompleteEngine;
struct ConsoleOutput;
struct CookieStorageKey;
struct DOMNodeProperties;

View file

@ -24,6 +24,9 @@ static constexpr auto new_tab_page_url_key = "newTabPageURL"sv;
static constexpr auto search_engine_key = "searchEngine"sv;
static constexpr auto search_engine_name_key = "name"sv;
static constexpr auto autocomplete_engine_key = "autocompleteEngine"sv;
static constexpr auto autocomplete_engine_name_key = "name"sv;
static constexpr auto site_setting_enabled_globally_key = "enabledGlobally"sv;
static constexpr auto site_setting_site_filters_key = "siteFilters"sv;
@ -81,6 +84,11 @@ Settings Settings::create(Badge<Application>)
settings.m_search_engine = find_search_engine_by_name(*search_engine_name);
}
if (auto autocomplete_engine = settings_json.value().get_object(autocomplete_engine_key); autocomplete_engine.has_value()) {
if (auto autocomplete_engine_name = autocomplete_engine->get_string(autocomplete_engine_name_key); autocomplete_engine_name.has_value())
settings.m_autocomplete_engine = find_autocomplete_engine_by_name(*autocomplete_engine_name);
}
auto load_site_setting = [&](SiteSetting& site_setting, StringView key) {
auto saved_settings = settings_json.value().get_object(key);
if (!saved_settings.has_value())
@ -122,6 +130,13 @@ JsonValue Settings::serialize_json() const
settings.set(search_engine_key, move(search_engine));
}
if (m_autocomplete_engine.has_value()) {
JsonObject autocomplete_engine;
autocomplete_engine.set(autocomplete_engine_name_key, m_autocomplete_engine->name);
settings.set(autocomplete_engine_key, move(autocomplete_engine));
}
auto save_site_setting = [&](SiteSetting const& site_setting, StringView key) {
JsonArray site_filters;
site_filters.ensure_capacity(site_setting.site_filters.size());
@ -145,6 +160,7 @@ void Settings::restore_defaults()
{
m_new_tab_page_url = URL::about_newtab();
m_search_engine.clear();
m_autocomplete_engine.clear();
m_autoplay = SiteSetting {};
persist_settings();
@ -175,6 +191,19 @@ void Settings::set_search_engine(Optional<StringView> search_engine_name)
observer.search_engine_changed();
}
void Settings::set_autocomplete_engine(Optional<StringView> autocomplete_engine_name)
{
if (autocomplete_engine_name.has_value())
m_autocomplete_engine = find_autocomplete_engine_by_name(*autocomplete_engine_name);
else
m_autocomplete_engine.clear();
persist_settings();
for (auto& observer : m_observers)
observer.autocomplete_engine_changed();
}
void Settings::set_autoplay_enabled_globally(bool enabled_globally)
{
m_autoplay.enabled_globally = enabled_globally;

View file

@ -11,6 +11,7 @@
#include <AK/JsonValue.h>
#include <AK/Optional.h>
#include <LibURL/URL.h>
#include <LibWebView/Autocomplete.h>
#include <LibWebView/Forward.h>
#include <LibWebView/SearchEngine.h>
@ -30,6 +31,7 @@ public:
virtual void new_tab_page_url_changed() { }
virtual void search_engine_changed() { }
virtual void autocomplete_engine_changed() { }
virtual void autoplay_settings_changed() { }
};
@ -47,6 +49,9 @@ public:
Optional<SearchEngine> const& search_engine() const { return m_search_engine; }
void set_search_engine(Optional<StringView> search_engine_name);
Optional<AutocompleteEngine> const& autocomplete_engine() const { return m_autocomplete_engine; }
void set_autocomplete_engine(Optional<StringView> autocomplete_engine_name);
SiteSetting const& autoplay_settings() const { return m_autoplay; }
void set_autoplay_enabled_globally(bool);
void add_autoplay_site_filter(String const&);
@ -65,6 +70,7 @@ private:
URL::URL m_new_tab_page_url;
Optional<SearchEngine> m_search_engine;
Optional<AutocompleteEngine> m_autocomplete_engine;
SiteSetting m_autoplay;
Vector<SettingsObserver&> m_observers;

View file

@ -20,15 +20,21 @@ void SettingsUI::register_interfaces()
register_interface("restoreDefaultSettings"sv, [this](auto const&) {
restore_default_settings();
});
register_interface("setNewTabPageURL"sv, [this](auto const& data) {
set_new_tab_page_url(data);
});
register_interface("loadAvailableSearchEngines"sv, [this](auto const&) {
load_available_search_engines();
register_interface("loadAvailableEngines"sv, [this](auto const&) {
load_available_engines();
});
register_interface("setSearchEngine"sv, [this](auto const& data) {
set_search_engine(data);
});
register_interface("setAutocompleteEngine"sv, [this](auto const& data) {
set_autocomplete_engine(data);
});
register_interface("loadForciblyEnabledSiteSettings"sv, [this](auto const&) {
load_forcibly_enabled_site_settings();
});
@ -70,13 +76,21 @@ void SettingsUI::set_new_tab_page_url(JsonValue const& new_tab_page_url)
WebView::Application::settings().set_new_tab_page_url(parsed_new_tab_page_url.release_value());
}
void SettingsUI::load_available_search_engines()
void SettingsUI::load_available_engines()
{
JsonArray engines;
for (auto const& engine : search_engines())
engines.must_append(engine.name);
JsonArray search_engines;
for (auto const& engine : WebView::search_engines())
search_engines.must_append(engine.name);
async_send_message("loadSearchEngines"sv, move(engines));
JsonArray autocomplete_engines;
for (auto const& engine : WebView::autocomplete_engines())
autocomplete_engines.must_append(engine.name);
JsonObject engines;
engines.set("search"sv, move(search_engines));
engines.set("autocomplete"sv, move(autocomplete_engines));
async_send_message("loadEngines"sv, move(engines));
}
void SettingsUI::set_search_engine(JsonValue const& search_engine)
@ -87,6 +101,14 @@ void SettingsUI::set_search_engine(JsonValue const& search_engine)
WebView::Application::settings().set_search_engine(search_engine.as_string());
}
void SettingsUI::set_autocomplete_engine(JsonValue const& autocomplete_engine)
{
if (autocomplete_engine.is_null())
WebView::Application::settings().set_autocomplete_engine({});
else if (autocomplete_engine.is_string())
WebView::Application::settings().set_autocomplete_engine(autocomplete_engine.as_string());
}
enum class SiteSettingType {
Autoplay,
};

View file

@ -18,9 +18,13 @@ private:
void load_current_settings();
void restore_default_settings();
void set_new_tab_page_url(JsonValue const&);
void load_available_search_engines();
void load_available_engines();
void set_search_engine(JsonValue const&);
void set_autocomplete_engine(JsonValue const&);
void load_forcibly_enabled_site_settings();
void set_site_setting_enabled_globally(JsonValue const&);
void add_site_setting_filter(JsonValue const&);