From 9ef1c76a2c65711dc682c5d8df1217b4d526fbdc Mon Sep 17 00:00:00 2001 From: Zephyron Date: Fri, 11 Jul 2025 16:22:28 +1000 Subject: [PATCH] feat: Add automatic update system with background checking Implement a complete auto-updater system for Citron with the following features: - Add UpdaterService class for handling update checks, downloads, and installations - Add UpdaterDialog with progress tracking and user interaction - Support both .zip and .7z archives with Windows PowerShell fallback - Automatic background update checking on startup (3-second delay) - Silent notifications when updates are available - Manual "Check for Updates" menu option in Help menu - User setting to enable/disable automatic update checks (enabled by default) - Graceful error handling with detailed logging - Restart functionality after successful updates - Libarchive integration for cross-platform archive support The system uses HTTP URLs to bypass SSL library compatibility issues and provides a smooth user experience with minimal interruption during automatic checks. Manual updates show a full dialog with progress tracking and changelog information. Fixes update distribution workflow and keeps users informed of new releases. Signed-off-by: Zephyron --- src/citron/CMakeLists.txt | 11 + src/citron/main.cpp | 59 ++ src/citron/main.h | 3 + src/citron/main.ui | 7 + src/citron/updater/updater_dialog.cpp | 353 +++++++++++ src/citron/updater/updater_dialog.h | 86 +++ src/citron/updater/updater_dialog.ui | 253 ++++++++ src/citron/updater/updater_service.cpp | 796 +++++++++++++++++++++++++ src/citron/updater/updater_service.h | 121 ++++ src/common/settings.h | 3 + 10 files changed, 1692 insertions(+) create mode 100644 src/citron/updater/updater_dialog.cpp create mode 100644 src/citron/updater/updater_dialog.h create mode 100644 src/citron/updater/updater_dialog.ui create mode 100644 src/citron/updater/updater_service.cpp create mode 100644 src/citron/updater/updater_service.h diff --git a/src/citron/CMakeLists.txt b/src/citron/CMakeLists.txt index f6de195bf..ee0613220 100644 --- a/src/citron/CMakeLists.txt +++ b/src/citron/CMakeLists.txt @@ -210,6 +210,11 @@ add_executable(citron startup_checks.h uisettings.cpp uisettings.h + updater/updater_service.cpp + updater/updater_service.h + updater/updater_dialog.cpp + updater/updater_dialog.h + updater/updater_dialog.ui util/clickable_label.cpp util/clickable_label.h util/controller_navigation.cpp @@ -385,6 +390,12 @@ target_link_libraries(citron PRIVATE common core input_common frontend_common ne target_link_libraries(citron PRIVATE Boost::headers glad Qt${QT_MAJOR_VERSION}::Widgets) target_link_libraries(citron PRIVATE ${PLATFORM_LIBRARIES} Threads::Threads) +# Add libarchive for updater functionality +if (TARGET LibArchive::LibArchive) + target_link_libraries(citron PRIVATE LibArchive::LibArchive) + target_compile_definitions(citron PRIVATE CITRON_ENABLE_LIBARCHIVE) +endif() + target_link_libraries(citron PRIVATE Vulkan::Headers) if (NOT WIN32) target_include_directories(citron PRIVATE ${Qt${QT_MAJOR_VERSION}Gui_PRIVATE_INCLUDE_DIRS}) diff --git a/src/citron/main.cpp b/src/citron/main.cpp index edd57e64a..2331756a9 100644 --- a/src/citron/main.cpp +++ b/src/citron/main.cpp @@ -161,6 +161,7 @@ static FileSys::VirtualFile VfsDirectoryCreateFileWrapper(const FileSys::Virtual #include "citron/play_time_manager.h" #include "citron/startup_checks.h" #include "citron/uisettings.h" +#include "citron/updater/updater_dialog.h" #include "citron/util/clickable_label.h" #include "citron/vk_device_info.h" @@ -431,6 +432,9 @@ GMainWindow::GMainWindow(std::unique_ptr config_, bool has_broken_vulk // Show one-time "callout" messages to the user ShowTelemetryCallout(); + // Check for updates automatically after a short delay (non-blocking) + QTimer::singleShot(3000, this, &GMainWindow::CheckForUpdatesAutomatically); + // make sure menubar has the arrow cursor instead of inheriting from this ui->menubar->setCursor(QCursor()); statusBar()->setCursor(QCursor()); @@ -1609,6 +1613,7 @@ void GMainWindow::ConnectMenuEvents() { connect_menu(ui->action_Verify_installed_contents, &GMainWindow::OnVerifyInstalledContents); connect_menu(ui->action_Install_Firmware, &GMainWindow::OnInstallFirmware); connect_menu(ui->action_Install_Keys, &GMainWindow::OnInstallDecryptionKeys); + connect_menu(ui->action_Check_For_Updates, &GMainWindow::OnCheckForUpdates); connect_menu(ui->action_About, &GMainWindow::OnAbout); } @@ -5347,6 +5352,60 @@ int main(int argc, char* argv[]) { return result; } +void GMainWindow::OnCheckForUpdates() { + // Use HTTP URL to bypass SSL issues (will be redirected to HTTPS but handled by updater) + // TODO: Fix SSL libraries and revert to https://releases.citron-emu.org/api/check + std::string update_url = "http://releases.citron-emu.org/api/check"; + + // Create and show the updater dialog + auto* updater_dialog = new UpdaterDialog(this); + updater_dialog->setAttribute(Qt::WA_DeleteOnClose); + updater_dialog->show(); + updater_dialog->CheckForUpdates(update_url); +} + +void GMainWindow::CheckForUpdatesAutomatically() { + // Check if automatic updates are enabled + if (!Settings::values.enable_auto_update_check.GetValue()) { + return; + } + + LOG_INFO(Frontend, "Checking for updates automatically..."); + + // Use HTTP URL to bypass SSL issues + std::string update_url = "http://releases.citron-emu.org/api/check"; + + // Create updater service for silent background check + auto* updater_service = new Updater::UpdaterService(this); + + // Connect to update check result + connect(updater_service, &Updater::UpdaterService::UpdateCheckCompleted, this, + [this, updater_service](bool has_update, const Updater::UpdateInfo& update_info) { + if (has_update) { + // Show a subtle notification that an update is available + QMessageBox::information(this, tr("Update Available"), + tr("A new version of Citron is available: %1\n\n" + "Click Help → Check for Updates to download it.") + .arg(QString::fromStdString(update_info.version))); + } + updater_service->deleteLater(); + }); + + // Connect to error handling + connect(updater_service, &Updater::UpdaterService::UpdateCompleted, this, + [updater_service](Updater::UpdaterService::UpdateResult result, const QString& message) { + if (result == Updater::UpdaterService::UpdateResult::NetworkError || + result == Updater::UpdaterService::UpdateResult::Failed) { + // Silent fail for automatic checks - just log the error + LOG_WARNING(Frontend, "Automatic update check failed: {}", message.toStdString()); + } + updater_service->deleteLater(); + }); + + // Start the silent update check + updater_service->CheckForUpdates(update_url); +} + void GMainWindow::OnToggleGridView() { game_list->ToggleViewMode(); } diff --git a/src/citron/main.h b/src/citron/main.h index e6b3212fc..081ec3161 100644 --- a/src/citron/main.h +++ b/src/citron/main.h @@ -59,6 +59,7 @@ class QtControllerSelectorDialog; class QtProfileSelectionDialog; class QtSoftwareKeyboardDialog; class QtNXWebEngineView; +class UpdaterDialog; enum class StartGameType { Normal, // Can use custom configuration @@ -383,6 +384,8 @@ private slots: void OnInstallFirmware(); void OnInstallDecryptionKeys(); void OnAbout(); + void OnCheckForUpdates(); + void CheckForUpdatesAutomatically(); void OnToggleFilterBar(); void OnToggleGridView(); void OnToggleStatusBar(); diff --git a/src/citron/main.ui b/src/citron/main.ui index 67d9c79ed..366832fcf 100644 --- a/src/citron/main.ui +++ b/src/citron/main.ui @@ -187,6 +187,8 @@ + + @@ -240,6 +242,11 @@ &Verify Installed Contents + + + Check for &Updates... + + &About citron diff --git a/src/citron/updater/updater_dialog.cpp b/src/citron/updater/updater_dialog.cpp new file mode 100644 index 000000000..51d5555ef --- /dev/null +++ b/src/citron/updater/updater_dialog.cpp @@ -0,0 +1,353 @@ +// SPDX-FileCopyrightText: Copyright 2025 citron Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include "citron/updater/updater_dialog.h" +#include "ui_updater_dialog.h" + +#include +#include +#include +#include +#include +#include +#include + +UpdaterDialog::UpdaterDialog(QWidget* parent) + : QDialog(parent), ui(std::make_unique()), + updater_service(std::make_unique(this)), + current_state(State::Checking), total_download_size(0), downloaded_bytes(0), + progress_timer(new QTimer(this)) { + + ui->setupUi(this); + + // Set up connections + connect(updater_service.get(), &Updater::UpdaterService::UpdateCheckCompleted, + this, &UpdaterDialog::OnUpdateCheckCompleted); + connect(updater_service.get(), &Updater::UpdaterService::UpdateDownloadProgress, + this, &UpdaterDialog::OnUpdateDownloadProgress); + connect(updater_service.get(), &Updater::UpdaterService::UpdateInstallProgress, + this, &UpdaterDialog::OnUpdateInstallProgress); + connect(updater_service.get(), &Updater::UpdaterService::UpdateCompleted, + this, &UpdaterDialog::OnUpdateCompleted); + connect(updater_service.get(), &Updater::UpdaterService::UpdateError, + this, &UpdaterDialog::OnUpdateError); + + // Set up UI connections + connect(ui->downloadButton, &QPushButton::clicked, this, &UpdaterDialog::OnDownloadButtonClicked); + connect(ui->cancelButton, &QPushButton::clicked, this, &UpdaterDialog::OnCancelButtonClicked); + connect(ui->closeButton, &QPushButton::clicked, this, &UpdaterDialog::OnCloseButtonClicked); + connect(ui->restartButton, &QPushButton::clicked, this, &UpdaterDialog::OnRestartButtonClicked); + + SetupUI(); + + // Set up progress timer for smooth updates + progress_timer->setInterval(100); // Update every 100ms + connect(progress_timer, &QTimer::timeout, this, [this]() { + if (current_state == State::Downloading) { + ui->downloadInfoLabel->setText( + QStringLiteral("Downloaded: %1 / %2") + .arg(FormatBytes(downloaded_bytes)) + .arg(FormatBytes(total_download_size)) + ); + } + }); +} + +UpdaterDialog::~UpdaterDialog() = default; + +void UpdaterDialog::CheckForUpdates(const std::string& update_url) { + ShowCheckingState(); + updater_service->CheckForUpdates(update_url); +} + +void UpdaterDialog::ShowUpdateAvailable(const Updater::UpdateInfo& update_info) { + current_update_info = update_info; + ShowUpdateAvailableState(); +} + +void UpdaterDialog::ShowUpdateChecking() { + ShowCheckingState(); +} + +void UpdaterDialog::OnUpdateCheckCompleted(bool has_update, const Updater::UpdateInfo& update_info) { + if (has_update) { + current_update_info = update_info; + ShowUpdateAvailableState(); + } else { + ShowNoUpdateState(); + } +} + +void UpdaterDialog::OnUpdateDownloadProgress(int percentage, qint64 bytes_received, qint64 bytes_total) { + downloaded_bytes = bytes_received; + total_download_size = bytes_total; + + ui->progressBar->setValue(percentage); + ui->progressLabel->setText(QStringLiteral("Downloading update... %1%").arg(percentage)); + + if (!progress_timer->isActive()) { + progress_timer->start(); + } +} + +void UpdaterDialog::OnUpdateInstallProgress(int percentage, const QString& current_file) { + progress_timer->stop(); + + ui->progressBar->setValue(percentage); + ui->progressLabel->setText(QStringLiteral("Installing update... %1%").arg(percentage)); + ui->downloadInfoLabel->setText(current_file); +} + +void UpdaterDialog::OnUpdateCompleted(Updater::UpdaterService::UpdateResult result, const QString& message) { + progress_timer->stop(); + + switch (result) { + case Updater::UpdaterService::UpdateResult::Success: + ShowCompletedState(); + break; + case Updater::UpdaterService::UpdateResult::Cancelled: + close(); + break; + default: + ShowErrorState(); + ui->statusLabel->setText(GetUpdateMessage(result) + QStringLiteral("\n\n") + message); + break; + } +} + +void UpdaterDialog::OnUpdateError(const QString& error_message) { + progress_timer->stop(); + ShowErrorState(); + ui->statusLabel->setText(QStringLiteral("Update failed: ") + error_message); +} + +void UpdaterDialog::OnDownloadButtonClicked() { + ShowDownloadingState(); + updater_service->DownloadAndInstallUpdate(current_update_info); +} + +void UpdaterDialog::OnCancelButtonClicked() { + if (updater_service->IsUpdateInProgress()) { + updater_service->CancelUpdate(); + } else { + close(); + } +} + +void UpdaterDialog::OnCloseButtonClicked() { + close(); +} + +void UpdaterDialog::OnRestartButtonClicked() { + // Ask for confirmation + int ret = QMessageBox::question(this, QStringLiteral("Restart Citron"), + QStringLiteral("Are you sure you want to restart Citron now?"), + QMessageBox::Yes | QMessageBox::No, + QMessageBox::Yes); + + if (ret == QMessageBox::Yes) { + // Get the current executable path + QString program = QApplication::applicationFilePath(); + QStringList arguments = QApplication::arguments(); + arguments.removeFirst(); // Remove the program name + + // Start the new instance + QProcess::startDetached(program, arguments); + + // Close the current instance + QApplication::quit(); + } +} + +void UpdaterDialog::SetupUI() { + setWindowFlags(windowFlags() & ~Qt::WindowContextHelpButtonHint); + setFixedSize(size()); + + // Set current version + ui->currentVersionValue->setText(QString::fromStdString(updater_service->GetCurrentVersion())); + + ShowCheckingState(); +} + +void UpdaterDialog::ShowCheckingState() { + current_state = State::Checking; + + ui->titleLabel->setText(QStringLiteral("Checking for updates...")); + ui->statusLabel->setText(QStringLiteral("Please wait while we check for available updates...")); + + ui->updateInfoGroup->setVisible(false); + ui->changelogGroup->setVisible(false); + ui->progressGroup->setVisible(false); + + ui->downloadButton->setVisible(false); + ui->cancelButton->setVisible(true); + ui->closeButton->setVisible(false); + ui->restartButton->setVisible(false); + + ui->cancelButton->setText(QStringLiteral("Cancel")); +} + +void UpdaterDialog::ShowNoUpdateState() { + current_state = State::NoUpdate; + + ui->titleLabel->setText(QStringLiteral("No updates available")); + ui->statusLabel->setText(QStringLiteral("You are running the latest version of Citron.")); + + ui->updateInfoGroup->setVisible(true); + ui->changelogGroup->setVisible(false); + ui->progressGroup->setVisible(false); + + ui->downloadButton->setVisible(false); + ui->cancelButton->setVisible(false); + ui->closeButton->setVisible(true); + ui->restartButton->setVisible(false); +} + +void UpdaterDialog::ShowUpdateAvailableState() { + current_state = State::UpdateAvailable; + + ui->titleLabel->setText(QStringLiteral("Update available")); + ui->statusLabel->setText(QStringLiteral("A new version of Citron is available for download.")); + + // Fill in update information + ui->latestVersionValue->setText(QString::fromStdString(current_update_info.version)); + ui->releaseDateValue->setText(QString::fromStdString(current_update_info.release_date)); + + // Show changelog if available + if (!current_update_info.changelog.empty()) { + ui->changelogText->setPlainText(QString::fromStdString(current_update_info.changelog)); + ui->changelogGroup->setVisible(true); + } else { + ui->changelogGroup->setVisible(false); + } + + ui->updateInfoGroup->setVisible(true); + ui->progressGroup->setVisible(false); + + ui->downloadButton->setVisible(true); + ui->cancelButton->setVisible(true); + ui->closeButton->setVisible(false); + ui->restartButton->setVisible(false); + + ui->cancelButton->setText(QStringLiteral("Later")); +} + +void UpdaterDialog::ShowDownloadingState() { + current_state = State::Downloading; + + ui->titleLabel->setText(QStringLiteral("Downloading update...")); + ui->statusLabel->setText(QStringLiteral("Please wait while the update is being downloaded and installed.")); + + ui->updateInfoGroup->setVisible(false); + ui->changelogGroup->setVisible(false); + ui->progressGroup->setVisible(true); + + ui->progressLabel->setText(QStringLiteral("Preparing download...")); + ui->progressBar->setValue(0); + ui->downloadInfoLabel->setText(QStringLiteral("")); + + ui->downloadButton->setVisible(false); + ui->cancelButton->setVisible(true); + ui->closeButton->setVisible(false); + ui->restartButton->setVisible(false); + + ui->cancelButton->setText(QStringLiteral("Cancel")); + + progress_timer->start(); +} + +void UpdaterDialog::ShowInstallingState() { + current_state = State::Installing; + + ui->titleLabel->setText(QStringLiteral("Installing update...")); + ui->statusLabel->setText(QStringLiteral("Please wait while the update is being installed. Do not close the application.")); + + ui->progressLabel->setText(QStringLiteral("Installing...")); + ui->downloadInfoLabel->setText(QStringLiteral("")); + + ui->cancelButton->setVisible(false); +} + +void UpdaterDialog::ShowCompletedState() { + current_state = State::Completed; + + ui->titleLabel->setText(QStringLiteral("Update completed!")); + ui->statusLabel->setText(QStringLiteral("The update has been installed successfully. Please restart Citron to use the new version.")); + + ui->progressGroup->setVisible(false); + + ui->downloadButton->setVisible(false); + ui->cancelButton->setVisible(false); + ui->closeButton->setVisible(true); + ui->restartButton->setVisible(true); + + ui->progressBar->setValue(100); +} + +void UpdaterDialog::ShowErrorState() { + current_state = State::Error; + + ui->titleLabel->setText(QStringLiteral("Update failed")); + // statusLabel text is set by the caller + + ui->updateInfoGroup->setVisible(false); + ui->changelogGroup->setVisible(false); + ui->progressGroup->setVisible(false); + + ui->downloadButton->setVisible(false); + ui->cancelButton->setVisible(false); + ui->closeButton->setVisible(true); + ui->restartButton->setVisible(false); +} + +void UpdaterDialog::UpdateDownloadProgress(int percentage, qint64 bytes_received, qint64 bytes_total) { + downloaded_bytes = bytes_received; + total_download_size = bytes_total; + + ui->progressBar->setValue(percentage); + ui->progressLabel->setText(QStringLiteral("Downloading update... %1%").arg(percentage)); +} + +void UpdaterDialog::UpdateInstallProgress(int percentage, const QString& current_file) { + ui->progressBar->setValue(percentage); + ui->progressLabel->setText(QStringLiteral("Installing update... %1%").arg(percentage)); + ui->downloadInfoLabel->setText(current_file); +} + +QString UpdaterDialog::FormatBytes(qint64 bytes) const { + const QStringList units = {QStringLiteral("B"), QStringLiteral("KB"), QStringLiteral("MB"), QStringLiteral("GB")}; + double size = bytes; + int unit = 0; + + while (size >= 1024.0 && unit < units.size() - 1) { + size /= 1024.0; + unit++; + } + + return QStringLiteral("%1 %2").arg(QString::number(size, 'f', unit == 0 ? 0 : 1)).arg(units[unit]); +} + +QString UpdaterDialog::GetUpdateMessage(Updater::UpdaterService::UpdateResult result) const { + switch (result) { + case Updater::UpdaterService::UpdateResult::Success: + return QStringLiteral("Update completed successfully!"); + case Updater::UpdaterService::UpdateResult::Failed: + return QStringLiteral("Update failed due to an unknown error."); + case Updater::UpdaterService::UpdateResult::Cancelled: + return QStringLiteral("Update was cancelled."); + case Updater::UpdaterService::UpdateResult::NetworkError: + return QStringLiteral("Update failed due to a network error."); + case Updater::UpdaterService::UpdateResult::ExtractionError: + return QStringLiteral("Failed to extract the update archive."); + case Updater::UpdaterService::UpdateResult::PermissionError: + return QStringLiteral("Update failed due to insufficient permissions."); + case Updater::UpdaterService::UpdateResult::InvalidArchive: + return QStringLiteral("The downloaded update archive is invalid."); + case Updater::UpdaterService::UpdateResult::NoUpdateAvailable: + return QStringLiteral("No update is available."); + default: + return QStringLiteral("Unknown error occurred."); + } +} + +#include "updater_dialog.moc" \ No newline at end of file diff --git a/src/citron/updater/updater_dialog.h b/src/citron/updater/updater_dialog.h new file mode 100644 index 000000000..c22d269df --- /dev/null +++ b/src/citron/updater/updater_dialog.h @@ -0,0 +1,86 @@ +// SPDX-FileCopyrightText: Copyright 2025 citron Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include "citron/updater/updater_service.h" + +namespace Ui { +class UpdaterDialog; +} + +class UpdaterDialog : public QDialog { + Q_OBJECT + +public: + explicit UpdaterDialog(QWidget* parent = nullptr); + ~UpdaterDialog() override; + + // Check for updates using the given URL + void CheckForUpdates(const std::string& update_url); + + // Show update available dialog + void ShowUpdateAvailable(const Updater::UpdateInfo& update_info); + + // Show update checking dialog + void ShowUpdateChecking(); + +private slots: + void OnUpdateCheckCompleted(bool has_update, const Updater::UpdateInfo& update_info); + void OnUpdateDownloadProgress(int percentage, qint64 bytes_received, qint64 bytes_total); + void OnUpdateInstallProgress(int percentage, const QString& current_file); + void OnUpdateCompleted(Updater::UpdaterService::UpdateResult result, const QString& message); + void OnUpdateError(const QString& error_message); + + void OnDownloadButtonClicked(); + void OnCancelButtonClicked(); + void OnCloseButtonClicked(); + void OnRestartButtonClicked(); + +private: + void SetupUI(); + void ShowCheckingState(); + void ShowNoUpdateState(); + void ShowUpdateAvailableState(); + void ShowDownloadingState(); + void ShowInstallingState(); + void ShowCompletedState(); + void ShowErrorState(); + + void UpdateDownloadProgress(int percentage, qint64 bytes_received, qint64 bytes_total); + void UpdateInstallProgress(int percentage, const QString& current_file); + + QString FormatBytes(qint64 bytes) const; + QString GetUpdateMessage(Updater::UpdaterService::UpdateResult result) const; + +private: + std::unique_ptr ui; + std::unique_ptr updater_service; + + Updater::UpdateInfo current_update_info; + + // UI state + enum class State { + Checking, + NoUpdate, + UpdateAvailable, + Downloading, + Installing, + Completed, + Error + }; + State current_state = State::Checking; + + // Progress tracking + qint64 total_download_size = 0; + qint64 downloaded_bytes = 0; + QTimer* progress_timer; +}; \ No newline at end of file diff --git a/src/citron/updater/updater_dialog.ui b/src/citron/updater/updater_dialog.ui new file mode 100644 index 000000000..791e807fd --- /dev/null +++ b/src/citron/updater/updater_dialog.ui @@ -0,0 +1,253 @@ + + + UpdaterDialog + + + + 0 + 0 + 500 + 400 + + + + Citron Updater + + + + :/icons/default/citron.ico:/icons/default/citron.ico + + + true + + + + 12 + + + 12 + + + + + Checking for updates... + + + Qt::AlignCenter + + + + 14 + 75 + true + + + + + + + + Please wait while we check for available updates... + + + Qt::AlignCenter + + + true + + + + + + + Update Information + + + false + + + + + + Current Version: + + + + + + + Unknown + + + + + + + Latest Version: + + + + + + + Unknown + + + + + + + Release Date: + + + + + + + Unknown + + + + + + + + + + Changelog + + + false + + + + + + + 16777215 + 150 + + + + <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0//EN" "http://www.w3.org/TR/REC-html40/strict.dtd"> +<html><head><meta name="qrichtext" content="1" /><style type="text/css"> +p, li { white-space: pre-wrap; } +</style></head><body style=" font-family:'MS Shell Dlg 2'; font-size:8.25pt; font-weight:400; font-style:normal;"> +<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">No changelog available.</p></body></html> + + + + + + + + + + Progress + + + false + + + + + + Preparing... + + + + + + + 0 + + + true + + + + + + + + + + Qt::AlignCenter + + + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Download Update + + + false + + + + + + + Cancel + + + + + + + Close + + + false + + + + + + + Restart Citron + + + false + + + + + + + + + + \ No newline at end of file diff --git a/src/citron/updater/updater_service.cpp b/src/citron/updater/updater_service.cpp new file mode 100644 index 000000000..85a5b0618 --- /dev/null +++ b/src/citron/updater/updater_service.cpp @@ -0,0 +1,796 @@ +// SPDX-FileCopyrightText: Copyright 2025 citron Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include "citron/updater/updater_service.h" +#include "common/logging/log.h" +#include "common/fs/path_util.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifdef CITRON_ENABLE_LIBARCHIVE +#include +#include +#endif + +#include +#include + +#ifdef _WIN32 +#include +#include +#endif + +namespace Updater { + +UpdaterService::UpdaterService(QObject* parent) : QObject(parent) { + network_manager = std::make_unique(this); + + // Initialize SSL support + InitializeSSL(); + + // Initialize paths + app_directory = GetApplicationDirectory(); + temp_download_path = GetTempDirectory(); + backup_path = GetBackupDirectory(); + + // Create necessary directories + EnsureDirectoryExists(temp_download_path); + EnsureDirectoryExists(backup_path); + + LOG_INFO(Frontend, "UpdaterService initialized"); + LOG_INFO(Frontend, "App directory: {}", app_directory.string()); + LOG_INFO(Frontend, "Temp directory: {}", temp_download_path.string()); + LOG_INFO(Frontend, "Backup directory: {}", backup_path.string()); + + // Log SSL support status + LOG_INFO(Frontend, "SSL support available: {}", QSslSocket::supportsSsl() ? "Yes" : "No"); + LOG_INFO(Frontend, "SSL library version: {}", QSslSocket::sslLibraryVersionString().toStdString()); +} + +UpdaterService::~UpdaterService() { + if (current_reply) { + current_reply->abort(); + current_reply->deleteLater(); + } + + // Cleanup temporary files + CleanupFiles(); +} + +void UpdaterService::InitializeSSL() { + // Log OpenSSL library information + LOG_INFO(Frontend, "Attempting to initialize SSL support..."); + + // On Windows, check for OpenSSL libraries +#ifdef _WIN32 + // Get the application directory + QString appDir = QCoreApplication::applicationDirPath(); + + // Check for OpenSSL libraries using proper path construction + QString sslLib = QDir(appDir).filePath(QStringLiteral("libssl-3-x64.dll")); + QString cryptoLib = QDir(appDir).filePath(QStringLiteral("libcrypto-3-x64.dll")); + + LOG_INFO(Frontend, "Looking for SSL libraries in: {}", appDir.toStdString()); + LOG_INFO(Frontend, "SSL library path: {}", sslLib.toStdString()); + LOG_INFO(Frontend, "Crypto library path: {}", cryptoLib.toStdString()); + + // Check if files exist + if (QFile::exists(sslLib) && QFile::exists(cryptoLib)) { + LOG_INFO(Frontend, "OpenSSL library files found"); + } else { + LOG_WARNING(Frontend, "OpenSSL library files not found at expected locations"); + } +#endif + + // Check if SSL is supported + bool sslSupported = QSslSocket::supportsSsl(); + LOG_INFO(Frontend, "SSL support available: {}", sslSupported ? "Yes" : "No"); + + if (!sslSupported) { + LOG_WARNING(Frontend, "SSL support not available after initialization"); + LOG_INFO(Frontend, "Build-time SSL library version: {}", QSslSocket::sslLibraryBuildVersionString().toStdString()); + return; + } + + // Set up SSL configuration + QSslConfiguration sslConfig = QSslConfiguration::defaultConfiguration(); + + // Use system certificates if available + auto certs = QSslConfiguration::systemCaCertificates(); + if (!certs.isEmpty()) { + sslConfig.setCaCertificates(certs); + LOG_INFO(Frontend, "Loaded {} system CA certificates", certs.size()); + } else { + LOG_WARNING(Frontend, "No system CA certificates available"); + } + + // Configure SSL protocol + sslConfig.setProtocol(QSsl::SecureProtocols); + + // Set as default + QSslConfiguration::setDefaultConfiguration(sslConfig); + + LOG_INFO(Frontend, "SSL initialized successfully"); + LOG_INFO(Frontend, "Runtime SSL library version: {}", QSslSocket::sslLibraryVersionString().toStdString()); +} + +void UpdaterService::CheckForUpdates(const std::string& update_url) { + 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; + } + + LOG_INFO(Frontend, "Checking for updates from: {}", update_url); + + // Try HTTPS first, fallback to HTTP if SSL not available + QString requestUrl = QString::fromStdString(update_url); + bool ssl_available = QSslSocket::supportsSsl(); + + if (!ssl_available && requestUrl.startsWith(QStringLiteral("https://"))) { + LOG_WARNING(Frontend, "SSL not supported, trying HTTP fallback"); + requestUrl.replace(QStringLiteral("https://"), QStringLiteral("http://")); + LOG_INFO(Frontend, "Using HTTP fallback URL: {}", requestUrl.toStdString()); + } + + QUrl url{requestUrl}; + QNetworkRequest request{url}; + request.setRawHeader("User-Agent", QByteArrayLiteral("Citron-Updater/1.0")); + request.setRawHeader("Accept", QByteArrayLiteral("application/json")); + + // Only enable automatic redirect following if SSL is available + // This prevents TLS initialization failures when redirecting HTTP -> HTTPS + if (ssl_available) { + request.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy); + } else { + // Disable automatic redirects when SSL is not available + request.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::ManualRedirectPolicy); + LOG_INFO(Frontend, "SSL not available, disabling automatic redirects"); + } + + // Configure SSL for HTTPS requests (if SSL is available) + if (requestUrl.startsWith(QStringLiteral("https://"))) { + ConfigureSSLForRequest(request); + } + + current_reply = network_manager->get(request); + + connect(current_reply, &QNetworkReply::finished, this, [this, ssl_available]() { + // Handle manual redirects when SSL is not available + if (!ssl_available && current_reply->error() == QNetworkReply::NoError) { + QVariant redirect_url = current_reply->attribute(QNetworkRequest::RedirectionTargetAttribute); + if (redirect_url.isValid()) { + QString redirect_str = redirect_url.toString(); + if (redirect_str.startsWith(QStringLiteral("https://"))) { + LOG_ERROR(Frontend, "Server redirected HTTP to HTTPS, but SSL is not available"); + emit UpdateError(QStringLiteral("SSL not available - cannot follow HTTPS redirect. Please check your Qt installation.")); + } else { + LOG_INFO(Frontend, "Following redirect to: {}", redirect_str.toStdString()); + // Follow the redirect manually + QUrl new_url = QUrl(redirect_str); + QNetworkRequest new_request(new_url); + new_request.setRawHeader("User-Agent", QByteArrayLiteral("Citron-Updater/1.0")); + new_request.setRawHeader("Accept", QByteArrayLiteral("application/json")); + new_request.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::ManualRedirectPolicy); + + current_reply->deleteLater(); + current_reply = network_manager->get(new_request); + + // Reconnect handlers for the new request + connect(current_reply, &QNetworkReply::finished, this, [this]() { + if (current_reply->error() == QNetworkReply::NoError) { + ParseUpdateResponse(current_reply->readAll()); + } else { + emit UpdateError(QStringLiteral("Failed to check for updates: %1").arg(current_reply->errorString())); + } + current_reply->deleteLater(); + current_reply = nullptr; + }); + + connect(current_reply, &QNetworkReply::errorOccurred, this, &UpdaterService::OnDownloadError); + } + return; + } + } + + // Normal response handling + if (current_reply->error() == QNetworkReply::NoError) { + ParseUpdateResponse(current_reply->readAll()); + } else { + emit UpdateError(QStringLiteral("Failed to check for updates: %1").arg(current_reply->errorString())); + } + current_reply->deleteLater(); + current_reply = nullptr; + }); + + connect(current_reply, &QNetworkReply::errorOccurred, this, &UpdaterService::OnDownloadError); +} + +void UpdaterService::ConfigureSSLForRequest(QNetworkRequest& request) { + if (!QSslSocket::supportsSsl()) { + LOG_WARNING(Frontend, "SSL not supported, request may fail for HTTPS URLs"); + return; + } + + QSslConfiguration sslConfig = QSslConfiguration::defaultConfiguration(); + + // For now, use permissive SSL verification for compatibility + // In production, this should be QSslSocket::VerifyPeer + sslConfig.setPeerVerifyMode(QSslSocket::VerifyNone); + + // Set secure protocols + sslConfig.setProtocol(QSsl::SecureProtocols); + + // Apply SSL configuration + request.setSslConfiguration(sslConfig); +} + +void UpdaterService::DownloadAndInstallUpdate(const UpdateInfo& update_info) { + if (update_in_progress.load()) { + emit UpdateError(QStringLiteral("Update operation already in progress")); + return; + } + + if (update_info.download_url.empty()) { + emit UpdateError(QStringLiteral("Invalid download URL")); + return; + } + + update_in_progress.store(true); + cancel_requested.store(false); + current_update_info = update_info; + + LOG_INFO(Frontend, "Starting download of update: {}", update_info.version); + LOG_INFO(Frontend, "Download URL: {}", update_info.download_url); + + // Create backup before starting update + if (!CreateBackup()) { + emit UpdateCompleted(UpdateResult::PermissionError, QStringLiteral("Failed to create backup")); + update_in_progress.store(false); + return; + } + + // Prepare download URL with HTTP fallback if needed + QString downloadUrl = QString::fromStdString(update_info.download_url); + bool ssl_available = QSslSocket::supportsSsl(); + + if (!ssl_available && downloadUrl.startsWith(QStringLiteral("https://"))) { + LOG_WARNING(Frontend, "SSL not supported, trying HTTP fallback for download"); + downloadUrl.replace(QStringLiteral("https://"), QStringLiteral("http://")); + LOG_INFO(Frontend, "Using HTTP fallback download URL: {}", downloadUrl.toStdString()); + } + + // Start download + QUrl downloadQUrl{downloadUrl}; + QNetworkRequest request{downloadQUrl}; + request.setRawHeader("User-Agent", QByteArrayLiteral("Citron-Updater/1.0")); + + // Only enable automatic redirect following if SSL is available + if (ssl_available) { + request.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy); + } else { + request.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::ManualRedirectPolicy); + LOG_INFO(Frontend, "SSL not available, disabling automatic redirects for download"); + } + + // Configure SSL for the download request (if SSL is available) + if (downloadUrl.startsWith(QStringLiteral("https://"))) { + ConfigureSSLForRequest(request); + } + + current_reply = network_manager->get(request); + + connect(current_reply, &QNetworkReply::downloadProgress, this, &UpdaterService::OnDownloadProgress); + connect(current_reply, &QNetworkReply::finished, this, [this, ssl_available]() { + // Handle manual redirects when SSL is not available + if (!ssl_available && current_reply->error() == QNetworkReply::NoError) { + QVariant redirect_url = current_reply->attribute(QNetworkRequest::RedirectionTargetAttribute); + if (redirect_url.isValid()) { + QString redirect_str = redirect_url.toString(); + if (redirect_str.startsWith(QStringLiteral("https://"))) { + LOG_ERROR(Frontend, "Server redirected HTTP to HTTPS for download, but SSL is not available"); + emit UpdateCompleted(UpdateResult::NetworkError, + QStringLiteral("SSL not available - cannot follow HTTPS redirect for download. Please check your Qt installation.")); + update_in_progress.store(false); + return; + } else { + LOG_INFO(Frontend, "Following download redirect to: {}", redirect_str.toStdString()); + // Follow the redirect manually + QUrl new_url = QUrl(redirect_str); + QNetworkRequest new_request(new_url); + new_request.setRawHeader("User-Agent", QByteArrayLiteral("Citron-Updater/1.0")); + new_request.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::ManualRedirectPolicy); + + current_reply->deleteLater(); + current_reply = network_manager->get(new_request); + + // Reconnect handlers for the new request + connect(current_reply, &QNetworkReply::downloadProgress, this, &UpdaterService::OnDownloadProgress); + connect(current_reply, &QNetworkReply::finished, this, &UpdaterService::OnDownloadFinished); + connect(current_reply, &QNetworkReply::errorOccurred, this, &UpdaterService::OnDownloadError); + } + return; + } + } + + // Normal download finished handling + OnDownloadFinished(); + }); + connect(current_reply, &QNetworkReply::errorOccurred, this, &UpdaterService::OnDownloadError); +} + +void UpdaterService::CancelUpdate() { + if (!update_in_progress.load()) { + return; + } + + cancel_requested.store(true); + + if (current_reply) { + current_reply->abort(); + } + + LOG_INFO(Frontend, "Update cancelled by user"); + emit UpdateCompleted(UpdateResult::Cancelled, QStringLiteral("Update cancelled by user")); + + update_in_progress.store(false); +} + +std::string UpdaterService::GetCurrentVersion() const { + // Try to read version from version.txt file + std::filesystem::path version_file = app_directory / CITRON_VERSION_FILE; + + if (std::filesystem::exists(version_file)) { + std::ifstream file(version_file); + if (file.is_open()) { + std::string version; + std::getline(file, version); + if (!version.empty()) { + return version; + } + } + } + + // Fallback to application version + return QCoreApplication::applicationVersion().toStdString(); +} + +bool UpdaterService::IsUpdateInProgress() const { + return update_in_progress.load(); +} + +void UpdaterService::OnDownloadFinished() { + if (cancel_requested.load()) { + update_in_progress.store(false); + return; + } + + if (current_reply->error() != QNetworkReply::NoError) { + emit UpdateCompleted(UpdateResult::NetworkError, + QStringLiteral("Download failed: %1").arg(current_reply->errorString())); + update_in_progress.store(false); + return; + } + + // Save downloaded file + QString filename = QStringLiteral("citron_update_%1.zip").arg(QString::fromStdString(current_update_info.version)); + std::filesystem::path download_path = temp_download_path / filename.toStdString(); + + QFile file(QString::fromStdString(download_path.string())); + if (!file.open(QIODevice::WriteOnly)) { + emit UpdateCompleted(UpdateResult::Failed, QStringLiteral("Failed to save downloaded file")); + update_in_progress.store(false); + return; + } + + file.write(current_reply->readAll()); + file.close(); + + LOG_INFO(Frontend, "Download completed: {}", download_path.string()); + + // Start extraction and installation + QTimer::singleShot(100, this, [this, download_path]() { + if (cancel_requested.load()) { + update_in_progress.store(false); + return; + } + + emit UpdateInstallProgress(10, QStringLiteral("Extracting update archive...")); + + std::filesystem::path extract_path = temp_download_path / "extracted"; + if (!ExtractArchive(download_path, extract_path)) { + emit UpdateCompleted(UpdateResult::ExtractionError, QStringLiteral("Failed to extract update archive")); + update_in_progress.store(false); + return; + } + + emit UpdateInstallProgress(70, QStringLiteral("Installing update...")); + + if (!InstallUpdate(extract_path)) { + RestoreBackup(); + emit UpdateCompleted(UpdateResult::Failed, QStringLiteral("Failed to install update")); + update_in_progress.store(false); + return; + } + + emit UpdateInstallProgress(100, QStringLiteral("Update completed successfully!")); + emit UpdateCompleted(UpdateResult::Success, QStringLiteral("Update installed successfully. Please restart the application.")); + + update_in_progress.store(false); + CleanupFiles(); + }); +} + +void UpdaterService::OnDownloadProgress(qint64 bytes_received, qint64 bytes_total) { + if (bytes_total > 0) { + int percentage = static_cast((bytes_received * 100) / bytes_total); + emit UpdateDownloadProgress(percentage, bytes_received, bytes_total); + } +} + +void UpdaterService::OnDownloadError(QNetworkReply::NetworkError error) { + QString error_message = QStringLiteral("Network error: %1").arg(current_reply->errorString()); + LOG_ERROR(Frontend, "Download error: {}", error_message.toStdString()); + emit UpdateCompleted(UpdateResult::NetworkError, error_message); + update_in_progress.store(false); +} + +void UpdaterService::ParseUpdateResponse(const QByteArray& response) { + QJsonParseError error; + QJsonDocument doc = QJsonDocument::fromJson(response, &error); + + if (error.error != QJsonParseError::NoError) { + emit UpdateError(QStringLiteral("Invalid JSON response: %1").arg(error.errorString())); + return; + } + + QJsonObject json = doc.object(); + UpdateInfo update_info; + + update_info.version = json.value(QStringLiteral("version")).toString().toStdString(); + update_info.download_url = json.value(QStringLiteral("download_url")).toString().toStdString(); + update_info.changelog = json.value(QStringLiteral("changelog")).toString().toStdString(); + update_info.release_date = json.value(QStringLiteral("release_date")).toString().toStdString(); + + std::string current_version = GetCurrentVersion(); + update_info.is_newer_version = CompareVersions(current_version, update_info.version); + + LOG_INFO(Frontend, "Update check completed - Current: {}, Latest: {}, Has update: {}", + current_version, update_info.version, update_info.is_newer_version); + + emit UpdateCheckCompleted(update_info.is_newer_version, update_info); +} + +bool UpdaterService::CompareVersions(const std::string& current, const std::string& latest) const { + // Simple version comparison (assumes semantic versioning like 1.2.3) + std::regex version_regex(R"((\d+)\.(\d+)\.(\d+)(?:-(.+))?)"); + std::smatch current_match, latest_match; + + if (!std::regex_match(current, current_match, version_regex) || + !std::regex_match(latest, latest_match, version_regex)) { + // Fallback to string comparison if regex fails + return latest > current; + } + + // Compare major, minor, patch versions + for (int i = 1; i <= 3; ++i) { + int current_num = std::stoi(current_match[i].str()); + int latest_num = std::stoi(latest_match[i].str()); + + if (latest_num > current_num) return true; + if (latest_num < current_num) return false; + } + + return false; // Versions are equal +} + +bool UpdaterService::ExtractArchive(const std::filesystem::path& archive_path, const std::filesystem::path& extract_path) { +#ifdef CITRON_ENABLE_LIBARCHIVE + struct archive* a = archive_read_new(); + struct archive* ext = archive_write_disk_new(); + struct archive_entry* entry; + int r; + + if (!a || !ext) { + LOG_ERROR(Frontend, "Failed to create archive objects"); + return false; + } + + // Configure archive reader for 7z + archive_read_support_format_7zip(a); + archive_read_support_filter_all(a); + + // Configure archive writer + archive_write_disk_set_options(ext, ARCHIVE_EXTRACT_TIME | ARCHIVE_EXTRACT_PERM); + archive_write_disk_set_standard_lookup(ext); + + r = archive_read_open_filename(a, archive_path.string().c_str(), 10240); + if (r != ARCHIVE_OK) { + LOG_ERROR(Frontend, "Failed to open archive: {}", archive_error_string(a)); + archive_read_free(a); + archive_write_free(ext); + return false; + } + + // Create extraction directory + EnsureDirectoryExists(extract_path); + + // Extract files + while (archive_read_next_header(a, &entry) == ARCHIVE_OK) { + if (cancel_requested.load()) { + break; + } + + // Set the extraction path + std::filesystem::path entry_path = extract_path / archive_entry_pathname(entry); + archive_entry_set_pathname(entry, entry_path.string().c_str()); + + r = archive_write_header(ext, entry); + if (r != ARCHIVE_OK) { + LOG_WARNING(Frontend, "Failed to write header for: {}", archive_entry_pathname(entry)); + continue; + } + + // Copy file data + const void* buff; + size_t size; + la_int64_t offset; + + while (archive_read_data_block(a, &buff, &size, &offset) == ARCHIVE_OK) { + if (cancel_requested.load()) { + break; + } + archive_write_data_block(ext, buff, size, offset); + } + + archive_write_finish_entry(ext); + } + + archive_read_close(a); + archive_read_free(a); + archive_write_close(ext); + archive_write_free(ext); + + return !cancel_requested.load(); +#else + // Windows fallback: use system 7zip or PowerShell + return ExtractArchiveWindows(archive_path, extract_path); +#endif +} + +#ifndef CITRON_ENABLE_LIBARCHIVE +bool UpdaterService::ExtractArchiveWindows(const std::filesystem::path& archive_path, const std::filesystem::path& extract_path) { + // Create extraction directory + EnsureDirectoryExists(extract_path); + + // Try 7zip first (most common on Windows) + std::string sevenzip_cmd = "7z x \"" + archive_path.string() + "\" -o\"" + extract_path.string() + "\" -y"; + + LOG_INFO(Frontend, "Attempting extraction with 7zip: {}", sevenzip_cmd); + + int result = std::system(sevenzip_cmd.c_str()); + if (result == 0) { + LOG_INFO(Frontend, "Archive extracted successfully with 7zip"); + return true; + } + + // Fallback to PowerShell for zip files (won't work for 7z) + std::string powershell_cmd = "powershell -Command \"Expand-Archive -Path \\\"" + + archive_path.string() + "\\\" -DestinationPath \\\"" + + extract_path.string() + "\\\" -Force\""; + + LOG_INFO(Frontend, "Attempting extraction with PowerShell: {}", powershell_cmd); + + result = std::system(powershell_cmd.c_str()); + if (result == 0) { + LOG_INFO(Frontend, "Archive extracted successfully with PowerShell"); + return true; + } + + // Both extraction methods failed + LOG_ERROR(Frontend, "Failed to extract archive automatically. Please install 7-Zip or ensure PowerShell is available."); + + // For now, return false - in a real implementation, you might want to: + // 1. Show a dialog asking user to install 7-Zip + // 2. Provide manual extraction instructions + // 3. Download and install 7-Zip automatically + return false; +} +#endif + +bool UpdaterService::InstallUpdate(const std::filesystem::path& update_path) { + try { + // Copy all files from update path to application directory + for (const auto& entry : std::filesystem::recursive_directory_iterator(update_path)) { + if (cancel_requested.load()) { + return false; + } + + if (entry.is_regular_file()) { + std::filesystem::path relative_path = std::filesystem::relative(entry.path(), update_path); + std::filesystem::path dest_path = app_directory / relative_path; + + // Create destination directory if it doesn't exist + std::filesystem::create_directories(dest_path.parent_path()); + + // Copy file + std::filesystem::copy_file(entry.path(), dest_path, + std::filesystem::copy_options::overwrite_existing); + + LOG_DEBUG(Frontend, "Installed file: {}", dest_path.string()); + } + } + + // Update version file + std::filesystem::path version_file = app_directory / CITRON_VERSION_FILE; + std::ofstream file(version_file); + if (file.is_open()) { + file << current_update_info.version; + file.close(); + } + + return true; + } catch (const std::exception& e) { + LOG_ERROR(Frontend, "Failed to install update: {}", e.what()); + return false; + } +} + +bool UpdaterService::CreateBackup() { + try { + std::filesystem::path backup_dir = backup_path / ("backup_" + GetCurrentVersion()); + + if (std::filesystem::exists(backup_dir)) { + std::filesystem::remove_all(backup_dir); + } + + std::filesystem::create_directories(backup_dir); + + // Backup essential files (executable, dlls, etc.) + std::vector backup_patterns = { + "citron.exe", "citron_cmd.exe", "*.dll", "*.pdb" + }; + + for (const auto& entry : std::filesystem::directory_iterator(app_directory)) { + if (entry.is_regular_file()) { + std::string filename = entry.path().filename().string(); + std::string extension = entry.path().extension().string(); + + // Check if file should be backed up + bool should_backup = false; + for (const auto& pattern : backup_patterns) { + if (pattern == filename || + (pattern.starts_with("*") && pattern.substr(1) == extension)) { + should_backup = true; + break; + } + } + + if (should_backup) { + std::filesystem::copy_file(entry.path(), backup_dir / filename); + } + } + } + + LOG_INFO(Frontend, "Backup created: {}", backup_dir.string()); + return true; + } catch (const std::exception& e) { + LOG_ERROR(Frontend, "Failed to create backup: {}", e.what()); + return false; + } +} + +bool UpdaterService::RestoreBackup() { + try { + std::filesystem::path backup_dir = backup_path / ("backup_" + GetCurrentVersion()); + + if (!std::filesystem::exists(backup_dir)) { + LOG_ERROR(Frontend, "Backup directory not found: {}", backup_dir.string()); + return false; + } + + for (const auto& entry : std::filesystem::directory_iterator(backup_dir)) { + if (entry.is_regular_file()) { + std::filesystem::path dest_path = app_directory / entry.path().filename(); + std::filesystem::copy_file(entry.path(), dest_path, + std::filesystem::copy_options::overwrite_existing); + } + } + + LOG_INFO(Frontend, "Backup restored successfully"); + return true; + } catch (const std::exception& e) { + LOG_ERROR(Frontend, "Failed to restore backup: {}", e.what()); + return false; + } +} + +bool UpdaterService::CleanupFiles() { + try { + // Remove temporary files + if (std::filesystem::exists(temp_download_path)) { + for (const auto& entry : std::filesystem::directory_iterator(temp_download_path)) { + if (entry.path().extension() == ".7z" || + entry.path().extension() == ".zip" || + entry.path().filename() == "extracted") { + std::filesystem::remove_all(entry.path()); + } + } + } + + // Remove old backups (keep only the latest 3) + std::vector backup_dirs; + for (const auto& entry : std::filesystem::directory_iterator(backup_path)) { + if (entry.is_directory() && entry.path().filename().string().starts_with("backup_")) { + backup_dirs.push_back(entry.path()); + } + } + + if (backup_dirs.size() > 3) { + std::sort(backup_dirs.begin(), backup_dirs.end(), + [](const std::filesystem::path& a, const std::filesystem::path& b) { + return std::filesystem::last_write_time(a) > std::filesystem::last_write_time(b); + }); + + for (size_t i = 3; i < backup_dirs.size(); ++i) { + std::filesystem::remove_all(backup_dirs[i]); + } + } + + return true; + } catch (const std::exception& e) { + LOG_ERROR(Frontend, "Failed to cleanup files: {}", e.what()); + return false; + } +} + +std::filesystem::path UpdaterService::GetTempDirectory() const { + return std::filesystem::temp_directory_path() / "citron_updater"; +} + +std::filesystem::path UpdaterService::GetApplicationDirectory() const { + return std::filesystem::path(QCoreApplication::applicationDirPath().toStdString()); +} + +std::filesystem::path UpdaterService::GetBackupDirectory() const { + return GetApplicationDirectory() / BACKUP_DIRECTORY; +} + +bool UpdaterService::EnsureDirectoryExists(const std::filesystem::path& path) const { + try { + if (!std::filesystem::exists(path)) { + std::filesystem::create_directories(path); + } + return true; + } catch (const std::exception& e) { + LOG_ERROR(Frontend, "Failed to create directory {}: {}", path.string(), e.what()); + return false; + } +} + +} // namespace Updater + +#include "updater_service.moc" \ No newline at end of file diff --git a/src/citron/updater/updater_service.h b/src/citron/updater/updater_service.h new file mode 100644 index 000000000..223a9a394 --- /dev/null +++ b/src/citron/updater/updater_service.h @@ -0,0 +1,121 @@ +// SPDX-FileCopyrightText: Copyright 2025 citron Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace Updater { + +struct UpdateInfo { + std::string version; + std::string download_url; + std::string changelog; + std::string release_date; + bool is_newer_version = false; +}; + +class UpdaterService : public QObject { + Q_OBJECT + +public: + enum class UpdateResult { + Success, + Failed, + Cancelled, + NetworkError, + ExtractionError, + PermissionError, + InvalidArchive, + NoUpdateAvailable + }; + + explicit UpdaterService(QObject* parent = nullptr); + ~UpdaterService() override; + + // Check for updates from the configured URL + void CheckForUpdates(const std::string& update_url); + + // Download and install update + void DownloadAndInstallUpdate(const UpdateInfo& update_info); + + // Cancel current operation + void CancelUpdate(); + + // Get current application version + std::string GetCurrentVersion() const; + + // Check if update is in progress + bool IsUpdateInProgress() const; + +signals: + void UpdateCheckCompleted(bool has_update, const UpdateInfo& update_info); + void UpdateDownloadProgress(int percentage, qint64 bytes_received, qint64 bytes_total); + void UpdateInstallProgress(int percentage, const QString& current_file); + void UpdateCompleted(UpdateResult result, const QString& message); + void UpdateError(const QString& error_message); + +private slots: + void OnDownloadFinished(); + void OnDownloadProgress(qint64 bytes_received, qint64 bytes_total); + void OnDownloadError(QNetworkReply::NetworkError error); + +private: + // Network operations + void ParseUpdateResponse(const QByteArray& response); + bool CompareVersions(const std::string& current, const std::string& latest) const; + + // SSL configuration + void InitializeSSL(); + void ConfigureSSLForRequest(QNetworkRequest& request); + + // File operations + bool ExtractArchive(const std::filesystem::path& archive_path, const std::filesystem::path& extract_path); +#ifndef CITRON_ENABLE_LIBARCHIVE + bool ExtractArchiveWindows(const std::filesystem::path& archive_path, const std::filesystem::path& extract_path); +#endif + bool InstallUpdate(const std::filesystem::path& update_path); + bool CreateBackup(); + bool RestoreBackup(); + bool CleanupFiles(); + + // Helper functions + std::filesystem::path GetTempDirectory() const; + std::filesystem::path GetApplicationDirectory() const; + std::filesystem::path GetBackupDirectory() const; + bool EnsureDirectoryExists(const std::filesystem::path& path) const; + + // Network components + std::unique_ptr network_manager; + QNetworkReply* current_reply = nullptr; + + // Update state + std::atomic update_in_progress{false}; + std::atomic cancel_requested{false}; + UpdateInfo current_update_info; + + // File paths + std::filesystem::path temp_download_path; + std::filesystem::path backup_path; + std::filesystem::path app_directory; + + // Constants + static constexpr const char* CITRON_VERSION_FILE = "version.txt"; + static constexpr const char* UPDATE_MANIFEST_FILE = "update_manifest.json"; + static constexpr const char* BACKUP_DIRECTORY = "backup"; + static constexpr const char* TEMP_DIRECTORY = "temp"; + static constexpr size_t MAX_DOWNLOAD_SIZE = 500 * 1024 * 1024; // 500MB limit +}; + +} // namespace Updater \ No newline at end of file diff --git a/src/common/settings.h b/src/common/settings.h index 6f7d4df93..a7ce9e5e8 100644 --- a/src/common/settings.h +++ b/src/common/settings.h @@ -617,6 +617,9 @@ struct Values { Category::WebService}; Setting citron_token{linkage, std::string(), "citron_token", Category::WebService}; + // Updater + Setting enable_auto_update_check{linkage, true, "enable_auto_update_check", Category::WebService}; + // Add-Ons std::map> disabled_addons; };