diff --git a/WheelWizard.Test/Features/Patches/LooseBrsarPatchFileNameTests.cs b/WheelWizard.Test/Features/Patches/LooseBrsarPatchFileNameTests.cs new file mode 100644 index 000000000..86b6a2964 --- /dev/null +++ b/WheelWizard.Test/Features/Patches/LooseBrsarPatchFileNameTests.cs @@ -0,0 +1,29 @@ +using WheelWizard.Features.Patches; + +namespace WheelWizard.Test.Features.Patches; + +public class LooseBrsarPatchFileNameTests +{ + [Theory] + [InlineData("[001]sound.BRWSD", "1.brwsd")] + [InlineData("[42].BRBNK", "42.brbnk")] + [InlineData("[0]name.brwsd", "0.brwsd")] + public void TaggedNamesNormalizeIdAndExtension(string name, string expected) + { + Assert.True(LooseBrsarPatchFileName.TryGetNormalizedFileName(name, out var result)); + Assert.Equal(expected, result); + } + + [Theory] + [InlineData("")] + [InlineData("[]sound.brwsd")] + [InlineData("[-1]sound.brwsd")] + [InlineData("[2147483648]sound.brwsd")] + [InlineData("[1]sound.szs")] + [InlineData("1.brwsd")] + public void InvalidNamesAreRejected(string name) + { + Assert.False(LooseBrsarPatchFileName.TryGetNormalizedFileName(name, out var result)); + Assert.Empty(result); + } +} diff --git a/WheelWizard.Test/Features/Patches/ModPatchCompatibilityTests.cs b/WheelWizard.Test/Features/Patches/ModPatchCompatibilityTests.cs new file mode 100644 index 000000000..cccd9abed --- /dev/null +++ b/WheelWizard.Test/Features/Patches/ModPatchCompatibilityTests.cs @@ -0,0 +1,47 @@ +using Microsoft.Extensions.Logging.Abstractions; +using Testably.Abstractions.Testing; +using WheelWizard.Features.Patches; +using WheelWizard.Models.Mods; +using WheelWizard.Mods; + +namespace WheelWizard.Test.Features.Patches; + +public class ModPatchCompatibilityTests +{ + [Theory] + [InlineData("course.szs", true)] + [InlineData("COURSE.SZS", true)] + [InlineData("nested/course.szs", true)] + [InlineData("revo_kart.brsar", true)] + [InlineData("[42]sound.BRWSD", true)] + [InlineData("course.tag.szs", false)] + [InlineData("notes.txt", false)] + [InlineData("sound.brwsd", false)] + public void CompatibilityUsesInjectedFilesAndRecognizesConvertibleArchives(string relativePath, bool expected) + { + var fs = new MockFileSystem(); + var root = fs.Path.GetFullPath("mods/Test"); + var file = fs.Path.GetFullPath(fs.Path.Combine(root, relativePath)); + fs.Directory.CreateDirectory(fs.Path.GetDirectoryName(file)!); + fs.File.WriteAllText(file, "archive"); + var paths = Substitute.For(); + paths.GetModDirectoryPath("Test").Returns(root); + var service = new ModPatchConversionService( + Substitute.For(), + NullLogger.Instance, + fs, + paths, + Substitute.For() + ); + var mod = new Mod { Title = "Test" }; + + service.RefreshCompatibility(mod); + + Assert.Equal(expected, mod.HasIncompatibleFiles); + Assert.Equal(expected ? new[] { file } : [], service.GetConvertibleArchiveFiles(mod)); + fs.Directory.Delete(root, recursive: true); + service.RefreshCompatibility(mod); + Assert.False(mod.HasIncompatibleFiles); + Assert.Empty(service.GetConvertibleArchiveFiles(mod)); + } +} diff --git a/WheelWizard.Test/Features/Recomp/RecompPlatformTests.cs b/WheelWizard.Test/Features/Recomp/RecompPlatformTests.cs new file mode 100644 index 000000000..e5015c0a1 --- /dev/null +++ b/WheelWizard.Test/Features/Recomp/RecompPlatformTests.cs @@ -0,0 +1,27 @@ +using System.Runtime.InteropServices; +using WheelWizard.Recomp; + +namespace WheelWizard.Test.Features.Recomp; + +public class RecompPlatformTests +{ + [Theory] + [InlineData("v1.2/../../../release", "v1-2----------release")] + [InlineData("", "")] + [InlineData("v123", "v123")] + public void CachedSetupNameKeepsTagsWithinSingleFileName(string tag, string sanitized) + { + var result = RecompPlatform.CachedSetupFileName(tag); + Assert.Equal($"WiiCompiled-Setup-{sanitized}{Path.GetExtension(RecompPlatform.SetupFileName)}", result); + Assert.Equal(result, Path.GetFileName(result)); + Assert.Equal($"WiiCompiled-Setup-*{Path.GetExtension(result)}", RecompPlatform.CachedSetupSearchPattern); + } + + [Theory] + [InlineData(Architecture.X64, "WiiCompiled-Setup-x86_64.AppImage")] + [InlineData(Architecture.Arm64, "WiiCompiled-Setup-aarch64.AppImage")] + [InlineData(Architecture.X86, null)] + [InlineData(Architecture.Arm, null)] + public void LinuxAssetsMatchSupportedArchitectures(Architecture architecture, string? expected) => + Assert.Equal(expected, RecompPlatform.LinuxReleaseAssetName(architecture)); +} diff --git a/WheelWizard.Test/Features/Settings/SettingsRecoveryTests.cs b/WheelWizard.Test/Features/Settings/SettingsRecoveryTests.cs index bcd54e20e..509b462b1 100644 --- a/WheelWizard.Test/Features/Settings/SettingsRecoveryTests.cs +++ b/WheelWizard.Test/Features/Settings/SettingsRecoveryTests.cs @@ -28,7 +28,7 @@ public void DolphinEditPreservesExternalValuesAndReloadDoesNotCarryPreviousProfi fs.File.WriteAllText("/config/GFX.ini", "[Settings]\nInternalResolution = invalid\n"); manager.ReloadSettings("/config"); Assert.Equal(1, resolution.Get()); - Assert.Equal(false, fps.Get()); + Assert.False(fps.Get()); Assert.Contains("invalid", fs.File.ReadAllText("/config/GFX.ini")); } @@ -44,10 +44,10 @@ public void RecompReloadMissingKeyUsesDefault() manager.LoadSettings("/config/Config.toml"); fs.File.WriteAllText("/config/Config.toml", "[video]\n"); manager.ReloadSettings("/config/Config.toml"); - Assert.Equal(true, setting.Get()); + Assert.True(setting.Get()); fs.File.Delete("/config/Config.toml"); Assert.False(setting.Set(false)); - Assert.Equal(true, setting.Get()); + Assert.True(setting.Get()); } [Fact] @@ -66,7 +66,7 @@ public void FailedSaveRollsBackAndCanBeRetried() var notifications = 0; setting.Changed += _ => notifications++; Assert.False(setting.Set(true)); - Assert.Equal(false, setting.Get()); + Assert.False(setting.Get()); Assert.Equal(0, notifications); fail = false; Assert.True(setting.Set(true)); @@ -86,7 +86,7 @@ public void JsonRetainsExistingKeysAndUnknownValuesAndPreservesCorruptFile() var setting = new WhWzSetting("EnableAnimations", true, s => manager.SaveSettings(path, s)); manager.RegisterSetting(setting); manager.LoadSettings(path); - Assert.Equal(false, setting.Get()); + Assert.False(setting.Get()); Assert.True(setting.Set(true)); Assert.Contains("FutureSetting", fs.File.ReadAllText(path)); Assert.Equal(original, fs.File.ReadAllText(path + ".bak")); diff --git a/WheelWizard.Test/Shared/IO/PathSafetyTests.cs b/WheelWizard.Test/Shared/IO/PathSafetyTests.cs new file mode 100644 index 000000000..c755f3800 --- /dev/null +++ b/WheelWizard.Test/Shared/IO/PathSafetyTests.cs @@ -0,0 +1,47 @@ +using WheelWizard.Shared.IO; + +namespace WheelWizard.Test.Shared.IO; + +public class PathSafetyTests +{ + [Theory] + [InlineData("../outside.bin")] + [InlineData("nested/../../outside.bin")] + [InlineData("nested\\..\\outside.bin")] + [InlineData("/absolute.bin")] + [InlineData("C:\\absolute.bin")] + [InlineData("C:/absolute.bin")] + [InlineData(" ")] + public void UnsafeRelativePathsAreRejected(string path) + { + Assert.False(PathSafety.TryGetPathWithinDirectory(Path.GetTempPath(), path, out var fullPath)); + Assert.Empty(fullPath); + } + + [Fact] + public void NestedPathIsNormalizedInsideDirectory() + { + var root = Path.Combine(Path.GetTempPath(), "PathSafetyTests"); + Assert.True(PathSafety.TryGetPathWithinDirectory(root, " ./nested\\file.bin ", out var path)); + Assert.Equal(Path.Combine(root, "nested", "file.bin"), path); + Assert.False(PathSafety.IsPathWithinDirectory(root, root + "-sibling/file.bin")); + Assert.True(PathSafety.IsPathWithinDirectory(root, root)); + } + + [Fact] + public void ColonInSegmentFollowsPlatformRules() + { + var accepted = PathSafety.TryGetPathWithinDirectory(Path.GetTempPath(), "nested/data:variant.bin", out _); + Assert.Equal(!OperatingSystem.IsWindows(), accepted); + } + + [Theory] + [InlineData("folder\\mii.bin", "mii.bin")] + [InlineData("folder/mii.bin", "mii.bin")] + [InlineData("\"mii.bin\"", "mii.bin")] + public void SafeFileNameReturnsOnlyLeaf(string input, string expected) + { + Assert.True(PathSafety.TryGetSafeFileName(input, out var result)); + Assert.Equal(expected, result); + } +} diff --git a/WheelWizard.UI.Test/ModInstallOwnerLifetimeTests.cs b/WheelWizard.UI.Test/ModInstallOwnerLifetimeTests.cs index ff6c374fd..4f23a0777 100644 --- a/WheelWizard.UI.Test/ModInstallOwnerLifetimeTests.cs +++ b/WheelWizard.UI.Test/ModInstallOwnerLifetimeTests.cs @@ -4,6 +4,7 @@ using Avalonia.Interactivity; using Avalonia.Threading; using NSubstitute; +using Testably.Abstractions; using WheelWizard.GameBanana; using WheelWizard.Mods; using WheelWizard.Mods.Views.Dialogs; @@ -47,7 +48,15 @@ public async Task ClosedOwner_CancelsDownloadAndSuppressesLateSuccessOrFailureDi // Deliberately return late instead of honoring cancellation, to exercise the race. return finished.Task; }); - var content = new ModContent(manager, gameBanana, downloads, Substitute.For(), paths, presentation); + var content = new ModContent( + new RealFileSystem(), + manager, + gameBanana, + downloads, + Substitute.For(), + paths, + presentation + ); var mod = ModTestFixture.CreateMod(123, "Test mod"); mod.Files = [new() { FileName = "mod.zip", DownloadUrl = "https://example.com/mod.zip" }]; typeof(ModContent).GetProperty("CurrentMod", BindingFlags.NonPublic | BindingFlags.Instance)!.SetValue(content, mod); diff --git a/WheelWizard.UI.Test/ModTestFixture.cs b/WheelWizard.UI.Test/ModTestFixture.cs index 2f9123c31..6f057dffe 100644 --- a/WheelWizard.UI.Test/ModTestFixture.cs +++ b/WheelWizard.UI.Test/ModTestFixture.cs @@ -1,4 +1,5 @@ using NSubstitute; +using Testably.Abstractions.Testing; using WheelWizard.GameBanana; using WheelWizard.GameBanana.Domain; using WheelWizard.Mods; @@ -11,6 +12,7 @@ internal static class ModTestFixture { public static ModContent CreateContent(IGameBananaSingletonService api, IGameBananaMediaService? media = null) => new( + new MockFileSystem(), Substitute.For(), api, Substitute.For(), diff --git a/WheelWizard.UI.Test/SharedControlTests.cs b/WheelWizard.UI.Test/SharedControlTests.cs index aabf211cc..d479f2212 100644 --- a/WheelWizard.UI.Test/SharedControlTests.cs +++ b/WheelWizard.UI.Test/SharedControlTests.cs @@ -467,10 +467,11 @@ public void Dropdown_SearchFiltersWithoutChangingSelectionAndShowsEmptyStates() Dispatcher.UIThread.RunJobs(); window.UpdateLayout(); var popup = dropdown.GetVisualDescendants().OfType().Single(); - var search = popup.Child!.GetVisualDescendants().OfType().Single(); - var empty = popup.Child.GetVisualDescendants().OfType().Single(x => x.Name == "PART_Empty"); - var width = popup.Child.Bounds.Width; - var height = popup.Child.Bounds.Height; + var popupChild = popup.Child!; + var search = popupChild.GetVisualDescendants().OfType().Single(); + var empty = popupChild.GetVisualDescendants().OfType().Single(x => x.Name == "PART_Empty"); + var width = popupChild.Bounds.Width; + var height = popupChild.Bounds.Height; Assert.True(width > dropdown.Bounds.Width); Dispatcher.UIThread.RunJobs(); Assert.True(search.IsFocused); @@ -484,26 +485,26 @@ public void Dropdown_SearchFiltersWithoutChangingSelectionAndShowsEmptyStates() Assert.True(dropdown.ContainerFromIndex(0)!.IsVisible); Assert.False(dropdown.ContainerFromIndex(1)!.IsVisible); Assert.Equal(1, dropdown.SelectedIndex); - Assert.Equal(width, popup.Child.Bounds.Width); - Assert.True(popup.Child.Bounds.Height < height); + Assert.Equal(width, popupChild.Bounds.Width); + Assert.True(popupChild.Bounds.Height < height); search.Text = "English"; Dispatcher.UIThread.RunJobs(); window.UpdateLayout(); - Assert.Equal(width, popup.Child.Bounds.Width); - Assert.True(popup.Child.Bounds.Height < height); + Assert.Equal(width, popupChild.Bounds.Width); + Assert.True(popupChild.Bounds.Height < height); Assert.True(search.IsFocused); Assert.True(popup.IsOpen); search.Text = "not a language"; Dispatcher.UIThread.RunJobs(); window.UpdateLayout(); Assert.True(empty.IsVisible); - Assert.Equal(width, popup.Child.Bounds.Width); - Assert.True(popup.Child.Bounds.Height < height); + Assert.Equal(width, popupChild.Bounds.Width); + Assert.True(popupChild.Bounds.Height < height); Assert.Equal("No options found", empty.Text); search.Text = ""; Dispatcher.UIThread.RunJobs(); window.UpdateLayout(); - Assert.Equal(height, popup.Child.Bounds.Height); + Assert.Equal(height, popupChild.Bounds.Height); Assert.False(empty.IsVisible); Assert.True(search.IsFocused, "Search lost focus after restoring options."); search.Text = "disabled"; @@ -691,7 +692,8 @@ public void Dropdown_OptionsCanMixIconsAndPlainText() window.UpdateLayout(); Assert.Same(iconOption, dropdown.SelectedItem); var fieldIcon = Assert.Single( - dropdown.GetVisualDescendants().OfType().Where(icon => icon.Classes.Contains("DropdownOptionIcon")) + dropdown.GetVisualDescendants().OfType(), + icon => icon.Classes.Contains("DropdownOptionIcon") ); Assert.Same(iconOption.Icon, fieldIcon.Data); Assert.Equal( @@ -701,7 +703,7 @@ public void Dropdown_OptionsCanMixIconsAndPlainText() dropdown.IsDropDownOpen = true; window.UpdateLayout(); var popup = dropdown.GetVisualDescendants().OfType().Single(); - Assert.Single(popup.Child!.GetVisualDescendants().OfType().Where(icon => icon.Classes.Contains("DropdownOptionIcon"))); + Assert.Single(popup.Child!.GetVisualDescendants().OfType(), icon => icon.Classes.Contains("DropdownOptionIcon")); Assert.Equal( (Color)window.FindResource("Brand300")!, Assert.IsAssignableFrom(Assert.IsType(dropdown.ContainerFromIndex(0)).Foreground).Color @@ -710,7 +712,7 @@ public void Dropdown_OptionsCanMixIconsAndPlainText() dropdown.SelectedIndex = 1; window.UpdateLayout(); Assert.Same(plainOption, dropdown.SelectedItem); - Assert.Empty(dropdown.GetVisualDescendants().OfType().Where(icon => icon.Classes.Contains("DropdownOptionIcon"))); + Assert.DoesNotContain(dropdown.GetVisualDescendants().OfType(), icon => icon.Classes.Contains("DropdownOptionIcon")); Assert.Contains(dropdown.GetVisualDescendants().OfType(), text => text.Text == plainOption.Text); } diff --git a/WheelWizard/Features/CustomDistributions/RetroRewindBeta.cs b/WheelWizard/Features/CustomDistributions/RetroRewindBeta.cs index 80ea10f49..fb7f90ee4 100644 --- a/WheelWizard/Features/CustomDistributions/RetroRewindBeta.cs +++ b/WheelWizard/Features/CustomDistributions/RetroRewindBeta.cs @@ -261,7 +261,9 @@ private static bool IsBadPasswordException(Exception ex) private bool TryGetRelativeExtractionPath(string normalizedPath, out string relativePath) { relativePath = string.Empty; - var archivePath = normalizedPath.Replace(Path.DirectorySeparatorChar, '/').Replace(Path.AltDirectorySeparatorChar, '/'); + var archivePath = normalizedPath + .Replace(_fileSystem.Path.DirectorySeparatorChar, '/') + .Replace(_fileSystem.Path.AltDirectorySeparatorChar, '/'); if (archivePath.Equals(FolderName, StringComparison.OrdinalIgnoreCase)) { @@ -271,7 +273,10 @@ private bool TryGetRelativeExtractionPath(string normalizedPath, out string rela if (archivePath.StartsWith($"{FolderName}/", StringComparison.OrdinalIgnoreCase)) { - relativePath = Path.Combine(FolderName, archivePath.Substring(FolderName.Length + 1).Replace('/', Path.DirectorySeparatorChar)); + relativePath = _fileSystem.Path.Combine( + FolderName, + archivePath.Substring(FolderName.Length + 1).Replace('/', _fileSystem.Path.DirectorySeparatorChar) + ); return true; } @@ -283,9 +288,9 @@ private bool TryGetRelativeExtractionPath(string normalizedPath, out string rela if (archivePath.StartsWith($"{XMLFolderName}/", StringComparison.OrdinalIgnoreCase)) { - relativePath = Path.Combine( + relativePath = _fileSystem.Path.Combine( XMLFolderName, - archivePath.Substring(XMLFolderName.Length + 1).Replace('/', Path.DirectorySeparatorChar) + archivePath.Substring(XMLFolderName.Length + 1).Replace('/', _fileSystem.Path.DirectorySeparatorChar) ); return true; } @@ -331,16 +336,18 @@ private OperationResult> MoveExtractedFiles(string tempExtractionPa return Ok(manifestEntries); } - private static bool IsRiivolutionPath(string relativePath) + private bool IsRiivolutionPath(string relativePath) { - var normalized = relativePath.Replace(Path.DirectorySeparatorChar, '/').Replace(Path.AltDirectorySeparatorChar, '/'); + var normalized = relativePath + .Replace(_fileSystem.Path.DirectorySeparatorChar, '/') + .Replace(_fileSystem.Path.AltDirectorySeparatorChar, '/'); return normalized.StartsWith("riivolution/", StringComparison.OrdinalIgnoreCase) || normalized.Equals("riivolution", StringComparison.OrdinalIgnoreCase); } - private static bool IsBetaRiivolutionFile(string relativePath) + private bool IsBetaRiivolutionFile(string relativePath) { - var fileName = Path.GetFileName(relativePath); + var fileName = _fileSystem.Path.GetFileName(relativePath); return fileName.StartsWith("RRBeta", StringComparison.OrdinalIgnoreCase); } diff --git a/WheelWizard/Features/Launching/RetroRewindLaunchDescriptor.cs b/WheelWizard/Features/Launching/RetroRewindLaunchDescriptor.cs index e05d9dc7e..3992b2d28 100644 --- a/WheelWizard/Features/Launching/RetroRewindLaunchDescriptor.cs +++ b/WheelWizard/Features/Launching/RetroRewindLaunchDescriptor.cs @@ -110,9 +110,9 @@ private static List BuildOptions(string sectionName, int myStuffCh return options; } - private static (string SectionName, int MyStuffChoice, bool EnableSeparateSave) GetLaunchInfo(string xmlFilePath) + private (string SectionName, int MyStuffChoice, bool EnableSeparateSave) GetLaunchInfo(string xmlFilePath) { - var fileName = Path.GetFileName(xmlFilePath); + var fileName = fileSystem.Path.GetFileName(xmlFilePath); if (fileName.Equals("RRBeta.xml", StringComparison.OrdinalIgnoreCase)) return ("Retro Rewind Beta", 0, true); diff --git a/WheelWizard/Features/MiiRendering/Services/NativeMiiRenderer.cs b/WheelWizard/Features/MiiRendering/Services/NativeMiiRenderer.cs index e7e0b7cf5..f50bf3f25 100644 --- a/WheelWizard/Features/MiiRendering/Services/NativeMiiRenderer.cs +++ b/WheelWizard/Features/MiiRendering/Services/NativeMiiRenderer.cs @@ -413,7 +413,7 @@ private static int MapExpression(MiiImageSpecifications.FaceExpression expressio _ => FflNativeInterop.FflExpressionNormal, }; - private static OperationResult GetOrCreateCachedHeadDrawParams( + private OperationResult GetOrCreateCachedHeadDrawParams( ManagedFflResourceArchive archive, FflNativeInterop.FFLiCharInfo charInfo, NativeMiiRenderRequest request, @@ -502,10 +502,10 @@ private static void EvictHeadDrawCacheIfNeeded_NoLock() } } - private static string BuildHeadDrawCacheKey(string studioData, int expressionId, int width, string resourcePath) + private string BuildHeadDrawCacheKey(string studioData, int expressionId, int width, string resourcePath) { var resolutionBucket = width <= 384 ? 256 : 512; - return $"{Path.GetFullPath(resourcePath)}|{resolutionBucket}|{expressionId}|{studioData}"; + return $"{fileSystem.Path.GetFullPath(resourcePath)}|{resolutionBucket}|{expressionId}|{studioData}"; } private static List BuildDecodedDrawMeshes(IReadOnlyList drawParams) diff --git a/WheelWizard/Features/Mods/Views/Dialogs/ModContent.axaml.cs b/WheelWizard/Features/Mods/Views/Dialogs/ModContent.axaml.cs index e2864ec49..62f772285 100644 --- a/WheelWizard/Features/Mods/Views/Dialogs/ModContent.axaml.cs +++ b/WheelWizard/Features/Mods/Views/Dialogs/ModContent.axaml.cs @@ -1,3 +1,4 @@ +using System.IO.Abstractions; using Avalonia; using Avalonia.Controls; using Avalonia.Interactivity; @@ -15,6 +16,8 @@ namespace WheelWizard.Mods.Views.Dialogs; public partial class ModContent : UserControl { + private readonly IFileSystem fileSystem; + private IModManager ModManager { get; } private IGameBananaSingletonService GameBananaService { get; } @@ -33,6 +36,7 @@ public partial class ModContent : UserControl private string? OverrideDownloadUrl { get; set; } public ModContent( + IFileSystem fileSystem, IModManager modManager, IGameBananaSingletonService gameBananaService, IDownloadService downloads, @@ -41,6 +45,7 @@ public ModContent( IModOperationPresentation modPresentation ) { + this.fileSystem = fileSystem; ModManager = modManager; GameBananaService = gameBananaService; Downloads = downloads; @@ -332,7 +337,7 @@ CancellationToken cancellationToken var url = downloadUrls.First(); var fileName = GetFileNameFromUrl(url); - var filePath = Path.Combine(ModPaths.DownloadFolderPath, fileName); + var filePath = fileSystem.Path.Combine(ModPaths.DownloadFolderPath, fileName); OperationResult downloadResult; try { @@ -354,7 +359,7 @@ CancellationToken cancellationToken return Ok(); } - if (!File.Exists(downloadedFilePath)) + if (!fileSystem.File.Exists(downloadedFilePath)) return Fail(t("message_warning.unable_download_mod.extra")); var popup = new TextInputWindow() @@ -377,7 +382,7 @@ CancellationToken cancellationToken return Fail("Mod name cannot be empty."); } - var invalidChars = Path.GetInvalidFileNameChars(); + var invalidChars = fileSystem.Path.GetInvalidFileNameChars(); if (modName.Any(c => invalidChars.Contains(c))) { TryDeleteTempModsFolder(); @@ -402,10 +407,10 @@ private async Task PrepareToDownloadFile() try { var tempFolder = ModPaths.DownloadFolderPath; - if (Directory.Exists(tempFolder)) - Directory.Delete(tempFolder, true); + if (fileSystem.Directory.Exists(tempFolder)) + fileSystem.Directory.Delete(tempFolder, true); - Directory.CreateDirectory(tempFolder); + fileSystem.Directory.CreateDirectory(tempFolder); await Task.CompletedTask; return Ok(); } @@ -419,8 +424,8 @@ private OperationResult TryDeleteTempModsFolder() { try { - if (Directory.Exists(ModPaths.DownloadFolderPath)) - Directory.Delete(ModPaths.DownloadFolderPath, true); + if (fileSystem.Directory.Exists(ModPaths.DownloadFolderPath)) + fileSystem.Directory.Delete(ModPaths.DownloadFolderPath, true); return Ok(); } @@ -450,9 +455,9 @@ CancellationToken cancellationToken /// /// Extracts the file name from a URL. /// - private static string GetFileNameFromUrl(string url) + private string GetFileNameFromUrl(string url) { - return Path.GetFileName(new Uri(url).AbsolutePath); + return fileSystem.Path.GetFileName(new Uri(url).AbsolutePath); } /// diff --git a/WheelWizard/Features/Patches/LooseBrsarPatchFileName.cs b/WheelWizard/Features/Patches/LooseBrsarPatchFileName.cs index 932eaf2b1..78be165da 100644 --- a/WheelWizard/Features/Patches/LooseBrsarPatchFileName.cs +++ b/WheelWizard/Features/Patches/LooseBrsarPatchFileName.cs @@ -2,6 +2,8 @@ namespace WheelWizard.Features.Patches; public static class LooseBrsarPatchFileName { + private static readonly System.IO.Abstractions.IPath NativePath = new Testably.Abstractions.RealFileSystem().Path; + public static bool TryGetNormalizedFileName(string fileName, out string normalizedFileName) { normalizedFileName = string.Empty; @@ -17,7 +19,7 @@ public static bool TryGetNormalizedFileName(string fileName, out string normaliz if (!idText.All(char.IsDigit) || !int.TryParse(idText, out var fileId)) return false; - var extension = Path.GetExtension(fileName); + var extension = NativePath.GetExtension(fileName); if ( !extension.Equals(".brwsd", StringComparison.OrdinalIgnoreCase) && !extension.Equals(".brbnk", StringComparison.OrdinalIgnoreCase) diff --git a/WheelWizard/Features/Patches/ModPatchConversionService.cs b/WheelWizard/Features/Patches/ModPatchConversionService.cs index b289a8719..03893c2e1 100644 --- a/WheelWizard/Features/Patches/ModPatchConversionService.cs +++ b/WheelWizard/Features/Patches/ModPatchConversionService.cs @@ -92,8 +92,12 @@ public async Task> ConvertToPatchesAsy return new ModPatchConversionResult(); } - var tempRoot = Path.Combine(Path.GetTempPath(), "WheelWizardPatchConversion", $"{mod.Title}-{Guid.NewGuid():N}"); - var tempModDirectory = Path.Combine(tempRoot, mod.Title); + var tempRoot = fileSystem.Path.Combine( + fileSystem.Path.GetTempPath(), + "WheelWizardPatchConversion", + $"{mod.Title}-{Guid.NewGuid():N}" + ); + var tempModDirectory = fileSystem.Path.Combine(tempRoot, mod.Title); progress?.Report(new(ModOperationStage.Converting, 0, TotalFiles: sourceFiles.Count)); try @@ -114,7 +118,7 @@ public async Task> ConvertToPatchesAsy { cancellationToken.ThrowIfCancellationRequested(); var file = tempFiles[index]; - var fileName = Path.GetFileName(file); + var fileName = fileSystem.Path.GetFileName(file); progress?.Report( new( @@ -129,7 +133,7 @@ public async Task> ConvertToPatchesAsy { AddArchiveBundleEntry( archiveBundles, - Path.Combine(Path.GetDirectoryName(file)!, $"{archivePrefix}.revo_kart.szs"), + fileSystem.Path.Combine(fileSystem.Path.GetDirectoryName(file)!, $"{archivePrefix}.revo_kart.szs"), normalizedPatchFileName, fileSystem.File.ReadAllBytes(file) ); @@ -160,13 +164,19 @@ public async Task> ConvertToPatchesAsy if (TryGetBundleTarget(conversion.Analysis, out var bundleTarget)) { - var bundleDestination = Path.Combine(Path.GetDirectoryName(file)!, $"{archivePrefix}.{bundleTarget}.szs"); + var bundleDestination = fileSystem.Path.Combine( + fileSystem.Path.GetDirectoryName(file)!, + $"{archivePrefix}.{bundleTarget}.szs" + ); foreach (var entry in conversion.Analysis.Entries) { if (IsDeletionPatchEntry(entry)) { - var deletionDestination = Path.Combine(Path.GetDirectoryName(file)!, entry.ExportPath); - fileSystem.Directory.CreateDirectory(Path.GetDirectoryName(deletionDestination)!); + var deletionDestination = fileSystem.Path.Combine( + fileSystem.Path.GetDirectoryName(file)!, + entry.ExportPath + ); + fileSystem.Directory.CreateDirectory(fileSystem.Path.GetDirectoryName(deletionDestination)!); fileSystem.File.WriteAllBytes(deletionDestination, entry.Bytes); writtenPatchCount++; continue; @@ -182,8 +192,8 @@ public async Task> ConvertToPatchesAsy { foreach (var entry in conversion.Analysis.Entries) { - var destination = Path.Combine(Path.GetDirectoryName(file)!, entry.ExportPath); - var destinationDirectory = Path.GetDirectoryName(destination)!; + var destination = fileSystem.Path.Combine(fileSystem.Path.GetDirectoryName(file)!, entry.ExportPath); + var destinationDirectory = fileSystem.Path.GetDirectoryName(destination)!; if (!fileSystem.Directory.Exists(destinationDirectory)) fileSystem.Directory.CreateDirectory(destinationDirectory); fileSystem.File.WriteAllBytes(destination, entry.Bytes); @@ -236,11 +246,11 @@ private OperationResult ConvertArchiveFile(string file) try { var fileBytes = fileSystem.File.ReadAllBytes(file); - var baseline = SelectBaseline(Path.GetFileName(file), fileBytes); + var baseline = SelectBaseline(fileSystem.Path.GetFileName(file), fileBytes); if (baseline == null) return new ArchiveConversion(null, new PatchConversionAnalysis()); - var analysisResult = AnalyzeArchive(baseline, Path.GetFileName(file), fileBytes); + var analysisResult = AnalyzeArchive(baseline, fileSystem.Path.GetFileName(file), fileBytes); if (analysisResult.IsFailure) return analysisResult.Error; @@ -289,16 +299,16 @@ private int EstimateDifference(BaselineEntry baseline, byte[] fileBytes) => private IReadOnlyList GetConvertibleArchiveFilesInDirectory(string directory) => fileSystem.Directory.EnumerateFiles(directory, "*", SearchOption.AllDirectories).Where(IsConvertibleArchiveFile).ToArray(); - private static bool IsConvertibleArchiveFile(string filePath) + private bool IsConvertibleArchiveFile(string filePath) { - var fileName = Path.GetFileName(filePath); + var fileName = fileSystem.Path.GetFileName(filePath); if (LooseBrsarPatchFileName.TryGetNormalizedFileName(fileName, out _)) return true; if (IsBrsarFileName(fileName)) return true; - return Path.GetExtension(filePath).Equals(".szs", StringComparison.OrdinalIgnoreCase) + return fileSystem.Path.GetExtension(filePath).Equals(".szs", StringComparison.OrdinalIgnoreCase) && !IsModdingArchiveFile(fileName) && !KartSzsAllowList.IsAllowedFullCharacterOrKart(fileName); } @@ -358,10 +368,12 @@ private int WriteArchiveBundles(Dictionary> a if (members.Count == 0) continue; - fileSystem.Directory.CreateDirectory(Path.GetDirectoryName(bundlePath)!); + fileSystem.Directory.CreateDirectory(fileSystem.Path.GetDirectoryName(bundlePath)!); if (fileSystem.File.Exists(bundlePath)) { - warnings.Add($"{Path.GetFileName(bundlePath)} already existed and was replaced with the converted archive bundle."); + warnings.Add( + $"{fileSystem.Path.GetFileName(bundlePath)} already existed and was replaced with the converted archive bundle." + ); fileSystem.File.Delete(bundlePath); } @@ -372,12 +384,12 @@ private int WriteArchiveBundles(Dictionary> a return writtenCount; } - private static bool IsModdingArchiveFile(string fileName) + private bool IsModdingArchiveFile(string fileName) { if (!fileName.EndsWith(".szs", StringComparison.OrdinalIgnoreCase)) return false; - var nameWithoutExtension = Path.GetFileNameWithoutExtension(fileName); + var nameWithoutExtension = fileSystem.Path.GetFileNameWithoutExtension(fileName); var tagSeparator = nameWithoutExtension.LastIndexOf('.'); return tagSeparator > 0 && tagSeparator + 1 < nameWithoutExtension.Length; } @@ -398,14 +410,16 @@ private void CopyDirectory(string sourceDirectory, string destinationDirectory, foreach (var directory in fileSystem.Directory.EnumerateDirectories(sourceDirectory, "*", SearchOption.AllDirectories)) { cancellationToken.ThrowIfCancellationRequested(); - fileSystem.Directory.CreateDirectory(Path.Combine(destinationDirectory, Path.GetRelativePath(sourceDirectory, directory))); + fileSystem.Directory.CreateDirectory( + fileSystem.Path.Combine(destinationDirectory, fileSystem.Path.GetRelativePath(sourceDirectory, directory)) + ); } foreach (var file in fileSystem.Directory.EnumerateFiles(sourceDirectory, "*", SearchOption.AllDirectories)) { cancellationToken.ThrowIfCancellationRequested(); - var destination = Path.Combine(destinationDirectory, Path.GetRelativePath(sourceDirectory, file)); - fileSystem.Directory.CreateDirectory(Path.GetDirectoryName(destination)!); + var destination = fileSystem.Path.Combine(destinationDirectory, fileSystem.Path.GetRelativePath(sourceDirectory, file)); + fileSystem.Directory.CreateDirectory(fileSystem.Path.GetDirectoryName(destination)!); fileSystem.File.Copy(file, destination, true); } } diff --git a/WheelWizard/Features/Patches/SzsPatchConverter.cs b/WheelWizard/Features/Patches/SzsPatchConverter.cs index e40d63553..ab73b04c8 100644 --- a/WheelWizard/Features/Patches/SzsPatchConverter.cs +++ b/WheelWizard/Features/Patches/SzsPatchConverter.cs @@ -5,6 +5,8 @@ namespace WheelWizard.Features.Patches; public sealed class SzsPatchConverter(ISzsArchiveDecoder archiveDecoder) : ISzsPatchConverter { + private static readonly System.IO.Abstractions.IPath NativePath = new Testably.Abstractions.RealFileSystem().Path; + public OperationResult AnalyzeAgainstBaseline(BaselineEntry baseline, string moddedName, byte[] moddedBytes) { // #todo: test conversion against real baselines, including changed, added, removed and unsupported archive members. @@ -197,7 +199,7 @@ private static string BuildTaggedPatchName(string logicalPath, string archiveTag private static bool IsBlockedLooseRawOverrideExtension(string path) { - var extension = Path.GetExtension(path); + var extension = NativePath.GetExtension(path); return extension.Equals(".kcl", StringComparison.OrdinalIgnoreCase) || extension.Equals(".kmp", StringComparison.OrdinalIgnoreCase) || extension.Equals(".slt", StringComparison.OrdinalIgnoreCase); @@ -205,7 +207,7 @@ private static bool IsBlockedLooseRawOverrideExtension(string path) private static string StripExtension(string fileName) { - var extension = Path.GetExtension(fileName); + var extension = NativePath.GetExtension(fileName); return string.IsNullOrEmpty(extension) ? fileName : fileName[..^extension.Length]; } } diff --git a/WheelWizard/Features/Recomp/RecompInstallService.cs b/WheelWizard/Features/Recomp/RecompInstallService.cs index 633fa1767..3844b2ea2 100644 --- a/WheelWizard/Features/Recomp/RecompInstallService.cs +++ b/WheelWizard/Features/Recomp/RecompInstallService.cs @@ -1,4 +1,4 @@ -using System.IO.Abstractions; +using System.IO.Abstractions; using System.Text.Json; using Microsoft.Extensions.Logging; using WheelWizard.Models.Enums; @@ -268,7 +268,7 @@ private bool IsInstallationBusy() { try { - var installRoot = Path.TrimEndingDirectorySeparator(fileSystem.Path.GetFullPath(environment.InstallFolderPath)); + var installRoot = fileSystem.Path.TrimEndingDirectorySeparator(fileSystem.Path.GetFullPath(environment.InstallFolderPath)); var parentFolderPath = fileSystem.Directory.GetParent(installRoot)?.FullName; if (parentFolderPath is null || !fileSystem.Directory.Exists(parentFolderPath)) return false; @@ -846,8 +846,8 @@ private bool PathsMatch(string? first, string? second) try { - var normalizedFirst = Path.TrimEndingDirectorySeparator(fileSystem.Path.GetFullPath(first)); - var normalizedSecond = Path.TrimEndingDirectorySeparator(fileSystem.Path.GetFullPath(second)); + var normalizedFirst = fileSystem.Path.TrimEndingDirectorySeparator(fileSystem.Path.GetFullPath(first)); + var normalizedSecond = fileSystem.Path.TrimEndingDirectorySeparator(fileSystem.Path.GetFullPath(second)); return normalizedFirst.Equals(normalizedSecond, StringComparison.OrdinalIgnoreCase); } catch (Exception exception) diff --git a/WheelWizard/Features/Recomp/RecompLinuxInstallService.cs b/WheelWizard/Features/Recomp/RecompLinuxInstallService.cs index 790325488..4871e809a 100644 --- a/WheelWizard/Features/Recomp/RecompLinuxInstallService.cs +++ b/WheelWizard/Features/Recomp/RecompLinuxInstallService.cs @@ -807,8 +807,8 @@ private bool PathsMatch(string? first, string? second) try { - var normalizedFirst = Path.TrimEndingDirectorySeparator(fileSystem.Path.GetFullPath(first)); - var normalizedSecond = Path.TrimEndingDirectorySeparator(fileSystem.Path.GetFullPath(second)); + var normalizedFirst = fileSystem.Path.TrimEndingDirectorySeparator(fileSystem.Path.GetFullPath(first)); + var normalizedSecond = fileSystem.Path.TrimEndingDirectorySeparator(fileSystem.Path.GetFullPath(second)); return normalizedFirst.Equals(normalizedSecond, StringComparison.Ordinal); } catch (Exception exception) diff --git a/WheelWizard/Features/Recomp/RecompPlatform.cs b/WheelWizard/Features/Recomp/RecompPlatform.cs index 9414e866e..57bd435ee 100644 --- a/WheelWizard/Features/Recomp/RecompPlatform.cs +++ b/WheelWizard/Features/Recomp/RecompPlatform.cs @@ -11,6 +11,8 @@ namespace WheelWizard.Recomp; /// public static class RecompPlatform { + private static readonly System.IO.Abstractions.IPath NativePath = new Testably.Abstractions.RealFileSystem().Path; + /// /// Whether this is a Linux build on an architecture the recomp publishes an AppImage for. The AppImage /// is always run unpacked (see ), so this holds inside a Flatpak @@ -43,9 +45,9 @@ public static class RecompPlatform public static string CachedSetupFileName(string tagName) { var sanitized = new string(tagName.Select(character => char.IsLetterOrDigit(character) ? character : '-').ToArray()); - return $"WiiCompiled-Setup-{sanitized}{Path.GetExtension(SetupFileName)}"; + return $"WiiCompiled-Setup-{sanitized}{NativePath.GetExtension(SetupFileName)}"; } /// The pattern that matches every cached setup of this platform, for pruning superseded ones. - public static string CachedSetupSearchPattern => $"WiiCompiled-Setup-*{Path.GetExtension(SetupFileName)}"; + public static string CachedSetupSearchPattern => $"WiiCompiled-Setup-*{NativePath.GetExtension(SetupFileName)}"; } diff --git a/WheelWizard/Features/Settings/Views/OtherSettings.axaml.cs b/WheelWizard/Features/Settings/Views/OtherSettings.axaml.cs index a10332d4e..5ba630c6f 100644 --- a/WheelWizard/Features/Settings/Views/OtherSettings.axaml.cs +++ b/WheelWizard/Features/Settings/Views/OtherSettings.axaml.cs @@ -1,3 +1,4 @@ +using System.IO.Abstractions; using Avalonia.Controls; using Avalonia.Interactivity; using WheelWizard.CustomDistributions; @@ -12,6 +13,8 @@ namespace WheelWizard.Settings.Views; public partial class OtherSettings : UserControl { + private readonly IFileSystem fileSystem; + private readonly bool _settingsAreDisabled; private IFilePickerService FilePicker { get; } @@ -23,12 +26,14 @@ public partial class OtherSettings : UserControl private ISettingsManager SettingsService { get; } public OtherSettings( + IFileSystem fileSystem, IFilePickerService filePicker, ICustomDistributionPaths distributionPaths, ICustomDistributionSingletonService customDistributionSingletonService, ISettingsManager settingsService ) { + this.fileSystem = fileSystem; FilePicker = filePicker; DistributionPaths = distributionPaths; CustomDistributionSingletonService = customDistributionSingletonService; @@ -41,7 +46,7 @@ ISettingsManager settingsService // controls individually so the recomp switch never becomes trapped behind Dolphin setup. LaunchRrOnStartup.IsEnabled = !_settingsAreDisabled; DolphinReinstallButton.IsEnabled = !_settingsAreDisabled; - OpenGameFolderButton.IsEnabled = !_settingsAreDisabled && Directory.Exists(DistributionPaths.RootFolderPath); + OpenGameFolderButton.IsEnabled = !_settingsAreDisabled && fileSystem.Directory.Exists(DistributionPaths.RootFolderPath); OpenSaveFolderButton.IsEnabled = !_settingsAreDisabled; if (!_settingsAreDisabled) LoadSettings(); @@ -57,8 +62,8 @@ private void LoadSettings() { // Only loads when the settings are not disabled (aka when the paths are set up correctly) LaunchRrOnStartup.IsChecked = SettingsService.Get(SettingsService.LAUNCH_RR_ON_STARTUP); - OpenGameFolderButton.IsEnabled = Directory.Exists(DistributionPaths.RootFolderPath); - OpenSaveFolderButton.IsEnabled = Directory.Exists(DistributionPaths.SaveFolderPath); + OpenGameFolderButton.IsEnabled = fileSystem.Directory.Exists(DistributionPaths.RootFolderPath); + OpenSaveFolderButton.IsEnabled = fileSystem.Directory.Exists(DistributionPaths.SaveFolderPath); } private void ForceLoadSettings() @@ -119,7 +124,7 @@ private void OpenSaveFolder_OnClick(object? sender, RoutedEventArgs e) private void GameFileFolder_Click(object? sender, RoutedEventArgs e) { - if (!Directory.Exists(DistributionPaths.RootFolderPath)) + if (!fileSystem.Directory.Exists(DistributionPaths.RootFolderPath)) return; FilePicker.OpenFolderInFileManager(DistributionPaths.RootFolderPath); diff --git a/WheelWizard/Features/Settings/Views/RecompSettings.axaml.cs b/WheelWizard/Features/Settings/Views/RecompSettings.axaml.cs index 5f222e689..cc1bfa0e2 100644 --- a/WheelWizard/Features/Settings/Views/RecompSettings.axaml.cs +++ b/WheelWizard/Features/Settings/Views/RecompSettings.axaml.cs @@ -1,3 +1,4 @@ +using System.IO.Abstractions; using Avalonia.Controls; using Avalonia.Interactivity; using WheelWizard.Recomp; @@ -12,6 +13,8 @@ namespace WheelWizard.Settings.Views; public partial class RecompSettings : UserControl { + private readonly IFileSystem fileSystem; + private bool _loading; private IFilePickerService FilePicker { get; } @@ -29,6 +32,7 @@ public partial class RecompSettings : UserControl private IRecompPaths RecompPaths { get; } public RecompSettings( + IFileSystem fileSystem, IFilePickerService filePicker, ISettingsManager settingsService, IRecompSettingManager recompSettingsFile, @@ -38,6 +42,7 @@ public RecompSettings( IRecompInstallService? recompInstallService = null ) { + this.fileSystem = fileSystem; FilePicker = filePicker; SettingsService = settingsService; RecompSettingsFile = recompSettingsFile; @@ -74,7 +79,7 @@ private void LoadSettings() var installFolder = RecompEnvironment?.InstallFolderPath ?? RecompPaths.InstallFolderPath; InstallLocationText.Text = installFolder; - OpenInstallFolder.IsEnabled = installed && Directory.Exists(installFolder); + OpenInstallFolder.IsEnabled = installed && fileSystem.Directory.Exists(installFolder); UninstallButton.IsEnabled = installed; WiiCompiledVersionText.Text = t( "helper_text.installed_version", @@ -90,7 +95,7 @@ private void LoadSettings() SharedNandWarningIcon.IsVisible = sharingDolphinData; var cloneFolder = RecompPaths.NandCopyFolderPath; - DolphinCloneStatus.Text = Directory.Exists(cloneFolder) + DolphinCloneStatus.Text = fileSystem.Directory.Exists(cloneFolder) ? t("status.recomp_dolphin_clone_available", new { path = cloneFolder }) : t("status.recomp_dolphin_clone_missing"); } @@ -269,7 +274,7 @@ private async void CloneDolphinData_OnClick(object? sender, RoutedEventArgs e) } var cloneFolder = RecompPaths.NandCopyFolderPath; - if (Directory.Exists(cloneFolder)) + if (fileSystem.Directory.Exists(cloneFolder)) { var overwrite = await new YesNoWindow() .SetMainText(t("question.recomp_overwrite_dolphin_clone.title")) @@ -329,7 +334,7 @@ private async Task ApplyNandSettingAsync() private void OpenInstallFolder_OnClick(object? sender, RoutedEventArgs e) { var installFolder = RecompEnvironment?.InstallFolderPath ?? RecompPaths.InstallFolderPath; - if (Directory.Exists(installFolder)) + if (fileSystem.Directory.Exists(installFolder)) FilePicker.OpenFolderInFileManager(installFolder); } diff --git a/WheelWizard/Features/Settings/Views/WhWzSettings.axaml.cs b/WheelWizard/Features/Settings/Views/WhWzSettings.axaml.cs index 7ed71ab4c..5c4f795f3 100644 --- a/WheelWizard/Features/Settings/Views/WhWzSettings.axaml.cs +++ b/WheelWizard/Features/Settings/Views/WhWzSettings.axaml.cs @@ -1,3 +1,4 @@ +using System.IO.Abstractions; using System.Runtime.InteropServices; using Avalonia.Controls; using Avalonia.Interactivity; @@ -24,6 +25,8 @@ namespace WheelWizard.Settings.Views; public partial class WhWzSettings : UserControl { + private readonly IFileSystem fileSystem; + // #todo: move settings state and location-change workflows into a view model so this page mostly handles the controls. private readonly IApplicationLogFiles _logFiles; @@ -44,6 +47,7 @@ public partial class WhWzSettings : UserControl private IDiscImageService DiscImages { get; } public WhWzSettings( + IFileSystem fileSystem, IApplicationLogFiles logFiles, IFilePickerService filePicker, IDolphinPaths dolphinPaths, @@ -54,6 +58,7 @@ public WhWzSettings( IDiscImageService discImages ) { + this.fileSystem = fileSystem; DiscImages = discImages; FilePicker = filePicker; DolphinPaths = dolphinPaths; @@ -166,7 +171,7 @@ private async void DolphinExeBrowse_OnClick(object sender, RoutedEventArgs e) if (string.IsNullOrWhiteSpace(resolvedFolder)) return; - var executablePath = Path.Combine(resolvedFolder, "Contents", "MacOS", "Dolphin"); + var executablePath = fileSystem.Path.Combine(resolvedFolder, "Contents", "MacOS", "Dolphin"); await ApplyLocationSettingAsync(SettingsService.DOLPHIN_LOCATION, ShellQuoting.QuoteUnixArgument(executablePath)); } @@ -178,7 +183,7 @@ private async void DolphinExeBrowse_OnClick(object sender, RoutedEventArgs e) { if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { - if (string.Equals(Path.GetFileName(filePath), "DolphinTool.exe", StringComparison.OrdinalIgnoreCase)) + if (string.Equals(fileSystem.Path.GetFileName(filePath), "DolphinTool.exe", StringComparison.OrdinalIgnoreCase)) { await MessageTranslationHelper.AwaitMessageAsync(MessageTranslation.Warning_DolphinToolSelected); return; @@ -283,7 +288,7 @@ private async void DolphinUserPathBrowse_OnClick(object sender, RoutedEventArgs var currentFolder = (string)SettingsService.USER_FOLDER_PATH.Get(); var topLevel = TopLevel.GetTopLevel(this); // If a current folder exists and is valid, suggest it as the starting location - if (!string.IsNullOrEmpty(currentFolder) && Directory.Exists(currentFolder)) + if (!string.IsNullOrEmpty(currentFolder) && fileSystem.Directory.Exists(currentFolder)) { var folder = await topLevel!.StorageProvider.TryGetFolderFromPathAsync(currentFolder); var folders = await FilePicker.SelectFolderAsync("Select Dolphin User Path", folder); @@ -312,7 +317,7 @@ private async void DolphinUserPathBrowse_OnClick(object sender, RoutedEventArgs private async Task ApplyLocationSettingAsync(Setting setting, string path) { - var normalizedPath = setting == SettingsService.USER_FOLDER_PATH ? Path.TrimEndingDirectorySeparator(path) : path; + var normalizedPath = setting == SettingsService.USER_FOLDER_PATH ? fileSystem.Path.TrimEndingDirectorySeparator(path) : path; if (!SettingsEditing.Set(SettingsService, setting, normalizedPath)) { @@ -333,7 +338,7 @@ private void GameLocationOpen_OnClick(object sender, RoutedEventArgs e) => private void DolphinUserFolderOpen_OnClick(object sender, RoutedEventArgs e) { - if (Directory.Exists(DolphinPaths.UserFolderPath)) + if (!string.IsNullOrWhiteSpace(DolphinPaths.UserFolderPath) && fileSystem.Directory.Exists(DolphinPaths.UserFolderPath)) FilePicker.OpenFolderInFileManager(DolphinPaths.UserFolderPath); } @@ -342,8 +347,8 @@ private void OpenContainingFolder(string filePath) try { var unquotedPath = filePath.Trim().Trim('\'', '"'); - var folderPath = Path.GetDirectoryName(Path.GetFullPath(unquotedPath)); - if (!string.IsNullOrWhiteSpace(folderPath) && Directory.Exists(folderPath)) + var folderPath = fileSystem.Path.GetDirectoryName(fileSystem.Path.GetFullPath(unquotedPath)); + if (!string.IsNullOrWhiteSpace(folderPath) && fileSystem.Directory.Exists(folderPath)) FilePicker.OpenFolderInFileManager(folderPath); } catch @@ -354,8 +359,8 @@ private void OpenContainingFolder(string filePath) private void AppDataLocationOpen_OnClick(object sender, RoutedEventArgs e) { - if (!Directory.Exists(ApplicationData.DirectoryPath)) - Directory.CreateDirectory(ApplicationData.DirectoryPath); + if (!fileSystem.Directory.Exists(ApplicationData.DirectoryPath)) + fileSystem.Directory.CreateDirectory(ApplicationData.DirectoryPath); FilePicker.OpenFolderInFileManager(ApplicationData.DirectoryPath); } @@ -391,7 +396,9 @@ private void UpdateLocationRows() DolphinExecutableOpenButton.IsVisible && CanOpenContainingFolder(DolphinPaths.ExecutablePath); GameLocationOpenButton.IsEnabled = CanOpenContainingFolder(SettingsService.Get(SettingsService.GAME_LOCATION)); UpdateGameFileDetails(); - DolphinUserFolderOpenButton.IsEnabled = Directory.Exists(DolphinPaths.UserFolderPath); + DolphinUserFolderOpenButton.IsEnabled = ( + !string.IsNullOrWhiteSpace(DolphinPaths.UserFolderPath) && fileSystem.Directory.Exists(DolphinPaths.UserFolderPath) + ); } private static void SetLocationRowState(PathIcon completeIcon, PathIcon warningIcon, SettingsButton changeButton, bool isValid) @@ -401,13 +408,13 @@ private static void SetLocationRowState(PathIcon completeIcon, PathIcon warningI changeButton.Tone = isValid ? ActionButtonTone.Secondary : ActionButtonTone.Brand; } - private static bool CanOpenContainingFolder(string filePath) + private bool CanOpenContainingFolder(string filePath) { try { var unquotedPath = filePath.Trim().Trim('\'', '"'); - var folderPath = Path.GetDirectoryName(Path.GetFullPath(unquotedPath)); - return !string.IsNullOrWhiteSpace(folderPath) && Directory.Exists(folderPath); + var folderPath = fileSystem.Path.GetDirectoryName(fileSystem.Path.GetFullPath(unquotedPath)); + return !string.IsNullOrWhiteSpace(folderPath) && fileSystem.Directory.Exists(folderPath); } catch { @@ -415,7 +422,8 @@ private static bool CanOpenContainingFolder(string filePath) } } - private static bool IsConfiguredExecutableFile(string value) => File.Exists(value.Trim().Trim('\'', '"')); + private bool IsConfiguredExecutableFile(string value) => + !string.IsNullOrWhiteSpace(value.Trim().Trim('\'', '"')) && fileSystem.File.Exists(value.Trim().Trim('\'', '"')); private void UpdateAppDataLocationUi() { @@ -661,7 +669,7 @@ private async void AppDataLocationChange_OnClick(object sender, RoutedEventArgs IStorageFolder? suggestedStart = null; var currentPath = ApplicationData.DirectoryPath; - if (!string.IsNullOrWhiteSpace(currentPath) && Directory.Exists(currentPath)) + if (!string.IsNullOrWhiteSpace(currentPath) && fileSystem.Directory.Exists(currentPath)) suggestedStart = await topLevel!.StorageProvider.TryGetFolderFromPathAsync(currentPath); var folders = await FilePicker.SelectFolderAsync("Select Wheel Wizard data folder", suggestedStart); diff --git a/WheelWizard/Features/WiiManagement/MiiManagement/Views/MiiListPage.axaml.cs b/WheelWizard/Features/WiiManagement/MiiManagement/Views/MiiListPage.axaml.cs index 43c6b7f12..b15a63caa 100644 --- a/WheelWizard/Features/WiiManagement/MiiManagement/Views/MiiListPage.axaml.cs +++ b/WheelWizard/Features/WiiManagement/MiiManagement/Views/MiiListPage.axaml.cs @@ -322,7 +322,7 @@ private void ExportMultipleMiiFiles(Mii[] miis) public static string ReplaceInvalidFileNameChars(string filename) { - var invalid = Path.GetInvalidFileNameChars(); + var invalid = new Testably.Abstractions.RealFileSystem().Path.GetInvalidFileNameChars(); return string.Join("_", filename.Split(invalid, StringSplitOptions.RemoveEmptyEntries)); } diff --git a/WheelWizard/Program.cs b/WheelWizard/Program.cs index 4fd2ef691..1f15a54b2 100644 --- a/WheelWizard/Program.cs +++ b/WheelWizard/Program.cs @@ -162,7 +162,8 @@ private static AppBuilder CreateWheelWizardApp(Func> creat private static void SetupWorkingDirectory() { - if (new RuntimeEnvironment().IsFlatpakSandboxed(new RealFileSystem())) + var fileSystem = new RealFileSystem(); + if (new RuntimeEnvironment().IsFlatpakSandboxed(fileSystem)) { // In this case, we would not want executable directory-relative paths, since this is in `/app/bin`. // We are going to use the home directory instead (this should be the original working directory anyway). @@ -171,7 +172,7 @@ private static void SetupWorkingDirectory() else { // Resolve all relative paths based on the WheelWizard executable's directory by default - var executableDirectory = Path.GetDirectoryName(Environment.ProcessPath); + var executableDirectory = fileSystem.Path.GetDirectoryName(Environment.ProcessPath); if (!string.IsNullOrWhiteSpace(executableDirectory)) Environment.CurrentDirectory = executableDirectory; } @@ -182,7 +183,7 @@ private static void SetupWorkingDirectory() var whWzBaseDir = Environment.GetEnvironmentVariable("WW_BASEDIR") ?? string.Empty; try { - var whWzBaseDirAbsolute = Path.GetFullPath(whWzBaseDir); + var whWzBaseDirAbsolute = fileSystem.Path.GetFullPath(whWzBaseDir); Environment.CurrentDirectory = whWzBaseDirAbsolute; } catch diff --git a/WheelWizard/Shared/Desktop/Storage/StoragePaths.cs b/WheelWizard/Shared/Desktop/Storage/StoragePaths.cs index 94b2dc235..69737eecf 100644 --- a/WheelWizard/Shared/Desktop/Storage/StoragePaths.cs +++ b/WheelWizard/Shared/Desktop/Storage/StoragePaths.cs @@ -4,6 +4,8 @@ namespace WheelWizard.Shared.Desktop.Storage; public static class StoragePaths { + private static readonly System.IO.Abstractions.IPath NativePath = new Testably.Abstractions.RealFileSystem().Path; + public static string? TryResolveLocalPath(IStorageItem? item) { if (item == null) @@ -38,7 +40,7 @@ public static class StoragePaths if (uri.IsAbsoluteUri) return null; var raw = uri.ToString(); - if (!string.IsNullOrWhiteSpace(raw) && Path.IsPathRooted(raw)) + if (!string.IsNullOrWhiteSpace(raw) && NativePath.IsPathRooted(raw)) return raw; } diff --git a/WheelWizard/Shared/IO/PathSafety.cs b/WheelWizard/Shared/IO/PathSafety.cs index 33dcf234b..a008a1ed1 100644 --- a/WheelWizard/Shared/IO/PathSafety.cs +++ b/WheelWizard/Shared/IO/PathSafety.cs @@ -2,6 +2,8 @@ namespace WheelWizard.Shared.IO; public static class PathSafety { + private static readonly System.IO.Abstractions.IPath NativePath = new Testably.Abstractions.RealFileSystem().Path; + public static bool TryGetPathWithinDirectory(string directory, string relativePath, out string fullPath) { fullPath = string.Empty; @@ -9,9 +11,9 @@ public static bool TryGetPathWithinDirectory(string directory, string relativePa if (!TryNormalizeRelativePath(relativePath, out var normalizedRelativePath)) return false; - var destinationPath = Path.Combine(directory, normalizedRelativePath); - var fullDirectory = Path.GetFullPath(directory); - var candidatePath = Path.GetFullPath(destinationPath); + var destinationPath = NativePath.Combine(directory, normalizedRelativePath); + var fullDirectory = NativePath.GetFullPath(directory); + var candidatePath = NativePath.GetFullPath(destinationPath); if (!IsPathWithinDirectory(fullDirectory, candidatePath)) return false; @@ -25,16 +27,16 @@ public static bool IsPathWithinDirectory(string directory, string path) if (string.IsNullOrWhiteSpace(directory) || string.IsNullOrWhiteSpace(path)) return false; - var fullDirectory = Path.GetFullPath(directory); - var fullPath = Path.GetFullPath(path); - var relativePath = Path.GetRelativePath(fullDirectory, fullPath); + var fullDirectory = NativePath.GetFullPath(directory); + var fullPath = NativePath.GetFullPath(path); + var relativePath = NativePath.GetRelativePath(fullDirectory, fullPath); return relativePath == "." || ( - !Path.IsPathRooted(relativePath) + !NativePath.IsPathRooted(relativePath) && !relativePath.Equals("..", StringComparison.Ordinal) - && !relativePath.StartsWith($"..{Path.DirectorySeparatorChar}", StringComparison.Ordinal) - && !relativePath.StartsWith($"..{Path.AltDirectorySeparatorChar}", StringComparison.Ordinal) + && !relativePath.StartsWith($"..{NativePath.DirectorySeparatorChar}", StringComparison.Ordinal) + && !relativePath.StartsWith($"..{NativePath.AltDirectorySeparatorChar}", StringComparison.Ordinal) ); } @@ -46,7 +48,12 @@ public static bool TryNormalizeRelativePath(string path, out string normalizedPa return false; var trimmedPath = path.Trim(); - if (Path.IsPathFullyQualified(trimmedPath) || trimmedPath.StartsWith('/') || trimmedPath.StartsWith('\\')) + if ( + NativePath.IsPathFullyQualified(trimmedPath) + || trimmedPath.StartsWith('/') + || trimmedPath.StartsWith('\\') + || IsWindowsDriveRooted(trimmedPath) + ) return false; var segments = trimmedPath.Replace('\\', '/').Split('/', StringSplitOptions.RemoveEmptyEntries).Where(segment => segment != "."); @@ -54,7 +61,7 @@ public static bool TryNormalizeRelativePath(string path, out string normalizedPa var safeSegments = new List(); foreach (var segment in segments) { - if (segment == ".." || segment.Contains(Path.VolumeSeparatorChar)) + if (segment == ".." || segment.Contains(NativePath.VolumeSeparatorChar)) return false; safeSegments.Add(segment); @@ -63,10 +70,14 @@ public static bool TryNormalizeRelativePath(string path, out string normalizedPa if (safeSegments.Count == 0) return false; - normalizedPath = Path.Combine(safeSegments.ToArray()); + normalizedPath = NativePath.Combine(safeSegments.ToArray()); return true; } + // Windows drive paths are rejected on every platform, even where ':' is a valid filename character. + private static bool IsWindowsDriveRooted(string path) => + path.Length >= 2 && char.IsAsciiLetter(path[0]) && path[1] == ':' && (path.Length == 2 || path[2] is '/' or '\\'); + public static bool TryGetSafeFileName(string fileName, out string safeFileName) { safeFileName = string.Empty; @@ -78,7 +89,7 @@ public static bool TryGetSafeFileName(string fileName, out string safeFileName) if (string.IsNullOrWhiteSpace(leafName) || leafName is "." or "..") return false; - if (leafName.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0) + if (leafName.IndexOfAny(NativePath.GetInvalidFileNameChars()) >= 0) return false; safeFileName = leafName; diff --git a/WheelWizard/Views/Shell/Layout.axaml.cs b/WheelWizard/Views/Shell/Layout.axaml.cs index 0072958b4..bf2416c88 100644 --- a/WheelWizard/Views/Shell/Layout.axaml.cs +++ b/WheelWizard/Views/Shell/Layout.axaml.cs @@ -442,7 +442,7 @@ private void UpdateLiveAlert(LiveStatusService sender) if (this.FindControl("LiveStatusIcon") is { } pathIcon) { // Parse the SVG path data - var geometry = Geometry.Parse(sender.Status.Icon!); + var geometry = Geometry.Parse(sender.Status!.Icon!); pathIcon.Data = geometry; // Apply custom color if provided, otherwise use a default @@ -461,7 +461,7 @@ private void UpdateLiveAlert(LiveStatusService sender) LiveStatusIcon.ClearValue(PathIcon.DataProperty); LiveStatusIcon.ClearValue(PathIcon.ForegroundProperty); // Use variant-based styling - LiveStatusBorder.Classes.Add(sender.Status.Variant.ToString()!); + LiveStatusBorder.Classes.Add(sender.Status!.Variant.ToString()!); } }