diff --git a/Downloads/DownloadQueueController.cs b/Downloads/DownloadQueueController.cs index 1465d4e..c26ffb2 100644 --- a/Downloads/DownloadQueueController.cs +++ b/Downloads/DownloadQueueController.cs @@ -178,7 +178,7 @@ private async Task DownloadAndInstall(DownloadQueueItem item, DownloadRequest re // Extract if needed (we treat extract as 0..100 in its own bar) // Don't extract archives when install flat is enabled - if (req.HasMultipleFiles || (req.AutoExtract && IsFileCompressed(req.GamePath) && !req.InstallFlat)) + if (req.DownloadAsArchive || (req.AutoExtract && IsFileCompressed(req.GamePath) && !req.InstallFlat)) { item.SetStatus(DownloadStatus.Extracting, "Extracting..."); Logger.Info($"Extracting {req.GamePath}..."); diff --git a/Downloads/DownloadRequest.cs b/Downloads/DownloadRequest.cs index 7f5e4d2..472e1cd 100644 --- a/Downloads/DownloadRequest.cs +++ b/Downloads/DownloadRequest.cs @@ -12,7 +12,7 @@ public class DownloadRequest public string DownloadUrl { get; set; } public string InstallDir { get; set; } // folder where to extract/install public string GamePath { get; set; } // full path to the downloaded file on disk - public bool HasMultipleFiles { get; set; } // whether archive contains multiple top-level files + public bool DownloadAsArchive { get; set; } // download is the ROM's folder as one archive, so it always gets extracted public bool AutoExtract { get; set; } = true; public bool Use7z { get; set; } = false; public string PathTo7Z { get; set; } = ""; diff --git a/Games/RomMImport.cs b/Games/RomMImport.cs index 9db0800..195005e 100644 --- a/Games/RomMImport.cs +++ b/Games/RomMImport.cs @@ -190,15 +190,13 @@ private Game ImportGame(RomMRom ROM, Guid StatusID) // not the display Name. Using Name drops the extension and can include characters that // don't match the installed file, breaking IsInstalled detection and the play path. // - // For folder-based ROMs we point at a real file inside the ROM's folder (fs_name): - // - nested single file: the one file in the folder, - // - multiple files: the primary file (the download descriptor's FileName is the folder - // name / archive base, which is not itself a real file, so use the primary file here). + // The factory already resolved which file that is, so the install path and the download + // descriptor cannot drift apart: for a folder download it is the primary file inside the + // ROM's folder (FileName there is the folder / archive base, not a real file), and for a + // single-file download it is the downloaded file itself. var baseRevision = BuildRevision(ROM); var folderName = baseRevision?.FolderName; - var playableFile = ROM.HasMultipleFiles - ? RomMRevisionFactory.RelativeFilePath(RomMRevisionFactory.SelectPrimaryFile(ROM.Files), folderName) - : baseRevision?.FileName; + var playableFile = baseRevision?.PlayableFile; // With no file list, fs_name still beats the extensionless display Name. var fileName = !string.IsNullOrEmpty(playableFile) ? playableFile : !string.IsNullOrEmpty(ROM.FileName) ? ROM.FileName @@ -210,8 +208,16 @@ private Game ImportGame(RomMRom ROM, Guid StatusID) return null; } - var gameInstallDir = _mapping.InstallFlat ? rootInstallDir : RomMInstallPaths.InstallDir(rootInstallDir, folderName, fileName); - var pathToGame = _mapping.InstallFlat ? $"{rootInstallDir}\\{fileName}" : RomMInstallPaths.GamePath(rootInstallDir, folderName, fileName); + // A ROM fetched as a whole folder keeps a folder of its own even under install flat, so + // the extras it carries are not scattered across the platform folder. + var flatLayout = RomMInstallPaths.UsesFlatLayout( + _mapping.InstallFlat, + RomMInstallPaths.IsSingleRomInFolder( + baseRevision?.DownloadAsArchive ?? false, + baseRevision?.HasMultipleFiles ?? false)); + + var gameInstallDir = flatLayout ? rootInstallDir : RomMInstallPaths.InstallDir(rootInstallDir, folderName, fileName); + var pathToGame = flatLayout ? $"{rootInstallDir}\\{fileName}" : RomMInstallPaths.GamePath(rootInstallDir, folderName, fileName); var status = _plugin.Playnite.Database.CompletionStatuses.Get(StatusID); var completionStatusProperty = status != null ? new MetadataNameProperty(status.Name) : null; diff --git a/Games/RomMInstallController.cs b/Games/RomMInstallController.cs index b9e6299..ab159e7 100644 --- a/Games/RomMInstallController.cs +++ b/Games/RomMInstallController.cs @@ -48,12 +48,20 @@ public override void Install(InstallActionArgs args) // IsInstalled detection lines up. var installDir = RomMInstallPaths.InstallDir(dstPath, _gameData.FolderName, _gameData.FileName); - if (_gameData.Mapping.InstallFlat) + var singleRomInFolder = RomMInstallPaths.IsSingleRomInFolder( + _gameData.DownloadAsArchive, _gameData.HasMultipleFiles); + + // Not _gameData.Mapping.InstallFlat directly: a ROM fetched as a whole folder keeps its + // own folder even under flat. Must stay the same call the importer makes, or the install + // path drifts from the one recorded at import and IsInstalled detection breaks. + var flatLayout = RomMInstallPaths.UsesFlatLayout(_gameData.Mapping.InstallFlat, singleRomInFolder); + + if (flatLayout) installDir = dstPath; - // If RomM indicates multiple files, we download as an archive name (zip) into the install folder. - // Otherwise we download the single ROM file. - var downloadFilePath = _gameData.HasMultipleFiles + // A folder download arrives as an archive named after the ROM folder; a single file keeps + // its own name. See RomMRevision.DownloadAsArchive for why this is not HasMultipleFiles. + var downloadFilePath = _gameData.DownloadAsArchive ? Path.Combine(installDir, _gameData.FileName + ".zip") : Path.Combine(installDir, _gameData.FileName); @@ -68,9 +76,11 @@ public override void Install(InstallActionArgs args) Use7z = _romM.Settings.Use7z, PathTo7Z = _romM.Settings.PathTo7z, - HasMultipleFiles = _gameData.HasMultipleFiles, + DownloadAsArchive = _gameData.DownloadAsArchive, AutoExtract = _gameData.Mapping != null && _gameData.Mapping.AutoExtract, - InstallFlat = _gameData.Mapping.InstallFlat, + // The layout actually being installed, so cancelling a download cleans up the game's + // folder when it has one. + InstallFlat = flatLayout, // Called by queue AFTER download/extract is done BuildRoms = () => @@ -84,6 +94,23 @@ public override void Install(InstallActionArgs args) return roms; } + // A single ROM in a folder has one launchable file; its extras are not offered + // as alternative ROMs the way a multi-file ROM's discs are. A missing or unsafe + // file falls through to the folder scan rather than leaving no ROM at all. + if (singleRomInFolder && + !string.IsNullOrEmpty(_gameData.PlayableFile) && + RomMInstallPaths.IsContained(_gameData.PlayableFile)) + { + var primaryPath = Path.Combine(installDir, _gameData.PlayableFile); + if (File.Exists(primaryPath)) + { + roms.Add(new GameRom(Game.Name, primaryPath)); + return roms; + } + + Logger.Warn($"Expected {primaryPath} after extracting {Game.Name}; falling back to scanning the install folder."); + } + // Otherwise, we assume extracted files are in installDir var supported = GetEmulatorSupportedFileTypes(_gameData); var actualRomFiles = GetRomFiles(installDir, supported); diff --git a/Games/RomMInstallPaths.cs b/Games/RomMInstallPaths.cs index 589c805..181d674 100644 --- a/Games/RomMInstallPaths.cs +++ b/Games/RomMInstallPaths.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.IO; using System.Linq; @@ -39,10 +40,10 @@ public static string ResolveWithin(string root, string relativePath) if (string.IsNullOrEmpty(relativePath)) throw new ArgumentException("Archive entry has no name, refusing to extract it."); - var fullRoot = Path.GetFullPath(root).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + var fullRoot = Path.GetFullPath(root); var destination = Path.GetFullPath(Path.Combine(fullRoot, Contained(relativePath))); - if (!destination.StartsWith(fullRoot + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) + if (!IsInside(fullRoot, destination)) throw new ArgumentException($"Path escapes the install directory: {relativePath}"); return destination; @@ -65,5 +66,84 @@ public static string GamePath(string rootInstallDir, string fileName) // /, using the folder-aware install dir. public static string GamePath(string rootInstallDir, string folderName, string fileName) => Path.Combine(InstallDir(rootInstallDir, folderName, fileName), Contained(fileName)); + + // Whether an install lays a ROM's files straight into the mapping's folder, the way "install + // flat" asks, or gives the game a folder of its own. + // + // A ROM fetched as a whole folder brings its own subfolders (patch/, dlc/) with it, so it + // cannot go flat: extraction would scatter them across the platform folder, where the patch/ + // of every game would merge into a single one, and a flat uninstall -- which removes only the + // files registered as the game's ROMs -- would leave them behind. Those ROMs keep a folder of + // their own even when the mapping asks for flat; a single file still installs flat, which is + // what the setting is for. + // + // ROMs RomM itself calls multi-file stay on the flat path they already take: moving them + // would strand installations whose paths were recorded the old way. + // + // The importer and the install controller both ask here, so the path one computes cannot + // drift from the other's -- they have to agree or IsInstalled detection stops matching. + public static bool UsesFlatLayout(bool installFlat, bool singleRomInFolder) + => installFlat && !singleRomInFolder; + + // A folder fetched whole for a ROM RomM still calls single-file (see + // RomMRevision.DownloadAsArchive): one playable file, plus extras that are not ROMs. + public static bool IsSingleRomInFolder(bool downloadAsArchive, bool hasMultipleFiles) + => downloadAsArchive && !hasMultipleFiles; + + // Whether path sits strictly inside root, ignoring a trailing separator and case. + // + // Uninstall removes a directory only when it can show the directory belongs to one game, and + // this is that proof. Two cases fail it, both of which must keep their files rather than lose + // a folder: a game installed flat, whose install directory *is* the mapping's folder, shared + // with every other game on the platform; and an install directory that no longer sits under + // the mapping at all, because the destination was repointed after the game was installed -- + // there the stale path is the previous platform folder, and deleting it would take every ROM + // in it. Asking how the game was actually installed also survives the setting being toggled + // afterwards, which the mapping's own flag does not. + public static bool IsInside(string root, string path) + { + if (string.IsNullOrEmpty(root) || string.IsNullOrEmpty(path)) + return false; + + return StartsWithDirectory(NormalizeDirectory(path), NormalizeDirectory(root)); + } + + // Whether some other game's install directory is installDir itself or lies inside it -- the + // folder every flat-installed game on a platform shares, or one that a simple single file and + // a same-named folder ROM both derive. IsInside alone cannot tell such a folder from a game's + // own: a mapping repointed to the parent of its old flat folder ("D:\Roms\SNES" -> "D:\Roms") + // puts that old platform folder inside the new destination, and deleting it would take every + // ROM still in it. + public static bool IsClaimedByAnother(string installDir, IEnumerable otherInstallDirs) + { + if (string.IsNullOrEmpty(installDir) || otherInstallDirs == null) + return false; + + var normalized = NormalizeDirectory(installDir); + return otherInstallDirs + .Where(other => !string.IsNullOrEmpty(other)) + .Select(NormalizeDirectory) + .Any(other => string.Equals(other, normalized, StringComparison.OrdinalIgnoreCase) + || StartsWithDirectory(other, normalized)); + } + + // Both arguments already normalised. + private static bool StartsWithDirectory(string path, string root) + => path.StartsWith(root + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase); + + private static string NormalizeDirectory(string path) + { + try + { + path = Path.GetFullPath(path); + } + catch (Exception) + { + // A path the filesystem will not resolve is compared as it arrived rather than + // throwing: this only decides which uninstall branch runs. + } + + return path.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + } } } diff --git a/Games/RomMRevisionFactory.cs b/Games/RomMRevisionFactory.cs index 7a39765..b359e79 100644 --- a/Games/RomMRevisionFactory.cs +++ b/Games/RomMRevisionFactory.cs @@ -40,25 +40,44 @@ public static string RelativeFilePath(RomMFile file, string folderName) : file.FileName; } + // Whether the ROM's whole folder has to be fetched instead of a single file. + // + // RomM reports a ROM whose folder holds extra content -- a Switch game with its update and + // DLC in patch/ and dlc/ subfolders -- as a *nested single file*, because the game is the + // only file at the folder's root. Trusting that flag alone downloads the game and silently + // leaves the rest on the server, so a folder holding more than one file is fetched whole, + // the way a ROM RomM itself calls multi-file already is. + // + // has_simple_single_file is excluded on purpose: there fs_name names the file itself rather + // than a folder, so there is no folder to ask the server for. + public static bool DownloadsWholeFolder(RomMRom rom) + => rom.HasMultipleFiles + || (rom.HasNestedSingleFile && (rom.Files?.Count ?? 0) > 1); + // Returns null when a single-file ROM has no resolvable file. Single files use the 4.9 // /files/content endpoint when a file id is present, falling back to the rom-level endpoint - // (so we never emit "api/roms//files/content/..."); multi-file ROMs download the whole archive. + // (so we never emit "api/roms//files/content/..."); folder downloads take the whole archive. public static RomMRevision Build(RomMRom rom, string romMHost) { var revision = new RomMRevision { Id = rom.Id, + // What RomM says the ROM is, kept as-is: it decides whether every extracted file is + // a playable ROM (the discs of a multi-file game) or only the primary one. HasMultipleFiles = rom.HasMultipleFiles, + DownloadAsArchive = DownloadsWholeFolder(rom), IsSelected = false }; - if (!rom.HasMultipleFiles) + if (!revision.DownloadAsArchive) { var romfile = SelectPrimaryFile(rom.Files); if (romfile == null) return null; revision.FileName = romfile.FileName; + // The downloaded file is itself the one to launch. + revision.PlayableFile = romfile.FileName; // A nested single file lives inside a folder named after the ROM (fs_name); a simple // single file sits directly in the platform folder and has no wrapping folder. revision.FolderName = rom.HasNestedSingleFile ? rom.FileName : null; @@ -69,8 +88,11 @@ public static RomMRevision Build(RomMRom rom, string romMHost) else { revision.FileName = rom.FileName; - // Multi-file ROMs are always stored in a folder named after the ROM (fs_name). + // Folder-based ROMs are always stored in a folder named after the ROM (fs_name). revision.FolderName = rom.FileName; + // FileName is the folder / archive base, which is not itself a real file, so the file + // to launch is resolved here once and reused by the importer for the install path. + revision.PlayableFile = RelativeFilePath(SelectPrimaryFile(rom.Files), rom.FileName); revision.DownloadURL = RomMUrl.Combine(romMHost, $"api/roms/{rom.Id}/content/{rom.FileName}"); } diff --git a/Games/RomMUninstallController.cs b/Games/RomMUninstallController.cs index 39a1d66..adf5a79 100644 --- a/Games/RomMUninstallController.cs +++ b/Games/RomMUninstallController.cs @@ -2,6 +2,7 @@ using Playnite.SDK.Plugins; using RomM.Settings; using System.IO; +using System.Linq; using System.Windows; namespace RomM.Games @@ -20,27 +21,44 @@ internal RomMUninstallController(Game game, IRomM romM, EmulatorMapping mapping) public override void Uninstall(UninstallActionArgs args) { - if(_mapping.InstallFlat) - { - foreach (var RomFile in Game.Roms) - { - if(File.Exists(RomFile.Path)) - File.Delete(RomFile.Path); - } - } - else + // A portable Playnite records these with the "{PlayniteDir}" token, which no filesystem + // call resolves. + var installDir = PlaynitePath.Resolve(_romM.Playnite, Game.InstallDirectory); + + // Whether the game owns its install directory, read from how it was actually installed + // rather than from what the mapping says now -- the setting can be toggled after a game is + // installed, and a ROM fetched as a whole folder keeps a folder of its own even when the + // mapping asks for flat. Anything else shares its directory with other games, or no longer + // sits under the mapping, and only the files registered as its ROMs may be removed. + // A directory another game also installs into is shared however it sits, and so is + // anything reached without a mapping (sidecar gone, mapping removed). + var otherInstallDirs = _romM.Playnite.Database.Games + .Where(g => g.Id != Game.Id && !string.IsNullOrEmpty(g.InstallDirectory)) + .Select(g => PlaynitePath.Resolve(_romM.Playnite, g.InstallDirectory)); + + if (RomMInstallPaths.IsInside(_mapping?.DestinationPathResolved, installDir) + && !RomMInstallPaths.IsClaimedByAnother(installDir, otherInstallDirs)) { - if (new DirectoryInfo(Game.InstallDirectory).Exists) + if (new DirectoryInfo(installDir).Exists) { - Directory.Delete(Game.InstallDirectory, true); + Directory.Delete(installDir, true); } else { _romM.Playnite.Dialogs.ShowMessage($"\"{Game.Name}\" folder could not be found. Marking as uninstalled.", "Game not found", MessageBoxButton.OK); } } + else if (Game.Roms != null) + { + foreach (var RomFile in Game.Roms) + { + var romPath = PlaynitePath.Resolve(_romM.Playnite, RomFile.Path); + if(File.Exists(romPath)) + File.Delete(romPath); + } + } - Game.Roms.Clear(); + Game.Roms?.Clear(); InvokeOnUninstalled(new GameUninstalledEventArgs()); } } diff --git a/Models/RomM/Rom/GameInstallInfo.cs b/Models/RomM/Rom/GameInstallInfo.cs index abdd76f..b695ab4 100644 --- a/Models/RomM/Rom/GameInstallInfo.cs +++ b/Models/RomM/Rom/GameInstallInfo.cs @@ -10,7 +10,22 @@ public struct GameInstallInfo public string FileName { get; set; } public string FolderName { get; set; } public bool HasMultipleFiles { get; set; } + public bool DownloadAsArchive { get; set; } + public string PlayableFile { get; set; } public string DownloadURL { get; set; } public EmulatorMapping Mapping { get; set; } + + public static GameInstallInfo From(RomMRevision revision, EmulatorMapping mapping) + => new GameInstallInfo + { + Id = revision.Id, + FileName = revision.FileName, + FolderName = revision.FolderName, + HasMultipleFiles = revision.HasMultipleFiles, + DownloadAsArchive = revision.DownloadAsArchive, + PlayableFile = revision.PlayableFile, + DownloadURL = revision.DownloadURL, + Mapping = mapping + }; } } diff --git a/Models/RomM/Rom/RomMRomLocal.cs b/Models/RomM/Rom/RomMRomLocal.cs index 95c393d..683584d 100644 --- a/Models/RomM/Rom/RomMRomLocal.cs +++ b/Models/RomM/Rom/RomMRomLocal.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Runtime.Serialization; namespace RomM.Models.RomM.Rom { @@ -22,8 +23,32 @@ public class RomMRevision public string FolderName { get; set; } public bool HasMultipleFiles { get; set; } + + // True when the download is the ROM's whole folder as one archive instead of a single file. + // Deliberately separate from HasMultipleFiles: RomM reports a Switch game that has an update + // or DLC beside it as a *single* file -- the game is the only file at the folder's root -- yet + // the folder still has to be fetched whole. Keeping the two apart is what lets a genuine + // multi-file ROM go on exposing every extracted file as a playable disc while these do not. + // Absent from sidecars written before this existed; see OnDeserialized. + public bool DownloadAsArchive { get; set; } + + // The file to launch, relative to the ROM folder: the file inside the extracted folder for an + // archive download, the downloaded file itself otherwise. Null in pre-existing sidecars. + public string PlayableFile { get; set; } + public string DownloadURL { get; set; } public bool IsSelected { get; set; } + + // A sidecar written before DownloadAsArchive existed reads it back as false, which would stop + // a ROM imported by an earlier version from being treated as the archive it still downloads + // as. A ROM RomM itself calls multi-file has always been fetched whole, so the flag is + // restored here, once, for every reader, until the next library update rewrites the sidecar. + [OnDeserialized] + internal void OnDeserialized(StreamingContext context) + { + if (HasMultipleFiles) + DownloadAsArchive = true; + } } public class RomMRomLocal diff --git a/RomM.Tests/RomMInstallPathsTests.cs b/RomM.Tests/RomMInstallPathsTests.cs index 476e12b..aaee33e 100644 --- a/RomM.Tests/RomMInstallPathsTests.cs +++ b/RomM.Tests/RomMInstallPathsTests.cs @@ -103,5 +103,97 @@ public void Null_or_empty_folder_name_falls_back_to_filename_derived_dir() RomMInstallPaths.GamePath(Root, file), RomMInstallPaths.GamePath(Root, "", file)); } + + [Theory] + // A folder fetched whole for a ROM RomM still calls single-file keeps a folder of its own: + // its patch/ and dlc/ must not be scattered across the folder every game on the platform + // shares, where a flat uninstall would then leave them behind. + [InlineData(true, true, false, false)] + // A single file installs flat, which is what the setting is for. + [InlineData(true, false, false, true)] + // ROMs RomM itself calls multi-file stay on the flat path they already took. + [InlineData(true, true, true, true)] + // Nothing goes flat when the mapping does not ask for it. + [InlineData(false, true, false, false)] + [InlineData(false, false, false, false)] + [InlineData(false, true, true, false)] + public void Flat_layout_makes_an_exception_for_ROMs_fetched_as_a_folder( + bool installFlat, bool downloadAsArchive, bool hasMultipleFiles, bool expected) + => Assert.Equal( + expected, + RomMInstallPaths.UsesFlatLayout( + installFlat, RomMInstallPaths.IsSingleRomInFolder(downloadAsArchive, hasMultipleFiles))); + + [Fact] + public void A_directory_under_the_destination_belongs_to_the_game() + { + var destination = Path.Combine(Path.GetTempPath(), "roms", "switch"); + + Assert.True(RomMInstallPaths.IsInside(destination, Path.Combine(destination, "Sample Game"))); + Assert.True(RomMInstallPaths.IsInside( + destination + Path.DirectorySeparatorChar, Path.Combine(destination, "Sample Game"))); + Assert.True(RomMInstallPaths.IsInside( + destination.ToUpperInvariant(), Path.Combine(destination.ToLowerInvariant(), "Sample Game"))); + } + + [Fact] + public void The_destination_itself_is_not_inside_it() + { + // A flat install lives in the folder every game on the platform shares, so uninstall has + // to remove its files and leave the folder alone. + var destination = Path.Combine(Path.GetTempPath(), "roms", "switch"); + + Assert.False(RomMInstallPaths.IsInside(destination, destination)); + Assert.False(RomMInstallPaths.IsInside(destination, destination + Path.DirectorySeparatorChar)); + } + + [Fact] + public void A_stale_install_dir_is_not_inside_the_current_destination() + { + // The mapping was repointed after the game was installed: the recorded path is the + // previous platform folder, and deleting that would take every ROM still in it. + var previous = Path.Combine(Path.GetTempPath(), "previous", "switch"); + var current = Path.Combine(Path.GetTempPath(), "current", "switch"); + + Assert.False(RomMInstallPaths.IsInside(current, previous)); + Assert.False(RomMInstallPaths.IsInside(current, Path.Combine(previous, "Sample Game"))); + } + + [Theory] + [InlineData(null, "anything")] + [InlineData("anything", null)] + [InlineData("", "anything")] + [InlineData("anything", "")] + public void IsInside_is_false_when_a_path_is_missing(string root, string path) + => Assert.False(RomMInstallPaths.IsInside(root, path)); + + [Fact] + public void An_old_flat_folder_under_a_repointed_parent_is_shared() + { + // The mapping moved from roms/snes (flat) to its parent roms: the old platform folder now + // sits inside the destination, but every other flat SNES game still records it as theirs. + var parent = Path.Combine(Path.GetTempPath(), "roms"); + var oldFlat = Path.Combine(parent, "snes"); + + Assert.True(RomMInstallPaths.IsInside(parent, oldFlat)); + Assert.True(RomMInstallPaths.IsClaimedByAnother(oldFlat, new[] { oldFlat + Path.DirectorySeparatorChar })); + } + + [Fact] + public void A_folder_holding_another_games_install_is_shared() + { + var dir = Path.Combine(Path.GetTempPath(), "roms", "snes"); + + Assert.True(RomMInstallPaths.IsClaimedByAnother(dir, new[] { Path.Combine(dir, "Other Game") })); + } + + [Fact] + public void A_games_own_folder_is_not_shared() + { + var root = Path.Combine(Path.GetTempPath(), "roms", "snes"); + var own = Path.Combine(root, "Sample Game"); + + Assert.False(RomMInstallPaths.IsClaimedByAnother(own, new[] { root, Path.Combine(root, "Other Game"), null, "" })); + } } } diff --git a/RomM.Tests/RomMRevisionFactoryTests.cs b/RomM.Tests/RomMRevisionFactoryTests.cs index 390951a..7d16e9e 100644 --- a/RomM.Tests/RomMRevisionFactoryTests.cs +++ b/RomM.Tests/RomMRevisionFactoryTests.cs @@ -161,5 +161,140 @@ public void Multi_file_uses_rom_content_endpoint() Assert.Equal("Game (Disc).zip", rev.FileName); Assert.Equal(Host + "/api/roms/40/content/Game (Disc).zip", rev.DownloadURL); } + + [Fact] + public void Nested_single_file_with_extras_downloads_the_whole_folder() + { + // RomM calls a game that keeps its update and DLC in subfolders a nested *single* file, + // because the game is the only file at the folder's root. Following that literally + // downloads the game and leaves the rest on the server, so the folder is fetched whole. + var rom = new RomMRom + { + Id = 51, + HasNestedSingleFile = true, + HasMultipleFiles = false, + FileName = "Sample Game", + Files = new List + { + new RomMFile { Id = 61, FileName = "Sample Game (Update).rom", FullPath = "Sample Game/patch/Sample Game (Update).rom" }, + new RomMFile { Id = 62, FileName = "Sample Game (DLC).rom", FullPath = "Sample Game/dlc/Sample Game (DLC).rom" }, + new RomMFile { Id = 60, FileName = "Sample Game.rom", FullPath = "Sample Game/Sample Game.rom" }, + }, + }; + + var rev = RomMRevisionFactory.Build(rom, Host); + + Assert.True(rev.DownloadAsArchive); + // Still not multi-file: the update and the DLC are not second playable ROMs. + Assert.False(rev.HasMultipleFiles); + Assert.Equal("Sample Game", rev.FileName); + Assert.Equal("Sample Game", rev.FolderName); + Assert.Equal("Sample Game.rom", rev.PlayableFile); + Assert.Equal(Host + "/api/roms/51/content/Sample Game", rev.DownloadURL); + } + + [Fact] + public void Nested_single_file_with_one_file_stays_on_the_file_endpoint() + { + var rom = new RomMRom + { + Id = 33, + HasNestedSingleFile = true, + HasMultipleFiles = false, + FileName = "All-Star Baseball '99", + Files = new List + { + new RomMFile { Id = 8, FileName = "All-Star Baseball '99 (Europe).zip", FullPath = "All-Star Baseball '99/All-Star Baseball '99 (Europe).zip" }, + }, + }; + + var rev = RomMRevisionFactory.Build(rom, Host); + + Assert.False(rev.DownloadAsArchive); + Assert.Equal("All-Star Baseball '99 (Europe).zip", rev.PlayableFile); + Assert.Equal(Host + "/api/roms/8/files/content/All-Star Baseball '99 (Europe).zip", rev.DownloadURL); + } + + [Fact] + public void Simple_single_file_is_never_fetched_as_a_folder() + { + // fs_name is the file itself here, not a folder, so there is no folder to ask for even + // when the payload carries more than one file. + var rom = new RomMRom + { + Id = 34, + HasSimpleSingleFile = true, + HasMultipleFiles = false, + FileName = "game.gba", + Files = new List + { + new RomMFile { Id = 9, FileName = "game.gba", FullPath = "roms/gba/game.gba" }, + new RomMFile { Id = 10, FileName = "game.sav", FullPath = "roms/gba/extra/game.sav" }, + }, + }; + + var rev = RomMRevisionFactory.Build(rom, Host); + + Assert.False(rev.DownloadAsArchive); + Assert.Null(rev.FolderName); + Assert.Equal(Host + "/api/roms/9/files/content/game.gba", rev.DownloadURL); + } + + [Fact] + public void Multi_file_keeps_its_archive_download_and_names_the_primary_file() + { + var rom = new RomMRom + { + Id = 41, + HasMultipleFiles = true, + FileName = "Final Fantasy VII", + Files = new List + { + new RomMFile { Id = 11, FileName = "disc1.bin", FullPath = "roms/ps1/Final Fantasy VII/Disc 1/disc1.bin" }, + new RomMFile { Id = 12, FileName = "ff7.cue", FullPath = "roms/ps1/Final Fantasy VII/ff7.cue" }, + }, + }; + + var rev = RomMRevisionFactory.Build(rom, Host); + + Assert.True(rev.DownloadAsArchive); + Assert.True(rev.HasMultipleFiles); + Assert.Equal("ff7.cue", rev.PlayableFile); + } + + [Fact] + public void Single_file_playable_file_is_the_downloaded_file() + { + var rom = new RomMRom + { + Id = 32, + HasMultipleFiles = false, + Files = new List { new RomMFile { Id = 7, FileName = "game.gba", FullPath = "game.gba" } }, + }; + + var rev = RomMRevisionFactory.Build(rom, Host); + + Assert.False(rev.DownloadAsArchive); + Assert.Equal("game.gba", rev.PlayableFile); + } + + [Fact] + public void Pre_upgrade_multi_file_sidecar_still_downloads_as_archive() + { + // Written before DownloadAsArchive existed: the flag is absent and must not read as false. + var rev = Newtonsoft.Json.JsonConvert.DeserializeObject( + "{\"Id\":41,\"FileName\":\"Final Fantasy VII\",\"HasMultipleFiles\":true}"); + + Assert.True(rev.DownloadAsArchive); + } + + [Fact] + public void Pre_upgrade_single_file_sidecar_stays_a_single_download() + { + var rev = Newtonsoft.Json.JsonConvert.DeserializeObject( + "{\"Id\":32,\"FileName\":\"game.gba\",\"HasMultipleFiles\":false}"); + + Assert.False(rev.DownloadAsArchive); + } } } diff --git a/RomM.cs b/RomM.cs index dccfd54..7e6d2d0 100644 --- a/RomM.cs +++ b/RomM.cs @@ -429,15 +429,9 @@ public override IEnumerable GetInstallActions(GetInstallActio } // Set ROM data to base ROM - romData = new GameInstallInfo - { - Id = gameData.ROMVersions[0].Id, - FileName = gameData.ROMVersions[0].FileName, - FolderName = gameData.ROMVersions[0].FolderName, - HasMultipleFiles = gameData.ROMVersions[0].HasMultipleFiles, - DownloadURL = gameData.ROMVersions[0].DownloadURL, - Mapping = Settings.Mappings.FirstOrDefault(x => x.MappingId == gameData.MappingID) - }; + romData = GameInstallInfo.From( + gameData.ROMVersions[0], + Settings.Mappings.FirstOrDefault(x => x.MappingId == gameData.MappingID)); // If Siblings are available prompt user with version selection if (Settings.MergeRevisions && gameData.ROMVersions?.Count > 1) @@ -480,11 +474,7 @@ public override IEnumerable GetInstallActions(GetInstallActio var selectedrevision = VersionSelectorControl.RomVersions.First(x => x.IsSelected); - romData.Id = selectedrevision.Id; - romData.FileName = selectedrevision.FileName; - romData.FolderName = selectedrevision.FolderName; - romData.HasMultipleFiles = selectedrevision.HasMultipleFiles; - romData.DownloadURL = selectedrevision.DownloadURL; + romData = GameInstallInfo.From(selectedrevision, romData.Mapping); gameData.ROMVersions = VersionSelectorControl.RomVersions.ToList();