diff options
Diffstat (limited to 'lib/webengine')
-rw-r--r-- | lib/webengine/CMakeLists.txt | 22 | ||||
-rw-r--r-- | lib/webengine/test/form.html | 10 | ||||
-rw-r--r-- | lib/webengine/test/icon.svg | 7 | ||||
-rw-r--r-- | lib/webengine/test/profile.cpp | 125 | ||||
-rw-r--r-- | lib/webengine/test/profilemanager.cpp | 120 | ||||
-rw-r--r-- | lib/webengine/test/sample.html | 7 | ||||
-rw-r--r-- | lib/webengine/test/testing.profile | 8 | ||||
-rw-r--r-- | lib/webengine/test/view.cpp | 92 | ||||
-rw-r--r-- | lib/webengine/urlinterceptor.cpp | 32 | ||||
-rw-r--r-- | lib/webengine/urlinterceptor.h | 31 | ||||
-rw-r--r-- | lib/webengine/webpage.cpp | 127 | ||||
-rw-r--r-- | lib/webengine/webpage.h | 30 | ||||
-rw-r--r-- | lib/webengine/webprofile.cpp | 138 | ||||
-rw-r--r-- | lib/webengine/webprofile.h | 242 | ||||
-rw-r--r-- | lib/webengine/webprofilemanager.cpp | 83 | ||||
-rw-r--r-- | lib/webengine/webprofilemanager.h | 126 | ||||
-rw-r--r-- | lib/webengine/webview.cpp | 87 | ||||
-rw-r--r-- | lib/webengine/webview.h | 67 | ||||
-rw-r--r-- | lib/webengine/webviewcontextmenu.cpp | 233 | ||||
-rw-r--r-- | lib/webengine/webviewcontextmenu.h | 21 |
20 files changed, 1608 insertions, 0 deletions
diff --git a/lib/webengine/CMakeLists.txt b/lib/webengine/CMakeLists.txt new file mode 100644 index 0000000..ba3017f --- /dev/null +++ b/lib/webengine/CMakeLists.txt @@ -0,0 +1,22 @@ +add_library(webengine STATIC + webprofile.h webprofile.cpp webprofilemanager.h webprofilemanager.cpp + webpage.h webpage.cpp + webview.h webview.cpp webviewcontextmenu.h webviewcontextmenu.cpp + urlinterceptor.h urlinterceptor.cpp) +target_include_directories(webengine PUBLIC ${CMAKE_SOURCE_DIR}/include ${CMAKE_CURRENT_SOURCE_DIR}) +# autogen: required for context menu icons; TODO: move context menu to src/ +target_link_libraries(webengine PUBLIC Qt5::WebEngineWidgets autogen fmt) + +# tests +add_executable(profile_test test/profile.cpp) +target_link_libraries(profile_test PRIVATE webengine Catch2::Catch2) +#target_sanitize(profile_test) + +add_executable(profilemanager_test test/profilemanager.cpp) +target_link_libraries(profilemanager_test PRIVATE webengine Catch2::Catch2) +#target_sanitize(profilemanager_test) + +add_test(NAME webengine_profile COMMAND profile_test) +add_test(NAME webengine_profilemanager COMMAND profilemanager_test) +set_tests_properties(webengine_profile webengine_profilemanager PROPERTIES + ENVIRONMENT "PROFILE=${CMAKE_CURRENT_SOURCE_DIR}/test/testing.profile") diff --git a/lib/webengine/test/form.html b/lib/webengine/test/form.html new file mode 100644 index 0000000..9d8b19e --- /dev/null +++ b/lib/webengine/test/form.html @@ -0,0 +1,10 @@ +<html> + +<head> + <title>Form completion test</title> +</head> + +<body> +<h2>Form completion test</h2> +</body> +</html> diff --git a/lib/webengine/test/icon.svg b/lib/webengine/test/icon.svg new file mode 100644 index 0000000..a348cab --- /dev/null +++ b/lib/webengine/test/icon.svg @@ -0,0 +1,7 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="300" height="300" version="1.1"> + <circle cx="150" cy="150" r="100" stroke="#000000" stroke-width="6" fill="#e60026"></circle> + <circle cx="150" cy="150" r="87" stroke="#000000" stroke-width="4" fill="#e5e4e2"></circle> + <path d="M230,150 A80,80 0 0 0 150,70 L150,150 Z" /> + <path d="M70,150 A80,80 0 0 0 150,230 L150,150 Z" /> +</svg> + diff --git a/lib/webengine/test/profile.cpp b/lib/webengine/test/profile.cpp new file mode 100644 index 0000000..ae3a4e3 --- /dev/null +++ b/lib/webengine/test/profile.cpp @@ -0,0 +1,125 @@ +#define CATCH_CONFIG_RUNNER + +// clazy:excludeall=non-pod-global-static + +#include "webprofilemanager.h" +#include <QApplication> +#include <catch2/catch.hpp> + +TEST_CASE("loading profile settings") +{ + const QString search = GENERATE(as<QString>{}, "https://search.url/t=%1", "https://duckduckgo.com/?q=%1&ia=web", "aaabbbccc"); + const QUrl homepage = GENERATE(as<QUrl>{}, "https://homepage.net", "about:blank", "aaabbbccc"); + const QUrl newtab = GENERATE(as<QUrl>{}, "https://newtab.net", "about:blank", "aaabbbccc"); + + auto *settings = WebProfile::load(QString(), search, homepage, newtab); + + REQUIRE(settings != nullptr); + REQUIRE(settings->value("search").toString() == search); + REQUIRE(settings->value("homepage").toUrl() == homepage); + REQUIRE(settings->value("newtab").toUrl() == newtab); + + delete settings; +} + +SCENARIO("profile properties") +{ + const QString search{ "about:blank" }; + const QUrl homepage{ "about:blank" }; + const QUrl newtab{ "about:blank" }; + const QString id{ "id" }; + + REQUIRE(qEnvironmentVariableIsSet("PROFILE")); + + // create an empty settings object + const QString settings_path = GENERATE(as<QString>{}, QString(), qgetenv("PROFILE")); + auto *settings = WebProfile::load(settings_path, search, homepage, newtab); + // create the actual profile + auto *profile = WebProfile::load(id, settings, true); + + REQUIRE(profile != nullptr); + REQUIRE(profile->isOffTheRecord()); + + WHEN("id constant") + { + REQUIRE(profile->getId() == id); + REQUIRE(profile->property("id").toString() == id); + } + + WHEN("changing profile name") + { + const QString name = GENERATE(as<QString>{}, "a", "bb", "ccc"); + profile->setName(name); + THEN("the name changes") + { + REQUIRE(profile->name() == name); + REQUIRE(settings->value("name").toString() == name); + } + } + WHEN("changing search") + { + const QString search = GENERATE(as<QString>{}, "a", "bb", "ccc"); + profile->setSearch(search); + THEN("the search url changes") + { + REQUIRE(profile->search() == search); + REQUIRE(settings->value("search").toString() == search); + } + } + WHEN("changing homepage url") + { + const QUrl url = GENERATE(as<QUrl>{}, "a", "bb", "ccc"); + profile->setHomepage(url); + THEN("homepage changes") + { + REQUIRE(profile->homepage() == url); + REQUIRE(settings->value("homepage").toUrl() == url); + } + } + WHEN("changing newtab url") + { + const QUrl url = GENERATE(as<QUrl>{}, "a", "bb", "ccc"); + profile->setNewtab(url); + THEN("newtab changes") + { + REQUIRE(profile->newtab() == url); + REQUIRE(settings->value("newtab").toUrl() == url); + } + } + + WHEN("changing cookies") + { + auto list = profile->cookies(); + REQUIRE(list.isEmpty()); + list.append(QNetworkCookie("name", "value").toRawForm()); + profile->setCookies(list); + THEN("new cookie list gets applied") + { + // There is no event loop, so the signals cannot fire and update the cookie list + //REQUIRE(list == profile->cookies()); + } + } + + WHEN("changing headers") + { + QMap<QString, QVariant> headers; + headers.insert("Dnt", "1"); + headers.insert("unknown", "pair"); + + profile->setHeaders(headers); + THEN("headers change") + { + REQUIRE(profile->headers() == headers); + } + } + + delete settings; + delete profile; +} + +int main(int argc, char **argv) +{ + QApplication app(argc, argv); + const auto r = Catch::Session().run(argc, argv); + return r; +} diff --git a/lib/webengine/test/profilemanager.cpp b/lib/webengine/test/profilemanager.cpp new file mode 100644 index 0000000..8f6a34f --- /dev/null +++ b/lib/webengine/test/profilemanager.cpp @@ -0,0 +1,120 @@ +#define CATCH_CONFIG_RUNNER + +// clazy:excludeall=non-pod-global-static + +#include "webprofilemanager.h" +#include <QApplication> +#include <catch2/catch.hpp> + +SCENARIO("WebProfileManager") +{ + const QString search{ "https://search.url/t=%1" }; + const QUrl homepage{ "https://homepage.net" }; + const QUrl newtab{ "https://newtab.net" }; + const QString default_id{ "default" }; + + GIVEN("an empty profile list") + { + WebProfileManager<false> profiles({}, default_id, search, homepage, newtab); + + REQUIRE(profiles.idList().count() == 1); + REQUIRE(profiles.profile(default_id) == WebProfile::defaultProfile()); + REQUIRE(profiles.profile("not-in-list") == nullptr); + + WHEN("adding a new profile") + { + const QString id{ "id" }; + auto *settings = WebProfile::load(QString(), search, homepage, newtab); + auto *profile = WebProfile::load(id, settings, true); + + THEN("doesn't add profile without settings") + { + profiles.add(id, profile, nullptr); + REQUIRE(profiles.idList().count() == 1); + } + + THEN("doesn't add settings without profile") + { + profiles.add(id, nullptr, settings); + REQUIRE(profiles.idList().count() == 1); + } + + THEN("adds new profile with settings") + { + profiles.add(id, profile, settings); + REQUIRE(profiles.idList().count() == 2); + } + } + + WHEN("moving") + { + WebProfileManager<false> other(std::move(profiles)); + THEN("moved has the same number of profiles") + { + REQUIRE(other.idList().count() == 1); + REQUIRE(other.profile(default_id) == WebProfile::defaultProfile()); + REQUIRE(other.profile("not-in-list") == nullptr); + } + } + } + + GIVEN("a number of profiles, default undefined") + { + REQUIRE(qEnvironmentVariableIsSet("PROFILE")); + + WebProfileManager<false> profiles(QString::fromLatin1(qgetenv("PROFILE")).split(';'), default_id, search, homepage, newtab); + + REQUIRE(profiles.idList().count() == 2); + REQUIRE(profiles.profile(default_id) == WebProfile::defaultProfile()); + REQUIRE(profiles.profile("testing") != nullptr); + REQUIRE(profiles.profile("not-in-list") == nullptr); + + WHEN("making global") + { + profiles.make_global(); + WebProfileManager other; + + THEN("global has the same number of profiles") + { + REQUIRE(other.idList().count() == 2); + REQUIRE(other.profile(default_id) == WebProfile::defaultProfile()); + REQUIRE(other.profile("testing") != nullptr); + REQUIRE(other.profile("not-in-list") == nullptr); + } + + THEN("walking has no nullptrs") + { + other.walk([](const QString &, WebProfile *profile, const QSettings *settings) { + REQUIRE(profile != nullptr); + REQUIRE(settings != nullptr); + }); + } + } + } + + GIVEN("a number of profiles, default defined") + { + REQUIRE(qEnvironmentVariableIsSet("PROFILE")); + + WebProfileManager<false> profiles(QString::fromLatin1(qgetenv("PROFILE")).split(';'), "testing", search, homepage, newtab); + + REQUIRE(profiles.idList().count() == 1); + REQUIRE(profiles.profile("testing") == WebProfile::defaultProfile()); + REQUIRE(profiles.profile("not-in-list") == nullptr); + + WHEN("walking") + { + profiles.walk([](const QString &, WebProfile *profile, const QSettings *settings) { + REQUIRE(profile != nullptr); + REQUIRE(settings != nullptr); + }); + } + } +} + +int main(int argc, char **argv) +{ + QApplication app(argc, argv); + const auto r = Catch::Session().run(argc, argv); + return r; +} diff --git a/lib/webengine/test/sample.html b/lib/webengine/test/sample.html new file mode 100644 index 0000000..54746d5 --- /dev/null +++ b/lib/webengine/test/sample.html @@ -0,0 +1,7 @@ +<html> +<head><title>sample page</title></head> +<body> + <h2><img src="icon.svg" />This is a sample page</h2> + <iframe width="100%" height="80%" src="form.html"></iframe> +</body> +</html> diff --git a/lib/webengine/test/testing.profile b/lib/webengine/test/testing.profile new file mode 100644 index 0000000..e345a3e --- /dev/null +++ b/lib/webengine/test/testing.profile @@ -0,0 +1,8 @@ +name=Test Profile +otr=true +search=https://duckduckgo.com/?q=%1&ia=web +homepage=about:blank +newtab=about:blank + +[headers] +Dnt=1 diff --git a/lib/webengine/test/view.cpp b/lib/webengine/test/view.cpp new file mode 100644 index 0000000..8aa639a --- /dev/null +++ b/lib/webengine/test/view.cpp @@ -0,0 +1,92 @@ +#define CATCH_CONFIG_RUNNER + +// clazy:excludeall=non-pod-global-static + +#include "webprofile.h" +#include "webview.h" +#include <QApplication> +#include <QMainWindow> +#include <QTimer> +#include <QtWebEngine> +#include <catch2/catch.hpp> + +SCENARIO("WebView") +{ + const QString profile_id{ "default" }; + auto *settings = WebProfile::load(qgetenv("PROFILE"), "about:blank", QUrl{ "about:blank" }, QUrl{ "about:blank" }); + auto *profile = WebProfile::load(profile_id, settings, true); + + QMainWindow window; + auto *view = new WebView(profile, nullptr); + window.setCentralWidget(view); + window.show(); + window.resize(800, 600); + + WHEN("created") + { + THEN("using the default profile") + { + REQUIRE(view->profile() == profile); + } + THEN("serialized using default profile") + { + const auto data = view->serialize(); + REQUIRE(data.profile == profile_id); + REQUIRE(data.url.isEmpty()); + REQUIRE(!data.history.isEmpty()); + } + THEN("loading a url") + { + // block until a loadFinished signal + QEventLoop pause; + QObject::connect(view, &WebView::loadFinished, &pause, &QEventLoop::quit); + view->load(QUrl{ qgetenv("URL") }); + pause.exec(); + + REQUIRE(view->isLoaded()); + } + } + + WHEN("changing profiles") + { + const QString swap_profile_id{ "swap_profile" }; + auto *swap_settings = WebProfile::load(QString(), "about:blank", QUrl{ "about:blank" }, QUrl{ "about:blank" }); + auto *swap_profile = WebProfile::load(swap_profile_id, swap_settings, true); + + view->setProfile(swap_profile); + THEN("using the swap profile") + { + REQUIRE(view->profile() == swap_profile); + } + THEN("serialized using swap profile") + { + const auto data = view->serialize(); + REQUIRE(data.profile == swap_profile_id); + REQUIRE(data.url.isEmpty()); + REQUIRE(!data.history.isEmpty()); + } + + view->setProfile(profile); + delete swap_settings; + delete swap_profile; + } + + // cleanup + window.close(); + delete view; + delete settings; + delete profile; +} + +int main(int argc, char **argv) +{ + QtWebEngine::initialize(); + QApplication app(argc, argv); + + QTimer::singleShot(0, &app, [argc, argv, &app]() { + const auto n_failed = Catch::Session().run(argc, argv); + app.exit(n_failed); + }); + + return app.exec(); +} diff --git a/lib/webengine/urlinterceptor.cpp b/lib/webengine/urlinterceptor.cpp new file mode 100644 index 0000000..047cad4 --- /dev/null +++ b/lib/webengine/urlinterceptor.cpp @@ -0,0 +1,32 @@ +/* + * This file is part of smolbote. It's copyrighted by the contributors recorded + * in the version control history of the file, available from its original + * location: https://neueland.iserlohn-fortress.net/gitea/aqua/smolbote + * + * SPDX-License-Identifier: GPL-3.0 + */ + +#include "urlinterceptor.h" +#include "webprofile.h" + +// test DNT on https://browserleaks.com/donottrack + +UrlRequestInterceptor::UrlRequestInterceptor(WebProfile* profile) + : QWebEngineUrlRequestInterceptor(profile) +{ + Q_CHECK_PTR(profile); + m_profile = profile; +} + +void UrlRequestInterceptor::interceptRequest(QWebEngineUrlRequestInfo &info) +{ + for(auto *filter : qAsConst(m_profile->m_filters)) { + filter->interceptRequest(info); + } + + // set headers + for(auto i = m_profile->m_headers.constBegin(); i != m_profile->m_headers.constEnd(); ++i) { + info.setHttpHeader(i.key(), i.value()); + } +} + diff --git a/lib/webengine/urlinterceptor.h b/lib/webengine/urlinterceptor.h new file mode 100644 index 0000000..eb3ce67 --- /dev/null +++ b/lib/webengine/urlinterceptor.h @@ -0,0 +1,31 @@ +/* + * This file is part of smolbote. It's copyrighted by the contributors recorded + * in the version control history of the file, available from its original + * location: https://neueland.iserlohn-fortress.net/gitea/aqua/smolbote + * + * SPDX-License-Identifier: GPL-3.0 + */ + +#ifndef SMOLBOTE_URLREQUESTINTERCEPTOR_H +#define SMOLBOTE_URLREQUESTINTERCEPTOR_H + +#include <QWebEngineUrlRequestInterceptor> + +class WebProfile; +class UrlRequestInterceptor : public QWebEngineUrlRequestInterceptor +{ + friend class WebProfile; + +public: + ~UrlRequestInterceptor() override = default; + + void interceptRequest(QWebEngineUrlRequestInfo &info) override; + +protected: + explicit UrlRequestInterceptor(WebProfile *profile); + +private: + WebProfile *m_profile; +}; + +#endif // SMOLBOTE_URLREQUESTINTERCEPTOR_H diff --git a/lib/webengine/webpage.cpp b/lib/webengine/webpage.cpp new file mode 100644 index 0000000..b2b19b5 --- /dev/null +++ b/lib/webengine/webpage.cpp @@ -0,0 +1,127 @@ +/* + * This file is part of smolbote. It's copyrighted by the contributors recorded + * in the version control history of the file, available from its original + * location: https://neueland.iserlohn-fortress.net/gitea/aqua/smolbote + * + * SPDX-License-Identifier: GPL-3.0 + */ + +#include "webpage.h" +#include <QLayout> +#include <QMessageBox> +#include <QTimer> +#include <QWebEngineFullScreenRequest> +#include <QWebEngineCertificateError> + +[[nodiscard]] inline QString tr_terminationStatus(QWebEnginePage::RenderProcessTerminationStatus status) +{ + switch(status) { + case QWebEnginePage::NormalTerminationStatus: + return QObject::tr("The render process terminated normally."); + case QWebEnginePage::AbnormalTerminationStatus: + return QObject::tr("The render process terminated with with a non-zero exit status."); + case QWebEnginePage::CrashedTerminationStatus: + return QObject::tr("The render process crashed, for example because of a segmentation fault."); + case QWebEnginePage::KilledTerminationStatus: + return QObject::tr("The render process was killed, for example by SIGKILL or task manager kill."); + } + + return QObject::tr("The render process was terminated with an unknown status."); +} + +[[nodiscard]] inline QString feature_toString(QWebEnginePage::Feature feature) +{ + switch(feature) { + case QWebEnginePage::Notifications: + return QObject::tr("Notifications"); + case QWebEnginePage::Geolocation: + return QObject::tr("Geolocation"); + case QWebEnginePage::MediaAudioCapture: + return QObject::tr("Audio Capture"); + case QWebEnginePage::MediaVideoCapture: + return QObject::tr("Video Capture"); + case QWebEnginePage::MediaAudioVideoCapture: + return QObject::tr("Audio and Video Capture"); + case QWebEnginePage::MouseLock: + return QObject::tr("Mouse Lock"); + case QWebEnginePage::DesktopVideoCapture: + return QObject::tr("Desktop Video Capture"); + case QWebEnginePage::DesktopAudioVideoCapture: + return QObject::tr("Desktop Audio and Video Capture"); + } + + return QObject::tr("Unknown feature"); +} + +WebPage::WebPage(QWebEngineProfile *profile, QObject *parent) + : QWebEnginePage(profile, parent) +{ + connect(this, &WebPage::fullScreenRequested, this, [](QWebEngineFullScreenRequest request) { + request.accept(); + }); + + connect(this, &QWebEnginePage::featurePermissionRequested, this, &WebPage::featurePermissionDialog); + connect(this, &QWebEnginePage::renderProcessTerminated, this, &WebPage::renderProcessCrashed); +} + +bool WebPage::certificateError(const QWebEngineCertificateError &certificateError) +{ + QMessageBox messageBox; + + messageBox.setWindowTitle(tr("SSL Error")); + if(certificateError.isOverridable()) + messageBox.setIcon(QMessageBox::Warning); + else + messageBox.setIcon(QMessageBox::Critical); + + messageBox.setText(tr("An SSL error has occurred on <strong>%1</strong>").arg(certificateError.url().toString())); + messageBox.setInformativeText(tr("<p>%1</p>" + "<p>This error %2 be overridden.</p>") + .arg(certificateError.errorDescription(), + certificateError.isOverridable() ? tr("can") : tr("cannot"))); + messageBox.setDetailedText(tr("Error code: %1").arg(certificateError.error())); + + if(certificateError.isOverridable()) { + messageBox.setStandardButtons(QMessageBox::Ignore | QMessageBox::Abort); + messageBox.setDefaultButton(QMessageBox::Ignore); + } else + messageBox.setStandardButtons(QMessageBox::Abort); + + auto resp = messageBox.exec(); + + return resp == QMessageBox::Ignore; +} + +void WebPage::featurePermissionDialog(const QUrl &securityOrigin, QWebEnginePage::Feature feature) +{ + QMessageBox messageBox; + + messageBox.setWindowTitle(tr("Feature permission request")); + messageBox.setIcon(QMessageBox::Question); + messageBox.setText(tr("<p>The webpage <strong>%1</strong> has requested permission to access: %2</p>" + "<p>Allow this feature?</p>") + .arg(securityOrigin.toString(), feature_toString(feature))); + + messageBox.setStandardButtons(QMessageBox::Yes | QMessageBox::No); + messageBox.setDefaultButton(QMessageBox::No); + + if(messageBox.exec() == QMessageBox::Yes) { + setFeaturePermission(securityOrigin, feature, QWebEnginePage::PermissionGrantedByUser); + } else { + setFeaturePermission(securityOrigin, feature, QWebEnginePage::PermissionDeniedByUser); + } +} + +void WebPage::renderProcessCrashed(QWebEnginePage::RenderProcessTerminationStatus terminationStatus, int exitCode) +{ + if(terminationStatus != QWebEnginePage::NormalTerminationStatus) { + QString page = "<html><body><h1>This tab has crashed!</h1>%message%</body></html>"; + page.replace(QLatin1String("%message%"), QString("<p>%1<br>Exit code is %2.</p>" + "<p>Press <a href='%3'>here</a> to reload this tab.</p>") + .arg(tr_terminationStatus(terminationStatus), QString::number(exitCode), this->url().toEncoded())); + + QTimer::singleShot(0, this, [this, page]() { + setHtml(page.toUtf8(), url()); + }); + } +} diff --git a/lib/webengine/webpage.h b/lib/webengine/webpage.h new file mode 100644 index 0000000..91ae4f3 --- /dev/null +++ b/lib/webengine/webpage.h @@ -0,0 +1,30 @@ +/* + * This file is part of smolbote. It's copyrighted by the contributors recorded + * in the version control history of the file, available from its original + * location: https://neueland.iserlohn-fortress.net/gitea/aqua/smolbote + * + * SPDX-License-Identifier: GPL-3.0 + */ + +#ifndef SMOLBOTE_WEBPAGE_H +#define SMOLBOTE_WEBPAGE_H + +#include <QWebEnginePage> + +class WebPage : public QWebEnginePage +{ + Q_OBJECT + +public: + WebPage(QWebEngineProfile *profile, QObject *parent = nullptr); + ~WebPage() override = default; + +protected: + bool certificateError(const QWebEngineCertificateError &certificateError) override; + +protected slots: + void featurePermissionDialog(const QUrl &securityOrigin, QWebEnginePage::Feature feature); + void renderProcessCrashed(QWebEnginePage::RenderProcessTerminationStatus terminationStatus, int exitCode); +}; + +#endif // SMOLBOTE_WEBPAGE_H diff --git a/lib/webengine/webprofile.cpp b/lib/webengine/webprofile.cpp new file mode 100644 index 0000000..f1e71fb --- /dev/null +++ b/lib/webengine/webprofile.cpp @@ -0,0 +1,138 @@ +/* + * This file is part of smolbote. It's copyrighted by the contributors recorded + * in the version control history of the file, available from its original + * location: https://neueland.iserlohn-fortress.net/gitea/aqua/smolbote + * + * SPDX-License-Identifier: GPL-3.0 + */ + +#include "webprofile.h" +#include "urlinterceptor.h" +#include <QFileInfo> +#include <QSettings> +#include <QWebEngineCookieStore> +#include <QWebEngineSettings> +#include <spdlog/spdlog.h> + +static WebProfile *s_profile = nullptr; + +void WebProfile::setDefaultProfile(WebProfile *profile) +{ + s_profile = profile; +} +WebProfile *WebProfile::defaultProfile() +{ + return s_profile; +} + +QSettings *WebProfile::load(const QString &path, const QString &search, const QUrl &homepage, const QUrl &newtab) +{ + auto *settings = new QSettings(path, QSettings::IniFormat); + + if(!settings->contains("search")) { + settings->setValue("search", search); + } + if(!settings->contains("homepage")) { + settings->setValue("homepage", homepage); + } + if(!settings->contains("newtab")) { + settings->setValue("newtab", newtab); + } + + return settings; +} + +WebProfile *WebProfile::load(const QString &id, QSettings *settings, bool isOffTheRecord) +{ + WebProfile *profile = nullptr; + + if(settings->value("otr", isOffTheRecord).toBool()) { + profile = new WebProfile(id, nullptr); + } else { + profile = new WebProfile(id, id, nullptr); + } + + profile->m_name = settings->value("name", id).toString(); + connect(profile, &WebProfile::nameChanged, profile, [settings](const QString &name) { settings->setValue("name", name); }); + profile->m_search = settings->value("search", "").toString(); + connect(profile, &WebProfile::searchChanged, settings, [settings](const QString &url) { settings->setValue("search", url); }); + profile->m_homepage = settings->value("homepage", "").toUrl(); + connect(profile, &WebProfile::homepageChanged, settings, [settings](const QUrl &url) { settings->setValue("homepage", url); }); + profile->m_newtab = settings->value("newtab", "").toUrl(); + connect(profile, &WebProfile::newtabChanged, settings, [settings](const QUrl &url) { settings->setValue("newtab", url); }); + + { + settings->beginGroup("properties"); + const auto keys = settings->childKeys(); + for(const QString &key : keys) { + profile->setProperty(qUtf8Printable(key), settings->value(key)); + } + settings->endGroup(); // properties + connect(profile, &WebProfile::propertyChanged, [settings](const QString &property, const QVariant &value) { + settings->setValue("properties/" + property, value); + }); + } + { + settings->beginGroup("attributes"); + const auto keys = settings->childKeys(); + auto *s = profile->settings(); + for(const QString &key : keys) { + auto attribute = static_cast<QWebEngineSettings::WebAttribute>(key.toInt()); + s->setAttribute(attribute, settings->value(key).toBool()); + } + settings->endGroup(); + connect(profile, &WebProfile::attributeChanged, [settings](const QWebEngineSettings::WebAttribute attr, const bool value) { + settings->setValue("attributes/" + QString::number(attr), value); + }); + } + { + // headers + settings->beginGroup("headers"); + const auto keys = settings->childKeys(); + for(const QString &key : keys) { + profile->setHttpHeader(key.toLatin1(), settings->value(key).toString().toLatin1()); + } + settings->endGroup(); + connect(profile, &WebProfile::headerChanged, [settings](const QString &name, const QString &value) { + settings->setValue("headers/" + name, value); + }); + connect(profile, &WebProfile::headerRemoved, [settings](const QString &name) { + settings->remove("headers/" + name); + }); + } + return profile; +} + +// off-the-record constructor +WebProfile::WebProfile(const QString &id, QObject *parent) + : QWebEngineProfile(parent) + , m_id(id) +{ + QWebEngineProfile::setUrlRequestInterceptor(new UrlRequestInterceptor(this)); + connect(this->cookieStore(), &QWebEngineCookieStore::cookieAdded, this, [this](const QNetworkCookie &cookie) { + spdlog::debug("[{}]: +cookie {}", qUtf8Printable(m_name), qUtf8Printable(cookie.name())); + m_cookies.append(cookie); + }); + connect(this->cookieStore(), &QWebEngineCookieStore::cookieRemoved, this, [this](const QNetworkCookie &cookie) { + spdlog::debug("[{}]: -cookie {}", qUtf8Printable(m_name), qUtf8Printable(cookie.name())); + m_cookies.removeOne(cookie); + }); + cookieStore()->loadAllCookies(); +} + +// default constructor +WebProfile::WebProfile(const QString &id, const QString &storageName, QObject *parent) + : QWebEngineProfile(storageName, parent) + , m_id(id) +{ + QWebEngineProfile::setUrlRequestInterceptor(new UrlRequestInterceptor(this)); + connect(this->cookieStore(), &QWebEngineCookieStore::cookieAdded, this, [this](const QNetworkCookie &cookie) { + spdlog::debug("[{}]: +cookie {}", qUtf8Printable(m_name), qUtf8Printable(cookie.name())); + m_cookies.append(cookie); + }); + connect(this->cookieStore(), &QWebEngineCookieStore::cookieRemoved, this, [this](const QNetworkCookie &cookie) { + spdlog::debug("[{}]: -cookie {}", qUtf8Printable(m_name), qUtf8Printable(cookie.name())); + m_cookies.removeOne(cookie); + }); + cookieStore()->loadAllCookies(); +} diff --git a/lib/webengine/webprofile.h b/lib/webengine/webprofile.h new file mode 100644 index 0000000..894463f --- /dev/null +++ b/lib/webengine/webprofile.h @@ -0,0 +1,242 @@ +/* + * This file is part of smolbote. It's copyrighted by the contributors recorded + * in the version control history of the file, available from its original + * location: https://neueland.iserlohn-fortress.net/gitea/aqua/smolbote + * + * SPDX-License-Identifier: GPL-3.0 + */ + +#ifndef SMOLBOTE_WEBENGINEPROFILE_H +#define SMOLBOTE_WEBENGINEPROFILE_H + +#include <QMap> +#include <QNetworkCookie> +#include <QSettings> +#include <QString> +#include <QUrl> +#include <QVariant> +#include <QVector> +#include <QWebEngineCookieStore> +#include <QWebEngineProfile> +#include <QWebEngineSettings> + +class UrlRequestInterceptor; +class WebProfile : public QWebEngineProfile +{ + friend class UrlRequestInterceptor; + + Q_OBJECT + + Q_PROPERTY(QString id READ getId CONSTANT) + Q_PROPERTY(QString name READ name WRITE setName NOTIFY nameChanged) + Q_PROPERTY(QString search READ search WRITE setSearch NOTIFY searchChanged) + Q_PROPERTY(QUrl homepage READ homepage WRITE setHomepage NOTIFY homepageChanged) + Q_PROPERTY(QUrl newtab READ newtab WRITE setNewtab NOTIFY newtabChanged) + + // QWebEngineProfile should-be properties + Q_PROPERTY(QString cachePath READ cachePath WRITE setCachePath NOTIFY propertyChanged) + Q_PROPERTY(QString persistentStoragePath READ persistentStoragePath WRITE setPersistentStoragePath NOTIFY propertyChanged) + Q_PROPERTY(int persistentCookiesPolicy READ persistentCookiesPolicy WRITE setPersistentCookiesPolicy NOTIFY propertyChanged) + + Q_PROPERTY(QString httpAcceptLanguage READ httpAcceptLanguage WRITE setHttpAcceptLanguage NOTIFY propertyChanged) + Q_PROPERTY(int httpCacheMaximumSize READ httpCacheMaximumSize WRITE setHttpCacheMaximumSize NOTIFY propertyChanged) + Q_PROPERTY(int httpCacheType READ httpCacheType WRITE setHttpCacheType NOTIFY propertyChanged) + Q_PROPERTY(QString httpUserAgent READ httpUserAgent WRITE setHttpUserAgent NOTIFY propertyChanged) + + Q_PROPERTY(bool spellCheckEnabled READ isSpellCheckEnabled WRITE setSpellCheckEnabled NOTIFY propertyChanged) + + // more custom properties + Q_PROPERTY(QList<QVariant> cookies READ cookies WRITE setCookies NOTIFY cookiesChanged) + Q_PROPERTY(QMap<QString, QVariant> headers READ headers WRITE setHeaders NOTIFY headersChanged) + +signals: + void nameChanged(const QString &name); + void searchChanged(const QString &url); + void homepageChanged(const QUrl &url); + void newtabChanged(const QUrl &url); + + void propertyChanged(const QString &name, const QVariant &value); + void attributeChanged(QWebEngineSettings::WebAttribute attribute, bool value); + + void cookiesChanged(); + void headersChanged(); + void headerChanged(const QString &name, const QString &value); + void headerRemoved(const QString &name); + +public: + [[nodiscard]] static QSettings *load(const QString &path, const QString &search = QString(), const QUrl &homepage = QUrl(), const QUrl &newtab = QUrl()); + [[nodiscard]] static WebProfile *load(const QString &id, QSettings *settings, bool isOffTheRecord = true); + + WebProfile(const WebProfile &) = delete; + WebProfile &operator=(const WebProfile &) = delete; + WebProfile(WebProfile &&) = delete; + WebProfile &operator=(WebProfile &&) = delete; + + static WebProfile *defaultProfile(); + static void setDefaultProfile(WebProfile *profile); + + ~WebProfile() override = default; + + [[nodiscard]] QString getId() const + { + return m_id; + } + [[nodiscard]] QString name() const + { + return m_name; + } + void setName(const QString &name) + { + m_name = name; + emit nameChanged(name); + } + + [[nodiscard]] QList<QVariant> cookies() const + { + QList<QVariant> r; + for(const auto &cookie : m_cookies) { + r.append(cookie.toRawForm()); + } + return r; + } + + [[nodiscard]] QString search() const + { + return m_search; + } + void setSearch(const QString &url) + { + m_search = url; + emit searchChanged(m_search); + } + + [[nodiscard]] QUrl homepage() const + { + return m_homepage; + } + void setHomepage(const QUrl &url) + { + m_homepage = url; + emit homepageChanged(m_homepage); + } + + [[nodiscard]] QUrl newtab() const + { + return m_newtab; + } + void setNewtab(const QUrl &url) + { + m_newtab = url; + emit newtabChanged(m_newtab); + } + + void setCachePath(const QString &path) + { + QWebEngineProfile::setCachePath(path); + emit propertyChanged("cachePath", path); + } + void setPersistentStoragePath(const QString &path) + { + QWebEngineProfile::setPersistentStoragePath(path); + emit propertyChanged("persistentStoragePath", path); + } + void setPersistentCookiesPolicy(int policy) + { + QWebEngineProfile::setPersistentCookiesPolicy(static_cast<QWebEngineProfile::PersistentCookiesPolicy>(policy)); + emit propertyChanged("persistentCookiesPolicy", policy); + } + + void setHttpAcceptLanguage(const QString &httpAcceptLanguage) + { + QWebEngineProfile::setHttpAcceptLanguage(httpAcceptLanguage); + emit propertyChanged("httpAcceptLanguage", httpAcceptLanguage); + } + void setHttpCacheMaximumSize(int maxSize) + { + QWebEngineProfile::setHttpCacheMaximumSize(maxSize); + emit propertyChanged("httpCacheMaximumSize", maxSize); + } + void setHttpCacheType(int type) + { + QWebEngineProfile::setHttpCacheType(static_cast<QWebEngineProfile::HttpCacheType>(type)); + emit propertyChanged("httpCacheType", type); + } + void setHttpUserAgent(const QString &userAgent) + { + QWebEngineProfile::setHttpUserAgent(userAgent); + emit propertyChanged("httpUserAgent", userAgent); + } + void setHttpHeader(const QString &name, const QString &value) + { + m_headers[name.toLatin1()] = value.toLatin1(); + emit headerChanged(name, value); + } + void removeHttpHeader(const QString &name) + { + if(m_headers.contains(name.toLatin1())) { + m_headers.remove(name.toLatin1()); + emit headerRemoved(name); + } + } + [[nodiscard]] QMap<QString, QVariant> headers() const + { + QMap<QString, QVariant> r; + auto it = m_headers.constBegin(); + while(it != m_headers.constEnd()) { + r.insert(QString(it.key()), QVariant(it.value())); + ++it; + } + return r; + } + void setSpellCheckEnabled(bool enable) + { + QWebEngineProfile::setSpellCheckEnabled(enable); + emit propertyChanged("spellCheckEnabed", enable); + } + + void setUrlRequestInterceptor(QWebEngineUrlRequestInterceptor *interceptor) + { + m_filters.append(interceptor); + } + +public slots: + void setCookies(const QList<QVariant> &cookies) + { + auto *store = cookieStore(); + store->deleteAllCookies(); + for(const auto &data : cookies) { + for(auto &cookie : QNetworkCookie::parseCookies(data.toByteArray())) { + store->setCookie(cookie); + } + } + emit cookiesChanged(); + } + void setHeaders(const QMap<QString, QVariant> &headers) + { + m_headers.clear(); + auto it = headers.constBegin(); + while(it != headers.constEnd()) { + m_headers.insert(it.key().toLatin1(), it.value().toByteArray()); + ++it; + } + emit headersChanged(); + } + +protected: + // off-the-record constructor + explicit WebProfile(const QString &id, QObject *parent = nullptr); + // default constructor + explicit WebProfile(const QString &id, const QString &storageName, QObject *parent = nullptr); + + const QString m_id; + QString m_name; + QString m_search = QString("about:blank"); + QUrl m_homepage = QUrl("about:blank"); + QUrl m_newtab = QUrl("about:blank"); + + QVector<QWebEngineUrlRequestInterceptor *> m_filters; + QList<QNetworkCookie> m_cookies; + QMap<QByteArray, QByteArray> m_headers; +}; + +#endif // SMOLBOTE_WEBENGINEPROFILE_H diff --git a/lib/webengine/webprofilemanager.cpp b/lib/webengine/webprofilemanager.cpp new file mode 100644 index 0000000..5cc83f8 --- /dev/null +++ b/lib/webengine/webprofilemanager.cpp @@ -0,0 +1,83 @@ +/* + * This file is part of smolbote. It's copyrighted by the contributors recorded + * in the version control history of the file, available from its original + * location: https://neueland.iserlohn-fortress.net/gitea/aqua/smolbote + * + * SPDX-License-Identifier: GPL-3.0 + */ + +#include "webprofilemanager.h" +#include "webprofile.h" + +static WebProfileManager<false> *s_instance = nullptr; + +template <> +void WebProfileManager<false>::make_global() +{ + if(s_instance == nullptr) { + s_instance = this; + } +} + +template <> +WebProfileManager<true>::~WebProfileManager() = default; + +template <> +WebProfileManager<false>::~WebProfileManager() +{ + for(Profile p : qAsConst(profiles)) { + if(p.selfDestruct && p.settings != nullptr) { + if(!p.ptr->isOffTheRecord()) { + if(!p.ptr->persistentStoragePath().isEmpty()) + QDir(p.ptr->persistentStoragePath()).removeRecursively(); + if(!p.ptr->cachePath().isEmpty()) + QDir(p.ptr->cachePath()).removeRecursively(); + } + const QString filename = p.settings->fileName(); + delete p.settings; + QFile::remove(filename); + } else if(p.settings != nullptr) { + p.settings->sync(); + delete p.settings; + } + } +} + +template <> +WebProfile *WebProfileManager<true>::profile(const QString &id) const +{ + return s_instance->profile(id); +} + +template <> +QStringList WebProfileManager<true>::idList() const +{ + return s_instance->idList(); +} + +template <> +void WebProfileManager<false>::walk(std::function<void(const QString &id, WebProfile *profile, QSettings *settings)> f) const +{ + for(auto iter = profiles.begin(); iter != profiles.end(); ++iter) { + f(iter.key(), iter.value().ptr, iter.value().settings); + } +} + +template <> +void WebProfileManager<true>::walk(std::function<void(const QString &id, WebProfile *profile, QSettings *settings)> f) const +{ + s_instance->walk(f); +} + +void profileMenu(QMenu *menu, const std::function<void(WebProfile *)> &callback, WebProfile *current, bool checkable) +{ + auto *group = new QActionGroup(menu); + QObject::connect(menu, &QMenu::aboutToHide, group, &QActionGroup::deleteLater); + + s_instance->walk([=](const QString &, WebProfile *profile, const QSettings *) { + auto *action = menu->addAction(profile->name(), profile, [=]() { callback(profile); }); + action->setCheckable(checkable); + action->setChecked(profile == current); + group->addAction(action); + }); +} diff --git a/lib/webengine/webprofilemanager.h b/lib/webengine/webprofilemanager.h new file mode 100644 index 0000000..e5df6d5 --- /dev/null +++ b/lib/webengine/webprofilemanager.h @@ -0,0 +1,126 @@ +/* + * This file is part of smolbote. It's copyrighted by the contributors recorded + * in the version control history of the file, available from its original + * location: https://neueland.iserlohn-fortress.net/gitea/aqua/smolbote + * + * SPDX-License-Identifier: GPL-3.0 + */ + +#ifndef SMOLBOTE_WEBPROFILEMANAGER_H +#define SMOLBOTE_WEBPROFILEMANAGER_H + +#include "webprofile.h" +#include <QDir> +#include <QFile> +#include <QFileInfo> +#include <QMap> +#include <QMenu> +#include <functional> + +#if defined(__clang__) +#define consumable(X) [[clang::consumable(X)]] +#define return_typestate(X) [[clang::return_typestate(X)]] +#define set_typestate(X) [[clang::set_typestate(X)]] +#define callable_when(X) [[clang::callable_when(X)]] +#define param_typestate(X) [[clang::param_typestate(X)]] +#else +#define consumable(X) +#define return_typestate(X) +#define set_typestate(X) +#define callable_when(X) +#define param_typestate(X) +#endif + +void profileMenu(QMenu *menu, const std::function<void(WebProfile *)> &callback, WebProfile *current = nullptr, bool checkable = false); + +template <bool use_global = true> +class consumable(unconsumed) WebProfileManager +{ +public: + return_typestate(unconsumed) WebProfileManager() + { + static_assert(use_global); + } + return_typestate(unconsumed) WebProfileManager(const QStringList &paths, const QString &default_id, + const QString &search = QString(), const QUrl &homepage = QUrl(), const QUrl &newtab = QUrl()) + { + static_assert(!use_global); + for(const auto &path : paths) { + const auto id = QFileInfo(path).baseName(); + Profile profile; + profile.settings = WebProfile::load(path, search, homepage, newtab); + profile.ptr = WebProfile::load(id, profile.settings, true); + profiles[id] = profile; + } + + if(!profiles.contains(default_id)) { + auto *settings = WebProfile::load(QString(), search, homepage, newtab); + profiles[default_id] = Profile{ + .settings = settings, + .ptr = WebProfile::load(default_id, settings, true), + }; + } + WebProfile::setDefaultProfile(profiles[default_id].ptr); + } + ~WebProfileManager(); + + WebProfileManager(const WebProfileManager &) = delete; + WebProfileManager &operator=(const WebProfileManager &) = delete; + + return_typestate(unconsumed) WebProfileManager(WebProfileManager<false> && other param_typestate(unconsumed)) + { + static_assert(!use_global); + profiles = std::move(other.profiles); + other.consume(); + } + WebProfileManager &operator=(WebProfileManager &&) = delete; + + callable_when(unconsumed) [[nodiscard]] WebProfile *profile(const QString &id) const + { + return profiles.value(id).ptr; + } + + callable_when(unconsumed) void add(const QString &id, WebProfile *profile, QSettings *settings) + { + if constexpr(use_global) { + return; + } + + if(profile != nullptr && settings != nullptr) { + profiles[id] = Profile{ settings, profile, false }; + } + } + + callable_when(unconsumed) void deleteProfile(const QString &id) + { + if constexpr(use_global) { + return; + } + + if(profiles.contains(id)) { + profiles[id].selfDestruct = true; + } + } + + callable_when(unconsumed) [[nodiscard]] QStringList idList() const + { + return profiles.keys(); + } + + callable_when(unconsumed) void walk(std::function<void(const QString &id, WebProfile *profile, QSettings *settings)>) const; + + callable_when(unconsumed) void make_global(); + +private: + set_typestate(consumed) void consume() {} + + struct Profile { + QSettings *settings = nullptr; + WebProfile *ptr = nullptr; + bool selfDestruct = false; + }; + + QMap<QString, Profile> profiles; +}; + +#endif // SMOLBOTE_PROFILEMANAGER_H diff --git a/lib/webengine/webview.cpp b/lib/webengine/webview.cpp new file mode 100644 index 0000000..bc52102 --- /dev/null +++ b/lib/webengine/webview.cpp @@ -0,0 +1,87 @@ +/* + * This file is part of smolbote. It's copyrighted by the contributors recorded + * in the version control history of the file, available from its original + * location: https://neueland.iserlohn-fortress.net/gitea/aqua/smolbote + * + * SPDX-License-Identifier: GPL-3.0 + */ + +#include "webview.h" +#include "webpage.h" +#include "webprofile.h" +#include "webprofilemanager.h" +#include "webviewcontextmenu.h" +#include <QContextMenuEvent> +#include <QWebEngineHistoryItem> + +WebView::WebView(QWidget *parent) + : QWebEngineView(parent) +{ + // load status and progress + connect(this, &QWebEngineView::loadStarted, this, [this]() { + m_loaded = false; + }); + connect(this, &QWebEngineView::loadFinished, this, [this]() { + m_loaded = true; + }); + + // TODO for Qt 5.15, check for fix on QTBUG 65223 + connect(this, &QWebEngineView::loadProgress, this, [this](int progress) { + if(progress == 100) { + emit loadFinished(true); + } + }); +} + +WebView::WebView(WebProfile *profile, cb_createWindow_t cb, QWidget *parent) + : WebView(parent) +{ + cb_createWindow = cb; + setProfile(profile); +} + +WebView::WebView(const Session::WebView &webview_data, cb_createWindow_t cb, QWidget *parent) + : WebView(parent) +{ + cb_createWindow = cb; + WebProfileManager profileManager; + setProfile(profileManager.profile(webview_data.profile)); + + if(!webview_data.url.isEmpty()) + load(QUrl::fromUserInput(webview_data.url)); + else { + QByteArray copy(webview_data.history); + QDataStream historyStream(©, QIODevice::ReadOnly); + historyStream >> *history(); + } +} + +void WebView::setProfile(WebProfile *profile) +{ + m_profile = (profile == nullptr) ? WebProfile::defaultProfile() : profile; + const auto url = this->url(); + setPage(new WebPage(m_profile, this)); + this->load(url); +} + +Session::WebView WebView::serialize() const +{ + QByteArray historyData; + QDataStream historyStream(&historyData, QIODevice::WriteOnly); + historyStream << *history(); + + return { profile()->getId(), QString(), historyData }; +} + +void WebView::search(const QString &term) +{ + const QString searchUrl = m_profile->search().arg(QString(QUrl::toPercentEncoding(term))); + load(searchUrl); +} + +void WebView::contextMenuEvent(QContextMenuEvent *event) +{ + auto *menu = new WebViewContextMenu(this); + //const auto ctxdata = page()->contextMenuData(); + menu->exec(event->globalPos()); +} diff --git a/lib/webengine/webview.h b/lib/webengine/webview.h new file mode 100644 index 0000000..538ffa9 --- /dev/null +++ b/lib/webengine/webview.h @@ -0,0 +1,67 @@ +/* + * This file is part of smolbote. It's copyrighted by the contributors recorded + * in the version control history of the file, available from its original + * location: https://neueland.iserlohn-fortress.net/gitea/aqua/smolbote + * + * SPDX-License-Identifier: GPL-3.0 + */ + +#ifndef SMOLBOTE_WEBVIEW_H +#define SMOLBOTE_WEBVIEW_H + +#include "smolbote/session.hpp" +#include "webpage.h" +#include <QWebEngineView> +#include <functional> + +class WebProfile; +class WebViewContextMenu; +class WebView final : public QWebEngineView +{ + friend class WebViewContextMenu; + + Q_OBJECT + + explicit WebView(QWidget *parent = nullptr); + +public: + typedef std::function<WebView *(QWebEnginePage::WebWindowType)> cb_createWindow_t; + + WebView(WebProfile *profile, cb_createWindow_t cb, QWidget *parent = nullptr); + WebView(const Session::WebView &webview_data, cb_createWindow_t cb, QWidget *parent = nullptr); + ~WebView() = default; + + [[nodiscard]] WebProfile *profile() const + { + return m_profile; + } + void setProfile(WebProfile *profile); + + [[nodiscard]] Session::WebView serialize() const; + + bool isLoaded() const + { + return m_loaded; + } + +public slots: + void search(const QString &term); + +signals: + void newBookmark(const QString &title, const QUrl &url); + +protected: + WebView *createWindow(QWebEnginePage::WebWindowType type) override + { + return cb_createWindow ? cb_createWindow(type) : nullptr; + } + void contextMenuEvent(QContextMenuEvent *event) override; + +private: + cb_createWindow_t cb_createWindow; + WebProfile *m_profile = nullptr; + + bool m_loaded = false; +}; + +#endif // SMOLBOTE_WEBVIEW_H diff --git a/lib/webengine/webviewcontextmenu.cpp b/lib/webengine/webviewcontextmenu.cpp new file mode 100644 index 0000000..c9d809f --- /dev/null +++ b/lib/webengine/webviewcontextmenu.cpp @@ -0,0 +1,233 @@ +/* + * This file is part of smolbote. It's copyrighted by the contributors recorded + * in the version control history of the file, available from its original + * location: https://neueland.iserlohn-fortress.net/gitea/aqua/smolbote + * + * SPDX-License-Identifier: GPL-3.0 + */ + +#include "webviewcontextmenu.h" +#include "webprofilemanager.h" +#include "webview.h" +#include "util.h" +#include <QContextMenuEvent> +#include <QDialog> +#include <QMenu> +#include <QSlider> +#include <QStyle> +#include <QToolButton> +#include <QVBoxLayout> +#include <QWebEngineContextMenuData> +#include <QWebEngineHistory> +#include <QWidgetAction> + +constexpr int min_width = 250; +constexpr QSize button_size(32, 32); + +inline QAction *historyAction(QWebEngineView *view, const QWebEngineHistoryItem &item) +{ + auto *action = new QAction(view); + if(item.title().isEmpty()) { + action->setText(item.url().toString()); + } else { + action->setText(QObject::tr("%1 (%2)").arg(item.title(), item.url().toString())); + } + + QObject::connect(action, &QAction::triggered, view, [view, item]() { + view->history()->goToItem(item); + }); + return action; +} + +WebViewContextMenu::WebViewContextMenu(WebView *view) + : QMenu(view) +{ + setMinimumWidth(min_width); + + auto *navButtons = new QWidgetAction(this); + + auto *buttons = new QWidget(this); + auto *buttonsLayout = new QHBoxLayout; + buttonsLayout->setContentsMargins(8, 0, 8, 0); + buttonsLayout->setSpacing(2); + + auto *backButton = new QToolButton(this); + backButton->setMinimumSize(button_size); + backButton->setEnabled(view->history()->canGoBack()); + backButton->setIcon(Util::icon<QStyle::SP_ArrowBack>()); + connect(backButton, &QToolButton::clicked, view, [this, view]() { + view->back(); + this->close(); + }); + buttonsLayout->addWidget(backButton); + + auto *forwardButton = new QToolButton(this); + forwardButton->setMinimumSize(button_size); + forwardButton->setEnabled(view->history()->canGoForward()); + forwardButton->setIcon(Util::icon<QStyle::SP_ArrowForward>()); + connect(forwardButton, &QToolButton::clicked, view, [this, view]() { + view->forward(); + this->close(); + }); + buttonsLayout->addWidget(forwardButton); + + auto *refreshButton = new QToolButton(this); + refreshButton->setMinimumSize(button_size); + refreshButton->setIcon(Util::icon<QStyle::SP_BrowserReload>()); + connect(refreshButton, &QToolButton::clicked, view, [view, this]() { + view->reload(); + this->close(); + }); + buttonsLayout->addWidget(refreshButton); + + buttonsLayout->addStretch(); + + auto *muteButton = new QToolButton(this); + muteButton->setMinimumSize(button_size); + muteButton->setCheckable(true); + muteButton->setChecked(view->page()->isAudioMuted()); + muteButton->setIcon(Util::icon<QStyle::SP_MediaVolume>()); + connect(muteButton, &QToolButton::clicked, view, [view, this](bool checked) { + view->page()->setAudioMuted(checked); + this->close(); + }); + buttonsLayout->addWidget(muteButton); + + buttons->setLayout(buttonsLayout); + navButtons->setDefaultWidget(buttons); + + this->addAction(navButtons); + this->addSeparator(); + + const auto ctxdata = view->page()->contextMenuData(); + + if(ctxdata.mediaType() == QWebEngineContextMenuData::MediaTypeNone) { + auto *backMenu = this->addMenu(tr("Back")); + if(!view->history()->canGoBack()) { + backMenu->setEnabled(false); + } else { + connect(backMenu, &QMenu::aboutToShow, view, [view, backMenu]() { + backMenu->clear(); + const auto backItems = view->history()->backItems(10); + for(const QWebEngineHistoryItem &item : backItems) { + backMenu->addAction(historyAction(view, item)); + } + }); + } + + auto *forwardMenu = this->addMenu(tr("Forward")); + if(!view->history()->canGoForward()) { + forwardMenu->setEnabled(false); + } else { + connect(forwardMenu, &QMenu::aboutToShow, view, [view, forwardMenu]() { + forwardMenu->clear(); + const auto forwardItems = view->history()->forwardItems(10); + for(const QWebEngineHistoryItem &item : forwardItems) { + forwardMenu->addAction(historyAction(view, item)); + } + }); + } + + connect(this->addAction(tr("Reload")), &QAction::triggered, view, [view]() { + view->page()->triggerAction(QWebEnginePage::Reload); + }); + connect(this->addAction(tr("Reload and bypass Cache")), &QAction::triggered, view, [view]() { + view->page()->triggerAction(QWebEnginePage::ReloadAndBypassCache); + }); + + this->addSeparator(); + + connect(this->addAction(tr("Select All")), &QAction::triggered, view, [view]() { + view->page()->triggerAction(QWebEnginePage::SelectAll); + }); + connect(this->addAction(tr("Clear Selection")), &QAction::triggered, view, [view]() { + view->page()->triggerAction(QWebEnginePage::Unselect); + }); + connect(this->addAction(tr("Copy to clipboard")), &QAction::triggered, view, [view]() { + view->page()->triggerAction(QWebEnginePage::Copy); + }); + + } else if(ctxdata.mediaType() == QWebEngineContextMenuData::MediaTypeImage) { + connect(this->addAction(tr("Copy image to clipboard")), &QAction::triggered, view, [view]() { + view->page()->triggerAction(QWebEnginePage::CopyImageToClipboard); + }); + connect(this->addAction(tr("Copy image URL to clipboard")), &QAction::triggered, view, [view]() { + view->page()->triggerAction(QWebEnginePage::CopyImageUrlToClipboard); + }); + if(!ctxdata.mediaUrl().isEmpty()) { + if(view->url() != ctxdata.mediaUrl()) { + connect(this->addAction(tr("Open image")), &QAction::triggered, view, [view, ctxdata]() { + view->load(ctxdata.mediaUrl()); + }); + connect(this->addAction(tr("Open image in new tab")), &QAction::triggered, view, [view, ctxdata]() { + view->createWindow(QWebEnginePage::WebBrowserTab)->load(ctxdata.mediaUrl()); + }); + } + connect(this->addAction(tr("Save image")), &QAction::triggered, view, [view, ctxdata]() { + view->page()->download(ctxdata.mediaUrl()); + }); + } + + } else { + addMenu(view->page()->createStandardContextMenu()); + } + + if(!ctxdata.linkUrl().isEmpty()) { + this->addSeparator(); + connect(this->addAction(tr("Open link in new tab")), &QAction::triggered, view, [view, ctxdata]() { + view->createWindow(QWebEnginePage::WebBrowserTab)->load(ctxdata.linkUrl()); + }); + + auto *newTabMenu = this->addMenu(tr("Open link in new tab with profile")); + profileMenu(newTabMenu, [view, ctxdata](WebProfile *profile) { + auto *v = view->createWindow(QWebEnginePage::WebBrowserTab); + v->setProfile(profile); + v->load(ctxdata.linkUrl()); + }); + + connect(this->addAction(tr("Open link in new window")), &QAction::triggered, view, [view, ctxdata]() { + view->createWindow(QWebEnginePage::WebBrowserWindow)->load(ctxdata.linkUrl()); + }); + + connect(this->addAction(tr("Copy link address")), &QAction::triggered, view, [view]() { + view->page()->triggerAction(QWebEnginePage::CopyLinkToClipboard); + }); + } + + // zoom widget + { + this->addSeparator(); + + auto *zoomSlider = new QSlider(Qt::Horizontal); + zoomSlider->setMinimum(5); + zoomSlider->setMaximum(50); + zoomSlider->setValue(static_cast<int>(view->zoomFactor() * 10)); + + auto *zoomAction = this->addAction(tr("Zoom: %1x").arg(view->zoomFactor())); + connect(zoomAction, &QAction::triggered, view, [zoomSlider]() { + zoomSlider->setValue(10); + }); + + connect(zoomSlider, &QSlider::valueChanged, view, [view, zoomAction](int value) { + zoomAction->setText(tr("Zoom: %1x").arg(static_cast<qreal>(value) / 10)); + view->setZoomFactor(static_cast<qreal>(value) / 10); + }); + + auto *zoomWidgetAction = new QWidgetAction(this); + zoomWidgetAction->setDefaultWidget(zoomSlider); + + this->addAction(zoomWidgetAction); + } + +#ifndef NDEBUG + /* + { + this->addSeparator(); + auto *autofillAction = this->addAction(tr("Autofill form")); + connect(autofillAction, &QAction::triggered, view, [view]() { + Wallet::autocompleteForm(view); + }); + }; + */ +#endif +} diff --git a/lib/webengine/webviewcontextmenu.h b/lib/webengine/webviewcontextmenu.h new file mode 100644 index 0000000..881670a --- /dev/null +++ b/lib/webengine/webviewcontextmenu.h @@ -0,0 +1,21 @@ +/* + * This file is part of smolbote. It's copyrighted by the contributors recorded + * in the version control history of the file, available from its original + * location: https://neueland.iserlohn-fortress.net/gitea/aqua/smolbote + * + * SPDX-License-Identifier: GPL-3.0 + */ + +#ifndef SMOLBOTE_WEBVIEWCONTEXTMENU_H +#define SMOLBOTE_WEBVIEWCONTEXTMENU_H + +#include <QMenu> + +class WebView; +class WebViewContextMenu : public QMenu +{ +public: + explicit WebViewContextMenu(WebView *view); +}; + +#endif // SMOLBOTE_WEBVIEWCONTEXTMENU_H |