diff --git a/inc/liquid.hpp b/inc/liquid.hpp index ab190f6..f11ae77 100644 --- a/inc/liquid.hpp +++ b/inc/liquid.hpp @@ -10,11 +10,13 @@ class Liquid static void applyQtStyleSheets(QWidget* widget); static void createDesktopFile(const QString liquidAppName, const QString liquidAppStartingUrl); static bool detectDarkMode(void); + static bool detectDarkModeUncached(void); static QByteArray generateRandomByteArray(const int byteLength); static QDir getAppsDir(void); static QDir getConfigDir(void); static QString getDefaultUserAgentString(void); static QStringList getLiquidAppsList(void); + static void raiseWindow(QWidget* window); static QString getUniqueFilePath(const QString dirPath, const QString baseName, const QString extension); static void removeDesktopFile(const QString liquidAppName); static void runLiquidApp(const QString liquidAppName); diff --git a/inc/liquidappwindow.hpp b/inc/liquidappwindow.hpp index cf9f233..250937c 100644 --- a/inc/liquidappwindow.hpp +++ b/inc/liquidappwindow.hpp @@ -8,6 +8,7 @@ #include #include #include +#include #include #include @@ -74,6 +75,7 @@ public slots: QWebEngineProfile* liquidAppWebProfile = Q_NULLPTR; QWebEngineSettings* liquidAppWebSettings = Q_NULLPTR; QByteArray liquidAppWindowGeometry; + QTimer* rememberGeometryTimer = Q_NULLPTR; QList zoomFactors; bool liquidAppWindowTitleIsReadOnly = false; diff --git a/inc/lqd.h b/inc/lqd.h index 9c0009f..aac1923 100644 --- a/inc/lqd.h +++ b/inc/lqd.h @@ -20,6 +20,13 @@ #define LQD_ZOOM_LVL_STEP 0.04 #define LQD_ZOOM_LVL_STEP_FINE (LQD_ZOOM_LVL_STEP / 10) +/* Instance IDs (for preventing duplicate instances, and for talking to running ones) */ +#define LQD_INSTANCE_ID_MAIN "main" +#define LQD_INSTANCE_ID_APP_PREFIX "app/" + +/* Messages that can be sent to running instances */ +#define LQD_INSTANCE_MSG_RAISE "raise" + /* Textual icons */ #define LQD_ICON_ADD "➕" #define LQD_ICON_EDIT "⚙" diff --git a/inc/singleinstance.hpp b/inc/singleinstance.hpp new file mode 100644 index 0000000..407034b --- /dev/null +++ b/inc/singleinstance.hpp @@ -0,0 +1,33 @@ +#pragma once + +#include +#include +#include + +class QLocalServer; + +// Ensures that only one instance (of the main window, or of each Liquid app) runs at a time, +// and lets other processes talk to the one that's running, using local sockets (named pipes on Windows). +// Nothing polls anything: the running instance only wakes up when another process connects to it. +class SingleInstance : public QObject +{ + Q_OBJECT + +public: + explicit SingleInstance(const QString& instanceId, QObject* parent = Q_NULLPTR); + + // Attempts to become the running instance; returns false if another one is already running + bool claim(void); + + // Delivers a message to the running instance; returns false if there's none running + static bool sendMessage(const QString& instanceId, const QByteArray& message); + +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 d2291b7..ba73d2b 100644 --- a/liquid.pro +++ b/liquid.pro @@ -29,6 +29,7 @@ HEADERS += inc/lqd.h \ inc/liquidappwebpage.hpp \ inc/liquidappwindow.hpp \ inc/mainwindow.hpp \ + inc/singleinstance.hpp \ SOURCES += src/liquid.cpp \ src/liquidappcookiejar.cpp \ @@ -37,6 +38,7 @@ SOURCES += src/liquid.cpp \ src/liquidappwindow.cpp \ src/main.cpp \ src/mainwindow.cpp \ + src/singleinstance.cpp \ RESOURCES = res/resources.qrc diff --git a/src/liquid.cpp b/src/liquid.cpp index 01d4899..8db84db 100644 --- a/src/liquid.cpp +++ b/src/liquid.cpp @@ -4,11 +4,13 @@ #include #include #include +#include #include #include #include #include #include +#include #include #if defined(Q_OS_MAC) @@ -86,6 +88,18 @@ void Liquid::createDesktopFile(const QString liquidAppName, const QString liquid } bool Liquid::detectDarkMode(void) +{ + // Detecting it may involve spawning a process, do it only once per program run + static int cachedResult = -1; + + if (cachedResult < 0) { + cachedResult = detectDarkModeUncached() ? 1 : 0; + } + + return cachedResult == 1; +} + +bool Liquid::detectDarkModeUncached(void) { #if defined(Q_OS_LINUX) QProcess process; @@ -183,6 +197,19 @@ QString Liquid::getUniqueFilePath(const QString dirPath, const QString baseName, return filePath; } +void Liquid::raiseWindow(QWidget* window) +{ + if (window == Q_NULLPTR) { + return; + } + + // 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(); +} + void Liquid::removeDesktopFile(const QString liquidAppName) { #if defined(Q_OS_LINUX) @@ -209,9 +236,16 @@ void Liquid::runLiquidApp(const QString liquidAppName) void Liquid::sleep(const int ms) { - const QTime proceedAfter = QTime::currentTime().addMSecs(ms); + // Keep processing events while waiting, but without spinning the CPU: + // block until an event arrives, with the timer guaranteeing one when it's time to stop waiting + QTimer timer; + timer.setSingleShot(true); + timer.start(ms); + + QElapsedTimer elapsed; + elapsed.start(); - while (QTime::currentTime() < proceedAfter) { - QCoreApplication::processEvents(QEventLoop::AllEvents, ms / 4); + while (elapsed.elapsed() < ms) { + QCoreApplication::processEvents(QEventLoop::AllEvents | QEventLoop::WaitForMoreEvents); } } diff --git a/src/liquidappwindow.cpp b/src/liquidappwindow.cpp index 9956a53..3d0be0b 100644 --- a/src/liquidappwindow.cpp +++ b/src/liquidappwindow.cpp @@ -127,6 +127,16 @@ LiquidAppWindow::LiquidAppWindow(const QString* name) : QWebEngineView() setWindowIcon(QIcon(":/images/" PROG_NAME ".svg")); #endif + // Remember window geometry only once it settles (see resizeEvent()) + rememberGeometryTimer = new QTimer(this); + rememberGeometryTimer->setSingleShot(true); + rememberGeometryTimer->setInterval(200); + connect(rememberGeometryTimer, &QTimer::timeout, this, [this]() { + if (!isFullScreen()) { + liquidAppWindowGeometry = saveGeometry(); + } + }); + // Disable default QWebEngineView's context menu setContextMenuPolicy(Qt::PreventContextMenu); @@ -812,12 +822,10 @@ void LiquidAppWindow::onIconChanged(QIcon icon) void LiquidAppWindow::resizeEvent(QResizeEvent* event) { - // Remember window size (unless in full-screen mode) + // Remember window size (unless in full-screen mode), + // once it stops changing (e.g. the user is done dragging, or resize animation is over) if (event->spontaneous() && !isFullScreen()) { - // Pause here to wait for any kind of window resize animations to finish - Liquid::sleep(200); - - liquidAppWindowGeometry = saveGeometry(); + rememberGeometryTimer->start(); } QWebEngineView::resizeEvent(event); diff --git a/src/main.cpp b/src/main.cpp index e44a873..792070e 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -10,32 +10,17 @@ #include "liquidappconfigwindow.hpp" #include "liquidappwindow.hpp" #include "mainwindow.hpp" +#include "singleinstance.hpp" QTextStream cout(stdout); -static QSharedMemory* sharedMemory = Q_NULLPTR; +static SingleInstance* singleInstance = Q_NULLPTR; LiquidAppWindow* liquidAppWindow; MainWindow* mainWindow; -QString getUserName() -{ - QString name = qgetenv("USER"); - - if (name.isEmpty()) { - name = qgetenv("USERNAME"); - } - - return name; -} - static void onSignalHandler(int signum) { - if (sharedMemory) { - delete sharedMemory; - sharedMemory = Q_NULLPTR; - } - if (liquidAppWindow) { liquidAppWindow->close(); delete liquidAppWindow; @@ -58,9 +43,8 @@ int main(int argc, char **argv) int ret = EXIT_SUCCESS; #if defined(Q_OS_LINUX) || defined(Q_OS_MAC) - // Handle any further termination signals to ensure - // that the QSharedMemory block is deleted - // even if the process crashes + // Handle termination signals to make sure that + // Liquid app's settings get saved upon exit signal(SIGHUP, onSignalHandler); signal(SIGINT, onSignalHandler); signal(SIGQUIT, onSignalHandler); @@ -89,16 +73,22 @@ int main(int argc, char **argv) QApplication app(argc, argv); if (argc < 2) { - // Allow only one instance - sharedMemory = new QSharedMemory(getUserName() + "_Liquid"); - if (!sharedMemory->create(4, QSharedMemory::ReadOnly)) { - delete sharedMemory; - qDebug().noquote() << QString("Only one instance of Liquid is allowed"); - exit(EXIT_FAILURE); + // Allow only one instance, bring the already running one to front instead of starting another + 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); + goto done; } // Show main program window mainWindow = new MainWindow; + + QObject::connect(singleInstance, &SingleInstance::messageReceived, mainWindow, [](const QByteArray& message) { + if (message == LQD_INSTANCE_MSG_RAISE) { + Liquid::raiseWindow(mainWindow); + } + }); } else { // App name provided // CLI flags and options QCommandLineParser parser; @@ -151,16 +141,23 @@ int main(int argc, char **argv) // Attempt to load app settings from a config file if (!parser.isSet(editAppDialogFlag) && tempAppSettings->contains(LQD_CFG_KEY_NAME_URL)) { - // // Allow only one instance - sharedMemory = new QSharedMemory(getUserName() + "_Liquid_app_" + liquidAppName); - if (!sharedMemory->create(4, QSharedMemory::ReadOnly)) { - delete sharedMemory; - qDebug().noquote() << QString("Only one instance of Liquid app “%1” is allowed").arg(liquidAppName); - exit(EXIT_FAILURE); + // Allow only one instance per Liquid app, bring the already running one to front instead of starting another + const QString instanceId = LQD_INSTANCE_ID_APP_PREFIX + liquidAppName; + 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); + goto done; } // Found existing liquid app settings file, show it liquidAppWindow = new LiquidAppWindow(&liquidAppName); + + QObject::connect(singleInstance, &SingleInstance::messageReceived, liquidAppWindow, [](const QByteArray& message) { + if (message == LQD_INSTANCE_MSG_RAISE) { + Liquid::raiseWindow(liquidAppWindow); + } + }); } else { // No such Liquid app found, open Liquid app creation dialog LiquidAppConfigDialog LiquidAppConfigDialog(mainWindow, liquidAppName); @@ -196,9 +193,10 @@ int main(int argc, char **argv) ret = app.exec(); done: - if (sharedMemory != Q_NULLPTR) { - delete sharedMemory; - sharedMemory = Q_NULLPTR; + if (singleInstance != Q_NULLPTR) { + // Stops listening (and removes the socket file, where applicable) + delete singleInstance; + singleInstance = Q_NULLPTR; } return ret; diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index 3284114..4811675 100644 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -58,12 +58,7 @@ MainWindow::MainWindow() : QScrollArea() switch (LiquidAppConfigDialog.exec()) { case QDialog::Accepted: // Give some time to the filesystem before scanning for the newly created Liquid App - { - QTime proceedAfter = QTime::currentTime().addMSecs(100); - while (QTime::currentTime() < proceedAfter) { - QCoreApplication::processEvents(QEventLoop::AllEvents, 50); - } - } + Liquid::sleep(100); flushTable(); populateTable(); @@ -214,12 +209,7 @@ void MainWindow::populateTable(void) switch (LiquidAppConfigDialog.exec()) { case QDialog::Accepted: // Give some time to the filesystem before scanning for the newly created app - { - QTime proceedAfter = QTime::currentTime().addMSecs(100); - while (QTime::currentTime() < proceedAfter) { - QCoreApplication::processEvents(QEventLoop::AllEvents, 50); - } - } + Liquid::sleep(100); flushTable(); populateTable(); break; diff --git a/src/singleinstance.cpp b/src/singleinstance.cpp new file mode 100644 index 0000000..f37296b --- /dev/null +++ b/src/singleinstance.cpp @@ -0,0 +1,83 @@ +#include +#include +#include +#include +#include + +#include "singleinstance.hpp" + +static const int CONNECT_TIMEOUT_MS = 500; + +SingleInstance::SingleInstance(const QString& instanceId, QObject* parent) : QObject(parent) +{ + this->instanceId = instanceId; +} + +QString SingleInstance::serverName(const QString& instanceId) +{ + // Per-user, so that different users on the same machine don't collide. + // Hashed, since Liquid app names may contain characters (or be of length) unsuitable for socket names. + const QByteArray key = (QDir::home().dirName() + "\n" + instanceId).toUtf8(); + + return QString(PROG_NAME "-") + QCryptographicHash::hash(key, QCryptographicHash::Sha1).toHex().left(24); +} + +bool SingleInstance::claim(void) +{ + const QString name = serverName(instanceId); + + // Is another instance listening? + { + QLocalSocket socket; + socket.connectToServer(name); + + if (socket.waitForConnected(CONNECT_TIMEOUT_MS)) { + socket.disconnectFromServer(); + return false; + } + } + + // This is the first instance: clear a stale socket possibly left behind by a crash, then listen + QLocalServer::removeServer(name); + + server = new QLocalServer(this); + server->setSocketOptions(QLocalServer::UserAccessOption); + + connect(server, &QLocalServer::newConnection, this, [this]() { + while (QLocalSocket* client = server->nextPendingConnection()) { + // Messages are newline-terminated + connect(client, &QLocalSocket::readyRead, this, [this, client]() { + while (client->canReadLine()) { + const QByteArray message = client->readLine().trimmed(); + + if (!message.isEmpty()) { + emit messageReceived(message); + } + } + }); + connect(client, &QLocalSocket::disconnected, client, &QObject::deleteLater); + } + }); + + if (!server->listen(name)) { + qDebug().noquote() << "Unable to listen for other instances:" << server->errorString(); + } + + return true; +} + +bool SingleInstance::sendMessage(const QString& instanceId, const QByteArray& message) +{ + QLocalSocket socket; + socket.connectToServer(serverName(instanceId)); + + if (!socket.waitForConnected(CONNECT_TIMEOUT_MS)) { + return false; + } + + socket.write(message + "\n"); + socket.waitForBytesWritten(CONNECT_TIMEOUT_MS); + socket.disconnectFromServer(); + + return true; +}