diff --git a/Models/RomM/Save/RomMDevice.cs b/Models/RomM/Save/RomMDevice.cs new file mode 100644 index 0000000..ee1a3ee --- /dev/null +++ b/Models/RomM/Save/RomMDevice.cs @@ -0,0 +1,45 @@ +using System; +using Newtonsoft.Json; + +namespace RomM.Models.RomM.Save +{ + /// + /// Payload for POST /api/devices. The server stores an arbitrary client string and a sync mode + /// (see KNOWN_DEVICES / SyncMode in romm). The Playnite plugin registers itself as the "playnite" + /// client using API sync mode. allow_existing lets us re-register idempotently per RomM account. + /// + public class RomMDeviceCreate + { + [JsonProperty("name")] + public string Name { get; set; } + + [JsonProperty("platform")] + public string Platform { get; set; } + + [JsonProperty("client")] + public string Client { get; set; } + + [JsonProperty("client_version")] + public string ClientVersion { get; set; } + + // SyncMode is a StrEnum on the server: "api" | "file_transfer" | "push_pull". + [JsonProperty("sync_mode")] + public string SyncMode { get; set; } = "api"; + + [JsonProperty("allow_existing")] + public bool AllowExisting { get; set; } = true; + } + + /// Response from POST /api/devices. We only persist . + public class RomMDeviceCreateResponse + { + [JsonProperty("device_id")] + public string DeviceId { get; set; } + + [JsonProperty("name")] + public string Name { get; set; } + + [JsonProperty("created_at")] + public DateTime CreatedAt { get; set; } + } +} diff --git a/Models/RomM/Save/RomMSave.cs b/Models/RomM/Save/RomMSave.cs new file mode 100644 index 0000000..8c531e0 --- /dev/null +++ b/Models/RomM/Save/RomMSave.cs @@ -0,0 +1,71 @@ +using System; +using System.Collections.Generic; +using Newtonsoft.Json; + +namespace RomM.Models.RomM.Save +{ + /// + /// Per-device sync state attached to a save. Returned by the server (see PR #3479) so the + /// client can tell which devices already hold a copy and which one is current. + /// + public class RomMDeviceSync + { + [JsonProperty("device_id")] + public string DeviceId { get; set; } + + [JsonProperty("device_name")] + public string DeviceName { get; set; } + + [JsonProperty("last_synced_at")] + public DateTime? LastSyncedAt { get; set; } + + [JsonProperty("is_untracked")] + public bool IsUntracked { get; set; } + + [JsonProperty("is_current")] + public bool IsCurrent { get; set; } + } + + /// + /// Subset of the server's SaveSchema that the plugin needs. The server returns many more + /// fields (file paths, screenshot, tags) which we deliberately ignore. + /// + public class RomMSave + { + [JsonProperty("id")] + public int Id { get; set; } + + [JsonProperty("rom_id")] + public int RomId { get; set; } + + [JsonProperty("file_name")] + public string FileName { get; set; } + + [JsonProperty("file_size_bytes")] + public long FileSizeBytes { get; set; } + + [JsonProperty("download_path")] + public string DownloadPath { get; set; } + + [JsonProperty("emulator")] + public string Emulator { get; set; } + + [JsonProperty("slot")] + public string Slot { get; set; } + + [JsonProperty("content_hash")] + public string ContentHash { get; set; } + + [JsonProperty("origin_device_id")] + public string OriginDeviceId { get; set; } + + [JsonProperty("created_at")] + public DateTime CreatedAt { get; set; } + + [JsonProperty("updated_at")] + public DateTime UpdatedAt { get; set; } + + [JsonProperty("device_syncs")] + public List DeviceSyncs { get; set; } = new List(); + } +} diff --git a/Models/RomM/Save/RomMSyncModels.cs b/Models/RomM/Save/RomMSyncModels.cs new file mode 100644 index 0000000..7f2e501 --- /dev/null +++ b/Models/RomM/Save/RomMSyncModels.cs @@ -0,0 +1,114 @@ +using System; +using System.Collections.Generic; +using Newtonsoft.Json; + +namespace RomM.Models.RomM.Save +{ + /// + /// A single locally-known save reported to POST /sync/negotiate. The server keys saves by + /// (rom_id, slot) and compares (MD5 hex) then + /// against its own copy to decide the sync action. + /// + public class RomMClientSaveState + { + [JsonProperty("rom_id")] + public int RomId { get; set; } + + [JsonProperty("file_name")] + public string FileName { get; set; } + + [JsonProperty("slot")] + public string Slot { get; set; } + + [JsonProperty("emulator")] + public string Emulator { get; set; } + + [JsonProperty("content_hash")] + public string ContentHash { get; set; } + + [JsonProperty("updated_at")] + public DateTime UpdatedAt { get; set; } + + [JsonProperty("file_size_bytes")] + public long FileSizeBytes { get; set; } + } + + public class RomMSyncNegotiatePayload + { + [JsonProperty("device_id")] + public string DeviceId { get; set; } + + [JsonProperty("saves")] + public List Saves { get; set; } = new List(); + } + + /// The action the server wants the client to perform for a given save. + public static class RomMSyncAction + { + public const string Upload = "upload"; + public const string Download = "download"; + public const string Conflict = "conflict"; + public const string NoOp = "no_op"; + } + + public class RomMSyncOperation + { + [JsonProperty("action")] + public string Action { get; set; } + + [JsonProperty("rom_id")] + public int RomId { get; set; } + + [JsonProperty("save_id")] + public int? SaveId { get; set; } + + [JsonProperty("file_name")] + public string FileName { get; set; } + + [JsonProperty("slot")] + public string Slot { get; set; } + + [JsonProperty("emulator")] + public string Emulator { get; set; } + + [JsonProperty("reason")] + public string Reason { get; set; } + + [JsonProperty("server_updated_at")] + public DateTime? ServerUpdatedAt { get; set; } + + [JsonProperty("server_content_hash")] + public string ServerContentHash { get; set; } + } + + public class RomMSyncNegotiateResponse + { + [JsonProperty("session_id")] + public int SessionId { get; set; } + + [JsonProperty("operations")] + public List Operations { get; set; } = new List(); + + [JsonProperty("total_upload")] + public int TotalUpload { get; set; } + + [JsonProperty("total_download")] + public int TotalDownload { get; set; } + + [JsonProperty("total_conflict")] + public int TotalConflict { get; set; } + + [JsonProperty("total_no_op")] + public int TotalNoOp { get; set; } + } + + /// Payload for POST /sync/sessions/{id}/complete. play_sessions is omitted (null). + public class RomMSyncCompletePayload + { + [JsonProperty("operations_completed")] + public int OperationsCompleted { get; set; } + + [JsonProperty("operations_failed")] + public int OperationsFailed { get; set; } + } +} diff --git a/RomM.Tests/RetroArchConfigTests.cs b/RomM.Tests/RetroArchConfigTests.cs new file mode 100644 index 0000000..e4fa8fb --- /dev/null +++ b/RomM.Tests/RetroArchConfigTests.cs @@ -0,0 +1,133 @@ +using System.Collections.Generic; +using System.IO; +using RomM.Saves; +using Xunit; + +namespace RomM.Tests +{ + public class RetroArchConfigTests + { + [Fact] + public void Parse_strips_quotes_comments_and_whitespace() + { + var cfg = RetroArchConfig.Parse(string.Join("\n", new[] + { + "# a comment", + "savefile_directory = \"C:\\saves\"", + "sort_savefiles_enable = \"true\"", + " sort_savefiles_by_content_enable = false ", + "", + "garbage line without separator", + })); + + Assert.Equal("C:\\saves", cfg["savefile_directory"]); + Assert.Equal("true", cfg["sort_savefiles_enable"]); + Assert.Equal("false", cfg["sort_savefiles_by_content_enable"]); + Assert.False(cfg.ContainsKey("garbage line without separator")); + } + + [Fact] + public void ResolveSaveFilePath_falls_back_to_content_directory_when_unset() + { + var content = Path.Combine("roms", "gba", "Game.gba"); + var cfg = new Dictionary(); + + var path = RetroArchConfig.ResolveSaveFilePath(cfg, content); + + Assert.Equal(Path.Combine("roms", "gba", "Game.srm"), path); + } + + [Theory] + [InlineData("")] + [InlineData("default")] + public void ResolveSaveFilePath_empty_or_default_uses_content_directory(string value) + { + var content = Path.Combine("roms", "Game.gba"); + var cfg = new Dictionary { ["savefile_directory"] = value }; + + var path = RetroArchConfig.ResolveSaveFilePath(cfg, content); + + Assert.Equal(Path.Combine("roms", "Game.srm"), path); + } + + [Fact] + public void ResolveSaveFilePath_uses_configured_directory() + { + var content = Path.Combine("roms", "Game.gba"); + var saveDir = Path.Combine("data", "saves"); + var cfg = new Dictionary { ["savefile_directory"] = saveDir }; + + var path = RetroArchConfig.ResolveSaveFilePath(cfg, content); + + Assert.Equal(Path.Combine(saveDir, "Game.srm"), path); + } + + [Fact] + public void ResolveSaveFilePath_sorts_by_content_when_enabled() + { + var content = Path.Combine("roms", "Game.gba"); + var saveDir = Path.Combine("data", "saves"); + var cfg = new Dictionary + { + ["savefile_directory"] = saveDir, + ["sort_savefiles_by_content_enable"] = "true", + }; + + var path = RetroArchConfig.ResolveSaveFilePath(cfg, content); + + Assert.Equal(Path.Combine(saveDir, "Game", "Game.srm"), path); + } + + [Fact] + public void ResolveSaveFilePath_sorts_by_core_only_when_core_known() + { + var content = Path.Combine("roms", "Game.gba"); + var saveDir = Path.Combine("data", "saves"); + var cfg = new Dictionary + { + ["savefile_directory"] = saveDir, + ["sort_savefiles_enable"] = "true", + }; + + // Unknown core -> no per-core subfolder (runtime search handles it instead). + Assert.Equal(Path.Combine(saveDir, "Game.srm"), + RetroArchConfig.ResolveSaveFilePath(cfg, content, coreName: null)); + + // Known core -> per-core subfolder. + Assert.Equal(Path.Combine(saveDir, "mgba_libretro", "Game.srm"), + RetroArchConfig.ResolveSaveFilePath(cfg, content, coreName: "mgba_libretro")); + } + + [Fact] + public void ResolveSaveFilePath_expands_leading_colon_base_token() + { + var content = Path.Combine("roms", "Game.gba"); + var baseDir = Path.Combine("opt", "retroarch"); + var cfg = new Dictionary { ["savefile_directory"] = ":\\saves" }; + + var path = RetroArchConfig.ResolveSaveFilePath(cfg, content, retroArchBaseDir: baseDir); + + Assert.Equal(Path.Combine(baseDir, "saves", "Game.srm"), path); + } + + [Fact] + public void ResolveSaveFilePath_returns_null_without_content() + { + Assert.Null(RetroArchConfig.ResolveSaveFilePath(new Dictionary(), null)); + } + + [Fact] + public void ResolveSaveBaseDirectory_ignores_sorting_subfolders() + { + var content = Path.Combine("roms", "Game.gba"); + var saveDir = Path.Combine("data", "saves"); + var cfg = new Dictionary + { + ["savefile_directory"] = saveDir, + ["sort_savefiles_by_content_enable"] = "true", + }; + + Assert.Equal(saveDir, RetroArchConfig.ResolveSaveBaseDirectory(cfg, content)); + } + } +} diff --git a/RomM.Tests/RomM.Tests.csproj b/RomM.Tests/RomM.Tests.csproj index d348673..1dfab77 100644 --- a/RomM.Tests/RomM.Tests.csproj +++ b/RomM.Tests/RomM.Tests.csproj @@ -51,6 +51,8 @@ + + diff --git a/RomM.Tests/SaveFileHashTests.cs b/RomM.Tests/SaveFileHashTests.cs new file mode 100644 index 0000000..1081b8b --- /dev/null +++ b/RomM.Tests/SaveFileHashTests.cs @@ -0,0 +1,45 @@ +using System.IO; +using System.Text; +using RomM.Saves; +using Xunit; + +namespace RomM.Tests +{ + public class SaveFileHashTests + { + // The server hashes saves with MD5 (hex). These are the canonical MD5 digests; if the client + // produced anything else, negotiate could never detect identical files. + [Fact] + public void Md5Hex_of_empty_stream_matches_known_digest() + { + using (var stream = new MemoryStream()) + { + Assert.Equal("d41d8cd98f00b204e9800998ecf8427e", SaveFileHash.Md5Hex(stream)); + } + } + + [Fact] + public void Md5Hex_of_abc_matches_known_digest() + { + using (var stream = new MemoryStream(Encoding.ASCII.GetBytes("abc"))) + { + Assert.Equal("900150983cd24fb0d6963f7d28e17f72", SaveFileHash.Md5Hex(stream)); + } + } + + [Fact] + public void Md5HexFile_reads_and_hashes_file() + { + var path = Path.GetTempFileName(); + try + { + File.WriteAllText(path, "abc"); + Assert.Equal("900150983cd24fb0d6963f7d28e17f72", SaveFileHash.Md5HexFile(path)); + } + finally + { + File.Delete(path); + } + } + } +} diff --git a/RomM.cs b/RomM.cs index 81780fb..74b4d17 100644 --- a/RomM.cs +++ b/RomM.cs @@ -5,6 +5,7 @@ using Playnite.SDK.Plugins; using RomM.Games; using RomM.Downloads; +using RomM.Saves; using RomM.VersionSelector; using RomM.Models.RomM.Collection; using RomM.Models.RomM.Rom; @@ -75,6 +76,8 @@ public class RomM : LibraryPlugin, IRomM internal RomMDownloadsSidebarItem DownloadsSidebar { get; private set; } private readonly DownloadQueueViewModel downloadsVm; + internal SaveSyncService SaveSync { get; private set; } + // Game ids whose next ItemUpdated was caused by the importer itself, so OnItemUpdated must // not echo the change back to the RomM server. private readonly ConcurrentDictionary ignoredGameIds = new ConcurrentDictionary(); @@ -93,6 +96,9 @@ public RomM(IPlayniteAPI api) : base(api) }; ROMDataPath = $"{Playnite.Paths.ExtensionsDataPath}\\{Id}\\Games\\"; + // Save sync (RetroArch <-> RomM). Reads Settings lazily, so constructing here is safe. + SaveSync = new SaveSyncService(this); + // Initialise the download queue downloadsVm = new DownloadQueueViewModel(); @@ -340,6 +346,15 @@ public override IEnumerable GetGameMenuItems(GetGameMenuItemsArgs var game = args.Games.First(); if (game.PluginId == PluginId && RomMGameId.TryParse(game.GameId, out int _, out var sha1)) { + if (Settings.EnableSaveSync) + { + gameMenuItems.Add(new GameMenuItem + { + Description = "Sync saves with RomM", + Action = (_) => SyncSavesWithNotification(args.Games.Where(g => g.PluginId == PluginId).ToList()) + }); + } + string romDataFile = $"{ROMDataPath}{sha1}.json"; if (Settings.MergeRevisions && File.Exists(romDataFile) && game.IsInstalled) { @@ -501,6 +516,32 @@ public override void OnGameInstalled(OnGameInstalledEventArgs args) } } + // Pull the newest save down before the emulator launches so the player continues from the + // latest device. Playnite blocks the launch until this returns, so any failure is swallowed + // (logged inside Sync) rather than preventing the game from starting. + public override void OnGameStarting(OnGameStartingEventArgs args) + { + base.OnGameStarting(args); + + if (Settings.EnableSaveSync && args.Game.PluginId == PluginId) + { + SaveSync.Sync(args.Game); + } + } + + // Push the save the player just produced back to RomM. Runs in the background so it never + // delays returning to the library. + public override void OnGameStopped(OnGameStoppedEventArgs args) + { + base.OnGameStopped(args); + + if (Settings.EnableSaveSync && args.Game.PluginId == PluginId) + { + var game = args.Game; + Task.Run(() => SaveSync.Sync(game)); + } + } + public override LibraryMetadataProvider GetMetadataDownloader() { return new RomMMetadataProvider(this); @@ -663,5 +704,56 @@ private void OnItemUpdated(object sender, ItemUpdatedEventArgs e) }); } #endregion + + #region RomM Save Syncing + // Manual "Sync saves with RomM" menu action: sync each selected game on a background thread + // and surface a single summary notification so the user gets feedback. + private void SyncSavesWithNotification(IList games) + { + if (games == null || games.Count == 0) + { + return; + } + + Task.Run(() => + { + int uploaded = 0, downloaded = 0, conflicts = 0, failed = 0, applicable = 0; + string lastMessage = null; + + foreach (var game in games) + { + var outcome = SaveSync.Sync(game); + if (outcome.Applicable) + { + applicable++; + uploaded += outcome.Uploaded; + downloaded += outcome.Downloaded; + conflicts += outcome.Conflicts; + failed += outcome.Failed; + } + + if (!string.IsNullOrEmpty(outcome.Message)) + { + lastMessage = outcome.Message; + } + } + + if (applicable == 0) + { + Playnite.Notifications.Add("RomMPlugin.SaveSync", + lastMessage ?? "Save sync currently only supports RetroArch games.", + NotificationType.Info); + return; + } + + var summary = $"Save sync complete: {downloaded} downloaded, {uploaded} uploaded" + + (conflicts > 0 ? $", {conflicts} conflict(s) resolved" : "") + + (failed > 0 ? $", {failed} failed" : "") + "."; + + Playnite.Notifications.Add("RomMPlugin.SaveSync", summary, + failed > 0 ? NotificationType.Error : NotificationType.Info); + }); + } + #endregion } } \ No newline at end of file diff --git a/Saves/RetroArchConfig.cs b/Saves/RetroArchConfig.cs new file mode 100644 index 0000000..4d24078 --- /dev/null +++ b/Saves/RetroArchConfig.cs @@ -0,0 +1,139 @@ +using System; +using System.Collections.Generic; +using System.IO; + +namespace RomM.Saves +{ + /// + /// Parses a retroarch.cfg and resolves where RetroArch writes a game's battery save (.srm). + /// Pure, Playnite-free logic so it can be unit-tested; the filesystem/emulator lookups live in + /// . + /// + /// RetroArch save path rules (battery / SRAM): + /// base = savefile_directory (empty / "default" -> the content's own directory) + /// + per-core subfolder when sort_savefiles_enable = true + /// + per-content subfolder when sort_savefiles_by_content_enable = true + /// file = <content base name>.srm + /// + public static class RetroArchConfig + { + /// RetroArch battery-save extension. RetroArch always writes SRAM here regardless of core. + public const string SaveExtension = ".srm"; + + /// + /// Parses retroarch.cfg text into a key/value map. Lines are "key = value"; values may be + /// wrapped in double quotes and lines starting with '#' are comments. + /// + public static Dictionary Parse(string cfgText) + { + var result = new Dictionary(StringComparer.Ordinal); + if (string.IsNullOrEmpty(cfgText)) + return result; + + foreach (var rawLine in cfgText.Split('\n')) + { + var line = rawLine.Trim(); + if (line.Length == 0 || line[0] == '#') + continue; + + int eq = line.IndexOf('='); + if (eq <= 0) + continue; + + var key = line.Substring(0, eq).Trim(); + var value = line.Substring(eq + 1).Trim(); + + // Strip a single pair of surrounding double quotes. + if (value.Length >= 2 && value[0] == '"' && value[value.Length - 1] == '"') + value = value.Substring(1, value.Length - 2); + + if (key.Length > 0) + result[key] = value; + } + + return result; + } + + /// + /// Resolves the expected .srm path for given the parsed + /// config. may be null when unknown (the per-core subfolder is + /// then skipped; falls back to a recursive search at runtime). + /// expands RetroArch's leading ':' base-directory token. + /// Returns null when no content path is available. + /// + public static string ResolveSaveFilePath( + IDictionary cfg, + string contentFilePath, + string coreName = null, + string retroArchBaseDir = null) + { + if (string.IsNullOrEmpty(contentFilePath)) + return null; + + var contentDir = Path.GetDirectoryName(contentFilePath); + var contentName = Path.GetFileNameWithoutExtension(contentFilePath); + + var saveDir = ExpandPath(GetValue(cfg, "savefile_directory"), retroArchBaseDir); + if (string.IsNullOrEmpty(saveDir)) + saveDir = contentDir; + + if (GetBool(cfg, "sort_savefiles_enable") && !string.IsNullOrEmpty(coreName)) + saveDir = Path.Combine(saveDir, coreName); + + if (GetBool(cfg, "sort_savefiles_by_content_enable")) + saveDir = Path.Combine(saveDir, contentName); + + return Path.Combine(saveDir, contentName + SaveExtension); + } + + /// + /// Resolves the configured save base directory (without any per-core / per-content sorting), + /// used as the root for a recursive ".srm" search when the exact path doesn't exist. Falls + /// back to the content directory when savefile_directory is unset. + /// + public static string ResolveSaveBaseDirectory( + IDictionary cfg, + string contentFilePath, + string retroArchBaseDir = null) + { + var saveDir = ExpandPath(GetValue(cfg, "savefile_directory"), retroArchBaseDir); + if (string.IsNullOrEmpty(saveDir) && !string.IsNullOrEmpty(contentFilePath)) + saveDir = Path.GetDirectoryName(contentFilePath); + + return saveDir; + } + + private static string GetValue(IDictionary cfg, string key) + { + return cfg != null && cfg.TryGetValue(key, out var v) ? v : null; + } + + private static bool GetBool(IDictionary cfg, string key) + { + var v = GetValue(cfg, key); + return string.Equals(v, "true", StringComparison.OrdinalIgnoreCase); + } + + /// + /// Normalises a RetroArch directory value: empty / "default" -> null (use content dir), + /// a leading ':' is RetroArch's base-directory token, and environment variables are expanded. + /// + private static string ExpandPath(string raw, string retroArchBaseDir) + { + if (string.IsNullOrWhiteSpace(raw) || string.Equals(raw, "default", StringComparison.OrdinalIgnoreCase)) + return null; + + raw = Environment.ExpandEnvironmentVariables(raw); + + if (raw.Length > 0 && raw[0] == ':') + { + var rest = raw.Substring(1).TrimStart('\\', '/'); + if (!string.IsNullOrEmpty(retroArchBaseDir)) + return Path.Combine(retroArchBaseDir, rest); + return rest; + } + + return raw; + } + } +} diff --git a/Saves/SaveFileHash.cs b/Saves/SaveFileHash.cs new file mode 100644 index 0000000..45880fe --- /dev/null +++ b/Saves/SaveFileHash.cs @@ -0,0 +1,32 @@ +using System.IO; +using System.Security.Cryptography; +using System.Text; + +namespace RomM.Saves +{ + /// + /// Computes the content hash RomM uses to compare saves. The server hashes save files with + /// MD5 (hex digest of the raw bytes), so the client must match that exactly or negotiate will + /// never report "no_op" / will misclassify identical files. See romm assets_handler. + /// + public static class SaveFileHash + { + public static string Md5Hex(Stream stream) + { + using (var md5 = MD5.Create()) + { + var hash = md5.ComputeHash(stream); + var sb = new StringBuilder(hash.Length * 2); + foreach (var b in hash) + sb.Append(b.ToString("x2")); + return sb.ToString(); + } + } + + public static string Md5HexFile(string path) + { + using (var fs = File.OpenRead(path)) + return Md5Hex(fs); + } + } +} diff --git a/Saves/SaveSyncService.cs b/Saves/SaveSyncService.cs new file mode 100644 index 0000000..9d6faad --- /dev/null +++ b/Saves/SaveSyncService.cs @@ -0,0 +1,509 @@ +using Newtonsoft.Json; +using Playnite.SDK; +using Playnite.SDK.Models; +using RomM.Games; +using RomM.Models.RomM.Save; +using RomM.Settings; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Reflection; +using System.Text; + +namespace RomM.Saves +{ + /// + /// Synchronises a game's local RetroArch battery save (.srm) with the RomM server using the + /// API sync mode (see romm PRs #3137 / #3479): register a device once, POST /sync/negotiate to + /// let the server decide upload / download / conflict / no_op per save, execute the returned + /// operations, then POST the session complete. Conflicts are resolved most-recent-wins. + /// + /// Currently scoped to RetroArch, whose SRAM location is derived from retroarch.cfg. + /// + internal class SaveSyncService + { + private const string Emulator = "retroarch"; + private const string DeviceClient = "playnite"; + + private readonly IRomM _romM; + private readonly object _deviceLock = new object(); + + public SaveSyncService(IRomM romM) + { + _romM = romM; + } + + private ILogger Logger => _romM.Logger; + private SettingsViewModel Settings => _romM.Settings; + + public class SyncOutcome + { + public bool Applicable { get; set; } + public int Uploaded { get; set; } + public int Downloaded { get; set; } + public int Conflicts { get; set; } + public int Failed { get; set; } + public string Message { get; set; } + } + + /// + /// Runs a full negotiate + apply cycle for a single game. Safe to call off the UI thread. + /// Never throws; failures are logged and reflected in the returned . + /// + public SyncOutcome Sync(Game game) + { + var outcome = new SyncOutcome(); + try + { + if (game == null || game.PluginId != _romM.Id) + { + return outcome; + } + + if (!RomMGameId.TryParse(game.GameId, out int romId, out string _)) + { + Logger.Warn($"[SaveSync] {game?.Name} has a malformed GameId, skipping."); + return outcome; + } + + var target = ResolveRetroArchTarget(game); + if (target == null) + { + outcome.Message = "Save sync currently only supports RetroArch games."; + return outcome; + } + + outcome.Applicable = true; + + var deviceId = EnsureDeviceRegistered(); + if (string.IsNullOrEmpty(deviceId)) + { + outcome.Message = "Could not register this device with RomM (check token scopes)."; + outcome.Failed++; + return outcome; + } + + var negotiation = Negotiate(deviceId, romId, target); + if (negotiation == null) + { + outcome.Message = "Save sync negotiation with RomM failed."; + outcome.Failed++; + return outcome; + } + + // Negotiate may surface operations for saves we didn't report (e.g. created on another + // device). We only resolved a local path for THIS game, so apply only its operations; + // other ROMs are handled when their own games sync. + foreach (var op in negotiation.Operations.Where(o => o.RomId == romId)) + { + ApplyOperation(op, deviceId, negotiation.SessionId, target, outcome); + } + + CompleteSession(negotiation.SessionId, + outcome.Uploaded + outcome.Downloaded, + outcome.Failed); + + Logger.Info($"[SaveSync] {game.Name}: {outcome.Uploaded} uploaded, {outcome.Downloaded} downloaded, " + + $"{outcome.Conflicts} conflicts, {outcome.Failed} failed."); + } + catch (Exception ex) + { + Logger.Error(ex, $"[SaveSync] Unexpected failure syncing {game?.Name}."); + outcome.Failed++; + outcome.Message = ex.Message; + } + + return outcome; + } + + #region Negotiate / session + + private RomMSyncNegotiateResponse Negotiate(string deviceId, int romId, RetroArchTarget target) + { + var payload = new RomMSyncNegotiatePayload { DeviceId = deviceId }; + + if (File.Exists(target.LocalSavePath)) + { + var info = new FileInfo(target.LocalSavePath); + payload.Saves.Add(new RomMClientSaveState + { + RomId = romId, + FileName = Path.GetFileName(target.LocalSavePath), + Slot = null, + Emulator = Emulator, + ContentHash = SaveFileHash.Md5HexFile(target.LocalSavePath), + UpdatedAt = info.LastWriteTimeUtc, + FileSizeBytes = info.Length, + }); + } + + var url = RomMUrl.Combine(Settings.RomMHost, "api/sync/negotiate"); + var body = PostJson(url, payload); + return body == null ? null : JsonConvert.DeserializeObject(body); + } + + private void CompleteSession(int sessionId, int completed, int failed) + { + try + { + var url = RomMUrl.Combine(Settings.RomMHost, $"api/sync/sessions/{sessionId}/complete"); + PostJson(url, new RomMSyncCompletePayload + { + OperationsCompleted = completed, + OperationsFailed = failed, + }); + } + catch (Exception ex) + { + Logger.Error(ex, $"[SaveSync] Failed to complete sync session {sessionId}."); + } + } + + #endregion + + #region Operation handling + + private void ApplyOperation(RomMSyncOperation op, string deviceId, int sessionId, RetroArchTarget target, SyncOutcome outcome) + { + try + { + switch (op.Action) + { + case RomMSyncAction.Upload: + if (Upload(op, deviceId, sessionId, target)) + outcome.Uploaded++; + else + outcome.Failed++; + break; + + case RomMSyncAction.Download: + if (Download(op, deviceId, sessionId, target)) + outcome.Downloaded++; + else + outcome.Failed++; + break; + + case RomMSyncAction.Conflict: + outcome.Conflicts++; + ResolveConflict(op, deviceId, sessionId, target, outcome); + break; + + case RomMSyncAction.NoOp: + default: + break; + } + } + catch (Exception ex) + { + Logger.Error(ex, $"[SaveSync] Operation '{op.Action}' failed for save {op.SaveId} (rom {op.RomId})."); + outcome.Failed++; + } + } + + /// Most-recent-wins: whichever side was modified later overwrites the other. + private void ResolveConflict(RomMSyncOperation op, string deviceId, int sessionId, RetroArchTarget target, SyncOutcome outcome) + { + var localTime = File.Exists(target.LocalSavePath) + ? (DateTime?)new FileInfo(target.LocalSavePath).LastWriteTimeUtc + : null; + var serverTime = op.ServerUpdatedAt?.ToUniversalTime(); + + bool serverWins = serverTime.HasValue && (!localTime.HasValue || serverTime.Value > localTime.Value); + + Logger.Warn($"[SaveSync] Conflict for rom {op.RomId} ({op.Reason}); " + + $"resolving most-recent-wins -> {(serverWins ? "download" : "upload")}."); + + if (serverWins) + { + if (Download(op, deviceId, sessionId, target)) outcome.Downloaded++; else outcome.Failed++; + } + else + { + if (Upload(op, deviceId, sessionId, target)) outcome.Uploaded++; else outcome.Failed++; + } + } + + private bool Upload(RomMSyncOperation op, string deviceId, int sessionId, RetroArchTarget target) + { + if (!File.Exists(target.LocalSavePath)) + { + Logger.Warn($"[SaveSync] Asked to upload rom {op.RomId} but no local save at {target.LocalSavePath}."); + return false; + } + + using (var content = BuildSaveContent(target.LocalSavePath)) + { + HttpResponseMessage response; + if (op.SaveId.HasValue) + { + var url = RomMUrl.Combine(Settings.RomMHost, + $"api/saves/{op.SaveId.Value}?device_id={WebUtility.UrlEncode(deviceId)}"); + response = HttpClientSingleton.Instance.PutAsync(url, content).GetAwaiter().GetResult(); + } + else + { + var url = RomMUrl.Combine(Settings.RomMHost, + $"api/saves?rom_id={op.RomId}&emulator={Emulator}" + + $"&device_id={WebUtility.UrlEncode(deviceId)}&session_id={sessionId}"); + response = HttpClientSingleton.Instance.PostAsync(url, content).GetAwaiter().GetResult(); + } + + using (response) + { + response.EnsureSuccessStatusCode(); + } + } + + return true; + } + + private bool Download(RomMSyncOperation op, string deviceId, int sessionId, RetroArchTarget target) + { + if (!op.SaveId.HasValue) + { + Logger.Warn($"[SaveSync] Download requested for rom {op.RomId} without a save id."); + return false; + } + + var url = RomMUrl.Combine(Settings.RomMHost, + $"api/saves/{op.SaveId.Value}/content?device_id={WebUtility.UrlEncode(deviceId)}&session_id={sessionId}"); + + byte[] bytes; + using (var response = HttpClientSingleton.Instance.GetAsync(url).GetAwaiter().GetResult()) + { + response.EnsureSuccessStatusCode(); + bytes = response.Content.ReadAsByteArrayAsync().GetAwaiter().GetResult(); + } + + // Overwrite the file RetroArch already uses when we found one, otherwise write to the + // path RetroArch would create for this content. + var destination = target.ExistingSavePath ?? target.LocalSavePath; + var directory = Path.GetDirectoryName(destination); + if (!string.IsNullOrEmpty(directory)) + Directory.CreateDirectory(directory); + + File.WriteAllBytes(destination, bytes); + + // Align the local timestamp with the server so the next negotiate sees them as in-sync. + if (op.ServerUpdatedAt.HasValue) + { + try { File.SetLastWriteTimeUtc(destination, op.ServerUpdatedAt.Value.ToUniversalTime()); } + catch (Exception ex) { Logger.Warn($"[SaveSync] Could not set save timestamp: {ex.Message}"); } + } + + ConfirmDownloaded(op.SaveId.Value, deviceId); + return true; + } + + private void ConfirmDownloaded(int saveId, string deviceId) + { + try + { + var url = RomMUrl.Combine(Settings.RomMHost, $"api/saves/{saveId}/downloaded"); + PostJson(url, new { device_id = deviceId }); + } + catch (Exception ex) + { + Logger.Warn($"[SaveSync] Could not confirm download of save {saveId}: {ex.Message}"); + } + } + + private static HttpContent BuildSaveContent(string filePath) + { + var fileContent = new ByteArrayContent(File.ReadAllBytes(filePath)); + fileContent.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream"); + + var form = new MultipartFormDataContent(); + form.Add(fileContent, "saveFile", Path.GetFileName(filePath)); + return form; + } + + #endregion + + #region Device registration + + /// Registers this machine as a RomM device once, persisting the returned id in settings. + private string EnsureDeviceRegistered() + { + if (!string.IsNullOrEmpty(Settings.SaveSyncDeviceId)) + return Settings.SaveSyncDeviceId; + + lock (_deviceLock) + { + if (!string.IsNullOrEmpty(Settings.SaveSyncDeviceId)) + return Settings.SaveSyncDeviceId; + + try + { + var payload = new RomMDeviceCreate + { + Name = Environment.MachineName, + Platform = "Windows", + Client = DeviceClient, + ClientVersion = Assembly.GetExecutingAssembly().GetName().Version.ToString(), + SyncMode = "api", + AllowExisting = true, + }; + + var url = RomMUrl.Combine(Settings.RomMHost, "api/devices"); + var body = PostJson(url, payload); + if (body == null) + return null; + + var created = JsonConvert.DeserializeObject(body); + if (created == null || string.IsNullOrEmpty(created.DeviceId)) + return null; + + Settings.SaveSyncDeviceId = created.DeviceId; + Settings.Persist(); + Logger.Info($"[SaveSync] Registered device '{created.DeviceId}' with RomM."); + return created.DeviceId; + } + catch (Exception ex) + { + Logger.Error(ex, "[SaveSync] Device registration failed."); + return null; + } + } + } + + #endregion + + #region RetroArch resolution + + /// Where a game's RetroArch save lives locally, plus any already-existing file. + private class RetroArchTarget + { + public string LocalSavePath { get; set; } + public string ExistingSavePath { get; set; } + } + + private RetroArchTarget ResolveRetroArchTarget(Game game) + { + try + { + var contentPath = game.Roms?.FirstOrDefault()?.Path; + if (string.IsNullOrEmpty(contentPath)) + return null; + + contentPath = _romM.Playnite.ExpandGameVariables(game, contentPath); + + var emulator = ResolveEmulator(game); + if (emulator == null || !IsRetroArch(emulator)) + return null; + + var cfgPath = FindRetroArchConfig(emulator); + var cfg = cfgPath != null + ? RetroArchConfig.Parse(File.ReadAllText(cfgPath)) + : new Dictionary(); + + var baseDir = emulator.InstallDir; + var localSavePath = RetroArchConfig.ResolveSaveFilePath(cfg, contentPath, null, baseDir); + if (string.IsNullOrEmpty(localSavePath)) + return null; + + var existing = File.Exists(localSavePath) + ? localSavePath + : FindExistingSave(RetroArchConfig.ResolveSaveBaseDirectory(cfg, contentPath, baseDir), + Path.GetFileNameWithoutExtension(contentPath)); + + return new RetroArchTarget + { + LocalSavePath = existing ?? localSavePath, + ExistingSavePath = existing, + }; + } + catch (Exception ex) + { + Logger.Error(ex, $"[SaveSync] Failed to resolve RetroArch save path for {game?.Name}."); + return null; + } + } + + private Emulator ResolveEmulator(Game game) + { + var action = game.GameActions?.FirstOrDefault(a => a.IsPlayAction && a.Type == GameActionType.Emulator) + ?? game.GameActions?.FirstOrDefault(a => a.Type == GameActionType.Emulator); + + if (action != null && action.EmulatorId != Guid.Empty) + return _romM.Playnite.Database.Emulators?.FirstOrDefault(e => e.Id == action.EmulatorId); + + return null; + } + + private static bool IsRetroArch(Emulator emulator) + { + if (string.Equals(emulator.BuiltInConfigId, "retroarch", StringComparison.OrdinalIgnoreCase)) + return true; + + if (!string.IsNullOrEmpty(emulator.Name) && + emulator.Name.IndexOf("retroarch", StringComparison.OrdinalIgnoreCase) >= 0) + return true; + + if (!string.IsNullOrEmpty(emulator.InstallDir) && + File.Exists(Path.Combine(emulator.InstallDir, "retroarch.exe"))) + return true; + + return false; + } + + private static string FindRetroArchConfig(Emulator emulator) + { + if (!string.IsNullOrEmpty(emulator.InstallDir)) + { + var inInstall = Path.Combine(emulator.InstallDir, "retroarch.cfg"); + if (File.Exists(inInstall)) + return inInstall; + } + + var appData = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "RetroArch", "retroarch.cfg"); + return File.Exists(appData) ? appData : null; + } + + private static string FindExistingSave(string baseDir, string contentName) + { + if (string.IsNullOrEmpty(baseDir) || !Directory.Exists(baseDir) || string.IsNullOrEmpty(contentName)) + return null; + + try + { + return Directory + .EnumerateFiles(baseDir, contentName + RetroArchConfig.SaveExtension, SearchOption.AllDirectories) + .FirstOrDefault(); + } + catch + { + return null; + } + } + + #endregion + + #region HTTP helper + + private string PostJson(string url, object payload) + { + var json = JsonConvert.SerializeObject(payload); + using (var content = new StringContent(json, Encoding.UTF8, "application/json")) + using (var response = HttpClientSingleton.Instance.PostAsync(url, content).GetAwaiter().GetResult()) + { + if (!response.IsSuccessStatusCode) + { + var error = response.Content.ReadAsStringAsync().GetAwaiter().GetResult(); + Logger.Error($"[SaveSync] POST {url} -> {(int)response.StatusCode}: {error}"); + return null; + } + + return response.Content.ReadAsStringAsync().GetAwaiter().GetResult(); + } + } + + #endregion + } +} diff --git a/Settings/Settings.cs b/Settings/Settings.cs index 33cfc05..8f61063 100644 --- a/Settings/Settings.cs +++ b/Settings/Settings.cs @@ -242,6 +242,11 @@ public string RomMProfileType public bool ScanGamesInFullScreen { get; set; } = false; public bool NotifyOnInstallComplete { get; set; } = false; public bool KeepRomMSynced { get; set; } = false; + + // Save sync (RetroArch saves <-> RomM server). DeviceId is assigned by the server on first + // registration and persisted so this machine keeps the same RomM device across sessions. + public bool EnableSaveSync { get; set; } = false; + public string SaveSyncDeviceId { get; set; } = ""; public bool Use7z { get; set; } = false; public string PathTo7z { @@ -325,7 +330,9 @@ internal SettingsViewModel(Plugin plugin, IRomM romM) PathTo7z = savedSettings.PathTo7z; MergeRevisions = savedSettings.MergeRevisions; KeepDeletedGames = savedSettings.KeepDeletedGames; - ExcludeGenres = savedSettings.ExcludeGenres; + ExcludeGenres = savedSettings.ExcludeGenres; + EnableSaveSync = savedSettings.EnableSaveSync; + SaveSyncDeviceId = savedSettings.SaveSyncDeviceId; } if (Mappings == null) @@ -525,6 +532,10 @@ public void EndEdit() } + // Persists the current settings to disk. Used by background features (e.g. save sync device + // registration) that need to store a value without going through the settings dialog edit cycle. + internal void Persist() => SavePluginSettings(this); + private void SavePluginSettings(SettingsViewModel settings) { var setDir = _plugin.GetPluginUserDataPath(); diff --git a/Settings/SettingsView.xaml b/Settings/SettingsView.xaml index c9e2968..678cc8c 100644 --- a/Settings/SettingsView.xaml +++ b/Settings/SettingsView.xaml @@ -307,6 +307,14 @@ + + +