ScreenTranslator/src/service/updates.cpp

960 lines
24 KiB
C++

#include "updates.h"
#include "debug.h"
#include <QApplication>
#include <QDir>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QMenu>
#include <QNetworkReply>
#include <QScopeGuard>
#include <QSortFilterProxyModel>
#include <QTemporaryFile>
#include <QTimer>
#include <QTreeView>
#include <random>
#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 <miniz/miniz.h>
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<State, QString> 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<Action, QString> 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::Component> Model::parse(const QJsonObject &json) const
{
auto result = std::make_unique<Component>();
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<QString, QString> &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<Component *>(index.internalPointer());
}
QModelIndex Model::toIndex(const Model::Component &component, int column) const
{
return createIndex(component.index, column,
const_cast<Model::Component *>(&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<Column, QString> 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<QTimer>();
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<QUrl> &updateUrls)
: model_(std::make_unique<Model>(*this))
, loader_(std::make_unique<Loader>(*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<QString, QString> &expansions)
{
model_->setExpansions(expansions);
}
void Updater::checkForUpdates()
{
loader_->download(updateUrls_);
}
void Updater::applyAction(Action action, const QVector<File> &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<AutoChecker>(*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<QAbstractItemView *>(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<const QSortFilterProxyModel *>(index.model());
if (!proxy)
return {};
return proxy->mapToSource(index);
}
} // namespace update