From d0f635530e3d3744f21769216b8b4141b48379c8 Mon Sep 17 00:00:00 2001 From: patchzyy <64382339+patchzyy@users.noreply.github.com> Date: Thu, 15 Oct 2026 19:48:38 +0200 Subject: [PATCH 01/10] Onboarding start --- .../RetroRewindBackgroundInstallTests.cs | 77 +++ .../RetroRewindMigrationServiceTests.cs | 201 ++++++ .../Dolphin/FeaturePathOwnershipTests.cs | 27 +- .../Features/DolphinReleaseClientTests.cs | 68 ++ .../Features/ManagedDolphinServiceTests.cs | 133 ++++ .../Features/MiiRepositoryServiceTests.cs | 4 +- .../Onboarding/OnboardingServiceTests.cs | 124 ++++ .../Features/Recomp/RecompConfigPathsTests.cs | 107 +++ .../Features/Recomp/RecompLauncherTests.cs | 8 +- .../Shell/Startup/DesktopStartupTests.cs | 4 + .../ViewModels/HomeDolphinLaunchTests.cs | 3 +- .../Shell/ViewModels/HomeViewModelTests.cs | 34 +- .../ApplicationCompositionTests.cs | 2 + WheelWizard.UI.Test/OnboardingShellTests.cs | 78 +++ .../ApplicationLifecycleExtensions.cs | 1 + .../StartupMaintenance.cs | 67 ++ .../CustomDistributionPaths.cs | 92 ++- .../CustomDistributionsExtentions.cs | 2 + .../DistributionOperation.cs | 7 +- .../CustomDistributions/RetroRewind.cs | 3 +- .../RetroRewindBackgroundInstall.cs | 127 ++++ .../RetroRewindMigrationService.cs | 173 +++++ .../Features/Dolphin/Paths/DolphinPaths.cs | 5 + .../DolphinInstallerExtensions.cs | 2 + .../DolphinInstaller/DolphinReleaseClient.cs | 101 +++ .../DolphinInstaller/ManagedDolphinService.cs | 557 +++++++++++++++ .../Onboarding/OnboardingExtensions.cs | 10 + .../Features/Onboarding/OnboardingService.cs | 310 +++++++++ .../Onboarding/Views/OnboardingPage.axaml | 235 +++++++ .../Onboarding/Views/OnboardingPage.axaml.cs | 642 ++++++++++++++++++ .../Recomp/RecompDolphinDataService.cs | 113 ++- WheelWizard/Features/Recomp/RecompLauncher.cs | 48 +- .../Features/Settings/ISettingsServices.cs | 3 + .../Features/Settings/SettingsManager.cs | 10 + .../Settings/Views/RecompSettings.axaml.cs | 2 +- .../Settings/Views/WhWzSettings.axaml | 12 + .../Settings/Views/WhWzSettings.axaml.cs | 123 +++- WheelWizard/Resources/Languages/en.yml | 72 ++ WheelWizard/SetupExtensions.cs | 2 + WheelWizard/Views/Shell/Layout.axaml.cs | 14 +- .../Shell/Navigation/NavigationService.cs | 13 + .../Views/Shell/PresentationExtensions.cs | 1 + .../Views/Shell/Startup/DesktopStartup.cs | 9 + .../Startup/ManagedDolphinUpdatePrompt.cs | 64 ++ .../Views/Shell/Startup/SplashWindow.axaml | 14 +- .../Views/Shell/Startup/SplashWindow.axaml.cs | 8 + .../Views/Shell/ViewModels/HomeViewModel.cs | 96 ++- WheelWizard/Views/Shell/Views/HomePage.axaml | 18 + .../Views/Shell/Views/HomePage.axaml.cs | 2 + 49 files changed, 3741 insertions(+), 87 deletions(-) create mode 100644 WheelWizard.Test/Features/CustomDistributions/RetroRewindBackgroundInstallTests.cs create mode 100644 WheelWizard.Test/Features/CustomDistributions/RetroRewindMigrationServiceTests.cs create mode 100644 WheelWizard.Test/Features/DolphinReleaseClientTests.cs create mode 100644 WheelWizard.Test/Features/ManagedDolphinServiceTests.cs create mode 100644 WheelWizard.Test/Features/Onboarding/OnboardingServiceTests.cs create mode 100644 WheelWizard.Test/Features/Recomp/RecompConfigPathsTests.cs create mode 100644 WheelWizard.UI.Test/OnboardingShellTests.cs create mode 100644 WheelWizard/Features/ApplicationLifecycle/StartupMaintenance.cs create mode 100644 WheelWizard/Features/CustomDistributions/RetroRewindBackgroundInstall.cs create mode 100644 WheelWizard/Features/CustomDistributions/RetroRewindMigrationService.cs create mode 100644 WheelWizard/Features/DolphinInstaller/DolphinReleaseClient.cs create mode 100644 WheelWizard/Features/DolphinInstaller/ManagedDolphinService.cs create mode 100644 WheelWizard/Features/Onboarding/OnboardingExtensions.cs create mode 100644 WheelWizard/Features/Onboarding/OnboardingService.cs create mode 100644 WheelWizard/Features/Onboarding/Views/OnboardingPage.axaml create mode 100644 WheelWizard/Features/Onboarding/Views/OnboardingPage.axaml.cs create mode 100644 WheelWizard/Views/Shell/Startup/ManagedDolphinUpdatePrompt.cs diff --git a/WheelWizard.Test/Features/CustomDistributions/RetroRewindBackgroundInstallTests.cs b/WheelWizard.Test/Features/CustomDistributions/RetroRewindBackgroundInstallTests.cs new file mode 100644 index 000000000..eb28b5632 --- /dev/null +++ b/WheelWizard.Test/Features/CustomDistributions/RetroRewindBackgroundInstallTests.cs @@ -0,0 +1,77 @@ +using Microsoft.Extensions.Logging.Abstractions; +using WheelWizard.CustomDistributions; + +namespace WheelWizard.Test.Features.CustomDistributions; + +public class RetroRewindBackgroundInstallTests +{ + [Fact] + public async Task Start_RunsOneUnattendedInstall_AndReportsCompletion() + { + var distribution = Substitute.For(); + DistributionOperation? seen = null; + distribution + .UpdateAsync(Arg.Do(operation => seen = operation)) + .Returns(_ => + { + seen!.Report(new(Percent: 40)); + return Task.FromResult(Ok()); + }); + var install = Create(distribution, new ImmediateTimeProvider()); + var percents = new List(); + install.Changed += (_, _) => percents.Add(install.Percent); + + var first = install.StartAsync(); + var second = install.StartAsync(); + + Assert.True((await first).IsSuccess); + Assert.Same(first, second); + Assert.True(seen!.Unattended); + Assert.Contains(40, percents); + Assert.Equal(BackgroundInstallState.Completed, install.State); + await distribution.Received(1).UpdateAsync(Arg.Any()); + } + + [Fact] + public async Task Failure_RetriesWithBackoff_ThenWaitsForAManualRetry() + { + var distribution = Substitute.For(); + distribution.UpdateAsync(Arg.Any()).Returns(Fail("offline")); + var install = Create(distribution, new ImmediateTimeProvider()); + + Assert.True((await install.StartAsync()).IsFailure); + Assert.Equal(BackgroundInstallState.Failed, install.State); + Assert.Equal("offline", install.ErrorMessage); + await distribution.Received(3).UpdateAsync(Arg.Any()); + + distribution.UpdateAsync(Arg.Any()).Returns(Ok()); + Assert.True((await install.RetryAsync()).IsSuccess); + Assert.Equal(BackgroundInstallState.Completed, install.State); + } + + private static RetroRewindBackgroundInstall Create(IDistribution distribution, TimeProvider time) + { + var distributions = Substitute.For(); + distributions.RetroRewind.Returns(distribution); + return new RetroRewindBackgroundInstall(distributions, time, NullLogger.Instance); + } + + /// Lets the retry backoff elapse at once. + private sealed class ImmediateTimeProvider : TimeProvider + { + public override ITimer CreateTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period) + { + ThreadPool.QueueUserWorkItem(_ => callback(state)); + return new NoTimer(); + } + + private sealed class NoTimer : ITimer + { + public bool Change(TimeSpan dueTime, TimeSpan period) => true; + + public void Dispose() { } + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + } + } +} diff --git a/WheelWizard.Test/Features/CustomDistributions/RetroRewindMigrationServiceTests.cs b/WheelWizard.Test/Features/CustomDistributions/RetroRewindMigrationServiceTests.cs new file mode 100644 index 000000000..96a6540d7 --- /dev/null +++ b/WheelWizard.Test/Features/CustomDistributions/RetroRewindMigrationServiceTests.cs @@ -0,0 +1,201 @@ +using Microsoft.Extensions.Logging.Abstractions; +using Testably.Abstractions.Testing; +using WheelWizard.ApplicationData; +using WheelWizard.CustomDistributions; +using WheelWizard.Dolphin.Paths; +using WheelWizard.Shared.IO; + +namespace WheelWizard.Test.Features.CustomDistributions; + +public class RetroRewindMigrationServiceTests +{ + private const string Legacy = "/dolphin-user/Load/Riivolution/WheelWizard"; + private const string Managed = "/launcher-data/RetroRewind"; + + [Fact] + public void Migrate_MovesLegacyInstall_WithSavesAndMarksItMoved() + { + var fixture = new Fixture(); + fixture.Fs.Directory.CreateDirectory($"{Legacy}/RetroRewind6"); + fixture.Fs.File.WriteAllText($"{Legacy}/RetroRewind6/version.txt", "6.5.0"); + fixture.Fs.Directory.CreateDirectory($"{Legacy}/riivolution/save/RetroWFC/RMCP"); + fixture.Fs.File.WriteAllText($"{Legacy}/riivolution/save/RetroWFC/RMCP/rksys.dat", "save"); + Assert.True(fixture.Service.IsPending); + + var result = fixture.Service.Migrate(); + + Assert.Equal(RetroRewindMigrationOutcome.Moved, result.Value); + Assert.Equal("save", fixture.Fs.File.ReadAllText($"{Managed}/riivolution/save/RetroWFC/RMCP/rksys.dat")); + Assert.True(fixture.Fs.File.Exists(fixture.Paths.MigratedMarkerFilePath)); + Assert.False(fixture.Fs.Directory.Exists(Legacy)); + Assert.Equal(Managed, fixture.Paths.RootFolderPath); + Assert.False(fixture.Service.IsPending); + } + + [Fact] + public void Migrate_KeepsAnExistingManagedInstall_AsBackup() + { + var fixture = new Fixture(); + fixture.Fs.Directory.CreateDirectory($"{Legacy}/RetroRewind6"); + fixture.Fs.File.WriteAllText($"{Legacy}/RetroRewind6/version.txt", "active"); + fixture.Fs.Directory.CreateDirectory($"{Managed}/RetroRewind6"); + fixture.Fs.File.WriteAllText($"{Managed}/RetroRewind6/version.txt", "older"); + + var result = fixture.Service.Migrate(); + + Assert.Equal(RetroRewindMigrationOutcome.Moved, result.Value); + Assert.Equal("active", fixture.Fs.File.ReadAllText($"{Managed}/RetroRewind6/version.txt")); + var backup = Assert.Single(fixture.Fs.Directory.GetDirectories("/launcher-data", "RetroRewind.old-*")); + Assert.Equal("older", fixture.Fs.File.ReadAllText(fixture.Fs.Path.Combine(backup, "RetroRewind6", "version.txt"))); + } + + [Fact] + public void Migrate_IsNotNeeded_WithoutLegacyInstall() + { + var fixture = new Fixture(); + fixture.Fs.Directory.CreateDirectory(Legacy); + + var result = fixture.Service.Migrate(); + + Assert.Equal(RetroRewindMigrationOutcome.NotNeeded, result.Value); + Assert.True(fixture.Fs.File.Exists(fixture.Paths.MigratedMarkerFilePath)); + + // An old install that shows up later (another Dolphin picked in settings) never takes over. + fixture.Fs.Directory.CreateDirectory($"{Legacy}/RetroRewind6"); + Assert.False(fixture.Service.IsPending); + Assert.Equal(Managed, fixture.Paths.RootFolderPath); + } + + [Fact] + public void Migrate_IsNotNeeded_WithoutAnyDolphinFolder() + { + var fixture = new Fixture(userFolder: "", loadFolder: ""); + + var result = fixture.Service.Migrate(); + + Assert.Equal(RetroRewindMigrationOutcome.NotNeeded, result.Value); + Assert.True(fixture.Fs.File.Exists(fixture.Paths.MigratedMarkerFilePath)); + } + + [Fact] + public void Migrate_WaitsForAnUnreachableDolphinFolder_InsteadOfForgettingTheInstallInIt() + { + // The Dolphin folder lives on a drive that is not plugged in during this start. + var fixture = new Fixture(); + + var result = fixture.Service.Migrate(); + + Assert.Equal(RetroRewindMigrationOutcome.Deferred, result.Value); + Assert.False(fixture.Fs.File.Exists(fixture.Paths.MigratedMarkerFilePath)); + + // The drive is back on the next start, with the install and its saves. + fixture.Fs.Directory.CreateDirectory($"{Legacy}/riivolution/save/RetroWFC/RMCP"); + fixture.Fs.File.WriteAllText($"{Legacy}/riivolution/save/RetroWFC/RMCP/rksys.dat", "save"); + Assert.True(fixture.Service.IsPending); + + Assert.Equal(RetroRewindMigrationOutcome.Moved, fixture.Service.Migrate().Value); + Assert.Equal("save", fixture.Fs.File.ReadAllText($"{Managed}/riivolution/save/RetroWFC/RMCP/rksys.dat")); + } + + [Fact] + public void Migrate_WaitsForAMissingCustomLoadFolder_EvenWhenTheUserFolderIsThere() + { + var fixture = new Fixture(loadFolder: "/usb/Load"); + fixture.Fs.Directory.CreateDirectory("/dolphin-user/Load"); + + Assert.Equal(RetroRewindMigrationOutcome.Deferred, fixture.Service.Migrate().Value); + Assert.False(fixture.Fs.File.Exists(fixture.Paths.MigratedMarkerFilePath)); + } + + [Fact] + public void Migrate_CopiesAcrossDrives_ThroughAStagingFolder() + { + var fixture = CrossDriveFixture.Create(); + fixture.Fs.Directory.CreateDirectory(@"D:\dolphin-user\Load\Riivolution\WheelWizard\riivolution\save"); + fixture.Fs.File.WriteAllText(@"D:\dolphin-user\Load\Riivolution\WheelWizard\riivolution\save\rksys.dat", "save"); + var copyStarted = false; + + var result = fixture.Service.Migrate(() => copyStarted = true); + + Assert.Equal(RetroRewindMigrationOutcome.Moved, result.Value); + Assert.True(copyStarted); + Assert.Equal("save", fixture.Fs.File.ReadAllText(@"C:\launcher-data\RetroRewind\riivolution\save\rksys.dat")); + Assert.True(fixture.Fs.File.Exists(fixture.Paths.MigratedMarkerFilePath)); + Assert.False(fixture.Fs.Directory.Exists(@"C:\launcher-data\RetroRewind.migrating")); + Assert.False(fixture.Fs.Directory.Exists(@"D:\dolphin-user\Load\Riivolution\WheelWizard")); + } + + [Fact] + public void Migrate_DiscardsACopyThatWasCutOff_AndKeepsNoBackupOfIt() + { + var fixture = CrossDriveFixture.Create(); + fixture.Fs.Directory.CreateDirectory(@"D:\dolphin-user\Load\Riivolution\WheelWizard\RetroRewind6"); + fixture.Fs.File.WriteAllText(@"D:\dolphin-user\Load\Riivolution\WheelWizard\RetroRewind6\version.txt", "6.5.0"); + // The app was closed halfway through the copy on an earlier start. + fixture.Fs.Directory.CreateDirectory(@"C:\launcher-data\RetroRewind.migrating\RetroRewind6"); + Assert.Equal(@"D:\dolphin-user\Load\Riivolution\WheelWizard", fixture.Paths.RootFolderPath); + + var result = fixture.Service.Migrate(); + + Assert.Equal(RetroRewindMigrationOutcome.Moved, result.Value); + Assert.Equal("6.5.0", fixture.Fs.File.ReadAllText(@"C:\launcher-data\RetroRewind\RetroRewind6\version.txt")); + Assert.Equal([@"C:\launcher-data\RetroRewind"], fixture.Fs.Directory.GetDirectories(@"C:\launcher-data")); + } + + private sealed class Fixture + { + public MockFileSystem Fs { get; } = new(options => options.SimulatingOperatingSystem(SimulationMode.Linux)); + public CustomDistributionPaths Paths { get; } + public RetroRewindMigrationService Service { get; } + + public Fixture(string userFolder = "/dolphin-user", string loadFolder = "") + { + var location = Substitute.For(); + location.DirectoryPath.Returns("/launcher-data"); + var dolphin = Substitute.For(); + dolphin.UserFolderPath.Returns(userFolder); + dolphin.ConfiguredLoadFolderPath.Returns(loadFolder); + dolphin.HasCustomLoadFolder.Returns(_ => loadFolder != "" && Fs.Directory.Exists(loadFolder)); + dolphin.LoadFolderPath.Returns(_ => loadFolder != "" && Fs.Directory.Exists(loadFolder) ? loadFolder : $"{userFolder}/Load"); + Paths = new CustomDistributionPaths(location, dolphin, Fs); + Service = new RetroRewindMigrationService( + Paths, + Fs, + new DirectoryTransferService(Fs), + System.TimeProvider.System, + NullLogger.Instance + ); + } + } + + private sealed record CrossDriveFixture(MockFileSystem Fs, CustomDistributionPaths Paths, RetroRewindMigrationService Service) + { + public static CrossDriveFixture Create() + { + var fs = new MockFileSystem(options => options.SimulatingOperatingSystem(SimulationMode.Windows)).WithDrive("D:"); + // Windows cannot rename a folder onto another drive. + fs.Intercept.Event( + _ => throw new IOException("Source and destination path must have identical roots."), + change => + change.ChangeType == WatcherChangeTypes.Renamed + && change.FileSystemType == FileSystemTypes.Directory + && change.OldPath?.StartsWith(@"D:\", StringComparison.OrdinalIgnoreCase) == true + ); + var location = Substitute.For(); + location.DirectoryPath.Returns(@"C:\launcher-data"); + var dolphin = Substitute.For(); + dolphin.UserFolderPath.Returns(@"D:\dolphin-user"); + dolphin.ConfiguredLoadFolderPath.Returns(""); + dolphin.LoadFolderPath.Returns(@"D:\dolphin-user\Load"); + var paths = new CustomDistributionPaths(location, dolphin, fs); + var service = new RetroRewindMigrationService( + paths, + fs, + new DirectoryTransferService(fs), + System.TimeProvider.System, + NullLogger.Instance + ); + return new(fs, paths, service); + } + } +} diff --git a/WheelWizard.Test/Features/Dolphin/FeaturePathOwnershipTests.cs b/WheelWizard.Test/Features/Dolphin/FeaturePathOwnershipTests.cs index cc3637859..f435373d5 100644 --- a/WheelWizard.Test/Features/Dolphin/FeaturePathOwnershipTests.cs +++ b/WheelWizard.Test/Features/Dolphin/FeaturePathOwnershipTests.cs @@ -71,22 +71,41 @@ public void DistributionWithoutDolphin_UsesApplicationData_AndFollowsRelocation( } [Fact] - public void DistributionWithDolphin_UsesEffectiveLoadDirectory_WhileDownloadsFollowAppData() + public void DistributionWithDolphin_UsesManagedFolder_WhenNoLegacyInstallExists() { var fs = NewFileSystem(); - var (settings, dolphin) = CreateDolphinPaths(fs, "/dolphin-user"); + var (_, dolphin) = CreateDolphinPaths(fs, "/dolphin-user"); var location = Substitute.For(); location.DirectoryPath.Returns("/launcher-data"); var paths = new CustomDistributionPaths(location, dolphin, fs); + fs.Directory.CreateDirectory("/dolphin-user/Load/Riivolution/WheelWizard"); - Assert.Equal("/dolphin-user/Load/Riivolution/WheelWizard", paths.RootFolderPath); - fs.Directory.CreateDirectory("/custom-load"); + Assert.Equal("/launcher-data/RetroRewind", paths.RootFolderPath); + Assert.Equal("/dolphin-user/Load/Riivolution/WheelWizard", paths.LegacyRootFolderPath); + Assert.False(paths.IsLegacyRootActive); + } + + [Fact] + public void DistributionWithLegacyInstall_StaysInEffectiveLoadDirectory_UntilMoved() + { + var fs = NewFileSystem(); + var (settings, dolphin) = CreateDolphinPaths(fs, "/dolphin-user"); + var location = Substitute.For(); + location.DirectoryPath.Returns("/launcher-data"); + var paths = new CustomDistributionPaths(location, dolphin, fs); + fs.Directory.CreateDirectory("/custom-load/Riivolution/WheelWizard/RetroRewind6"); settings.LOAD_PATH.Set("/custom-load", skipSave: true); location.DirectoryPath.Returns("/relocated"); Assert.Equal("/custom-load/Riivolution/WheelWizard", paths.RootFolderPath); Assert.Equal("/custom-load/Riivolution/WheelWizard/RRBeta/Patches", paths.BetaPatchesFolderPath); Assert.Equal("/relocated/Mods/Temp/RetroRewind.zip", paths.RetroRewindArchivePath); + + // Once moved, a leftover that could not be deleted is never picked again. + fs.Directory.CreateDirectory("/relocated/RetroRewind"); + fs.File.WriteAllText(paths.MigratedMarkerFilePath, "/custom-load/Riivolution/WheelWizard"); + + Assert.Equal("/relocated/RetroRewind", paths.RootFolderPath); } private static MockFileSystem NewFileSystem() => new(options => options.SimulatingOperatingSystem(SimulationMode.Linux)); diff --git a/WheelWizard.Test/Features/DolphinReleaseClientTests.cs b/WheelWizard.Test/Features/DolphinReleaseClientTests.cs new file mode 100644 index 000000000..719c2ea7e --- /dev/null +++ b/WheelWizard.Test/Features/DolphinReleaseClientTests.cs @@ -0,0 +1,68 @@ +using System.Net; +using System.Net.Http; +using System.Runtime.InteropServices; +using WheelWizard.DolphinInstaller; +using WheelWizard.Shared.Platform; + +namespace WheelWizard.Test.Features; + +public class DolphinReleaseClientTests +{ + private const string Response = """ + {"shortrev": "2609a", "hash": "409881c3", "artifacts": [ + {"system": "Linux x86_64 (Flatpak)", "url": "https://dl.dolphin-emu.org/releases/2609a/dolphin-2609a-x86_64.flatpak"}, + {"system": "macOS (ARM/Intel Universal)", "url": "https://dl.dolphin-emu.org/releases/2609a/dolphin-2609a-universal.dmg"}, + {"system": "Windows arm64", "url": "https://dl.dolphin-emu.org/releases/2609a/dolphin-2609a-arm64.7z"}, + {"system": "Windows x64", "url": "https://dl.dolphin-emu.org/releases/2609a/dolphin-2609a-x64.7z"}, + {"system": "Windows x64", "url": "http://evil.example/dolphin.7z"} + ]} + """; + + [Fact] + public async Task LatestRelease_KeepsOnlyOfficialHttpsDownloads_AndPicksThisMachinesBuild() + { + var environment = Substitute.For(); + environment.IsWindows.Returns(true); + environment.OSArchitecture.Returns(Architecture.X64); + var client = new DolphinReleaseClient(Clients(Response), environment); + + var release = await client.GetLatestReleaseAsync(); + + Assert.True(release.IsSuccess); + Assert.Equal("2609a", release.Value.ShortRevision); + Assert.Equal(4, release.Value.Artifacts.Count); + Assert.Equal("https://dl.dolphin-emu.org/releases/2609a/dolphin-2609a-x64.7z", client.SelectArtifact(release.Value)!.Url); + + environment.OSArchitecture.Returns(Architecture.Arm64); + Assert.EndsWith("arm64.7z", client.SelectArtifact(release.Value)!.Url); + + environment.IsWindows.Returns(false); + environment.IsMacOS.Returns(true); + Assert.EndsWith("universal.dmg", client.SelectArtifact(release.Value)!.Url); + + environment.IsMacOS.Returns(false); + environment.IsLinux.Returns(true); + Assert.Null(client.SelectArtifact(release.Value)); + } + + [Theory] + [InlineData("https://dl.dolphin-emu.org/releases/x.7z", true)] + [InlineData("http://dl.dolphin-emu.org/releases/x.7z", false)] + [InlineData("https://dl.dolphin-emu.org.evil.example/x.7z", false)] + [InlineData("not a url", false)] + public void OnlyOfficialHttpsLinks_AreFollowed(string url, bool expected) => + Assert.Equal(expected, DolphinReleaseClient.IsOfficialDownload(url)); + + private static IHttpClientFactory Clients(string body) + { + var factory = Substitute.For(); + factory.CreateClient(Arg.Any()).Returns(_ => new HttpClient(new StaticHandler(body))); + return factory; + } + + private sealed class StaticHandler(string body) : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => + Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(body) }); + } +} diff --git a/WheelWizard.Test/Features/ManagedDolphinServiceTests.cs b/WheelWizard.Test/Features/ManagedDolphinServiceTests.cs new file mode 100644 index 000000000..ab06d21c4 --- /dev/null +++ b/WheelWizard.Test/Features/ManagedDolphinServiceTests.cs @@ -0,0 +1,133 @@ +using Microsoft.Extensions.Logging.Abstractions; +using Testably.Abstractions.Testing; +using WheelWizard.ApplicationData; +using WheelWizard.CustomDistributions; +using WheelWizard.DiscImages; +using WheelWizard.Dolphin.Discovery; +using WheelWizard.Dolphin.Paths; +using WheelWizard.DolphinInstaller; +using WheelWizard.Onboarding; +using WheelWizard.Settings; +using WheelWizard.Settings.Types; +using WheelWizard.Shared.Downloads; +using WheelWizard.Shared.Platform; +using WheelWizard.Shared.Processes; +using WheelWizard.WiiManagement.GameLicense; +using WheelWizard.WiiManagement.MiiManagement; + +namespace WheelWizard.Test.Features; + +public class ManagedDolphinServiceTests +{ + private const string DataFolder = "/launcher-data"; + private const string ManagedUser = "/launcher-data/Dolphin/User"; + + [Fact] + public void Remove_KeepsTheUserFolder_WhenTheUsersOwnDolphinWasGivenIt() + { + var fixture = new Fixture(); + fixture.InstallManagedDolphin(); + + // A Dolphin that never ran has no user folder of its own, so onboarding hands it the managed one. + Assert.True(fixture.Onboarding.UseOwnDolphin("/own/Dolphin.exe").IsSuccess); + Assert.Equal(ManagedUser, fixture.UserFolder.Get()); + Assert.False(fixture.Managed.IsActive); + fixture.Fs.Directory.CreateDirectory($"{ManagedUser}/Wii/title/00010004/524d4345/data"); + fixture.Fs.File.WriteAllText($"{ManagedUser}/Wii/title/00010004/524d4345/data/rksys.dat", "save"); + + Assert.False(fixture.Managed.CanRemove); + var result = fixture.Managed.Remove(); + + Assert.True(fixture.Fs.File.Exists($"{ManagedUser}/Wii/title/00010004/524d4345/data/rksys.dat")); + Assert.True(result.IsFailure); + } + + [Fact] + public void Remove_DeletesEverything_WhenNothingUsesTheManagedFolders() + { + var fixture = new Fixture(); + fixture.InstallManagedDolphin(); + fixture.Fs.Directory.CreateDirectory("/own/User"); + fixture.UserFolder.Set("/own/User", skipSave: true); + fixture.Location.Set("/own/Dolphin.exe", skipSave: true); + fixture.Managed.Deactivate(); + + Assert.True(fixture.Managed.CanRemove); + Assert.True(fixture.Managed.Remove().IsSuccess); + Assert.False(fixture.Fs.Directory.Exists($"{DataFolder}/Dolphin")); + } + + private sealed class Fixture + { + public MockFileSystem Fs { get; } = new(options => options.SimulatingOperatingSystem(SimulationMode.Linux)); + public ISettingsManager Settings { get; } = Substitute.For(); + public WhWzSetting Location { get; } = new("DolphinLocation", ""); + public WhWzSetting UserFolder { get; } = new("UserFolderPath", ""); + public WhWzSetting Managed_ { get; } = new("DolphinManaged", false); + public ManagedDolphinService Managed { get; } + public OnboardingService Onboarding { get; } + + public Fixture() + { + Settings.DOLPHIN_LOCATION.Returns(Location); + Settings.USER_FOLDER_PATH.Returns(UserFolder); + Settings.DOLPHIN_MANAGED.Returns(Managed_); + Settings.ONBOARDING_COMPLETED.Returns(new WhWzSetting("OnboardingCompleted", false)); + Settings.Get(Arg.Any>()).Returns(call => call.Arg>().Get()); + Settings.Get(Arg.Any>()).Returns(call => call.Arg>().Get()); + Settings + .Set(Arg.Any>(), Arg.Any(), Arg.Any()) + .Returns(call => call.Arg>().Set(call.ArgAt(1), skipSave: true)); + Settings + .Set(Arg.Any>(), Arg.Any(), Arg.Any()) + .Returns(call => call.Arg>().Set(call.ArgAt(1), skipSave: true)); + + var environment = Substitute.For(); + environment.IsWindows.Returns(true); + var applicationData = Substitute.For(); + applicationData.DirectoryPath.Returns(DataFolder); + var discovery = Substitute.For(); + discovery.FindUserDirectory(Arg.Any(), Arg.Any()).Returns((string?)null); + + Managed = new ManagedDolphinService( + Settings, + applicationData, + Fs, + environment, + Substitute.For(), + Substitute.For(), + Substitute.For(), + Substitute.For(), + discovery, + Substitute.For(), + Substitute.For(), + NullLogger.Instance + ); + Onboarding = new OnboardingService( + Settings, + Fs, + environment, + Substitute.For(), + discovery, + Substitute.For(), + Substitute.For(), + Substitute.For(), + Managed, + Substitute.For(), + Substitute.For(), + Substitute.For(), + Substitute.For(), + NullLogger.Instance + ); + } + + /// The state after the onboarding installed the managed Dolphin and the user went back to pick their own. + public void InstallManagedDolphin() + { + Fs.Directory.CreateDirectory($"{DataFolder}/Dolphin/App/Dolphin-x64"); + Fs.File.WriteAllText($"{DataFolder}/Dolphin/App/Dolphin-x64/Dolphin.exe", ""); + Assert.True(Managed.Activate().IsSuccess); + Assert.True(Managed.IsInstalled); + } + } +} diff --git a/WheelWizard.Test/Features/MiiRepositoryServiceTests.cs b/WheelWizard.Test/Features/MiiRepositoryServiceTests.cs index 886851c35..7b74b9248 100644 --- a/WheelWizard.Test/Features/MiiRepositoryServiceTests.cs +++ b/WheelWizard.Test/Features/MiiRepositoryServiceTests.cs @@ -1,4 +1,5 @@ using Testably.Abstractions.Testing; +using WheelWizard.CustomDistributions; using WheelWizard.Dolphin.Discovery; using WheelWizard.Dolphin.Paths; using WheelWizard.Recomp; @@ -44,7 +45,8 @@ public MiiRepositoryServiceTests() Substitute.For(), _fileSystem, Substitute.For(), - _recompPaths + _recompPaths, + Substitute.For() ); _repository = new MiiRepositoryServiceService(_fileSystem, _settings, _recompPaths, _dolphinPaths, _dolphinData); } diff --git a/WheelWizard.Test/Features/Onboarding/OnboardingServiceTests.cs b/WheelWizard.Test/Features/Onboarding/OnboardingServiceTests.cs new file mode 100644 index 000000000..02e9c0dc5 --- /dev/null +++ b/WheelWizard.Test/Features/Onboarding/OnboardingServiceTests.cs @@ -0,0 +1,124 @@ +using Microsoft.Extensions.Logging.Abstractions; +using Testably.Abstractions.Testing; +using WheelWizard.CustomDistributions; +using WheelWizard.DiscImages; +using WheelWizard.Dolphin.Discovery; +using WheelWizard.Dolphin.Paths; +using WheelWizard.DolphinInstaller; +using WheelWizard.Onboarding; +using WheelWizard.Recomp; +using WheelWizard.Settings; +using WheelWizard.Settings.Types; +using WheelWizard.Shared.Platform; +using WheelWizard.Shared.Processes; +using WheelWizard.WiiManagement.GameLicense; +using WheelWizard.WiiManagement.MiiManagement; + +namespace WheelWizard.Test.Features.Onboarding; + +public class OnboardingServiceTests +{ + [Fact] + public void IsRequired_OnlyWhenNothingWorks_AndNeverAfterCompletion() + { + var fixture = new Fixture(); + Assert.True(fixture.Service.IsRequired); + + fixture.Completed.Set(true, skipSave: true); + Assert.False(fixture.Service.IsRequired); + } + + [Fact] + public void AWorkingDolphinSetup_IsMarkedAsOnboarded() + { + var fixture = new Fixture(); + fixture.Settings.DolphinPathsSetupCorrectly().Returns(true); + + Assert.False(fixture.Service.IsRequired); + fixture.Service.MarkCompletedIfAlreadySetUp(); + + Assert.True(fixture.Completed.Get()); + } + + [Fact] + public void AnInstalledWiiCompiledWithAGame_CountsAsWorking() + { + var fixture = new Fixture(); + var install = Substitute.For(); + install.IsInstalled.Returns(true); + fixture.Services.GetService(typeof(IRecompInstallService)).Returns(install); + fixture.Fs.File.WriteAllText("/game.rvz", ""); + fixture.Game.Set("/game.rvz", skipSave: true); + + Assert.False(fixture.Service.IsRequired); + } + + [Fact] + public void AnEarlierSetupThatIsBrokenToday_GetsRepairedInsteadOfOnboarded() + { + // The Dolphin user folder is on a drive that is not plugged in on the first start of this version. + var fixture = new Fixture(); + fixture.UserFolder.Set("/unplugged/Dolphin Emulator", skipSave: true); + fixture.Location.Set("/unplugged/Dolphin.exe", skipSave: true); + + Assert.False(fixture.Service.IsRequired); + fixture.Service.MarkCompletedIfAlreadySetUp(); + + Assert.True(fixture.Completed.Get()); + } + + [Fact] + public void TheFlatpaksDefaultDolphinLocation_DoesNotCountAsSetUp() + { + var fixture = new Fixture(defaultLocation: "/app/extensions/backends/dolphin-emu/bin/dolphin-emu-wrapper"); + + Assert.True(fixture.Service.IsRequired); + fixture.Service.MarkCompletedIfAlreadySetUp(); + + Assert.False(fixture.Completed.Get()); + } + + private sealed class Fixture + { + public MockFileSystem Fs { get; } = new(options => options.SimulatingOperatingSystem(SimulationMode.Linux)); + public ISettingsManager Settings { get; } = Substitute.For(); + public IServiceProvider Services { get; } = Substitute.For(); + public WhWzSetting Completed { get; } = new("OnboardingCompleted", false); + public WhWzSetting Game { get; } + public WhWzSetting Location { get; } + public WhWzSetting UserFolder { get; } = new("UserFolderPath", ""); + public OnboardingService Service { get; } + + public Fixture(string defaultLocation = "") + { + Game = new WhWzSetting("GameLocation", "").SetValidation(value => Fs.File.Exists(value)); + Location = new WhWzSetting("DolphinLocation", defaultLocation); + Settings.ONBOARDING_COMPLETED.Returns(Completed); + Settings.GAME_LOCATION.Returns(Game); + Settings.DOLPHIN_LOCATION.Returns(Location); + Settings.USER_FOLDER_PATH.Returns(UserFolder); + Settings.Get(Arg.Any>()).Returns(call => call.Arg>().Get()); + Settings.Get(Arg.Any>()).Returns(call => call.Arg>().Get()); + Settings + .Set(Arg.Any>(), Arg.Any(), Arg.Any()) + .Returns(call => call.Arg>().Set(call.ArgAt(1), skipSave: true)); + + Service = new OnboardingService( + Settings, + Fs, + Substitute.For(), + Substitute.For(), + Substitute.For(), + Substitute.For(), + Substitute.For(), + Substitute.For(), + Substitute.For(), + Substitute.For(), + Substitute.For(), + Substitute.For(), + Services, + NullLogger.Instance + ); + } + } +} diff --git a/WheelWizard.Test/Features/Recomp/RecompConfigPathsTests.cs b/WheelWizard.Test/Features/Recomp/RecompConfigPathsTests.cs new file mode 100644 index 000000000..73ee1b653 --- /dev/null +++ b/WheelWizard.Test/Features/Recomp/RecompConfigPathsTests.cs @@ -0,0 +1,107 @@ +using Testably.Abstractions.Testing; +using WheelWizard.CustomDistributions; +using WheelWizard.Dolphin.Discovery; +using WheelWizard.Recomp; +using WheelWizard.Settings; +using WheelWizard.Settings.Types; + +namespace WheelWizard.Test.Features.Recomp; + +public class RecompConfigPathsTests +{ + [Fact] + public void ApplyPaths_WritesTheCurrentRetroRewindAndNandFolders() + { + var fixture = new Fixture(); + fixture.Fs.Directory.CreateDirectory("/data/RetroRewind/RetroRewind6"); + fixture.Fs.Directory.CreateDirectory("/dolphin/Wii"); + fixture.UseDolphinData.Set(true, skipSave: true); + + Assert.True(fixture.Service.ApplyPathsToRecompConfig().IsSuccess); + + Assert.Equal("/data/RetroRewind/RetroRewind6", fixture.RetroRewindRoot.Get()); + Assert.Equal("/dolphin/Wii", fixture.NandRoot.Get()); + } + + [Fact] + public void ApplyPaths_FollowsAMovedRetroRewind_AndKeepsTheSetupValueWhenNothingIsInstalled() + { + var fixture = new Fixture(); + fixture.RetroRewindRoot.Set("/old/Load/Riivolution/WheelWizard/RetroRewind6", skipSave: true); + + Assert.True(fixture.Service.ApplyPathsToRecompConfig().IsSuccess); + Assert.Equal("/old/Load/Riivolution/WheelWizard/RetroRewind6", fixture.RetroRewindRoot.Get()); + + fixture.Fs.Directory.CreateDirectory("/data/RetroRewind/RetroRewind6"); + Assert.True(fixture.Service.ApplyPathsToRecompConfig().IsSuccess); + Assert.Equal("/data/RetroRewind/RetroRewind6", fixture.RetroRewindRoot.Get()); + } + + [Fact] + public void NandChoices_ShareCopyAndPrivate_SetTheMatchingFlags() + { + var data = Substitute.For(); + data.CopyNandForRecomp().Returns(Ok()); + + data.ApplyNandChoice(RecompNandChoice.Share); + data.Received(1).SetSharingEnabled(true); + + data.ApplyNandChoice(RecompNandChoice.Copy); + Received.InOrder(() => + { + data.CopyNandForRecomp(); + data.SetCopyEnabled(true); + data.SetSharingEnabled(false); + }); + + data.CopyNandForRecomp().Returns(Fail("disk full")); + data.ClearReceivedCalls(); + Assert.True(data.ApplyNandChoice(RecompNandChoice.Copy).IsFailure); + data.DidNotReceive().SetCopyEnabled(Arg.Any()); + } + + private sealed class Fixture + { + public MockFileSystem Fs { get; } = new(options => options.SimulatingOperatingSystem(SimulationMode.Linux)); + public WhWzSetting NandRoot { get; } = new("nand_root", ""); + public WhWzSetting RetroRewindRoot { get; } = new("retro_rewind_root", ""); + public WhWzSetting UseDolphinData { get; } = new("UseDolphinData", false); + public RecompDolphinDataService Service { get; } + + public Fixture() + { + var settings = Substitute.For(); + var userFolder = new WhWzSetting("UserFolderPath", "/dolphin"); + var nandOverride = new WhWzSetting("NandRootPath", ""); + var copy = new WhWzSetting("CopyNand", false); + settings.RECOMP_NAND_ROOT.Returns(NandRoot); + settings.RECOMP_RETRO_REWIND_ROOT.Returns(RetroRewindRoot); + settings.RECOMP_USE_DOLPHIN_DATA.Returns(UseDolphinData); + settings.RECOMP_COPY_DOLPHIN_NAND.Returns(copy); + settings.USER_FOLDER_PATH.Returns(userFolder); + settings.NAND_ROOT_PATH.Returns(nandOverride); + settings.Get(Arg.Any>()).Returns(call => call.Arg>().Get()); + settings.Get(Arg.Any>()).Returns(call => call.Arg>().Get()); + settings + .Set(Arg.Any>(), Arg.Any(), Arg.Any()) + .Returns(call => call.Arg>().Set(call.ArgAt(1), skipSave: true)); + + var paths = Substitute.For(); + paths.ConfigFilePath.Returns("/data/Recomp/UserData/Config.toml"); + Fs.Directory.CreateDirectory("/data/Recomp/UserData"); + Fs.File.WriteAllText("/data/Recomp/UserData/Config.toml", "[paths]\n"); + + var distribution = Substitute.For(); + distribution.RetroRewindFolderPath.Returns("/data/RetroRewind/RetroRewind6"); + + Service = new RecompDolphinDataService( + settings, + Substitute.For(), + Fs, + Substitute.For(), + paths, + distribution + ); + } + } +} diff --git a/WheelWizard.Test/Features/Recomp/RecompLauncherTests.cs b/WheelWizard.Test/Features/Recomp/RecompLauncherTests.cs index eb021ff31..7388b1424 100644 --- a/WheelWizard.Test/Features/Recomp/RecompLauncherTests.cs +++ b/WheelWizard.Test/Features/Recomp/RecompLauncherTests.cs @@ -29,7 +29,7 @@ await fixture .Install.Received(1) .InstallAsync(Arg.Any>(), Arg.Any>>(), CancellationToken.None); Assert.Contains(fixture.Progress, value => value.CanCancel == false); - fixture.Nand.Received(1).ApplyNandToRecompConfig(); + fixture.Nand.Received(1).ApplyPathsToRecompConfig(); } [Fact] @@ -50,7 +50,7 @@ public async Task CancelledDistributionUpdate_DoesNotStartRecompOrApplyConfigura await fixture .Install.DidNotReceive() .InstallAsync(Arg.Any>(), Arg.Any>>(), Arg.Any()); - fixture.Nand.DidNotReceive().ApplyNandToRecompConfig(); + fixture.Nand.DidNotReceive().ApplyPathsToRecompConfig(); } [Fact] @@ -107,7 +107,7 @@ public async Task SuccessfulNandCopy_IsEnabledBeforeApplyingInstalledConfigurati fixture.Nand.CopyNandForRecomp(); fixture.Nand.SetCopyEnabled(true); fixture.Nand.SetSharingEnabled(false); - fixture.Nand.ApplyNandToRecompConfig(); + fixture.Nand.ApplyPathsToRecompConfig(); }); } @@ -165,7 +165,7 @@ public Fixture() Install .InstallAsync(Arg.Any>(), Arg.Any>>(), Arg.Any()) .Returns(Ok()); - Nand.ApplyNandToRecompConfig().Returns(Ok()); + Nand.ApplyPathsToRecompConfig().Returns(Ok()); Presentation .RunAsync(Arg.Any(), Arg.Any>>()) .Returns(async call => diff --git a/WheelWizard.Test/Views/Shell/Startup/DesktopStartupTests.cs b/WheelWizard.Test/Views/Shell/Startup/DesktopStartupTests.cs index 290b97a24..2fbe3f4e6 100644 --- a/WheelWizard.Test/Views/Shell/Startup/DesktopStartupTests.cs +++ b/WheelWizard.Test/Views/Shell/Startup/DesktopStartupTests.cs @@ -3,6 +3,7 @@ using WheelWizard.ApplicationLifecycle; using WheelWizard.AutoUpdating; using WheelWizard.MiiRendering.Services; +using WheelWizard.Onboarding; using WheelWizard.Shared; using WheelWizard.Views.Shell; using WheelWizard.Views.Shell.Startup; @@ -118,6 +119,9 @@ public Fixture() => Windows, Substitute.For(), Startup, + Substitute.For(), + Substitute.For(), + Substitute.For(), NullLogger.Instance ); } diff --git a/WheelWizard.Test/Views/Shell/ViewModels/HomeDolphinLaunchTests.cs b/WheelWizard.Test/Views/Shell/ViewModels/HomeDolphinLaunchTests.cs index d35b615b4..fb226e579 100644 --- a/WheelWizard.Test/Views/Shell/ViewModels/HomeDolphinLaunchTests.cs +++ b/WheelWizard.Test/Views/Shell/ViewModels/HomeDolphinLaunchTests.cs @@ -51,7 +51,8 @@ private static HomeViewModel CreateHomeModel(IDolphinLaunchService dolphin, IHom presentation, Substitute.For(), Substitute.For(), - new ElapsedTimeProvider() + new ElapsedTimeProvider(), + Substitute.For() ); } diff --git a/WheelWizard.Test/Views/Shell/ViewModels/HomeViewModelTests.cs b/WheelWizard.Test/Views/Shell/ViewModels/HomeViewModelTests.cs index 17627a391..8afb4db7c 100644 --- a/WheelWizard.Test/Views/Shell/ViewModels/HomeViewModelTests.cs +++ b/WheelWizard.Test/Views/Shell/ViewModels/HomeViewModelTests.cs @@ -1,5 +1,7 @@ using Testably.Abstractions; +using WheelWizard.CustomDistributions; using WheelWizard.Launching; +using WheelWizard.Localization; using WheelWizard.Models.Enums; using WheelWizard.Settings; using WheelWizard.Shared; @@ -98,6 +100,35 @@ public async Task IncompleteConfiguration_NavigatesToSettingsWithoutLaunching() await fixture.Launcher.DidNotReceive().Launch(); } + [Fact] + public async Task ABrokenSetup_ExplainsWhatIsMissing_AndHidesOnceFixed() + { + using var fixture = new Fixture(); + fixture.Launcher.GetCurrentStatus().Returns(WheelWizardStatus.ConfigNotFinished); + fixture + .Settings.ValidateCorePathSettings() + .Returns( + Ok( + new SettingsValidationReport( + [ + new(SettingsValidationCode.InvalidUserFolderPath, "UserFolderPath", ""), + new(SettingsValidationCode.InvalidGameLocation, "GameLocation", ""), + ] + ) + ) + ); + + await fixture.Model.RefreshAsync(); + + Assert.True(fixture.Model.IsRepairVisible); + Assert.Equal(TranslationFunctions.t("onboarding.repair_user_folder"), fixture.Model.RepairMessage); + + fixture.Launcher.GetCurrentStatus().Returns(WheelWizardStatus.Ready); + await fixture.Model.RefreshAsync(); + + Assert.False(fixture.Model.IsRepairVisible); + } + [Fact] public async Task RecompMode_HidesAndDisablesDirectDolphinLaunch() { @@ -166,7 +197,8 @@ public Fixture() Presentation, Calendar, Substitute.For(), - new ElapsedTimeProvider() + new ElapsedTimeProvider(), + Substitute.For() ); } diff --git a/WheelWizard.UI.Test/ApplicationCompositionTests.cs b/WheelWizard.UI.Test/ApplicationCompositionTests.cs index 2c046589b..c004f4e54 100644 --- a/WheelWizard.UI.Test/ApplicationCompositionTests.cs +++ b/WheelWizard.UI.Test/ApplicationCompositionTests.cs @@ -85,6 +85,8 @@ public async Task MainWindowAndPages_ConstructWithoutStaticServiceInitialization var settings = services.GetRequiredService(); settings.NAND_ROOT_PATH.Set(directory, skipSave: true); settings.LOAD_PATH.Set(directory, skipSave: true); + // This covers an existing install; the onboarding a fresh one opens with has its own coverage. + settings.ONBOARDING_COMPLETED.Set(true, skipSave: true); services.GetRequiredService().Install(Application.Current!.Resources); services.GetRequiredService().Install(Application.Current.Resources); var desktop = Substitute.For(); diff --git a/WheelWizard.UI.Test/OnboardingShellTests.cs b/WheelWizard.UI.Test/OnboardingShellTests.cs new file mode 100644 index 000000000..5a3f4a024 --- /dev/null +++ b/WheelWizard.UI.Test/OnboardingShellTests.cs @@ -0,0 +1,78 @@ +using System.IO.Abstractions; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Headless.XUnit; +using Avalonia.Threading; +using Microsoft.Extensions.DependencyInjection; +using NSubstitute; +using Testably.Abstractions.Testing; +using WheelWizard.ApplicationData; +using WheelWizard.CustomDistributions; +using WheelWizard.MiiImages.Views; +using WheelWizard.Mods; +using WheelWizard.Onboarding.Views; +using WheelWizard.Settings; +using WheelWizard.Shared; +using WheelWizard.Shared.Services; +using WheelWizard.Views.Shell; +using WheelWizard.Views.Shell.Navigation; +using WheelWizard.Views.Shell.Views; + +namespace WheelWizard.UI.Test; + +public class OnboardingShellTests +{ + [AvaloniaFact] + public async Task FreshInstall_OpensOnboarding_WithTheRestOfTheAppOutOfReach() + { + var fileSystem = new MockFileSystem(); + var directory = Path.Combine(Path.GetTempPath(), "wheelwizard-ui-onboarding"); + fileSystem.Directory.CreateDirectory(directory); + var location = Substitute.For(); + location.DirectoryPath.Returns(directory); + var registrations = new ServiceCollection(); + registrations.AddWheelWizardServices(location); + registrations.AddSingleton(fileSystem); + registrations.AddTransient(typeof(IApiCaller<>), typeof(ApplicationCompositionTests.OfflineApiCaller<>)); + var mods = Substitute.For(); + mods.ReloadAsync().Returns(Task.FromResult(OperationResult.Ok())); + registrations.AddSingleton(mods); + var download = Substitute.For(); + download.State.Returns(BackgroundInstallState.Running); + download.IsRunning.Returns(true); + download.Percent.Returns(42); + download.StartAsync().Returns(new TaskCompletionSource().Task); + registrations.AddSingleton(download); + using var services = registrations.BuildServiceProvider( + new ServiceProviderOptions { ValidateOnBuild = true, ValidateScopes = true } + ); + services.GetRequiredService().Initialize(); + var settings = services.GetRequiredService(); + settings.NAND_ROOT_PATH.Set(directory, skipSave: true); + settings.LOAD_PATH.Set(directory, skipSave: true); + services.GetRequiredService().Install(Application.Current!.Resources); + services.GetRequiredService().Install(Application.Current.Resources); + + var window = services.GetRequiredService(); + try + { + window.Show(); + window.UpdateLayout(); + await Dispatcher.UIThread.InvokeAsync(() => { }, DispatcherPriority.Background); + + var navigation = services.GetRequiredService(); + var page = Assert.IsType(navigation.CurrentPage); + Assert.False(window.FindControl("SidebarGrid")!.IsEnabled); + await download.Received(1).StartAsync(); + Assert.Contains("42%", page.FindControl("RetroRewindText")!.Text); + + navigation.NavigateTo(); + Assert.IsType(navigation.CurrentPage); + } + finally + { + services.GetRequiredService().IsLocked = false; + window.Close(); + } + } +} diff --git a/WheelWizard/Features/ApplicationLifecycle/ApplicationLifecycleExtensions.cs b/WheelWizard/Features/ApplicationLifecycle/ApplicationLifecycleExtensions.cs index a89df857c..418f8b11d 100644 --- a/WheelWizard/Features/ApplicationLifecycle/ApplicationLifecycleExtensions.cs +++ b/WheelWizard/Features/ApplicationLifecycle/ApplicationLifecycleExtensions.cs @@ -7,6 +7,7 @@ public static class ApplicationLifecycleExtensions public static IServiceCollection AddApplicationLifecycle(this IServiceCollection services) { services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/WheelWizard/Features/ApplicationLifecycle/StartupMaintenance.cs b/WheelWizard/Features/ApplicationLifecycle/StartupMaintenance.cs new file mode 100644 index 000000000..9554f165b --- /dev/null +++ b/WheelWizard/Features/ApplicationLifecycle/StartupMaintenance.cs @@ -0,0 +1,67 @@ +using Microsoft.Extensions.Logging; +using WheelWizard.CustomDistributions; +using WheelWizard.DolphinInstaller; +using WheelWizard.Onboarding; +using WheelWizard.Recomp; + +namespace WheelWizard.ApplicationLifecycle; + +/// One-time and every-start housekeeping that has to finish before the first page decides what to show. +public interface IStartupMaintenance +{ + /// Receives a short status line for slow work, or once it is done; may be called from any thread. + Task RunAsync(Action showStatus, CancellationToken cancellationToken = default); +} + +public sealed class StartupMaintenance( + IManagedDolphinService managedDolphin, + IRetroRewindMigrationService migration, + IOnboardingService onboarding, + IServiceProvider services, + ILogger logger +) : IStartupMaintenance +{ + public async Task RunAsync(Action showStatus, CancellationToken cancellationToken = default) + { + try + { + // A relocated data folder moves the managed Dolphin with it; the settings follow before anything reads them. + await Task.Run(managedDolphin.SyncSettings, cancellationToken); + + var progress = new InlineProgress(value => showStatus(t("onboarding.rr_moving", new { percent = (int)(value * 100) }))); + var result = await Task.Run( + () => migration.Migrate(() => showStatus(t("onboarding.rr_moving", new { percent = 0 })), progress), + cancellationToken + ); + if (result.IsSuccess && result.Value == RetroRewindMigrationOutcome.Moved) + ApplyRecompPaths(); + + onboarding.MarkCompletedIfAlreadySetUp(); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception exception) + { + logger.LogError(exception, "Startup maintenance failed: {Message}", exception.Message); + } + finally + { + showStatus(null); + } + } + + /// WiiCompiled's Config.toml still names the old Retro Rewind folder until it is rewritten. + private void ApplyRecompPaths() + { + var result = services.GetService()?.ApplyPathsToRecompConfig(); + if (result is { IsFailure: true }) + logger.LogWarning("Could not point WiiCompiled at the moved Retro Rewind: {Message}", result.Error.Message); + } + + private sealed class InlineProgress(Action report) : IProgress + { + public void Report(double value) => report(value); + } +} diff --git a/WheelWizard/Features/CustomDistributions/CustomDistributionPaths.cs b/WheelWizard/Features/CustomDistributions/CustomDistributionPaths.cs index 94b525c0a..bc7718799 100644 --- a/WheelWizard/Features/CustomDistributions/CustomDistributionPaths.cs +++ b/WheelWizard/Features/CustomDistributions/CustomDistributionPaths.cs @@ -1,12 +1,39 @@ using System.IO.Abstractions; using WheelWizard.ApplicationData; using WheelWizard.Dolphin.Paths; +using WheelWizard.Shared.IO; namespace WheelWizard.CustomDistributions; public interface ICustomDistributionPaths { + /// Where Retro Rewind lives right now: the managed folder, or a legacy install that has not been moved yet. string RootFolderPath { get; } + + /// The single Wheel Wizard-owned Retro Rewind folder inside the data folder, for every frontend. + string ManagedRootFolderPath { get; } + + /// + /// The pre-onboarding location inside Dolphin's Load folder, or when no Dolphin + /// folder is configured. It stays active until has moved it. + /// + string? LegacyRootFolderPath { get; } + + /// Whether a legacy install still holds Retro Rewind and is therefore the active root. + bool IsLegacyRootActive { get; } + + /// + /// Whether "no legacy install" can be trusted right now: there is no Dolphin folder at all, or the folder that would + /// hold the legacy install can be read. An unplugged drive or an unreachable share cannot rule one out. + /// + bool CanRuleOutLegacyRoot { get; } + + /// + /// Written into the managed root once the startup migration moved a legacy install there or found none, + /// so a Retro Rewind in some Dolphin's Load folder is never picked up after that. + /// + string MigratedMarkerFilePath { get; } + string RetroRewindFolderPath { get; } string BetaFolderPath { get; } string PatchesFolderPath { get; } @@ -26,12 +53,69 @@ public interface ICustomDistributionPaths public sealed class CustomDistributionPaths(IApplicationDataLocation applicationData, IDolphinPaths dolphin, IFileSystem fileSystem) : ICustomDistributionPaths { - // Both frontends share this installation. Recomp-only setups use launcher-owned data until - // a Dolphin user folder or valid custom Load folder has been configured. - public string RootFolderPath => + public const string MigratedMarkerFileName = ".wheelwizard-moved"; + + // Both frontends share this installation. The launch descriptor passes the root to Dolphin explicitly, + // so it does not have to live in Dolphin's Load folder; older installs did, and keep working from + // there until the startup migration has moved them. + public string RootFolderPath => IsLegacyRootActive ? LegacyRootFolderPath! : ManagedRootFolderPath; + public string ManagedRootFolderPath => fileSystem.Path.Combine(applicationData.DirectoryPath, "RetroRewind"); + public string MigratedMarkerFilePath => fileSystem.Path.Combine(ManagedRootFolderPath, MigratedMarkerFileName); + + public string? LegacyRootFolderPath => dolphin.HasCustomLoadFolder || !string.IsNullOrWhiteSpace(dolphin.UserFolderPath) ? fileSystem.Path.Combine(dolphin.LoadFolderPath, "Riivolution", "WheelWizard") - : fileSystem.Path.Combine(applicationData.DirectoryPath, "RetroRewind"); + : null; + + public bool IsLegacyRootActive + { + get + { + var legacy = LegacyRootFolderPath; + if (legacy is null || fileSystem.Path.PathsEqual(legacy, ManagedRootFolderPath)) + return false; + try + { + return !fileSystem.File.Exists(MigratedMarkerFilePath) + && fileSystem.Directory.Exists(legacy) + && fileSystem.Directory.EnumerateFileSystemEntries(legacy).Any(); + } + catch + { + return false; + } + } + } + + public bool CanRuleOutLegacyRoot + { + get + { + if (LegacyRootFolderPath is null) + return true; + // A custom Load folder that is configured but missing falls back to the user folder's Load folder, + // which would wrongly look empty. + if (!dolphin.HasCustomLoadFolder && !string.IsNullOrWhiteSpace(dolphin.ConfiguredLoadFolderPath)) + return false; + return IsReadable(dolphin.HasCustomLoadFolder ? dolphin.LoadFolderPath : dolphin.UserFolderPath); + } + } + + private bool IsReadable(string folder) + { + try + { + if (!fileSystem.Directory.Exists(folder)) + return false; + _ = fileSystem.Directory.EnumerateFileSystemEntries(folder).Any(); + return true; + } + catch + { + return false; + } + } + public string RetroRewindFolderPath => fileSystem.Path.Combine(RootFolderPath, "RetroRewind6"); public string BetaFolderPath => fileSystem.Path.Combine(RootFolderPath, "RRBeta"); public string PatchesFolderPath => fileSystem.Path.Combine(RetroRewindFolderPath, "Patches"); diff --git a/WheelWizard/Features/CustomDistributions/CustomDistributionsExtentions.cs b/WheelWizard/Features/CustomDistributions/CustomDistributionsExtentions.cs index 61cc4c0c0..f67e8714b 100644 --- a/WheelWizard/Features/CustomDistributions/CustomDistributionsExtentions.cs +++ b/WheelWizard/Features/CustomDistributions/CustomDistributionsExtentions.cs @@ -10,6 +10,8 @@ public static IServiceCollection AddCustomDistributionService(this IServiceColle services.AddWhWzRefitApi(RetroRewindEndpoints.BaseUrl); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/WheelWizard/Features/CustomDistributions/DistributionOperation.cs b/WheelWizard/Features/CustomDistributions/DistributionOperation.cs index 6be9a5faa..c931ab938 100644 --- a/WheelWizard/Features/CustomDistributions/DistributionOperation.cs +++ b/WheelWizard/Features/CustomDistributions/DistributionOperation.cs @@ -10,7 +10,12 @@ public sealed record DistributionProgress( bool? CanCancel = null ); -public sealed record DistributionOperation(IProgress? Progress = null, CancellationToken CancellationToken = default) +/// Runs without asking anything, e.g. the onboarding's background install; questions take their safe default. +public sealed record DistributionOperation( + IProgress? Progress = null, + CancellationToken CancellationToken = default, + bool Unattended = false +) { public void Report(DistributionProgress progress) => Progress?.Report(progress); } diff --git a/WheelWizard/Features/CustomDistributions/RetroRewind.cs b/WheelWizard/Features/CustomDistributions/RetroRewind.cs index e84bbe696..3bd9bcdb8 100644 --- a/WheelWizard/Features/CustomDistributions/RetroRewind.cs +++ b/WheelWizard/Features/CustomDistributions/RetroRewind.cs @@ -66,7 +66,8 @@ public async Task InstallAsync(DistributionOperation operation) if (HasOldRksys()) { - if (await _prompts.ConfirmOldSaveBackupAsync()) + // Backing up only copies the old save next to the new install, so it is the safe unattended answer. + if (operation.Unattended || await _prompts.ConfirmOldSaveBackupAsync()) await BackupOldrksys(); } var serverResponse = await _api.CallApiAsync(api => api.Ping()); // actual response doesnt matter diff --git a/WheelWizard/Features/CustomDistributions/RetroRewindBackgroundInstall.cs b/WheelWizard/Features/CustomDistributions/RetroRewindBackgroundInstall.cs new file mode 100644 index 000000000..22821656c --- /dev/null +++ b/WheelWizard/Features/CustomDistributions/RetroRewindBackgroundInstall.cs @@ -0,0 +1,127 @@ +using Microsoft.Extensions.Logging; + +namespace WheelWizard.CustomDistributions; + +public enum BackgroundInstallState +{ + Idle, + Running, + Failed, + Completed, +} + +/// +/// Installs or updates Retro Rewind without any window, so the onboarding can keep going while it downloads +/// and Home can show the same progress afterwards. There is at most one run at a time. +/// +public interface IRetroRewindBackgroundInstall +{ + BackgroundInstallState State { get; } + bool IsRunning { get; } + int Percent { get; } + string? Message { get; } + string? ErrorMessage { get; } + + /// Raised from any thread whenever the state, percentage or message changes. + event EventHandler? Changed; + + /// Starts a run unless one is already running or finished; returns that run. + Task StartAsync(); + + /// Starts a new run after a failure. + Task RetryAsync(); +} + +public sealed class RetroRewindBackgroundInstall( + ICustomDistributionSingletonService distributions, + TimeProvider time, + ILogger logger +) : IRetroRewindBackgroundInstall +{ + private static readonly TimeSpan[] RetryDelays = [TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(15)]; + private readonly Lock _lock = new(); + private Task? _run; + + public BackgroundInstallState State { get; private set; } + public bool IsRunning => State == BackgroundInstallState.Running; + public int Percent { get; private set; } + public string? Message { get; private set; } + public string? ErrorMessage { get; private set; } + public event EventHandler? Changed; + + public Task StartAsync() + { + lock (_lock) + { + if (_run is not null && State != BackgroundInstallState.Failed) + return _run; + return _run = RunAsync(); + } + } + + public Task RetryAsync() => StartAsync(); + + private async Task RunAsync() + { + Update(BackgroundInstallState.Running, 0, null, null); + OperationResult result = Fail("Retro Rewind could not be installed."); + for (var attempt = 0; attempt <= RetryDelays.Length; attempt++) + { + if (attempt > 0) + await Task.Delay(RetryDelays[attempt - 1], time); + + try + { + result = await Task.Run(InstallOrUpdateAsync); + } + catch (Exception exception) + { + result = exception; + } + + if (result.IsSuccess) + break; + logger.LogWarning( + result.Error.Exception, + "Background Retro Rewind install attempt {Attempt} failed: {Message}", + attempt + 1, + result.Error.Message + ); + } + + if (result.IsSuccess) + Update(BackgroundInstallState.Completed, 100, null, null); + else + Update(BackgroundInstallState.Failed, Percent, null, result.Error.Message); + return result; + } + + private Task InstallOrUpdateAsync() + { + var progress = new InlineProgress(value => + { + if (value.Message is null && value.Percent is null) + return; + Update(BackgroundInstallState.Running, value.Percent ?? Percent, value.Message ?? Message, null); + }); + var operation = new DistributionOperation(progress, Unattended: true); + var retroRewind = distributions.RetroRewind; + // UpdateAsync installs when nothing is there yet, and is a no-op when already current. + return retroRewind.UpdateAsync(operation); + } + + private void Update(BackgroundInstallState state, int percent, string? message, string? error) + { + State = state; + Percent = Math.Clamp(percent, 0, 100); + Message = message; + ErrorMessage = error; + Changed?.Invoke(this, EventArgs.Empty); + } + + /// Reports synchronously; would post to whatever context started the run. + private sealed class InlineProgress(Action report) : IProgress + { + public void Report(DistributionProgress value) => report(value); + } +} diff --git a/WheelWizard/Features/CustomDistributions/RetroRewindMigrationService.cs b/WheelWizard/Features/CustomDistributions/RetroRewindMigrationService.cs new file mode 100644 index 000000000..875bd97e4 --- /dev/null +++ b/WheelWizard/Features/CustomDistributions/RetroRewindMigrationService.cs @@ -0,0 +1,173 @@ +using System.IO.Abstractions; +using Microsoft.Extensions.Logging; +using WheelWizard.Shared.IO; + +namespace WheelWizard.CustomDistributions; + +public enum RetroRewindMigrationOutcome +{ + /// There was no legacy install to move. + NotNeeded, + Moved, + + /// The move failed; the legacy install stays active and the next start tries again. + Failed, + + /// The Dolphin folder that may hold a legacy install cannot be read right now; the next start checks again. + Deferred, +} + +public interface IRetroRewindMigrationService +{ + bool IsPending { get; } + + /// + /// Moves a Retro Rewind install out of Dolphin's Load folder into the managed folder, or records that there + /// was none once that folder could actually be read, so this only ever happens once. A same-volume move + /// is a rename; otherwise fires once before the (slow) verified copy, which + /// is staged next to the managed folder and only takes its place when complete. + /// + OperationResult Migrate(Action? copyStarted = null, IProgress? progress = null); +} + +public sealed class RetroRewindMigrationService( + ICustomDistributionPaths paths, + IFileSystem fileSystem, + IDirectoryTransferService transfer, + TimeProvider time, + ILogger logger +) : IRetroRewindMigrationService +{ + public bool IsPending => paths.IsLegacyRootActive; + + public OperationResult Migrate(Action? copyStarted = null, IProgress? progress = null) + { + var destination = paths.ManagedRootFolderPath; + var staging = $"{destination}.migrating"; + // Left behind by a copy that was cut off (the app closed on the splash). The source is only removed once + // the copy is in place, so this is never the only copy. + TryDelete(staging); + + if (!paths.IsLegacyRootActive) + { + if (!paths.CanRuleOutLegacyRoot) + { + logger.LogInformation("The Dolphin folder that may hold Retro Rewind cannot be read; checking again next start"); + return RetroRewindMigrationOutcome.Deferred; + } + + MarkChecked(); + return RetroRewindMigrationOutcome.NotNeeded; + } + + var source = paths.LegacyRootFolderPath!; + try + { + SetAsideExistingManagedInstall(destination); + + var parent = fileSystem.Path.GetDirectoryName(destination); + if (!string.IsNullOrEmpty(parent)) + fileSystem.Directory.CreateDirectory(parent); + + if (TryRename(source, destination)) + { + fileSystem.File.WriteAllText(paths.MigratedMarkerFilePath, source); + logger.LogInformation("Moved Retro Rewind from {Source} to {Destination}", source, destination); + return RetroRewindMigrationOutcome.Moved; + } + + // Another drive: copy next to the managed root first, so an interrupted copy never becomes the active install. + copyStarted?.Invoke(); + var result = transfer.MoveContents(source, staging, deleteSource: false, progress); + if (!result.IsSuccessful) + { + TryDelete(staging); + logger.LogError(result.Exception, "Moving Retro Rewind failed: {Message}", result.ErrorMessage); + return RetroRewindMigrationOutcome.Failed; + } + + // The marker goes in before the rename, so the copy is never active while the old one could still win. + fileSystem.File.WriteAllText(fileSystem.Path.Combine(staging, CustomDistributionPaths.MigratedMarkerFileName), source); + fileSystem.Directory.Move(staging, destination); + logger.LogInformation("Moved Retro Rewind from {Source} to {Destination}", source, destination); + + // The copy is complete and verified; the marker keeps a leftover from ever being picked again. + if (!TryDelete(source)) + logger.LogWarning("Moved Retro Rewind, but the old folder {Source} could not be removed", source); + return RetroRewindMigrationOutcome.Moved; + } + catch (Exception exception) + { + TryDelete(staging); + logger.LogError(exception, "Moving Retro Rewind from {Source} failed", source); + return RetroRewindMigrationOutcome.Failed; + } + } + + /// + /// Only an install that predates the managed folder may take over; once the first check found nothing to + /// move, a Retro Rewind left in some Dolphin's Load folder later on is never picked up again. + /// + private void MarkChecked() + { + try + { + if (fileSystem.File.Exists(paths.MigratedMarkerFilePath)) + return; + fileSystem.Directory.CreateDirectory(paths.ManagedRootFolderPath); + fileSystem.File.WriteAllText(paths.MigratedMarkerFilePath, string.Empty); + } + catch (Exception exception) + { + logger.LogWarning(exception, "Could not mark the Retro Rewind folder as checked"); + } + } + + /// + /// A WiiCompiled-only install may already sit in the managed folder. The legacy install is the one Wheel + /// Wizard has been using, so it wins; the other one is kept next to it instead of being deleted. + /// + private void SetAsideExistingManagedInstall(string destination) + { + if (!fileSystem.Directory.Exists(destination)) + return; + if (!fileSystem.Directory.EnumerateFileSystemEntries(destination).Any()) + { + fileSystem.Directory.Delete(destination); + return; + } + + var backup = $"{destination}.old-{time.GetLocalNow():yyyyMMdd-HHmmss}"; + fileSystem.Directory.Move(destination, backup); + logger.LogInformation("Kept the existing managed Retro Rewind as {Backup}", backup); + } + + private bool TryRename(string source, string destination) + { + try + { + fileSystem.Directory.Move(source, destination); + return true; + } + catch (IOException) + { + // Different volumes (or a busy file); fall back to a verified copy. + return false; + } + } + + private bool TryDelete(string folder) + { + try + { + if (fileSystem.Directory.Exists(folder)) + fileSystem.Directory.Delete(folder, recursive: true); + return true; + } + catch (Exception exception) + { + logger.LogWarning(exception, "Could not remove {Folder}", folder); + return false; + } + } +} diff --git a/WheelWizard/Features/Dolphin/Paths/DolphinPaths.cs b/WheelWizard/Features/Dolphin/Paths/DolphinPaths.cs index 40a0bb35e..5d2e82a35 100644 --- a/WheelWizard/Features/Dolphin/Paths/DolphinPaths.cs +++ b/WheelWizard/Features/Dolphin/Paths/DolphinPaths.cs @@ -10,6 +10,10 @@ public interface IDolphinPaths string UserFolderPath { get; } string ConfigFolderPath { get; } bool HasCustomLoadFolder { get; } + + /// Dolphin's Load folder setting as written, even when that folder is not there right now (an unplugged drive). + string ConfiguredLoadFolderPath { get; } + string LoadFolderPath { get; } string WiiFolderPath { get; } } @@ -21,6 +25,7 @@ public sealed class DolphinPaths(ISettingsManager settings, IDolphinPathResolver public string UserFolderPath => settings.Get(settings.USER_FOLDER_PATH); public string ConfigFolderPath => Layout.ConfigFolderPath; public bool HasCustomLoadFolder => settings.LOAD_PATH.IsValid(); + public string ConfiguredLoadFolderPath => settings.Get(settings.LOAD_PATH); public string LoadFolderPath => HasCustomLoadFolder ? settings.Get(settings.LOAD_PATH) : fileSystem.Path.Combine(UserFolderPath, "Load"); public string WiiFolderPath => diff --git a/WheelWizard/Features/DolphinInstaller/DolphinInstallerExtensions.cs b/WheelWizard/Features/DolphinInstaller/DolphinInstallerExtensions.cs index d2c8d682c..98a008a3b 100644 --- a/WheelWizard/Features/DolphinInstaller/DolphinInstallerExtensions.cs +++ b/WheelWizard/Features/DolphinInstaller/DolphinInstallerExtensions.cs @@ -8,6 +8,8 @@ public static IServiceCollection AddDolphinInstaller(this IServiceCollection ser { services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); return services; } } diff --git a/WheelWizard/Features/DolphinInstaller/DolphinReleaseClient.cs b/WheelWizard/Features/DolphinInstaller/DolphinReleaseClient.cs new file mode 100644 index 000000000..ab7d1434c --- /dev/null +++ b/WheelWizard/Features/DolphinInstaller/DolphinReleaseClient.cs @@ -0,0 +1,101 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using WheelWizard.Shared.Downloads; +using WheelWizard.Shared.Platform; + +namespace WheelWizard.DolphinInstaller; + +public sealed record DolphinReleaseArtifact(string System, string Url); + +public sealed record DolphinRelease(string ShortRevision, string Hash, IReadOnlyList Artifacts); + +public interface IDolphinReleaseClient +{ + /// The newest official Dolphin release, as published by dolphin-emu.org. + Task> GetLatestReleaseAsync(CancellationToken cancellationToken = default); + + /// The download for this machine, or when Dolphin publishes none we can install. + DolphinReleaseArtifact? SelectArtifact(DolphinRelease release); +} + +public sealed class DolphinReleaseClient(IHttpClientFactory clients, IRuntimeEnvironment environment) : IDolphinReleaseClient +{ + // The "beta" track is the one Dolphin's own updater uses for releases; it is the only track that + // answers and always points at the newest release (e.g. 2609a), never a dev build. + public const string LatestReleaseUrl = "https://dolphin-emu.org/update/latest/beta/"; + public const string DownloadHost = "dl.dolphin-emu.org"; + + public async Task> GetLatestReleaseAsync(CancellationToken cancellationToken = default) + { + try + { + using var client = clients.CreateClient(DownloadService.ClientName); + await using var stream = await client.GetStreamAsync(LatestReleaseUrl, cancellationToken); + var response = await JsonSerializer.DeserializeAsync(stream, cancellationToken: cancellationToken); + if (response is null || string.IsNullOrWhiteSpace(response.ShortRevision) || response.Artifacts is null) + return Fail("Dolphin's release information could not be read."); + + var artifacts = response + .Artifacts.Where(artifact => !string.IsNullOrWhiteSpace(artifact.System) && IsOfficialDownload(artifact.Url)) + .Select(artifact => new DolphinReleaseArtifact(artifact.System!, artifact.Url!)) + .ToList(); + return new DolphinRelease(response.ShortRevision.Trim(), response.Hash ?? string.Empty, artifacts); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception exception) + { + return new OperationError { Message = $"Could not reach dolphin-emu.org: {exception.Message}", Exception = exception }; + } + } + + public DolphinReleaseArtifact? SelectArtifact(DolphinRelease release) + { + bool Matches(DolphinReleaseArtifact artifact, string system) => + artifact.System.StartsWith(system, StringComparison.OrdinalIgnoreCase); + + if (environment.IsWindows) + { + var system = environment.OSArchitecture == System.Runtime.InteropServices.Architecture.Arm64 ? "Windows arm64" : "Windows x64"; + return release.Artifacts.FirstOrDefault(artifact => + Matches(artifact, system) && artifact.Url.EndsWith(".7z", StringComparison.OrdinalIgnoreCase) + ); + } + + if (environment.IsMacOS) + return release.Artifacts.FirstOrDefault(artifact => + Matches(artifact, "macOS") && artifact.Url.EndsWith(".dmg", StringComparison.OrdinalIgnoreCase) + ); + + return null; + } + + /// Only HTTPS links on Dolphin's own download host are ever followed. + public static bool IsOfficialDownload(string? url) => + Uri.TryCreate(url, UriKind.Absolute, out var uri) + && uri.Scheme == Uri.UriSchemeHttps + && string.Equals(uri.Host, DownloadHost, StringComparison.OrdinalIgnoreCase); + + private sealed class ReleaseResponse + { + [JsonPropertyName("shortrev")] + public string? ShortRevision { get; init; } + + [JsonPropertyName("hash")] + public string? Hash { get; init; } + + [JsonPropertyName("artifacts")] + public List? Artifacts { get; init; } + } + + private sealed class ReleaseArtifact + { + [JsonPropertyName("system")] + public string? System { get; init; } + + [JsonPropertyName("url")] + public string? Url { get; init; } + } +} diff --git a/WheelWizard/Features/DolphinInstaller/ManagedDolphinService.cs b/WheelWizard/Features/DolphinInstaller/ManagedDolphinService.cs new file mode 100644 index 000000000..1eb10e7e4 --- /dev/null +++ b/WheelWizard/Features/DolphinInstaller/ManagedDolphinService.cs @@ -0,0 +1,557 @@ +using System.IO.Abstractions; +using System.Text.Json; +using Microsoft.Extensions.Logging; +using SharpCompress.Archives; +using WheelWizard.ApplicationData; +using WheelWizard.Dolphin.Discovery; +using WheelWizard.Dolphin.Paths; +using WheelWizard.Settings; +using WheelWizard.Shared.Downloads; +using WheelWizard.Shared.IO; +using WheelWizard.Shared.Platform; +using WheelWizard.Shared.Processes; + +namespace WheelWizard.DolphinInstaller; + +/// How Wheel Wizard can manage Dolphin on this machine. +public enum ManagedDolphinKind +{ + Unsupported, + + /// Windows and macOS: the official build, unpacked into Wheel Wizard's data folder. + Portable, + + /// Native Linux: the official Flatpak, installed for the current user. + Flatpak, + + /// The Wheel Wizard Flatpak ships its own Dolphin extension; only its user folder is ours. + Bundled, +} + +public sealed record ManagedDolphinProgress(string Message, int Percent); + +public interface IManagedDolphinService +{ + ManagedDolphinKind Kind { get; } + bool IsActive { get; } + bool IsInstalled { get; } + string? InstalledVersion { get; } + string RootFolderPath { get; } + string UserFolderPath { get; } + + Task InstallAsync(IProgress? progress = null, CancellationToken cancellationToken = default); + + /// Points the Dolphin settings at the managed Dolphin and marks it as managed. + OperationResult Activate(); + + /// Stops managing Dolphin; the files stay so switching back is instant. + void Deactivate(); + + /// Re-derives the managed paths from the current data folder (it can be relocated). + void SyncSettings(); + + /// Copies Miis, saves, controller and graphics settings from another Dolphin into the managed one. + OperationResult ImportUserData(string sourceWiiFolderPath, string sourceConfigFolderPath); + + /// The newer release when the managed Dolphin is out of date, otherwise . + Task CheckForUpdateAsync(CancellationToken cancellationToken = default); + + Task UpdateAsync(IProgress? progress = null, CancellationToken cancellationToken = default); + + long GetInstalledSize(); + + /// Nothing points into the managed folder, not even a user folder that was handed to the user's own Dolphin. + bool CanRemove { get; } + + OperationResult Remove(); +} + +public sealed class ManagedDolphinService( + ISettingsManager settings, + IApplicationDataLocation applicationData, + IFileSystem fileSystem, + IRuntimeEnvironment environment, + IDolphinReleaseClient releases, + IDownloadService downloads, + IUnixProcessService processes, + ILinuxDolphinInstaller linuxInstaller, + IDolphinDiscoveryService discovery, + IDolphinPathResolver dolphinPaths, + IDolphinVersionService versions, + ILogger logger +) : IManagedDolphinService +{ + public const string FlatpakAppId = "org.DolphinEmu.dolphin-emu"; + public const string FlatpakCommand = $"flatpak run {FlatpakAppId}"; + + /// The files worth keeping from another Dolphin besides its NAND; Dolphin.ini holds that install's own paths. + private static readonly string[] ImportedConfigFiles = ["GCPadNew.ini", "WiimoteNew.ini", "GFX.ini"]; + + public ManagedDolphinKind Kind => + environment.IsFlatpakSandboxed(fileSystem) ? ManagedDolphinKind.Bundled + : environment.IsWindows || environment.IsMacOS ? ManagedDolphinKind.Portable + : environment.IsLinux ? ManagedDolphinKind.Flatpak + : ManagedDolphinKind.Unsupported; + + public bool IsActive => settings.Get(settings.DOLPHIN_MANAGED); + public string RootFolderPath => fileSystem.Path.Combine(applicationData.DirectoryPath, "Dolphin"); + public string UserFolderPath => fileSystem.Path.Combine(RootFolderPath, "User"); + private string AppFolderPath => fileSystem.Path.Combine(RootFolderPath, "App"); + private string DownloadFolderPath => fileSystem.Path.Combine(RootFolderPath, "Download"); + private string VersionFilePath => fileSystem.Path.Combine(RootFolderPath, "version.json"); + + public bool IsInstalled => + Kind switch + { + ManagedDolphinKind.Portable => FindExecutable() is not null, + ManagedDolphinKind.Flatpak => linuxInstaller.IsDolphinInstalledInFlatpak(), + ManagedDolphinKind.Bundled => !environment.IsMissingDolphinFlatpakExtension(fileSystem), + _ => false, + }; + + public string? InstalledVersion + { + get + { + if (Kind != ManagedDolphinKind.Portable) + return IsActive ? versions.CheckConfiguredDolphin().Version : null; + try + { + if (!fileSystem.File.Exists(VersionFilePath)) + return null; + return JsonSerializer.Deserialize(fileSystem.File.ReadAllText(VersionFilePath))?.ShortRevision; + } + catch + { + return null; + } + } + } + + public async Task InstallAsync( + IProgress? progress = null, + CancellationToken cancellationToken = default + ) + { + switch (Kind) + { + case ManagedDolphinKind.Portable: + var install = await InstallPortableAsync(progress, cancellationToken); + return install.IsFailure ? install : Activate(); + case ManagedDolphinKind.Flatpak: + progress?.Report(new(t("onboarding.dolphin_installing"), 0)); + var flatpak = await linuxInstaller.InstallFlatpakDolphin( + new Progress(percent => progress?.Report(new(t("onboarding.dolphin_installing"), percent))) + ); + return flatpak.IsFailure ? flatpak : Activate(); + case ManagedDolphinKind.Bundled: + return environment.IsMissingDolphinFlatpakExtension(fileSystem) + ? Fail("The Dolphin extension for the Wheel Wizard Flatpak is not installed.") + : Activate(); + default: + return Fail("Wheel Wizard cannot install Dolphin on this system."); + } + } + + public OperationResult Activate() + { + try + { + switch (Kind) + { + case ManagedDolphinKind.Portable: + { + var executable = FindExecutable(); + if (executable is null) + return Fail("The managed Dolphin is not installed."); + EnsureUserFolder(); + var location = environment.IsWindows ? executable : ShellQuoting.QuoteUnixArgument(executable); + if (!settings.Set(settings.DOLPHIN_LOCATION, location) || !settings.Set(settings.USER_FOLDER_PATH, UserFolderPath)) + return Fail("Wheel Wizard could not save the managed Dolphin location."); + break; + } + case ManagedDolphinKind.Flatpak: + { + var userFolder = discovery.FindUserDirectory(FlatpakCommand, string.Empty); + if (string.IsNullOrWhiteSpace(userFolder)) + return Fail("Dolphin was installed, but its user folder could not be found."); + if (!settings.Set(settings.DOLPHIN_LOCATION, FlatpakCommand) || !settings.Set(settings.USER_FOLDER_PATH, userFolder)) + return Fail("Wheel Wizard could not save the managed Dolphin location."); + break; + } + case ManagedDolphinKind.Bundled: + EnsureUserFolder(); + if (!settings.Set(settings.USER_FOLDER_PATH, UserFolderPath)) + return Fail("Wheel Wizard could not save the managed Dolphin user folder."); + break; + default: + return Fail("Wheel Wizard cannot manage Dolphin on this system."); + } + + settings.Set(settings.DOLPHIN_MANAGED, true); + return Ok(); + } + catch (Exception exception) + { + return exception; + } + } + + public void Deactivate() => settings.Set(settings.DOLPHIN_MANAGED, false); + + public void SyncSettings() + { + if (!IsActive || Kind is not (ManagedDolphinKind.Portable or ManagedDolphinKind.Bundled)) + return; + + var executable = Kind == ManagedDolphinKind.Portable ? FindExecutable() : null; + if (Kind == ManagedDolphinKind.Portable && executable is null) + { + // The files are gone; leave the settings alone so onboarding or settings can pick another Dolphin. + logger.LogWarning("The managed Dolphin is missing from {Folder}", AppFolderPath); + return; + } + + EnsureUserFolder(); + if (executable is not null) + { + var location = environment.IsWindows ? executable : ShellQuoting.QuoteUnixArgument(executable); + if (settings.Get(settings.DOLPHIN_LOCATION) != location) + settings.Set(settings.DOLPHIN_LOCATION, location); + } + + if (!fileSystem.Path.PathsEqual(settings.Get(settings.USER_FOLDER_PATH), UserFolderPath)) + settings.Set(settings.USER_FOLDER_PATH, UserFolderPath); + } + + public OperationResult ImportUserData(string sourceWiiFolderPath, string sourceConfigFolderPath) + { + try + { + // Called once the managed Dolphin is active, so its folders are whatever the settings now resolve to; + // that also covers the Flatpak, whose config lives apart from its data. + var userFolder = settings.Get(settings.USER_FOLDER_PATH); + var wiiFolder = fileSystem.Path.Combine(userFolder, "Wii"); + var configFolder = dolphinPaths.Resolve(settings.Get(settings.DOLPHIN_LOCATION), userFolder).ConfigFolderPath; + fileSystem.Directory.CreateDirectory(configFolder); + + if (fileSystem.Directory.Exists(sourceWiiFolderPath) && !fileSystem.Path.PathsEqual(sourceWiiFolderPath, wiiFolder)) + CopyDirectory(sourceWiiFolderPath, wiiFolder); + + if (fileSystem.Path.PathsEqual(sourceConfigFolderPath, configFolder)) + return Ok(); + foreach (var file in ImportedConfigFiles) + { + var source = fileSystem.Path.Combine(sourceConfigFolderPath, file); + if (fileSystem.File.Exists(source)) + fileSystem.File.Copy(source, fileSystem.Path.Combine(configFolder, file), overwrite: true); + } + + var profiles = fileSystem.Path.Combine(sourceConfigFolderPath, "Profiles"); + if (fileSystem.Directory.Exists(profiles)) + CopyDirectory(profiles, fileSystem.Path.Combine(configFolder, "Profiles")); + return Ok(); + } + catch (Exception exception) + { + logger.LogError(exception, "Importing Dolphin data failed"); + return Fail($"Wheel Wizard could not copy your Dolphin data: {exception.Message}"); + } + } + + public async Task CheckForUpdateAsync(CancellationToken cancellationToken = default) + { + if (!IsActive || Kind is not (ManagedDolphinKind.Portable or ManagedDolphinKind.Flatpak)) + return null; + + var installed = await Task.Run(() => InstalledVersion, cancellationToken); + if (string.IsNullOrWhiteSpace(installed)) + return null; + + var latest = await releases.GetLatestReleaseAsync(cancellationToken); + if (latest.IsFailure) + return null; + return string.Equals(latest.Value.ShortRevision, installed, StringComparison.OrdinalIgnoreCase) ? null : latest.Value; + } + + public async Task UpdateAsync( + IProgress? progress = null, + CancellationToken cancellationToken = default + ) => + Kind switch + { + ManagedDolphinKind.Portable => await InstallPortableAsync(progress, cancellationToken) is { IsFailure: true } failure + ? failure + : Activate(), + ManagedDolphinKind.Flatpak => await linuxInstaller.UpdateFlatpakDolphin( + FlatpakAppId, + new Progress(percent => progress?.Report(new(t("onboarding.dolphin_updating"), percent))) + ), + _ => Fail("Wheel Wizard does not manage a Dolphin it can update here."), + }; + + public long GetInstalledSize() + { + try + { + return fileSystem.Directory.Exists(RootFolderPath) + ? fileSystem + .Directory.EnumerateFiles(RootFolderPath, "*", SearchOption.AllDirectories) + .Sum(file => fileSystem.FileInfo.New(file).Length) + : 0; + } + catch + { + return 0; + } + } + + public bool CanRemove => !IsActive && !IsUsedByConfiguredDolphin(); + + public OperationResult Remove() + { + if (IsActive) + return Fail("Switch to another Dolphin before removing the managed one."); + // Deleting the folder would take that Dolphin's saves and Miis with it. + if (IsUsedByConfiguredDolphin()) + return Fail("Your Dolphin still uses the folder Wheel Wizard installed Dolphin into."); + try + { + if (fileSystem.Directory.Exists(RootFolderPath)) + fileSystem.Directory.Delete(RootFolderPath, recursive: true); + return Ok(); + } + catch (Exception exception) + { + return Fail($"Wheel Wizard could not remove the managed Dolphin: {exception.Message}"); + } + } + + private async Task InstallPortableAsync( + IProgress? progress, + CancellationToken cancellationToken + ) + { + var downloadingText = t("onboarding.dolphin_downloading"); + progress?.Report(new(downloadingText, 0)); + var release = await releases.GetLatestReleaseAsync(cancellationToken); + if (release.IsFailure) + return release.Error; + + var artifact = releases.SelectArtifact(release.Value); + if (artifact is null) + return Fail("Dolphin does not publish a build for this system."); + + var stagingFolder = fileSystem.Path.Combine(RootFolderPath, "App.new"); + var archivePath = fileSystem.Path.Combine(DownloadFolderPath, fileSystem.Path.GetFileName(new Uri(artifact.Url).AbsolutePath)); + try + { + var download = await downloads.DownloadAsync( + artifact.Url, + archivePath, + useExactPath: true, + new Progress(value => progress?.Report(new(downloadingText, value.Percentage * 7 / 10))), + cancellationToken + ); + if (download.IsFailure) + return download.Error; + + progress?.Report(new(t("state.extracting"), 70)); + if (fileSystem.Directory.Exists(stagingFolder)) + fileSystem.Directory.Delete(stagingFolder, recursive: true); + fileSystem.Directory.CreateDirectory(stagingFolder); + + var unpacked = await Task.Run( + () => + environment.IsWindows + ? Extract7z( + download.Value, + stagingFolder, + percent => progress?.Report(new(t("state.extracting"), 70 + percent / 4)) + ) + : CopyFromDiskImage(download.Value, stagingFolder), + cancellationToken + ); + if (unpacked.IsFailure) + return unpacked; + + var swap = ReplaceAppFolder(stagingFolder); + if (swap.IsFailure) + return swap; + + fileSystem.File.WriteAllText( + VersionFilePath, + JsonSerializer.Serialize(new InstalledRelease(release.Value.ShortRevision, release.Value.Hash)) + ); + EnsureUserFolder(); + progress?.Report(new(t("onboarding.dolphin_ready"), 100)); + logger.LogInformation("Installed managed Dolphin {Version}", release.Value.ShortRevision); + return Ok(); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + return Fail("The Dolphin download was cancelled."); + } + catch (Exception exception) + { + logger.LogError(exception, "Installing the managed Dolphin failed"); + return Fail($"Wheel Wizard could not install Dolphin: {exception.Message}"); + } + finally + { + TryDeleteFolder(DownloadFolderPath); + TryDeleteFolder(stagingFolder); + } + } + + private OperationResult Extract7z(string archivePath, string destination, Action reportPercent) + { + using var stream = fileSystem.File.OpenRead(archivePath); + using var archive = ArchiveFactory.OpenArchive(stream); + var total = Math.Max(1, archive.Entries.Count(entry => !entry.IsDirectory)); + var done = 0; + // Dolphin's archives are solid, so read them front to back instead of seeking per entry. + using var reader = archive.ExtractAllEntries(); + while (reader.MoveToNextEntry()) + { + var entry = reader.Entry; + if (entry.IsDirectory) + continue; + if (!PathSafety.TryGetPathWithinDirectory(destination, entry.Key ?? string.Empty, out var target)) + return Fail("The Dolphin archive contains a file outside its folder."); + + var directory = fileSystem.Path.GetDirectoryName(target); + if (!string.IsNullOrEmpty(directory)) + fileSystem.Directory.CreateDirectory(directory); + using (var input = reader.OpenEntryStream()) + using (var output = fileSystem.File.Create(target)) + input.CopyTo(output); + reportPercent(++done * 100 / total); + } + + return Ok(); + } + + /// Mounts the official .dmg read-only and copies Dolphin.app out with ditto, which keeps its signature intact. + private OperationResult CopyFromDiskImage(string imagePath, string destination) + { + var mountPoint = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), $"wheelwizard-dolphin-{Guid.NewGuid():N}"); + fileSystem.Directory.CreateDirectory(mountPoint); + var attach = processes.Run( + "hdiutil", + ["attach", "-nobrowse", "-readonly", "-noautoopen", "-mountpoint", mountPoint, imagePath], + out _, + out var attachError + ); + if (attach.IsFailure || attach.Value != 0) + return Fail($"The Dolphin disk image could not be opened. {attachError}".Trim()); + + try + { + var app = fileSystem.Path.Combine(mountPoint, "Dolphin.app"); + if (!fileSystem.Directory.Exists(app)) + return Fail("The Dolphin disk image does not contain Dolphin.app."); + + var copy = processes.Run("ditto", [app, fileSystem.Path.Combine(destination, "Dolphin.app")], out _, out var copyError); + return copy.IsSuccess && copy.Value == 0 ? Ok() : Fail($"Dolphin.app could not be copied. {copyError}".Trim()); + } + finally + { + processes.Run("hdiutil", ["detach", mountPoint, "-force"], out _, out _); + TryDeleteFolder(mountPoint); + } + } + + /// Swaps in the new build while keeping the user folder, which lives next to it. + private OperationResult ReplaceAppFolder(string stagingFolder) + { + var previous = fileSystem.Path.Combine(RootFolderPath, "App.old"); + TryDeleteFolder(previous); + try + { + if (fileSystem.Directory.Exists(AppFolderPath)) + fileSystem.Directory.Move(AppFolderPath, previous); + } + catch (IOException) + { + return Fail("Close Dolphin and try again; it is still running from the folder Wheel Wizard needs to update."); + } + + try + { + fileSystem.Directory.Move(stagingFolder, AppFolderPath); + } + catch (Exception exception) + { + if (fileSystem.Directory.Exists(previous) && !fileSystem.Directory.Exists(AppFolderPath)) + fileSystem.Directory.Move(previous, AppFolderPath); + return Fail($"The new Dolphin could not be put in place: {exception.Message}"); + } + + TryDeleteFolder(previous); + return Ok(); + } + + /// + /// The onboarding gives a Dolphin that never ran the managed user folder, and a user folder can also be picked + /// by hand; either way the managed Dolphin is inactive while its folder is still in use. + /// + private bool IsUsedByConfiguredDolphin() + { + bool IsWithinRoot(string path) => + !string.IsNullOrWhiteSpace(path) + && (fileSystem.Path.PathsEqual(path, RootFolderPath) || fileSystem.Path.IsDescendantPath(path, RootFolderPath)); + + // macOS and Linux locations are shell-quoted. + return IsWithinRoot(settings.Get(settings.USER_FOLDER_PATH)) + || IsWithinRoot(settings.Get(settings.DOLPHIN_LOCATION).Trim('\'', '"')); + } + + private string? FindExecutable() + { + if (!fileSystem.Directory.Exists(AppFolderPath)) + return null; + + if (environment.IsMacOS) + { + var app = fileSystem.Path.Combine(AppFolderPath, "Dolphin.app", "Contents", "MacOS", "Dolphin"); + return fileSystem.File.Exists(app) ? app : null; + } + + // The Windows archive wraps everything in a folder such as Dolphin-x64; take the shallowest Dolphin.exe. + return fileSystem + .Directory.EnumerateFiles(AppFolderPath, "Dolphin.exe", SearchOption.AllDirectories) + .OrderBy(path => path.Length) + .FirstOrDefault(); + } + + private void EnsureUserFolder() + { + fileSystem.Directory.CreateDirectory(UserFolderPath); + fileSystem.Directory.CreateDirectory(fileSystem.Path.Combine(UserFolderPath, "Config")); + } + + private void CopyDirectory(string source, string destination) + { + fileSystem.Directory.CreateDirectory(destination); + foreach (var file in fileSystem.Directory.GetFiles(source)) + fileSystem.File.Copy(file, fileSystem.Path.Combine(destination, fileSystem.Path.GetFileName(file)), overwrite: true); + foreach (var folder in fileSystem.Directory.GetDirectories(source)) + CopyDirectory(folder, fileSystem.Path.Combine(destination, fileSystem.Path.GetFileName(folder))); + } + + private void TryDeleteFolder(string folder) + { + try + { + if (fileSystem.Directory.Exists(folder)) + fileSystem.Directory.Delete(folder, recursive: true); + } + catch (Exception exception) + { + logger.LogWarning(exception, "Could not remove {Folder}", folder); + } + } + + private sealed record InstalledRelease(string ShortRevision, string Hash); +} diff --git a/WheelWizard/Features/Onboarding/OnboardingExtensions.cs b/WheelWizard/Features/Onboarding/OnboardingExtensions.cs new file mode 100644 index 000000000..17a02b84c --- /dev/null +++ b/WheelWizard/Features/Onboarding/OnboardingExtensions.cs @@ -0,0 +1,10 @@ +namespace WheelWizard.Onboarding; + +public static class OnboardingExtensions +{ + public static IServiceCollection AddOnboarding(this IServiceCollection services) + { + services.AddSingleton(); + return services; + } +} diff --git a/WheelWizard/Features/Onboarding/OnboardingService.cs b/WheelWizard/Features/Onboarding/OnboardingService.cs new file mode 100644 index 000000000..900cd8f22 --- /dev/null +++ b/WheelWizard/Features/Onboarding/OnboardingService.cs @@ -0,0 +1,310 @@ +using System.IO.Abstractions; +using Microsoft.Extensions.Logging; +using WheelWizard.CustomDistributions; +using WheelWizard.DiscImages; +using WheelWizard.Dolphin.Discovery; +using WheelWizard.Dolphin.Paths; +using WheelWizard.DolphinInstaller; +using WheelWizard.Models.Enums; +using WheelWizard.Recomp; +using WheelWizard.Recomp.Domain; +using WheelWizard.Settings; +using WheelWizard.Settings.Types; +using WheelWizard.Shared.IO; +using WheelWizard.Shared.Platform; +using WheelWizard.Shared.Processes; +using WheelWizard.WiiManagement; +using WheelWizard.WiiManagement.GameLicense; +using WheelWizard.WiiManagement.MiiManagement; + +namespace WheelWizard.Onboarding; + +/// A Dolphin found on this machine, ready to be used as-is. +public sealed record DetectedDolphin(string Location, string DisplayPath, string? Version); + +/// Another Dolphin's data that a managed Dolphin can import. +public sealed record DetectedDolphinData(string UserFolderPath, string WiiFolderPath, string ConfigFolderPath); + +public sealed record GameFileCheck(bool IsValid, string Message); + +public interface IOnboardingService +{ + /// Nothing was ever set up and the onboarding was never finished. + bool IsRequired { get; } + + /// + /// Marks a setup that works, or that was set up before, as onboarded, so a later breakage never brings the + /// onboarding back; Home's repair banner covers that instead. + /// + void MarkCompletedIfAlreadySetUp(); + + bool IsWiiCompiledAvailable { get; } + ManagedDolphinKind ManagedDolphinKind { get; } + + /// A usable Dolphin that needs no choice at all (the Flatpak's bundled extension). + bool HasBundledDolphin { get; } + + /// The Wheel Wizard Flatpak was installed without its Dolphin extension. + bool IsMissingBundledDolphin { get; } + + DetectedDolphin? DetectDolphin(); + DetectedDolphinData? DetectDolphinData(); + + /// Uses a detected or picked Dolphin as the user's own (not managed). + OperationResult UseOwnDolphin(string location); + + /// The Flatpak's own Dolphin: existing Dolphin data when there is some, otherwise a managed user folder. + OperationResult UseBundledDolphin(); + + GameFileCheck CheckGameFile(string path); + OperationResult SetGameFile(string path); + + /// Whether the WiiCompiled path has Dolphin data it can offer to share or copy. + bool HasDolphinNandToOffer { get; } + + Task ApplyNandChoiceAsync(RecompNandChoice choice); + + /// Finishes the Dolphin path; Retro Rewind may still be downloading. + OperationResult FinishDolphin(); + + /// Installs WiiCompiled once Retro Rewind is in place, then finishes the onboarding. + Task FinishWiiCompiledAsync(IProgress progress, CancellationToken cancellationToken); +} + +public sealed class OnboardingService( + ISettingsManager settings, + IFileSystem fileSystem, + IRuntimeEnvironment environment, + IDiscImageService discImages, + IDolphinDiscoveryService discovery, + IDolphinPathResolver dolphinPaths, + IUnixCommandService commands, + ILinuxDolphinInstaller linuxDolphin, + IManagedDolphinService managedDolphin, + IMiiRepositoryService miiRepository, + IGameLicenseSingletonService licenses, + ICustomDistributionPaths distributionPaths, + IServiceProvider services, + ILogger logger +) : IOnboardingService +{ + private IRecompInstallService? RecompInstall => services.GetService(); + private IRecompDolphinDataService? RecompData => services.GetService(); + + public bool IsRequired => !settings.Get(settings.ONBOARDING_COMPLETED) && !WasSetUpBefore() && !IsSetupUsable(); + + public void MarkCompletedIfAlreadySetUp() + { + if (!settings.Get(settings.ONBOARDING_COMPLETED) && (WasSetUpBefore() || IsSetupUsable())) + settings.Set(settings.ONBOARDING_COMPLETED, true); + } + + /// + /// Someone picked paths before, possibly before the onboarding existed. A path that does not resolve today + /// (an unplugged drive, a moved Dolphin) needs repairing, not a first-run flow that would replace it. + /// + private bool WasSetUpBefore() => IsSet(settings.DOLPHIN_LOCATION) || IsSet(settings.USER_FOLDER_PATH) || IsSet(settings.GAME_LOCATION); + + // The Flatpak defaults the Dolphin location to its bundled wrapper, which nobody picked. + private bool IsSet(Setting setting) => + settings.Get(setting) is { } value && !string.IsNullOrWhiteSpace(value) && value != setting.DefaultValue; + + private bool IsSetupUsable() + { + if (settings.DolphinPathsSetupCorrectly()) + return true; + return RecompInstall?.IsInstalled == true && settings.GAME_LOCATION.IsValid(); + } + + public bool IsWiiCompiledAvailable => + RecompPlatform.IsSupported && RecompInstall is not null && !environment.IsMissingRecompFlatpakExtension(fileSystem); + + public ManagedDolphinKind ManagedDolphinKind => managedDolphin.Kind; + public bool HasBundledDolphin => managedDolphin.Kind == ManagedDolphinKind.Bundled && !IsMissingBundledDolphin; + public bool IsMissingBundledDolphin => environment.IsMissingDolphinFlatpakExtension(fileSystem); + + public DetectedDolphin? DetectDolphin() + { + try + { + if (environment.IsMacOS) + { + var app = discovery.FindApplication(); + return app is null ? null : Detected(ShellQuoting.QuoteUnixArgument(app), app); + } + + if (environment.IsLinux && !environment.IsFlatpakSandboxed(fileSystem)) + { + if (linuxDolphin.IsDolphinInstalledInFlatpak()) + return Detected(ManagedDolphinService.FlatpakCommand, ManagedDolphinService.FlatpakCommand); + if (linuxDolphin.IsDolphinInstalledNative()) + return Detected("dolphin-emu", "dolphin-emu"); + } + + // Windows Dolphin builds are plain archives without an install location to look in. + return null; + } + catch (Exception exception) + { + logger.LogWarning(exception, "Looking for an installed Dolphin failed"); + return null; + } + } + + private DetectedDolphin Detected(string location, string displayPath) + { + var layout = dolphinPaths.Resolve(location, string.Empty); + string? version = null; + try + { + var arguments = environment.IsLinux && !layout.IsFlatpakDolphinFilePath(location) ? "QT_QPA_PLATFORM=wayland;xcb " : ""; + var result = services + .GetRequiredService() + .Run("/usr/bin/env", ["sh", "-c", "--", $"{arguments}{location} --version"], out var stdOut, out var stdErr); + if (result.IsSuccess && result.Value == 0) + DolphinVersion.GetStatus(string.IsNullOrWhiteSpace(stdOut) ? stdErr : stdOut, out version); + } + catch + { + // The version is only shown, never required. + } + + return new(location, displayPath, version); + } + + public DetectedDolphinData? DetectDolphinData() + { + try + { + var location = settings.Get(settings.DOLPHIN_LOCATION); + var userFolder = discovery.FindUserDirectory(location, settings.Get(settings.USER_FOLDER_PATH)); + if ( + string.IsNullOrWhiteSpace(userFolder) + || fileSystem.Path.PathsEqual(userFolder, managedDolphin.UserFolderPath) + || settings.DOLPHIN_MANAGED.Get() && fileSystem.Path.PathsEqual(userFolder, settings.Get(settings.USER_FOLDER_PATH)) + ) + return null; + + var wii = fileSystem.Path.Combine(userFolder, "Wii"); + if (!fileSystem.Directory.Exists(wii)) + return null; + return new(userFolder, wii, dolphinPaths.Resolve(location, userFolder).ConfigFolderPath); + } + catch (Exception exception) + { + logger.LogWarning(exception, "Looking for existing Dolphin data failed"); + return null; + } + } + + public OperationResult UseOwnDolphin(string location) + { + if (!settings.Set(settings.DOLPHIN_LOCATION, location)) + return Fail(t("onboarding.dolphin_invalid")); + + var userFolder = discovery.FindUserDirectory(location, settings.Get(settings.USER_FOLDER_PATH)); + if (string.IsNullOrWhiteSpace(userFolder) || !settings.Set(settings.USER_FOLDER_PATH, userFolder)) + { + // A Dolphin that never ran has no user folder yet; give it the one Wheel Wizard would manage. + fileSystem.Directory.CreateDirectory(managedDolphin.UserFolderPath); + if (!settings.Set(settings.USER_FOLDER_PATH, managedDolphin.UserFolderPath)) + return Fail(t("onboarding.dolphin_user_folder_invalid")); + } + + settings.Set(settings.DOLPHIN_MANAGED, false); + return Ok(); + } + + public OperationResult UseBundledDolphin() + { + var data = DetectDolphinData(); + if (data is null) + return managedDolphin.Activate(); + + if (!settings.Set(settings.USER_FOLDER_PATH, data.UserFolderPath)) + return Fail(t("onboarding.dolphin_user_folder_invalid")); + settings.Set(settings.DOLPHIN_MANAGED, false); + return Ok(); + } + + public GameFileCheck CheckGameFile(string path) + { + var info = discImages.ReadInfo(path); + if (info.IsFailure) + return new(false, t("message_error.invalid_game_file.unreadable")); + + var region = GameRegion.FromGameId(info.Value.GameId); + if (region == MarioKartWiiEnums.Regions.None) + return new( + false, + t("message_error.invalid_game_file.wrong_game", new { name = info.Value.InternalName, id = info.Value.GameId }) + ); + + return new(true, $"Mario Kart Wii · {GameRegion.GetDisplayName(region)} · {info.Value.Format.ToString().ToUpperInvariant()}"); + } + + public OperationResult SetGameFile(string path) => + CheckGameFile(path) is { IsValid: false } check ? Fail(check.Message) + : settings.Set(settings.GAME_LOCATION, path) ? Ok() + : Fail(t("message_error.invalid_game_file.unreadable")); + + public bool HasDolphinNandToOffer => RecompInstall?.IsInstalled == false && RecompData?.SourceNandFolderPath is not null; + + public Task ApplyNandChoiceAsync(RecompNandChoice choice) => + RecompData is { } data ? Task.Run(() => data.ApplyNandChoice(choice)) : Task.FromResult(Ok()); + + public OperationResult FinishDolphin() + { + settings.Set(settings.ENABLE_RECOMP, false); + CreateMiiDatabase(); + return Complete(); + } + + public async Task FinishWiiCompiledAsync( + IProgress progress, + CancellationToken cancellationToken + ) + { + if (RecompInstall is not { } install || RecompData is not { } data) + return Fail("WiiCompiled is not available on this system."); + + settings.Set(settings.ENABLE_RECOMP, true); + if (!fileSystem.Directory.Exists(distributionPaths.RetroRewindFolderPath)) + return Fail(t("onboarding.rr_missing")); + + var result = await install.InstallAsync(progress, confirmOfflineInstall: null, cancellationToken); + if (result.IsFailure) + return result; + + var paths = await Task.Run(data.ApplyPathsToRecompConfig, cancellationToken); + if (paths.IsFailure) + return paths; + + CreateMiiDatabase(); + return Complete(); + } + + /// The game only creates its Mii database on first boot; creating an empty one lets the Mii editor work right away. + private void CreateMiiDatabase() + { + try + { + if (miiRepository.Exists()) + return; + var result = miiRepository.ForceCreateDatabase(); + if (result.IsFailure) + logger.LogWarning("Could not create an empty Mii database: {Message}", result.Error.Message); + } + catch (Exception exception) + { + logger.LogWarning(exception, "Could not create an empty Mii database"); + } + } + + private OperationResult Complete() + { + settings.Set(settings.ONBOARDING_COMPLETED, true); + licenses.LoadLicense(); + return Ok(); + } +} diff --git a/WheelWizard/Features/Onboarding/Views/OnboardingPage.axaml b/WheelWizard/Features/Onboarding/Views/OnboardingPage.axaml new file mode 100644 index 000000000..4ded754cf --- /dev/null +++ b/WheelWizard/Features/Onboarding/Views/OnboardingPage.axaml @@ -0,0 +1,235 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/WheelWizard/Features/Onboarding/Views/OnboardingPage.axaml.cs b/WheelWizard/Features/Onboarding/Views/OnboardingPage.axaml.cs new file mode 100644 index 000000000..7fd0b4ba1 --- /dev/null +++ b/WheelWizard/Features/Onboarding/Views/OnboardingPage.axaml.cs @@ -0,0 +1,642 @@ +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Media; +using Avalonia.Platform.Storage; +using Avalonia.Threading; +using Microsoft.Extensions.Logging; +using WheelWizard.CustomDistributions; +using WheelWizard.DiscImages; +using WheelWizard.DolphinInstaller; +using WheelWizard.Recomp; +using WheelWizard.Recomp.Domain; +using WheelWizard.Settings; +using WheelWizard.Shared.Desktop.Storage; +using WheelWizard.Shared.Processes; +using WheelWizard.Views.Dialogs; +using WheelWizard.Views.Shell; +using WheelWizard.Views.Shell.Navigation; +using WheelWizard.Views.Shell.Views; + +namespace WheelWizard.Onboarding.Views; + +/// +/// The first-run setup. Retro Rewind starts downloading the moment this page opens, while the user picks a +/// frontend, a Dolphin and the game. Until it finishes, the rest of the app stays out of reach. +/// +public partial class OnboardingPage : UserControl, IBlockingPage +{ + private enum Step + { + Choice, + DolphinDetected, + DolphinSource, + DolphinMissingExtension, + DolphinInstalling, + DolphinImport, + GameFile, + NandChoice, + WiiCompiledInstalling, + } + + private enum Frontend + { + Dolphin, + WiiCompiled, + } + + private readonly IOnboardingService _onboarding; + private readonly IRetroRewindBackgroundInstall _retroRewind; + private readonly IManagedDolphinService _managedDolphin; + private readonly IFilePickerService _filePicker; + private readonly IDiscImageService _discImages; + private readonly INavigationService _navigation; + private readonly ISettingsManager _settings; + private readonly ILogger _logger; + + private readonly Stack _history = new(); + private Step _step; + private Frontend _frontend = Frontend.Dolphin; + private DetectedDolphin? _detectedDolphin; + private DetectedDolphinData? _detectedData; + private string? _gameFile; + private bool _busy; + + public OnboardingPage( + IOnboardingService onboarding, + IRetroRewindBackgroundInstall retroRewind, + IManagedDolphinService managedDolphin, + IFilePickerService filePicker, + IDiscImageService discImages, + INavigationService navigation, + ISettingsManager settings, + ILogger logger + ) + { + _settings = settings; + _onboarding = onboarding; + _retroRewind = retroRewind; + _managedDolphin = managedDolphin; + _filePicker = filePicker; + _discImages = discImages; + _navigation = navigation; + _logger = logger; + InitializeComponent(); + + GameDropZone.AddHandler(DragDrop.DropEvent, GameDropZone_OnDrop); + EnterCommandButton.IsVisible = OperatingSystem.IsLinux(); + ManagedDescriptionText.Text = + _managedDolphin.Kind == ManagedDolphinKind.Flatpak + ? t("onboarding.dolphin_managed_description_flatpak") + : t("onboarding.dolphin_managed_description"); + ManagedTile.IsVisible = _managedDolphin.Kind is ManagedDolphinKind.Portable or ManagedDolphinKind.Flatpak; + ExtensionCommandText.Text = "flatpak install flathub io.github.TeamWheelWizard.WheelWizard.Backend.DolphinEmu"; + + _navigation.IsLocked = true; + if (_onboarding.IsWiiCompiledAvailable) + Show(Step.Choice); + else + _ = EnterDolphinPathAsync(remember: false); + } + + protected override void OnAttachedToVisualTree(Avalonia.VisualTreeAttachmentEventArgs e) + { + base.OnAttachedToVisualTree(e); + _retroRewind.Changed += RetroRewind_OnChanged; + UpdateRetroRewindStatus(); + // Retro Rewind is needed whichever way the user plays, so it downloads while they decide. + _ = _retroRewind.StartAsync(); + } + + protected override void OnDetachedFromVisualTree(Avalonia.VisualTreeAttachmentEventArgs e) + { + _retroRewind.Changed -= RetroRewind_OnChanged; + base.OnDetachedFromVisualTree(e); + } + + #region Steps + + private void GoTo(Step step) + { + _history.Push(_step); + Show(step); + } + + private void Show(Step step) + { + _step = step; + ChoicePanel.IsVisible = step == Step.Choice; + DetectedPanel.IsVisible = step == Step.DolphinDetected; + SourcePanel.IsVisible = step == Step.DolphinSource; + MissingExtensionPanel.IsVisible = step == Step.DolphinMissingExtension; + ProgressPanel.IsVisible = step is Step.DolphinInstalling or Step.WiiCompiledInstalling; + ImportPanel.IsVisible = step == Step.DolphinImport; + GamePanel.IsVisible = step == Step.GameFile; + NandPanel.IsVisible = step == Step.NandChoice; + SourceErrorText.IsVisible = false; + ImportErrorText.IsVisible = false; + NandErrorText.IsVisible = false; + + (TitleText.Text, BodyText.Text) = step switch + { + Step.Choice => (t("onboarding.choice_title"), t("onboarding.choice_body")), + Step.DolphinDetected => (t("onboarding.dolphin_detected_title"), t("onboarding.dolphin_body")), + Step.DolphinSource => (t("onboarding.dolphin_title"), t("onboarding.dolphin_body")), + Step.DolphinMissingExtension => ( + t("onboarding.dolphin_missing_extension_title"), + t("onboarding.dolphin_missing_extension_body") + ), + Step.DolphinInstalling => (t("onboarding.dolphin_installing_title"), string.Empty), + Step.DolphinImport => (t("onboarding.import_title"), t("onboarding.import_body")), + Step.GameFile => (t("onboarding.game_title"), t("onboarding.game_body")), + Step.NandChoice => (t("onboarding.nand_title"), t("onboarding.nand_body")), + Step.WiiCompiledInstalling => (t("onboarding.wiicompiled_title"), string.Empty), + _ => (string.Empty, string.Empty), + }; + BodyText.IsVisible = !string.IsNullOrEmpty(BodyText.Text); + + if (step == Step.GameFile) + ShowGameFile(_gameFile ?? ExistingGameFile()); + UpdateBackButton(); + } + + private void UpdateBackButton() => + BackButton.IsVisible = + !_busy && _history.Count > 0 && _step is not (Step.DolphinInstalling or Step.WiiCompiledInstalling or Step.DolphinImport); + + private void Back_OnClick(object? sender, RoutedEventArgs e) + { + if (_busy || _history.Count == 0) + return; + Show(_history.Pop()); + } + + private void SetBusy(bool busy) + { + _busy = busy; + IsEnabled = !busy || _step is Step.DolphinInstalling or Step.WiiCompiledInstalling; + UpdateBackButton(); + } + + #endregion + + #region Choice + + private void ChooseDolphin_OnClick(object? sender, RoutedEventArgs e) + { + _frontend = Frontend.Dolphin; + _ = EnterDolphinPathAsync(remember: true); + } + + private void ChooseWiiCompiled_OnClick(object? sender, RoutedEventArgs e) + { + _frontend = Frontend.WiiCompiled; + GoTo(Step.GameFile); + } + + #endregion + + #region Dolphin + + private async Task EnterDolphinPathAsync(bool remember) + { + void Enter(Step step) + { + if (remember) + GoTo(step); + else + Show(step); + } + + if (_onboarding.IsMissingBundledDolphin) + { + Enter(Step.DolphinMissingExtension); + return; + } + + if (_onboarding.HasBundledDolphin) + { + // The Flatpak ships its own Dolphin, so there is nothing to choose. + var result = _onboarding.UseBundledDolphin(); + if (result.IsFailure) + _logger.LogWarning("Setting up the bundled Dolphin failed: {Message}", result.Error.Message); + Enter(Step.GameFile); + return; + } + + // Asking a found Dolphin for its version starts a process, so keep it off the UI thread. + SetBusy(true); + try + { + _detectedDolphin = await Task.Run(_onboarding.DetectDolphin); + } + finally + { + SetBusy(false); + } + + if (_detectedDolphin is not null) + { + DetectedVersionText.Text = _detectedDolphin.Version is { } version + ? $"Dolphin · {t("onboarding.dolphin_detected_version", new { version })}" + : "Dolphin"; + DetectedPathText.Text = _detectedDolphin.DisplayPath; + Enter(Step.DolphinDetected); + return; + } + + Enter(Step.DolphinSource); + } + + private void UseDetected_OnClick(object? sender, RoutedEventArgs e) + { + if (_detectedDolphin is null) + return; + var result = _onboarding.UseOwnDolphin(_detectedDolphin.Location); + if (result.IsFailure) + { + GoTo(Step.DolphinSource); + ShowSourceError(result.Error.Message); + return; + } + + GoTo(Step.GameFile); + } + + private void UseOtherDolphin_OnClick(object? sender, RoutedEventArgs e) => GoTo(Step.DolphinSource); + + private void ManagedDolphin_OnClick(object? sender, RoutedEventArgs e) + { + GoTo(Step.DolphinInstalling); + _ = InstallManagedDolphinAsync(); + } + + private async Task InstallManagedDolphinAsync() + { + SetBusy(true); + ShowProgress(0, t("onboarding.dolphin_downloading")); + StepRetryButton.IsVisible = false; + SelectOwnInsteadButton.IsVisible = false; + StepErrorText.IsVisible = false; + try + { + // Look for the user's other Dolphin before the install points the settings at the managed one. + _detectedData ??= await Task.Run(_onboarding.DetectDolphinData); + var progress = new Progress(value => ShowProgress(value.Percent, value.Message)); + // Coming back to this step after an install must not download Dolphin a second time. + var result = _managedDolphin.IsInstalled ? _managedDolphin.Activate() : await _managedDolphin.InstallAsync(progress); + if (result.IsFailure) + { + ShowStepError($"{t("onboarding.dolphin_install_failed")} {result.Error.Message}", allowOwnDolphin: true); + return; + } + + if (_detectedData is not null) + { + ImportPathText.Text = _detectedData.UserFolderPath; + Show(Step.DolphinImport); + } + else + { + Show(Step.GameFile); + } + } + finally + { + SetBusy(false); + } + } + + private async void OwnDolphin_OnClick(object? sender, RoutedEventArgs e) + { + var location = await PickDolphinAsync(); + if (location is null) + return; + ApplyOwnDolphin(location); + } + + private async void EnterCommand_OnClick(object? sender, RoutedEventArgs e) + { + var command = await new TextInputWindow() + .SetMainText(t("onboarding.dolphin_enter_command")) + .SetPlaceholderText($"Example: {ManagedDolphinService.FlatpakCommand}") + .SetButtonText(t("action.cancel"), t("action.save")) + .SetValidation((_, value) => string.IsNullOrWhiteSpace(value) ? Fail("Enter a launch command.") : Ok()) + .ShowDialog(); + if (!string.IsNullOrWhiteSpace(command)) + ApplyOwnDolphin(command.Trim()); + } + + private void ApplyOwnDolphin(string location) + { + var result = _onboarding.UseOwnDolphin(location); + if (result.IsFailure) + { + ShowSourceError(result.Error.Message); + return; + } + + GoTo(Step.GameFile); + } + + private async Task PickDolphinAsync() + { + if (OperatingSystem.IsMacOS()) + { + var folders = await _filePicker.SelectFolderAsync("Select Dolphin.app"); + var app = folders.FirstOrDefault()?.TryGetLocalPath(); + return app is null ? null : ShellQuoting.QuoteUnixArgument(Path.Combine(app, "Contents", "MacOS", "Dolphin")); + } + + var patterns = OperatingSystem.IsWindows() ? new[] { "*.exe" } : new[] { "*" }; + var file = await _filePicker.OpenSingleFileAsync("Select Dolphin Emulator", [new("Dolphin") { Patterns = patterns }]); + if (string.IsNullOrWhiteSpace(file)) + return null; + if (OperatingSystem.IsWindows()) + { + if (string.Equals(Path.GetFileName(file), "DolphinTool.exe", StringComparison.OrdinalIgnoreCase)) + { + ShowSourceError(t("onboarding.dolphin_invalid")); + return null; + } + + return file; + } + + return ShellQuoting.QuoteUnixArgument(file); + } + + private void SelectOwnInstead_OnClick(object? sender, RoutedEventArgs e) + { + if (_busy) + return; + // Leave the failed install behind; the source step is where the own Dolphin gets picked. + _history.Clear(); + if (_onboarding.IsWiiCompiledAvailable) + _history.Push(Step.Choice); + Show(Step.DolphinSource); + } + + private void ShowSourceError(string message) + { + SourceErrorText.Text = message; + SourceErrorText.IsVisible = true; + } + + private async void Import_OnClick(object? sender, RoutedEventArgs e) + { + if (_detectedData is null) + return; + SetBusy(true); + ImportButton.IsLoading = true; + try + { + var data = _detectedData; + var result = await Task.Run(() => _managedDolphin.ImportUserData(data.WiiFolderPath, data.ConfigFolderPath)); + if (result.IsFailure) + { + ImportErrorText.Text = $"{t("onboarding.import_failed")} {result.Error.Message}"; + ImportErrorText.IsVisible = true; + return; + } + + GoTo(Step.GameFile); + } + finally + { + ImportButton.IsLoading = false; + SetBusy(false); + } + } + + private void SkipImport_OnClick(object? sender, RoutedEventArgs e) => GoTo(Step.GameFile); + + #endregion + + #region Game file + + private string? ExistingGameFile() + { + var current = _settings.Get(_settings.GAME_LOCATION); + return string.IsNullOrWhiteSpace(current) ? null : current; + } + + private async void PickGame_OnClick(object? sender, RoutedEventArgs e) + { + var fileType = new FilePickerFileType("Game files") + { + Patterns = _discImages.SupportedExtensions.Select(extension => "*" + extension).ToArray(), + }; + var file = await _filePicker.OpenSingleFileAsync("Select Mario Kart Wii Game File", [fileType]); + if (!string.IsNullOrEmpty(file)) + ShowGameFile(file); + } + + private void GameDropZone_OnDrop(object? sender, DragEventArgs e) + { + var file = e.DataTransfer.TryGetFiles()?.FirstOrDefault()?.TryGetLocalPath(); + if (!string.IsNullOrEmpty(file)) + ShowGameFile(file); + } + + private void ShowGameFile(string? file) + { + _gameFile = file; + if (string.IsNullOrWhiteSpace(file)) + { + GameResult.IsVisible = false; + GameContinueButton.IsEnabled = false; + return; + } + + var check = _onboarding.CheckGameFile(file); + GameResult.IsVisible = true; + GameResultIcon.Data = Resource(check.IsValid ? "SuccessTip" : "ErrorTip"); + GameResultIcon.Foreground = new SolidColorBrush(Resource(check.IsValid ? "Brand300" : "Danger400")); + GameResultText.Text = check.IsValid ? $"{check.Message}\n{Path.GetFileName(file)}" : check.Message; + GameContinueButton.IsEnabled = check.IsValid; + } + + private async void GameContinue_OnClick(object? sender, RoutedEventArgs e) + { + if (_gameFile is null || _busy) + return; + var result = _onboarding.SetGameFile(_gameFile); + if (result.IsFailure) + { + ShowGameFile(_gameFile); + return; + } + + if (_frontend == Frontend.Dolphin) + { + Finish(_onboarding.FinishDolphin()); + return; + } + + if (_onboarding.HasDolphinNandToOffer) + { + GoTo(Step.NandChoice); + return; + } + + await ApplyNandChoiceAsync(RecompNandChoice.Private); + } + + #endregion + + #region WiiCompiled + + private void NandShare_OnClick(object? sender, RoutedEventArgs e) => _ = ApplyNandChoiceAsync(RecompNandChoice.Share); + + private void NandCopy_OnClick(object? sender, RoutedEventArgs e) => _ = ApplyNandChoiceAsync(RecompNandChoice.Copy); + + private void NandFresh_OnClick(object? sender, RoutedEventArgs e) => _ = ApplyNandChoiceAsync(RecompNandChoice.Private); + + private async Task ApplyNandChoiceAsync(RecompNandChoice choice) + { + if (_busy) + return; + SetBusy(true); + try + { + var result = await _onboarding.ApplyNandChoiceAsync(choice); + if (result.IsFailure) + { + NandErrorText.Text = result.Error.Message; + NandErrorText.IsVisible = true; + return; + } + } + finally + { + SetBusy(false); + } + + GoTo(Step.WiiCompiledInstalling); + await InstallWiiCompiledAsync(); + } + + private async Task InstallWiiCompiledAsync() + { + SetBusy(true); + StepRetryButton.IsVisible = false; + SelectOwnInsteadButton.IsVisible = false; + StepErrorText.IsVisible = false; + try + { + if (_retroRewind.State != BackgroundInstallState.Completed) + { + ShowProgress(_retroRewind.Percent, t("onboarding.wiicompiled_waiting")); + var download = await _retroRewind.StartAsync(); + if (download.IsFailure) + { + ShowStepError(_retroRewind.ErrorMessage ?? t("onboarding.rr_failed"), allowOwnDolphin: false); + return; + } + } + + ShowProgress(0, t("onboarding.wiicompiled_title")); + var progress = new Progress(value => ShowProgress(value.Percent, value.Message)); + var result = await _onboarding.FinishWiiCompiledAsync(progress, CancellationToken.None); + if (result.IsFailure) + { + ShowStepError($"{t("onboarding.wiicompiled_failed")} {result.Error.Message}", allowOwnDolphin: false); + return; + } + } + finally + { + SetBusy(false); + } + + Finish(Ok()); + } + + #endregion + + #region Progress and finishing + + private void ShowProgress(int percent, string message) + { + StepProgressBar.Value = Math.Clamp(percent, 0, 100); + StepProgressText.Text = message; + } + + private void ShowStepError(string message, bool allowOwnDolphin) + { + StepErrorText.Text = message; + StepErrorText.IsVisible = true; + StepRetryButton.IsVisible = true; + SelectOwnInsteadButton.IsVisible = allowOwnDolphin; + } + + private void StepRetry_OnClick(object? sender, RoutedEventArgs e) + { + if (_busy) + return; + if (_step == Step.DolphinInstalling) + _ = InstallManagedDolphinAsync(); + else if (_step == Step.WiiCompiledInstalling) + { + if (_retroRewind.State == BackgroundInstallState.Failed) + _ = _retroRewind.RetryAsync(); + _ = InstallWiiCompiledAsync(); + } + } + + private void Finish(OperationResult result) + { + if (result.IsFailure) + { + _logger.LogError("Finishing the onboarding failed: {Message}", result.Error.Message); + return; + } + + _navigation.IsLocked = false; + _navigation.NavigateAway(typeof(HomePage)); + if (ViewUtils.GetLayout() is { } layout) + { + layout.UpdateSidebarProfile(); + layout.UpdateFriendCount(); + } + } + + #endregion + + #region Retro Rewind status + + private void RetroRewind_OnChanged(object? sender, EventArgs e) => Dispatcher.UIThread.Post(UpdateRetroRewindStatus); + + private void UpdateRetroRewindStatus() + { + var state = _retroRewind.State; + RetroRewindStatus.IsVisible = state != BackgroundInstallState.Idle; + RetroRewindSpinner.IsVisible = state == BackgroundInstallState.Running; + RetroRewindIcon.IsVisible = state != BackgroundInstallState.Running; + RetroRewindRetryButton.IsVisible = state == BackgroundInstallState.Failed; + var failed = state == BackgroundInstallState.Failed; + RetroRewindIcon.Data = Resource(failed ? "WarningTip" : "SuccessTip"); + RetroRewindIcon.Foreground = new SolidColorBrush(Resource(failed ? "Warning400" : "Brand300")); + RetroRewindText.Text = state switch + { + BackgroundInstallState.Running when _retroRewind.Message == t("state.extracting") => t( + "onboarding.rr_extracting", + new { percent = _retroRewind.Percent } + ), + BackgroundInstallState.Running => t("onboarding.rr_downloading", new { percent = _retroRewind.Percent }), + BackgroundInstallState.Failed => t("onboarding.rr_failed"), + BackgroundInstallState.Completed => t("onboarding.rr_ready"), + _ => string.Empty, + }; + + if (_step == Step.WiiCompiledInstalling && state == BackgroundInstallState.Running) + ShowProgress(_retroRewind.Percent, t("onboarding.wiicompiled_waiting")); + } + + private void RetroRewindRetry_OnClick(object? sender, RoutedEventArgs e) => _ = _retroRewind.RetryAsync(); + + #endregion + + private T Resource(string key) => (T)this.FindResource(key)!; +} diff --git a/WheelWizard/Features/Recomp/RecompDolphinDataService.cs b/WheelWizard/Features/Recomp/RecompDolphinDataService.cs index 47b5ba16b..164a088b8 100644 --- a/WheelWizard/Features/Recomp/RecompDolphinDataService.cs +++ b/WheelWizard/Features/Recomp/RecompDolphinDataService.cs @@ -1,6 +1,8 @@ using System.IO.Abstractions; +using WheelWizard.CustomDistributions; using WheelWizard.Dolphin.Discovery; using WheelWizard.Settings; +using WheelWizard.Settings.Types; namespace WheelWizard.Recomp; @@ -25,7 +27,12 @@ public interface IRecompDolphinDataService OperationResult CopyNandForRecomp(); - OperationResult ApplyNandToRecompConfig(); + /// + /// Writes the NAND and Retro Rewind folders Wheel Wizard resolves today into the recomp's Config.toml. + /// Both move (sharing toggles, a relocated data folder, the Retro Rewind migration), and the runtime only + /// knows what this file says, so this runs before every launch as well as after each of those changes. + /// + OperationResult ApplyPathsToRecompConfig(); } public sealed class RecompDolphinDataService( @@ -33,7 +40,8 @@ public sealed class RecompDolphinDataService( IRecompSettingManager recompSettings, IFileSystem fileSystem, IDolphinDiscoveryService dolphinDiscovery, - IRecompPaths paths + IRecompPaths paths, + ICustomDistributionPaths distributionPaths ) : IRecompDolphinDataService { public bool IsSharingEnabled => settings.Get(settings.RECOMP_USE_DOLPHIN_DATA); @@ -134,7 +142,7 @@ public OperationResult CopyNandForRecomp() return Ok(); } - public OperationResult ApplyNandToRecompConfig() + public OperationResult ApplyPathsToRecompConfig() { var nandFolderPath = NandFolderPath?.TrimEnd('\\', '/'); try @@ -143,21 +151,14 @@ public OperationResult ApplyNandToRecompConfig() // file is brand new and this manager may never have seen it. Without a file there is // nothing to configure yet, and the next successful install applies this again. recompSettings.ReloadSettings(paths.ConfigFilePath); - - if (nandFolderPath is null) - { - recompSettings.RemoveTomlSetting(paths.ConfigFilePath, "paths", "nand_root"); - settings.Set(settings.RECOMP_NAND_ROOT, "", skipSave: true); + if (!fileSystem.File.Exists(paths.ConfigFilePath)) return Ok(); - } - // Reset before assigning so the write happens even when this process already holds the - // same value in memory - a reinstall recreates Config.toml without the key, and only - // an actual change would otherwise be saved. - settings.Set(settings.RECOMP_NAND_ROOT, "", skipSave: true); - return settings.Set(settings.RECOMP_NAND_ROOT, nandFolderPath) - ? Ok() - : Fail("Wheel Wizard could not save the Wii data folder to the WiiCompiled configuration."); + var nandResult = ApplyNandRoot(nandFolderPath); + if (nandResult.IsFailure) + return nandResult; + + return ApplyRetroRewindRoot(); } catch (Exception exception) { @@ -165,6 +166,41 @@ public OperationResult ApplyNandToRecompConfig() } } + private OperationResult ApplyNandRoot(string? nandFolderPath) + { + if (nandFolderPath is null) + { + recompSettings.RemoveTomlSetting(paths.ConfigFilePath, "paths", "nand_root"); + settings.Set(settings.RECOMP_NAND_ROOT, "", skipSave: true); + return Ok(); + } + + return ForceWrite(settings.RECOMP_NAND_ROOT, nandFolderPath) + ? Ok() + : Fail("Wheel Wizard could not save the Wii data folder to the WiiCompiled configuration."); + } + + private OperationResult ApplyRetroRewindRoot() + { + // Without an installed Retro Rewind the setup's own value is the only one there is, so keep it. + var retroRewindFolderPath = distributionPaths.RetroRewindFolderPath; + if (!fileSystem.Directory.Exists(retroRewindFolderPath)) + return Ok(); + + return ForceWrite(settings.RECOMP_RETRO_REWIND_ROOT, fileSystem.Path.GetFullPath(retroRewindFolderPath).TrimEnd('\\', '/')) + ? Ok() + : Fail("Wheel Wizard could not save the Retro Rewind folder to the WiiCompiled configuration."); + } + + // Reset before assigning so the write happens even when this process already holds the + // same value in memory - a reinstall recreates Config.toml without the key, and only + // an actual change would otherwise be saved. + private bool ForceWrite(Setting setting, string value) + { + settings.Set(setting, "", skipSave: true); + return settings.Set(setting, value); + } + private void CopyDirectory(string sourceFolder, string destinationFolder) { fileSystem.Directory.CreateDirectory(destinationFolder); @@ -212,3 +248,48 @@ private void CopyDirectory(string sourceFolder, string destinationFolder) } } } + +/// Where a fresh WiiCompiled install takes its Wii data from. +public enum RecompNandChoice +{ + /// The runtime's own NAND; Dolphin's data is left alone. + Private, + + /// Dolphin's live NAND, shared in place. + Share, + + /// A Wheel Wizard-owned copy of Dolphin's NAND. + Copy, +} + +public static class RecompNandChoices +{ + /// + /// Applies a fresh install's NAND choice. The launcher asks it in popups and the onboarding inline, so both + /// share this one definition of what each answer means. + /// + public static OperationResult ApplyNandChoice(this IRecompDolphinDataService data, RecompNandChoice choice) + { + switch (choice) + { + case RecompNandChoice.Share: + data.SetCopyEnabled(false); + data.SetSharingEnabled(true); + return Ok(); + case RecompNandChoice.Copy: + var copyResult = data.CopyNandForRecomp(); + if (copyResult.IsFailure) + return copyResult; + + // Flipped only after the copy durably exists, so a failed copy can never leave the settings + // pointing at a NAND that is not there. + data.SetCopyEnabled(true); + data.SetSharingEnabled(false); + return Ok(); + default: + data.SetSharingEnabled(false); + data.SetCopyEnabled(false); + return Ok(); + } + } +} diff --git a/WheelWizard/Features/Recomp/RecompLauncher.cs b/WheelWizard/Features/Recomp/RecompLauncher.cs index b37b9a94b..9541aa33c 100644 --- a/WheelWizard/Features/Recomp/RecompLauncher.cs +++ b/WheelWizard/Features/Recomp/RecompLauncher.cs @@ -45,6 +45,11 @@ public async Task Launch() if (preparation.IsFailure) return preparation; + // The NAND and Retro Rewind folders can move between launches; Config.toml must follow them. + var pathsResult = await Task.Run(dolphinData.ApplyPathsToRecompConfig); + if (pathsResult.IsFailure) + return pathsResult; + // The presentation scope closes before launching; the game session is not cancellable. return await installService.LaunchAsync(CancellationToken.None); } @@ -66,7 +71,7 @@ public async Task Install() // The recomp compiled without any NAND knowledge; pointing its Config.toml at the chosen // Wii data is what makes the choice real, and it applies on the very next launch. - return dolphinData.ApplyNandToRecompConfig(); + return dolphinData.ApplyPathsToRecompConfig(); } /// @@ -81,7 +86,7 @@ public async Task Update() if (updateResult.IsFailure) return updateResult; - return dolphinData.ApplyNandToRecompConfig(); + return dolphinData.ApplyPathsToRecompConfig(); } public async Task GetCurrentStatus() @@ -137,40 +142,21 @@ private async Task AskForDolphinNandChoiceAsync() if (installService.IsInstalled) return Ok(); + // There is no Dolphin data to offer, so the recomp simply starts with its own. var sourceNand = await Task.Run(() => dolphinData.SourceNandFolderPath); if (sourceNand is null) - { - // There is no Dolphin data to offer, so the recomp simply starts with its own. - dolphinData.SetSharingEnabled(false); - dolphinData.SetCopyEnabled(false); - return Ok(); - } + return dolphinData.ApplyNandChoice(RecompNandChoice.Private); - var useDolphinData = await presentation.ConfirmUseDolphinDataAsync(); - if (!useDolphinData) - { - dolphinData.SetSharingEnabled(false); - dolphinData.SetCopyEnabled(false); - return Ok(); - } - - var copyNand = await presentation.ConfirmCopyDolphinDataAsync(); - if (!copyNand) - { - dolphinData.SetCopyEnabled(false); - dolphinData.SetSharingEnabled(true); - return Ok(); - } + if (!await presentation.ConfirmUseDolphinDataAsync()) + return dolphinData.ApplyNandChoice(RecompNandChoice.Private); - var copyResult = await presentation.RunAsync(RecompOperationKind.CopyNand, _ => Task.Run(dolphinData.CopyNandForRecomp)); - if (copyResult.IsFailure) - return copyResult; + if (!await presentation.ConfirmCopyDolphinDataAsync()) + return dolphinData.ApplyNandChoice(RecompNandChoice.Share); - // Flipped only after the copy durably exists, so a failed copy can never leave the settings - // pointing at a NAND that is not there. - dolphinData.SetCopyEnabled(true); - dolphinData.SetSharingEnabled(false); - return Ok(); + return await presentation.RunAsync( + RecompOperationKind.CopyNand, + _ => Task.Run(() => dolphinData.ApplyNandChoice(RecompNandChoice.Copy)) + ); } private async Task RunSetupAsync(RecompOperationKind kind) diff --git a/WheelWizard/Features/Settings/ISettingsServices.cs b/WheelWizard/Features/Settings/ISettingsServices.cs index 9e9957145..07cfe35cd 100644 --- a/WheelWizard/Features/Settings/ISettingsServices.cs +++ b/WheelWizard/Features/Settings/ISettingsServices.cs @@ -37,6 +37,8 @@ public interface ISettingsProperties Setting USER_FOLDER_PATH { get; } Setting DOLPHIN_LOCATION { get; } Setting GAME_LOCATION { get; } + Setting DOLPHIN_MANAGED { get; } + Setting ONBOARDING_COMPLETED { get; } Setting FORCE_WIIMOTE { get; } Setting LAUNCH_WITH_DOLPHIN { get; } Setting LAUNCH_RR_ON_STARTUP { get; } @@ -66,6 +68,7 @@ public interface ISettingsProperties Setting RECOMP_SHOW_FPS { get; } Setting RECOMP_PREVENT_STUTTERS { get; } Setting RECOMP_NAND_ROOT { get; } + Setting RECOMP_RETRO_REWIND_ROOT { get; } } public interface ISettingsManager : ISettingsProperties diff --git a/WheelWizard/Features/Settings/SettingsManager.cs b/WheelWizard/Features/Settings/SettingsManager.cs index d796c5bdc..e3bd847fb 100644 --- a/WheelWizard/Features/Settings/SettingsManager.cs +++ b/WheelWizard/Features/Settings/SettingsManager.cs @@ -208,6 +208,10 @@ IUnixCommandService commands ); GAME_LOCATION = RegisterWhWz("GameLocation", "", value => _fileSystem.File.Exists(value)); + // Wheel Wizard installed this Dolphin into its own data folder (or, on native Linux, as a user Flatpak). + // While set, the Dolphin location and user folder are derived from the data folder on every start. + DOLPHIN_MANAGED = RegisterWhWz("DolphinManaged", false); + ONBOARDING_COMPLETED = RegisterWhWz("OnboardingCompleted", false); FORCE_WIIMOTE = RegisterWhWz("ForceWiimote", false); LAUNCH_WITH_DOLPHIN = RegisterWhWz("LaunchWithDolphin", false); LAUNCH_RR_ON_STARTUP = RegisterWhWz("LaunchRrOnStartup", false); @@ -258,6 +262,9 @@ IUnixCommandService commands // The Wii data folder the runtime should use, written by RecompDolphinDataService after an // install and whenever the sharing choice changes. Empty/absent means the runtime's private NAND. RECOMP_NAND_ROOT = RegisterRecomp(("paths", "nand_root"), ""); + // The Retro Rewind folder the runtime loads. The setup writes it once at install time, so Wheel + // Wizard rewrites it before every launch to follow a moved Retro Rewind or data folder. + RECOMP_RETRO_REWIND_ROOT = RegisterRecomp(("paths", "retro_rewind_root"), ""); #endregion #region Virtual settings @@ -312,6 +319,8 @@ private static void SaveRecommended(Setting setting, T value) public Setting USER_FOLDER_PATH { get; } public Setting DOLPHIN_LOCATION { get; } public Setting GAME_LOCATION { get; } + public Setting DOLPHIN_MANAGED { get; } + public Setting ONBOARDING_COMPLETED { get; } public Setting FORCE_WIIMOTE { get; } public Setting LAUNCH_WITH_DOLPHIN { get; } public Setting LAUNCH_RR_ON_STARTUP { get; } @@ -342,6 +351,7 @@ private static void SaveRecommended(Setting setting, T value) public Setting RECOMP_SHOW_FPS { get; } public Setting RECOMP_PREVENT_STUTTERS { get; } public Setting RECOMP_NAND_ROOT { get; } + public Setting RECOMP_RETRO_REWIND_ROOT { get; } #endregion #region Public API diff --git a/WheelWizard/Features/Settings/Views/RecompSettings.axaml.cs b/WheelWizard/Features/Settings/Views/RecompSettings.axaml.cs index 5f222e689..129e2c06a 100644 --- a/WheelWizard/Features/Settings/Views/RecompSettings.axaml.cs +++ b/WheelWizard/Features/Settings/Views/RecompSettings.axaml.cs @@ -321,7 +321,7 @@ private async Task ApplyNandSettingAsync() if (dolphinData is null) return; - var result = await Task.Run(dolphinData.ApplyNandToRecompConfig); + var result = await Task.Run(dolphinData.ApplyPathsToRecompConfig); if (result.IsFailure) MessageTranslationHelper.ShowMessage(result.Error); } diff --git a/WheelWizard/Features/Settings/Views/WhWzSettings.axaml b/WheelWizard/Features/Settings/Views/WhWzSettings.axaml index b983b8745..7036be766 100644 --- a/WheelWizard/Features/Settings/Views/WhWzSettings.axaml +++ b/WheelWizard/Features/Settings/Views/WhWzSettings.axaml @@ -169,6 +169,18 @@ + + + + + + ApplyLocationSettingAsync(Setting setting, stri return false; } + // Picking a Dolphin or user folder by hand means the user now manages Dolphin themselves. + if ((setting == SettingsService.DOLPHIN_LOCATION || setting == SettingsService.USER_FOLDER_PATH) && ManagedDolphin.IsActive) + ManagedDolphin.Deactivate(); + UpdateLocationRows(); await MessageTranslationHelper.AwaitMessageAsync(MessageTranslation.Success_PathSettingsSaved); @@ -383,9 +397,11 @@ private void UpdateLocationRows() ); LocationWarningIcon.IsVisible = !SettingsService.PathsSetupCorrectly(); - DolphinExecutableValueText.Text = string.IsNullOrWhiteSpace(DolphinPaths.ExecutablePath) - ? GetDolphinExecutableHelperText() + DolphinExecutableValueText.Text = + ManagedDolphin.IsActive ? ManagedDolphinText() + : string.IsNullOrWhiteSpace(DolphinPaths.ExecutablePath) ? GetDolphinExecutableHelperText() : DolphinPaths.ExecutablePath; + UpdateManagedDolphinActions(); DolphinExecutableOpenButton.IsVisible = !OperatingSystem.IsLinux() || IsConfiguredExecutableFile(DolphinPaths.ExecutablePath); DolphinExecutableOpenButton.IsEnabled = DolphinExecutableOpenButton.IsVisible && CanOpenContainingFolder(DolphinPaths.ExecutablePath); @@ -394,6 +410,106 @@ private void UpdateLocationRows() DolphinUserFolderOpenButton.IsEnabled = Directory.Exists(DolphinPaths.UserFolderPath); } + private string ManagedDolphinText() + { + var version = ManagedDolphin.Kind == ManagedDolphinKind.Portable ? ManagedDolphin.InstalledVersion : null; + return version is null ? t("onboarding.managed_dolphin_label") : $"{t("onboarding.managed_dolphin_label")} · {version}"; + } + + private void UpdateManagedDolphinActions() + { + var canManage = ManagedDolphin.Kind is ManagedDolphinKind.Portable or ManagedDolphinKind.Flatpak; + ManageDolphinButton.IsVisible = canManage && !ManagedDolphin.IsActive && !SettingsService.IsRecompModeActive(); + RemoveManagedDolphinButton.IsVisible = + ManagedDolphin.Kind == ManagedDolphinKind.Portable && ManagedDolphin.IsInstalled && ManagedDolphin.CanRemove; + ManagedDolphinActions.IsVisible = ManageDolphinButton.IsVisible || RemoveManagedDolphinButton.IsVisible; + } + + private async void ManageDolphin_OnClick(object? sender, RoutedEventArgs e) + { + var confirmed = await new YesNoWindow() + .SetMainText(t("onboarding.managed_dolphin_switch")) + .SetExtraText(t("onboarding.managed_dolphin_switch_question")) + .AwaitAnswer(); + if (!confirmed) + return; + + // Read the current Dolphin's data folders before the switch points the settings elsewhere. + var sourceWii = SettingsService.USER_FOLDER_PATH.IsValid() ? DolphinPaths.WiiFolderPath : null; + var sourceConfig = SettingsService.USER_FOLDER_PATH.IsValid() ? DolphinPaths.ConfigFolderPath : null; + + var window = new ProgressWindow(t("onboarding.dolphin_installing_title")); + OperationResult result; + try + { + window.Show(); + var progress = new Progress(value => + { + window.SetExtraText(value.Message); + window.UpdateProgress(value.Percent); + }); + result = ManagedDolphin.IsInstalled ? ManagedDolphin.Activate() : await ManagedDolphin.InstallAsync(progress); + if (result.IsSuccess && sourceWii is not null && sourceConfig is not null) + result = await Task.Run(() => ManagedDolphin.ImportUserData(sourceWii, sourceConfig)); + } + finally + { + window.Close(); + } + + UpdateLocationRows(); + if (result.IsFailure) + { + await new MessageBoxWindow() + .SetMessageType(MessageBoxWindow.MessageType.Error) + .SetTitleText(t("onboarding.dolphin_install_failed")) + .SetInfoText(result.Error.Message) + .ShowDialog(); + return; + } + + RefreshLicensesAfterDolphinChange(); + } + + private async void RemoveManagedDolphin_OnClick(object? sender, RoutedEventArgs e) + { + var size = await Task.Run(ManagedDolphin.GetInstalledSize); + var confirmed = await new YesNoWindow() + .SetMainText(t("onboarding.managed_dolphin_remove")) + .SetExtraText(t("onboarding.managed_dolphin_remove_question", new { size = $"{size / (1024d * 1024d):0} MB" })) + .AwaitAnswer(); + if (!confirmed) + return; + + var result = await Task.Run(ManagedDolphin.Remove); + UpdateLocationRows(); + if (result.IsFailure) + { + await new MessageBoxWindow() + .SetMessageType(MessageBoxWindow.MessageType.Error) + .SetTitleText(t("onboarding.managed_dolphin_remove")) + .SetInfoText(result.Error.Message) + .ShowDialog(); + } + } + + private static void RefreshLicensesAfterDolphinChange() + { + var layout = ViewUtils.GetLayout(); + layout.UpdateSidebarProfile(); + layout.UpdateFriendCount(); + } + + /// Everything Wheel Wizard derives from its data folder has to follow it to the new place. + private void FollowRelocatedDataFolder() + { + ManagedDolphin.SyncSettings(); + var result = Services.GetService()?.ApplyPathsToRecompConfig(); + if (result is { IsFailure: true }) + Serilog.Log.Warning("Could not update WiiCompiled after moving the data folder: {Message}", result.Error.Message); + UpdateLocationRows(); + } + private static void SetLocationRowState(PathIcon completeIcon, PathIcon warningIcon, SettingsButton changeButton, bool isValid) { completeIcon.IsVisible = isValid; @@ -537,6 +653,7 @@ private async Task MoveWheelWizardDataAsync(string targetPath) if (success) { await HandleSuccessfulAppdataMoveAsync(details, errorMessage); + FollowRelocatedDataFolder(); } else { diff --git a/WheelWizard/Resources/Languages/en.yml b/WheelWizard/Resources/Languages/en.yml index 6c5d8aa68..5336db172 100644 --- a/WheelWizard/Resources/Languages/en.yml +++ b/WheelWizard/Resources/Languages/en.yml @@ -741,3 +741,75 @@ en: empty: "No options found" input: invalid_number: "Enter a number" + onboarding: + choice_title: "Welcome to Wheel Wizard" + choice_body: "Let's get Retro Rewind ready. How do you want to play?" + choice_dolphin: "Dolphin" + choice_dolphin_description: "Play in the Dolphin emulator. Works on every system." + choice_wiicompiled: "WiiCompiled" + choice_wiicompiled_description: "A native PC version of the game. Smoother and faster to start." + back: "Back" + continue: "Continue" + retry: "Retry" + dolphin_title: "Set up Dolphin" + dolphin_body: "Wheel Wizard needs Dolphin to run Retro Rewind." + dolphin_detected_title: "We found Dolphin" + dolphin_detected_version: "Version %{version}" + dolphin_use_other: "Use a different Dolphin" + dolphin_managed: "Download and manage Dolphin for me" + dolphin_managed_description: "Wheel Wizard installs the latest Dolphin and keeps it up to date." + dolphin_managed_description_flatpak: "Wheel Wizard installs the official Dolphin Flatpak and keeps it up to date." + dolphin_own: "I already have Dolphin" + dolphin_own_description: "Select the Dolphin you already use." + dolphin_enter_command: "Enter a launch command instead" + dolphin_invalid: "This Dolphin could not be used. Please select the Dolphin program itself." + dolphin_user_folder_invalid: "Wheel Wizard could not find a user folder for this Dolphin." + dolphin_installing_title: "Installing Dolphin" + dolphin_installing: "Installing Dolphin" + dolphin_downloading: "Downloading Dolphin" + dolphin_updating: "Updating Dolphin" + dolphin_ready: "Dolphin is ready" + dolphin_install_failed: "Dolphin could not be installed." + dolphin_select_own_instead: "Select my own Dolphin instead" + dolphin_missing_extension_title: "Dolphin extension missing" + dolphin_missing_extension_body: "This Wheel Wizard Flatpak needs its Dolphin extension. Run this command, then restart Wheel Wizard:" + import_title: "Use your Dolphin data?" + import_body: "We found Dolphin data on this computer. Wheel Wizard can copy your Miis, saves and controller settings into the new Dolphin. Your existing Dolphin stays untouched." + import_use: "Copy my data" + import_fresh: "Start fresh" + import_failed: "Your Dolphin data could not be copied." + game_title: "Select your game" + game_body: "Choose your Mario Kart Wii disc image. Wheel Wizard never uploads or changes it." + game_pick: "Select game file" + game_drop: "or drop it here" + nand_title: "Use your Dolphin Miis and saves?" + nand_body: "WiiCompiled can use the Miis and licenses from your Dolphin." + nand_share: "Share with Dolphin" + nand_share_description: "Both use the same Miis and saves. Changes show up in both." + nand_copy: "Copy them" + nand_copy_description: "WiiCompiled gets its own copy. Dolphin is never touched." + nand_fresh: "Start fresh" + nand_fresh_description: "WiiCompiled starts with its own empty data." + wiicompiled_title: "Installing WiiCompiled" + wiicompiled_waiting: "Waiting for Retro Rewind to finish downloading" + wiicompiled_failed: "WiiCompiled could not be installed." + rr_downloading: "Downloading Retro Rewind · %{percent}%" + rr_extracting: "Unpacking Retro Rewind · %{percent}%" + rr_failed: "Retro Rewind download failed" + rr_ready: "Retro Rewind is ready" + rr_missing: "Retro Rewind is not installed yet." + rr_installing_button: "Installing %{percent}%" + rr_moving: "Moving Retro Rewind · %{percent}%" + dolphin_update_title: "Dolphin update available" + dolphin_update_body: "Dolphin %{version} is available. Wheel Wizard can update the Dolphin it manages for you now." + dolphin_update_action: "Update" + dolphin_update_later: "Later" + managed_dolphin_label: "Managed by Wheel Wizard" + managed_dolphin_switch: "Let Wheel Wizard manage Dolphin" + managed_dolphin_switch_question: "Wheel Wizard will install its own Dolphin and copy your Miis, saves and controller settings into it. Your current Dolphin stays untouched." + managed_dolphin_remove: "Remove managed Dolphin" + managed_dolphin_remove_question: "This deletes the Dolphin Wheel Wizard installed, including its Miis and saves (%{size}). Your own Dolphin is not affected." + repair_dolphin: "Wheel Wizard can't find Dolphin. It may have been moved or removed." + repair_user_folder: "Wheel Wizard can't find your Dolphin folder. Is the drive it's on connected?" + repair_game: "Wheel Wizard can't find your game file. Is the drive it's on connected?" + repair_action: "Fix in Settings" diff --git a/WheelWizard/SetupExtensions.cs b/WheelWizard/SetupExtensions.cs index 5538e46fe..650cfff8d 100644 --- a/WheelWizard/SetupExtensions.cs +++ b/WheelWizard/SetupExtensions.cs @@ -16,6 +16,7 @@ using WheelWizard.MiiAnimations; using WheelWizard.MiiImages; using WheelWizard.Mods; +using WheelWizard.Onboarding; using WheelWizard.Recomp; using WheelWizard.RrRooms; using WheelWizard.Settings; @@ -54,6 +55,7 @@ public static void AddWheelWizardServices(this IServiceCollection services, IApp services.AddPatches(); services.AddMods(); services.AddRecomp(); + services.AddOnboarding(); services.AddPresentation(); } } diff --git a/WheelWizard/Views/Shell/Layout.axaml.cs b/WheelWizard/Views/Shell/Layout.axaml.cs index e26e1247e..c51f69bf4 100644 --- a/WheelWizard/Views/Shell/Layout.axaml.cs +++ b/WheelWizard/Views/Shell/Layout.axaml.cs @@ -11,6 +11,8 @@ using WheelWizard.Localization; using WheelWizard.MiiImages.Views; using WheelWizard.Mods; +using WheelWizard.Onboarding; +using WheelWizard.Onboarding.Views; using WheelWizard.RrRooms; using WheelWizard.RrRooms.Views; using WheelWizard.Settings; @@ -79,6 +81,8 @@ public partial class Layout : BaseWindow, IPollingListener private IModManager ModManagerService { get; } + private IOnboardingService Onboarding { get; } + public Layout( INavigationService navigation, LiveRoomsService liveRooms, @@ -88,9 +92,11 @@ public Layout( ISettingsManager settingsService, ISettingsSignalBus settingsSignalBus, IModManager modManagerService, - IAutoUpdaterSingletonService autoUpdater + IAutoUpdaterSingletonService autoUpdater, + IOnboardingService onboarding ) { + Onboarding = onboarding; Navigation = navigation; LiveRooms = liveRooms; LiveStatus = liveStatus; @@ -143,6 +149,8 @@ protected override void OnLoaded(RoutedEventArgs e) if (Navigation.CurrentPage is { } page) Navigation_OnPageChanged(this, page); + else if (Onboarding.IsRequired) + Navigation.NavigateTo(); else Navigation.NavigateTo(); _loaded.TrySetResult(); @@ -331,6 +339,10 @@ public void NavigateToPage(UserControl page) ContentArea.Content = page; UpdateSidebarSelection(page); _ = LockSidebarAsync(page is ILockedSidebarPage); + // A page that has to be finished first keeps the rest of the app visible but out of reach. + var blocking = page is IBlockingPage; + SidebarGrid.IsEnabled = !blocking; + SidebarGrid.Opacity = blocking ? 0.35 : 1; } private void UpdateSidebarSelection(UserControl page) diff --git a/WheelWizard/Views/Shell/Navigation/NavigationService.cs b/WheelWizard/Views/Shell/Navigation/NavigationService.cs index 14f1f35c0..4d247fef5 100644 --- a/WheelWizard/Views/Shell/Navigation/NavigationService.cs +++ b/WheelWizard/Views/Shell/Navigation/NavigationService.cs @@ -12,6 +12,12 @@ void NavigateTo(params object[] arguments) /// Navigates without asking the current page (see ), e.g. after it saved. void NavigateAway(Type pageType, params object[] arguments); + + /// + /// While set, only changes the page; sidebar links, menus and other requests are + /// ignored. The onboarding holds this until it finishes. + /// + bool IsLocked { get; set; } } /// A page that may want to stop you from leaving it, e.g. because you'd lose unsaved work. @@ -27,14 +33,21 @@ public interface INavigationGuard /// Implement on a page to keep the main sidebar collapsed and locked while it is shown. Leaving restores the user's preference. public interface ILockedSidebarPage; +/// A page that has to be finished first: the sidebar is also greyed out and cannot be used. +public interface IBlockingPage : ILockedSidebarPage; + public sealed class NavigationService(IPageFactory pages) : INavigationService { private int _generation; public UserControl? CurrentPage { get; private set; } public event EventHandler? PageChanged; + public bool IsLocked { get; set; } public void NavigateTo(Type pageType, params object[] arguments) { + if (IsLocked) + return; + if (CurrentPage is INavigationGuard { HasUnsavedWork: true } guard) { _ = NavigateAfterConfirmAsync(guard, pageType, arguments); diff --git a/WheelWizard/Views/Shell/PresentationExtensions.cs b/WheelWizard/Views/Shell/PresentationExtensions.cs index 9605ed88d..40d054649 100644 --- a/WheelWizard/Views/Shell/PresentationExtensions.cs +++ b/WheelWizard/Views/Shell/PresentationExtensions.cs @@ -62,6 +62,7 @@ public static IServiceCollection AddPresentation(this IServiceCollection service services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddTransient(); diff --git a/WheelWizard/Views/Shell/Startup/DesktopStartup.cs b/WheelWizard/Views/Shell/Startup/DesktopStartup.cs index 4c05cf111..9141c8a42 100644 --- a/WheelWizard/Views/Shell/Startup/DesktopStartup.cs +++ b/WheelWizard/Views/Shell/Startup/DesktopStartup.cs @@ -1,9 +1,11 @@ using Avalonia.Controls; using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Threading; using Microsoft.Extensions.Logging; using WheelWizard.ApplicationLifecycle; using WheelWizard.AutoUpdating; using WheelWizard.MiiRendering.Services; +using WheelWizard.Onboarding; using WheelWizard.Views.Dialogs; using WheelWizard.WiiManagement.GameLicense; @@ -31,6 +33,9 @@ public sealed class DesktopStartup( IMainWindowService windows, IGameLicenseSingletonService licenses, IApplicationStartup startup, + IStartupMaintenance maintenance, + IOnboardingService onboarding, + IManagedDolphinUpdatePrompt dolphinUpdates, ILogger logger ) : IDesktopStartup { @@ -45,6 +50,8 @@ CancellationToken cancellationToken cancellationToken.ThrowIfCancellationRequested(); desktop.ShutdownMode = ShutdownMode.OnExplicitShutdown; _ = cleanup.CleanupStaleExtractionsAsync(); + var splash = desktop.MainWindow as SplashWindow; + await maintenance.RunAsync(status => Dispatcher.UIThread.Post(() => splash?.SetStatus(status)), cancellationToken); await windows.ShowAsync(desktop, cancellationToken); if (resources.GetResolvedResourcePath().IsFailure) { @@ -59,6 +66,8 @@ CancellationToken cancellationToken cancellationToken.ThrowIfCancellationRequested(); licenses.LoadLicense(); await startup.RunAsync(options, cancellationToken); + if (!onboarding.IsRequired) + await dolphinUpdates.OfferUpdateAsync(cancellationToken); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { } catch (Exception exception) diff --git a/WheelWizard/Views/Shell/Startup/ManagedDolphinUpdatePrompt.cs b/WheelWizard/Views/Shell/Startup/ManagedDolphinUpdatePrompt.cs new file mode 100644 index 000000000..0950258a2 --- /dev/null +++ b/WheelWizard/Views/Shell/Startup/ManagedDolphinUpdatePrompt.cs @@ -0,0 +1,64 @@ +using Microsoft.Extensions.Logging; +using WheelWizard.DolphinInstaller; +using WheelWizard.Views.Dialogs; + +namespace WheelWizard.Views.Shell.Startup; + +public interface IManagedDolphinUpdatePrompt +{ + /// Asks once per start whether to update the Dolphin Wheel Wizard manages; never updates on its own. + Task OfferUpdateAsync(CancellationToken cancellationToken = default); +} + +public sealed class ManagedDolphinUpdatePrompt(IManagedDolphinService managedDolphin, ILogger logger) + : IManagedDolphinUpdatePrompt +{ + public async Task OfferUpdateAsync(CancellationToken cancellationToken = default) + { + try + { + var release = await managedDolphin.CheckForUpdateAsync(cancellationToken); + if (release is null) + return; + + var update = await new YesNoWindow() + .SetMainText(t("onboarding.dolphin_update_title")) + .SetExtraText(t("onboarding.dolphin_update_body", new { version = release.ShortRevision })) + .SetButtonText(t("onboarding.dolphin_update_action"), t("onboarding.dolphin_update_later")) + .AwaitAnswer(); + if (!update) + return; + + var window = new ProgressWindow(t("onboarding.dolphin_updating")); + OperationResult result; + try + { + window.Show(); + var progress = new Progress(value => + { + window.SetExtraText(value.Message); + window.UpdateProgress(value.Percent); + }); + result = await managedDolphin.UpdateAsync(progress, cancellationToken); + } + finally + { + window.Close(); + } + + if (result.IsFailure) + { + await new MessageBoxWindow() + .SetMessageType(MessageBoxWindow.MessageType.Error) + .SetTitleText(t("onboarding.dolphin_update_title")) + .SetInfoText(result.Error.Message) + .ShowDialog(); + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { } + catch (Exception exception) + { + logger.LogWarning(exception, "Checking for a managed Dolphin update failed"); + } + } +} diff --git a/WheelWizard/Views/Shell/Startup/SplashWindow.axaml b/WheelWizard/Views/Shell/Startup/SplashWindow.axaml index 3cb5a7f83..f79012e95 100644 --- a/WheelWizard/Views/Shell/Startup/SplashWindow.axaml +++ b/WheelWizard/Views/Shell/Startup/SplashWindow.axaml @@ -10,9 +10,17 @@ - - + + + + + + + + diff --git a/WheelWizard/Views/Shell/Startup/SplashWindow.axaml.cs b/WheelWizard/Views/Shell/Startup/SplashWindow.axaml.cs index 2d98b1170..15676c049 100644 --- a/WheelWizard/Views/Shell/Startup/SplashWindow.axaml.cs +++ b/WheelWizard/Views/Shell/Startup/SplashWindow.axaml.cs @@ -21,6 +21,14 @@ public SplashWindow() WindowDecorationsTheme = null; } + /// Shows a short line under the wheel; hides it. + public void SetStatus(string? status) + { + var border = this.FindControl("StatusBorder")!; + this.FindControl("StatusText")!.Text = status; + border.IsVisible = !string.IsNullOrEmpty(status); + } + protected override void OnOpened(EventArgs e) { base.OnOpened(e); diff --git a/WheelWizard/Views/Shell/ViewModels/HomeViewModel.cs b/WheelWizard/Views/Shell/ViewModels/HomeViewModel.cs index 36c75e759..183a13068 100644 --- a/WheelWizard/Views/Shell/ViewModels/HomeViewModel.cs +++ b/WheelWizard/Views/Shell/ViewModels/HomeViewModel.cs @@ -1,5 +1,6 @@ using System.ComponentModel; using Testably.Abstractions; +using WheelWizard.CustomDistributions; using WheelWizard.Launching; using WheelWizard.Models.Enums; using WheelWizard.Settings; @@ -27,8 +28,11 @@ public sealed class HomeViewModel : INotifyPropertyChanged, IDisposable private readonly ISeasonalCalendar _calendar; private readonly IRandomSystem _random; private readonly TimeProvider _time; + private readonly IRetroRewindBackgroundInstall _backgroundInstall; + private readonly SynchronizationContext? _context = SynchronizationContext.Current; private readonly CancellationTokenSource _lifetime = new(); private int _refreshGeneration; + private string? _repairMessage; private bool _disposed; private bool _busy; private WheelWizardStatus _status = WheelWizardStatus.Loading; @@ -40,9 +44,12 @@ public HomeViewModel( IHomePresentation presentation, ISeasonalCalendar calendar, IRandomSystem random, - TimeProvider time + TimeProvider time, + IRetroRewindBackgroundInstall backgroundInstall ) { + _backgroundInstall = backgroundInstall; + _backgroundInstall.Changed += BackgroundInstall_OnChanged; _launcher = launchers.GetActiveLauncher(); _dolphin = dolphin; _settings = settings; @@ -57,11 +64,19 @@ TimeProvider time public WheelWizardStatus Status => _status; public bool IsBusy => _busy; public bool IsInteractable => !_busy && !_disposed; + + /// The onboarding's Retro Rewind download is still running; Play waits for it. + public bool IsBackgroundInstalling => _backgroundInstall.IsRunning; + + /// What stops a setup from playing right now, e.g. a Dolphin folder on an unplugged drive; when nothing does. + public string? RepairMessage => _repairMessage; + public bool IsRepairVisible => _repairMessage is not null; public bool IsDolphinVisible => !_settings.IsRecompModeActive(); public bool AnimationsEnabled => _settings.Get(_settings.ENABLE_ANIMATIONS); public string GameTitle => _launcher.GameTitle == "Retro Rewind" && _calendar.IsAprilFirst ? "Retro Beefbai" : _launcher.GameTitle; public bool CanExecuteMain => IsInteractable + && !IsBackgroundInstalling && _status is WheelWizardStatus.NoServerButInstalled or WheelWizardStatus.NoDolphin @@ -76,19 +91,21 @@ or WheelWizardStatus.OutOfDate && _status is not (WheelWizardStatus.Loading or WheelWizardStatus.NoDolphin or WheelWizardStatus.NoDolphinExtension); public string ButtonText => - _status switch - { - WheelWizardStatus.NoServer => t("state.no_server"), - WheelWizardStatus.NoDolphinExtension => t("state.no_dolphin_extension"), - WheelWizardStatus.NoRecompExtension => t("state.no_recomp_extension"), - WheelWizardStatus.NoServerButInstalled => t("action.play_offline"), - WheelWizardStatus.NoDolphin => "Dolphin not setup", - WheelWizardStatus.ConfigNotFinished => t("state.config_not_finished"), - WheelWizardStatus.NotInstalled => t("action.install"), - WheelWizardStatus.OutOfDate => t("action.update"), - WheelWizardStatus.Ready => t("action.play"), - _ => t("state.loading"), - }; + IsBackgroundInstalling + ? t("onboarding.rr_installing_button", new { percent = _backgroundInstall.Percent }) + : _status switch + { + WheelWizardStatus.NoServer => t("state.no_server"), + WheelWizardStatus.NoDolphinExtension => t("state.no_dolphin_extension"), + WheelWizardStatus.NoRecompExtension => t("state.no_recomp_extension"), + WheelWizardStatus.NoServerButInstalled => t("action.play_offline"), + WheelWizardStatus.NoDolphin => "Dolphin not setup", + WheelWizardStatus.ConfigNotFinished => t("state.config_not_finished"), + WheelWizardStatus.NotInstalled => t("action.install"), + WheelWizardStatus.OutOfDate => t("action.update"), + WheelWizardStatus.Ready => t("action.play"), + _ => t("state.loading"), + }; public ActionButtonTone ButtonTone => _status switch @@ -100,7 +117,7 @@ or WheelWizardStatus.OutOfDate _ => ActionButtonTone.Primary, }; - public bool IsLoading => IsBusy || Status == WheelWizardStatus.Loading; + public bool IsLoading => IsBusy || IsBackgroundInstalling || Status == WheelWizardStatus.Loading; public string IconName => _status switch @@ -130,12 +147,42 @@ public async Task RefreshAsync() if (!_disposed && generation == _refreshGeneration) _presentation.ShowError(new OperationError { Message = "Failed to check game status.", Exception = ex }); } + var repairMessage = status is WheelWizardStatus.NoDolphin or WheelWizardStatus.ConfigNotFinished + ? await Task.Run(GetRepairMessage) + : null; if (_disposed || generation != _refreshGeneration) return; _status = status; + _repairMessage = repairMessage; NotifyStateChanged(); } + public void OpenSettings() => _presentation.OpenSettings(); + + private string? GetRepairMessage() + { + try + { + var report = _settings.ValidateCorePathSettings(); + if (report is not { IsSuccess: true }) + return null; + var codes = report.Value.Issues.Select(issue => issue.Code).ToHashSet(); + // A missing Dolphin usually takes its user folder with it, so that is the one to name. + if (codes.Contains(SettingsValidationCode.InvalidDolphinLocation)) + return t("onboarding.repair_dolphin"); + if (codes.Contains(SettingsValidationCode.InvalidUserFolderPath)) + return t("onboarding.repair_user_folder"); + if (codes.Contains(SettingsValidationCode.InvalidGameLocation)) + return t("onboarding.repair_game"); + return null; + } + catch + { + // The banner only explains the Settings button; it must never break Home. + return null; + } + } + public async Task ExecuteMainAsync() { if (!CanExecuteMain) @@ -221,11 +268,30 @@ private async Task ShowAprilFirstPromptsAsync() private void NotifyStateChanged() => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(string.Empty)); + private void BackgroundInstall_OnChanged(object? sender, EventArgs e) + { + void Apply() + { + if (_disposed) + return; + if (_backgroundInstall.IsRunning) + NotifyStateChanged(); + else + _ = RefreshAsync(); + } + + if (_context is null) + Apply(); + else + _context.Post(_ => Apply(), null); + } + public void Dispose() { if (_disposed) return; _disposed = true; + _backgroundInstall.Changed -= BackgroundInstall_OnChanged; ++_refreshGeneration; _lifetime.Cancel(); _lifetime.Dispose(); diff --git a/WheelWizard/Views/Shell/Views/HomePage.axaml b/WheelWizard/Views/Shell/Views/HomePage.axaml index 217825eb5..1fe7192de 100644 --- a/WheelWizard/Views/Shell/Views/HomePage.axaml +++ b/WheelWizard/Views/Shell/Views/HomePage.axaml @@ -174,6 +174,24 @@ ToolTip.ShowDelay="50" --> + + + + + + + + + + + + diff --git a/WheelWizard/Views/Shell/Views/HomePage.axaml.cs b/WheelWizard/Views/Shell/Views/HomePage.axaml.cs index 4bcc40869..098f7630e 100644 --- a/WheelWizard/Views/Shell/Views/HomePage.axaml.cs +++ b/WheelWizard/Views/Shell/Views/HomePage.axaml.cs @@ -76,6 +76,8 @@ private void Model_OnPropertyChanged(object? sender, PropertyChangedEventArgs e) private async void DolphinButton_OnClick(object? sender, RoutedEventArgs e) => await Model.LaunchDolphinAsync(); + private void RepairButton_OnClick(object? sender, RoutedEventArgs e) => Model.OpenSettings(); + private void ApplyDolphinTrailColors() { RecolorTrail(HomeTrail1, "Blue400", "Blue700"); From f1f48871912a0013692f1bfa6ab256ddf0678f9e Mon Sep 17 00:00:00 2001 From: patchzyy <64382339+patchzyy@users.noreply.github.com> Date: Thu, 15 Oct 2026 22:48:32 +0200 Subject: [PATCH 02/10] Handle onboarding install cancel and rollback = --- .../Onboarding/OnboardingServiceTests.cs | 47 +++- .../DolphinInstaller/ManagedDolphinService.cs | 6 +- .../Features/Onboarding/OnboardingService.cs | 44 ++++ .../Onboarding/Views/OnboardingPage.axaml.cs | 226 ++++++++++++------ 4 files changed, 251 insertions(+), 72 deletions(-) diff --git a/WheelWizard.Test/Features/Onboarding/OnboardingServiceTests.cs b/WheelWizard.Test/Features/Onboarding/OnboardingServiceTests.cs index 02e9c0dc5..b4c3817e9 100644 --- a/WheelWizard.Test/Features/Onboarding/OnboardingServiceTests.cs +++ b/WheelWizard.Test/Features/Onboarding/OnboardingServiceTests.cs @@ -78,6 +78,42 @@ public void TheFlatpaksDefaultDolphinLocation_DoesNotCountAsSetUp() Assert.False(fixture.Completed.Get()); } + [Fact] + public void RestoringFrontendSettings_UndoesAPickedDolphin_EvenToPathsTheValidatorsReject() + { + // Picking a Dolphin and then backing out to the choice must leave nothing behind, but an empty + // location is not a valid one to set while the Dolphin frontend is active. + var fixture = new Fixture(); + var snapshot = fixture.Service.CaptureFrontendSettings(); + fixture.Location.Set("/managed/Dolphin.exe", skipSave: true); + fixture.UserFolder.Set("/managed/User", skipSave: true); + fixture.Managed.Set(true, skipSave: true); + fixture.ShareNand.Set(true, skipSave: true); + + fixture.Service.RestoreFrontendSettings(snapshot); + + Assert.Equal("", fixture.Location.Get()); + Assert.Equal("", fixture.UserFolder.Get()); + Assert.False(fixture.Managed.Get()); + Assert.False(fixture.ShareNand.Get()); + } + + [Fact] + public void RestoringFrontendSettings_PutsBackValuesThatWereSetBefore() + { + var fixture = new Fixture(); + fixture.Location.Set("/own/Dolphin.exe", skipSave: true); + fixture.CopyNand.Set(true, skipSave: true); + var snapshot = fixture.Service.CaptureFrontendSettings(); + fixture.Location.Set("/managed/Dolphin.exe", skipSave: true); + fixture.CopyNand.Set(false, skipSave: true); + + fixture.Service.RestoreFrontendSettings(snapshot); + + Assert.Equal("/own/Dolphin.exe", fixture.Location.Get()); + Assert.True(fixture.CopyNand.Get()); + } + private sealed class Fixture { public MockFileSystem Fs { get; } = new(options => options.SimulatingOperatingSystem(SimulationMode.Linux)); @@ -87,21 +123,30 @@ private sealed class Fixture public WhWzSetting Game { get; } public WhWzSetting Location { get; } public WhWzSetting UserFolder { get; } = new("UserFolderPath", ""); + public WhWzSetting Managed { get; } = new("DolphinManaged", false); + public WhWzSetting ShareNand { get; } = new("RecompUseDolphinData", false); + public WhWzSetting CopyNand { get; } = new("RecompCopyDolphinNand", false); public OnboardingService Service { get; } public Fixture(string defaultLocation = "") { Game = new WhWzSetting("GameLocation", "").SetValidation(value => Fs.File.Exists(value)); - Location = new WhWzSetting("DolphinLocation", defaultLocation); + Location = new WhWzSetting("DolphinLocation", defaultLocation).SetValidation(value => value.Length > 0); Settings.ONBOARDING_COMPLETED.Returns(Completed); Settings.GAME_LOCATION.Returns(Game); Settings.DOLPHIN_LOCATION.Returns(Location); Settings.USER_FOLDER_PATH.Returns(UserFolder); + Settings.DOLPHIN_MANAGED.Returns(Managed); + Settings.RECOMP_USE_DOLPHIN_DATA.Returns(ShareNand); + Settings.RECOMP_COPY_DOLPHIN_NAND.Returns(CopyNand); Settings.Get(Arg.Any>()).Returns(call => call.Arg>().Get()); Settings.Get(Arg.Any>()).Returns(call => call.Arg>().Get()); Settings .Set(Arg.Any>(), Arg.Any(), Arg.Any()) .Returns(call => call.Arg>().Set(call.ArgAt(1), skipSave: true)); + Settings + .Set(Arg.Any>(), Arg.Any(), Arg.Any()) + .Returns(call => call.Arg>().Set(call.ArgAt(1), skipSave: true)); Service = new OnboardingService( Settings, diff --git a/WheelWizard/Features/DolphinInstaller/ManagedDolphinService.cs b/WheelWizard/Features/DolphinInstaller/ManagedDolphinService.cs index 1eb10e7e4..9b285e0ea 100644 --- a/WheelWizard/Features/DolphinInstaller/ManagedDolphinService.cs +++ b/WheelWizard/Features/DolphinInstaller/ManagedDolphinService.cs @@ -137,7 +137,11 @@ public async Task InstallAsync( { case ManagedDolphinKind.Portable: var install = await InstallPortableAsync(progress, cancellationToken); - return install.IsFailure ? install : Activate(); + if (install.IsFailure) + return install; + // The last steps do not stop halfway, so a cancelled install can still complete. Whoever cancelled has + // moved on, so it stays installed without taking over the Dolphin settings. + return cancellationToken.IsCancellationRequested ? Fail("The Dolphin download was cancelled.") : Activate(); case ManagedDolphinKind.Flatpak: progress?.Report(new(t("onboarding.dolphin_installing"), 0)); var flatpak = await linuxInstaller.InstallFlatpakDolphin( diff --git a/WheelWizard/Features/Onboarding/OnboardingService.cs b/WheelWizard/Features/Onboarding/OnboardingService.cs index 900cd8f22..3bdd10b32 100644 --- a/WheelWizard/Features/Onboarding/OnboardingService.cs +++ b/WheelWizard/Features/Onboarding/OnboardingService.cs @@ -27,6 +27,15 @@ public sealed record DetectedDolphinData(string UserFolderPath, string WiiFolder public sealed record GameFileCheck(bool IsValid, string Message); +/// The settings picking a Dolphin or a WiiCompiled NAND writes, as they were before the pick. +public sealed record FrontendSettingsSnapshot( + string DolphinLocation, + string UserFolderPath, + bool DolphinManaged, + bool RecompSharesDolphinData, + bool RecompCopiesDolphinNand +); + public interface IOnboardingService { /// Nothing was ever set up and the onboarding was never finished. @@ -64,6 +73,11 @@ public interface IOnboardingService Task ApplyNandChoiceAsync(RecompNandChoice choice); + /// Remembers the settings a frontend path writes, so backing out to the frontend choice can undo that path. + FrontendSettingsSnapshot CaptureFrontendSettings(); + + void RestoreFrontendSettings(FrontendSettingsSnapshot snapshot); + /// Finishes the Dolphin path; Retro Rewind may still be downloading. OperationResult FinishDolphin(); @@ -253,6 +267,34 @@ public OperationResult SetGameFile(string path) => public Task ApplyNandChoiceAsync(RecompNandChoice choice) => RecompData is { } data ? Task.Run(() => data.ApplyNandChoice(choice)) : Task.FromResult(Ok()); + public FrontendSettingsSnapshot CaptureFrontendSettings() => + new( + settings.Get(settings.DOLPHIN_LOCATION), + settings.Get(settings.USER_FOLDER_PATH), + settings.Get(settings.DOLPHIN_MANAGED), + settings.Get(settings.RECOMP_USE_DOLPHIN_DATA), + settings.Get(settings.RECOMP_COPY_DOLPHIN_NAND) + ); + + public void RestoreFrontendSettings(FrontendSettingsSnapshot snapshot) + { + // The location goes first: whether a user folder is valid depends on the Dolphin it belongs to. + Restore(settings.DOLPHIN_LOCATION, snapshot.DolphinLocation); + Restore(settings.USER_FOLDER_PATH, snapshot.UserFolderPath); + Restore(settings.DOLPHIN_MANAGED, snapshot.DolphinManaged); + Restore(settings.RECOMP_USE_DOLPHIN_DATA, snapshot.RecompSharesDolphinData); + Restore(settings.RECOMP_COPY_DOLPHIN_NAND, snapshot.RecompCopiesDolphinNand); + } + + private void Restore(Setting setting, T value) + { + // Before the onboarding these are mostly unset, and the path validators reject an empty path. + if (EqualityComparer.Default.Equals(value, setting.DefaultValue)) + setting.Reset(); + else + settings.Set(setting, value); + } + public OperationResult FinishDolphin() { settings.Set(settings.ENABLE_RECOMP, false); @@ -280,6 +322,8 @@ CancellationToken cancellationToken if (paths.IsFailure) return paths; + // Backing out of the install must not finish the onboarding behind the user's back. + cancellationToken.ThrowIfCancellationRequested(); CreateMiiDatabase(); return Complete(); } diff --git a/WheelWizard/Features/Onboarding/Views/OnboardingPage.axaml.cs b/WheelWizard/Features/Onboarding/Views/OnboardingPage.axaml.cs index 7fd0b4ba1..f98fe28af 100644 --- a/WheelWizard/Features/Onboarding/Views/OnboardingPage.axaml.cs +++ b/WheelWizard/Features/Onboarding/Views/OnboardingPage.axaml.cs @@ -62,6 +62,16 @@ private enum Frontend private string? _gameFile; private bool _busy; + // What the frontend choice looked like, so backing out to it undoes the path taken from there. + private FrontendSettingsSnapshot? _choiceSettings; + + // The running install, which Back cancels. A cancelled one may still be unwinding, so the next waits for it. + private CancellationTokenSource? _installCancellation; + private Task _installRun = Task.CompletedTask; + + // Set while an install is in a part that cannot be stopped halfway (a Flatpak install, WiiCompiled's setup). + private volatile bool _installUncancellable; + public OnboardingPage( IOnboardingService onboarding, IRetroRewindBackgroundInstall retroRewind, @@ -160,15 +170,33 @@ private void Show(Step step) UpdateBackButton(); } - private void UpdateBackButton() => - BackButton.IsVisible = - !_busy && _history.Count > 0 && _step is not (Step.DolphinInstalling or Step.WiiCompiledInstalling or Step.DolphinImport); + // An install is backed out of by cancelling it; other work (detecting, importing, copying) is short. + private bool CanGoBack => _history.Count > 0 && (_installCancellation is not null ? !_installUncancellable : !_busy); - private void Back_OnClick(object? sender, RoutedEventArgs e) + private void UpdateBackButton() => BackButton.IsVisible = CanGoBack; + + private void Back_OnClick(object? sender, RoutedEventArgs e) => GoBack(); + + private void GoBack() { - if (_busy || _history.Count == 0) + if (!CanGoBack) return; - Show(_history.Pop()); + + if (_installCancellation is { } install) + { + install.Cancel(); + _installCancellation = null; + SetBusy(false); + } + + var step = _history.Pop(); + if (step == Step.Choice && _choiceSettings is { } settings) + { + _onboarding.RestoreFrontendSettings(settings); + _choiceSettings = null; + } + + Show(step); } private void SetBusy(bool busy) @@ -178,18 +206,69 @@ private void SetBusy(bool busy) UpdateBackButton(); } + private void SetInstallUncancellable(bool uncancellable) + { + _installUncancellable = uncancellable; + UpdateBackButton(); + } + + private void StartInstall(Func install) + { + var cancellation = new CancellationTokenSource(); + _installCancellation = cancellation; + _installRun = RunInstallAsync(_installRun, install, cancellation); + } + + private async Task RunInstallAsync(Task previous, Func install, CancellationTokenSource cancellation) + { + SetInstallUncancellable(false); + SetBusy(true); + StepRetryButton.IsVisible = false; + SelectOwnInsteadButton.IsVisible = false; + StepErrorText.IsVisible = false; + try + { + await previous; + cancellation.Token.ThrowIfCancellationRequested(); + await install(cancellation.Token); + } + catch (OperationCanceledException) when (cancellation.IsCancellationRequested) + { + // Back cancelled it and already moved on. + } + catch (Exception exception) when (!cancellation.IsCancellationRequested) + { + _logger.LogError(exception, "An onboarding install failed"); + ShowStepError(exception.Message, allowOwnDolphin: _step == Step.DolphinInstalling); + } + finally + { + // Back already let go of a cancelled install, and a newer one may own the page by now. + if (_installCancellation == cancellation) + { + _installCancellation = null; + SetInstallUncancellable(false); + SetBusy(false); + } + + cancellation.Dispose(); + } + } + #endregion #region Choice private void ChooseDolphin_OnClick(object? sender, RoutedEventArgs e) { + _choiceSettings = _onboarding.CaptureFrontendSettings(); _frontend = Frontend.Dolphin; _ = EnterDolphinPathAsync(remember: true); } private void ChooseWiiCompiled_OnClick(object? sender, RoutedEventArgs e) { + _choiceSettings = _onboarding.CaptureFrontendSettings(); _frontend = Frontend.WiiCompiled; GoTo(Step.GameFile); } @@ -268,42 +347,48 @@ private void UseDetected_OnClick(object? sender, RoutedEventArgs e) private void ManagedDolphin_OnClick(object? sender, RoutedEventArgs e) { GoTo(Step.DolphinInstalling); - _ = InstallManagedDolphinAsync(); + StartInstall(InstallManagedDolphinAsync); } - private async Task InstallManagedDolphinAsync() + private async Task InstallManagedDolphinAsync(CancellationToken cancellationToken) { - SetBusy(true); ShowProgress(0, t("onboarding.dolphin_downloading")); - StepRetryButton.IsVisible = false; - SelectOwnInsteadButton.IsVisible = false; - StepErrorText.IsVisible = false; - try + // Look for the user's other Dolphin before the install points the settings at the managed one. + _detectedData ??= await Task.Run(_onboarding.DetectDolphinData, cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + + OperationResult result; + // Coming back to this step after an install must not download Dolphin a second time. + if (_managedDolphin.IsInstalled) + result = _managedDolphin.Activate(); + else { - // Look for the user's other Dolphin before the install points the settings at the managed one. - _detectedData ??= await Task.Run(_onboarding.DetectDolphinData); - var progress = new Progress(value => ShowProgress(value.Percent, value.Message)); - // Coming back to this step after an install must not download Dolphin a second time. - var result = _managedDolphin.IsInstalled ? _managedDolphin.Activate() : await _managedDolphin.InstallAsync(progress); - if (result.IsFailure) + // A Flatpak install cannot be stopped halfway. + SetInstallUncancellable(_managedDolphin.Kind == ManagedDolphinKind.Flatpak); + var progress = new Progress(value => { - ShowStepError($"{t("onboarding.dolphin_install_failed")} {result.Error.Message}", allowOwnDolphin: true); - return; - } + if (!cancellationToken.IsCancellationRequested) + ShowProgress(value.Percent, value.Message); + }); + result = await _managedDolphin.InstallAsync(progress, cancellationToken); + } - if (_detectedData is not null) - { - ImportPathText.Text = _detectedData.UserFolderPath; - Show(Step.DolphinImport); - } - else - { - Show(Step.GameFile); - } + cancellationToken.ThrowIfCancellationRequested(); + if (result.IsFailure) + { + ShowStepError($"{t("onboarding.dolphin_install_failed")} {result.Error.Message}", allowOwnDolphin: true); + return; } - finally + + // Replacing the install step keeps it out of the history, so Back returns to where Dolphin was picked. + if (_detectedData is not null) { - SetBusy(false); + ImportPathText.Text = _detectedData.UserFolderPath; + Show(Step.DolphinImport); + } + else + { + Show(Step.GameFile); } } @@ -366,16 +451,8 @@ private void ApplyOwnDolphin(string location) return ShellQuoting.QuoteUnixArgument(file); } - private void SelectOwnInstead_OnClick(object? sender, RoutedEventArgs e) - { - if (_busy) - return; - // Leave the failed install behind; the source step is where the own Dolphin gets picked. - _history.Clear(); - if (_onboarding.IsWiiCompiledAvailable) - _history.Push(Step.Choice); - Show(Step.DolphinSource); - } + // The managed install only starts from the source step, which is where the own Dolphin gets picked. + private void SelectOwnInstead_OnClick(object? sender, RoutedEventArgs e) => GoBack(); private void ShowSourceError(string message) { @@ -514,45 +591,54 @@ private async Task ApplyNandChoiceAsync(RecompNandChoice choice) } GoTo(Step.WiiCompiledInstalling); - await InstallWiiCompiledAsync(); + StartInstall(InstallWiiCompiledAsync); } - private async Task InstallWiiCompiledAsync() + private async Task InstallWiiCompiledAsync(CancellationToken cancellationToken) { - SetBusy(true); - StepRetryButton.IsVisible = false; - SelectOwnInsteadButton.IsVisible = false; - StepErrorText.IsVisible = false; - try + if (_retroRewind.State != BackgroundInstallState.Completed) { - if (_retroRewind.State != BackgroundInstallState.Completed) - { - ShowProgress(_retroRewind.Percent, t("onboarding.wiicompiled_waiting")); - var download = await _retroRewind.StartAsync(); - if (download.IsFailure) - { - ShowStepError(_retroRewind.ErrorMessage ?? t("onboarding.rr_failed"), allowOwnDolphin: false); - return; - } - } - - ShowProgress(0, t("onboarding.wiicompiled_title")); - var progress = new Progress(value => ShowProgress(value.Percent, value.Message)); - var result = await _onboarding.FinishWiiCompiledAsync(progress, CancellationToken.None); - if (result.IsFailure) + ShowProgress(_retroRewind.Percent, t("onboarding.wiicompiled_waiting")); + // Backing out only stops the waiting; Retro Rewind keeps downloading for whichever path comes next. + var download = await _retroRewind.StartAsync().WaitAsync(cancellationToken); + if (download.IsFailure) { - ShowStepError($"{t("onboarding.wiicompiled_failed")} {result.Error.Message}", allowOwnDolphin: false); + ShowStepError(_retroRewind.ErrorMessage ?? t("onboarding.rr_failed"), allowOwnDolphin: false); return; } } - finally + + ShowProgress(0, t("onboarding.wiicompiled_title")); + var progress = new InlineProgress(value => { - SetBusy(false); + // From here the setup rewrites installed files, so stopping it would leave a broken install. This runs on + // the installer's own thread, before the setup starts, so Back is gone by the time it could do harm. + if (!cancellationToken.IsCancellationRequested && value.Percent >= RecompSetupHostAcquirer.SetupPercentFloor) + _installUncancellable = true; + Dispatcher.UIThread.Post(() => + { + if (cancellationToken.IsCancellationRequested) + return; + UpdateBackButton(); + ShowProgress(value.Percent, value.Message); + }); + }); + var result = await _onboarding.FinishWiiCompiledAsync(progress, cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + if (result.IsFailure) + { + ShowStepError($"{t("onboarding.wiicompiled_failed")} {result.Error.Message}", allowOwnDolphin: false); + return; } Finish(Ok()); } + private sealed class InlineProgress(Action report) : IProgress + { + public void Report(T value) => report(value); + } + #endregion #region Progress and finishing @@ -576,12 +662,12 @@ private void StepRetry_OnClick(object? sender, RoutedEventArgs e) if (_busy) return; if (_step == Step.DolphinInstalling) - _ = InstallManagedDolphinAsync(); + StartInstall(InstallManagedDolphinAsync); else if (_step == Step.WiiCompiledInstalling) { if (_retroRewind.State == BackgroundInstallState.Failed) _ = _retroRewind.RetryAsync(); - _ = InstallWiiCompiledAsync(); + StartInstall(InstallWiiCompiledAsync); } } From 557530f956e7f6ca21dffd9aebcbd476589cb3fa Mon Sep 17 00:00:00 2001 From: patchzyy <64382339+patchzyy@users.noreply.github.com> Date: Thu, 15 Oct 2026 23:52:51 +0200 Subject: [PATCH 03/10] Preserve existing setups over upgrades --- .../ExistingSetups/ExistingInstall.cs | 469 ++++++++++++++ .../ExistingRecompSetupTests.cs | 178 ++++++ .../ExistingRetroRewindInstallTests.cs | 576 ++++++++++++++++++ .../ExistingSetups/ExistingSetupAssertions.cs | 94 +++ .../ExistingSetupMigrationSafetyTests.cs | 252 ++++++++ .../ExistingSetupOnboardingGateTests.cs | 273 +++++++++ .../ExistingSetups/ExistingSetupSweepTests.cs | 221 +++++++ .../ExistingSetups/RealisticFileSystem.cs | 67 ++ .../Settings/UnavailableSettingValueTests.cs | 142 +++++ .../CustomDistributionPaths.cs | 5 +- .../Features/Dolphin/Paths/DolphinPaths.cs | 9 +- .../Features/Onboarding/OnboardingService.cs | 12 +- .../Recomp/RecompDolphinDataService.cs | 5 + .../Settings/DolphinSettingManager.cs | 4 + .../Features/Settings/ISettingsServices.cs | 6 + .../Features/Settings/SettingsManager.cs | 39 +- .../Features/Settings/Types/DolphinSetting.cs | 10 + .../Features/Settings/Types/Setting.cs | 25 + .../Features/Settings/Types/WhWzSetting.cs | 17 +- .../Features/Settings/WhWzSettingManager.cs | 5 + 20 files changed, 2394 insertions(+), 15 deletions(-) create mode 100644 WheelWizard.Test/Features/ExistingSetups/ExistingInstall.cs create mode 100644 WheelWizard.Test/Features/ExistingSetups/ExistingRecompSetupTests.cs create mode 100644 WheelWizard.Test/Features/ExistingSetups/ExistingRetroRewindInstallTests.cs create mode 100644 WheelWizard.Test/Features/ExistingSetups/ExistingSetupAssertions.cs create mode 100644 WheelWizard.Test/Features/ExistingSetups/ExistingSetupMigrationSafetyTests.cs create mode 100644 WheelWizard.Test/Features/ExistingSetups/ExistingSetupOnboardingGateTests.cs create mode 100644 WheelWizard.Test/Features/ExistingSetups/ExistingSetupSweepTests.cs create mode 100644 WheelWizard.Test/Features/ExistingSetups/RealisticFileSystem.cs create mode 100644 WheelWizard.Test/Features/Settings/UnavailableSettingValueTests.cs diff --git a/WheelWizard.Test/Features/ExistingSetups/ExistingInstall.cs b/WheelWizard.Test/Features/ExistingSetups/ExistingInstall.cs new file mode 100644 index 000000000..f27258faf --- /dev/null +++ b/WheelWizard.Test/Features/ExistingSetups/ExistingInstall.cs @@ -0,0 +1,469 @@ +using System.IO.Abstractions; +using System.Runtime.InteropServices; +using System.Text.Json; +using Microsoft.Extensions.Logging.Abstractions; +using Testably.Abstractions.Testing; +using WheelWizard.ApplicationData; +using WheelWizard.ApplicationLifecycle; +using WheelWizard.CustomDistributions; +using WheelWizard.DiscImages; +using WheelWizard.Dolphin.Discovery; +using WheelWizard.Dolphin.Paths; +using WheelWizard.DolphinInstaller; +using WheelWizard.Launching; +using WheelWizard.Onboarding; +using WheelWizard.Recomp; +using WheelWizard.Settings; +using WheelWizard.Shared.Downloads; +using WheelWizard.Shared.IO; +using WheelWizard.Shared.Platform; +using WheelWizard.Shared.Processes; +using WheelWizard.WiiManagement.GameLicense; +using WheelWizard.WiiManagement.MiiManagement; + +namespace WheelWizard.Test.Features.ExistingSetups; + +public enum HostPlatform +{ + Windows, + Linux, + MacOS, +} + +/// +/// An installation of Wheel Wizard as an earlier release left it on disk: a config.json, the user's Dolphin and +/// game files, and a Retro Rewind install inside Dolphin's Load folder. then starts this +/// version of Wheel Wizard on top of it, with the real settings, path, migration, onboarding and startup +/// services, so a test states what an existing user has and what has to still be true after the update. +/// +internal sealed class ExistingInstall +{ + public const string RetroRewindVersion = "6.5.1"; + + public HostPlatform Platform { get; } + public MockFileSystem Fs { get; } + public string Home { get; } + public string DataDir { get; private set; } + + /// The value of the DolphinLocation setting as an earlier release wrote it. + public string DolphinLocation { get; private set; } = ""; + + /// The Dolphin user folder, or empty when none was configured. + public string UserFolder { get; private set; } = ""; + + public string GameFile { get; private set; } = ""; + public Dictionary Config { get; } = new(); + + /// The file system as the app sees it; is the same disk for seeding and asserting. + public IFileSystem AppFs { get; } + public HashSet AvailableCommands { get; } = new(); + public bool IsFlatpakSandbox { get; private set; } + public bool RecompInstalled { get; set; } + + public ExistingInstall(HostPlatform platform = HostPlatform.Windows, string? home = null, string? dataDir = null) + { + Platform = platform; + Fs = new MockFileSystem(options => + options.SimulatingOperatingSystem(platform == HostPlatform.Windows ? SimulationMode.Windows : SimulationMode.Linux) + ); + if (platform == HostPlatform.Windows) + { + Fs.WithDrive("D:"); + // Windows cannot rename a folder onto another drive. + Fs.Intercept.Event( + _ => throw new IOException("Source and destination path must have identical roots."), + change => + change.ChangeType == WatcherChangeTypes.Renamed + && change.FileSystemType == FileSystemTypes.Directory + && change.OldPath is not null + && !string.Equals( + Fs.Path.GetPathRoot(change.OldPath), + Fs.Path.GetPathRoot(change.Path), + StringComparison.OrdinalIgnoreCase + ) + ); + } + + Home = + home + ?? platform switch + { + HostPlatform.Windows => @"C:\Users\racer", + HostPlatform.MacOS => "/Users/racer", + _ => "/home/racer", + }; + DataDir = + dataDir + ?? platform switch + { + HostPlatform.Windows => @"C:\Users\racer\AppData\Roaming\WheelWizard", + HostPlatform.MacOS => "/Users/racer/Library/Application Support/WheelWizard", + _ => "/home/racer/.local/share/WheelWizard", + }; + Fs.Directory.CreateDirectory(DataDir); + AppFs = RealisticFileSystem.Wrap(Fs); + } + + /// Wheel Wizard itself runs from its Flatpak, with the Dolphin extension that bundles a Dolphin. + public ExistingInstall WithFlatpakSandbox(bool dolphinExtension = true) + { + IsFlatpakSandbox = true; + Fs.File.WriteAllText("/.flatpak-info", ""); + if (dolphinExtension) + { + Fs.Directory.CreateDirectory("/app/extensions/backends/dolphin-emu/bin"); + Fs.File.WriteAllText("/app/extensions/backends/dolphin-emu/bin/dolphin-emu-wrapper", ""); + } + + return this; + } + + private readonly List<(string Original, string Stashed, bool IsDirectory)> _unplugged = []; + + /// Takes a folder or file away as if its drive was disconnected; puts it all back. + public ExistingInstall Unplug(string path) + { + var isDirectory = Fs.Directory.Exists(path); + if (!isDirectory && !Fs.File.Exists(path)) + return this; + + var stashed = Combine(Fs.Path.GetPathRoot(path)!, ".unplugged", _unplugged.Count.ToString()); + Fs.Directory.CreateDirectory(Fs.Path.GetDirectoryName(stashed)!); + if (isDirectory) + Fs.Directory.Move(path, stashed); + else + Fs.File.Move(path, stashed); + _unplugged.Add((path, stashed, isDirectory)); + return this; + } + + public ExistingInstall Replug() + { + foreach (var (original, stashed, isDirectory) in Enumerable.Reverse(_unplugged)) + { + Fs.Directory.CreateDirectory(Fs.Path.GetDirectoryName(original)!); + if (isDirectory) + Fs.Directory.Move(stashed, original); + else + Fs.File.Move(stashed, original); + } + + _unplugged.Clear(); + return this; + } + + /// The location of the folder that took away, while it is away. + public string UnpluggedLocation(string original) => _unplugged.First(item => item.Original == original).Stashed; + + /// Settings > relocate data folder: everything moves and the next start uses the new location. + public ExistingInstall RelocateDataFolder(string newDataDir) + { + Fs.Directory.CreateDirectory(newDataDir); + foreach (var directory in Fs.Directory.GetDirectories(DataDir)) + Fs.Directory.Move(directory, Combine(newDataDir, Fs.Path.GetFileName(directory))); + foreach (var file in Fs.Directory.GetFiles(DataDir)) + Fs.File.Move(file, Combine(newDataDir, Fs.Path.GetFileName(file))); + DataDir = newDataDir; + return this; + } + + public string Combine(params string[] parts) => Fs.Path.Combine(parts); + + public string ConfigFilePath => Combine(DataDir, "config.json"); + public string LoadFolder => Combine(UserFolder, "Load"); + public string LegacyRoot => Combine(LoadFolder, "Riivolution", "WheelWizard"); + public string ManagedRoot => Combine(DataDir, "RetroRewind"); + public string DolphinIniPath => Combine(DolphinConfigFolder, "Dolphin.ini"); + public string DolphinConfigFolder => Resolver.Resolve(DolphinLocation, UserFolder).ConfigFolderPath; + + private IDolphinPathResolver Resolver => new DolphinPathResolver(AppFs, Environment()); + + #region Seeding + /// A Dolphin the user picked and a game file, the way the settings page wrote them. + public ExistingInstall WithDolphin(string location, string userFolder, string? game = null, bool createFiles = true) + { + DolphinLocation = location; + UserFolder = userFolder; + Config["DolphinLocation"] = location; + Config["UserFolderPath"] = userFolder; + + if (createFiles) + { + if (Platform == HostPlatform.Windows) + { + Fs.Directory.CreateDirectory(Fs.Path.GetDirectoryName(location)!); + Fs.File.WriteAllText(location, "MZ"); + } + else + { + AvailableCommands.Add(location); + } + + Fs.Directory.CreateDirectory(userFolder); + Fs.Directory.CreateDirectory(DolphinConfigFolder); + Fs.Directory.CreateDirectory(Combine(userFolder, "Wii", "title")); + Fs.Directory.CreateDirectory(LoadFolder); + Fs.File.WriteAllText(DolphinIniPath, "[General]\nWirelessMac = 02:01:02:03:04:05\n[Core]\nGFXBackend = Vulkan\n"); + } + + if (game is not null) + WithGame(game); + return this; + } + + public ExistingInstall WithGame(string path, bool createFile = true) + { + GameFile = path; + Config["GameLocation"] = path; + if (createFile) + { + Fs.Directory.CreateDirectory(Fs.Path.GetDirectoryName(path)!); + Fs.File.WriteAllText(path, "RMCP01"); + } + + return this; + } + + public ExistingInstall WithConfig(string key, object? value) + { + Config[key] = value; + return this; + } + + /// Sets Dolphin's Load folder the way Dolphin records it in Dolphin.ini. + public ExistingInstall WithCustomLoadFolder(string folder, bool exists = true) + { + if (exists) + Fs.Directory.CreateDirectory(folder); + var ini = Fs.File.ReadAllText(DolphinIniPath); + Fs.File.WriteAllText(DolphinIniPath, ini.Replace("[General]\n", $"[General]\nLoadPath = {folder}\n")); + return this; + } + + /// An install a release before the managed folder left in (default: the legacy Load folder). + public Dictionary WithRetroRewind(string? root = null, bool withBeta = false, int extraFiles = 0) + { + root ??= LegacyRoot; + var files = new Dictionary + { + ["RetroRewind6/version.txt"] = RetroRewindVersion, + ["RetroRewind6/Track/Luigi.szs"] = "luigi circuit", + ["RetroRewind6/Patches/Main.bin"] = "patch", + ["riivolution/RetroRewind6.xml"] = "", + ["riivolution/save/RetroWFC/RMCP/rksys.dat"] = "my ghosts and 12000 VR", + }; + if (withBeta) + { + files["RRBeta/version.txt"] = "6.6.0-beta"; + files["riivolution/RRBeta.xml"] = ""; + } + + for (var i = 0; i < extraFiles; i++) + files[$"RetroRewind6/Track/Custom/{i / 20}/track{i}.szs"] = $"track {i}"; + + foreach (var (relative, content) in files) + { + var path = Combine(root, relative.Replace('/', Fs.Path.DirectorySeparatorChar)); + Fs.Directory.CreateDirectory(Fs.Path.GetDirectoryName(path)!); + Fs.File.WriteAllText(path, content); + } + + return files; + } + + public ExistingInstall FlushConfig() + { + Fs.File.WriteAllText(ConfigFilePath, JsonSerializer.Serialize(Config, new JsonSerializerOptions { WriteIndented = true })); + return this; + } + #endregion + + #region Reading back + /// Every file under by relative path (forward slashes) with its content. + public Dictionary Snapshot(string folder) + { + var result = new Dictionary(); + if (!Fs.Directory.Exists(folder)) + return result; + foreach (var file in Fs.Directory.EnumerateFiles(folder, "*", SearchOption.AllDirectories)) + { + var relative = Fs.Path.GetRelativePath(folder, file).Replace('\\', '/'); + result[relative] = Fs.File.ReadAllText(file); + } + + return result; + } + + public Dictionary ReadConfig() => + JsonSerializer.Deserialize>(Fs.File.ReadAllText(ConfigFilePath))!; + #endregion + + #region Booting + public IRuntimeEnvironment Environment() + { + var environment = Substitute.For(); + environment.IsWindows.Returns(Platform == HostPlatform.Windows); + environment.IsLinux.Returns(Platform == HostPlatform.Linux); + environment.IsMacOS.Returns(Platform == HostPlatform.MacOS); + environment.OSArchitecture.Returns(Architecture.X64); + environment + .GetFolderPath(Arg.Any()) + .Returns(call => FolderPath(call.Arg())); + environment.GetEnvironmentVariable(Arg.Any()).Returns((string?)null); + if (IsFlatpakSandbox) + environment.GetEnvironmentVariable("FLATPAK_ID").Returns("io.github.TeamWheelWizard.WheelWizard"); + return environment; + } + + private string FolderPath(System.Environment.SpecialFolder folder) => + (folder, Platform) switch + { + (System.Environment.SpecialFolder.UserProfile, _) => Home, + (System.Environment.SpecialFolder.ApplicationData, HostPlatform.Windows) => Combine(Home, "AppData", "Roaming"), + (System.Environment.SpecialFolder.LocalApplicationData, HostPlatform.Windows) => Combine(Home, "AppData", "Local"), + (System.Environment.SpecialFolder.ApplicationData, HostPlatform.MacOS) => Combine(Home, "Library", "Application Support"), + (System.Environment.SpecialFolder.LocalApplicationData, HostPlatform.MacOS) => Combine(Home, ".local", "share"), + (System.Environment.SpecialFolder.ApplicationData, _) => Combine(Home, ".config"), + (System.Environment.SpecialFolder.LocalApplicationData, _) => Combine(Home, ".local", "share"), + _ => Home, + }; + + /// Starts Wheel Wizard on the current disk state, as a new process would, up to the first page deciding what to show. + public Session Boot(bool runStartup = true) + { + var session = new Session(this); + if (runStartup) + session.RunStartup(); + return session; + } + #endregion +} + +internal sealed class Session +{ + public ExistingInstall Install { get; } + public SettingsManager Settings { get; } + public CustomDistributionPaths Paths { get; } + public RetroRewindMigrationService Migration { get; } + public ManagedDolphinService ManagedDolphin { get; } + public OnboardingService Onboarding { get; } + public StartupMaintenance Maintenance { get; } + public RecompDolphinDataService RecompData { get; } + public RecompSettingManager RecompSettings { get; } + public RecompPaths RecompPaths { get; } + public RetroRewindLaunchDescriptor LaunchDescriptor { get; } + public IDolphinPaths DolphinPaths { get; } + public List Statuses { get; } = []; + + /// The user's files as they were before this start touched anything. + public string? InitialConfigJson { get; } + public string? InitialDolphinIni { get; } + + public Session(ExistingInstall install) + { + Install = install; + var fs = install.AppFs; + InitialConfigJson = fs.File.Exists(install.ConfigFilePath) ? fs.File.ReadAllText(install.ConfigFilePath) : null; + InitialDolphinIni = + install.UserFolder != "" && fs.File.Exists(install.DolphinIniPath) ? fs.File.ReadAllText(install.DolphinIniPath) : null; + var environment = install.Environment(); + var location = Substitute.For(); + location.DirectoryPath.Returns(install.DataDir); + + var commands = Substitute.For(); + commands.IsCommandAvailable(Arg.Any()).Returns(call => install.AvailableCommands.Contains(call.Arg())); + + var resolver = new DolphinPathResolver(fs, environment); + RecompPaths = new RecompPaths(location, fs, environment); + RecompSettings = new RecompSettingManager(fs); + Settings = new SettingsManager( + new WhWzSettingManager(NullLogger.Instance, fs), + new DolphinSettingManager(fs), + RecompSettings, + fs, + new SettingsSignalBus(NullLogger.Instance), + resolver, + location, + RecompPaths, + environment, + commands + ); + // Program.Setup: the settings are loaded before anything else looks at them. + Settings.LoadSettings(); + + DolphinPaths = new DolphinPaths(Settings, resolver, fs); + Paths = new CustomDistributionPaths(location, DolphinPaths, fs); + Migration = new RetroRewindMigrationService( + Paths, + fs, + new DirectoryTransferService(fs), + System.TimeProvider.System, + NullLogger.Instance + ); + + var discovery = Substitute.For(); + ManagedDolphin = new ManagedDolphinService( + Settings, + location, + fs, + environment, + Substitute.For(), + Substitute.For(), + Substitute.For(), + Substitute.For(), + discovery, + resolver, + Substitute.For(), + NullLogger.Instance + ); + + RecompData = new RecompDolphinDataService(Settings, RecompSettings, fs, discovery, RecompPaths, Paths); + var services = Substitute.For(); + services.GetService(typeof(IRecompDolphinDataService)).Returns(RecompData); + var recompInstall = Substitute.For(); + recompInstall.IsInstalled.Returns(install.RecompInstalled); + services.GetService(typeof(IRecompInstallService)).Returns(recompInstall); + Onboarding = new OnboardingService( + Settings, + fs, + environment, + Substitute.For(), + discovery, + resolver, + commands, + Substitute.For(), + ManagedDolphin, + Substitute.For(), + Substitute.For(), + Paths, + services, + NullLogger.Instance + ); + Maintenance = new StartupMaintenance(ManagedDolphin, Migration, Onboarding, services, NullLogger.Instance); + LaunchDescriptor = new RetroRewindLaunchDescriptor(fs, Settings, Paths); + } + + /// What Desktop startup runs before the main window decides which page to open. + public void RunStartup() => Maintenance.RunAsync(Statuses.Add).GetAwaiter().GetResult(); + + public string ReadLaunchRoot() + { + LaunchDescriptor.GenerateLaunchJson(); + using var document = JsonDocument.Parse(Install.Fs.File.ReadAllText(Paths.LaunchJsonFilePath)); + return document.RootElement.GetProperty("riivolution").GetProperty("patches")[0].GetProperty("root").GetString()!; + } + + public string ReadLaunchXml() + { + LaunchDescriptor.GenerateLaunchJson(); + using var document = JsonDocument.Parse(Install.Fs.File.ReadAllText(Paths.LaunchJsonFilePath)); + return document.RootElement.GetProperty("riivolution").GetProperty("patches")[0].GetProperty("xml").GetString()!; + } + + /// The Retro Rewind version Wheel Wizard would report; this is what decides between "Play" and "Install". + public string? InstalledRetroRewindVersion() + { + var file = Install.Combine(Paths.RetroRewindFolderPath, "version.txt"); + return Install.Fs.File.Exists(file) ? Install.Fs.File.ReadAllText(file).Trim() : null; + } +} diff --git a/WheelWizard.Test/Features/ExistingSetups/ExistingRecompSetupTests.cs b/WheelWizard.Test/Features/ExistingSetups/ExistingRecompSetupTests.cs new file mode 100644 index 000000000..48986b7b8 --- /dev/null +++ b/WheelWizard.Test/Features/ExistingSetups/ExistingRecompSetupTests.cs @@ -0,0 +1,178 @@ +using WheelWizard.CustomDistributions; +using static WheelWizard.Test.Features.ExistingSetups.ExistingSetupAssertions; + +namespace WheelWizard.Test.Features.ExistingSetups; + +/// +/// WiiCompiled reads Retro Rewind's and the Wii data's location from its own Config.toml. This version +/// rewrites those two keys before every launch, so every earlier way of having them set has to keep working. +/// +public class ExistingRecompSetupTests +{ + private static ExistingInstall RecompUser(out string toml, bool dolphinConfigured = true, bool recompEnabled = true) + { + var install = new ExistingInstall(HostPlatform.Windows); + if (dolphinConfigured) + install.WithDolphin(@"C:\Dolphin\Dolphin.exe", @"C:\Users\racer\Documents\Dolphin Emulator", @"C:\Games\Mario Kart Wii.iso"); + else + install.WithGame(@"C:\Games\Mario Kart Wii.iso"); + install.WithConfig("EnableRecomp", recompEnabled); + install.RecompInstalled = true; + toml = install.Combine(install.DataDir, "Recomp", "UserData", "Config.toml"); + return install; + } + + private static void WriteToml(ExistingInstall install, string path, string content) + { + install.Fs.Directory.CreateDirectory(install.Fs.Path.GetDirectoryName(path)!); + install.Fs.File.WriteAllText(path, content); + } + + [Fact] + public void ADolphinUserWhoSwitchedToWiiCompiled_GetsTheirRetroRewindFollowedToTheNewFolder() + { + var install = RecompUser(out var toml); + var files = install.WithRetroRewind(); + var wii = install.Combine(install.UserFolder, "Wii"); + WriteToml( + install, + toml, + $"# my settings\n[paths]\nnand_root = \"{Escape(wii)}\"\nretro_rewind_root = \"{Escape(install.Combine(install.LegacyRoot, "RetroRewind6"))}\"\n\n[video]\nresolution_multiplier = 2.0\nshow_fps = false\n" + ); + install.WithConfig("RecompUseDolphinData", true).FlushConfig(); + + var session = install.Boot(); + + AssertStillWorks(session, files, install.ManagedRoot, expectPlayable: true); + var after = install.Fs.File.ReadAllText(toml); + var reread = install.Boot(runStartup: false); + Assert.Equal(install.Combine(install.ManagedRoot, "RetroRewind6"), reread.Settings.RECOMP_RETRO_REWIND_ROOT.Get()); + Assert.Equal(wii, reread.Settings.RECOMP_NAND_ROOT.Get()); + Assert.Contains("# my settings", after); + Assert.Contains("resolution_multiplier = 2.0", after); + Assert.Contains("show_fps = false", after); + } + + [Fact] + public void AWiiCompiledOnlyUser_KeepsTheirConfigWorkingBeforeEveryLaunch() + { + var install = RecompUser(out var toml, dolphinConfigured: false); + var files = install.WithRetroRewind(install.ManagedRoot); + var retroRewind = install.Combine(install.ManagedRoot, "RetroRewind6"); + WriteToml(install, toml, $"[paths]\nretro_rewind_root = \"{Escape(retroRewind)}\"\n[video]\nshow_fps = true\n"); + install.FlushConfig(); + + var session = install.Boot(); + Assert.True(session.RecompData.ApplyPathsToRecompConfig().IsSuccess); + Assert.True(session.RecompData.ApplyPathsToRecompConfig().IsSuccess); + + Assert.False(session.Onboarding.IsRequired); + var reread = install.Boot(runStartup: false); + Assert.Equal(retroRewind, reread.Settings.RECOMP_RETRO_REWIND_ROOT.Get()); + Assert.Equal("", reread.Settings.RECOMP_NAND_ROOT.Get()); + Assert.Equal( + files, + install.Snapshot(install.ManagedRoot).Where(pair => pair.Key != CustomDistributionPaths.MigratedMarkerFileName).ToDictionary() + ); + } + + [Fact] + public void PrivateMode_NeverGainsANandRoot() + { + var install = RecompUser(out var toml); + install.WithRetroRewind(); + WriteToml(install, toml, "[paths]\n[video]\nshow_fps = true\n"); + install.FlushConfig(); + + var session = install.Boot(); + Assert.True(session.RecompData.ApplyPathsToRecompConfig().IsSuccess); + + Assert.DoesNotContain("nand_root", install.Fs.File.ReadAllText(toml)); + } + + [Fact] + public void ACopiedNand_StaysTheNandTheRuntimeUses() + { + var install = RecompUser(out var toml); + install.WithRetroRewind(); + var copy = install.Combine(install.DataDir, "Recomp", "Nand"); + install.Fs.Directory.CreateDirectory(install.Combine(copy, "title")); + WriteToml(install, toml, $"[paths]\nnand_root = \"{Escape(copy)}\"\n"); + install.WithConfig("RecompCopyDolphinNand", true).FlushConfig(); + + var session = install.Boot(); + Assert.True(session.RecompData.ApplyPathsToRecompConfig().IsSuccess); + + Assert.Equal(copy, install.Boot(runStartup: false).Settings.RECOMP_NAND_ROOT.Get()); + } + + [Fact] + public void SharingDolphinsNand_WhileDolphinsFolderIsOnAnUnpluggedDrive_KeepsTheNandRoot() + { + // Applying must not turn "the Wii data is on a drive that is not there right now" into "use a new, + // empty private NAND": the next time the drive is back the user expects their saves and Miis. + var install = RecompUser(out var toml); + install.WithRetroRewind(); + var wii = install.Combine(install.UserFolder, "Wii"); + WriteToml(install, toml, $"[paths]\nnand_root = \"{Escape(wii)}\"\n"); + install.WithConfig("RecompUseDolphinData", true).FlushConfig(); + install.Fs.Directory.Delete(install.UserFolder, recursive: true); + + var session = install.Boot(); + Assert.True(session.RecompData.ApplyPathsToRecompConfig().IsSuccess); + + Assert.Contains("nand_root", install.Fs.File.ReadAllText(toml)); + Assert.Equal(wii, install.Boot(runStartup: false).Settings.RECOMP_NAND_ROOT.Get()); + } + + [Fact] + public void WithoutAConfigFileYet_NothingIsCreated() + { + var install = RecompUser(out var toml); + install.WithRetroRewind(); + install.FlushConfig(); + + var session = install.Boot(); + Assert.True(session.RecompData.ApplyPathsToRecompConfig().IsSuccess); + + Assert.False(install.Fs.File.Exists(toml)); + } + + [Fact] + public void WhenRetroRewindIsNotInstalled_TheRuntimesOwnValueIsKept() + { + var install = RecompUser(out var toml); + const string custom = @"D:\My RR\RetroRewind6"; + WriteToml(install, toml, $"[paths]\nretro_rewind_root = \"{Escape(custom)}\"\n"); + install.FlushConfig(); + + var session = install.Boot(); + Assert.True(session.RecompData.ApplyPathsToRecompConfig().IsSuccess); + + Assert.Equal(custom, install.Boot(runStartup: false).Settings.RECOMP_RETRO_REWIND_ROOT.Get()); + } + + [Fact] + public void ADolphinUserWhoAlsoHasWiiCompiledInstalled_GetsItFollowedToo_ButStaysOnDolphin() + { + var install = RecompUser(out var toml, recompEnabled: false); + var files = install.WithRetroRewind(); + WriteToml( + install, + toml, + $"[paths]\nretro_rewind_root = \"{Escape(install.Combine(install.LegacyRoot, "RetroRewind6"))}\"\n[video]\nshow_fps = true\n" + ); + install.FlushConfig(); + + var session = install.Boot(); + + AssertStillWorks(session, files, install.ManagedRoot); + Assert.False(session.Settings.IsRecompModeActive()); + Assert.Equal( + install.Combine(install.ManagedRoot, "RetroRewind6"), + install.Boot(runStartup: false).Settings.RECOMP_RETRO_REWIND_ROOT.Get() + ); + } + + private static string Escape(string path) => path.Replace("\\", "\\\\"); +} diff --git a/WheelWizard.Test/Features/ExistingSetups/ExistingRetroRewindInstallTests.cs b/WheelWizard.Test/Features/ExistingSetups/ExistingRetroRewindInstallTests.cs new file mode 100644 index 000000000..d5c169401 --- /dev/null +++ b/WheelWizard.Test/Features/ExistingSetups/ExistingRetroRewindInstallTests.cs @@ -0,0 +1,576 @@ +using WheelWizard.CustomDistributions; +using static WheelWizard.Test.Features.ExistingSetups.ExistingSetupAssertions; + +namespace WheelWizard.Test.Features.ExistingSetups; + +/// +/// Retro Rewind used to live inside Dolphin's Load folder; this version keeps it in Wheel Wizard's own data +/// folder and moves older installs there on start. Every setup that worked before has to come out of that +/// with the same files, the same version and the same save, and without being asked to set anything up. +/// +public class ExistingRetroRewindInstallTests +{ + #region Where Dolphin and the data folder live + [Fact] + public void Windows_DolphinAndDataOnTheSameDrive_RenamesTheInstall() + { + var install = new ExistingInstall().WithDolphin( + @"C:\Dolphin\Dolphin.exe", + @"C:\Users\racer\Documents\Dolphin Emulator", + @"C:\Games\Mario Kart Wii.iso" + ); + var files = install.WithRetroRewind(); + install.FlushConfig(); + Assert.Equal(install.LegacyRoot, install.Boot(runStartup: false).Paths.RootFolderPath); + + var session = install.Boot(); + + AssertStillWorks(session, files, install.ManagedRoot); + Assert.False(install.Fs.Directory.Exists(install.LegacyRoot)); + AssertConfigKeepsWhatTheUserHad(session); + Assert.True(install.ReadConfig()["OnboardingCompleted"].GetBoolean()); + } + + [Fact] + public void Windows_DolphinOnAnotherDrive_CopiesTheInstallAcrossDrives() + { + var install = new ExistingInstall().WithDolphin( + @"D:\Dolphin\Dolphin.exe", + @"D:\Dolphin\Dolphin Emulator", + @"D:\Games\Mario Kart Wii.iso" + ); + var files = install.WithRetroRewind(withBeta: true); + install.FlushConfig(); + + var session = install.Boot(); + + AssertStillWorks(session, files, install.ManagedRoot); + Assert.False(install.Fs.Directory.Exists(install.LegacyRoot)); + Assert.Contains(session.Statuses, status => status is not null); + Assert.Null(session.Statuses[^1]); + } + + [Fact] + public void Windows_DataFolderOnAnotherDrive_CopiesTheInstallAcrossDrives() + { + var install = new ExistingInstall(dataDir: @"D:\WheelWizard").WithDolphin( + @"C:\Dolphin\Dolphin.exe", + @"C:\Users\racer\Documents\Dolphin Emulator", + @"C:\Games\Mario Kart Wii.iso" + ); + var files = install.WithRetroRewind(); + install.FlushConfig(); + + var session = install.Boot(); + + AssertStillWorks(session, files, @"D:\WheelWizard\RetroRewind"); + } + + [Fact] + public void Windows_PortableDolphinWithItsUserFolderNextToTheExecutable() + { + var install = new ExistingInstall().WithDolphin( + @"C:\Games\Dolphin-x64\Dolphin.exe", + @"C:\Games\Dolphin-x64\User", + @"C:\Games\Mario Kart Wii.iso" + ); + install.Fs.File.WriteAllText(@"C:\Games\Dolphin-x64\portable.txt", ""); + var files = install.WithRetroRewind(); + install.FlushConfig(); + + var session = install.Boot(); + + AssertStillWorks(session, files, install.ManagedRoot); + Assert.True(install.Fs.File.Exists(@"C:\Games\Dolphin-x64\portable.txt")); + } + + [Fact] + public void Windows_CustomLoadFolder_IsTheFolderTheInstallIsMovedFrom() + { + var install = new ExistingInstall() + .WithDolphin(@"C:\Dolphin\Dolphin.exe", @"C:\Users\racer\Documents\Dolphin Emulator", @"C:\Games\Mario Kart Wii.iso") + .WithCustomLoadFolder(@"D:\Mods\Dolphin Load"); + var customLegacy = @"D:\Mods\Dolphin Load\Riivolution\WheelWizard"; + var files = install.WithRetroRewind(customLegacy); + install.FlushConfig(); + Assert.Equal(customLegacy, install.Boot(runStartup: false).Paths.RootFolderPath); + + var session = install.Boot(); + + AssertStillWorks(session, files, install.ManagedRoot); + Assert.False(install.Fs.Directory.Exists(customLegacy)); + Assert.True(install.Fs.Directory.Exists(@"D:\Mods\Dolphin Load"), "the user's Load folder itself stays"); + } + + [Fact] + public void Windows_NamesWithSpacesAndAccents_SurviveTheMove() + { + var install = new ExistingInstall( + home: @"C:\Users\José Müller", + dataDir: @"C:\Users\José Müller\AppData\Roaming\Wheel Wizard" + ).WithDolphin( + @"C:\Spiele\Dolphin 2603\Dolphin.exe", + @"C:\Users\José Müller\Documents\Dolphin Emulator", + @"C:\Spiele\Mario Kart Wii (Europe).rvz" + ); + var files = install.WithRetroRewind(); + install.FlushConfig(); + + AssertStillWorks(install.Boot(), files, install.ManagedRoot); + } + + [Fact] + public void Linux_NativeDolphin_WithSplitConfigAndDataFolders() + { + var install = new ExistingInstall(HostPlatform.Linux).WithDolphin( + "dolphin-emu", + "/home/racer/.local/share/dolphin-emu", + "/home/racer/Games/mkw.iso" + ); + var files = install.WithRetroRewind(); + install.FlushConfig(); + Assert.Equal("/home/racer/.config/dolphin-emu", install.DolphinConfigFolder); + + var session = install.Boot(); + + AssertStillWorks(session, files, install.ManagedRoot); + } + + [Fact] + public void Linux_FlatpakDolphin_KeepsItsDataFolderAndGetsItsInstallMoved() + { + var install = new ExistingInstall(HostPlatform.Linux).WithDolphin( + "flatpak run org.DolphinEmu.dolphin-emu", + "/home/racer/.var/app/org.DolphinEmu.dolphin-emu/data/dolphin-emu", + "/home/racer/Games/mkw.iso" + ); + var files = install.WithRetroRewind(); + install.FlushConfig(); + + var session = install.Boot(); + + AssertStillWorks(session, files, install.ManagedRoot); + } + + [Fact] + public void MacOs_DolphinApp_WithQuotedLocation() + { + var install = new ExistingInstall(HostPlatform.MacOS).WithDolphin( + "'/Applications/Dolphin.app/Contents/MacOS/Dolphin'", + "/Users/racer/Library/Application Support/Dolphin", + "/Users/racer/Games/mkw.iso" + ); + var files = install.WithRetroRewind(); + install.FlushConfig(); + + var session = install.Boot(); + + AssertStillWorks(session, files, install.ManagedRoot); + } + #endregion + + #region What was installed + [Fact] + public void StableAndBetaTogether_AreMovedTogether() + { + var install = WindowsInstall(); + var files = install.WithRetroRewind(withBeta: true); + install.FlushConfig(); + + var session = install.Boot(); + + AssertStillWorks(session, files, install.ManagedRoot); + Assert.Equal("6.6.0-beta", install.Fs.File.ReadAllText(install.Combine(session.Paths.BetaFolderPath, "version.txt"))); + Assert.True(install.Fs.File.Exists(session.Paths.BetaXmlFilePath)); + } + + [Fact] + public void ALargeInstall_WithManyCustomTracks_ArrivesComplete() + { + var install = WindowsInstall(); + var files = install.WithRetroRewind(extraFiles: 400); + install.FlushConfig(); + + var session = install.Boot(); + + AssertStillWorks(session, files, install.ManagedRoot); + Assert.Equal(405, files.Count); + } + + [Fact] + public void ABrokenInstallWithOnlyItsXml_IsStillMoved_AndStillCountsAsNotInstalled() + { + var install = WindowsInstall(); + install.Fs.Directory.CreateDirectory(install.Combine(install.LegacyRoot, "riivolution")); + install.Fs.File.WriteAllText(install.Combine(install.LegacyRoot, "riivolution", "RetroRewind6.xml"), ""); + install.FlushConfig(); + + var session = install.Boot(); + + Assert.Equal(install.ManagedRoot, session.Paths.RootFolderPath); + Assert.Equal("", install.Fs.File.ReadAllText(session.Paths.XmlFilePath)); + Assert.Null(session.InstalledRetroRewindVersion()); + AssertUserSettingsUntouched(session); + Assert.False(session.Onboarding.IsRequired); + } + + [Fact] + public void AVersionFileTheServerHasNotShippedYet_IsKeptByteForByte() + { + var install = WindowsInstall(); + var files = install.WithRetroRewind(); + files["RetroRewind6/version.txt"] = "6.5.1\r\n"; + install.Fs.File.WriteAllText(install.Combine(install.LegacyRoot, "RetroRewind6", "version.txt"), "6.5.1\r\n"); + install.FlushConfig(); + + var session = install.Boot(); + + Assert.Equal("6.5.1\r\n", install.Fs.File.ReadAllText(install.Combine(session.Paths.RetroRewindFolderPath, "version.txt"))); + } + + [Fact] + public void FilesNextToWheelWizardsFolder_InDolphinsLoadFolder_AreNeverTouched() + { + var install = WindowsInstall(); + install.WithRetroRewind(); + var others = new Dictionary + { + [install.Combine(install.LoadFolder, "Riivolution", "SomeOtherMod.xml")] = "", + [install.Combine(install.LoadFolder, "Riivolution", "OtherMod", "data.bin")] = "other", + [install.Combine(install.LoadFolder, "GameSettings", "RMCP01.ini")] = "[Core]", + [install.Combine(install.LoadFolder, "Textures", "RMC", "tex1_64x64.png")] = "png", + }; + foreach (var (path, content) in others) + { + install.Fs.Directory.CreateDirectory(install.Fs.Path.GetDirectoryName(path)!); + install.Fs.File.WriteAllText(path, content); + } + + install.FlushConfig(); + install.Boot(); + + foreach (var (path, content) in others) + Assert.Equal(content, install.Fs.File.ReadAllText(path)); + } + #endregion + + #region Nothing to move + [Fact] + public void NeverInstalledRetroRewind_StaysUninstalled_AndNothingIsInvented() + { + var install = WindowsInstall(); + install.FlushConfig(); + + var session = install.Boot(); + + Assert.Equal(install.ManagedRoot, session.Paths.RootFolderPath); + Assert.Null(session.InstalledRetroRewindVersion()); + Assert.Empty(install.Snapshot(install.ManagedRoot).Keys.Where(key => key != CustomDistributionPaths.MigratedMarkerFileName)); + AssertUserSettingsUntouched(session); + Assert.False(session.Onboarding.IsRequired); + } + + [Fact] + public void AnEmptyLeftoverFolder_IsNotAnInstall() + { + var install = WindowsInstall(); + install.Fs.Directory.CreateDirectory(install.LegacyRoot); + install.FlushConfig(); + + var session = install.Boot(); + + Assert.Equal(install.ManagedRoot, session.Paths.RootFolderPath); + Assert.True(install.Fs.Directory.Exists(install.LegacyRoot), "an empty folder of the user's is left alone"); + } + + [Fact] + public void WiiCompiledOnlyUser_WithoutAnyDolphin_KeepsTheirInstallWhereItIs() + { + var install = new ExistingInstall().WithGame(@"C:\Games\Mario Kart Wii.iso"); + var files = install.WithRetroRewind(install.ManagedRoot); + install.FlushConfig(); + + var session = install.Boot(); + + Assert.Equal(install.ManagedRoot, session.Paths.RootFolderPath); + Assert.Equal( + files, + install.Snapshot(install.ManagedRoot).Where(pair => pair.Key != CustomDistributionPaths.MigratedMarkerFileName).ToDictionary() + ); + Assert.False(session.Onboarding.IsRequired); + } + + [Fact] + public void AnInstallAlreadyInTheManagedFolder_IsNotTouched_EvenWithADolphinConfigured() + { + var install = WindowsInstall(); + var files = install.WithRetroRewind(install.ManagedRoot); + install.FlushConfig(); + + var session = install.Boot(); + + AssertStillWorks(session, files, install.ManagedRoot); + Assert.Empty(install.Fs.Directory.GetDirectories(install.DataDir, "RetroRewind.old-*")); + } + #endregion + + #region Both a legacy and a managed install + [Fact] + public void LegacyInstallWins_AndTheManagedOneIsKeptNextToIt() + { + var install = WindowsInstall(); + var legacy = install.WithRetroRewind(); + install.Fs.Directory.CreateDirectory(install.Combine(install.ManagedRoot, "RetroRewind6")); + install.Fs.File.WriteAllText(install.Combine(install.ManagedRoot, "RetroRewind6", "version.txt"), "6.0.0"); + install.FlushConfig(); + + var session = install.Boot(); + + AssertStillWorks(session, legacy, install.ManagedRoot); + var backup = Assert.Single(install.Fs.Directory.GetDirectories(install.DataDir, "RetroRewind.old-*")); + Assert.Equal("6.0.0", install.Fs.File.ReadAllText(install.Combine(backup, "RetroRewind6", "version.txt"))); + } + #endregion + + #region Later starts + [Fact] + public void StartingAgainAfterTheMove_ChangesNothing() + { + var install = WindowsInstall(); + var files = install.WithRetroRewind(withBeta: true); + install.FlushConfig(); + install.Boot(); + var afterFirst = install.Snapshot(install.DataDir); + + var second = install.Boot(); + var third = install.Boot(); + + Assert.Equal(afterFirst.OrderBy(pair => pair.Key), install.Snapshot(install.DataDir).OrderBy(pair => pair.Key)); + AssertStillWorks(third, files, install.ManagedRoot); + Assert.Equal(install.ManagedRoot, second.Paths.RootFolderPath); + } + + [Fact] + public void ADolphinPickedLaterThatHasItsOwnOldInstall_NeverTakesOver() + { + var install = WindowsInstall(); + var files = install.WithRetroRewind(); + install.FlushConfig(); + install.Boot(); + + // The user points Wheel Wizard at a second Dolphin that still has an old Retro Rewind in its Load folder. + var other = @"C:\Users\racer\Documents\Other Dolphin"; + install.Fs.Directory.CreateDirectory(other); + install.WithRetroRewind(install.Combine(other, "Load", "Riivolution", "WheelWizard")).Clear(); + install.Config["UserFolderPath"] = other; + install.FlushConfig(); + + var session = install.Boot(); + + Assert.Equal(install.ManagedRoot, session.Paths.RootFolderPath); + Assert.Equal( + files["riivolution/save/RetroWFC/RMCP/rksys.dat"], + install.Fs.File.ReadAllText(install.Combine(install.ManagedRoot, "riivolution", "save", "RetroWFC", "RMCP", "rksys.dat")) + ); + } + + [Fact] + public void ADolphinOnAnUnpluggedDrive_IsRetriedOnTheNextStart_WithoutLosingAnything() + { + var install = new ExistingInstall().WithDolphin( + @"C:\Dolphin\Dolphin.exe", + @"E:\Dolphin\Dolphin Emulator", + @"C:\Games\Mario Kart Wii.iso", + createFiles: false + ); + install.Fs.Directory.CreateDirectory(@"C:\Dolphin"); + install.Fs.File.WriteAllText(@"C:\Dolphin\Dolphin.exe", "MZ"); + install.FlushConfig(); + + var unplugged = install.Boot(); + + Assert.False(unplugged.Onboarding.IsRequired); + Assert.False( + install.Fs.File.Exists(unplugged.Paths.MigratedMarkerFilePath), + "an unreadable folder must not be written off as having no install" + ); + Assert.Null(unplugged.InstalledRetroRewindVersion()); + + // The drive comes back with the install on it. + install.Fs.WithDrive("E:"); + install.Fs.Directory.CreateDirectory(@"E:\Dolphin\Dolphin Emulator\Config"); + var files = install.WithRetroRewind(@"E:\Dolphin\Dolphin Emulator\Load\Riivolution\WheelWizard"); + install.Fs.File.WriteAllText(@"E:\Dolphin\Dolphin Emulator\Config\Dolphin.ini", "[General]\n"); + + var plugged = install.Boot(); + + Assert.Equal(install.ManagedRoot, plugged.Paths.RootFolderPath); + Assert.Equal(ExistingInstall.RetroRewindVersion, plugged.InstalledRetroRewindVersion()); + Assert.Equal( + files["riivolution/save/RetroWFC/RMCP/rksys.dat"], + install.Fs.File.ReadAllText(install.Combine(install.ManagedRoot, "riivolution", "save", "RetroWFC", "RMCP", "rksys.dat")) + ); + } + #endregion + + #region More shapes + [Fact] + public void ChangingAnotherSetting_WhileTheDriveIsAway_DoesNotForgetTheirPaths() + { + var install = new ExistingInstall().WithDolphin( + @"D:\Dolphin\Dolphin.exe", + @"D:\Dolphin\Dolphin Emulator", + @"D:\Games\Mario Kart Wii.iso" + ); + install.WithRetroRewind(); + install.FlushConfig(); + install.Unplug(@"D:\Dolphin"); + install.Unplug(@"D:\Games"); + + var session = install.Boot(); + Assert.True(session.Settings.Set(session.Settings.LAUNCH_RR_ON_STARTUP, true)); + + var config = install.ReadConfig(); + Assert.Equal(@"D:\Dolphin\Dolphin.exe", config["DolphinLocation"].GetString()); + Assert.Equal(@"D:\Dolphin\Dolphin Emulator", config["UserFolderPath"].GetString()); + Assert.Equal(@"D:\Games\Mario Kart Wii.iso", config["GameLocation"].GetString()); + + // The drive comes back: everything is exactly as it was, with the install moved in. + install.Replug(); + var back = install.Boot(); + Assert.True(back.Settings.PathsSetupCorrectly()); + Assert.Equal(install.ManagedRoot, back.Paths.RootFolderPath); + Assert.Equal(ExistingInstall.RetroRewindVersion, back.InstalledRetroRewindVersion()); + } + + [Fact] + public void PickingAnotherFolderInSettings_WhileTheOldOneIsAway_ReplacesIt() + { + var install = new ExistingInstall().WithDolphin( + @"C:\Dolphin\Dolphin.exe", + @"D:\Dolphin\Dolphin Emulator", + @"C:\Games\Mario Kart Wii.iso" + ); + install.FlushConfig(); + install.Unplug(@"D:\Dolphin"); + var replacement = @"C:\Users\racer\Documents\Dolphin Emulator"; + install.Fs.Directory.CreateDirectory(replacement); + + var session = install.Boot(); + Assert.True(session.Settings.Set(session.Settings.USER_FOLDER_PATH, replacement)); + + Assert.Equal(replacement, install.ReadConfig()["UserFolderPath"].GetString()); + Assert.False(install.Boot().Onboarding.IsRequired); + } + + [Fact] + public void FlatpakWheelWizard_WithItsBundledDolphin_BringsItsInstallAlong() + { + var install = new ExistingInstall(HostPlatform.Linux).WithFlatpakSandbox(); + var userFolder = "/home/racer/.var/app/io.github.TeamWheelWizard.WheelWizard/data/dolphin-emu"; + install.Fs.Directory.CreateDirectory(userFolder); + install.WithGame("/home/racer/Games/mkw.iso"); + install.Config["UserFolderPath"] = userFolder; + var legacy = install.Combine(userFolder, "Load", "Riivolution", "WheelWizard"); + var files = install.WithRetroRewind(legacy); + install.FlushConfig(); + + var session = install.Boot(); + + Assert.False(session.Onboarding.IsRequired); + Assert.Equal(install.ManagedRoot, session.Paths.RootFolderPath); + Assert.Equal( + files, + install + .Snapshot(session.Paths.RootFolderPath) + .Where(pair => pair.Key != CustomDistributionPaths.MigratedMarkerFileName) + .ToDictionary() + ); + Assert.False(install.Fs.Directory.Exists(legacy)); + } + + [Fact] + public void ABetaOnlyInstall_IsMovedAndStaysBetaOnly() + { + var install = new ExistingInstall().WithDolphin( + @"C:\Dolphin\Dolphin.exe", + @"C:\Users\racer\Documents\Dolphin Emulator", + @"C:\Games\Mario Kart Wii.iso" + ); + var beta = new Dictionary + { + ["RRBeta/version.txt"] = "6.6.0-beta", + ["RRBeta/Patches/Main.bin"] = "beta patch", + ["riivolution/RRBeta.xml"] = "", + ["riivolution/save/RetroWFC/RMCP/rksys.dat"] = "beta save", + }; + foreach (var (relative, content) in beta) + { + var path = install.Combine(install.LegacyRoot, relative.Replace('/', install.Fs.Path.DirectorySeparatorChar)); + install.Fs.Directory.CreateDirectory(install.Fs.Path.GetDirectoryName(path)!); + install.Fs.File.WriteAllText(path, content); + } + + install.FlushConfig(); + + var session = install.Boot(); + + Assert.Equal( + beta.OrderBy(pair => pair.Key), + install + .Snapshot(session.Paths.RootFolderPath) + .Where(pair => pair.Key != CustomDistributionPaths.MigratedMarkerFileName) + .OrderBy(pair => pair.Key) + ); + Assert.Null(session.InstalledRetroRewindVersion()); + Assert.True(install.Fs.File.Exists(session.Paths.BetaXmlFilePath)); + } + + [Fact] + public async Task AnExistingDolphinSetup_IsNeverOfferedAManagedDolphinUpdate() + { + var install = new ExistingInstall().WithDolphin( + @"C:\Dolphin\Dolphin.exe", + @"C:\Users\racer\Documents\Dolphin Emulator", + @"C:\Games\Mario Kart Wii.iso" + ); + install.FlushConfig(); + + var session = install.Boot(); + + Assert.Null(await session.ManagedDolphin.CheckForUpdateAsync()); + Assert.False(session.ManagedDolphin.IsActive); + Assert.Equal(@"C:\Dolphin\Dolphin.exe", session.Settings.DOLPHIN_LOCATION.Get()); + } + + [Fact] + public void ACustomLoadFolderOnAnUnpluggedDrive_IsRetriedWhenItIsBack() + { + var install = new ExistingInstall() + .WithDolphin(@"C:\Dolphin\Dolphin.exe", @"C:\Users\racer\Documents\Dolphin Emulator", @"C:\Games\Mario Kart Wii.iso") + .WithCustomLoadFolder(@"E:\Mods\Load", exists: false); + install.Fs.WithDrive("E:"); + var legacy = @"E:\Mods\Load\Riivolution\WheelWizard"; + var files = install.WithRetroRewind(legacy); + install.FlushConfig(); + install.Unplug(@"E:\Mods"); + + var away = install.Boot(); + + Assert.False(away.Onboarding.IsRequired); + Assert.False(install.Fs.File.Exists(away.Paths.MigratedMarkerFilePath)); + + install.Replug(); + var back = install.Boot(); + + AssertStillWorks(back, files, install.ManagedRoot); + } + #endregion + + private static ExistingInstall WindowsInstall() => + new ExistingInstall().WithDolphin( + @"C:\Dolphin\Dolphin.exe", + @"C:\Users\racer\Documents\Dolphin Emulator", + @"C:\Games\Mario Kart Wii.iso" + ); +} diff --git a/WheelWizard.Test/Features/ExistingSetups/ExistingSetupAssertions.cs b/WheelWizard.Test/Features/ExistingSetups/ExistingSetupAssertions.cs new file mode 100644 index 000000000..253d1c384 --- /dev/null +++ b/WheelWizard.Test/Features/ExistingSetups/ExistingSetupAssertions.cs @@ -0,0 +1,94 @@ +using System.Text.Json; +using WheelWizard.CustomDistributions; + +namespace WheelWizard.Test.Features.ExistingSetups; + +internal static class ExistingSetupAssertions +{ + /// + /// What an existing user relies on after an update: they are not onboarded again, the settings they + /// picked still mean the same, Dolphin's own files are untouched, and Retro Rewind (with its save) is + /// where Wheel Wizard now looks for it. + /// + public static void AssertStillWorks( + Session session, + Dictionary? retroRewind = null, + string? expectedRoot = null, + bool expectPlayable = true + ) + { + var install = session.Install; + + Assert.False(session.Onboarding.IsRequired, "an existing setup must never be sent through the onboarding"); + AssertUserSettingsUntouched(session); + Assert.False(session.ManagedDolphin.IsActive, "an existing Dolphin must not be turned into a managed one"); + + if (expectPlayable) + Assert.True(session.Settings.PathsSetupCorrectly(), "the paths that were valid before are still valid"); + + if (expectedRoot is not null) + Assert.Equal(expectedRoot, session.Paths.RootFolderPath); + + if (retroRewind is not null) + { + var current = install.Snapshot(session.Paths.RootFolderPath); + current.Remove(CustomDistributionPaths.MigratedMarkerFileName); + Assert.Equal(retroRewind.OrderBy(pair => pair.Key), current.OrderBy(pair => pair.Key)); + Assert.Equal(ExistingInstall.RetroRewindVersion, session.InstalledRetroRewindVersion()); + Assert.Equal( + retroRewind["riivolution/save/RetroWFC/RMCP/rksys.dat"], + install.Fs.File.ReadAllText(install.Combine(session.Paths.SaveFolderPath, "RMCP", "rksys.dat")) + ); + } + + if (expectPlayable) + AssertLaunchPointsAtRetroRewind(session); + AssertNoLeftovers(session); + } + + public static void AssertUserSettingsUntouched(Session session) + { + var install = session.Install; + Assert.Equal(install.DolphinLocation, session.Settings.DOLPHIN_LOCATION.Get()); + Assert.Equal(install.UserFolder, session.Settings.USER_FOLDER_PATH.Get()); + Assert.Equal(install.GameFile, session.Settings.GAME_LOCATION.Get()); + if (session.InitialDolphinIni is not null) + Assert.Equal(session.InitialDolphinIni, install.Fs.File.ReadAllText(install.DolphinIniPath)); + } + + public static void AssertLaunchPointsAtRetroRewind(Session session) + { + var install = session.Install; + var root = session.ReadLaunchRoot(); + Assert.Equal(install.Fs.Path.GetFullPath(session.Paths.RootFolderPath), root); + var xml = session.ReadLaunchXml(); + Assert.True(install.Fs.File.Exists(xml), $"the Riivolution XML {xml} Dolphin is launched with must exist"); + Assert.StartsWith(root, xml, StringComparison.OrdinalIgnoreCase); + Assert.True(install.Fs.Directory.Exists(root)); + } + + public static void AssertNoLeftovers(Session session) + { + var install = session.Install; + var parent = install.Fs.Path.GetDirectoryName(install.ManagedRoot)!; + var siblings = install.Fs.Directory.GetDirectories(parent).Select(install.Fs.Path.GetFileName).ToArray(); + Assert.DoesNotContain(siblings, name => name!.EndsWith(".migrating", StringComparison.OrdinalIgnoreCase)); + } + + /// + /// Saving rewrites config.json with every registered setting, so new keys with defaults show up; what + /// matters is that nothing the user had, including keys this version does not know, changed its value. + /// + public static void AssertConfigKeepsWhatTheUserHad(Session session) + { + var install = session.Install; + Assert.NotNull(session.InitialConfigJson); + var before = JsonSerializer.Deserialize>(session.InitialConfigJson!)!; + var after = install.ReadConfig(); + foreach (var (key, value) in before) + { + Assert.True(after.ContainsKey(key), $"config.json lost {key}"); + Assert.Equal(value.GetRawText(), after[key].GetRawText()); + } + } +} diff --git a/WheelWizard.Test/Features/ExistingSetups/ExistingSetupMigrationSafetyTests.cs b/WheelWizard.Test/Features/ExistingSetups/ExistingSetupMigrationSafetyTests.cs new file mode 100644 index 000000000..fdaca1a12 --- /dev/null +++ b/WheelWizard.Test/Features/ExistingSetups/ExistingSetupMigrationSafetyTests.cs @@ -0,0 +1,252 @@ +using Testably.Abstractions.Testing; +using WheelWizard.CustomDistributions; +using static WheelWizard.Test.Features.ExistingSetups.ExistingSetupAssertions; + +namespace WheelWizard.Test.Features.ExistingSetups; + +/// +/// The Retro Rewind folder holds what a player cannot download again: their save (VR, ghosts, license). +/// Whatever goes wrong while it is being moved, the original has to stay usable and the next start has to +/// finish the job. +/// +public class ExistingSetupMigrationSafetyTests +{ + private static ExistingInstall CrossDriveInstall() => + new ExistingInstall().WithDolphin(@"D:\Dolphin\Dolphin.exe", @"D:\Dolphin\Dolphin Emulator", @"D:\Games\Mario Kart Wii.iso"); + + private static ExistingInstall SameDriveInstall() => + new ExistingInstall().WithDolphin( + @"C:\Dolphin\Dolphin.exe", + @"C:\Users\racer\Documents\Dolphin Emulator", + @"C:\Games\Mario Kart Wii.iso" + ); + + [Fact] + public void ACopyThatFailsHalfway_LeavesTheOriginalActiveAndComplete_AndTheNextStartFinishesTheMove() + { + var install = CrossDriveInstall(); + var files = install.WithRetroRewind(extraFiles: 30); + install.FlushConfig(); + using var failing = install.Fs.Intercept.Event( + _ => throw new IOException("There is not enough space on the disk."), + change => + change.ChangeType == WatcherChangeTypes.Changed + && change.FileSystemType == FileSystemTypes.File + && change.Path.EndsWith("rksys.dat", StringComparison.OrdinalIgnoreCase) + && change.Path.Contains(".migrating", StringComparison.OrdinalIgnoreCase) + ); + + var failed = install.Boot(); + + AssertStillWorks(failed, files, install.LegacyRoot); + Assert.False(install.Fs.File.Exists(failed.Paths.MigratedMarkerFilePath)); + Assert.False(install.Fs.Directory.Exists(install.ManagedRoot), "an unfinished copy must never become the active install"); + Assert.False(install.Fs.Directory.Exists(install.ManagedRoot + ".migrating")); + + failing.Dispose(); + var retried = install.Boot(); + + AssertStillWorks(retried, files, install.ManagedRoot); + Assert.False(install.Fs.Directory.Exists(install.LegacyRoot)); + } + + [Fact] + public void ARenameThatIsRefused_BecauseTheFolderIsInUse_FallsBackToACopy() + { + var install = SameDriveInstall(); + var files = install.WithRetroRewind(); + install.FlushConfig(); + using var busy = install.Fs.Intercept.Event( + _ => throw new IOException("The process cannot access the file because it is being used by another process."), + change => + change.ChangeType == WatcherChangeTypes.Renamed + && change.FileSystemType == FileSystemTypes.Directory + && change.OldPath is not null + && change.OldPath.EndsWith("WheelWizard", StringComparison.OrdinalIgnoreCase) + ); + + var session = install.Boot(); + + AssertStillWorks(session, files, install.ManagedRoot); + } + + [Fact] + public void AMoveThatIsDeniedOutright_KeepsTheOriginalWorkingAndTriesAgainNextStart() + { + var install = SameDriveInstall(); + var files = install.WithRetroRewind(); + install.FlushConfig(); + using var denied = install.Fs.Intercept.Event( + _ => throw new UnauthorizedAccessException("Access to the path is denied."), + change => + change.ChangeType == WatcherChangeTypes.Renamed + && change.FileSystemType == FileSystemTypes.Directory + && change.OldPath is not null + && change.OldPath.EndsWith("WheelWizard", StringComparison.OrdinalIgnoreCase) + ); + + var blocked = install.Boot(); + + AssertStillWorks(blocked, files, install.LegacyRoot); + Assert.False(install.Fs.File.Exists(blocked.Paths.MigratedMarkerFilePath)); + + denied.Dispose(); + AssertStillWorks(install.Boot(), files, install.ManagedRoot); + } + + [Fact] + public void AnOldFolderThatCannotBeDeletedAfterTheCopy_IsNeverPickedAgain_AndTheCopyIsComplete() + { + var install = CrossDriveInstall(); + var files = install.WithRetroRewind(); + install.FlushConfig(); + using var stuck = install.Fs.Intercept.Event( + _ => throw new IOException("The directory is not empty."), + change => + change.ChangeType == WatcherChangeTypes.Deleted + && change.Path.StartsWith(@"D:\Dolphin\Dolphin Emulator", StringComparison.OrdinalIgnoreCase) + ); + + var session = install.Boot(); + + AssertStillWorks(session, files, install.ManagedRoot); + Assert.True(install.Fs.Directory.Exists(install.LegacyRoot), "the folder that could not be removed is simply left behind"); + // Starting again must not copy it over the managed install, nor make it the active one. + var again = install.Boot(); + AssertStillWorks(again, files, install.ManagedRoot); + Assert.Empty(install.Fs.Directory.GetDirectories(install.DataDir, "RetroRewind.old-*")); + } + + [Fact] + public void AStagingFolderLeftByAnAppThatWasClosedMidCopy_IsDiscardedAndNeverUsed() + { + var install = CrossDriveInstall(); + var files = install.WithRetroRewind(); + install.Fs.Directory.CreateDirectory(install.Combine(install.ManagedRoot + ".migrating", "RetroRewind6")); + install.Fs.File.WriteAllText(install.Combine(install.ManagedRoot + ".migrating", "RetroRewind6", "version.txt"), "6.0.0-partial"); + install.FlushConfig(); + + var session = install.Boot(); + + AssertStillWorks(session, files, install.ManagedRoot); + } + + [Fact] + public void AnUnwritableDataFolder_LeavesTheOriginalInPlace() + { + var install = CrossDriveInstall(); + var files = install.WithRetroRewind(); + install.FlushConfig(); + using var readOnly = install.Fs.Intercept.Event( + _ => throw new UnauthorizedAccessException("Access to the path is denied."), + change => + change.ChangeType == WatcherChangeTypes.Created + && change.Path.StartsWith(install.ManagedRoot, StringComparison.OrdinalIgnoreCase) + ); + + var session = install.Boot(); + + AssertStillWorks(session, files, install.LegacyRoot); + } + + [Fact] + public void ACancelledStartup_MovesNothing() + { + var install = SameDriveInstall(); + var files = install.WithRetroRewind(); + install.FlushConfig(); + var session = install.Boot(runStartup: false); + using var cancelled = new CancellationTokenSource(); + cancelled.Cancel(); + + Assert.ThrowsAny( + () => session.Maintenance.RunAsync(_ => { }, cancelled.Token).GetAwaiter().GetResult() + ); + + Assert.Equal(files, install.Snapshot(install.LegacyRoot)); + Assert.False(install.Fs.Directory.Exists(install.ManagedRoot)); + } + + [Fact] + public void RelocatingTheDataFolderAfterTheMove_TakesRetroRewindAlongAndKeepsUsingIt() + { + var install = SameDriveInstall(); + var files = install.WithRetroRewind(); + install.FlushConfig(); + install.Boot(); + + install.RelocateDataFolder(@"C:\Users\racer\Games\WheelWizardData"); + var session = install.Boot(); + + AssertStillWorks(session, files, @"C:\Users\racer\Games\WheelWizardData\RetroRewind"); + } + + [Fact] + public void RelocatingTheDataFolderBeforeTheMove_StillMovesTheOldInstallIntoTheNewFolder() + { + var install = SameDriveInstall(); + var files = install.WithRetroRewind(); + install.FlushConfig(); + install.RelocateDataFolder(@"D:\WheelWizardData"); + + var session = install.Boot(); + + AssertStillWorks(session, files, @"D:\WheelWizardData\RetroRewind"); + } + + [Fact] + public void WhileTheMoveIsPending_EveryPathWheelWizardUsesStillAddressesTheOldInstall_AndAfterwardsTheNewOne() + { + var install = SameDriveInstall(); + install.WithRetroRewind(withBeta: true); + install.FlushConfig(); + + var before = install.Boot(runStartup: false).Paths; + Assert.True(before.IsLegacyRootActive); + foreach ( + var path in new[] + { + before.RetroRewindFolderPath, + before.BetaFolderPath, + before.PatchesFolderPath, + before.SaveFolderPath, + before.XmlFilePath, + before.BetaXmlFilePath, + } + ) + Assert.StartsWith(install.LegacyRoot, path, StringComparison.OrdinalIgnoreCase); + + var after = install.Boot().Paths; + foreach ( + var path in new[] + { + after.RetroRewindFolderPath, + after.BetaFolderPath, + after.PatchesFolderPath, + after.SaveFolderPath, + after.XmlFilePath, + after.BetaXmlFilePath, + } + ) + Assert.StartsWith(install.ManagedRoot, path, StringComparison.OrdinalIgnoreCase); + Assert.False(after.IsLegacyRootActive); + } + + [Fact] + public void TheMigrationMarkerNeverEndsUpInTheGamesSight() + { + // The marker lives in the managed root, next to RetroRewind6 and riivolution, which Dolphin is told about + // explicitly; it must not sit inside either of them. + var install = SameDriveInstall(); + install.WithRetroRewind(); + install.FlushConfig(); + + var session = install.Boot(); + + var marker = session.Paths.MigratedMarkerFilePath; + Assert.Equal(install.ManagedRoot, install.Fs.Path.GetDirectoryName(marker)); + Assert.False( + install.Fs.File.Exists(install.Combine(session.Paths.RetroRewindFolderPath, CustomDistributionPaths.MigratedMarkerFileName)) + ); + } +} diff --git a/WheelWizard.Test/Features/ExistingSetups/ExistingSetupOnboardingGateTests.cs b/WheelWizard.Test/Features/ExistingSetups/ExistingSetupOnboardingGateTests.cs new file mode 100644 index 000000000..fd7e11da6 --- /dev/null +++ b/WheelWizard.Test/Features/ExistingSetups/ExistingSetupOnboardingGateTests.cs @@ -0,0 +1,273 @@ +using static WheelWizard.Test.Features.ExistingSetups.ExistingSetupAssertions; + +namespace WheelWizard.Test.Features.ExistingSetups; + +/// +/// The onboarding is for somebody who has nothing set up. Anybody who set Wheel Wizard up with an earlier +/// release, working or not at this very moment, must land on Home (with its repair hint), never in the wizard. +/// +public class ExistingSetupOnboardingGateTests +{ + public static TheoryData Platforms => new() { HostPlatform.Windows, HostPlatform.Linux, HostPlatform.MacOS }; + + private static ExistingInstall WorkingSetup(HostPlatform platform) => + platform switch + { + HostPlatform.Windows => new ExistingInstall(platform).WithDolphin( + @"C:\Dolphin\Dolphin.exe", + @"C:\Users\racer\Documents\Dolphin Emulator", + @"C:\Games\Mario Kart Wii.iso" + ), + HostPlatform.MacOS => new ExistingInstall(platform).WithDolphin( + "'/Applications/Dolphin.app/Contents/MacOS/Dolphin'", + "/Users/racer/Library/Application Support/Dolphin", + "/Users/racer/Games/mkw.iso" + ), + _ => new ExistingInstall(platform).WithDolphin( + "dolphin-emu", + "/home/racer/.local/share/dolphin-emu", + "/home/racer/Games/mkw.iso" + ), + }; + + private static void Unplug(ExistingInstall install, string path) + { + if (install.Fs.Directory.Exists(path)) + install.Fs.Directory.Delete(path, recursive: true); + else if (install.Fs.File.Exists(path)) + install.Fs.File.Delete(path); + } + + #region Control: somebody who really has nothing + [Theory] + [MemberData(nameof(Platforms))] + public void ABrandNewInstall_IsOnboarded(HostPlatform platform) + { + var install = new ExistingInstall(platform); + + var session = install.Boot(); + + Assert.True(session.Onboarding.IsRequired); + Assert.False(install.Fs.File.Exists(install.ConfigFilePath), "starting up must not write a config for somebody who has not set up"); + } + + [Fact] + public void ARetroRewindFolderAloneIsNotASetup() + { + // e.g. a data folder carried over from somewhere: nothing was ever picked in this Wheel Wizard. + var install = new ExistingInstall(); + install.WithRetroRewind(install.ManagedRoot); + + Assert.True(install.Boot().Onboarding.IsRequired); + } + #endregion + + #region Working setups + [Theory] + [MemberData(nameof(Platforms))] + public void AWorkingSetup_IsNeverOnboarded_AndIsRememberedAsOnboarded(HostPlatform platform) + { + var install = WorkingSetup(platform); + install.WithRetroRewind(); + install.WithConfig("LaunchRrOnStartup", true).WithConfig("WW_Language", "nl").WithConfig("SomeFutureSetting", 42).FlushConfig(); + + var session = install.Boot(); + + Assert.False(session.Onboarding.IsRequired); + Assert.True(session.Settings.ONBOARDING_COMPLETED.Get()); + AssertConfigKeepsWhatTheUserHad(session); + Assert.True(install.ReadConfig()["OnboardingCompleted"].GetBoolean()); + Assert.Equal(42, install.ReadConfig()["SomeFutureSetting"].GetInt32()); + } + + [Theory] + [MemberData(nameof(Platforms))] + public void AWorkingSetup_WithoutRetroRewindYet_IsNotOnboarded(HostPlatform platform) + { + var install = WorkingSetup(platform).FlushConfig(); + + Assert.False(install.Boot().Onboarding.IsRequired); + } + + [Fact] + public void ASetupWrittenByAVeryOldRelease_WithOnlyThePathsInConfig_IsNotOnboarded() + { + var install = WorkingSetup(HostPlatform.Windows); + install.Config.Clear(); + install + .WithConfig("DolphinLocation", install.DolphinLocation) + .WithConfig("UserFolderPath", install.UserFolder) + .WithConfig("GameLocation", install.GameFile); + install.FlushConfig(); + + Assert.False(install.Boot().Onboarding.IsRequired); + } + + [Fact] + public void AFlatpakWheelWizardWithItsBundledDolphin_IsNotOnboarded() + { + var install = new ExistingInstall(HostPlatform.Linux).WithFlatpakSandbox(); + var userFolder = "/home/racer/.var/app/io.github.TeamWheelWizard.WheelWizard/data/dolphin-emu"; + install.Fs.Directory.CreateDirectory(userFolder); + install.WithGame("/home/racer/Games/mkw.iso"); + install.Config["UserFolderPath"] = userFolder; + install.FlushConfig(); + + var session = install.Boot(); + + Assert.False(session.Onboarding.IsRequired); + } + + [Fact] + public void AFlatpakWheelWizardThatKeptTheDefaultDolphinLocation_AndOnlyPickedAGame_IsNotOnboarded() + { + var install = new ExistingInstall(HostPlatform.Linux).WithFlatpakSandbox(); + install.WithGame("/home/racer/Games/mkw.iso").FlushConfig(); + + Assert.False(install.Boot().Onboarding.IsRequired); + } + #endregion + + #region A path that does not resolve right now (unplugged drive, moved or renamed folder) + [Theory] + [MemberData(nameof(Platforms))] + public void TheDolphinWasMovedAway_ButTheRestIsThere(HostPlatform platform) + { + var install = WorkingSetup(platform); + install.FlushConfig(); + install.AvailableCommands.Clear(); + if (platform == HostPlatform.Windows) + Unplug(install, install.DolphinLocation); + + var session = install.Boot(); + + Assert.False(session.Onboarding.IsRequired); + Assert.Equal(install.UserFolder, session.Settings.USER_FOLDER_PATH.Get()); + Assert.Equal(install.GameFile, session.Settings.GAME_LOCATION.Get()); + } + + [Theory] + [MemberData(nameof(Platforms))] + public void TheUserFolderIsOnAnUnpluggedDrive(HostPlatform platform) + { + var install = WorkingSetup(platform); + install.WithRetroRewind(); + install.FlushConfig(); + Unplug(install, install.UserFolder); + + var session = install.Boot(); + + Assert.False(session.Onboarding.IsRequired); + } + + [Theory] + [MemberData(nameof(Platforms))] + public void TheGameFileWasMoved_ButDolphinIsThere(HostPlatform platform) + { + var install = WorkingSetup(platform); + install.FlushConfig(); + Unplug(install, install.GameFile); + + var session = install.Boot(); + + Assert.False(session.Onboarding.IsRequired); + } + + [Theory] + [MemberData(nameof(Platforms))] + public void EverythingLivesOnADriveThatIsNotThereToday(HostPlatform platform) + { + var install = WorkingSetup(platform); + install.WithRetroRewind(); + install.FlushConfig(); + install.AvailableCommands.Clear(); + Unplug(install, install.UserFolder); + Unplug(install, install.GameFile); + if (platform == HostPlatform.Windows) + Unplug(install, install.DolphinLocation); + + var session = install.Boot(); + + Assert.False(session.Onboarding.IsRequired); + } + + [Fact] + public void ABrokenSetup_StaysNotOnboarded_OnEveryStart() + { + var install = WorkingSetup(HostPlatform.Windows); + install.FlushConfig(); + Unplug(install, install.UserFolder); + Unplug(install, install.GameFile); + + Assert.False(install.Boot().Onboarding.IsRequired); + Assert.False(install.Boot().Onboarding.IsRequired); + } + + [Fact] + public void AnUnpluggedUserFolder_IsNotForgottenInConfigJson_ByTheStartup() + { + var install = WorkingSetup(HostPlatform.Windows); + install.FlushConfig(); + Unplug(install, install.UserFolder); + + var session = install.Boot(); + + AssertConfigKeepsWhatTheUserHad(session); + } + #endregion + + #region WiiCompiled + [Fact] + public void AnInstalledWiiCompiled_WithItsGame_IsNotOnboarded() + { + var install = new ExistingInstall(HostPlatform.Windows).WithGame(@"C:\Games\Mario Kart Wii.iso").WithConfig("EnableRecomp", true); + install.RecompInstalled = true; + install.FlushConfig(); + + Assert.False(install.Boot().Onboarding.IsRequired); + } + + [Fact] + public void AnInstalledWiiCompiled_WhoseGameIsOnAnUnpluggedDrive_IsNotOnboarded() + { + var install = new ExistingInstall(HostPlatform.Windows).WithGame(@"D:\Games\Mario Kart Wii.iso").WithConfig("EnableRecomp", true); + install.RecompInstalled = true; + install.FlushConfig(); + Unplug(install, install.GameFile); + + Assert.False(install.Boot().Onboarding.IsRequired); + } + #endregion + + #region The settings file itself + [Fact] + public void ASettingsFileThatCannotBeRead_IsNotAFreshInstall() + { + var install = WorkingSetup(HostPlatform.Windows); + install.FlushConfig(); + const string broken = "{ \"DolphinLocation\": \"C:\\\\Dolphin\\\\Dolphin.exe\", "; + install.Fs.File.WriteAllText(install.ConfigFilePath, broken); + + var session = install.Boot(); + + Assert.False( + session.Onboarding.IsRequired, + "the user's paths are in the unreadable file, the onboarding would only fail to save over it" + ); + Assert.Equal(broken, install.Fs.File.ReadAllText(install.ConfigFilePath)); + } + #endregion + + [Fact] + public void TheOnboardingFlagSurvivesRestarts_EvenWhenEverythingIsLostLater() + { + var install = WorkingSetup(HostPlatform.Windows).FlushConfig(); + install.Boot(); + + Unplug(install, install.UserFolder); + Unplug(install, install.GameFile); + Unplug(install, install.DolphinLocation); + + Assert.False(install.Boot().Onboarding.IsRequired); + } +} diff --git a/WheelWizard.Test/Features/ExistingSetups/ExistingSetupSweepTests.cs b/WheelWizard.Test/Features/ExistingSetups/ExistingSetupSweepTests.cs new file mode 100644 index 000000000..7cb78cb30 --- /dev/null +++ b/WheelWizard.Test/Features/ExistingSetups/ExistingSetupSweepTests.cs @@ -0,0 +1,221 @@ +using WheelWizard.CustomDistributions; +using static WheelWizard.Test.Features.ExistingSetups.ExistingSetupAssertions; + +namespace WheelWizard.Test.Features.ExistingSetups; + +public enum LoadFolder +{ + /// Dolphin's own Load folder inside its user folder. + Default, + CustomAvailable, + CustomUnplugged, +} + +public enum RetroRewindState +{ + None, + Stable, + StableAndBeta, + OnlyTheXml, +} + +public sealed record SetupShape( + HostPlatform Platform, + bool UserFolderUnplugged, + LoadFolder Load, + RetroRewindState RetroRewind, + bool ManagedFolderAlreadyThere, + bool DolphinMissing, + bool GameMissing +) +{ + public override string ToString() => + $"{Platform}, userFolder {(UserFolderUnplugged ? "unplugged" : "there")}, load {Load}, RR {RetroRewind}, " + + $"{(ManagedFolderAlreadyThere ? "managed folder present" : "no managed folder")}, " + + $"dolphin {(DolphinMissing ? "missing" : "there")}, game {(GameMissing ? "missing" : "there")}"; +} + +/// +/// Every combination of the things that differ between players who set Wheel Wizard up before the update. +/// Whatever the combination, nobody is onboarded, nothing of theirs is lost or overwritten, and a drive that +/// is away on this start is simply picked up on the next one. +/// +public class ExistingSetupSweepTests +{ + public static TheoryData Shapes() + { + var data = new TheoryData(); + foreach (var platform in Enum.GetValues()) + foreach (var userFolderUnplugged in new[] { false, true }) + foreach (var load in Enum.GetValues()) + foreach (var retroRewind in Enum.GetValues()) + foreach (var managed in new[] { false, true }) + foreach (var dolphinMissing in new[] { false, true }) + foreach (var gameMissing in new[] { false, true }) + data.Add(new SetupShape(platform, userFolderUnplugged, load, retroRewind, managed, dolphinMissing, gameMissing)); + return data; + } + + private sealed record Built( + ExistingInstall Install, + Dictionary Files, + string LegacyRoot, + bool Reachable, + string CustomLoad + ); + + private static Built Build(SetupShape shape) + { + var install = shape.Platform switch + { + HostPlatform.Windows => new ExistingInstall(shape.Platform).WithDolphin( + @"C:\Dolphin\Dolphin.exe", + @"D:\Dolphin\Dolphin Emulator", + @"D:\Games\Mario Kart Wii.iso" + ), + HostPlatform.MacOS => new ExistingInstall(shape.Platform).WithDolphin( + "'/Applications/Dolphin.app/Contents/MacOS/Dolphin'", + "/Users/racer/Library/Application Support/Dolphin", + "/Users/racer/Games/mkw.iso" + ), + _ => new ExistingInstall(shape.Platform).WithDolphin( + "dolphin-emu", + "/home/racer/.local/share/dolphin-emu", + "/home/racer/Games/mkw.iso" + ), + }; + + var customLoad = shape.Platform switch + { + HostPlatform.Windows => @"D:\Mods\Dolphin Load", + HostPlatform.MacOS => "/Volumes/Mods/Load", + _ => "/mnt/mods/Load", + }; + if (shape.Load != LoadFolder.Default) + install.WithCustomLoadFolder(customLoad); + var legacy = shape.Load == LoadFolder.Default ? install.LegacyRoot : install.Combine(customLoad, "Riivolution", "WheelWizard"); + + var files = new Dictionary(); + switch (shape.RetroRewind) + { + case RetroRewindState.Stable: + files = install.WithRetroRewind(legacy); + break; + case RetroRewindState.StableAndBeta: + files = install.WithRetroRewind(legacy, withBeta: true); + break; + case RetroRewindState.OnlyTheXml: + var xml = install.Combine(legacy, "riivolution", "RetroRewind6.xml"); + install.Fs.Directory.CreateDirectory(install.Fs.Path.GetDirectoryName(xml)!); + install.Fs.File.WriteAllText(xml, ""); + files = new() { ["riivolution/RetroRewind6.xml"] = "" }; + break; + } + + if (shape.ManagedFolderAlreadyThere) + { + var version = install.Combine(install.ManagedRoot, "RetroRewind6", "version.txt"); + install.Fs.Directory.CreateDirectory(install.Fs.Path.GetDirectoryName(version)!); + install.Fs.File.WriteAllText(version, "6.0.0"); + } + + install.WithConfig("LaunchRrOnStartup", true).WithConfig("FavoriteUser", 2).WithConfig("SomethingFromTheFuture", "kept"); + install.FlushConfig(); + + // Whatever is unplugged takes what lives on it along, including the Retro Rewind install. + if (shape.UserFolderUnplugged) + install.Unplug(install.UserFolder); + if (shape.Load == LoadFolder.CustomUnplugged) + install.Unplug(customLoad); + if (shape.GameMissing) + install.Unplug(install.GameFile); + if (shape.DolphinMissing) + { + install.AvailableCommands.Clear(); + if (shape.Platform == HostPlatform.Windows) + install.Unplug(install.DolphinLocation); + } + + var reachable = !shape.UserFolderUnplugged && shape.Load != LoadFolder.CustomUnplugged; + return new Built(install, files, legacy, reachable, customLoad); + } + + [Theory] + [MemberData(nameof(Shapes))] + public void NoSetup_IsOnboarded_LosesFilesOrIsRewrittenBehindItsBack(SetupShape shape) + { + var (install, files, legacy, reachable, customLoad) = Build(shape); + var managedVersionBefore = shape.ManagedFolderAlreadyThere ? "6.0.0" : null; + + var session = install.Boot(); + + // The user is never asked to set anything up. + Assert.False(session.Onboarding.IsRequired); + AssertConfigKeepsWhatTheUserHad(session); + var config = install.ReadConfig(); + Assert.True(config["LaunchRrOnStartup"].GetBoolean()); + Assert.Equal(2, config["FavoriteUser"].GetInt32()); + Assert.Equal("kept", config["SomethingFromTheFuture"].GetString()); + Assert.Equal(install.DolphinLocation, config["DolphinLocation"].GetString()); + Assert.Equal(install.UserFolder, config["UserFolderPath"].GetString()); + Assert.Equal(install.GameFile, config["GameLocation"].GetString()); + Assert.False(session.ManagedDolphin.IsActive); + + var hasLegacyFiles = files.Count > 0; + if (!reachable) + { + // Nothing can be known about a folder that is away: no marker, nothing moved, nothing invented. + Assert.False(install.Fs.File.Exists(session.Paths.MigratedMarkerFilePath)); + Assert.False(install.Fs.Directory.Exists(install.ManagedRoot + ".migrating")); + if (managedVersionBefore is not null) + Assert.Equal( + managedVersionBefore, + install.Fs.File.ReadAllText(install.Combine(install.ManagedRoot, "RetroRewind6", "version.txt")) + ); + if (hasLegacyFiles && shape.Load == LoadFolder.CustomUnplugged) + { + var stashed = install.UnpluggedLocation(customLoad); + Assert.Equal(files, install.Snapshot(install.Combine(stashed, "Riivolution", "WheelWizard"))); + } + + // The drive is back on the next start. + install.Replug(); + if (shape.DolphinMissing && shape.Platform != HostPlatform.Windows) + install.AvailableCommands.Add(install.DolphinLocation); + session = install.Boot(); + Assert.False(session.Onboarding.IsRequired); + } + + // From here on the drives are all there, so the install has been found and brought along, whole. + if (hasLegacyFiles) + { + var current = install.Snapshot(session.Paths.RootFolderPath); + current.Remove(CustomDistributionPaths.MigratedMarkerFileName); + Assert.Equal(install.ManagedRoot, session.Paths.RootFolderPath); + Assert.Equal(files.OrderBy(pair => pair.Key), current.OrderBy(pair => pair.Key)); + Assert.False(install.Fs.Directory.Exists(legacy), "the old folder is gone once its content is safely elsewhere"); + if (managedVersionBefore is not null) + { + // The one that was already there is kept next to it, not deleted. + var backup = Assert.Single(install.Fs.Directory.GetDirectories(install.DataDir, "RetroRewind.old-*")); + Assert.Equal(managedVersionBefore, install.Fs.File.ReadAllText(install.Combine(backup, "RetroRewind6", "version.txt"))); + } + } + else + { + Assert.Equal(install.ManagedRoot, session.Paths.RootFolderPath); + if (managedVersionBefore is not null) + Assert.Equal( + managedVersionBefore, + install.Fs.File.ReadAllText(install.Combine(install.ManagedRoot, "RetroRewind6", "version.txt")) + ); + } + + // And it stays that way. + var settled = install.Snapshot(install.DataDir); + var again = install.Boot(); + Assert.Equal(settled.OrderBy(pair => pair.Key), install.Snapshot(install.DataDir).OrderBy(pair => pair.Key)); + Assert.Equal(install.ManagedRoot, again.Paths.RootFolderPath); + Assert.False(again.Onboarding.IsRequired); + } +} diff --git a/WheelWizard.Test/Features/ExistingSetups/RealisticFileSystem.cs b/WheelWizard.Test/Features/ExistingSetups/RealisticFileSystem.cs new file mode 100644 index 000000000..b97d2ad91 --- /dev/null +++ b/WheelWizard.Test/Features/ExistingSetups/RealisticFileSystem.cs @@ -0,0 +1,67 @@ +using System.IO.Abstractions; +using System.Reflection; + +namespace WheelWizard.Test.Features.ExistingSetups; + +/// +/// The in-memory file system throws for an empty path where the real one answers "does not exist"; the +/// settings validators ask about paths that were never set, so the app sees this wrapper instead. +/// +public class RealisticFileSystem : DispatchProxy +{ + private IFileSystem _inner = null!; + private object? _directory; + private object? _file; + + public static IFileSystem Wrap(IFileSystem inner) + { + var proxy = Create(); + var self = (RealisticFileSystem)(object)proxy; + self._inner = inner; + self._directory = Existence.Create(inner.Directory); + self._file = Existence.Create(inner.File); + return proxy; + } + + protected override object? Invoke(MethodInfo? targetMethod, object?[]? args) + { + if (targetMethod!.Name == "get_Directory") + return _directory; + if (targetMethod.Name == "get_File") + return _file; + return Forward(targetMethod, _inner, args); + } + + private static object? Forward(MethodInfo method, object target, object?[]? args) + { + try + { + return method.Invoke(target, args); + } + catch (TargetInvocationException exception) when (exception.InnerException is not null) + { + System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(exception.InnerException).Throw(); + throw; + } + } + + public class Existence : DispatchProxy + where T : class + { + private T _inner = null!; + + public static T Create(T inner) + { + var proxy = DispatchProxy.Create>(); + ((Existence)(object)proxy)._inner = inner; + return proxy; + } + + protected override object? Invoke(MethodInfo? targetMethod, object?[]? args) + { + if (targetMethod!.Name == "Exists" && args is { Length: 1 } && args[0] is null or "") + return false; + return Forward(targetMethod, _inner, args); + } + } +} diff --git a/WheelWizard.Test/Features/Settings/UnavailableSettingValueTests.cs b/WheelWizard.Test/Features/Settings/UnavailableSettingValueTests.cs new file mode 100644 index 000000000..1fb97481a --- /dev/null +++ b/WheelWizard.Test/Features/Settings/UnavailableSettingValueTests.cs @@ -0,0 +1,142 @@ +using Microsoft.Extensions.Logging.Abstractions; +using Testably.Abstractions.Testing; +using WheelWizard.Settings; +using WheelWizard.Settings.Types; + +namespace WheelWizard.Test.Features.Settings; + +/// +/// A path setting whose folder is not there at load (an unplugged drive) falls back to its default, but the +/// choice must not be forgotten: it still counts as configured, and saving something else must not erase it. +/// +public class UnavailableSettingValueTests +{ + private const string Config = "/config/config.json"; + + private static (WhWzSettingManager Manager, WhWzSetting Path, WhWzSetting Other, MockFileSystem Fs) Create( + string json, + Func pathExists + ) + { + var fs = new MockFileSystem(); + fs.Directory.CreateDirectory("/config"); + fs.File.WriteAllText(Config, json); + var manager = new WhWzSettingManager(NullLogger.Instance, fs); + var path = new WhWzSetting("Folder", "", setting => manager.SaveSettings(Config, setting)).SetValidation(pathExists); + path.SetRetainUnavailable(); + var other = new WhWzSetting("Other", false, setting => manager.SaveSettings(Config, setting)); + manager.RegisterSetting(path); + manager.RegisterSetting(other); + manager.LoadSettings(Config); + return (manager, path, other, fs); + } + + [Fact] + public void AFolderThatIsNotThere_IsNotTheValue_ButItIsRemembered() + { + var (_, path, _, _) = Create("{\"Folder\":\"/usb/Dolphin\"}", _ => false); + + Assert.Equal("", path.Get()); + Assert.False(path.IsValid()); + Assert.Equal("/usb/Dolphin", path.UnavailableValue); + Assert.True(path.HasUnavailableValue); + } + + [Fact] + public void SavingAnotherSetting_WhileTheFolderIsAway_WritesTheChoiceBack() + { + var (_, _, other, fs) = Create("{\"Folder\":\"/usb/Dolphin\",\"Unknown\":7}", _ => false); + + Assert.True(other.Set(true)); + + var saved = fs.File.ReadAllText(Config); + Assert.Contains("\"Folder\": \"/usb/Dolphin\"", saved); + Assert.Contains("\"Unknown\": 7", saved); + Assert.Contains("\"Other\": true", saved); + } + + [Fact] + public void PickingANewFolder_ReplacesTheRememberedOne() + { + var (_, path, _, fs) = Create("{\"Folder\":\"/usb/Dolphin\"}", value => value == "/home/Dolphin"); + + Assert.True(path.Set("/home/Dolphin")); + + Assert.False(path.HasUnavailableValue); + Assert.Contains("\"Folder\": \"/home/Dolphin\"", fs.File.ReadAllText(Config)); + } + + [Fact] + public void ResettingTheSetting_ForgetsTheRememberedFolder() + { + var (_, path, other, fs) = Create("{\"Folder\":\"/usb/Dolphin\"}", _ => false); + + path.Reset(); + other.Set(true); + + Assert.False(path.HasUnavailableValue); + Assert.Contains("\"Folder\": \"\"", fs.File.ReadAllText(Config)); + } + + [Fact] + public void AFolderThatIsThere_IsJustTheValue() + { + var (_, path, _, _) = Create("{\"Folder\":\"/home/Dolphin\"}", _ => true); + + Assert.Equal("/home/Dolphin", path.Get()); + Assert.False(path.HasUnavailableValue); + } + + [Fact] + public void OnlySettingsThatAskForIt_RememberRejectedValues() + { + var fs = new MockFileSystem(); + fs.Directory.CreateDirectory("/config"); + fs.File.WriteAllText(Config, "{\"Language\":\"xx\"}"); + var manager = new WhWzSettingManager(NullLogger.Instance, fs); + var language = new WhWzSetting("Language", "en", setting => manager.SaveSettings(Config, setting)).SetValidation(value => + value == "en" + ); + var other = new WhWzSetting("Other", false, setting => manager.SaveSettings(Config, setting)); + manager.RegisterSetting(language); + manager.RegisterSetting(other); + manager.LoadSettings(Config); + + Assert.Equal("en", language.Get()); + Assert.False(language.HasUnavailableValue); + other.Set(true); + Assert.DoesNotContain("xx", fs.File.ReadAllText(Config)); + } + + [Fact] + public void ASettingsFileThatCannotBeRead_IsReported_AndLeftAlone() + { + var (manager, path, other, fs) = Create("{ broken", _ => true); + + Assert.True(manager.HadLoadError); + Assert.False(other.Set(true)); + Assert.Equal("{ broken", fs.File.ReadAllText(Config)); + Assert.Equal("", path.Get()); + } + + [Fact] + public void ADolphinIniLoadPath_ThatIsNotThere_IsRememberedToo() + { + var fs = new MockFileSystem(); + fs.Directory.CreateDirectory("/dolphin/Config"); + fs.File.WriteAllText("/dolphin/Config/Dolphin.ini", "[General]\nLoadPath = /usb/Load\n"); + var manager = new DolphinSettingManager(fs); + var loadPath = new DolphinSetting(("Dolphin.ini", "General", "LoadPath"), "").SetValidation(value => + fs.Directory.Exists(value) + ); + loadPath.SetRetainUnavailable(); + manager.RegisterSetting(loadPath); + + manager.LoadSettings("/dolphin/Config"); + + Assert.Equal("", loadPath.Get()); + Assert.Equal("/usb/Load", loadPath.UnavailableValue); + // Dolphin's own file is never rewritten by loading. + Assert.Equal("[General]\nLoadPath = /usb/Load\n", fs.File.ReadAllText("/dolphin/Config/Dolphin.ini")); + } +} diff --git a/WheelWizard/Features/CustomDistributions/CustomDistributionPaths.cs b/WheelWizard/Features/CustomDistributions/CustomDistributionPaths.cs index bc7718799..7f44f866a 100644 --- a/WheelWizard/Features/CustomDistributions/CustomDistributionPaths.cs +++ b/WheelWizard/Features/CustomDistributions/CustomDistributionPaths.cs @@ -91,8 +91,11 @@ public bool CanRuleOutLegacyRoot { get { + // Nothing usable is set. That only rules a legacy install out when nothing is configured at all: a + // Dolphin or Load folder that was chosen but is not reachable right now (an unplugged drive) may hold it. if (LegacyRootFolderPath is null) - return true; + return string.IsNullOrWhiteSpace(dolphin.ConfiguredUserFolderPath) + && string.IsNullOrWhiteSpace(dolphin.ConfiguredLoadFolderPath); // A custom Load folder that is configured but missing falls back to the user folder's Load folder, // which would wrongly look empty. if (!dolphin.HasCustomLoadFolder && !string.IsNullOrWhiteSpace(dolphin.ConfiguredLoadFolderPath)) diff --git a/WheelWizard/Features/Dolphin/Paths/DolphinPaths.cs b/WheelWizard/Features/Dolphin/Paths/DolphinPaths.cs index 5d2e82a35..b60fc79b2 100644 --- a/WheelWizard/Features/Dolphin/Paths/DolphinPaths.cs +++ b/WheelWizard/Features/Dolphin/Paths/DolphinPaths.cs @@ -14,6 +14,9 @@ public interface IDolphinPaths /// Dolphin's Load folder setting as written, even when that folder is not there right now (an unplugged drive). string ConfiguredLoadFolderPath { get; } + /// The user folder setting as written, even when that folder is not there right now (an unplugged drive). + string ConfiguredUserFolderPath { get; } + string LoadFolderPath { get; } string WiiFolderPath { get; } } @@ -25,7 +28,11 @@ public sealed class DolphinPaths(ISettingsManager settings, IDolphinPathResolver public string UserFolderPath => settings.Get(settings.USER_FOLDER_PATH); public string ConfigFolderPath => Layout.ConfigFolderPath; public bool HasCustomLoadFolder => settings.LOAD_PATH.IsValid(); - public string ConfiguredLoadFolderPath => settings.Get(settings.LOAD_PATH); + public string ConfiguredLoadFolderPath => + settings.Get(settings.LOAD_PATH) is { Length: > 0 } value ? value : settings.LOAD_PATH.UnavailableValue ?? ""; + + public string ConfiguredUserFolderPath => + UserFolderPath is { Length: > 0 } value ? value : settings.USER_FOLDER_PATH.UnavailableValue ?? ""; public string LoadFolderPath => HasCustomLoadFolder ? settings.Get(settings.LOAD_PATH) : fileSystem.Path.Combine(UserFolderPath, "Load"); public string WiiFolderPath => diff --git a/WheelWizard/Features/Onboarding/OnboardingService.cs b/WheelWizard/Features/Onboarding/OnboardingService.cs index 3bdd10b32..84fa111c4 100644 --- a/WheelWizard/Features/Onboarding/OnboardingService.cs +++ b/WheelWizard/Features/Onboarding/OnboardingService.cs @@ -105,10 +105,14 @@ ILogger logger private IRecompInstallService? RecompInstall => services.GetService(); private IRecompDolphinDataService? RecompData => services.GetService(); - public bool IsRequired => !settings.Get(settings.ONBOARDING_COMPLETED) && !WasSetUpBefore() && !IsSetupUsable(); + // A settings file that could not be read says nothing about the user, and nothing could be saved over it anyway. + public bool IsRequired => + !settings.SettingsFileUnreadable && !settings.Get(settings.ONBOARDING_COMPLETED) && !WasSetUpBefore() && !IsSetupUsable(); public void MarkCompletedIfAlreadySetUp() { + if (settings.SettingsFileUnreadable) + return; if (!settings.Get(settings.ONBOARDING_COMPLETED) && (WasSetUpBefore() || IsSetupUsable())) settings.Set(settings.ONBOARDING_COMPLETED, true); } @@ -119,9 +123,11 @@ public void MarkCompletedIfAlreadySetUp() /// private bool WasSetUpBefore() => IsSet(settings.DOLPHIN_LOCATION) || IsSet(settings.USER_FOLDER_PATH) || IsSet(settings.GAME_LOCATION); - // The Flatpak defaults the Dolphin location to its bundled wrapper, which nobody picked. + // The Flatpak defaults the Dolphin location to its bundled wrapper, which nobody picked. A path that was + // picked but is not there today (a drive that is not plugged in) is not loaded as a value, yet it counts. private bool IsSet(Setting setting) => - settings.Get(setting) is { } value && !string.IsNullOrWhiteSpace(value) && value != setting.DefaultValue; + (settings.Get(setting) is { } value && !string.IsNullOrWhiteSpace(value) && value != setting.DefaultValue) + || setting.HasUnavailableValue; private bool IsSetupUsable() { diff --git a/WheelWizard/Features/Recomp/RecompDolphinDataService.cs b/WheelWizard/Features/Recomp/RecompDolphinDataService.cs index 164a088b8..b425d635b 100644 --- a/WheelWizard/Features/Recomp/RecompDolphinDataService.cs +++ b/WheelWizard/Features/Recomp/RecompDolphinDataService.cs @@ -168,6 +168,11 @@ public OperationResult ApplyPathsToRecompConfig() private OperationResult ApplyNandRoot(string? nandFolderPath) { + // Sharing Dolphin's NAND while Dolphin's folder is out of reach (an unplugged drive): this runs before every + // launch now, and dropping the key would start the runtime on a new empty NAND instead of the shared one. + if (nandFolderPath is null && IsSharingEnabled) + return Ok(); + if (nandFolderPath is null) { recompSettings.RemoveTomlSetting(paths.ConfigFilePath, "paths", "nand_root"); diff --git a/WheelWizard/Features/Settings/DolphinSettingManager.cs b/WheelWizard/Features/Settings/DolphinSettingManager.cs index c8d7436cb..4f7f7487b 100644 --- a/WheelWizard/Features/Settings/DolphinSettingManager.cs +++ b/WheelWizard/Features/Settings/DolphinSettingManager.cs @@ -85,7 +85,11 @@ public void LoadSettings(string configDirectory) { var value = ReadValue(lines, setting.Section, setting.Name); if (value == null || !setting.SetFromString(value, skipSave: true)) + { setting.Reset(skipSave: true); + if (value != null) + setting.RememberUnavailable(value); + } } } _loaded = true; diff --git a/WheelWizard/Features/Settings/ISettingsServices.cs b/WheelWizard/Features/Settings/ISettingsServices.cs index 07cfe35cd..7daf11d17 100644 --- a/WheelWizard/Features/Settings/ISettingsServices.cs +++ b/WheelWizard/Features/Settings/ISettingsServices.cs @@ -8,6 +8,9 @@ public interface IWhWzSettingManager void RegisterSetting(IWhWzSetting setting); void SaveSettings(string configPath, IWhWzSetting invokingSetting); void LoadSettings(string configPath); + + /// The settings file exists but could not be read, so what the user had in it is unknown (and it is left untouched). + bool HadLoadError { get; } } public interface IDolphinSettingManager @@ -88,6 +91,9 @@ public interface ISettingsManager : ISettingsProperties /// bool IsRecompModeActive(); + /// The settings file exists but could not be read; every setting is at its default and says nothing about the user. + bool SettingsFileUnreadable { get; } + void LoadSettings(); } diff --git a/WheelWizard/Features/Settings/SettingsManager.cs b/WheelWizard/Features/Settings/SettingsManager.cs index e3bd847fb..c6531a496 100644 --- a/WheelWizard/Features/Settings/SettingsManager.cs +++ b/WheelWizard/Features/Settings/SettingsManager.cs @@ -89,7 +89,8 @@ IUnixCommandService commands } return _fileSystem.File.Exists(pathOrCommand); - } + }, + retainUnavailable: true ); USER_FOLDER_PATH = RegisterWhWz( @@ -204,10 +205,11 @@ IUnixCommandService commands } return true; - } + }, + retainUnavailable: true ); - GAME_LOCATION = RegisterWhWz("GameLocation", "", value => _fileSystem.File.Exists(value)); + GAME_LOCATION = RegisterWhWz("GameLocation", "", value => _fileSystem.File.Exists(value), retainUnavailable: true); // Wheel Wizard installed this Dolphin into its own data folder (or, on native Linux, as a user Flatpak). // While set, the Dolphin location and user folder are derived from the data folder on every start. DOLPHIN_MANAGED = RegisterWhWz("DolphinManaged", false); @@ -230,7 +232,12 @@ IUnixCommandService commands #region Dolphin settings NAND_ROOT_PATH = RegisterDolphin(("Dolphin.ini", "General", "NANDRootPath"), "", value => _fileSystem.Directory.Exists(value)); - LOAD_PATH = RegisterDolphin(("Dolphin.ini", "General", "LoadPath"), "", value => _fileSystem.Directory.Exists(value)); + LOAD_PATH = RegisterDolphin( + ("Dolphin.ini", "General", "LoadPath"), + "", + value => _fileSystem.Directory.Exists(value), + retainUnavailable: true + ); VSYNC = RegisterDolphin(("GFX.ini", "Hardware", "VSync"), false); INTERNAL_RESOLUTION = RegisterDolphin(("GFX.ini", "Settings", "InternalResolution"), 1, value => value >= 0); @@ -392,6 +399,8 @@ public OperationResult ValidateCorePathSettings() private OperationResult ValidateDolphinPathSettings() => ValidatePathSettings(requireDolphin: true); + public bool SettingsFileUnreadable => _whWzSettingManager.HadLoadError; + public bool IsRecompModeActive() => RecompPlatform.IsSupported && Get(ENABLE_RECOMP); private OperationResult ValidatePathSettings(bool requireDolphin) @@ -429,16 +438,19 @@ public void LoadSettings() return; _whWzSettingManager.LoadSettings(_fileSystem.Path.Combine(_applicationData.DirectoryPath, "config.json")); - _dolphinSettingManager.LoadSettings( - _dolphinPaths.Resolve(Get(DOLPHIN_LOCATION), Get(USER_FOLDER_PATH)).ConfigFolderPath - ); + // Where Dolphin keeps its config can depend on which Dolphin it is (Linux splits it from the data folder). A + // Dolphin that is not there today still says where its Dolphin.ini is, and the Load folder is written in it. + var dolphinLocation = Get(DOLPHIN_LOCATION); + if (string.IsNullOrWhiteSpace(dolphinLocation)) + dolphinLocation = DOLPHIN_LOCATION.UnavailableValue ?? ""; + _dolphinSettingManager.LoadSettings(_dolphinPaths.Resolve(dolphinLocation, Get(USER_FOLDER_PATH)).ConfigFolderPath); _recompSettingManager.LoadSettings(_recompPaths.ConfigFilePath); _hasLoadedSettings = true; } #endregion #region Registration Helpers - private WhWzSetting RegisterWhWz(string name, T defaultValue, Func? validation = null) + private WhWzSetting RegisterWhWz(string name, T defaultValue, Func? validation = null, bool retainUnavailable = false) { var setting = new WhWzSetting( name, @@ -447,13 +459,20 @@ private WhWzSetting RegisterWhWz(string name, T defaultValue, Func RegisterDolphin((string, string, string) location, T defaultValue, Func? validation = null) + private DolphinSetting RegisterDolphin( + (string, string, string) location, + T defaultValue, + Func? validation = null, + bool retainUnavailable = false + ) { var setting = new DolphinSetting( location, @@ -466,6 +485,8 @@ private DolphinSetting RegisterDolphin((string, string, string) location, ); if (validation != null) setting.SetValidation(validation); + if (retainUnavailable) + setting.SetRetainUnavailable(); _dolphinSettingManager.RegisterSetting(setting); _ownedSettings.Add(setting); diff --git a/WheelWizard/Features/Settings/Types/DolphinSetting.cs b/WheelWizard/Features/Settings/Types/DolphinSetting.cs index c6abcda45..dfeff810b 100644 --- a/WheelWizard/Features/Settings/Types/DolphinSetting.cs +++ b/WheelWizard/Features/Settings/Types/DolphinSetting.cs @@ -9,6 +9,10 @@ public interface IDolphinSetting string Section { get; } string GetStringValue(); bool SetFromString(string value, bool skipSave = false); + + /// Remembers a stored value the setting could not take (see ). + void RememberUnavailable(string value); + void Reset(bool skipSave = false); } @@ -56,6 +60,12 @@ public bool SetFromString(string value, bool skipSave = false) } } + public void RememberUnavailable(string value) + { + if (value is T text) + KeepUnavailable(text); + } + public new DolphinSetting SetValidation(Func validation) { base.SetValidation(validation); diff --git a/WheelWizard/Features/Settings/Types/Setting.cs b/WheelWizard/Features/Settings/Types/Setting.cs index 281e3a40a..6b26b0a3b 100644 --- a/WheelWizard/Features/Settings/Types/Setting.cs +++ b/WheelWizard/Features/Settings/Types/Setting.cs @@ -35,6 +35,16 @@ public abstract class Setting(string name, T defaultValue) : Setting(name) public T Value { get; protected set; } = defaultValue; protected bool SaveEvenIfNotValid { get; private set; } private Func? _validation; + private bool _retainsUnavailable; + + /// + /// What was stored for this setting when it was loaded but could not be used then, e.g. a folder on a drive that + /// is not connected. It is never the setting's value. It tells that something was configured, and it is what + /// gets written back until a new value is set, so an absent drive does not erase the choice. + /// + public T? UnavailableValue { get; private set; } + + public bool HasUnavailableValue => UnavailableValue is { } value && !EqualityComparer.Default.Equals(value, DefaultValue); public T Get() => Value; @@ -42,6 +52,7 @@ public bool Set(T newValue, bool skipSave = false) { ArgumentNullException.ThrowIfNull(newValue); SaveError = null; + UnavailableValue = default; if (EqualityComparer.Default.Equals(Value, newValue)) return true; if (!SaveEvenIfNotValid && _validation?.Invoke(newValue) == false) @@ -94,4 +105,18 @@ public Setting SetForceSave(bool enabled) SaveEvenIfNotValid = enabled; return this; } + + /// Opts in to ; meant for settings that point at something outside the app. + public Setting SetRetainUnavailable() + { + _retainsUnavailable = true; + return this; + } + + /// For the loaders, right after a stored value was rejected and the default put in its place. + public void KeepUnavailable(T value) + { + if (_retainsUnavailable) + UnavailableValue = value; + } } diff --git a/WheelWizard/Features/Settings/Types/WhWzSetting.cs b/WheelWizard/Features/Settings/Types/WhWzSetting.cs index 4186f6448..8ee91bde9 100644 --- a/WheelWizard/Features/Settings/Types/WhWzSetting.cs +++ b/WheelWizard/Features/Settings/Types/WhWzSetting.cs @@ -7,6 +7,9 @@ public interface IWhWzSetting string Name { get; } object? GetValue(); bool SetFromJson(JsonElement value, bool skipSave = false); + + /// Remembers a stored value the setting could not take (see ). + void RememberUnavailable(JsonElement value); void Reset(bool skipSave = false); } @@ -20,7 +23,19 @@ protected override void ApplyValue(bool skipSave) saveAction?.Invoke(this); } - object? IWhWzSetting.GetValue() => Value; + // Until a value is picked again, a choice that could not be used at load (an unplugged drive) is written back as it was. + object? IWhWzSetting.GetValue() => + HasUnavailableValue && EqualityComparer.Default.Equals(Value, DefaultValue) ? UnavailableValue : Value; + + void IWhWzSetting.RememberUnavailable(JsonElement value) + { + try + { + if (value.Deserialize() is { } parsed) + KeepUnavailable(parsed); + } + catch (JsonException) { } + } public bool SetFromJson(JsonElement value, bool skipSave = false) { diff --git a/WheelWizard/Features/Settings/WhWzSettingManager.cs b/WheelWizard/Features/Settings/WhWzSettingManager.cs index 8013ba2b2..df22ae828 100644 --- a/WheelWizard/Features/Settings/WhWzSettingManager.cs +++ b/WheelWizard/Features/Settings/WhWzSettingManager.cs @@ -10,6 +10,8 @@ public class WhWzSettingManager(ILogger logger, IFileSystem private readonly object _sync = new(); private bool _loaded; private Exception? _loadError; + + public bool HadLoadError => _loadError is not null; private readonly Dictionary _settings = new(); private readonly Dictionary _unknownSettings = new(); @@ -74,7 +76,10 @@ public void LoadSettings(string configPath) try { if (!setting.SetFromJson(value, skipSave: true)) + { setting.Reset(skipSave: true); + setting.RememberUnavailable(value); + } } catch (Exception exception) { From 71b94dd49ec55436471b2f4bbb6408be99806a05 Mon Sep 17 00:00:00 2001 From: patchzyy <64382339+patchzyy@users.noreply.github.com> Date: Fri, 16 Oct 2026 01:53:55 +0200 Subject: [PATCH 04/10] adres comments --- .../Features/ManagedDolphinServiceTests.cs | 1 + .../Onboarding/OnboardingServiceTests.cs | 30 ++++++++++++++++ .../Settings/UnavailableSettingValueTests.cs | 36 +++++++++++++++++++ .../Features/Onboarding/OnboardingService.cs | 21 +++++++++-- .../Onboarding/Views/OnboardingPage.axaml.cs | 1 + .../Features/Settings/ISettingsServices.cs | 1 + .../Features/Settings/SettingsManager.cs | 3 ++ .../Features/Settings/Types/Setting.cs | 22 +++++++++--- 8 files changed, 108 insertions(+), 7 deletions(-) diff --git a/WheelWizard.Test/Features/ManagedDolphinServiceTests.cs b/WheelWizard.Test/Features/ManagedDolphinServiceTests.cs index ab06d21c4..148167c2f 100644 --- a/WheelWizard.Test/Features/ManagedDolphinServiceTests.cs +++ b/WheelWizard.Test/Features/ManagedDolphinServiceTests.cs @@ -73,6 +73,7 @@ public Fixture() Settings.USER_FOLDER_PATH.Returns(UserFolder); Settings.DOLPHIN_MANAGED.Returns(Managed_); Settings.ONBOARDING_COMPLETED.Returns(new WhWzSetting("OnboardingCompleted", false)); + Settings.ONBOARDING_STARTED.Returns(new WhWzSetting("OnboardingStarted", false)); Settings.Get(Arg.Any>()).Returns(call => call.Arg>().Get()); Settings.Get(Arg.Any>()).Returns(call => call.Arg>().Get()); Settings diff --git a/WheelWizard.Test/Features/Onboarding/OnboardingServiceTests.cs b/WheelWizard.Test/Features/Onboarding/OnboardingServiceTests.cs index b4c3817e9..9d6dd98f9 100644 --- a/WheelWizard.Test/Features/Onboarding/OnboardingServiceTests.cs +++ b/WheelWizard.Test/Features/Onboarding/OnboardingServiceTests.cs @@ -67,6 +67,34 @@ public void AnEarlierSetupThatIsBrokenToday_GetsRepairedInsteadOfOnboarded() Assert.True(fixture.Completed.Get()); } + [Fact] + public void PathsSavedByAnUnfinishedOnboarding_DoNotCountAsAnEarlierSetup() + { + // The app was closed after picking a Dolphin and a game, before the onboarding finished. + var fixture = new Fixture(); + fixture.Service.MarkStarted(); + fixture.Location.Set("/own/Dolphin.exe", skipSave: true); + fixture.UserFolder.Set("/own/User", skipSave: true); + fixture.Settings.DolphinPathsSetupCorrectly().Returns(true); + + fixture.Service.MarkCompletedIfAlreadySetUp(); + + Assert.False(fixture.Completed.Get()); + Assert.True(fixture.Service.IsRequired); + } + + [Fact] + public void StartingTheOnboarding_AfterItWasCompleted_ChangesNothing() + { + var fixture = new Fixture(); + fixture.Completed.Set(true, skipSave: true); + + fixture.Service.MarkStarted(); + + Assert.False(fixture.Started.Get()); + Assert.False(fixture.Service.IsRequired); + } + [Fact] public void TheFlatpaksDefaultDolphinLocation_DoesNotCountAsSetUp() { @@ -120,6 +148,7 @@ private sealed class Fixture public ISettingsManager Settings { get; } = Substitute.For(); public IServiceProvider Services { get; } = Substitute.For(); public WhWzSetting Completed { get; } = new("OnboardingCompleted", false); + public WhWzSetting Started { get; } = new("OnboardingStarted", false); public WhWzSetting Game { get; } public WhWzSetting Location { get; } public WhWzSetting UserFolder { get; } = new("UserFolderPath", ""); @@ -133,6 +162,7 @@ public Fixture(string defaultLocation = "") Game = new WhWzSetting("GameLocation", "").SetValidation(value => Fs.File.Exists(value)); Location = new WhWzSetting("DolphinLocation", defaultLocation).SetValidation(value => value.Length > 0); Settings.ONBOARDING_COMPLETED.Returns(Completed); + Settings.ONBOARDING_STARTED.Returns(Started); Settings.GAME_LOCATION.Returns(Game); Settings.DOLPHIN_LOCATION.Returns(Location); Settings.USER_FOLDER_PATH.Returns(UserFolder); diff --git a/WheelWizard.Test/Features/Settings/UnavailableSettingValueTests.cs b/WheelWizard.Test/Features/Settings/UnavailableSettingValueTests.cs index 1fb97481a..3c193b5eb 100644 --- a/WheelWizard.Test/Features/Settings/UnavailableSettingValueTests.cs +++ b/WheelWizard.Test/Features/Settings/UnavailableSettingValueTests.cs @@ -78,6 +78,42 @@ public void ResettingTheSetting_ForgetsTheRememberedFolder() Assert.Contains("\"Folder\": \"\"", fs.File.ReadAllText(Config)); } + [Fact] + public void ARejectedFolder_DoesNotEraseTheRememberedOne() + { + var (_, path, other, fs) = Create("{\"Folder\":\"/usb/Dolphin\"}", value => value == "/home/Dolphin"); + + Assert.False(path.Set("/elsewhere/Dolphin")); + other.Set(true); + + Assert.True(path.HasUnavailableValue); + Assert.Contains("\"Folder\": \"/usb/Dolphin\"", fs.File.ReadAllText(Config)); + } + + [Fact] + public void ValueSettingsWithANonDefaultDefault_AreWrittenAsTheyAre() + { + // default(T) is not "nothing remembered": a bool that defaults to true must not be saved as false. + var fs = new MockFileSystem(); + fs.Directory.CreateDirectory("/config"); + fs.File.WriteAllText(Config, "{}"); + var manager = new WhWzSettingManager(NullLogger.Instance, fs); + var animations = new WhWzSetting("EnableAnimations", true, setting => manager.SaveSettings(Config, setting)); + var scale = new WhWzSetting("WindowScale", 1.0, setting => manager.SaveSettings(Config, setting)); + var other = new WhWzSetting("Other", false, setting => manager.SaveSettings(Config, setting)); + manager.RegisterSetting(animations); + manager.RegisterSetting(scale); + manager.RegisterSetting(other); + manager.LoadSettings(Config); + + other.Set(true); + + Assert.False(animations.HasUnavailableValue); + var saved = fs.File.ReadAllText(Config); + Assert.Contains("\"EnableAnimations\": true", saved); + Assert.Contains("\"WindowScale\": 1", saved); + } + [Fact] public void AFolderThatIsThere_IsJustTheValue() { diff --git a/WheelWizard/Features/Onboarding/OnboardingService.cs b/WheelWizard/Features/Onboarding/OnboardingService.cs index 84fa111c4..71108ad58 100644 --- a/WheelWizard/Features/Onboarding/OnboardingService.cs +++ b/WheelWizard/Features/Onboarding/OnboardingService.cs @@ -47,6 +47,12 @@ public interface IOnboardingService /// void MarkCompletedIfAlreadySetUp(); + /// + /// Records that the onboarding is under way, so the paths it saves before it finishes are not taken for an + /// earlier setup when the app is closed halfway; the onboarding then comes back on the next start. + /// + void MarkStarted(); + bool IsWiiCompiledAvailable { get; } ManagedDolphinKind ManagedDolphinKind { get; } @@ -107,16 +113,27 @@ ILogger logger // A settings file that could not be read says nothing about the user, and nothing could be saved over it anyway. public bool IsRequired => - !settings.SettingsFileUnreadable && !settings.Get(settings.ONBOARDING_COMPLETED) && !WasSetUpBefore() && !IsSetupUsable(); + !settings.SettingsFileUnreadable + && !settings.Get(settings.ONBOARDING_COMPLETED) + && (IsUnfinished || !WasSetUpBefore() && !IsSetupUsable()); public void MarkCompletedIfAlreadySetUp() { - if (settings.SettingsFileUnreadable) + if (settings.SettingsFileUnreadable || IsUnfinished) return; if (!settings.Get(settings.ONBOARDING_COMPLETED) && (WasSetUpBefore() || IsSetupUsable())) settings.Set(settings.ONBOARDING_COMPLETED, true); } + public void MarkStarted() + { + if (!settings.Get(settings.ONBOARDING_COMPLETED)) + settings.Set(settings.ONBOARDING_STARTED, true); + } + + // The onboarding only ever starts when nothing was set up, so whatever is set now is what it set itself. + private bool IsUnfinished => settings.Get(settings.ONBOARDING_STARTED) && !settings.Get(settings.ONBOARDING_COMPLETED); + /// /// Someone picked paths before, possibly before the onboarding existed. A path that does not resolve today /// (an unplugged drive, a moved Dolphin) needs repairing, not a first-run flow that would replace it. diff --git a/WheelWizard/Features/Onboarding/Views/OnboardingPage.axaml.cs b/WheelWizard/Features/Onboarding/Views/OnboardingPage.axaml.cs index f98fe28af..720a8f9a1 100644 --- a/WheelWizard/Features/Onboarding/Views/OnboardingPage.axaml.cs +++ b/WheelWizard/Features/Onboarding/Views/OnboardingPage.axaml.cs @@ -103,6 +103,7 @@ ILogger logger ExtensionCommandText.Text = "flatpak install flathub io.github.TeamWheelWizard.WheelWizard.Backend.DolphinEmu"; _navigation.IsLocked = true; + _onboarding.MarkStarted(); if (_onboarding.IsWiiCompiledAvailable) Show(Step.Choice); else diff --git a/WheelWizard/Features/Settings/ISettingsServices.cs b/WheelWizard/Features/Settings/ISettingsServices.cs index 7daf11d17..16c56c0eb 100644 --- a/WheelWizard/Features/Settings/ISettingsServices.cs +++ b/WheelWizard/Features/Settings/ISettingsServices.cs @@ -42,6 +42,7 @@ public interface ISettingsProperties Setting GAME_LOCATION { get; } Setting DOLPHIN_MANAGED { get; } Setting ONBOARDING_COMPLETED { get; } + Setting ONBOARDING_STARTED { get; } Setting FORCE_WIIMOTE { get; } Setting LAUNCH_WITH_DOLPHIN { get; } Setting LAUNCH_RR_ON_STARTUP { get; } diff --git a/WheelWizard/Features/Settings/SettingsManager.cs b/WheelWizard/Features/Settings/SettingsManager.cs index c6531a496..1f4eb9342 100644 --- a/WheelWizard/Features/Settings/SettingsManager.cs +++ b/WheelWizard/Features/Settings/SettingsManager.cs @@ -214,6 +214,8 @@ IUnixCommandService commands // While set, the Dolphin location and user folder are derived from the data folder on every start. DOLPHIN_MANAGED = RegisterWhWz("DolphinManaged", false); ONBOARDING_COMPLETED = RegisterWhWz("OnboardingCompleted", false); + // The onboarding saves paths as it goes; while it is unfinished those are its own, not an earlier setup. + ONBOARDING_STARTED = RegisterWhWz("OnboardingStarted", false); FORCE_WIIMOTE = RegisterWhWz("ForceWiimote", false); LAUNCH_WITH_DOLPHIN = RegisterWhWz("LaunchWithDolphin", false); LAUNCH_RR_ON_STARTUP = RegisterWhWz("LaunchRrOnStartup", false); @@ -328,6 +330,7 @@ private static void SaveRecommended(Setting setting, T value) public Setting GAME_LOCATION { get; } public Setting DOLPHIN_MANAGED { get; } public Setting ONBOARDING_COMPLETED { get; } + public Setting ONBOARDING_STARTED { get; } public Setting FORCE_WIIMOTE { get; } public Setting LAUNCH_WITH_DOLPHIN { get; } public Setting LAUNCH_RR_ON_STARTUP { get; } diff --git a/WheelWizard/Features/Settings/Types/Setting.cs b/WheelWizard/Features/Settings/Types/Setting.cs index 6b26b0a3b..52d57619e 100644 --- a/WheelWizard/Features/Settings/Types/Setting.cs +++ b/WheelWizard/Features/Settings/Types/Setting.cs @@ -42,9 +42,13 @@ public abstract class Setting(string name, T defaultValue) : Setting(name) /// is not connected. It is never the setting's value. It tells that something was configured, and it is what /// gets written back until a new value is set, so an absent drive does not erase the choice. /// - public T? UnavailableValue { get; private set; } + public T? UnavailableValue => _hasUnavailable ? _unavailable : default; - public bool HasUnavailableValue => UnavailableValue is { } value && !EqualityComparer.Default.Equals(value, DefaultValue); + // Tracked separately: for a value type, T? is plain T, so default(T) cannot stand for "nothing remembered". + public bool HasUnavailableValue => _hasUnavailable && !EqualityComparer.Default.Equals(_unavailable, DefaultValue); + + private T _unavailable = defaultValue; + private bool _hasUnavailable; public T Get() => Value; @@ -52,14 +56,19 @@ public bool Set(T newValue, bool skipSave = false) { ArgumentNullException.ThrowIfNull(newValue); SaveError = null; - UnavailableValue = default; if (EqualityComparer.Default.Equals(Value, newValue)) + { + _hasUnavailable = false; return true; + } if (!SaveEvenIfNotValid && _validation?.Invoke(newValue) == false) return false; + // Only a value that is taken replaces the remembered one; a rejected or unsaved one leaves it as it was. var previous = Value; + var hadUnavailable = _hasUnavailable; Value = newValue; + _hasUnavailable = false; try { ApplyValue(skipSave); @@ -67,6 +76,7 @@ public bool Set(T newValue, bool skipSave = false) catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) { RestoreValueAfterSaveFailure(previous); + _hasUnavailable = hadUnavailable; SaveError = exception; return false; } @@ -116,7 +126,9 @@ public Setting SetRetainUnavailable() /// For the loaders, right after a stored value was rejected and the default put in its place. public void KeepUnavailable(T value) { - if (_retainsUnavailable) - UnavailableValue = value; + if (!_retainsUnavailable) + return; + _unavailable = value; + _hasUnavailable = true; } } From 935d6aed5e79bae8005ef7b97a20afc16f36d56c Mon Sep 17 00:00:00 2001 From: patchzyy <64382339+patchzyy@users.noreply.github.com> Date: Fri, 16 Oct 2026 16:40:15 +0200 Subject: [PATCH 05/10] Game file folder extraction flow --- .github/workflows/ci-cd.yml | 2 +- .../ExistingSetups/ExistingInstall.cs | 35 +- .../ExistingSetupGameFilesTests.cs | 159 ++++ .../Features/GameFiles/DiscExtractorTests.cs | 320 +++++++ .../Features/GameFiles/GameFilesHarness.cs | 220 +++++ .../Features/GameFiles/GameFilesLinkTests.cs | 179 ++++ .../GameFiles/GameFilesServiceTests.cs | 676 ++++++++++++++ .../Features/GameFiles/SyntheticWiiDisc.cs | 264 ++++++ .../Launching/DolphinLaunchServiceTests.cs | 52 +- .../Features/Launching/LaunchPathTests.cs | 19 +- .../RetroRewindLaunchServiceTests.cs | 33 +- .../Features/Recomp/RecompGameDirTests.cs | 279 ++++++ .../Features/Recomp/RecompLinuxTests.cs | 11 +- .../Features/Recomp/RecompTests.cs | 8 +- .../Features/SaveRegionServiceTests.cs | 19 +- .../ViewModels/HomeDolphinLaunchTests.cs | 9 +- WheelWizard.UI.Test/GameSetupShellTests.cs | 88 ++ .../StartupMaintenance.cs | 5 + .../Features/GameFiles/DiscExtractor.cs | 438 +++++++++ .../GameFiles/Domain/GameFilesManifest.cs | 28 + .../Features/GameFiles/GameFilesExtensions.cs | 15 + .../Features/GameFiles/GameFilesPaths.cs | 76 ++ .../Features/GameFiles/GameFilesService.cs | 858 ++++++++++++++++++ .../Features/GameFiles/Views/GameFilesFlow.cs | 265 ++++++ .../GameFiles/Views/GameSetupPage.axaml | 66 ++ .../GameFiles/Views/GameSetupPage.axaml.cs | 282 ++++++ .../Launching/DolphinLaunchService.cs | 12 +- .../Launching/RetroRewindLaunchDescriptor.cs | 7 +- .../Launching/RetroRewindLaunchService.cs | 8 +- .../Onboarding/Views/OnboardingPage.axaml | 4 + .../Onboarding/Views/OnboardingPage.axaml.cs | 68 +- .../Recomp/Domain/RecompInstallModels.cs | 13 +- .../Recomp/Domain/RecompSetupEvent.cs | 12 + .../Features/Recomp/RecompEnvironment.cs | 23 +- .../Features/Recomp/RecompInstallService.cs | 107 ++- .../Recomp/RecompLinuxInstallService.cs | 116 ++- .../Recomp/RecompLinuxSetupCommandBuilder.cs | 20 +- .../Recomp/RecompSetupCommandBuilder.cs | 23 +- .../Recomp/RecompSetupHostAcquirer.cs | 22 + .../Recomp/RecompSetupOutputParser.cs | 16 + .../Features/Settings/SettingsManager.cs | 13 +- .../Settings/Views/WhWzSettings.axaml.cs | 63 +- .../GameLicense/SaveRegionService.cs | 12 +- WheelWizard/Resources/Languages/en.yml | 44 + WheelWizard/SetupExtensions.cs | 2 + .../Shared/IO/DirectoryTransferService.cs | 86 +- .../Views/Dialogs/YesNoWindow.axaml.cs | 8 + WheelWizard/Views/Shell/Layout.axaml.cs | 35 +- 48 files changed, 4974 insertions(+), 146 deletions(-) create mode 100644 WheelWizard.Test/Features/ExistingSetups/ExistingSetupGameFilesTests.cs create mode 100644 WheelWizard.Test/Features/GameFiles/DiscExtractorTests.cs create mode 100644 WheelWizard.Test/Features/GameFiles/GameFilesHarness.cs create mode 100644 WheelWizard.Test/Features/GameFiles/GameFilesLinkTests.cs create mode 100644 WheelWizard.Test/Features/GameFiles/GameFilesServiceTests.cs create mode 100644 WheelWizard.Test/Features/GameFiles/SyntheticWiiDisc.cs create mode 100644 WheelWizard.Test/Features/Recomp/RecompGameDirTests.cs create mode 100644 WheelWizard.UI.Test/GameSetupShellTests.cs create mode 100644 WheelWizard/Features/GameFiles/DiscExtractor.cs create mode 100644 WheelWizard/Features/GameFiles/Domain/GameFilesManifest.cs create mode 100644 WheelWizard/Features/GameFiles/GameFilesExtensions.cs create mode 100644 WheelWizard/Features/GameFiles/GameFilesPaths.cs create mode 100644 WheelWizard/Features/GameFiles/GameFilesService.cs create mode 100644 WheelWizard/Features/GameFiles/Views/GameFilesFlow.cs create mode 100644 WheelWizard/Features/GameFiles/Views/GameSetupPage.axaml create mode 100644 WheelWizard/Features/GameFiles/Views/GameSetupPage.axaml.cs diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index 68773bfc2..67ce7695a 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -13,7 +13,7 @@ jobs: strategy: fail-fast: false matrix: - os: [ubuntu-latest, windows-latest] + os: [ubuntu-latest, windows-latest, macos-latest] runs-on: ${{ matrix.os }} steps: - name: Checkout repository diff --git a/WheelWizard.Test/Features/ExistingSetups/ExistingInstall.cs b/WheelWizard.Test/Features/ExistingSetups/ExistingInstall.cs index f27258faf..3750a512f 100644 --- a/WheelWizard.Test/Features/ExistingSetups/ExistingInstall.cs +++ b/WheelWizard.Test/Features/ExistingSetups/ExistingInstall.cs @@ -10,6 +10,7 @@ using WheelWizard.Dolphin.Discovery; using WheelWizard.Dolphin.Paths; using WheelWizard.DolphinInstaller; +using WheelWizard.GameFiles; using WheelWizard.Launching; using WheelWizard.Onboarding; using WheelWizard.Recomp; @@ -224,6 +225,16 @@ public ExistingInstall WithGame(string path, bool createFile = true) return this; } + /// A real (synthetic) Mario Kart Wii image instead of a placeholder file, for setups that get extracted. + public ExistingInstall WithRealGame(string path, string gameId = "RMCP01") + { + GameFile = path; + Config["GameLocation"] = path; + Fs.Directory.CreateDirectory(Fs.Path.GetDirectoryName(path)!); + Fs.File.WriteAllBytes(path, new GameFiles.SyntheticWiiDisc { GameId = gameId }.Build("rvz")); + return this; + } + public ExistingInstall WithConfig(string key, object? value) { Config[key] = value; @@ -352,6 +363,8 @@ internal sealed class Session public RecompSettingManager RecompSettings { get; } public RecompPaths RecompPaths { get; } public RetroRewindLaunchDescriptor LaunchDescriptor { get; } + public GameFilesPaths GamePaths { get; } + public GameFilesService GameFiles { get; } public IDolphinPaths DolphinPaths { get; } public List Statuses { get; } = []; @@ -439,8 +452,26 @@ public Session(ExistingInstall install) services, NullLogger.Instance ); - Maintenance = new StartupMaintenance(ManagedDolphin, Migration, Onboarding, services, NullLogger.Instance); - LaunchDescriptor = new RetroRewindLaunchDescriptor(fs, Settings, Paths); + GamePaths = new GameFilesPaths(location, fs); + GameFiles = new GameFilesService( + GamePaths, + new DiscExtractor(fs), + Settings, + fs, + Substitute.For(), + DolphinPaths, + new DiscImageService(fs), + NullLogger.Instance + ); + Maintenance = new StartupMaintenance( + ManagedDolphin, + Migration, + Onboarding, + GameFiles, + services, + NullLogger.Instance + ); + LaunchDescriptor = new RetroRewindLaunchDescriptor(fs, Paths, GamePaths); } /// What Desktop startup runs before the main window decides which page to open. diff --git a/WheelWizard.Test/Features/ExistingSetups/ExistingSetupGameFilesTests.cs b/WheelWizard.Test/Features/ExistingSetups/ExistingSetupGameFilesTests.cs new file mode 100644 index 000000000..f0e595d69 --- /dev/null +++ b/WheelWizard.Test/Features/ExistingSetups/ExistingSetupGameFilesTests.cs @@ -0,0 +1,159 @@ +using System.Text.Json; +using WheelWizard.GameFiles; +using WheelWizard.GameFiles.Views; + +namespace WheelWizard.Test.Features.ExistingSetups; + +/// +/// Earlier releases handed Dolphin the game image itself. This release plays an extracted copy, so somebody +/// updating is asked to extract once. Whatever state their setup is in, starting up must not touch their image, +/// rewrite where it is, or remove anything that is theirs. +/// +public class ExistingSetupGameFilesTests +{ + public static TheoryData Platforms => new() { HostPlatform.Windows, HostPlatform.Linux, HostPlatform.MacOS }; + + private static ExistingInstall Upgrading(HostPlatform platform) + { + var install = platform switch + { + HostPlatform.Windows => new ExistingInstall(platform) + .WithDolphin(@"C:\Dolphin\Dolphin.exe", @"C:\Users\racer\Documents\Dolphin Emulator") + .WithRealGame(@"D:\Wii Games\Mario Kart Wii (Europe).rvz"), + HostPlatform.MacOS => new ExistingInstall(platform) + .WithDolphin("'/Applications/Dolphin.app/Contents/MacOS/Dolphin'", "/Users/racer/Library/Application Support/Dolphin") + .WithRealGame("/Users/racer/Games/Mario Kart Wii.rvz"), + _ => new ExistingInstall(platform) + .WithDolphin("dolphin-emu", "/home/racer/.local/share/dolphin-emu") + .WithRealGame("/home/racer/Games/Mario Kart Wii.rvz"), + }; + install.FlushConfig(); + return install; + } + + private static byte[] Image(ExistingInstall install) => install.Fs.File.ReadAllBytes(install.GameFile); + + private static void AssertStoredGameLocation(ExistingInstall install) + { + using var config = JsonDocument.Parse(install.Fs.File.ReadAllText(install.ConfigFilePath)); + Assert.Equal(install.GameFile, config.RootElement.GetProperty("GameLocation").GetString()); + } + + [Theory] + [MemberData(nameof(Platforms))] + public void AnUpgradingUser_IsAskedToExtract_WithEverythingLeftAsItWas(HostPlatform platform) + { + var install = Upgrading(platform); + var image = Image(install); + + var session = install.Boot(); + + var status = session.GameFiles.GetStatus(); + Assert.Equal(GameFilesState.NeedsExtraction, status.State); + Assert.True(GameSetupPage.IsNeeded(status)); + Assert.False(session.Onboarding.IsRequired); + Assert.Equal(image, Image(install)); + Assert.Equal(install.GameFile, session.Settings.GAME_LOCATION.Get()); + Assert.False(install.Fs.Directory.Exists(session.GamePaths.RootPath), "starting up must not extract by itself"); + AssertStoredGameLocation(install); + } + + [Theory] + [MemberData(nameof(Platforms))] + public void AnUpgradingUserWithTheImageUnplugged_IsAskedToFindIt_AndKeepsTheSetting(HostPlatform platform) + { + var install = Upgrading(platform); + install.Unplug(install.GameFile); + + var session = install.Boot(); + + Assert.Equal(GameFilesState.ImageMissing, session.GameFiles.GetStatus().State); + Assert.Equal(install.GameFile, session.GameFiles.ImagePath); + Assert.False(session.Onboarding.IsRequired); + // The stored game location survives the drive being away. + AssertStoredGameLocation(install); + } + + [Theory] + [MemberData(nameof(Platforms))] + public async Task AfterExtracting_DolphinIsHandedTheExtractedGame(HostPlatform platform) + { + var install = Upgrading(platform); + var image = Image(install); + var session = install.Boot(); + + var result = await session.GameFiles.ExtractAsync(install.GameFile, replaceExisting: false); + + Assert.True(result.IsSuccess, result.IsFailure ? result.Error.Message : null); + var next = install.Boot(); + Assert.True(next.GameFiles.GetStatus().IsReady); + next.LaunchDescriptor.GenerateLaunchJson(); + using var json = JsonDocument.Parse(install.Fs.File.ReadAllText(next.Paths.LaunchJsonFilePath)); + Assert.Equal(install.Fs.Path.GetFullPath(next.GamePaths.MainDolPath), json.RootElement.GetProperty("base-file").GetString()); + Assert.Equal(image, Image(install)); + } + + [Theory] + [MemberData(nameof(Platforms))] + public async Task OnceExtracted_TheImageMayGo(HostPlatform platform) + { + var install = Upgrading(platform); + var session = install.Boot(); + Assert.True((await session.GameFiles.ExtractAsync(install.GameFile, false)).IsSuccess); + + install.Unplug(install.GameFile); + var next = install.Boot(); + + Assert.True(next.GameFiles.GetStatus().IsReady); + Assert.False(GameSetupPage.IsNeeded(next.GameFiles.GetStatus())); + Assert.True(next.Settings.DolphinPathsSetupCorrectly()); + Assert.False(next.Onboarding.IsRequired); + } + + [Fact] + public void AStartAfterACrashMidExtraction_RemovesOnlyWheelWizardsOwnLeftovers() + { + var install = Upgrading(HostPlatform.Windows); + var session = install.Boot(runStartup: false); + var staging = session.GamePaths.StagingPath; + install.Fs.Directory.CreateDirectory(install.Combine(staging, "DATA")); + install.Fs.File.WriteAllText(install.Combine(staging, GameFilesPaths.WorkMarkerFileName), ""); + var backup = session.GamePaths.RootPath + ".backup-20260101-120000"; + install.Fs.Directory.CreateDirectory(backup); + install.Fs.File.WriteAllText(install.Combine(backup, "mine.txt"), "mine"); + + install.Boot(); + + Assert.False(install.Fs.Directory.Exists(staging)); + Assert.Equal("mine", install.Fs.File.ReadAllText(install.Combine(backup, "mine.txt"))); + Assert.True(install.Fs.File.Exists(install.GameFile)); + } + + [Fact] + public async Task MovingTheDataFolder_TakesTheExtractedGameAlong() + { + var install = Upgrading(HostPlatform.Linux); + var session = install.Boot(); + Assert.True((await session.GameFiles.ExtractAsync(install.GameFile, false)).IsSuccess); + + install.RelocateDataFolder("/mnt/big/WheelWizard"); + var next = install.Boot(); + + Assert.True(next.GameFiles.GetStatus().IsReady); + Assert.StartsWith("/mnt/big/WheelWizard", next.GamePaths.MainDolPath); + } + + [Fact] + public void ASetupThatNeverHadAGame_IsNotStoppedByTheGameSetupPage() + { + var install = new ExistingInstall(HostPlatform.Windows).WithDolphin( + @"C:\Dolphin\Dolphin.exe", + @"C:\Users\racer\Documents\Dolphin Emulator" + ); + install.FlushConfig(); + + var session = install.Boot(); + + Assert.False(GameSetupPage.IsNeeded(session.GameFiles.GetStatus())); + } +} diff --git a/WheelWizard.Test/Features/GameFiles/DiscExtractorTests.cs b/WheelWizard.Test/Features/GameFiles/DiscExtractorTests.cs new file mode 100644 index 000000000..60004647b --- /dev/null +++ b/WheelWizard.Test/Features/GameFiles/DiscExtractorTests.cs @@ -0,0 +1,320 @@ +using System.Text; +using Testably.Abstractions.Testing; +using WheelWizard.GameFiles; + +namespace WheelWizard.Test.Features.GameFiles; + +public class DiscExtractorTests +{ + private static MockFileSystem Disk(SimulationMode mode = SimulationMode.Linux) + { + var fs = new MockFileSystem(options => options.SimulatingOperatingSystem(mode)); + fs.Directory.CreateDirectory(Root(mode, "games")); + fs.Directory.CreateDirectory(Root(mode, "out")); + return fs; + } + + private static string Root(SimulationMode mode, string name) => mode == SimulationMode.Windows ? $@"C:\{name}" : $"/{name}"; + + [Fact] + public void Extract_WritesTheLayoutDolphinBootsFrom() + { + var fs = Disk(); + var disc = new SyntheticWiiDisc(); + fs.File.WriteAllBytes("/games/mkw.iso", disc.BuildIso()); + + var result = new DiscExtractor(fs).Extract("/games/mkw.iso", "/out"); + + Assert.True(result.IsSuccess, result.IsFailure ? result.Error.Message : null); + var iso = fs.File.ReadAllBytes("/games/mkw.iso"); + Assert.Equal(iso[..0x100], fs.File.ReadAllBytes("/out/disc/header.bin")); + Assert.Equal(iso[0x4E000..0x4E020], fs.File.ReadAllBytes("/out/disc/region.bin")); + Assert.Equal(disc.Ticket, fs.File.ReadAllBytes("/out/DATA/ticket.bin")); + Assert.Equal(disc.Tmd, fs.File.ReadAllBytes("/out/DATA/tmd.bin")); + Assert.Equal(disc.Certificates, fs.File.ReadAllBytes("/out/DATA/cert.bin")); + Assert.Equal(disc.H3, fs.File.ReadAllBytes("/out/DATA/h3.bin")); + Assert.Equal(disc.Boot, fs.File.ReadAllBytes("/out/DATA/sys/boot.bin")); + Assert.Equal(disc.Bi2, fs.File.ReadAllBytes("/out/DATA/sys/bi2.bin")); + Assert.Equal(disc.Apploader, fs.File.ReadAllBytes("/out/DATA/sys/apploader.img")); + Assert.Equal(disc.MainDol, fs.File.ReadAllBytes("/out/DATA/sys/main.dol")); + Assert.Equal(disc.Fst, fs.File.ReadAllBytes("/out/DATA/sys/fst.bin")); + foreach (var (path, data) in disc.Files) + Assert.Equal(data, fs.File.ReadAllBytes("/out/DATA/files/" + path)); + } + + [Fact] + public void Extract_ReportsTheHashesTheManifestPins() + { + var fs = Disk(); + var disc = new SyntheticWiiDisc(); + fs.File.WriteAllBytes("/games/mkw.iso", disc.BuildIso()); + + var result = new DiscExtractor(fs).Extract("/games/mkw.iso", "/out"); + + Assert.Equal(SyntheticWiiDisc.Sha256(disc.MainDol), result.Value.MainDolSha256); + Assert.Equal(SyntheticWiiDisc.Sha256(disc.Files["rel/StaticR.rel"]), result.Value.StaticRelSha256); + } + + [Theory] + [InlineData("iso")] + [InlineData("wbfs")] + [InlineData("gcz")] + [InlineData("ciso")] + [InlineData("rvz")] + public void Extract_EveryContainerGivesTheSameFiles(string format) + { + var fs = Disk(); + var disc = new SyntheticWiiDisc(); + fs.File.WriteAllBytes("/games/iso.iso", disc.BuildIso()); + fs.File.WriteAllBytes($"/games/other.{format}", disc.Build(format)); + var extractor = new DiscExtractor(fs); + + Assert.True(extractor.Extract("/games/iso.iso", "/out/iso").IsSuccess); + var result = extractor.Extract($"/games/other.{format}", "/out/other"); + + Assert.True(result.IsSuccess, result.IsFailure ? result.Error.Message : null); + var expected = Snapshot(fs, "/out/iso"); + Assert.Equal(expected.Keys.Order(), Snapshot(fs, "/out/other").Keys.Order()); + foreach (var (path, data) in Snapshot(fs, "/out/other")) + Assert.Equal(expected[path], data); + } + + [Theory] + [InlineData(SimulationMode.Windows)] + [InlineData(SimulationMode.Linux)] + [InlineData(SimulationMode.MacOS)] + public void Extract_WorksOnEveryPlatform(SimulationMode mode) + { + var fs = Disk(mode); + var disc = new SyntheticWiiDisc(); + var image = fs.Path.Combine(Root(mode, "games"), "Mario Kart Wii (Europe) (En,Fr,De,Es,It).rvz"); + fs.File.WriteAllBytes(image, disc.Build("rvz")); + var destination = fs.Path.Combine(Root(mode, "out"), "with spaces", "GAME"); + + var result = new DiscExtractor(fs).Extract(image, destination); + + Assert.True(result.IsSuccess, result.IsFailure ? result.Error.Message : null); + Assert.Equal(disc.MainDol, fs.File.ReadAllBytes(fs.Path.Combine(destination, "DATA", "sys", "main.dol"))); + } + + [Fact] + public void Extract_OnlyReadsTheImage() + { + var fs = Disk(); + var iso = new SyntheticWiiDisc().BuildIso(); + fs.File.WriteAllBytes("/games/mkw.iso", iso); + var written = fs.File.GetLastWriteTimeUtc("/games/mkw.iso"); + + // Another program holding the image open for reading must not stop the extraction. + using (fs.File.Open("/games/mkw.iso", FileMode.Open, FileAccess.Read, FileShare.Read)) + Assert.True(new DiscExtractor(fs).Extract("/games/mkw.iso", "/out").IsSuccess); + + Assert.Equal(iso, fs.File.ReadAllBytes("/games/mkw.iso")); + Assert.Equal(written, fs.File.GetLastWriteTimeUtc("/games/mkw.iso")); + } + + [Fact] + public void Extract_ReadOnlyImageWorks() + { + var fs = Disk(); + fs.File.WriteAllBytes("/games/mkw.iso", new SyntheticWiiDisc().BuildIso()); + fs.FileInfo.New("/games/mkw.iso").IsReadOnly = true; + + Assert.True(new DiscExtractor(fs).Extract("/games/mkw.iso", "/out").IsSuccess); + Assert.True(fs.FileInfo.New("/games/mkw.iso").IsReadOnly); + } + + [Theory] + [InlineData("..")] + [InlineData(".")] + [InlineData("C:")] + [InlineData("a\\..\\..\\evil")] + [InlineData("CON")] + [InlineData("nul.txt")] + [InlineData("trailing.")] + [InlineData("trailing ")] + [InlineData("star*")] + [InlineData("tab\tname")] + public void Extract_RefusesNamesThatCouldEscapeOrBreakOnSomePlatform(string name) + { + var fs = Disk(); + var disc = new SyntheticWiiDisc + { + Files = new() { ["rel/StaticR.rel"] = [1, 2, 3], [$"{name}/payload.bin"] = [9] }, + }; + fs.File.WriteAllBytes("/games/evil.iso", disc.BuildIso()); + + var result = new DiscExtractor(fs).Extract("/games/evil.iso", "/out/GAME"); + + Assert.True(result.IsFailure); + Assert.False(fs.File.Exists("/payload.bin")); + Assert.False(fs.File.Exists("/out/payload.bin")); + Assert.False(fs.File.Exists("/evil/payload.bin")); + } + + [Fact] + public void Extract_RefusesTwoNamesACaseInsensitiveDriveWouldMerge() + { + var fs = Disk(); + var disc = new SyntheticWiiDisc + { + Files = new() + { + ["rel/StaticR.rel"] = [1], + ["Race/a.szs"] = [2], + ["race/A.szs"] = [3], + }, + }; + fs.File.WriteAllBytes("/games/dupe.iso", disc.BuildIso()); + + var result = new DiscExtractor(fs).Extract("/games/dupe.iso", "/out"); + + Assert.True(result.IsFailure); + Assert.Contains("capitalization", result.Error.Message); + } + + [Fact] + public void Extract_NeverOverwritesExistingFiles() + { + var fs = Disk(); + fs.File.WriteAllBytes("/games/mkw.iso", new SyntheticWiiDisc().BuildIso()); + fs.Directory.CreateDirectory("/out/DATA/sys"); + fs.File.WriteAllText("/out/DATA/sys/main.dol", "mine"); + + Assert.True(new DiscExtractor(fs).Extract("/games/mkw.iso", "/out").IsFailure); + Assert.Equal("mine", fs.File.ReadAllText("/out/DATA/sys/main.dol")); + } + + [Fact] + public void Extract_FailsWithoutStaticRel() + { + var fs = Disk(); + fs.File.WriteAllBytes("/games/other.iso", new SyntheticWiiDisc { Files = new() { ["a.bin"] = [1] } }.BuildIso()); + + var result = new DiscExtractor(fs).Extract("/games/other.iso", "/out"); + + Assert.True(result.IsFailure); + Assert.Contains("StaticR.rel", result.Error.Message); + } + + [Theory] + [InlineData(0x8000)] + [InlineData(0x50000)] + [InlineData(0x60000)] + public void Extract_FailsCleanlyOnATruncatedImage(int length) + { + var fs = Disk(); + fs.File.WriteAllBytes("/games/cut.iso", new SyntheticWiiDisc().BuildIso()[..length]); + + Assert.True(new DiscExtractor(fs).Extract("/games/cut.iso", "/out").IsFailure); + } + + [Fact] + public void Extract_RejectsAGameCubeOrGarbageFile() + { + var fs = Disk(); + var gamecube = new byte[0x50000]; + Encoding.ASCII.GetBytes("GALE01").CopyTo(gamecube, 0); + new byte[] { 0xC2, 0x33, 0x9F, 0x3D }.CopyTo(gamecube, 0x1C); + fs.File.WriteAllBytes("/games/melee.iso", gamecube); + fs.File.WriteAllBytes("/games/notes.iso", Encoding.ASCII.GetBytes("not a disc")); + var extractor = new DiscExtractor(fs); + + Assert.True(extractor.Extract("/games/melee.iso", "/out/a").IsFailure); + Assert.True(extractor.Extract("/games/notes.iso", "/out/b").IsFailure); + Assert.True(extractor.Extract("/games/missing.iso", "/out/c").IsFailure); + } + + [Fact] + public void Extract_ObservesCancellation() + { + var fs = Disk(); + fs.File.WriteAllBytes("/games/mkw.iso", new SyntheticWiiDisc().BuildIso()); + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + + Assert.ThrowsAny( + () => new DiscExtractor(fs).Extract("/games/mkw.iso", "/out", null, cancellation.Token) + ); + } + + [Fact] + public void Extract_ReportsProgressUpToTheEnd() + { + var fs = Disk(); + fs.File.WriteAllBytes("/games/mkw.iso", new SyntheticWiiDisc().BuildIso()); + var reports = new List(); + + new DiscExtractor(fs).Extract("/games/mkw.iso", "/out", new SyncProgress(reports.Add)); + + Assert.NotEmpty(reports); + Assert.Equal(1, reports[^1]); + Assert.Equal(reports.Order(), reports); + } + + [Fact] + public void Inspect_ReadsTheGameWithoutExtracting() + { + var fs = Disk(); + var disc = new SyntheticWiiDisc { GameId = "RMCE01", Revision = 1 }; + fs.File.WriteAllBytes("/games/mkw.wbfs", disc.Build("wbfs")); + + var contents = new DiscExtractor(fs).Inspect("/games/mkw.wbfs"); + + Assert.True(contents.IsSuccess, contents.IsFailure ? contents.Error.Message : null); + Assert.Equal("RMCE01", contents.Value.GameId); + Assert.Equal(1, contents.Value.Revision); + Assert.True(contents.Value.RequiredBytes >= disc.Files.Values.Sum(file => file.Length)); + Assert.Empty(fs.Directory.EnumerateFileSystemEntries("/out")); + } + + [Fact] + public void RestoreCode_ReplacesOnlyMainDolAndStaticRel() + { + var fs = Disk(); + var disc = new SyntheticWiiDisc(); + fs.File.WriteAllBytes("/games/mkw.iso", disc.BuildIso()); + var extractor = new DiscExtractor(fs); + extractor.Extract("/games/mkw.iso", "/out"); + fs.File.WriteAllText("/out/DATA/sys/main.dol", "patched"); + fs.File.Delete("/out/DATA/files/rel/StaticR.rel"); + fs.File.WriteAllText("/out/DATA/files/Scene/UI/Title.szs", "my edit"); + + var result = extractor.RestoreCode("/games/mkw.iso", "/out/DATA"); + + Assert.True(result.IsSuccess, result.IsFailure ? result.Error.Message : null); + Assert.Equal(disc.MainDol, fs.File.ReadAllBytes("/out/DATA/sys/main.dol")); + Assert.Equal(disc.Files["rel/StaticR.rel"], fs.File.ReadAllBytes("/out/DATA/files/rel/StaticR.rel")); + Assert.Equal("my edit", fs.File.ReadAllText("/out/DATA/files/Scene/UI/Title.szs")); + Assert.Empty(fs.Directory.EnumerateFiles("/out", "*.restore", SearchOption.AllDirectories)); + } + + [Theory] + [InlineData("StaticR.rel", true)] + [InlineData("Race", true)] + [InlineData("..", false)] + [InlineData("", false)] + [InlineData("a/b", false)] + [InlineData("a:b", false)] + [InlineData("LPT1", false)] + [InlineData("aux.szs", false)] + [InlineData("console.szs", true)] + public void SafeName_UsesTheStrictestPlatformRules(string name, bool safe) + { + if (safe) + Assert.Equal(name, DiscExtractor.SafeName(name)); + else + Assert.Throws(() => DiscExtractor.SafeName(name)); + } + + private static Dictionary Snapshot(MockFileSystem fs, string root) => + fs + .Directory.EnumerateFiles(root, "*", SearchOption.AllDirectories) + .ToDictionary(file => fs.Path.GetRelativePath(root, file).Replace('\\', '/'), file => fs.File.ReadAllBytes(file)); +} + +/// Reports on the calling thread, unlike , so a test sees every report in order. +internal sealed class SyncProgress(Action report) : IProgress +{ + public void Report(T value) => report(value); +} diff --git a/WheelWizard.Test/Features/GameFiles/GameFilesHarness.cs b/WheelWizard.Test/Features/GameFiles/GameFilesHarness.cs new file mode 100644 index 000000000..381c55e7f --- /dev/null +++ b/WheelWizard.Test/Features/GameFiles/GameFilesHarness.cs @@ -0,0 +1,220 @@ +using System.IO.Abstractions; +using System.Text.Json; +using Microsoft.Extensions.Logging.Abstractions; +using Testably.Abstractions.Testing; +using WheelWizard.ApplicationData; +using WheelWizard.DiscImages; +using WheelWizard.Dolphin.Paths; +using WheelWizard.GameFiles; +using WheelWizard.GameFiles.Domain; +using WheelWizard.Settings; +using WheelWizard.Settings.Types; +using WheelWizard.Shared.Processes; + +namespace WheelWizard.Test.Features.GameFiles; + +/// +/// A data folder, some game images and the real game files service, over a mock disk or (for links, which the mock +/// does not follow) a temporary folder on the real one. Every image it writes is remembered, so a test can assert at +/// the end that none of them was changed in any way. +/// +internal sealed class GameFilesHarness : IDisposable +{ + private readonly string? _realRoot; + private readonly Dictionary _images = new(); + + public IFileSystem Fs { get; } + public SimulationMode Mode { get; } + public string DataDir { get; } + public string GamesDir { get; } + public IProcessLauncher Processes { get; } = Substitute.For(); + public WhWzSetting GameLocation { get; } = + (WhWzSetting)new WhWzSetting("GameLocation", "").SetRetainUnavailable(); + public ISettingsManager Settings { get; } = Substitute.For(); + public GameFilesPaths Paths { get; } + public GameFilesService Service { get; } + public DiscExtractor Extractor { get; } + + public GameFilesHarness(SimulationMode mode = SimulationMode.Linux, string? dataDir = null, Action? configure = null) + : this(CreateMock(mode, configure), mode, dataDir, realRoot: null) { } + + private static MockFileSystem CreateMock(SimulationMode mode, Action? configure) + { + var fs = new MockFileSystem(options => options.SimulatingOperatingSystem(mode)); + configure?.Invoke(fs); + return fs; + } + + /// A harness on the real disk, in a fresh temporary folder that is removed again on dispose. + public static GameFilesHarness OnRealDisk() + { + var root = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "ww-gamefiles-" + Guid.NewGuid().ToString("N")); + var mode = + OperatingSystem.IsWindows() ? SimulationMode.Windows + : OperatingSystem.IsMacOS() ? SimulationMode.MacOS + : SimulationMode.Linux; + return new GameFilesHarness(new Testably.Abstractions.RealFileSystem(), mode, System.IO.Path.Combine(root, "CT-MKWII"), root); + } + + private GameFilesHarness(IFileSystem fs, SimulationMode mode, string? dataDir, string? realRoot) + { + Mode = mode; + Fs = fs; + _realRoot = realRoot; + DataDir = + dataDir ?? (mode == SimulationMode.Windows ? @"C:\Users\Player\AppData\Roaming\CT-MKWII" : "/home/player/.config/CT-MKWII"); + GamesDir = + realRoot is not null ? Fs.Path.Combine(realRoot, "Wii Games") + : mode == SimulationMode.Windows ? @"C:\Games\Wii Games" + : "/home/player/Games/Wii Games"; + Fs.Directory.CreateDirectory(DataDir); + Fs.Directory.CreateDirectory(GamesDir); + + Settings.GAME_LOCATION.Returns(GameLocation); + Settings.Get(GameLocation).Returns(_ => (string)GameLocation.Get()); + Settings + .Set(GameLocation, Arg.Any(), Arg.Any()) + .Returns(call => GameLocation.Set(call.ArgAt(1), skipSave: true)); + + var location = Substitute.For(); + location.DirectoryPath.Returns(_ => DataDir); + Paths = new GameFilesPaths(location, Fs); + Extractor = new DiscExtractor(Fs); + Service = new GameFilesService( + Paths, + Extractor, + Settings, + Fs, + Processes, + Substitute.For(), + new DiscImageService(Fs), + NullLogger.Instance + ); + } + + public string Game => Paths.RootPath; + + /// A folder next to the data folder, standing in for another drive. + public string Elsewhere(string name) => Fs.Path.Combine(_realRoot ?? "/mnt", "other drive", name); + + /// + /// Creates a directory link: a symlink, or on Windows without the right to make one, a junction (which is what + /// most Windows users make anyway). False only when neither can be made here. + /// + public bool TryLink(string link, string target) + { + try + { + Fs.Directory.CreateSymbolicLink(link, target); + return true; + } + catch (Exception exception) when (exception is UnauthorizedAccessException or IOException) + { + if (!OperatingSystem.IsWindows()) + return false; + } + + var junction = System.Diagnostics.Process.Start( + new System.Diagnostics.ProcessStartInfo("cmd.exe") + { + ArgumentList = { "/c", "mklink", "/J", link, target }, + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + } + )!; + junction.WaitForExit(); + return junction.ExitCode == 0 && Fs.DirectoryInfo.New(link).LinkTarget is not null; + } + + public void Dispose() + { + if (_realRoot is null || !Directory.Exists(_realRoot)) + return; + // A recursive delete trips over junctions on Windows, so links are removed on their own first. + RemoveLinks(_realRoot); + Directory.Delete(_realRoot, recursive: true); + } + + private static void RemoveLinks(string folder) + { + foreach (var directory in Directory.GetDirectories(folder)) + { + if (new DirectoryInfo(directory).LinkTarget is null) + RemoveLinks(directory); + else if (OperatingSystem.IsWindows()) + Directory.Delete(directory); + else + File.Delete(directory); + } + } + + public string In(params string[] parts) => Fs.Path.Combine([Game, .. parts]); + + /// Writes a game image and remembers exactly what it looked like. + public string WriteImage(SyntheticWiiDisc disc, string name = "Mario Kart Wii (Europe).rvz", string format = "rvz") + { + var path = Fs.Path.Combine(GamesDir, name); + Fs.File.WriteAllBytes(path, disc.Build(format)); + _images[path] = (Fs.File.ReadAllBytes(path), Fs.File.GetLastWriteTimeUtc(path)); + return path; + } + + public string WriteImage(string gameId = "RMCP01", string? name = null) => + WriteImage(new SyntheticWiiDisc { GameId = gameId }, name ?? $"{gameId}.rvz"); + + /// The one promise above all others: Wheel Wizard never changes, moves or deletes a game image. + public void AssertImagesUntouched() + { + foreach (var (path, (bytes, lastWrite)) in _images) + { + Assert.True(Fs.File.Exists(path), $"The game image {path} is gone."); + Assert.Equal(bytes, Fs.File.ReadAllBytes(path)); + Assert.Equal(lastWrite, Fs.File.GetLastWriteTimeUtc(path)); + } + } + + public async Task ExtractAsync(string image, bool replace = false) + { + var result = await Service.ExtractAsync(image, replace); + Assert.True(result.IsSuccess, result.IsFailure ? result.Error.Message : null); + } + + public GameFilesManifest? Manifest(string? manifestPath = null) + { + var path = manifestPath ?? Paths.ManifestPath; + return Fs.File.Exists(path) ? JsonSerializer.Deserialize(Fs.File.ReadAllText(path)) : null; + } + + /// Lays a finished extraction into , as an interrupted swap leaves it. + public void Stage(string image, string gameId) + { + var staging = Paths.StagingPath; + Fs.Directory.CreateDirectory(staging); + Fs.File.WriteAllText(Fs.Path.Combine(staging, GameFilesPaths.WorkMarkerFileName), ""); + var code = Extractor.Extract(image, staging); + Assert.True(code.IsSuccess, code.IsFailure ? code.Error.Message : null); + Fs.File.WriteAllText( + Fs.Path.Combine(staging, GameFilesPaths.ManifestFileName), + JsonSerializer.Serialize( + new GameFilesManifest + { + GameId = gameId, + SourcePath = image, + MainDolSha256 = code.Value.MainDolSha256, + StaticRelSha256 = code.Value.StaticRelSha256, + } + ) + ); + } + + public string[] Siblings() => Fs.Directory.GetFileSystemEntries(Fs.Path.GetDirectoryName(Paths.ResolvedRootPath)!); + + public void SetRunning(params string[] processNames) + { + Processes.GetProcessIds(Arg.Any()).Returns(Array.Empty()); + foreach (var name in processNames) + Processes.GetProcessIds(name).Returns([4242]); + } +} diff --git a/WheelWizard.Test/Features/GameFiles/GameFilesLinkTests.cs b/WheelWizard.Test/Features/GameFiles/GameFilesLinkTests.cs new file mode 100644 index 000000000..5580a60b4 --- /dev/null +++ b/WheelWizard.Test/Features/GameFiles/GameFilesLinkTests.cs @@ -0,0 +1,179 @@ +using WheelWizard.GameFiles; +using WheelWizard.Shared.IO; + +namespace WheelWizard.Test.Features.GameFiles; + +/// +/// Someone put the game folder on another drive with a symlink or junction. The in-memory disk does not follow +/// links, so these run in a temporary folder on the real one. Where this account can't create links (Windows +/// without Developer Mode) there is nothing to test, and they return early. +/// +public sealed class GameFilesLinkTests : IDisposable +{ + private readonly GameFilesHarness _harness = GameFilesHarness.OnRealDisk(); + + public void Dispose() => _harness.Dispose(); + + private bool Linked(out string target) + { + target = _harness.Elsewhere("Mario Kart Wii"); + _harness.Fs.Directory.CreateDirectory(target); + return _harness.TryLink(_harness.Game, target); + } + + private bool IsLink(string path) => _harness.Fs.DirectoryInfo.New(path).LinkTarget is not null; + + [Fact] + public async Task ExtractsIntoWhereTheLinkPoints() + { + if (!Linked(out var target)) + return; + + await _harness.ExtractAsync(_harness.WriteImage()); + + Assert.True(IsLink(_harness.Game)); + Assert.True(_harness.Fs.File.Exists(Path.Combine(target, "DATA", "sys", "main.dol"))); + Assert.True(_harness.Service.GetStatus().IsReady); + // The work folder sits next to the target, on the same drive, and is gone afterwards. + Assert.False(_harness.Fs.Directory.Exists(target + ".partial")); + Assert.False(_harness.Fs.Directory.Exists(_harness.Game + ".partial")); + _harness.AssertImagesUntouched(); + } + + [Fact] + public async Task ReplacingKeepsTheLinkAndUnrelatedFiles() + { + if (!Linked(out var target)) + return; + await _harness.ExtractAsync(_harness.WriteImage()); + _harness.Fs.File.WriteAllText(Path.Combine(target, "readme.txt"), "mine"); + + await _harness.ExtractAsync(_harness.WriteImage("RMCJ01"), replace: true); + + Assert.True(IsLink(_harness.Game)); + Assert.Equal("RMCJ01", _harness.Manifest()!.GameId); + Assert.Equal("mine", _harness.Fs.File.ReadAllText(Path.Combine(target, "readme.txt"))); + Assert.False(_harness.Fs.Directory.Exists(target + ".replaced")); + _harness.AssertImagesUntouched(); + } + + [Fact] + public async Task RecoversAnInterruptedSwapInsideTheTarget() + { + if (!Linked(out var target)) + return; + await _harness.ExtractAsync(_harness.WriteImage()); + _harness.Stage(_harness.WriteImage("RMCE01"), "RMCE01"); + Directory.CreateDirectory(target + ".replaced"); + File.WriteAllText(Path.Combine(target + ".replaced", GameFilesPaths.WorkMarkerFileName), ""); + File.Move( + Path.Combine(target, GameFilesPaths.ManifestFileName), + Path.Combine(target + ".replaced", GameFilesPaths.ManifestFileName) + ); + + _harness.Service.RecoverInterruptedWork(); + + Assert.True(IsLink(_harness.Game)); + Assert.Equal("RMCE01", _harness.Manifest()!.GameId); + Assert.False(Directory.Exists(target + ".replaced")); + Assert.False(Directory.Exists(target + ".partial")); + } + + [Fact] + public void DeletingAnUnknownLink_LeavesWhatItPointsTo() + { + if (!Linked(out var target)) + return; + File.WriteAllText(Path.Combine(target, "precious.bin"), "precious"); + + Assert.Equal(GameFilesState.UnknownFolder, _harness.Service.GetStatus().State); + Assert.True(_harness.Service.DeleteUnknown().IsSuccess); + + Assert.False(Directory.Exists(_harness.Game)); + Assert.Equal("precious", File.ReadAllText(Path.Combine(target, "precious.bin"))); + } + + [Fact] + public void MovingAnUnknownLinkAside_MovesOnlyTheLink() + { + if (!Linked(out var target)) + return; + File.WriteAllText(Path.Combine(target, "precious.bin"), "precious"); + + var moved = _harness.Service.MoveUnknownAside(); + + Assert.True(moved.IsSuccess, moved.IsFailure ? moved.Error.Message : null); + Assert.True(IsLink(moved.Value)); + Assert.False(Directory.Exists(_harness.Game)); + Assert.Equal("precious", File.ReadAllText(Path.Combine(target, "precious.bin"))); + Assert.Equal("precious", File.ReadAllText(Path.Combine(moved.Value, "precious.bin"))); + } + + [Fact] + public void DeletingAnUnknownFolder_NeverFollowsALinkInside() + { + var documents = _harness.Elsewhere("Documents"); + Directory.CreateDirectory(documents); + File.WriteAllText(Path.Combine(documents, "thesis.docx"), "years of work"); + Directory.CreateDirectory(_harness.In("DATA")); + if (!_harness.TryLink(_harness.In("DATA", "docs"), documents)) + return; + + Assert.True(_harness.Service.DeleteUnknown().IsSuccess); + + Assert.False(Directory.Exists(_harness.Game)); + Assert.Equal("years of work", File.ReadAllText(Path.Combine(documents, "thesis.docx"))); + } + + [Fact] + public async Task ALinkWhoseDriveIsUnplugged_IsNotExtractedInto() + { + var target = _harness.Elsewhere("unplugged drive/GAME"); + if (!_harness.TryLink(_harness.Game, target)) + return; + + Assert.Equal(GameFilesState.UnknownFolder, _harness.Service.GetStatus().State); + Assert.True((await _harness.Service.ExtractAsync(_harness.WriteImage(), true)).IsFailure); + Assert.False(Directory.Exists(target)); + } + + [Fact] + public async Task MovingTheDataFolder_KeepsALinkedGameWhereItIs() + { + if (!Linked(out var target)) + return; + await _harness.ExtractAsync(_harness.WriteImage()); + File.WriteAllText(_harness.Fs.Path.Combine(_harness.DataDir, "config.json"), "{}"); + var newData = _harness.Elsewhere("New Data Folder"); + + var moved = new DirectoryTransferService(_harness.Fs).MoveContents(_harness.DataDir, newData); + + Assert.Equal(DirectoryMoveOutcome.Success, moved.Outcome); + var movedGame = Path.Combine(newData, GameFilesPaths.FolderName); + Assert.True(Directory.Exists(movedGame)); + Assert.True(File.Exists(Path.Combine(movedGame, "DATA", "sys", "main.dol"))); + // The extracted game stayed on the other drive; only the link (or, where none can be made, a copy) moved. + Assert.True(File.Exists(Path.Combine(target, "DATA", "sys", "main.dol"))); + Assert.False(Directory.Exists(_harness.DataDir)); + _harness.AssertImagesUntouched(); + } + + [Fact] + public async Task RealDisk_FullRoundTrip() + { + // The same flow as on the mock, on a real drive: extract, edit, restore the code, switch region. + await _harness.ExtractAsync(_harness.WriteImage()); + File.WriteAllText(_harness.In("DATA", "sys", "main.dol"), "patched"); + File.WriteAllText(_harness.In("DATA", "files", "Scene", "UI", "Title.szs"), "my edit"); + Assert.Equal(GameFilesState.CodeModified, _harness.Service.GetStatus().State); + + Assert.True((await _harness.Service.RestoreCodeAsync()).IsSuccess); + Assert.True(_harness.Service.GetStatus().IsReady); + Assert.Equal("my edit", File.ReadAllText(_harness.In("DATA", "files", "Scene", "UI", "Title.szs"))); + + await _harness.ExtractAsync(_harness.WriteImage("RMCE01"), replace: true); + Assert.Equal("RMCE01", _harness.Manifest()!.GameId); + Assert.Single(Directory.GetFileSystemEntries(_harness.DataDir)); + _harness.AssertImagesUntouched(); + } +} diff --git a/WheelWizard.Test/Features/GameFiles/GameFilesServiceTests.cs b/WheelWizard.Test/Features/GameFiles/GameFilesServiceTests.cs new file mode 100644 index 000000000..5379c7769 --- /dev/null +++ b/WheelWizard.Test/Features/GameFiles/GameFilesServiceTests.cs @@ -0,0 +1,676 @@ +using System.Text.Json; +using Testably.Abstractions.Testing; +using WheelWizard.GameFiles; +using WheelWizard.GameFiles.Domain; +using WheelWizard.Models.Enums; +using WheelWizard.Settings.Types; + +namespace WheelWizard.Test.Features.GameFiles; + +public class GameFilesServiceTests +{ + #region Status + + [Fact] + public void Status_NothingPicked_IsLeftToTheOnboarding() + { + var harness = new GameFilesHarness(); + + Assert.Equal(GameFilesState.NoImage, harness.Service.GetStatus().State); + } + + [Fact] + public void Status_AnImageFromAnOlderVersion_NeedsExtraction() + { + var harness = new GameFilesHarness(); + harness.GameLocation.Set(harness.WriteImage(), skipSave: true); + + var status = harness.Service.GetStatus(); + + Assert.Equal(GameFilesState.NeedsExtraction, status.State); + Assert.False(harness.Fs.Directory.Exists(harness.Game)); + } + + [Fact] + public void Status_AnImageThatIsGone_IsMissing() + { + var harness = new GameFilesHarness(); + harness.GameLocation.Set("/media/usb/mkw.iso", skipSave: true); + + Assert.Equal(GameFilesState.ImageMissing, harness.Service.GetStatus().State); + } + + [Fact] + public void Status_AnImageOnAnUnpluggedDrive_IsMissingNotUnset() + { + var harness = new GameFilesHarness(); + ((IWhWzSetting)harness.GameLocation).RememberUnavailable(JsonSerializer.SerializeToElement("/media/usb/mkw.iso")); + + var status = harness.Service.GetStatus(); + + Assert.Equal(GameFilesState.ImageMissing, status.State); + Assert.Equal("/media/usb/mkw.iso", status.ImagePath); + } + + [Fact] + public void Status_AFolderWithoutManifest_IsUnknown() + { + var harness = new GameFilesHarness(); + harness.Fs.Directory.CreateDirectory(harness.In("DATA", "sys")); + harness.Fs.File.WriteAllText(harness.In("DATA", "sys", "main.dol"), "someone's own extraction"); + + Assert.Equal(GameFilesState.UnknownFolder, harness.Service.GetStatus().State); + } + + [Fact] + public void Status_AFileWhereTheFolderGoes_IsUnknown() + { + var harness = new GameFilesHarness(); + harness.Fs.File.WriteAllText(harness.Game, "not a folder"); + + Assert.Equal(GameFilesState.UnknownFolder, harness.Service.GetStatus().State); + } + + [Theory] + [InlineData("{ not json")] + [InlineData("""{"SchemaVersion":99,"GameId":"RMCP01","MainDolSha256":"a","StaticRelSha256":"b"}""")] + [InlineData("""{"SchemaVersion":1,"GameId":"","MainDolSha256":"a","StaticRelSha256":"b"}""")] + public void Status_AManifestThatCantBeTrusted_IsUnknown(string manifest) + { + var harness = new GameFilesHarness(); + harness.Fs.Directory.CreateDirectory(harness.Game); + harness.Fs.File.WriteAllText(harness.Paths.ManifestPath, manifest); + + Assert.Equal(GameFilesState.UnknownFolder, harness.Service.GetStatus().State); + } + + [Fact] + public async Task Status_EditingAnyOtherFile_StaysReady() + { + var harness = new GameFilesHarness(); + await harness.ExtractAsync(harness.WriteImage()); + + harness.Fs.File.WriteAllText(harness.In("DATA", "files", "Scene", "UI", "Title.szs"), "my custom title screen"); + harness.Fs.File.Delete(harness.In("DATA", "files", "Race", "Course", "beginner_course.szs")); + harness.Fs.File.WriteAllText(harness.In("DATA", "files", "my-new-asset.brres"), "new"); + harness.Fs.File.WriteAllText(harness.In("DATA", "sys", "bi2.bin"), "edited"); + + Assert.Equal(GameFilesState.Ready, harness.Service.GetStatus().State); + } + + [Theory] + [InlineData("DATA/sys/main.dol", false)] + [InlineData("DATA/files/rel/StaticR.rel", false)] + [InlineData("DATA/sys/main.dol", true)] + [InlineData("DATA/files/rel/StaticR.rel", true)] + public async Task Status_EditingOrDeletingTheGameCode_IsModified(string file, bool delete) + { + var harness = new GameFilesHarness(); + await harness.ExtractAsync(harness.WriteImage()); + var path = harness.In(file.Split('/')); + + if (delete) + harness.Fs.File.Delete(path); + else + harness.Fs.File.WriteAllText(path, "patched"); + + Assert.Equal(GameFilesState.CodeModified, harness.Service.GetStatus().State); + } + + [Fact] + public async Task Status_ADeletedImage_DoesNotMatterOnceExtracted() + { + var harness = new GameFilesHarness(); + var image = harness.WriteImage(); + await harness.ExtractAsync(image); + + harness.Fs.File.Delete(image); + + Assert.Equal(GameFilesState.Ready, harness.Service.GetStatus().State); + Assert.Equal(MarioKartWiiEnums.Regions.Europe, harness.Service.GetRegion()); + } + + #endregion + + #region Extracting + + [Theory] + [InlineData(SimulationMode.Windows)] + [InlineData(SimulationMode.Linux)] + [InlineData(SimulationMode.MacOS)] + public async Task Extract_MakesTheGameReadyAndRemembersTheImage(SimulationMode mode) + { + var harness = new GameFilesHarness(mode); + var image = harness.WriteImage(new SyntheticWiiDisc(), "Mario Kart Wii (Europe) (En,Fr,De,Es,It).rvz"); + + await harness.ExtractAsync(image); + + var status = harness.Service.GetStatus(); + Assert.Equal(GameFilesState.Ready, status.State); + Assert.Equal(image, harness.GameLocation.Get()); + Assert.Equal("RMCP01", status.Manifest!.GameId); + Assert.Equal(harness.Fs.Path.GetFullPath(image), status.Manifest.SourcePath); + Assert.True(harness.Fs.File.Exists(harness.In("DATA", "sys", "main.dol"))); + Assert.True(harness.Fs.File.Exists(harness.In("disc", "header.bin"))); + // Nothing of the work in progress is left next to it. + Assert.Equal([harness.Game], harness.Siblings().Where(entry => entry.Contains("GAME")).ToArray()); + harness.AssertImagesUntouched(); + } + + [Theory] + [InlineData("iso")] + [InlineData("wbfs")] + [InlineData("gcz")] + [InlineData("ciso")] + [InlineData("rvz")] + public async Task Extract_EveryFormatThePickerOffers(string format) + { + var harness = new GameFilesHarness(); + var image = harness.WriteImage(new SyntheticWiiDisc(), $"mkw.{format}", format); + + await harness.ExtractAsync(image); + + Assert.True(harness.Service.GetStatus().IsReady); + harness.AssertImagesUntouched(); + } + + [Fact] + public async Task Extract_AnotherGame_ChangesNothing() + { + var harness = new GameFilesHarness(); + var image = harness.WriteImage(new SyntheticWiiDisc { GameId = "RSPE01", InternalName = "Wii Sports" }, "sports.iso", "iso"); + + var result = await harness.Service.ExtractAsync(image, replaceExisting: true); + + Assert.True(result.IsFailure); + Assert.False(harness.Fs.Directory.Exists(harness.Game)); + Assert.False(harness.Fs.Directory.Exists(harness.Paths.StagingPath)); + Assert.Equal("", harness.GameLocation.Get()); + harness.AssertImagesUntouched(); + } + + [Fact] + public async Task Extract_AMissingImage_ChangesNothing() + { + var harness = new GameFilesHarness(); + + Assert.True((await harness.Service.ExtractAsync("/nowhere/mkw.iso", false)).IsFailure); + Assert.False(harness.Fs.Directory.Exists(harness.Game)); + } + + [Fact] + public async Task Extract_AnImageKeptInsideTheGameFolder_IsRefused() + { + var harness = new GameFilesHarness(); + var image = harness.WriteImage(); + await harness.ExtractAsync(image); + var inside = harness.In("DATA", "files", "backup.rvz"); + harness.Fs.File.Copy(image, inside); + var other = harness.WriteImage("RMCE01"); + harness.Fs.File.Copy(other, harness.In("DATA", "files", "usa.rvz")); + + Assert.True(harness.Service.Plan(inside).IsFailure); + Assert.True((await harness.Service.ExtractAsync(harness.In("DATA", "files", "usa.rvz"), true)).IsFailure); + Assert.True(harness.Fs.File.Exists(inside)); + Assert.Equal("RMCP01", harness.Manifest()!.GameId); + } + + [Fact] + public async Task Extract_TheSameGameAgain_KeepsEveryChange() + { + var harness = new GameFilesHarness(); + await harness.ExtractAsync(harness.WriteImage()); + harness.Fs.File.WriteAllText(harness.In("DATA", "files", "Scene", "UI", "Title.szs"), "my edit"); + var moved = harness.WriteImage(new SyntheticWiiDisc(), "moved copy.iso", "iso"); + + Assert.Equal(GameChangeKind.SameGame, harness.Service.Plan(moved).Value.Kind); + await harness.ExtractAsync(moved); + + Assert.Equal("my edit", harness.Fs.File.ReadAllText(harness.In("DATA", "files", "Scene", "UI", "Title.szs"))); + Assert.Equal(moved, harness.GameLocation.Get()); + Assert.Equal(harness.Fs.Path.GetFullPath(moved), harness.Manifest()!.SourcePath); + harness.AssertImagesUntouched(); + } + + [Fact] + public async Task Extract_ADifferentGame_NeedsConfirmation() + { + var harness = new GameFilesHarness(); + await harness.ExtractAsync(harness.WriteImage()); + harness.Fs.File.WriteAllText(harness.In("DATA", "files", "Scene", "UI", "Title.szs"), "my edit"); + var usa = harness.WriteImage("RMCE01"); + + Assert.Equal(GameChangeKind.Replace, harness.Service.Plan(usa).Value.Kind); + Assert.True((await harness.Service.ExtractAsync(usa, replaceExisting: false)).IsFailure); + + Assert.Equal("RMCP01", harness.Manifest()!.GameId); + Assert.Equal("my edit", harness.Fs.File.ReadAllText(harness.In("DATA", "files", "Scene", "UI", "Title.szs"))); + } + + [Fact] + public async Task Extract_ADifferentRevision_IsADifferentGame() + { + var harness = new GameFilesHarness(); + await harness.ExtractAsync(harness.WriteImage()); + var revised = harness.WriteImage(new SyntheticWiiDisc { Revision = 1 }, "rev1.rvz"); + + Assert.Equal(GameChangeKind.Replace, harness.Service.Plan(revised).Value.Kind); + } + + [Fact] + public async Task Extract_ReplacingConfirmed_SwapsTheGameButKeepsUnrelatedFiles() + { + var harness = new GameFilesHarness(); + var europe = harness.WriteImage(); + await harness.ExtractAsync(europe); + harness.Fs.File.WriteAllText(harness.In("DATA", "files", "Scene", "UI", "Title.szs"), "my edit"); + harness.Fs.File.WriteAllText(harness.In("notes.txt"), "the user's own file"); + var usa = harness.WriteImage(new SyntheticWiiDisc { GameId = "RMCE01", MainDol = SyntheticWiiDisc.Dol(0x300, 5) }, "usa.rvz"); + + await harness.ExtractAsync(usa, replace: true); + + Assert.Equal("RMCE01", harness.Manifest()!.GameId); + Assert.True(harness.Service.GetStatus().IsReady); + Assert.NotEqual("my edit", harness.Fs.File.ReadAllText(harness.In("DATA", "files", "Scene", "UI", "Title.szs"))); + Assert.Equal("the user's own file", harness.Fs.File.ReadAllText(harness.In("notes.txt"))); + Assert.Equal(usa, harness.GameLocation.Get()); + Assert.Equal([harness.Game], harness.Siblings().Where(entry => entry.Contains("GAME")).ToArray()); + harness.AssertImagesUntouched(); + } + + [Fact] + public async Task Extract_SomethingUnknownInTheWay_IsNeverTouched() + { + var harness = new GameFilesHarness(); + harness.Fs.Directory.CreateDirectory(harness.In("my stuff")); + harness.Fs.File.WriteAllText(harness.In("my stuff", "keep.txt"), "precious"); + var image = harness.WriteImage(); + + Assert.Equal(GameChangeKind.UnknownFolder, harness.Service.Plan(image).Value.Kind); + Assert.True((await harness.Service.ExtractAsync(image, replaceExisting: true)).IsFailure); + + Assert.Equal("precious", harness.Fs.File.ReadAllText(harness.In("my stuff", "keep.txt"))); + Assert.False(harness.Fs.File.Exists(harness.Paths.ManifestPath)); + } + + [Theory] + [InlineData("Dolphin")] + [InlineData("dolphin-emu")] + [InlineData("WiiCompiled")] + [InlineData("RetroRewind")] + public async Task Extract_WhileTheGameRuns_WaitsWithoutTouchingAnything(string process) + { + var harness = new GameFilesHarness(); + var europe = harness.WriteImage(); + await harness.ExtractAsync(europe); + var usa = harness.WriteImage("RMCE01"); + harness.SetRunning(process); + + Assert.True(harness.Service.IsGameRunning()); + Assert.True((await harness.Service.ExtractAsync(usa, replaceExisting: true)).IsFailure); + Assert.Equal("RMCP01", harness.Manifest()!.GameId); + + harness.SetRunning(); + await harness.ExtractAsync(usa, replace: true); + Assert.Equal("RMCE01", harness.Manifest()!.GameId); + } + + [Fact] + public void CloseRunningGame_ClosesDolphinAndWiiCompiled() + { + var harness = new GameFilesHarness(); + + harness.Service.CloseRunningGame(); + + harness.Processes.Received().KillByName("Dolphin"); + harness.Processes.Received().KillByName("WiiCompiled"); + harness.Processes.Received().KillByName("RetroRewind"); + } + + [Fact] + public async Task Extract_NotEnoughSpace_SaysSoAndWritesNothing() + { + var harness = new GameFilesHarness(configure: fs => fs.WithDrive(drive => drive.SetTotalSize(64L * 1024 * 1024))); + var image = harness.WriteImage(); + + var result = await harness.Service.ExtractAsync(image, false); + + Assert.True(result.IsFailure); + Assert.Contains("GB", result.Error.Message); + Assert.False(harness.Fs.Directory.Exists(harness.Game)); + Assert.False(harness.Fs.Directory.Exists(harness.Paths.StagingPath)); + harness.AssertImagesUntouched(); + } + + [Fact] + public async Task Extract_Cancelled_LeavesTheCurrentGameAsItWas() + { + var harness = new GameFilesHarness(); + await harness.ExtractAsync(harness.WriteImage()); + harness.Fs.File.WriteAllText(harness.In("DATA", "files", "Scene", "UI", "Title.szs"), "my edit"); + var usa = harness.WriteImage("RMCE01"); + using var cancellation = new CancellationTokenSource(); + var progress = new SyncProgress(value => + { + if (value.Fraction > 0) + cancellation.Cancel(); + }); + + await Assert.ThrowsAnyAsync( + () => harness.Service.ExtractAsync(usa, true, progress, cancellation.Token) + ); + + Assert.Equal("RMCP01", harness.Manifest()!.GameId); + Assert.Equal("my edit", harness.Fs.File.ReadAllText(harness.In("DATA", "files", "Scene", "UI", "Title.szs"))); + Assert.False(harness.Fs.Directory.Exists(harness.Paths.StagingPath)); + Assert.True(harness.Service.GetStatus().IsReady); + } + + [Fact] + public async Task Extract_ADamagedImage_LeavesTheCurrentGameAsItWas() + { + var harness = new GameFilesHarness(); + await harness.ExtractAsync(harness.WriteImage()); + var full = new SyntheticWiiDisc { GameId = "RMCE01" }.BuildIso(); + // The header and file table are there, the file data is not. + var damaged = harness.Fs.Path.Combine(harness.GamesDir, "damaged.iso"); + harness.Fs.File.WriteAllBytes(damaged, full[..0x7A000]); + + var result = await harness.Service.ExtractAsync(damaged, replaceExisting: true); + + Assert.True(result.IsFailure); + Assert.Equal("RMCP01", harness.Manifest()!.GameId); + Assert.True(harness.Service.GetStatus().IsReady); + Assert.False(harness.Fs.Directory.Exists(harness.Paths.StagingPath)); + } + + [Fact] + public async Task Extract_ReportsProgressWithAMessage() + { + var harness = new GameFilesHarness(); + var reports = new List(); + + Assert.True( + ( + await harness.Service.ExtractAsync(harness.WriteImage(), false, new SyncProgress(reports.Add)) + ).IsSuccess + ); + + Assert.NotEmpty(reports); + Assert.All(reports, report => Assert.False(string.IsNullOrWhiteSpace(report.Message))); + Assert.Equal(1, reports[^1].Fraction); + } + + #endregion + + #region Folder in the way + + [Fact] + public async Task EmptyFolder_IsJustAPlaceToExtractInto() + { + var harness = new GameFilesHarness(); + harness.Fs.Directory.CreateDirectory(harness.Game); + + Assert.Equal(GameFilesState.NoImage, harness.Service.GetStatus().State); + await harness.ExtractAsync(harness.WriteImage()); + Assert.True(harness.Service.GetStatus().IsReady); + } + + [Fact] + public async Task LinkToTheRootOfADrive_IsNeverExtractedInto() + { + var fs = new MockFileSystem(options => options.SimulatingOperatingSystem(SimulationMode.Linux)); + fs.Directory.CreateDirectory("/games"); + fs.File.WriteAllBytes("/games/mkw.rvz", new SyntheticWiiDisc().Build("rvz")); + var paths = Substitute.For(); + paths.RootPath.Returns("/data/GAME"); + paths.ResolvedRootPath.Returns("/"); + paths.ManifestPath.Returns("/data/GAME/" + GameFilesPaths.ManifestFileName); + paths.StagingPath.Returns("/.partial"); + paths.ReplacedPath.Returns("/.replaced"); + var settings = Substitute.For(); + settings.GAME_LOCATION.Returns(new WhWzSetting("GameLocation", "")); + var service = new GameFilesService( + paths, + new DiscExtractor(fs), + settings, + fs, + Substitute.For(), + Substitute.For(), + new WheelWizard.DiscImages.DiscImageService(fs), + Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance + ); + + var result = await service.ExtractAsync("/games/mkw.rvz", true); + + Assert.True(result.IsFailure); + Assert.False(fs.Directory.Exists("/DATA")); + Assert.False(fs.Directory.Exists("/.partial")); + } + + #endregion + + #region Unknown folder + + [Fact] + public void Unknown_MoveAside_KeepsEverythingUnderANewName() + { + var harness = new GameFilesHarness(); + harness.Fs.Directory.CreateDirectory(harness.In("DATA")); + harness.Fs.File.WriteAllText(harness.In("DATA", "keep.bin"), "keep"); + harness.GameLocation.Set(harness.WriteImage(), skipSave: true); + + var first = harness.Service.MoveUnknownAside(); + harness.Fs.Directory.CreateDirectory(harness.Game); + var second = harness.Service.MoveUnknownAside(); + + Assert.True(first.IsSuccess); + Assert.True(second.IsSuccess); + Assert.NotEqual(first.Value, second.Value); + Assert.Equal("keep", harness.Fs.File.ReadAllText(harness.Fs.Path.Combine(first.Value, "DATA", "keep.bin"))); + Assert.Equal(GameFilesState.NeedsExtraction, harness.Service.GetStatus().State); + } + + [Fact] + public void Unknown_DeleteRemovesIt() + { + var harness = new GameFilesHarness(); + harness.Fs.Directory.CreateDirectory(harness.In("DATA", "sys")); + harness.Fs.File.WriteAllText(harness.In("DATA", "sys", "main.dol"), "x"); + harness.Fs.FileInfo.New(harness.In("DATA", "sys", "main.dol")).IsReadOnly = true; + + Assert.True(harness.Service.DeleteUnknown().IsSuccess); + Assert.False(harness.Fs.Directory.Exists(harness.Game)); + } + + [Fact] + public async Task Unknown_ActionsRefuseAFolderWheelWizardOwns() + { + var harness = new GameFilesHarness(); + await harness.ExtractAsync(harness.WriteImage()); + + Assert.True(harness.Service.DeleteUnknown().IsFailure); + Assert.True(harness.Service.MoveUnknownAside().IsFailure); + Assert.True(harness.Service.GetStatus().IsReady); + } + + #endregion + + #region Interrupted work + + [Fact] + public void Recover_AnUnfinishedExtraction_IsRemoved() + { + var harness = new GameFilesHarness(); + var staging = harness.Paths.StagingPath; + harness.Fs.Directory.CreateDirectory(harness.Fs.Path.Combine(staging, "DATA", "files")); + harness.Fs.File.WriteAllText(harness.Fs.Path.Combine(staging, GameFilesPaths.WorkMarkerFileName), ""); + harness.Fs.File.WriteAllText(harness.Fs.Path.Combine(staging, "DATA", "files", "half.szs"), "half"); + + harness.Service.RecoverInterruptedWork(); + + Assert.False(harness.Fs.Directory.Exists(staging)); + } + + [Fact] + public async Task Recover_NeverDeletesAFolderWithoutItsMarker() + { + var harness = new GameFilesHarness(); + harness.Fs.Directory.CreateDirectory(harness.Paths.StagingPath); + harness.Fs.File.WriteAllText(harness.Fs.Path.Combine(harness.Paths.StagingPath, "mine.txt"), "mine"); + harness.Fs.Directory.CreateDirectory(harness.Paths.ReplacedPath); + harness.Fs.File.WriteAllText(harness.Fs.Path.Combine(harness.Paths.ReplacedPath, "mine.txt"), "mine"); + + harness.Service.RecoverInterruptedWork(); + var result = await harness.Service.ExtractAsync(harness.WriteImage(), false); + + Assert.True(result.IsFailure); + Assert.Equal("mine", harness.Fs.File.ReadAllText(harness.Fs.Path.Combine(harness.Paths.StagingPath, "mine.txt"))); + Assert.Equal("mine", harness.Fs.File.ReadAllText(harness.Fs.Path.Combine(harness.Paths.ReplacedPath, "mine.txt"))); + } + + [Fact] + public async Task Recover_ACrashRightAfterTheOldManifestLeft_FinishesTheSwap() + { + var harness = new GameFilesHarness(); + await harness.ExtractAsync(harness.WriteImage()); + var usa = harness.WriteImage("RMCE01"); + harness.Stage(usa, "RMCE01"); + // The swap moved the old manifest out, then the power went. + harness.Fs.Directory.CreateDirectory(harness.Paths.ReplacedPath); + harness.Fs.File.WriteAllText(harness.Fs.Path.Combine(harness.Paths.ReplacedPath, GameFilesPaths.WorkMarkerFileName), ""); + harness.Fs.File.Move( + harness.Paths.ManifestPath, + harness.Fs.Path.Combine(harness.Paths.ReplacedPath, GameFilesPaths.ManifestFileName) + ); + Assert.Equal(GameFilesState.UnknownFolder, harness.Service.GetStatus().State); + + harness.Service.RecoverInterruptedWork(); + + Assert.Equal("RMCE01", harness.Manifest()!.GameId); + Assert.True(harness.Service.GetStatus().IsReady); + Assert.Equal(usa, harness.GameLocation.Get()); + Assert.False(harness.Fs.Directory.Exists(harness.Paths.StagingPath)); + Assert.False(harness.Fs.Directory.Exists(harness.Paths.ReplacedPath)); + } + + [Fact] + public async Task Recover_ACrashHalfwayThroughTheMoves_FinishesTheSwap() + { + var harness = new GameFilesHarness(); + await harness.ExtractAsync(harness.WriteImage()); + harness.Stage(harness.WriteImage("RMCE01"), "RMCE01"); + var replaced = harness.Paths.ReplacedPath; + harness.Fs.Directory.CreateDirectory(replaced); + harness.Fs.File.WriteAllText(harness.Fs.Path.Combine(replaced, GameFilesPaths.WorkMarkerFileName), ""); + harness.Fs.File.Move(harness.Paths.ManifestPath, harness.Fs.Path.Combine(replaced, GameFilesPaths.ManifestFileName)); + harness.Fs.Directory.Move(harness.In("DATA"), harness.Fs.Path.Combine(replaced, "DATA")); + harness.Fs.Directory.Move(harness.Fs.Path.Combine(harness.Paths.StagingPath, "DATA"), harness.In("DATA")); + + harness.Service.RecoverInterruptedWork(); + + Assert.Equal("RMCE01", harness.Manifest()!.GameId); + Assert.True(harness.Service.GetStatus().IsReady); + Assert.True(harness.Fs.File.Exists(harness.In("disc", "header.bin"))); + Assert.Equal("RMCE01", System.Text.Encoding.ASCII.GetString(harness.Fs.File.ReadAllBytes(harness.In("disc", "header.bin"))[..6])); + Assert.False(harness.Fs.Directory.Exists(replaced)); + } + + [Fact] + public async Task Recover_ACrashBeforeCleanup_RemovesOnlyTheLeftovers() + { + var harness = new GameFilesHarness(); + await harness.ExtractAsync(harness.WriteImage()); + var replaced = harness.Paths.ReplacedPath; + harness.Fs.Directory.CreateDirectory(harness.Fs.Path.Combine(replaced, "DATA")); + harness.Fs.File.WriteAllText(harness.Fs.Path.Combine(replaced, GameFilesPaths.WorkMarkerFileName), ""); + + harness.Service.RecoverInterruptedWork(); + + Assert.False(harness.Fs.Directory.Exists(replaced)); + Assert.True(harness.Service.GetStatus().IsReady); + } + + #endregion + + #region Restoring the game code + + [Fact] + public async Task Restore_PutsBackOnlyTheGameCode() + { + var harness = new GameFilesHarness(); + var disc = new SyntheticWiiDisc(); + var image = harness.WriteImage(disc); + await harness.ExtractAsync(image); + harness.Fs.File.WriteAllText(harness.In("DATA", "sys", "main.dol"), "patched"); + harness.Fs.File.WriteAllText(harness.In("DATA", "files", "Scene", "UI", "Title.szs"), "my edit"); + Assert.Equal(GameFilesState.CodeModified, harness.Service.GetStatus().State); + + var result = await harness.Service.RestoreCodeAsync(); + + Assert.True(result.IsSuccess, result.IsFailure ? result.Error.Message : null); + Assert.True(harness.Service.GetStatus().IsReady); + Assert.Equal(disc.MainDol, harness.Fs.File.ReadAllBytes(harness.In("DATA", "sys", "main.dol"))); + Assert.Equal("my edit", harness.Fs.File.ReadAllText(harness.In("DATA", "files", "Scene", "UI", "Title.szs"))); + harness.AssertImagesUntouched(); + } + + [Fact] + public async Task Restore_FromAnotherRegion_IsRefused() + { + var harness = new GameFilesHarness(); + await harness.ExtractAsync(harness.WriteImage()); + harness.Fs.File.WriteAllText(harness.In("DATA", "sys", "main.dol"), "patched"); + + var result = await harness.Service.RestoreCodeAsync(harness.WriteImage("RMCK01")); + + Assert.True(result.IsFailure); + Assert.Equal("patched", harness.Fs.File.ReadAllText(harness.In("DATA", "sys", "main.dol"))); + } + + [Fact] + public async Task Restore_WithTheImageGone_AsksForOne() + { + var harness = new GameFilesHarness(); + var image = harness.WriteImage(); + await harness.ExtractAsync(image); + harness.Fs.File.WriteAllText(harness.In("DATA", "sys", "main.dol"), "patched"); + harness.Fs.File.Delete(image); + + Assert.True((await harness.Service.RestoreCodeAsync()).IsFailure); + + var copy = harness.WriteImage(new SyntheticWiiDisc(), "found it again.iso", "iso"); + Assert.True((await harness.Service.RestoreCodeAsync(copy)).IsSuccess); + Assert.True(harness.Service.GetStatus().IsReady); + Assert.Equal(copy, harness.GameLocation.Get()); + } + + [Fact] + public async Task Restore_WhileTheGameRuns_ChangesNothing() + { + var harness = new GameFilesHarness(); + await harness.ExtractAsync(harness.WriteImage()); + harness.Fs.File.WriteAllText(harness.In("DATA", "sys", "main.dol"), "patched"); + harness.SetRunning("Dolphin"); + + Assert.True((await harness.Service.RestoreCodeAsync()).IsFailure); + Assert.Equal("patched", harness.Fs.File.ReadAllText(harness.In("DATA", "sys", "main.dol"))); + } + + #endregion + + [Fact] + public void Region_ComesFromTheImageBeforeExtracting() + { + var harness = new GameFilesHarness(); + harness.GameLocation.Set(harness.WriteImage("RMCJ01"), skipSave: true); + + Assert.Equal(MarioKartWiiEnums.Regions.Japan, harness.Service.GetRegion()); + } + + [Fact] + public void Region_IsNoneWithoutAGame() + { + Assert.Equal(MarioKartWiiEnums.Regions.None, new GameFilesHarness().Service.GetRegion()); + } +} diff --git a/WheelWizard.Test/Features/GameFiles/SyntheticWiiDisc.cs b/WheelWizard.Test/Features/GameFiles/SyntheticWiiDisc.cs new file mode 100644 index 000000000..3626db884 --- /dev/null +++ b/WheelWizard.Test/Features/GameFiles/SyntheticWiiDisc.cs @@ -0,0 +1,264 @@ +using System.Buffers.Binary; +using System.Security.Cryptography; +using System.Text; +using RVZSharp; +using RVZSharp.Blobs; +using RVZSharp.Models; +using RVZSharp.Wii; + +namespace WheelWizard.Test.Features.GameFiles; + +/// +/// Builds a tiny but real Wii disc image: a partition table, an unencrypted partition header (ticket, TMD, +/// certificates, H3 table) and a game partition encrypted with the ticket's title key, holding a boot header, +/// apploader, main.dol, FST and files. Small enough to build per test, real enough that RVZSharp reads it exactly +/// like a retail disc, so extraction runs end to end without any copyrighted data. +/// +internal sealed class SyntheticWiiDisc +{ + private const int Cluster = 0x8000; + private const int Payload = 0x7C00; + private const long PartitionOffset = 0x50000; + private const long PartitionDataOffset = 0x20000; + + public string GameId { get; init; } = "RMCP01"; + public byte Revision { get; init; } + public string InternalName { get; init; } = "MARIO KART WII"; + + /// Files by their path on the disc; directories come from the paths. + public Dictionary Files { get; init; } = + new() + { + ["rel/StaticR.rel"] = Bytes(5000, 1), + ["Race/Course/beginner_course.szs"] = Encoding.ASCII.GetBytes("course data"), + ["Scene/UI/Title.szs"] = Bytes(1234, 7), + ["empty.bin"] = [], + }; + + public byte[] MainDol { get; init; } = Dol(0x200, 3); + + public static byte[] Bytes(int length, int seed) => Enumerable.Range(0, length).Select(index => (byte)(index * seed + seed)).ToArray(); + + public static byte[] Dol(int textSize, int seed) + { + var dol = new byte[0x100 + textSize]; + BinaryPrimitives.WriteUInt32BigEndian(dol.AsSpan(0x0), 0x100); + BinaryPrimitives.WriteUInt32BigEndian(dol.AsSpan(0x48), 0x80004000); + BinaryPrimitives.WriteUInt32BigEndian(dol.AsSpan(0x90), (uint)textSize); + for (var index = 0x100; index < dol.Length; index++) + dol[index] = (byte)(index * seed); + return dol; + } + + public static string Sha256(byte[] data) => Convert.ToHexString(SHA256.HashData(data)).ToLowerInvariant(); + + public byte[] Ticket { get; private set; } = []; + public byte[] Tmd { get; } = Bytes(0x208, 3); + public byte[] Certificates { get; } = Bytes(0xA00, 9); + public byte[] H3 { get; } = Bytes(0x18000, 5); + public byte[] Bi2 { get; } = Bytes(0x2000, 11); + public byte[] Apploader { get; private set; } = []; + public byte[] Fst { get; private set; } = []; + public byte[] Boot { get; private set; } = []; + + /// The image as a plain ISO. + public byte[] BuildIso() + { + var (plain, fst) = BuildPartitionData(); + Fst = fst; + var clusters = plain.Length / Payload; + var disc = new byte[PartitionOffset + PartitionDataOffset + (long)clusters * Cluster]; + + WriteHeader(disc, 0); + // Partition table: one group of one partition, the game partition (type 0). + WriteUInt32(disc, 0x40000, 1); + WriteUInt32(disc, 0x40004, 0x40020 >> 2); + WriteUInt32(disc, 0x40020, (uint)(PartitionOffset >> 2)); + WriteUInt32(disc, 0x40024, 0); + WriteUInt32(disc, 0x4E000, 2); + + var ticket = new byte[0x2A4]; + WriteUInt32(ticket, 0, 0x00010001); + for (var index = 0; index < 16; index++) + ticket[0x1BF + index] = (byte)(index * 13 + 1); + WriteUInt32(ticket, 0x1DC, 0x00010000); + Encoding.ASCII.GetBytes(GameId[..4]).CopyTo(ticket, 0x1E0); + Ticket = ticket; + ticket.CopyTo(disc, (int)PartitionOffset); + + var header = (int)PartitionOffset; + WriteUInt32(disc, header + 0x2A4, (uint)Tmd.Length); + WriteUInt32(disc, header + 0x2A8, 0x2C0 >> 2); + WriteUInt32(disc, header + 0x2AC, (uint)Certificates.Length); + WriteUInt32(disc, header + 0x2B0, 0x500 >> 2); + WriteUInt32(disc, header + 0x2B4, 0x8000 >> 2); + WriteUInt32(disc, header + 0x2B8, (uint)(PartitionDataOffset >> 2)); + WriteUInt32(disc, header + 0x2BC, (uint)((long)clusters * Cluster >> 2)); + Tmd.CopyTo(disc, header + 0x2C0); + Certificates.CopyTo(disc, header + 0x500); + H3.CopyTo(disc, header + 0x8000); + + // Clusters: an encrypted (here empty) hash block, then the data encrypted with an IV taken from it. + using var aes = Aes.Create(); + aes.Key = WiiVolume.GetTitleKey(ticket); + for (var cluster = 0; cluster < clusters; cluster++) + { + var hashes = aes.EncryptCbc(new byte[0x400], new byte[16], PaddingMode.None); + var data = aes.EncryptCbc(plain.AsSpan(cluster * Payload, Payload), hashes.AsSpan(0x3D0, 16), PaddingMode.None); + var offset = (int)(PartitionOffset + PartitionDataOffset + (long)cluster * Cluster); + hashes.CopyTo(disc, offset); + data.CopyTo(disc, offset + 0x400); + } + + return disc; + } + + /// The same disc in another container, written by RVZSharp's own encoders. + public byte[] Build(string format) + { + var iso = BuildIso(); + if (format == "iso") + return iso; + + using var reader = Blob.Open(new MemoryStream(iso), "disc.iso", leaveOpen: false); + using var output = new MemoryStream(); + switch (format) + { + case "wbfs": + WbfsWriter.Write(reader, output, WbfsWriteOptions.Default, null!, CancellationToken.None); + break; + case "gcz": + GczWriter.Write(reader, output, new GczWriteOptions(), null!, CancellationToken.None); + break; + case "ciso": + CisoWriter.Write(reader, output, new CisoWriteOptions(), null!, CancellationToken.None); + break; + case "rvz": + RvzWriter.Write(reader, output, RvzWriteOptions.Default, null!, CancellationToken.None); + break; + default: + throw new ArgumentOutOfRangeException(nameof(format), format, null); + } + + return output.ToArray(); + } + + private (byte[] Plain, byte[] Fst) BuildPartitionData() + { + const long dolOffset = 0x10000; + const long fstOffset = 0x11000; + var (fst, placed, end) = BuildFst(firstDataOffset: 0x20000); + + var size = (end + Payload - 1) / Payload * Payload; + var plain = new byte[size]; + var boot = new byte[0x440]; + WriteHeader(boot, 0); + WriteUInt32(boot, 0x420, (uint)(dolOffset >> 2)); + WriteUInt32(boot, 0x424, (uint)(fstOffset >> 2)); + WriteUInt32(boot, 0x428, (uint)((fst.Length + 3) >> 2)); + WriteUInt32(boot, 0x42C, (uint)((fst.Length + 3) >> 2)); + Boot = boot; + boot.CopyTo(plain, 0); + Bi2.CopyTo(plain, 0x440); + + var apploader = new byte[0x20 + 0x40 + 0x10]; + Encoding.ASCII.GetBytes("2008/04/01").CopyTo(apploader, 0); + WriteUInt32(apploader, 0x14, 0x40); + WriteUInt32(apploader, 0x18, 0x10); + for (var index = 0x20; index < apploader.Length; index++) + apploader[index] = (byte)index; + Apploader = apploader; + apploader.CopyTo(plain, 0x2440); + + MainDol.CopyTo(plain, (int)dolOffset); + fst.CopyTo(plain, (int)fstOffset); + foreach (var (offset, data) in placed) + data.CopyTo(plain, (int)offset); + + // The FST is stored rounded up to four bytes, and that is what a dump contains. + var storedFst = new byte[(fst.Length + 3) & ~3]; + fst.CopyTo(storedFst, 0); + return (plain, storedFst); + } + + private sealed class Node + { + public SortedDictionary Directories { get; } = new(StringComparer.Ordinal); + public SortedDictionary Files { get; } = new(StringComparer.Ordinal); + } + + private (byte[] Fst, List<(long Offset, byte[] Data)> Placed, long End) BuildFst(long firstDataOffset) + { + var root = new Node(); + foreach (var (path, data) in Files) + { + var parts = path.Split('/'); + var node = root; + foreach (var directory in parts[..^1]) + { + if (!node.Directories.TryGetValue(directory, out var child)) + node.Directories[directory] = child = new Node(); + node = child; + } + + node.Files[parts[^1]] = data; + } + + var names = new MemoryStream(); + var entries = new List<(bool Directory, uint Name, uint A, uint B)> { (true, 0, 0, 0) }; + var placed = new List<(long, byte[])>(); + var cursor = firstDataOffset; + + uint Name(string name) + { + var offset = (uint)names.Length; + names.Write(Encoding.ASCII.GetBytes(name)); + names.WriteByte(0); + return offset; + } + + void Add(Node node, int parent) + { + foreach (var (name, data) in node.Files) + { + placed.Add((cursor, data)); + entries.Add((false, Name(name), (uint)(cursor >> 2), (uint)data.Length)); + cursor = (cursor + data.Length + 0x3F) & ~0x3FL; + } + + foreach (var (name, child) in node.Directories) + { + var index = entries.Count; + entries.Add((true, Name(name), (uint)parent, 0)); + Add(child, index); + entries[index] = (true, entries[index].Name, (uint)parent, (uint)entries.Count); + } + } + + Add(root, 0); + entries[0] = (true, 0, 0, (uint)entries.Count); + + var fst = new byte[entries.Count * 12 + names.Length]; + for (var index = 0; index < entries.Count; index++) + { + var (isDirectory, name, a, b) = entries[index]; + WriteUInt32(fst, index * 12, (isDirectory ? 1u << 24 : 0) | name); + WriteUInt32(fst, index * 12 + 4, a); + WriteUInt32(fst, index * 12 + 8, b); + } + + names.ToArray().CopyTo(fst, entries.Count * 12); + return (fst, placed, cursor); + } + + private void WriteHeader(byte[] target, int offset) + { + Encoding.ASCII.GetBytes(GameId).CopyTo(target, offset); + target[offset + 7] = Revision; + WriteUInt32(target, offset + 0x18, 0x5D1C9EA3); + Encoding.ASCII.GetBytes(InternalName).CopyTo(target, offset + 0x20); + } + + private static void WriteUInt32(byte[] target, int offset, uint value) => + BinaryPrimitives.WriteUInt32BigEndian(target.AsSpan(offset), value); +} diff --git a/WheelWizard.Test/Features/Launching/DolphinLaunchServiceTests.cs b/WheelWizard.Test/Features/Launching/DolphinLaunchServiceTests.cs index d9c7dbd4b..9c95e020d 100644 --- a/WheelWizard.Test/Features/Launching/DolphinLaunchServiceTests.cs +++ b/WheelWizard.Test/Features/Launching/DolphinLaunchServiceTests.cs @@ -3,6 +3,7 @@ using WheelWizard.CustomDistributions; using WheelWizard.Dolphin.Paths; using WheelWizard.DolphinInstaller; +using WheelWizard.GameFiles; using WheelWizard.Launching; using WheelWizard.Settings; using WheelWizard.Settings.Types; @@ -108,8 +109,7 @@ public async Task StartFailure_ReturnsErrorAndShowsReason() [InlineData(1, true)] public async Task PortalExport_OnlySkipsFilesystemPermissionAfterSuccessfulExit(int exitCode, bool expectedFallback) { - var fixture = new Fixture(command: "flatpak run org.DolphinEmu.dolphin-emu"); - fixture.GamePath.Set("/run/user/1000/doc/game/game.iso", skipSave: true); + var fixture = new Fixture(command: "flatpak run org.DolphinEmu.dolphin-emu", distributionRoot: "/run/user/1000/doc/rr"); fixture .LinuxProcesses.Run("flatpak", Arg.Any>(), out Arg.Any(), out Arg.Any()) .Returns(Ok(exitCode)); @@ -119,17 +119,48 @@ public async Task PortalExport_OnlySkipsFilesystemPermissionAfterSuccessfulExit( Assert.True((await fixture.Service.LaunchDolphin(versionPreflightResult: Ok())).IsSuccess); Assert.NotNull(captured); - Assert.Equal(expectedFallback, captured.ArgumentList[^1].Contains("--filesystem='/run/user/1000/doc/game/game.iso':ro")); + Assert.Equal(expectedFallback, captured.ArgumentList[^1].Contains("--filesystem='/run/user/1000/doc/rr':rw")); fixture .LinuxProcesses.Received(1) .Run( "flatpak", - Arg.Is>(args => args.Contains("/run/user/1000/doc/game/game.iso")), + Arg.Is>(args => args.Contains("/run/user/1000/doc/rr")), out Arg.Any(), out Arg.Any() ); } + [Fact] + public async Task FlatpakDolphin_GetsReadOnlyAccessToTheWholeExtractedGame() + { + var fixture = new Fixture(command: "flatpak run org.DolphinEmu.dolphin-emu"); + ProcessStartInfo? captured = null; + fixture.Processes.When(process => process.Start(Arg.Any())).Do(call => captured = call.Arg()); + + Assert.True((await fixture.Service.LaunchDolphin(versionPreflightResult: Ok())).IsSuccess); + + Assert.NotNull(captured); + // Dolphin boots DATA/sys/main.dol but reads disc/ and the rest of DATA/ around it. + Assert.Contains("--filesystem='/data/GAME':ro", captured.ArgumentList[^1]); + Assert.DoesNotContain("game.iso", captured.ArgumentList[^1]); + } + + [Fact] + public async Task FlatpakDolphin_AlsoGetsTheFolderALinkedGamePointsTo() + { + var fixture = new Fixture(command: "flatpak run org.DolphinEmu.dolphin-emu"); + fixture.GameFiles.IsLinked.Returns(true); + fixture.GameFiles.ResolvedRootPath.Returns("/mnt/big/GAME"); + ProcessStartInfo? captured = null; + fixture.Processes.When(process => process.Start(Arg.Any())).Do(call => captured = call.Arg()); + + Assert.True((await fixture.Service.LaunchDolphin(versionPreflightResult: Ok())).IsSuccess); + + Assert.NotNull(captured); + Assert.Contains("--filesystem='/data/GAME':ro", captured.ArgumentList[^1]); + Assert.Contains("--filesystem='/mnt/big/GAME':ro", captured.ArgumentList[^1]); + } + [Fact] public async Task FlatpakUpdate_UsesConfiguredAppIdAndStillBlocksLaunch() { @@ -154,10 +185,10 @@ private sealed class Fixture public IDolphinVersionService Versions { get; } = Substitute.For(); public ILinuxDolphinInstaller Installer { get; } = Substitute.For(); public IDolphinLaunchPresentation Presentation { get; } = Substitute.For(); - public WhWzSetting GamePath { get; } = new("GamePath", "/game.iso"); + public IGameFilesPaths GameFiles { get; } = Substitute.For(); public DolphinLaunchService Service { get; } - public Fixture(bool windows = false, string command = "dolphin-emu") + public Fixture(bool windows = false, string command = "dolphin-emu", string distributionRoot = "/distribution") { var fs = new MockFileSystem(options => options.SimulatingOperatingSystem(windows ? SimulationMode.Windows : SimulationMode.Linux) @@ -168,19 +199,18 @@ public Fixture(bool windows = false, string command = "dolphin-emu") environment.GetFolderPath(Environment.SpecialFolder.UserProfile).Returns("/home/test"); environment.GetFolderPath(Environment.SpecialFolder.ApplicationData).Returns("/home/test/.config"); environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData).Returns("/home/test/.local/share"); - var settings = Substitute.For(); - settings.GAME_LOCATION.Returns(GamePath); - settings.Get(GamePath).Returns(_ => (string)GamePath.Get()); + GameFiles.RootPath.Returns("/data/GAME"); + GameFiles.ResolvedRootPath.Returns("/data/GAME"); var paths = Substitute.For(); var user = windows ? @"C:\Dolphin User" : "/dolphin-user"; paths.UserFolderPath.Returns(user); paths.ExecutablePath.Returns(windows ? @"C:\Dolphin\Dolphin.exe" : command); paths.Layout.Returns(new DolphinPathLayout(fs.Path, environment, false, command, user)); var distribution = Substitute.For(); - distribution.RootFolderPath.Returns("/distribution"); + distribution.RootFolderPath.Returns(distributionRoot); distribution.LaunchJsonFilePath.Returns("/distribution/RR.json"); Versions.CheckConfiguredDolphin().Returns((DolphinVersionStatus.Supported, "2606a")); - Service = new(settings, paths, distribution, fs, environment, Processes, LinuxProcesses, Versions, Installer, Presentation); + Service = new(paths, distribution, fs, environment, Processes, LinuxProcesses, Versions, Installer, Presentation, GameFiles); } } } diff --git a/WheelWizard.Test/Features/Launching/LaunchPathTests.cs b/WheelWizard.Test/Features/Launching/LaunchPathTests.cs index 584ef393f..49a1d3ae1 100644 --- a/WheelWizard.Test/Features/Launching/LaunchPathTests.cs +++ b/WheelWizard.Test/Features/Launching/LaunchPathTests.cs @@ -3,6 +3,7 @@ using WheelWizard.ApplicationData; using WheelWizard.CustomDistributions; using WheelWizard.Dolphin.Paths; +using WheelWizard.GameFiles; using WheelWizard.Launching; using WheelWizard.Mods; using WheelWizard.Settings; @@ -22,12 +23,13 @@ public void Descriptor_PreservesGameRootAndDistributionOptions(bool beta, string { var fs = new MockFileSystem(options => options.SimulatingOperatingSystem(SimulationMode.Linux)); fs.Directory.CreateDirectory("/output"); - var settings = CreateSettings(); var paths = Substitute.For(); paths.LaunchJsonFilePath.Returns("/output/RR.json"); paths.RootFolderPath.Returns("/distribution"); paths.XmlFilePath.Returns("/distribution/RR.xml"); - var descriptor = new RetroRewindLaunchDescriptor(fs, settings, paths); + var gameFiles = Substitute.For(); + gameFiles.MainDolPath.Returns("/data/GAME/DATA/sys/main.dol"); + var descriptor = new RetroRewindLaunchDescriptor(fs, paths, gameFiles); if (beta) descriptor.GenerateLaunchJson("/distribution/RRBeta.xml"); @@ -36,7 +38,8 @@ public void Descriptor_PreservesGameRootAndDistributionOptions(bool beta, string using var json = JsonDocument.Parse(fs.File.ReadAllText("/output/RR.json")); var root = json.RootElement; - Assert.Equal("/game.iso", root.GetProperty("base-file").GetString()); + // Dolphin boots the extracted game, never the image it came from. + Assert.Equal("/data/GAME/DATA/sys/main.dol", root.GetProperty("base-file").GetString()); Assert.Equal("dolphin-game-mod-descriptor", root.GetProperty("type").GetString()); var patch = root.GetProperty("riivolution").GetProperty("patches")[0]; Assert.Equal("/distribution", patch.GetProperty("root").GetString()); @@ -81,7 +84,8 @@ public async Task BlockedDistributionPreflight_DoesNotKillPrepareOrWrite(bool be descriptor, environment, Substitute.For(), - new InlineModPresentation() + new InlineModPresentation(), + ReadyGame() ); Assert.True((await launcher.LaunchAsync(beta)).IsFailure); @@ -91,6 +95,13 @@ public async Task BlockedDistributionPreflight_DoesNotKillPrepareOrWrite(bool be Assert.Empty(descriptor.ReceivedCalls()); } + private static IGameFilesService ReadyGame() + { + var gameFiles = Substitute.For(); + gameFiles.GetStatus().Returns(new GameFilesStatus(GameFilesState.Ready, null, "/game.iso")); + return gameFiles; + } + private static ISettingsManager CreateSettings() { var settings = Substitute.For(); diff --git a/WheelWizard.Test/Features/Launching/RetroRewindLaunchServiceTests.cs b/WheelWizard.Test/Features/Launching/RetroRewindLaunchServiceTests.cs index 3d7492d40..0481f74bb 100644 --- a/WheelWizard.Test/Features/Launching/RetroRewindLaunchServiceTests.cs +++ b/WheelWizard.Test/Features/Launching/RetroRewindLaunchServiceTests.cs @@ -1,6 +1,7 @@ using Testably.Abstractions.Testing; using WheelWizard.CustomDistributions; using WheelWizard.Dolphin.Paths; +using WheelWizard.GameFiles; using WheelWizard.Launching; using WheelWizard.Mods; using WheelWizard.Recomp; @@ -38,6 +39,31 @@ await fixture ); } + [Theory] + [InlineData(GameFilesState.CodeModified)] + [InlineData(GameFilesState.UnknownFolder)] + [InlineData(GameFilesState.ImageMissing)] + public async Task GameFilesNotReady_NeverStartsDolphin(GameFilesState state) + { + var fixture = new Fixture(); + fixture.GameFiles.GetStatus().Returns(new GameFilesStatus(state, null, "/game.iso")); + + Assert.True((await fixture.Service.LaunchAsync()).IsFailure); + + Assert.Empty(fixture.Dolphin.ReceivedCalls()); + Assert.Empty(fixture.Mods.ReceivedCalls()); + } + + [Fact] + public async Task DeletedImage_StillLaunchesOnceExtracted() + { + var fixture = new Fixture(); + fixture.Fs.File.Delete("/game.iso"); + + Assert.True((await fixture.Service.LaunchAsync()).IsSuccess); + await fixture.Dolphin.Received(1).LaunchDolphin(Arg.Any(), Arg.Any(), Arg.Any()); + } + [Fact] public async Task FailedModPreparation_DoesNotWriteDescriptorOrStartGame() { @@ -56,7 +82,7 @@ public async Task FailedModPreparation_DoesNotWriteDescriptorOrStartGame() public async Task MissingBetaGame_StopsBeforeVersionCheckOrPreparation() { var fixture = new Fixture(); - fixture.Fs.File.Delete("/game.iso"); + fixture.GameFiles.GetStatus().Returns(new GameFilesStatus(GameFilesState.NeedsExtraction, null, "/game.iso")); Assert.True((await fixture.Service.LaunchAsync(true)).IsFailure); @@ -115,11 +141,13 @@ private sealed class Fixture public IModsLaunchService Mods { get; } = Substitute.For(); public IRetroRewindLaunchDescriptor Descriptor { get; } = Substitute.For(); public IWiiRemoteConfigurationService Remotes { get; } = Substitute.For(); + public IGameFilesService GameFiles { get; } = Substitute.For(); public ILaunchPrompts Prompts { get; } = Substitute.For(); public RetroRewindLaunchService Service { get; } public Fixture() { + GameFiles.GetStatus().Returns(new GameFilesStatus(GameFilesState.Ready, null, "/game.iso")); Fs.File.WriteAllText("/game.iso", "game"); var settings = Substitute.For(); settings.GAME_LOCATION.Returns(new WhWzSetting("Game", "/game.iso")); @@ -149,7 +177,8 @@ public Fixture() Descriptor, Substitute.For(), Prompts, - new InlineModPresentation() + new InlineModPresentation(), + GameFiles ); } } diff --git a/WheelWizard.Test/Features/Recomp/RecompGameDirTests.cs b/WheelWizard.Test/Features/Recomp/RecompGameDirTests.cs new file mode 100644 index 000000000..61c5aabe0 --- /dev/null +++ b/WheelWizard.Test/Features/Recomp/RecompGameDirTests.cs @@ -0,0 +1,279 @@ +using System.Text.Json; +using Microsoft.Extensions.Logging.Abstractions; +using Testably.Abstractions.Testing; +using WheelWizard.GitHub; +using WheelWizard.GitHub.Domain; +using WheelWizard.Models.Enums; +using WheelWizard.Recomp; +using WheelWizard.Recomp.Domain; +using WheelWizard.Settings; +using WheelWizard.Shared; + +namespace WheelWizard.Test.Features.Recomp; + +/// +/// WiiCompiled reads Wheel Wizard's extracted game in place. These pin the contract with its setup (the +/// capability probe and the link command) and what Wheel Wizard does with an installation that still reads a game +/// copy of its own. +/// +public class RecompGameDirTests +{ + [Theory] + [InlineData("""{"type":"capabilities","capabilities":["game-dir"]}""", true)] + [InlineData("""{"type":"capabilities","capabilities":["GAME-DIR","other"]}""", true)] + [InlineData("""{"type":"capabilities","capabilities":[]}""", false)] + [InlineData("""{"type":"capabilities"}""", false)] + [InlineData("""{"type":"capabilities","capabilities":[1,null]}""", false)] + public void Capabilities_AreParsed(string line, bool gameDir) + { + var parsed = Assert.IsType(RecompSetupOutputParser.Parse(line)); + Assert.Equal(gameDir, parsed.Has(RecompCapabilitiesEvent.GameDir)); + } + + [Fact] + public void LinkCommand_NamesTheInstallationAndTheExtractedGame() + { + Assert.Equal( + ["--link-game-dir", "--install-dir", @"C:\WW\Recomp\Install", "--game-dir", @"C:\WW\GAME", "--progress-json"], + RecompSetupCommandBuilder.BuildLinkGameDirArguments(@"C:\WW\Recomp\Install", @"C:\WW\GAME") + ); + Assert.Equal( + ["link-game-dir", "--game-dir", "/data/GAME", "--progress-json"], + RecompLinuxSetupCommandBuilder.BuildLinkGameDirArguments("/data/GAME") + ); + Assert.Equal(["--capabilities"], RecompSetupCommandBuilder.BuildCapabilitiesArguments()); + } + + [Fact] + public void SilentInstall_NeverPassesAnImage() + { + var arguments = RecompSetupCommandBuilder.BuildSilentInstallArguments( + new() { GameFolderPath = @"C:\WW\GAME", InstallFolderPath = @"C:\WW\Recomp\Install" } + ); + + Assert.Contains("--game-dir", arguments); + Assert.DoesNotContain("--game", arguments); + } + + [Fact] + public async Task SupportsGameDir_AnOldSetupThatRejectsTheProbe_DoesNot() + { + var runner = Substitute.For(); + runner + .RunAsync( + "setup.exe", + Arg.Any>(), + Arg.Any(), + Arg.Any?>(), + Arg.Any() + ) + .Returns(Ok(2)); + var hosts = Hosts(runner); + + Assert.False(await hosts.SupportsGameDirAsync("setup.exe", CancellationToken.None)); + } + + [Fact] + public async Task SupportsGameDir_ASetupThatSaysSo_Does() + { + var runner = Substitute.For(); + runner + .RunAsync( + "setup.exe", + Arg.Any>(), + Arg.Any(), + Arg.Any?>(), + Arg.Any() + ) + .Returns(call => + { + call.ArgAt?>(3)?.Invoke("""{"type":"capabilities","capabilities":["game-dir"]}"""); + return Ok(0); + }); + + Assert.True(await Hosts(runner).SupportsGameDirAsync("setup.exe", CancellationToken.None)); + } + + [Fact] + public async Task Status_AnInstallationReadingItsOwnCopy_AsksForAnUpdate() + { + var fixture = new Fixture(supportsGameDir: false); + + Assert.Equal(WheelWizardStatus.OutOfDate, await fixture.Service.GetCurrentStatusAsync()); + } + + [Fact] + public async Task Link_AnOldSetup_FailsAndChangesNothing() + { + var fixture = new Fixture(supportsGameDir: false); + + var result = await fixture.Service.LinkGameDataAsync(); + + Assert.True(result.IsFailure); + Assert.DoesNotContain(fixture.Calls, call => call.Contains("--link-game-dir")); + Assert.Null(fixture.State().GameDataDir); + } + + [Fact] + public async Task Link_ASetupThatCan_PointsItAtTheExtractedGame() + { + var fixture = new Fixture(supportsGameDir: true); + + var result = await fixture.Service.LinkGameDataAsync(); + + Assert.True(result.IsSuccess, result.IsFailure ? result.Error.Message : null); + Assert.Contains( + fixture.Calls, + call => call.SequenceEqual(RecompSetupCommandBuilder.BuildLinkGameDirArguments(Fixture.Install, Fixture.Game)) + ); + Assert.Equal(Fixture.Game + "/DATA", fixture.State().GameDataDir!.Replace('\\', '/')); + } + + [Fact] + public async Task Link_AlreadyLinked_RunsNothing() + { + var fixture = new Fixture(supportsGameDir: true, linked: true); + + Assert.True((await fixture.Service.LinkGameDataAsync()).IsSuccess); + Assert.Empty(fixture.Calls); + } + + [Fact] + public async Task Link_WithoutAnExtractedGame_RunsNothing() + { + var fixture = new Fixture(supportsGameDir: true, gameReady: false); + + Assert.True((await fixture.Service.LinkGameDataAsync()).IsFailure); + Assert.DoesNotContain(fixture.Calls, call => call.Contains("--link-game-dir")); + } + + [Fact] + public async Task Link_NothingInstalled_IsNothingToDo() + { + var fixture = new Fixture(supportsGameDir: true, installed: false); + + Assert.True((await fixture.Service.LinkGameDataAsync()).IsSuccess); + Assert.Empty(fixture.Calls); + } + + [Fact] + public async Task Launch_AnInstallationReadingItsOwnCopy_IsRefused() + { + var fixture = new Fixture(supportsGameDir: false); + + Assert.True((await fixture.Service.ReconcileForLaunchAsync()).IsFailure); + Assert.DoesNotContain(fixture.Calls, call => call.Contains("--repair-products")); + } + + private static RecompSetupHostAcquirer Hosts(IRecompProcessRunner runner) + { + var github = Substitute.For(); + github + .GetReleasesAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(new OperationResult>(new OperationError { Message = "offline" })); + return new RecompSetupHostAcquirer( + runner, + Substitute.For(), + github, + new MockFileSystem(), + NullLogger.Instance + ); + } + + private sealed class Fixture + { + public const string Install = "/ww/Recomp/Install"; + public const string Game = "/ww/GAME"; + private const string Setup = Install + "/WiiCompiled-Setup.exe"; + private const string StateFile = Install + "/install-state.json"; + + private readonly MockFileSystem _fs = new(options => options.SimulatingOperatingSystem(SimulationMode.Linux)); + public List> Calls { get; } = []; + public RecompInstallService Service { get; } + + public Fixture(bool supportsGameDir, bool linked = false, bool gameReady = true, bool installed = true) + { + _fs.Directory.CreateDirectory(Install); + if (installed) + { + _fs.File.WriteAllText(Setup, "setup"); + WriteState(linked ? Game + "/DATA" : null); + } + + var environment = Substitute.For(); + environment.InstallFolderPath.Returns(Install); + environment.InstalledSetupFilePath.Returns(Setup); + environment.InstallStateFilePath.Returns(StateFile); + environment.BackendStateFilePath.Returns(StateFile); + environment.GameFolderPath.Returns(Game); + environment.IsGameReady.Returns(gameReady); + environment.RetroRewindFolderPath.Returns("/ww/RetroRewind/RetroRewind6"); + environment.CacheFolderPath.Returns("/ww/Recomp/Cache"); + environment.GetExtensionConfigurationInfo.Returns(ExtensionConfigurationInfo.ExtensionFoundOrNotNeeded); + + var runner = Substitute.For(); + runner + .RunAsync( + Arg.Any(), + Arg.Any>(), + Arg.Any(), + Arg.Any?>(), + Arg.Any() + ) + .Returns(call => + { + var arguments = call.ArgAt>(1); + var output = call.ArgAt?>(3); + if (arguments[0] == "--version") + { + output?.Invoke("0.3.0"); + return Ok(0); + } + + if (arguments[0] == "--capabilities") + { + if (!supportsGameDir) + return Ok(1); + output?.Invoke("""{"type":"capabilities","capabilities":["game-dir"]}"""); + return Ok(0); + } + + Calls.Add(arguments); + if (arguments[0] == "--link-game-dir") + { + WriteState(arguments[^2] + "/DATA"); + output?.Invoke("""{"type":"result","success":true,"version":"0.3.0","installDir":"/ww/Recomp/Install"}"""); + } + + return Ok(0); + }); + + Service = new RecompInstallService( + environment, + runner, + Hosts(runner), + Substitute.For(), + _fs, + NullLogger.Instance + ); + } + + private void WriteState(string? gameDataDir) => + _fs.File.WriteAllText( + StateFile, + JsonSerializer.Serialize( + new RecompInstallState + { + SchemaVersion = 1, + SetupVersion = "0.3.0", + InstallDir = Install, + RetroRewindInstalled = true, + RetroWfcPayloadMode = "downloaded", + GameDataDir = gameDataDir, + } + ) + ); + + public RecompInstallState State() => JsonSerializer.Deserialize(_fs.File.ReadAllText(StateFile))!; + } +} diff --git a/WheelWizard.Test/Features/Recomp/RecompLinuxTests.cs b/WheelWizard.Test/Features/Recomp/RecompLinuxTests.cs index 6d1587518..44f780170 100644 --- a/WheelWizard.Test/Features/Recomp/RecompLinuxTests.cs +++ b/WheelWizard.Test/Features/Recomp/RecompLinuxTests.cs @@ -23,15 +23,15 @@ public class RecompLinuxTests public void Install_PassesTheGameAndRetroRewindTheWayTheAppImageExpects() { var arguments = RecompLinuxSetupCommandBuilder.BuildInstallArguments( - "/home/user/Games/Mario Kart Wii.rvz", + "/home/user/.config/CT-MKWII/GAME", "/home/user/.local/share/WheelWizard/RiivolutionWW/RetroRewind6" ); Assert.Equal( [ "install", - "--game", - "/home/user/Games/Mario Kart Wii.rvz", + "--game-dir", + "/home/user/.config/CT-MKWII/GAME", "--retro-dir", "/home/user/.local/share/WheelWizard/RiivolutionWW/RetroRewind6", "--download-retro-wfc-payload", @@ -47,6 +47,7 @@ public void Install_WithoutAGameReusesTheExtractedAssets() var arguments = RecompLinuxSetupCommandBuilder.BuildInstallArguments(null, "/rr/RetroRewind6", RecompRetroWfcPayloadMode.Skip); Assert.Equal(["install", "--retro-dir", "/rr/RetroRewind6", "--skip-retro-wfc-payload", "--progress-json"], arguments); + Assert.DoesNotContain("--game-dir", arguments); Assert.DoesNotContain("--game", arguments); } @@ -54,8 +55,8 @@ public void Install_WithoutAGameReusesTheExtractedAssets() public void Install_BaseOnlyPassesNoPayloadOption() { Assert.Equal( - ["install", "--game", "/g.iso", "--progress-json"], - RecompLinuxSetupCommandBuilder.BuildInstallArguments("/g.iso", null) + ["install", "--game-dir", "/data/GAME", "--progress-json"], + RecompLinuxSetupCommandBuilder.BuildInstallArguments("/data/GAME", null) ); } diff --git a/WheelWizard.Test/Features/Recomp/RecompTests.cs b/WheelWizard.Test/Features/Recomp/RecompTests.cs index f92c24363..3d42fc388 100644 --- a/WheelWizard.Test/Features/Recomp/RecompTests.cs +++ b/WheelWizard.Test/Features/Recomp/RecompTests.cs @@ -17,7 +17,7 @@ public void SilentInstall_BuildsTheCommandLineTheSetupExecutableExpects() var arguments = RecompSetupCommandBuilder.BuildSilentInstallArguments( new() { - GameFilePath = @"D:\Games\Mario Kart Wii.rvz", + GameFolderPath = @"D:\WheelWizard\GAME", InstallFolderPath = @"D:\WheelWizard\Recomp\Install", RetroRewindFolderPath = @"D:\WheelWizard\RetroRewind6", Portable = true, @@ -27,8 +27,8 @@ public void SilentInstall_BuildsTheCommandLineTheSetupExecutableExpects() Assert.Equal( [ "--silent", - "--game", - @"D:\Games\Mario Kart Wii.rvz", + "--game-dir", + @"D:\WheelWizard\GAME", "--install-dir", @"D:\WheelWizard\Recomp\Install", "--portable", @@ -47,7 +47,7 @@ public void SilentInstall_SkipsThePayloadOnlyWhenAskedTo() var arguments = RecompSetupCommandBuilder.BuildSilentInstallArguments( new() { - GameFilePath = @"D:\Games\Mario Kart Wii.rvz", + GameFolderPath = @"D:\WheelWizard\GAME", InstallFolderPath = @"D:\WheelWizard\Recomp\Install", RetroRewindFolderPath = @"D:\WheelWizard\RetroRewind6", RetroWfcPayloadMode = RecompRetroWfcPayloadMode.Skip, diff --git a/WheelWizard.Test/Features/SaveRegionServiceTests.cs b/WheelWizard.Test/Features/SaveRegionServiceTests.cs index 95be40a7c..ae7734fc4 100644 --- a/WheelWizard.Test/Features/SaveRegionServiceTests.cs +++ b/WheelWizard.Test/Features/SaveRegionServiceTests.cs @@ -1,6 +1,5 @@ using Testably.Abstractions.Testing; -using WheelWizard.DiscImages; -using WheelWizard.DiscImages.Domain; +using WheelWizard.GameFiles; using WheelWizard.Models.Enums; using WheelWizard.Settings; using WheelWizard.Settings.Types; @@ -49,9 +48,7 @@ public void GameRegion_FromGameId(string? gameId, MarioKartWiiEnums.Regions expe public void ActiveRegion_FollowsGameFile_OverLastUsedRegion() { var fixture = Create(new MockFileSystem(), MarioKartWiiEnums.Regions.America); - fixture - .Discs.ReadInfo("/game.rvz") - .Returns((OperationResult)new DiscImageInfo("RMCP01", "MarioKartWii", 0, DiscImageFormat.Rvz)); + fixture.Game.GetRegion().Returns(MarioKartWiiEnums.Regions.Europe); Assert.Equal(MarioKartWiiEnums.Regions.Europe, fixture.Service.ResolveActiveRegion("/saves")); } @@ -60,9 +57,7 @@ public void ActiveRegion_FollowsGameFile_OverLastUsedRegion() public void ActiveRegion_IgnoresOtherGames() { var fixture = Create(new MockFileSystem(), MarioKartWiiEnums.Regions.Japan); - fixture - .Discs.ReadInfo("/game.rvz") - .Returns((OperationResult)new DiscImageInfo("RWSJ01", "MARIO & SONIC", 0, DiscImageFormat.Rvz)); + fixture.Game.GetRegion().Returns(MarioKartWiiEnums.Regions.None); Assert.Equal(MarioKartWiiEnums.Regions.Japan, fixture.Service.ResolveActiveRegion("/saves")); } @@ -74,12 +69,12 @@ public void ActiveRegion_WithoutReadableGame_FallsBackToFirstSave() fs.Directory.CreateDirectory("/saves/RMCK"); fs.File.WriteAllText("/saves/RMCK/rksys.dat", "save"); var fixture = Create(fs); - fixture.Discs.ReadInfo(Arg.Any()).Returns((OperationResult)Fail("Game file not found")); + fixture.Game.GetRegion().Returns(MarioKartWiiEnums.Regions.None); Assert.Equal(MarioKartWiiEnums.Regions.Korea, fixture.Service.ResolveActiveRegion("/saves")); } - private static (SaveRegionService Service, IDiscImageService Discs) Create( + private static (SaveRegionService Service, IGameFilesService Game) Create( MockFileSystem fs, MarioKartWiiEnums.Regions lastRegion = MarioKartWiiEnums.Regions.None ) @@ -91,7 +86,7 @@ private static (SaveRegionService Service, IDiscImageService Discs) Create( settings.RR_REGION.Returns(region); settings.Get(gamePath).Returns(_ => (string)gamePath.Get()); settings.Get(region).Returns(_ => (MarioKartWiiEnums.Regions)region.Get()); - var discs = Substitute.For(); - return (new SaveRegionService(fs, settings, discs), discs); + var game = Substitute.For(); + return (new SaveRegionService(fs, settings, game), game); } } diff --git a/WheelWizard.Test/Views/Shell/ViewModels/HomeDolphinLaunchTests.cs b/WheelWizard.Test/Views/Shell/ViewModels/HomeDolphinLaunchTests.cs index fb226e579..de2df95ef 100644 --- a/WheelWizard.Test/Views/Shell/ViewModels/HomeDolphinLaunchTests.cs +++ b/WheelWizard.Test/Views/Shell/ViewModels/HomeDolphinLaunchTests.cs @@ -119,7 +119,6 @@ private sealed class Fixture public IDolphinVersionService Versions { get; } = Substitute.For(); public ILinuxDolphinInstaller Installer { get; } = Substitute.For(); public IDolphinLaunchPresentation Presentation { get; } = Substitute.For(); - public WhWzSetting GamePath { get; } = new("GamePath", "/game.iso"); public DolphinLaunchService Service { get; } public Fixture(bool windows = false, string command = "dolphin-emu") @@ -133,9 +132,9 @@ public Fixture(bool windows = false, string command = "dolphin-emu") environment.GetFolderPath(Environment.SpecialFolder.UserProfile).Returns("/home/test"); environment.GetFolderPath(Environment.SpecialFolder.ApplicationData).Returns("/home/test/.config"); environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData).Returns("/home/test/.local/share"); - var settings = Substitute.For(); - settings.GAME_LOCATION.Returns(GamePath); - settings.Get(GamePath).Returns(_ => (string)GamePath.Get()); + var gameFiles = Substitute.For(); + gameFiles.RootPath.Returns("/data/GAME"); + gameFiles.ResolvedRootPath.Returns("/data/GAME"); var paths = Substitute.For(); var user = windows ? @"C:\Dolphin User" : "/dolphin-user"; paths.UserFolderPath.Returns(user); @@ -145,7 +144,7 @@ public Fixture(bool windows = false, string command = "dolphin-emu") distribution.RootFolderPath.Returns("/distribution"); distribution.LaunchJsonFilePath.Returns("/distribution/RR.json"); Versions.CheckConfiguredDolphin().Returns((DolphinVersionStatus.Supported, "2606a")); - Service = new(settings, paths, distribution, fs, environment, Processes, LinuxProcesses, Versions, Installer, Presentation); + Service = new(paths, distribution, fs, environment, Processes, LinuxProcesses, Versions, Installer, Presentation, gameFiles); } } } diff --git a/WheelWizard.UI.Test/GameSetupShellTests.cs b/WheelWizard.UI.Test/GameSetupShellTests.cs new file mode 100644 index 000000000..d81f7209b --- /dev/null +++ b/WheelWizard.UI.Test/GameSetupShellTests.cs @@ -0,0 +1,88 @@ +using System.IO.Abstractions; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Headless.XUnit; +using Avalonia.Threading; +using Microsoft.Extensions.DependencyInjection; +using NSubstitute; +using Testably.Abstractions.Testing; +using WheelWizard.ApplicationData; +using WheelWizard.GameFiles.Views; +using WheelWizard.MiiImages.Views; +using WheelWizard.Mods; +using WheelWizard.Settings; +using WheelWizard.Settings.Views; +using WheelWizard.Shared; +using WheelWizard.Shared.Services; +using WheelWizard.Views.Shell; +using WheelWizard.Views.Shell.Navigation; +using WheelWizard.Views.Shell.Views; + +namespace WheelWizard.UI.Test; + +public class GameSetupShellTests +{ + [AvaloniaFact] + public async Task AnUpgradingUser_IsHeldOnTheGameSetupPage_WithOnlySettingsReachable() + { + var fileSystem = new MockFileSystem(); + var directory = Path.Combine(Path.GetTempPath(), "wheelwizard-ui-game-setup"); + fileSystem.Directory.CreateDirectory(directory); + var image = Path.Combine(directory, "Mario Kart Wii.rvz"); + fileSystem.File.WriteAllText(image, "an image from before the game was extracted"); + var location = Substitute.For(); + location.DirectoryPath.Returns(directory); + var registrations = new ServiceCollection(); + registrations.AddWheelWizardServices(location); + registrations.AddSingleton(fileSystem); + registrations.AddTransient(typeof(IApiCaller<>), typeof(ApplicationCompositionTests.OfflineApiCaller<>)); + var mods = Substitute.For(); + mods.ReloadAsync().Returns(Task.FromResult(OperationResult.Ok())); + registrations.AddSingleton(mods); + using var services = registrations.BuildServiceProvider( + new ServiceProviderOptions { ValidateOnBuild = true, ValidateScopes = true } + ); + services.GetRequiredService().Initialize(); + var settings = services.GetRequiredService(); + settings.NAND_ROOT_PATH.Set(directory, skipSave: true); + settings.LOAD_PATH.Set(directory, skipSave: true); + Assert.True(settings.GAME_LOCATION.Set(image, skipSave: true)); + settings.ONBOARDING_COMPLETED.Set(true, skipSave: true); + services.GetRequiredService().Install(Application.Current!.Resources); + services.GetRequiredService().Install(Application.Current.Resources); + + var window = services.GetRequiredService(); + var navigation = services.GetRequiredService(); + try + { + window.Show(); + window.UpdateLayout(); + await Dispatcher.UIThread.InvokeAsync(() => { }, DispatcherPriority.Background); + + Assert.IsType(navigation.CurrentPage); + Assert.False(window.FindControl("SidebarGrid")!.IsEnabled); + + // The sidebar and every other way to Home stay closed. + navigation.NavigateTo(); + Assert.IsType(navigation.CurrentPage); + navigation.IsLocked = false; + navigation.NavigateAway(typeof(HomePage)); + Assert.IsType(navigation.CurrentPage); + + // Settings is the way out to free up space or move the data folder (what "Open Settings" does), and + // leaving it for anything else comes straight back. + navigation.IsLocked = false; + navigation.NavigateAway(typeof(SettingsPage)); + Assert.IsType(navigation.CurrentPage); + navigation.NavigateTo(); + Assert.IsType(navigation.CurrentPage); + + Assert.Equal("an image from before the game was extracted", fileSystem.File.ReadAllText(image)); + } + finally + { + navigation.IsLocked = false; + window.Close(); + } + } +} diff --git a/WheelWizard/Features/ApplicationLifecycle/StartupMaintenance.cs b/WheelWizard/Features/ApplicationLifecycle/StartupMaintenance.cs index 9554f165b..888d14d07 100644 --- a/WheelWizard/Features/ApplicationLifecycle/StartupMaintenance.cs +++ b/WheelWizard/Features/ApplicationLifecycle/StartupMaintenance.cs @@ -1,6 +1,7 @@ using Microsoft.Extensions.Logging; using WheelWizard.CustomDistributions; using WheelWizard.DolphinInstaller; +using WheelWizard.GameFiles; using WheelWizard.Onboarding; using WheelWizard.Recomp; @@ -17,6 +18,7 @@ public sealed class StartupMaintenance( IManagedDolphinService managedDolphin, IRetroRewindMigrationService migration, IOnboardingService onboarding, + IGameFilesService gameFiles, IServiceProvider services, ILogger logger ) : IStartupMaintenance @@ -36,6 +38,9 @@ public async Task RunAsync(Action showStatus, CancellationToken cancell if (result.IsSuccess && result.Value == RetroRewindMigrationOutcome.Moved) ApplyRecompPaths(); + // An extraction interrupted by a crash or power cut is finished or cleaned up before anything looks at it. + await Task.Run(gameFiles.RecoverInterruptedWork, cancellationToken); + onboarding.MarkCompletedIfAlreadySetUp(); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) diff --git a/WheelWizard/Features/GameFiles/DiscExtractor.cs b/WheelWizard/Features/GameFiles/DiscExtractor.cs new file mode 100644 index 000000000..f0b1b1e57 --- /dev/null +++ b/WheelWizard/Features/GameFiles/DiscExtractor.cs @@ -0,0 +1,438 @@ +using System.Buffers.Binary; +using System.IO.Abstractions; +using System.Security.Cryptography; +using RVZSharp.Blobs; +using RVZSharp.Files; +using RVZSharp.Interfaces; +using RVZSharp.Models; +using RVZSharp.Wii; + +namespace WheelWizard.GameFiles; + +/// What a disc image holds, read from its header and file system table without extracting anything. +public sealed record DiscContents(string GameId, byte Revision, string InternalName, long RequiredBytes); + +/// The two files whose hashes a finished extraction is pinned to. +public sealed record ExtractedCode(string MainDolSha256, string StaticRelSha256); + +public interface IDiscExtractor +{ + /// Bumped whenever the extracted layout changes. + int Version { get; } + + OperationResult Inspect(string imagePath); + + /// + /// Writes the game partition of a Wii disc image into an empty , in the layout + /// Dolphin's "Extract Entire Disc" uses: disc/ plus DATA/{sys,files,ticket.bin,tmd.bin,cert.bin,h3.bin}. + /// Only reads the image. + /// + OperationResult Extract( + string imagePath, + string destinationRoot, + IProgress? progress = null, + CancellationToken cancellationToken = default + ); + + /// Writes only main.dol and StaticR.rel again, replacing whatever is in . + OperationResult RestoreCode(string imagePath, string dataPath, CancellationToken cancellationToken = default); +} + +public sealed class DiscExtractor(IFileSystem fileSystem) : IDiscExtractor +{ + private const int TicketSize = 0x2A4; + private const int H3Size = 0x18000; + private const long RegionOffset = 0x4E000; + private const int RegionSize = 0x20; + private const int DiscHeaderSize = 0x100; + private const int BootSize = 0x440; + private const int Bi2Size = 0x2000; + private const long ApploaderOffset = 0x2440; + private const uint GamePartitionType = 0; + private const string StaticRelDiscPath = "rel/StaticR.rel"; + + // Room for file system overhead (clusters, directory entries) on top of the bytes themselves. + private const long PerFileOverhead = 4096; + + public int Version => 1; + + public OperationResult Inspect(string imagePath) + { + try + { + using var disc = OpenDisc(imagePath); + var info = DiscInfo.TryRead(disc.Reader); + if (info is null) + return Fail("This file is not a Wii disc image."); + + using var files = DiscFileSystem.Open(disc.Reader, disc.Game); + var fileBytes = 0L; + var count = 0; + foreach (var file in EnumerateFiles(files.Root)) + { + fileBytes += file.Size; + count++; + } + + // The system files are a few MB at most; counting them generously keeps the estimate on the safe side. + var required = fileBytes + (count + 16) * PerFileOverhead + 16L * 1024 * 1024; + return new DiscContents(info.GameId, info.Revision, info.InternalName, required); + } + catch (Exception exception) + { + return new OperationError { Message = $"Could not read the game file: {exception.Message}", Exception = exception }; + } + } + + public OperationResult Extract( + string imagePath, + string destinationRoot, + IProgress? progress = null, + CancellationToken cancellationToken = default + ) + { + try + { + using var disc = OpenDisc(imagePath); + var data = fileSystem.Path.Combine(destinationRoot, "DATA"); + + WriteNew(fileSystem.Path.Combine(destinationRoot, "disc", "header.bin"), ReadExact(disc.Reader, 0, DiscHeaderSize)); + WriteNew(fileSystem.Path.Combine(destinationRoot, "disc", "region.bin"), ReadExact(disc.Reader, RegionOffset, RegionSize)); + WritePartitionHeader(disc, data); + + string mainDolSha; + using (var partition = new PartitionReader(disc.Reader, disc.Game)) + mainDolSha = WriteSystemFiles(partition, fileSystem.Path.Combine(data, "sys")); + + using var files = DiscFileSystem.Open(disc.Reader, disc.Game); + var all = EnumerateFiles(files.Root).ToList(); + var total = Math.Max(1, all.Sum(file => file.Size)); + var written = 0L; + var relativePaths = new HashSet(StringComparer.OrdinalIgnoreCase); + string? staticRelSha = null; + + CreateDirectories(files.Root, fileSystem.Path.Combine(data, "files")); + foreach (var file in all) + { + cancellationToken.ThrowIfCancellationRequested(); + var target = ResolveTarget(data, file, relativePaths); + var sha = CopyFile(files, file, target, hash: IsStaticRel(file), cancellationToken); + if (sha is not null) + staticRelSha = sha; + written += file.Size; + progress?.Report((double)written / total); + } + + if (staticRelSha is null) + return Fail("This disc has no StaticR.rel, so it is not Mario Kart Wii."); + + progress?.Report(1); + return new ExtractedCode(mainDolSha, staticRelSha); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception exception) + { + return new OperationError { Message = $"Extracting the game failed: {exception.Message}", Exception = exception }; + } + } + + public OperationResult RestoreCode(string imagePath, string dataPath, CancellationToken cancellationToken = default) + { + try + { + using var disc = OpenDisc(imagePath); + byte[] dol; + using (var partition = new PartitionReader(disc.Reader, disc.Game)) + dol = ReadMainDol(partition); + + using var files = DiscFileSystem.Open(disc.Reader, disc.Game); + var rel = files.Find(StaticRelDiscPath); + if (rel is null || rel.IsDirectory) + return Fail("This disc has no StaticR.rel, so it is not Mario Kart Wii."); + + var dolPath = fileSystem.Path.Combine(dataPath, "sys", "main.dol"); + var relPath = fileSystem.Path.Combine(dataPath, "files", "rel", "StaticR.rel"); + ReplaceFile(dolPath, temp => WriteNew(temp, dol)); + string? relSha = null; + ReplaceFile(relPath, temp => relSha = CopyFile(files, rel, temp, hash: true, cancellationToken)); + return new ExtractedCode(Sha256(dol), relSha!); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception exception) + { + return new OperationError { Message = $"Restoring the game code failed: {exception.Message}", Exception = exception }; + } + } + + #region Disc + + private sealed class OpenedDisc(IBlobReader reader, Partition game) : IDisposable + { + public IBlobReader Reader { get; } = reader; + public Partition Game { get; } = game; + + public void Dispose() => Reader.Dispose(); + } + + private OpenedDisc OpenDisc(string imagePath) + { + // Read-only and shared: the image stays usable by anything else that has it open, and is never written. + var stream = fileSystem.File.Open(imagePath, FileMode.Open, FileAccess.Read, FileShare.Read); + IBlobReader reader; + try + { + reader = Blob.Open(stream, imagePath, leaveOpen: false); + } + catch + { + // Blob.Open only takes ownership of the stream once it succeeds. + stream.Dispose(); + throw; + } + + try + { + if (!WiiVolume.IsWiiDisc(reader)) + throw new InvalidDataException("This file is not a Wii disc image."); + var game = WiiVolume.GetPartitions(reader).Where(partition => partition.Type == GamePartitionType).ToList(); + if (game.Count == 0) + throw new InvalidDataException("This disc has no game partition."); + return new(reader, game[0]); + } + catch + { + reader.Dispose(); + throw; + } + } + + private void WritePartitionHeader(OpenedDisc disc, string data) + { + // The partition header is not encrypted: the ticket, then the sizes and (shifted) offsets of the rest. + var offset = (long)disc.Game.Offset; + var header = ReadExact(disc.Reader, offset, 0x2C0); + var tmdSize = (int)ReadUInt32(header, 0x2A4); + var tmdOffset = (long)ReadUInt32(header, 0x2A8) << 2; + var certSize = (int)ReadUInt32(header, 0x2AC); + var certOffset = (long)ReadUInt32(header, 0x2B0) << 2; + var h3Offset = (long)ReadUInt32(header, 0x2B4) << 2; + + WriteNew(fileSystem.Path.Combine(data, "ticket.bin"), header[..TicketSize]); + WriteNew(fileSystem.Path.Combine(data, "tmd.bin"), ReadExact(disc.Reader, offset + tmdOffset, tmdSize)); + WriteNew(fileSystem.Path.Combine(data, "cert.bin"), ReadExact(disc.Reader, offset + certOffset, certSize)); + WriteNew(fileSystem.Path.Combine(data, "h3.bin"), ReadExact(disc.Reader, offset + h3Offset, H3Size)); + } + + private string WriteSystemFiles(PartitionReader partition, string sys) + { + var boot = ReadExact(partition, 0, BootSize); + WriteNew(fileSystem.Path.Combine(sys, "boot.bin"), boot); + WriteNew(fileSystem.Path.Combine(sys, "bi2.bin"), ReadExact(partition, BootSize, Bi2Size)); + + var apploaderSize = WiiVolume.GetApploaderSize(partition) ?? throw new InvalidDataException("The disc has no apploader."); + WriteNew(fileSystem.Path.Combine(sys, "apploader.img"), ReadExact(partition, ApploaderOffset, checked((int)apploaderSize))); + + var dol = ReadMainDol(partition); + WriteNew(fileSystem.Path.Combine(sys, "main.dol"), dol); + + // Wii offsets in the boot header are stored shifted right by two. + var fstOffset = (long)ReadUInt32(boot, 0x424) << 2; + var fstSize = checked((int)((long)ReadUInt32(boot, 0x428) << 2)); + WriteNew(fileSystem.Path.Combine(sys, "fst.bin"), ReadExact(partition, fstOffset, fstSize)); + return Sha256(dol); + } + + private static byte[] ReadMainDol(PartitionReader partition) + { + var offset = WiiVolume.GetBootDolOffset(partition) ?? throw new InvalidDataException("The disc has no main.dol."); + var size = WiiVolume.GetBootDolSize(partition, offset) ?? throw new InvalidDataException("The disc's main.dol is unreadable."); + return ReadExact(partition, checked((long)offset), checked((int)size)); + } + + private static byte[] ReadExact(IBlobReader reader, long offset, int length) + { + var buffer = new byte[length]; + var read = 0; + while (read < length) + { + var count = reader.ReadAt(offset + read, buffer.AsSpan(read)); + if (count <= 0) + throw new InvalidDataException("The game file ended early; it may be incomplete or damaged."); + read += count; + } + + return buffer; + } + + private static uint ReadUInt32(byte[] data, int offset) => BinaryPrimitives.ReadUInt32BigEndian(data.AsSpan(offset)); + + #endregion + + #region Files + + private static IEnumerable EnumerateFiles(DiscFileInfo directory) + { + foreach (var child in directory.Children) + { + if (!child.IsDirectory) + { + yield return child; + continue; + } + + foreach (var file in EnumerateFiles(child)) + yield return file; + } + } + + private static bool IsStaticRel(DiscFileInfo file) => + string.Equals(file.Path.TrimStart('/'), StaticRelDiscPath, StringComparison.OrdinalIgnoreCase); + + private void CreateDirectories(DiscFileInfo directory, string path) + { + fileSystem.Directory.CreateDirectory(path); + foreach (var child in directory.Children.Where(child => child.IsDirectory)) + CreateDirectories(child, fileSystem.Path.Combine(path, SafeName(child.Name))); + } + + /// + /// Builds the output path from the disc's own names, which come from the image and are not trusted: a name that + /// would climb out of the folder, or two names only a case-insensitive drive would merge, stop the extraction. + /// + private string ResolveTarget(string data, DiscFileInfo file, HashSet seen) + { + var segments = new Stack(); + for (var entry = file; entry is { IsRoot: false }; entry = entry.Parent) + segments.Push(SafeName(entry.Name)); + + var relative = string.Join('/', segments); + if (!seen.Add(relative)) + throw new InvalidDataException($"The disc has two files named '{relative}' that only differ in capitalization."); + return fileSystem.Path.Combine([data, "files", .. segments]); + } + + private static readonly char[] UnsafeNameCharacters = ['<', '>', ':', '"', '/', '\\', '|', '?', '*']; + + private static readonly HashSet ReservedWindowsNames = new(StringComparer.OrdinalIgnoreCase) + { + "CON", + "PRN", + "AUX", + "NUL", + "COM1", + "COM2", + "COM3", + "COM4", + "COM5", + "COM6", + "COM7", + "COM8", + "COM9", + "LPT1", + "LPT2", + "LPT3", + "LPT4", + "LPT5", + "LPT6", + "LPT7", + "LPT8", + "LPT9", + }; + + /// The same rules on every platform, so an extraction that works on Linux also works on Windows. + public static string SafeName(string name) + { + if ( + string.IsNullOrEmpty(name) + || name is "." or ".." + || name.IndexOfAny(UnsafeNameCharacters) >= 0 + || name.Any(char.IsControl) + || name.EndsWith('.') + || name.EndsWith(' ') + || ReservedWindowsNames.Contains(name.Split('.')[0]) + ) + throw new InvalidDataException($"The disc contains a file name that can't be extracted safely: '{name}'."); + return name; + } + + /// The SHA-256 of the copied bytes when is set. + private string? CopyFile(DiscFileSystem files, DiscFileInfo file, string target, bool hash, CancellationToken cancellationToken) + { + using var output = fileSystem.FileStream.New(target, FileMode.CreateNew, FileAccess.Write, FileShare.None); + using var hasher = hash ? IncrementalHash.CreateHash(HashAlgorithmName.SHA256) : null; + Stream destination = hasher is null ? output : new HashingStream(output, hasher); + var copied = files.CopyFileTo(file, destination, cancellationToken); + output.Flush(flushToDisk: true); + if (copied != file.Size || output.Length != file.Size) + throw new InvalidDataException($"'{file.Path}' was not written completely."); + return hasher is null ? null : Convert.ToHexString(hasher.GetHashAndReset()).ToLowerInvariant(); + } + + private void WriteNew(string path, byte[] contents) + { + fileSystem.Directory.CreateDirectory(fileSystem.Path.GetDirectoryName(path)!); + using var output = fileSystem.FileStream.New(path, FileMode.CreateNew, FileAccess.Write, FileShare.None); + output.Write(contents); + output.Flush(flushToDisk: true); + if (output.Length != contents.Length) + throw new InvalidDataException($"'{fileSystem.Path.GetFileName(path)}' was not written completely."); + } + + /// Writes next to first, so a failed write leaves the current file as it was. + private void ReplaceFile(string path, Action write) + { + var temp = path + ".restore"; + if (fileSystem.File.Exists(temp)) + fileSystem.File.Delete(temp); + try + { + write(temp); + fileSystem.File.Move(temp, path, overwrite: true); + } + finally + { + if (fileSystem.File.Exists(temp)) + fileSystem.File.Delete(temp); + } + } + + public static string Sha256(byte[] data) => Convert.ToHexString(SHA256.HashData(data)).ToLowerInvariant(); + + private sealed class HashingStream(Stream inner, IncrementalHash hash) : Stream + { + public override bool CanRead => false; + public override bool CanSeek => false; + public override bool CanWrite => true; + public override long Length => inner.Length; + + public override long Position + { + get => inner.Position; + set => throw new NotSupportedException(); + } + + public override void Write(byte[] buffer, int offset, int count) => Write(buffer.AsSpan(offset, count)); + + public override void Write(ReadOnlySpan buffer) + { + hash.AppendData(buffer); + inner.Write(buffer); + } + + public override void Flush() => inner.Flush(); + + public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + + public override void SetLength(long value) => throw new NotSupportedException(); + } + + #endregion +} diff --git a/WheelWizard/Features/GameFiles/Domain/GameFilesManifest.cs b/WheelWizard/Features/GameFiles/Domain/GameFilesManifest.cs new file mode 100644 index 000000000..fc11a4098 --- /dev/null +++ b/WheelWizard/Features/GameFiles/Domain/GameFilesManifest.cs @@ -0,0 +1,28 @@ +namespace WheelWizard.GameFiles.Domain; + +/// +/// Describes a finished extraction. Only main.dol and StaticR.rel are pinned by hash: every other file is +/// the user's to edit, and Wheel Wizard never checks it. +/// +public sealed record GameFilesManifest +{ + public const int CurrentSchemaVersion = 1; + + public int SchemaVersion { get; init; } = CurrentSchemaVersion; + public required string GameId { get; init; } + public byte Revision { get; init; } + public string InternalName { get; init; } = ""; + + /// The image this was extracted from. Informational: the extracted game never needs it again. + public string SourcePath { get; init; } = ""; + + public long SourceLength { get; init; } + public DateTime SourceLastWriteUtc { get; init; } + public required string MainDolSha256 { get; init; } + public required string StaticRelSha256 { get; init; } + public int ExtractorVersion { get; init; } + public DateTime ExtractedUtc { get; init; } + + public bool IsSameGame(string gameId, byte revision) => + string.Equals(GameId, gameId, StringComparison.OrdinalIgnoreCase) && Revision == revision; +} diff --git a/WheelWizard/Features/GameFiles/GameFilesExtensions.cs b/WheelWizard/Features/GameFiles/GameFilesExtensions.cs new file mode 100644 index 000000000..fb4c98a72 --- /dev/null +++ b/WheelWizard/Features/GameFiles/GameFilesExtensions.cs @@ -0,0 +1,15 @@ +using WheelWizard.GameFiles.Views; + +namespace WheelWizard.GameFiles; + +public static class GameFilesExtensions +{ + public static IServiceCollection AddGameFiles(this IServiceCollection services) + { + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + return services; + } +} diff --git a/WheelWizard/Features/GameFiles/GameFilesPaths.cs b/WheelWizard/Features/GameFiles/GameFilesPaths.cs new file mode 100644 index 000000000..5c1675c47 --- /dev/null +++ b/WheelWizard/Features/GameFiles/GameFilesPaths.cs @@ -0,0 +1,76 @@ +using System.IO.Abstractions; +using WheelWizard.ApplicationData; + +namespace WheelWizard.GameFiles; + +/// +/// Where the extracted game lives. The layout is the one Dolphin writes with "Extract Entire Disc" and boots from +/// DATA/sys/main.dol; WiiCompiled reads the same DATA folder. +/// +public interface IGameFilesPaths +{ + /// The extracted game: disc/ and DATA/. May be a link the user made to another drive. + string RootPath { get; } + + string DataPath { get; } + string MainDolPath { get; } + string StaticRelPath { get; } + string BootBinPath { get; } + + /// Written last, so an extraction without it never finished. + string ManifestPath { get; } + + /// + /// The folder really is: the link's target when the user linked it elsewhere, which is where + /// files are written and replaced so the link itself is never touched. + /// + string ResolvedRootPath { get; } + + /// Whether is a symbolic link or junction. + bool IsLinked { get; } + + /// Where a new extraction is written before it takes the place of the current one. Same drive as the target. + string StagingPath { get; } + + /// Where a replaced extraction waits to be deleted, so a crash mid-swap never loses both. + string ReplacedPath { get; } +} + +public sealed class GameFilesPaths(IApplicationDataLocation applicationData, IFileSystem fileSystem) : IGameFilesPaths +{ + public const string FolderName = "GAME"; + public const string ManifestFileName = ".wheelwizard-game.json"; + + /// Marks a folder as Wheel Wizard's own unfinished or replaced work, which it may always delete. + public const string WorkMarkerFileName = ".wheelwizard-work"; + + public string RootPath => fileSystem.Path.Combine(applicationData.DirectoryPath, FolderName); + public string DataPath => fileSystem.Path.Combine(RootPath, "DATA"); + public string MainDolPath => fileSystem.Path.Combine(DataPath, "sys", "main.dol"); + public string StaticRelPath => fileSystem.Path.Combine(DataPath, "files", "rel", "StaticR.rel"); + public string BootBinPath => fileSystem.Path.Combine(DataPath, "sys", "boot.bin"); + public string ManifestPath => fileSystem.Path.Combine(RootPath, ManifestFileName); + + public bool IsLinked => LinkTarget() is not null; + + public string ResolvedRootPath => LinkTarget() ?? RootPath; + + public string StagingPath => ResolvedRootPath + ".partial"; + public string ReplacedPath => ResolvedRootPath + ".replaced"; + + private string? LinkTarget() + { + try + { + var directory = fileSystem.DirectoryInfo.New(RootPath); + if (directory.LinkTarget is null) + return null; + var target = directory.ResolveLinkTarget(returnFinalTarget: true); + return target is null ? null : fileSystem.Path.GetFullPath(target.FullName); + } + catch + { + return null; + } + } +} diff --git a/WheelWizard/Features/GameFiles/GameFilesService.cs b/WheelWizard/Features/GameFiles/GameFilesService.cs new file mode 100644 index 000000000..bdacabb92 --- /dev/null +++ b/WheelWizard/Features/GameFiles/GameFilesService.cs @@ -0,0 +1,858 @@ +using System.IO.Abstractions; +using System.Security.Cryptography; +using System.Text.Json; +using Microsoft.Extensions.Logging; +using WheelWizard.DiscImages; +using WheelWizard.Dolphin.Paths; +using WheelWizard.GameFiles.Domain; +using WheelWizard.Models.Enums; +using WheelWizard.Settings; +using WheelWizard.Shared.IO; +using WheelWizard.Shared.Processes; +using WheelWizard.WiiManagement.GameLicense; + +namespace WheelWizard.GameFiles; + +public enum GameFilesState +{ + /// Extracted, and main.dol and StaticR.rel are what was extracted. + Ready, + + /// No game has ever been picked (a first run, which the onboarding handles). + NoImage, + + /// An image is picked and reachable, but nothing is extracted yet. + NeedsExtraction, + + /// An image is picked but cannot be read right now (moved, deleted, a drive that is not plugged in). + ImageMissing, + + /// Something is where the extracted game goes that Wheel Wizard did not finish writing. + UnknownFolder, + + /// Extracted, but main.dol or StaticR.rel changed or disappeared since. + CodeModified, +} + +public sealed record GameFilesStatus(GameFilesState State, GameFilesManifest? Manifest, string? ImagePath) +{ + public bool IsReady => State == GameFilesState.Ready; +} + +public enum GameChangeKind +{ + /// Nothing is extracted yet. + Extract, + + /// The same game and revision is already extracted; only the remembered image changes. + SameGame, + + /// A different game or revision is extracted; replacing it deletes every change made to it. + Replace, + + /// A folder Wheel Wizard does not know is in the way and has to be moved aside or deleted first. + UnknownFolder, +} + +public sealed record GameChangePlan(GameChangeKind Kind, DiscContents Image, GameFilesManifest? Current); + +public sealed record GameExtractionProgress(double Fraction, string Message); + +public interface IGameFilesService +{ + GameFilesStatus GetStatus(); + + /// The picked image, also when it is not reachable right now. + string? ImagePath { get; } + + /// + /// Checks a game image without changing anything: whether it is Mario Kart Wii, and what picking it would do to + /// the extracted game. + /// + OperationResult Plan(string imagePath); + + /// + /// Extracts and makes it the game, then remembers the image. Never writes to, moves + /// or deletes the image. + /// + /// + /// Required to replace an extraction of a different game or revision, which deletes it with every change made to it. + /// + Task ExtractAsync( + string imagePath, + bool replaceExisting, + IProgress? progress = null, + CancellationToken cancellationToken = default + ); + + /// Restores main.dol and StaticR.rel from the image of the same game; nothing else is touched. + Task RestoreCodeAsync(string? imagePath = null, CancellationToken cancellationToken = default); + + /// Renames whatever is in the way to GAME.backup-…. A link is renamed, never followed. + OperationResult MoveUnknownAside(); + + /// Deletes whatever is in the way. A link is removed without touching what it points to. + OperationResult DeleteUnknown(); + + /// + /// Finishes or rolls back work a previous run was interrupted in, and removes Wheel Wizard's own leftovers. + /// Only ever deletes folders that carry its work marker. + /// + void RecoverInterruptedWork(); + + /// Dolphin or WiiCompiled has files of the game open, which blocks replacing them on Windows. + bool IsGameRunning(); + + void CloseRunningGame(); + + /// The Mario Kart Wii region of the extracted game, or of the image when nothing is extracted yet. + MarioKartWiiEnums.Regions GetRegion(); +} + +public sealed class GameFilesService( + IGameFilesPaths paths, + IDiscExtractor extractor, + ISettingsManager settings, + IFileSystem fileSystem, + IProcessLauncher processes, + IDolphinPaths dolphinPaths, + IDiscImageService discImages, + ILogger logger +) : IGameFilesService +{ + private const long FreeSpaceMargin = 256L * 1024 * 1024; + + private static readonly JsonSerializerOptions ManifestJson = new() { WriteIndented = true }; + + // The two hashes are checked on every status read; rehashing ~7 MB only when the files change keeps that cheap. + private readonly Dictionary _hashCache = new(); + private readonly object _hashLock = new(); + private readonly SemaphoreSlim _gate = new(1, 1); + + public string? ImagePath + { + get + { + var value = settings.Get(settings.GAME_LOCATION); + if (!string.IsNullOrWhiteSpace(value)) + return value; + return settings.GAME_LOCATION.UnavailableValue is { } unavailable && !string.IsNullOrWhiteSpace(unavailable) + ? unavailable + : null; + } + } + + #region Status + + public GameFilesStatus GetStatus() + { + var image = ImagePath; + var manifest = ReadManifest(); + if (manifest is not null) + return new(CodeMatches(manifest) ? GameFilesState.Ready : GameFilesState.CodeModified, manifest, image); + + if (RootExists()) + return new(GameFilesState.UnknownFolder, null, image); + if (image is null) + return new(GameFilesState.NoImage, null, null); + return new(IsReadableFile(image) ? GameFilesState.NeedsExtraction : GameFilesState.ImageMissing, null, image); + } + + /// + /// Whether something is where the extracted game goes. An empty folder, or a link to one (made to put the game on + /// another drive), is just a place to extract into. A link whose target is not there (an unplugged drive) is not: + /// extracting would create the folder somewhere the user did not mean. + /// + private bool RootExists() + { + try + { + var root = paths.RootPath; + if (fileSystem.Directory.Exists(root)) + return fileSystem.Directory.EnumerateFileSystemEntries(root).Any(); + return fileSystem.File.Exists(root) || fileSystem.IsSymlink(root); + } + catch + { + // Unreadable is not empty. + return true; + } + } + + private bool IsReadableFile(string path) + { + try + { + return fileSystem.File.Exists(path); + } + catch + { + return false; + } + } + + private GameFilesManifest? ReadManifest() => ReadManifest(paths.ManifestPath); + + private GameFilesManifest? ReadManifest(string manifestPath) + { + try + { + if (!fileSystem.File.Exists(manifestPath)) + return null; + var manifest = JsonSerializer.Deserialize(fileSystem.File.ReadAllText(manifestPath)); + return manifest is { SchemaVersion: GameFilesManifest.CurrentSchemaVersion } && !string.IsNullOrWhiteSpace(manifest.GameId) + ? manifest + : null; + } + catch (Exception exception) + { + logger.LogWarning(exception, "The extracted game's manifest could not be read"); + return null; + } + } + + private void WriteManifest(string manifestPath, GameFilesManifest manifest) + { + var json = JsonSerializer.Serialize(manifest, ManifestJson); + // Written in place of nothing, flushed, then renamed: a manifest that exists is always complete. + var temp = manifestPath + ".tmp"; + using (var stream = fileSystem.FileStream.New(temp, FileMode.Create, FileAccess.Write, FileShare.None)) + using (var writer = new StreamWriter(stream)) + { + writer.Write(json); + writer.Flush(); + stream.Flush(flushToDisk: true); + } + + fileSystem.File.Move(temp, manifestPath, overwrite: true); + } + + private bool CodeMatches(GameFilesManifest manifest) => + HashEquals(HashFile(paths.MainDolPath), manifest.MainDolSha256) + && HashEquals(HashFile(paths.StaticRelPath), manifest.StaticRelSha256); + + private static bool HashEquals(string? actual, string expected) => + actual is not null && string.Equals(actual, expected, StringComparison.OrdinalIgnoreCase); + + private string? HashFile(string path) + { + try + { + var file = fileSystem.FileInfo.New(path); + if (!file.Exists) + return null; + lock (_hashLock) + { + if ( + _hashCache.TryGetValue(path, out var cached) + && cached.Length == file.Length + && cached.LastWrite == file.LastWriteTimeUtc + ) + return cached.Sha; + } + + using var stream = fileSystem.File.OpenRead(path); + var sha = Convert.ToHexString(SHA256.HashData(stream)).ToLowerInvariant(); + lock (_hashLock) + _hashCache[path] = (file.Length, file.LastWriteTimeUtc, sha); + return sha; + } + catch (Exception exception) + { + logger.LogWarning(exception, "Could not hash {Path}", path); + return null; + } + } + + public MarioKartWiiEnums.Regions GetRegion() + { + var manifest = ReadManifest(); + if (manifest is not null) + return GameRegion.FromGameId(manifest.GameId); + // The license loader asks on every poll; the disc image service only reopens the image once it changes. + var image = ImagePath; + if (image is null) + return MarioKartWiiEnums.Regions.None; + var info = discImages.ReadInfo(image); + return info.IsSuccess ? GameRegion.FromGameId(info.Value.GameId) : MarioKartWiiEnums.Regions.None; + } + + #endregion + + #region Planning + + public OperationResult Plan(string imagePath) + { + var contents = Inspect(imagePath); + if (contents.IsFailure) + return contents.Error; + + var manifest = ReadManifest(); + if (manifest is not null) + return new GameChangePlan( + manifest.IsSameGame(contents.Value.GameId, contents.Value.Revision) ? GameChangeKind.SameGame : GameChangeKind.Replace, + contents.Value, + manifest + ); + + return new GameChangePlan(RootExists() ? GameChangeKind.UnknownFolder : GameChangeKind.Extract, contents.Value, null); + } + + private OperationResult Inspect(string imagePath) + { + if (string.IsNullOrWhiteSpace(imagePath) || !IsReadableFile(imagePath)) + return Fail(t("message_error.invalid_game_file.unreadable")); + if (IsInsideGameFiles(imagePath)) + return Fail(t("game_files.image_inside_game_folder")); + + var contents = extractor.Inspect(imagePath); + if (contents.IsFailure) + return Fail(t("message_error.invalid_game_file.unreadable")); + if (GameRegion.FromGameId(contents.Value.GameId) == MarioKartWiiEnums.Regions.None) + return Fail( + t("message_error.invalid_game_file.wrong_game", new { name = contents.Value.InternalName, id = contents.Value.GameId }) + ); + return contents; + } + + /// An image kept inside the extracted game would be deleted along with it on the next replace. + private bool IsInsideGameFiles(string imagePath) + { + try + { + var full = fileSystem.Path.GetFullPath(imagePath); + return new[] { paths.RootPath, paths.ResolvedRootPath, paths.StagingPath, paths.ReplacedPath }.Any(folder => + fileSystem.Path.IsDescendantPath(full, folder) + ); + } + catch + { + return false; + } + } + + #endregion + + #region Extraction + + public async Task ExtractAsync( + string imagePath, + bool replaceExisting, + IProgress? progress = null, + CancellationToken cancellationToken = default + ) + { + if (!await _gate.WaitAsync(0, cancellationToken)) + return Fail(t("game_files.busy")); + try + { + return await Task.Run(() => ExtractCore(imagePath, replaceExisting, progress, cancellationToken), cancellationToken); + } + finally + { + _gate.Release(); + } + } + + private OperationResult ExtractCore( + string imagePath, + bool replaceExisting, + IProgress? progress, + CancellationToken cancellationToken + ) + { + RecoverInterruptedWork(); + var planResult = Plan(imagePath); + if (planResult.IsFailure) + return planResult.Error; + var plan = planResult.Value; + + switch (plan.Kind) + { + case GameChangeKind.UnknownFolder: + return Fail(t("game_files.unknown_folder_body")); + case GameChangeKind.Replace when !replaceExisting: + return Fail(t("game_files.replace_not_confirmed")); + case GameChangeKind.SameGame: + return RememberImage(imagePath, plan.Current!); + } + + var root = paths.ResolvedRootPath; + if (fileSystem.Path.IsRootDirectory(root)) + return Fail(t("game_files.link_to_drive_root")); + if (IsGameRunning()) + return Fail(t("game_files.close_game_body")); + + var staging = paths.StagingPath; + var space = EnsureFreeSpace(fileSystem.Path.GetDirectoryName(staging) ?? staging, plan.Image.RequiredBytes); + if (space.IsFailure) + return space; + + try + { + DeleteWorkFolder(staging); + fileSystem.Directory.CreateDirectory(staging); + fileSystem.File.WriteAllText(fileSystem.Path.Combine(staging, GameFilesPaths.WorkMarkerFileName), ""); + + progress?.Report(new(0, t("game_files.extracting"))); + var extracted = extractor.Extract( + imagePath, + staging, + new InlineProgress(value => progress?.Report(new(value, t("game_files.extracting")))), + cancellationToken + ); + if (extracted.IsFailure) + { + DeleteWorkFolder(staging); + return extracted.Error; + } + + cancellationToken.ThrowIfCancellationRequested(); + var image = fileSystem.FileInfo.New(imagePath); + WriteManifest( + fileSystem.Path.Combine(staging, GameFilesPaths.ManifestFileName), + new() + { + GameId = plan.Image.GameId, + Revision = plan.Image.Revision, + InternalName = plan.Image.InternalName, + SourcePath = fileSystem.Path.GetFullPath(imagePath), + SourceLength = image.Length, + SourceLastWriteUtc = image.LastWriteTimeUtc, + MainDolSha256 = extracted.Value.MainDolSha256, + StaticRelSha256 = extracted.Value.StaticRelSha256, + ExtractorVersion = extractor.Version, + ExtractedUtc = DateTime.UtcNow, + } + ); + + progress?.Report(new(1, t("game_files.finishing"))); + Promote(); + } + catch (OperationCanceledException) + { + TryDeleteWorkFolder(staging); + throw; + } + catch (Exception exception) + { + logger.LogError(exception, "Extracting the game failed"); + // A failure before the swap leaves only the staging folder; a failure during it is finished on the next start. + if (ReadManifest(fileSystem.Path.Combine(staging, GameFilesPaths.ManifestFileName)) is null) + TryDeleteWorkFolder(staging); + return new OperationError { Message = $"{t("game_files.extract_failed")} {exception.Message}", Exception = exception }; + } + + return settings.Set(settings.GAME_LOCATION, imagePath) ? Ok() : Fail(t("message_error.invalid_game_file.unreadable")); + } + + private OperationResult RememberImage(string imagePath, GameFilesManifest current) + { + try + { + var image = fileSystem.FileInfo.New(imagePath); + WriteManifest( + paths.ManifestPath, + current with + { + SourcePath = fileSystem.Path.GetFullPath(imagePath), + SourceLength = image.Length, + SourceLastWriteUtc = image.LastWriteTimeUtc, + } + ); + } + catch (Exception exception) + { + // The manifest only remembers the image for information; the game itself is fine as it is. + logger.LogWarning(exception, "Could not record the new game image in the manifest"); + } + + return settings.Set(settings.GAME_LOCATION, imagePath) ? Ok() : Fail(t("message_error.invalid_game_file.unreadable")); + } + + private OperationResult EnsureFreeSpace(string folder, long required) + { + try + { + fileSystem.Directory.CreateDirectory(folder); + var drive = fileSystem.DriveInfo.New(fileSystem.Path.GetPathRoot(fileSystem.Path.GetFullPath(folder))!); + var needed = required + FreeSpaceMargin; + if (drive.AvailableFreeSpace >= needed) + return Ok(); + return Fail( + t("game_files.not_enough_space", new { needed = FormatSize(needed), available = FormatSize(drive.AvailableFreeSpace) }) + ); + } + catch (Exception exception) + { + // Some drives (network shares, some Linux mounts) can't say; the extraction itself still stops on a full disk. + logger.LogWarning(exception, "Could not check the free space for the game files"); + return Ok(); + } + } + + private static string FormatSize(long bytes) => $"{bytes / (1024.0 * 1024 * 1024):0.0} GB"; + + /// The top-level entries of an extraction. Anything else the user put in the folder is never moved or deleted. + private static readonly string[] LayoutEntries = ["DATA", "disc"]; + + /// + /// Swaps the finished staging folder in. The current manifest leaves first and the new one arrives last, so at + /// every point in between can tell which half is old and which is new. + /// + private void Promote() + { + var root = paths.ResolvedRootPath; + var staging = paths.StagingPath; + var replaced = paths.ReplacedPath; + fileSystem.Directory.CreateDirectory(root); + + var currentManifest = fileSystem.Path.Combine(root, GameFilesPaths.ManifestFileName); + if (fileSystem.File.Exists(currentManifest)) + { + EnsureWorkFolder(replaced); + fileSystem.File.Move(currentManifest, fileSystem.Path.Combine(replaced, GameFilesPaths.ManifestFileName), overwrite: true); + } + + FinishPromote(); + } + + private void FinishPromote() + { + var root = paths.ResolvedRootPath; + var staging = paths.StagingPath; + var replaced = paths.ReplacedPath; + + foreach (var entry in LayoutEntries) + { + var incoming = fileSystem.Path.Combine(staging, entry); + if (!fileSystem.Directory.Exists(incoming)) + continue; + + var current = fileSystem.Path.Combine(root, entry); + if (fileSystem.Directory.Exists(current) || fileSystem.File.Exists(current)) + { + EnsureWorkFolder(replaced); + var parked = fileSystem.Path.Combine(replaced, entry); + if (fileSystem.Directory.Exists(parked)) + DeleteTree(parked); + MoveEntry(current, parked); + } + + fileSystem.Directory.Move(incoming, current); + } + + fileSystem.File.Move( + fileSystem.Path.Combine(staging, GameFilesPaths.ManifestFileName), + fileSystem.Path.Combine(root, GameFilesPaths.ManifestFileName), + overwrite: true + ); + lock (_hashLock) + _hashCache.Clear(); + + TryDeleteWorkFolder(replaced); + TryDeleteWorkFolder(staging); + } + + private void MoveEntry(string from, string to) + { + if (fileSystem.File.Exists(from) && !fileSystem.Directory.Exists(from)) + fileSystem.File.Move(from, to); + else + fileSystem.Directory.Move(from, to); + } + + public void RecoverInterruptedWork() + { + try + { + var staging = paths.StagingPath; + var stagingManifest = fileSystem.Path.Combine(staging, GameFilesPaths.ManifestFileName); + if (IsWorkFolder(staging) && ReadManifest(stagingManifest) is not null && ReadManifest() is null) + { + // The swap had started: the new extraction is complete, the old manifest already left. + logger.LogInformation("Finishing an interrupted game extraction"); + FinishPromote(); + if (ReadManifest() is { } finished && IsReadableFile(finished.SourcePath)) + settings.Set(settings.GAME_LOCATION, finished.SourcePath); + return; + } + + TryDeleteWorkFolder(staging); + if (ReadManifest() is not null) + TryDeleteWorkFolder(paths.ReplacedPath); + } + catch (Exception exception) + { + logger.LogWarning(exception, "Could not clean up an interrupted game extraction"); + } + } + + #endregion + + #region Restore + + public async Task RestoreCodeAsync(string? imagePath = null, CancellationToken cancellationToken = default) + { + if (!await _gate.WaitAsync(0, cancellationToken)) + return Fail(t("game_files.busy")); + try + { + return await Task.Run(() => RestoreCodeCore(imagePath ?? ImagePath, cancellationToken), cancellationToken); + } + finally + { + _gate.Release(); + } + } + + private OperationResult RestoreCodeCore(string? imagePath, CancellationToken cancellationToken) + { + var manifest = ReadManifest(); + if (manifest is null) + return Fail(t("game_files.not_extracted")); + if (imagePath is null) + return Fail(t("message_error.invalid_game_file.unreadable")); + + var contents = Inspect(imagePath); + if (contents.IsFailure) + return contents.Error; + if (!manifest.IsSameGame(contents.Value.GameId, contents.Value.Revision)) + return Fail(t("game_files.restore_wrong_game", new { id = manifest.GameId })); + if (IsGameRunning()) + return Fail(t("game_files.close_game_body")); + + var restored = extractor.RestoreCode(imagePath, fileSystem.Path.Combine(paths.ResolvedRootPath, "DATA"), cancellationToken); + if (restored.IsFailure) + return restored.Error; + + lock (_hashLock) + _hashCache.Clear(); + try + { + var image = fileSystem.FileInfo.New(imagePath); + WriteManifest( + paths.ManifestPath, + manifest with + { + MainDolSha256 = restored.Value.MainDolSha256, + StaticRelSha256 = restored.Value.StaticRelSha256, + SourcePath = fileSystem.Path.GetFullPath(imagePath), + SourceLength = image.Length, + SourceLastWriteUtc = image.LastWriteTimeUtc, + } + ); + } + catch (Exception exception) + { + return new OperationError { Message = $"{t("game_files.extract_failed")} {exception.Message}", Exception = exception }; + } + + return settings.Set(settings.GAME_LOCATION, imagePath) ? Ok() : Fail(t("message_error.invalid_game_file.unreadable")); + } + + #endregion + + #region Unknown folder + + public OperationResult MoveUnknownAside() + { + if (ReadManifest() is not null) + return Fail(t("game_files.not_unknown")); + try + { + var root = paths.RootPath; + var backup = $"{root}.backup-{DateTime.Now:yyyyMMdd-HHmmss}"; + for (var attempt = 2; fileSystem.Directory.Exists(backup) || fileSystem.File.Exists(backup); attempt++) + backup = $"{root}.backup-{DateTime.Now:yyyyMMdd-HHmmss}-{attempt}"; + + // Moving the link itself, not its target: the folder it points to stays where the user put it. + if (fileSystem.IsSymlink(root)) + MoveLink(root, backup); + else if (fileSystem.File.Exists(root) && !fileSystem.Directory.Exists(root)) + fileSystem.File.Move(root, backup); + else + fileSystem.Directory.Move(root, backup); + logger.LogInformation("Moved an unknown game folder aside to {Backup}", backup); + return backup; + } + catch (Exception exception) + { + return new OperationError { Message = $"{t("game_files.move_aside_failed")} {exception.Message}", Exception = exception }; + } + } + + public OperationResult DeleteUnknown() + { + if (ReadManifest() is not null) + return Fail(t("game_files.not_unknown")); + try + { + var root = paths.RootPath; + if (fileSystem.IsSymlink(root)) + DeleteLink(root); + else if (fileSystem.Directory.Exists(root)) + DeleteTree(root); + else if (fileSystem.File.Exists(root)) + fileSystem.File.Delete(root); + logger.LogInformation("Deleted an unknown game folder at {Root}", root); + return Ok(); + } + catch (Exception exception) + { + return new OperationError { Message = $"{t("game_files.delete_failed")} {exception.Message}", Exception = exception }; + } + } + + /// Recreates the link under the new name and removes the old one, so nothing behind it moves. + private void MoveLink(string from, string to) + { + var target = fileSystem.FileInfo.New(from).LinkTarget!; + try + { + fileSystem.Directory.CreateSymbolicLink(to, target); + } + catch (Exception exception) when (exception is UnauthorizedAccessException or IOException) + { + // Windows only lets some accounts create links, but renaming one (a junction too) moves just the link. + fileSystem.Directory.Move(from, to); + return; + } + + DeleteLink(from); + } + + private void DeleteLink(string path) + { + // A directory link (symlink or junction) is removed as a directory on Windows and unlinked like a file + // elsewhere; neither touches its target. + if (fileSystem.Path.DirectorySeparatorChar == '\\' && fileSystem.Directory.Exists(path)) + fileSystem.Directory.Delete(path, recursive: false); + else + fileSystem.File.Delete(path); + } + + #endregion + + #region Deleting + + private bool IsWorkFolder(string path) + { + try + { + return fileSystem.Directory.Exists(path) + && !fileSystem.IsSymlink(path) + && fileSystem.File.Exists(fileSystem.Path.Combine(path, GameFilesPaths.WorkMarkerFileName)); + } + catch + { + return false; + } + } + + private void EnsureWorkFolder(string path) + { + if (IsWorkFolder(path)) + return; + if (fileSystem.Directory.Exists(path) || fileSystem.File.Exists(path)) + throw new IOException($"'{path}' is in the way and is not Wheel Wizard's to remove."); + fileSystem.Directory.CreateDirectory(path); + fileSystem.File.WriteAllText(fileSystem.Path.Combine(path, GameFilesPaths.WorkMarkerFileName), ""); + } + + private void DeleteWorkFolder(string path) + { + if (!fileSystem.Directory.Exists(path) && !fileSystem.File.Exists(path)) + return; + if (!IsWorkFolder(path)) + throw new IOException($"'{path}' is in the way and is not Wheel Wizard's to remove."); + DeleteTree(path); + } + + private void TryDeleteWorkFolder(string path) + { + try + { + if (IsWorkFolder(path)) + DeleteTree(path); + } + catch (Exception exception) + { + logger.LogWarning(exception, "Could not delete {Path}; it is retried on the next start", path); + } + } + + /// Deletes a folder without ever following a link inside it: links are removed, their targets are left alone. + private void DeleteTree(string path) + { + foreach (var entry in fileSystem.Directory.EnumerateFileSystemEntries(path)) + { + if (fileSystem.IsSymlink(entry)) + { + DeleteLink(entry); + continue; + } + + if (fileSystem.Directory.Exists(entry)) + { + DeleteTree(entry); + continue; + } + + var file = fileSystem.FileInfo.New(entry); + if (file.IsReadOnly) + file.IsReadOnly = false; + file.Delete(); + } + + fileSystem.Directory.Delete(path, recursive: false); + } + + #endregion + + #region Running game + + // Dolphin's process name on each platform and packaging, and WiiCompiled's two runtimes. + private static readonly string[] GameProcessNames = ["Dolphin", "dolphin-emu", "DolphinQt", "WiiCompiled", "RetroRewind"]; + + public bool IsGameRunning() => RunningProcessNames().Any(name => processes.GetProcessIds(name).Count > 0); + + public void CloseRunningGame() + { + foreach (var name in RunningProcessNames()) + { + try + { + processes.KillByName(name); + } + catch (Exception exception) + { + logger.LogWarning(exception, "Could not close {Process}", name); + } + } + } + + private IEnumerable RunningProcessNames() + { + var names = new HashSet(GameProcessNames, StringComparer.OrdinalIgnoreCase); + try + { + var configured = fileSystem.Path.GetFileNameWithoutExtension(dolphinPaths.ExecutablePath); + if (!string.IsNullOrWhiteSpace(configured) && !configured.Contains(' ')) + names.Add(configured); + } + catch + { + // A Dolphin command that isn't a path (a Flatpak command line) is covered by the names above. + } + + return names; + } + + #endregion + + private sealed class InlineProgress(Action report) : IProgress + { + public void Report(T value) => report(value); + } +} diff --git a/WheelWizard/Features/GameFiles/Views/GameFilesFlow.cs b/WheelWizard/Features/GameFiles/Views/GameFilesFlow.cs new file mode 100644 index 000000000..1c0743c20 --- /dev/null +++ b/WheelWizard/Features/GameFiles/Views/GameFilesFlow.cs @@ -0,0 +1,265 @@ +using Avalonia.Threading; +using Microsoft.Extensions.Logging; +using WheelWizard.Recomp; +using WheelWizard.Views.Components; +using WheelWizard.Views.Dialogs; +using WheelWizard.WiiManagement.GameLicense; + +namespace WheelWizard.GameFiles.Views; + +/// +/// The questions around changing the extracted game: what to do with what is there now, waiting for a running game +/// to close, then extracting with progress. Shared by Settings, the game setup page and the onboarding. +/// +public interface IGameFilesFlow +{ + /// Makes the game, asking whatever has to be asked first. + /// Shows progress inline; without it a progress window is shown. + /// Whether the game is now extracted from (or remembered as) this image. + Task ChangeImageAsync( + string imagePath, + IProgress? progress = null, + CancellationToken cancellationToken = default + ); + + /// Extracts the image already picked, for someone coming from an older version. + Task ExtractCurrentAsync(IProgress? progress = null, CancellationToken cancellationToken = default); + + /// Asks whether to move aside or delete a folder that is in the way. + Task ResolveUnknownFolderAsync(); + + /// Waits until Dolphin and WiiCompiled are closed, offering to close them. False when the user gave up. + Task WaitForGameClosedAsync(); + + /// Points an installed WiiCompiled at the extracted game. Failures leave WiiCompiled asking for an update. + Task LinkWiiCompiledAsync(); +} + +public sealed class GameFilesFlow(IGameFilesService gameFiles, IServiceProvider services, ILogger logger) : IGameFilesFlow +{ + private const string PalGameIdPrefix = "RMCP"; + + public async Task ChangeImageAsync( + string imagePath, + IProgress? progress = null, + CancellationToken cancellationToken = default + ) + { + var plan = gameFiles.Plan(imagePath); + if (plan.IsFailure) + { + await ShowErrorAsync(t("message_error.invalid_game_file.title"), plan.Error.Message); + return false; + } + + var replace = false; + switch (plan.Value.Kind) + { + case GameChangeKind.SameGame: + return await RunAsync(imagePath, replaceExisting: false, progress, cancellationToken); + case GameChangeKind.UnknownFolder: + if (!await ResolveUnknownFolderAsync()) + return false; + break; + case GameChangeKind.Replace: + if (!await ConfirmReplaceAsync(plan.Value)) + return false; + replace = true; + break; + } + + return await RunAsync(imagePath, replace, progress, cancellationToken); + } + + public async Task ExtractCurrentAsync( + IProgress? progress = null, + CancellationToken cancellationToken = default + ) + { + var image = gameFiles.ImagePath; + return image is not null && await ChangeImageAsync(image, progress, cancellationToken); + } + + private async Task ConfirmReplaceAsync(GameChangePlan plan) + { + var current = plan.Current!; + var body = t( + "game_files.replace_body", + new { current = Describe(current.GameId, current.Revision), incoming = Describe(plan.Image.GameId, plan.Image.Revision) } + ); + var recomp = services.GetService(); + if (recomp?.IsInstalled == true && !plan.Image.GameId.StartsWith(PalGameIdPrefix, StringComparison.OrdinalIgnoreCase)) + body += "\n\n" + t("game_files.replace_breaks_wiicompiled"); + + return await new YesNoWindow() + .SetMainText(t("game_files.replace_title")) + .SetExtraText(body) + .SetButtonText(t("game_files.replace_action"), t("action.cancel")) + .SetButtonTones(ActionButtonTone.Danger, ActionButtonTone.Secondary) + .AwaitAnswer(); + } + + private static string Describe(string gameId, byte revision) + { + var region = GameRegion.GetDisplayName(GameRegion.FromGameId(gameId)); + return revision == 0 ? $"Mario Kart Wii · {region} ({gameId})" : $"Mario Kart Wii · {region} ({gameId}, rev {revision})"; + } + + public async Task ResolveUnknownFolderAsync() + { + var moveAside = await new YesNoWindow() + .SetMainText(t("game_files.unknown_folder_title")) + .SetExtraText(t("game_files.unknown_folder_body")) + .SetButtonText(t("game_files.move_aside"), t("game_files.delete")) + .SetButtonTones(ActionButtonTone.Brand, ActionButtonTone.Secondary) + .AwaitWithChoiceAsync(); + + switch (moveAside) + { + case true: + { + var moved = await Task.Run(gameFiles.MoveUnknownAside); + if (moved.IsSuccess) + return true; + await ShowErrorAsync(t("game_files.unknown_folder_title"), moved.Error.Message); + return false; + } + case false: + { + var sure = await new YesNoWindow() + .SetMainText(t("game_files.delete_confirm_title")) + .SetExtraText(t("game_files.delete_confirm_body")) + .SetButtonText(t("game_files.delete"), t("action.cancel")) + .SetButtonTones(ActionButtonTone.Danger, ActionButtonTone.Secondary) + .AwaitAnswer(); + if (!sure) + return false; + var deleted = await Task.Run(gameFiles.DeleteUnknown); + if (deleted.IsSuccess) + return true; + await ShowErrorAsync(t("game_files.unknown_folder_title"), deleted.Error.Message); + return false; + } + default: + return false; + } + } + + public async Task WaitForGameClosedAsync() + { + if (!await Task.Run(gameFiles.IsGameRunning)) + return true; + + var window = new YesNoWindow() + .SetMainText(t("game_files.close_game_title")) + .SetExtraText(t("game_files.close_game_body")) + .SetButtonText(t("game_files.close_game_action"), t("action.cancel")) + .SetButtonTones(ActionButtonTone.Secondary, ActionButtonTone.Secondary); + + // The prompt goes away by itself once the game is closed, however that happened. + var closedByItself = false; + using var polling = new CancellationTokenSource(); + _ = Task.Run(async () => + { + while (!polling.IsCancellationRequested) + { + await Task.Delay(TimeSpan.FromSeconds(1), polling.Token).ConfigureAwait(false); + if (gameFiles.IsGameRunning()) + continue; + closedByItself = true; + await Dispatcher.UIThread.InvokeAsync(window.Close); + return; + } + }); + + var closeForMe = await window.AwaitAnswer(); + await polling.CancelAsync(); + if (closedByItself) + return true; + if (!closeForMe) + return false; + + await Task.Run(gameFiles.CloseRunningGame); + for (var attempt = 0; attempt < 20 && await Task.Run(gameFiles.IsGameRunning); attempt++) + await Task.Delay(250); + return !await Task.Run(gameFiles.IsGameRunning) || await WaitForGameClosedAsync(); + } + + private async Task RunAsync( + string imagePath, + bool replaceExisting, + IProgress? progress, + CancellationToken cancellationToken + ) + { + if (!await WaitForGameClosedAsync()) + return false; + + OperationResult result; + ProgressWindow? window = null; + using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + try + { + if (progress is null) + { + window = new ProgressWindow(t("game_files.extracting_title")) + .SetGoal(t("game_files.extracting_title")) + .SetExtraText(t("game_files.extracting")) + .SetCancellationTokenSource(cancellation); + window.Show(); + var shown = window; + progress = new Progress(value => + { + shown.UpdateProgress((int)(value.Fraction * 100)); + shown.SetExtraText(value.Message); + }); + } + + result = await gameFiles.ExtractAsync(imagePath, replaceExisting, progress, cancellation.Token); + } + catch (OperationCanceledException) when (cancellation.IsCancellationRequested) + { + return false; + } + finally + { + window?.Close(); + } + + if (result.IsFailure) + { + await ShowErrorAsync(t("game_files.extract_failed_title"), result.Error.Message); + return false; + } + + await LinkWiiCompiledAsync(); + return true; + } + + public async Task LinkWiiCompiledAsync() + { + var recomp = services.GetService(); + if (recomp is not { IsInstalled: true }) + return; + + var window = new ProgressWindow(t("game_files.linking_wiicompiled")).SetIndeterminate(); + try + { + window.Show(); + var result = await recomp.LinkGameDataAsync(); + if (result.IsFailure) + logger.LogWarning("WiiCompiled could not be pointed at the extracted game: {Message}", result.Error.Message); + } + catch (Exception exception) + { + logger.LogWarning(exception, "WiiCompiled could not be pointed at the extracted game"); + } + finally + { + window.Close(); + } + } + + private static Task ShowErrorAsync(string title, string message) => + new MessageBoxWindow().SetMessageType(MessageBoxWindow.MessageType.Error).SetTitleText(title).SetInfoText(message).ShowDialog(); +} diff --git a/WheelWizard/Features/GameFiles/Views/GameSetupPage.axaml b/WheelWizard/Features/GameFiles/Views/GameSetupPage.axaml new file mode 100644 index 000000000..4b0e8a8b3 --- /dev/null +++ b/WheelWizard/Features/GameFiles/Views/GameSetupPage.axaml @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/WheelWizard/Features/GameFiles/Views/GameSetupPage.axaml.cs b/WheelWizard/Features/GameFiles/Views/GameSetupPage.axaml.cs new file mode 100644 index 000000000..9a1d94f75 --- /dev/null +++ b/WheelWizard/Features/GameFiles/Views/GameSetupPage.axaml.cs @@ -0,0 +1,282 @@ +using Avalonia.Controls; +using Avalonia.Interactivity; +using Avalonia.Media; +using Avalonia.Platform.Storage; +using Microsoft.Extensions.Logging; +using WheelWizard.DiscImages; +using WheelWizard.Settings.Views; +using WheelWizard.Shared.Desktop.Storage; +using WheelWizard.Views.Shell; +using WheelWizard.Views.Shell.Navigation; +using WheelWizard.Views.Shell.Views; +using WheelWizard.WiiManagement.GameLicense; + +namespace WheelWizard.GameFiles.Views; + +/// +/// Shown instead of Home while the extracted game is not usable: someone coming from a version that played the image +/// directly, an image that can't be found, a changed main.dol or StaticR.rel, or a folder in the way. Only Settings +/// stays reachable, to free up or move the data folder. +/// +public partial class GameSetupPage : UserControl, IBlockingPage +{ + private readonly IGameFilesService _gameFiles; + private readonly IGameFilesFlow _flow; + private readonly IFilePickerService _filePicker; + private readonly IDiscImageService _discImages; + private readonly INavigationService _navigation; + private readonly ILogger _logger; + + private GameFilesStatus _status = new(GameFilesState.NoImage, null, null); + private CancellationTokenSource? _extraction; + private bool _busy; + + public GameSetupPage( + IGameFilesService gameFiles, + IGameFilesFlow flow, + IFilePickerService filePicker, + IDiscImageService discImages, + INavigationService navigation, + ILogger logger + ) + { + _gameFiles = gameFiles; + _flow = flow; + _filePicker = filePicker; + _discImages = discImages; + _navigation = navigation; + _logger = logger; + InitializeComponent(); + _navigation.IsLocked = true; + Refresh(); + } + + /// Whether the game needs this page before anything can be played. + public static bool IsNeeded(GameFilesStatus status) => status.State is not (GameFilesState.Ready or GameFilesState.NoImage); + + private void Refresh() + { + _gameFiles.RecoverInterruptedWork(); + _status = _gameFiles.GetStatus(); + if (!IsNeeded(_status)) + { + Finish(); + return; + } + + var (title, body) = _status.State switch + { + GameFilesState.ImageMissing => (t("game_files.missing_title"), t("game_files.missing_body")), + GameFilesState.CodeModified => (t("game_files.modified_title"), t("game_files.modified_body")), + GameFilesState.UnknownFolder => (t("game_files.unknown_folder_title"), t("game_files.unknown_folder_body")), + _ => (t("game_files.setup_title"), t("game_files.setup_body")), + }; + TitleText.Text = title; + BodyText.Text = body; + ShowGame(); + + var hasImage = _status.ImagePath is { } image && File.Exists(image); + (PrimaryButton.Text, PrimaryButton.IsVisible) = _status.State switch + { + GameFilesState.NeedsExtraction => (t("game_files.extract_action"), true), + GameFilesState.ImageMissing => (t("game_files.pick_game"), true), + GameFilesState.CodeModified => hasImage ? (t("game_files.restore_action"), true) : (t("game_files.pick_game"), true), + GameFilesState.UnknownFolder => (t("game_files.move_aside"), true), + _ => (string.Empty, false), + }; + SecondaryButton.Text = t("game_files.delete"); + SecondaryButton.IsVisible = _status.State == GameFilesState.UnknownFolder; + PickOtherButton.IsVisible = + _status.State is GameFilesState.NeedsExtraction || (_status.State == GameFilesState.CodeModified && hasImage); + SetBusy(false); + } + + private void ShowGame() + { + var image = _status.ImagePath; + GameBox.IsVisible = image is not null || _status.Manifest is not null; + var gameId = + _status.Manifest?.GameId + ?? (image is not null && _discImages.ReadInfo(image) is { IsSuccess: true } info ? info.Value.GameId : null); + var region = GameRegion.FromGameId(gameId); + GameNameText.Text = + region == Models.Enums.MarioKartWiiEnums.Regions.None + ? "Mario Kart Wii" + : $"Mario Kart Wii · {GameRegion.GetDisplayName(region)}"; + GamePathText.Text = image ?? string.Empty; + GamePathText.IsVisible = image is not null; + var reachable = image is not null && File.Exists(image); + // The page is filled in while it is built, before it is in a window whose theme it could look in. + if (this.TryFindResource(reachable ? "SuccessTip" : "WarningTip", out var icon) && icon is Geometry geometry) + GameIcon.Data = geometry; + if (this.TryFindResource(reachable ? "Brand300" : "Warning400", out var tone) && tone is Color color) + GameIcon.Foreground = new SolidColorBrush(color); + } + + private void SetBusy(bool busy, bool extracting = false) + { + _busy = busy; + ActionsPanel.IsEnabled = !busy; + ActionsPanel.IsVisible = !extracting; + ProgressPanel.IsVisible = extracting; + if (!busy) + ProgressPanel.IsVisible = false; + } + + private void ShowError(string? message) + { + ErrorText.Text = message ?? string.Empty; + ErrorText.IsVisible = !string.IsNullOrEmpty(message); + } + + private async void Primary_OnClick(object? sender, RoutedEventArgs e) + { + if (_busy) + return; + ShowError(null); + switch (_status.State) + { + case GameFilesState.NeedsExtraction: + await ExtractAsync(progress => _flow.ExtractCurrentAsync(progress, _extraction!.Token)); + break; + case GameFilesState.ImageMissing: + await PickAndUseAsync(); + break; + case GameFilesState.CodeModified when _status.ImagePath is { } image && File.Exists(image): + await RestoreAsync(image); + break; + case GameFilesState.CodeModified: + await PickAndUseAsync(); + break; + case GameFilesState.UnknownFolder: + await RunBusyAsync(async () => + { + var moved = await Task.Run(_gameFiles.MoveUnknownAside); + if (moved.IsFailure) + ShowError(moved.Error.Message); + }); + break; + } + + Refresh(); + } + + private async void Secondary_OnClick(object? sender, RoutedEventArgs e) + { + if (_busy || _status.State != GameFilesState.UnknownFolder) + return; + ShowError(null); + await RunBusyAsync(() => _flow.ResolveUnknownFolderAsync()); + Refresh(); + } + + private async void PickOther_OnClick(object? sender, RoutedEventArgs e) + { + if (_busy) + return; + ShowError(null); + await PickAndUseAsync(); + Refresh(); + } + + private async Task PickAndUseAsync() + { + var fileType = new FilePickerFileType("Game files") + { + Patterns = _discImages.SupportedExtensions.Select(extension => "*" + extension).ToArray(), + }; + var file = await _filePicker.OpenSingleFileAsync("Select Mario Kart Wii Game File", [fileType]); + if (string.IsNullOrEmpty(file)) + return; + + if (_status.State == GameFilesState.CodeModified) + await RestoreAsync(file); + else + await ExtractAsync(progress => _flow.ChangeImageAsync(file, progress, _extraction!.Token)); + } + + private async Task RestoreAsync(string image) + { + await RunBusyAsync(async () => + { + if (!await _flow.WaitForGameClosedAsync()) + return; + var restored = await _gameFiles.RestoreCodeAsync(image); + if (restored.IsFailure) + ShowError(restored.Error.Message); + }); + } + + private async Task ExtractAsync(Func, Task> extract) + { + _extraction = new CancellationTokenSource(); + SetBusy(true, extracting: true); + CancelButton.IsEnabled = true; + ExtractProgressBar.Value = 0; + ProgressText.Text = t("game_files.extracting"); + var progress = new Progress(value => + { + ExtractProgressBar.Value = Math.Clamp(value.Fraction * 100, 0, 100); + ProgressText.Text = $"{value.Message} · {(int)(value.Fraction * 100)}%"; + }); + try + { + await extract(progress); + } + catch (Exception exception) + { + _logger.LogError(exception, "Preparing the game files failed"); + ShowError(exception.Message); + } + finally + { + _extraction.Dispose(); + _extraction = null; + SetBusy(false); + } + } + + private async Task RunBusyAsync(Func action) + { + SetBusy(true); + try + { + await action(); + } + catch (Exception exception) + { + _logger.LogError(exception, "A game setup action failed"); + ShowError(exception.Message); + } + finally + { + SetBusy(false); + } + } + + private void Cancel_OnClick(object? sender, RoutedEventArgs e) + { + CancelButton.IsEnabled = false; + _extraction?.Cancel(); + } + + private void OpenSettings_OnClick(object? sender, RoutedEventArgs e) + { + if (_busy) + return; + // Settings is the one page reachable from here; leaving it for anything else comes back to this page. + _navigation.IsLocked = false; + _navigation.NavigateAway(typeof(SettingsPage)); + } + + private void Finish() + { + _navigation.IsLocked = false; + _navigation.NavigateAway(typeof(HomePage)); + if (ViewUtils.GetLayout() is { } layout) + { + layout.UpdateSidebarProfile(); + layout.UpdateFriendCount(); + } + } +} diff --git a/WheelWizard/Features/Launching/DolphinLaunchService.cs b/WheelWizard/Features/Launching/DolphinLaunchService.cs index 3a29cfb6d..9dbb3bf7f 100644 --- a/WheelWizard/Features/Launching/DolphinLaunchService.cs +++ b/WheelWizard/Features/Launching/DolphinLaunchService.cs @@ -4,7 +4,7 @@ using WheelWizard.CustomDistributions; using WheelWizard.Dolphin.Paths; using WheelWizard.DolphinInstaller; -using WheelWizard.Settings; +using WheelWizard.GameFiles; using WheelWizard.Shared.IO; using WheelWizard.Shared.Platform; using WheelWizard.Shared.Processes; @@ -19,7 +19,6 @@ public interface IDolphinLaunchService } public sealed class DolphinLaunchService( - ISettingsManager settings, IDolphinPaths paths, ICustomDistributionPaths distributionPaths, IFileSystem fileSystem, @@ -28,7 +27,8 @@ public sealed class DolphinLaunchService( IUnixProcessService linuxProcesses, IDolphinVersionService versions, ILinuxDolphinInstaller installer, - IDolphinLaunchPresentation presentation + IDolphinLaunchPresentation presentation, + IGameFilesPaths gameFiles ) : IDolphinLaunchService { private string QuotePath(string path) => ShellQuoting.QuoteArgument(path, environment.IsWindows); @@ -140,8 +140,10 @@ void AddFilesystemPerm(string newFilesystemPerm, string mode = "") // Read-only permissions on files where possible - if (!TryFixFlatpakPortalAccess(settings.Get(settings.GAME_LOCATION), dolphinAppId, "-r")) - AddFilesystemPerm(settings.Get(settings.GAME_LOCATION), ":ro"); + // Dolphin boots the extracted game, so it needs the whole folder, and the folder a linked one points to. + AddFilesystemPerm(gameFiles.RootPath, ":ro"); + if (gameFiles.IsLinked) + AddFilesystemPerm(gameFiles.ResolvedRootPath, ":ro"); // We need to provide the directory where the `RR.json` is located in for portal access! if (!TryFixFlatpakPortalAccess(fileSystem.Path.GetDirectoryName(distributionPaths.LaunchJsonFilePath) ?? "", dolphinAppId, "-r")) AddFilesystemPerm(distributionPaths.LaunchJsonFilePath, ":ro"); diff --git a/WheelWizard/Features/Launching/RetroRewindLaunchDescriptor.cs b/WheelWizard/Features/Launching/RetroRewindLaunchDescriptor.cs index e05d9dc7e..58072dd58 100644 --- a/WheelWizard/Features/Launching/RetroRewindLaunchDescriptor.cs +++ b/WheelWizard/Features/Launching/RetroRewindLaunchDescriptor.cs @@ -2,8 +2,8 @@ using System.Text.Json; using System.Text.Json.Serialization; using WheelWizard.CustomDistributions; +using WheelWizard.GameFiles; using WheelWizard.Models.RRLaunchModels; -using WheelWizard.Settings; namespace WheelWizard.Launching; @@ -13,7 +13,7 @@ public interface IRetroRewindLaunchDescriptor void GenerateLaunchJson(string xmlFilePath); } -public sealed class RetroRewindLaunchDescriptor(IFileSystem fileSystem, ISettingsManager settings, ICustomDistributionPaths paths) +public sealed class RetroRewindLaunchDescriptor(IFileSystem fileSystem, ICustomDistributionPaths paths, IGameFilesPaths gameFiles) : IRetroRewindLaunchDescriptor { private string XmlFilePath => paths.XmlFilePath; @@ -46,7 +46,8 @@ bool enableSeparateSave { var launchConfig = new LaunchConfig { - BaseFile = fileSystem.Path.GetFullPath(settings.Get(settings.GAME_LOCATION)), + // Dolphin opens an extracted disc through its sys/main.dol and finds the rest of the layout around it. + BaseFile = fileSystem.Path.GetFullPath(gameFiles.MainDolPath), DisplayName = "RR", Riivolution = new() { diff --git a/WheelWizard/Features/Launching/RetroRewindLaunchService.cs b/WheelWizard/Features/Launching/RetroRewindLaunchService.cs index 41159fe64..1c0daa5c1 100644 --- a/WheelWizard/Features/Launching/RetroRewindLaunchService.cs +++ b/WheelWizard/Features/Launching/RetroRewindLaunchService.cs @@ -1,6 +1,7 @@ using System.IO.Abstractions; using WheelWizard.CustomDistributions; using WheelWizard.Dolphin.Paths; +using WheelWizard.GameFiles; using WheelWizard.Mods; using WheelWizard.Settings; using WheelWizard.Shared.Platform; @@ -30,7 +31,8 @@ public sealed class RetroRewindLaunchService( IRetroRewindLaunchDescriptor descriptor, IRuntimeEnvironment environment, ILaunchPrompts prompts, - IModOperationPresentation modPresentation + IModOperationPresentation modPresentation, + IGameFilesService gameFiles ) : IRetroRewindLaunchService { private string QuotePath(string path) => ShellQuoting.QuoteArgument(path, environment.IsWindows); @@ -39,8 +41,8 @@ public async Task LaunchAsync(bool beta = false) { try { - //case SHOULD be impossible since launch button should be disabled - if (!fileSystem.File.Exists(settings.Get(settings.GAME_LOCATION))) + // Dolphin boots the extracted game; the game setup page keeps Home out of reach until it is ready. + if (!gameFiles.GetStatus().IsReady) return Fail(t("message_warning.not_find_game.extra")); // Check first so a blocked launch does not kill Dolphin or prepare patches. diff --git a/WheelWizard/Features/Onboarding/Views/OnboardingPage.axaml b/WheelWizard/Features/Onboarding/Views/OnboardingPage.axaml index 4ded754cf..15e083fe0 100644 --- a/WheelWizard/Features/Onboarding/Views/OnboardingPage.axaml +++ b/WheelWizard/Features/Onboarding/Views/OnboardingPage.axaml @@ -184,6 +184,10 @@ + + + + diff --git a/WheelWizard/Features/Onboarding/Views/OnboardingPage.axaml.cs b/WheelWizard/Features/Onboarding/Views/OnboardingPage.axaml.cs index 720a8f9a1..15433123e 100644 --- a/WheelWizard/Features/Onboarding/Views/OnboardingPage.axaml.cs +++ b/WheelWizard/Features/Onboarding/Views/OnboardingPage.axaml.cs @@ -8,6 +8,8 @@ using WheelWizard.CustomDistributions; using WheelWizard.DiscImages; using WheelWizard.DolphinInstaller; +using WheelWizard.GameFiles; +using WheelWizard.GameFiles.Views; using WheelWizard.Recomp; using WheelWizard.Recomp.Domain; using WheelWizard.Settings; @@ -52,6 +54,7 @@ private enum Frontend private readonly IDiscImageService _discImages; private readonly INavigationService _navigation; private readonly ISettingsManager _settings; + private readonly IGameFilesFlow _gameFilesFlow; private readonly ILogger _logger; private readonly Stack _history = new(); @@ -80,10 +83,12 @@ public OnboardingPage( IDiscImageService discImages, INavigationService navigation, ISettingsManager settings, + IGameFilesFlow gameFilesFlow, ILogger logger ) { _settings = settings; + _gameFilesFlow = gameFilesFlow; _onboarding = onboarding; _retroRewind = retroRewind; _managedDolphin = managedDolphin; @@ -203,7 +208,8 @@ private void GoBack() private void SetBusy(bool busy) { _busy = busy; - IsEnabled = !busy || _step is Step.DolphinInstalling or Step.WiiCompiledInstalling; + // While something installs (or the game extracts) the page stays usable, so Back can cancel it. + IsEnabled = !busy || _step is Step.DolphinInstalling or Step.WiiCompiledInstalling or Step.GameFile; UpdateBackButton(); } @@ -539,13 +545,65 @@ private async void GameContinue_OnClick(object? sender, RoutedEventArgs e) { if (_gameFile is null || _busy) return; - var result = _onboarding.SetGameFile(_gameFile); - if (result.IsFailure) + if (await ExtractGameAsync(_gameFile)) + await ContinueAfterGameAsync(); + } + + /// + /// The game is played from an extracted copy, so it is extracted here, before anything else needs it. Back + /// cancels it like an install, which removes the unfinished extraction. + /// + private async Task ExtractGameAsync(string file) + { + var cancellation = new CancellationTokenSource(); + _installCancellation = cancellation; + SetBusy(true); + SetGameExtracting(true); + try { - ShowGameFile(_gameFile); - return; + var progress = new Progress(value => + { + if (cancellation.IsCancellationRequested) + return; + GameProgressBar.Value = Math.Clamp(value.Fraction * 100, 0, 100); + GameProgressText.Text = $"{value.Message} · {(int)(value.Fraction * 100)}%"; + }); + return await _gameFilesFlow.ChangeImageAsync(file, progress, cancellation.Token) && !cancellation.IsCancellationRequested; + } + catch (OperationCanceledException) when (cancellation.IsCancellationRequested) + { + return false; + } + catch (Exception exception) + { + _logger.LogError(exception, "Extracting the game failed"); + return false; + } + finally + { + SetGameExtracting(false); + // Back already let go of a cancelled extraction and moved on. + if (_installCancellation == cancellation) + { + _installCancellation = null; + SetBusy(false); + } + + cancellation.Dispose(); } + } + private void SetGameExtracting(bool extracting) + { + GameProgressPanel.IsVisible = extracting; + GameDropZone.IsEnabled = !extracting; + GameContinueButton.IsEnabled = !extracting && _gameFile is not null; + GameProgressBar.Value = 0; + GameProgressText.Text = t("game_files.extracting"); + } + + private async Task ContinueAfterGameAsync() + { if (_frontend == Frontend.Dolphin) { Finish(_onboarding.FinishDolphin()); diff --git a/WheelWizard/Features/Recomp/Domain/RecompInstallModels.cs b/WheelWizard/Features/Recomp/Domain/RecompInstallModels.cs index bd2fb7560..26ae72787 100644 --- a/WheelWizard/Features/Recomp/Domain/RecompInstallModels.cs +++ b/WheelWizard/Features/Recomp/Domain/RecompInstallModels.cs @@ -36,6 +36,12 @@ public class RecompInstallState /// public string? RetroWfcPayloadMode { get; set; } + /// + /// The extracted game folder (…/GAME/DATA) the installation reads its game data from. Empty for an + /// installation that still uses a copy of its own, which has to be pointed at Wheel Wizard's before it can launch. + /// + public string? GameDataDir { get; set; } + /// Whether the installed Retro Rewind product was built without a Retro-WFC payload. public bool IsRetroWfcPayloadSkipped => string.Equals(RetroWfcPayloadMode, "skipped", StringComparison.OrdinalIgnoreCase); } @@ -59,8 +65,11 @@ public enum RecompRetroWfcPayloadMode /// public sealed record RecompInstallRequest { - /// Path to the user's Mario Kart Wii disc image (WheelWizard's GameLocation setting). - public required string GameFilePath { get; init; } + /// + /// Wheel Wizard's extracted game (the folder holding DATA/). WiiCompiled reads it in place instead of + /// extracting a copy of its own. + /// + public required string GameFolderPath { get; init; } /// Directory the recomp should install into. public required string InstallFolderPath { get; init; } diff --git a/WheelWizard/Features/Recomp/Domain/RecompSetupEvent.cs b/WheelWizard/Features/Recomp/Domain/RecompSetupEvent.cs index a4dfb84f8..7ba34aaa6 100644 --- a/WheelWizard/Features/Recomp/Domain/RecompSetupEvent.cs +++ b/WheelWizard/Features/Recomp/Domain/RecompSetupEvent.cs @@ -16,6 +16,18 @@ public sealed record RecompSetupProgressEvent(string Stage, string Message, int /// {"type":"result","success":true,"version":"...","installDir":"..."} or /// {"type":"result","success":false,"error":"..."} /// +/// +/// {"type":"capabilities","capabilities":["game-dir"]}, the answer to --capabilities. A setup too old +/// to know the command rejects it instead, which counts as having none. +/// +public sealed record RecompCapabilitiesEvent(IReadOnlyList Capabilities) : RecompSetupEvent +{ + /// Takes --game-dir and --link-game-dir: reads Wheel Wizard's extracted game in place. + public const string GameDir = "game-dir"; + + public bool Has(string capability) => Capabilities.Contains(capability, StringComparer.OrdinalIgnoreCase); +} + public sealed record RecompSetupResultEvent(bool Success, string? Version, string? InstallDir, string? Error) : RecompSetupEvent; /// diff --git a/WheelWizard/Features/Recomp/RecompEnvironment.cs b/WheelWizard/Features/Recomp/RecompEnvironment.cs index 51a6e8ad3..bb0447901 100644 --- a/WheelWizard/Features/Recomp/RecompEnvironment.cs +++ b/WheelWizard/Features/Recomp/RecompEnvironment.cs @@ -1,5 +1,6 @@ using System.IO.Abstractions; using WheelWizard.CustomDistributions; +using WheelWizard.GameFiles; using WheelWizard.Settings; namespace WheelWizard.Recomp; @@ -10,8 +11,11 @@ namespace WheelWizard.Recomp; /// public interface IRecompEnvironment { - /// The user's Mario Kart Wii disc image. - string GameFilePath { get; } + /// Wheel Wizard's extracted game (the folder holding DATA/), which WiiCompiled reads in place. + string GameFolderPath { get; } + + /// Whether the extracted game is there to hand over. + bool IsGameReady { get; } /// The directory the recomp is installed into. string InstallFolderPath { get; } @@ -64,11 +68,13 @@ public interface IRecompEnvironment public sealed class RecompEnvironment( IFileSystem fileSystem, IRecompPaths paths, - ISettingsManager settings, - ICustomDistributionPaths distributionPaths + ICustomDistributionPaths distributionPaths, + IGameFilesPaths gameFiles ) : IRecompEnvironment { - public string GameFilePath => settings.Get(settings.GAME_LOCATION); + public string GameFolderPath => gameFiles.RootPath; + + public bool IsGameReady => fileSystem.File.Exists(gameFiles.ManifestPath) && fileSystem.File.Exists(gameFiles.MainDolPath); public string InstallFolderPath => paths.InstallFolderPath; @@ -110,10 +116,13 @@ public sealed class RecompLinuxEnvironment( IFileSystem fileSystem, IRecompPaths paths, ISettingsManager settings, - ICustomDistributionPaths distributionPaths + ICustomDistributionPaths distributionPaths, + IGameFilesPaths gameFiles ) : IRecompEnvironment { - public string GameFilePath => settings.Get(settings.GAME_LOCATION); + public string GameFolderPath => gameFiles.RootPath; + + public bool IsGameReady => fileSystem.File.Exists(gameFiles.ManifestPath) && fileSystem.File.Exists(gameFiles.MainDolPath); public string InstallFolderPath => paths.LinuxBackendFolderPath; diff --git a/WheelWizard/Features/Recomp/RecompInstallService.cs b/WheelWizard/Features/Recomp/RecompInstallService.cs index 633fa1767..d69ed8aef 100644 --- a/WheelWizard/Features/Recomp/RecompInstallService.cs +++ b/WheelWizard/Features/Recomp/RecompInstallService.cs @@ -77,6 +77,13 @@ Task ReconcileForLaunchAsync( /// no registry entry exists and the Retro Rewind installation is deliberately left in place. /// Task UninstallAsync(CancellationToken cancellationToken = default); + + /// + /// Points the installation at Wheel Wizard's extracted game, which also lets it drop the copy it extracted + /// itself. Quick: nothing is rebuilt. Fails when the installed setup is too old to do that, which leaves + /// WiiCompiled asking for an update. + /// + Task LinkGameDataAsync(CancellationToken cancellationToken = default); } /// @@ -184,6 +191,10 @@ public async Task GetCurrentStatusAsync(CancellationToken can if (hasInstalledHost && state != null && !IsCurrentInstallState(state)) return IsGameFileConfigured() ? WheelWizardStatus.OutOfDate : WheelWizardStatus.ConfigNotFinished; + // An installation still reading a game copy of its own can't launch until it reads the extracted game. + if (hasInstalledHost && IsCurrentInstallState(state) && IsGameFileConfigured() && !IsLinkedToGameFiles(state)) + return WheelWizardStatus.OutOfDate; + var installedVersion = IsCurrentInstallState(state) ? state!.SetupVersion : null; var latestRelease = await TryGetLatestReleaseAsync(cancellationToken); var setupUpgradeRequired = @@ -410,22 +421,40 @@ CancellationToken cancellationToken // rebuilds when told the payload choice changed, and --check-products alone never says so. var forceRetroRebuild = state is { IsRetroWfcPayloadSkipped: true } && payloadMode == RecompRetroWfcPayloadMode.Download; + // An installation that still reads a game copy of its own is pointed at the extracted game first. A host + // too old to do that can only be replaced by a release that can. + var needsLink = hasInstalledHost && IsCurrentInstallState(state) && !IsLinkedToGameFiles(state); + if (needsLink && await hosts.SupportsGameDirAsync(environment.InstalledSetupFilePath, cancellationToken)) + { + var linked = await RunLinkGameDataAsync(progress, cancellationToken); + if (linked.IsFailure) + return linked; + needsLink = false; + } + // A repair is deliberately independent of GitHub: once a setup host has been installed, // it owns the installed toolkit/workspace and can bring changed compile inputs current offline. if (release is null) { - if (hasInstalledHost && await InstalledHostCanRepairAsync(installedVersion, cancellationToken)) + if (!needsLink && hasInstalledHost && await InstalledHostCanRepairAsync(installedVersion, cancellationToken)) return await RepairWhatTheCheckDemandsAsync(progress, payloadMode, forceRetroRebuild, cancellationToken); - return Fail("Could not verify a current WiiCompiled setup release or installed repair host."); + return Fail( + needsLink + ? t("game_files.wiicompiled_needs_update") + : "Could not verify a current WiiCompiled setup release or installed repair host." + ); } - if (hasInstalledHost && await InstalledHostCanRepairAsync(release.TagName, cancellationToken)) + if (!needsLink && hasInstalledHost && await InstalledHostCanRepairAsync(release.TagName, cancellationToken)) return await RepairWhatTheCheckDemandsAsync(progress, payloadMode, forceRetroRebuild, cancellationToken); var setupResult = await hosts.EnsureSetupDownloadedAsync(release, environment.CacheFolderPath, progress, cancellationToken); if (setupResult.IsFailure) return setupResult.Error; + // WiiCompiled reads the extracted game in place; a release from before that has nothing to read it with. + if (!await hosts.SupportsGameDirAsync(setupResult.Value, cancellationToken)) + return Fail(t("game_files.wiicompiled_needs_update")); return await RunSilentInstallAsync(setupResult.Value, payloadMode, progress, cancellationToken); } @@ -462,6 +491,8 @@ CancellationToken cancellationToken return Fail("WiiCompiled requires a current install-state.json for its fixed installation path."); if (!await SetupMatchesVersionAsync(environment.InstalledSetupFilePath, state.SetupVersion, cancellationToken)) return Fail("The installed WiiCompiled host does not match its current install state."); + if (!IsLinkedToGameFiles(state)) + return Fail(t("game_files.wiicompiled_needs_update")); // A launch never asks about offline play and never forces the payload upgrade: the user pressed // Play, not Update. A repair the check demands anyway still gains the payload when it is reachable. @@ -534,6 +565,68 @@ private async Task LaunchCoreAsync(CancellationToken cancellati return launchResult.Value == 0 ? Ok() : Fail($"WiiCompiled exited with code {launchResult.Value}."); } + public async Task LinkGameDataAsync(CancellationToken cancellationToken = default) + { + if (!await _operationGate.WaitAsync(0, cancellationToken)) + return Fail(OperationAlreadyRunningMessage); + + try + { + if (!IsInstalled) + return Ok(); + var state = ReadCurrentInstallState(); + if (state is null) + return Fail("WiiCompiled requires a current install-state.json for its fixed installation path."); + if (IsLinkedToGameFiles(state)) + return Ok(); + if (!IsGameFileConfigured()) + return Fail(t("game_files.not_extracted")); + if (!await hosts.SupportsGameDirAsync(environment.InstalledSetupFilePath, cancellationToken)) + return Fail(t("game_files.wiicompiled_needs_update")); + return await RunLinkGameDataAsync(progress: null, cancellationToken); + } + finally + { + _operationGate.Release(); + } + } + + private async Task RunLinkGameDataAsync( + IProgress? progress, + CancellationToken cancellationToken + ) + { + var arguments = RecompSetupCommandBuilder.BuildLinkGameDirArguments(environment.InstallFolderPath, environment.GameFolderPath); + logger.LogInformation("Pointing WiiCompiled at the extracted game: {Arguments}", string.Join(' ', arguments)); + var resultHolder = new EventHolder(); + OperationResult runResult; + try + { + runResult = await processRunner.RunAsync( + environment.InstalledSetupFilePath, + arguments, + environment.InstallFolderPath, + line => HandleSetupOutput(line, progress, resultHolder), + cancellationToken + ); + } + finally + { + _launchReconciled = false; + } + + var finished = FinishSetupRun(runResult, resultHolder, progress, reportCompletion: false); + if (finished.IsFailure) + return finished; + return IsLinkedToGameFiles(ReadInstalledState()) + ? Ok() + : Fail("WiiCompiled finished, but does not report reading the extracted game."); + } + + /// Whether the installation reads Wheel Wizard's extracted game rather than a copy of its own. + private bool IsLinkedToGameFiles(RecompInstallState? state) => + state is not null && PathsMatch(state.GameDataDir, fileSystem.Path.Combine(environment.GameFolderPath, "DATA")); + public async Task UninstallAsync(CancellationToken cancellationToken = default) { if (!await _operationGate.WaitAsync(0, cancellationToken)) @@ -607,7 +700,7 @@ CancellationToken cancellationToken { var request = new RecompInstallRequest { - GameFilePath = environment.GameFilePath, + GameFolderPath = environment.GameFolderPath, InstallFolderPath = environment.InstallFolderPath, RetroRewindFolderPath = environment.RetroRewindFolderPath, Portable = environment.IsPortableInstall, @@ -857,11 +950,7 @@ private bool PathsMatch(string? first, string? second) } } - private bool IsGameFileConfigured() - { - var gameFilePath = environment.GameFilePath; - return !string.IsNullOrWhiteSpace(gameFilePath) && fileSystem.File.Exists(gameFilePath); - } + private bool IsGameFileConfigured() => environment.IsGameReady; private static void Report(IProgress? progress, string message, int percent) => RecompSetupHostAcquirer.Report(progress, message, percent); diff --git a/WheelWizard/Features/Recomp/RecompLinuxInstallService.cs b/WheelWizard/Features/Recomp/RecompLinuxInstallService.cs index 790325488..0e0c74401 100644 --- a/WheelWizard/Features/Recomp/RecompLinuxInstallService.cs +++ b/WheelWizard/Features/Recomp/RecompLinuxInstallService.cs @@ -131,6 +131,10 @@ public async Task GetCurrentStatusAsync(CancellationToken can if (hasInstalledHost && state != null && !IsCurrentInstallState(state)) return IsGameFileConfigured() ? WheelWizardStatus.OutOfDate : WheelWizardStatus.ConfigNotFinished; + // An installation still reading a game copy of its own can't launch until it reads the extracted game. + if (hasInstalledHost && IsCurrentInstallState(state) && IsGameFileConfigured() && !IsLinkedToGameFiles(state)) + return WheelWizardStatus.OutOfDate; + var installedVersion = IsCurrentInstallState(state) ? state!.SetupVersion : null; var ignoreRecompRelease = IsBundledRecomp(environment.InstalledSetupFilePath); var latestReleaseVersion = (ignoreRecompRelease ? null : await hosts.TryGetLatestReleaseAsync(cancellationToken))?.Version; @@ -255,11 +259,23 @@ CancellationToken cancellationToken // If Wheel Wizard is running as a Flatpak, the setup file path should never be an AppImage var ignoreRecompRelease = IsBundledRecomp(environment.InstalledSetupFilePath); + // An installation that still reads a game copy of its own is pointed at the extracted game first. A host + // too old to do that can only be replaced by a release that can. + var needsLink = HasInstalledHost && IsCurrentInstallState(state) && !IsLinkedToGameFiles(state); + if (needsLink && await hosts.SupportsGameDirAsync(environment.InstalledSetupFilePath, cancellationToken)) + { + var linked = await RunLinkGameDataAsync(progress, cancellationToken); + if (linked.IsFailure) + return linked; + needsLink = false; + state = ReadInstalledState(); + } + // Avoid fetching the GitHub release if we don't want it var release = ignoreRecompRelease ? null : await hosts.TryGetLatestReleaseAsync(cancellationToken); // The installed host repairs on its own, offline included, as long as no newer release exists. - if (await InstalledHostIsCurrentAsync(state, release, cancellationToken)) + if (!needsLink && await InstalledHostIsCurrentAsync(state, release, cancellationToken)) return await RepairWhatTheCheckDemandsAsync( progress, payloadMode, @@ -270,14 +286,25 @@ CancellationToken cancellationToken // Return early in case we have to use the bundled recomp if (ignoreRecompRelease) + { + if (!await hosts.SupportsGameDirAsync(environment.InstalledSetupFilePath, cancellationToken)) + return Fail(t("game_files.wiicompiled_needs_update")); return await RunFullInstallAsync(environment.InstalledSetupFilePath, null, payloadMode, progress, cancellationToken); + } if (release is null) - return Fail("Could not verify a current WiiCompiled setup release or installed repair host."); + return Fail( + needsLink + ? t("game_files.wiicompiled_needs_update") + : "Could not verify a current WiiCompiled setup release or installed repair host." + ); var setupResult = await hosts.EnsureSetupDownloadedAsync(release, environment.CacheFolderPath, progress, cancellationToken); if (setupResult.IsFailure) return setupResult.Error; + // WiiCompiled reads the extracted game in place; a release from before that has nothing to read it with. + if (!await hosts.SupportsGameDirAsync(setupResult.Value, cancellationToken)) + return Fail(t("game_files.wiicompiled_needs_update")); return await RunFullInstallAsync(setupResult.Value, release, payloadMode, progress, cancellationToken); } @@ -309,7 +336,7 @@ CancellationToken cancellationToken ) { var arguments = RecompLinuxSetupCommandBuilder.BuildInstallArguments( - environment.GameFilePath, + environment.GameFolderPath, environment.RetroRewindFolderPath, payloadMode ); @@ -389,6 +416,8 @@ private Task RecordInstalledHostAsync( InstallDir = environment.InstallFolderPath, RetroRewindInstalled = retroRewindInstalled, RetroWfcPayloadMode = retroRewindInstalled ? PayloadModeName(payloadMode) : string.Empty, + // A full install always reads the extracted game in place. + GameDataDir = GameDataFolderPath, } ); }, @@ -428,6 +457,8 @@ CancellationToken cancellationToken return Fail(StaleStateMessage); if (!await hosts.SetupMatchesVersionAsync(environment.InstalledSetupFilePath, state.SetupVersion, cancellationToken)) return Fail("The installed WiiCompiled host does not match its current install state."); + if (!IsLinkedToGameFiles(state)) + return Fail(t("game_files.wiicompiled_needs_update")); // A launch never asks about offline play and never forces the payload upgrade: the user pressed // Play, not Update. A repair the check demands anyway still gains the payload when it is reachable. @@ -493,6 +524,77 @@ private async Task LaunchCoreAsync(CancellationToken cancellati return launchResult.Value == 0 ? Ok() : Fail($"WiiCompiled exited with code {launchResult.Value}."); } + public async Task LinkGameDataAsync(CancellationToken cancellationToken = default) + { + if (!await _operationGate.WaitAsync(0, cancellationToken)) + return Fail(OperationAlreadyRunningMessage); + + try + { + if (!IsInstalled) + return Ok(); + var state = ReadCurrentInstallState(); + if (state is null) + return Fail(StaleStateMessage); + if (IsLinkedToGameFiles(state)) + return Ok(); + if (!IsGameFileConfigured()) + return Fail(t("game_files.not_extracted")); + if (!await hosts.SupportsGameDirAsync(environment.InstalledSetupFilePath, cancellationToken)) + return Fail(t("game_files.wiicompiled_needs_update")); + return await RunLinkGameDataAsync(progress: null, cancellationToken); + } + finally + { + _operationGate.Release(); + } + } + + private async Task RunLinkGameDataAsync( + IProgress? progress, + CancellationToken cancellationToken + ) + { + var arguments = RecompLinuxSetupCommandBuilder.BuildLinkGameDirArguments(environment.GameFolderPath); + logger.LogInformation("Pointing WiiCompiled at the extracted game: {Arguments}", string.Join(' ', arguments)); + var resultHolder = new EventHolder(); + OperationResult runResult; + try + { + runResult = await processRunner.RunAsync( + environment.InstalledSetupFilePath, + arguments, + workingDirectory: null, + line => HandleSetupOutput(line, progress, resultHolder), + cancellationToken + ); + } + finally + { + _launchReconciled = false; + } + + var finished = FinishSetupRun(runResult, resultHolder); + if (finished.IsFailure) + return finished; + + // The AppImage keeps its own state; this is Wheel Wizard's record that it now reads the extracted game. + return TryCatch( + () => + { + var state = ReadInstalledState() ?? throw new InvalidOperationException(StaleStateMessage); + state.GameDataDir = GameDataFolderPath; + WriteInstallState(state); + }, + errorMessage: "WiiCompiled now reads the extracted game, but Wheel Wizard could not record that." + ); + } + + private string GameDataFolderPath => fileSystem.Path.Combine(environment.GameFolderPath, "DATA"); + + /// Whether the installation reads Wheel Wizard's extracted game rather than a copy of its own. + private bool IsLinkedToGameFiles(RecompInstallState? state) => state is not null && PathsMatch(state.GameDataDir, GameDataFolderPath); + public async Task UninstallAsync(CancellationToken cancellationToken = default) { if (!await _operationGate.WaitAsync(0, cancellationToken)) @@ -618,7 +720,7 @@ CancellationToken cancellationToken if (string.IsNullOrWhiteSpace(retroRewindFolderPath)) return Fail("Retro Rewind must be installed before WiiCompiled can repair or launch."); - var arguments = RecompLinuxSetupCommandBuilder.BuildInstallArguments(gameFilePath: null, retroRewindFolderPath, payloadMode); + var arguments = RecompLinuxSetupCommandBuilder.BuildInstallArguments(gameFolderPath: null, retroRewindFolderPath, payloadMode); logger.LogInformation( "Running the recomp setup: {Setup} {Arguments}", environment.InstalledSetupFilePath, @@ -818,11 +920,7 @@ private bool PathsMatch(string? first, string? second) } } - private bool IsGameFileConfigured() - { - var gameFilePath = environment.GameFilePath; - return !string.IsNullOrWhiteSpace(gameFilePath) && fileSystem.File.Exists(gameFilePath); - } + private bool IsGameFileConfigured() => environment.IsGameReady; private static void Report(IProgress? progress, string message, int percent) => RecompSetupHostAcquirer.Report(progress, message, percent); diff --git a/WheelWizard/Features/Recomp/RecompLinuxSetupCommandBuilder.cs b/WheelWizard/Features/Recomp/RecompLinuxSetupCommandBuilder.cs index caa58496e..54512f5bd 100644 --- a/WheelWizard/Features/Recomp/RecompLinuxSetupCommandBuilder.cs +++ b/WheelWizard/Features/Recomp/RecompLinuxSetupCommandBuilder.cs @@ -13,21 +13,21 @@ namespace WheelWizard.Recomp; public static class RecompLinuxSetupCommandBuilder { /// - /// Builds the install command. With the AppImage validates and - /// extracts the disc first; without it, it reuses the assets it already extracted and only + /// Builds the install command. With the AppImage validates and reads + /// Wheel Wizard's extracted game in place; without it, it reuses the game it already knows and only /// retranslates and recompiles what changed, which is how a repair or a Retro Rewind update is run. /// public static IReadOnlyList BuildInstallArguments( - string? gameFilePath, + string? gameFolderPath, string? retroRewindFolderPath, RecompRetroWfcPayloadMode retroWfcPayloadMode = RecompRetroWfcPayloadMode.Download ) { var arguments = new List { "install" }; - if (!string.IsNullOrWhiteSpace(gameFilePath)) + if (!string.IsNullOrWhiteSpace(gameFolderPath)) { - arguments.Add("--game"); - arguments.Add(gameFilePath); + arguments.Add("--game-dir"); + arguments.Add(gameFolderPath); } // The AppImage requires exactly one payload option whenever a Retro Rewind source is passed, @@ -53,4 +53,12 @@ public static IReadOnlyList BuildInstallArguments( /// Makes the AppImage print its own semantic version. public static IReadOnlyList BuildVersionArguments() => ["--version"]; + + /// Points the installation at Wheel Wizard's extracted game without rebuilding anything. + public static IReadOnlyList BuildLinkGameDirArguments(string gameFolderPath) + { + if (string.IsNullOrWhiteSpace(gameFolderPath)) + throw new ArgumentException("The extracted game folder is required.", nameof(gameFolderPath)); + return ["link-game-dir", "--game-dir", gameFolderPath, "--progress-json"]; + } } diff --git a/WheelWizard/Features/Recomp/RecompSetupCommandBuilder.cs b/WheelWizard/Features/Recomp/RecompSetupCommandBuilder.cs index 15c3c0979..943232795 100644 --- a/WheelWizard/Features/Recomp/RecompSetupCommandBuilder.cs +++ b/WheelWizard/Features/Recomp/RecompSetupCommandBuilder.cs @@ -21,12 +21,12 @@ public static class RecompSetupCommandBuilder public static IReadOnlyList BuildSilentInstallArguments(RecompInstallRequest request) { ArgumentNullException.ThrowIfNull(request); - if (string.IsNullOrWhiteSpace(request.GameFilePath)) - throw new ArgumentException("A game image path is required.", nameof(request)); + if (string.IsNullOrWhiteSpace(request.GameFolderPath)) + throw new ArgumentException("The extracted game folder is required.", nameof(request)); if (string.IsNullOrWhiteSpace(request.InstallFolderPath)) throw new ArgumentException("An install directory is required.", nameof(request)); - var arguments = new List { "--silent", "--game", request.GameFilePath, "--install-dir", request.InstallFolderPath }; + var arguments = new List { "--silent", "--game-dir", request.GameFolderPath, "--install-dir", request.InstallFolderPath }; // Only a silent install may request the portable layout, and only when WheelWizard actually // handed over its own portable location. An installation that predates portable mode keeps @@ -105,6 +105,23 @@ public static IReadOnlyList BuildCheckProductsArguments(string installFo /// public static IReadOnlyList BuildVersionArguments() => ["--version"]; + /// Asks the setup what it supports. Shared by both platforms' setups. + public static IReadOnlyList BuildCapabilitiesArguments() => ["--capabilities"]; + + /// + /// Points an existing installation at Wheel Wizard's extracted game and drops the copy it extracted itself, + /// without rebuilding anything. + /// + public static IReadOnlyList BuildLinkGameDirArguments(string installFolderPath, string gameFolderPath) + { + if (string.IsNullOrWhiteSpace(installFolderPath)) + throw new ArgumentException("An install directory is required.", nameof(installFolderPath)); + if (string.IsNullOrWhiteSpace(gameFolderPath)) + throw new ArgumentException("The extracted game folder is required.", nameof(gameFolderPath)); + + return ["--link-game-dir", "--install-dir", installFolderPath, "--game-dir", gameFolderPath, "--progress-json"]; + } + // The contract requires exactly one payload option whenever --retro-dir is passed. WheelWizard // downloads unless the payload service is unreachable and the user chose an offline-only build. private static string RetroWfcPayloadArgument(RecompRetroWfcPayloadMode mode) => diff --git a/WheelWizard/Features/Recomp/RecompSetupHostAcquirer.cs b/WheelWizard/Features/Recomp/RecompSetupHostAcquirer.cs index a863fd682..add5fc738 100644 --- a/WheelWizard/Features/Recomp/RecompSetupHostAcquirer.cs +++ b/WheelWizard/Features/Recomp/RecompSetupHostAcquirer.cs @@ -114,6 +114,28 @@ public async Task SetupMatchesVersionAsync(string setupFilePath, string? e && cachedVersion.ComparePrecedenceTo(expected) == 0; } + /// + /// Whether the setup can read Wheel Wizard's extracted game in place (--game-dir, --link-game-dir). + /// A setup from before that rejects --capabilities, which reads as no. + /// + public async Task SupportsGameDirAsync(string setupFilePath, CancellationToken cancellationToken) + { + RecompCapabilitiesEvent? capabilities = null; + var runResult = await processRunner.RunAsync( + setupFilePath, + RecompSetupCommandBuilder.BuildCapabilitiesArguments(), + workingDirectory: null, + line => + { + if (RecompSetupOutputParser.Parse(line) is RecompCapabilitiesEvent parsed) + capabilities = parsed; + }, + cancellationToken + ); + + return runResult.IsSuccess && runResult.Value == 0 && capabilities?.Has(RecompCapabilitiesEvent.GameDir) == true; + } + public static bool VersionsMatch(string? first, string? second) => RecompVersion.TryParse(first, out var parsedFirst) && RecompVersion.TryParse(second, out var parsedSecond) diff --git a/WheelWizard/Features/Recomp/RecompSetupOutputParser.cs b/WheelWizard/Features/Recomp/RecompSetupOutputParser.cs index 937a4eadb..74c8f25ee 100644 --- a/WheelWizard/Features/Recomp/RecompSetupOutputParser.cs +++ b/WheelWizard/Features/Recomp/RecompSetupOutputParser.cs @@ -38,6 +38,7 @@ public static class RecompSetupOutputParser "progress" => ParseProgress(root), "result" => ParseResult(root), "products" => ParseProducts(root), + "capabilities" => ParseCapabilities(root), _ => null, }; } @@ -47,6 +48,21 @@ public static class RecompSetupOutputParser } } + private static RecompCapabilitiesEvent ParseCapabilities(JsonElement root) + { + var capabilities = new List(); + if (root.TryGetProperty("capabilities", out var list) && list.ValueKind == JsonValueKind.Array) + { + foreach (var item in list.EnumerateArray()) + { + if (item.ValueKind == JsonValueKind.String && item.GetString() is { } capability) + capabilities.Add(capability); + } + } + + return new(capabilities); + } + private static RecompSetupProgressEvent ParseProgress(JsonElement root) => new(GetString(root, "stage") ?? string.Empty, GetString(root, "message") ?? string.Empty, GetPercent(root, "percent")); diff --git a/WheelWizard/Features/Settings/SettingsManager.cs b/WheelWizard/Features/Settings/SettingsManager.cs index 1f4eb9342..97cc77391 100644 --- a/WheelWizard/Features/Settings/SettingsManager.cs +++ b/WheelWizard/Features/Settings/SettingsManager.cs @@ -2,6 +2,7 @@ using WheelWizard.ApplicationData; using WheelWizard.Dolphin.Paths; using WheelWizard.DolphinInstaller; +using WheelWizard.GameFiles; using WheelWizard.Models.Enums; using WheelWizard.Recomp; using WheelWizard.Settings.Types; @@ -424,7 +425,7 @@ private OperationResult ValidatePathSettings(bool requ new(SettingsValidationCode.InvalidDolphinLocation, DOLPHIN_LOCATION.Name, "Dolphin path or command is invalid.") ); - if (!GAME_LOCATION.IsValid()) + if (!IsGameAvailable()) issues.Add(new(SettingsValidationCode.InvalidGameLocation, GAME_LOCATION.Name, "Game file path is invalid.")); return Ok(new SettingsValidationReport(issues)); @@ -435,6 +436,16 @@ private OperationResult ValidatePathSettings(bool requ } } + /// + /// The extracted game is what gets played; once it is there the image it came from may be deleted or unplugged. + /// Before that, the picked image still counts, and the game setup page takes it from there. + /// + private bool IsGameAvailable() + { + var game = new GameFilesPaths(_applicationData, _fileSystem); + return (_fileSystem.File.Exists(game.ManifestPath) && _fileSystem.File.Exists(game.MainDolPath)) || GAME_LOCATION.IsValid(); + } + public void LoadSettings() { if (_hasLoadedSettings) diff --git a/WheelWizard/Features/Settings/Views/WhWzSettings.axaml.cs b/WheelWizard/Features/Settings/Views/WhWzSettings.axaml.cs index 73b8fe003..0d0c36951 100644 --- a/WheelWizard/Features/Settings/Views/WhWzSettings.axaml.cs +++ b/WheelWizard/Features/Settings/Views/WhWzSettings.axaml.cs @@ -8,6 +8,8 @@ using WheelWizard.Dolphin.Discovery; using WheelWizard.Dolphin.Paths; using WheelWizard.DolphinInstaller; +using WheelWizard.GameFiles; +using WheelWizard.GameFiles.Views; using WheelWizard.Models.Enums; using WheelWizard.Recomp; using WheelWizard.Settings; @@ -49,6 +51,10 @@ public partial class WhWzSettings : UserControl private IServiceProvider Services { get; } + private IGameFilesService GameFiles => Services.GetRequiredService(); + + private IGameFilesPaths GameFilesPaths => Services.GetRequiredService(); + public WhWzSettings( IApplicationLogFiles logFiles, IFilePickerService filePicker, @@ -231,37 +237,29 @@ private async void GameLocationBrowse_OnClick(object sender, RoutedEventArgs e) if (string.IsNullOrEmpty(filePath)) return; - var info = DiscImages.ReadInfo(filePath); - if (info.IsFailure || GameRegion.FromGameId(info.Value.GameId) == MarioKartWiiEnums.Regions.None) - { - var reason = info.IsFailure - ? t("message_error.invalid_game_file.unreadable") - : t("message_error.invalid_game_file.wrong_game", new { name = info.Value.InternalName, id = info.Value.GameId }); - await new MessageBoxWindow() - .SetMessageType(MessageBoxWindow.MessageType.Error) - .SetTitleText(t("message_error.invalid_game_file.title")) - .SetInfoText(reason) - .ShowDialog(); + // The flow checks the file, asks before replacing an extracted game, and extracts it. + var changed = await Services.GetRequiredService().ChangeImageAsync(filePath); + UpdateLocationRows(); + if (!changed) return; - } - if (await ApplyLocationSettingAsync(SettingsService.GAME_LOCATION, filePath)) - { - // A game from another region switches to that region's save. - var layout = ViewUtils.GetLayout(); - layout.UpdateSidebarProfile(); - layout.UpdateFriendCount(); - } + // A game from another region switches to that region's save. + var layout = ViewUtils.GetLayout(); + layout.UpdateSidebarProfile(); + layout.UpdateFriendCount(); + await MessageTranslationHelper.AwaitMessageAsync(MessageTranslation.Success_PathSettingsSaved); } private void UpdateGameFileDetails() { - var info = DiscImages.ReadInfo(SettingsService.Get(SettingsService.GAME_LOCATION)); - var region = info.IsSuccess ? GameRegion.FromGameId(info.Value.GameId) : MarioKartWiiEnums.Regions.None; + var status = GameFiles.GetStatus(); + var region = GameRegion.FromGameId(status.Manifest?.GameId); MarioKartHelperText.Text = - region != MarioKartWiiEnums.Regions.None - ? $"Mario Kart Wii · {GameRegion.GetDisplayName(region)} · {info.Value.Format.ToString().ToUpperInvariant()}" + status.Manifest is not null && region != MarioKartWiiEnums.Regions.None + ? $"Mario Kart Wii · {GameRegion.GetDisplayName(region)} · {t("game_files.status_extracted")}" : t("helper_text.end_with_x") + " " + string.Join("/", DiscImages.SupportedExtensions); + // The image the game was extracted from, which is no longer needed but good to know. + ToolTip.SetTip(MarioKartHelperText, status.ImagePath); } private async void DolphinUserPathBrowse_OnClick(object sender, RoutedEventArgs e) @@ -342,8 +340,11 @@ private async Task ApplyLocationSettingAsync(Setting setting, stri private void DolphinExecutableOpen_OnClick(object sender, RoutedEventArgs e) => OpenContainingFolder(DolphinPaths.ExecutablePath); - private void GameLocationOpen_OnClick(object sender, RoutedEventArgs e) => - OpenContainingFolder(SettingsService.Get(SettingsService.GAME_LOCATION)); + private void GameLocationOpen_OnClick(object sender, RoutedEventArgs e) + { + if (Directory.Exists(GameFilesPaths.RootPath)) + FilePicker.OpenFolderInFileManager(GameFilesPaths.RootPath); + } private void DolphinUserFolderOpen_OnClick(object sender, RoutedEventArgs e) { @@ -382,13 +383,7 @@ private void UpdateLocationRows() DolphinExecutableChangeButton, SettingsService.DOLPHIN_LOCATION.IsValid() && !string.IsNullOrWhiteSpace(DolphinPaths.ExecutablePath) ); - SetLocationRowState( - GameLocationCompleteIcon, - GameLocationWarningIcon, - GameLocationChangeButton, - SettingsService.GAME_LOCATION.IsValid() - && !string.IsNullOrWhiteSpace(SettingsService.Get(SettingsService.GAME_LOCATION)) - ); + SetLocationRowState(GameLocationCompleteIcon, GameLocationWarningIcon, GameLocationChangeButton, GameFiles.GetStatus().IsReady); SetLocationRowState( DolphinUserFolderCompleteIcon, DolphinUserFolderWarningIcon, @@ -405,7 +400,7 @@ private void UpdateLocationRows() DolphinExecutableOpenButton.IsVisible = !OperatingSystem.IsLinux() || IsConfiguredExecutableFile(DolphinPaths.ExecutablePath); DolphinExecutableOpenButton.IsEnabled = DolphinExecutableOpenButton.IsVisible && CanOpenContainingFolder(DolphinPaths.ExecutablePath); - GameLocationOpenButton.IsEnabled = CanOpenContainingFolder(SettingsService.Get(SettingsService.GAME_LOCATION)); + GameLocationOpenButton.IsEnabled = Directory.Exists(GameFilesPaths.RootPath); UpdateGameFileDetails(); DolphinUserFolderOpenButton.IsEnabled = Directory.Exists(DolphinPaths.UserFolderPath); } @@ -654,6 +649,8 @@ private async Task MoveWheelWizardDataAsync(string targetPath) { await HandleSuccessfulAppdataMoveAsync(details, errorMessage); FollowRelocatedDataFolder(); + // The extracted game moved with the data folder; WiiCompiled still names the old place until relinked. + await Services.GetRequiredService().LinkWiiCompiledAsync(); } else { diff --git a/WheelWizard/Features/WiiManagement/GameLicense/SaveRegionService.cs b/WheelWizard/Features/WiiManagement/GameLicense/SaveRegionService.cs index 5dceb68c0..60a311568 100644 --- a/WheelWizard/Features/WiiManagement/GameLicense/SaveRegionService.cs +++ b/WheelWizard/Features/WiiManagement/GameLicense/SaveRegionService.cs @@ -1,5 +1,5 @@ using System.IO.Abstractions; -using WheelWizard.DiscImages; +using WheelWizard.GameFiles; using WheelWizard.Models.Enums; using WheelWizard.Settings; @@ -9,7 +9,7 @@ public interface ISaveRegionService { List GetAvailableRegions(string saveDirectory); - /// The region of the Mario Kart Wii game file in the settings, or None when it can't be read. + /// The region of the extracted game (or of the picked image before that), or None when it can't be read. MarioKartWiiEnums.Regions GetGameFileRegion(); /// @@ -19,7 +19,7 @@ public interface ISaveRegionService MarioKartWiiEnums.Regions ResolveActiveRegion(string saveDirectory); } -public sealed class SaveRegionService(IFileSystem fileSystem, ISettingsManager settings, IDiscImageService discImages) : ISaveRegionService +public sealed class SaveRegionService(IFileSystem fileSystem, ISettingsManager settings, IGameFilesService gameFiles) : ISaveRegionService { public List GetAvailableRegions(string saveDirectory) { @@ -49,11 +49,7 @@ public sealed class SaveRegionService(IFileSystem fileSystem, ISettingsManager s return validRegions; } - public MarioKartWiiEnums.Regions GetGameFileRegion() - { - var info = discImages.ReadInfo(settings.Get(settings.GAME_LOCATION)); - return info.IsSuccess ? GameRegion.FromGameId(info.Value.GameId) : MarioKartWiiEnums.Regions.None; - } + public MarioKartWiiEnums.Regions GetGameFileRegion() => gameFiles.GetRegion(); public MarioKartWiiEnums.Regions ResolveActiveRegion(string saveDirectory) { diff --git a/WheelWizard/Resources/Languages/en.yml b/WheelWizard/Resources/Languages/en.yml index 5336db172..6c4534fc9 100644 --- a/WheelWizard/Resources/Languages/en.yml +++ b/WheelWizard/Resources/Languages/en.yml @@ -813,3 +813,47 @@ en: repair_user_folder: "Wheel Wizard can't find your Dolphin folder. Is the drive it's on connected?" repair_game: "Wheel Wizard can't find your game file. Is the drive it's on connected?" repair_action: "Fix in Settings" + game_files: + setup_title: "Prepare your game files" + setup_body: "Wheel Wizard now plays Mario Kart Wii from its own extracted copy of your game, so it can use the game's files directly. This takes about a minute and needs around 3 GB. Your game file is only read, never changed." + missing_title: "Find your game file" + missing_body: "Wheel Wizard needs your game file once more to prepare the game, but can't find it. Is the drive it's on connected?" + modified_title: "Game code was changed" + modified_body: "main.dol or StaticR.rel in the game folder no longer match your game. These two files have to stay unedited, so Wheel Wizard restores them from your game file. Everything else you changed stays as it is." + unknown_folder_title: "A game folder is already there" + unknown_folder_body: "Wheel Wizard found a GAME folder it didn't finish creating. Move it aside to keep what's in it, or delete it." + extract_action: "Prepare game files" + restore_action: "Restore" + pick_other: "Use a different game file" + pick_game: "Select game file" + open_settings: "Open Settings" + move_aside: "Move it aside" + delete: "Delete it" + delete_confirm_title: "Delete the GAME folder?" + delete_confirm_body: "Everything in it is deleted. If it is a link to another folder, only the link is removed." + replace_title: "Replace your game files?" + replace_body: "Your extracted %{current} will be replaced by %{incoming}. Any changes you made in the GAME folder are deleted." + replace_breaks_wiicompiled: "WiiCompiled only supports the European (PAL) game, so it stops working until you switch back." + replace_action: "Replace" + replace_not_confirmed: "Replacing the extracted game has to be confirmed first." + close_game_title: "Close the game to continue" + close_game_body: "Dolphin or WiiCompiled is still running and using the game files. Close it to continue; this continues by itself once it's closed." + close_game_action: "Close it for me" + extracting_title: "Preparing game files" + extracting: "Extracting the game" + finishing: "Finishing up" + cancel: "Cancel" + extract_failed_title: "The game files could not be prepared" + extract_failed: "Extracting the game failed." + not_enough_space: "There is not enough free space. Preparing the game needs %{needed}, but only %{available} is free. Free up space, or move Wheel Wizard's data folder to a bigger drive in Settings." + link_to_drive_root: "The GAME folder links to the root of a drive. Link it to a folder instead." + image_inside_game_folder: "Pick a game file that is not inside Wheel Wizard's GAME folder." + not_extracted: "The game is not extracted yet." + not_unknown: "The GAME folder belongs to Wheel Wizard and does not need to be moved." + restore_wrong_game: "Pick the same game you extracted (%{id})." + move_aside_failed: "The GAME folder could not be moved aside." + delete_failed: "The GAME folder could not be deleted." + busy: "The game files are already being prepared." + linking_wiicompiled: "Updating WiiCompiled" + status_extracted: "extracted" + wiicompiled_needs_update: "WiiCompiled needs an update that can use your extracted game files. Update it from Home once one is available." diff --git a/WheelWizard/SetupExtensions.cs b/WheelWizard/SetupExtensions.cs index 650cfff8d..48d67ea11 100644 --- a/WheelWizard/SetupExtensions.cs +++ b/WheelWizard/SetupExtensions.cs @@ -10,6 +10,7 @@ using WheelWizard.Features.Archives; using WheelWizard.Features.Patches; using WheelWizard.GameBanana; +using WheelWizard.GameFiles; using WheelWizard.GitHub; using WheelWizard.Launching; using WheelWizard.Localization; @@ -46,6 +47,7 @@ public static void AddWheelWizardServices(this IServiceCollection services, IApp services.AddRrRooms(); services.AddWhWzData(); services.AddDiscImages(); + services.AddGameFiles(); services.AddWiiManagement(); services.AddGameBanana(); services.AddMiiImages(); diff --git a/WheelWizard/Shared/IO/DirectoryTransferService.cs b/WheelWizard/Shared/IO/DirectoryTransferService.cs index 2122920e0..4bdff1cd5 100644 --- a/WheelWizard/Shared/IO/DirectoryTransferService.cs +++ b/WheelWizard/Shared/IO/DirectoryTransferService.cs @@ -99,9 +99,22 @@ DirectoryMoveContentsResult CreateResult( progress?.Report(0.0); - var directories = fileSystem.Directory.GetDirectories(normalizedSource, "*", SearchOption.AllDirectories); - var files = fileSystem.Directory.GetFiles(normalizedSource, "*", SearchOption.AllDirectories); - var totalSteps = directories.Length + files.Length; + // A linked folder (e.g. a game folder the user put on another drive) moves as a link; only when the link can't + // be recreated here are its contents copied instead. Deleting the source then removes the link, never its target. + var directories = new List(); + var files = new List(); + var sourceLinks = new List(); + try + { + Collect(normalizedSource, normalizedSource, normalizedDestination, directories, files, sourceLinks, insideLink: false); + } + catch (Exception ex) + { + progress?.Report(1.0); + return CreateResult(DirectoryMoveOutcome.CopyFailed, errorMessage: $"Failed to copy data: {ex.Message}", exception: ex); + } + + var totalSteps = directories.Count + files.Count; var processedSteps = 0; void ReportProgress() @@ -118,7 +131,7 @@ void ReportProgress() progress.Report(Math.Clamp((double)processedSteps / totalSteps, 0.0, 1.0)); } - var copyAttempted = directories.Length > 0 || files.Length > 0; + var copyAttempted = directories.Count > 0 || files.Count > 0; try { @@ -213,6 +226,11 @@ void ReportProgress() { try { + // Links (recreated at the destination, or copied through when that wasn't possible) go first, on their + // own: that keeps the recursive delete away from their targets and from junctions, which Windows + // refuses to delete that way. Deepest first, in case a linked folder held links of its own. + foreach (var link in sourceLinks.OrderByDescending(link => link.Length)) + DeleteLink(link); fileSystem.Directory.Delete(normalizedSource, true); } catch (Exception ex) @@ -237,4 +255,64 @@ void ReportProgress() sourceDeletionSucceeded: true ); } + + private void Collect( + string sourceRoot, + string folder, + string destinationRoot, + List directories, + List files, + List sourceLinks, + bool insideLink + ) + { + foreach (var directory in fileSystem.Directory.EnumerateDirectories(folder)) + { + var link = fileSystem.DirectoryInfo.New(directory).LinkTarget; + var isLink = !string.IsNullOrEmpty(link); + if (isLink) + { + // Only links in the folder itself are removed later; one inside a linked folder is not ours to touch. + if (!insideLink) + sourceLinks.Add(directory); + if (TryRecreateLink(directory, link!, sourceRoot, destinationRoot)) + continue; + } + + directories.Add(directory); + Collect(sourceRoot, directory, destinationRoot, directories, files, sourceLinks, insideLink || isLink); + } + + files.AddRange(fileSystem.Directory.EnumerateFiles(folder)); + } + + private void DeleteLink(string path) + { + if (fileSystem.Path.DirectorySeparatorChar == '\\') + fileSystem.Directory.Delete(path, recursive: false); + else + fileSystem.File.Delete(path); + } + + private bool TryRecreateLink(string linkPath, string target, string sourceRoot, string destinationRoot) + { + try + { + var destination = fileSystem.Path.Combine(destinationRoot, fileSystem.Path.GetRelativePath(sourceRoot, linkPath)); + if (fileSystem.Directory.Exists(destination) || fileSystem.File.Exists(destination)) + return false; + fileSystem.Directory.CreateDirectory(fileSystem.Path.GetDirectoryName(destination)!); + // A relative target keeps pointing at the same place only when it is resolved from the old folder. + var absoluteTarget = fileSystem.Path.IsPathRooted(target) + ? target + : fileSystem.Path.GetFullPath(fileSystem.Path.Combine(fileSystem.Path.GetDirectoryName(linkPath)!, target)); + fileSystem.Directory.CreateSymbolicLink(destination, absoluteTarget); + return true; + } + catch + { + // Windows only lets some accounts create links; the contents are copied instead. + return false; + } + } } diff --git a/WheelWizard/Views/Dialogs/YesNoWindow.axaml.cs b/WheelWizard/Views/Dialogs/YesNoWindow.axaml.cs index c23ab34ed..e8d2856ea 100644 --- a/WheelWizard/Views/Dialogs/YesNoWindow.axaml.cs +++ b/WheelWizard/Views/Dialogs/YesNoWindow.axaml.cs @@ -76,6 +76,14 @@ protected override void BeforeClose() _tcs?.TrySetResult(false); } + /// Yes, no, or when the window was dismissed without picking either. + public async Task AwaitWithChoiceAsync() + { + if (await AwaitAnswer()) + return true; + return NoButtonClicked ? false : null; + } + public async Task AwaitAnswer() { if (!Dispatcher.UIThread.CheckAccess()) diff --git a/WheelWizard/Views/Shell/Layout.axaml.cs b/WheelWizard/Views/Shell/Layout.axaml.cs index c51f69bf4..acf8fc3f2 100644 --- a/WheelWizard/Views/Shell/Layout.axaml.cs +++ b/WheelWizard/Views/Shell/Layout.axaml.cs @@ -8,6 +8,8 @@ using Avalonia.VisualTree; using WheelWizard.AutoUpdating; using WheelWizard.Branding; +using WheelWizard.GameFiles; +using WheelWizard.GameFiles.Views; using WheelWizard.Localization; using WheelWizard.MiiImages.Views; using WheelWizard.Mods; @@ -83,6 +85,8 @@ public partial class Layout : BaseWindow, IPollingListener private IOnboardingService Onboarding { get; } + private IGameFilesService GameFiles { get; } + public Layout( INavigationService navigation, LiveRoomsService liveRooms, @@ -93,10 +97,12 @@ public Layout( ISettingsSignalBus settingsSignalBus, IModManager modManagerService, IAutoUpdaterSingletonService autoUpdater, - IOnboardingService onboarding + IOnboardingService onboarding, + IGameFilesService gameFiles ) { Onboarding = onboarding; + GameFiles = gameFiles; Navigation = navigation; LiveRooms = liveRooms; LiveStatus = liveStatus; @@ -151,6 +157,8 @@ protected override void OnLoaded(RoutedEventArgs e) Navigation_OnPageChanged(this, page); else if (Onboarding.IsRequired) Navigation.NavigateTo(); + else if (IsGameSetupNeeded()) + Navigation.NavigateTo(); else Navigation.NavigateTo(); _loaded.TrySetResult(); @@ -277,7 +285,30 @@ private void UpdateModsButtonText() // ModsButton.WarningTip = "Some mods need to be converted to patches."; // } - private void Navigation_OnPageChanged(object? sender, UserControl page) => NavigateToPage(page); + private void Navigation_OnPageChanged(object? sender, UserControl page) + { + // Until the game files are usable only the setup page and Settings (to make room or move the data folder) + // can be shown; anything else goes back to the setup page. + if (page is not (GameSetupPage or SettingsPage or OnboardingPage) && IsGameSetupNeeded()) + { + Navigation.NavigateAway(typeof(GameSetupPage)); + return; + } + + NavigateToPage(page); + } + + private bool IsGameSetupNeeded() + { + try + { + return !Onboarding.IsRequired && GameSetupPage.IsNeeded(GameFiles.GetStatus()); + } + catch + { + return false; + } + } /// /// The current page may ask before letting you go (see ); until it does, the From 0c9e0ef633136c90a74ba07a244797cf89eae844 Mon Sep 17 00:00:00 2001 From: patchzyy <64382339+patchzyy@users.noreply.github.com> Date: Fri, 16 Oct 2026 17:07:12 +0200 Subject: [PATCH 06/10] Update ci-cd.yml --- .github/workflows/ci-cd.yml | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index 67ce7695a..7999608d5 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -14,6 +14,10 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, windows-latest, macos-latest] + include: + # Without an explicit RID, the SDK's osx-arm64 apphost inference breaks on xunit.v3 test projects + - os: macos-latest + rid-args: -p:RuntimeIdentifier=osx-arm64 runs-on: ${{ matrix.os }} steps: - name: Checkout repository @@ -25,10 +29,10 @@ jobs: dotnet-version: '10.0.x' - name: Restore dependencies - run: dotnet restore + run: dotnet restore ${{ matrix.rid-args }} - name: Build solution - run: dotnet build --configuration Release --no-restore + run: dotnet build --configuration Release --no-restore ${{ matrix.rid-args }} - name: Run tests - run: dotnet test --configuration Release --no-build --verbosity normal + run: dotnet test --configuration Release --no-build --verbosity normal ${{ matrix.rid-args }} From fb2cef6d348c4dff981c2dbdbd7d12bb2db6c454 Mon Sep 17 00:00:00 2001 From: patchzyy <64382339+patchzyy@users.noreply.github.com> Date: Fri, 16 Oct 2026 17:16:40 +0200 Subject: [PATCH 07/10] Fix macOS RID handling in UI test project --- .github/workflows/ci-cd.yml | 10 +++------- WheelWizard.UI.Test/WheelWizard.UI.Test.csproj | 2 ++ 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index 7999608d5..67ce7695a 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -14,10 +14,6 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, windows-latest, macos-latest] - include: - # Without an explicit RID, the SDK's osx-arm64 apphost inference breaks on xunit.v3 test projects - - os: macos-latest - rid-args: -p:RuntimeIdentifier=osx-arm64 runs-on: ${{ matrix.os }} steps: - name: Checkout repository @@ -29,10 +25,10 @@ jobs: dotnet-version: '10.0.x' - name: Restore dependencies - run: dotnet restore ${{ matrix.rid-args }} + run: dotnet restore - name: Build solution - run: dotnet build --configuration Release --no-restore ${{ matrix.rid-args }} + run: dotnet build --configuration Release --no-restore - name: Run tests - run: dotnet test --configuration Release --no-build --verbosity normal ${{ matrix.rid-args }} + run: dotnet test --configuration Release --no-build --verbosity normal diff --git a/WheelWizard.UI.Test/WheelWizard.UI.Test.csproj b/WheelWizard.UI.Test/WheelWizard.UI.Test.csproj index 01a894aa1..cb3e0f673 100644 --- a/WheelWizard.UI.Test/WheelWizard.UI.Test.csproj +++ b/WheelWizard.UI.Test/WheelWizard.UI.Test.csproj @@ -5,6 +5,8 @@ enable false true + + $(NETCoreSdkRuntimeIdentifier) From 9e97f492457edda9123985db7e0c3d2e7a2cfc3e Mon Sep 17 00:00:00 2001 From: patchzyy <64382339+patchzyy@users.noreply.github.com> Date: Fri, 16 Oct 2026 17:51:12 +0200 Subject: [PATCH 08/10] Stop recovery call during setup refresh --- WheelWizard/Features/GameFiles/Views/GameSetupPage.axaml.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/WheelWizard/Features/GameFiles/Views/GameSetupPage.axaml.cs b/WheelWizard/Features/GameFiles/Views/GameSetupPage.axaml.cs index 9a1d94f75..a71614c3c 100644 --- a/WheelWizard/Features/GameFiles/Views/GameSetupPage.axaml.cs +++ b/WheelWizard/Features/GameFiles/Views/GameSetupPage.axaml.cs @@ -56,7 +56,6 @@ ILogger logger private void Refresh() { - _gameFiles.RecoverInterruptedWork(); _status = _gameFiles.GetStatus(); if (!IsNeeded(_status)) { From 0b9b7d303b4327980b867e67a694ee58231b5354 Mon Sep 17 00:00:00 2001 From: patchzyy <64382339+patchzyy@users.noreply.github.com> Date: Fri, 16 Oct 2026 18:18:03 +0200 Subject: [PATCH 09/10] Harden migration and missing-drive handling --- .../ExistingSetupMigrationSafetyTests.cs | 53 +++++++++++++++++++ WheelWizard.UI.Test/GameSetupShellTests.cs | 50 +++++++++++++++++ .../CustomDistributionPaths.cs | 18 ++++++- .../RetroRewindMigrationService.cs | 26 ++++++++- .../Features/GameFiles/GameFilesService.cs | 2 +- .../Features/Onboarding/OnboardingService.cs | 10 ++-- .../Features/Settings/SettingsManager.cs | 28 +++++----- .../Settings/Views/WhWzSettings.axaml.cs | 11 ++++ WheelWizard/Shared/IO/DriveExtensions.cs | 27 ++++++++++ WheelWizard/Views/Shell/Layout.axaml.cs | 5 +- 10 files changed, 210 insertions(+), 20 deletions(-) create mode 100644 WheelWizard/Shared/IO/DriveExtensions.cs diff --git a/WheelWizard.Test/Features/ExistingSetups/ExistingSetupMigrationSafetyTests.cs b/WheelWizard.Test/Features/ExistingSetups/ExistingSetupMigrationSafetyTests.cs index fdaca1a12..459bd94a1 100644 --- a/WheelWizard.Test/Features/ExistingSetups/ExistingSetupMigrationSafetyTests.cs +++ b/WheelWizard.Test/Features/ExistingSetups/ExistingSetupMigrationSafetyTests.cs @@ -1,4 +1,5 @@ using Testably.Abstractions.Testing; +using Testably.Abstractions.Testing.FileSystem; using WheelWizard.CustomDistributions; using static WheelWizard.Test.Features.ExistingSetups.ExistingSetupAssertions; @@ -249,4 +250,56 @@ public void TheMigrationMarkerNeverEndsUpInTheGamesSight() install.Fs.File.Exists(install.Combine(session.Paths.RetroRewindFolderPath, CustomDistributionPaths.MigratedMarkerFileName)) ); } + + [Fact] + public void ACopyThatCannotFit_IsNotStarted_AndRunsOnceThereIsRoom() + { + var install = CrossDriveInstall(); + var files = install.WithRetroRewind(extraFiles: 30); + install.FlushConfig(); + // The data folder's drive has room for its own files, but not for Retro Rewind and a safety margin. + var drive = install.Fs.DriveInfo.New(@"C:\"); + var used = drive.TotalSize - drive.AvailableFreeSpace; + install.Fs.WithDrive(@"C:\", d => d.SetTotalSize(used + 32L * 1024 * 1024)); + var copies = 0; + using var counting = install.Fs.Notify.OnEvent( + _ => copies++, + change => + change.ChangeType == WatcherChangeTypes.Created && change.Path.Contains(".migrating", StringComparison.OrdinalIgnoreCase) + ); + + var full = install.Boot(); + + AssertStillWorks(full, files, install.LegacyRoot); + Assert.Equal(0, copies); + Assert.False(install.Fs.File.Exists(full.Paths.MigratedMarkerFilePath)); + Assert.False(install.Fs.Directory.Exists(install.ManagedRoot + ".migrating")); + + install.Fs.WithDrive(@"C:\", d => d.SetTotalSize(used + 1024L * 1024 * 1024)); + var roomy = install.Boot(); + + AssertStillWorks(roomy, files, install.ManagedRoot); + Assert.False(install.Fs.Directory.Exists(install.LegacyRoot)); + } + + [Fact] + public void ALegacyFolderThatCannotBeListed_IsNotRuledOut_SoItsSaveIsNeverHidden() + { + var install = SameDriveInstall(); + var files = install.WithRetroRewind(); + install.FlushConfig(); + var legacy = install.LegacyRoot; + install.Fs.WithAccessControlStrategy( + new DefaultAccessControlStrategy((path, _) => !path.StartsWith(legacy, StringComparison.OrdinalIgnoreCase)) + ); + + var denied = install.Boot(); + + Assert.False(install.Fs.File.Exists(denied.Paths.MigratedMarkerFilePath), "a folder that could not be read is not an empty one"); + + install.Fs.WithAccessControlStrategy(new DefaultAccessControlStrategy((_, _) => true)); + var readable = install.Boot(); + + AssertStillWorks(readable, files, install.ManagedRoot); + } } diff --git a/WheelWizard.UI.Test/GameSetupShellTests.cs b/WheelWizard.UI.Test/GameSetupShellTests.cs index d81f7209b..eef5d38d0 100644 --- a/WheelWizard.UI.Test/GameSetupShellTests.cs +++ b/WheelWizard.UI.Test/GameSetupShellTests.cs @@ -85,4 +85,54 @@ public async Task AnUpgradingUser_IsHeldOnTheGameSetupPage_WithOnlySettingsReach window.Close(); } } + + [AvaloniaFact] + public async Task AnUpgradingUser_WhoseGameDriveIsUnplugged_KeepsTheRestOfTheApp() + { + var fileSystem = new MockFileSystem(); + var directory = Path.Combine(Path.GetTempPath(), "wheelwizard-ui-game-unplugged"); + fileSystem.Directory.CreateDirectory(directory); + var image = Path.Combine(directory, "Mario Kart Wii.wbfs"); + fileSystem.File.WriteAllText(image, "an image on a drive that is not connected today"); + var location = Substitute.For(); + location.DirectoryPath.Returns(directory); + var registrations = new ServiceCollection(); + registrations.AddWheelWizardServices(location); + registrations.AddSingleton(fileSystem); + registrations.AddTransient(typeof(IApiCaller<>), typeof(ApplicationCompositionTests.OfflineApiCaller<>)); + var mods = Substitute.For(); + mods.ReloadAsync().Returns(Task.FromResult(OperationResult.Ok())); + registrations.AddSingleton(mods); + using var services = registrations.BuildServiceProvider( + new ServiceProviderOptions { ValidateOnBuild = true, ValidateScopes = true } + ); + services.GetRequiredService().Initialize(); + var settings = services.GetRequiredService(); + settings.NAND_ROOT_PATH.Set(directory, skipSave: true); + settings.LOAD_PATH.Set(directory, skipSave: true); + Assert.True(settings.GAME_LOCATION.Set(image, skipSave: true)); + settings.ONBOARDING_COMPLETED.Set(true, skipSave: true); + fileSystem.File.Delete(image); + services.GetRequiredService().Install(Application.Current!.Resources); + services.GetRequiredService().Install(Application.Current.Resources); + + var window = services.GetRequiredService(); + var navigation = services.GetRequiredService(); + try + { + window.Show(); + window.UpdateLayout(); + await Dispatcher.UIThread.InvokeAsync(() => { }, DispatcherPriority.Background); + + // Only playing needs the game; as before the extracted game existed, Home says so and the rest stays open. + Assert.IsType(navigation.CurrentPage); + Assert.True(window.FindControl("SidebarGrid")!.IsEnabled); + Assert.Equal(image, settings.GAME_LOCATION.Get()); + } + finally + { + navigation.IsLocked = false; + window.Close(); + } + } } diff --git a/WheelWizard/Features/CustomDistributions/CustomDistributionPaths.cs b/WheelWizard/Features/CustomDistributions/CustomDistributionPaths.cs index 7f44f866a..04f689897 100644 --- a/WheelWizard/Features/CustomDistributions/CustomDistributionPaths.cs +++ b/WheelWizard/Features/CustomDistributions/CustomDistributionPaths.cs @@ -100,7 +100,23 @@ public bool CanRuleOutLegacyRoot // which would wrongly look empty. if (!dolphin.HasCustomLoadFolder && !string.IsNullOrWhiteSpace(dolphin.ConfiguredLoadFolderPath)) return false; - return IsReadable(dolphin.HasCustomLoadFolder ? dolphin.LoadFolderPath : dolphin.UserFolderPath); + if (!IsReadable(dolphin.HasCustomLoadFolder ? dolphin.LoadFolderPath : dolphin.UserFolderPath)) + return false; + // A legacy folder that is there but cannot be listed (wrong owner, a flaky share) looks empty to + // IsLegacyRootActive; ruling it out would hide that install and its saves for good. + return !SafeDirectoryExists(LegacyRootFolderPath) || IsReadable(LegacyRootFolderPath); + } + } + + private bool SafeDirectoryExists(string folder) + { + try + { + return fileSystem.Directory.Exists(folder); + } + catch + { + return true; } } diff --git a/WheelWizard/Features/CustomDistributions/RetroRewindMigrationService.cs b/WheelWizard/Features/CustomDistributions/RetroRewindMigrationService.cs index 875bd97e4..21c2a1b91 100644 --- a/WheelWizard/Features/CustomDistributions/RetroRewindMigrationService.cs +++ b/WheelWizard/Features/CustomDistributions/RetroRewindMigrationService.cs @@ -38,6 +38,8 @@ public sealed class RetroRewindMigrationService( ILogger logger ) : IRetroRewindMigrationService { + private const long FreeSpaceMargin = 64L * 1024 * 1024; + public bool IsPending => paths.IsLegacyRootActive; public OperationResult Migrate(Action? copyStarted = null, IProgress? progress = null) @@ -76,7 +78,14 @@ public OperationResult Migrate(Action? copyStarted return RetroRewindMigrationOutcome.Moved; } - // Another drive: copy next to the managed root first, so an interrupted copy never becomes the active install. + // Another drive. Only start when it fits, so a full disk is a quick check each start, not a copy thrown away. + if (!HasRoomFor(source, parent ?? destination)) + { + logger.LogWarning("Not enough free space to move Retro Rewind from {Source}; checking again next start", source); + return RetroRewindMigrationOutcome.Deferred; + } + + // Copy next to the managed root first, so an interrupted copy never becomes the active install. copyStarted?.Invoke(); var result = transfer.MoveContents(source, staging, deleteSource: false, progress); if (!result.IsSuccessful) @@ -142,6 +151,21 @@ private void SetAsideExistingManagedInstall(string destination) logger.LogInformation("Kept the existing managed Retro Rewind as {Backup}", backup); } + private bool HasRoomFor(string source, string destinationFolder) + { + try + { + var needed = fileSystem.DirectoryInfo.New(source).EnumerateFiles("*", SearchOption.AllDirectories).Sum(file => file.Length); + return fileSystem.DriveContaining(destinationFolder).AvailableFreeSpace >= needed + FreeSpaceMargin; + } + catch (Exception exception) + { + // Some drives cannot say; the copy itself still stops on a full disk. + logger.LogWarning(exception, "Could not check the free space for moving Retro Rewind"); + return true; + } + } + private bool TryRename(string source, string destination) { try diff --git a/WheelWizard/Features/GameFiles/GameFilesService.cs b/WheelWizard/Features/GameFiles/GameFilesService.cs index bdacabb92..8d305e195 100644 --- a/WheelWizard/Features/GameFiles/GameFilesService.cs +++ b/WheelWizard/Features/GameFiles/GameFilesService.cs @@ -475,7 +475,7 @@ private OperationResult EnsureFreeSpace(string folder, long required) try { fileSystem.Directory.CreateDirectory(folder); - var drive = fileSystem.DriveInfo.New(fileSystem.Path.GetPathRoot(fileSystem.Path.GetFullPath(folder))!); + var drive = fileSystem.DriveContaining(folder); var needed = required + FreeSpaceMargin; if (drive.AvailableFreeSpace >= needed) return Ok(); diff --git a/WheelWizard/Features/Onboarding/OnboardingService.cs b/WheelWizard/Features/Onboarding/OnboardingService.cs index 71108ad58..d4538d67c 100644 --- a/WheelWizard/Features/Onboarding/OnboardingService.cs +++ b/WheelWizard/Features/Onboarding/OnboardingService.cs @@ -194,10 +194,12 @@ private DetectedDolphin Detected(string location, string displayPath) string? version = null; try { - var arguments = environment.IsLinux && !layout.IsFlatpakDolphinFilePath(location) ? "QT_QPA_PLATFORM=wayland;xcb " : ""; - var result = services - .GetRequiredService() - .Run("/usr/bin/env", ["sh", "-c", "--", $"{arguments}{location} --version"], out var stdOut, out var stdErr); + // Set through env, not inside the sh -c script: there the ';' would end the command. + List arguments = []; + if (environment.IsLinux && !layout.IsFlatpakDolphinFilePath(location)) + arguments.Add("QT_QPA_PLATFORM=wayland;xcb"); + arguments.AddRange(["sh", "-c", "--", $"{location} --version"]); + var result = services.GetRequiredService().Run("/usr/bin/env", arguments, out var stdOut, out var stdErr); if (result.IsSuccess && result.Value == 0) DolphinVersion.GetStatus(string.IsNullOrWhiteSpace(stdOut) ? stdErr : stdOut, out version); } diff --git a/WheelWizard/Features/Settings/SettingsManager.cs b/WheelWizard/Features/Settings/SettingsManager.cs index 97cc77391..2e0bb1c69 100644 --- a/WheelWizard/Features/Settings/SettingsManager.cs +++ b/WheelWizard/Features/Settings/SettingsManager.cs @@ -380,7 +380,7 @@ public bool Set(Setting setting, T value, bool skipSave = false) !EqualityComparer.Default.Equals(previous, value) && (ReferenceEquals(setting, USER_FOLDER_PATH) || ReferenceEquals(setting, DOLPHIN_LOCATION)) ) - _dolphinSettingManager.ReloadSettings(_dolphinPaths.Resolve(DOLPHIN_LOCATION.Value, USER_FOLDER_PATH.Value).ConfigFolderPath); + _dolphinSettingManager.ReloadSettings(DolphinConfigFolderPath()); return true; } @@ -452,17 +452,25 @@ public void LoadSettings() return; _whWzSettingManager.LoadSettings(_fileSystem.Path.Combine(_applicationData.DirectoryPath, "config.json")); - // Where Dolphin keeps its config can depend on which Dolphin it is (Linux splits it from the data folder). A - // Dolphin that is not there today still says where its Dolphin.ini is, and the Load folder is written in it. - var dolphinLocation = Get(DOLPHIN_LOCATION); - if (string.IsNullOrWhiteSpace(dolphinLocation)) - dolphinLocation = DOLPHIN_LOCATION.UnavailableValue ?? ""; - _dolphinSettingManager.LoadSettings(_dolphinPaths.Resolve(dolphinLocation, Get(USER_FOLDER_PATH)).ConfigFolderPath); + _dolphinSettingManager.LoadSettings(DolphinConfigFolderPath()); _recompSettingManager.LoadSettings(_recompPaths.ConfigFilePath); _hasLoadedSettings = true; } #endregion + /// + /// Where Dolphin keeps its config can depend on which Dolphin it is (Linux splits it from the data folder). A Dolphin + /// that is not there today (not on PATH right now) still says where its Dolphin.ini is; reading and writing both + /// follow it, so a setting is never read from one Dolphin.ini and saved into another. + /// + private string DolphinConfigFolderPath() + { + var dolphinLocation = Get(DOLPHIN_LOCATION); + if (string.IsNullOrWhiteSpace(dolphinLocation)) + dolphinLocation = DOLPHIN_LOCATION.UnavailableValue ?? ""; + return _dolphinPaths.Resolve(dolphinLocation, Get(USER_FOLDER_PATH)).ConfigFolderPath; + } + #region Registration Helpers private WhWzSetting RegisterWhWz(string name, T defaultValue, Func? validation = null, bool retainUnavailable = false) { @@ -491,11 +499,7 @@ private DolphinSetting RegisterDolphin( var setting = new DolphinSetting( location, defaultValue!, - setting => - _dolphinSettingManager.SaveSettings( - _dolphinPaths.Resolve(Get(DOLPHIN_LOCATION), Get(USER_FOLDER_PATH)).ConfigFolderPath, - setting - ) + setting => _dolphinSettingManager.SaveSettings(DolphinConfigFolderPath(), setting) ); if (validation != null) setting.SetValidation(validation); diff --git a/WheelWizard/Features/Settings/Views/WhWzSettings.axaml.cs b/WheelWizard/Features/Settings/Views/WhWzSettings.axaml.cs index 0d0c36951..044d8dbcd 100644 --- a/WheelWizard/Features/Settings/Views/WhWzSettings.axaml.cs +++ b/WheelWizard/Features/Settings/Views/WhWzSettings.axaml.cs @@ -432,6 +432,10 @@ private async void ManageDolphin_OnClick(object? sender, RoutedEventArgs e) // Read the current Dolphin's data folders before the switch points the settings elsewhere. var sourceWii = SettingsService.USER_FOLDER_PATH.IsValid() ? DolphinPaths.WiiFolderPath : null; var sourceConfig = SettingsService.USER_FOLDER_PATH.IsValid() ? DolphinPaths.ConfigFolderPath : null; + // The switch points the settings at the managed Dolphin before the import; a failure goes back to the user's own. + var previousLocation = SettingsService.Get(SettingsService.DOLPHIN_LOCATION); + var previousUserFolder = SettingsService.Get(SettingsService.USER_FOLDER_PATH); + var wasManaged = ManagedDolphin.IsActive; var window = new ProgressWindow(t("onboarding.dolphin_installing_title")); OperationResult result; @@ -452,6 +456,13 @@ private async void ManageDolphin_OnClick(object? sender, RoutedEventArgs e) window.Close(); } + if (result.IsFailure && !wasManaged) + { + ManagedDolphin.Deactivate(); + SettingsService.Set(SettingsService.DOLPHIN_LOCATION, previousLocation); + SettingsService.Set(SettingsService.USER_FOLDER_PATH, previousUserFolder); + } + UpdateLocationRows(); if (result.IsFailure) { diff --git a/WheelWizard/Shared/IO/DriveExtensions.cs b/WheelWizard/Shared/IO/DriveExtensions.cs new file mode 100644 index 000000000..e7a02bba4 --- /dev/null +++ b/WheelWizard/Shared/IO/DriveExtensions.cs @@ -0,0 +1,27 @@ +using System.IO.Abstractions; + +namespace WheelWizard.Shared.IO; + +public static class DriveExtensions +{ + /// + /// The drive a folder is on. On Linux and macOS every path's root is "/", but /home or /Volumes/X is often its + /// own disk, so the deepest mount point that contains the folder is the one that counts. + /// + public static IDriveInfo DriveContaining(this IFileSystem fileSystem, string path) + { + var fullPath = fileSystem.Path.GetFullPath(path); + var root = fileSystem.Path.GetPathRoot(fullPath)!; + if (fileSystem.Path.DirectorySeparatorChar == '\\') + return fileSystem.DriveInfo.New(root); + + var withSlash = fullPath.TrimEnd('/') + "/"; + return fileSystem + .DriveInfo.GetDrives() + .Select(drive => (Drive: drive, Mount: drive.RootDirectory.FullName.TrimEnd('/') + "/")) + .Where(entry => withSlash.StartsWith(entry.Mount, StringComparison.Ordinal)) + .OrderByDescending(entry => entry.Mount.Length) + .Select(entry => entry.Drive) + .FirstOrDefault() ?? fileSystem.DriveInfo.New(root); + } +} diff --git a/WheelWizard/Views/Shell/Layout.axaml.cs b/WheelWizard/Views/Shell/Layout.axaml.cs index 5be77f797..533748ca0 100644 --- a/WheelWizard/Views/Shell/Layout.axaml.cs +++ b/WheelWizard/Views/Shell/Layout.axaml.cs @@ -303,7 +303,10 @@ private bool IsGameSetupNeeded() { try { - return !Onboarding.IsRequired && GameSetupPage.IsNeeded(GameFiles.GetStatus()); + // A game image that is not reachable right now (an unplugged drive) only stops playing, as it did before + // the extracted game existed: Home says so and links to Settings, and the rest of the app stays usable. + var status = GameFiles.GetStatus(); + return !Onboarding.IsRequired && status.State != GameFilesState.ImageMissing && GameSetupPage.IsNeeded(status); } catch { From 26e77343c1da3fe25467ef138fdffbb77ebb0b4f Mon Sep 17 00:00:00 2001 From: patchzyy <64382339+patchzyy@users.noreply.github.com> Date: Fri, 16 Oct 2026 19:28:05 +0200 Subject: [PATCH 10/10] undo game extraction --- .../ExistingSetups/ExistingInstall.cs | 35 +- .../ExistingSetupGameFilesTests.cs | 159 ---- .../Features/GameFiles/DiscExtractorTests.cs | 320 ------- .../Features/GameFiles/GameFilesHarness.cs | 220 ----- .../Features/GameFiles/GameFilesLinkTests.cs | 179 ---- .../GameFiles/GameFilesServiceTests.cs | 676 -------------- .../Features/GameFiles/SyntheticWiiDisc.cs | 264 ------ .../Launching/DolphinLaunchServiceTests.cs | 52 +- .../Features/Launching/LaunchPathTests.cs | 19 +- .../RetroRewindLaunchServiceTests.cs | 33 +- .../Features/Recomp/RecompGameDirTests.cs | 279 ------ .../Features/Recomp/RecompLinuxTests.cs | 11 +- .../Features/Recomp/RecompTests.cs | 8 +- .../Features/SaveRegionServiceTests.cs | 19 +- .../ViewModels/HomeDolphinLaunchTests.cs | 9 +- WheelWizard.UI.Test/GameSetupShellTests.cs | 138 --- .../StartupMaintenance.cs | 5 - .../Features/GameFiles/DiscExtractor.cs | 438 --------- .../GameFiles/Domain/GameFilesManifest.cs | 28 - .../Features/GameFiles/GameFilesExtensions.cs | 15 - .../Features/GameFiles/GameFilesPaths.cs | 76 -- .../Features/GameFiles/GameFilesService.cs | 858 ------------------ .../Features/GameFiles/Views/GameFilesFlow.cs | 265 ------ .../GameFiles/Views/GameSetupPage.axaml | 66 -- .../GameFiles/Views/GameSetupPage.axaml.cs | 281 ------ .../Launching/DolphinLaunchService.cs | 12 +- .../Launching/RetroRewindLaunchDescriptor.cs | 7 +- .../Launching/RetroRewindLaunchService.cs | 8 +- .../Onboarding/Views/OnboardingPage.axaml | 4 - .../Onboarding/Views/OnboardingPage.axaml.cs | 68 +- .../Recomp/Domain/RecompInstallModels.cs | 13 +- .../Recomp/Domain/RecompSetupEvent.cs | 12 - .../Features/Recomp/RecompEnvironment.cs | 23 +- .../Features/Recomp/RecompInstallService.cs | 107 +-- .../Recomp/RecompLinuxInstallService.cs | 116 +-- .../Recomp/RecompLinuxSetupCommandBuilder.cs | 20 +- .../Recomp/RecompSetupCommandBuilder.cs | 23 +- .../Recomp/RecompSetupHostAcquirer.cs | 22 - .../Recomp/RecompSetupOutputParser.cs | 16 - .../Features/Settings/SettingsManager.cs | 13 +- .../Settings/Views/WhWzSettings.axaml.cs | 63 +- .../GameLicense/SaveRegionService.cs | 12 +- WheelWizard/Resources/Languages/en.yml | 44 - WheelWizard/SetupExtensions.cs | 2 - .../Shared/IO/DirectoryTransferService.cs | 86 +- .../Views/Dialogs/YesNoWindow.axaml.cs | 8 - WheelWizard/Views/Shell/Layout.axaml.cs | 38 +- 47 files changed, 145 insertions(+), 5025 deletions(-) delete mode 100644 WheelWizard.Test/Features/ExistingSetups/ExistingSetupGameFilesTests.cs delete mode 100644 WheelWizard.Test/Features/GameFiles/DiscExtractorTests.cs delete mode 100644 WheelWizard.Test/Features/GameFiles/GameFilesHarness.cs delete mode 100644 WheelWizard.Test/Features/GameFiles/GameFilesLinkTests.cs delete mode 100644 WheelWizard.Test/Features/GameFiles/GameFilesServiceTests.cs delete mode 100644 WheelWizard.Test/Features/GameFiles/SyntheticWiiDisc.cs delete mode 100644 WheelWizard.Test/Features/Recomp/RecompGameDirTests.cs delete mode 100644 WheelWizard.UI.Test/GameSetupShellTests.cs delete mode 100644 WheelWizard/Features/GameFiles/DiscExtractor.cs delete mode 100644 WheelWizard/Features/GameFiles/Domain/GameFilesManifest.cs delete mode 100644 WheelWizard/Features/GameFiles/GameFilesExtensions.cs delete mode 100644 WheelWizard/Features/GameFiles/GameFilesPaths.cs delete mode 100644 WheelWizard/Features/GameFiles/GameFilesService.cs delete mode 100644 WheelWizard/Features/GameFiles/Views/GameFilesFlow.cs delete mode 100644 WheelWizard/Features/GameFiles/Views/GameSetupPage.axaml delete mode 100644 WheelWizard/Features/GameFiles/Views/GameSetupPage.axaml.cs diff --git a/WheelWizard.Test/Features/ExistingSetups/ExistingInstall.cs b/WheelWizard.Test/Features/ExistingSetups/ExistingInstall.cs index 3750a512f..f27258faf 100644 --- a/WheelWizard.Test/Features/ExistingSetups/ExistingInstall.cs +++ b/WheelWizard.Test/Features/ExistingSetups/ExistingInstall.cs @@ -10,7 +10,6 @@ using WheelWizard.Dolphin.Discovery; using WheelWizard.Dolphin.Paths; using WheelWizard.DolphinInstaller; -using WheelWizard.GameFiles; using WheelWizard.Launching; using WheelWizard.Onboarding; using WheelWizard.Recomp; @@ -225,16 +224,6 @@ public ExistingInstall WithGame(string path, bool createFile = true) return this; } - /// A real (synthetic) Mario Kart Wii image instead of a placeholder file, for setups that get extracted. - public ExistingInstall WithRealGame(string path, string gameId = "RMCP01") - { - GameFile = path; - Config["GameLocation"] = path; - Fs.Directory.CreateDirectory(Fs.Path.GetDirectoryName(path)!); - Fs.File.WriteAllBytes(path, new GameFiles.SyntheticWiiDisc { GameId = gameId }.Build("rvz")); - return this; - } - public ExistingInstall WithConfig(string key, object? value) { Config[key] = value; @@ -363,8 +352,6 @@ internal sealed class Session public RecompSettingManager RecompSettings { get; } public RecompPaths RecompPaths { get; } public RetroRewindLaunchDescriptor LaunchDescriptor { get; } - public GameFilesPaths GamePaths { get; } - public GameFilesService GameFiles { get; } public IDolphinPaths DolphinPaths { get; } public List Statuses { get; } = []; @@ -452,26 +439,8 @@ public Session(ExistingInstall install) services, NullLogger.Instance ); - GamePaths = new GameFilesPaths(location, fs); - GameFiles = new GameFilesService( - GamePaths, - new DiscExtractor(fs), - Settings, - fs, - Substitute.For(), - DolphinPaths, - new DiscImageService(fs), - NullLogger.Instance - ); - Maintenance = new StartupMaintenance( - ManagedDolphin, - Migration, - Onboarding, - GameFiles, - services, - NullLogger.Instance - ); - LaunchDescriptor = new RetroRewindLaunchDescriptor(fs, Paths, GamePaths); + Maintenance = new StartupMaintenance(ManagedDolphin, Migration, Onboarding, services, NullLogger.Instance); + LaunchDescriptor = new RetroRewindLaunchDescriptor(fs, Settings, Paths); } /// What Desktop startup runs before the main window decides which page to open. diff --git a/WheelWizard.Test/Features/ExistingSetups/ExistingSetupGameFilesTests.cs b/WheelWizard.Test/Features/ExistingSetups/ExistingSetupGameFilesTests.cs deleted file mode 100644 index f0e595d69..000000000 --- a/WheelWizard.Test/Features/ExistingSetups/ExistingSetupGameFilesTests.cs +++ /dev/null @@ -1,159 +0,0 @@ -using System.Text.Json; -using WheelWizard.GameFiles; -using WheelWizard.GameFiles.Views; - -namespace WheelWizard.Test.Features.ExistingSetups; - -/// -/// Earlier releases handed Dolphin the game image itself. This release plays an extracted copy, so somebody -/// updating is asked to extract once. Whatever state their setup is in, starting up must not touch their image, -/// rewrite where it is, or remove anything that is theirs. -/// -public class ExistingSetupGameFilesTests -{ - public static TheoryData Platforms => new() { HostPlatform.Windows, HostPlatform.Linux, HostPlatform.MacOS }; - - private static ExistingInstall Upgrading(HostPlatform platform) - { - var install = platform switch - { - HostPlatform.Windows => new ExistingInstall(platform) - .WithDolphin(@"C:\Dolphin\Dolphin.exe", @"C:\Users\racer\Documents\Dolphin Emulator") - .WithRealGame(@"D:\Wii Games\Mario Kart Wii (Europe).rvz"), - HostPlatform.MacOS => new ExistingInstall(platform) - .WithDolphin("'/Applications/Dolphin.app/Contents/MacOS/Dolphin'", "/Users/racer/Library/Application Support/Dolphin") - .WithRealGame("/Users/racer/Games/Mario Kart Wii.rvz"), - _ => new ExistingInstall(platform) - .WithDolphin("dolphin-emu", "/home/racer/.local/share/dolphin-emu") - .WithRealGame("/home/racer/Games/Mario Kart Wii.rvz"), - }; - install.FlushConfig(); - return install; - } - - private static byte[] Image(ExistingInstall install) => install.Fs.File.ReadAllBytes(install.GameFile); - - private static void AssertStoredGameLocation(ExistingInstall install) - { - using var config = JsonDocument.Parse(install.Fs.File.ReadAllText(install.ConfigFilePath)); - Assert.Equal(install.GameFile, config.RootElement.GetProperty("GameLocation").GetString()); - } - - [Theory] - [MemberData(nameof(Platforms))] - public void AnUpgradingUser_IsAskedToExtract_WithEverythingLeftAsItWas(HostPlatform platform) - { - var install = Upgrading(platform); - var image = Image(install); - - var session = install.Boot(); - - var status = session.GameFiles.GetStatus(); - Assert.Equal(GameFilesState.NeedsExtraction, status.State); - Assert.True(GameSetupPage.IsNeeded(status)); - Assert.False(session.Onboarding.IsRequired); - Assert.Equal(image, Image(install)); - Assert.Equal(install.GameFile, session.Settings.GAME_LOCATION.Get()); - Assert.False(install.Fs.Directory.Exists(session.GamePaths.RootPath), "starting up must not extract by itself"); - AssertStoredGameLocation(install); - } - - [Theory] - [MemberData(nameof(Platforms))] - public void AnUpgradingUserWithTheImageUnplugged_IsAskedToFindIt_AndKeepsTheSetting(HostPlatform platform) - { - var install = Upgrading(platform); - install.Unplug(install.GameFile); - - var session = install.Boot(); - - Assert.Equal(GameFilesState.ImageMissing, session.GameFiles.GetStatus().State); - Assert.Equal(install.GameFile, session.GameFiles.ImagePath); - Assert.False(session.Onboarding.IsRequired); - // The stored game location survives the drive being away. - AssertStoredGameLocation(install); - } - - [Theory] - [MemberData(nameof(Platforms))] - public async Task AfterExtracting_DolphinIsHandedTheExtractedGame(HostPlatform platform) - { - var install = Upgrading(platform); - var image = Image(install); - var session = install.Boot(); - - var result = await session.GameFiles.ExtractAsync(install.GameFile, replaceExisting: false); - - Assert.True(result.IsSuccess, result.IsFailure ? result.Error.Message : null); - var next = install.Boot(); - Assert.True(next.GameFiles.GetStatus().IsReady); - next.LaunchDescriptor.GenerateLaunchJson(); - using var json = JsonDocument.Parse(install.Fs.File.ReadAllText(next.Paths.LaunchJsonFilePath)); - Assert.Equal(install.Fs.Path.GetFullPath(next.GamePaths.MainDolPath), json.RootElement.GetProperty("base-file").GetString()); - Assert.Equal(image, Image(install)); - } - - [Theory] - [MemberData(nameof(Platforms))] - public async Task OnceExtracted_TheImageMayGo(HostPlatform platform) - { - var install = Upgrading(platform); - var session = install.Boot(); - Assert.True((await session.GameFiles.ExtractAsync(install.GameFile, false)).IsSuccess); - - install.Unplug(install.GameFile); - var next = install.Boot(); - - Assert.True(next.GameFiles.GetStatus().IsReady); - Assert.False(GameSetupPage.IsNeeded(next.GameFiles.GetStatus())); - Assert.True(next.Settings.DolphinPathsSetupCorrectly()); - Assert.False(next.Onboarding.IsRequired); - } - - [Fact] - public void AStartAfterACrashMidExtraction_RemovesOnlyWheelWizardsOwnLeftovers() - { - var install = Upgrading(HostPlatform.Windows); - var session = install.Boot(runStartup: false); - var staging = session.GamePaths.StagingPath; - install.Fs.Directory.CreateDirectory(install.Combine(staging, "DATA")); - install.Fs.File.WriteAllText(install.Combine(staging, GameFilesPaths.WorkMarkerFileName), ""); - var backup = session.GamePaths.RootPath + ".backup-20260101-120000"; - install.Fs.Directory.CreateDirectory(backup); - install.Fs.File.WriteAllText(install.Combine(backup, "mine.txt"), "mine"); - - install.Boot(); - - Assert.False(install.Fs.Directory.Exists(staging)); - Assert.Equal("mine", install.Fs.File.ReadAllText(install.Combine(backup, "mine.txt"))); - Assert.True(install.Fs.File.Exists(install.GameFile)); - } - - [Fact] - public async Task MovingTheDataFolder_TakesTheExtractedGameAlong() - { - var install = Upgrading(HostPlatform.Linux); - var session = install.Boot(); - Assert.True((await session.GameFiles.ExtractAsync(install.GameFile, false)).IsSuccess); - - install.RelocateDataFolder("/mnt/big/WheelWizard"); - var next = install.Boot(); - - Assert.True(next.GameFiles.GetStatus().IsReady); - Assert.StartsWith("/mnt/big/WheelWizard", next.GamePaths.MainDolPath); - } - - [Fact] - public void ASetupThatNeverHadAGame_IsNotStoppedByTheGameSetupPage() - { - var install = new ExistingInstall(HostPlatform.Windows).WithDolphin( - @"C:\Dolphin\Dolphin.exe", - @"C:\Users\racer\Documents\Dolphin Emulator" - ); - install.FlushConfig(); - - var session = install.Boot(); - - Assert.False(GameSetupPage.IsNeeded(session.GameFiles.GetStatus())); - } -} diff --git a/WheelWizard.Test/Features/GameFiles/DiscExtractorTests.cs b/WheelWizard.Test/Features/GameFiles/DiscExtractorTests.cs deleted file mode 100644 index 60004647b..000000000 --- a/WheelWizard.Test/Features/GameFiles/DiscExtractorTests.cs +++ /dev/null @@ -1,320 +0,0 @@ -using System.Text; -using Testably.Abstractions.Testing; -using WheelWizard.GameFiles; - -namespace WheelWizard.Test.Features.GameFiles; - -public class DiscExtractorTests -{ - private static MockFileSystem Disk(SimulationMode mode = SimulationMode.Linux) - { - var fs = new MockFileSystem(options => options.SimulatingOperatingSystem(mode)); - fs.Directory.CreateDirectory(Root(mode, "games")); - fs.Directory.CreateDirectory(Root(mode, "out")); - return fs; - } - - private static string Root(SimulationMode mode, string name) => mode == SimulationMode.Windows ? $@"C:\{name}" : $"/{name}"; - - [Fact] - public void Extract_WritesTheLayoutDolphinBootsFrom() - { - var fs = Disk(); - var disc = new SyntheticWiiDisc(); - fs.File.WriteAllBytes("/games/mkw.iso", disc.BuildIso()); - - var result = new DiscExtractor(fs).Extract("/games/mkw.iso", "/out"); - - Assert.True(result.IsSuccess, result.IsFailure ? result.Error.Message : null); - var iso = fs.File.ReadAllBytes("/games/mkw.iso"); - Assert.Equal(iso[..0x100], fs.File.ReadAllBytes("/out/disc/header.bin")); - Assert.Equal(iso[0x4E000..0x4E020], fs.File.ReadAllBytes("/out/disc/region.bin")); - Assert.Equal(disc.Ticket, fs.File.ReadAllBytes("/out/DATA/ticket.bin")); - Assert.Equal(disc.Tmd, fs.File.ReadAllBytes("/out/DATA/tmd.bin")); - Assert.Equal(disc.Certificates, fs.File.ReadAllBytes("/out/DATA/cert.bin")); - Assert.Equal(disc.H3, fs.File.ReadAllBytes("/out/DATA/h3.bin")); - Assert.Equal(disc.Boot, fs.File.ReadAllBytes("/out/DATA/sys/boot.bin")); - Assert.Equal(disc.Bi2, fs.File.ReadAllBytes("/out/DATA/sys/bi2.bin")); - Assert.Equal(disc.Apploader, fs.File.ReadAllBytes("/out/DATA/sys/apploader.img")); - Assert.Equal(disc.MainDol, fs.File.ReadAllBytes("/out/DATA/sys/main.dol")); - Assert.Equal(disc.Fst, fs.File.ReadAllBytes("/out/DATA/sys/fst.bin")); - foreach (var (path, data) in disc.Files) - Assert.Equal(data, fs.File.ReadAllBytes("/out/DATA/files/" + path)); - } - - [Fact] - public void Extract_ReportsTheHashesTheManifestPins() - { - var fs = Disk(); - var disc = new SyntheticWiiDisc(); - fs.File.WriteAllBytes("/games/mkw.iso", disc.BuildIso()); - - var result = new DiscExtractor(fs).Extract("/games/mkw.iso", "/out"); - - Assert.Equal(SyntheticWiiDisc.Sha256(disc.MainDol), result.Value.MainDolSha256); - Assert.Equal(SyntheticWiiDisc.Sha256(disc.Files["rel/StaticR.rel"]), result.Value.StaticRelSha256); - } - - [Theory] - [InlineData("iso")] - [InlineData("wbfs")] - [InlineData("gcz")] - [InlineData("ciso")] - [InlineData("rvz")] - public void Extract_EveryContainerGivesTheSameFiles(string format) - { - var fs = Disk(); - var disc = new SyntheticWiiDisc(); - fs.File.WriteAllBytes("/games/iso.iso", disc.BuildIso()); - fs.File.WriteAllBytes($"/games/other.{format}", disc.Build(format)); - var extractor = new DiscExtractor(fs); - - Assert.True(extractor.Extract("/games/iso.iso", "/out/iso").IsSuccess); - var result = extractor.Extract($"/games/other.{format}", "/out/other"); - - Assert.True(result.IsSuccess, result.IsFailure ? result.Error.Message : null); - var expected = Snapshot(fs, "/out/iso"); - Assert.Equal(expected.Keys.Order(), Snapshot(fs, "/out/other").Keys.Order()); - foreach (var (path, data) in Snapshot(fs, "/out/other")) - Assert.Equal(expected[path], data); - } - - [Theory] - [InlineData(SimulationMode.Windows)] - [InlineData(SimulationMode.Linux)] - [InlineData(SimulationMode.MacOS)] - public void Extract_WorksOnEveryPlatform(SimulationMode mode) - { - var fs = Disk(mode); - var disc = new SyntheticWiiDisc(); - var image = fs.Path.Combine(Root(mode, "games"), "Mario Kart Wii (Europe) (En,Fr,De,Es,It).rvz"); - fs.File.WriteAllBytes(image, disc.Build("rvz")); - var destination = fs.Path.Combine(Root(mode, "out"), "with spaces", "GAME"); - - var result = new DiscExtractor(fs).Extract(image, destination); - - Assert.True(result.IsSuccess, result.IsFailure ? result.Error.Message : null); - Assert.Equal(disc.MainDol, fs.File.ReadAllBytes(fs.Path.Combine(destination, "DATA", "sys", "main.dol"))); - } - - [Fact] - public void Extract_OnlyReadsTheImage() - { - var fs = Disk(); - var iso = new SyntheticWiiDisc().BuildIso(); - fs.File.WriteAllBytes("/games/mkw.iso", iso); - var written = fs.File.GetLastWriteTimeUtc("/games/mkw.iso"); - - // Another program holding the image open for reading must not stop the extraction. - using (fs.File.Open("/games/mkw.iso", FileMode.Open, FileAccess.Read, FileShare.Read)) - Assert.True(new DiscExtractor(fs).Extract("/games/mkw.iso", "/out").IsSuccess); - - Assert.Equal(iso, fs.File.ReadAllBytes("/games/mkw.iso")); - Assert.Equal(written, fs.File.GetLastWriteTimeUtc("/games/mkw.iso")); - } - - [Fact] - public void Extract_ReadOnlyImageWorks() - { - var fs = Disk(); - fs.File.WriteAllBytes("/games/mkw.iso", new SyntheticWiiDisc().BuildIso()); - fs.FileInfo.New("/games/mkw.iso").IsReadOnly = true; - - Assert.True(new DiscExtractor(fs).Extract("/games/mkw.iso", "/out").IsSuccess); - Assert.True(fs.FileInfo.New("/games/mkw.iso").IsReadOnly); - } - - [Theory] - [InlineData("..")] - [InlineData(".")] - [InlineData("C:")] - [InlineData("a\\..\\..\\evil")] - [InlineData("CON")] - [InlineData("nul.txt")] - [InlineData("trailing.")] - [InlineData("trailing ")] - [InlineData("star*")] - [InlineData("tab\tname")] - public void Extract_RefusesNamesThatCouldEscapeOrBreakOnSomePlatform(string name) - { - var fs = Disk(); - var disc = new SyntheticWiiDisc - { - Files = new() { ["rel/StaticR.rel"] = [1, 2, 3], [$"{name}/payload.bin"] = [9] }, - }; - fs.File.WriteAllBytes("/games/evil.iso", disc.BuildIso()); - - var result = new DiscExtractor(fs).Extract("/games/evil.iso", "/out/GAME"); - - Assert.True(result.IsFailure); - Assert.False(fs.File.Exists("/payload.bin")); - Assert.False(fs.File.Exists("/out/payload.bin")); - Assert.False(fs.File.Exists("/evil/payload.bin")); - } - - [Fact] - public void Extract_RefusesTwoNamesACaseInsensitiveDriveWouldMerge() - { - var fs = Disk(); - var disc = new SyntheticWiiDisc - { - Files = new() - { - ["rel/StaticR.rel"] = [1], - ["Race/a.szs"] = [2], - ["race/A.szs"] = [3], - }, - }; - fs.File.WriteAllBytes("/games/dupe.iso", disc.BuildIso()); - - var result = new DiscExtractor(fs).Extract("/games/dupe.iso", "/out"); - - Assert.True(result.IsFailure); - Assert.Contains("capitalization", result.Error.Message); - } - - [Fact] - public void Extract_NeverOverwritesExistingFiles() - { - var fs = Disk(); - fs.File.WriteAllBytes("/games/mkw.iso", new SyntheticWiiDisc().BuildIso()); - fs.Directory.CreateDirectory("/out/DATA/sys"); - fs.File.WriteAllText("/out/DATA/sys/main.dol", "mine"); - - Assert.True(new DiscExtractor(fs).Extract("/games/mkw.iso", "/out").IsFailure); - Assert.Equal("mine", fs.File.ReadAllText("/out/DATA/sys/main.dol")); - } - - [Fact] - public void Extract_FailsWithoutStaticRel() - { - var fs = Disk(); - fs.File.WriteAllBytes("/games/other.iso", new SyntheticWiiDisc { Files = new() { ["a.bin"] = [1] } }.BuildIso()); - - var result = new DiscExtractor(fs).Extract("/games/other.iso", "/out"); - - Assert.True(result.IsFailure); - Assert.Contains("StaticR.rel", result.Error.Message); - } - - [Theory] - [InlineData(0x8000)] - [InlineData(0x50000)] - [InlineData(0x60000)] - public void Extract_FailsCleanlyOnATruncatedImage(int length) - { - var fs = Disk(); - fs.File.WriteAllBytes("/games/cut.iso", new SyntheticWiiDisc().BuildIso()[..length]); - - Assert.True(new DiscExtractor(fs).Extract("/games/cut.iso", "/out").IsFailure); - } - - [Fact] - public void Extract_RejectsAGameCubeOrGarbageFile() - { - var fs = Disk(); - var gamecube = new byte[0x50000]; - Encoding.ASCII.GetBytes("GALE01").CopyTo(gamecube, 0); - new byte[] { 0xC2, 0x33, 0x9F, 0x3D }.CopyTo(gamecube, 0x1C); - fs.File.WriteAllBytes("/games/melee.iso", gamecube); - fs.File.WriteAllBytes("/games/notes.iso", Encoding.ASCII.GetBytes("not a disc")); - var extractor = new DiscExtractor(fs); - - Assert.True(extractor.Extract("/games/melee.iso", "/out/a").IsFailure); - Assert.True(extractor.Extract("/games/notes.iso", "/out/b").IsFailure); - Assert.True(extractor.Extract("/games/missing.iso", "/out/c").IsFailure); - } - - [Fact] - public void Extract_ObservesCancellation() - { - var fs = Disk(); - fs.File.WriteAllBytes("/games/mkw.iso", new SyntheticWiiDisc().BuildIso()); - using var cancellation = new CancellationTokenSource(); - cancellation.Cancel(); - - Assert.ThrowsAny( - () => new DiscExtractor(fs).Extract("/games/mkw.iso", "/out", null, cancellation.Token) - ); - } - - [Fact] - public void Extract_ReportsProgressUpToTheEnd() - { - var fs = Disk(); - fs.File.WriteAllBytes("/games/mkw.iso", new SyntheticWiiDisc().BuildIso()); - var reports = new List(); - - new DiscExtractor(fs).Extract("/games/mkw.iso", "/out", new SyncProgress(reports.Add)); - - Assert.NotEmpty(reports); - Assert.Equal(1, reports[^1]); - Assert.Equal(reports.Order(), reports); - } - - [Fact] - public void Inspect_ReadsTheGameWithoutExtracting() - { - var fs = Disk(); - var disc = new SyntheticWiiDisc { GameId = "RMCE01", Revision = 1 }; - fs.File.WriteAllBytes("/games/mkw.wbfs", disc.Build("wbfs")); - - var contents = new DiscExtractor(fs).Inspect("/games/mkw.wbfs"); - - Assert.True(contents.IsSuccess, contents.IsFailure ? contents.Error.Message : null); - Assert.Equal("RMCE01", contents.Value.GameId); - Assert.Equal(1, contents.Value.Revision); - Assert.True(contents.Value.RequiredBytes >= disc.Files.Values.Sum(file => file.Length)); - Assert.Empty(fs.Directory.EnumerateFileSystemEntries("/out")); - } - - [Fact] - public void RestoreCode_ReplacesOnlyMainDolAndStaticRel() - { - var fs = Disk(); - var disc = new SyntheticWiiDisc(); - fs.File.WriteAllBytes("/games/mkw.iso", disc.BuildIso()); - var extractor = new DiscExtractor(fs); - extractor.Extract("/games/mkw.iso", "/out"); - fs.File.WriteAllText("/out/DATA/sys/main.dol", "patched"); - fs.File.Delete("/out/DATA/files/rel/StaticR.rel"); - fs.File.WriteAllText("/out/DATA/files/Scene/UI/Title.szs", "my edit"); - - var result = extractor.RestoreCode("/games/mkw.iso", "/out/DATA"); - - Assert.True(result.IsSuccess, result.IsFailure ? result.Error.Message : null); - Assert.Equal(disc.MainDol, fs.File.ReadAllBytes("/out/DATA/sys/main.dol")); - Assert.Equal(disc.Files["rel/StaticR.rel"], fs.File.ReadAllBytes("/out/DATA/files/rel/StaticR.rel")); - Assert.Equal("my edit", fs.File.ReadAllText("/out/DATA/files/Scene/UI/Title.szs")); - Assert.Empty(fs.Directory.EnumerateFiles("/out", "*.restore", SearchOption.AllDirectories)); - } - - [Theory] - [InlineData("StaticR.rel", true)] - [InlineData("Race", true)] - [InlineData("..", false)] - [InlineData("", false)] - [InlineData("a/b", false)] - [InlineData("a:b", false)] - [InlineData("LPT1", false)] - [InlineData("aux.szs", false)] - [InlineData("console.szs", true)] - public void SafeName_UsesTheStrictestPlatformRules(string name, bool safe) - { - if (safe) - Assert.Equal(name, DiscExtractor.SafeName(name)); - else - Assert.Throws(() => DiscExtractor.SafeName(name)); - } - - private static Dictionary Snapshot(MockFileSystem fs, string root) => - fs - .Directory.EnumerateFiles(root, "*", SearchOption.AllDirectories) - .ToDictionary(file => fs.Path.GetRelativePath(root, file).Replace('\\', '/'), file => fs.File.ReadAllBytes(file)); -} - -/// Reports on the calling thread, unlike , so a test sees every report in order. -internal sealed class SyncProgress(Action report) : IProgress -{ - public void Report(T value) => report(value); -} diff --git a/WheelWizard.Test/Features/GameFiles/GameFilesHarness.cs b/WheelWizard.Test/Features/GameFiles/GameFilesHarness.cs deleted file mode 100644 index 381c55e7f..000000000 --- a/WheelWizard.Test/Features/GameFiles/GameFilesHarness.cs +++ /dev/null @@ -1,220 +0,0 @@ -using System.IO.Abstractions; -using System.Text.Json; -using Microsoft.Extensions.Logging.Abstractions; -using Testably.Abstractions.Testing; -using WheelWizard.ApplicationData; -using WheelWizard.DiscImages; -using WheelWizard.Dolphin.Paths; -using WheelWizard.GameFiles; -using WheelWizard.GameFiles.Domain; -using WheelWizard.Settings; -using WheelWizard.Settings.Types; -using WheelWizard.Shared.Processes; - -namespace WheelWizard.Test.Features.GameFiles; - -/// -/// A data folder, some game images and the real game files service, over a mock disk or (for links, which the mock -/// does not follow) a temporary folder on the real one. Every image it writes is remembered, so a test can assert at -/// the end that none of them was changed in any way. -/// -internal sealed class GameFilesHarness : IDisposable -{ - private readonly string? _realRoot; - private readonly Dictionary _images = new(); - - public IFileSystem Fs { get; } - public SimulationMode Mode { get; } - public string DataDir { get; } - public string GamesDir { get; } - public IProcessLauncher Processes { get; } = Substitute.For(); - public WhWzSetting GameLocation { get; } = - (WhWzSetting)new WhWzSetting("GameLocation", "").SetRetainUnavailable(); - public ISettingsManager Settings { get; } = Substitute.For(); - public GameFilesPaths Paths { get; } - public GameFilesService Service { get; } - public DiscExtractor Extractor { get; } - - public GameFilesHarness(SimulationMode mode = SimulationMode.Linux, string? dataDir = null, Action? configure = null) - : this(CreateMock(mode, configure), mode, dataDir, realRoot: null) { } - - private static MockFileSystem CreateMock(SimulationMode mode, Action? configure) - { - var fs = new MockFileSystem(options => options.SimulatingOperatingSystem(mode)); - configure?.Invoke(fs); - return fs; - } - - /// A harness on the real disk, in a fresh temporary folder that is removed again on dispose. - public static GameFilesHarness OnRealDisk() - { - var root = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "ww-gamefiles-" + Guid.NewGuid().ToString("N")); - var mode = - OperatingSystem.IsWindows() ? SimulationMode.Windows - : OperatingSystem.IsMacOS() ? SimulationMode.MacOS - : SimulationMode.Linux; - return new GameFilesHarness(new Testably.Abstractions.RealFileSystem(), mode, System.IO.Path.Combine(root, "CT-MKWII"), root); - } - - private GameFilesHarness(IFileSystem fs, SimulationMode mode, string? dataDir, string? realRoot) - { - Mode = mode; - Fs = fs; - _realRoot = realRoot; - DataDir = - dataDir ?? (mode == SimulationMode.Windows ? @"C:\Users\Player\AppData\Roaming\CT-MKWII" : "/home/player/.config/CT-MKWII"); - GamesDir = - realRoot is not null ? Fs.Path.Combine(realRoot, "Wii Games") - : mode == SimulationMode.Windows ? @"C:\Games\Wii Games" - : "/home/player/Games/Wii Games"; - Fs.Directory.CreateDirectory(DataDir); - Fs.Directory.CreateDirectory(GamesDir); - - Settings.GAME_LOCATION.Returns(GameLocation); - Settings.Get(GameLocation).Returns(_ => (string)GameLocation.Get()); - Settings - .Set(GameLocation, Arg.Any(), Arg.Any()) - .Returns(call => GameLocation.Set(call.ArgAt(1), skipSave: true)); - - var location = Substitute.For(); - location.DirectoryPath.Returns(_ => DataDir); - Paths = new GameFilesPaths(location, Fs); - Extractor = new DiscExtractor(Fs); - Service = new GameFilesService( - Paths, - Extractor, - Settings, - Fs, - Processes, - Substitute.For(), - new DiscImageService(Fs), - NullLogger.Instance - ); - } - - public string Game => Paths.RootPath; - - /// A folder next to the data folder, standing in for another drive. - public string Elsewhere(string name) => Fs.Path.Combine(_realRoot ?? "/mnt", "other drive", name); - - /// - /// Creates a directory link: a symlink, or on Windows without the right to make one, a junction (which is what - /// most Windows users make anyway). False only when neither can be made here. - /// - public bool TryLink(string link, string target) - { - try - { - Fs.Directory.CreateSymbolicLink(link, target); - return true; - } - catch (Exception exception) when (exception is UnauthorizedAccessException or IOException) - { - if (!OperatingSystem.IsWindows()) - return false; - } - - var junction = System.Diagnostics.Process.Start( - new System.Diagnostics.ProcessStartInfo("cmd.exe") - { - ArgumentList = { "/c", "mklink", "/J", link, target }, - UseShellExecute = false, - CreateNoWindow = true, - RedirectStandardOutput = true, - RedirectStandardError = true, - } - )!; - junction.WaitForExit(); - return junction.ExitCode == 0 && Fs.DirectoryInfo.New(link).LinkTarget is not null; - } - - public void Dispose() - { - if (_realRoot is null || !Directory.Exists(_realRoot)) - return; - // A recursive delete trips over junctions on Windows, so links are removed on their own first. - RemoveLinks(_realRoot); - Directory.Delete(_realRoot, recursive: true); - } - - private static void RemoveLinks(string folder) - { - foreach (var directory in Directory.GetDirectories(folder)) - { - if (new DirectoryInfo(directory).LinkTarget is null) - RemoveLinks(directory); - else if (OperatingSystem.IsWindows()) - Directory.Delete(directory); - else - File.Delete(directory); - } - } - - public string In(params string[] parts) => Fs.Path.Combine([Game, .. parts]); - - /// Writes a game image and remembers exactly what it looked like. - public string WriteImage(SyntheticWiiDisc disc, string name = "Mario Kart Wii (Europe).rvz", string format = "rvz") - { - var path = Fs.Path.Combine(GamesDir, name); - Fs.File.WriteAllBytes(path, disc.Build(format)); - _images[path] = (Fs.File.ReadAllBytes(path), Fs.File.GetLastWriteTimeUtc(path)); - return path; - } - - public string WriteImage(string gameId = "RMCP01", string? name = null) => - WriteImage(new SyntheticWiiDisc { GameId = gameId }, name ?? $"{gameId}.rvz"); - - /// The one promise above all others: Wheel Wizard never changes, moves or deletes a game image. - public void AssertImagesUntouched() - { - foreach (var (path, (bytes, lastWrite)) in _images) - { - Assert.True(Fs.File.Exists(path), $"The game image {path} is gone."); - Assert.Equal(bytes, Fs.File.ReadAllBytes(path)); - Assert.Equal(lastWrite, Fs.File.GetLastWriteTimeUtc(path)); - } - } - - public async Task ExtractAsync(string image, bool replace = false) - { - var result = await Service.ExtractAsync(image, replace); - Assert.True(result.IsSuccess, result.IsFailure ? result.Error.Message : null); - } - - public GameFilesManifest? Manifest(string? manifestPath = null) - { - var path = manifestPath ?? Paths.ManifestPath; - return Fs.File.Exists(path) ? JsonSerializer.Deserialize(Fs.File.ReadAllText(path)) : null; - } - - /// Lays a finished extraction into , as an interrupted swap leaves it. - public void Stage(string image, string gameId) - { - var staging = Paths.StagingPath; - Fs.Directory.CreateDirectory(staging); - Fs.File.WriteAllText(Fs.Path.Combine(staging, GameFilesPaths.WorkMarkerFileName), ""); - var code = Extractor.Extract(image, staging); - Assert.True(code.IsSuccess, code.IsFailure ? code.Error.Message : null); - Fs.File.WriteAllText( - Fs.Path.Combine(staging, GameFilesPaths.ManifestFileName), - JsonSerializer.Serialize( - new GameFilesManifest - { - GameId = gameId, - SourcePath = image, - MainDolSha256 = code.Value.MainDolSha256, - StaticRelSha256 = code.Value.StaticRelSha256, - } - ) - ); - } - - public string[] Siblings() => Fs.Directory.GetFileSystemEntries(Fs.Path.GetDirectoryName(Paths.ResolvedRootPath)!); - - public void SetRunning(params string[] processNames) - { - Processes.GetProcessIds(Arg.Any()).Returns(Array.Empty()); - foreach (var name in processNames) - Processes.GetProcessIds(name).Returns([4242]); - } -} diff --git a/WheelWizard.Test/Features/GameFiles/GameFilesLinkTests.cs b/WheelWizard.Test/Features/GameFiles/GameFilesLinkTests.cs deleted file mode 100644 index 5580a60b4..000000000 --- a/WheelWizard.Test/Features/GameFiles/GameFilesLinkTests.cs +++ /dev/null @@ -1,179 +0,0 @@ -using WheelWizard.GameFiles; -using WheelWizard.Shared.IO; - -namespace WheelWizard.Test.Features.GameFiles; - -/// -/// Someone put the game folder on another drive with a symlink or junction. The in-memory disk does not follow -/// links, so these run in a temporary folder on the real one. Where this account can't create links (Windows -/// without Developer Mode) there is nothing to test, and they return early. -/// -public sealed class GameFilesLinkTests : IDisposable -{ - private readonly GameFilesHarness _harness = GameFilesHarness.OnRealDisk(); - - public void Dispose() => _harness.Dispose(); - - private bool Linked(out string target) - { - target = _harness.Elsewhere("Mario Kart Wii"); - _harness.Fs.Directory.CreateDirectory(target); - return _harness.TryLink(_harness.Game, target); - } - - private bool IsLink(string path) => _harness.Fs.DirectoryInfo.New(path).LinkTarget is not null; - - [Fact] - public async Task ExtractsIntoWhereTheLinkPoints() - { - if (!Linked(out var target)) - return; - - await _harness.ExtractAsync(_harness.WriteImage()); - - Assert.True(IsLink(_harness.Game)); - Assert.True(_harness.Fs.File.Exists(Path.Combine(target, "DATA", "sys", "main.dol"))); - Assert.True(_harness.Service.GetStatus().IsReady); - // The work folder sits next to the target, on the same drive, and is gone afterwards. - Assert.False(_harness.Fs.Directory.Exists(target + ".partial")); - Assert.False(_harness.Fs.Directory.Exists(_harness.Game + ".partial")); - _harness.AssertImagesUntouched(); - } - - [Fact] - public async Task ReplacingKeepsTheLinkAndUnrelatedFiles() - { - if (!Linked(out var target)) - return; - await _harness.ExtractAsync(_harness.WriteImage()); - _harness.Fs.File.WriteAllText(Path.Combine(target, "readme.txt"), "mine"); - - await _harness.ExtractAsync(_harness.WriteImage("RMCJ01"), replace: true); - - Assert.True(IsLink(_harness.Game)); - Assert.Equal("RMCJ01", _harness.Manifest()!.GameId); - Assert.Equal("mine", _harness.Fs.File.ReadAllText(Path.Combine(target, "readme.txt"))); - Assert.False(_harness.Fs.Directory.Exists(target + ".replaced")); - _harness.AssertImagesUntouched(); - } - - [Fact] - public async Task RecoversAnInterruptedSwapInsideTheTarget() - { - if (!Linked(out var target)) - return; - await _harness.ExtractAsync(_harness.WriteImage()); - _harness.Stage(_harness.WriteImage("RMCE01"), "RMCE01"); - Directory.CreateDirectory(target + ".replaced"); - File.WriteAllText(Path.Combine(target + ".replaced", GameFilesPaths.WorkMarkerFileName), ""); - File.Move( - Path.Combine(target, GameFilesPaths.ManifestFileName), - Path.Combine(target + ".replaced", GameFilesPaths.ManifestFileName) - ); - - _harness.Service.RecoverInterruptedWork(); - - Assert.True(IsLink(_harness.Game)); - Assert.Equal("RMCE01", _harness.Manifest()!.GameId); - Assert.False(Directory.Exists(target + ".replaced")); - Assert.False(Directory.Exists(target + ".partial")); - } - - [Fact] - public void DeletingAnUnknownLink_LeavesWhatItPointsTo() - { - if (!Linked(out var target)) - return; - File.WriteAllText(Path.Combine(target, "precious.bin"), "precious"); - - Assert.Equal(GameFilesState.UnknownFolder, _harness.Service.GetStatus().State); - Assert.True(_harness.Service.DeleteUnknown().IsSuccess); - - Assert.False(Directory.Exists(_harness.Game)); - Assert.Equal("precious", File.ReadAllText(Path.Combine(target, "precious.bin"))); - } - - [Fact] - public void MovingAnUnknownLinkAside_MovesOnlyTheLink() - { - if (!Linked(out var target)) - return; - File.WriteAllText(Path.Combine(target, "precious.bin"), "precious"); - - var moved = _harness.Service.MoveUnknownAside(); - - Assert.True(moved.IsSuccess, moved.IsFailure ? moved.Error.Message : null); - Assert.True(IsLink(moved.Value)); - Assert.False(Directory.Exists(_harness.Game)); - Assert.Equal("precious", File.ReadAllText(Path.Combine(target, "precious.bin"))); - Assert.Equal("precious", File.ReadAllText(Path.Combine(moved.Value, "precious.bin"))); - } - - [Fact] - public void DeletingAnUnknownFolder_NeverFollowsALinkInside() - { - var documents = _harness.Elsewhere("Documents"); - Directory.CreateDirectory(documents); - File.WriteAllText(Path.Combine(documents, "thesis.docx"), "years of work"); - Directory.CreateDirectory(_harness.In("DATA")); - if (!_harness.TryLink(_harness.In("DATA", "docs"), documents)) - return; - - Assert.True(_harness.Service.DeleteUnknown().IsSuccess); - - Assert.False(Directory.Exists(_harness.Game)); - Assert.Equal("years of work", File.ReadAllText(Path.Combine(documents, "thesis.docx"))); - } - - [Fact] - public async Task ALinkWhoseDriveIsUnplugged_IsNotExtractedInto() - { - var target = _harness.Elsewhere("unplugged drive/GAME"); - if (!_harness.TryLink(_harness.Game, target)) - return; - - Assert.Equal(GameFilesState.UnknownFolder, _harness.Service.GetStatus().State); - Assert.True((await _harness.Service.ExtractAsync(_harness.WriteImage(), true)).IsFailure); - Assert.False(Directory.Exists(target)); - } - - [Fact] - public async Task MovingTheDataFolder_KeepsALinkedGameWhereItIs() - { - if (!Linked(out var target)) - return; - await _harness.ExtractAsync(_harness.WriteImage()); - File.WriteAllText(_harness.Fs.Path.Combine(_harness.DataDir, "config.json"), "{}"); - var newData = _harness.Elsewhere("New Data Folder"); - - var moved = new DirectoryTransferService(_harness.Fs).MoveContents(_harness.DataDir, newData); - - Assert.Equal(DirectoryMoveOutcome.Success, moved.Outcome); - var movedGame = Path.Combine(newData, GameFilesPaths.FolderName); - Assert.True(Directory.Exists(movedGame)); - Assert.True(File.Exists(Path.Combine(movedGame, "DATA", "sys", "main.dol"))); - // The extracted game stayed on the other drive; only the link (or, where none can be made, a copy) moved. - Assert.True(File.Exists(Path.Combine(target, "DATA", "sys", "main.dol"))); - Assert.False(Directory.Exists(_harness.DataDir)); - _harness.AssertImagesUntouched(); - } - - [Fact] - public async Task RealDisk_FullRoundTrip() - { - // The same flow as on the mock, on a real drive: extract, edit, restore the code, switch region. - await _harness.ExtractAsync(_harness.WriteImage()); - File.WriteAllText(_harness.In("DATA", "sys", "main.dol"), "patched"); - File.WriteAllText(_harness.In("DATA", "files", "Scene", "UI", "Title.szs"), "my edit"); - Assert.Equal(GameFilesState.CodeModified, _harness.Service.GetStatus().State); - - Assert.True((await _harness.Service.RestoreCodeAsync()).IsSuccess); - Assert.True(_harness.Service.GetStatus().IsReady); - Assert.Equal("my edit", File.ReadAllText(_harness.In("DATA", "files", "Scene", "UI", "Title.szs"))); - - await _harness.ExtractAsync(_harness.WriteImage("RMCE01"), replace: true); - Assert.Equal("RMCE01", _harness.Manifest()!.GameId); - Assert.Single(Directory.GetFileSystemEntries(_harness.DataDir)); - _harness.AssertImagesUntouched(); - } -} diff --git a/WheelWizard.Test/Features/GameFiles/GameFilesServiceTests.cs b/WheelWizard.Test/Features/GameFiles/GameFilesServiceTests.cs deleted file mode 100644 index 5379c7769..000000000 --- a/WheelWizard.Test/Features/GameFiles/GameFilesServiceTests.cs +++ /dev/null @@ -1,676 +0,0 @@ -using System.Text.Json; -using Testably.Abstractions.Testing; -using WheelWizard.GameFiles; -using WheelWizard.GameFiles.Domain; -using WheelWizard.Models.Enums; -using WheelWizard.Settings.Types; - -namespace WheelWizard.Test.Features.GameFiles; - -public class GameFilesServiceTests -{ - #region Status - - [Fact] - public void Status_NothingPicked_IsLeftToTheOnboarding() - { - var harness = new GameFilesHarness(); - - Assert.Equal(GameFilesState.NoImage, harness.Service.GetStatus().State); - } - - [Fact] - public void Status_AnImageFromAnOlderVersion_NeedsExtraction() - { - var harness = new GameFilesHarness(); - harness.GameLocation.Set(harness.WriteImage(), skipSave: true); - - var status = harness.Service.GetStatus(); - - Assert.Equal(GameFilesState.NeedsExtraction, status.State); - Assert.False(harness.Fs.Directory.Exists(harness.Game)); - } - - [Fact] - public void Status_AnImageThatIsGone_IsMissing() - { - var harness = new GameFilesHarness(); - harness.GameLocation.Set("/media/usb/mkw.iso", skipSave: true); - - Assert.Equal(GameFilesState.ImageMissing, harness.Service.GetStatus().State); - } - - [Fact] - public void Status_AnImageOnAnUnpluggedDrive_IsMissingNotUnset() - { - var harness = new GameFilesHarness(); - ((IWhWzSetting)harness.GameLocation).RememberUnavailable(JsonSerializer.SerializeToElement("/media/usb/mkw.iso")); - - var status = harness.Service.GetStatus(); - - Assert.Equal(GameFilesState.ImageMissing, status.State); - Assert.Equal("/media/usb/mkw.iso", status.ImagePath); - } - - [Fact] - public void Status_AFolderWithoutManifest_IsUnknown() - { - var harness = new GameFilesHarness(); - harness.Fs.Directory.CreateDirectory(harness.In("DATA", "sys")); - harness.Fs.File.WriteAllText(harness.In("DATA", "sys", "main.dol"), "someone's own extraction"); - - Assert.Equal(GameFilesState.UnknownFolder, harness.Service.GetStatus().State); - } - - [Fact] - public void Status_AFileWhereTheFolderGoes_IsUnknown() - { - var harness = new GameFilesHarness(); - harness.Fs.File.WriteAllText(harness.Game, "not a folder"); - - Assert.Equal(GameFilesState.UnknownFolder, harness.Service.GetStatus().State); - } - - [Theory] - [InlineData("{ not json")] - [InlineData("""{"SchemaVersion":99,"GameId":"RMCP01","MainDolSha256":"a","StaticRelSha256":"b"}""")] - [InlineData("""{"SchemaVersion":1,"GameId":"","MainDolSha256":"a","StaticRelSha256":"b"}""")] - public void Status_AManifestThatCantBeTrusted_IsUnknown(string manifest) - { - var harness = new GameFilesHarness(); - harness.Fs.Directory.CreateDirectory(harness.Game); - harness.Fs.File.WriteAllText(harness.Paths.ManifestPath, manifest); - - Assert.Equal(GameFilesState.UnknownFolder, harness.Service.GetStatus().State); - } - - [Fact] - public async Task Status_EditingAnyOtherFile_StaysReady() - { - var harness = new GameFilesHarness(); - await harness.ExtractAsync(harness.WriteImage()); - - harness.Fs.File.WriteAllText(harness.In("DATA", "files", "Scene", "UI", "Title.szs"), "my custom title screen"); - harness.Fs.File.Delete(harness.In("DATA", "files", "Race", "Course", "beginner_course.szs")); - harness.Fs.File.WriteAllText(harness.In("DATA", "files", "my-new-asset.brres"), "new"); - harness.Fs.File.WriteAllText(harness.In("DATA", "sys", "bi2.bin"), "edited"); - - Assert.Equal(GameFilesState.Ready, harness.Service.GetStatus().State); - } - - [Theory] - [InlineData("DATA/sys/main.dol", false)] - [InlineData("DATA/files/rel/StaticR.rel", false)] - [InlineData("DATA/sys/main.dol", true)] - [InlineData("DATA/files/rel/StaticR.rel", true)] - public async Task Status_EditingOrDeletingTheGameCode_IsModified(string file, bool delete) - { - var harness = new GameFilesHarness(); - await harness.ExtractAsync(harness.WriteImage()); - var path = harness.In(file.Split('/')); - - if (delete) - harness.Fs.File.Delete(path); - else - harness.Fs.File.WriteAllText(path, "patched"); - - Assert.Equal(GameFilesState.CodeModified, harness.Service.GetStatus().State); - } - - [Fact] - public async Task Status_ADeletedImage_DoesNotMatterOnceExtracted() - { - var harness = new GameFilesHarness(); - var image = harness.WriteImage(); - await harness.ExtractAsync(image); - - harness.Fs.File.Delete(image); - - Assert.Equal(GameFilesState.Ready, harness.Service.GetStatus().State); - Assert.Equal(MarioKartWiiEnums.Regions.Europe, harness.Service.GetRegion()); - } - - #endregion - - #region Extracting - - [Theory] - [InlineData(SimulationMode.Windows)] - [InlineData(SimulationMode.Linux)] - [InlineData(SimulationMode.MacOS)] - public async Task Extract_MakesTheGameReadyAndRemembersTheImage(SimulationMode mode) - { - var harness = new GameFilesHarness(mode); - var image = harness.WriteImage(new SyntheticWiiDisc(), "Mario Kart Wii (Europe) (En,Fr,De,Es,It).rvz"); - - await harness.ExtractAsync(image); - - var status = harness.Service.GetStatus(); - Assert.Equal(GameFilesState.Ready, status.State); - Assert.Equal(image, harness.GameLocation.Get()); - Assert.Equal("RMCP01", status.Manifest!.GameId); - Assert.Equal(harness.Fs.Path.GetFullPath(image), status.Manifest.SourcePath); - Assert.True(harness.Fs.File.Exists(harness.In("DATA", "sys", "main.dol"))); - Assert.True(harness.Fs.File.Exists(harness.In("disc", "header.bin"))); - // Nothing of the work in progress is left next to it. - Assert.Equal([harness.Game], harness.Siblings().Where(entry => entry.Contains("GAME")).ToArray()); - harness.AssertImagesUntouched(); - } - - [Theory] - [InlineData("iso")] - [InlineData("wbfs")] - [InlineData("gcz")] - [InlineData("ciso")] - [InlineData("rvz")] - public async Task Extract_EveryFormatThePickerOffers(string format) - { - var harness = new GameFilesHarness(); - var image = harness.WriteImage(new SyntheticWiiDisc(), $"mkw.{format}", format); - - await harness.ExtractAsync(image); - - Assert.True(harness.Service.GetStatus().IsReady); - harness.AssertImagesUntouched(); - } - - [Fact] - public async Task Extract_AnotherGame_ChangesNothing() - { - var harness = new GameFilesHarness(); - var image = harness.WriteImage(new SyntheticWiiDisc { GameId = "RSPE01", InternalName = "Wii Sports" }, "sports.iso", "iso"); - - var result = await harness.Service.ExtractAsync(image, replaceExisting: true); - - Assert.True(result.IsFailure); - Assert.False(harness.Fs.Directory.Exists(harness.Game)); - Assert.False(harness.Fs.Directory.Exists(harness.Paths.StagingPath)); - Assert.Equal("", harness.GameLocation.Get()); - harness.AssertImagesUntouched(); - } - - [Fact] - public async Task Extract_AMissingImage_ChangesNothing() - { - var harness = new GameFilesHarness(); - - Assert.True((await harness.Service.ExtractAsync("/nowhere/mkw.iso", false)).IsFailure); - Assert.False(harness.Fs.Directory.Exists(harness.Game)); - } - - [Fact] - public async Task Extract_AnImageKeptInsideTheGameFolder_IsRefused() - { - var harness = new GameFilesHarness(); - var image = harness.WriteImage(); - await harness.ExtractAsync(image); - var inside = harness.In("DATA", "files", "backup.rvz"); - harness.Fs.File.Copy(image, inside); - var other = harness.WriteImage("RMCE01"); - harness.Fs.File.Copy(other, harness.In("DATA", "files", "usa.rvz")); - - Assert.True(harness.Service.Plan(inside).IsFailure); - Assert.True((await harness.Service.ExtractAsync(harness.In("DATA", "files", "usa.rvz"), true)).IsFailure); - Assert.True(harness.Fs.File.Exists(inside)); - Assert.Equal("RMCP01", harness.Manifest()!.GameId); - } - - [Fact] - public async Task Extract_TheSameGameAgain_KeepsEveryChange() - { - var harness = new GameFilesHarness(); - await harness.ExtractAsync(harness.WriteImage()); - harness.Fs.File.WriteAllText(harness.In("DATA", "files", "Scene", "UI", "Title.szs"), "my edit"); - var moved = harness.WriteImage(new SyntheticWiiDisc(), "moved copy.iso", "iso"); - - Assert.Equal(GameChangeKind.SameGame, harness.Service.Plan(moved).Value.Kind); - await harness.ExtractAsync(moved); - - Assert.Equal("my edit", harness.Fs.File.ReadAllText(harness.In("DATA", "files", "Scene", "UI", "Title.szs"))); - Assert.Equal(moved, harness.GameLocation.Get()); - Assert.Equal(harness.Fs.Path.GetFullPath(moved), harness.Manifest()!.SourcePath); - harness.AssertImagesUntouched(); - } - - [Fact] - public async Task Extract_ADifferentGame_NeedsConfirmation() - { - var harness = new GameFilesHarness(); - await harness.ExtractAsync(harness.WriteImage()); - harness.Fs.File.WriteAllText(harness.In("DATA", "files", "Scene", "UI", "Title.szs"), "my edit"); - var usa = harness.WriteImage("RMCE01"); - - Assert.Equal(GameChangeKind.Replace, harness.Service.Plan(usa).Value.Kind); - Assert.True((await harness.Service.ExtractAsync(usa, replaceExisting: false)).IsFailure); - - Assert.Equal("RMCP01", harness.Manifest()!.GameId); - Assert.Equal("my edit", harness.Fs.File.ReadAllText(harness.In("DATA", "files", "Scene", "UI", "Title.szs"))); - } - - [Fact] - public async Task Extract_ADifferentRevision_IsADifferentGame() - { - var harness = new GameFilesHarness(); - await harness.ExtractAsync(harness.WriteImage()); - var revised = harness.WriteImage(new SyntheticWiiDisc { Revision = 1 }, "rev1.rvz"); - - Assert.Equal(GameChangeKind.Replace, harness.Service.Plan(revised).Value.Kind); - } - - [Fact] - public async Task Extract_ReplacingConfirmed_SwapsTheGameButKeepsUnrelatedFiles() - { - var harness = new GameFilesHarness(); - var europe = harness.WriteImage(); - await harness.ExtractAsync(europe); - harness.Fs.File.WriteAllText(harness.In("DATA", "files", "Scene", "UI", "Title.szs"), "my edit"); - harness.Fs.File.WriteAllText(harness.In("notes.txt"), "the user's own file"); - var usa = harness.WriteImage(new SyntheticWiiDisc { GameId = "RMCE01", MainDol = SyntheticWiiDisc.Dol(0x300, 5) }, "usa.rvz"); - - await harness.ExtractAsync(usa, replace: true); - - Assert.Equal("RMCE01", harness.Manifest()!.GameId); - Assert.True(harness.Service.GetStatus().IsReady); - Assert.NotEqual("my edit", harness.Fs.File.ReadAllText(harness.In("DATA", "files", "Scene", "UI", "Title.szs"))); - Assert.Equal("the user's own file", harness.Fs.File.ReadAllText(harness.In("notes.txt"))); - Assert.Equal(usa, harness.GameLocation.Get()); - Assert.Equal([harness.Game], harness.Siblings().Where(entry => entry.Contains("GAME")).ToArray()); - harness.AssertImagesUntouched(); - } - - [Fact] - public async Task Extract_SomethingUnknownInTheWay_IsNeverTouched() - { - var harness = new GameFilesHarness(); - harness.Fs.Directory.CreateDirectory(harness.In("my stuff")); - harness.Fs.File.WriteAllText(harness.In("my stuff", "keep.txt"), "precious"); - var image = harness.WriteImage(); - - Assert.Equal(GameChangeKind.UnknownFolder, harness.Service.Plan(image).Value.Kind); - Assert.True((await harness.Service.ExtractAsync(image, replaceExisting: true)).IsFailure); - - Assert.Equal("precious", harness.Fs.File.ReadAllText(harness.In("my stuff", "keep.txt"))); - Assert.False(harness.Fs.File.Exists(harness.Paths.ManifestPath)); - } - - [Theory] - [InlineData("Dolphin")] - [InlineData("dolphin-emu")] - [InlineData("WiiCompiled")] - [InlineData("RetroRewind")] - public async Task Extract_WhileTheGameRuns_WaitsWithoutTouchingAnything(string process) - { - var harness = new GameFilesHarness(); - var europe = harness.WriteImage(); - await harness.ExtractAsync(europe); - var usa = harness.WriteImage("RMCE01"); - harness.SetRunning(process); - - Assert.True(harness.Service.IsGameRunning()); - Assert.True((await harness.Service.ExtractAsync(usa, replaceExisting: true)).IsFailure); - Assert.Equal("RMCP01", harness.Manifest()!.GameId); - - harness.SetRunning(); - await harness.ExtractAsync(usa, replace: true); - Assert.Equal("RMCE01", harness.Manifest()!.GameId); - } - - [Fact] - public void CloseRunningGame_ClosesDolphinAndWiiCompiled() - { - var harness = new GameFilesHarness(); - - harness.Service.CloseRunningGame(); - - harness.Processes.Received().KillByName("Dolphin"); - harness.Processes.Received().KillByName("WiiCompiled"); - harness.Processes.Received().KillByName("RetroRewind"); - } - - [Fact] - public async Task Extract_NotEnoughSpace_SaysSoAndWritesNothing() - { - var harness = new GameFilesHarness(configure: fs => fs.WithDrive(drive => drive.SetTotalSize(64L * 1024 * 1024))); - var image = harness.WriteImage(); - - var result = await harness.Service.ExtractAsync(image, false); - - Assert.True(result.IsFailure); - Assert.Contains("GB", result.Error.Message); - Assert.False(harness.Fs.Directory.Exists(harness.Game)); - Assert.False(harness.Fs.Directory.Exists(harness.Paths.StagingPath)); - harness.AssertImagesUntouched(); - } - - [Fact] - public async Task Extract_Cancelled_LeavesTheCurrentGameAsItWas() - { - var harness = new GameFilesHarness(); - await harness.ExtractAsync(harness.WriteImage()); - harness.Fs.File.WriteAllText(harness.In("DATA", "files", "Scene", "UI", "Title.szs"), "my edit"); - var usa = harness.WriteImage("RMCE01"); - using var cancellation = new CancellationTokenSource(); - var progress = new SyncProgress(value => - { - if (value.Fraction > 0) - cancellation.Cancel(); - }); - - await Assert.ThrowsAnyAsync( - () => harness.Service.ExtractAsync(usa, true, progress, cancellation.Token) - ); - - Assert.Equal("RMCP01", harness.Manifest()!.GameId); - Assert.Equal("my edit", harness.Fs.File.ReadAllText(harness.In("DATA", "files", "Scene", "UI", "Title.szs"))); - Assert.False(harness.Fs.Directory.Exists(harness.Paths.StagingPath)); - Assert.True(harness.Service.GetStatus().IsReady); - } - - [Fact] - public async Task Extract_ADamagedImage_LeavesTheCurrentGameAsItWas() - { - var harness = new GameFilesHarness(); - await harness.ExtractAsync(harness.WriteImage()); - var full = new SyntheticWiiDisc { GameId = "RMCE01" }.BuildIso(); - // The header and file table are there, the file data is not. - var damaged = harness.Fs.Path.Combine(harness.GamesDir, "damaged.iso"); - harness.Fs.File.WriteAllBytes(damaged, full[..0x7A000]); - - var result = await harness.Service.ExtractAsync(damaged, replaceExisting: true); - - Assert.True(result.IsFailure); - Assert.Equal("RMCP01", harness.Manifest()!.GameId); - Assert.True(harness.Service.GetStatus().IsReady); - Assert.False(harness.Fs.Directory.Exists(harness.Paths.StagingPath)); - } - - [Fact] - public async Task Extract_ReportsProgressWithAMessage() - { - var harness = new GameFilesHarness(); - var reports = new List(); - - Assert.True( - ( - await harness.Service.ExtractAsync(harness.WriteImage(), false, new SyncProgress(reports.Add)) - ).IsSuccess - ); - - Assert.NotEmpty(reports); - Assert.All(reports, report => Assert.False(string.IsNullOrWhiteSpace(report.Message))); - Assert.Equal(1, reports[^1].Fraction); - } - - #endregion - - #region Folder in the way - - [Fact] - public async Task EmptyFolder_IsJustAPlaceToExtractInto() - { - var harness = new GameFilesHarness(); - harness.Fs.Directory.CreateDirectory(harness.Game); - - Assert.Equal(GameFilesState.NoImage, harness.Service.GetStatus().State); - await harness.ExtractAsync(harness.WriteImage()); - Assert.True(harness.Service.GetStatus().IsReady); - } - - [Fact] - public async Task LinkToTheRootOfADrive_IsNeverExtractedInto() - { - var fs = new MockFileSystem(options => options.SimulatingOperatingSystem(SimulationMode.Linux)); - fs.Directory.CreateDirectory("/games"); - fs.File.WriteAllBytes("/games/mkw.rvz", new SyntheticWiiDisc().Build("rvz")); - var paths = Substitute.For(); - paths.RootPath.Returns("/data/GAME"); - paths.ResolvedRootPath.Returns("/"); - paths.ManifestPath.Returns("/data/GAME/" + GameFilesPaths.ManifestFileName); - paths.StagingPath.Returns("/.partial"); - paths.ReplacedPath.Returns("/.replaced"); - var settings = Substitute.For(); - settings.GAME_LOCATION.Returns(new WhWzSetting("GameLocation", "")); - var service = new GameFilesService( - paths, - new DiscExtractor(fs), - settings, - fs, - Substitute.For(), - Substitute.For(), - new WheelWizard.DiscImages.DiscImageService(fs), - Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance - ); - - var result = await service.ExtractAsync("/games/mkw.rvz", true); - - Assert.True(result.IsFailure); - Assert.False(fs.Directory.Exists("/DATA")); - Assert.False(fs.Directory.Exists("/.partial")); - } - - #endregion - - #region Unknown folder - - [Fact] - public void Unknown_MoveAside_KeepsEverythingUnderANewName() - { - var harness = new GameFilesHarness(); - harness.Fs.Directory.CreateDirectory(harness.In("DATA")); - harness.Fs.File.WriteAllText(harness.In("DATA", "keep.bin"), "keep"); - harness.GameLocation.Set(harness.WriteImage(), skipSave: true); - - var first = harness.Service.MoveUnknownAside(); - harness.Fs.Directory.CreateDirectory(harness.Game); - var second = harness.Service.MoveUnknownAside(); - - Assert.True(first.IsSuccess); - Assert.True(second.IsSuccess); - Assert.NotEqual(first.Value, second.Value); - Assert.Equal("keep", harness.Fs.File.ReadAllText(harness.Fs.Path.Combine(first.Value, "DATA", "keep.bin"))); - Assert.Equal(GameFilesState.NeedsExtraction, harness.Service.GetStatus().State); - } - - [Fact] - public void Unknown_DeleteRemovesIt() - { - var harness = new GameFilesHarness(); - harness.Fs.Directory.CreateDirectory(harness.In("DATA", "sys")); - harness.Fs.File.WriteAllText(harness.In("DATA", "sys", "main.dol"), "x"); - harness.Fs.FileInfo.New(harness.In("DATA", "sys", "main.dol")).IsReadOnly = true; - - Assert.True(harness.Service.DeleteUnknown().IsSuccess); - Assert.False(harness.Fs.Directory.Exists(harness.Game)); - } - - [Fact] - public async Task Unknown_ActionsRefuseAFolderWheelWizardOwns() - { - var harness = new GameFilesHarness(); - await harness.ExtractAsync(harness.WriteImage()); - - Assert.True(harness.Service.DeleteUnknown().IsFailure); - Assert.True(harness.Service.MoveUnknownAside().IsFailure); - Assert.True(harness.Service.GetStatus().IsReady); - } - - #endregion - - #region Interrupted work - - [Fact] - public void Recover_AnUnfinishedExtraction_IsRemoved() - { - var harness = new GameFilesHarness(); - var staging = harness.Paths.StagingPath; - harness.Fs.Directory.CreateDirectory(harness.Fs.Path.Combine(staging, "DATA", "files")); - harness.Fs.File.WriteAllText(harness.Fs.Path.Combine(staging, GameFilesPaths.WorkMarkerFileName), ""); - harness.Fs.File.WriteAllText(harness.Fs.Path.Combine(staging, "DATA", "files", "half.szs"), "half"); - - harness.Service.RecoverInterruptedWork(); - - Assert.False(harness.Fs.Directory.Exists(staging)); - } - - [Fact] - public async Task Recover_NeverDeletesAFolderWithoutItsMarker() - { - var harness = new GameFilesHarness(); - harness.Fs.Directory.CreateDirectory(harness.Paths.StagingPath); - harness.Fs.File.WriteAllText(harness.Fs.Path.Combine(harness.Paths.StagingPath, "mine.txt"), "mine"); - harness.Fs.Directory.CreateDirectory(harness.Paths.ReplacedPath); - harness.Fs.File.WriteAllText(harness.Fs.Path.Combine(harness.Paths.ReplacedPath, "mine.txt"), "mine"); - - harness.Service.RecoverInterruptedWork(); - var result = await harness.Service.ExtractAsync(harness.WriteImage(), false); - - Assert.True(result.IsFailure); - Assert.Equal("mine", harness.Fs.File.ReadAllText(harness.Fs.Path.Combine(harness.Paths.StagingPath, "mine.txt"))); - Assert.Equal("mine", harness.Fs.File.ReadAllText(harness.Fs.Path.Combine(harness.Paths.ReplacedPath, "mine.txt"))); - } - - [Fact] - public async Task Recover_ACrashRightAfterTheOldManifestLeft_FinishesTheSwap() - { - var harness = new GameFilesHarness(); - await harness.ExtractAsync(harness.WriteImage()); - var usa = harness.WriteImage("RMCE01"); - harness.Stage(usa, "RMCE01"); - // The swap moved the old manifest out, then the power went. - harness.Fs.Directory.CreateDirectory(harness.Paths.ReplacedPath); - harness.Fs.File.WriteAllText(harness.Fs.Path.Combine(harness.Paths.ReplacedPath, GameFilesPaths.WorkMarkerFileName), ""); - harness.Fs.File.Move( - harness.Paths.ManifestPath, - harness.Fs.Path.Combine(harness.Paths.ReplacedPath, GameFilesPaths.ManifestFileName) - ); - Assert.Equal(GameFilesState.UnknownFolder, harness.Service.GetStatus().State); - - harness.Service.RecoverInterruptedWork(); - - Assert.Equal("RMCE01", harness.Manifest()!.GameId); - Assert.True(harness.Service.GetStatus().IsReady); - Assert.Equal(usa, harness.GameLocation.Get()); - Assert.False(harness.Fs.Directory.Exists(harness.Paths.StagingPath)); - Assert.False(harness.Fs.Directory.Exists(harness.Paths.ReplacedPath)); - } - - [Fact] - public async Task Recover_ACrashHalfwayThroughTheMoves_FinishesTheSwap() - { - var harness = new GameFilesHarness(); - await harness.ExtractAsync(harness.WriteImage()); - harness.Stage(harness.WriteImage("RMCE01"), "RMCE01"); - var replaced = harness.Paths.ReplacedPath; - harness.Fs.Directory.CreateDirectory(replaced); - harness.Fs.File.WriteAllText(harness.Fs.Path.Combine(replaced, GameFilesPaths.WorkMarkerFileName), ""); - harness.Fs.File.Move(harness.Paths.ManifestPath, harness.Fs.Path.Combine(replaced, GameFilesPaths.ManifestFileName)); - harness.Fs.Directory.Move(harness.In("DATA"), harness.Fs.Path.Combine(replaced, "DATA")); - harness.Fs.Directory.Move(harness.Fs.Path.Combine(harness.Paths.StagingPath, "DATA"), harness.In("DATA")); - - harness.Service.RecoverInterruptedWork(); - - Assert.Equal("RMCE01", harness.Manifest()!.GameId); - Assert.True(harness.Service.GetStatus().IsReady); - Assert.True(harness.Fs.File.Exists(harness.In("disc", "header.bin"))); - Assert.Equal("RMCE01", System.Text.Encoding.ASCII.GetString(harness.Fs.File.ReadAllBytes(harness.In("disc", "header.bin"))[..6])); - Assert.False(harness.Fs.Directory.Exists(replaced)); - } - - [Fact] - public async Task Recover_ACrashBeforeCleanup_RemovesOnlyTheLeftovers() - { - var harness = new GameFilesHarness(); - await harness.ExtractAsync(harness.WriteImage()); - var replaced = harness.Paths.ReplacedPath; - harness.Fs.Directory.CreateDirectory(harness.Fs.Path.Combine(replaced, "DATA")); - harness.Fs.File.WriteAllText(harness.Fs.Path.Combine(replaced, GameFilesPaths.WorkMarkerFileName), ""); - - harness.Service.RecoverInterruptedWork(); - - Assert.False(harness.Fs.Directory.Exists(replaced)); - Assert.True(harness.Service.GetStatus().IsReady); - } - - #endregion - - #region Restoring the game code - - [Fact] - public async Task Restore_PutsBackOnlyTheGameCode() - { - var harness = new GameFilesHarness(); - var disc = new SyntheticWiiDisc(); - var image = harness.WriteImage(disc); - await harness.ExtractAsync(image); - harness.Fs.File.WriteAllText(harness.In("DATA", "sys", "main.dol"), "patched"); - harness.Fs.File.WriteAllText(harness.In("DATA", "files", "Scene", "UI", "Title.szs"), "my edit"); - Assert.Equal(GameFilesState.CodeModified, harness.Service.GetStatus().State); - - var result = await harness.Service.RestoreCodeAsync(); - - Assert.True(result.IsSuccess, result.IsFailure ? result.Error.Message : null); - Assert.True(harness.Service.GetStatus().IsReady); - Assert.Equal(disc.MainDol, harness.Fs.File.ReadAllBytes(harness.In("DATA", "sys", "main.dol"))); - Assert.Equal("my edit", harness.Fs.File.ReadAllText(harness.In("DATA", "files", "Scene", "UI", "Title.szs"))); - harness.AssertImagesUntouched(); - } - - [Fact] - public async Task Restore_FromAnotherRegion_IsRefused() - { - var harness = new GameFilesHarness(); - await harness.ExtractAsync(harness.WriteImage()); - harness.Fs.File.WriteAllText(harness.In("DATA", "sys", "main.dol"), "patched"); - - var result = await harness.Service.RestoreCodeAsync(harness.WriteImage("RMCK01")); - - Assert.True(result.IsFailure); - Assert.Equal("patched", harness.Fs.File.ReadAllText(harness.In("DATA", "sys", "main.dol"))); - } - - [Fact] - public async Task Restore_WithTheImageGone_AsksForOne() - { - var harness = new GameFilesHarness(); - var image = harness.WriteImage(); - await harness.ExtractAsync(image); - harness.Fs.File.WriteAllText(harness.In("DATA", "sys", "main.dol"), "patched"); - harness.Fs.File.Delete(image); - - Assert.True((await harness.Service.RestoreCodeAsync()).IsFailure); - - var copy = harness.WriteImage(new SyntheticWiiDisc(), "found it again.iso", "iso"); - Assert.True((await harness.Service.RestoreCodeAsync(copy)).IsSuccess); - Assert.True(harness.Service.GetStatus().IsReady); - Assert.Equal(copy, harness.GameLocation.Get()); - } - - [Fact] - public async Task Restore_WhileTheGameRuns_ChangesNothing() - { - var harness = new GameFilesHarness(); - await harness.ExtractAsync(harness.WriteImage()); - harness.Fs.File.WriteAllText(harness.In("DATA", "sys", "main.dol"), "patched"); - harness.SetRunning("Dolphin"); - - Assert.True((await harness.Service.RestoreCodeAsync()).IsFailure); - Assert.Equal("patched", harness.Fs.File.ReadAllText(harness.In("DATA", "sys", "main.dol"))); - } - - #endregion - - [Fact] - public void Region_ComesFromTheImageBeforeExtracting() - { - var harness = new GameFilesHarness(); - harness.GameLocation.Set(harness.WriteImage("RMCJ01"), skipSave: true); - - Assert.Equal(MarioKartWiiEnums.Regions.Japan, harness.Service.GetRegion()); - } - - [Fact] - public void Region_IsNoneWithoutAGame() - { - Assert.Equal(MarioKartWiiEnums.Regions.None, new GameFilesHarness().Service.GetRegion()); - } -} diff --git a/WheelWizard.Test/Features/GameFiles/SyntheticWiiDisc.cs b/WheelWizard.Test/Features/GameFiles/SyntheticWiiDisc.cs deleted file mode 100644 index 3626db884..000000000 --- a/WheelWizard.Test/Features/GameFiles/SyntheticWiiDisc.cs +++ /dev/null @@ -1,264 +0,0 @@ -using System.Buffers.Binary; -using System.Security.Cryptography; -using System.Text; -using RVZSharp; -using RVZSharp.Blobs; -using RVZSharp.Models; -using RVZSharp.Wii; - -namespace WheelWizard.Test.Features.GameFiles; - -/// -/// Builds a tiny but real Wii disc image: a partition table, an unencrypted partition header (ticket, TMD, -/// certificates, H3 table) and a game partition encrypted with the ticket's title key, holding a boot header, -/// apploader, main.dol, FST and files. Small enough to build per test, real enough that RVZSharp reads it exactly -/// like a retail disc, so extraction runs end to end without any copyrighted data. -/// -internal sealed class SyntheticWiiDisc -{ - private const int Cluster = 0x8000; - private const int Payload = 0x7C00; - private const long PartitionOffset = 0x50000; - private const long PartitionDataOffset = 0x20000; - - public string GameId { get; init; } = "RMCP01"; - public byte Revision { get; init; } - public string InternalName { get; init; } = "MARIO KART WII"; - - /// Files by their path on the disc; directories come from the paths. - public Dictionary Files { get; init; } = - new() - { - ["rel/StaticR.rel"] = Bytes(5000, 1), - ["Race/Course/beginner_course.szs"] = Encoding.ASCII.GetBytes("course data"), - ["Scene/UI/Title.szs"] = Bytes(1234, 7), - ["empty.bin"] = [], - }; - - public byte[] MainDol { get; init; } = Dol(0x200, 3); - - public static byte[] Bytes(int length, int seed) => Enumerable.Range(0, length).Select(index => (byte)(index * seed + seed)).ToArray(); - - public static byte[] Dol(int textSize, int seed) - { - var dol = new byte[0x100 + textSize]; - BinaryPrimitives.WriteUInt32BigEndian(dol.AsSpan(0x0), 0x100); - BinaryPrimitives.WriteUInt32BigEndian(dol.AsSpan(0x48), 0x80004000); - BinaryPrimitives.WriteUInt32BigEndian(dol.AsSpan(0x90), (uint)textSize); - for (var index = 0x100; index < dol.Length; index++) - dol[index] = (byte)(index * seed); - return dol; - } - - public static string Sha256(byte[] data) => Convert.ToHexString(SHA256.HashData(data)).ToLowerInvariant(); - - public byte[] Ticket { get; private set; } = []; - public byte[] Tmd { get; } = Bytes(0x208, 3); - public byte[] Certificates { get; } = Bytes(0xA00, 9); - public byte[] H3 { get; } = Bytes(0x18000, 5); - public byte[] Bi2 { get; } = Bytes(0x2000, 11); - public byte[] Apploader { get; private set; } = []; - public byte[] Fst { get; private set; } = []; - public byte[] Boot { get; private set; } = []; - - /// The image as a plain ISO. - public byte[] BuildIso() - { - var (plain, fst) = BuildPartitionData(); - Fst = fst; - var clusters = plain.Length / Payload; - var disc = new byte[PartitionOffset + PartitionDataOffset + (long)clusters * Cluster]; - - WriteHeader(disc, 0); - // Partition table: one group of one partition, the game partition (type 0). - WriteUInt32(disc, 0x40000, 1); - WriteUInt32(disc, 0x40004, 0x40020 >> 2); - WriteUInt32(disc, 0x40020, (uint)(PartitionOffset >> 2)); - WriteUInt32(disc, 0x40024, 0); - WriteUInt32(disc, 0x4E000, 2); - - var ticket = new byte[0x2A4]; - WriteUInt32(ticket, 0, 0x00010001); - for (var index = 0; index < 16; index++) - ticket[0x1BF + index] = (byte)(index * 13 + 1); - WriteUInt32(ticket, 0x1DC, 0x00010000); - Encoding.ASCII.GetBytes(GameId[..4]).CopyTo(ticket, 0x1E0); - Ticket = ticket; - ticket.CopyTo(disc, (int)PartitionOffset); - - var header = (int)PartitionOffset; - WriteUInt32(disc, header + 0x2A4, (uint)Tmd.Length); - WriteUInt32(disc, header + 0x2A8, 0x2C0 >> 2); - WriteUInt32(disc, header + 0x2AC, (uint)Certificates.Length); - WriteUInt32(disc, header + 0x2B0, 0x500 >> 2); - WriteUInt32(disc, header + 0x2B4, 0x8000 >> 2); - WriteUInt32(disc, header + 0x2B8, (uint)(PartitionDataOffset >> 2)); - WriteUInt32(disc, header + 0x2BC, (uint)((long)clusters * Cluster >> 2)); - Tmd.CopyTo(disc, header + 0x2C0); - Certificates.CopyTo(disc, header + 0x500); - H3.CopyTo(disc, header + 0x8000); - - // Clusters: an encrypted (here empty) hash block, then the data encrypted with an IV taken from it. - using var aes = Aes.Create(); - aes.Key = WiiVolume.GetTitleKey(ticket); - for (var cluster = 0; cluster < clusters; cluster++) - { - var hashes = aes.EncryptCbc(new byte[0x400], new byte[16], PaddingMode.None); - var data = aes.EncryptCbc(plain.AsSpan(cluster * Payload, Payload), hashes.AsSpan(0x3D0, 16), PaddingMode.None); - var offset = (int)(PartitionOffset + PartitionDataOffset + (long)cluster * Cluster); - hashes.CopyTo(disc, offset); - data.CopyTo(disc, offset + 0x400); - } - - return disc; - } - - /// The same disc in another container, written by RVZSharp's own encoders. - public byte[] Build(string format) - { - var iso = BuildIso(); - if (format == "iso") - return iso; - - using var reader = Blob.Open(new MemoryStream(iso), "disc.iso", leaveOpen: false); - using var output = new MemoryStream(); - switch (format) - { - case "wbfs": - WbfsWriter.Write(reader, output, WbfsWriteOptions.Default, null!, CancellationToken.None); - break; - case "gcz": - GczWriter.Write(reader, output, new GczWriteOptions(), null!, CancellationToken.None); - break; - case "ciso": - CisoWriter.Write(reader, output, new CisoWriteOptions(), null!, CancellationToken.None); - break; - case "rvz": - RvzWriter.Write(reader, output, RvzWriteOptions.Default, null!, CancellationToken.None); - break; - default: - throw new ArgumentOutOfRangeException(nameof(format), format, null); - } - - return output.ToArray(); - } - - private (byte[] Plain, byte[] Fst) BuildPartitionData() - { - const long dolOffset = 0x10000; - const long fstOffset = 0x11000; - var (fst, placed, end) = BuildFst(firstDataOffset: 0x20000); - - var size = (end + Payload - 1) / Payload * Payload; - var plain = new byte[size]; - var boot = new byte[0x440]; - WriteHeader(boot, 0); - WriteUInt32(boot, 0x420, (uint)(dolOffset >> 2)); - WriteUInt32(boot, 0x424, (uint)(fstOffset >> 2)); - WriteUInt32(boot, 0x428, (uint)((fst.Length + 3) >> 2)); - WriteUInt32(boot, 0x42C, (uint)((fst.Length + 3) >> 2)); - Boot = boot; - boot.CopyTo(plain, 0); - Bi2.CopyTo(plain, 0x440); - - var apploader = new byte[0x20 + 0x40 + 0x10]; - Encoding.ASCII.GetBytes("2008/04/01").CopyTo(apploader, 0); - WriteUInt32(apploader, 0x14, 0x40); - WriteUInt32(apploader, 0x18, 0x10); - for (var index = 0x20; index < apploader.Length; index++) - apploader[index] = (byte)index; - Apploader = apploader; - apploader.CopyTo(plain, 0x2440); - - MainDol.CopyTo(plain, (int)dolOffset); - fst.CopyTo(plain, (int)fstOffset); - foreach (var (offset, data) in placed) - data.CopyTo(plain, (int)offset); - - // The FST is stored rounded up to four bytes, and that is what a dump contains. - var storedFst = new byte[(fst.Length + 3) & ~3]; - fst.CopyTo(storedFst, 0); - return (plain, storedFst); - } - - private sealed class Node - { - public SortedDictionary Directories { get; } = new(StringComparer.Ordinal); - public SortedDictionary Files { get; } = new(StringComparer.Ordinal); - } - - private (byte[] Fst, List<(long Offset, byte[] Data)> Placed, long End) BuildFst(long firstDataOffset) - { - var root = new Node(); - foreach (var (path, data) in Files) - { - var parts = path.Split('/'); - var node = root; - foreach (var directory in parts[..^1]) - { - if (!node.Directories.TryGetValue(directory, out var child)) - node.Directories[directory] = child = new Node(); - node = child; - } - - node.Files[parts[^1]] = data; - } - - var names = new MemoryStream(); - var entries = new List<(bool Directory, uint Name, uint A, uint B)> { (true, 0, 0, 0) }; - var placed = new List<(long, byte[])>(); - var cursor = firstDataOffset; - - uint Name(string name) - { - var offset = (uint)names.Length; - names.Write(Encoding.ASCII.GetBytes(name)); - names.WriteByte(0); - return offset; - } - - void Add(Node node, int parent) - { - foreach (var (name, data) in node.Files) - { - placed.Add((cursor, data)); - entries.Add((false, Name(name), (uint)(cursor >> 2), (uint)data.Length)); - cursor = (cursor + data.Length + 0x3F) & ~0x3FL; - } - - foreach (var (name, child) in node.Directories) - { - var index = entries.Count; - entries.Add((true, Name(name), (uint)parent, 0)); - Add(child, index); - entries[index] = (true, entries[index].Name, (uint)parent, (uint)entries.Count); - } - } - - Add(root, 0); - entries[0] = (true, 0, 0, (uint)entries.Count); - - var fst = new byte[entries.Count * 12 + names.Length]; - for (var index = 0; index < entries.Count; index++) - { - var (isDirectory, name, a, b) = entries[index]; - WriteUInt32(fst, index * 12, (isDirectory ? 1u << 24 : 0) | name); - WriteUInt32(fst, index * 12 + 4, a); - WriteUInt32(fst, index * 12 + 8, b); - } - - names.ToArray().CopyTo(fst, entries.Count * 12); - return (fst, placed, cursor); - } - - private void WriteHeader(byte[] target, int offset) - { - Encoding.ASCII.GetBytes(GameId).CopyTo(target, offset); - target[offset + 7] = Revision; - WriteUInt32(target, offset + 0x18, 0x5D1C9EA3); - Encoding.ASCII.GetBytes(InternalName).CopyTo(target, offset + 0x20); - } - - private static void WriteUInt32(byte[] target, int offset, uint value) => - BinaryPrimitives.WriteUInt32BigEndian(target.AsSpan(offset), value); -} diff --git a/WheelWizard.Test/Features/Launching/DolphinLaunchServiceTests.cs b/WheelWizard.Test/Features/Launching/DolphinLaunchServiceTests.cs index 9c95e020d..d9c7dbd4b 100644 --- a/WheelWizard.Test/Features/Launching/DolphinLaunchServiceTests.cs +++ b/WheelWizard.Test/Features/Launching/DolphinLaunchServiceTests.cs @@ -3,7 +3,6 @@ using WheelWizard.CustomDistributions; using WheelWizard.Dolphin.Paths; using WheelWizard.DolphinInstaller; -using WheelWizard.GameFiles; using WheelWizard.Launching; using WheelWizard.Settings; using WheelWizard.Settings.Types; @@ -109,7 +108,8 @@ public async Task StartFailure_ReturnsErrorAndShowsReason() [InlineData(1, true)] public async Task PortalExport_OnlySkipsFilesystemPermissionAfterSuccessfulExit(int exitCode, bool expectedFallback) { - var fixture = new Fixture(command: "flatpak run org.DolphinEmu.dolphin-emu", distributionRoot: "/run/user/1000/doc/rr"); + var fixture = new Fixture(command: "flatpak run org.DolphinEmu.dolphin-emu"); + fixture.GamePath.Set("/run/user/1000/doc/game/game.iso", skipSave: true); fixture .LinuxProcesses.Run("flatpak", Arg.Any>(), out Arg.Any(), out Arg.Any()) .Returns(Ok(exitCode)); @@ -119,48 +119,17 @@ public async Task PortalExport_OnlySkipsFilesystemPermissionAfterSuccessfulExit( Assert.True((await fixture.Service.LaunchDolphin(versionPreflightResult: Ok())).IsSuccess); Assert.NotNull(captured); - Assert.Equal(expectedFallback, captured.ArgumentList[^1].Contains("--filesystem='/run/user/1000/doc/rr':rw")); + Assert.Equal(expectedFallback, captured.ArgumentList[^1].Contains("--filesystem='/run/user/1000/doc/game/game.iso':ro")); fixture .LinuxProcesses.Received(1) .Run( "flatpak", - Arg.Is>(args => args.Contains("/run/user/1000/doc/rr")), + Arg.Is>(args => args.Contains("/run/user/1000/doc/game/game.iso")), out Arg.Any(), out Arg.Any() ); } - [Fact] - public async Task FlatpakDolphin_GetsReadOnlyAccessToTheWholeExtractedGame() - { - var fixture = new Fixture(command: "flatpak run org.DolphinEmu.dolphin-emu"); - ProcessStartInfo? captured = null; - fixture.Processes.When(process => process.Start(Arg.Any())).Do(call => captured = call.Arg()); - - Assert.True((await fixture.Service.LaunchDolphin(versionPreflightResult: Ok())).IsSuccess); - - Assert.NotNull(captured); - // Dolphin boots DATA/sys/main.dol but reads disc/ and the rest of DATA/ around it. - Assert.Contains("--filesystem='/data/GAME':ro", captured.ArgumentList[^1]); - Assert.DoesNotContain("game.iso", captured.ArgumentList[^1]); - } - - [Fact] - public async Task FlatpakDolphin_AlsoGetsTheFolderALinkedGamePointsTo() - { - var fixture = new Fixture(command: "flatpak run org.DolphinEmu.dolphin-emu"); - fixture.GameFiles.IsLinked.Returns(true); - fixture.GameFiles.ResolvedRootPath.Returns("/mnt/big/GAME"); - ProcessStartInfo? captured = null; - fixture.Processes.When(process => process.Start(Arg.Any())).Do(call => captured = call.Arg()); - - Assert.True((await fixture.Service.LaunchDolphin(versionPreflightResult: Ok())).IsSuccess); - - Assert.NotNull(captured); - Assert.Contains("--filesystem='/data/GAME':ro", captured.ArgumentList[^1]); - Assert.Contains("--filesystem='/mnt/big/GAME':ro", captured.ArgumentList[^1]); - } - [Fact] public async Task FlatpakUpdate_UsesConfiguredAppIdAndStillBlocksLaunch() { @@ -185,10 +154,10 @@ private sealed class Fixture public IDolphinVersionService Versions { get; } = Substitute.For(); public ILinuxDolphinInstaller Installer { get; } = Substitute.For(); public IDolphinLaunchPresentation Presentation { get; } = Substitute.For(); - public IGameFilesPaths GameFiles { get; } = Substitute.For(); + public WhWzSetting GamePath { get; } = new("GamePath", "/game.iso"); public DolphinLaunchService Service { get; } - public Fixture(bool windows = false, string command = "dolphin-emu", string distributionRoot = "/distribution") + public Fixture(bool windows = false, string command = "dolphin-emu") { var fs = new MockFileSystem(options => options.SimulatingOperatingSystem(windows ? SimulationMode.Windows : SimulationMode.Linux) @@ -199,18 +168,19 @@ public Fixture(bool windows = false, string command = "dolphin-emu", string dist environment.GetFolderPath(Environment.SpecialFolder.UserProfile).Returns("/home/test"); environment.GetFolderPath(Environment.SpecialFolder.ApplicationData).Returns("/home/test/.config"); environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData).Returns("/home/test/.local/share"); - GameFiles.RootPath.Returns("/data/GAME"); - GameFiles.ResolvedRootPath.Returns("/data/GAME"); + var settings = Substitute.For(); + settings.GAME_LOCATION.Returns(GamePath); + settings.Get(GamePath).Returns(_ => (string)GamePath.Get()); var paths = Substitute.For(); var user = windows ? @"C:\Dolphin User" : "/dolphin-user"; paths.UserFolderPath.Returns(user); paths.ExecutablePath.Returns(windows ? @"C:\Dolphin\Dolphin.exe" : command); paths.Layout.Returns(new DolphinPathLayout(fs.Path, environment, false, command, user)); var distribution = Substitute.For(); - distribution.RootFolderPath.Returns(distributionRoot); + distribution.RootFolderPath.Returns("/distribution"); distribution.LaunchJsonFilePath.Returns("/distribution/RR.json"); Versions.CheckConfiguredDolphin().Returns((DolphinVersionStatus.Supported, "2606a")); - Service = new(paths, distribution, fs, environment, Processes, LinuxProcesses, Versions, Installer, Presentation, GameFiles); + Service = new(settings, paths, distribution, fs, environment, Processes, LinuxProcesses, Versions, Installer, Presentation); } } } diff --git a/WheelWizard.Test/Features/Launching/LaunchPathTests.cs b/WheelWizard.Test/Features/Launching/LaunchPathTests.cs index 49a1d3ae1..584ef393f 100644 --- a/WheelWizard.Test/Features/Launching/LaunchPathTests.cs +++ b/WheelWizard.Test/Features/Launching/LaunchPathTests.cs @@ -3,7 +3,6 @@ using WheelWizard.ApplicationData; using WheelWizard.CustomDistributions; using WheelWizard.Dolphin.Paths; -using WheelWizard.GameFiles; using WheelWizard.Launching; using WheelWizard.Mods; using WheelWizard.Settings; @@ -23,13 +22,12 @@ public void Descriptor_PreservesGameRootAndDistributionOptions(bool beta, string { var fs = new MockFileSystem(options => options.SimulatingOperatingSystem(SimulationMode.Linux)); fs.Directory.CreateDirectory("/output"); + var settings = CreateSettings(); var paths = Substitute.For(); paths.LaunchJsonFilePath.Returns("/output/RR.json"); paths.RootFolderPath.Returns("/distribution"); paths.XmlFilePath.Returns("/distribution/RR.xml"); - var gameFiles = Substitute.For(); - gameFiles.MainDolPath.Returns("/data/GAME/DATA/sys/main.dol"); - var descriptor = new RetroRewindLaunchDescriptor(fs, paths, gameFiles); + var descriptor = new RetroRewindLaunchDescriptor(fs, settings, paths); if (beta) descriptor.GenerateLaunchJson("/distribution/RRBeta.xml"); @@ -38,8 +36,7 @@ public void Descriptor_PreservesGameRootAndDistributionOptions(bool beta, string using var json = JsonDocument.Parse(fs.File.ReadAllText("/output/RR.json")); var root = json.RootElement; - // Dolphin boots the extracted game, never the image it came from. - Assert.Equal("/data/GAME/DATA/sys/main.dol", root.GetProperty("base-file").GetString()); + Assert.Equal("/game.iso", root.GetProperty("base-file").GetString()); Assert.Equal("dolphin-game-mod-descriptor", root.GetProperty("type").GetString()); var patch = root.GetProperty("riivolution").GetProperty("patches")[0]; Assert.Equal("/distribution", patch.GetProperty("root").GetString()); @@ -84,8 +81,7 @@ public async Task BlockedDistributionPreflight_DoesNotKillPrepareOrWrite(bool be descriptor, environment, Substitute.For(), - new InlineModPresentation(), - ReadyGame() + new InlineModPresentation() ); Assert.True((await launcher.LaunchAsync(beta)).IsFailure); @@ -95,13 +91,6 @@ public async Task BlockedDistributionPreflight_DoesNotKillPrepareOrWrite(bool be Assert.Empty(descriptor.ReceivedCalls()); } - private static IGameFilesService ReadyGame() - { - var gameFiles = Substitute.For(); - gameFiles.GetStatus().Returns(new GameFilesStatus(GameFilesState.Ready, null, "/game.iso")); - return gameFiles; - } - private static ISettingsManager CreateSettings() { var settings = Substitute.For(); diff --git a/WheelWizard.Test/Features/Launching/RetroRewindLaunchServiceTests.cs b/WheelWizard.Test/Features/Launching/RetroRewindLaunchServiceTests.cs index 0481f74bb..3d7492d40 100644 --- a/WheelWizard.Test/Features/Launching/RetroRewindLaunchServiceTests.cs +++ b/WheelWizard.Test/Features/Launching/RetroRewindLaunchServiceTests.cs @@ -1,7 +1,6 @@ using Testably.Abstractions.Testing; using WheelWizard.CustomDistributions; using WheelWizard.Dolphin.Paths; -using WheelWizard.GameFiles; using WheelWizard.Launching; using WheelWizard.Mods; using WheelWizard.Recomp; @@ -39,31 +38,6 @@ await fixture ); } - [Theory] - [InlineData(GameFilesState.CodeModified)] - [InlineData(GameFilesState.UnknownFolder)] - [InlineData(GameFilesState.ImageMissing)] - public async Task GameFilesNotReady_NeverStartsDolphin(GameFilesState state) - { - var fixture = new Fixture(); - fixture.GameFiles.GetStatus().Returns(new GameFilesStatus(state, null, "/game.iso")); - - Assert.True((await fixture.Service.LaunchAsync()).IsFailure); - - Assert.Empty(fixture.Dolphin.ReceivedCalls()); - Assert.Empty(fixture.Mods.ReceivedCalls()); - } - - [Fact] - public async Task DeletedImage_StillLaunchesOnceExtracted() - { - var fixture = new Fixture(); - fixture.Fs.File.Delete("/game.iso"); - - Assert.True((await fixture.Service.LaunchAsync()).IsSuccess); - await fixture.Dolphin.Received(1).LaunchDolphin(Arg.Any(), Arg.Any(), Arg.Any()); - } - [Fact] public async Task FailedModPreparation_DoesNotWriteDescriptorOrStartGame() { @@ -82,7 +56,7 @@ public async Task FailedModPreparation_DoesNotWriteDescriptorOrStartGame() public async Task MissingBetaGame_StopsBeforeVersionCheckOrPreparation() { var fixture = new Fixture(); - fixture.GameFiles.GetStatus().Returns(new GameFilesStatus(GameFilesState.NeedsExtraction, null, "/game.iso")); + fixture.Fs.File.Delete("/game.iso"); Assert.True((await fixture.Service.LaunchAsync(true)).IsFailure); @@ -141,13 +115,11 @@ private sealed class Fixture public IModsLaunchService Mods { get; } = Substitute.For(); public IRetroRewindLaunchDescriptor Descriptor { get; } = Substitute.For(); public IWiiRemoteConfigurationService Remotes { get; } = Substitute.For(); - public IGameFilesService GameFiles { get; } = Substitute.For(); public ILaunchPrompts Prompts { get; } = Substitute.For(); public RetroRewindLaunchService Service { get; } public Fixture() { - GameFiles.GetStatus().Returns(new GameFilesStatus(GameFilesState.Ready, null, "/game.iso")); Fs.File.WriteAllText("/game.iso", "game"); var settings = Substitute.For(); settings.GAME_LOCATION.Returns(new WhWzSetting("Game", "/game.iso")); @@ -177,8 +149,7 @@ public Fixture() Descriptor, Substitute.For(), Prompts, - new InlineModPresentation(), - GameFiles + new InlineModPresentation() ); } } diff --git a/WheelWizard.Test/Features/Recomp/RecompGameDirTests.cs b/WheelWizard.Test/Features/Recomp/RecompGameDirTests.cs deleted file mode 100644 index 61c5aabe0..000000000 --- a/WheelWizard.Test/Features/Recomp/RecompGameDirTests.cs +++ /dev/null @@ -1,279 +0,0 @@ -using System.Text.Json; -using Microsoft.Extensions.Logging.Abstractions; -using Testably.Abstractions.Testing; -using WheelWizard.GitHub; -using WheelWizard.GitHub.Domain; -using WheelWizard.Models.Enums; -using WheelWizard.Recomp; -using WheelWizard.Recomp.Domain; -using WheelWizard.Settings; -using WheelWizard.Shared; - -namespace WheelWizard.Test.Features.Recomp; - -/// -/// WiiCompiled reads Wheel Wizard's extracted game in place. These pin the contract with its setup (the -/// capability probe and the link command) and what Wheel Wizard does with an installation that still reads a game -/// copy of its own. -/// -public class RecompGameDirTests -{ - [Theory] - [InlineData("""{"type":"capabilities","capabilities":["game-dir"]}""", true)] - [InlineData("""{"type":"capabilities","capabilities":["GAME-DIR","other"]}""", true)] - [InlineData("""{"type":"capabilities","capabilities":[]}""", false)] - [InlineData("""{"type":"capabilities"}""", false)] - [InlineData("""{"type":"capabilities","capabilities":[1,null]}""", false)] - public void Capabilities_AreParsed(string line, bool gameDir) - { - var parsed = Assert.IsType(RecompSetupOutputParser.Parse(line)); - Assert.Equal(gameDir, parsed.Has(RecompCapabilitiesEvent.GameDir)); - } - - [Fact] - public void LinkCommand_NamesTheInstallationAndTheExtractedGame() - { - Assert.Equal( - ["--link-game-dir", "--install-dir", @"C:\WW\Recomp\Install", "--game-dir", @"C:\WW\GAME", "--progress-json"], - RecompSetupCommandBuilder.BuildLinkGameDirArguments(@"C:\WW\Recomp\Install", @"C:\WW\GAME") - ); - Assert.Equal( - ["link-game-dir", "--game-dir", "/data/GAME", "--progress-json"], - RecompLinuxSetupCommandBuilder.BuildLinkGameDirArguments("/data/GAME") - ); - Assert.Equal(["--capabilities"], RecompSetupCommandBuilder.BuildCapabilitiesArguments()); - } - - [Fact] - public void SilentInstall_NeverPassesAnImage() - { - var arguments = RecompSetupCommandBuilder.BuildSilentInstallArguments( - new() { GameFolderPath = @"C:\WW\GAME", InstallFolderPath = @"C:\WW\Recomp\Install" } - ); - - Assert.Contains("--game-dir", arguments); - Assert.DoesNotContain("--game", arguments); - } - - [Fact] - public async Task SupportsGameDir_AnOldSetupThatRejectsTheProbe_DoesNot() - { - var runner = Substitute.For(); - runner - .RunAsync( - "setup.exe", - Arg.Any>(), - Arg.Any(), - Arg.Any?>(), - Arg.Any() - ) - .Returns(Ok(2)); - var hosts = Hosts(runner); - - Assert.False(await hosts.SupportsGameDirAsync("setup.exe", CancellationToken.None)); - } - - [Fact] - public async Task SupportsGameDir_ASetupThatSaysSo_Does() - { - var runner = Substitute.For(); - runner - .RunAsync( - "setup.exe", - Arg.Any>(), - Arg.Any(), - Arg.Any?>(), - Arg.Any() - ) - .Returns(call => - { - call.ArgAt?>(3)?.Invoke("""{"type":"capabilities","capabilities":["game-dir"]}"""); - return Ok(0); - }); - - Assert.True(await Hosts(runner).SupportsGameDirAsync("setup.exe", CancellationToken.None)); - } - - [Fact] - public async Task Status_AnInstallationReadingItsOwnCopy_AsksForAnUpdate() - { - var fixture = new Fixture(supportsGameDir: false); - - Assert.Equal(WheelWizardStatus.OutOfDate, await fixture.Service.GetCurrentStatusAsync()); - } - - [Fact] - public async Task Link_AnOldSetup_FailsAndChangesNothing() - { - var fixture = new Fixture(supportsGameDir: false); - - var result = await fixture.Service.LinkGameDataAsync(); - - Assert.True(result.IsFailure); - Assert.DoesNotContain(fixture.Calls, call => call.Contains("--link-game-dir")); - Assert.Null(fixture.State().GameDataDir); - } - - [Fact] - public async Task Link_ASetupThatCan_PointsItAtTheExtractedGame() - { - var fixture = new Fixture(supportsGameDir: true); - - var result = await fixture.Service.LinkGameDataAsync(); - - Assert.True(result.IsSuccess, result.IsFailure ? result.Error.Message : null); - Assert.Contains( - fixture.Calls, - call => call.SequenceEqual(RecompSetupCommandBuilder.BuildLinkGameDirArguments(Fixture.Install, Fixture.Game)) - ); - Assert.Equal(Fixture.Game + "/DATA", fixture.State().GameDataDir!.Replace('\\', '/')); - } - - [Fact] - public async Task Link_AlreadyLinked_RunsNothing() - { - var fixture = new Fixture(supportsGameDir: true, linked: true); - - Assert.True((await fixture.Service.LinkGameDataAsync()).IsSuccess); - Assert.Empty(fixture.Calls); - } - - [Fact] - public async Task Link_WithoutAnExtractedGame_RunsNothing() - { - var fixture = new Fixture(supportsGameDir: true, gameReady: false); - - Assert.True((await fixture.Service.LinkGameDataAsync()).IsFailure); - Assert.DoesNotContain(fixture.Calls, call => call.Contains("--link-game-dir")); - } - - [Fact] - public async Task Link_NothingInstalled_IsNothingToDo() - { - var fixture = new Fixture(supportsGameDir: true, installed: false); - - Assert.True((await fixture.Service.LinkGameDataAsync()).IsSuccess); - Assert.Empty(fixture.Calls); - } - - [Fact] - public async Task Launch_AnInstallationReadingItsOwnCopy_IsRefused() - { - var fixture = new Fixture(supportsGameDir: false); - - Assert.True((await fixture.Service.ReconcileForLaunchAsync()).IsFailure); - Assert.DoesNotContain(fixture.Calls, call => call.Contains("--repair-products")); - } - - private static RecompSetupHostAcquirer Hosts(IRecompProcessRunner runner) - { - var github = Substitute.For(); - github - .GetReleasesAsync(Arg.Any(), Arg.Any(), Arg.Any()) - .Returns(new OperationResult>(new OperationError { Message = "offline" })); - return new RecompSetupHostAcquirer( - runner, - Substitute.For(), - github, - new MockFileSystem(), - NullLogger.Instance - ); - } - - private sealed class Fixture - { - public const string Install = "/ww/Recomp/Install"; - public const string Game = "/ww/GAME"; - private const string Setup = Install + "/WiiCompiled-Setup.exe"; - private const string StateFile = Install + "/install-state.json"; - - private readonly MockFileSystem _fs = new(options => options.SimulatingOperatingSystem(SimulationMode.Linux)); - public List> Calls { get; } = []; - public RecompInstallService Service { get; } - - public Fixture(bool supportsGameDir, bool linked = false, bool gameReady = true, bool installed = true) - { - _fs.Directory.CreateDirectory(Install); - if (installed) - { - _fs.File.WriteAllText(Setup, "setup"); - WriteState(linked ? Game + "/DATA" : null); - } - - var environment = Substitute.For(); - environment.InstallFolderPath.Returns(Install); - environment.InstalledSetupFilePath.Returns(Setup); - environment.InstallStateFilePath.Returns(StateFile); - environment.BackendStateFilePath.Returns(StateFile); - environment.GameFolderPath.Returns(Game); - environment.IsGameReady.Returns(gameReady); - environment.RetroRewindFolderPath.Returns("/ww/RetroRewind/RetroRewind6"); - environment.CacheFolderPath.Returns("/ww/Recomp/Cache"); - environment.GetExtensionConfigurationInfo.Returns(ExtensionConfigurationInfo.ExtensionFoundOrNotNeeded); - - var runner = Substitute.For(); - runner - .RunAsync( - Arg.Any(), - Arg.Any>(), - Arg.Any(), - Arg.Any?>(), - Arg.Any() - ) - .Returns(call => - { - var arguments = call.ArgAt>(1); - var output = call.ArgAt?>(3); - if (arguments[0] == "--version") - { - output?.Invoke("0.3.0"); - return Ok(0); - } - - if (arguments[0] == "--capabilities") - { - if (!supportsGameDir) - return Ok(1); - output?.Invoke("""{"type":"capabilities","capabilities":["game-dir"]}"""); - return Ok(0); - } - - Calls.Add(arguments); - if (arguments[0] == "--link-game-dir") - { - WriteState(arguments[^2] + "/DATA"); - output?.Invoke("""{"type":"result","success":true,"version":"0.3.0","installDir":"/ww/Recomp/Install"}"""); - } - - return Ok(0); - }); - - Service = new RecompInstallService( - environment, - runner, - Hosts(runner), - Substitute.For(), - _fs, - NullLogger.Instance - ); - } - - private void WriteState(string? gameDataDir) => - _fs.File.WriteAllText( - StateFile, - JsonSerializer.Serialize( - new RecompInstallState - { - SchemaVersion = 1, - SetupVersion = "0.3.0", - InstallDir = Install, - RetroRewindInstalled = true, - RetroWfcPayloadMode = "downloaded", - GameDataDir = gameDataDir, - } - ) - ); - - public RecompInstallState State() => JsonSerializer.Deserialize(_fs.File.ReadAllText(StateFile))!; - } -} diff --git a/WheelWizard.Test/Features/Recomp/RecompLinuxTests.cs b/WheelWizard.Test/Features/Recomp/RecompLinuxTests.cs index 44f780170..6d1587518 100644 --- a/WheelWizard.Test/Features/Recomp/RecompLinuxTests.cs +++ b/WheelWizard.Test/Features/Recomp/RecompLinuxTests.cs @@ -23,15 +23,15 @@ public class RecompLinuxTests public void Install_PassesTheGameAndRetroRewindTheWayTheAppImageExpects() { var arguments = RecompLinuxSetupCommandBuilder.BuildInstallArguments( - "/home/user/.config/CT-MKWII/GAME", + "/home/user/Games/Mario Kart Wii.rvz", "/home/user/.local/share/WheelWizard/RiivolutionWW/RetroRewind6" ); Assert.Equal( [ "install", - "--game-dir", - "/home/user/.config/CT-MKWII/GAME", + "--game", + "/home/user/Games/Mario Kart Wii.rvz", "--retro-dir", "/home/user/.local/share/WheelWizard/RiivolutionWW/RetroRewind6", "--download-retro-wfc-payload", @@ -47,7 +47,6 @@ public void Install_WithoutAGameReusesTheExtractedAssets() var arguments = RecompLinuxSetupCommandBuilder.BuildInstallArguments(null, "/rr/RetroRewind6", RecompRetroWfcPayloadMode.Skip); Assert.Equal(["install", "--retro-dir", "/rr/RetroRewind6", "--skip-retro-wfc-payload", "--progress-json"], arguments); - Assert.DoesNotContain("--game-dir", arguments); Assert.DoesNotContain("--game", arguments); } @@ -55,8 +54,8 @@ public void Install_WithoutAGameReusesTheExtractedAssets() public void Install_BaseOnlyPassesNoPayloadOption() { Assert.Equal( - ["install", "--game-dir", "/data/GAME", "--progress-json"], - RecompLinuxSetupCommandBuilder.BuildInstallArguments("/data/GAME", null) + ["install", "--game", "/g.iso", "--progress-json"], + RecompLinuxSetupCommandBuilder.BuildInstallArguments("/g.iso", null) ); } diff --git a/WheelWizard.Test/Features/Recomp/RecompTests.cs b/WheelWizard.Test/Features/Recomp/RecompTests.cs index 3d42fc388..f92c24363 100644 --- a/WheelWizard.Test/Features/Recomp/RecompTests.cs +++ b/WheelWizard.Test/Features/Recomp/RecompTests.cs @@ -17,7 +17,7 @@ public void SilentInstall_BuildsTheCommandLineTheSetupExecutableExpects() var arguments = RecompSetupCommandBuilder.BuildSilentInstallArguments( new() { - GameFolderPath = @"D:\WheelWizard\GAME", + GameFilePath = @"D:\Games\Mario Kart Wii.rvz", InstallFolderPath = @"D:\WheelWizard\Recomp\Install", RetroRewindFolderPath = @"D:\WheelWizard\RetroRewind6", Portable = true, @@ -27,8 +27,8 @@ public void SilentInstall_BuildsTheCommandLineTheSetupExecutableExpects() Assert.Equal( [ "--silent", - "--game-dir", - @"D:\WheelWizard\GAME", + "--game", + @"D:\Games\Mario Kart Wii.rvz", "--install-dir", @"D:\WheelWizard\Recomp\Install", "--portable", @@ -47,7 +47,7 @@ public void SilentInstall_SkipsThePayloadOnlyWhenAskedTo() var arguments = RecompSetupCommandBuilder.BuildSilentInstallArguments( new() { - GameFolderPath = @"D:\WheelWizard\GAME", + GameFilePath = @"D:\Games\Mario Kart Wii.rvz", InstallFolderPath = @"D:\WheelWizard\Recomp\Install", RetroRewindFolderPath = @"D:\WheelWizard\RetroRewind6", RetroWfcPayloadMode = RecompRetroWfcPayloadMode.Skip, diff --git a/WheelWizard.Test/Features/SaveRegionServiceTests.cs b/WheelWizard.Test/Features/SaveRegionServiceTests.cs index ae7734fc4..95be40a7c 100644 --- a/WheelWizard.Test/Features/SaveRegionServiceTests.cs +++ b/WheelWizard.Test/Features/SaveRegionServiceTests.cs @@ -1,5 +1,6 @@ using Testably.Abstractions.Testing; -using WheelWizard.GameFiles; +using WheelWizard.DiscImages; +using WheelWizard.DiscImages.Domain; using WheelWizard.Models.Enums; using WheelWizard.Settings; using WheelWizard.Settings.Types; @@ -48,7 +49,9 @@ public void GameRegion_FromGameId(string? gameId, MarioKartWiiEnums.Regions expe public void ActiveRegion_FollowsGameFile_OverLastUsedRegion() { var fixture = Create(new MockFileSystem(), MarioKartWiiEnums.Regions.America); - fixture.Game.GetRegion().Returns(MarioKartWiiEnums.Regions.Europe); + fixture + .Discs.ReadInfo("/game.rvz") + .Returns((OperationResult)new DiscImageInfo("RMCP01", "MarioKartWii", 0, DiscImageFormat.Rvz)); Assert.Equal(MarioKartWiiEnums.Regions.Europe, fixture.Service.ResolveActiveRegion("/saves")); } @@ -57,7 +60,9 @@ public void ActiveRegion_FollowsGameFile_OverLastUsedRegion() public void ActiveRegion_IgnoresOtherGames() { var fixture = Create(new MockFileSystem(), MarioKartWiiEnums.Regions.Japan); - fixture.Game.GetRegion().Returns(MarioKartWiiEnums.Regions.None); + fixture + .Discs.ReadInfo("/game.rvz") + .Returns((OperationResult)new DiscImageInfo("RWSJ01", "MARIO & SONIC", 0, DiscImageFormat.Rvz)); Assert.Equal(MarioKartWiiEnums.Regions.Japan, fixture.Service.ResolveActiveRegion("/saves")); } @@ -69,12 +74,12 @@ public void ActiveRegion_WithoutReadableGame_FallsBackToFirstSave() fs.Directory.CreateDirectory("/saves/RMCK"); fs.File.WriteAllText("/saves/RMCK/rksys.dat", "save"); var fixture = Create(fs); - fixture.Game.GetRegion().Returns(MarioKartWiiEnums.Regions.None); + fixture.Discs.ReadInfo(Arg.Any()).Returns((OperationResult)Fail("Game file not found")); Assert.Equal(MarioKartWiiEnums.Regions.Korea, fixture.Service.ResolveActiveRegion("/saves")); } - private static (SaveRegionService Service, IGameFilesService Game) Create( + private static (SaveRegionService Service, IDiscImageService Discs) Create( MockFileSystem fs, MarioKartWiiEnums.Regions lastRegion = MarioKartWiiEnums.Regions.None ) @@ -86,7 +91,7 @@ private static (SaveRegionService Service, IGameFilesService Game) Create( settings.RR_REGION.Returns(region); settings.Get(gamePath).Returns(_ => (string)gamePath.Get()); settings.Get(region).Returns(_ => (MarioKartWiiEnums.Regions)region.Get()); - var game = Substitute.For(); - return (new SaveRegionService(fs, settings, game), game); + var discs = Substitute.For(); + return (new SaveRegionService(fs, settings, discs), discs); } } diff --git a/WheelWizard.Test/Views/Shell/ViewModels/HomeDolphinLaunchTests.cs b/WheelWizard.Test/Views/Shell/ViewModels/HomeDolphinLaunchTests.cs index de2df95ef..fb226e579 100644 --- a/WheelWizard.Test/Views/Shell/ViewModels/HomeDolphinLaunchTests.cs +++ b/WheelWizard.Test/Views/Shell/ViewModels/HomeDolphinLaunchTests.cs @@ -119,6 +119,7 @@ private sealed class Fixture public IDolphinVersionService Versions { get; } = Substitute.For(); public ILinuxDolphinInstaller Installer { get; } = Substitute.For(); public IDolphinLaunchPresentation Presentation { get; } = Substitute.For(); + public WhWzSetting GamePath { get; } = new("GamePath", "/game.iso"); public DolphinLaunchService Service { get; } public Fixture(bool windows = false, string command = "dolphin-emu") @@ -132,9 +133,9 @@ public Fixture(bool windows = false, string command = "dolphin-emu") environment.GetFolderPath(Environment.SpecialFolder.UserProfile).Returns("/home/test"); environment.GetFolderPath(Environment.SpecialFolder.ApplicationData).Returns("/home/test/.config"); environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData).Returns("/home/test/.local/share"); - var gameFiles = Substitute.For(); - gameFiles.RootPath.Returns("/data/GAME"); - gameFiles.ResolvedRootPath.Returns("/data/GAME"); + var settings = Substitute.For(); + settings.GAME_LOCATION.Returns(GamePath); + settings.Get(GamePath).Returns(_ => (string)GamePath.Get()); var paths = Substitute.For(); var user = windows ? @"C:\Dolphin User" : "/dolphin-user"; paths.UserFolderPath.Returns(user); @@ -144,7 +145,7 @@ public Fixture(bool windows = false, string command = "dolphin-emu") distribution.RootFolderPath.Returns("/distribution"); distribution.LaunchJsonFilePath.Returns("/distribution/RR.json"); Versions.CheckConfiguredDolphin().Returns((DolphinVersionStatus.Supported, "2606a")); - Service = new(paths, distribution, fs, environment, Processes, LinuxProcesses, Versions, Installer, Presentation, gameFiles); + Service = new(settings, paths, distribution, fs, environment, Processes, LinuxProcesses, Versions, Installer, Presentation); } } } diff --git a/WheelWizard.UI.Test/GameSetupShellTests.cs b/WheelWizard.UI.Test/GameSetupShellTests.cs deleted file mode 100644 index eef5d38d0..000000000 --- a/WheelWizard.UI.Test/GameSetupShellTests.cs +++ /dev/null @@ -1,138 +0,0 @@ -using System.IO.Abstractions; -using Avalonia; -using Avalonia.Controls; -using Avalonia.Headless.XUnit; -using Avalonia.Threading; -using Microsoft.Extensions.DependencyInjection; -using NSubstitute; -using Testably.Abstractions.Testing; -using WheelWizard.ApplicationData; -using WheelWizard.GameFiles.Views; -using WheelWizard.MiiImages.Views; -using WheelWizard.Mods; -using WheelWizard.Settings; -using WheelWizard.Settings.Views; -using WheelWizard.Shared; -using WheelWizard.Shared.Services; -using WheelWizard.Views.Shell; -using WheelWizard.Views.Shell.Navigation; -using WheelWizard.Views.Shell.Views; - -namespace WheelWizard.UI.Test; - -public class GameSetupShellTests -{ - [AvaloniaFact] - public async Task AnUpgradingUser_IsHeldOnTheGameSetupPage_WithOnlySettingsReachable() - { - var fileSystem = new MockFileSystem(); - var directory = Path.Combine(Path.GetTempPath(), "wheelwizard-ui-game-setup"); - fileSystem.Directory.CreateDirectory(directory); - var image = Path.Combine(directory, "Mario Kart Wii.rvz"); - fileSystem.File.WriteAllText(image, "an image from before the game was extracted"); - var location = Substitute.For(); - location.DirectoryPath.Returns(directory); - var registrations = new ServiceCollection(); - registrations.AddWheelWizardServices(location); - registrations.AddSingleton(fileSystem); - registrations.AddTransient(typeof(IApiCaller<>), typeof(ApplicationCompositionTests.OfflineApiCaller<>)); - var mods = Substitute.For(); - mods.ReloadAsync().Returns(Task.FromResult(OperationResult.Ok())); - registrations.AddSingleton(mods); - using var services = registrations.BuildServiceProvider( - new ServiceProviderOptions { ValidateOnBuild = true, ValidateScopes = true } - ); - services.GetRequiredService().Initialize(); - var settings = services.GetRequiredService(); - settings.NAND_ROOT_PATH.Set(directory, skipSave: true); - settings.LOAD_PATH.Set(directory, skipSave: true); - Assert.True(settings.GAME_LOCATION.Set(image, skipSave: true)); - settings.ONBOARDING_COMPLETED.Set(true, skipSave: true); - services.GetRequiredService().Install(Application.Current!.Resources); - services.GetRequiredService().Install(Application.Current.Resources); - - var window = services.GetRequiredService(); - var navigation = services.GetRequiredService(); - try - { - window.Show(); - window.UpdateLayout(); - await Dispatcher.UIThread.InvokeAsync(() => { }, DispatcherPriority.Background); - - Assert.IsType(navigation.CurrentPage); - Assert.False(window.FindControl("SidebarGrid")!.IsEnabled); - - // The sidebar and every other way to Home stay closed. - navigation.NavigateTo(); - Assert.IsType(navigation.CurrentPage); - navigation.IsLocked = false; - navigation.NavigateAway(typeof(HomePage)); - Assert.IsType(navigation.CurrentPage); - - // Settings is the way out to free up space or move the data folder (what "Open Settings" does), and - // leaving it for anything else comes straight back. - navigation.IsLocked = false; - navigation.NavigateAway(typeof(SettingsPage)); - Assert.IsType(navigation.CurrentPage); - navigation.NavigateTo(); - Assert.IsType(navigation.CurrentPage); - - Assert.Equal("an image from before the game was extracted", fileSystem.File.ReadAllText(image)); - } - finally - { - navigation.IsLocked = false; - window.Close(); - } - } - - [AvaloniaFact] - public async Task AnUpgradingUser_WhoseGameDriveIsUnplugged_KeepsTheRestOfTheApp() - { - var fileSystem = new MockFileSystem(); - var directory = Path.Combine(Path.GetTempPath(), "wheelwizard-ui-game-unplugged"); - fileSystem.Directory.CreateDirectory(directory); - var image = Path.Combine(directory, "Mario Kart Wii.wbfs"); - fileSystem.File.WriteAllText(image, "an image on a drive that is not connected today"); - var location = Substitute.For(); - location.DirectoryPath.Returns(directory); - var registrations = new ServiceCollection(); - registrations.AddWheelWizardServices(location); - registrations.AddSingleton(fileSystem); - registrations.AddTransient(typeof(IApiCaller<>), typeof(ApplicationCompositionTests.OfflineApiCaller<>)); - var mods = Substitute.For(); - mods.ReloadAsync().Returns(Task.FromResult(OperationResult.Ok())); - registrations.AddSingleton(mods); - using var services = registrations.BuildServiceProvider( - new ServiceProviderOptions { ValidateOnBuild = true, ValidateScopes = true } - ); - services.GetRequiredService().Initialize(); - var settings = services.GetRequiredService(); - settings.NAND_ROOT_PATH.Set(directory, skipSave: true); - settings.LOAD_PATH.Set(directory, skipSave: true); - Assert.True(settings.GAME_LOCATION.Set(image, skipSave: true)); - settings.ONBOARDING_COMPLETED.Set(true, skipSave: true); - fileSystem.File.Delete(image); - services.GetRequiredService().Install(Application.Current!.Resources); - services.GetRequiredService().Install(Application.Current.Resources); - - var window = services.GetRequiredService(); - var navigation = services.GetRequiredService(); - try - { - window.Show(); - window.UpdateLayout(); - await Dispatcher.UIThread.InvokeAsync(() => { }, DispatcherPriority.Background); - - // Only playing needs the game; as before the extracted game existed, Home says so and the rest stays open. - Assert.IsType(navigation.CurrentPage); - Assert.True(window.FindControl("SidebarGrid")!.IsEnabled); - Assert.Equal(image, settings.GAME_LOCATION.Get()); - } - finally - { - navigation.IsLocked = false; - window.Close(); - } - } -} diff --git a/WheelWizard/Features/ApplicationLifecycle/StartupMaintenance.cs b/WheelWizard/Features/ApplicationLifecycle/StartupMaintenance.cs index 888d14d07..9554f165b 100644 --- a/WheelWizard/Features/ApplicationLifecycle/StartupMaintenance.cs +++ b/WheelWizard/Features/ApplicationLifecycle/StartupMaintenance.cs @@ -1,7 +1,6 @@ using Microsoft.Extensions.Logging; using WheelWizard.CustomDistributions; using WheelWizard.DolphinInstaller; -using WheelWizard.GameFiles; using WheelWizard.Onboarding; using WheelWizard.Recomp; @@ -18,7 +17,6 @@ public sealed class StartupMaintenance( IManagedDolphinService managedDolphin, IRetroRewindMigrationService migration, IOnboardingService onboarding, - IGameFilesService gameFiles, IServiceProvider services, ILogger logger ) : IStartupMaintenance @@ -38,9 +36,6 @@ public async Task RunAsync(Action showStatus, CancellationToken cancell if (result.IsSuccess && result.Value == RetroRewindMigrationOutcome.Moved) ApplyRecompPaths(); - // An extraction interrupted by a crash or power cut is finished or cleaned up before anything looks at it. - await Task.Run(gameFiles.RecoverInterruptedWork, cancellationToken); - onboarding.MarkCompletedIfAlreadySetUp(); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) diff --git a/WheelWizard/Features/GameFiles/DiscExtractor.cs b/WheelWizard/Features/GameFiles/DiscExtractor.cs deleted file mode 100644 index f0b1b1e57..000000000 --- a/WheelWizard/Features/GameFiles/DiscExtractor.cs +++ /dev/null @@ -1,438 +0,0 @@ -using System.Buffers.Binary; -using System.IO.Abstractions; -using System.Security.Cryptography; -using RVZSharp.Blobs; -using RVZSharp.Files; -using RVZSharp.Interfaces; -using RVZSharp.Models; -using RVZSharp.Wii; - -namespace WheelWizard.GameFiles; - -/// What a disc image holds, read from its header and file system table without extracting anything. -public sealed record DiscContents(string GameId, byte Revision, string InternalName, long RequiredBytes); - -/// The two files whose hashes a finished extraction is pinned to. -public sealed record ExtractedCode(string MainDolSha256, string StaticRelSha256); - -public interface IDiscExtractor -{ - /// Bumped whenever the extracted layout changes. - int Version { get; } - - OperationResult Inspect(string imagePath); - - /// - /// Writes the game partition of a Wii disc image into an empty , in the layout - /// Dolphin's "Extract Entire Disc" uses: disc/ plus DATA/{sys,files,ticket.bin,tmd.bin,cert.bin,h3.bin}. - /// Only reads the image. - /// - OperationResult Extract( - string imagePath, - string destinationRoot, - IProgress? progress = null, - CancellationToken cancellationToken = default - ); - - /// Writes only main.dol and StaticR.rel again, replacing whatever is in . - OperationResult RestoreCode(string imagePath, string dataPath, CancellationToken cancellationToken = default); -} - -public sealed class DiscExtractor(IFileSystem fileSystem) : IDiscExtractor -{ - private const int TicketSize = 0x2A4; - private const int H3Size = 0x18000; - private const long RegionOffset = 0x4E000; - private const int RegionSize = 0x20; - private const int DiscHeaderSize = 0x100; - private const int BootSize = 0x440; - private const int Bi2Size = 0x2000; - private const long ApploaderOffset = 0x2440; - private const uint GamePartitionType = 0; - private const string StaticRelDiscPath = "rel/StaticR.rel"; - - // Room for file system overhead (clusters, directory entries) on top of the bytes themselves. - private const long PerFileOverhead = 4096; - - public int Version => 1; - - public OperationResult Inspect(string imagePath) - { - try - { - using var disc = OpenDisc(imagePath); - var info = DiscInfo.TryRead(disc.Reader); - if (info is null) - return Fail("This file is not a Wii disc image."); - - using var files = DiscFileSystem.Open(disc.Reader, disc.Game); - var fileBytes = 0L; - var count = 0; - foreach (var file in EnumerateFiles(files.Root)) - { - fileBytes += file.Size; - count++; - } - - // The system files are a few MB at most; counting them generously keeps the estimate on the safe side. - var required = fileBytes + (count + 16) * PerFileOverhead + 16L * 1024 * 1024; - return new DiscContents(info.GameId, info.Revision, info.InternalName, required); - } - catch (Exception exception) - { - return new OperationError { Message = $"Could not read the game file: {exception.Message}", Exception = exception }; - } - } - - public OperationResult Extract( - string imagePath, - string destinationRoot, - IProgress? progress = null, - CancellationToken cancellationToken = default - ) - { - try - { - using var disc = OpenDisc(imagePath); - var data = fileSystem.Path.Combine(destinationRoot, "DATA"); - - WriteNew(fileSystem.Path.Combine(destinationRoot, "disc", "header.bin"), ReadExact(disc.Reader, 0, DiscHeaderSize)); - WriteNew(fileSystem.Path.Combine(destinationRoot, "disc", "region.bin"), ReadExact(disc.Reader, RegionOffset, RegionSize)); - WritePartitionHeader(disc, data); - - string mainDolSha; - using (var partition = new PartitionReader(disc.Reader, disc.Game)) - mainDolSha = WriteSystemFiles(partition, fileSystem.Path.Combine(data, "sys")); - - using var files = DiscFileSystem.Open(disc.Reader, disc.Game); - var all = EnumerateFiles(files.Root).ToList(); - var total = Math.Max(1, all.Sum(file => file.Size)); - var written = 0L; - var relativePaths = new HashSet(StringComparer.OrdinalIgnoreCase); - string? staticRelSha = null; - - CreateDirectories(files.Root, fileSystem.Path.Combine(data, "files")); - foreach (var file in all) - { - cancellationToken.ThrowIfCancellationRequested(); - var target = ResolveTarget(data, file, relativePaths); - var sha = CopyFile(files, file, target, hash: IsStaticRel(file), cancellationToken); - if (sha is not null) - staticRelSha = sha; - written += file.Size; - progress?.Report((double)written / total); - } - - if (staticRelSha is null) - return Fail("This disc has no StaticR.rel, so it is not Mario Kart Wii."); - - progress?.Report(1); - return new ExtractedCode(mainDolSha, staticRelSha); - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception exception) - { - return new OperationError { Message = $"Extracting the game failed: {exception.Message}", Exception = exception }; - } - } - - public OperationResult RestoreCode(string imagePath, string dataPath, CancellationToken cancellationToken = default) - { - try - { - using var disc = OpenDisc(imagePath); - byte[] dol; - using (var partition = new PartitionReader(disc.Reader, disc.Game)) - dol = ReadMainDol(partition); - - using var files = DiscFileSystem.Open(disc.Reader, disc.Game); - var rel = files.Find(StaticRelDiscPath); - if (rel is null || rel.IsDirectory) - return Fail("This disc has no StaticR.rel, so it is not Mario Kart Wii."); - - var dolPath = fileSystem.Path.Combine(dataPath, "sys", "main.dol"); - var relPath = fileSystem.Path.Combine(dataPath, "files", "rel", "StaticR.rel"); - ReplaceFile(dolPath, temp => WriteNew(temp, dol)); - string? relSha = null; - ReplaceFile(relPath, temp => relSha = CopyFile(files, rel, temp, hash: true, cancellationToken)); - return new ExtractedCode(Sha256(dol), relSha!); - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception exception) - { - return new OperationError { Message = $"Restoring the game code failed: {exception.Message}", Exception = exception }; - } - } - - #region Disc - - private sealed class OpenedDisc(IBlobReader reader, Partition game) : IDisposable - { - public IBlobReader Reader { get; } = reader; - public Partition Game { get; } = game; - - public void Dispose() => Reader.Dispose(); - } - - private OpenedDisc OpenDisc(string imagePath) - { - // Read-only and shared: the image stays usable by anything else that has it open, and is never written. - var stream = fileSystem.File.Open(imagePath, FileMode.Open, FileAccess.Read, FileShare.Read); - IBlobReader reader; - try - { - reader = Blob.Open(stream, imagePath, leaveOpen: false); - } - catch - { - // Blob.Open only takes ownership of the stream once it succeeds. - stream.Dispose(); - throw; - } - - try - { - if (!WiiVolume.IsWiiDisc(reader)) - throw new InvalidDataException("This file is not a Wii disc image."); - var game = WiiVolume.GetPartitions(reader).Where(partition => partition.Type == GamePartitionType).ToList(); - if (game.Count == 0) - throw new InvalidDataException("This disc has no game partition."); - return new(reader, game[0]); - } - catch - { - reader.Dispose(); - throw; - } - } - - private void WritePartitionHeader(OpenedDisc disc, string data) - { - // The partition header is not encrypted: the ticket, then the sizes and (shifted) offsets of the rest. - var offset = (long)disc.Game.Offset; - var header = ReadExact(disc.Reader, offset, 0x2C0); - var tmdSize = (int)ReadUInt32(header, 0x2A4); - var tmdOffset = (long)ReadUInt32(header, 0x2A8) << 2; - var certSize = (int)ReadUInt32(header, 0x2AC); - var certOffset = (long)ReadUInt32(header, 0x2B0) << 2; - var h3Offset = (long)ReadUInt32(header, 0x2B4) << 2; - - WriteNew(fileSystem.Path.Combine(data, "ticket.bin"), header[..TicketSize]); - WriteNew(fileSystem.Path.Combine(data, "tmd.bin"), ReadExact(disc.Reader, offset + tmdOffset, tmdSize)); - WriteNew(fileSystem.Path.Combine(data, "cert.bin"), ReadExact(disc.Reader, offset + certOffset, certSize)); - WriteNew(fileSystem.Path.Combine(data, "h3.bin"), ReadExact(disc.Reader, offset + h3Offset, H3Size)); - } - - private string WriteSystemFiles(PartitionReader partition, string sys) - { - var boot = ReadExact(partition, 0, BootSize); - WriteNew(fileSystem.Path.Combine(sys, "boot.bin"), boot); - WriteNew(fileSystem.Path.Combine(sys, "bi2.bin"), ReadExact(partition, BootSize, Bi2Size)); - - var apploaderSize = WiiVolume.GetApploaderSize(partition) ?? throw new InvalidDataException("The disc has no apploader."); - WriteNew(fileSystem.Path.Combine(sys, "apploader.img"), ReadExact(partition, ApploaderOffset, checked((int)apploaderSize))); - - var dol = ReadMainDol(partition); - WriteNew(fileSystem.Path.Combine(sys, "main.dol"), dol); - - // Wii offsets in the boot header are stored shifted right by two. - var fstOffset = (long)ReadUInt32(boot, 0x424) << 2; - var fstSize = checked((int)((long)ReadUInt32(boot, 0x428) << 2)); - WriteNew(fileSystem.Path.Combine(sys, "fst.bin"), ReadExact(partition, fstOffset, fstSize)); - return Sha256(dol); - } - - private static byte[] ReadMainDol(PartitionReader partition) - { - var offset = WiiVolume.GetBootDolOffset(partition) ?? throw new InvalidDataException("The disc has no main.dol."); - var size = WiiVolume.GetBootDolSize(partition, offset) ?? throw new InvalidDataException("The disc's main.dol is unreadable."); - return ReadExact(partition, checked((long)offset), checked((int)size)); - } - - private static byte[] ReadExact(IBlobReader reader, long offset, int length) - { - var buffer = new byte[length]; - var read = 0; - while (read < length) - { - var count = reader.ReadAt(offset + read, buffer.AsSpan(read)); - if (count <= 0) - throw new InvalidDataException("The game file ended early; it may be incomplete or damaged."); - read += count; - } - - return buffer; - } - - private static uint ReadUInt32(byte[] data, int offset) => BinaryPrimitives.ReadUInt32BigEndian(data.AsSpan(offset)); - - #endregion - - #region Files - - private static IEnumerable EnumerateFiles(DiscFileInfo directory) - { - foreach (var child in directory.Children) - { - if (!child.IsDirectory) - { - yield return child; - continue; - } - - foreach (var file in EnumerateFiles(child)) - yield return file; - } - } - - private static bool IsStaticRel(DiscFileInfo file) => - string.Equals(file.Path.TrimStart('/'), StaticRelDiscPath, StringComparison.OrdinalIgnoreCase); - - private void CreateDirectories(DiscFileInfo directory, string path) - { - fileSystem.Directory.CreateDirectory(path); - foreach (var child in directory.Children.Where(child => child.IsDirectory)) - CreateDirectories(child, fileSystem.Path.Combine(path, SafeName(child.Name))); - } - - /// - /// Builds the output path from the disc's own names, which come from the image and are not trusted: a name that - /// would climb out of the folder, or two names only a case-insensitive drive would merge, stop the extraction. - /// - private string ResolveTarget(string data, DiscFileInfo file, HashSet seen) - { - var segments = new Stack(); - for (var entry = file; entry is { IsRoot: false }; entry = entry.Parent) - segments.Push(SafeName(entry.Name)); - - var relative = string.Join('/', segments); - if (!seen.Add(relative)) - throw new InvalidDataException($"The disc has two files named '{relative}' that only differ in capitalization."); - return fileSystem.Path.Combine([data, "files", .. segments]); - } - - private static readonly char[] UnsafeNameCharacters = ['<', '>', ':', '"', '/', '\\', '|', '?', '*']; - - private static readonly HashSet ReservedWindowsNames = new(StringComparer.OrdinalIgnoreCase) - { - "CON", - "PRN", - "AUX", - "NUL", - "COM1", - "COM2", - "COM3", - "COM4", - "COM5", - "COM6", - "COM7", - "COM8", - "COM9", - "LPT1", - "LPT2", - "LPT3", - "LPT4", - "LPT5", - "LPT6", - "LPT7", - "LPT8", - "LPT9", - }; - - /// The same rules on every platform, so an extraction that works on Linux also works on Windows. - public static string SafeName(string name) - { - if ( - string.IsNullOrEmpty(name) - || name is "." or ".." - || name.IndexOfAny(UnsafeNameCharacters) >= 0 - || name.Any(char.IsControl) - || name.EndsWith('.') - || name.EndsWith(' ') - || ReservedWindowsNames.Contains(name.Split('.')[0]) - ) - throw new InvalidDataException($"The disc contains a file name that can't be extracted safely: '{name}'."); - return name; - } - - /// The SHA-256 of the copied bytes when is set. - private string? CopyFile(DiscFileSystem files, DiscFileInfo file, string target, bool hash, CancellationToken cancellationToken) - { - using var output = fileSystem.FileStream.New(target, FileMode.CreateNew, FileAccess.Write, FileShare.None); - using var hasher = hash ? IncrementalHash.CreateHash(HashAlgorithmName.SHA256) : null; - Stream destination = hasher is null ? output : new HashingStream(output, hasher); - var copied = files.CopyFileTo(file, destination, cancellationToken); - output.Flush(flushToDisk: true); - if (copied != file.Size || output.Length != file.Size) - throw new InvalidDataException($"'{file.Path}' was not written completely."); - return hasher is null ? null : Convert.ToHexString(hasher.GetHashAndReset()).ToLowerInvariant(); - } - - private void WriteNew(string path, byte[] contents) - { - fileSystem.Directory.CreateDirectory(fileSystem.Path.GetDirectoryName(path)!); - using var output = fileSystem.FileStream.New(path, FileMode.CreateNew, FileAccess.Write, FileShare.None); - output.Write(contents); - output.Flush(flushToDisk: true); - if (output.Length != contents.Length) - throw new InvalidDataException($"'{fileSystem.Path.GetFileName(path)}' was not written completely."); - } - - /// Writes next to first, so a failed write leaves the current file as it was. - private void ReplaceFile(string path, Action write) - { - var temp = path + ".restore"; - if (fileSystem.File.Exists(temp)) - fileSystem.File.Delete(temp); - try - { - write(temp); - fileSystem.File.Move(temp, path, overwrite: true); - } - finally - { - if (fileSystem.File.Exists(temp)) - fileSystem.File.Delete(temp); - } - } - - public static string Sha256(byte[] data) => Convert.ToHexString(SHA256.HashData(data)).ToLowerInvariant(); - - private sealed class HashingStream(Stream inner, IncrementalHash hash) : Stream - { - public override bool CanRead => false; - public override bool CanSeek => false; - public override bool CanWrite => true; - public override long Length => inner.Length; - - public override long Position - { - get => inner.Position; - set => throw new NotSupportedException(); - } - - public override void Write(byte[] buffer, int offset, int count) => Write(buffer.AsSpan(offset, count)); - - public override void Write(ReadOnlySpan buffer) - { - hash.AppendData(buffer); - inner.Write(buffer); - } - - public override void Flush() => inner.Flush(); - - public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException(); - - public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); - - public override void SetLength(long value) => throw new NotSupportedException(); - } - - #endregion -} diff --git a/WheelWizard/Features/GameFiles/Domain/GameFilesManifest.cs b/WheelWizard/Features/GameFiles/Domain/GameFilesManifest.cs deleted file mode 100644 index fc11a4098..000000000 --- a/WheelWizard/Features/GameFiles/Domain/GameFilesManifest.cs +++ /dev/null @@ -1,28 +0,0 @@ -namespace WheelWizard.GameFiles.Domain; - -/// -/// Describes a finished extraction. Only main.dol and StaticR.rel are pinned by hash: every other file is -/// the user's to edit, and Wheel Wizard never checks it. -/// -public sealed record GameFilesManifest -{ - public const int CurrentSchemaVersion = 1; - - public int SchemaVersion { get; init; } = CurrentSchemaVersion; - public required string GameId { get; init; } - public byte Revision { get; init; } - public string InternalName { get; init; } = ""; - - /// The image this was extracted from. Informational: the extracted game never needs it again. - public string SourcePath { get; init; } = ""; - - public long SourceLength { get; init; } - public DateTime SourceLastWriteUtc { get; init; } - public required string MainDolSha256 { get; init; } - public required string StaticRelSha256 { get; init; } - public int ExtractorVersion { get; init; } - public DateTime ExtractedUtc { get; init; } - - public bool IsSameGame(string gameId, byte revision) => - string.Equals(GameId, gameId, StringComparison.OrdinalIgnoreCase) && Revision == revision; -} diff --git a/WheelWizard/Features/GameFiles/GameFilesExtensions.cs b/WheelWizard/Features/GameFiles/GameFilesExtensions.cs deleted file mode 100644 index fb4c98a72..000000000 --- a/WheelWizard/Features/GameFiles/GameFilesExtensions.cs +++ /dev/null @@ -1,15 +0,0 @@ -using WheelWizard.GameFiles.Views; - -namespace WheelWizard.GameFiles; - -public static class GameFilesExtensions -{ - public static IServiceCollection AddGameFiles(this IServiceCollection services) - { - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - return services; - } -} diff --git a/WheelWizard/Features/GameFiles/GameFilesPaths.cs b/WheelWizard/Features/GameFiles/GameFilesPaths.cs deleted file mode 100644 index 5c1675c47..000000000 --- a/WheelWizard/Features/GameFiles/GameFilesPaths.cs +++ /dev/null @@ -1,76 +0,0 @@ -using System.IO.Abstractions; -using WheelWizard.ApplicationData; - -namespace WheelWizard.GameFiles; - -/// -/// Where the extracted game lives. The layout is the one Dolphin writes with "Extract Entire Disc" and boots from -/// DATA/sys/main.dol; WiiCompiled reads the same DATA folder. -/// -public interface IGameFilesPaths -{ - /// The extracted game: disc/ and DATA/. May be a link the user made to another drive. - string RootPath { get; } - - string DataPath { get; } - string MainDolPath { get; } - string StaticRelPath { get; } - string BootBinPath { get; } - - /// Written last, so an extraction without it never finished. - string ManifestPath { get; } - - /// - /// The folder really is: the link's target when the user linked it elsewhere, which is where - /// files are written and replaced so the link itself is never touched. - /// - string ResolvedRootPath { get; } - - /// Whether is a symbolic link or junction. - bool IsLinked { get; } - - /// Where a new extraction is written before it takes the place of the current one. Same drive as the target. - string StagingPath { get; } - - /// Where a replaced extraction waits to be deleted, so a crash mid-swap never loses both. - string ReplacedPath { get; } -} - -public sealed class GameFilesPaths(IApplicationDataLocation applicationData, IFileSystem fileSystem) : IGameFilesPaths -{ - public const string FolderName = "GAME"; - public const string ManifestFileName = ".wheelwizard-game.json"; - - /// Marks a folder as Wheel Wizard's own unfinished or replaced work, which it may always delete. - public const string WorkMarkerFileName = ".wheelwizard-work"; - - public string RootPath => fileSystem.Path.Combine(applicationData.DirectoryPath, FolderName); - public string DataPath => fileSystem.Path.Combine(RootPath, "DATA"); - public string MainDolPath => fileSystem.Path.Combine(DataPath, "sys", "main.dol"); - public string StaticRelPath => fileSystem.Path.Combine(DataPath, "files", "rel", "StaticR.rel"); - public string BootBinPath => fileSystem.Path.Combine(DataPath, "sys", "boot.bin"); - public string ManifestPath => fileSystem.Path.Combine(RootPath, ManifestFileName); - - public bool IsLinked => LinkTarget() is not null; - - public string ResolvedRootPath => LinkTarget() ?? RootPath; - - public string StagingPath => ResolvedRootPath + ".partial"; - public string ReplacedPath => ResolvedRootPath + ".replaced"; - - private string? LinkTarget() - { - try - { - var directory = fileSystem.DirectoryInfo.New(RootPath); - if (directory.LinkTarget is null) - return null; - var target = directory.ResolveLinkTarget(returnFinalTarget: true); - return target is null ? null : fileSystem.Path.GetFullPath(target.FullName); - } - catch - { - return null; - } - } -} diff --git a/WheelWizard/Features/GameFiles/GameFilesService.cs b/WheelWizard/Features/GameFiles/GameFilesService.cs deleted file mode 100644 index 8d305e195..000000000 --- a/WheelWizard/Features/GameFiles/GameFilesService.cs +++ /dev/null @@ -1,858 +0,0 @@ -using System.IO.Abstractions; -using System.Security.Cryptography; -using System.Text.Json; -using Microsoft.Extensions.Logging; -using WheelWizard.DiscImages; -using WheelWizard.Dolphin.Paths; -using WheelWizard.GameFiles.Domain; -using WheelWizard.Models.Enums; -using WheelWizard.Settings; -using WheelWizard.Shared.IO; -using WheelWizard.Shared.Processes; -using WheelWizard.WiiManagement.GameLicense; - -namespace WheelWizard.GameFiles; - -public enum GameFilesState -{ - /// Extracted, and main.dol and StaticR.rel are what was extracted. - Ready, - - /// No game has ever been picked (a first run, which the onboarding handles). - NoImage, - - /// An image is picked and reachable, but nothing is extracted yet. - NeedsExtraction, - - /// An image is picked but cannot be read right now (moved, deleted, a drive that is not plugged in). - ImageMissing, - - /// Something is where the extracted game goes that Wheel Wizard did not finish writing. - UnknownFolder, - - /// Extracted, but main.dol or StaticR.rel changed or disappeared since. - CodeModified, -} - -public sealed record GameFilesStatus(GameFilesState State, GameFilesManifest? Manifest, string? ImagePath) -{ - public bool IsReady => State == GameFilesState.Ready; -} - -public enum GameChangeKind -{ - /// Nothing is extracted yet. - Extract, - - /// The same game and revision is already extracted; only the remembered image changes. - SameGame, - - /// A different game or revision is extracted; replacing it deletes every change made to it. - Replace, - - /// A folder Wheel Wizard does not know is in the way and has to be moved aside or deleted first. - UnknownFolder, -} - -public sealed record GameChangePlan(GameChangeKind Kind, DiscContents Image, GameFilesManifest? Current); - -public sealed record GameExtractionProgress(double Fraction, string Message); - -public interface IGameFilesService -{ - GameFilesStatus GetStatus(); - - /// The picked image, also when it is not reachable right now. - string? ImagePath { get; } - - /// - /// Checks a game image without changing anything: whether it is Mario Kart Wii, and what picking it would do to - /// the extracted game. - /// - OperationResult Plan(string imagePath); - - /// - /// Extracts and makes it the game, then remembers the image. Never writes to, moves - /// or deletes the image. - /// - /// - /// Required to replace an extraction of a different game or revision, which deletes it with every change made to it. - /// - Task ExtractAsync( - string imagePath, - bool replaceExisting, - IProgress? progress = null, - CancellationToken cancellationToken = default - ); - - /// Restores main.dol and StaticR.rel from the image of the same game; nothing else is touched. - Task RestoreCodeAsync(string? imagePath = null, CancellationToken cancellationToken = default); - - /// Renames whatever is in the way to GAME.backup-…. A link is renamed, never followed. - OperationResult MoveUnknownAside(); - - /// Deletes whatever is in the way. A link is removed without touching what it points to. - OperationResult DeleteUnknown(); - - /// - /// Finishes or rolls back work a previous run was interrupted in, and removes Wheel Wizard's own leftovers. - /// Only ever deletes folders that carry its work marker. - /// - void RecoverInterruptedWork(); - - /// Dolphin or WiiCompiled has files of the game open, which blocks replacing them on Windows. - bool IsGameRunning(); - - void CloseRunningGame(); - - /// The Mario Kart Wii region of the extracted game, or of the image when nothing is extracted yet. - MarioKartWiiEnums.Regions GetRegion(); -} - -public sealed class GameFilesService( - IGameFilesPaths paths, - IDiscExtractor extractor, - ISettingsManager settings, - IFileSystem fileSystem, - IProcessLauncher processes, - IDolphinPaths dolphinPaths, - IDiscImageService discImages, - ILogger logger -) : IGameFilesService -{ - private const long FreeSpaceMargin = 256L * 1024 * 1024; - - private static readonly JsonSerializerOptions ManifestJson = new() { WriteIndented = true }; - - // The two hashes are checked on every status read; rehashing ~7 MB only when the files change keeps that cheap. - private readonly Dictionary _hashCache = new(); - private readonly object _hashLock = new(); - private readonly SemaphoreSlim _gate = new(1, 1); - - public string? ImagePath - { - get - { - var value = settings.Get(settings.GAME_LOCATION); - if (!string.IsNullOrWhiteSpace(value)) - return value; - return settings.GAME_LOCATION.UnavailableValue is { } unavailable && !string.IsNullOrWhiteSpace(unavailable) - ? unavailable - : null; - } - } - - #region Status - - public GameFilesStatus GetStatus() - { - var image = ImagePath; - var manifest = ReadManifest(); - if (manifest is not null) - return new(CodeMatches(manifest) ? GameFilesState.Ready : GameFilesState.CodeModified, manifest, image); - - if (RootExists()) - return new(GameFilesState.UnknownFolder, null, image); - if (image is null) - return new(GameFilesState.NoImage, null, null); - return new(IsReadableFile(image) ? GameFilesState.NeedsExtraction : GameFilesState.ImageMissing, null, image); - } - - /// - /// Whether something is where the extracted game goes. An empty folder, or a link to one (made to put the game on - /// another drive), is just a place to extract into. A link whose target is not there (an unplugged drive) is not: - /// extracting would create the folder somewhere the user did not mean. - /// - private bool RootExists() - { - try - { - var root = paths.RootPath; - if (fileSystem.Directory.Exists(root)) - return fileSystem.Directory.EnumerateFileSystemEntries(root).Any(); - return fileSystem.File.Exists(root) || fileSystem.IsSymlink(root); - } - catch - { - // Unreadable is not empty. - return true; - } - } - - private bool IsReadableFile(string path) - { - try - { - return fileSystem.File.Exists(path); - } - catch - { - return false; - } - } - - private GameFilesManifest? ReadManifest() => ReadManifest(paths.ManifestPath); - - private GameFilesManifest? ReadManifest(string manifestPath) - { - try - { - if (!fileSystem.File.Exists(manifestPath)) - return null; - var manifest = JsonSerializer.Deserialize(fileSystem.File.ReadAllText(manifestPath)); - return manifest is { SchemaVersion: GameFilesManifest.CurrentSchemaVersion } && !string.IsNullOrWhiteSpace(manifest.GameId) - ? manifest - : null; - } - catch (Exception exception) - { - logger.LogWarning(exception, "The extracted game's manifest could not be read"); - return null; - } - } - - private void WriteManifest(string manifestPath, GameFilesManifest manifest) - { - var json = JsonSerializer.Serialize(manifest, ManifestJson); - // Written in place of nothing, flushed, then renamed: a manifest that exists is always complete. - var temp = manifestPath + ".tmp"; - using (var stream = fileSystem.FileStream.New(temp, FileMode.Create, FileAccess.Write, FileShare.None)) - using (var writer = new StreamWriter(stream)) - { - writer.Write(json); - writer.Flush(); - stream.Flush(flushToDisk: true); - } - - fileSystem.File.Move(temp, manifestPath, overwrite: true); - } - - private bool CodeMatches(GameFilesManifest manifest) => - HashEquals(HashFile(paths.MainDolPath), manifest.MainDolSha256) - && HashEquals(HashFile(paths.StaticRelPath), manifest.StaticRelSha256); - - private static bool HashEquals(string? actual, string expected) => - actual is not null && string.Equals(actual, expected, StringComparison.OrdinalIgnoreCase); - - private string? HashFile(string path) - { - try - { - var file = fileSystem.FileInfo.New(path); - if (!file.Exists) - return null; - lock (_hashLock) - { - if ( - _hashCache.TryGetValue(path, out var cached) - && cached.Length == file.Length - && cached.LastWrite == file.LastWriteTimeUtc - ) - return cached.Sha; - } - - using var stream = fileSystem.File.OpenRead(path); - var sha = Convert.ToHexString(SHA256.HashData(stream)).ToLowerInvariant(); - lock (_hashLock) - _hashCache[path] = (file.Length, file.LastWriteTimeUtc, sha); - return sha; - } - catch (Exception exception) - { - logger.LogWarning(exception, "Could not hash {Path}", path); - return null; - } - } - - public MarioKartWiiEnums.Regions GetRegion() - { - var manifest = ReadManifest(); - if (manifest is not null) - return GameRegion.FromGameId(manifest.GameId); - // The license loader asks on every poll; the disc image service only reopens the image once it changes. - var image = ImagePath; - if (image is null) - return MarioKartWiiEnums.Regions.None; - var info = discImages.ReadInfo(image); - return info.IsSuccess ? GameRegion.FromGameId(info.Value.GameId) : MarioKartWiiEnums.Regions.None; - } - - #endregion - - #region Planning - - public OperationResult Plan(string imagePath) - { - var contents = Inspect(imagePath); - if (contents.IsFailure) - return contents.Error; - - var manifest = ReadManifest(); - if (manifest is not null) - return new GameChangePlan( - manifest.IsSameGame(contents.Value.GameId, contents.Value.Revision) ? GameChangeKind.SameGame : GameChangeKind.Replace, - contents.Value, - manifest - ); - - return new GameChangePlan(RootExists() ? GameChangeKind.UnknownFolder : GameChangeKind.Extract, contents.Value, null); - } - - private OperationResult Inspect(string imagePath) - { - if (string.IsNullOrWhiteSpace(imagePath) || !IsReadableFile(imagePath)) - return Fail(t("message_error.invalid_game_file.unreadable")); - if (IsInsideGameFiles(imagePath)) - return Fail(t("game_files.image_inside_game_folder")); - - var contents = extractor.Inspect(imagePath); - if (contents.IsFailure) - return Fail(t("message_error.invalid_game_file.unreadable")); - if (GameRegion.FromGameId(contents.Value.GameId) == MarioKartWiiEnums.Regions.None) - return Fail( - t("message_error.invalid_game_file.wrong_game", new { name = contents.Value.InternalName, id = contents.Value.GameId }) - ); - return contents; - } - - /// An image kept inside the extracted game would be deleted along with it on the next replace. - private bool IsInsideGameFiles(string imagePath) - { - try - { - var full = fileSystem.Path.GetFullPath(imagePath); - return new[] { paths.RootPath, paths.ResolvedRootPath, paths.StagingPath, paths.ReplacedPath }.Any(folder => - fileSystem.Path.IsDescendantPath(full, folder) - ); - } - catch - { - return false; - } - } - - #endregion - - #region Extraction - - public async Task ExtractAsync( - string imagePath, - bool replaceExisting, - IProgress? progress = null, - CancellationToken cancellationToken = default - ) - { - if (!await _gate.WaitAsync(0, cancellationToken)) - return Fail(t("game_files.busy")); - try - { - return await Task.Run(() => ExtractCore(imagePath, replaceExisting, progress, cancellationToken), cancellationToken); - } - finally - { - _gate.Release(); - } - } - - private OperationResult ExtractCore( - string imagePath, - bool replaceExisting, - IProgress? progress, - CancellationToken cancellationToken - ) - { - RecoverInterruptedWork(); - var planResult = Plan(imagePath); - if (planResult.IsFailure) - return planResult.Error; - var plan = planResult.Value; - - switch (plan.Kind) - { - case GameChangeKind.UnknownFolder: - return Fail(t("game_files.unknown_folder_body")); - case GameChangeKind.Replace when !replaceExisting: - return Fail(t("game_files.replace_not_confirmed")); - case GameChangeKind.SameGame: - return RememberImage(imagePath, plan.Current!); - } - - var root = paths.ResolvedRootPath; - if (fileSystem.Path.IsRootDirectory(root)) - return Fail(t("game_files.link_to_drive_root")); - if (IsGameRunning()) - return Fail(t("game_files.close_game_body")); - - var staging = paths.StagingPath; - var space = EnsureFreeSpace(fileSystem.Path.GetDirectoryName(staging) ?? staging, plan.Image.RequiredBytes); - if (space.IsFailure) - return space; - - try - { - DeleteWorkFolder(staging); - fileSystem.Directory.CreateDirectory(staging); - fileSystem.File.WriteAllText(fileSystem.Path.Combine(staging, GameFilesPaths.WorkMarkerFileName), ""); - - progress?.Report(new(0, t("game_files.extracting"))); - var extracted = extractor.Extract( - imagePath, - staging, - new InlineProgress(value => progress?.Report(new(value, t("game_files.extracting")))), - cancellationToken - ); - if (extracted.IsFailure) - { - DeleteWorkFolder(staging); - return extracted.Error; - } - - cancellationToken.ThrowIfCancellationRequested(); - var image = fileSystem.FileInfo.New(imagePath); - WriteManifest( - fileSystem.Path.Combine(staging, GameFilesPaths.ManifestFileName), - new() - { - GameId = plan.Image.GameId, - Revision = plan.Image.Revision, - InternalName = plan.Image.InternalName, - SourcePath = fileSystem.Path.GetFullPath(imagePath), - SourceLength = image.Length, - SourceLastWriteUtc = image.LastWriteTimeUtc, - MainDolSha256 = extracted.Value.MainDolSha256, - StaticRelSha256 = extracted.Value.StaticRelSha256, - ExtractorVersion = extractor.Version, - ExtractedUtc = DateTime.UtcNow, - } - ); - - progress?.Report(new(1, t("game_files.finishing"))); - Promote(); - } - catch (OperationCanceledException) - { - TryDeleteWorkFolder(staging); - throw; - } - catch (Exception exception) - { - logger.LogError(exception, "Extracting the game failed"); - // A failure before the swap leaves only the staging folder; a failure during it is finished on the next start. - if (ReadManifest(fileSystem.Path.Combine(staging, GameFilesPaths.ManifestFileName)) is null) - TryDeleteWorkFolder(staging); - return new OperationError { Message = $"{t("game_files.extract_failed")} {exception.Message}", Exception = exception }; - } - - return settings.Set(settings.GAME_LOCATION, imagePath) ? Ok() : Fail(t("message_error.invalid_game_file.unreadable")); - } - - private OperationResult RememberImage(string imagePath, GameFilesManifest current) - { - try - { - var image = fileSystem.FileInfo.New(imagePath); - WriteManifest( - paths.ManifestPath, - current with - { - SourcePath = fileSystem.Path.GetFullPath(imagePath), - SourceLength = image.Length, - SourceLastWriteUtc = image.LastWriteTimeUtc, - } - ); - } - catch (Exception exception) - { - // The manifest only remembers the image for information; the game itself is fine as it is. - logger.LogWarning(exception, "Could not record the new game image in the manifest"); - } - - return settings.Set(settings.GAME_LOCATION, imagePath) ? Ok() : Fail(t("message_error.invalid_game_file.unreadable")); - } - - private OperationResult EnsureFreeSpace(string folder, long required) - { - try - { - fileSystem.Directory.CreateDirectory(folder); - var drive = fileSystem.DriveContaining(folder); - var needed = required + FreeSpaceMargin; - if (drive.AvailableFreeSpace >= needed) - return Ok(); - return Fail( - t("game_files.not_enough_space", new { needed = FormatSize(needed), available = FormatSize(drive.AvailableFreeSpace) }) - ); - } - catch (Exception exception) - { - // Some drives (network shares, some Linux mounts) can't say; the extraction itself still stops on a full disk. - logger.LogWarning(exception, "Could not check the free space for the game files"); - return Ok(); - } - } - - private static string FormatSize(long bytes) => $"{bytes / (1024.0 * 1024 * 1024):0.0} GB"; - - /// The top-level entries of an extraction. Anything else the user put in the folder is never moved or deleted. - private static readonly string[] LayoutEntries = ["DATA", "disc"]; - - /// - /// Swaps the finished staging folder in. The current manifest leaves first and the new one arrives last, so at - /// every point in between can tell which half is old and which is new. - /// - private void Promote() - { - var root = paths.ResolvedRootPath; - var staging = paths.StagingPath; - var replaced = paths.ReplacedPath; - fileSystem.Directory.CreateDirectory(root); - - var currentManifest = fileSystem.Path.Combine(root, GameFilesPaths.ManifestFileName); - if (fileSystem.File.Exists(currentManifest)) - { - EnsureWorkFolder(replaced); - fileSystem.File.Move(currentManifest, fileSystem.Path.Combine(replaced, GameFilesPaths.ManifestFileName), overwrite: true); - } - - FinishPromote(); - } - - private void FinishPromote() - { - var root = paths.ResolvedRootPath; - var staging = paths.StagingPath; - var replaced = paths.ReplacedPath; - - foreach (var entry in LayoutEntries) - { - var incoming = fileSystem.Path.Combine(staging, entry); - if (!fileSystem.Directory.Exists(incoming)) - continue; - - var current = fileSystem.Path.Combine(root, entry); - if (fileSystem.Directory.Exists(current) || fileSystem.File.Exists(current)) - { - EnsureWorkFolder(replaced); - var parked = fileSystem.Path.Combine(replaced, entry); - if (fileSystem.Directory.Exists(parked)) - DeleteTree(parked); - MoveEntry(current, parked); - } - - fileSystem.Directory.Move(incoming, current); - } - - fileSystem.File.Move( - fileSystem.Path.Combine(staging, GameFilesPaths.ManifestFileName), - fileSystem.Path.Combine(root, GameFilesPaths.ManifestFileName), - overwrite: true - ); - lock (_hashLock) - _hashCache.Clear(); - - TryDeleteWorkFolder(replaced); - TryDeleteWorkFolder(staging); - } - - private void MoveEntry(string from, string to) - { - if (fileSystem.File.Exists(from) && !fileSystem.Directory.Exists(from)) - fileSystem.File.Move(from, to); - else - fileSystem.Directory.Move(from, to); - } - - public void RecoverInterruptedWork() - { - try - { - var staging = paths.StagingPath; - var stagingManifest = fileSystem.Path.Combine(staging, GameFilesPaths.ManifestFileName); - if (IsWorkFolder(staging) && ReadManifest(stagingManifest) is not null && ReadManifest() is null) - { - // The swap had started: the new extraction is complete, the old manifest already left. - logger.LogInformation("Finishing an interrupted game extraction"); - FinishPromote(); - if (ReadManifest() is { } finished && IsReadableFile(finished.SourcePath)) - settings.Set(settings.GAME_LOCATION, finished.SourcePath); - return; - } - - TryDeleteWorkFolder(staging); - if (ReadManifest() is not null) - TryDeleteWorkFolder(paths.ReplacedPath); - } - catch (Exception exception) - { - logger.LogWarning(exception, "Could not clean up an interrupted game extraction"); - } - } - - #endregion - - #region Restore - - public async Task RestoreCodeAsync(string? imagePath = null, CancellationToken cancellationToken = default) - { - if (!await _gate.WaitAsync(0, cancellationToken)) - return Fail(t("game_files.busy")); - try - { - return await Task.Run(() => RestoreCodeCore(imagePath ?? ImagePath, cancellationToken), cancellationToken); - } - finally - { - _gate.Release(); - } - } - - private OperationResult RestoreCodeCore(string? imagePath, CancellationToken cancellationToken) - { - var manifest = ReadManifest(); - if (manifest is null) - return Fail(t("game_files.not_extracted")); - if (imagePath is null) - return Fail(t("message_error.invalid_game_file.unreadable")); - - var contents = Inspect(imagePath); - if (contents.IsFailure) - return contents.Error; - if (!manifest.IsSameGame(contents.Value.GameId, contents.Value.Revision)) - return Fail(t("game_files.restore_wrong_game", new { id = manifest.GameId })); - if (IsGameRunning()) - return Fail(t("game_files.close_game_body")); - - var restored = extractor.RestoreCode(imagePath, fileSystem.Path.Combine(paths.ResolvedRootPath, "DATA"), cancellationToken); - if (restored.IsFailure) - return restored.Error; - - lock (_hashLock) - _hashCache.Clear(); - try - { - var image = fileSystem.FileInfo.New(imagePath); - WriteManifest( - paths.ManifestPath, - manifest with - { - MainDolSha256 = restored.Value.MainDolSha256, - StaticRelSha256 = restored.Value.StaticRelSha256, - SourcePath = fileSystem.Path.GetFullPath(imagePath), - SourceLength = image.Length, - SourceLastWriteUtc = image.LastWriteTimeUtc, - } - ); - } - catch (Exception exception) - { - return new OperationError { Message = $"{t("game_files.extract_failed")} {exception.Message}", Exception = exception }; - } - - return settings.Set(settings.GAME_LOCATION, imagePath) ? Ok() : Fail(t("message_error.invalid_game_file.unreadable")); - } - - #endregion - - #region Unknown folder - - public OperationResult MoveUnknownAside() - { - if (ReadManifest() is not null) - return Fail(t("game_files.not_unknown")); - try - { - var root = paths.RootPath; - var backup = $"{root}.backup-{DateTime.Now:yyyyMMdd-HHmmss}"; - for (var attempt = 2; fileSystem.Directory.Exists(backup) || fileSystem.File.Exists(backup); attempt++) - backup = $"{root}.backup-{DateTime.Now:yyyyMMdd-HHmmss}-{attempt}"; - - // Moving the link itself, not its target: the folder it points to stays where the user put it. - if (fileSystem.IsSymlink(root)) - MoveLink(root, backup); - else if (fileSystem.File.Exists(root) && !fileSystem.Directory.Exists(root)) - fileSystem.File.Move(root, backup); - else - fileSystem.Directory.Move(root, backup); - logger.LogInformation("Moved an unknown game folder aside to {Backup}", backup); - return backup; - } - catch (Exception exception) - { - return new OperationError { Message = $"{t("game_files.move_aside_failed")} {exception.Message}", Exception = exception }; - } - } - - public OperationResult DeleteUnknown() - { - if (ReadManifest() is not null) - return Fail(t("game_files.not_unknown")); - try - { - var root = paths.RootPath; - if (fileSystem.IsSymlink(root)) - DeleteLink(root); - else if (fileSystem.Directory.Exists(root)) - DeleteTree(root); - else if (fileSystem.File.Exists(root)) - fileSystem.File.Delete(root); - logger.LogInformation("Deleted an unknown game folder at {Root}", root); - return Ok(); - } - catch (Exception exception) - { - return new OperationError { Message = $"{t("game_files.delete_failed")} {exception.Message}", Exception = exception }; - } - } - - /// Recreates the link under the new name and removes the old one, so nothing behind it moves. - private void MoveLink(string from, string to) - { - var target = fileSystem.FileInfo.New(from).LinkTarget!; - try - { - fileSystem.Directory.CreateSymbolicLink(to, target); - } - catch (Exception exception) when (exception is UnauthorizedAccessException or IOException) - { - // Windows only lets some accounts create links, but renaming one (a junction too) moves just the link. - fileSystem.Directory.Move(from, to); - return; - } - - DeleteLink(from); - } - - private void DeleteLink(string path) - { - // A directory link (symlink or junction) is removed as a directory on Windows and unlinked like a file - // elsewhere; neither touches its target. - if (fileSystem.Path.DirectorySeparatorChar == '\\' && fileSystem.Directory.Exists(path)) - fileSystem.Directory.Delete(path, recursive: false); - else - fileSystem.File.Delete(path); - } - - #endregion - - #region Deleting - - private bool IsWorkFolder(string path) - { - try - { - return fileSystem.Directory.Exists(path) - && !fileSystem.IsSymlink(path) - && fileSystem.File.Exists(fileSystem.Path.Combine(path, GameFilesPaths.WorkMarkerFileName)); - } - catch - { - return false; - } - } - - private void EnsureWorkFolder(string path) - { - if (IsWorkFolder(path)) - return; - if (fileSystem.Directory.Exists(path) || fileSystem.File.Exists(path)) - throw new IOException($"'{path}' is in the way and is not Wheel Wizard's to remove."); - fileSystem.Directory.CreateDirectory(path); - fileSystem.File.WriteAllText(fileSystem.Path.Combine(path, GameFilesPaths.WorkMarkerFileName), ""); - } - - private void DeleteWorkFolder(string path) - { - if (!fileSystem.Directory.Exists(path) && !fileSystem.File.Exists(path)) - return; - if (!IsWorkFolder(path)) - throw new IOException($"'{path}' is in the way and is not Wheel Wizard's to remove."); - DeleteTree(path); - } - - private void TryDeleteWorkFolder(string path) - { - try - { - if (IsWorkFolder(path)) - DeleteTree(path); - } - catch (Exception exception) - { - logger.LogWarning(exception, "Could not delete {Path}; it is retried on the next start", path); - } - } - - /// Deletes a folder without ever following a link inside it: links are removed, their targets are left alone. - private void DeleteTree(string path) - { - foreach (var entry in fileSystem.Directory.EnumerateFileSystemEntries(path)) - { - if (fileSystem.IsSymlink(entry)) - { - DeleteLink(entry); - continue; - } - - if (fileSystem.Directory.Exists(entry)) - { - DeleteTree(entry); - continue; - } - - var file = fileSystem.FileInfo.New(entry); - if (file.IsReadOnly) - file.IsReadOnly = false; - file.Delete(); - } - - fileSystem.Directory.Delete(path, recursive: false); - } - - #endregion - - #region Running game - - // Dolphin's process name on each platform and packaging, and WiiCompiled's two runtimes. - private static readonly string[] GameProcessNames = ["Dolphin", "dolphin-emu", "DolphinQt", "WiiCompiled", "RetroRewind"]; - - public bool IsGameRunning() => RunningProcessNames().Any(name => processes.GetProcessIds(name).Count > 0); - - public void CloseRunningGame() - { - foreach (var name in RunningProcessNames()) - { - try - { - processes.KillByName(name); - } - catch (Exception exception) - { - logger.LogWarning(exception, "Could not close {Process}", name); - } - } - } - - private IEnumerable RunningProcessNames() - { - var names = new HashSet(GameProcessNames, StringComparer.OrdinalIgnoreCase); - try - { - var configured = fileSystem.Path.GetFileNameWithoutExtension(dolphinPaths.ExecutablePath); - if (!string.IsNullOrWhiteSpace(configured) && !configured.Contains(' ')) - names.Add(configured); - } - catch - { - // A Dolphin command that isn't a path (a Flatpak command line) is covered by the names above. - } - - return names; - } - - #endregion - - private sealed class InlineProgress(Action report) : IProgress - { - public void Report(T value) => report(value); - } -} diff --git a/WheelWizard/Features/GameFiles/Views/GameFilesFlow.cs b/WheelWizard/Features/GameFiles/Views/GameFilesFlow.cs deleted file mode 100644 index 1c0743c20..000000000 --- a/WheelWizard/Features/GameFiles/Views/GameFilesFlow.cs +++ /dev/null @@ -1,265 +0,0 @@ -using Avalonia.Threading; -using Microsoft.Extensions.Logging; -using WheelWizard.Recomp; -using WheelWizard.Views.Components; -using WheelWizard.Views.Dialogs; -using WheelWizard.WiiManagement.GameLicense; - -namespace WheelWizard.GameFiles.Views; - -/// -/// The questions around changing the extracted game: what to do with what is there now, waiting for a running game -/// to close, then extracting with progress. Shared by Settings, the game setup page and the onboarding. -/// -public interface IGameFilesFlow -{ - /// Makes the game, asking whatever has to be asked first. - /// Shows progress inline; without it a progress window is shown. - /// Whether the game is now extracted from (or remembered as) this image. - Task ChangeImageAsync( - string imagePath, - IProgress? progress = null, - CancellationToken cancellationToken = default - ); - - /// Extracts the image already picked, for someone coming from an older version. - Task ExtractCurrentAsync(IProgress? progress = null, CancellationToken cancellationToken = default); - - /// Asks whether to move aside or delete a folder that is in the way. - Task ResolveUnknownFolderAsync(); - - /// Waits until Dolphin and WiiCompiled are closed, offering to close them. False when the user gave up. - Task WaitForGameClosedAsync(); - - /// Points an installed WiiCompiled at the extracted game. Failures leave WiiCompiled asking for an update. - Task LinkWiiCompiledAsync(); -} - -public sealed class GameFilesFlow(IGameFilesService gameFiles, IServiceProvider services, ILogger logger) : IGameFilesFlow -{ - private const string PalGameIdPrefix = "RMCP"; - - public async Task ChangeImageAsync( - string imagePath, - IProgress? progress = null, - CancellationToken cancellationToken = default - ) - { - var plan = gameFiles.Plan(imagePath); - if (plan.IsFailure) - { - await ShowErrorAsync(t("message_error.invalid_game_file.title"), plan.Error.Message); - return false; - } - - var replace = false; - switch (plan.Value.Kind) - { - case GameChangeKind.SameGame: - return await RunAsync(imagePath, replaceExisting: false, progress, cancellationToken); - case GameChangeKind.UnknownFolder: - if (!await ResolveUnknownFolderAsync()) - return false; - break; - case GameChangeKind.Replace: - if (!await ConfirmReplaceAsync(plan.Value)) - return false; - replace = true; - break; - } - - return await RunAsync(imagePath, replace, progress, cancellationToken); - } - - public async Task ExtractCurrentAsync( - IProgress? progress = null, - CancellationToken cancellationToken = default - ) - { - var image = gameFiles.ImagePath; - return image is not null && await ChangeImageAsync(image, progress, cancellationToken); - } - - private async Task ConfirmReplaceAsync(GameChangePlan plan) - { - var current = plan.Current!; - var body = t( - "game_files.replace_body", - new { current = Describe(current.GameId, current.Revision), incoming = Describe(plan.Image.GameId, plan.Image.Revision) } - ); - var recomp = services.GetService(); - if (recomp?.IsInstalled == true && !plan.Image.GameId.StartsWith(PalGameIdPrefix, StringComparison.OrdinalIgnoreCase)) - body += "\n\n" + t("game_files.replace_breaks_wiicompiled"); - - return await new YesNoWindow() - .SetMainText(t("game_files.replace_title")) - .SetExtraText(body) - .SetButtonText(t("game_files.replace_action"), t("action.cancel")) - .SetButtonTones(ActionButtonTone.Danger, ActionButtonTone.Secondary) - .AwaitAnswer(); - } - - private static string Describe(string gameId, byte revision) - { - var region = GameRegion.GetDisplayName(GameRegion.FromGameId(gameId)); - return revision == 0 ? $"Mario Kart Wii · {region} ({gameId})" : $"Mario Kart Wii · {region} ({gameId}, rev {revision})"; - } - - public async Task ResolveUnknownFolderAsync() - { - var moveAside = await new YesNoWindow() - .SetMainText(t("game_files.unknown_folder_title")) - .SetExtraText(t("game_files.unknown_folder_body")) - .SetButtonText(t("game_files.move_aside"), t("game_files.delete")) - .SetButtonTones(ActionButtonTone.Brand, ActionButtonTone.Secondary) - .AwaitWithChoiceAsync(); - - switch (moveAside) - { - case true: - { - var moved = await Task.Run(gameFiles.MoveUnknownAside); - if (moved.IsSuccess) - return true; - await ShowErrorAsync(t("game_files.unknown_folder_title"), moved.Error.Message); - return false; - } - case false: - { - var sure = await new YesNoWindow() - .SetMainText(t("game_files.delete_confirm_title")) - .SetExtraText(t("game_files.delete_confirm_body")) - .SetButtonText(t("game_files.delete"), t("action.cancel")) - .SetButtonTones(ActionButtonTone.Danger, ActionButtonTone.Secondary) - .AwaitAnswer(); - if (!sure) - return false; - var deleted = await Task.Run(gameFiles.DeleteUnknown); - if (deleted.IsSuccess) - return true; - await ShowErrorAsync(t("game_files.unknown_folder_title"), deleted.Error.Message); - return false; - } - default: - return false; - } - } - - public async Task WaitForGameClosedAsync() - { - if (!await Task.Run(gameFiles.IsGameRunning)) - return true; - - var window = new YesNoWindow() - .SetMainText(t("game_files.close_game_title")) - .SetExtraText(t("game_files.close_game_body")) - .SetButtonText(t("game_files.close_game_action"), t("action.cancel")) - .SetButtonTones(ActionButtonTone.Secondary, ActionButtonTone.Secondary); - - // The prompt goes away by itself once the game is closed, however that happened. - var closedByItself = false; - using var polling = new CancellationTokenSource(); - _ = Task.Run(async () => - { - while (!polling.IsCancellationRequested) - { - await Task.Delay(TimeSpan.FromSeconds(1), polling.Token).ConfigureAwait(false); - if (gameFiles.IsGameRunning()) - continue; - closedByItself = true; - await Dispatcher.UIThread.InvokeAsync(window.Close); - return; - } - }); - - var closeForMe = await window.AwaitAnswer(); - await polling.CancelAsync(); - if (closedByItself) - return true; - if (!closeForMe) - return false; - - await Task.Run(gameFiles.CloseRunningGame); - for (var attempt = 0; attempt < 20 && await Task.Run(gameFiles.IsGameRunning); attempt++) - await Task.Delay(250); - return !await Task.Run(gameFiles.IsGameRunning) || await WaitForGameClosedAsync(); - } - - private async Task RunAsync( - string imagePath, - bool replaceExisting, - IProgress? progress, - CancellationToken cancellationToken - ) - { - if (!await WaitForGameClosedAsync()) - return false; - - OperationResult result; - ProgressWindow? window = null; - using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - try - { - if (progress is null) - { - window = new ProgressWindow(t("game_files.extracting_title")) - .SetGoal(t("game_files.extracting_title")) - .SetExtraText(t("game_files.extracting")) - .SetCancellationTokenSource(cancellation); - window.Show(); - var shown = window; - progress = new Progress(value => - { - shown.UpdateProgress((int)(value.Fraction * 100)); - shown.SetExtraText(value.Message); - }); - } - - result = await gameFiles.ExtractAsync(imagePath, replaceExisting, progress, cancellation.Token); - } - catch (OperationCanceledException) when (cancellation.IsCancellationRequested) - { - return false; - } - finally - { - window?.Close(); - } - - if (result.IsFailure) - { - await ShowErrorAsync(t("game_files.extract_failed_title"), result.Error.Message); - return false; - } - - await LinkWiiCompiledAsync(); - return true; - } - - public async Task LinkWiiCompiledAsync() - { - var recomp = services.GetService(); - if (recomp is not { IsInstalled: true }) - return; - - var window = new ProgressWindow(t("game_files.linking_wiicompiled")).SetIndeterminate(); - try - { - window.Show(); - var result = await recomp.LinkGameDataAsync(); - if (result.IsFailure) - logger.LogWarning("WiiCompiled could not be pointed at the extracted game: {Message}", result.Error.Message); - } - catch (Exception exception) - { - logger.LogWarning(exception, "WiiCompiled could not be pointed at the extracted game"); - } - finally - { - window.Close(); - } - } - - private static Task ShowErrorAsync(string title, string message) => - new MessageBoxWindow().SetMessageType(MessageBoxWindow.MessageType.Error).SetTitleText(title).SetInfoText(message).ShowDialog(); -} diff --git a/WheelWizard/Features/GameFiles/Views/GameSetupPage.axaml b/WheelWizard/Features/GameFiles/Views/GameSetupPage.axaml deleted file mode 100644 index 4b0e8a8b3..000000000 --- a/WheelWizard/Features/GameFiles/Views/GameSetupPage.axaml +++ /dev/null @@ -1,66 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/WheelWizard/Features/GameFiles/Views/GameSetupPage.axaml.cs b/WheelWizard/Features/GameFiles/Views/GameSetupPage.axaml.cs deleted file mode 100644 index a71614c3c..000000000 --- a/WheelWizard/Features/GameFiles/Views/GameSetupPage.axaml.cs +++ /dev/null @@ -1,281 +0,0 @@ -using Avalonia.Controls; -using Avalonia.Interactivity; -using Avalonia.Media; -using Avalonia.Platform.Storage; -using Microsoft.Extensions.Logging; -using WheelWizard.DiscImages; -using WheelWizard.Settings.Views; -using WheelWizard.Shared.Desktop.Storage; -using WheelWizard.Views.Shell; -using WheelWizard.Views.Shell.Navigation; -using WheelWizard.Views.Shell.Views; -using WheelWizard.WiiManagement.GameLicense; - -namespace WheelWizard.GameFiles.Views; - -/// -/// Shown instead of Home while the extracted game is not usable: someone coming from a version that played the image -/// directly, an image that can't be found, a changed main.dol or StaticR.rel, or a folder in the way. Only Settings -/// stays reachable, to free up or move the data folder. -/// -public partial class GameSetupPage : UserControl, IBlockingPage -{ - private readonly IGameFilesService _gameFiles; - private readonly IGameFilesFlow _flow; - private readonly IFilePickerService _filePicker; - private readonly IDiscImageService _discImages; - private readonly INavigationService _navigation; - private readonly ILogger _logger; - - private GameFilesStatus _status = new(GameFilesState.NoImage, null, null); - private CancellationTokenSource? _extraction; - private bool _busy; - - public GameSetupPage( - IGameFilesService gameFiles, - IGameFilesFlow flow, - IFilePickerService filePicker, - IDiscImageService discImages, - INavigationService navigation, - ILogger logger - ) - { - _gameFiles = gameFiles; - _flow = flow; - _filePicker = filePicker; - _discImages = discImages; - _navigation = navigation; - _logger = logger; - InitializeComponent(); - _navigation.IsLocked = true; - Refresh(); - } - - /// Whether the game needs this page before anything can be played. - public static bool IsNeeded(GameFilesStatus status) => status.State is not (GameFilesState.Ready or GameFilesState.NoImage); - - private void Refresh() - { - _status = _gameFiles.GetStatus(); - if (!IsNeeded(_status)) - { - Finish(); - return; - } - - var (title, body) = _status.State switch - { - GameFilesState.ImageMissing => (t("game_files.missing_title"), t("game_files.missing_body")), - GameFilesState.CodeModified => (t("game_files.modified_title"), t("game_files.modified_body")), - GameFilesState.UnknownFolder => (t("game_files.unknown_folder_title"), t("game_files.unknown_folder_body")), - _ => (t("game_files.setup_title"), t("game_files.setup_body")), - }; - TitleText.Text = title; - BodyText.Text = body; - ShowGame(); - - var hasImage = _status.ImagePath is { } image && File.Exists(image); - (PrimaryButton.Text, PrimaryButton.IsVisible) = _status.State switch - { - GameFilesState.NeedsExtraction => (t("game_files.extract_action"), true), - GameFilesState.ImageMissing => (t("game_files.pick_game"), true), - GameFilesState.CodeModified => hasImage ? (t("game_files.restore_action"), true) : (t("game_files.pick_game"), true), - GameFilesState.UnknownFolder => (t("game_files.move_aside"), true), - _ => (string.Empty, false), - }; - SecondaryButton.Text = t("game_files.delete"); - SecondaryButton.IsVisible = _status.State == GameFilesState.UnknownFolder; - PickOtherButton.IsVisible = - _status.State is GameFilesState.NeedsExtraction || (_status.State == GameFilesState.CodeModified && hasImage); - SetBusy(false); - } - - private void ShowGame() - { - var image = _status.ImagePath; - GameBox.IsVisible = image is not null || _status.Manifest is not null; - var gameId = - _status.Manifest?.GameId - ?? (image is not null && _discImages.ReadInfo(image) is { IsSuccess: true } info ? info.Value.GameId : null); - var region = GameRegion.FromGameId(gameId); - GameNameText.Text = - region == Models.Enums.MarioKartWiiEnums.Regions.None - ? "Mario Kart Wii" - : $"Mario Kart Wii · {GameRegion.GetDisplayName(region)}"; - GamePathText.Text = image ?? string.Empty; - GamePathText.IsVisible = image is not null; - var reachable = image is not null && File.Exists(image); - // The page is filled in while it is built, before it is in a window whose theme it could look in. - if (this.TryFindResource(reachable ? "SuccessTip" : "WarningTip", out var icon) && icon is Geometry geometry) - GameIcon.Data = geometry; - if (this.TryFindResource(reachable ? "Brand300" : "Warning400", out var tone) && tone is Color color) - GameIcon.Foreground = new SolidColorBrush(color); - } - - private void SetBusy(bool busy, bool extracting = false) - { - _busy = busy; - ActionsPanel.IsEnabled = !busy; - ActionsPanel.IsVisible = !extracting; - ProgressPanel.IsVisible = extracting; - if (!busy) - ProgressPanel.IsVisible = false; - } - - private void ShowError(string? message) - { - ErrorText.Text = message ?? string.Empty; - ErrorText.IsVisible = !string.IsNullOrEmpty(message); - } - - private async void Primary_OnClick(object? sender, RoutedEventArgs e) - { - if (_busy) - return; - ShowError(null); - switch (_status.State) - { - case GameFilesState.NeedsExtraction: - await ExtractAsync(progress => _flow.ExtractCurrentAsync(progress, _extraction!.Token)); - break; - case GameFilesState.ImageMissing: - await PickAndUseAsync(); - break; - case GameFilesState.CodeModified when _status.ImagePath is { } image && File.Exists(image): - await RestoreAsync(image); - break; - case GameFilesState.CodeModified: - await PickAndUseAsync(); - break; - case GameFilesState.UnknownFolder: - await RunBusyAsync(async () => - { - var moved = await Task.Run(_gameFiles.MoveUnknownAside); - if (moved.IsFailure) - ShowError(moved.Error.Message); - }); - break; - } - - Refresh(); - } - - private async void Secondary_OnClick(object? sender, RoutedEventArgs e) - { - if (_busy || _status.State != GameFilesState.UnknownFolder) - return; - ShowError(null); - await RunBusyAsync(() => _flow.ResolveUnknownFolderAsync()); - Refresh(); - } - - private async void PickOther_OnClick(object? sender, RoutedEventArgs e) - { - if (_busy) - return; - ShowError(null); - await PickAndUseAsync(); - Refresh(); - } - - private async Task PickAndUseAsync() - { - var fileType = new FilePickerFileType("Game files") - { - Patterns = _discImages.SupportedExtensions.Select(extension => "*" + extension).ToArray(), - }; - var file = await _filePicker.OpenSingleFileAsync("Select Mario Kart Wii Game File", [fileType]); - if (string.IsNullOrEmpty(file)) - return; - - if (_status.State == GameFilesState.CodeModified) - await RestoreAsync(file); - else - await ExtractAsync(progress => _flow.ChangeImageAsync(file, progress, _extraction!.Token)); - } - - private async Task RestoreAsync(string image) - { - await RunBusyAsync(async () => - { - if (!await _flow.WaitForGameClosedAsync()) - return; - var restored = await _gameFiles.RestoreCodeAsync(image); - if (restored.IsFailure) - ShowError(restored.Error.Message); - }); - } - - private async Task ExtractAsync(Func, Task> extract) - { - _extraction = new CancellationTokenSource(); - SetBusy(true, extracting: true); - CancelButton.IsEnabled = true; - ExtractProgressBar.Value = 0; - ProgressText.Text = t("game_files.extracting"); - var progress = new Progress(value => - { - ExtractProgressBar.Value = Math.Clamp(value.Fraction * 100, 0, 100); - ProgressText.Text = $"{value.Message} · {(int)(value.Fraction * 100)}%"; - }); - try - { - await extract(progress); - } - catch (Exception exception) - { - _logger.LogError(exception, "Preparing the game files failed"); - ShowError(exception.Message); - } - finally - { - _extraction.Dispose(); - _extraction = null; - SetBusy(false); - } - } - - private async Task RunBusyAsync(Func action) - { - SetBusy(true); - try - { - await action(); - } - catch (Exception exception) - { - _logger.LogError(exception, "A game setup action failed"); - ShowError(exception.Message); - } - finally - { - SetBusy(false); - } - } - - private void Cancel_OnClick(object? sender, RoutedEventArgs e) - { - CancelButton.IsEnabled = false; - _extraction?.Cancel(); - } - - private void OpenSettings_OnClick(object? sender, RoutedEventArgs e) - { - if (_busy) - return; - // Settings is the one page reachable from here; leaving it for anything else comes back to this page. - _navigation.IsLocked = false; - _navigation.NavigateAway(typeof(SettingsPage)); - } - - private void Finish() - { - _navigation.IsLocked = false; - _navigation.NavigateAway(typeof(HomePage)); - if (ViewUtils.GetLayout() is { } layout) - { - layout.UpdateSidebarProfile(); - layout.UpdateFriendCount(); - } - } -} diff --git a/WheelWizard/Features/Launching/DolphinLaunchService.cs b/WheelWizard/Features/Launching/DolphinLaunchService.cs index 9dbb3bf7f..3a29cfb6d 100644 --- a/WheelWizard/Features/Launching/DolphinLaunchService.cs +++ b/WheelWizard/Features/Launching/DolphinLaunchService.cs @@ -4,7 +4,7 @@ using WheelWizard.CustomDistributions; using WheelWizard.Dolphin.Paths; using WheelWizard.DolphinInstaller; -using WheelWizard.GameFiles; +using WheelWizard.Settings; using WheelWizard.Shared.IO; using WheelWizard.Shared.Platform; using WheelWizard.Shared.Processes; @@ -19,6 +19,7 @@ public interface IDolphinLaunchService } public sealed class DolphinLaunchService( + ISettingsManager settings, IDolphinPaths paths, ICustomDistributionPaths distributionPaths, IFileSystem fileSystem, @@ -27,8 +28,7 @@ public sealed class DolphinLaunchService( IUnixProcessService linuxProcesses, IDolphinVersionService versions, ILinuxDolphinInstaller installer, - IDolphinLaunchPresentation presentation, - IGameFilesPaths gameFiles + IDolphinLaunchPresentation presentation ) : IDolphinLaunchService { private string QuotePath(string path) => ShellQuoting.QuoteArgument(path, environment.IsWindows); @@ -140,10 +140,8 @@ void AddFilesystemPerm(string newFilesystemPerm, string mode = "") // Read-only permissions on files where possible - // Dolphin boots the extracted game, so it needs the whole folder, and the folder a linked one points to. - AddFilesystemPerm(gameFiles.RootPath, ":ro"); - if (gameFiles.IsLinked) - AddFilesystemPerm(gameFiles.ResolvedRootPath, ":ro"); + if (!TryFixFlatpakPortalAccess(settings.Get(settings.GAME_LOCATION), dolphinAppId, "-r")) + AddFilesystemPerm(settings.Get(settings.GAME_LOCATION), ":ro"); // We need to provide the directory where the `RR.json` is located in for portal access! if (!TryFixFlatpakPortalAccess(fileSystem.Path.GetDirectoryName(distributionPaths.LaunchJsonFilePath) ?? "", dolphinAppId, "-r")) AddFilesystemPerm(distributionPaths.LaunchJsonFilePath, ":ro"); diff --git a/WheelWizard/Features/Launching/RetroRewindLaunchDescriptor.cs b/WheelWizard/Features/Launching/RetroRewindLaunchDescriptor.cs index 58072dd58..e05d9dc7e 100644 --- a/WheelWizard/Features/Launching/RetroRewindLaunchDescriptor.cs +++ b/WheelWizard/Features/Launching/RetroRewindLaunchDescriptor.cs @@ -2,8 +2,8 @@ using System.Text.Json; using System.Text.Json.Serialization; using WheelWizard.CustomDistributions; -using WheelWizard.GameFiles; using WheelWizard.Models.RRLaunchModels; +using WheelWizard.Settings; namespace WheelWizard.Launching; @@ -13,7 +13,7 @@ public interface IRetroRewindLaunchDescriptor void GenerateLaunchJson(string xmlFilePath); } -public sealed class RetroRewindLaunchDescriptor(IFileSystem fileSystem, ICustomDistributionPaths paths, IGameFilesPaths gameFiles) +public sealed class RetroRewindLaunchDescriptor(IFileSystem fileSystem, ISettingsManager settings, ICustomDistributionPaths paths) : IRetroRewindLaunchDescriptor { private string XmlFilePath => paths.XmlFilePath; @@ -46,8 +46,7 @@ bool enableSeparateSave { var launchConfig = new LaunchConfig { - // Dolphin opens an extracted disc through its sys/main.dol and finds the rest of the layout around it. - BaseFile = fileSystem.Path.GetFullPath(gameFiles.MainDolPath), + BaseFile = fileSystem.Path.GetFullPath(settings.Get(settings.GAME_LOCATION)), DisplayName = "RR", Riivolution = new() { diff --git a/WheelWizard/Features/Launching/RetroRewindLaunchService.cs b/WheelWizard/Features/Launching/RetroRewindLaunchService.cs index 1c0daa5c1..41159fe64 100644 --- a/WheelWizard/Features/Launching/RetroRewindLaunchService.cs +++ b/WheelWizard/Features/Launching/RetroRewindLaunchService.cs @@ -1,7 +1,6 @@ using System.IO.Abstractions; using WheelWizard.CustomDistributions; using WheelWizard.Dolphin.Paths; -using WheelWizard.GameFiles; using WheelWizard.Mods; using WheelWizard.Settings; using WheelWizard.Shared.Platform; @@ -31,8 +30,7 @@ public sealed class RetroRewindLaunchService( IRetroRewindLaunchDescriptor descriptor, IRuntimeEnvironment environment, ILaunchPrompts prompts, - IModOperationPresentation modPresentation, - IGameFilesService gameFiles + IModOperationPresentation modPresentation ) : IRetroRewindLaunchService { private string QuotePath(string path) => ShellQuoting.QuoteArgument(path, environment.IsWindows); @@ -41,8 +39,8 @@ public async Task LaunchAsync(bool beta = false) { try { - // Dolphin boots the extracted game; the game setup page keeps Home out of reach until it is ready. - if (!gameFiles.GetStatus().IsReady) + //case SHOULD be impossible since launch button should be disabled + if (!fileSystem.File.Exists(settings.Get(settings.GAME_LOCATION))) return Fail(t("message_warning.not_find_game.extra")); // Check first so a blocked launch does not kill Dolphin or prepare patches. diff --git a/WheelWizard/Features/Onboarding/Views/OnboardingPage.axaml b/WheelWizard/Features/Onboarding/Views/OnboardingPage.axaml index 15e083fe0..4ded754cf 100644 --- a/WheelWizard/Features/Onboarding/Views/OnboardingPage.axaml +++ b/WheelWizard/Features/Onboarding/Views/OnboardingPage.axaml @@ -184,10 +184,6 @@ - - - - diff --git a/WheelWizard/Features/Onboarding/Views/OnboardingPage.axaml.cs b/WheelWizard/Features/Onboarding/Views/OnboardingPage.axaml.cs index 15433123e..720a8f9a1 100644 --- a/WheelWizard/Features/Onboarding/Views/OnboardingPage.axaml.cs +++ b/WheelWizard/Features/Onboarding/Views/OnboardingPage.axaml.cs @@ -8,8 +8,6 @@ using WheelWizard.CustomDistributions; using WheelWizard.DiscImages; using WheelWizard.DolphinInstaller; -using WheelWizard.GameFiles; -using WheelWizard.GameFiles.Views; using WheelWizard.Recomp; using WheelWizard.Recomp.Domain; using WheelWizard.Settings; @@ -54,7 +52,6 @@ private enum Frontend private readonly IDiscImageService _discImages; private readonly INavigationService _navigation; private readonly ISettingsManager _settings; - private readonly IGameFilesFlow _gameFilesFlow; private readonly ILogger _logger; private readonly Stack _history = new(); @@ -83,12 +80,10 @@ public OnboardingPage( IDiscImageService discImages, INavigationService navigation, ISettingsManager settings, - IGameFilesFlow gameFilesFlow, ILogger logger ) { _settings = settings; - _gameFilesFlow = gameFilesFlow; _onboarding = onboarding; _retroRewind = retroRewind; _managedDolphin = managedDolphin; @@ -208,8 +203,7 @@ private void GoBack() private void SetBusy(bool busy) { _busy = busy; - // While something installs (or the game extracts) the page stays usable, so Back can cancel it. - IsEnabled = !busy || _step is Step.DolphinInstalling or Step.WiiCompiledInstalling or Step.GameFile; + IsEnabled = !busy || _step is Step.DolphinInstalling or Step.WiiCompiledInstalling; UpdateBackButton(); } @@ -545,65 +539,13 @@ private async void GameContinue_OnClick(object? sender, RoutedEventArgs e) { if (_gameFile is null || _busy) return; - if (await ExtractGameAsync(_gameFile)) - await ContinueAfterGameAsync(); - } - - /// - /// The game is played from an extracted copy, so it is extracted here, before anything else needs it. Back - /// cancels it like an install, which removes the unfinished extraction. - /// - private async Task ExtractGameAsync(string file) - { - var cancellation = new CancellationTokenSource(); - _installCancellation = cancellation; - SetBusy(true); - SetGameExtracting(true); - try - { - var progress = new Progress(value => - { - if (cancellation.IsCancellationRequested) - return; - GameProgressBar.Value = Math.Clamp(value.Fraction * 100, 0, 100); - GameProgressText.Text = $"{value.Message} · {(int)(value.Fraction * 100)}%"; - }); - return await _gameFilesFlow.ChangeImageAsync(file, progress, cancellation.Token) && !cancellation.IsCancellationRequested; - } - catch (OperationCanceledException) when (cancellation.IsCancellationRequested) - { - return false; - } - catch (Exception exception) - { - _logger.LogError(exception, "Extracting the game failed"); - return false; - } - finally + var result = _onboarding.SetGameFile(_gameFile); + if (result.IsFailure) { - SetGameExtracting(false); - // Back already let go of a cancelled extraction and moved on. - if (_installCancellation == cancellation) - { - _installCancellation = null; - SetBusy(false); - } - - cancellation.Dispose(); + ShowGameFile(_gameFile); + return; } - } - private void SetGameExtracting(bool extracting) - { - GameProgressPanel.IsVisible = extracting; - GameDropZone.IsEnabled = !extracting; - GameContinueButton.IsEnabled = !extracting && _gameFile is not null; - GameProgressBar.Value = 0; - GameProgressText.Text = t("game_files.extracting"); - } - - private async Task ContinueAfterGameAsync() - { if (_frontend == Frontend.Dolphin) { Finish(_onboarding.FinishDolphin()); diff --git a/WheelWizard/Features/Recomp/Domain/RecompInstallModels.cs b/WheelWizard/Features/Recomp/Domain/RecompInstallModels.cs index 26ae72787..bd2fb7560 100644 --- a/WheelWizard/Features/Recomp/Domain/RecompInstallModels.cs +++ b/WheelWizard/Features/Recomp/Domain/RecompInstallModels.cs @@ -36,12 +36,6 @@ public class RecompInstallState /// public string? RetroWfcPayloadMode { get; set; } - /// - /// The extracted game folder (…/GAME/DATA) the installation reads its game data from. Empty for an - /// installation that still uses a copy of its own, which has to be pointed at Wheel Wizard's before it can launch. - /// - public string? GameDataDir { get; set; } - /// Whether the installed Retro Rewind product was built without a Retro-WFC payload. public bool IsRetroWfcPayloadSkipped => string.Equals(RetroWfcPayloadMode, "skipped", StringComparison.OrdinalIgnoreCase); } @@ -65,11 +59,8 @@ public enum RecompRetroWfcPayloadMode /// public sealed record RecompInstallRequest { - /// - /// Wheel Wizard's extracted game (the folder holding DATA/). WiiCompiled reads it in place instead of - /// extracting a copy of its own. - /// - public required string GameFolderPath { get; init; } + /// Path to the user's Mario Kart Wii disc image (WheelWizard's GameLocation setting). + public required string GameFilePath { get; init; } /// Directory the recomp should install into. public required string InstallFolderPath { get; init; } diff --git a/WheelWizard/Features/Recomp/Domain/RecompSetupEvent.cs b/WheelWizard/Features/Recomp/Domain/RecompSetupEvent.cs index 7ba34aaa6..a4dfb84f8 100644 --- a/WheelWizard/Features/Recomp/Domain/RecompSetupEvent.cs +++ b/WheelWizard/Features/Recomp/Domain/RecompSetupEvent.cs @@ -16,18 +16,6 @@ public sealed record RecompSetupProgressEvent(string Stage, string Message, int /// {"type":"result","success":true,"version":"...","installDir":"..."} or /// {"type":"result","success":false,"error":"..."} /// -/// -/// {"type":"capabilities","capabilities":["game-dir"]}, the answer to --capabilities. A setup too old -/// to know the command rejects it instead, which counts as having none. -/// -public sealed record RecompCapabilitiesEvent(IReadOnlyList Capabilities) : RecompSetupEvent -{ - /// Takes --game-dir and --link-game-dir: reads Wheel Wizard's extracted game in place. - public const string GameDir = "game-dir"; - - public bool Has(string capability) => Capabilities.Contains(capability, StringComparer.OrdinalIgnoreCase); -} - public sealed record RecompSetupResultEvent(bool Success, string? Version, string? InstallDir, string? Error) : RecompSetupEvent; /// diff --git a/WheelWizard/Features/Recomp/RecompEnvironment.cs b/WheelWizard/Features/Recomp/RecompEnvironment.cs index bb0447901..51a6e8ad3 100644 --- a/WheelWizard/Features/Recomp/RecompEnvironment.cs +++ b/WheelWizard/Features/Recomp/RecompEnvironment.cs @@ -1,6 +1,5 @@ using System.IO.Abstractions; using WheelWizard.CustomDistributions; -using WheelWizard.GameFiles; using WheelWizard.Settings; namespace WheelWizard.Recomp; @@ -11,11 +10,8 @@ namespace WheelWizard.Recomp; /// public interface IRecompEnvironment { - /// Wheel Wizard's extracted game (the folder holding DATA/), which WiiCompiled reads in place. - string GameFolderPath { get; } - - /// Whether the extracted game is there to hand over. - bool IsGameReady { get; } + /// The user's Mario Kart Wii disc image. + string GameFilePath { get; } /// The directory the recomp is installed into. string InstallFolderPath { get; } @@ -68,13 +64,11 @@ public interface IRecompEnvironment public sealed class RecompEnvironment( IFileSystem fileSystem, IRecompPaths paths, - ICustomDistributionPaths distributionPaths, - IGameFilesPaths gameFiles + ISettingsManager settings, + ICustomDistributionPaths distributionPaths ) : IRecompEnvironment { - public string GameFolderPath => gameFiles.RootPath; - - public bool IsGameReady => fileSystem.File.Exists(gameFiles.ManifestPath) && fileSystem.File.Exists(gameFiles.MainDolPath); + public string GameFilePath => settings.Get(settings.GAME_LOCATION); public string InstallFolderPath => paths.InstallFolderPath; @@ -116,13 +110,10 @@ public sealed class RecompLinuxEnvironment( IFileSystem fileSystem, IRecompPaths paths, ISettingsManager settings, - ICustomDistributionPaths distributionPaths, - IGameFilesPaths gameFiles + ICustomDistributionPaths distributionPaths ) : IRecompEnvironment { - public string GameFolderPath => gameFiles.RootPath; - - public bool IsGameReady => fileSystem.File.Exists(gameFiles.ManifestPath) && fileSystem.File.Exists(gameFiles.MainDolPath); + public string GameFilePath => settings.Get(settings.GAME_LOCATION); public string InstallFolderPath => paths.LinuxBackendFolderPath; diff --git a/WheelWizard/Features/Recomp/RecompInstallService.cs b/WheelWizard/Features/Recomp/RecompInstallService.cs index d69ed8aef..633fa1767 100644 --- a/WheelWizard/Features/Recomp/RecompInstallService.cs +++ b/WheelWizard/Features/Recomp/RecompInstallService.cs @@ -77,13 +77,6 @@ Task ReconcileForLaunchAsync( /// no registry entry exists and the Retro Rewind installation is deliberately left in place. /// Task UninstallAsync(CancellationToken cancellationToken = default); - - /// - /// Points the installation at Wheel Wizard's extracted game, which also lets it drop the copy it extracted - /// itself. Quick: nothing is rebuilt. Fails when the installed setup is too old to do that, which leaves - /// WiiCompiled asking for an update. - /// - Task LinkGameDataAsync(CancellationToken cancellationToken = default); } /// @@ -191,10 +184,6 @@ public async Task GetCurrentStatusAsync(CancellationToken can if (hasInstalledHost && state != null && !IsCurrentInstallState(state)) return IsGameFileConfigured() ? WheelWizardStatus.OutOfDate : WheelWizardStatus.ConfigNotFinished; - // An installation still reading a game copy of its own can't launch until it reads the extracted game. - if (hasInstalledHost && IsCurrentInstallState(state) && IsGameFileConfigured() && !IsLinkedToGameFiles(state)) - return WheelWizardStatus.OutOfDate; - var installedVersion = IsCurrentInstallState(state) ? state!.SetupVersion : null; var latestRelease = await TryGetLatestReleaseAsync(cancellationToken); var setupUpgradeRequired = @@ -421,40 +410,22 @@ CancellationToken cancellationToken // rebuilds when told the payload choice changed, and --check-products alone never says so. var forceRetroRebuild = state is { IsRetroWfcPayloadSkipped: true } && payloadMode == RecompRetroWfcPayloadMode.Download; - // An installation that still reads a game copy of its own is pointed at the extracted game first. A host - // too old to do that can only be replaced by a release that can. - var needsLink = hasInstalledHost && IsCurrentInstallState(state) && !IsLinkedToGameFiles(state); - if (needsLink && await hosts.SupportsGameDirAsync(environment.InstalledSetupFilePath, cancellationToken)) - { - var linked = await RunLinkGameDataAsync(progress, cancellationToken); - if (linked.IsFailure) - return linked; - needsLink = false; - } - // A repair is deliberately independent of GitHub: once a setup host has been installed, // it owns the installed toolkit/workspace and can bring changed compile inputs current offline. if (release is null) { - if (!needsLink && hasInstalledHost && await InstalledHostCanRepairAsync(installedVersion, cancellationToken)) + if (hasInstalledHost && await InstalledHostCanRepairAsync(installedVersion, cancellationToken)) return await RepairWhatTheCheckDemandsAsync(progress, payloadMode, forceRetroRebuild, cancellationToken); - return Fail( - needsLink - ? t("game_files.wiicompiled_needs_update") - : "Could not verify a current WiiCompiled setup release or installed repair host." - ); + return Fail("Could not verify a current WiiCompiled setup release or installed repair host."); } - if (!needsLink && hasInstalledHost && await InstalledHostCanRepairAsync(release.TagName, cancellationToken)) + if (hasInstalledHost && await InstalledHostCanRepairAsync(release.TagName, cancellationToken)) return await RepairWhatTheCheckDemandsAsync(progress, payloadMode, forceRetroRebuild, cancellationToken); var setupResult = await hosts.EnsureSetupDownloadedAsync(release, environment.CacheFolderPath, progress, cancellationToken); if (setupResult.IsFailure) return setupResult.Error; - // WiiCompiled reads the extracted game in place; a release from before that has nothing to read it with. - if (!await hosts.SupportsGameDirAsync(setupResult.Value, cancellationToken)) - return Fail(t("game_files.wiicompiled_needs_update")); return await RunSilentInstallAsync(setupResult.Value, payloadMode, progress, cancellationToken); } @@ -491,8 +462,6 @@ CancellationToken cancellationToken return Fail("WiiCompiled requires a current install-state.json for its fixed installation path."); if (!await SetupMatchesVersionAsync(environment.InstalledSetupFilePath, state.SetupVersion, cancellationToken)) return Fail("The installed WiiCompiled host does not match its current install state."); - if (!IsLinkedToGameFiles(state)) - return Fail(t("game_files.wiicompiled_needs_update")); // A launch never asks about offline play and never forces the payload upgrade: the user pressed // Play, not Update. A repair the check demands anyway still gains the payload when it is reachable. @@ -565,68 +534,6 @@ private async Task LaunchCoreAsync(CancellationToken cancellati return launchResult.Value == 0 ? Ok() : Fail($"WiiCompiled exited with code {launchResult.Value}."); } - public async Task LinkGameDataAsync(CancellationToken cancellationToken = default) - { - if (!await _operationGate.WaitAsync(0, cancellationToken)) - return Fail(OperationAlreadyRunningMessage); - - try - { - if (!IsInstalled) - return Ok(); - var state = ReadCurrentInstallState(); - if (state is null) - return Fail("WiiCompiled requires a current install-state.json for its fixed installation path."); - if (IsLinkedToGameFiles(state)) - return Ok(); - if (!IsGameFileConfigured()) - return Fail(t("game_files.not_extracted")); - if (!await hosts.SupportsGameDirAsync(environment.InstalledSetupFilePath, cancellationToken)) - return Fail(t("game_files.wiicompiled_needs_update")); - return await RunLinkGameDataAsync(progress: null, cancellationToken); - } - finally - { - _operationGate.Release(); - } - } - - private async Task RunLinkGameDataAsync( - IProgress? progress, - CancellationToken cancellationToken - ) - { - var arguments = RecompSetupCommandBuilder.BuildLinkGameDirArguments(environment.InstallFolderPath, environment.GameFolderPath); - logger.LogInformation("Pointing WiiCompiled at the extracted game: {Arguments}", string.Join(' ', arguments)); - var resultHolder = new EventHolder(); - OperationResult runResult; - try - { - runResult = await processRunner.RunAsync( - environment.InstalledSetupFilePath, - arguments, - environment.InstallFolderPath, - line => HandleSetupOutput(line, progress, resultHolder), - cancellationToken - ); - } - finally - { - _launchReconciled = false; - } - - var finished = FinishSetupRun(runResult, resultHolder, progress, reportCompletion: false); - if (finished.IsFailure) - return finished; - return IsLinkedToGameFiles(ReadInstalledState()) - ? Ok() - : Fail("WiiCompiled finished, but does not report reading the extracted game."); - } - - /// Whether the installation reads Wheel Wizard's extracted game rather than a copy of its own. - private bool IsLinkedToGameFiles(RecompInstallState? state) => - state is not null && PathsMatch(state.GameDataDir, fileSystem.Path.Combine(environment.GameFolderPath, "DATA")); - public async Task UninstallAsync(CancellationToken cancellationToken = default) { if (!await _operationGate.WaitAsync(0, cancellationToken)) @@ -700,7 +607,7 @@ CancellationToken cancellationToken { var request = new RecompInstallRequest { - GameFolderPath = environment.GameFolderPath, + GameFilePath = environment.GameFilePath, InstallFolderPath = environment.InstallFolderPath, RetroRewindFolderPath = environment.RetroRewindFolderPath, Portable = environment.IsPortableInstall, @@ -950,7 +857,11 @@ private bool PathsMatch(string? first, string? second) } } - private bool IsGameFileConfigured() => environment.IsGameReady; + private bool IsGameFileConfigured() + { + var gameFilePath = environment.GameFilePath; + return !string.IsNullOrWhiteSpace(gameFilePath) && fileSystem.File.Exists(gameFilePath); + } private static void Report(IProgress? progress, string message, int percent) => RecompSetupHostAcquirer.Report(progress, message, percent); diff --git a/WheelWizard/Features/Recomp/RecompLinuxInstallService.cs b/WheelWizard/Features/Recomp/RecompLinuxInstallService.cs index 0e0c74401..790325488 100644 --- a/WheelWizard/Features/Recomp/RecompLinuxInstallService.cs +++ b/WheelWizard/Features/Recomp/RecompLinuxInstallService.cs @@ -131,10 +131,6 @@ public async Task GetCurrentStatusAsync(CancellationToken can if (hasInstalledHost && state != null && !IsCurrentInstallState(state)) return IsGameFileConfigured() ? WheelWizardStatus.OutOfDate : WheelWizardStatus.ConfigNotFinished; - // An installation still reading a game copy of its own can't launch until it reads the extracted game. - if (hasInstalledHost && IsCurrentInstallState(state) && IsGameFileConfigured() && !IsLinkedToGameFiles(state)) - return WheelWizardStatus.OutOfDate; - var installedVersion = IsCurrentInstallState(state) ? state!.SetupVersion : null; var ignoreRecompRelease = IsBundledRecomp(environment.InstalledSetupFilePath); var latestReleaseVersion = (ignoreRecompRelease ? null : await hosts.TryGetLatestReleaseAsync(cancellationToken))?.Version; @@ -259,23 +255,11 @@ CancellationToken cancellationToken // If Wheel Wizard is running as a Flatpak, the setup file path should never be an AppImage var ignoreRecompRelease = IsBundledRecomp(environment.InstalledSetupFilePath); - // An installation that still reads a game copy of its own is pointed at the extracted game first. A host - // too old to do that can only be replaced by a release that can. - var needsLink = HasInstalledHost && IsCurrentInstallState(state) && !IsLinkedToGameFiles(state); - if (needsLink && await hosts.SupportsGameDirAsync(environment.InstalledSetupFilePath, cancellationToken)) - { - var linked = await RunLinkGameDataAsync(progress, cancellationToken); - if (linked.IsFailure) - return linked; - needsLink = false; - state = ReadInstalledState(); - } - // Avoid fetching the GitHub release if we don't want it var release = ignoreRecompRelease ? null : await hosts.TryGetLatestReleaseAsync(cancellationToken); // The installed host repairs on its own, offline included, as long as no newer release exists. - if (!needsLink && await InstalledHostIsCurrentAsync(state, release, cancellationToken)) + if (await InstalledHostIsCurrentAsync(state, release, cancellationToken)) return await RepairWhatTheCheckDemandsAsync( progress, payloadMode, @@ -286,25 +270,14 @@ CancellationToken cancellationToken // Return early in case we have to use the bundled recomp if (ignoreRecompRelease) - { - if (!await hosts.SupportsGameDirAsync(environment.InstalledSetupFilePath, cancellationToken)) - return Fail(t("game_files.wiicompiled_needs_update")); return await RunFullInstallAsync(environment.InstalledSetupFilePath, null, payloadMode, progress, cancellationToken); - } if (release is null) - return Fail( - needsLink - ? t("game_files.wiicompiled_needs_update") - : "Could not verify a current WiiCompiled setup release or installed repair host." - ); + return Fail("Could not verify a current WiiCompiled setup release or installed repair host."); var setupResult = await hosts.EnsureSetupDownloadedAsync(release, environment.CacheFolderPath, progress, cancellationToken); if (setupResult.IsFailure) return setupResult.Error; - // WiiCompiled reads the extracted game in place; a release from before that has nothing to read it with. - if (!await hosts.SupportsGameDirAsync(setupResult.Value, cancellationToken)) - return Fail(t("game_files.wiicompiled_needs_update")); return await RunFullInstallAsync(setupResult.Value, release, payloadMode, progress, cancellationToken); } @@ -336,7 +309,7 @@ CancellationToken cancellationToken ) { var arguments = RecompLinuxSetupCommandBuilder.BuildInstallArguments( - environment.GameFolderPath, + environment.GameFilePath, environment.RetroRewindFolderPath, payloadMode ); @@ -416,8 +389,6 @@ private Task RecordInstalledHostAsync( InstallDir = environment.InstallFolderPath, RetroRewindInstalled = retroRewindInstalled, RetroWfcPayloadMode = retroRewindInstalled ? PayloadModeName(payloadMode) : string.Empty, - // A full install always reads the extracted game in place. - GameDataDir = GameDataFolderPath, } ); }, @@ -457,8 +428,6 @@ CancellationToken cancellationToken return Fail(StaleStateMessage); if (!await hosts.SetupMatchesVersionAsync(environment.InstalledSetupFilePath, state.SetupVersion, cancellationToken)) return Fail("The installed WiiCompiled host does not match its current install state."); - if (!IsLinkedToGameFiles(state)) - return Fail(t("game_files.wiicompiled_needs_update")); // A launch never asks about offline play and never forces the payload upgrade: the user pressed // Play, not Update. A repair the check demands anyway still gains the payload when it is reachable. @@ -524,77 +493,6 @@ private async Task LaunchCoreAsync(CancellationToken cancellati return launchResult.Value == 0 ? Ok() : Fail($"WiiCompiled exited with code {launchResult.Value}."); } - public async Task LinkGameDataAsync(CancellationToken cancellationToken = default) - { - if (!await _operationGate.WaitAsync(0, cancellationToken)) - return Fail(OperationAlreadyRunningMessage); - - try - { - if (!IsInstalled) - return Ok(); - var state = ReadCurrentInstallState(); - if (state is null) - return Fail(StaleStateMessage); - if (IsLinkedToGameFiles(state)) - return Ok(); - if (!IsGameFileConfigured()) - return Fail(t("game_files.not_extracted")); - if (!await hosts.SupportsGameDirAsync(environment.InstalledSetupFilePath, cancellationToken)) - return Fail(t("game_files.wiicompiled_needs_update")); - return await RunLinkGameDataAsync(progress: null, cancellationToken); - } - finally - { - _operationGate.Release(); - } - } - - private async Task RunLinkGameDataAsync( - IProgress? progress, - CancellationToken cancellationToken - ) - { - var arguments = RecompLinuxSetupCommandBuilder.BuildLinkGameDirArguments(environment.GameFolderPath); - logger.LogInformation("Pointing WiiCompiled at the extracted game: {Arguments}", string.Join(' ', arguments)); - var resultHolder = new EventHolder(); - OperationResult runResult; - try - { - runResult = await processRunner.RunAsync( - environment.InstalledSetupFilePath, - arguments, - workingDirectory: null, - line => HandleSetupOutput(line, progress, resultHolder), - cancellationToken - ); - } - finally - { - _launchReconciled = false; - } - - var finished = FinishSetupRun(runResult, resultHolder); - if (finished.IsFailure) - return finished; - - // The AppImage keeps its own state; this is Wheel Wizard's record that it now reads the extracted game. - return TryCatch( - () => - { - var state = ReadInstalledState() ?? throw new InvalidOperationException(StaleStateMessage); - state.GameDataDir = GameDataFolderPath; - WriteInstallState(state); - }, - errorMessage: "WiiCompiled now reads the extracted game, but Wheel Wizard could not record that." - ); - } - - private string GameDataFolderPath => fileSystem.Path.Combine(environment.GameFolderPath, "DATA"); - - /// Whether the installation reads Wheel Wizard's extracted game rather than a copy of its own. - private bool IsLinkedToGameFiles(RecompInstallState? state) => state is not null && PathsMatch(state.GameDataDir, GameDataFolderPath); - public async Task UninstallAsync(CancellationToken cancellationToken = default) { if (!await _operationGate.WaitAsync(0, cancellationToken)) @@ -720,7 +618,7 @@ CancellationToken cancellationToken if (string.IsNullOrWhiteSpace(retroRewindFolderPath)) return Fail("Retro Rewind must be installed before WiiCompiled can repair or launch."); - var arguments = RecompLinuxSetupCommandBuilder.BuildInstallArguments(gameFolderPath: null, retroRewindFolderPath, payloadMode); + var arguments = RecompLinuxSetupCommandBuilder.BuildInstallArguments(gameFilePath: null, retroRewindFolderPath, payloadMode); logger.LogInformation( "Running the recomp setup: {Setup} {Arguments}", environment.InstalledSetupFilePath, @@ -920,7 +818,11 @@ private bool PathsMatch(string? first, string? second) } } - private bool IsGameFileConfigured() => environment.IsGameReady; + private bool IsGameFileConfigured() + { + var gameFilePath = environment.GameFilePath; + return !string.IsNullOrWhiteSpace(gameFilePath) && fileSystem.File.Exists(gameFilePath); + } private static void Report(IProgress? progress, string message, int percent) => RecompSetupHostAcquirer.Report(progress, message, percent); diff --git a/WheelWizard/Features/Recomp/RecompLinuxSetupCommandBuilder.cs b/WheelWizard/Features/Recomp/RecompLinuxSetupCommandBuilder.cs index 54512f5bd..caa58496e 100644 --- a/WheelWizard/Features/Recomp/RecompLinuxSetupCommandBuilder.cs +++ b/WheelWizard/Features/Recomp/RecompLinuxSetupCommandBuilder.cs @@ -13,21 +13,21 @@ namespace WheelWizard.Recomp; public static class RecompLinuxSetupCommandBuilder { /// - /// Builds the install command. With the AppImage validates and reads - /// Wheel Wizard's extracted game in place; without it, it reuses the game it already knows and only + /// Builds the install command. With the AppImage validates and + /// extracts the disc first; without it, it reuses the assets it already extracted and only /// retranslates and recompiles what changed, which is how a repair or a Retro Rewind update is run. /// public static IReadOnlyList BuildInstallArguments( - string? gameFolderPath, + string? gameFilePath, string? retroRewindFolderPath, RecompRetroWfcPayloadMode retroWfcPayloadMode = RecompRetroWfcPayloadMode.Download ) { var arguments = new List { "install" }; - if (!string.IsNullOrWhiteSpace(gameFolderPath)) + if (!string.IsNullOrWhiteSpace(gameFilePath)) { - arguments.Add("--game-dir"); - arguments.Add(gameFolderPath); + arguments.Add("--game"); + arguments.Add(gameFilePath); } // The AppImage requires exactly one payload option whenever a Retro Rewind source is passed, @@ -53,12 +53,4 @@ public static IReadOnlyList BuildInstallArguments( /// Makes the AppImage print its own semantic version. public static IReadOnlyList BuildVersionArguments() => ["--version"]; - - /// Points the installation at Wheel Wizard's extracted game without rebuilding anything. - public static IReadOnlyList BuildLinkGameDirArguments(string gameFolderPath) - { - if (string.IsNullOrWhiteSpace(gameFolderPath)) - throw new ArgumentException("The extracted game folder is required.", nameof(gameFolderPath)); - return ["link-game-dir", "--game-dir", gameFolderPath, "--progress-json"]; - } } diff --git a/WheelWizard/Features/Recomp/RecompSetupCommandBuilder.cs b/WheelWizard/Features/Recomp/RecompSetupCommandBuilder.cs index 943232795..15c3c0979 100644 --- a/WheelWizard/Features/Recomp/RecompSetupCommandBuilder.cs +++ b/WheelWizard/Features/Recomp/RecompSetupCommandBuilder.cs @@ -21,12 +21,12 @@ public static class RecompSetupCommandBuilder public static IReadOnlyList BuildSilentInstallArguments(RecompInstallRequest request) { ArgumentNullException.ThrowIfNull(request); - if (string.IsNullOrWhiteSpace(request.GameFolderPath)) - throw new ArgumentException("The extracted game folder is required.", nameof(request)); + if (string.IsNullOrWhiteSpace(request.GameFilePath)) + throw new ArgumentException("A game image path is required.", nameof(request)); if (string.IsNullOrWhiteSpace(request.InstallFolderPath)) throw new ArgumentException("An install directory is required.", nameof(request)); - var arguments = new List { "--silent", "--game-dir", request.GameFolderPath, "--install-dir", request.InstallFolderPath }; + var arguments = new List { "--silent", "--game", request.GameFilePath, "--install-dir", request.InstallFolderPath }; // Only a silent install may request the portable layout, and only when WheelWizard actually // handed over its own portable location. An installation that predates portable mode keeps @@ -105,23 +105,6 @@ public static IReadOnlyList BuildCheckProductsArguments(string installFo /// public static IReadOnlyList BuildVersionArguments() => ["--version"]; - /// Asks the setup what it supports. Shared by both platforms' setups. - public static IReadOnlyList BuildCapabilitiesArguments() => ["--capabilities"]; - - /// - /// Points an existing installation at Wheel Wizard's extracted game and drops the copy it extracted itself, - /// without rebuilding anything. - /// - public static IReadOnlyList BuildLinkGameDirArguments(string installFolderPath, string gameFolderPath) - { - if (string.IsNullOrWhiteSpace(installFolderPath)) - throw new ArgumentException("An install directory is required.", nameof(installFolderPath)); - if (string.IsNullOrWhiteSpace(gameFolderPath)) - throw new ArgumentException("The extracted game folder is required.", nameof(gameFolderPath)); - - return ["--link-game-dir", "--install-dir", installFolderPath, "--game-dir", gameFolderPath, "--progress-json"]; - } - // The contract requires exactly one payload option whenever --retro-dir is passed. WheelWizard // downloads unless the payload service is unreachable and the user chose an offline-only build. private static string RetroWfcPayloadArgument(RecompRetroWfcPayloadMode mode) => diff --git a/WheelWizard/Features/Recomp/RecompSetupHostAcquirer.cs b/WheelWizard/Features/Recomp/RecompSetupHostAcquirer.cs index add5fc738..a863fd682 100644 --- a/WheelWizard/Features/Recomp/RecompSetupHostAcquirer.cs +++ b/WheelWizard/Features/Recomp/RecompSetupHostAcquirer.cs @@ -114,28 +114,6 @@ public async Task SetupMatchesVersionAsync(string setupFilePath, string? e && cachedVersion.ComparePrecedenceTo(expected) == 0; } - /// - /// Whether the setup can read Wheel Wizard's extracted game in place (--game-dir, --link-game-dir). - /// A setup from before that rejects --capabilities, which reads as no. - /// - public async Task SupportsGameDirAsync(string setupFilePath, CancellationToken cancellationToken) - { - RecompCapabilitiesEvent? capabilities = null; - var runResult = await processRunner.RunAsync( - setupFilePath, - RecompSetupCommandBuilder.BuildCapabilitiesArguments(), - workingDirectory: null, - line => - { - if (RecompSetupOutputParser.Parse(line) is RecompCapabilitiesEvent parsed) - capabilities = parsed; - }, - cancellationToken - ); - - return runResult.IsSuccess && runResult.Value == 0 && capabilities?.Has(RecompCapabilitiesEvent.GameDir) == true; - } - public static bool VersionsMatch(string? first, string? second) => RecompVersion.TryParse(first, out var parsedFirst) && RecompVersion.TryParse(second, out var parsedSecond) diff --git a/WheelWizard/Features/Recomp/RecompSetupOutputParser.cs b/WheelWizard/Features/Recomp/RecompSetupOutputParser.cs index 74c8f25ee..937a4eadb 100644 --- a/WheelWizard/Features/Recomp/RecompSetupOutputParser.cs +++ b/WheelWizard/Features/Recomp/RecompSetupOutputParser.cs @@ -38,7 +38,6 @@ public static class RecompSetupOutputParser "progress" => ParseProgress(root), "result" => ParseResult(root), "products" => ParseProducts(root), - "capabilities" => ParseCapabilities(root), _ => null, }; } @@ -48,21 +47,6 @@ public static class RecompSetupOutputParser } } - private static RecompCapabilitiesEvent ParseCapabilities(JsonElement root) - { - var capabilities = new List(); - if (root.TryGetProperty("capabilities", out var list) && list.ValueKind == JsonValueKind.Array) - { - foreach (var item in list.EnumerateArray()) - { - if (item.ValueKind == JsonValueKind.String && item.GetString() is { } capability) - capabilities.Add(capability); - } - } - - return new(capabilities); - } - private static RecompSetupProgressEvent ParseProgress(JsonElement root) => new(GetString(root, "stage") ?? string.Empty, GetString(root, "message") ?? string.Empty, GetPercent(root, "percent")); diff --git a/WheelWizard/Features/Settings/SettingsManager.cs b/WheelWizard/Features/Settings/SettingsManager.cs index 2e0bb1c69..d31f5520a 100644 --- a/WheelWizard/Features/Settings/SettingsManager.cs +++ b/WheelWizard/Features/Settings/SettingsManager.cs @@ -2,7 +2,6 @@ using WheelWizard.ApplicationData; using WheelWizard.Dolphin.Paths; using WheelWizard.DolphinInstaller; -using WheelWizard.GameFiles; using WheelWizard.Models.Enums; using WheelWizard.Recomp; using WheelWizard.Settings.Types; @@ -425,7 +424,7 @@ private OperationResult ValidatePathSettings(bool requ new(SettingsValidationCode.InvalidDolphinLocation, DOLPHIN_LOCATION.Name, "Dolphin path or command is invalid.") ); - if (!IsGameAvailable()) + if (!GAME_LOCATION.IsValid()) issues.Add(new(SettingsValidationCode.InvalidGameLocation, GAME_LOCATION.Name, "Game file path is invalid.")); return Ok(new SettingsValidationReport(issues)); @@ -436,16 +435,6 @@ private OperationResult ValidatePathSettings(bool requ } } - /// - /// The extracted game is what gets played; once it is there the image it came from may be deleted or unplugged. - /// Before that, the picked image still counts, and the game setup page takes it from there. - /// - private bool IsGameAvailable() - { - var game = new GameFilesPaths(_applicationData, _fileSystem); - return (_fileSystem.File.Exists(game.ManifestPath) && _fileSystem.File.Exists(game.MainDolPath)) || GAME_LOCATION.IsValid(); - } - public void LoadSettings() { if (_hasLoadedSettings) diff --git a/WheelWizard/Features/Settings/Views/WhWzSettings.axaml.cs b/WheelWizard/Features/Settings/Views/WhWzSettings.axaml.cs index 044d8dbcd..5d31ffbaf 100644 --- a/WheelWizard/Features/Settings/Views/WhWzSettings.axaml.cs +++ b/WheelWizard/Features/Settings/Views/WhWzSettings.axaml.cs @@ -8,8 +8,6 @@ using WheelWizard.Dolphin.Discovery; using WheelWizard.Dolphin.Paths; using WheelWizard.DolphinInstaller; -using WheelWizard.GameFiles; -using WheelWizard.GameFiles.Views; using WheelWizard.Models.Enums; using WheelWizard.Recomp; using WheelWizard.Settings; @@ -51,10 +49,6 @@ public partial class WhWzSettings : UserControl private IServiceProvider Services { get; } - private IGameFilesService GameFiles => Services.GetRequiredService(); - - private IGameFilesPaths GameFilesPaths => Services.GetRequiredService(); - public WhWzSettings( IApplicationLogFiles logFiles, IFilePickerService filePicker, @@ -237,29 +231,37 @@ private async void GameLocationBrowse_OnClick(object sender, RoutedEventArgs e) if (string.IsNullOrEmpty(filePath)) return; - // The flow checks the file, asks before replacing an extracted game, and extracts it. - var changed = await Services.GetRequiredService().ChangeImageAsync(filePath); - UpdateLocationRows(); - if (!changed) + var info = DiscImages.ReadInfo(filePath); + if (info.IsFailure || GameRegion.FromGameId(info.Value.GameId) == MarioKartWiiEnums.Regions.None) + { + var reason = info.IsFailure + ? t("message_error.invalid_game_file.unreadable") + : t("message_error.invalid_game_file.wrong_game", new { name = info.Value.InternalName, id = info.Value.GameId }); + await new MessageBoxWindow() + .SetMessageType(MessageBoxWindow.MessageType.Error) + .SetTitleText(t("message_error.invalid_game_file.title")) + .SetInfoText(reason) + .ShowDialog(); return; + } - // A game from another region switches to that region's save. - var layout = ViewUtils.GetLayout(); - layout.UpdateSidebarProfile(); - layout.UpdateFriendCount(); - await MessageTranslationHelper.AwaitMessageAsync(MessageTranslation.Success_PathSettingsSaved); + if (await ApplyLocationSettingAsync(SettingsService.GAME_LOCATION, filePath)) + { + // A game from another region switches to that region's save. + var layout = ViewUtils.GetLayout(); + layout.UpdateSidebarProfile(); + layout.UpdateFriendCount(); + } } private void UpdateGameFileDetails() { - var status = GameFiles.GetStatus(); - var region = GameRegion.FromGameId(status.Manifest?.GameId); + var info = DiscImages.ReadInfo(SettingsService.Get(SettingsService.GAME_LOCATION)); + var region = info.IsSuccess ? GameRegion.FromGameId(info.Value.GameId) : MarioKartWiiEnums.Regions.None; MarioKartHelperText.Text = - status.Manifest is not null && region != MarioKartWiiEnums.Regions.None - ? $"Mario Kart Wii · {GameRegion.GetDisplayName(region)} · {t("game_files.status_extracted")}" + region != MarioKartWiiEnums.Regions.None + ? $"Mario Kart Wii · {GameRegion.GetDisplayName(region)} · {info.Value.Format.ToString().ToUpperInvariant()}" : t("helper_text.end_with_x") + " " + string.Join("/", DiscImages.SupportedExtensions); - // The image the game was extracted from, which is no longer needed but good to know. - ToolTip.SetTip(MarioKartHelperText, status.ImagePath); } private async void DolphinUserPathBrowse_OnClick(object sender, RoutedEventArgs e) @@ -340,11 +342,8 @@ private async Task ApplyLocationSettingAsync(Setting setting, stri private void DolphinExecutableOpen_OnClick(object sender, RoutedEventArgs e) => OpenContainingFolder(DolphinPaths.ExecutablePath); - private void GameLocationOpen_OnClick(object sender, RoutedEventArgs e) - { - if (Directory.Exists(GameFilesPaths.RootPath)) - FilePicker.OpenFolderInFileManager(GameFilesPaths.RootPath); - } + private void GameLocationOpen_OnClick(object sender, RoutedEventArgs e) => + OpenContainingFolder(SettingsService.Get(SettingsService.GAME_LOCATION)); private void DolphinUserFolderOpen_OnClick(object sender, RoutedEventArgs e) { @@ -383,7 +382,13 @@ private void UpdateLocationRows() DolphinExecutableChangeButton, SettingsService.DOLPHIN_LOCATION.IsValid() && !string.IsNullOrWhiteSpace(DolphinPaths.ExecutablePath) ); - SetLocationRowState(GameLocationCompleteIcon, GameLocationWarningIcon, GameLocationChangeButton, GameFiles.GetStatus().IsReady); + SetLocationRowState( + GameLocationCompleteIcon, + GameLocationWarningIcon, + GameLocationChangeButton, + SettingsService.GAME_LOCATION.IsValid() + && !string.IsNullOrWhiteSpace(SettingsService.Get(SettingsService.GAME_LOCATION)) + ); SetLocationRowState( DolphinUserFolderCompleteIcon, DolphinUserFolderWarningIcon, @@ -400,7 +405,7 @@ private void UpdateLocationRows() DolphinExecutableOpenButton.IsVisible = !OperatingSystem.IsLinux() || IsConfiguredExecutableFile(DolphinPaths.ExecutablePath); DolphinExecutableOpenButton.IsEnabled = DolphinExecutableOpenButton.IsVisible && CanOpenContainingFolder(DolphinPaths.ExecutablePath); - GameLocationOpenButton.IsEnabled = Directory.Exists(GameFilesPaths.RootPath); + GameLocationOpenButton.IsEnabled = CanOpenContainingFolder(SettingsService.Get(SettingsService.GAME_LOCATION)); UpdateGameFileDetails(); DolphinUserFolderOpenButton.IsEnabled = Directory.Exists(DolphinPaths.UserFolderPath); } @@ -660,8 +665,6 @@ private async Task MoveWheelWizardDataAsync(string targetPath) { await HandleSuccessfulAppdataMoveAsync(details, errorMessage); FollowRelocatedDataFolder(); - // The extracted game moved with the data folder; WiiCompiled still names the old place until relinked. - await Services.GetRequiredService().LinkWiiCompiledAsync(); } else { diff --git a/WheelWizard/Features/WiiManagement/GameLicense/SaveRegionService.cs b/WheelWizard/Features/WiiManagement/GameLicense/SaveRegionService.cs index 60a311568..5dceb68c0 100644 --- a/WheelWizard/Features/WiiManagement/GameLicense/SaveRegionService.cs +++ b/WheelWizard/Features/WiiManagement/GameLicense/SaveRegionService.cs @@ -1,5 +1,5 @@ using System.IO.Abstractions; -using WheelWizard.GameFiles; +using WheelWizard.DiscImages; using WheelWizard.Models.Enums; using WheelWizard.Settings; @@ -9,7 +9,7 @@ public interface ISaveRegionService { List GetAvailableRegions(string saveDirectory); - /// The region of the extracted game (or of the picked image before that), or None when it can't be read. + /// The region of the Mario Kart Wii game file in the settings, or None when it can't be read. MarioKartWiiEnums.Regions GetGameFileRegion(); /// @@ -19,7 +19,7 @@ public interface ISaveRegionService MarioKartWiiEnums.Regions ResolveActiveRegion(string saveDirectory); } -public sealed class SaveRegionService(IFileSystem fileSystem, ISettingsManager settings, IGameFilesService gameFiles) : ISaveRegionService +public sealed class SaveRegionService(IFileSystem fileSystem, ISettingsManager settings, IDiscImageService discImages) : ISaveRegionService { public List GetAvailableRegions(string saveDirectory) { @@ -49,7 +49,11 @@ public sealed class SaveRegionService(IFileSystem fileSystem, ISettingsManager s return validRegions; } - public MarioKartWiiEnums.Regions GetGameFileRegion() => gameFiles.GetRegion(); + public MarioKartWiiEnums.Regions GetGameFileRegion() + { + var info = discImages.ReadInfo(settings.Get(settings.GAME_LOCATION)); + return info.IsSuccess ? GameRegion.FromGameId(info.Value.GameId) : MarioKartWiiEnums.Regions.None; + } public MarioKartWiiEnums.Regions ResolveActiveRegion(string saveDirectory) { diff --git a/WheelWizard/Resources/Languages/en.yml b/WheelWizard/Resources/Languages/en.yml index f2fbff7c7..ed776395e 100644 --- a/WheelWizard/Resources/Languages/en.yml +++ b/WheelWizard/Resources/Languages/en.yml @@ -815,50 +815,6 @@ en: repair_user_folder: "Wheel Wizard can't find your Dolphin folder. Is the drive it's on connected?" repair_game: "Wheel Wizard can't find your game file. Is the drive it's on connected?" repair_action: "Fix in Settings" - game_files: - setup_title: "Prepare your game files" - setup_body: "Wheel Wizard now plays Mario Kart Wii from its own extracted copy of your game, so it can use the game's files directly. This takes about a minute and needs around 3 GB. Your game file is only read, never changed." - missing_title: "Find your game file" - missing_body: "Wheel Wizard needs your game file once more to prepare the game, but can't find it. Is the drive it's on connected?" - modified_title: "Game code was changed" - modified_body: "main.dol or StaticR.rel in the game folder no longer match your game. These two files have to stay unedited, so Wheel Wizard restores them from your game file. Everything else you changed stays as it is." - unknown_folder_title: "A game folder is already there" - unknown_folder_body: "Wheel Wizard found a GAME folder it didn't finish creating. Move it aside to keep what's in it, or delete it." - extract_action: "Prepare game files" - restore_action: "Restore" - pick_other: "Use a different game file" - pick_game: "Select game file" - open_settings: "Open Settings" - move_aside: "Move it aside" - delete: "Delete it" - delete_confirm_title: "Delete the GAME folder?" - delete_confirm_body: "Everything in it is deleted. If it is a link to another folder, only the link is removed." - replace_title: "Replace your game files?" - replace_body: "Your extracted %{current} will be replaced by %{incoming}. Any changes you made in the GAME folder are deleted." - replace_breaks_wiicompiled: "WiiCompiled only supports the European (PAL) game, so it stops working until you switch back." - replace_action: "Replace" - replace_not_confirmed: "Replacing the extracted game has to be confirmed first." - close_game_title: "Close the game to continue" - close_game_body: "Dolphin or WiiCompiled is still running and using the game files. Close it to continue; this continues by itself once it's closed." - close_game_action: "Close it for me" - extracting_title: "Preparing game files" - extracting: "Extracting the game" - finishing: "Finishing up" - cancel: "Cancel" - extract_failed_title: "The game files could not be prepared" - extract_failed: "Extracting the game failed." - not_enough_space: "There is not enough free space. Preparing the game needs %{needed}, but only %{available} is free. Free up space, or move Wheel Wizard's data folder to a bigger drive in Settings." - link_to_drive_root: "The GAME folder links to the root of a drive. Link it to a folder instead." - image_inside_game_folder: "Pick a game file that is not inside Wheel Wizard's GAME folder." - not_extracted: "The game is not extracted yet." - not_unknown: "The GAME folder belongs to Wheel Wizard and does not need to be moved." - restore_wrong_game: "Pick the same game you extracted (%{id})." - move_aside_failed: "The GAME folder could not be moved aside." - delete_failed: "The GAME folder could not be deleted." - busy: "The game files are already being prepared." - linking_wiicompiled: "Updating WiiCompiled" - status_extracted: "extracted" - wiicompiled_needs_update: "WiiCompiled needs an update that can use your extracted game files. Update it from Home once one is available." mod_browser: title: "Browse mods" diff --git a/WheelWizard/SetupExtensions.cs b/WheelWizard/SetupExtensions.cs index 48d67ea11..650cfff8d 100644 --- a/WheelWizard/SetupExtensions.cs +++ b/WheelWizard/SetupExtensions.cs @@ -10,7 +10,6 @@ using WheelWizard.Features.Archives; using WheelWizard.Features.Patches; using WheelWizard.GameBanana; -using WheelWizard.GameFiles; using WheelWizard.GitHub; using WheelWizard.Launching; using WheelWizard.Localization; @@ -47,7 +46,6 @@ public static void AddWheelWizardServices(this IServiceCollection services, IApp services.AddRrRooms(); services.AddWhWzData(); services.AddDiscImages(); - services.AddGameFiles(); services.AddWiiManagement(); services.AddGameBanana(); services.AddMiiImages(); diff --git a/WheelWizard/Shared/IO/DirectoryTransferService.cs b/WheelWizard/Shared/IO/DirectoryTransferService.cs index 4bdff1cd5..2122920e0 100644 --- a/WheelWizard/Shared/IO/DirectoryTransferService.cs +++ b/WheelWizard/Shared/IO/DirectoryTransferService.cs @@ -99,22 +99,9 @@ DirectoryMoveContentsResult CreateResult( progress?.Report(0.0); - // A linked folder (e.g. a game folder the user put on another drive) moves as a link; only when the link can't - // be recreated here are its contents copied instead. Deleting the source then removes the link, never its target. - var directories = new List(); - var files = new List(); - var sourceLinks = new List(); - try - { - Collect(normalizedSource, normalizedSource, normalizedDestination, directories, files, sourceLinks, insideLink: false); - } - catch (Exception ex) - { - progress?.Report(1.0); - return CreateResult(DirectoryMoveOutcome.CopyFailed, errorMessage: $"Failed to copy data: {ex.Message}", exception: ex); - } - - var totalSteps = directories.Count + files.Count; + var directories = fileSystem.Directory.GetDirectories(normalizedSource, "*", SearchOption.AllDirectories); + var files = fileSystem.Directory.GetFiles(normalizedSource, "*", SearchOption.AllDirectories); + var totalSteps = directories.Length + files.Length; var processedSteps = 0; void ReportProgress() @@ -131,7 +118,7 @@ void ReportProgress() progress.Report(Math.Clamp((double)processedSteps / totalSteps, 0.0, 1.0)); } - var copyAttempted = directories.Count > 0 || files.Count > 0; + var copyAttempted = directories.Length > 0 || files.Length > 0; try { @@ -226,11 +213,6 @@ void ReportProgress() { try { - // Links (recreated at the destination, or copied through when that wasn't possible) go first, on their - // own: that keeps the recursive delete away from their targets and from junctions, which Windows - // refuses to delete that way. Deepest first, in case a linked folder held links of its own. - foreach (var link in sourceLinks.OrderByDescending(link => link.Length)) - DeleteLink(link); fileSystem.Directory.Delete(normalizedSource, true); } catch (Exception ex) @@ -255,64 +237,4 @@ void ReportProgress() sourceDeletionSucceeded: true ); } - - private void Collect( - string sourceRoot, - string folder, - string destinationRoot, - List directories, - List files, - List sourceLinks, - bool insideLink - ) - { - foreach (var directory in fileSystem.Directory.EnumerateDirectories(folder)) - { - var link = fileSystem.DirectoryInfo.New(directory).LinkTarget; - var isLink = !string.IsNullOrEmpty(link); - if (isLink) - { - // Only links in the folder itself are removed later; one inside a linked folder is not ours to touch. - if (!insideLink) - sourceLinks.Add(directory); - if (TryRecreateLink(directory, link!, sourceRoot, destinationRoot)) - continue; - } - - directories.Add(directory); - Collect(sourceRoot, directory, destinationRoot, directories, files, sourceLinks, insideLink || isLink); - } - - files.AddRange(fileSystem.Directory.EnumerateFiles(folder)); - } - - private void DeleteLink(string path) - { - if (fileSystem.Path.DirectorySeparatorChar == '\\') - fileSystem.Directory.Delete(path, recursive: false); - else - fileSystem.File.Delete(path); - } - - private bool TryRecreateLink(string linkPath, string target, string sourceRoot, string destinationRoot) - { - try - { - var destination = fileSystem.Path.Combine(destinationRoot, fileSystem.Path.GetRelativePath(sourceRoot, linkPath)); - if (fileSystem.Directory.Exists(destination) || fileSystem.File.Exists(destination)) - return false; - fileSystem.Directory.CreateDirectory(fileSystem.Path.GetDirectoryName(destination)!); - // A relative target keeps pointing at the same place only when it is resolved from the old folder. - var absoluteTarget = fileSystem.Path.IsPathRooted(target) - ? target - : fileSystem.Path.GetFullPath(fileSystem.Path.Combine(fileSystem.Path.GetDirectoryName(linkPath)!, target)); - fileSystem.Directory.CreateSymbolicLink(destination, absoluteTarget); - return true; - } - catch - { - // Windows only lets some accounts create links; the contents are copied instead. - return false; - } - } } diff --git a/WheelWizard/Views/Dialogs/YesNoWindow.axaml.cs b/WheelWizard/Views/Dialogs/YesNoWindow.axaml.cs index 6ffa16873..2c5f553f5 100644 --- a/WheelWizard/Views/Dialogs/YesNoWindow.axaml.cs +++ b/WheelWizard/Views/Dialogs/YesNoWindow.axaml.cs @@ -76,14 +76,6 @@ protected override void BeforeClose() _tcs?.TrySetResult(false); } - /// Yes, no, or when the window was dismissed without picking either. - public async Task AwaitWithChoiceAsync() - { - if (await AwaitAnswer()) - return true; - return NoButtonClicked ? false : null; - } - public async Task AwaitAnswer() { if (!Dispatcher.UIThread.CheckAccess()) diff --git a/WheelWizard/Views/Shell/Layout.axaml.cs b/WheelWizard/Views/Shell/Layout.axaml.cs index 533748ca0..da7869280 100644 --- a/WheelWizard/Views/Shell/Layout.axaml.cs +++ b/WheelWizard/Views/Shell/Layout.axaml.cs @@ -8,8 +8,6 @@ using Avalonia.VisualTree; using WheelWizard.AutoUpdating; using WheelWizard.Branding; -using WheelWizard.GameFiles; -using WheelWizard.GameFiles.Views; using WheelWizard.Localization; using WheelWizard.MiiImages.Views; using WheelWizard.Mods; @@ -86,8 +84,6 @@ public partial class Layout : BaseWindow, IPollingListener private IOnboardingService Onboarding { get; } - private IGameFilesService GameFiles { get; } - public Layout( INavigationService navigation, LiveRoomsService liveRooms, @@ -98,12 +94,10 @@ public Layout( ISettingsSignalBus settingsSignalBus, IModManager modManagerService, IAutoUpdaterSingletonService autoUpdater, - IOnboardingService onboarding, - IGameFilesService gameFiles + IOnboardingService onboarding ) { Onboarding = onboarding; - GameFiles = gameFiles; Navigation = navigation; LiveRooms = liveRooms; LiveStatus = liveStatus; @@ -158,8 +152,6 @@ protected override void OnLoaded(RoutedEventArgs e) Navigation_OnPageChanged(this, page); else if (Onboarding.IsRequired) Navigation.NavigateTo(); - else if (IsGameSetupNeeded()) - Navigation.NavigateTo(); else Navigation.NavigateTo(); _loaded.TrySetResult(); @@ -286,33 +278,7 @@ private void UpdateModsButtonText() // ModsButton.WarningTip = "Some mods need to be converted to patches."; // } - private void Navigation_OnPageChanged(object? sender, UserControl page) - { - // Until the game files are usable only the setup page and Settings (to make room or move the data folder) - // can be shown; anything else goes back to the setup page. - if (page is not (GameSetupPage or SettingsPage or OnboardingPage) && IsGameSetupNeeded()) - { - Navigation.NavigateAway(typeof(GameSetupPage)); - return; - } - - NavigateToPage(page); - } - - private bool IsGameSetupNeeded() - { - try - { - // A game image that is not reachable right now (an unplugged drive) only stops playing, as it did before - // the extracted game existed: Home says so and links to Settings, and the rest of the app stays usable. - var status = GameFiles.GetStatus(); - return !Onboarding.IsRequired && status.State != GameFilesState.ImageMissing && GameSetupPage.IsNeeded(status); - } - catch - { - return false; - } - } + private void Navigation_OnPageChanged(object? sender, UserControl page) => NavigateToPage(page); /// /// The current page may ask before letting you go (see ); until it does, the