diff --git a/inc/liquid.hpp b/inc/liquid.hpp index 36c754b..c30ac22 100644 --- a/inc/liquid.hpp +++ b/inc/liquid.hpp @@ -12,6 +12,10 @@ class Liquid static void applyQtStyleSheets(QWidget* widget); static void createDesktopFile(const QString liquidAppName, const QString liquidAppStartingUrl); static bool detectDarkMode(void); + // Makes detectDarkMode() tell what's been chosen (e.g. for a Liquid app), rather than what the system uses + static void overrideDarkMode(const bool isDark); + static void clearDarkModeOverride(void); + static bool detectSystemDarkMode(void); static bool detectDarkModeUncached(void); static QByteArray generateRandomByteArray(const int byteLength); static QDir getAppsDir(void); diff --git a/inc/liquidappconfigwindow.hpp b/inc/liquidappconfigwindow.hpp index f2bd0c4..80681c8 100644 --- a/inc/liquidappconfigwindow.hpp +++ b/inc/liquidappconfigwindow.hpp @@ -82,6 +82,7 @@ public slots: QPlainTextEdit* notesTextArea; // Appearance tab + QComboBox* colorSchemeComboBox; QCheckBox* hideScrollBarsCheckBox; QCheckBox* removeWindowFrameCheckBox; QCheckBox* useCustomBackgroundCheckBox; diff --git a/inc/liquidappwindow.hpp b/inc/liquidappwindow.hpp index 7d09737..f9db9c3 100644 --- a/inc/liquidappwindow.hpp +++ b/inc/liquidappwindow.hpp @@ -47,6 +47,8 @@ public slots: void setBackground(const QColor& color); void setAdditionalCss(const QString& css); void setFrameless(const bool frameless); + // "light", "dark", or anything else for the system's; applies right away (to web pages and Liquid's own windows) + void setColorScheme(const QString& colorScheme); // Keeps sending session storage of the page that's currently shown over the connection, until it closes void watchSessionStorage(QLocalSocket* connection); void removeSessionStorageItems(const QString& origin, const QStringList& keys); @@ -102,6 +104,13 @@ public slots: // Remembers what websites keep in local storage (if allowed to) LiquidAppLocalStorage* liquidAppLocalStorage = Q_NULLPTR; + + // Web pages can only be told which color scheme to use upon start: switching it while running is done by emulating it + // through a hidden DevTools frontend (in-process, nothing gets exposed), kept only while it differs from the initial one + bool isDarkUponStart = false; + QWebEnginePage* colorSchemeDevToolsPage = Q_NULLPTR; + bool isEmulatingDark = false; + void sendColorSchemeEmulation(void); QList> sessionStorageWatchers; void sendSessionStorage(QLocalSocket* connection); diff --git a/inc/lqd.h b/inc/lqd.h index 01eb55d..14805d2 100644 --- a/inc/lqd.h +++ b/inc/lqd.h @@ -35,6 +35,7 @@ #define LQD_INSTANCE_MSG_RELOAD_SETTINGS "reload-settings" // Liquid apps only #define LQD_INSTANCE_MSG_PREVIEW_BG "preview-background" // Liquid apps only, followed by a space and #AARRGGBB (or "none") #define LQD_INSTANCE_MSG_PREVIEW_CSS "preview-css" // Liquid apps only, followed by a space and base64-encoded CSS +#define LQD_INSTANCE_MSG_PREVIEW_SCHEME "preview-color-scheme" // Liquid apps only, followed by a space and "light", "dark", or "system" #define LQD_INSTANCE_MSG_PREVIEW_FRAME "preview-frame" // Liquid apps only, followed by a space and "on" or "off" #define LQD_INSTANCE_MSG_PREVIEW_SCROLL "preview-scrollbars" // Liquid apps only, followed by a space and "on" or "off" #define LQD_INSTANCE_MSG_ICON_CHANGED "icon-changed" // Main window only, followed by a space and Liquid app's name @@ -65,6 +66,7 @@ #define LQD_FIND_BOX_HEIGHT 42 // Pixels #define LQD_FIND_BOX_MARGIN 16 // Pixels, between the search box and the edges of small windows #define LQD_TYPE_TO_FIND_DELAY 50 // Milliseconds to wait for the page to handle typed keys first +#define LQD_COLOR_SCHEME_EMULATION_DELAY 1000 // Milliseconds for the hidden DevTools frontend to settle before switching color schemes #define LQD_INSTANCE_STOP_TIMEOUT 5000 // Milliseconds to wait for a Liquid app to quit /* Names of scripts Liquid injects into web pages */ @@ -85,6 +87,10 @@ #define LQD_ICON_RUN "➤" #define LQD_ICON_STOP "■" +/* Color schemes */ +#define LQD_COLOR_SCHEME_LIGHT "light" +#define LQD_COLOR_SCHEME_DARK "dark" + /* Liquid App config file group names */ #define LQD_CFG_GROUP_NAME_COOKIES "Cookies" #define LQD_CFG_GROUP_NAME_PROXY "Proxy" @@ -101,6 +107,7 @@ #define LQD_CFG_KEY_NAME_ADDITIONAL_JS "AdditionalJS" // text #define LQD_CFG_KEY_NAME_ALLOW_COOKIES "AllowCookies" // boolean, defults to FALSE #define LQD_CFG_KEY_NAME_ALLOW_3RD_PARTY_COOKIES "AllowThirdPartyCookies" // boolean, defaults to FALSE +#define LQD_CFG_KEY_NAME_COLOR_SCHEME "ColorScheme" // text: "light" or "dark", system's if unset #define LQD_CFG_KEY_NAME_CUSTOM_BG_COLOR "CustomBackgroundColor" // text #define LQD_CFG_KEY_NAME_ENABLE_JS "EnableJS" // boolean, defaults to FALSE #define LQD_CFG_KEY_NAME_HIDE_SCROLLBARS "HideScrollBars" // boolean, defaults to FALSE diff --git a/src/liquid.cpp b/src/liquid.cpp index b2912e1..1ecf192 100644 --- a/src/liquid.cpp +++ b/src/liquid.cpp @@ -147,7 +147,29 @@ void Liquid::updateDesktopFileIcon(const QString liquidAppName) #endif } +// Whether dark mode is on (1) or off (0) for this program, if not up to the system (-1) +static int darkModeOverride = -1; + +void Liquid::overrideDarkMode(const bool isDark) +{ + darkModeOverride = (isDark) ? 1 : 0; +} + +void Liquid::clearDarkModeOverride(void) +{ + darkModeOverride = -1; +} + bool Liquid::detectDarkMode(void) +{ + if (darkModeOverride >= 0) { + return darkModeOverride == 1; + } + + return detectSystemDarkMode(); +} + +bool Liquid::detectSystemDarkMode(void) { // Detecting it may involve spawning a process, do it only once per program run static int cachedResult = -1; @@ -315,6 +337,10 @@ void Liquid::removeDesktopFile(const QString liquidAppName) #endif } +// Web engine flags as they were upon start: Liquid apps add their own (e.g. for dark mode), which must not get passed on +// to Liquid apps they run (e.g. upon restarting, with a different color scheme) +static const QByteArray initialChromiumFlags = qgetenv("QTWEBENGINE_CHROMIUM_FLAGS"); + void Liquid::runLiquidApp(const QString liquidAppName, const QString activationToken) { QProcess process; @@ -327,6 +353,10 @@ void Liquid::runLiquidApp(const QString liquidAppName, const QString activationT if (!activationToken.isEmpty()) { environment.insert("XDG_ACTIVATION_TOKEN", activationToken); } + environment.remove("QTWEBENGINE_CHROMIUM_FLAGS"); + if (!initialChromiumFlags.isEmpty()) { + environment.insert("QTWEBENGINE_CHROMIUM_FLAGS", QString::fromLocal8Bit(initialChromiumFlags)); + } process.setProcessEnvironment(environment); process.startDetached(); diff --git a/src/liquidappconfigwindow.cpp b/src/liquidappconfigwindow.cpp index 2703092..41312c6 100644 --- a/src/liquidappconfigwindow.cpp +++ b/src/liquidappconfigwindow.cpp @@ -376,6 +376,37 @@ LiquidAppConfigDialog::LiquidAppConfigDialog(QWidget* parent, QString liquidAppN appearanceTabWidget->setLayout(appearanceTabWidgetLayout); tabWidget->addTab(appearanceTabWidget, tr("Appearance")); + // Color scheme + { + QHBoxLayout* colorSchemeLayout = new QHBoxLayout(); + + QLabel* colorSchemeLabel = new QLabel(tr("Color scheme:"), this); + colorSchemeLayout->addWidget(colorSchemeLabel); + + colorSchemeComboBox = new QComboBox(this); + colorSchemeComboBox->setCursor(Qt::PointingHandCursor); + colorSchemeComboBox->addItem(tr("System"), QString()); + colorSchemeComboBox->addItem(tr("Light"), QStringLiteral(LQD_COLOR_SCHEME_LIGHT)); + colorSchemeComboBox->addItem(tr("Dark"), QStringLiteral(LQD_COLOR_SCHEME_DARK)); + + if (isEditingExistingBool) { + const int index = colorSchemeComboBox->findData(existingLiquidAppConfig->value(LQD_CFG_KEY_NAME_COLOR_SCHEME).toString()); + colorSchemeComboBox->setCurrentIndex((index >= 0) ? index : 0); + } + + // The running Liquid app switches right away + connect(colorSchemeComboBox, QOverload::of(&QComboBox::currentIndexChanged), this, [this]() { + const QString colorScheme = colorSchemeComboBox->currentData().toString(); + sendPreview(QByteArray(LQD_INSTANCE_MSG_PREVIEW_SCHEME " ") + ((colorScheme.isEmpty()) ? QByteArray("system") : colorScheme.toUtf8())); + }); + + colorSchemeLayout->addWidget(colorSchemeComboBox); + colorSchemeLayout->addStretch(); + + appearanceTabWidgetLayout->addLayout(colorSchemeLayout); + appearanceTabWidgetLayout->addWidget(separator()); + } + // Hide scrollbars checkbox #if QT_VERSION >= QT_VERSION_CHECK(5, 10, 0) { @@ -1122,6 +1153,17 @@ void LiquidAppConfigDialog::save() } #if QT_VERSION >= QT_VERSION_CHECK(5, 10, 0) + // Color scheme (the system's, unless chosen otherwise) + { + const QString colorScheme = colorSchemeComboBox->currentData().toString(); + + if (colorScheme.isEmpty()) { + tempLiquidAppConfig->remove(LQD_CFG_KEY_NAME_COLOR_SCHEME); + } else { + tempLiquidAppConfig->setValue(LQD_CFG_KEY_NAME_COLOR_SCHEME, colorScheme); + } + } + // Hide scrollbars { if (isEditingExistingBool) { diff --git a/src/liquidappfile.cpp b/src/liquidappfile.cpp index 8bcdc40..f58a9c2 100644 --- a/src/liquidappfile.cpp +++ b/src/liquidappfile.cpp @@ -53,6 +53,7 @@ const AttributeMapping ATTRIBUTE_MAPPINGS[] = { { LQD_CFG_KEY_NAME_HIDE_SCROLLBARS, "viewport", "hide-scrollbars" }, { LQD_CFG_KEY_NAME_USE_CUSTOM_BG, "viewport", "custom-background" }, { LQD_CFG_KEY_NAME_CUSTOM_BG_COLOR, "viewport", "background-color" }, + { LQD_CFG_KEY_NAME_COLOR_SCHEME, "viewport", "color-scheme" }, { LQD_CFG_KEY_NAME_MUTE_AUDIO, "audio", "muted" }, { LQD_CFG_KEY_NAME_USE_PROXY, "proxy", "enabled" }, { LQD_CFG_KEY_NAME_PROXY_USE_SOCKS, "proxy", "socks" }, diff --git a/src/liquidappwindow.cpp b/src/liquidappwindow.cpp index 1626f8a..043b7ac 100644 --- a/src/liquidappwindow.cpp +++ b/src/liquidappwindow.cpp @@ -2,6 +2,7 @@ #include #include +#include #include #include #include @@ -125,16 +126,6 @@ LiquidAppWindow::LiquidAppWindow(const QString* name) : QWebEngineView() qputenv("QTWEBENGINE_CHROMIUM_FLAGS", qgetenv("QTWEBENGINE_CHROMIUM_FLAGS") + " " + "--num-raster-threads=4"); qputenv("QTWEBENGINE_CHROMIUM_FLAGS", qgetenv("QTWEBENGINE_CHROMIUM_FLAGS") + " " + "--use-fake-ui-for-media-stream"); -#if QT_VERSION >= QT_VERSION_CHECK(5, 14, 0) - // Ensure dark mode is enabled on the web page in case the system theme is dark - if (Liquid::detectDarkMode()) { - qputenv("QTWEBENGINE_CHROMIUM_FLAGS", qgetenv("QTWEBENGINE_CHROMIUM_FLAGS") + " " + "--force-dark-mode"); - qputenv("QTWEBENGINE_CHROMIUM_FLAGS", qgetenv("QTWEBENGINE_CHROMIUM_FLAGS") + " " + "--blink-settings=forceDarkModeEnabled=true"); - qputenv("QTWEBENGINE_CHROMIUM_FLAGS", qgetenv("QTWEBENGINE_CHROMIUM_FLAGS") + " " + "--blink-settings=darkMode=4"); - qputenv("QTWEBENGINE_CHROMIUM_FLAGS", qgetenv("QTWEBENGINE_CHROMIUM_FLAGS") + " " + "--blink-settings=darkModeEnabled=true"); - } -#endif - // Set default icon #if !defined(Q_OS_LINUX) // This doesn't work on X11 setWindowIcon(QIcon(":/images/" PROG_NAME ".svg")); @@ -154,6 +145,27 @@ LiquidAppWindow::LiquidAppWindow(const QString* name) : QWebEngineView() liquidAppConfig = LiquidAppFile::open(*name); + // Color scheme: the one chosen for this Liquid app, or the system's; it applies to this Liquid app's own windows + // (e.g. its settings), and to web pages (which can only be told upon start, before the web engine gets going) + { + const QString colorScheme = liquidAppConfig->value(LQD_CFG_KEY_NAME_COLOR_SCHEME).toString(); + + if (colorScheme == LQD_COLOR_SCHEME_DARK || colorScheme == LQD_COLOR_SCHEME_LIGHT) { + Liquid::overrideDarkMode(colorScheme == LQD_COLOR_SCHEME_DARK); + } + + isDarkUponStart = Liquid::detectDarkMode(); + +#if QT_VERSION >= QT_VERSION_CHECK(5, 14, 0) + if (isDarkUponStart) { + qputenv("QTWEBENGINE_CHROMIUM_FLAGS", qgetenv("QTWEBENGINE_CHROMIUM_FLAGS") + " " + "--force-dark-mode"); + qputenv("QTWEBENGINE_CHROMIUM_FLAGS", qgetenv("QTWEBENGINE_CHROMIUM_FLAGS") + " " + "--blink-settings=forceDarkModeEnabled=true"); + qputenv("QTWEBENGINE_CHROMIUM_FLAGS", qgetenv("QTWEBENGINE_CHROMIUM_FLAGS") + " " + "--blink-settings=darkMode=4"); + qputenv("QTWEBENGINE_CHROMIUM_FLAGS", qgetenv("QTWEBENGINE_CHROMIUM_FLAGS") + " " + "--blink-settings=darkModeEnabled=true"); + } +#endif + } + // These default settings affect everything (including sub-frames) #if QT_VERSION < QT_VERSION_CHECK(6, 0, 0) LiquidAppWebPage::setWebSettingsToDefault(QWebEngineSettings::globalSettings()); @@ -1063,6 +1075,9 @@ void LiquidAppWindow::reloadLiquidAppConfig(void) maybeFetchIcon(); } + // Color scheme + setColorScheme(liquidAppConfig->value(LQD_CFG_KEY_NAME_COLOR_SCHEME).toString()); + // Local storage (may have been allowed or disallowed to be remembered, or had its items edited) liquidAppLocalStorage->reloadConfig(); @@ -2087,3 +2102,80 @@ void LiquidAppWindow::onTypedOutsideOfInputs(QKeyEvent* event) typeToFindTimer->start(); } } + +void LiquidAppWindow::setColorScheme(const QString& colorScheme) +{ + const bool isChosen = colorScheme == LQD_COLOR_SCHEME_DARK || colorScheme == LQD_COLOR_SCHEME_LIGHT; + const bool isDark = (isChosen) ? colorScheme == LQD_COLOR_SCHEME_DARK : Liquid::detectSystemDarkMode(); + + // Liquid's own windows (e.g. settings opened from this Liquid app) + if (isChosen) { + Liquid::overrideDarkMode(isDark); + } else { + Liquid::clearDarkModeOverride(); + } + for (QWidget* widget : QApplication::topLevelWidgets()) { + if (widget == this || qobject_cast(widget) != Q_NULLPTR) { + Liquid::applyQtStyleSheets(widget); + } + } + + // Web pages: as they were told upon start, or emulated + if (isDark == isDarkUponStart) { + if (colorSchemeDevToolsPage != Q_NULLPTR) { + // Detaching ends the emulation + page()->setDevToolsPage(Q_NULLPTR); + colorSchemeDevToolsPage->deleteLater(); + colorSchemeDevToolsPage = Q_NULLPTR; + } + return; + } + + isEmulatingDark = isDark; + + if (colorSchemeDevToolsPage == Q_NULLPTR) { + colorSchemeDevToolsPage = new QWebEnginePage(liquidAppWebProfile, this); + // The DevTools frontend is made of JavaScript (and so is how it gets told what to do) + colorSchemeDevToolsPage->settings()->setAttribute(QWebEngineSettings::JavascriptEnabled, true); + + // Once it's loaded (and done setting itself up, which includes resetting emulation), and then once more, + // just in case it's taken longer than usual + connect(colorSchemeDevToolsPage, &QWebEnginePage::loadFinished, this, [this]() { + QTimer::singleShot(LQD_COLOR_SCHEME_EMULATION_DELAY, this, &LiquidAppWindow::sendColorSchemeEmulation); + QTimer::singleShot(LQD_COLOR_SCHEME_EMULATION_DELAY * 3, this, &LiquidAppWindow::sendColorSchemeEmulation); + }); + + // Loads the DevTools frontend into it, attached to this Liquid app's page + page()->setDevToolsPage(colorSchemeDevToolsPage); + } else { + sendColorSchemeEmulation(); + } +} + +void LiquidAppWindow::sendColorSchemeEmulation(void) +{ + if (colorSchemeDevToolsPage == Q_NULLPTR) { + return; + } + + // A DevTools protocol command, passed to the page through the frontend's own (in-process) channel + static int commandId = 1000000; // Far from the frontend's own command IDs + QJsonObject feature; + feature.insert("name", "prefers-color-scheme"); + feature.insert("value", (isEmulatingDark) ? "dark" : "light"); + QJsonObject params; + params.insert("features", QJsonArray() << feature); + QJsonObject command; + command.insert("id", ++commandId); + command.insert("method", "Emulation.setEmulatedMedia"); + command.insert("params", params); + + QJsonObject message; + message.insert("id", commandId); + message.insert("method", "dispatchProtocolMessage"); + message.insert("params", QJsonArray() << QString::fromUtf8(QJsonDocument(command).toJson(QJsonDocument::Compact))); + + const QString quotedMessage = QString::fromUtf8(QJsonDocument(QJsonArray() << QString::fromUtf8(QJsonDocument(message).toJson(QJsonDocument::Compact))) + .toJson(QJsonDocument::Compact)).mid(1).chopped(1); + colorSchemeDevToolsPage->runJavaScript(QString("window.DevToolsHost && DevToolsHost.sendMessageToEmbedder(%1);").arg(quotedMessage)); +} diff --git a/src/main.cpp b/src/main.cpp index 58dddd4..ab32069 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -184,6 +184,9 @@ int main(int argc, char **argv) // Temporary, while the settings are being edited (reloading settings brings back the saved one) const QString color = QString::fromUtf8(message.mid(qstrlen(LQD_INSTANCE_MSG_PREVIEW_BG " "))).trimmed(); liquidAppWindow->setBackground((color == "none") ? QColor() : QColor(color)); + } else if (message.startsWith(LQD_INSTANCE_MSG_PREVIEW_SCHEME " ")) { + // Temporary, while the settings are being edited (reloading settings brings back the saved one) + liquidAppWindow->setColorScheme(QString::fromUtf8(message.mid(qstrlen(LQD_INSTANCE_MSG_PREVIEW_SCHEME " "))).trimmed()); } else if (message.startsWith(LQD_INSTANCE_MSG_PREVIEW_FRAME " ")) { // Temporary, while the settings are being edited (reloading settings brings back the saved one) liquidAppWindow->setFrameless(message.mid(qstrlen(LQD_INSTANCE_MSG_PREVIEW_FRAME " ")).trimmed() == "off");