From 1d5434fad8ff2555183a15901d1f1c968643d304 Mon Sep 17 00:00:00 2001 From: Sunshine Date: Sun, 25 Oct 2026 18:43:07 -0400 Subject: [PATCH] automatically retrieve and update liquid app icons --- .github/workflows/ci.yml | 4 +- README.md | 8 +- inc/liquid.hpp | 3 + inc/liquidappconfigwindow.hpp | 5 + inc/liquidappicon.hpp | 92 +++++++ inc/liquidappwindow.hpp | 13 +- inc/lqd.h | 7 + inc/mainwindow.hpp | 1 + liquid.pro | 4 +- src/liquid.cpp | 46 +++- src/liquidappconfigwindow.cpp | 73 +++++ src/liquidappfile.cpp | 25 +- src/liquidappicon.cpp | 483 ++++++++++++++++++++++++++++++++++ src/liquidappwindow.cpp | 117 ++++++-- src/main.cpp | 4 + src/mainwindow.cpp | 23 +- 16 files changed, 868 insertions(+), 40 deletions(-) create mode 100644 inc/liquidappicon.hpp create mode 100644 src/liquidappicon.cpp diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f76be3d..4bae5e5 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 libx11-dev + sudo apt-get install -y qtbase5-dev qtwebengine5-dev libqt5svg5-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 libx11-dev + sudo apt-get install -y qt6-base-dev qt6-webengine-dev qt6-svg-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 187855c..108fb41 100644 --- a/README.md +++ b/README.md @@ -76,17 +76,19 @@ What gets stored (if allowed): cookies. #### Dependencies -Liquid is built with Qt 6 (Qt 5.15 is still supported) and Qt WebEngine. +Liquid is built with Qt 6 (Qt 5.15 is still supported), Qt WebEngine, and Qt SVG. On Debian/Ubuntu: ```console -sudo apt install qt6-base-dev qt6-webengine-dev qmake6 libx11-dev build-essential +sudo apt install qt6-base-dev qt6-webengine-dev qt6-svg-dev qmake6 libx11-dev build-essential ``` +For Qt 5.15, the packages are `qtbase5-dev qtwebengine5-dev libqt5svg5-dev qt5-qmake` instead. + 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). +On macOS and Windows, install Qt 6 with the Qt WebEngine, Qt WebChannel, and Qt Positioning modules (Qt SVG comes with Qt itself) using the [Qt Online Installer](https://www.qt.io/download-qt-installer). #### Build diff --git a/inc/liquid.hpp b/inc/liquid.hpp index ed88b7c..21ec35f 100644 --- a/inc/liquid.hpp +++ b/inc/liquid.hpp @@ -18,6 +18,8 @@ class Liquid static QDir getDataDir(void); static QString getLiquidAppFilePath(const QString liquidAppName); static QString getDefaultUserAgentString(void); + static QString getDesktopFilePath(const QString liquidAppName); + static QString getDesktopFileIconValue(const QString liquidAppName); static QStringList getLiquidAppsList(void); static void raiseWindow(QWidget* window, const QString activationToken = QString()); static void requestActivationToken(QWidget* window, const std::function callback); @@ -27,4 +29,5 @@ class Liquid static void removeDesktopFile(const QString liquidAppName); static void runLiquidApp(const QString liquidAppName, const QString activationToken = QString()); static void sleep(const int ms); + static void updateDesktopFileIcon(const QString liquidAppName); }; diff --git a/inc/liquidappconfigwindow.hpp b/inc/liquidappconfigwindow.hpp index acef98c..039b240 100644 --- a/inc/liquidappconfigwindow.hpp +++ b/inc/liquidappconfigwindow.hpp @@ -13,6 +13,8 @@ #include #include +#include "liquidappicon.hpp" + class LiquidAppConfigDialog : public QDialog { public: @@ -49,6 +51,9 @@ public slots: QLineEdit* nameInput; QLineEdit* addressInput; QCheckBox* createIconCheckBox; + QToolButton* iconButton; + LiquidAppIcon chosenIcon; + bool iconWasEdited = false; QCheckBox* planningToRunCheckBox = Q_NULLPTR; // General tab diff --git a/inc/liquidappicon.hpp b/inc/liquidappicon.hpp new file mode 100644 index 0000000..d0e2740 --- /dev/null +++ b/inc/liquidappicon.hpp @@ -0,0 +1,92 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include "lqd.h" + +class QNetworkAccessManager; +class QNetworkReply; +class QWebEnginePage; + +// A Liquid app's icon: either an SVG image (kept as-is, so it stays sharp at any size), or a raster one (kept as PNG); +// stored inside .liquid files, with a copy next to them for .desktop files to point at +class LiquidAppIcon +{ +public: + LiquidAppIcon(void) {} + + // Accepts SVG and any raster format Qt can read (picking the largest image for files containing several, e.g. .ico); + // SVG images that could make whatever displays them reach out for something (e.g. external images) get rasterized + static LiquidAppIcon fromData(const QByteArray& data); + static LiquidAppIcon fromImage(const QImage& image); + static LiquidAppIcon fromIcon(const QIcon& icon); // Takes the largest image the icon contains + + // The Liquid app's own icon (null if it doesn't have one yet) + static LiquidAppIcon load(QSettings* liquidAppConfig); + + // The Liquid app's own icon, or Liquid's icon if it doesn't have one yet + static QIcon loadIcon(QSettings* liquidAppConfig); + + // Makes this the Liquid app's icon + void save(QSettings* liquidAppConfig, const QString& liquidAppName) const; + + // Takes the icon away from the Liquid app (it'll get fetched from the website again) + static void remove(QSettings* liquidAppConfig, const QString& liquidAppName); + + // Puts the icon file in place (for .desktop files) if it's missing, e.g. for icons from older versions of Liquid + static void ensureIconFile(QSettings* liquidAppConfig, const QString& liquidAppName); + + // Where the copy of the icon for .desktop files lives (an empty string if there's none) + static QString getIconFilePath(const QString& liquidAppName); + static void removeIconFile(const QString& liquidAppName); + + bool isNull(void) const; + bool isSvg(void) const; + + // How good of an icon this is (vector images beat raster ones of any size) + int score(void) const; + + QImage toImage(const int size = LQD_ICON_MAX_SIZE) const; + QIcon toIcon(void) const; + +private: + QByteArray svg; + QImage image; +}; + +// Finds the best icon a website has to offer (looking at the page that's currently shown), downloads all candidates +// and picks the best one +class LiquidAppIconFetcher : public QObject +{ + Q_OBJECT + +public: + LiquidAppIconFetcher(QWebEnginePage* page, QObject* parent = Q_NULLPTR); + + void start(void); + +signals: + // The icon is null if no usable one has been found + void finished(const LiquidAppIcon& icon); + +private: + void onPageInspected(const QString& json); + void download(const QUrl& url, const bool isMaskable); + void onDownloaded(QNetworkReply* reply, const bool isMaskable); + void onManifestDownloaded(QNetworkReply* reply); + void finishIfDone(void); + + QWebEnginePage* page; + QNetworkAccessManager* networkAccessManager; + QList requestedUrls; + int pendingRequestCount = 0; + LiquidAppIcon bestIcon; + qreal bestScore = 0; + bool isDone = false; +}; diff --git a/inc/liquidappwindow.hpp b/inc/liquidappwindow.hpp index de23d4e..5af834c 100644 --- a/inc/liquidappwindow.hpp +++ b/inc/liquidappwindow.hpp @@ -14,6 +14,8 @@ #include "liquidappwebpage.hpp" +class LiquidAppIcon; +class LiquidAppIconFetcher; class LiquidAppWebPage; class SnapshotFlashOverlay; class QPainter; @@ -75,11 +77,20 @@ public slots: QVariant runJavaScriptSync(const QString& js, const int timeoutMs = 5000); void waitForNextFrame(void); bool waitForDocumentToFinishLoading(void); + void applyIcon(void); + void maybeFetchIcon(void); + void useFetchedIcon(const LiquidAppIcon& icon); + bool isShowingOwnWebsite(void); QString liquidAppName; QString liquidAppWindowTitle; - QIcon iconToSave; + + // The icon provided by the page that's currently shown (used only until the Liquid app has an icon of its own) + QIcon pageIcon; + // Fetching the website's icon happens once per run, and only if the Liquid app doesn't have one yet + LiquidAppIconFetcher* iconFetcher = Q_NULLPTR; + bool iconFetchAttempted = false; LiquidAppWebPage* liquidAppWebPage = Q_NULLPTR; QWebEngineProfile* liquidAppWebProfile = Q_NULLPTR; diff --git a/inc/lqd.h b/inc/lqd.h index 17e4b33..0f93d01 100644 --- a/inc/lqd.h +++ b/inc/lqd.h @@ -4,6 +4,7 @@ #define LQD_PROG_TITLE "Liquid" #define LQD_APPS_DIR_NAME "apps" #define LQD_APP_FILE_EXTENSION "liquid" +#define LQD_ICONS_DIR_NAME "icons" // Copies of Liquid apps' icons, for .desktop files to point at #define LQD_DEFAULT_BG_COLOR Qt::white #define LQD_DEFAULT_PROXY_HOST "0.0.0.0" #define LQD_DEFAULT_PROXY_PORT 8080 @@ -36,12 +37,18 @@ #define LQD_INSTANCE_MSG_PREVIEW_CSS "preview-css" // Liquid apps only, followed by a space and base64-encoded CSS #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 #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_PREVIEW_CONNECT_TIMEOUT 100 // Milliseconds to wait for connecting to a Liquid app for previewing settings #define LQD_RECREATED_WINDOW_RAISE_DELAY 100 // Milliseconds to wait before bringing a re-created native window to front #define LQD_SCROLLBARS_RELAYOUT_DELAY 100 // Milliseconds to keep the document unscrollable for, to make it redo its scrollbars #define LQD_CSS_PREVIEW_DELAY 150 // Milliseconds to wait after the last keystroke before previewing CSS +#define LQD_ICON_DOWNLOAD_TIMEOUT 10000 // Milliseconds to wait for an icon (or web app manifest) to download +#define LQD_ICON_MAX_CANDIDATES 16 // How many icons a website gets to offer at most +#define LQD_ICON_MAX_FILE_SIZE (4 * 1024 * 1024) // Bytes +#define LQD_ICON_MAX_SVG_SIZE (512 * 1024) // Bytes, larger SVG icons get rasterized +#define LQD_ICON_MAX_SIZE 256 // Pixels, larger icons get scaled down before they get stored #define LQD_INSTANCE_STOP_TIMEOUT 5000 // Milliseconds to wait for a Liquid app to quit /* Names of scripts Liquid injects into web pages */ diff --git a/inc/mainwindow.hpp b/inc/mainwindow.hpp index 766f5f2..b876cfe 100644 --- a/inc/mainwindow.hpp +++ b/inc/mainwindow.hpp @@ -15,6 +15,7 @@ class MainWindow : public QScrollArea ~MainWindow(); // Starts keeping track of the Liquid app by the given name (called when Liquid apps report being started) + void updateLiquidAppIcon(const QString& liquidAppName); void watchLiquidApp(const QString& liquidAppName); protected: diff --git a/liquid.pro b/liquid.pro index 57cf124..1828729 100644 --- a/liquid.pro +++ b/liquid.pro @@ -4,7 +4,7 @@ VERSION_PATCH = 11 VERSION = $${VERSION_MAJOR}.$${VERSION_MINOR}.$${VERSION_PATCH} -QT += core gui network widgets webenginecore webenginewidgets +QT += core gui network svg widgets webenginecore webenginewidgets CONFIG += c++17 TEMPLATE = app @@ -25,6 +25,7 @@ INCLUDEPATH += $${INC_DIR} HEADERS += inc/lqd.h \ inc/liquid.hpp \ inc/liquidappfile.hpp \ + inc/liquidappicon.hpp \ inc/liquidappcookiejar.hpp \ inc/liquidappconfigwindow.hpp \ inc/liquidappwebpage.hpp \ @@ -35,6 +36,7 @@ HEADERS += inc/lqd.h \ SOURCES += src/liquid.cpp \ src/liquidappfile.cpp \ + src/liquidappicon.cpp \ src/liquidappcookiejar.cpp \ src/liquidappconfigwindow.cpp \ src/liquidappwebpage.cpp \ diff --git a/src/liquid.cpp b/src/liquid.cpp index dcc908f..b8ad926 100644 --- a/src/liquid.cpp +++ b/src/liquid.cpp @@ -1,5 +1,6 @@ #include "lqd.h" #include "liquid.hpp" +#include "liquidappicon.hpp" #if defined(Q_OS_UNIX) && !defined(Q_OS_MACOS) #include "liquidx11.h" #endif @@ -73,7 +74,7 @@ void Liquid::createDesktopFile(const QString liquidAppName, const QString liquid context += "[Desktop Entry]\n"; context += "Type=Application\n"; context += "Name=" + liquidAppName + "\n"; - context += "Icon=internet-web-browser\n"; + context += "Icon=" + getDesktopFileIconValue(liquidAppName) + "\n"; context += "Exec=liquid " + liquidAppName + "\n"; context += "Comment=" + liquidAppStartingUrl + "\n"; context += "Categories=Network;WebBrowser;\n"; @@ -103,6 +104,49 @@ void Liquid::createDesktopFile(const QString liquidAppName, const QString liquid #endif } +QString Liquid::getDesktopFilePath(const QString liquidAppName) +{ + // QString desktopPath = QStandardPaths::writableLocation(QStandardPaths::DesktopLocation); + return QDir::homePath() + QDir::separator() + "Desktop" + QDir::separator() + liquidAppName + ".desktop"; +} + +QString Liquid::getDesktopFileIconValue(const QString liquidAppName) +{ + const QString iconFilePath = LiquidAppIcon::getIconFilePath(liquidAppName); + + return (iconFilePath.isEmpty()) ? "internet-web-browser" : iconFilePath; +} + +void Liquid::updateDesktopFileIcon(const QString liquidAppName) +{ +#if defined(Q_OS_LINUX) + // Only the icon gets changed, the rest of the file may have been edited by the user + QFile file(getDesktopFilePath(liquidAppName)); + if (!file.open(QIODevice::ReadOnly)) { + return; + } + QStringList lines = QString::fromUtf8(file.readAll()).split('\n'); + file.close(); + + bool changed = false; + for (QString& line : lines) { + if (line.startsWith("Icon=")) { + const QString newLine = "Icon=" + getDesktopFileIconValue(liquidAppName); + changed = changed || line != newLine; + line = newLine; + } + } + + // Writing in place keeps the file's permissions (it has to remain executable) + if (changed && file.open(QIODevice::WriteOnly | QIODevice::Truncate)) { + file.write(lines.join('\n').toUtf8()); + file.close(); + } +#else + Q_UNUSED(liquidAppName); +#endif +} + bool Liquid::detectDarkMode(void) { // Detecting it may involve spawning a process, do it only once per program run diff --git a/src/liquidappconfigwindow.cpp b/src/liquidappconfigwindow.cpp index f85a6a3..ec4766c 100644 --- a/src/liquidappconfigwindow.cpp +++ b/src/liquidappconfigwindow.cpp @@ -6,6 +6,7 @@ #include "liquid.hpp" #include "liquidappfile.hpp" +#include "liquidappicon.hpp" #include "liquidappconfigwindow.hpp" #include "lqd.h" #include "mainwindow.hpp" @@ -87,6 +88,69 @@ LiquidAppConfigDialog::LiquidAppConfigDialog(QWidget* parent, QString liquidAppN basicLayout->addWidget(addressInput, 1, 1); } + // Icon (gets fetched from the website the first time it loads, unless one is chosen here) + { + if (isEditingExistingBool) { + chosenIcon = LiquidAppIcon::load(existingLiquidAppConfig); + } + + iconButton = new QToolButton(this); + iconButton->setCursor(Qt::PointingHandCursor); + iconButton->setAutoRaise(true); + iconButton->setPopupMode(QToolButton::InstantPopup); + iconButton->setToolButtonStyle(Qt::ToolButtonIconOnly); + iconButton->setIconSize(QSize(40, 40)); + iconButton->setFixedSize(52, 52); + + QMenu* iconMenu = new QMenu(iconButton); + QAction* chooseIconAction = iconMenu->addAction(tr("Choose image…")); + QAction* resetIconAction = iconMenu->addAction(tr("Get from website")); + iconButton->setMenu(iconMenu); + + const auto updateIconButton = [this, resetIconAction]() { + iconButton->setIcon((chosenIcon.isNull()) ? QIcon(":/images/" PROG_NAME ".svg") : chosenIcon.toIcon()); + iconButton->setToolTip((chosenIcon.isNull()) ? tr("Icon (will be taken from the website)") : tr("Icon")); + resetIconAction->setEnabled(!chosenIcon.isNull()); + }; + updateIconButton(); + + connect(chooseIconAction, &QAction::triggered, this, [this, updateIconButton]() { + QStringList patterns; + for (const QByteArray& format : QImageReader::supportedImageFormats()) { + patterns << "*." + QString::fromLatin1(format); + } + patterns << "*.svg" << "*.svgz"; + patterns.removeDuplicates(); + + const QString path = QFileDialog::getOpenFileName(this, tr("Choose icon"), QDir::homePath(), + tr("Images (%1)").arg(patterns.join(' '))); + if (path.isEmpty()) { + return; + } + + QFile file(path); + const LiquidAppIcon icon = (file.size() <= LQD_ICON_MAX_FILE_SIZE && file.open(QIODevice::ReadOnly)) + ? LiquidAppIcon::fromData(file.readAll()) : LiquidAppIcon(); + if (icon.isNull()) { + QMessageBox::warning(this, tr("Unable to use image"), tr("“%1” couldn't be read as an image.").arg(path)); + return; + } + + chosenIcon = icon; + iconWasEdited = true; + updateIconButton(); + }); + + // Forgetting the icon makes the Liquid app fetch it again (right away, if it's running) + connect(resetIconAction, &QAction::triggered, this, [this, updateIconButton]() { + chosenIcon = LiquidAppIcon(); + iconWasEdited = true; + updateIconButton(); + }); + + basicLayout->addWidget(iconButton, 0, 2, 2, 1, Qt::AlignCenter); + } + // Extra checkboxes visible only in "Create" mode if (!isEditingExistingBool) { QHBoxLayout* extraCheckboxesLayout = new QHBoxLayout(); @@ -1011,6 +1075,15 @@ void LiquidAppConfigDialog::save() } } + // Icon (has to be in place before the desktop icon gets created, for it to use it) + if (iconWasEdited) { + if (chosenIcon.isNull()) { + LiquidAppIcon::remove(tempLiquidAppConfig, appName); + } else { + chosenIcon.save(tempLiquidAppConfig, appName); + } + } + // Create desktop icon { // TODO: make it possible to remove and (re-)create desktop icons for existing Liquid Apps diff --git a/src/liquidappfile.cpp b/src/liquidappfile.cpp index 04500c2..9b8b760 100644 --- a/src/liquidappfile.cpp +++ b/src/liquidappfile.cpp @@ -11,7 +11,8 @@ namespace { const int FORMAT_VERSION = 1; const QString ROOT_ELEMENT = QStringLiteral("liquid"); -const QString ICON_DATA_URL_PREFIX = QStringLiteral("data:image/png;base64,"); +// Data URL prefixes of the image types icons can be stored as +const QStringList ICON_TYPES = { QStringLiteral("image/png"), QStringLiteral("image/svg+xml") }; const QString COOKIES_KEY_PREFIX = QStringLiteral(LQD_CFG_GROUP_NAME_COOKIES "/"); // Settings stored as text of their own elements @@ -199,7 +200,12 @@ bool LiquidAppFile::read(QIODevice& device, QSettings::SettingsMap& map) } else if (element == "icon") { static const QRegularExpression whitespace(QStringLiteral("\\s")); - map.insert(LQD_CFG_KEY_NAME_ICON, ICON_DATA_URL_PREFIX + xml.readElementText().remove(whitespace)); + QString type = xml.attributes().value("type").toString(); + if (!ICON_TYPES.contains(type)) { + type = ICON_TYPES.first(); + } + + map.insert(LQD_CFG_KEY_NAME_ICON, "data:" + type + ";base64," + xml.readElementText().remove(whitespace)); } else { const QXmlStreamAttributes attributes = xml.attributes(); @@ -284,9 +290,16 @@ bool LiquidAppFile::write(QIODevice& device, const QSettings::SettingsMap& setti } QString icon; - const bool hasIcon = take(map, LQD_CFG_KEY_NAME_ICON, &icon) && icon.startsWith(ICON_DATA_URL_PREFIX); + QString iconType; + take(map, LQD_CFG_KEY_NAME_ICON, &icon); + for (const QString& type : ICON_TYPES) { + if (icon.startsWith("data:" + type + ";base64,")) { + iconType = type; + } + } + const bool hasIcon = !iconType.isEmpty(); if (!hasIcon && !icon.isEmpty()) { - map.insert(LQD_CFG_KEY_NAME_ICON, icon); // Not a PNG data URL, keep it as a generic setting + map.insert(LQD_CFG_KEY_NAME_ICON, icon); // Not an image data URL of a known type, keep it as a generic setting } // Anything else @@ -325,8 +338,8 @@ bool LiquidAppFile::write(QIODevice& device, const QSettings::SettingsMap& setti if (hasIcon) { xml.writeStartElement("icon"); - xml.writeAttribute("type", "image/png"); - xml.writeCharacters(icon.mid(ICON_DATA_URL_PREFIX.size())); + xml.writeAttribute("type", iconType); + xml.writeCharacters(icon.mid(QString("data:" + iconType + ";base64,").size())); xml.writeEndElement(); } diff --git a/src/liquidappicon.cpp b/src/liquidappicon.cpp new file mode 100644 index 0000000..832be94 --- /dev/null +++ b/src/liquidappicon.cpp @@ -0,0 +1,483 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "liquid.hpp" +#include "liquidappicon.hpp" +#include "lqd.h" + +static const QString PNG_DATA_URL_PREFIX = QStringLiteral("data:image/png;base64,"); +static const QString SVG_DATA_URL_PREFIX = QStringLiteral("data:image/svg+xml;base64,"); + +// Sizes vector icons get rendered at for showing them in the program (so that none ever needs to get scaled) +static const int SVG_ICON_SIZES[] = { 16, 22, 24, 32, 48, 64, 96, 128, 256 }; + +// Scripts can't tell what the page links to without JS (which may be disabled for the page itself), +// so this runs in Liquid's own JS world; it only reads the document, never changes it +static const char* const ICON_LINKS_SCRIPT = + "(function() {" + "const result = { icons: [], manifest: null };" + "for (const link of document.querySelectorAll('link[rel][href]')) {" + "const rel = link.getAttribute('rel').toLowerCase().split(/\\s+/);" + "if (rel.includes('manifest')) {" + "result.manifest = result.manifest || link.href;" + "} else if (rel.includes('icon') || rel.includes('apple-touch-icon') || rel.includes('apple-touch-icon-precomposed')) {" + "result.icons.push(link.href);" + "}" + "}" + "return JSON.stringify(result);" + "})()"; + +static bool looksLikeSvg(const QByteArray& data) +{ + // Compressed SVG (.svgz) included + if (!data.startsWith("\x1f\x8b") && !data.left(4096).toLower().contains(" LQD_ICON_MAX_SVG_SIZE || data.startsWith("\x1f\x8b")) { + return false; + } + + const QString svg = QString::fromUtf8(data); + + static const QRegularExpression forbidden(QStringLiteral("<\\s*(script|foreignObject|iframe|!ENTITY)|@import|\\bon[a-z]+\\s*="), + QRegularExpression::CaseInsensitiveOption); + if (svg.contains(forbidden)) { + return false; + } + + // References may only point to the image's own parts, or to embedded images + static const QRegularExpression href(QStringLiteral("\\bhref\\s*=\\s*([\"'])\\s*([^\"']*)\\1"), QRegularExpression::CaseInsensitiveOption); + static const QRegularExpression url(QStringLiteral("\\burl\\s*\\(\\s*[\"']?\\s*([^\"')\\s]*)"), QRegularExpression::CaseInsensitiveOption); + const auto isInternal = [](const QString& reference) { + return reference.startsWith('#') || reference.startsWith(QLatin1String("data:image/"), Qt::CaseInsensitive); + }; + + for (const QRegularExpression& pattern : { href, url }) { + QRegularExpressionMatchIterator matches = pattern.globalMatch(svg); + + while (matches.hasNext()) { + const QRegularExpressionMatch match = matches.next(); + + if (!isInternal(match.captured(match.lastCapturedIndex()))) { + return false; + } + } + } + + return true; +} + +static QImage renderSvg(const QByteArray& data, const int size) +{ + QSvgRenderer renderer(data); + + if (!renderer.isValid()) { + return QImage(); + } + + QSize imageSize = renderer.defaultSize(); + if (imageSize.isEmpty()) { + imageSize = QSize(size, size); + } + imageSize = imageSize.scaled(size, size, Qt::KeepAspectRatio); + + QImage image(imageSize, QImage::Format_ARGB32_Premultiplied); + image.fill(Qt::transparent); + + QPainter painter(&image); + painter.setRenderHint(QPainter::Antialiasing); + painter.setRenderHint(QPainter::SmoothPixmapTransform); + renderer.render(&painter); + painter.end(); + + return image.convertToFormat(QImage::Format_ARGB32); +} + +// Picks the largest image for files containing several (e.g. .ico) +static QImage decodeRaster(const QByteArray& data) +{ + QBuffer buffer; + buffer.setData(data); + buffer.open(QIODevice::ReadOnly); + + QImageReader reader(&buffer); + + QImage largestImage; + int i = 0; + do { + const QImage image = reader.read(); + + if (!image.isNull() && qMin(image.width(), image.height()) > qMin(largestImage.width(), largestImage.height())) { + largestImage = image; + } + } while (++i < 32 && reader.jumpToNextImage()); + + return largestImage; +} + +LiquidAppIcon LiquidAppIcon::fromData(const QByteArray& data) +{ + if (looksLikeSvg(data)) { + if (isSelfContainedSvg(data)) { + LiquidAppIcon icon; + icon.svg = data; + + return icon; + } + + return fromImage(renderSvg(data, LQD_ICON_MAX_SIZE)); + } + + return fromImage(decodeRaster(data)); +} + +LiquidAppIcon LiquidAppIcon::fromImage(const QImage& image) +{ + LiquidAppIcon icon; + + if (!image.isNull()) { + icon.image = image.convertToFormat(QImage::Format_ARGB32); + + // Large raster icons get scaled down before they get stored + if (icon.image.width() > LQD_ICON_MAX_SIZE || icon.image.height() > LQD_ICON_MAX_SIZE) { + icon.image = icon.image.scaled(LQD_ICON_MAX_SIZE, LQD_ICON_MAX_SIZE, Qt::KeepAspectRatio, Qt::SmoothTransformation); + } + } + + return icon; +} + +LiquidAppIcon LiquidAppIcon::fromIcon(const QIcon& icon) +{ + QSize largestSize; + + for (const QSize& size : icon.availableSizes()) { + if (size.width() * size.height() > largestSize.width() * largestSize.height()) { + largestSize = size; + } + } + + if (largestSize.isEmpty()) { + return LiquidAppIcon(); + } + + return fromImage(icon.pixmap(largestSize).toImage()); +} + +LiquidAppIcon LiquidAppIcon::load(QSettings* liquidAppConfig) +{ + const QString value = liquidAppConfig->value(LQD_CFG_KEY_NAME_ICON).toString(); + LiquidAppIcon icon; + + if (value.startsWith(SVG_DATA_URL_PREFIX)) { + icon.svg = QByteArray::fromBase64(value.mid(SVG_DATA_URL_PREFIX.size()).toLatin1()); + } else if (value.startsWith(PNG_DATA_URL_PREFIX)) { + icon.image.loadFromData(QByteArray::fromBase64(value.mid(PNG_DATA_URL_PREFIX.size()).toLatin1()), "PNG"); + } + + return icon; +} + +QIcon LiquidAppIcon::loadIcon(QSettings* liquidAppConfig) +{ + const LiquidAppIcon icon = load(liquidAppConfig); + + return (icon.isNull()) ? QIcon(":/images/" PROG_NAME ".svg") : icon.toIcon(); +} + +void LiquidAppIcon::save(QSettings* liquidAppConfig, const QString& liquidAppName) const +{ + if (isNull()) { + return; + } + + QByteArray fileData; + if (isSvg()) { + fileData = svg; + liquidAppConfig->setValue(LQD_CFG_KEY_NAME_ICON, SVG_DATA_URL_PREFIX + QString::fromLatin1(svg.toBase64())); + } else { + QBuffer buffer(&fileData); + buffer.open(QIODevice::WriteOnly); + image.save(&buffer, "PNG"); + liquidAppConfig->setValue(LQD_CFG_KEY_NAME_ICON, PNG_DATA_URL_PREFIX + QString::fromLatin1(fileData.toBase64())); + } + liquidAppConfig->sync(); + +#if defined(Q_OS_LINUX) + // .desktop files can only point to icons, not contain them + removeIconFile(liquidAppName); + + const QDir iconsDir(Liquid::getDataDir().filePath(LQD_ICONS_DIR_NAME)); + QDir().mkpath(iconsDir.path()); + QSaveFile iconFile(iconsDir.filePath(liquidAppName + ((isSvg()) ? ".svg" : ".png"))); + if (iconFile.open(QIODevice::WriteOnly)) { + iconFile.write(fileData); + iconFile.commit(); + } + + Liquid::updateDesktopFileIcon(liquidAppName); +#else + Q_UNUSED(liquidAppName); +#endif +} + +void LiquidAppIcon::remove(QSettings* liquidAppConfig, const QString& liquidAppName) +{ + liquidAppConfig->remove(LQD_CFG_KEY_NAME_ICON); + liquidAppConfig->sync(); + + removeIconFile(liquidAppName); + Liquid::updateDesktopFileIcon(liquidAppName); +} + +void LiquidAppIcon::ensureIconFile(QSettings* liquidAppConfig, const QString& liquidAppName) +{ +#if defined(Q_OS_LINUX) + if (getIconFilePath(liquidAppName).isEmpty()) { + load(liquidAppConfig).save(liquidAppConfig, liquidAppName); + } +#else + Q_UNUSED(liquidAppConfig); + Q_UNUSED(liquidAppName); +#endif +} + +QString LiquidAppIcon::getIconFilePath(const QString& liquidAppName) +{ + const QDir iconsDir(Liquid::getDataDir().filePath(LQD_ICONS_DIR_NAME)); + + for (const char* const extension : { ".svg", ".png" }) { + const QString path = iconsDir.filePath(liquidAppName + extension); + + if (QFile::exists(path)) { + return path; + } + } + + return QString(); +} + +void LiquidAppIcon::removeIconFile(const QString& liquidAppName) +{ + const QDir iconsDir(Liquid::getDataDir().filePath(LQD_ICONS_DIR_NAME)); + + QFile::remove(iconsDir.filePath(liquidAppName + ".svg")); + QFile::remove(iconsDir.filePath(liquidAppName + ".png")); +} + +bool LiquidAppIcon::isNull(void) const +{ + return svg.isEmpty() && image.isNull(); +} + +bool LiquidAppIcon::isSvg(void) const +{ + return !svg.isEmpty(); +} + +int LiquidAppIcon::score(void) const +{ + if (isSvg()) { + return LQD_ICON_MAX_SIZE * 2; + } + + return qMin(image.width(), image.height()); +} + +QImage LiquidAppIcon::toImage(const int size) const +{ + if (isSvg()) { + return renderSvg(svg, size); + } + + if (image.width() > size || image.height() > size) { + return image.scaled(size, size, Qt::KeepAspectRatio, Qt::SmoothTransformation); + } + + return image; +} + +QIcon LiquidAppIcon::toIcon(void) const +{ + if (isNull()) { + return QIcon(); + } + + if (!isSvg()) { + return QIcon(QPixmap::fromImage(image)); + } + + QIcon icon; + for (const int size : SVG_ICON_SIZES) { + icon.addPixmap(QPixmap::fromImage(renderSvg(svg, size))); + } + + return icon; +} + +LiquidAppIconFetcher::LiquidAppIconFetcher(QWebEnginePage* page, QObject* parent) : QObject(parent), page(page) +{ + networkAccessManager = new QNetworkAccessManager(this); + networkAccessManager->setRedirectPolicy(QNetworkRequest::NoLessSafeRedirectPolicy); +} + +void LiquidAppIconFetcher::start(void) +{ + QPointer self(this); + + page->runJavaScript(ICON_LINKS_SCRIPT, QWebEngineScript::ApplicationWorld, [self](const QVariant& result) { + if (self) { + self->onPageInspected(result.toString()); + } + }); +} + +void LiquidAppIconFetcher::onPageInspected(const QString& json) +{ + const QJsonObject result = QJsonDocument::fromJson(json.toUtf8()).object(); + const QUrl pageUrl = page->url(); + + // Whatever the page links to + for (const QJsonValue& icon : result.value("icons").toArray()) { + download(QUrl(icon.toString()), false); + } + + // Icons for "installing" the website, these tend to be the largest ones + const QString manifest = result.value("manifest").toString(); + if (!manifest.isEmpty()) { + const QUrl manifestUrl(manifest); + + if (manifestUrl.scheme() == "http" || manifestUrl.scheme() == "https") { + QNetworkRequest request(manifestUrl); + request.setHeader(QNetworkRequest::UserAgentHeader, page->profile()->httpUserAgent()); + request.setTransferTimeout(LQD_ICON_DOWNLOAD_TIMEOUT); + + QNetworkReply* reply = networkAccessManager->get(request); + pendingRequestCount++; + connect(reply, &QNetworkReply::finished, this, [this, reply]() { + onManifestDownloaded(reply); + }); + } + } + + // Places where websites tend to keep icons without linking to them + if (pageUrl.scheme() == "http" || pageUrl.scheme() == "https") { + download(pageUrl.resolved(QUrl("/apple-touch-icon.png")), false); + download(pageUrl.resolved(QUrl("/favicon.ico")), false); + } + + finishIfDone(); +} + +void LiquidAppIconFetcher::download(const QUrl& url, const bool isMaskable) +{ + const QString scheme = url.scheme(); + + if (!url.isValid() || (scheme != "http" && scheme != "https" && scheme != "data")) { + return; + } + + if (requestedUrls.contains(url) || requestedUrls.size() >= LQD_ICON_MAX_CANDIDATES) { + return; + } + requestedUrls << url; + + QNetworkRequest request(url); + request.setHeader(QNetworkRequest::UserAgentHeader, page->profile()->httpUserAgent()); + request.setTransferTimeout(LQD_ICON_DOWNLOAD_TIMEOUT); + + QNetworkReply* reply = networkAccessManager->get(request); + pendingRequestCount++; + + // Icons are small, anything big is something else + connect(reply, &QNetworkReply::downloadProgress, reply, [reply](const qint64 bytesReceived, const qint64 bytesTotal) { + if (bytesReceived > LQD_ICON_MAX_FILE_SIZE || bytesTotal > LQD_ICON_MAX_FILE_SIZE) { + reply->abort(); + } + }); + + connect(reply, &QNetworkReply::finished, this, [this, reply, isMaskable]() { + onDownloaded(reply, isMaskable); + }); +} + +void LiquidAppIconFetcher::onDownloaded(QNetworkReply* reply, const bool isMaskable) +{ + reply->deleteLater(); + pendingRequestCount--; + + if (reply->error() == QNetworkReply::NoError) { + const LiquidAppIcon icon = LiquidAppIcon::fromData(reply->readAll()); + + if (!icon.isNull()) { + // Maskable icons are meant to get cropped (they have extra space around them), + // so others of about the same size are preferred + const qreal score = icon.score() * ((isMaskable) ? 0.75 : 1.0); + + if (score > bestScore) { + bestScore = score; + bestIcon = icon; + } + } + } + + finishIfDone(); +} + +void LiquidAppIconFetcher::onManifestDownloaded(QNetworkReply* reply) +{ + reply->deleteLater(); + pendingRequestCount--; + + if (reply->error() == QNetworkReply::NoError && reply->size() <= LQD_ICON_MAX_FILE_SIZE) { + const QUrl manifestUrl = reply->url(); + const QJsonObject manifest = QJsonDocument::fromJson(reply->readAll()).object(); + + for (const QJsonValue& value : manifest.value("icons").toArray()) { + const QJsonObject icon = value.toObject(); + const QStringList purposes = icon.value("purpose").toString("any").toLower().split(' ', Qt::SkipEmptyParts); + + // Single-color silhouettes aren't meant to be shown as-is + if (!purposes.contains("any") && !purposes.contains("maskable")) { + continue; + } + + download(manifestUrl.resolved(QUrl(icon.value("src").toString())), !purposes.contains("any")); + } + } + + finishIfDone(); +} + +void LiquidAppIconFetcher::finishIfDone(void) +{ + if (pendingRequestCount > 0 || isDone) { + return; + } + + isDone = true; + emit finished(bestIcon); +} diff --git a/src/liquidappwindow.cpp b/src/liquidappwindow.cpp index e94468e..7dee0ab 100644 --- a/src/liquidappwindow.cpp +++ b/src/liquidappwindow.cpp @@ -12,6 +12,7 @@ #include #include #include +#include #include #include #include @@ -25,9 +26,11 @@ #include "liquid.hpp" #include "liquidappfile.hpp" +#include "liquidappicon.hpp" #include "liquidappcookiejar.hpp" #include "liquidappwebpage.hpp" #include "liquidappwindow.hpp" +#include "singleinstance.hpp" #include "svgsnapshot.hpp" #include "lqd.h" #ifdef Q_OS_MAC @@ -588,6 +591,11 @@ void LiquidAppWindow::loadFinished(bool ok) } updateWindowTitle(title()); + + // Get the website's icon, if the Liquid app doesn't have one yet + if (ok && !pageHasError) { + maybeFetchIcon(); + } } void LiquidAppWindow::loadLiquidAppConfig(void) @@ -736,14 +744,9 @@ void LiquidAppWindow::loadLiquidAppConfig(void) // 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 + // Liquid app's own icon (if it has one already) + applyIcon(); + LiquidAppIcon::ensureIconFile(liquidAppConfig, liquidAppName); } void LiquidAppWindow::applyAllowedDomains(void) @@ -922,6 +925,15 @@ void LiquidAppWindow::reloadLiquidAppConfig(void) applyWebSettings(); applyAdditionalCssAndJs(); + // Icon (settings may have taken it away, to have it fetched from the website again) + if (!liquidAppConfig->contains(LQD_CFG_KEY_NAME_ICON)) { + iconFetchAttempted = false; + } + applyIcon(); + if (!pageIsLoading && !pageHasError) { + maybeFetchIcon(); + } + // Custom CSS gets applied to the page that's currently shown without reloading it // (this also undoes whatever CSS has been previewed while editing settings) setAdditionalCss(liquidAppConfig->value(LQD_CFG_KEY_NAME_ADDITIONAL_CSS).toString()); @@ -977,10 +989,85 @@ bool LiquidAppWindow::isPositionManagedByWindowSystem(void) void LiquidAppWindow::onIconChanged(QIcon icon) { - // Set window icon - setWindowIcon(icon); + pageIcon = icon; + + if (!liquidAppConfig->contains(LQD_CFG_KEY_NAME_ICON)) { + // Show it until the Liquid app has an icon of its own + applyIcon(); + + // Fetching may have found nothing usable before the page's own icon arrived + if (iconFetchAttempted && iconFetcher == Q_NULLPTR && !icon.isNull() && isShowingOwnWebsite()) { + useFetchedIcon(LiquidAppIcon()); + } + } +} + +void LiquidAppWindow::applyIcon(void) +{ + const LiquidAppIcon liquidAppIcon = LiquidAppIcon::load(liquidAppConfig); + + if (!liquidAppIcon.isNull()) { + const QIcon icon = liquidAppIcon.toIcon(); - iconToSave = icon; + setWindowIcon(icon); + // Also used for the Dock on macOS, and for whatever new windows (e.g. dialogs) this Liquid app opens + QApplication::setWindowIcon(icon); + } else if (!pageIcon.isNull()) { + setWindowIcon(pageIcon); + } +} + +bool LiquidAppWindow::isShowingOwnWebsite(void) +{ + // Icons of whatever the Liquid app may pass through first (e.g. sign-in pages elsewhere) are of no interest + static const QRegularExpression wwwPrefix("^www\\."); + const QString startingHost = QUrl(liquidAppConfig->value(LQD_CFG_KEY_NAME_URL).toString()).host().toLower().remove(wwwPrefix); + const QString currentHost = url().host().toLower().remove(wwwPrefix); + + if (startingHost.isEmpty() || currentHost.isEmpty()) { + return false; + } + + return currentHost == startingHost + || currentHost.endsWith("." + startingHost) + || startingHost.endsWith("." + currentHost); +} + +void LiquidAppWindow::maybeFetchIcon(void) +{ + if (iconFetchAttempted || liquidAppConfig->contains(LQD_CFG_KEY_NAME_ICON) || !isShowingOwnWebsite()) { + return; + } + + iconFetchAttempted = true; + + iconFetcher = new LiquidAppIconFetcher(page(), this); + connect(iconFetcher, &LiquidAppIconFetcher::finished, this, [this](const LiquidAppIcon& icon) { + iconFetcher->deleteLater(); + iconFetcher = Q_NULLPTR; + + useFetchedIcon(icon); + }); + iconFetcher->start(); +} + +void LiquidAppWindow::useFetchedIcon(const LiquidAppIcon& icon) +{ + // The icon the page has provided may be better than anything that got downloaded (e.g. if the website needs cookies) + const LiquidAppIcon pageLiquidAppIcon = LiquidAppIcon::fromIcon(pageIcon); + const LiquidAppIcon& bestIcon = (pageLiquidAppIcon.score() > icon.score()) ? pageLiquidAppIcon : icon; + + // Settings may have been given an icon in the meantime + liquidAppConfig->sync(); + if (bestIcon.isNull() || liquidAppConfig->contains(LQD_CFG_KEY_NAME_ICON)) { + return; + } + + bestIcon.save(liquidAppConfig, liquidAppName); + applyIcon(); + + // Let the main window (if it's open) show it + SingleInstance::sendMessage(LQD_INSTANCE_ID_MAIN, QByteArray(LQD_INSTANCE_MSG_ICON_CHANGED) + " " + liquidAppName.toUtf8()); } void LiquidAppWindow::resizeEvent(QResizeEvent* event) @@ -1011,14 +1098,6 @@ void LiquidAppWindow::saveLiquidAppConfig(void) } } - // Save icon data as base64 string - if (!iconToSave.isNull()) { - QBuffer buffer; - buffer.open(QIODevice::WriteOnly); - iconToSave.pixmap(iconToSave.availableSizes()[0]).save(&buffer, "PNG"); - liquidAppConfig->setValue(LQD_CFG_KEY_NAME_ICON, QString("data:image/png;base64,%1").arg(QString(buffer.data().toBase64()))); - } - // Window geometry rememberGeometry(); if (rememberedGeometry.isValid()) { diff --git a/src/main.cpp b/src/main.cpp index 3f4408f..cbf7a49 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -88,11 +88,15 @@ int main(int argc, char **argv) QObject::connect(singleInstance, &SingleInstance::messageReceived, mainWindow, [](const QByteArray& message) { static const QByteArray startedPrefix = QByteArray(LQD_INSTANCE_MSG_STARTED) + " "; + static const QByteArray iconChangedPrefix = QByteArray(LQD_INSTANCE_MSG_ICON_CHANGED) + " "; QString activationToken; if (Liquid::parseRaiseMessage(message, &activationToken)) { Liquid::raiseWindow(mainWindow, activationToken); + } else if (message.startsWith(iconChangedPrefix)) { + // A Liquid app has got its icon from its website + mainWindow->updateLiquidAppIcon(QString::fromUtf8(message.mid(iconChangedPrefix.size()))); } else if (message.startsWith(startedPrefix)) { // A Liquid app has started, show it as running mainWindow->watchLiquidApp(QString::fromUtf8(message.mid(startedPrefix.size()))); diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index 663c07c..dd60aa2 100644 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -1,5 +1,6 @@ #include #include +#include #include #include #include @@ -9,6 +10,7 @@ #include "liquid.hpp" #include "liquidappfile.hpp" +#include "liquidappicon.hpp" #include "liquidappconfigwindow.hpp" #include "lqd.h" #include "mainwindow.hpp" @@ -148,13 +150,7 @@ void MainWindow::populateTable(void) QTableWidgetItem* appItemWidgetFirstColumn = new QTableWidgetItem(); // Make them read-only (no text edit upon double-click) appItemWidgetFirstColumn->setFlags(appItemWidgetFirstColumn->flags() ^ Qt::ItemIsEditable); - 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"); - appItemWidgetFirstColumn->setIcon(QIcon(pixmap)); - } else { - appItemWidgetFirstColumn->setIcon(QIcon(":/images/" PROG_NAME ".svg")); - } + appItemWidgetFirstColumn->setIcon(LiquidAppIcon::loadIcon(liquidAppConfig)); appItemWidgetFirstColumn->setText(liquidAppName); appListTable->setItem(i, 0, appItemWidgetFirstColumn); @@ -184,6 +180,7 @@ void MainWindow::populateTable(void) } Liquid::removeDesktopFile(liquidAppName); + LiquidAppIcon::removeIconFile(liquidAppName); // Shred and unlink Liquid app settings file QFile liquidAppConfigFile(liquidAppConfig->fileName()); @@ -258,6 +255,18 @@ void MainWindow::populateTable(void) } } +void MainWindow::updateLiquidAppIcon(const QString& liquidAppName) +{ + for (int i = 0; i < appListTable->rowCount(); i++) { + QTableWidgetItem* item = appListTable->item(i, 0); + + if (item != Q_NULLPTR && item->text() == liquidAppName) { + QScopedPointer liquidAppConfig(LiquidAppFile::open(liquidAppName)); + item->setIcon(LiquidAppIcon::loadIcon(liquidAppConfig.data())); + } + } +} + void MainWindow::watchLiquidApp(const QString& liquidAppName) { if (liquidAppConnections.contains(liquidAppName)) {