diff --git a/inc/liquidappconfigwindow.hpp b/inc/liquidappconfigwindow.hpp index af7358c..f2bd0c4 100644 --- a/inc/liquidappconfigwindow.hpp +++ b/inc/liquidappconfigwindow.hpp @@ -12,6 +12,7 @@ #include #include #include +#include #include "liquidappicon.hpp" @@ -39,6 +40,16 @@ public slots: static QFrame* separator(void); static QString getSettingsRequiringRestart(QSettings* liquidAppConfig); void previewBackground(const QColor& color); + static QString getStorageItemId(const QString& origin, const QString& key); + void setUpStorageTableView(QTableView* tableView, QStandardItemModel* model, const int stretchingColumn); + static QStandardItem* newRemoveButtonItem(void); + void addRemoveButtons(QTableView* tableView, QStandardItemModel* model); + void removeStorageItem(QTableView* tableView, const int row); + void fillCookiesModel(QSettings* liquidAppConfig); + void fillLocalStorageModel(QSettings* liquidAppConfig); + void fillSessionStorageModel(void); + void refreshStorage(const QString& liquidAppName); + bool connectToLiquidApp(void); void sendPreview(const QByteArray& message); bool isEditingExistingBool = false; @@ -87,6 +98,21 @@ public slots: QCheckBox* allowThirdPartyCookiesCheckBox; QTableView* cookiesTableView; QStandardItemModel* cookiesModel; + QCheckBox* rememberLocalStorageCheckBox; + QTableView* localStorageTableView; + QStandardItemModel* localStorageModel; + QTableView* sessionStorageTableView; + QStandardItemModel* sessionStorageModel; + // What's been removed or edited here, kept on top of what the Liquid app stores (until saved, or discarded) + QSet removedCookieIds; // As identified in the Liquid app's file + QSet removedLocalStorageIds; // See getStorageItemId() + QMap localStorageEdits; // { "origin", "key", "value" } by ID + QSet removedSessionStorageIds; + // Latest session storage the Liquid app has sent over: { "origin", "items" } + QJsonObject sessionStorage; + // For showing cookies and local storage items as the Liquid app stores them + QFileSystemWatcher* liquidAppFileWatcher = Q_NULLPTR; + QTimer* storageRefreshTimer = Q_NULLPTR; // Network tab QRadioButton* proxyModeSystemRadioButton; diff --git a/inc/liquidappcookiejar.hpp b/inc/liquidappcookiejar.hpp index 02efafb..6a8215b 100644 --- a/inc/liquidappcookiejar.hpp +++ b/inc/liquidappcookiejar.hpp @@ -20,6 +20,12 @@ class LiquidAppCookieJar : public QNetworkCookieJar void restoreCookies(QWebEngineCookieStore* cookieStore); + // Removes cookies that are no longer in the Liquid app's file (e.g. after getting removed in settings) + void removeCookiesMissingFromConfig(QWebEngineCookieStore* cookieStore); + + // How cookies are identified in Liquid apps' files + static QString getCookieId(const QNetworkCookie& cookie); + private: QSettings* liquidAppConfig; LiquidAppWindow* liquidAppWindow; diff --git a/inc/liquidapplocalstorage.hpp b/inc/liquidapplocalstorage.hpp new file mode 100644 index 0000000..88d3147 --- /dev/null +++ b/inc/liquidapplocalstorage.hpp @@ -0,0 +1,75 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +class QTimer; +class QUrl; +class QWebEnginePage; + +// Remembers what websites keep in their local storage (window.localStorage) across runs of a Liquid app. +// +// Qt provides no access to local storage, and Liquid apps' profiles are off-the-record (nothing gets written to disk), +// so it's done via Liquid's own JS world (which works even if JavaScript is disabled for the page itself): +// whatever the page keeps there gets read every now and then (and before quitting), and stored in the .liquid file, +// and saved items get put back in place before the website's own code runs, the first time it's visited in a run. +// Only the top-level page's storage is remembered (pages embedded in frames can't be reached this way in Qt 5). +class LiquidAppLocalStorage : public QObject +{ + Q_OBJECT + +public: + LiquidAppLocalStorage(QWebEnginePage* page, QSettings* liquidAppConfig, QObject* parent = Q_NULLPTR); + + // Picks up changes made to settings (whether to remember local storage, items edited in settings) + void reloadConfig(void); + + bool isEnabled(void) const; + + // The script that reads the page's local storage, and what to do with its result + // (for reading it synchronously right before quitting) + static QString getCaptureScript(void); + void onCaptured(const QString& json); + + // Session storage of the page that's currently shown (read only while it's being watched, e.g. by settings) + void setWatchingSessionStorage(const bool watching); + QString getSessionStorageOrigin(void) const; + QJsonObject getSessionStorageItems(void) const; + void removeSessionStorageItems(const QString& origin, const QStringList& keys); + + // Stored items: { "origin": { "key": "value", ... }, ... } + static QJsonObject load(QSettings* liquidAppConfig); + static void save(QSettings* liquidAppConfig, const QJsonObject& items); + +signals: + void sessionStorageChanged(void); + +private: + void capture(void); + void updateCaptureTimer(void); + void updateRestoreScript(void); + void markOriginAsLive(const QString& origin); + void onOriginVisited(const QString& origin); + static QString getOrigin(const QUrl& url); + static QString getRestoreScript(const QJsonObject& savedItems, const QJsonObject& editedItems); + + QWebEnginePage* page; + QSettings* liquidAppConfig; + QTimer* captureTimer; + bool isEnabledBool = false; + bool isWatchingSessionStorageBool = false; + QString sessionStorageOrigin; + QJsonObject sessionStorageItems; + + // What's known to be in the local storage of each origin + QJsonObject items; + // Origins visited during this run: their local storage in the page is the up-to-date one, + // so it never gets restored from the saved items again (e.g. after the website has removed some of them) + QSet liveOrigins; + // Origins whose items have been edited in settings, to be put in place once they get visited + QSet editedOrigins; +}; diff --git a/inc/liquidappwindow.hpp b/inc/liquidappwindow.hpp index cca49af..1f4ccbf 100644 --- a/inc/liquidappwindow.hpp +++ b/inc/liquidappwindow.hpp @@ -15,8 +15,11 @@ #include "liquidappwebpage.hpp" +class LiquidAppCookieJar; +class QLocalSocket; class LiquidAppIcon; class LiquidAppIconFetcher; +class LiquidAppLocalStorage; class LiquidAppWebPage; class SnapshotFlashOverlay; class QPainter; @@ -43,6 +46,9 @@ public slots: void setBackground(const QColor& color); void setAdditionalCss(const QString& css); void setFrameless(const bool frameless); + // Keeps sending session storage of the page that's currently shown over the connection, until it closes + void watchSessionStorage(QLocalSocket* connection); + void removeSessionStorageItems(const QString& origin, const QStringList& keys); void setScrollBarsHidden(const bool hidden); void onIconChanged(QIcon icon); void stopLoadingOrExitFullScreenMode(void); @@ -92,6 +98,14 @@ public slots: 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; + + // Remembers what websites keep in local storage (if allowed to) + LiquidAppLocalStorage* liquidAppLocalStorage = Q_NULLPTR; + QList> sessionStorageWatchers; + void sendSessionStorage(QLocalSocket* connection); + + // Keeps cookies in the Liquid app's file + LiquidAppCookieJar* liquidAppCookieJar = Q_NULLPTR; bool iconFetchAttempted = false; // Settings dialog, while it's open diff --git a/inc/lqd.h b/inc/lqd.h index fa158f8..c1b0179 100644 --- a/inc/lqd.h +++ b/inc/lqd.h @@ -38,6 +38,9 @@ #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_WATCH_SESSION_STORAGE "watch-session-storage" // Liquid apps only; they keep sending session storage back +#define LQD_INSTANCE_MSG_SESSION_STORAGE "session-storage" // Sent back to watchers, followed by a space and base64-encoded JSON +#define LQD_INSTANCE_MSG_REMOVE_SESSION_STORAGE "remove-session-storage" // Liquid apps only, followed by a space and base64-encoded JSON #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 @@ -49,11 +52,17 @@ #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_LOCAL_STORAGE_CAPTURE_INTERVAL 2000 // Milliseconds between checks of what websites keep in local storage +#define LQD_UI_REMOVE_BUTTON_COLUMN_WIDTH 28 // Pixels +#define LQD_UI_TOOLTIP_MAX_LENGTH 2000 // Characters of long values shown in tooltips +#define LQD_STORAGE_REFRESH_DELAY 300 // Milliseconds to wait for the .liquid file to settle before re-reading it in settings +#define LQD_LOCAL_STORAGE_QUIT_TIMEOUT 1000 // Milliseconds to wait for reading local storage before quitting #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" +#define LQD_SCRIPT_NAME_LOCAL_STORAGE "liquid-local-storage" /* Textual icons */ #define LQD_ICON_ADD "➕" @@ -87,6 +96,7 @@ #define LQD_CFG_KEY_NAME_ENABLE_JS "EnableJS" // boolean, defaults to FALSE #define LQD_CFG_KEY_NAME_HIDE_SCROLLBARS "HideScrollBars" // boolean, defaults to FALSE #define LQD_CFG_KEY_NAME_ICON "Icon" // text +#define LQD_CFG_KEY_NAME_LOCAL_STORAGE "LocalStorage" // text, JSON: { "origin": { "key": "value", ... }, ... } #define LQD_CFG_KEY_NAME_LOCK_WIN_GEOM "LockWindowGeometry" // boolean, defaults to FALSE #define LQD_CFG_KEY_NAME_MUTE_AUDIO "MuteAudio" // boolean, defaults to FALSE #define LQD_CFG_KEY_NAME_NOTES "Notes" // text @@ -96,6 +106,7 @@ #define LQD_CFG_KEY_NAME_PROXY_USE_SOCKS LQD_CFG_GROUP_NAME_PROXY "/" "UseSocks" // boolean, defaults to FALSE #define LQD_CFG_KEY_NAME_PROXY_USER_NAME LQD_CFG_GROUP_NAME_PROXY "/" "UserName" // text #define LQD_CFG_KEY_NAME_PROXY_USER_PASSWORD LQD_CFG_GROUP_NAME_PROXY "/" "UserPassword" // text +#define LQD_CFG_KEY_NAME_REMEMBER_LOCAL_STORAGE "RememberLocalStorage" // boolean, defaults to FALSE #define LQD_CFG_KEY_NAME_REMOVE_WINDOW_FRAME "RemoveWindowFrame" // boolean, defaults to FALSE #define LQD_CFG_KEY_NAME_TITLE "Title" // text #define LQD_CFG_KEY_NAME_USE_PROXY "UseProxy" // boolean, defaults to FALSE diff --git a/inc/singleinstance.hpp b/inc/singleinstance.hpp index d568a87..35c6623 100644 --- a/inc/singleinstance.hpp +++ b/inc/singleinstance.hpp @@ -5,6 +5,7 @@ #include class QLocalServer; +class QLocalSocket; // 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). @@ -30,6 +31,8 @@ class SingleInstance : public QObject signals: void messageReceived(const QByteArray& message); + // The same, along with the connection it came through (for replying, or for keeping on sending things over it) + void messageReceivedFrom(QLocalSocket* connection, const QByteArray& message); private: QString instanceId; diff --git a/liquid.pro b/liquid.pro index 1828729..e2dbcda 100644 --- a/liquid.pro +++ b/liquid.pro @@ -26,6 +26,7 @@ HEADERS += inc/lqd.h \ inc/liquid.hpp \ inc/liquidappfile.hpp \ inc/liquidappicon.hpp \ + inc/liquidapplocalstorage.hpp \ inc/liquidappcookiejar.hpp \ inc/liquidappconfigwindow.hpp \ inc/liquidappwebpage.hpp \ @@ -37,6 +38,7 @@ HEADERS += inc/lqd.h \ SOURCES += src/liquid.cpp \ src/liquidappfile.cpp \ src/liquidappicon.cpp \ + src/liquidapplocalstorage.cpp \ src/liquidappcookiejar.cpp \ src/liquidappconfigwindow.cpp \ src/liquidappwebpage.cpp \ diff --git a/src/liquidappconfigwindow.cpp b/src/liquidappconfigwindow.cpp index 14f4329..2703092 100644 --- a/src/liquidappconfigwindow.cpp +++ b/src/liquidappconfigwindow.cpp @@ -1,4 +1,8 @@ #include +#include +#include +#include +#include #include #include #include @@ -7,6 +11,7 @@ #include "liquid.hpp" #include "liquidappfile.hpp" #include "liquidappicon.hpp" +#include "liquidapplocalstorage.hpp" #include "liquidappconfigwindow.hpp" #include "lqd.h" #include "mainwindow.hpp" @@ -571,14 +576,14 @@ LiquidAppConfigDialog::LiquidAppConfigDialog(QWidget* parent, QString liquidAppN } ///////////////// - // Cookies tab // + // Storage tab // ///////////////// { QWidget* cookiesTabWidget = new QWidget(this); QVBoxLayout* cookiesTabWidgetLayout = new QVBoxLayout(); cookiesTabWidget->setLayout(cookiesTabWidgetLayout); - tabWidget->addTab(cookiesTabWidget, tr("Cookies")); + tabWidget->addTab(cookiesTabWidget, tr("Storage")); // Allow cookies & allow third-party cookies checkboxes { @@ -628,59 +633,165 @@ LiquidAppConfigDialog::LiquidAppConfigDialog(QWidget* parent, QString liquidAppN } } - // Cookies list view + // Cookies list { cookiesTabWidgetLayout->addWidget(separator()); + cookiesTabWidgetLayout->addWidget(new QLabel(tr("Cookies:"), this)); - // Cookies list label - { - QLabel* cookiesListLabel = new QLabel(tr("Cookie jar:"), this); - cookiesTabWidgetLayout->addWidget(cookiesListLabel); + cookiesTableView = new QTableView(this); + cookiesModel = new QStandardItemModel(this); + + cookiesModel->setHorizontalHeaderLabels({ tr("Name"), tr("Value"), tr("Domain"), tr("Path"), tr("Expires"), tr("HttpOnly"), tr("Secure"), QString() }); + setUpStorageTableView(cookiesTableView, cookiesModel, 1); + // The cookie jar belongs to the Liquid app, it's only shown here (and can have cookies removed) + cookiesTableView->setEditTriggers(QAbstractItemView::NoEditTriggers); + + if (isEditingExistingBool) { + fillCookiesModel(existingLiquidAppConfig); } - // Cookies list - { - cookiesTableView = new QTableView(this); - cookiesModel = new QStandardItemModel(this); + cookiesTabWidgetLayout->addWidget(cookiesTableView); + } - cookiesModel->setHorizontalHeaderItem(0, new QStandardItem(tr("Name"))); - cookiesModel->setHorizontalHeaderItem(1, new QStandardItem(tr("Value"))); - cookiesModel->setHorizontalHeaderItem(2, new QStandardItem(tr("Domain"))); - cookiesModel->setHorizontalHeaderItem(3, new QStandardItem(tr("Path"))); - cookiesModel->setHorizontalHeaderItem(4, new QStandardItem(tr("Expires"))); - cookiesModel->setHorizontalHeaderItem(5, new QStandardItem(tr("HttpOnly"))); - cookiesModel->setHorizontalHeaderItem(6, new QStandardItem(tr("Secure"))); + // Session storage list (only while the Liquid app is running: it's never stored anywhere, so it comes from there) + { + cookiesTabWidgetLayout->addWidget(separator()); + cookiesTabWidgetLayout->addWidget(new QLabel(tr("Session storage:"), this)); - // Assign model - cookiesTableView->setModel(cookiesModel); + sessionStorageTableView = new QTableView(this); + sessionStorageModel = new QStandardItemModel(this); - // Fill model items - if (isEditingExistingBool) { - existingLiquidAppConfig->beginGroup(LQD_CFG_GROUP_NAME_COOKIES); - int i = 0; - foreach(QString cookieId, existingLiquidAppConfig->allKeys()) { - const QByteArray rawCookie = existingLiquidAppConfig->value(cookieId).toByteArray(); - QList cookies = QNetworkCookie::parseCookies(rawCookie); - if (cookies.size() > 0) { - QNetworkCookie cookie = cookies[0]; - - cookiesModel->appendRow(new QStandardItem()); - cookiesModel->setItem(i, 0, new QStandardItem(QString(cookie.name()))); - cookiesModel->setItem(i, 1, new QStandardItem(QString(cookie.value()))); - cookiesModel->setItem(i, 2, new QStandardItem(cookie.domain())); - cookiesModel->setItem(i, 3, new QStandardItem(cookie.path())); - cookiesModel->setItem(i, 4, new QStandardItem(cookie.expirationDate().toString())); - cookiesModel->setItem(i, 5, new QStandardItem(cookie.isHttpOnly() ? "true" : "false")); - cookiesModel->setItem(i, 6, new QStandardItem(cookie.isSecure() ? "true" : "false")); - - i++; - } - } - existingLiquidAppConfig->endGroup(); - } + sessionStorageModel->setHorizontalHeaderLabels({ tr("Website"), tr("Key"), tr("Value"), QString() }); + setUpStorageTableView(sessionStorageTableView, sessionStorageModel, 2); + sessionStorageTableView->setEditTriggers(QAbstractItemView::NoEditTriggers); + sessionStorageTableView->setToolTip(tr("Session storage of the page the Liquid app is showing (while it's running)")); + + cookiesTabWidgetLayout->addWidget(sessionStorageTableView); + } + + // Local storage list + { + cookiesTabWidgetLayout->addWidget(separator()); + + QLabel* localStorageListLabel = new QLabel(tr("Local storage:"), this); + cookiesTabWidgetLayout->addWidget(localStorageListLabel); + + localStorageTableView = new QTableView(this); + localStorageModel = new QStandardItemModel(this); + + localStorageModel->setHorizontalHeaderLabels({ tr("Website"), tr("Key"), tr("Value"), QString() }); + setUpStorageTableView(localStorageTableView, localStorageModel, 2); + + if (isEditingExistingBool) { + fillLocalStorageModel(existingLiquidAppConfig); } - cookiesTabWidgetLayout->addWidget(cookiesTableView); + cookiesTabWidgetLayout->addWidget(localStorageTableView); + + // Remember local storage checkbox (local storage works regardless, this is about keeping it after quitting) + rememberLocalStorageCheckBox = new QCheckBox(tr("Remember local storage"), this); + rememberLocalStorageCheckBox->setCursor(Qt::PointingHandCursor); + rememberLocalStorageCheckBox->setToolTip(tr("Keep what websites store in their local storage after quitting")); + if (isEditingExistingBool) { + rememberLocalStorageCheckBox->setChecked(existingLiquidAppConfig->value(LQD_CFG_KEY_NAME_REMEMBER_LOCAL_STORAGE, false).toBool()); + } else { + // Checked by default (when creating new Liquid App) + rememberLocalStorageCheckBox->setChecked(true); + } + cookiesTabWidgetLayout->addWidget(rememberLocalStorageCheckBox); + + // Editing within the list (edits are kept on top of what the Liquid app stores, which keeps getting shown) + connect(localStorageModel, &QStandardItemModel::itemChanged, this, [this](QStandardItem* item) { + // Only keys and values can be edited + if (item->column() != 1 && item->column() != 2) { + return; + } + + const int row = item->row(); + QStandardItem* originItem = localStorageModel->item(row, 0); + const QString origin = originItem->text(); + const QString key = localStorageModel->item(row, 1)->text(); + const QString previousId = originItem->data(Qt::UserRole).toString(); + const QString id = getStorageItemId(origin, key); + + // A renamed key is the old item removed, and a new one added + if (id != previousId) { + removedLocalStorageIds.insert(previousId); + localStorageEdits.remove(previousId); + + const QSignalBlocker blocker(localStorageModel); + originItem->setData(id, Qt::UserRole); + } + + QJsonObject edit; + edit.insert("origin", origin); + edit.insert("key", key); + edit.insert("value", localStorageModel->item(row, 2)->text()); + localStorageEdits.insert(id, edit); + removedLocalStorageIds.remove(id); + }); + + // The list only matters if local storage gets remembered (otherwise it gets forgotten upon saving) + const auto onRememberToggled = [this, localStorageListLabel](const bool isOn) { + localStorageListLabel->setEnabled(isOn); + localStorageTableView->setEnabled(isOn); + }; + onRememberToggled(rememberLocalStorageCheckBox->isChecked()); + connect(rememberLocalStorageCheckBox, &QCheckBox::toggled, this, onRememberToggled); + } + + // The Delete key removes selected items (just like clicking their "✖") + for (QTableView* tableView : { cookiesTableView, sessionStorageTableView, localStorageTableView }) { + QShortcut* removeShortcut = new QShortcut(QKeySequence::Delete, tableView, Q_NULLPTR, Q_NULLPTR, Qt::WidgetShortcut); + connect(removeShortcut, &QShortcut::activated, this, [this, tableView]() { + QList rows; + for (const QModelIndex& index : tableView->selectionModel()->selectedRows()) { + rows << index.row(); + } + + // From the bottom up, so that the remaining rows' numbers stay valid + std::sort(rows.begin(), rows.end(), std::greater()); + for (const int row : rows) { + removeStorageItem(tableView, row); + } + }); + } + + // Keep the lists up to date while the Liquid app runs + if (isEditingExistingBool) { + const QString liquidAppFilePath = Liquid::getLiquidAppFilePath(liquidAppName); + + // It keeps writing cookies and local storage to its file + storageRefreshTimer = new QTimer(this); + storageRefreshTimer->setSingleShot(true); + storageRefreshTimer->setInterval(LQD_STORAGE_REFRESH_DELAY); + connect(storageRefreshTimer, &QTimer::timeout, this, [this, liquidAppName]() { + refreshStorage(liquidAppName); + }); + + // The file gets replaced rather than overwritten when saved, which makes the watcher lose track of it, + // so its directory gets watched too + liquidAppFileWatcher = new QFileSystemWatcher(this); + liquidAppFileWatcher->addPath(liquidAppFilePath); + liquidAppFileWatcher->addPath(QFileInfo(liquidAppFilePath).absolutePath()); + const auto onChanged = [this, liquidAppFilePath]() { + if (!liquidAppFileWatcher->files().contains(liquidAppFilePath) && QFile::exists(liquidAppFilePath)) { + liquidAppFileWatcher->addPath(liquidAppFilePath); + } + + storageRefreshTimer->start(); + }; + connect(liquidAppFileWatcher, &QFileSystemWatcher::fileChanged, this, onChanged); + connect(liquidAppFileWatcher, &QFileSystemWatcher::directoryChanged, this, onChanged); + + // Session storage gets sent over by the Liquid app itself + if (connectToLiquidApp()) { + liquidAppConnection->write(LQD_INSTANCE_MSG_WATCH_SESSION_STORAGE "\n"); + liquidAppConnection->flush(); + } + sessionStorageTableView->setEnabled(liquidAppConnection != Q_NULLPTR && liquidAppConnection->state() == QLocalSocket::ConnectedState); + } else { + sessionStorageTableView->setEnabled(false); } } @@ -897,6 +1008,12 @@ LiquidAppConfigDialog::LiquidAppConfigDialog(QWidget* parent, QString liquidAppN LiquidAppConfigDialog::~LiquidAppConfigDialog(void) { + // Qt deletes child widgets before it cuts their connections, and some children signal things while getting deleted: + // most notably, the connection to the Liquid app (which goes last) signals that it's been disconnected, + // by which time the lists it updates are long gone; nothing should reach this half-destroyed dialog + for (QObject* child : findChildren()) { + disconnect(child, Q_NULLPTR, this, Q_NULLPTR); + } } void LiquidAppConfigDialog::save() @@ -944,6 +1061,66 @@ void LiquidAppConfigDialog::save() tempLiquidAppConfig->setValue(LQD_CFG_KEY_NAME_ALLOW_3RD_PARTY_COOKIES, allowThirdPartyCookiesCheckBox->isChecked()); } + // Cookies removed in settings (only those, the Liquid app may have stored new ones in the meantime) + if (!removedCookieIds.isEmpty()) { + tempLiquidAppConfig->beginGroup(LQD_CFG_GROUP_NAME_COOKIES); + for (const QString& cookieId : removedCookieIds) { + tempLiquidAppConfig->remove(cookieId); + } + tempLiquidAppConfig->endGroup(); + } + + // Local storage + { + tempLiquidAppConfig->setValue(LQD_CFG_KEY_NAME_REMEMBER_LOCAL_STORAGE, rememberLocalStorageCheckBox->isChecked()); + + if (!rememberLocalStorageCheckBox->isChecked()) { + // Not to be remembered, forget it + tempLiquidAppConfig->remove(LQD_CFG_KEY_NAME_LOCAL_STORAGE); + } else if (!removedLocalStorageIds.isEmpty() || !localStorageEdits.isEmpty()) { + // Only what's been removed or edited here: the Liquid app (if running) may have stored more in the meantime + QJsonObject localStorage = LiquidAppLocalStorage::load(tempLiquidAppConfig); + + for (const QString& id : removedLocalStorageIds) { + const QString origin = id.section(QChar(0x1F), 0, 0); + QJsonObject items = localStorage.value(origin).toObject(); + items.remove(id.section(QChar(0x1F), 1)); + if (items.isEmpty()) { + localStorage.remove(origin); + } else { + localStorage.insert(origin, items); + } + } + + for (const QJsonObject& edit : localStorageEdits) { + const QString origin = edit.value("origin").toString(); + QJsonObject items = localStorage.value(origin).toObject(); + items.insert(edit.value("key").toString(), edit.value("value")); + localStorage.insert(origin, items); + } + + LiquidAppLocalStorage::save(tempLiquidAppConfig, localStorage); + } + } + + // Session storage items removed here get removed from the running Liquid app's page + if (!removedSessionStorageIds.isEmpty() && connectToLiquidApp()) { + QMap keysByOrigin; + for (const QString& id : removedSessionStorageIds) { + keysByOrigin[id.section(QChar(0x1F), 0, 0)].append(id.section(QChar(0x1F), 1)); + } + + for (QMap::const_iterator it = keysByOrigin.constBegin(); it != keysByOrigin.constEnd(); ++it) { + QJsonObject removal; + removal.insert("origin", it.key()); + removal.insert("keys", it.value()); + + liquidAppConnection->write(QByteArray(LQD_INSTANCE_MSG_REMOVE_SESSION_STORAGE " ") + + QJsonDocument(removal).toJson(QJsonDocument::Compact).toBase64() + "\n"); + } + liquidAppConnection->flush(); + } + #if QT_VERSION >= QT_VERSION_CHECK(5, 10, 0) // Hide scrollbars { @@ -1310,17 +1487,7 @@ void LiquidAppConfigDialog::previewBackground(const QColor& color) void LiquidAppConfigDialog::sendPreview(const QByteArray& message) { // Only applies to Liquid apps that already exist (and are running) - if (!isEditingExistingBool) { - return; - } - - if (liquidAppConnection == Q_NULLPTR) { - liquidAppConnection = new QLocalSocket(this); - liquidAppConnection->connectToServer(SingleInstance::serverName(LQD_INSTANCE_ID_APP_PREFIX + nameInput->text())); - liquidAppConnection->waitForConnected(LQD_PREVIEW_CONNECT_TIMEOUT); - } - - if (liquidAppConnection->state() == QLocalSocket::ConnectedState) { + if (connectToLiquidApp()) { liquidAppConnection->write(message + "\n"); liquidAppConnection->flush(); hasPreviewedChanges = true; @@ -1410,3 +1577,299 @@ bool LiquidAppConfigDialog::isRestartRequired(void) { return isRestartRequiredBool; } + +namespace { + +// Rows are told apart by the text of some of their columns, so that the selection survives refilling the list +QString getRowId(const QStandardItemModel* model, const int row, const QList& idColumns) +{ + QStringList parts; + for (const int column : idColumns) { + parts << model->item(row, column)->text(); + } + + return parts.join(QChar(0x1F)); +} + +struct TableViewState { + QSet selectedRowIds; + QString currentRowId; + int verticalScroll; + int horizontalScroll; +}; + +TableViewState saveTableViewState(const QTableView* view, const QStandardItemModel* model, const QList& idColumns) +{ + TableViewState state; + + for (const QModelIndex& index : view->selectionModel()->selectedRows()) { + state.selectedRowIds.insert(getRowId(model, index.row(), idColumns)); + } + if (view->currentIndex().isValid()) { + state.currentRowId = getRowId(model, view->currentIndex().row(), idColumns); + } + state.verticalScroll = view->verticalScrollBar()->value(); + state.horizontalScroll = view->horizontalScrollBar()->value(); + + return state; +} + +void restoreTableViewState(QTableView* view, const QStandardItemModel* model, const QList& idColumns, const TableViewState& state) +{ + QItemSelection selection; + + for (int row = 0; row < model->rowCount(); row++) { + const QString rowId = getRowId(model, row, idColumns); + + if (rowId == state.currentRowId) { + view->selectionModel()->setCurrentIndex(model->index(row, 0), QItemSelectionModel::NoUpdate); + } + if (state.selectedRowIds.contains(rowId)) { + selection.select(model->index(row, 0), model->index(row, model->columnCount() - 1)); + } + } + + view->selectionModel()->select(selection, QItemSelectionModel::ClearAndSelect); + view->verticalScrollBar()->setValue(state.verticalScroll); + view->horizontalScrollBar()->setValue(state.horizontalScroll); +} + +} // namespace + +QString LiquidAppConfigDialog::getStorageItemId(const QString& origin, const QString& key) +{ + return origin + QChar(0x1F) + key; +} + +void LiquidAppConfigDialog::setUpStorageTableView(QTableView* tableView, QStandardItemModel* model, const int stretchingColumn) +{ + tableView->setModel(model); + tableView->setSelectionBehavior(QAbstractItemView::SelectRows); + tableView->setSelectionMode(QAbstractItemView::ExtendedSelection); + tableView->setWordWrap(false); + tableView->verticalHeader()->hide(); + + // The last column has the "✖" buttons + QHeaderView* header = tableView->horizontalHeader(); + const int removeColumn = model->columnCount() - 1; + header->setStretchLastSection(false); + header->setSectionResizeMode(stretchingColumn, QHeaderView::Stretch); + header->setSectionResizeMode(removeColumn, QHeaderView::Fixed); + header->resizeSection(removeColumn, LQD_UI_REMOVE_BUTTON_COLUMN_WIDTH); +} + +QStandardItem* LiquidAppConfigDialog::newRemoveButtonItem(void) +{ + // An empty cell for the "✖" button to sit in (it has to be part of the row from the start: setting items of rows + // that already exist counts as editing them) + QStandardItem* item = new QStandardItem(); + item->setEditable(false); + + return item; +} + +void LiquidAppConfigDialog::addRemoveButtons(QTableView* tableView, QStandardItemModel* model) +{ + const int removeColumn = model->columnCount() - 1; + + for (int row = 0; row < model->rowCount(); row++) { + QToolButton* removeButton = new QToolButton(tableView); + removeButton->setText(LQD_ICON_DELETE); + removeButton->setToolTip(tr("Remove")); + removeButton->setCursor(Qt::PointingHandCursor); + removeButton->setAutoRaise(true); + + // Rows move around as others get removed, the index keeps track of where this one is + const QPersistentModelIndex index(model->index(row, 0)); + connect(removeButton, &QToolButton::clicked, this, [this, tableView, index]() { + if (index.isValid()) { + removeStorageItem(tableView, index.row()); + } + }); + + tableView->setIndexWidget(model->index(row, removeColumn), removeButton); + } +} + +void LiquidAppConfigDialog::removeStorageItem(QTableView* tableView, const int row) +{ + // Removed items are remembered (the lists get refilled as the Liquid app stores things), and get removed for real + // only upon saving (until then, they're merely hidden) + if (tableView == cookiesTableView) { + removedCookieIds.insert(cookiesModel->item(row, 0)->data(Qt::UserRole).toString()); + cookiesModel->removeRow(row); + } else if (tableView == sessionStorageTableView) { + removedSessionStorageIds.insert(sessionStorageModel->item(row, 0)->data(Qt::UserRole).toString()); + sessionStorageModel->removeRow(row); + } else if (tableView == localStorageTableView) { + const QString id = localStorageModel->item(row, 0)->data(Qt::UserRole).toString(); + removedLocalStorageIds.insert(id); + localStorageEdits.remove(id); + localStorageModel->removeRow(row); + } +} + +void LiquidAppConfigDialog::fillCookiesModel(QSettings* liquidAppConfig) +{ + static const QList idColumns = { 0, 2, 3 }; // Name, domain, path + const TableViewState state = saveTableViewState(cookiesTableView, cookiesModel, idColumns); + + cookiesModel->removeRows(0, cookiesModel->rowCount()); + + liquidAppConfig->beginGroup(LQD_CFG_GROUP_NAME_COOKIES); + for (const QString& cookieId : liquidAppConfig->allKeys()) { + const QList cookies = QNetworkCookie::parseCookies(liquidAppConfig->value(cookieId).toByteArray()); + + // Removed here, but the Liquid app doesn't know it yet (it will once settings get saved) + if (cookies.size() > 0 && !removedCookieIds.contains(cookieId)) { + const QNetworkCookie& cookie = cookies[0]; + + QStandardItem* nameItem = new QStandardItem(QString(cookie.name())); + nameItem->setData(cookieId, Qt::UserRole); + + cookiesModel->appendRow({ + nameItem, + new QStandardItem(QString(cookie.value())), + new QStandardItem(cookie.domain()), + new QStandardItem(cookie.path()), + new QStandardItem(cookie.expirationDate().toString()), + new QStandardItem(cookie.isHttpOnly() ? "true" : "false"), + new QStandardItem(cookie.isSecure() ? "true" : "false"), + newRemoveButtonItem(), + }); + } + } + liquidAppConfig->endGroup(); + + addRemoveButtons(cookiesTableView, cookiesModel); + restoreTableViewState(cookiesTableView, cookiesModel, idColumns, state); +} + +void LiquidAppConfigDialog::fillLocalStorageModel(QSettings* liquidAppConfig) +{ + static const QList idColumns = { 0, 1 }; // Website, key + const TableViewState state = saveTableViewState(localStorageTableView, localStorageModel, idColumns); + + // What the Liquid app has stored, with what's been removed or edited here on top of it + QMap rows; + const QJsonObject localStorage = LiquidAppLocalStorage::load(liquidAppConfig); + for (QJsonObject::const_iterator origin = localStorage.constBegin(); origin != localStorage.constEnd(); ++origin) { + const QJsonObject items = origin.value().toObject(); + + for (QJsonObject::const_iterator item = items.constBegin(); item != items.constEnd(); ++item) { + const QString id = getStorageItemId(origin.key(), item.key()); + + if (!removedLocalStorageIds.contains(id)) { + QJsonObject row; + row.insert("origin", origin.key()); + row.insert("key", item.key()); + row.insert("value", item.value()); + rows.insert(id, row); + } + } + } + for (QMap::const_iterator edit = localStorageEdits.constBegin(); edit != localStorageEdits.constEnd(); ++edit) { + rows.insert(edit.key(), edit.value()); + } + + localStorageModel->removeRows(0, localStorageModel->rowCount()); + + for (QMap::const_iterator row = rows.constBegin(); row != rows.constEnd(); ++row) { + QStandardItem* originItem = new QStandardItem(row.value().value("origin").toString()); + originItem->setEditable(false); + originItem->setData(row.key(), Qt::UserRole); + + QStandardItem* valueItem = new QStandardItem(row.value().value("value").toString()); + valueItem->setToolTip(valueItem->text().left(LQD_UI_TOOLTIP_MAX_LENGTH)); + + // Appending whole rows doesn't count as editing them (unlike setting items of rows that already exist) + localStorageModel->appendRow({ originItem, new QStandardItem(row.value().value("key").toString()), valueItem, newRemoveButtonItem() }); + } + + addRemoveButtons(localStorageTableView, localStorageModel); + + restoreTableViewState(localStorageTableView, localStorageModel, idColumns, state); +} + +void LiquidAppConfigDialog::fillSessionStorageModel(void) +{ + static const QList idColumns = { 0, 1 }; // Website, key + const TableViewState state = saveTableViewState(sessionStorageTableView, sessionStorageModel, idColumns); + + sessionStorageModel->removeRows(0, sessionStorageModel->rowCount()); + + const QString origin = sessionStorage.value("origin").toString(); + const QJsonObject items = sessionStorage.value("items").toObject(); + for (QJsonObject::const_iterator item = items.constBegin(); item != items.constEnd(); ++item) { + const QString id = getStorageItemId(origin, item.key()); + + if (!removedSessionStorageIds.contains(id)) { + QStandardItem* originItem = new QStandardItem(origin); + originItem->setData(id, Qt::UserRole); + + QStandardItem* valueItem = new QStandardItem(item.value().toString()); + valueItem->setToolTip(valueItem->text().left(LQD_UI_TOOLTIP_MAX_LENGTH)); + + sessionStorageModel->appendRow({ originItem, new QStandardItem(item.key()), valueItem, newRemoveButtonItem() }); + } + } + + addRemoveButtons(sessionStorageTableView, sessionStorageModel); + restoreTableViewState(sessionStorageTableView, sessionStorageModel, idColumns, state); +} + +void LiquidAppConfigDialog::refreshStorage(const QString& liquidAppName) +{ + QScopedPointer liquidAppConfig(LiquidAppFile::open(liquidAppName)); + + fillCookiesModel(liquidAppConfig.data()); + + // Don't pull the rug from under an edit in progress, try again once it's done + // (cells get edited in editor widgets placed within the list) + const QWidget* focusWidget = QApplication::focusWidget(); + const bool isEditingCell = focusWidget != Q_NULLPTR && focusWidget != localStorageTableView + && localStorageTableView->isAncestorOf(focusWidget) && qobject_cast(focusWidget) == Q_NULLPTR; + if (isEditingCell) { + storageRefreshTimer->start(); + return; + } + + fillLocalStorageModel(liquidAppConfig.data()); +} + +bool LiquidAppConfigDialog::connectToLiquidApp(void) +{ + // Only Liquid apps that already exist (and are running) can be talked to + if (!isEditingExistingBool) { + return false; + } + + if (liquidAppConnection == Q_NULLPTR) { + liquidAppConnection = new QLocalSocket(this); + liquidAppConnection->connectToServer(SingleInstance::serverName(LQD_INSTANCE_ID_APP_PREFIX + nameInput->text())); + liquidAppConnection->waitForConnected(LQD_PREVIEW_CONNECT_TIMEOUT); + + // Things the Liquid app sends back (newline-terminated, like everything else sent between instances) + connect(liquidAppConnection, &QLocalSocket::readyRead, this, [this]() { + static const QByteArray sessionStoragePrefix = QByteArray(LQD_INSTANCE_MSG_SESSION_STORAGE) + " "; + + while (liquidAppConnection->canReadLine()) { + const QByteArray message = liquidAppConnection->readLine().trimmed(); + + if (message.startsWith(sessionStoragePrefix)) { + sessionStorage = QJsonDocument::fromJson(QByteArray::fromBase64(message.mid(sessionStoragePrefix.size()))).object(); + fillSessionStorageModel(); + } + } + }); + + // The Liquid app has quit, its session storage is gone + connect(liquidAppConnection, &QLocalSocket::disconnected, this, [this]() { + sessionStorage = QJsonObject(); + fillSessionStorageModel(); + sessionStorageTableView->setEnabled(false); + }); + } + + return liquidAppConnection->state() == QLocalSocket::ConnectedState; +} diff --git a/src/liquidappcookiejar.cpp b/src/liquidappcookiejar.cpp index 10a4b0d..76102ba 100644 --- a/src/liquidappcookiejar.cpp +++ b/src/liquidappcookiejar.cpp @@ -1,3 +1,5 @@ +#include + #include "lqd.h" #include "liquidappcookiejar.hpp" #include "liquidappwindow.hpp" @@ -102,13 +104,43 @@ void LiquidAppCookieJar::save(void) } // Save all cookies foreach(QNetworkCookie cookie, allCookies()) { - QByteArray cookieId = QByteArray(cookie.domain().toLatin1() + - "_" + cookie.path().toLatin1() + - "_" + cookie.name()); QString rawCookie = QString(cookie.toRawForm(QNetworkCookie::Full)); // TODO: save Session cookies? - liquidAppConfig->setValue(cookieId, rawCookie); + liquidAppConfig->setValue(getCookieId(cookie), rawCookie); } liquidAppConfig->endGroup(); liquidAppConfig->sync(); } + +QString LiquidAppCookieJar::getCookieId(const QNetworkCookie& cookie) +{ + const QString cookieId = cookie.domain() + "_" + cookie.path() + "_" + QString::fromLatin1(cookie.name()); + + // The same as it ends up being as a key in settings (where slashes separate groups, and get normalized) + static const QRegularExpression multipleSlashes(QStringLiteral("/{2,}")); + QString key = cookieId; + key.replace(multipleSlashes, QStringLiteral("/")); + while (key.startsWith('/')) { + key.remove(0, 1); + } + while (key.endsWith('/')) { + key.chop(1); + } + + return key; +} + +void LiquidAppCookieJar::removeCookiesMissingFromConfig(QWebEngineCookieStore* cookieStore) +{ + liquidAppConfig->beginGroup(LQD_CFG_GROUP_NAME_COOKIES); + const QStringList cookieIds = liquidAppConfig->allKeys(); + liquidAppConfig->endGroup(); + + // The file always lists all of the cookies this jar has (it gets rewritten upon every change), + // so whatever's missing from it has been removed in settings; removing it from the store removes it from here too + foreach(QNetworkCookie cookie, allCookies()) { + if (!cookieIds.contains(getCookieId(cookie))) { + cookieStore->deleteCookie(cookie); + } + } +} diff --git a/src/liquidappfile.cpp b/src/liquidappfile.cpp index 9b8b760..8bcdc40 100644 --- a/src/liquidappfile.cpp +++ b/src/liquidappfile.cpp @@ -1,4 +1,6 @@ #include +#include +#include #include #include #include @@ -97,6 +99,12 @@ QString sanitize(const QString& text) return result; } +// Whether the text can't be stored as-is in an XML document (line breaks made of "\r" wouldn't survive either) +bool needsEncoding(const QString& text) +{ + return text.contains('\r') || sanitize(text) != text; +} + // Removes the setting from the map, returning its value as text bool take(QSettings::SettingsMap& map, const QString& key, QString* value) { @@ -187,6 +195,50 @@ bool LiquidAppFile::read(QIODevice& device, QSettings::SettingsMap& map) xml.skipCurrentElement(); } } + } else if (element == "local-storage") { + // value...... + if (xml.attributes().hasAttribute("remember")) { + map.insert(LQD_CFG_KEY_NAME_REMEMBER_LOCAL_STORAGE, xml.attributes().value("remember").toString()); + } + + QJsonObject localStorage; + + while (xml.readNextStartElement()) { + const QString origin = xml.attributes().value("url").toString(); + + if (xml.name() != QLatin1String("origin") || origin.isEmpty()) { + xml.skipCurrentElement(); + continue; + } + + QJsonObject items = localStorage.value(origin).toObject(); + + while (xml.readNextStartElement()) { + if (xml.name() != QLatin1String("item") || !xml.attributes().hasAttribute("key")) { + xml.skipCurrentElement(); + continue; + } + + const bool isEncoded = xml.attributes().value("encoding") == QLatin1String("base64"); + QString key = xml.attributes().value("key").toString(); + QString value = xml.readElementText(); + + if (isEncoded) { + key = QString::fromUtf8(QByteArray::fromBase64(key.toLatin1())); + value = QString::fromUtf8(QByteArray::fromBase64(value.toLatin1())); + } + + items.insert(key, value); + } + + if (!items.isEmpty()) { + localStorage.insert(origin, items); + } + } + + if (!localStorage.isEmpty()) { + map.insert(LQD_CFG_KEY_NAME_LOCAL_STORAGE, QString::fromUtf8(QJsonDocument(localStorage).toJson(QJsonDocument::Compact))); + } } else if (element == "settings") { while (xml.readNextStartElement()) { const QString key = xml.attributes().value("key").toString(); @@ -281,7 +333,7 @@ bool LiquidAppFile::write(QIODevice& device, const QSettings::SettingsMap& setti } } - // Cookies and the icon go last, since they're long and not meant to be read by humans + // Cookies, local storage, and the icon go last, since they're long and not meant to be read by humans QStringList cookieKeys; for (const QString& key : map.keys()) { if (key.startsWith(COOKIES_KEY_PREFIX)) { @@ -289,6 +341,12 @@ bool LiquidAppFile::write(QIODevice& device, const QSettings::SettingsMap& setti } } + QString rememberLocalStorage; + QString localStorageJson; + const bool hasRememberLocalStorage = take(map, LQD_CFG_KEY_NAME_REMEMBER_LOCAL_STORAGE, &rememberLocalStorage); + take(map, LQD_CFG_KEY_NAME_LOCAL_STORAGE, &localStorageJson); + const QJsonObject localStorage = QJsonDocument::fromJson(localStorageJson.toUtf8()).object(); + QString icon; QString iconType; take(map, LQD_CFG_KEY_NAME_ICON, &icon); @@ -336,6 +394,43 @@ bool LiquidAppFile::write(QIODevice& device, const QSettings::SettingsMap& setti xml.writeEndElement(); } + if (hasRememberLocalStorage || !localStorage.isEmpty()) { + xml.writeStartElement("local-storage"); + + if (hasRememberLocalStorage) { + xml.writeAttribute("remember", rememberLocalStorage); + } + + for (QJsonObject::const_iterator origin = localStorage.constBegin(); origin != localStorage.constEnd(); ++origin) { + xml.writeStartElement("origin"); + xml.writeAttribute("url", sanitize(origin.key())); + + const QJsonObject items = origin.value().toObject(); + for (QJsonObject::const_iterator item = items.constBegin(); item != items.constEnd(); ++item) { + const QString key = item.key(); + const QString value = item.value().toString(); + + xml.writeStartElement("item"); + + // Anything that can't be stored in XML as-is gets stored as base64-encoded UTF-8 + if (needsEncoding(key) || needsEncoding(value)) { + xml.writeAttribute("encoding", "base64"); + xml.writeAttribute("key", QString::fromLatin1(key.toUtf8().toBase64())); + xml.writeCharacters(QString::fromLatin1(value.toUtf8().toBase64())); + } else { + xml.writeAttribute("key", key); + xml.writeCharacters(value); + } + + xml.writeEndElement(); + } + + xml.writeEndElement(); + } + + xml.writeEndElement(); + } + if (hasIcon) { xml.writeStartElement("icon"); xml.writeAttribute("type", iconType); diff --git a/src/liquidapplocalstorage.cpp b/src/liquidapplocalstorage.cpp new file mode 100644 index 0000000..495a5f3 --- /dev/null +++ b/src/liquidapplocalstorage.cpp @@ -0,0 +1,334 @@ +#include +#include +#include +#include +#include +#include +#include +#include + +#include "liquidapplocalstorage.hpp" +#include "lqd.h" + +// Returns { origin, items, sessionItems } (items of local and session storage), +// or null if the page has no storage (e.g. it's not from a website) +static const char* const CAPTURE_SCRIPT = + "(function() {" + "try {" + "const read = function(storage) {" + "const items = {};" + "for (let i = 0; i < storage.length; i++) {" + "const key = storage.key(i);" + "items[key] = storage.getItem(key);" + "}" + "return items;" + "};" + "return JSON.stringify({ origin: location.origin, items: read(window.localStorage), sessionItems: read(window.sessionStorage) });" + "} catch (e) {" + "return null;" + "}" + "})()"; + +LiquidAppLocalStorage::LiquidAppLocalStorage(QWebEnginePage* page, QSettings* liquidAppConfig, QObject* parent) + : QObject(parent), page(page), liquidAppConfig(liquidAppConfig) +{ + captureTimer = new QTimer(this); + captureTimer->setInterval(LQD_LOCAL_STORAGE_CAPTURE_INTERVAL); + connect(captureTimer, &QTimer::timeout, this, &LiquidAppLocalStorage::capture); + + // Whatever is in the page's local storage once it's loaded is what the website intends to keep there + connect(page, &QWebEnginePage::loadFinished, this, [this](const bool ok) { + if (ok && isEnabledBool) { + onOriginVisited(getOrigin(this->page->url())); + } + if (ok) { + capture(); + } + }); + + reloadConfig(); +} + +void LiquidAppLocalStorage::reloadConfig(void) +{ + isEnabledBool = liquidAppConfig->value(LQD_CFG_KEY_NAME_REMEMBER_LOCAL_STORAGE, false).toBool(); + + if (!isEnabledBool) { + // Local storage keeps working, it just won't be remembered after quitting + updateCaptureTimer(); + items = QJsonObject(); + liveOrigins.clear(); + editedOrigins.clear(); + updateRestoreScript(); + return; + } + + // Items may have been edited in settings: those of the origins that have changed get put in place right away + // (if it's the page that's currently shown), or whenever their pages are visited next + const QJsonObject savedItems = load(liquidAppConfig); + const QString currentOrigin = getOrigin(page->url()); + QStringList origins = savedItems.keys() + items.keys(); + origins.removeDuplicates(); + + for (const QString& origin : origins) { + if (savedItems.value(origin) != items.value(origin)) { + QJsonObject editedItems; + editedItems.insert(origin, savedItems.value(origin).toObject()); + + if (origin == currentOrigin) { + page->runJavaScript(getRestoreScript(QJsonObject(), editedItems), QWebEngineScript::ApplicationWorld); + } else { + // Visited during this run or not, its local storage has to become what's been saved, once it's visited next + liveOrigins.remove(origin); + editedOrigins.insert(origin); + } + } + } + + items = savedItems; + updateRestoreScript(); + updateCaptureTimer(); +} + +void LiquidAppLocalStorage::updateCaptureTimer(void) +{ + // Session storage is never remembered, it only gets read while settings are open to show it + const bool isNeeded = isEnabledBool || isWatchingSessionStorageBool; + + if (isNeeded && !captureTimer->isActive()) { + captureTimer->start(); + } else if (!isNeeded) { + captureTimer->stop(); + } +} + +void LiquidAppLocalStorage::setWatchingSessionStorage(const bool watching) +{ + isWatchingSessionStorageBool = watching; + updateCaptureTimer(); + + if (watching) { + capture(); + } +} + +QString LiquidAppLocalStorage::getSessionStorageOrigin(void) const +{ + return sessionStorageOrigin; +} + +QJsonObject LiquidAppLocalStorage::getSessionStorageItems(void) const +{ + return sessionStorageItems; +} + +void LiquidAppLocalStorage::removeSessionStorageItems(const QString& origin, const QStringList& keys) +{ + // Session storage can only be reached from a page of its origin (and this is the only tab there is) + const QString script = QString( + "(function(origin, keys) {" + "if (location.origin !== origin) return;" + "try {" + "for (const key of keys) {" + "window.sessionStorage.removeItem(key);" + "}" + "} catch (e) {}" + "})(%1, %2)" + ).arg(QString::fromUtf8(QJsonDocument(QJsonArray() << origin).toJson(QJsonDocument::Compact)).mid(1).chopped(1), + QString::fromUtf8(QJsonDocument(QJsonArray::fromStringList(keys)).toJson(QJsonDocument::Compact))); + + page->runJavaScript(script, QWebEngineScript::ApplicationWorld); + capture(); +} + +bool LiquidAppLocalStorage::isEnabled(void) const +{ + return isEnabledBool; +} + +QString LiquidAppLocalStorage::getCaptureScript(void) +{ + return QString::fromLatin1(CAPTURE_SCRIPT); +} + +void LiquidAppLocalStorage::capture(void) +{ + if (!isEnabledBool && !isWatchingSessionStorageBool) { + return; + } + + QPointer self(this); + + page->runJavaScript(getCaptureScript(), QWebEngineScript::ApplicationWorld, [self](const QVariant& result) { + if (self) { + self->onCaptured(result.toString()); + } + }); +} + +void LiquidAppLocalStorage::onCaptured(const QString& json) +{ + const QJsonObject result = QJsonDocument::fromJson(json.toUtf8()).object(); + const QString origin = result.value("origin").toString().toLower(); + const QJsonObject originItems = result.value("items").toObject(); + + // Session storage (only to be shown in settings, never stored) + const QString newSessionStorageOrigin = (origin == "null") ? QString() : origin; + const QJsonObject newSessionStorageItems = result.value("sessionItems").toObject(); + if (newSessionStorageOrigin != sessionStorageOrigin || newSessionStorageItems != sessionStorageItems) { + sessionStorageOrigin = newSessionStorageOrigin; + sessionStorageItems = newSessionStorageItems; + emit sessionStorageChanged(); + } + + if (!isEnabledBool || json.isEmpty()) { + return; + } + + // Pages that aren't from websites (e.g. "about:blank") have no origin of their own + if (origin.isEmpty() || origin == "null") { + return; + } + + markOriginAsLive(origin); + + // Items of an origin edited in settings may not have been put in place yet (e.g. the page was still loading) + if (editedOrigins.contains(origin)) { + return; + } + + if (items.value(origin).toObject() == originItems) { + return; + } + + if (originItems.isEmpty()) { + items.remove(origin); + } else { + items.insert(origin, originItems); + } + + save(liquidAppConfig, items); +} + +void LiquidAppLocalStorage::markOriginAsLive(const QString& origin) +{ + if (origin.isEmpty() || liveOrigins.contains(origin)) { + return; + } + + liveOrigins.insert(origin); + + if (items.contains(origin) || editedOrigins.contains(origin)) { + updateRestoreScript(); + } +} + +void LiquidAppLocalStorage::onOriginVisited(const QString& origin) +{ + // The page has just loaded, so whatever's been edited in settings for its origin is in place by now + if (editedOrigins.remove(origin)) { + markOriginAsLive(origin); + updateRestoreScript(); + } +} + +QString LiquidAppLocalStorage::getOrigin(const QUrl& url) +{ + // Same as what location.origin is in the page + const QString scheme = url.scheme().toLower(); + QString host = url.host(QUrl::FullyEncoded).toLower(); + + if (host.isEmpty() || (scheme != "http" && scheme != "https")) { + return QString(); + } + + if (host.contains(':')) { + host = "[" + host + "]"; // IPv6 + } + + const int port = url.port(); + const bool isDefaultPort = port < 0 || (scheme == "http" && port == 80) || (scheme == "https" && port == 443); + + return scheme + "://" + host + ((isDefaultPort) ? QString() : ":" + QString::number(port)); +} + +QString LiquidAppLocalStorage::getRestoreScript(const QJsonObject& savedItems, const QJsonObject& editedItems) +{ + // Saved items get put in place only if the local storage is empty (as it is when the origin gets visited for + // the first time in this run), so that the second page of a website loaded right after the first one + // (e.g. upon a redirect) doesn't lose what the first one has written there; + // items edited in settings replace whatever is there + return QString( + "(function(saved, edited) {" + "try {" + "const storage = window.localStorage;" + "let items = edited[location.origin];" + "if (!items) {" + "items = saved[location.origin];" + "if (!items || storage.length > 0) return;" + "}" + "storage.clear();" + "for (const key of Object.keys(items)) {" + "storage.setItem(key, items[key]);" + "}" + "} catch (e) {}" + "})(%1, %2)" + ).arg(QString::fromUtf8(QJsonDocument(savedItems).toJson(QJsonDocument::Compact)), + QString::fromUtf8(QJsonDocument(editedItems).toJson(QJsonDocument::Compact))); +} + +void LiquidAppLocalStorage::updateRestoreScript(void) +{ + QWebEngineScriptCollection& scripts = page->scripts(); + +#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) + const QList existingScripts = scripts.find(LQD_SCRIPT_NAME_LOCAL_STORAGE); +#else + const QList existingScripts = scripts.findScripts(LQD_SCRIPT_NAME_LOCAL_STORAGE); +#endif + for (const QWebEngineScript& existingScript : existingScripts) { + scripts.remove(existingScript); + } + + // Only origins not yet visited during this run get their items restored + QJsonObject savedItems; + QJsonObject editedItems; + for (QJsonObject::const_iterator it = items.constBegin(); it != items.constEnd(); ++it) { + if (!liveOrigins.contains(it.key()) && !editedOrigins.contains(it.key())) { + savedItems.insert(it.key(), it.value()); + } + } + for (const QString& origin : editedOrigins) { + editedItems.insert(origin, items.value(origin).toObject()); // An empty object empties the local storage + } + + if (!isEnabledBool || (savedItems.isEmpty() && editedItems.isEmpty())) { + return; + } + + // Runs before any of the website's own code does + QWebEngineScript script; + script.setName(LQD_SCRIPT_NAME_LOCAL_STORAGE); + script.setInjectionPoint(QWebEngineScript::DocumentCreation); + script.setRunsOnSubFrames(false); + script.setWorldId(QWebEngineScript::ApplicationWorld); + script.setSourceCode(getRestoreScript(savedItems, editedItems)); + scripts.insert(script); +} + +QJsonObject LiquidAppLocalStorage::load(QSettings* liquidAppConfig) +{ + return QJsonDocument::fromJson(liquidAppConfig->value(LQD_CFG_KEY_NAME_LOCAL_STORAGE).toString().toUtf8()).object(); +} + +void LiquidAppLocalStorage::save(QSettings* liquidAppConfig, const QJsonObject& items) +{ + if (items.isEmpty()) { + liquidAppConfig->remove(LQD_CFG_KEY_NAME_LOCAL_STORAGE); + } else { + liquidAppConfig->setValue(LQD_CFG_KEY_NAME_LOCAL_STORAGE, + QString::fromUtf8(QJsonDocument(items).toJson(QJsonDocument::Compact))); + } + + liquidAppConfig->sync(); +} diff --git a/src/liquidappwindow.cpp b/src/liquidappwindow.cpp index 91c46de..2ac400f 100644 --- a/src/liquidappwindow.cpp +++ b/src/liquidappwindow.cpp @@ -6,6 +6,8 @@ #include #include #include +#include +#include #include #include #include @@ -27,6 +29,7 @@ #include "liquid.hpp" #include "liquidappfile.hpp" #include "liquidappicon.hpp" +#include "liquidapplocalstorage.hpp" #include "liquidappconfigwindow.hpp" #include "liquidappcookiejar.hpp" #include "liquidappwebpage.hpp" @@ -243,6 +246,16 @@ LiquidAppWindow::LiquidAppWindow(const QString* name) : QWebEngineView() // Catch loading's end connect(liquidAppWebPage, &QWebEnginePage::loadFinished, this, &LiquidAppWindow::loadFinished); + // Put saved local storage items in place before the first page gets loaded + liquidAppLocalStorage = new LiquidAppLocalStorage(liquidAppWebPage, liquidAppConfig, this); + connect(liquidAppLocalStorage, &LiquidAppLocalStorage::sessionStorageChanged, this, [this]() { + for (const QPointer& connection : sessionStorageWatchers) { + if (connection) { + sendSessionStorage(connection); + } + } + }); + // Load Liquid app's starting URL load(startingUrl); } @@ -469,6 +482,12 @@ void LiquidAppWindow::closeEvent(QCloseEvent* event) settingsDialog->reject(); } + // Whatever has changed in local storage since it's last been checked + if (liquidAppLocalStorage->isEnabled()) { + const QVariant result = runJavaScriptSync(LiquidAppLocalStorage::getCaptureScript(), LQD_LOCAL_STORAGE_QUIT_TIMEOUT); + liquidAppLocalStorage->onCaptured(result.toString()); + } + event->accept(); deleteLater(); } @@ -678,7 +697,7 @@ void LiquidAppWindow::loadLiquidAppConfig(void) // Deal with Cookies { - LiquidAppCookieJar *liquidAppCookieJar = new LiquidAppCookieJar(this); + liquidAppCookieJar = new LiquidAppCookieJar(this); QWebEngineCookieStore *cookieStore = page()->profile()->cookieStore(); connect(cookieStore, &QWebEngineCookieStore::cookieAdded, liquidAppCookieJar, &LiquidAppCookieJar::upsertCookie); @@ -958,6 +977,12 @@ void LiquidAppWindow::reloadLiquidAppConfig(void) maybeFetchIcon(); } + // Local storage (may have been allowed or disallowed to be remembered, or had its items edited) + liquidAppLocalStorage->reloadConfig(); + + // Cookies removed in settings + liquidAppCookieJar->removeCookiesMissingFromConfig(page()->profile()->cookieStore()); + // 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()); @@ -1901,3 +1926,39 @@ void LiquidAppWindow::setScrollBarsHidden(const bool hidden) }); } } + +void LiquidAppWindow::watchSessionStorage(QLocalSocket* connection) +{ + sessionStorageWatchers << QPointer(connection); + + connect(connection, &QLocalSocket::disconnected, this, [this]() { + sessionStorageWatchers.removeAll(QPointer()); + for (int i = sessionStorageWatchers.size() - 1; i >= 0; i--) { + if (sessionStorageWatchers[i]->state() != QLocalSocket::ConnectedState) { + sessionStorageWatchers.removeAt(i); + } + } + + liquidAppLocalStorage->setWatchingSessionStorage(!sessionStorageWatchers.isEmpty()); + }); + + // What's known so far, more comes as it changes + sendSessionStorage(connection); + liquidAppLocalStorage->setWatchingSessionStorage(true); +} + +void LiquidAppWindow::sendSessionStorage(QLocalSocket* connection) +{ + QJsonObject sessionStorage; + sessionStorage.insert("origin", liquidAppLocalStorage->getSessionStorageOrigin()); + sessionStorage.insert("items", liquidAppLocalStorage->getSessionStorageItems()); + + connection->write(QByteArray(LQD_INSTANCE_MSG_SESSION_STORAGE " ") + + QJsonDocument(sessionStorage).toJson(QJsonDocument::Compact).toBase64() + "\n"); + connection->flush(); +} + +void LiquidAppWindow::removeSessionStorageItems(const QString& origin, const QStringList& keys) +{ + liquidAppLocalStorage->removeSessionStorageItems(origin, keys); +} diff --git a/src/main.cpp b/src/main.cpp index 330336b..58dddd4 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -5,6 +5,11 @@ #include #include +#include +#include +#include +#include + #include "lqd.h" #include "liquid.hpp" #include "liquidappfile.hpp" @@ -188,6 +193,23 @@ int main(int argc, char **argv) } }); + // Settings showing session storage (which is never stored anywhere, so it can only come from here) + QObject::connect(singleInstance, &SingleInstance::messageReceivedFrom, liquidAppWindow, [](QLocalSocket* connection, const QByteArray& message) { + static const QByteArray removePrefix = QByteArray(LQD_INSTANCE_MSG_REMOVE_SESSION_STORAGE) + " "; + + if (message == LQD_INSTANCE_MSG_WATCH_SESSION_STORAGE) { + liquidAppWindow->watchSessionStorage(connection); + } else if (message.startsWith(removePrefix)) { + // { "origin": "...", "keys": [ ... ] } + const QJsonObject removal = QJsonDocument::fromJson(QByteArray::fromBase64(message.mid(removePrefix.size()))).object(); + QStringList keys; + for (const QJsonValue& key : removal.value("keys").toArray()) { + keys << key.toString(); + } + liquidAppWindow->removeSessionStorageItems(removal.value("origin").toString(), keys); + } + }); + // 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 { diff --git a/src/singleinstance.cpp b/src/singleinstance.cpp index 472c36d..bc4f3d8 100644 --- a/src/singleinstance.cpp +++ b/src/singleinstance.cpp @@ -52,6 +52,7 @@ bool SingleInstance::claim(void) if (!message.isEmpty()) { emit messageReceived(message); + emit messageReceivedFrom(client, message); } } });