diff options
Diffstat (limited to 'src/mumble/PluginUpdater.cpp')
-rw-r--r-- | src/mumble/PluginUpdater.cpp | 379 |
1 files changed, 379 insertions, 0 deletions
diff --git a/src/mumble/PluginUpdater.cpp b/src/mumble/PluginUpdater.cpp new file mode 100644 index 000000000..e7f4f2594 --- /dev/null +++ b/src/mumble/PluginUpdater.cpp @@ -0,0 +1,379 @@ +// Copyright 2021 The Mumble Developers. All rights reserved. +// Use of this source code is governed by a BSD-style license +// that can be found in the LICENSE file at the root of the +// Mumble source tree or at <https://www.mumble.info/LICENSE>. + +#include "PluginUpdater.h" +#include "PluginManager.h" +#include "Log.h" +#include "PluginInstaller.h" +#include "Global.h" + +#include <QtWidgets/QCheckBox> +#include <QtWidgets/QLabel> +#include <QtCore/QHashIterator> +#include <QtCore/QSignalBlocker> +#include <QtCore/QByteArray> +#include <QtCore/QDir> +#include <QtCore/QFile> +#include <QtConcurrent> +#include <QNetworkRequest> + +#include <algorithm> + +PluginUpdater::PluginUpdater(QWidget *parent) + : QDialog(parent), + m_wasInterrupted(false), + m_dataMutex(), + m_pluginsToUpdate(), + m_networkManager(), + m_pluginUpdateWidgets() { + + QObject::connect(&m_networkManager, &QNetworkAccessManager::finished, this, &PluginUpdater::on_updateDownloaded); +} + +PluginUpdater::~PluginUpdater() { + m_wasInterrupted.store(true); +} + +void PluginUpdater::checkForUpdates() { + // Dispatch a thread in which each plugin can check for updates + QtConcurrent::run([this]() { + QMutexLocker lock(&m_dataMutex); + + const QVector<const_plugin_ptr_t> plugins = Global::get().pluginManager->getPlugins(); + + for (int i = 0; i < plugins.size(); i++) { + const_plugin_ptr_t plugin = plugins[i]; + + if (plugin->hasUpdate()) { + QUrl updateURL = plugin->getUpdateDownloadURL(); + + if (updateURL.isValid() && !updateURL.isEmpty() && !updateURL.fileName().isEmpty()) { + UpdateEntry entry = { plugin->getID(), updateURL, updateURL.fileName(), 0 }; + m_pluginsToUpdate << entry; + } + } + + // if the update has been asked to be interrupted, exit here + if (m_wasInterrupted.load()) { + emit updateInterrupted(); + return; + } + } + + if (!m_pluginsToUpdate.isEmpty()) { + emit updatesAvailable(); + } + }); +} + +void PluginUpdater::promptAndUpdate() { + setupUi(this); + populateUI(); + + setWindowIcon(QIcon(QLatin1String("skin:mumble.svg"))); + + QObject::connect(qcbSelectAll, &QCheckBox::stateChanged, this, &PluginUpdater::on_selectAll); + QObject::connect(this, &QDialog::finished, this, &PluginUpdater::on_finished); + + if (exec() == QDialog::Accepted) { + update(); + } +} + +void PluginUpdater::update() { + QMutexLocker l(&m_dataMutex); + + for (int i = 0; i < m_pluginsToUpdate.size(); i++) { + UpdateEntry currentEntry = m_pluginsToUpdate[i]; + + // The network manager will be emit a signal once the request has finished processing. + // Thus we can ignore the returned QNetworkReply* here. + m_networkManager.get(QNetworkRequest(currentEntry.updateURL)); + } +} + +void PluginUpdater::populateUI() { + clearUI(); + + QMutexLocker l(&m_dataMutex); + for (int i = 0; i < m_pluginsToUpdate.size(); i++) { + UpdateEntry currentEntry = m_pluginsToUpdate[i]; + plugin_id_t pluginID = currentEntry.pluginID; + + const_plugin_ptr_t plugin = Global::get().pluginManager->getPlugin(pluginID); + + if (!plugin) { + continue; + } + + QCheckBox *checkBox = new QCheckBox(qwContent); + checkBox->setText(plugin->getName()); + checkBox->setToolTip(plugin->getDescription()); + + checkBox->setProperty("pluginID", pluginID); + + QLabel *urlLabel = new QLabel(qwContent); + urlLabel->setText(currentEntry.updateURL.toString()); + urlLabel->setTextInteractionFlags(Qt::TextSelectableByMouse); + + UpdateWidgetPair pair = { checkBox, urlLabel }; + m_pluginUpdateWidgets << pair; + + QObject::connect(checkBox, &QCheckBox::stateChanged, this, &PluginUpdater::on_singleSelectionChanged); + } + + // sort the plugins alphabetically + std::sort(m_pluginUpdateWidgets.begin(), m_pluginUpdateWidgets.end(), [](const UpdateWidgetPair &first, const UpdateWidgetPair &second) { + return first.pluginCheckBox->text().compare(second.pluginCheckBox->text(), Qt::CaseInsensitive) < 0; + }); + + // add the widgets to the layout + for (int i = 0; i < m_pluginUpdateWidgets.size(); i++) { + UpdateWidgetPair ¤tPair = m_pluginUpdateWidgets[i]; + + static_cast<QFormLayout*>(qwContent->layout())->addRow(currentPair.pluginCheckBox, currentPair.urlLabel); + } +} + +void PluginUpdater::clearUI() { + // There are always as many checkboxes as there are labels + for (int i = 0; i < m_pluginUpdateWidgets.size(); i++) { + UpdateWidgetPair ¤tPair = m_pluginUpdateWidgets[i]; + + qwContent->layout()->removeWidget(currentPair.pluginCheckBox); + qwContent->layout()->removeWidget(currentPair.urlLabel); + + delete currentPair.pluginCheckBox; + delete currentPair.urlLabel; + } +} + +void PluginUpdater::on_selectAll(int checkState) { + // failsafe for partially selected state (shouldn't happen though) + if (checkState == Qt::PartiallyChecked) { + checkState = Qt::Unchecked; + } + + // Select or deselect all plugins + for (int i = 0; i < m_pluginUpdateWidgets.size(); i++) { + UpdateWidgetPair ¤tPair = m_pluginUpdateWidgets[i]; + + currentPair.pluginCheckBox->setCheckState(static_cast<Qt::CheckState>(checkState)); + } +} + +void PluginUpdater::on_singleSelectionChanged(int checkState) { + bool isChecked = checkState == Qt::Checked; + + // Block signals for the selectAll checkBox in order to not trigger its + // check-logic when changing its check-state here + const QSignalBlocker blocker(qcbSelectAll); + + if (!isChecked) { + // If even a single item is unchecked, the selectAll checkbox has to be unchecked + qcbSelectAll->setCheckState(Qt::Unchecked); + return; + } + + // iterate through all checkboxes to see whether we have to toggle the selectAll checkbox + for (int i = 0; i < m_pluginUpdateWidgets.size(); i++) { + const UpdateWidgetPair ¤tPair = m_pluginUpdateWidgets[i]; + + if (!currentPair.pluginCheckBox->isChecked()) { + // One unchecked checkBox is enough to know that the selectAll + // CheckBox can't be checked, so we can abort at this point + return; + } + } + + qcbSelectAll->setCheckState(Qt::Checked); +} + +void PluginUpdater::on_finished(int result) { + if (result == QDialog::Accepted) { + if (qcbSelectAll->isChecked()) { + // all plugins shall be updated, so we don't have to check them individually + return; + } + + QMutexLocker l(&m_dataMutex); + + // The user wants to update the selected plugins only + // remove the plugins that shouldn't be updated from m_pluginsToUpdate + auto it = m_pluginsToUpdate.begin(); + while (it != m_pluginsToUpdate.end()) { + plugin_id_t id = it->pluginID; + + // find the corresponding checkbox + bool updateCurrent = false; + for (int k = 0; k < m_pluginUpdateWidgets.size(); k++) { + QCheckBox *checkBox = m_pluginUpdateWidgets[k].pluginCheckBox; + QVariant idVariant = checkBox->property("pluginID"); + + if (idVariant.isValid() && static_cast<plugin_id_t>(idVariant.toInt()) == id) { + updateCurrent = checkBox->isChecked(); + break; + } + } + + if (!updateCurrent) { + // remove this entry from the update-vector + it = m_pluginsToUpdate.erase(it); + } else { + it++; + } + } + } else { + // Nothing to do as the user doesn't want to update anyways + } +} + +void PluginUpdater::interrupt() { + m_wasInterrupted.store(true); +} + +void PluginUpdater::on_updateDownloaded(QNetworkReply *reply) { + if (reply) { + // Schedule reply for deletion + reply->deleteLater(); + + if (m_wasInterrupted.load()) { + emit updateInterrupted(); + return; + } + + // Find the ID of the plugin this update is for by comparing the URLs + UpdateEntry entry; + bool foundID = false; + { + QMutexLocker l(&m_dataMutex); + + for (int i = 0; i < m_pluginsToUpdate.size(); i++) { + if (m_pluginsToUpdate[i].updateURL == reply->url()) { + foundID = true; + + // remove that entry from the vector as it is being updated right here + entry = m_pluginsToUpdate.takeAt(i); + break; + } + } + } + + if (!foundID) { + // Can't match the URL to a pluginID + qWarning() << "PluginUpdater: Requested update for plugin from" + << reply->url() << "but didn't find corresponding plugin again!"; + return; + } + + // Now get a handle to that plugin + const_plugin_ptr_t plugin = Global::get().pluginManager->getPlugin(entry.pluginID); + + if (!plugin) { + // Can't find plugin with given ID + qWarning() << "PluginUpdater: Got update for plugin with id" + << entry.pluginID << "but it doesn't seem to exist anymore!"; + return; + } + + // We can start actually checking the reply here + if (reply->error() != QNetworkReply::NoError) { + // There was an error during this request. Report it + Log::logOrDefer(Log::Warning, + tr("Unable to download plugin update for \"%1\" from \"%2\" (%3)").arg( + plugin->getName()).arg(reply->url().toString()).arg( + QString::fromLatin1( + QMetaEnum::fromType<QNetworkReply::NetworkError>().valueToKey(reply->error()) + ) + ) + ); + return; + } + + // Check HTTP status code (just because the request was successful, doesn't + // mean the data was downloaded successfully + int httpStatusCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + + if (httpStatusCode >= 300 && httpStatusCode < 400) { + // We have been redirected + if (entry.redirects >= MAX_REDIRECTS - 1) { + // Maximum redirect count exceeded + Log::logOrDefer(Log::Warning, tr("Update for plugin \"%1\" failed due to too many redirects").arg(plugin->getName())); + + return; + } + + QUrl redirectedUrl = reply->attribute(QNetworkRequest::RedirectionTargetAttribute).toUrl(); + // Because the redirection url can be relative, + // we have to use the previous one to resolve it + redirectedUrl = reply->url().resolved(redirectedUrl); + + // Re-insert the current plugin into the list of updating plugins (using the + // new URL so that it will be associated with that instead of the old one) + entry.updateURL = redirectedUrl; + entry.redirects++; + { + QMutexLocker l(&m_dataMutex); + + m_pluginsToUpdate << entry; + } + + // Post a new request for the file to the new URL + m_networkManager.get(QNetworkRequest(redirectedUrl)); + + return; + } + + if (httpStatusCode < 200 || httpStatusCode >= 300) { + // HTTP request has failed + Log::logOrDefer(Log::Warning, + tr("Unable to download plugin update for \"%1\" from \"%2\" (HTTP status code %3)").arg( + plugin->getName()).arg(reply->url().toString()).arg(httpStatusCode) + ); + + return; + } + + // Reply seems fine -> write file to disk and fire installer + QByteArray content = reply->readAll(); + + // Write the content to a file in the temp-dir + if (content.isEmpty()) { + qWarning() << "PluginUpdater: Update for" << plugin->getName() << "from" + << reply->url().toString() << "resulted in no content!"; + return; + } + + QFile file(QDir::temp().filePath(entry.fileName)); + if (!file.open(QIODevice::WriteOnly)) { + qWarning() << "PluginUpdater: Can't open" << file.fileName() << "for writing!"; + return; + } + + file.write(content); + file.close(); + + try { + // Launch installer + PluginInstaller installer(QFileInfo(file.fileName())); + installer.install(); + + Log::logOrDefer(Log::Information, tr("Successfully updated plugin \"%1\"").arg(plugin->getName())); + + // Make sure Mumble won't use the old version of the plugin + Global::get().pluginManager->rescanPlugins(); + } catch (const PluginInstallException &e) { + Log::logOrDefer(Log::CriticalError, e.getMessage()); + } + + { + QMutexLocker l(&m_dataMutex); + + if (m_pluginsToUpdate.isEmpty()) { + emit updatingFinished(); + } + } + } +} |