Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 7 additions & 2 deletions inc/liquid.hpp
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
#pragma once

#include <functional>

#include <QDir>
#include <QUrl>
#include <QWidget>
Expand All @@ -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<void(const QString&)> 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);
};
1 change: 1 addition & 0 deletions inc/liquidappconfigwindow.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions inc/liquidappwebpage.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
6 changes: 6 additions & 0 deletions inc/liquidappwindow.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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<qreal> zoomFactors;
Expand Down Expand Up @@ -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);
};
5 changes: 5 additions & 0 deletions inc/liquidx11.h
Original file line number Diff line number Diff line change
@@ -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);
12 changes: 11 additions & 1 deletion inc/lqd.h
Original file line number Diff line number Diff line change
Expand Up @@ -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 "➕"
Expand All @@ -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"
Expand Down
16 changes: 15 additions & 1 deletion inc/mainwindow.hpp
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
#pragma once

#include <QAction>
#include <QLocalSocket>
#include <QMap>
#include <QPushButton>
#include <QSettings>
#include <QScrollArea>
Expand All @@ -12,19 +14,31 @@ 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;

private:
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<QString, QLocalSocket*> liquidAppConnections;
// Run/Stop buttons of Liquid apps currently listed in the table
QMap<QString, QPushButton*> runButtons;
};
8 changes: 6 additions & 2 deletions inc/singleinstance.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
15 changes: 15 additions & 0 deletions liquid.pro
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
3 changes: 3 additions & 0 deletions res/styles/dark.qss
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
3 changes: 3 additions & 0 deletions res/styles/light.qss
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
121 changes: 118 additions & 3 deletions src/liquid.cpp
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
#include "lqd.h"
#include "liquid.hpp"
#if defined(Q_OS_UNIX) && !defined(Q_OS_MACOS)
#include "liquidx11.h"
#endif

#include <QApplication>
#include <QCoreApplication>
Expand All @@ -13,6 +16,18 @@
#include <QTime>
#include <QTimer>
#include <QWebEngineProfile>
#include <QWindow>
#include <QGuiApplication>

#if defined(LQD_WAYLAND_ACTIVATION) && QT_VERSION >= QT_VERSION_CHECK(6, 5, 0)
#include <memory>
#include <QGuiApplication>
#include <QtGui/qpa/qplatformwindow_p.h>
// 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 <CoreFoundation/CoreFoundation.h>
Expand Down Expand Up @@ -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)
Expand All @@ -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<void(const QString&)> 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<QWaylandWindow>() : Q_NULLPTR;
auto* waylandApplication = qGuiApp->nativeInterface<QNativeInterface::QWaylandApplication>();

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<bool>(false);
const auto connection = std::make_shared<QMetaObject::Connection>();

*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 <activation token>"
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)
Expand Down
Loading
Loading