From 2f05a435fc16cfd52be0ccac37a3920f0c9bacdf Mon Sep 17 00:00:00 2001 From: Gus Date: Tue, 30 Jun 2026 21:45:07 -0500 Subject: [PATCH] CloudSync: pull save from Dropbox on init, push after; add Linux/AppImage rclone support Pushes the local save directory to Dropbox via rclone after SAVEInit, and pulls it down first if no local save exists yet (e.g. fresh install or after wiping local saves). Shows in-game overlay notifications for both. Adds a Linux implementation alongside the existing Windows one, using posix_spawn with a hardened rclone lookup (probes common install paths plus a /proc/1/root fallback to escape the AppImage's mount namespace) and a stripped child environment (drops LD_LIBRARY_PATH/APPDIR/etc.) so the AppImage's bundled libs don't leak into rclone's process. --- src/Cafe/OS/libs/nn_save/nn_save.cpp | 243 ++++++++++++++++++++++++++- 1 file changed, 242 insertions(+), 1 deletion(-) diff --git a/src/Cafe/OS/libs/nn_save/nn_save.cpp b/src/Cafe/OS/libs/nn_save/nn_save.cpp index 2c608482..893ca1c2 100644 --- a/src/Cafe/OS/libs/nn_save/nn_save.cpp +++ b/src/Cafe/OS/libs/nn_save/nn_save.cpp @@ -6,11 +6,24 @@ #include #include +#include #include "config/ActiveSettings.h" #include "Cafe/OS/libs/coreinit/coreinit_Thread.h" #include "Cafe/OS/libs/coreinit/coreinit_FS.h" #include "Cafe/CafeSystem.h" #include "Cafe/Filesystem/fsc.h" +#include "Cafe/HW/Latte/Core/LatteOverlay.h" + +#if BOOST_OS_WINDOWS +#include +#else +#include +#include +#include +#include +#include +extern char** environ; +#endif #define SAVE_STATUS_OK ((FSStatus)FS_RESULT::SUCCESS) #define SAVE_MAX_PATH_SIZE (FSA_CMD_PATH_MAX_LENGTH) @@ -229,6 +242,229 @@ namespace save } + // Host path of the local save directory: /usr/save/// + fs::path CloudSync_GetLocalSaveDir(uint32 high, uint32 low) + { + char subDir[64]; + sprintf(subDir, "usr/save/%08x/%08x", high, low); + return ActiveSettings::GetMlcPath(subDir); + } + + bool CloudSync_LocalSaveDirExists(uint32 high, uint32 low) + { + std::error_code ec; + return fs::is_directory(CloudSync_GetLocalSaveDir(high, low), ec); + } + + // Dropbox:Cemu Cloud Saves/ () + std::string CloudSync_GetRemotePath(uint64 titleId) + { + std::string gameName = CafeSystem::GetForegroundTitleName(); + for (char& c : gameName) + { + if (c == '/' || c == '\\' || c == ':' || c == '*' || c == '?' || c == '"' || c == '<' || c == '>' || c == '|') + c = '_'; + } + if (gameName.empty()) + gameName = "Unknown Game"; + + return fmt::format("Dropbox:Cemu Cloud Saves/{} ({:016x})", gameName, titleId); + } + +#if BOOST_OS_WINDOWS + // Runs rclone and blocks the calling thread until it finishes. Returns the process exit code, or -1 on launch failure. + sint32 CloudSync_RunRcloneBlocking(std::wstring cmdLine) + { + STARTUPINFOW si{}; + si.cb = sizeof(si); + si.dwFlags = STARTF_USESHOWWINDOW; + si.wShowWindow = SW_HIDE; + PROCESS_INFORMATION pi{}; + + if (!CreateProcessW(nullptr, cmdLine.data(), nullptr, nullptr, FALSE, CREATE_NO_WINDOW, nullptr, nullptr, &si, &pi)) + { + cemuLog_log(LogType::Save, "CloudSync: failed to launch rclone, error code {}", (uint32)GetLastError()); + return -1; + } + + WaitForSingleObject(pi.hProcess, INFINITE); + DWORD exitCode = 0; + GetExitCodeProcess(pi.hProcess, &exitCode); + CloseHandle(pi.hThread); + CloseHandle(pi.hProcess); + return (sint32)exitCode; + } +#else + // AppImages mount their payload as a squashfs and typically don't expose the host's real + // $PATH to child processes the way a normally installed binary would, so a plain execvp("rclone", ...) + // often fails to find a system-installed rclone. We instead probe a fixed list of common install + // locations directly. /proc/1/root re-anchors the lookup at PID 1's root filesystem, which lets us + // reach the real host filesystem even if we're running inside a mount namespace/sandbox that + // otherwise hides it (e.g. some AppImage integrations, Steam Deck's read-only rootfs). + std::string CloudSync_FindRclonePath() + { + std::vector candidates = { + "/usr/bin/rclone", + "/usr/local/bin/rclone", + "/var/lib/flatpak/exports/bin/rclone", + }; + if (const char* home = getenv("HOME")) + { + candidates.emplace_back(std::string(home) + "/bin/rclone"); + candidates.emplace_back(std::string(home) + "/.local/bin/rclone"); + } + + const std::vector baseCandidates = candidates; + for (const std::string& path : baseCandidates) + candidates.push_back("/proc/1/root" + path); + + for (const std::string& path : candidates) + { + if (access(path.c_str(), X_OK) == 0) + return path; + } + return {}; + } + + // Builds a copy of the current environment with AppImage-specific variables stripped. + // Cemu's AppImage sets APPDIR/APPIMAGE/OWD and may prepend its bundled lib dir to + // LD_LIBRARY_PATH; if that leaks into rclone's process it can load Cemu-bundled versions of + // shared libs (e.g. libssl/libcrypto) that don't match what rclone was linked/tested against, + // causing obscure TLS/certificate failures. Stripping them lets rclone resolve its own + // dependencies from the host system as if launched outside the AppImage. + std::vector CloudSync_BuildCleanEnv() + { + static const std::vector blockedVars = { + "LD_LIBRARY_PATH", "LD_PRELOAD", "APPDIR", "APPIMAGE", "OWD", "ARGV0" + }; + + std::vector env; + for (char** envp = environ; *envp != nullptr; ++envp) + { + std::string entry(*envp); + const size_t eq = entry.find('='); + const std::string key = (eq == std::string::npos) ? entry : entry.substr(0, eq); + + bool blocked = false; + for (const std::string& blockedVar : blockedVars) + { + if (key == blockedVar) + { + blocked = true; + break; + } + } + if (!blocked) + env.push_back(std::move(entry)); + } + return env; + } + + // Runs rclone via posix_spawn and blocks the calling thread until it finishes. + // Returns the process exit code, or -1 if rclone couldn't be found/launched. + sint32 CloudSync_RunRcloneBlocking(const std::vector& args) + { + const std::string rclonePath = CloudSync_FindRclonePath(); + if (rclonePath.empty()) + { + cemuLog_log(LogType::Save, "CloudSync: could not find an rclone executable"); + return -1; + } + + std::vector argv; + argv.push_back(const_cast(rclonePath.c_str())); + for (const std::string& arg : args) + argv.push_back(const_cast(arg.c_str())); + argv.push_back(nullptr); + + std::vector envStorage = CloudSync_BuildCleanEnv(); + std::vector envp; + for (const std::string& entry : envStorage) + envp.push_back(const_cast(entry.c_str())); + envp.push_back(nullptr); + + pid_t pid; + if (posix_spawn(&pid, rclonePath.c_str(), nullptr, nullptr, argv.data(), envp.data()) != 0) + { + cemuLog_log(LogType::Save, "CloudSync: failed to spawn rclone at {}", rclonePath); + return -1; + } + + int status = 0; + waitpid(pid, &status, 0); + if (WIFEXITED(status)) + return WEXITSTATUS(status); + return -1; + } +#endif + + // Pushes the local save directory for the current title to Dropbox. Fire-and-forget on a background thread. + void CloudSync_PushSaveToDropbox(uint32 high, uint32 low, uint64 titleId) + { +#if BOOST_OS_WINDOWS + std::string localPath = _pathToUtf8(CloudSync_GetLocalSaveDir(high, low)); + std::string remotePath = CloudSync_GetRemotePath(titleId); + + std::wstring cmdLine = L"rclone copy \"" + boost::nowide::widen(localPath) + L"\" \"" + boost::nowide::widen(remotePath) + L"\" -v"; + cmdLine.push_back(L'\0'); // CreateProcessW requires a writable, mutable buffer + + std::thread([cmdLine = std::move(cmdLine)]() mutable { + sint32 exitCode = CloudSync_RunRcloneBlocking(std::move(cmdLine)); + cemuLog_log(LogType::Save, "CloudSync: rclone push finished with exit code {}", exitCode); + if (exitCode == 0) + LatteOverlay_pushNotification("Save pushed to Dropbox", 3000); + else + LatteOverlay_pushNotification("CloudSync: push to Dropbox failed", 5000); + }).detach(); +#else + std::string localPath = _pathToUtf8(CloudSync_GetLocalSaveDir(high, low)); + std::string remotePath = CloudSync_GetRemotePath(titleId); + + std::thread([localPath, remotePath]() { + sint32 exitCode = CloudSync_RunRcloneBlocking({"copy", localPath, remotePath, "-v"}); + cemuLog_log(LogType::Save, "CloudSync: rclone push finished with exit code {}", exitCode); + if (exitCode == 0) + LatteOverlay_pushNotification("Save pushed to Dropbox", 3000); + else + LatteOverlay_pushNotification("CloudSync: push to Dropbox failed", 5000); + }).detach(); +#endif + } + + // Pulls the save directory down from Dropbox. Runs synchronously (blocking) since the game + // is about to start reading save data right after SAVEInit returns. + void CloudSync_PullSaveFromDropbox(uint32 high, uint32 low, uint64 titleId) + { +#if BOOST_OS_WINDOWS + std::string localPath = _pathToUtf8(CloudSync_GetLocalSaveDir(high, low)); + std::string remotePath = CloudSync_GetRemotePath(titleId); + + cemuLog_log(LogType::Save, "CloudSync: no local save dir found, pulling from {}", remotePath); + + std::wstring cmdLine = L"rclone copy \"" + boost::nowide::widen(remotePath) + L"\" \"" + boost::nowide::widen(localPath) + L"\" -v"; + cmdLine.push_back(L'\0'); // CreateProcessW requires a writable, mutable buffer + + sint32 exitCode = CloudSync_RunRcloneBlocking(std::move(cmdLine)); + cemuLog_log(LogType::Save, "CloudSync: rclone pull finished with exit code {}", exitCode); + if (exitCode == 0) + LatteOverlay_pushNotification("Save pulled from Dropbox", 3000); + else + LatteOverlay_pushNotification("CloudSync: pull from Dropbox failed", 5000); +#else + std::string localPath = _pathToUtf8(CloudSync_GetLocalSaveDir(high, low)); + std::string remotePath = CloudSync_GetRemotePath(titleId); + + cemuLog_log(LogType::Save, "CloudSync: no local save dir found, pulling from {}", remotePath); + + sint32 exitCode = CloudSync_RunRcloneBlocking({"copy", remotePath, localPath, "-v"}); + cemuLog_log(LogType::Save, "CloudSync: rclone pull finished with exit code {}", exitCode); + if (exitCode == 0) + LatteOverlay_pushNotification("Save pulled from Dropbox", 3000); + else + LatteOverlay_pushNotification("CloudSync: pull from Dropbox failed", 5000); +#endif + } + SAVEStatus SAVEInit() { const uint64 titleId = CafeSystem::GetForegroundTitleId(); @@ -244,13 +480,16 @@ namespace save uint32 persistentId = act::GetPersistentIdEx(accountId); SetPersistentIdToLocalCache(accountId, persistentId); } - + SAVEMountSaveDir(); g_nn_save->initialized = true; uint32 high = GetTitleIdHigh(titleId) & (~0xC); uint32 low = GetTitleIdLow(titleId); + if (!CloudSync_LocalSaveDirExists(high, low)) + CloudSync_PullSaveFromDropbox(high, low, titleId); + sint32 fscStatus = FSC_STATUS_FILE_NOT_FOUND; char path[256]; sprintf(path, "%susr/save/%08x/", "/vol/storage_mlc01/", high); @@ -261,6 +500,8 @@ namespace save fsc_createDir(path, &fscStatus); iosu::acp::CreateSaveMetaFiles(ActiveSettings::GetPersistentId(), titleId); + + CloudSync_PushSaveToDropbox(high, low, titleId); } return SAVE_STATUS_OK;