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() {
#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);
updater_dialog->setAttribute(Qt::WA_DeleteOnClose);
updater_dialog->show();
updater_dialog->CheckForUpdates(update_url);
updater_dialog->CheckForUpdates();
#else
QMessageBox::information(this, tr("Updates"),
tr("The automatic updater is not enabled in this build."));
@@ -6208,8 +6206,6 @@ void GMainWindow::CheckForUpdatesAutomatically() {
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);
connect(updater_service, &Updater::UpdaterService::UpdateCheckCompleted, this,
@@ -6249,7 +6245,7 @@ void GMainWindow::CheckForUpdatesAutomatically() {
updater_service->deleteLater();
});
updater_service->CheckForUpdates(update_url);
updater_service->CheckForUpdates();
#endif
}

View File

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

View File

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

View File

@@ -23,6 +23,7 @@
#include <QSslSocket>
#include <QCryptographicHash>
#include <QProcess>
#include <QSettings>
#ifdef CITRON_ENABLE_LIBARCHIVE
#include <archive.h>
@@ -39,7 +40,9 @@
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::regex re("\\b([0-9a-fA-F]{7,40})\\b");
std::smatch match;
@@ -116,15 +119,15 @@ void UpdaterService::InitializeSSL() {
LOG_INFO(Frontend, "SSL initialized successfully");
}
void UpdaterService::CheckForUpdates(const std::string& update_url) {
void UpdaterService::CheckForUpdates() {
if (update_in_progress.load()) {
emit UpdateError(QStringLiteral("Update operation already in progress"));
return;
}
if (update_url.empty()) {
emit UpdateError(QStringLiteral("Update URL not configured"));
return;
}
QSettings settings;
QString channel = settings.value(QStringLiteral("updater/channel"), QStringLiteral("Stable")).toString();
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);
QUrl url{QString::fromStdString(update_url)};
QNetworkRequest request{url};
@@ -132,10 +135,10 @@ void UpdaterService::CheckForUpdates(const std::string& update_url) {
request.setRawHeader("Accept", QByteArrayLiteral("application/json"));
request.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy);
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->error() == QNetworkReply::NoError) {
ParseUpdateResponse(current_reply->readAll());
ParseUpdateResponse(current_reply->readAll(), channel);
} else {
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 build_version = Common::g_build_version;
if (!build_version.empty()) {
std::string hash = ExtractCommitHash(build_version);
if (!hash.empty()) {
return hash;
QSettings settings;
QString channel = settings.value(QStringLiteral("updater/channel"), QStringLiteral("Stable")).toString();
// If the user's setting is Nightly, we must ignore version.txt and only use the commit 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)) {
std::ifstream file(version_file);
if (file.is_open()) {
std::string version_from_file;
std::getline(file, version_from_file);
if (!version_from_file.empty()) {
std::string hash = ExtractCommitHash(version_from_file);
if (!hash.empty()){
return hash;
}
return version_from_file;
}
}
}
// 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 "";
}
@@ -227,17 +257,19 @@ bool UpdaterService::IsUpdateInProgress() const {
}
void UpdaterService::OnDownloadFinished() {
if (cancel_requested.load()) {
if (cancel_requested.load() || !current_reply) {
update_in_progress.store(false);
return;
}
if (!current_reply || current_reply->error() != QNetworkReply::NoError) {
if(current_reply) emit UpdateError(QStringLiteral("Download failed: %1").arg(current_reply->errorString()));
if (current_reply->error() != QNetworkReply::NoError) {
emit UpdateError(QStringLiteral("Download failed: %1").arg(current_reply->errorString()));
update_in_progress.store(false);
return;
}
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.
@@ -311,16 +343,29 @@ void UpdaterService::OnDownloadFinished() {
return;
}
// Replace the old AppImage with the new one.
std::error_code ec;
std::filesystem::rename(new_appimage_path, original_appimage_path, ec);
if (ec) {
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);
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.");
emit UpdateCompleted(UpdateResult::Success, QStringLiteral("Update successful. Please restart the application."));
update_in_progress.store(false);
@@ -329,8 +374,8 @@ void UpdaterService::OnDownloadFinished() {
void UpdaterService::OnDownloadProgress(qint64 bytes_received, qint64 bytes_total) {
if (bytes_total > 0) {
int percentage = static_cast<int>((bytes_received * 100) / bytes_total);
emit UpdateDownloadProgress(percentage, bytes_received, bytes_total);
emit UpdateDownloadProgress(static_cast<int>((bytes_received * 100) / bytes_total),
bytes_received, bytes_total);
}
}
@@ -341,63 +386,51 @@ void UpdaterService::OnDownloadError(QNetworkReply::NetworkError) {
update_in_progress.store(false);
}
void UpdaterService::ParseUpdateResponse(const QByteArray& response) {
void UpdaterService::ParseUpdateResponse(const QByteArray& response, const QString& channel) {
QJsonParseError error;
QJsonDocument doc = QJsonDocument::fromJson(response, &error);
if (error.error != QJsonParseError::NoError) {
emit UpdateError(QStringLiteral("Failed to parse JSON: %1").arg(error.errorString()));
if (error.error != QJsonParseError::NoError || !doc.isArray()) {
emit UpdateError(QStringLiteral("Failed to parse update response."));
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()) {
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)) {
std::string latest_hash = ExtractCommitHash(release_name.toStdString());
if (latest_version.empty()) continue;
if (latest_hash.empty()) {
continue;
}
UpdateInfo update_info;
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;
update_info.version = latest_hash;
update_info.changelog = release_obj.value(QStringLiteral("body")).toString().toStdString();
update_info.release_date = release_obj.value(QStringLiteral("published_at")).toString().toStdString();
QJsonArray assets = release_obj.value(QStringLiteral("assets")).toArray();
for (const QJsonValue& asset_value : assets) {
QJsonObject asset_obj = asset_value.toObject();
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"))) {
QJsonArray assets = release_obj.value(QStringLiteral("assets")).toArray();
for (const QJsonValue& asset_value : assets) {
QJsonObject asset_obj = asset_value.toObject();
QString asset_name = asset_obj.value(QStringLiteral("name")).toString();
#if defined(__linux__)
if (asset_name.endsWith(QStringLiteral(".AppImage"))) {
#else
if (asset_name.endsWith(QStringLiteral(".zip"))) {
#endif
DownloadOption option;
option.name = asset_name.toStdString();
option.url = asset_obj.value(QStringLiteral("browser_download_url")).toString().toStdString();
update_info.download_options.push_back(option);
}
DownloadOption option;
option.name = asset_name.toStdString();
option.url = asset_obj.value(QStringLiteral("browser_download_url")).toString().toStdString();
update_info.download_options.push_back(option);
}
}
if (!update_info.download_options.empty()) {
update_info.is_newer_version = CompareVersions(GetCurrentVersion(), update_info.version);
current_update_info = update_info;
emit UpdateCheckCompleted(update_info.is_newer_version, update_info);
return;
}
if (!update_info.download_options.empty()) {
update_info.is_newer_version = CompareVersions(GetCurrentVersion(), update_info.version);
current_update_info = update_info;
emit UpdateCheckCompleted(update_info.is_newer_version, update_info);
return;
}
}
emit UpdateError(QStringLiteral("Could not find a recent update for your platform."));

View File

@@ -44,7 +44,7 @@ public:
explicit UpdaterService(QObject* parent = nullptr);
~UpdaterService() override;
void CheckForUpdates(const std::string& update_url);
void CheckForUpdates();
void DownloadAndInstallUpdate(const std::string& download_url);
void CancelUpdate();
std::string GetCurrentVersion() const;
@@ -72,7 +72,7 @@ private slots:
private:
void InitializeSSL();
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;
#ifdef _WIN32