Add update channel support (Stable/Nightly) to updater system

- Add STABLE_UPDATE_URL and NIGHTLY_UPDATE_URL constants
- Update CheckForUpdates() to read channel from QSettings
- Implement channel-aware version checking
  - Nightly: Always use commit hash from build version
  - Stable: Prioritize version.txt file, fallback to commit hash
- Update ParseUpdateResponse() to handle different API formats
  - Stable: Parse tag_name from Gitea API
  - Nightly: Extract commit hash from GitHub release name
- Update Linux AppImage updates to manage version.txt based on channel
- Remove update_url parameter from CheckForUpdates() methods
- Update main.cpp to use new CheckForUpdates() signature

The updater now supports both Stable and Nightly channels with
appropriate version checking and update source selection for each.

Signed-off-by: Zephyron <zephyron@citron-emu.org>
This commit is contained in:
Zephyron
2025-11-22 16:56:48 +10:00
parent f2987a428b
commit c27533949c
5 changed files with 109 additions and 80 deletions

View File

@@ -6187,12 +6187,10 @@ int main(int argc, char* argv[]) {
void GMainWindow::OnCheckForUpdates() { void GMainWindow::OnCheckForUpdates() {
#ifdef CITRON_USE_AUTO_UPDATER #ifdef CITRON_USE_AUTO_UPDATER
std::string update_url = "https://api.github.com/repos/Zephyron-Dev/Citron-CI/releases";
auto* updater_dialog = new Updater::UpdaterDialog(this); auto* updater_dialog = new Updater::UpdaterDialog(this);
updater_dialog->setAttribute(Qt::WA_DeleteOnClose); updater_dialog->setAttribute(Qt::WA_DeleteOnClose);
updater_dialog->show(); updater_dialog->show();
updater_dialog->CheckForUpdates(update_url); updater_dialog->CheckForUpdates();
#else #else
QMessageBox::information(this, tr("Updates"), QMessageBox::information(this, tr("Updates"),
tr("The automatic updater is not enabled in this build.")); tr("The automatic updater is not enabled in this build."));
@@ -6208,8 +6206,6 @@ void GMainWindow::CheckForUpdatesAutomatically() {
LOG_INFO(Frontend, "Checking for updates automatically..."); LOG_INFO(Frontend, "Checking for updates automatically...");
std::string update_url = "https://api.github.com/repos/Zephyron-Dev/Citron-CI/releases";
auto* updater_service = new Updater::UpdaterService(this); auto* updater_service = new Updater::UpdaterService(this);
connect(updater_service, &Updater::UpdaterService::UpdateCheckCompleted, this, connect(updater_service, &Updater::UpdaterService::UpdateCheckCompleted, this,
@@ -6249,7 +6245,7 @@ void GMainWindow::CheckForUpdatesAutomatically() {
updater_service->deleteLater(); updater_service->deleteLater();
}); });
updater_service->CheckForUpdates(update_url); updater_service->CheckForUpdates();
#endif #endif
} }

View File

@@ -93,9 +93,9 @@ UpdaterDialog::UpdaterDialog(QWidget* parent)
UpdaterDialog::~UpdaterDialog() = default; UpdaterDialog::~UpdaterDialog() = default;
void UpdaterDialog::CheckForUpdates(const std::string& update_url) { void UpdaterDialog::CheckForUpdates() {
ShowCheckingState(); ShowCheckingState();
updater_service->CheckForUpdates(update_url); updater_service->CheckForUpdates();
} }
void UpdaterDialog::OnUpdateCheckCompleted(bool has_update, const Updater::UpdateInfo& update_info) { void UpdaterDialog::OnUpdateCheckCompleted(bool has_update, const Updater::UpdateInfo& update_info) {

View File

@@ -27,7 +27,7 @@ namespace Updater {
explicit UpdaterDialog(QWidget* parent = nullptr); explicit UpdaterDialog(QWidget* parent = nullptr);
~UpdaterDialog() override; ~UpdaterDialog() override;
void CheckForUpdates(const std::string& update_url); void CheckForUpdates();
private slots: private slots:
void OnUpdateCheckCompleted(bool has_update, const Updater::UpdateInfo& update_info); void OnUpdateCheckCompleted(bool has_update, const Updater::UpdateInfo& update_info);

View File

@@ -23,6 +23,7 @@
#include <QSslSocket> #include <QSslSocket>
#include <QCryptographicHash> #include <QCryptographicHash>
#include <QProcess> #include <QProcess>
#include <QSettings>
#ifdef CITRON_ENABLE_LIBARCHIVE #ifdef CITRON_ENABLE_LIBARCHIVE
#include <archive.h> #include <archive.h>
@@ -39,7 +40,9 @@
namespace Updater { namespace Updater {
// Helper function to extract a commit hash from a string using std::regex. const std::string STABLE_UPDATE_URL = "https://git.citron-emu.org/api/v1/repos/Citron/Emulator/releases";
const std::string NIGHTLY_UPDATE_URL = "https://api.github.com/repos/Zephyron-Dev/Citron-CI/releases";
std::string ExtractCommitHash(const std::string& version_string) { std::string ExtractCommitHash(const std::string& version_string) {
std::regex re("\\b([0-9a-fA-F]{7,40})\\b"); std::regex re("\\b([0-9a-fA-F]{7,40})\\b");
std::smatch match; std::smatch match;
@@ -116,15 +119,15 @@ void UpdaterService::InitializeSSL() {
LOG_INFO(Frontend, "SSL initialized successfully"); LOG_INFO(Frontend, "SSL initialized successfully");
} }
void UpdaterService::CheckForUpdates(const std::string& update_url) { void UpdaterService::CheckForUpdates() {
if (update_in_progress.load()) { if (update_in_progress.load()) {
emit UpdateError(QStringLiteral("Update operation already in progress")); emit UpdateError(QStringLiteral("Update operation already in progress"));
return; return;
} }
if (update_url.empty()) { QSettings settings;
emit UpdateError(QStringLiteral("Update URL not configured")); QString channel = settings.value(QStringLiteral("updater/channel"), QStringLiteral("Stable")).toString();
return; std::string update_url = (channel == QStringLiteral("Nightly")) ? NIGHTLY_UPDATE_URL : STABLE_UPDATE_URL;
} LOG_INFO(Frontend, "Selected update channel: {}", channel.toStdString());
LOG_INFO(Frontend, "Checking for updates from: {}", update_url); LOG_INFO(Frontend, "Checking for updates from: {}", update_url);
QUrl url{QString::fromStdString(update_url)}; QUrl url{QString::fromStdString(update_url)};
QNetworkRequest request{url}; QNetworkRequest request{url};
@@ -132,10 +135,10 @@ void UpdaterService::CheckForUpdates(const std::string& update_url) {
request.setRawHeader("Accept", QByteArrayLiteral("application/json")); request.setRawHeader("Accept", QByteArrayLiteral("application/json"));
request.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy); request.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy);
current_reply = network_manager->get(request); current_reply = network_manager->get(request);
connect(current_reply, &QNetworkReply::finished, this, [this]() { connect(current_reply, &QNetworkReply::finished, this, [this, channel]() {
if (!current_reply) return; if (!current_reply) return;
if (current_reply->error() == QNetworkReply::NoError) { if (current_reply->error() == QNetworkReply::NoError) {
ParseUpdateResponse(current_reply->readAll()); ParseUpdateResponse(current_reply->readAll(), channel);
} else { } else {
emit UpdateError(QStringLiteral("Update check failed: %1").arg(current_reply->errorString())); emit UpdateError(QStringLiteral("Update check failed: %1").arg(current_reply->errorString()));
} }
@@ -196,29 +199,56 @@ void UpdaterService::CancelUpdate() {
} }
std::string UpdaterService::GetCurrentVersion() const { std::string UpdaterService::GetCurrentVersion() const {
std::string build_version = Common::g_build_version; QSettings settings;
if (!build_version.empty()) { QString channel = settings.value(QStringLiteral("updater/channel"), QStringLiteral("Stable")).toString();
std::string hash = ExtractCommitHash(build_version);
if (!hash.empty()) { // If the user's setting is Nightly, we must ignore version.txt and only use the commit hash.
return hash; if (channel == QStringLiteral("Nightly")) {
std::string build_version = Common::g_build_version;
if (!build_version.empty()) {
std::string hash = ExtractCommitHash(build_version);
if (!hash.empty()) {
return hash;
}
} }
return ""; // Fallback if no hash is found
} }
std::filesystem::path version_file = app_directory / CITRON_VERSION_FILE; // Otherwise (channel is Stable), we prioritize version.txt.
std::filesystem::path search_path;
#ifdef __linux__
const char* appimage_path_env = qgetenv("APPIMAGE").constData();
if (appimage_path_env && strlen(appimage_path_env) > 0) {
search_path = std::filesystem::path(appimage_path_env).parent_path();
} else {
search_path = app_directory;
}
#else
search_path = app_directory;
#endif
std::filesystem::path version_file = search_path / CITRON_VERSION_FILE;
if (std::filesystem::exists(version_file)) { if (std::filesystem::exists(version_file)) {
std::ifstream file(version_file); std::ifstream file(version_file);
if (file.is_open()) { if (file.is_open()) {
std::string version_from_file; std::string version_from_file;
std::getline(file, version_from_file); std::getline(file, version_from_file);
if (!version_from_file.empty()) { if (!version_from_file.empty()) {
std::string hash = ExtractCommitHash(version_from_file); return version_from_file;
if (!hash.empty()){
return hash;
}
} }
} }
} }
// Fallback for Stable channel: If version.txt is missing, use the commit hash.
// This allows a nightly build to correctly check for a stable update.
std::string build_version = Common::g_build_version;
if (!build_version.empty()) {
std::string hash = ExtractCommitHash(build_version);
if (!hash.empty()) {
return hash;
}
}
return ""; return "";
} }
@@ -227,17 +257,19 @@ bool UpdaterService::IsUpdateInProgress() const {
} }
void UpdaterService::OnDownloadFinished() { void UpdaterService::OnDownloadFinished() {
if (cancel_requested.load()) { if (cancel_requested.load() || !current_reply) {
update_in_progress.store(false); update_in_progress.store(false);
return; return;
} }
if (!current_reply || current_reply->error() != QNetworkReply::NoError) { if (current_reply->error() != QNetworkReply::NoError) {
if(current_reply) emit UpdateError(QStringLiteral("Download failed: %1").arg(current_reply->errorString())); emit UpdateError(QStringLiteral("Download failed: %1").arg(current_reply->errorString()));
update_in_progress.store(false); update_in_progress.store(false);
return; return;
} }
QByteArray downloaded_data = current_reply->readAll(); QByteArray downloaded_data = current_reply->readAll();
QSettings settings;
QString channel = settings.value(QStringLiteral("updater/channel"), QStringLiteral("Stable")).toString();
// This logic has been simplified for clarity. The checksum part can be re-added later. // This logic has been simplified for clarity. The checksum part can be re-added later.
@@ -311,16 +343,29 @@ void UpdaterService::OnDownloadFinished() {
return; return;
} }
// Replace the old AppImage with the new one.
std::error_code ec; std::error_code ec;
std::filesystem::rename(new_appimage_path, original_appimage_path, ec); std::filesystem::rename(new_appimage_path, original_appimage_path, ec);
if (ec) { if (ec) {
LOG_ERROR(Frontend, "Failed to replace old AppImage: {}", ec.message()); LOG_ERROR(Frontend, "Failed to replace old AppImage: {}", ec.message());
emit UpdateError(QStringLiteral("Failed to replace old AppImage. Please close the application and replace it manually.")); emit UpdateError(QStringLiteral("Failed to replace old AppImage."));
update_in_progress.store(false); update_in_progress.store(false);
return; return;
} }
std::filesystem::path version_file_path = original_appimage_path.parent_path() / CITRON_VERSION_FILE;
if (channel == QStringLiteral("Stable")) {
LOG_INFO(Frontend, "Writing stable version marker: {}", current_update_info.version);
std::ofstream version_file(version_file_path);
if (version_file.is_open()) {
version_file << current_update_info.version;
}
} else {
LOG_INFO(Frontend, "Nightly update, removing stable version marker if it exists.");
if (std::filesystem::exists(version_file_path)) {
std::filesystem::remove(version_file_path);
}
}
LOG_INFO(Frontend, "AppImage updated successfully."); LOG_INFO(Frontend, "AppImage updated successfully.");
emit UpdateCompleted(UpdateResult::Success, QStringLiteral("Update successful. Please restart the application.")); emit UpdateCompleted(UpdateResult::Success, QStringLiteral("Update successful. Please restart the application."));
update_in_progress.store(false); update_in_progress.store(false);
@@ -329,8 +374,8 @@ void UpdaterService::OnDownloadFinished() {
void UpdaterService::OnDownloadProgress(qint64 bytes_received, qint64 bytes_total) { void UpdaterService::OnDownloadProgress(qint64 bytes_received, qint64 bytes_total) {
if (bytes_total > 0) { if (bytes_total > 0) {
int percentage = static_cast<int>((bytes_received * 100) / bytes_total); emit UpdateDownloadProgress(static_cast<int>((bytes_received * 100) / bytes_total),
emit UpdateDownloadProgress(percentage, bytes_received, bytes_total); bytes_received, bytes_total);
} }
} }
@@ -341,63 +386,51 @@ void UpdaterService::OnDownloadError(QNetworkReply::NetworkError) {
update_in_progress.store(false); update_in_progress.store(false);
} }
void UpdaterService::ParseUpdateResponse(const QByteArray& response) { void UpdaterService::ParseUpdateResponse(const QByteArray& response, const QString& channel) {
QJsonParseError error; QJsonParseError error;
QJsonDocument doc = QJsonDocument::fromJson(response, &error); QJsonDocument doc = QJsonDocument::fromJson(response, &error);
if (error.error != QJsonParseError::NoError) { if (error.error != QJsonParseError::NoError || !doc.isArray()) {
emit UpdateError(QStringLiteral("Failed to parse JSON: %1").arg(error.errorString())); emit UpdateError(QStringLiteral("Failed to parse update response."));
return; return;
} }
if (!doc.isArray()) {
emit UpdateError(QStringLiteral("JSON response is not an array."));
return;
}
QString platform_identifier;
#if defined(_WIN32)
platform_identifier = QStringLiteral("windows");
#elif defined(__linux__)
platform_identifier = QStringLiteral("linux");
#endif
for (const QJsonValue& release_value : doc.array()) { for (const QJsonValue& release_value : doc.array()) {
QJsonObject release_obj = release_value.toObject(); QJsonObject release_obj = release_value.toObject();
QString release_name = release_obj.value(QStringLiteral("name")).toString(); std::string latest_version;
if (channel == QStringLiteral("Stable")) {
latest_version = release_obj.value(QStringLiteral("tag_name")).toString().toStdString();
} else {
latest_version = ExtractCommitHash(release_obj.value(QStringLiteral("name")).toString().toStdString());
}
if (release_name.toLower().contains(platform_identifier)) { if (latest_version.empty()) continue;
std::string latest_hash = ExtractCommitHash(release_name.toStdString());
if (latest_hash.empty()) { UpdateInfo update_info;
continue; update_info.version = latest_version;
} update_info.changelog = release_obj.value(QStringLiteral("body")).toString().toStdString();
update_info.release_date = release_obj.value(QStringLiteral("published_at")).toString().toStdString();
UpdateInfo update_info; QJsonArray assets = release_obj.value(QStringLiteral("assets")).toArray();
update_info.version = latest_hash; for (const QJsonValue& asset_value : assets) {
update_info.changelog = release_obj.value(QStringLiteral("body")).toString().toStdString(); QJsonObject asset_obj = asset_value.toObject();
update_info.release_date = release_obj.value(QStringLiteral("published_at")).toString().toStdString(); QString asset_name = asset_obj.value(QStringLiteral("name")).toString();
#if defined(__linux__)
QJsonArray assets = release_obj.value(QStringLiteral("assets")).toArray(); if (asset_name.endsWith(QStringLiteral(".AppImage"))) {
for (const QJsonValue& asset_value : assets) { #else
QJsonObject asset_obj = asset_value.toObject(); if (asset_name.endsWith(QStringLiteral(".zip"))) {
QString asset_name = asset_obj.value(QStringLiteral("name")).toString();
#if defined(_WIN32)
if (asset_name.endsWith(QStringLiteral(".zip"))) {
#elif defined(__linux__)
if (asset_name.endsWith(QStringLiteral(".AppImage"))) {
#endif #endif
DownloadOption option; DownloadOption option;
option.name = asset_name.toStdString(); option.name = asset_name.toStdString();
option.url = asset_obj.value(QStringLiteral("browser_download_url")).toString().toStdString(); option.url = asset_obj.value(QStringLiteral("browser_download_url")).toString().toStdString();
update_info.download_options.push_back(option); update_info.download_options.push_back(option);
}
} }
}
if (!update_info.download_options.empty()) { if (!update_info.download_options.empty()) {
update_info.is_newer_version = CompareVersions(GetCurrentVersion(), update_info.version); update_info.is_newer_version = CompareVersions(GetCurrentVersion(), update_info.version);
current_update_info = update_info; current_update_info = update_info;
emit UpdateCheckCompleted(update_info.is_newer_version, update_info); emit UpdateCheckCompleted(update_info.is_newer_version, update_info);
return; return;
}
} }
} }
emit UpdateError(QStringLiteral("Could not find a recent update for your platform.")); emit UpdateError(QStringLiteral("Could not find a recent update for your platform."));

View File

@@ -44,7 +44,7 @@ public:
explicit UpdaterService(QObject* parent = nullptr); explicit UpdaterService(QObject* parent = nullptr);
~UpdaterService() override; ~UpdaterService() override;
void CheckForUpdates(const std::string& update_url); void CheckForUpdates();
void DownloadAndInstallUpdate(const std::string& download_url); void DownloadAndInstallUpdate(const std::string& download_url);
void CancelUpdate(); void CancelUpdate();
std::string GetCurrentVersion() const; std::string GetCurrentVersion() const;
@@ -72,7 +72,7 @@ private slots:
private: private:
void InitializeSSL(); void InitializeSSL();
void ConfigureSSLForRequest(QNetworkRequest& request); void ConfigureSSLForRequest(QNetworkRequest& request);
void ParseUpdateResponse(const QByteArray& response); void ParseUpdateResponse(const QByteArray& response, const QString& channel);
bool CompareVersions(const std::string& current, const std::string& latest) const; bool CompareVersions(const std::string& current, const std::string& latest) const;
#ifdef _WIN32 #ifdef _WIN32