feat: add automatic save backup with auto/manual folders

This commit is contained in:
Nikilite 2026-04-24 01:10:46 +02:00 committed by crueter
parent 4a11d5db2f
commit 28d658763a
6 changed files with 418 additions and 0 deletions

View file

@ -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

252
src/yuzu/backup_manager.cpp Normal file
View file

@ -0,0 +1,252 @@
// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
// SPDX-License-Identifier: GPL-3.0-or-later
#include "backup_manager.h"
#include <QDateTime>
#include <QRunnable>
#include <QThreadPool>
#include <chrono>
#include <filesystem>
#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: <base>/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<fs::directory_entry> 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));
}

48
src/yuzu/backup_manager.h Normal file
View file

@ -0,0 +1,48 @@
// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <QObject>
#include <QSettings>
#include <QString>
#include <atomic>
#include <filesystem>
#include <string>
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<bool> running_{false};
};

View file

@ -225,6 +225,8 @@
<addaction name="separator"/>
<addaction name="action_Capture_Screenshot"/>
<addaction name="menuTAS"/>
<addaction name="separator"/>
<addaction name="action_Backup_Settings"/>
</widget>
<widget class="QMenu" name="menu_Help">
<property name="title">
@ -620,6 +622,11 @@
<string>Show &amp;Performance Overlay</string>
</property>
</action>
<action name="action_Backup_Settings">
<property name="text">
<string>Automatic &amp;Backup...</string>
</property>
</action>
</widget>
<resources>
<include location="yuzu.qrc"/>

View file

@ -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 <QFileInfo>
#include <QGuiApplication>
#include <QInputDialog>
#include <QLabel>
#include <QLineEdit>
#include <QMimeData>
#include <QPalette>
#include <QProgressDialog>
#include <QScreen>
#include <QShortcut>
#include <QStatusBar>
#include <QVBoxLayout>
#include <QtConcurrentRun>
// Qt Common //
@ -419,6 +423,11 @@ MainWindow::MainWindow(bool has_broken_vulkan)
UpdateWindowTitle();
// initialize backup manager
backup_manager = std::make_unique<BackupManager>(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<QMetaObject::Connection>();
*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();

View file

@ -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<QtConfig> config;
// Automatic save backup
std::unique_ptr<BackupManager> backup_manager;
// Whether emulation is currently running in yuzu.
bool emulation_running = false;
// The path to the game currently running