diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 640773f..f76be3d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,14 +40,14 @@ jobs: - name: Resolve dependencies (Ubuntu, Qt 5) run: | sudo apt-get update - sudo apt-get install -y qtbase5-dev qtwebengine5-dev qt5-qmake + sudo apt-get install -y qtbase5-dev qtwebengine5-dev qt5-qmake libx11-dev echo "QMAKE=qmake" >> "$GITHUB_ENV" if: runner.os == 'Linux' && matrix.qt == 5 - name: Resolve dependencies (Ubuntu, Qt 6) run: | sudo apt-get update - sudo apt-get install -y qt6-base-dev qt6-webengine-dev qmake6 libgl-dev + sudo apt-get install -y qt6-base-dev qt6-webengine-dev qmake6 libgl-dev libx11-dev echo "QMAKE=qmake6" >> "$GITHUB_ENV" if: runner.os == 'Linux' && matrix.qt == 6 diff --git a/README.md b/README.md index 79279e0..187855c 100644 --- a/README.md +++ b/README.md @@ -81,9 +81,11 @@ Liquid is built with Qt 6 (Qt 5.15 is still supported) and Qt WebEngine. On Debian/Ubuntu: ```console -sudo apt install qt6-base-dev qt6-webengine-dev qmake6 build-essential +sudo apt install qt6-base-dev qt6-webengine-dev qmake6 libx11-dev build-essential ``` +Optionally, also install `qt6-base-private-dev` (requires Qt 6.5 or newer): it lets Liquid bring windows of already running Liquid apps to front on Wayland, instead of the desktop environment merely showing a notification. + On macOS and Windows, install Qt 6 with the Qt WebEngine, Qt WebChannel, and Qt Positioning modules using the [Qt Online Installer](https://www.qt.io/download-qt-installer). #### Build diff --git a/inc/liquid.hpp b/inc/liquid.hpp index e26dc8f..ed88b7c 100644 --- a/inc/liquid.hpp +++ b/inc/liquid.hpp @@ -1,5 +1,7 @@ #pragma once +#include + #include #include #include @@ -17,9 +19,12 @@ class Liquid static QString getLiquidAppFilePath(const QString liquidAppName); static QString getDefaultUserAgentString(void); static QStringList getLiquidAppsList(void); - static void raiseWindow(QWidget* window); + static void raiseWindow(QWidget* window, const QString activationToken = QString()); + static void requestActivationToken(QWidget* window, const std::function callback); + static bool parseRaiseMessage(const QByteArray& message, QString* activationToken); + static QByteArray makeRaiseMessage(const QString& activationToken); static QString getUniqueFilePath(const QString dirPath, const QString baseName, const QString extension); static void removeDesktopFile(const QString liquidAppName); - static void runLiquidApp(const QString liquidAppName); + static void runLiquidApp(const QString liquidAppName, const QString activationToken = QString()); static void sleep(const int ms); }; diff --git a/inc/liquidappconfigwindow.hpp b/inc/liquidappconfigwindow.hpp index 7e44562..1679d3b 100644 --- a/inc/liquidappconfigwindow.hpp +++ b/inc/liquidappconfigwindow.hpp @@ -28,6 +28,7 @@ public slots: private: void bindShortcuts(void); static QFrame* separator(void); + static QString getSettingsRequiringRestart(QSettings* liquidAppConfig); bool isEditingExistingBool = false; bool isPlanningToRunBool = false; diff --git a/inc/liquidappwebpage.hpp b/inc/liquidappwebpage.hpp index 545fd5e..b37b6c7 100644 --- a/inc/liquidappwebpage.hpp +++ b/inc/liquidappwebpage.hpp @@ -17,6 +17,7 @@ class LiquidAppWebPage : public QWebEnginePage void addAllowedDomain(const QString domain); void addAllowedDomains(const QStringList domainList); + void setAllowedDomains(const QStringList domainList); void closeJsDialog(); static void setWebSettingsToDefault(QWebEngineSettings* webSettings); diff --git a/inc/liquidappwindow.hpp b/inc/liquidappwindow.hpp index b581ed3..a0e36f5 100644 --- a/inc/liquidappwindow.hpp +++ b/inc/liquidappwindow.hpp @@ -36,6 +36,7 @@ public slots: void hardReload(void); void loadFinished(bool ok); void loadStarted(void); + void reloadLiquidAppConfig(void); void onIconChanged(QIcon icon); void stopLoadingOrExitFullScreenMode(void); void takeSnapshotSlot(void); @@ -79,6 +80,7 @@ public slots: LiquidAppWebPage* liquidAppWebPage = Q_NULLPTR; QWebEngineProfile* liquidAppWebProfile = Q_NULLPTR; QWebEngineSettings* liquidAppWebSettings = Q_NULLPTR; + QString defaultHttpUserAgent; QRect rememberedGeometry; // Window geometry while in normal state (neither maximized nor full-screen) QTimer* rememberGeometryTimer = Q_NULLPTR; QList zoomFactors; @@ -133,5 +135,9 @@ public slots: void bindKeyboardShortcuts(void); void loadLiquidAppConfig(void); void saveLiquidAppConfig(void); + void applyAllowedDomains(void); + void applyBackgroundColor(void); + void applyWebSettings(void); + void applyAdditionalCssAndJs(void); void setupContextMenu(void); }; diff --git a/inc/liquidx11.h b/inc/liquidx11.h new file mode 100644 index 0000000..02e3caf --- /dev/null +++ b/inc/liquidx11.h @@ -0,0 +1,5 @@ +#pragma once + +// Asks the X11 window manager to activate (un-minimize, raise, and focus) the given window, +// the same way taskbars do it, which window managers honor even for windows that weren't recently interacted with +void liquidX11ActivateWindow(const unsigned long windowId); diff --git a/inc/lqd.h b/inc/lqd.h index af8d34c..b343b6e 100644 --- a/inc/lqd.h +++ b/inc/lqd.h @@ -29,7 +29,16 @@ #define LQD_INSTANCE_ID_APP_PREFIX "app/" /* Messages that can be sent to running instances */ -#define LQD_INSTANCE_MSG_RAISE "raise" +#define LQD_INSTANCE_MSG_RAISE "raise" +#define LQD_INSTANCE_MSG_QUIT "quit" // Liquid apps only +#define LQD_INSTANCE_MSG_RELOAD_SETTINGS "reload-settings" // Liquid apps only +#define LQD_INSTANCE_MSG_STARTED "started" // Main window only, followed by a space and Liquid app's name +#define LQD_ACTIVATION_TOKEN_TIMEOUT 500 // Milliseconds to wait for the compositor to issue an activation token +#define LQD_INSTANCE_STOP_TIMEOUT 5000 // Milliseconds to wait for a Liquid app to quit + +/* Names of scripts Liquid injects into web pages */ +#define LQD_SCRIPT_NAME_ADDITIONAL_CSS "liquid-additional-css" +#define LQD_SCRIPT_NAME_ADDITIONAL_JS "liquid-additional-js" /* Textual icons */ #define LQD_ICON_ADD "➕" @@ -41,6 +50,7 @@ #define LQD_ICON_WARNING "⚠️" #define LQD_ICON_DELETE "✖" #define LQD_ICON_RUN "➤" +#define LQD_ICON_STOP "■" /* Liquid App config file group names */ #define LQD_CFG_GROUP_NAME_COOKIES "Cookies" diff --git a/inc/mainwindow.hpp b/inc/mainwindow.hpp index 9a7e5dc..766f5f2 100644 --- a/inc/mainwindow.hpp +++ b/inc/mainwindow.hpp @@ -1,6 +1,8 @@ #pragma once #include +#include +#include #include #include #include @@ -12,6 +14,9 @@ class MainWindow : public QScrollArea MainWindow(); ~MainWindow(); + // Starts keeping track of the Liquid app by the given name (called when Liquid apps report being started) + void watchLiquidApp(const QString& liquidAppName); + protected: void closeEvent(QCloseEvent *event) override; @@ -19,12 +24,21 @@ class MainWindow : public QScrollArea void bindShortcuts(void); void flushTable(void); void populateTable(void); - void runLiquidApp(const QString liquidAppName); void saveSettings(void); + bool isLiquidAppRunning(const QString& liquidAppName); + void sendToLiquidApp(const QString& liquidAppName, const QByteArray& message); + bool stopLiquidApp(const QString& liquidAppName, const bool waitUntilStopped); + void updateRunButton(const QString& liquidAppName); + QTableWidget* appListTable; QPushButton* createNewLiquidAppButton; QSettings* settings; QAction* quitAction; + + // Connections to running Liquid apps (they get closed by the system once a Liquid app quits or crashes) + QMap liquidAppConnections; + // Run/Stop buttons of Liquid apps currently listed in the table + QMap runButtons; }; diff --git a/inc/singleinstance.hpp b/inc/singleinstance.hpp index 407034b..d568a87 100644 --- a/inc/singleinstance.hpp +++ b/inc/singleinstance.hpp @@ -22,12 +22,16 @@ class SingleInstance : public QObject // Delivers a message to the running instance; returns false if there's none running static bool sendMessage(const QString& instanceId, const QByteArray& message); + // Tells whether an instance with the given ID is currently running + static bool isRunning(const QString& instanceId); + + // Name of the local socket (named pipe on Windows) the instance with the given ID listens on + static QString serverName(const QString& instanceId); + signals: void messageReceived(const QByteArray& message); private: - static QString serverName(const QString& instanceId); - QString instanceId; QLocalServer* server = Q_NULLPTR; }; diff --git a/liquid.pro b/liquid.pro index 5d10d64..57cf124 100644 --- a/liquid.pro +++ b/liquid.pro @@ -65,6 +65,21 @@ DEFINES += VERSION=\\\"$${VERSION}\\\" # GNU/Linux, FreeBSD, and similar unix:!mac { + # Needed for bringing windows to front under X11 + HEADERS += inc/liquidx11.h + SOURCES += src/liquidx11.cpp + LIBS += -lX11 + + # Bringing windows to front on Wayland requires private Qt API (6.5+), use it if available + greaterThan(QT_MAJOR_VERSION, 5) { + exists($$[QT_INSTALL_HEADERS]/QtGui/$$[QT_VERSION]/QtGui/qpa/qplatformwindow_p.h) { + QT += gui-private + DEFINES += LQD_WAYLAND_ACTIVATION + } else { + message("Qt's private headers not found (e.g. qt6-base-private-dev), windows won't be raised on Wayland") + } + } + isEmpty(PREFIX) { PREFIX = /usr } diff --git a/res/styles/dark.qss b/res/styles/dark.qss index a328232..30ad2cc 100644 --- a/res/styles/dark.qss +++ b/res/styles/dark.qss @@ -19,6 +19,9 @@ QTableWidget QPushButton.liquidAppsListButtonEdit:hover { QTableWidget QPushButton.liquidAppsListButtonRun:hover { color: rgba(0, 125, 255, 0.8); } +QTableWidget QPushButton.liquidAppsListButtonStop:hover { + color: rgba(255, 140, 0, 0.9); +} QTableWidget::item { background-color: qlineargradient(spread:pad, x1:1, y1:1, x2:1, y2:0, stop:0 rgba(0, 0, 0, 255), stop:1 rgba(20, 20, 20, 255)); diff --git a/res/styles/light.qss b/res/styles/light.qss index 5f92e0d..cc78c89 100644 --- a/res/styles/light.qss +++ b/res/styles/light.qss @@ -30,3 +30,6 @@ QTableWidget QPushButton.liquidAppsListButtonEdit:hover { QTableWidget QPushButton.liquidAppsListButtonRun:hover { background-color: rgba(0, 0, 255, 0.1); } +QTableWidget QPushButton.liquidAppsListButtonStop:hover { + background-color: rgba(255, 140, 0, 0.25); +} diff --git a/src/liquid.cpp b/src/liquid.cpp index 87cf1e1..dcc908f 100644 --- a/src/liquid.cpp +++ b/src/liquid.cpp @@ -1,5 +1,8 @@ #include "lqd.h" #include "liquid.hpp" +#if defined(Q_OS_UNIX) && !defined(Q_OS_MACOS) +#include "liquidx11.h" +#endif #include #include @@ -13,6 +16,18 @@ #include #include #include +#include +#include + +#if defined(LQD_WAYLAND_ACTIVATION) && QT_VERSION >= QT_VERSION_CHECK(6, 5, 0) +#include +#include +#include +// Qt 6.5 makes the interface available on all Unix-like systems, later versions only when built with Wayland support +#if !defined(QT_FEATURE_wayland) || QT_FEATURE_wayland == 1 +#define LQD_CAN_REQUEST_ACTIVATION_TOKEN +#endif +#endif #if defined(Q_OS_MAC) #include @@ -209,17 +224,32 @@ QString Liquid::getUniqueFilePath(const QString dirPath, const QString baseName, return filePath; } -void Liquid::raiseWindow(QWidget* window) +void Liquid::raiseWindow(QWidget* window, const QString activationToken) { if (window == Q_NULLPTR) { return; } + // On Wayland, windows may only be brought to front using a token obtained by whoever the user is interacting with + // (otherwise the compositor shows an "is ready" notification instead); Qt picks it up from this environment variable + if (!activationToken.isEmpty()) { + qputenv("XDG_ACTIVATION_TOKEN", activationToken.toUtf8()); + } + // Un-minimize (without affecting full-screen or maximized state), then bring to front window->setWindowState(window->windowState() & ~Qt::WindowMinimized); window->show(); window->raise(); window->activateWindow(); + +#if defined(Q_OS_UNIX) && !defined(Q_OS_MACOS) + // X11 window managers (e.g. GNOME's) ignore activation requests Qt makes on behalf of windows that weren't interacted + // with recently (showing an "is ready" notification instead), so ask them the same way taskbars do + // (Qt 5 always ends up using X11 on GNOME, even in Wayland sessions) + if (QGuiApplication::platformName() == "xcb") { + liquidX11ActivateWindow(window->effectiveWinId()); + } +#endif } void Liquid::removeDesktopFile(const QString liquidAppName) @@ -241,9 +271,94 @@ void Liquid::removeDesktopFile(const QString liquidAppName) #endif } -void Liquid::runLiquidApp(const QString liquidAppName) +void Liquid::runLiquidApp(const QString liquidAppName, const QString activationToken) { - QProcess::startDetached(QCoreApplication::applicationFilePath(), QStringList() << QStringLiteral("%1").arg(liquidAppName)); + QProcess process; + process.setProgram(QCoreApplication::applicationFilePath()); + process.setArguments(QStringList() << liquidAppName); + + // Lets the new Liquid app's window show up in front on Wayland (see raiseWindow()) + QProcessEnvironment environment = QProcessEnvironment::systemEnvironment(); + environment.remove("XDG_ACTIVATION_TOKEN"); + if (!activationToken.isEmpty()) { + environment.insert("XDG_ACTIVATION_TOKEN", activationToken); + } + process.setProcessEnvironment(environment); + + process.startDetached(); +} + +void Liquid::requestActivationToken(QWidget* window, const std::function callback) +{ +#if defined(LQD_CAN_REQUEST_ACTIVATION_TOKEN) + // Ask the compositor for a token on behalf of the window the user is interacting with (requires private Qt API) + using QNativeInterface::Private::QWaylandWindow; + + QWindow* windowHandle = (window != Q_NULLPTR) ? window->window()->windowHandle() : Q_NULLPTR; + QWaylandWindow* waylandWindow = (windowHandle != Q_NULLPTR) ? windowHandle->nativeInterface() : Q_NULLPTR; + auto* waylandApplication = qGuiApp->nativeInterface(); + + if (waylandWindow != Q_NULLPTR && waylandApplication != Q_NULLPTR) { + // The callback gets called exactly once: either with the token, or without one if the compositor doesn't respond + const auto isDone = std::make_shared(false); + const auto connection = std::make_shared(); + + *connection = QObject::connect(waylandWindow, &QWaylandWindow::xdgActivationTokenCreated, window, + [isDone, connection, callback](const QString& token) { + QObject::disconnect(*connection); + + if (!*isDone) { + *isDone = true; + callback(token); + } + }); + + QTimer::singleShot(LQD_ACTIVATION_TOKEN_TIMEOUT, window, [isDone, connection, callback]() { + QObject::disconnect(*connection); + + if (!*isDone) { + *isDone = true; + callback(QString()); + } + }); + + waylandWindow->requestXdgActivationToken(waylandApplication->lastInputSerial()); + return; + } +#else + Q_UNUSED(window); +#endif + + callback(QString()); +} + +bool Liquid::parseRaiseMessage(const QByteArray& message, QString* activationToken) +{ + // Either just "raise", or "raise " + static const QByteArray prefix = QByteArray(LQD_INSTANCE_MSG_RAISE) + " "; + + if (message == LQD_INSTANCE_MSG_RAISE) { + activationToken->clear(); + return true; + } + + if (message.startsWith(prefix)) { + *activationToken = QString::fromUtf8(message.mid(prefix.size())).trimmed(); + return true; + } + + return false; +} + +QByteArray Liquid::makeRaiseMessage(const QString& activationToken) +{ + QByteArray message = LQD_INSTANCE_MSG_RAISE; + + if (!activationToken.isEmpty()) { + message += " " + activationToken.toUtf8(); + } + + return message; } void Liquid::sleep(const int ms) diff --git a/src/liquidappconfigwindow.cpp b/src/liquidappconfigwindow.cpp index 678e9f0..d8a88cb 100644 --- a/src/liquidappconfigwindow.cpp +++ b/src/liquidappconfigwindow.cpp @@ -1,4 +1,5 @@ #include +#include #include #include @@ -7,6 +8,7 @@ #include "liquidappconfigwindow.hpp" #include "lqd.h" #include "mainwindow.hpp" +#include "singleinstance.hpp" LiquidAppConfigDialog::LiquidAppConfigDialog(QWidget* parent, QString liquidAppName) : QDialog(parent) { @@ -813,6 +815,9 @@ void LiquidAppConfigDialog::save() QSettings* tempLiquidAppConfig = LiquidAppFile::open(appName); + // Some settings can't be changed while the Liquid app is running, remember them to tell whether it needs a restart + const QString settingsRequiringRestartBefore = getSettingsRequiringRestart(tempLiquidAppConfig); + // URL { QUrl url(QUrl::fromUserInput(addressInput->text())); @@ -1148,9 +1153,60 @@ void LiquidAppConfigDialog::save() tempLiquidAppConfig->sync(); + // Apply new settings to the Liquid app if it's running + if (isEditingExistingBool) { + const QString instanceId = LQD_INSTANCE_ID_APP_PREFIX + appName; + + if (getSettingsRequiringRestart(tempLiquidAppConfig) != settingsRequiringRestartBefore) { + // Restart it (wait for it to quit first, it's not possible to have two instances running at once) + if (SingleInstance::sendMessage(instanceId, LQD_INSTANCE_MSG_QUIT)) { + QElapsedTimer timer; + timer.start(); + + while (SingleInstance::isRunning(instanceId) && timer.elapsed() < LQD_INSTANCE_STOP_TIMEOUT) { + Liquid::sleep(100); + } + + if (!SingleInstance::isRunning(instanceId)) { + Liquid::runLiquidApp(appName); + } + } + } else { + SingleInstance::sendMessage(instanceId, LQD_INSTANCE_MSG_RELOAD_SETTINGS); + } + } + + delete tempLiquidAppConfig; + accept(); } +QString LiquidAppConfigDialog::getSettingsRequiringRestart(QSettings* liquidAppConfig) +{ + // Proxy settings get picked up by the web engine only upon start, + // and window transparency can't be turned on or off once the window is shown + static const QStringList keys = { + LQD_CFG_KEY_NAME_USE_PROXY, + LQD_CFG_KEY_NAME_PROXY_USE_SOCKS, + LQD_CFG_KEY_NAME_PROXY_HOST, + LQD_CFG_KEY_NAME_PROXY_PORT, + LQD_CFG_KEY_NAME_PROXY_USE_AUTH, + LQD_CFG_KEY_NAME_PROXY_USER_NAME, + LQD_CFG_KEY_NAME_PROXY_USER_PASSWORD, + }; + + QStringList values; + for (const QString& key : keys) { + values << ((liquidAppConfig->contains(key)) ? liquidAppConfig->value(key).toString() : QStringLiteral("")); + } + + const bool isTranslucent = liquidAppConfig->value(LQD_CFG_KEY_NAME_USE_CUSTOM_BG, false).toBool() + && QColor(liquidAppConfig->value(LQD_CFG_KEY_NAME_CUSTOM_BG_COLOR).toString()).alpha() < 255; + values << ((isTranslucent) ? "translucent" : "opaque"); + + return values.join('\n'); +} + void LiquidAppConfigDialog::bindShortcuts(void) { // Connect keyboard shortcut that closes the dialog diff --git a/src/liquidappwebpage.cpp b/src/liquidappwebpage.cpp index cc01e25..805d21a 100644 --- a/src/liquidappwebpage.cpp +++ b/src/liquidappwebpage.cpp @@ -41,6 +41,11 @@ void LiquidAppWebPage::addAllowedDomains(const QStringList domainsList) { // TODO: remove duplicates from allowedDomainsList } +void LiquidAppWebPage::setAllowedDomains(const QStringList domainsList) { + *allowedDomainsList = domainsList; + allowedDomainsList->removeDuplicates(); +} + bool LiquidAppWebPage::acceptNavigationRequest(const QUrl& reqUrl, const QWebEnginePage::NavigationType navReqType, const bool isMainFrame) { const bool isDomainAllowed = allowedDomainsList->contains(reqUrl.host()); diff --git a/src/liquidappwindow.cpp b/src/liquidappwindow.cpp index 18656da..29eec7d 100644 --- a/src/liquidappwindow.cpp +++ b/src/liquidappwindow.cpp @@ -152,6 +152,7 @@ LiquidAppWindow::LiquidAppWindow(const QString* name) : QWebEngineView() liquidAppWebProfile = new QWebEngineProfile(QString(), this); liquidAppWebProfile->setHttpCacheType(QWebEngineProfile::MemoryHttpCache); liquidAppWebProfile->setPersistentCookiesPolicy(QWebEngineProfile::NoPersistentCookies); + defaultHttpUserAgent = liquidAppWebProfile->httpUserAgent(); if (!liquidAppWebProfile->isOffTheRecord()) { qDebug().noquote() << "Web profile is not off-the-record!"; @@ -641,22 +642,16 @@ void LiquidAppWindow::loadLiquidAppConfig(void) const QColor backgroundColor = QColor(liquidAppConfig->value(LQD_CFG_KEY_NAME_CUSTOM_BG_COLOR).toString()); if (backgroundColor.alpha() < 255) { - // Make window background transparent + // Make window background transparent (can only be done before the window is shown) setAttribute(Qt::WA_TranslucentBackground, true); } - - page()->setBackgroundColor(backgroundColor); - } else { - page()->setBackgroundColor(LQD_DEFAULT_BG_COLOR); } + + applyBackgroundColor(); } // Determine where this Liquid app is allowed to navigate, and what should be opened in external browser - if (liquidAppConfig->contains(LQD_CFG_KEY_NAME_ADDITIONAL_DOMAINS)) { - liquidAppWebPage->addAllowedDomains( - liquidAppConfig->value(LQD_CFG_KEY_NAME_ADDITIONAL_DOMAINS).toString().split(" ") - ); - } + applyAllowedDomains(); // Deal with Cookies { @@ -719,23 +714,8 @@ void LiquidAppWindow::loadLiquidAppConfig(void) } } - // Toggle JavaScript on if enabled in application config - if (liquidAppConfig->contains(LQD_CFG_KEY_NAME_ENABLE_JS)) { - settings()->setAttribute( - QWebEngineSettings::JavascriptEnabled, - liquidAppConfig->value(LQD_CFG_KEY_NAME_ENABLE_JS).toBool() - ); - } - -#if QT_VERSION >= QT_VERSION_CHECK(5, 10, 0) - // Hide scrollbars - if (liquidAppConfig->contains(LQD_CFG_KEY_NAME_HIDE_SCROLLBARS)) { - settings()->setAttribute( - QWebEngineSettings::ShowScrollBars, - !liquidAppConfig->value(LQD_CFG_KEY_NAME_HIDE_SCROLLBARS).toBool() - ); - } -#endif + // JavaScript, scrollbars, user-agent string + applyWebSettings(); // Mute audio if muted in application config if (liquidAppConfig->contains(LQD_CFG_KEY_NAME_MUTE_AUDIO)) { @@ -762,9 +742,68 @@ void LiquidAppWindow::loadLiquidAppConfig(void) } } - // Custom user-agent string - if (liquidAppConfig->contains(LQD_CFG_KEY_NAME_USER_AGENT)) { - liquidAppWebProfile->setHttpUserAgent(liquidAppConfig->value(LQD_CFG_KEY_NAME_USER_AGENT).toString()); + // Additional user-defined CSS and JS + applyAdditionalCssAndJs(); + +#if !defined(Q_OS_LINUX) // This doesn't work on X11 + // Set window icon + if (liquidAppConfig->contains(LQD_CFG_KEY_NAME_ICON)) { + QPixmap pixmap; + pixmap.loadFromData(QByteArray::fromBase64(liquidAppConfig->value(LQD_CFG_KEY_NAME_ICON).toByteArray().remove(0, 22)), "PNG"); + window()->setWindowIcon(QIcon(pixmap)); + } +#endif +} + +void LiquidAppWindow::applyAllowedDomains(void) +{ + // Determine where this Liquid app is allowed to navigate, and what should be opened in external browser + QStringList domains; + domains << QUrl(liquidAppConfig->value(LQD_CFG_KEY_NAME_URL).toString()).host(); + + if (liquidAppConfig->contains(LQD_CFG_KEY_NAME_ADDITIONAL_DOMAINS)) { + domains << liquidAppConfig->value(LQD_CFG_KEY_NAME_ADDITIONAL_DOMAINS).toString().split(' ', Qt::SkipEmptyParts); + } + + liquidAppWebPage->setAllowedDomains(domains); +} + +void LiquidAppWindow::applyBackgroundColor(void) +{ + // Set the page's background color behind the document's body + if (liquidAppConfig->value(LQD_CFG_KEY_NAME_USE_CUSTOM_BG, false).toBool() && liquidAppConfig->contains(LQD_CFG_KEY_NAME_CUSTOM_BG_COLOR)) { + page()->setBackgroundColor(QColor(liquidAppConfig->value(LQD_CFG_KEY_NAME_CUSTOM_BG_COLOR).toString())); + } else { + page()->setBackgroundColor(LQD_DEFAULT_BG_COLOR); + } +} + +void LiquidAppWindow::applyWebSettings(void) +{ + settings()->setAttribute(QWebEngineSettings::JavascriptEnabled, + liquidAppConfig->value(LQD_CFG_KEY_NAME_ENABLE_JS, false).toBool()); + + settings()->setAttribute(QWebEngineSettings::ShowScrollBars, + !liquidAppConfig->value(LQD_CFG_KEY_NAME_HIDE_SCROLLBARS, false).toBool()); + + const QString customUserAgent = liquidAppConfig->value(LQD_CFG_KEY_NAME_USER_AGENT).toString(); + liquidAppWebProfile->setHttpUserAgent((customUserAgent.isEmpty()) ? defaultHttpUserAgent : customUserAgent); +} + +void LiquidAppWindow::applyAdditionalCssAndJs(void) +{ + QWebEngineScriptCollection& scripts = liquidAppWebPage->scripts(); + + // Remove previously added ones + for (const QString& name : { QStringLiteral(LQD_SCRIPT_NAME_ADDITIONAL_CSS), QStringLiteral(LQD_SCRIPT_NAME_ADDITIONAL_JS) }) { +#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) + const QList existingScripts = scripts.find(name); +#else + const QList existingScripts = scripts.findScripts(name); +#endif + for (const QWebEngineScript& existingScript : existingScripts) { + scripts.remove(existingScript); + } } // Additional user-defined CSS (does't require JavaScript enabled in order to work) @@ -777,32 +816,76 @@ void LiquidAppWindow::loadLiquidAppConfig(void) "document.head.appendChild(styleEl)"\ "})();").arg(additionalCss.replace("\n", " ").replace("'", "\\'")); QWebEngineScript script; + script.setName(LQD_SCRIPT_NAME_ADDITIONAL_CSS); script.setInjectionPoint(QWebEngineScript::DocumentReady); script.setRunsOnSubFrames(false); script.setSourceCode(js); script.setWorldId(QWebEngineScript::ApplicationWorld); - liquidAppWebPage->scripts().insert(script); + scripts.insert(script); } // Additional user-defined JS (does't require JavaScript enabled in order to work) if (liquidAppConfig->contains(LQD_CFG_KEY_NAME_ADDITIONAL_JS)) { - QString js = liquidAppConfig->value(LQD_CFG_KEY_NAME_ADDITIONAL_JS).toString(); QWebEngineScript script; + script.setName(LQD_SCRIPT_NAME_ADDITIONAL_JS); script.setInjectionPoint(QWebEngineScript::DocumentReady); script.setRunsOnSubFrames(false); - script.setSourceCode(js); + script.setSourceCode(liquidAppConfig->value(LQD_CFG_KEY_NAME_ADDITIONAL_JS).toString()); script.setWorldId(QWebEngineScript::ApplicationWorld); - liquidAppWebPage->scripts().insert(script); + scripts.insert(script); } +} -#if !defined(Q_OS_LINUX) // This doesn't work on X11 - // Set window icon - if (liquidAppConfig->contains(LQD_CFG_KEY_NAME_ICON)) { - QPixmap pixmap; - pixmap.loadFromData(QByteArray::fromBase64(liquidAppConfig->value(LQD_CFG_KEY_NAME_ICON).toByteArray().remove(0, 22)), "PNG"); - window()->setWindowIcon(QIcon(pixmap)); +void LiquidAppWindow::reloadLiquidAppConfig(void) +{ + // Remember settings that only take effect upon loading the page, to tell whether it needs to be reloaded + static const QStringList pageAffectingKeys = { + LQD_CFG_KEY_NAME_ENABLE_JS, + LQD_CFG_KEY_NAME_USER_AGENT, + LQD_CFG_KEY_NAME_ADDITIONAL_CSS, + LQD_CFG_KEY_NAME_ADDITIONAL_JS, + }; + QVariantList previousValues; + for (const QString& key : pageAffectingKeys) { + previousValues << liquidAppConfig->value(key); + } + const QString previousUrl = liquidAppConfig->value(LQD_CFG_KEY_NAME_URL).toString(); + const bool wasFrameless = liquidAppConfig->value(LQD_CFG_KEY_NAME_REMOVE_WINDOW_FRAME, false).toBool(); + + // Pick up changes made to the file by another process (e.g. the main window) + liquidAppConfig->sync(); + + // Window title + liquidAppWindowTitleIsReadOnly = liquidAppConfig->contains(LQD_CFG_KEY_NAME_TITLE); + if (liquidAppWindowTitleIsReadOnly) { + liquidAppWindowTitle = liquidAppConfig->value(LQD_CFG_KEY_NAME_TITLE).toString(); + } + updateWindowTitle(title()); + + applyAllowedDomains(); + applyBackgroundColor(); + applyWebSettings(); + applyAdditionalCssAndJs(); + + // Window frame (changing window flags hides the window, so it has to be shown again) + const bool isFrameless = liquidAppConfig->value(LQD_CFG_KEY_NAME_REMOVE_WINDOW_FRAME, false).toBool(); + if (isFrameless != wasFrameless) { + setWindowFlag(Qt::FramelessWindowHint, isFrameless); + show(); + } + + // Load the new starting URL, or reload the page if anything it depends on has changed + const QString url = liquidAppConfig->value(LQD_CFG_KEY_NAME_URL).toString(); + if (url != previousUrl) { + load(QUrl(url)); + } else { + for (int i = 0; i < pageAffectingKeys.size(); i++) { + if (liquidAppConfig->value(pageAffectingKeys[i]) != previousValues[i]) { + reload(); + break; + } + } } -#endif } void LiquidAppWindow::loadStarted(void) diff --git a/src/liquidx11.cpp b/src/liquidx11.cpp new file mode 100644 index 0000000..06cad78 --- /dev/null +++ b/src/liquidx11.cpp @@ -0,0 +1,35 @@ +// Kept separate from the rest of the code, since X11 headers define macros (None, Bool, Status, etc) that clash with Qt + +#include + +#include "liquidx11.h" + +void liquidX11ActivateWindow(const unsigned long windowId) +{ + Display* display = XOpenDisplay(nullptr); + + if (display == nullptr) { + return; + } + + const Atom activeWindowAtom = XInternAtom(display, "_NET_ACTIVE_WINDOW", False); + + if (activeWindowAtom != None) { + XEvent event = {}; + + event.xclient.type = ClientMessage; + event.xclient.send_event = True; + event.xclient.display = display; + event.xclient.window = windowId; + event.xclient.message_type = activeWindowAtom; + event.xclient.format = 32; + event.xclient.data.l[0] = 2; // Source indication: pager (acting on behalf of the user, like taskbars do) + event.xclient.data.l[1] = CurrentTime; // Lets the window manager use its own current time + event.xclient.data.l[2] = 0; + + XSendEvent(display, DefaultRootWindow(display), False, SubstructureRedirectMask | SubstructureNotifyMask, &event); + } + + XFlush(display); + XCloseDisplay(display); +} diff --git a/src/main.cpp b/src/main.cpp index e336058..f21a606 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -78,7 +78,8 @@ int main(int argc, char **argv) singleInstance = new SingleInstance(LQD_INSTANCE_ID_MAIN); if (!singleInstance->claim()) { qDebug().noquote() << QString("Liquid is already running"); - SingleInstance::sendMessage(LQD_INSTANCE_ID_MAIN, LQD_INSTANCE_MSG_RAISE); + // Pass on the activation token this process may have been launched with (e.g. by the desktop environment) + SingleInstance::sendMessage(LQD_INSTANCE_ID_MAIN, Liquid::makeRaiseMessage(qEnvironmentVariable("XDG_ACTIVATION_TOKEN"))); goto done; } @@ -86,8 +87,15 @@ int main(int argc, char **argv) mainWindow = new MainWindow; QObject::connect(singleInstance, &SingleInstance::messageReceived, mainWindow, [](const QByteArray& message) { - if (message == LQD_INSTANCE_MSG_RAISE) { - Liquid::raiseWindow(mainWindow); + static const QByteArray startedPrefix = QByteArray(LQD_INSTANCE_MSG_STARTED) + " "; + + QString activationToken; + + if (Liquid::parseRaiseMessage(message, &activationToken)) { + Liquid::raiseWindow(mainWindow, activationToken); + } else if (message.startsWith(startedPrefix)) { + // A Liquid app has started, show it as running + mainWindow->watchLiquidApp(QString::fromUtf8(message.mid(startedPrefix.size()))); } }); } else { // App name provided @@ -143,7 +151,7 @@ int main(int argc, char **argv) singleInstance = new SingleInstance(instanceId); if (!singleInstance->claim()) { qDebug().noquote() << QString("Liquid app “%1” is already running").arg(liquidAppName); - SingleInstance::sendMessage(instanceId, LQD_INSTANCE_MSG_RAISE); + SingleInstance::sendMessage(instanceId, Liquid::makeRaiseMessage(qEnvironmentVariable("XDG_ACTIVATION_TOKEN"))); goto done; } @@ -151,10 +159,19 @@ int main(int argc, char **argv) liquidAppWindow = new LiquidAppWindow(&liquidAppName); QObject::connect(singleInstance, &SingleInstance::messageReceived, liquidAppWindow, [](const QByteArray& message) { - if (message == LQD_INSTANCE_MSG_RAISE) { - Liquid::raiseWindow(liquidAppWindow); + QString activationToken; + + if (Liquid::parseRaiseMessage(message, &activationToken)) { + Liquid::raiseWindow(liquidAppWindow, activationToken); + } else if (message == LQD_INSTANCE_MSG_QUIT) { + liquidAppWindow->close(); + } else if (message == LQD_INSTANCE_MSG_RELOAD_SETTINGS) { + liquidAppWindow->reloadLiquidAppConfig(); } }); + + // Let the main window (if it's open) know that this Liquid app is now running + SingleInstance::sendMessage(LQD_INSTANCE_ID_MAIN, QByteArray(LQD_INSTANCE_MSG_STARTED) + " " + liquidAppName.toUtf8()); } else { // No such Liquid app found, open Liquid app creation dialog LiquidAppConfigDialog LiquidAppConfigDialog(mainWindow, liquidAppName); diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index 1be94e5..663c07c 100644 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -1,8 +1,10 @@ #include +#include #include #include #include #include +#include #include #include "liquid.hpp" @@ -10,6 +12,7 @@ #include "liquidappconfigwindow.hpp" #include "lqd.h" #include "mainwindow.hpp" +#include "singleinstance.hpp" MainWindow::MainWindow() : QScrollArea() { @@ -73,10 +76,19 @@ MainWindow::MainWindow() : QScrollArea() mainWindowWidget->setLayout(mainWindowLayout); - // Run the liquid app upon double-click on its row in the table + // Run the liquid app upon double-click on its row in the table (or bring it to front if it's already running) connect(appListTable, &QTableWidget::cellDoubleClicked, [&](int row, int col) { Q_UNUSED(col); - Liquid::runLiquidApp(appListTable->item(row, 0)->text()); + const QString liquidAppName = appListTable->item(row, 0)->text(); + + // The token lets the Liquid app's window come to front on Wayland + Liquid::requestActivationToken(this, [this, liquidAppName](const QString& activationToken) { + if (isLiquidAppRunning(liquidAppName)) { + sendToLiquidApp(liquidAppName, Liquid::makeRaiseMessage(activationToken)); + } else { + Liquid::runLiquidApp(liquidAppName, activationToken); + } + }); }); // Connect keyboard shortcuts @@ -113,6 +125,8 @@ void MainWindow::closeEvent(QCloseEvent *event) void MainWindow::flushTable(void) { + runButtons.clear(); + for (int i = appListTable->rowCount(); i > -1 ; i--) { appListTable->removeRow(i); } @@ -162,6 +176,13 @@ void MainWindow::populateTable(void) const QString text = QString("Are you sure you want to delete Liquid app “%1”?").arg(liquidAppName); const QMessageBox::StandardButton reply = QMessageBox::question(this, "Confirmation", text, QMessageBox::Yes | QMessageBox::No); if (reply == QMessageBox::Yes) { + // Close the Liquid app first (otherwise it'd write its file back to disk upon quitting) + if (isLiquidAppRunning(liquidAppName) && !stopLiquidApp(liquidAppName, true)) { + QMessageBox::warning(this, tr("Unable to delete"), + tr("Liquid app “%1” didn't quit (it may be busy), please try again.").arg(liquidAppName)); + return; + } + Liquid::removeDesktopFile(liquidAppName); // Shred and unlink Liquid app settings file @@ -214,17 +235,115 @@ void MainWindow::populateTable(void) }); appItemLayout->addWidget(editButton); - // Run button + // Run/Stop button QPushButton* runButton = new QPushButton(tr(LQD_ICON_RUN), this); runButton->setCursor(Qt::PointingHandCursor); - runButton->setProperty("class", "liquidAppsListButtonRun"); appItemLayout->addWidget(runButton); - connect(runButton, &QPushButton::clicked, [liquidAppName]() { - Liquid::runLiquidApp(liquidAppName); + connect(runButton, &QPushButton::clicked, [this, liquidAppName]() { + if (isLiquidAppRunning(liquidAppName)) { + stopLiquidApp(liquidAppName, false); + } else { + Liquid::requestActivationToken(this, [liquidAppName](const QString& activationToken) { + Liquid::runLiquidApp(liquidAppName, activationToken); + }); + } }); + runButtons.insert(liquidAppName, runButton); appListTable->setCellWidget(i, 1, appItemActionButtonsWidget); + + // Find out whether it's running (and keep track of it from now on) + watchLiquidApp(liquidAppName); + updateRunButton(liquidAppName); + } +} + +void MainWindow::watchLiquidApp(const QString& liquidAppName) +{ + if (liquidAppConnections.contains(liquidAppName)) { + return; } + + // A Liquid app that's not in the table yet (e.g. just created via CLI) + if (!runButtons.contains(liquidAppName) && Liquid::getLiquidAppsList().contains(liquidAppName)) { + flushTable(); + populateTable(); + + if (liquidAppConnections.contains(liquidAppName)) { + return; // Populating the table has taken care of it + } + } + + QLocalSocket* connection = new QLocalSocket(this); + liquidAppConnections.insert(liquidAppName, connection); + + connect(connection, &QLocalSocket::connected, this, [this, liquidAppName]() { + updateRunButton(liquidAppName); + }); + + // Happens once the Liquid app quits or crashes (or right away, if it isn't running) + const auto forget = [this, liquidAppName, connection]() { + if (liquidAppConnections.value(liquidAppName) == connection) { + liquidAppConnections.remove(liquidAppName); + connection->deleteLater(); + updateRunButton(liquidAppName); + } + }; + connect(connection, &QLocalSocket::disconnected, this, forget); + connect(connection, &QLocalSocket::errorOccurred, this, forget); + + connection->connectToServer(SingleInstance::serverName(LQD_INSTANCE_ID_APP_PREFIX + liquidAppName)); +} + +bool MainWindow::isLiquidAppRunning(const QString& liquidAppName) +{ + const QLocalSocket* connection = liquidAppConnections.value(liquidAppName, Q_NULLPTR); + + return connection != Q_NULLPTR && connection->state() == QLocalSocket::ConnectedState; +} + +void MainWindow::sendToLiquidApp(const QString& liquidAppName, const QByteArray& message) +{ + if (isLiquidAppRunning(liquidAppName)) { + QLocalSocket* connection = liquidAppConnections.value(liquidAppName); + connection->write(message + "\n"); + connection->flush(); + } +} + +bool MainWindow::stopLiquidApp(const QString& liquidAppName, const bool waitUntilStopped) +{ + sendToLiquidApp(liquidAppName, LQD_INSTANCE_MSG_QUIT); + + if (waitUntilStopped) { + QElapsedTimer timer; + timer.start(); + + while (isLiquidAppRunning(liquidAppName) && timer.elapsed() < LQD_INSTANCE_STOP_TIMEOUT) { + Liquid::sleep(50); + } + } + + return !isLiquidAppRunning(liquidAppName); +} + +void MainWindow::updateRunButton(const QString& liquidAppName) +{ + QPushButton* runButton = runButtons.value(liquidAppName, Q_NULLPTR); + + if (runButton == Q_NULLPTR) { + return; + } + + const bool isRunning = isLiquidAppRunning(liquidAppName); + + runButton->setText((isRunning) ? tr(LQD_ICON_STOP) : tr(LQD_ICON_RUN)); + runButton->setToolTip((isRunning) ? tr("Stop") : tr("Run")); + runButton->setProperty("class", (isRunning) ? "liquidAppsListButtonStop" : "liquidAppsListButtonRun"); + + // Make the stylesheet take the new class into account + runButton->style()->unpolish(runButton); + runButton->style()->polish(runButton); } void MainWindow::saveSettings(void) diff --git a/src/singleinstance.cpp b/src/singleinstance.cpp index f37296b..472c36d 100644 --- a/src/singleinstance.cpp +++ b/src/singleinstance.cpp @@ -66,6 +66,20 @@ bool SingleInstance::claim(void) return true; } +bool SingleInstance::isRunning(const QString& instanceId) +{ + QLocalSocket socket; + socket.connectToServer(serverName(instanceId)); + + if (!socket.waitForConnected(CONNECT_TIMEOUT_MS)) { + return false; + } + + socket.disconnectFromServer(); + + return true; +} + bool SingleInstance::sendMessage(const QString& instanceId, const QByteArray& message) { QLocalSocket socket;