Files
86Box/src/qt/qt_updatecheck.cpp
cold-brewed c6da2caff2 vmm preview
2025-05-30 15:20:23 +05:00

361 lines
13 KiB
C++

/*
* 86Box A hypervisor and IBM PC system emulator that specializes in
* running old operating systems and software designed for IBM
* PC systems and compatibles from 1981 through fairly recent
* system designs based on the PCI bus.
*
* This file is part of the 86Box distribution.
*
* Update check module
*
*
*
* Authors: cold-brewed
*
* Copyright 2024 cold-brewed
*/
#include <QDebug>
#include <QJsonArray>
#include <QJsonDocument>
#include <QStandardPaths>
#include <QTimer>
#include "qt_updatecheck.hpp"
#include "qt_downloader.hpp"
#include "qt_updatedetails.hpp"
extern "C" {
#include <86box/version.h>
}
UpdateCheck::
UpdateCheck(const UpdateChannel channel, QObject *parent) : QObject(parent)
{
updateChannel = channel;
currentVersion = getCurrentVersion(channel);
}
UpdateCheck::~
UpdateCheck()
= default;
void
UpdateCheck::checkForUpdates()
{
if (updateChannel == UpdateChannel::Stable) {
const auto githubDownloader = new Downloader(Downloader::DownloadLocation::Temp);
connect(githubDownloader, &Downloader::downloadCompleted, this, &UpdateCheck::githubDownloadComplete);
connect(githubDownloader, &Downloader::errorOccurred, this, &UpdateCheck::generalDownloadError);
githubDownloader->download(QUrl(githubReleaseApi), "github_releases.json");
} else {
const auto jenkinsDownloader = new Downloader(Downloader::DownloadLocation::Temp);
connect(jenkinsDownloader, &Downloader::downloadCompleted, this, &UpdateCheck::jenkinsDownloadComplete);
connect(jenkinsDownloader, &Downloader::errorOccurred, this, &UpdateCheck::generalDownloadError);
jenkinsDownloader->download(jenkinsLatestNReleasesUrl(10), "jenkins_list.json");
}
}
void
UpdateCheck::jenkinsDownloadComplete(const QString &filename, const QVariant &varData)
{
auto generalError = tr("Unable to determine release information");
auto jenkinsReleaseListResult = parseJenkinsJson(filename);
auto latestVersion = 0; // NOLINT (Default value as a fallback)
if(!jenkinsReleaseListResult.has_value() || jenkinsReleaseListResult.value().isEmpty()) {
generalDownloadError(generalError);
return;
}
const auto jenkinsReleaseList = jenkinsReleaseListResult.value();
latestVersion = jenkinsReleaseListResult->first().buildNumber;
// If we can't determine the local build (blank current version), always show an update as available.
// Callers can adjust accordingly.
// Otherwise, do a comparison with EMU_BUILD_NUM
bool updateAvailable = false;
bool upToDate = true;
if(currentVersion.isEmpty() || EMU_BUILD_NUM < latestVersion) {
updateAvailable = true;
upToDate = false;
}
const auto updateResult = UpdateResult {
.channel = updateChannel,
.updateAvailable = updateAvailable,
.upToDate = upToDate,
.currentVersion = currentVersion,
.latestVersion = QString::number(latestVersion),
.githubInfo = {},
.jenkinsInfo = jenkinsReleaseList,
};
emit updateCheckComplete(updateResult);
}
void
UpdateCheck::generalDownloadError(const QString &error)
{
emit updateCheckError(error);
}
void
UpdateCheck::githubDownloadComplete(const QString &filename, const QVariant &varData)
{
const auto generalError = tr("Unable to determine release information");
const auto githubReleaseListResult = parseGithubJson(filename);
QString latestVersion = "0.0";
if(!githubReleaseListResult.has_value() || githubReleaseListResult.value().isEmpty()) {
generalDownloadError(generalError);
}
auto githubReleaseList = githubReleaseListResult.value();
// Warning: this check (using the tag name) relies on a consistent naming scheme: "v<number>"
// where <number> is the release number. For example, 4.2 from v4.2 as the tag name.
// Another option would be parsing the name field which is generally "86Box <number>" but
// either option requires a consistent naming scheme.
latestVersion = githubReleaseList.first().tag_name.replace("v", "");
for (const auto &release: githubReleaseList) {
qDebug().noquote().nospace() << release.name << ": " << release.html_url << " (" << release.created_at << ")";
}
// const auto updateDetails = new UpdateDetails(githubReleaseList, currentVersion);
bool updateAvailable = false;
bool upToDate = true;
if(currentVersion.isEmpty() || (versionCompare(currentVersion, latestVersion) < 0)) {
updateAvailable = true;
upToDate = false;
}
const auto updateResult = UpdateResult {
.channel = updateChannel,
.updateAvailable = updateAvailable,
.upToDate = upToDate,
.currentVersion = currentVersion,
.latestVersion = latestVersion,
.githubInfo = githubReleaseList,
.jenkinsInfo = {},
};
emit updateCheckComplete(updateResult);
}
QUrl
UpdateCheck::jenkinsLatestNReleasesUrl(const int &count)
{
const auto urlPath = QString("https://ci.86box.net/job/86box/api/json?tree=builds[number,result,timestamp,changeSets[items[commitId,affectedPaths,author[fullName],msg,id]]]{0,%1}").arg(count);
return { urlPath };
}
QString
UpdateCheck::getCurrentVersion(const UpdateChannel &updateChannel)
{
if (updateChannel == UpdateChannel::Stable) {
return {EMU_VERSION};
}
// If EMU_BUILD_NUM is anything other than the default of zero it was set by the build process
if constexpr (EMU_BUILD_NUM != 0) {
return QString::number(EMU_BUILD_NUM); // NOLINT because EMU_BUILD_NUM is defined as 0 by default and is set at build time
}
// EMU_BUILD_NUM is not set, most likely a local build
return {}; // NOLINT (Having EMU_BUILD_NUM assigned to a default number throws off the linter)
}
std::optional<QList<UpdateCheck::JenkinsReleaseInfo>>
UpdateCheck::parseJenkinsJson(const QString &filename)
{
QList<JenkinsReleaseInfo> releaseInfoList;
QFile json_file(filename);
if (!json_file.open(QIODevice::ReadOnly | QIODevice::Text)) {
qWarning() << "Couldn't open the json file: error" << json_file.error();
return std::nullopt;
}
const QString read_file = json_file.readAll();
json_file.close();
const auto json_doc = QJsonDocument::fromJson(read_file.toUtf8());
if (json_doc.isNull()) {
qWarning("Failed to create QJsonDocument, possibly invalid JSON");
return std::nullopt;
}
if (!json_doc.isObject()) {
qWarning("JSON does not have the expected format (object in root), cannot continue");
return std::nullopt;
}
auto json_object = json_doc.object();
// The json contains multiple release
if(json_object.contains("builds") && json_object["builds"].isArray()) {
QJsonArray builds = json_object["builds"].toArray();
for (const auto &each_build: builds) {
if (auto build = parseJenkinsRelease(each_build.toObject()); build.has_value() && build.value().result == "SUCCESS") {
releaseInfoList.append(build.value());
}
}
} else if(json_object.contains("changeSets") && json_object["changeSets"].isArray()) {
// The json contains only one release, as obtained by the lastSuccessfulBuild api
if (const auto build = parseJenkinsRelease(json_object); build.has_value()) {
releaseInfoList.append(build.value());
}
} else {
qWarning("JSON is missing data or has invalid data, cannot continue");
qDebug() << json_object;
return std::nullopt;
}
return releaseInfoList;
}
std::optional<UpdateCheck::JenkinsReleaseInfo>
UpdateCheck::parseJenkinsRelease(const QJsonObject &json)
{
// The root should contain number, result, and timestamp.
if (!json.contains("number") || !json.contains("result") || !json.contains("timestamp")) {
return std::nullopt;
}
auto releaseInfo = JenkinsReleaseInfo {
.buildNumber = json["number"].toInt(),
.result = json["result"].toString(),
.timestamp = static_cast<qint64>(json["timestamp"].toDouble())
};
// Overview
// Each build should contain a changeSets object with an array. Only the first element is needed.
// The first element should be an object containing an items object with an array.
// Each array element in the items object has information releated to the build. More or less: commit data
// In jq parlance it would be similar to `builds[].changeSets[0].items[]`
// To break down the somewhat complicated if-init statement below:
// * Get the object for `changeSets`
// * Convert the value to array
// * Grab the first element in the array
// Proceed if
// * the element (first in changeSets) is an object that contains the key `items`
if (const auto changeSet = json["changeSets"].toArray().first(); changeSet.isObject() && changeSet.toObject().contains("items")) {
// Then proceed to process each `items` array element
for (const auto &item : changeSet.toObject()["items"].toArray()) {
auto itemObject = item.toObject();
// Basic validation
if (!itemObject.contains("commitId") || !itemObject.contains("msg") || !itemObject.contains("affectedPaths")) {
return std::nullopt;
}
// Convert the paths for each commit to a string list
QStringList paths;
for (const auto &each_path : itemObject["affectedPaths"].toArray().toVariantList()) {
if (each_path.type() == QVariant::String) {
paths.append(each_path.toString());
}
}
// Build the structure
const auto releaseItem = JenkinsChangeSetItem {
.buildId = itemObject["commitId"].toString(),
.author = itemObject["author"].toObject()["fullName"].toString(),
.message = itemObject["msg"].toString(),
.affectedPaths = paths,
};
releaseInfo.changeSetItems.append(releaseItem);
}
} else {
qWarning("Could not parse release information, possibly invalid JSON");
}
return releaseInfo;
}
std::optional<QList<UpdateCheck::GithubReleaseInfo>>
UpdateCheck::parseGithubJson(const QString &filename)
{
QList<GithubReleaseInfo> releaseInfoList;
QFile json_file(filename);
if (!json_file.open(QIODevice::ReadOnly | QIODevice::Text)) {
qWarning("Couldn't open the json file: error %d", json_file.error());
return std::nullopt;
}
const QString read_file = json_file.readAll();
json_file.close();
const auto json_doc = QJsonDocument::fromJson(read_file.toUtf8());
if (json_doc.isNull()) {
qWarning("Failed to create QJsonDocument, possibly invalid JSON");
return std::nullopt;
}
if (!json_doc.isArray()) {
qWarning("JSON does not have the expected format (array in root), cannot continue");
return std::nullopt;
}
auto release_array = json_doc.array();
for (const auto &each_release: release_array) {
if (auto release = parseGithubRelease(each_release.toObject()); release.has_value()) {
releaseInfoList.append(release.value());
}
}
return releaseInfoList;
}
std::optional<UpdateCheck::GithubReleaseInfo>
UpdateCheck::parseGithubRelease(const QJsonObject &json)
{
// Perform some basic validation
if (!json.contains("name") || !json.contains("tag_name") || !json.contains("html_url")) {
return std::nullopt;
}
auto githubRelease = GithubReleaseInfo {
.name = json["name"].toString(),
.tag_name = json["tag_name"].toString(),
.html_url = json["html_url"].toString(),
.target_commitish = json["target_commitish"].toString(),
.created_at = json["created_at"].toString(),
.published_at = json["published_at"].toString(),
.body = json["body"].toString(),
};
return githubRelease;
}
// A simple method to compare version numbers
// Should work for comparing x.y.z and x.y. Missing
// values (parts) will be treated as zeroes
int
UpdateCheck::versionCompare(const QString &version1, const QString &version2)
{
// Split both
QStringList v1List = version1.split('.');
QStringList v2List = version2.split('.');
// Out of the two versions get the maximum amount of "parts"
const int maxParts = std::max(v1List.size(), v2List.size());
// Initialize both with zeros
QVector<int> v1Parts(maxParts, 0);
QVector<int> v2Parts(maxParts, 0);
for (int i = 0; i < v1List.size(); ++i) {
v1Parts[i] = v1List[i].toInt();
}
for (int i = 0; i < v2List.size(); ++i) {
v2Parts[i] = v2List[i].toInt();
}
for (int i = 0; i < maxParts; ++i) {
// First version is greater
if (v1Parts[i] > v2Parts[i])
return 1;
// First version is less
if (v1Parts[i] < v2Parts[i])
return -1;
}
// They are equal
return 0;
}