mirror of
https://git.eden-emu.dev/eden-emu/eden.git
synced 2026-06-06 01:13:45 -04:00
feat: add automatic save backup with auto/manual folders
This commit is contained in:
parent
4a11d5db2f
commit
28d658763a
6 changed files with 418 additions and 0 deletions
|
|
@ -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
252
src/yuzu/backup_manager.cpp
Normal 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
48
src/yuzu/backup_manager.h
Normal 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};
|
||||
};
|
||||
|
|
@ -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 &Performance Overlay</string>
|
||||
</property>
|
||||
</action>
|
||||
<action name="action_Backup_Settings">
|
||||
<property name="text">
|
||||
<string>Automatic &Backup...</string>
|
||||
</property>
|
||||
</action>
|
||||
</widget>
|
||||
<resources>
|
||||
<include location="yuzu.qrc"/>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue