LibWeb+LibWebView+WebContent: Introduce a basic about:settings page

This adds a basic settings page to manage persistent Ladybird settings.
As a first pass, this exposes settings for the new tab page URL and the
default search engine.

The way the search engine option works is that once search is enabled,
the user must choose their default search engine; we do not apply any
default automatically. Search remains disabled until this is done.

There are a couple of improvements that we should make here:

* Settings changes are not broadcasted to all open about:settings pages.
  So if two instances are open, and the user changes the search engine
  in one instance, the other instance will have a stale UI.

* Adding an IPC per setting is going to get annoying. It would be nice
  if we can come up with a smaller set of IPCs to send only the relevant
  changed settings.
This commit is contained in:
Timothy Flynn 2025-03-21 09:18:02 -04:00 committed by Alexander Kalenik
parent e084a86861
commit b169a98495
Notes: github-actions[bot] 2025-03-22 16:28:49 +00:00
16 changed files with 475 additions and 2 deletions

View file

@ -0,0 +1,254 @@
<!doctype html>
<html>
<head>
<title>Settings</title>
<link rel="stylesheet" type="text/css" href="resource://ladybird/ladybird.css" />
<style>
@media (prefers-color-scheme: light) {
:root {
--card-background-color: #f9f9f9;
--card-header-background-color: #f0f2f5;
--input-background-color: white;
--border-color: #dcdde1;
}
}
@media (prefers-color-scheme: dark) {
:root {
--card-background-color: #2c2c2c;
--card-header-background-color: #252525;
--border-color: #3d3d3d;
}
}
* {
box-sizing: border-box;
}
body {
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
header {
display: flex;
align-items: center;
margin-bottom: 30px;
}
header h1 {
font-size: 20px;
}
header img {
height: 48px;
margin-right: 10px;
float: left;
}
.card {
background-color: var(--card-background-color);
border-radius: 8px;
margin-bottom: 20px;
overflow: hidden;
}
.card-header {
background-color: var(--card-header-background-color);
border-bottom: 1px solid var(--border-color);
padding: 15px 20px;
}
.card-body {
padding: 20px;
}
.card-group {
margin-bottom: 20px;
}
.card-group:last-child {
margin-bottom: 0;
}
label {
display: block;
margin-bottom: 8px;
font-size: 14px;
}
input[type="url"],
select {
background-color: var(--input-background-color);
width: 100%;
border: 1px solid var(--border-color);
}
input[type="url"].success {
border: 1px solid green;
}
input[type="url"].error {
border: 1px solid red;
}
.button-container {
display: flex;
justify-content: flex-end;
margin-top: 20px;
gap: 10px;
}
</style>
</head>
<body>
<header>
<picture>
<source
srcset="resource://icons/128x128/app-browser.png"
media="(prefers-color-scheme: dark)"
/>
<img src="resource://icons/128x128/app-browser-dark.png" />
</picture>
<h1>Ladybird Settings</h1>
</header>
<div class="card">
<div class="card-header">General</div>
<div class="card-body">
<div class="card-group">
<label for="new-tab-page-url">New Tab Page URL</label>
<input id="new-tab-page-url" type="url" placeholder="about:newtab" />
</div>
</div>
</div>
<div class="card">
<div class="card-header">Search</div>
<div class="card-body">
<div class="card-group">
<div class="toggle-container">
<label for="search-enabled">Enable Search</label>
<label class="toggle">
<input id="search-enabled" type="checkbox" />
<span class="toggle-button"></span>
</label>
</div>
</div>
<div id="search-engine-list" class="card-group" style="display: none">
<label for="search-engine">Default Search Engine</label>
<select id="search-engine">
<option value="">Please Select a Search Engine</option>
<hr />
</select>
</div>
</div>
</div>
<div class="button-container">
<button id="restore-defaults" class="primary-button">Restore Defaults</button>
</div>
<script>
const newTabPageURL = document.querySelector("#new-tab-page-url");
const searchEngineList = document.querySelector("#search-engine-list");
const searchEnabled = document.querySelector("#search-enabled");
const searchEngine = document.querySelector("#search-engine");
const restoreDefaults = document.querySelector("#restore-defaults");
settings.settings = {};
const renderSettings = () => {
newTabPageURL.classList.remove("error");
newTabPageURL.value = settings.settings.newTabPageURL;
const searchEngineName = settings.settings.searchEngine?.name;
if (searchEngineName) {
searchEnabled.checked = true;
searchEngine.value = searchEngineName;
} else {
searchEnabled.checked = false;
}
renderSearchEngine();
};
const renderSearchEngine = () => {
searchEngineList.style.display = searchEnabled.checked ? "block" : "none";
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) {
settings.setSearchEngine(searchEngine.value);
} else if (!searchEnabled.checked) {
settings.setSearchEngine(null);
}
renderSearchEngine();
};
newTabPageURL.addEventListener("change", () => {
newTabPageURL.classList.remove("success");
newTabPageURL.classList.remove("error");
if (!newTabPageURL.checkValidity()) {
newTabPageURL.classList.add("error");
return;
}
settings.setNewTabPageURL(newTabPageURL.value);
newTabPageURL.classList.add("success");
setTimeout(() => {
newTabPageURL.classList.remove("success");
}, 1000);
});
searchEnabled.addEventListener("change", saveSearchEngine);
searchEngine.addEventListener("change", saveSearchEngine);
restoreDefaults.addEventListener("click", () => {
settings.restoreDefaultSettings();
});
settings.loadSettings = settings => {
window.settings.settings = JSON.parse(settings);
renderSettings();
};
settings.loadSearchEngines = engines => {
for (const engine of JSON.parse(engines)) {
const option = document.createElement("option");
option.text = engine;
option.value = engine;
searchEngine.add(option);
}
};
document.addEventListener("DOMContentLoaded", () => {
settings.loadAvailableSearchEngines();
settings.loadCurrentSettings();
});
</script>
</body>
</html>

View file

@ -564,6 +564,7 @@ set(SOURCES
Internals/Internals.cpp
Internals/InternalsBase.cpp
Internals/Processes.cpp
Internals/Settings.cpp
IntersectionObserver/IntersectionObserver.cpp
IntersectionObserver/IntersectionObserverEntry.cpp
Layout/AudioBox.cpp

View file

@ -61,6 +61,7 @@
#include <LibWeb/Infra/CharacterTypes.h>
#include <LibWeb/Internals/Internals.h>
#include <LibWeb/Internals/Processes.h>
#include <LibWeb/Internals/Settings.h>
#include <LibWeb/Layout/Viewport.h>
#include <LibWeb/Page/Page.h>
#include <LibWeb/Painting/PaintableBox.h>
@ -738,9 +739,10 @@ WebIDL::ExceptionOr<void> Window::initialize_web_interfaces(Badge<WindowEnvironm
if (url.scheme() == "about"sv && url.paths().size() == 1) {
auto const& path = url.paths().first();
if (path == "processes"sv) {
if (path == "processes"sv)
define_direct_property("processes", realm.create<Internals::Processes>(realm), JS::default_attributes);
}
else if (path == "settings"sv)
define_direct_property("settings", realm.create<Internals::Settings>(realm), JS::default_attributes);
}
return {};

View file

@ -0,0 +1,57 @@
/*
* Copyright (c) 2025, Tim Flynn <trflynn89@ladybird.org>
*
* SPDX-License-Identifier: BSD-2-Clause
*/
#include <LibURL/Parser.h>
#include <LibURL/URL.h>
#include <LibWeb/Bindings/Intrinsics.h>
#include <LibWeb/Bindings/SettingsPrototype.h>
#include <LibWeb/Internals/Settings.h>
#include <LibWeb/Page/Page.h>
namespace Web::Internals {
GC_DEFINE_ALLOCATOR(Settings);
Settings::Settings(JS::Realm& realm)
: InternalsBase(realm)
{
}
Settings::~Settings() = default;
void Settings::initialize(JS::Realm& realm)
{
Base::initialize(realm);
WEB_SET_PROTOTYPE_FOR_INTERFACE(Settings);
}
void Settings::load_current_settings()
{
page().client().request_current_settings();
}
void Settings::restore_default_settings()
{
page().client().restore_default_settings();
}
void Settings::set_new_tab_page_url(String const& new_tab_page_url)
{
if (auto parsed_new_tab_page_url = URL::Parser::basic_parse(new_tab_page_url); parsed_new_tab_page_url.has_value())
page().client().set_new_tab_page_url(*parsed_new_tab_page_url);
}
void Settings::load_available_search_engines()
{
page().client().request_available_search_engines();
}
void Settings::set_search_engine(Optional<String> const& search_engine)
{
page().client().set_search_engine(search_engine);
}
}

View file

@ -0,0 +1,34 @@
/*
* Copyright (c) 2025, Tim Flynn <trflynn89@ladybird.org>
*
* SPDX-License-Identifier: BSD-2-Clause
*/
#pragma once
#include <LibWeb/Internals/InternalsBase.h>
namespace Web::Internals {
class Settings final : public InternalsBase {
WEB_PLATFORM_OBJECT(Settings, InternalsBase);
GC_DECLARE_ALLOCATOR(Settings);
public:
virtual ~Settings() override;
void load_current_settings();
void restore_default_settings();
void set_new_tab_page_url(String const& new_tab_page_url);
void load_available_search_engines();
void set_search_engine(Optional<String> const& search_engine);
private:
explicit Settings(JS::Realm&);
virtual void initialize(JS::Realm&) override;
};
}

View file

@ -0,0 +1,10 @@
[Exposed=Nobody]
interface Settings {
undefined loadCurrentSettings();
undefined restoreDefaultSettings();
undefined setNewTabPageURL(USVString newTabPageURL);
undefined loadAvailableSearchEngines();
undefined setSearchEngine(DOMString? search_engine);
};

View file

@ -402,6 +402,12 @@ public:
virtual void update_process_statistics() { }
virtual void request_current_settings() { }
virtual void restore_default_settings() { }
virtual void set_new_tab_page_url(URL::URL const&) { }
virtual void request_available_search_engines() { }
virtual void set_search_engine(Optional<String> const&) { }
virtual bool is_ready_to_paint() const = 0;
virtual DisplayListPlayerType display_list_player_type() const = 0;

View file

@ -267,6 +267,7 @@ libweb_js_bindings(IndexedDB/IDBVersionChangeEvent)
libweb_js_bindings(Internals/InternalAnimationTimeline)
libweb_js_bindings(Internals/Internals)
libweb_js_bindings(Internals/Processes)
libweb_js_bindings(Internals/Settings)
libweb_js_bindings(IntersectionObserver/IntersectionObserver)
libweb_js_bindings(IntersectionObserver/IntersectionObserverEntry)
libweb_js_bindings(MathML/MathMLElement)

View file

@ -5,6 +5,7 @@
*/
#include <AK/Debug.h>
#include <AK/JsonArraySerializer.h>
#include <LibCore/ArgsParser.h>
#include <LibCore/Environment.h>
#include <LibCore/StandardPaths.h>
@ -336,6 +337,35 @@ void Application::send_updated_process_statistics_to_view(ViewImplementation& vi
view.run_javascript(MUST(builder.to_string()));
}
void Application::send_current_settings_to_view(ViewImplementation& view)
{
auto settings = m_settings.serialize_json();
StringBuilder builder;
builder.append("settings.loadSettings(\""sv);
builder.append_escaped_for_json(settings);
builder.append("\");"sv);
view.run_javascript(MUST(builder.to_string()));
}
void Application::send_available_search_engines_to_view(ViewImplementation& view)
{
StringBuilder engines;
auto serializer = MUST(JsonArraySerializer<>::try_create(engines));
for (auto const& engine : search_engines())
MUST(serializer.add(engine.name));
MUST(serializer.finish());
StringBuilder builder;
builder.append("settings.loadSearchEngines(\""sv);
builder.append_escaped_for_json(engines.string_view());
builder.append("\");"sv);
view.run_javascript(MUST(builder.to_string()));
}
void Application::process_did_exit(Process&& process)
{
if (m_in_shutdown)

View file

@ -61,6 +61,9 @@ public:
void send_updated_process_statistics_to_view(ViewImplementation&);
void send_current_settings_to_view(ViewImplementation&);
void send_available_search_engines_to_view(ViewImplementation&);
ErrorOr<LexicalPath> path_for_downloaded_file(StringView file) const;
enum class DevtoolsState {

View file

@ -670,6 +670,39 @@ void WebContentClient::update_process_statistics(u64 page_id)
WebView::Application::the().send_updated_process_statistics_to_view(*view);
}
void WebContentClient::request_current_settings(u64 page_id)
{
if (auto view = view_for_page_id(page_id); view.has_value())
WebView::Application::the().send_current_settings_to_view(*view);
}
void WebContentClient::restore_default_settings(u64 page_id)
{
WebView::Application::settings().restore_defaults();
request_current_settings(page_id);
}
void WebContentClient::set_new_tab_page_url(u64 page_id, URL::URL new_tab_page_url)
{
WebView::Application::settings().set_new_tab_page_url(move(new_tab_page_url));
request_current_settings(page_id);
}
void WebContentClient::request_available_search_engines(u64 page_id)
{
if (auto view = view_for_page_id(page_id); view.has_value())
WebView::Application::the().send_available_search_engines_to_view(*view);
}
void WebContentClient::set_search_engine(u64 page_id, Optional<String> search_engine)
{
WebView::Application::settings().set_search_engine(search_engine.map([](auto const& search_engine) {
return search_engine.bytes_as_string_view();
}));
request_current_settings(page_id);
}
Optional<ViewImplementation&> WebContentClient::view_for_page_id(u64 page_id, SourceLocation location)
{
// Don't bother logging anything for the spare WebContent process. It will only receive a load notification for about:blank.

View file

@ -130,6 +130,11 @@ private:
virtual void did_allocate_backing_stores(u64 page_id, i32 front_bitmap_id, Gfx::ShareableBitmap, i32 back_bitmap_id, Gfx::ShareableBitmap) override;
virtual Messages::WebContentClient::RequestWorkerAgentResponse request_worker_agent(u64 page_id) override;
virtual void update_process_statistics(u64 page_id) override;
virtual void request_current_settings(u64 page_id) override;
virtual void restore_default_settings(u64 page_id) override;
virtual void set_new_tab_page_url(u64 page_id, URL::URL new_tab_page_url) override;
virtual void request_available_search_engines(u64 page_id) override;
virtual void set_search_engine(u64 page_id, Optional<String> search_engine) override;
Optional<ViewImplementation&> view_for_page_id(u64, SourceLocation = SourceLocation::current());

View file

@ -704,6 +704,31 @@ void PageClient::update_process_statistics()
client().async_update_process_statistics(m_id);
}
void PageClient::request_current_settings()
{
client().async_request_current_settings(m_id);
}
void PageClient::restore_default_settings()
{
client().async_restore_default_settings(m_id);
}
void PageClient::set_new_tab_page_url(URL::URL const& new_tab_page_url)
{
client().async_set_new_tab_page_url(m_id, new_tab_page_url);
}
void PageClient::request_available_search_engines()
{
client().async_request_available_search_engines(m_id);
}
void PageClient::set_search_engine(Optional<String> const& search_engine)
{
client().async_set_search_engine(m_id, search_engine);
}
ErrorOr<void> PageClient::connect_to_webdriver(ByteString const& webdriver_ipc_path)
{
VERIFY(!m_webdriver);

View file

@ -175,6 +175,11 @@ private:
virtual IPC::File request_worker_agent() override;
virtual void page_did_mutate_dom(FlyString const& type, Web::DOM::Node const& target, Web::DOM::NodeList& added_nodes, Web::DOM::NodeList& removed_nodes, GC::Ptr<Web::DOM::Node> previous_sibling, GC::Ptr<Web::DOM::Node> next_sibling, Optional<String> const& attribute_name) override;
virtual void update_process_statistics() override;
virtual void request_current_settings() override;
virtual void restore_default_settings() override;
virtual void set_new_tab_page_url(URL::URL const&) override;
virtual void request_available_search_engines() override;
virtual void set_search_engine(Optional<String> const&) override;
Web::Layout::Viewport* layout_root();
void setup_palette();

View file

@ -111,4 +111,10 @@ endpoint WebContentClient
request_worker_agent(u64 page_id) => (IPC::File socket) // FIXME: Add required attributes to select a SharedWorker Agent
update_process_statistics(u64 page_id) =|
request_current_settings(u64 page_id) =|
restore_default_settings(u64 page_id) =|
set_new_tab_page_url(u64 page_id, URL::URL new_tab_page_url) =|
request_available_search_engines(u64 page_id) =|
set_search_engine(u64 page_id, Optional<String> search_engine) =|
}

View file

@ -70,6 +70,7 @@ set(ABOUT_PAGES
about.html
newtab.html
processes.html
settings.html
)
list(TRANSFORM ABOUT_PAGES PREPEND "${LADYBIRD_SOURCE_DIR}/Base/res/ladybird/about-pages/")