#include "updates.h" #include "debug.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #define MINIZ_NO_ZLIB_APIS #define MINIZ_NO_ZLIB_COMPATIBLE_NAMES #define MINIZ_NO_MALLOC #define MINIZ_NO_STDIO #define MINIZ_NO_ARCHIVE_WRITING_APIS #include static QByteArray unpack(const QByteArray &data) { if (data.size() <= 4 || data.left(2) != "PK") { LTRACE() << "Incorrect data to unpack" << LARG(data.size()) << data.left(10); return {}; } mz_zip_archive zip; memset(&zip, 0, sizeof(zip)); if (!mz_zip_reader_init_mem(&zip, data.data(), data.size(), 0)) { LTRACE() << "Failed to init zip reader for " << data.left(10) << mz_zip_get_error_string(zip.m_last_error); return {}; } const auto guard = qScopeGuard([&zip] { mz_zip_reader_end(&zip); }); const auto fileCount = mz_zip_reader_get_num_files(&zip); if (fileCount < 1) { LTRACE() << "No files in zip archive"; return {}; } for (auto i = 0u; i < fileCount; ++i) { mz_zip_archive_file_stat file_stat; if (!mz_zip_reader_file_stat(&zip, i, &file_stat)) { LTRACE() << "Failed to get file info" << LARG(i) << mz_zip_get_error_string(zip.m_last_error); return {}; } if (file_stat.m_is_directory) continue; QByteArray result(file_stat.m_uncomp_size, 0); mz_zip_reader_extract_to_mem(&zip, 0, result.data(), result.size(), 0); return result; } return {}; } namespace update { namespace { const auto versionKey = "version"; const auto filesKey = "files"; QString sizeString(qint64 bytes, int precision) { if (bytes < 1) return {}; const auto kb = 1024.0; const auto mb = 1024 * kb; const auto gb = 1024 * mb; const auto tb = 1024 * gb; if (bytes >= tb) { return QString::number(bytes / tb, 'f', precision) + ' ' + QApplication::translate("Updates", "Tb"); } if (bytes >= gb) { return QString::number(bytes / gb, 'f', precision) + ' ' + QApplication::translate("Updates", "Gb"); } if (bytes >= mb) { return QString::number(bytes / mb, 'f', precision) + ' ' + QApplication::translate("Updates", "Mb"); } if (bytes >= kb) { return QString::number(bytes / kb, 'f', precision) + ' ' + QApplication::translate("Updates", "Kb"); } return QString::number(bytes) + ' ' + QApplication::translate("Updates", "bytes"); } QString toString(State state) { const QMap names{ {State::NotAvailable, {}}, {State::NotInstalled, QApplication::translate("Updates", "Not installed")}, {State::UpdateAvailable, QApplication::translate("Updates", "Update available")}, {State::Actual, QApplication::translate("Updates", "Up to date")}, }; return names.value(state); } QString toString(Action action) { const QMap names{ {Action::Remove, QApplication::translate("Updates", "Remove")}, {Action::Install, QApplication::translate("Updates", "Install/Update")}, }; return names.value(action); } QStringList toList(const QJsonValue &value) { if (value.isString()) return {value.toString()}; if (!value.isArray()) return {}; const auto array = value.toArray(); QStringList result; for (const auto &i : array) { if (i.isString()) result.append(i.toString()); } return result; } } // namespace // Model::Model(Updater &updater) : updater_(updater) { } QString Model::parse(const QByteArray &data) { QJsonParseError error; const auto doc = QJsonDocument::fromJson(data, &error); if (doc.isNull()) { return tr("Failed to parse: %1 at %2") .arg(error.errorString()) .arg(error.offset); } const auto json = doc.object(); const auto version = json[versionKey].toInt(); if (version != 1) { return tr("Wrong updates version: %1").arg(version); } beginResetModel(); root_ = parse(json); if (root_) updateStates(); endResetModel(); if (!root_) return tr("No data parsed"); return {}; } std::unique_ptr Model::parse(const QJsonObject &json) const { auto result = std::make_unique(); if (json[filesKey].isArray()) { // concrete component const auto host = json["host"].toString().toLower(); if (!host.isEmpty()) { #if defined(Q_OS_LINUX) && defined(Q_PROCESSOR_X86_64) if (host != "linux") return {}; #elif defined(Q_OS_WINDOWS) && defined(Q_PROCESSOR_X86_64) if (host != "win64") return {}; #elif defined(Q_OS_WINDOWS) && defined(Q_PROCESSOR_X86) if (host != "win32") return {}; #elif defined(Q_OS_MACOS) if (host != "macos") return {}; #else return {}; #endif } result->version = json["version"].toString(); const auto files = json[filesKey].toArray(); result->files.reserve(files.size()); for (const auto &fileInfo : files) { const auto object = fileInfo.toObject(); File file; for (const auto &s : toList(object["url"])) { const auto url = QUrl(s); if (url.isValid()) file.urls.append(url); } if (file.urls.isEmpty()) { result->checkOnly = true; } else if (file.urls.size() > 1) { std::random_device device; std::mt19937 generator(device()); std::shuffle(file.urls.begin(), file.urls.end(), generator); } file.rawPath = object["path"].toString(); file.md5 = object["md5"].toString(); file.versionDate = QDateTime::fromString(object["date"].toString(), Qt::ISODate); const auto size = object["size"].toInt(); result->size += size; result->files.push_back(file); } return result; } result->children.reserve(json.size()); auto index = -1; for (const auto &name : json.keys()) { if (name == versionKey) continue; auto child = parse(json[name].toObject()); if (!child) continue; child->name = name; child->index = ++index; child->parent = result.get(); result->children.push_back(std::move(child)); } return result; } void Model::updateProgress(const QUrl &url, int progress) { if (!root_ || url.isEmpty()) return; auto visitor = [this](Component &component, const QUrl &url, int progress, auto v) -> bool { if (!component.files.empty()) { for (auto &file : component.files) { if (!file.urls.contains(url)) continue; file.progress = progress; component.progress = progress; for (const auto &f : qAsConst(component.files)) component.progress = std::max(f.progress, component.progress); const auto index = toIndex(component, int(Column::Progress)); emit dataChanged(index, index, {Qt::DisplayRole}); return true; } } else if (!component.children.empty()) { for (auto &child : component.children) { if (v(*child, url, progress, v)) return true; } } return false; }; visitor(*root_, url, progress, visitor); } void Model::setExpansions(const QHash &expansions) { expansions_ = expansions; updateStates(); } void Model::updateStates() { if (!root_) return; auto visitor = [this](Component &component, auto v) -> void { if (!component.files.empty()) { component.state = State::Actual; for (auto &file : component.files) { file.expandedPath = expanded(file.rawPath); const auto fileState = currentState(file); component.state = std::min(component.state, fileState); } auto index = toIndex(component, int(Column::State)); emit dataChanged(index, index, {Qt::DisplayRole}); } else if (!component.children.empty()) { for (auto &child : component.children) v(*child, v); } }; visitor(*root_, visitor); } State Model::currentState(const File &file) const { if (file.expandedPath.isEmpty() || (file.md5.isEmpty() && !file.versionDate.isValid())) return State::NotAvailable; if (!QFile::exists(file.expandedPath)) return State::NotInstalled; if (file.versionDate.isValid()) { QFileInfo info(file.expandedPath); const auto date = info.fileTime(QFile::FileModificationTime); if (!date.isValid()) return State::NotInstalled; return date >= file.versionDate ? State::Actual : State::UpdateAvailable; } QFile f(file.expandedPath); if (!f.open(QFile::ReadOnly)) return State::NotInstalled; const auto data = f.readAll(); const auto md5 = QCryptographicHash::hash(data, QCryptographicHash::Md5).toHex(); if (md5 != file.md5) return State::UpdateAvailable; return State::Actual; } QString Model::expanded(const QString &source) const { auto result = source; for (auto it = expansions_.cbegin(), end = expansions_.cend(); it != end; ++it) { if (!result.contains(it.key())) continue; result.replace(it.key(), it.value()); } return result; } bool Model::hasUpdates() const { if (!root_) return false; const auto visitor = [](const Component &component, auto v) -> bool { for (const auto &i : component.children) { if (i->state == State::UpdateAvailable || v(*i, v)) return true; } return false; }; return visitor(*root_, visitor); } void Model::selectAllUpdates() { if (!root_) return; const auto visitor = [this](Component &component, auto v) -> void { if (component.checkOnly) return; if (component.state == State::UpdateAvailable) updater_.applyAction(Action::Install, component.files); if (!component.children.empty()) { for (auto &child : component.children) v(*child, v); } }; visitor(*root_, visitor); } void Model::tryAction(Action action, const QModelIndex &index) { const auto visitor = [this, action](Component &component, auto v) -> void { if (component.checkOnly) return; const auto &state = component.state; auto ok = (action == Action::Remove && (state == State::UpdateAvailable || state == State::Actual)) || (action == Action::Install && (state == State::UpdateAvailable || state == State::NotInstalled)); if (ok) updater_.applyAction(action, component.files); if (!component.children.empty()) { for (auto &child : component.children) v(*child, v); } }; auto component = toComponent(index); SOFT_ASSERT(component, return ); visitor(*component, visitor); } Model::Component *Model::toComponent(const QModelIndex &index) const { return static_cast(index.internalPointer()); } QModelIndex Model::toIndex(const Model::Component &component, int column) const { return createIndex(component.index, column, const_cast(&component)); } QModelIndex Model::index(int row, int column, const QModelIndex &parent) const { if (!root_) return {}; if (auto ptr = toComponent(parent)) { SOFT_ASSERT(row >= 0 && row < int(ptr->children.size()), return {}); return toIndex(*ptr->children[row], column); } if (row < 0 && row >= int(root_->children.size())) return {}; return toIndex(*root_->children[row], column); } QModelIndex Model::parent(const QModelIndex &child) const { auto ptr = toComponent(child); if (auto parent = ptr->parent) return createIndex(parent->index, 0, parent); return {}; } int Model::rowCount(const QModelIndex &parent) const { if (auto ptr = toComponent(parent)) { return int(ptr->children.size()); } return root_ ? int(root_->children.size()) : 0; } int Model::columnCount(const QModelIndex & /*parent*/) const { return int(Column::Count); } QVariant Model::headerData(int section, Qt::Orientation orientation, int role) const { if (role != Qt::DisplayRole) return {}; if (orientation == Qt::Vertical) return section + 1; const QMap names{ {Column::Name, tr("Name")}, {Column::State, tr("State")}, {Column::Size, tr("Size")}, {Column::Version, tr("Version")}, {Column::Progress, tr("Progress")}, }; return names.value(Column(section)); } QVariant Model::data(const QModelIndex &index, int role) const { if ((role != Qt::DisplayRole && role != Qt::EditRole) || !index.isValid()) return {}; auto ptr = toComponent(index); SOFT_ASSERT(ptr, return {}); switch (index.column()) { case int(Column::Name): return QObject::tr(qPrintable(ptr->name)); case int(Column::State): return toString(ptr->state); case int(Column::Size): return sizeString(ptr->size, 1); case int(Column::Version): return ptr->version; case int(Column::Progress): return ptr->progress > 0 ? ptr->progress : QVariant(); } return {}; } Qt::ItemFlags Model::flags(const QModelIndex &index) const { auto ptr = toComponent(index); SOFT_ASSERT(ptr, return {}); auto result = Qt::NoItemFlags | Qt::ItemIsSelectable; if (ptr->checkOnly) return result; result |= Qt::ItemIsEnabled; return result; } // Loader::Loader(Updater &updater) : updater_(updater) , network_(new QNetworkAccessManager(this)) { network_->setRedirectPolicy( QNetworkRequest::RedirectPolicy::NoLessSafeRedirectPolicy); connect(network_, &QNetworkAccessManager::finished, // this, &Loader::handleReply); } void Loader::download(const Urls &urls) { start(urls, {}, {}); } void Loader::start(const Urls &urls, const QUrl &previous, const QString &error) { if (!error.isEmpty()) qCritical() << error; if (urls.isEmpty()) { if (!previous.isEmpty()) updater_.downloadFailed(previous, error); return; } auto leftUrls = urls; const auto current = leftUrls.takeFirst(); auto reply = network_->get(QNetworkRequest(current)); downloads_.insert(reply, leftUrls); connect(reply, &QNetworkReply::downloadProgress, // this, [this, current](qint64 bytesSent, qint64 bytesTotal) { updater_.updateProgress(current, bytesSent, bytesTotal); }); if (reply->error() == QNetworkReply::NoError) { updater_.updateProgress(current, -1, -1); return; } handleReply(reply); } void Loader::handleReply(QNetworkReply *reply) { reply->deleteLater(); SOFT_ASSERT(downloads_.contains(reply), return ); const auto leftUrls = downloads_.take(reply); const auto url = reply->request().url(); if (reply->error() != QNetworkReply::NoError) { const auto error = tr("Failed to download file\n%1. Error %2") .arg(reply->url().toString(), reply->errorString()); start(leftUrls, url, error); return; } const auto replyData = reply->readAll(); if (replyData.isEmpty()) { const auto error = tr("Empty data downloaded from\n%1").arg(url.toString()); start(leftUrls, url, error); return; } updater_.downloaded(url, replyData); } // UpdateDelegate::UpdateDelegate(QObject *parent) : QStyledItemDelegate(parent) { } void UpdateDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const { if (index.column() != int(Model::Column::Progress) || index.data().isNull()) { QStyledItemDelegate::paint(painter, option, index); return; } QStyleOptionProgressBar progressBarOption; progressBarOption.rect = option.rect; progressBarOption.minimum = 0; progressBarOption.maximum = 100; const auto progress = index.data().toInt(); progressBarOption.progress = progress; progressBarOption.text = QString::number(progress) + "%"; progressBarOption.textVisible = true; QApplication::style()->drawControl(QStyle::CE_ProgressBar, &progressBarOption, painter); } // void Installer::checkInstall(const File &file) { QFileInfo installDir(QFileInfo(file.expandedPath).absolutePath()); if (installDir.exists() && !installDir.isWritable()) { error_ += QApplication::translate("Updates", "Directory is not writable\n%1") .arg(installDir.absolutePath()); } } void Installer::remove(const File &file) { QFile f(file.expandedPath); if (!f.exists()) return; if (!f.remove()) { error_ += QApplication::translate("Updates", "Failed to remove file\n%1\nError %2") .arg(f.fileName(), f.errorString()); } } void Installer::install(const File &file, const QByteArray &data) { auto installDir = QFileInfo(file.expandedPath).absoluteDir(); if (!installDir.exists() && !installDir.mkpath(".")) { error_ += QApplication::translate("Updates", "Failed to create path\n%1") .arg(installDir.absolutePath()); return; } QTemporaryFile tmp; if (!tmp.open()) { error_ += QApplication::translate( "Updates", "Failed to create temp file\n%1\nError %2") .arg(tmp.fileName(), tmp.errorString()); return; } const auto wrote = tmp.write(data); if (wrote != data.size()) { error_ += QApplication::translate( "Updates", "Failed to write to temp file\n%1\nError %2") .arg(tmp.fileName(), tmp.errorString()); return; } tmp.close(); QFile existing(file.expandedPath); if (existing.exists() && !existing.remove()) { error_ += QApplication::translate("Updates", "Failed to remove file\n%1\nError %2") .arg(existing.fileName(), existing.errorString()); return; } if (!tmp.copy(file.expandedPath)) { error_ += QApplication::translate( "Updates", "Failed to copy file\n%1\nto %2\nError %3") .arg(tmp.fileName(), file.expandedPath, tmp.errorString()); return; } } const QString &Installer::error() const { return error_; } // AutoChecker::AutoChecker(Updater &updater, int intervalDays, const QDateTime &lastCheck) : updater_(updater) , checkIntervalDays_(intervalDays) , lastCheckDate_(lastCheck) { connect(&updater_, &Updater::checkedForUpdates, // this, &AutoChecker::updateLastCheckDate); scheduleNextCheck(); } AutoChecker::~AutoChecker() = default; const QDateTime &AutoChecker::lastCheckDate() const { return lastCheckDate_; } void AutoChecker::scheduleNextCheck() { if (timer_) timer_->stop(); if (checkIntervalDays_ < 1) return; if (!timer_) { timer_ = std::make_unique(); timer_->setSingleShot(true); connect(timer_.get(), &QTimer::timeout, // &updater_, &Updater::checkForUpdates); } const auto now = QDateTime::currentDateTime(); const auto &last = lastCheckDate_.isValid() ? lastCheckDate_ : now; auto nextTime = last.addDays(checkIntervalDays_); if (nextTime <= now) nextTime = now.addSecs(5); timer_->start(now.msecsTo(nextTime)); } void AutoChecker::updateLastCheckDate() { lastCheckDate_ = QDateTime::currentDateTime(); scheduleNextCheck(); } // Updater::Updater(const QVector &updateUrls) : model_(std::make_unique(*this)) , loader_(std::make_unique(*this)) , updateUrls_(updateUrls) { std::random_device device; std::mt19937 generator(device()); std::shuffle(updateUrls_.begin(), updateUrls_.end(), generator); } void Updater::initView(QTreeView *view) { view->setSelectionMode(QAbstractItemView::ExtendedSelection); auto proxy = new QSortFilterProxyModel(view); proxy->setSourceModel(model_.get()); view->setModel(proxy); view->setItemDelegate(new update::UpdateDelegate(view)); view->setSortingEnabled(true); view->sortByColumn(int(Model::Column::Name), Qt::AscendingOrder); view->setContextMenuPolicy(Qt::CustomContextMenu); connect(view, &QAbstractItemView::doubleClicked, // this, &Updater::handleModelDoubleClick); connect(view, &QAbstractItemView::customContextMenuRequested, // this, &Updater::showModelContextMenu); } void Updater::setExpansions(const QHash &expansions) { model_->setExpansions(expansions); } void Updater::checkForUpdates() { loader_->download(updateUrls_); } void Updater::applyAction(Action action, const QVector &files) { for (const auto &file : files) { LTRACE() << "applyAction" << int(action) << file.rawPath; if (action == Action::Remove) { Installer installer; installer.remove(file); if (!installer.error().isEmpty()) { emit error(installer.error()); continue; } model_->updateStates(); emit updated(); continue; } if (action == Action::Install) { if (file.urls.isEmpty() || findDownload(file.urls.first()) != -1) continue; Installer installer; installer.checkInstall(file); if (!installer.error().isEmpty()) { emit error(installer.error()); continue; } downloading_.push_back(file); loader_->download(file.urls); continue; } } } void Updater::downloaded(const QUrl &url, const QByteArray &data) { LTRACE() << "downloaded" << url << LARG(data.size()); if (updateUrls_.contains(url)) { const auto errors = model_->parse(data); emit checkedForUpdates(); if (!errors.isEmpty()) { emit error(errors); return; } if (model_->hasUpdates()) emit updatesAvailable(); return; } model_->updateProgress(url, 0); const auto index = findDownload(url); if (index == -1) return; const auto file = downloading_.takeAt(index); LTRACE() << "downloaded file" << url << file.expandedPath; const auto mustUnpack = url.toString().endsWith(".zip") && !file.expandedPath.endsWith(".zip"); const auto unpacked = mustUnpack ? unpack(data) : data; if (unpacked.isEmpty()) { emit error(tr("Empty data unpacked from\n%1").arg(url.toString())); return; } Installer installer; installer.install(file, unpacked); if (!installer.error().isEmpty()) { emit error(installer.error()); return; } model_->updateStates(); emit updated(); } void Updater::updateProgress(const QUrl &url, qint64 bytesSent, qint64 bytesTotal) { auto progress = bytesTotal < 1 ? 1 : int(100.0 * bytesSent / bytesTotal); model_->updateProgress(url, progress); } void Updater::downloadFailed(const QUrl &url, const QString &error) { if (updateUrls_.contains(url)) { emit checkedForUpdates(); emit this->error(error); return; } model_->updateProgress(url, 0); const auto index = findDownload(url); if (index == -1) return; downloading_.removeAt(index); emit this->error(error); } QDateTime Updater::lastUpdateCheck() const { if (!autoChecker_) return {}; return autoChecker_->lastCheckDate(); } void Updater::setAutoUpdate(int intervalDays, const QDateTime &lastCheck) { autoChecker_ = std::make_unique(*this, intervalDays, lastCheck); } void Updater::handleModelDoubleClick(const QModelIndex &index) { if (!index.isValid()) return; model_->tryAction(Action::Install, fromProxy(index)); } void Updater::showModelContextMenu() { QMenu menu; auto install = menu.addAction(toString(Action::Install)); menu.addAction(toString(Action::Remove)); menu.addSeparator(); auto updateAll = menu.addAction(tr("Update all")); const auto choice = menu.exec(QCursor::pos()); if (!choice) return; if (choice == updateAll) { model_->selectAllUpdates(); return; } auto view = qobject_cast(sender()); SOFT_ASSERT(view, return ); const auto selection = view->selectionModel(); SOFT_ASSERT(selection, return ); const auto indexes = selection->selectedRows(int(Model::Column::Name)); if (indexes.isEmpty()) return; const auto action = choice == install ? Action::Install : Action::Remove; for (const auto &index : indexes) model_->tryAction(action, fromProxy(index)); } int Updater::findDownload(const QUrl &url) const { auto it = std::find_if(downloading_.cbegin(), downloading_.cend(), [url](const File &f) { return f.urls.contains(url); }); if (it == downloading_.end()) return -1; return std::distance(downloading_.cbegin(), it); } QModelIndex Updater::fromProxy(const QModelIndex &index) const { if (!index.isValid() || index.model() == model_.get()) return index; auto proxy = qobject_cast(index.model()); if (!proxy) return {}; return proxy->mapToSource(index); } } // namespace update