From 28d658763a92c193c7b569fbe78d130f726ee95d Mon Sep 17 00:00:00 2001 From: Nikilite Date: Fri, 24 Apr 2026 01:10:46 +0200 Subject: [PATCH] feat: add automatic save backup with auto/manual folders --- src/yuzu/CMakeLists.txt | 1 + src/yuzu/backup_manager.cpp | 252 ++++++++++++++++++++++++++++++++++++ src/yuzu/backup_manager.h | 48 +++++++ src/yuzu/main.ui | 7 + src/yuzu/main_window.cpp | 104 +++++++++++++++ src/yuzu/main_window.h | 6 + 6 files changed, 418 insertions(+) create mode 100644 src/yuzu/backup_manager.cpp create mode 100644 src/yuzu/backup_manager.h diff --git a/src/yuzu/CMakeLists.txt b/src/yuzu/CMakeLists.txt index 0bddcca3e8..1d9a0443f2 100644 --- a/src/yuzu/CMakeLists.txt +++ b/src/yuzu/CMakeLists.txt @@ -236,6 +236,7 @@ add_executable(yuzu data_widget.ui ryujinx_dialog.h ryujinx_dialog.cpp ryujinx_dialog.ui main_window.h main_window.cpp main.ui + backup_manager.h backup_manager.cpp configuration/system/new_user_dialog.h configuration/system/new_user_dialog.cpp configuration/system/new_user_dialog.ui configuration/system/profile_avatar_dialog.h configuration/system/profile_avatar_dialog.cpp diff --git a/src/yuzu/backup_manager.cpp b/src/yuzu/backup_manager.cpp new file mode 100644 index 0000000000..a5f6a53d55 --- /dev/null +++ b/src/yuzu/backup_manager.cpp @@ -0,0 +1,252 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "backup_manager.h" + +#include +#include +#include + +#include +#include + +#include "common/fs/fs.h" +#include "common/fs/path_util.h" +#include "common/logging.h" + +namespace fs = std::filesystem; + +namespace { + +class BackupWorker final : public QRunnable { +public: + BackupWorker(BackupManager* manager, fs::path source, fs::path destination) + : manager_{manager}, source_{std::move(source)}, destination_{std::move(destination)} { + setAutoDelete(true); + } + + void run() override { + manager_->DoBackupInternal(source_, destination_); + } + +private: + BackupManager* manager_; + fs::path source_; + fs::path destination_; +}; + +} // anonymous namespace + +BackupManager::BackupManager(QObject* parent) : QObject(parent) {} + +BackupManager::~BackupManager() = default; + +bool BackupManager::IsAutoEnabled() const { + QSettings settings; + return settings.value(QStringLiteral("backup/auto_enabled"), false).toBool(); +} + +void BackupManager::SetAutoEnabled(bool enabled) { + QSettings settings; + settings.setValue(QStringLiteral("backup/auto_enabled"), enabled); +} + +std::filesystem::path BackupManager::GetSourcePath() const { + return Common::FS::GetEdenPath(Common::FS::EdenPath::NANDDir) / "user" / "save"; +} + +std::filesystem::path BackupManager::GetAutoBasePath() const { + return Common::FS::GetEdenPath(Common::FS::EdenPath::EdenDir) / "backups" / "auto"; +} + +std::filesystem::path BackupManager::GetManualBasePath() const { + return Common::FS::GetEdenPath(Common::FS::EdenPath::EdenDir) / "backups" / "manual"; +} + +bool BackupManager::IsRunning() const { + return running_.load(std::memory_order_acquire); +} + +void BackupManager::BackupAll(bool manual) { + if (running_.load(std::memory_order_acquire)) { + LOG_WARNING(Frontend, "Backup already in progress, skipping"); + return; + } + + const auto source = GetSourcePath(); + if (!fs::exists(source)) { + LOG_INFO(Frontend, "Save directory does not exist yet, nothing to back up: {}", + source.string()); + emit BackupFinished(true, tr("No save data to back up.")); + return; + } + + // build timestamped destination: /yyyy-mm-dd_hh-mm-ss/ + const auto timestamp = + QDateTime::currentDateTime().toString(QStringLiteral("yyyy-MM-dd_HH-mm-ss")); + const auto base = manual ? GetManualBasePath() : GetAutoBasePath(); + const auto destination = base / timestamp.toStdString(); + + running_.store(true, std::memory_order_release); + + LOG_INFO(Frontend, "Starting save backup: {} -> {}", source.string(), destination.string()); + + auto* worker = new BackupWorker(this, source, destination); + QThreadPool::globalInstance()->start(worker); +} + +void BackupManager::BackupGame(const std::string& title_id) { + if (running_.load(std::memory_order_acquire)) { + LOG_WARNING(Frontend, "Backup already in progress, skipping"); + return; + } + + const auto source = GetSourcePath() / title_id; + if (!fs::exists(source)) { + LOG_INFO(Frontend, "Save directory for title {} does not exist", title_id); + emit BackupFinished(true, tr("No save data found for this title.")); + return; + } + + const auto timestamp = + QDateTime::currentDateTime().toString(QStringLiteral("yyyy-MM-dd_HH-mm-ss")); + const auto destination = GetAutoBasePath() / timestamp.toStdString() / title_id; + + running_.store(true, std::memory_order_release); + + LOG_INFO(Frontend, "Starting per-game backup for {}: {} -> {}", title_id, source.string(), + destination.string()); + + auto* worker = new BackupWorker(this, source, destination); + QThreadPool::globalInstance()->start(worker); +} + +void BackupManager::DoBackupInternal(const std::filesystem::path& source, + const std::filesystem::path& destination) { + std::error_code ec; + std::size_t files_copied = 0; + std::size_t files_skipped = 0; + + auto finish = [&](bool success, const QString& msg) { + running_.store(false, std::memory_order_release); + // emit on the manager's thread (queued connection) + QMetaObject::invokeMethod( + this, [this, success, msg] { emit BackupFinished(success, msg); }, + Qt::QueuedConnection); + }; + + // create destination hierarchy + fs::create_directories(destination, ec); + if (ec) { + LOG_ERROR(Frontend, "Failed to create backup directory {}: {}", destination.string(), + ec.message()); + finish(false, tr("Failed to create backup directory.")); + return; + } + + // recursively iterate the source + for (auto it = fs::recursive_directory_iterator( + source, fs::directory_options::skip_permission_denied, ec); + it != fs::recursive_directory_iterator(); it.increment(ec)) { + + if (ec) { + LOG_WARNING(Frontend, "Error iterating source directory: {}", ec.message()); + continue; + } + + const auto& entry = *it; + const auto relative = fs::relative(entry.path(), source, ec); + if (ec) { + LOG_WARNING(Frontend, "Failed to compute relative path for {}: {}", + entry.path().string(), ec.message()); + continue; + } + + const auto dest_path = destination / relative; + + if (entry.is_directory()) { + fs::create_directories(dest_path, ec); + if (ec) { + LOG_WARNING(Frontend, "Failed to create directory {}: {}", dest_path.string(), + ec.message()); + } + continue; + } + + if (!entry.is_regular_file()) { + continue; + } + + // incremental check: skip unchanged files if dest already exists + if (fs::exists(dest_path, ec)) { + const auto src_size = entry.file_size(ec); + const auto dst_size = fs::file_size(dest_path, ec); + const auto src_time = entry.last_write_time(ec); + const auto dst_time = fs::last_write_time(dest_path, ec); + + if (src_size == dst_size && src_time == dst_time) { + ++files_skipped; + continue; + } + } + + // atomic copy: write to a temporary file, then rename + const auto temp_path = fs::path(dest_path).concat(".tmp"); + fs::create_directories(dest_path.parent_path(), ec); + + fs::copy_file(entry.path(), temp_path, fs::copy_options::overwrite_existing, ec); + if (ec) { + LOG_ERROR(Frontend, "Failed to copy {} -> {}: {}", entry.path().string(), + temp_path.string(), ec.message()); + // clean up partial temp file + fs::remove(temp_path, ec); + continue; + } + + // preserve last-write-time for future incremental checks + const auto src_mtime = fs::last_write_time(entry.path(), ec); + if (!ec) { + fs::last_write_time(temp_path, src_mtime, ec); + } + + // rename temp -> final (atomic on most filesystems) + fs::rename(temp_path, dest_path, ec); + if (ec) { + LOG_ERROR(Frontend, "Failed to rename temp file {} -> {}: {}", temp_path.string(), + dest_path.string(), ec.message()); + fs::remove(temp_path, ec); + continue; + } + + ++files_copied; + } + + const auto base_path = destination.parent_path(); + if (base_path.filename() == "auto") { + std::error_code prune_ec; + std::vector dirs; + for (auto it = fs::directory_iterator(base_path, prune_ec); it != fs::directory_iterator(); + ++it) { + if (!prune_ec && it->is_directory()) { + dirs.push_back(*it); + } + } + std::sort(dirs.begin(), dirs.end(), [](const auto& a, const auto& b) { + return a.path().filename().string() < b.path().filename().string(); + }); + while (dirs.size() > 10) { + fs::remove_all(dirs.front().path(), prune_ec); + if (!prune_ec) { + LOG_INFO(Frontend, "Pruned old auto-backup: {}", dirs.front().path().string()); + } + dirs.erase(dirs.begin()); + } + } + + LOG_INFO(Frontend, "Backup complete: {} files copied, {} unchanged", files_copied, + files_skipped); + + finish( + true, + tr("Backup complete: %1 files copied, %2 unchanged.").arg(files_copied).arg(files_skipped)); +} diff --git a/src/yuzu/backup_manager.h b/src/yuzu/backup_manager.h new file mode 100644 index 0000000000..45c7f9df89 --- /dev/null +++ b/src/yuzu/backup_manager.h @@ -0,0 +1,48 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include +#include + +#include +#include +#include + +namespace Common::FS { +enum class EdenPath; +} + +// manages save data backups +class BackupManager : public QObject { + Q_OBJECT + +public: + explicit BackupManager(QObject* parent = nullptr); + ~BackupManager() override; + + [[nodiscard]] bool IsAutoEnabled() const; + void SetAutoEnabled(bool enabled); + + [[nodiscard]] std::filesystem::path GetSourcePath() const; + [[nodiscard]] std::filesystem::path GetAutoBasePath() const; + [[nodiscard]] std::filesystem::path GetManualBasePath() const; + + [[nodiscard]] bool IsRunning() const; + + // runs asynchronously on qthreadpool + void BackupAll(bool manual = false); + void BackupGame(const std::string& title_id); + + // internal entry point for worker thread + void DoBackupInternal(const std::filesystem::path& source, + const std::filesystem::path& destination); + +signals: + void BackupFinished(bool success, const QString& message); + +private: + std::atomic running_{false}; +}; diff --git a/src/yuzu/main.ui b/src/yuzu/main.ui index 59aab0ef93..332e078e50 100644 --- a/src/yuzu/main.ui +++ b/src/yuzu/main.ui @@ -225,6 +225,8 @@ + + @@ -620,6 +622,11 @@ Show &Performance Overlay + + + Automatic &Backup... + + diff --git a/src/yuzu/main_window.cpp b/src/yuzu/main_window.cpp index 5d60bd3a8f..3fcc6baaaf 100644 --- a/src/yuzu/main_window.cpp +++ b/src/yuzu/main_window.cpp @@ -25,6 +25,7 @@ #include "debugger/controller.h" #include "about_dialog.h" +#include "backup_manager.h" #include "data_dialog.h" #include "deps_dialog.h" #include "install_dialog.h" @@ -71,12 +72,15 @@ #include #include #include +#include +#include #include #include #include #include #include #include +#include #include // Qt Common // @@ -419,6 +423,11 @@ MainWindow::MainWindow(bool has_broken_vulkan) UpdateWindowTitle(); + // initialize backup manager + backup_manager = std::make_unique(this); + connect(backup_manager.get(), &BackupManager::BackupFinished, this, + &MainWindow::OnBackupFinished); + show(); #ifdef ENABLE_UPDATE_CHECKER @@ -1624,6 +1633,9 @@ void MainWindow::ConnectMenuEvents() { connect_menu(ui->action_About, &MainWindow::OnAbout); connect_menu(ui->action_Eden_Dependencies, &MainWindow::OnEdenDependencies); connect_menu(ui->action_Data_Manager, &MainWindow::OnDataDialog); + + // backup + connect_menu(ui->action_Backup_Settings, &MainWindow::OnBackupSettings); } void MainWindow::UpdateMenuState() { @@ -3849,6 +3861,92 @@ void MainWindow::OnDataDialog() { OnGameListRefresh(); } +void MainWindow::OnBackupSettings() { + QDialog dialog(this); + dialog.setWindowTitle(tr("Automatic Save Backup")); + dialog.setMinimumWidth(580); + + auto* layout = new QVBoxLayout(&dialog); + + // enable checkbox + auto* enable_check = new QCheckBox(tr("Enable automatic backup on exit"), &dialog); + enable_check->setChecked(backup_manager->IsAutoEnabled()); + layout->addWidget(enable_check); + + layout->addSpacing(8); + + // source (read-only) + auto* src_label = new QLabel(tr("Source:"), &dialog); + layout->addWidget(src_label); + auto* src_field = + new QLineEdit(QString::fromStdString(backup_manager->GetSourcePath().string()), &dialog); + src_field->setReadOnly(true); + layout->addWidget(src_field); + + // auto backups folder (read-only) + auto* auto_label = new QLabel(tr("Auto backups folder:"), &dialog); + layout->addWidget(auto_label); + auto* auto_field = new QLineEdit( + QString::fromStdString(backup_manager->GetAutoBasePath().string()), &dialog); + auto_field->setReadOnly(true); + auto_field->setToolTip(tr("Automatic backups created on exit (last 10 kept)")); + layout->addWidget(auto_field); + + // manual backups folder (read-only) + auto* manual_label = new QLabel(tr("Manual backups folder:"), &dialog); + layout->addWidget(manual_label); + auto* manual_field = new QLineEdit( + QString::fromStdString(backup_manager->GetManualBasePath().string()), &dialog); + manual_field->setReadOnly(true); + manual_field->setToolTip(tr("Backups created when you click 'Backup Now'")); + layout->addWidget(manual_field); + + layout->addSpacing(12); + + // buttons + auto* button_layout = new QHBoxLayout(); + auto* backup_now_btn = new QPushButton(tr("Backup Now"), &dialog); + auto* close_btn = new QPushButton(tr("Close"), &dialog); + button_layout->addStretch(); + button_layout->addWidget(backup_now_btn); + button_layout->addWidget(close_btn); + layout->addLayout(button_layout); + + // connections + connect(enable_check, &QCheckBox::toggled, this, + [this](bool checked) { backup_manager->SetAutoEnabled(checked); }); + + connect(backup_now_btn, &QPushButton::clicked, this, [this, backup_now_btn] { + backup_now_btn->setEnabled(false); + backup_now_btn->setText(tr("Backing up...")); + backup_manager->BackupAll(true); + + // re-enable after backup finishes + auto conn = std::make_shared(); + *conn = connect(backup_manager.get(), &BackupManager::BackupFinished, this, + [this, backup_now_btn, conn](bool /*success*/, const QString& /*msg*/) { + backup_now_btn->setEnabled(true); + backup_now_btn->setText(tr("Backup Now")); + disconnect(*conn); + }); + }); + + connect(close_btn, &QPushButton::clicked, &dialog, &QDialog::accept); + + dialog.exec(); +} + +void MainWindow::OnBackupFinished(bool success, const QString& message) { + if (success) { + LOG_INFO(Frontend, "Backup finished: {}", message.toStdString()); + } else { + LOG_ERROR(Frontend, "Backup failed: {}", message.toStdString()); + } + if (message_label) { + message_label->setText(message); + } +} + void MainWindow::OnToggleFilterBar() { game_list->SetFilterVisible(ui->action_Show_Filter_Bar->isChecked()); if (ui->action_Show_Filter_Bar->isChecked()) @@ -4432,6 +4530,12 @@ void MainWindow::closeEvent(QCloseEvent* event) { return; } + // run automatic backup on exit (synchronously so data is safe before quit) + if (backup_manager && backup_manager->IsAutoEnabled()) { + LOG_INFO(Frontend, "Running automatic save backup on exit"); + backup_manager->BackupAll(); + } + UpdateUISettings(); game_list->SaveInterfaceLayout(); UISettings::SaveWindowState(); diff --git a/src/yuzu/main_window.h b/src/yuzu/main_window.h index 65da01e9ca..a956bc547a 100644 --- a/src/yuzu/main_window.h +++ b/src/yuzu/main_window.h @@ -57,6 +57,7 @@ class QSlider; class QHBoxLayout; class WaitTreeWidget; class PerformanceOverlay; +class BackupManager; enum class GameListOpenTarget; enum class DumpRomFSTarget; class GameListPlaceholder; @@ -392,6 +393,8 @@ private slots: void OnAbout(); void OnEdenDependencies(); void OnDataDialog(); + void OnBackupSettings(); + void OnBackupFinished(bool success, const QString& message); void OnToggleFilterBar(); void OnToggleStatusBar(); void OnTogglePerfOverlay(); @@ -528,6 +531,9 @@ private: UserDataMigrator user_data_migrator; std::unique_ptr config; + // Automatic save backup + std::unique_ptr backup_manager; + // Whether emulation is currently running in yuzu. bool emulation_running = false; // The path to the game currently running