/* * 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 #include #include #include #include #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" // where 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 " 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> UpdateCheck::parseJenkinsJson(const QString &filename) { QList 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::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(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> UpdateCheck::parseGithubJson(const QString &filename) { QList 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::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 v1Parts(maxParts, 0); QVector 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; }