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/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/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..459bd94a1 --- /dev/null +++ b/WheelWizard.Test/Features/ExistingSetups/ExistingSetupMigrationSafetyTests.cs @@ -0,0 +1,305 @@ +using Testably.Abstractions.Testing; +using Testably.Abstractions.Testing.FileSystem; +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)) + ); + } + + [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.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/ManagedDolphinServiceTests.cs b/WheelWizard.Test/Features/ManagedDolphinServiceTests.cs new file mode 100644 index 000000000..148167c2f --- /dev/null +++ b/WheelWizard.Test/Features/ManagedDolphinServiceTests.cs @@ -0,0 +1,134 @@ +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.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 + .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..9d6dd98f9 --- /dev/null +++ b/WheelWizard.Test/Features/Onboarding/OnboardingServiceTests.cs @@ -0,0 +1,199 @@ +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 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() + { + 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()); + } + + [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)); + 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", ""); + 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).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); + 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, + 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/Features/Settings/UnavailableSettingValueTests.cs b/WheelWizard.Test/Features/Settings/UnavailableSettingValueTests.cs new file mode 100644 index 000000000..3c193b5eb --- /dev/null +++ b/WheelWizard.Test/Features/Settings/UnavailableSettingValueTests.cs @@ -0,0 +1,178 @@ +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 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() + { + 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.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 7b883f345..e20ae3f10 100644 --- a/WheelWizard.UI.Test/ApplicationCompositionTests.cs +++ b/WheelWizard.UI.Test/ApplicationCompositionTests.cs @@ -86,6 +86,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.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) 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..04f689897 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,88 @@ 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 + { + // 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 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)) + return false; + 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; + } + } + + 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..21c2a1b91 --- /dev/null +++ b/WheelWizard/Features/CustomDistributions/RetroRewindMigrationService.cs @@ -0,0 +1,197 @@ +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 +{ + private const long FreeSpaceMargin = 64L * 1024 * 1024; + + 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. 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) + { + 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 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 + { + 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..b60fc79b2 100644 --- a/WheelWizard/Features/Dolphin/Paths/DolphinPaths.cs +++ b/WheelWizard/Features/Dolphin/Paths/DolphinPaths.cs @@ -10,6 +10,13 @@ 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; } + + /// 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; } } @@ -21,6 +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) 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/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..9b285e0ea --- /dev/null +++ b/WheelWizard/Features/DolphinInstaller/ManagedDolphinService.cs @@ -0,0 +1,561 @@ +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); + 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( + 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..d4538d67c --- /dev/null +++ b/WheelWizard/Features/Onboarding/OnboardingService.cs @@ -0,0 +1,379 @@ +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); + +/// 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. + 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(); + + /// + /// 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; } + + /// 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); + + /// 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(); + + /// 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(); + + // 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) + && (IsUnfinished || !WasSetUpBefore() && !IsSetupUsable()); + + public void MarkCompletedIfAlreadySetUp() + { + 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. + /// + 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. 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) + || setting.HasUnavailableValue; + + 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 + { + // 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); + } + 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 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); + 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; + + // Backing out of the install must not finish the onboarding behind the user's back. + cancellationToken.ThrowIfCancellationRequested(); + 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..720a8f9a1 --- /dev/null +++ b/WheelWizard/Features/Onboarding/Views/OnboardingPage.axaml.cs @@ -0,0 +1,729 @@ +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; + + // 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, + 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; + _onboarding.MarkStarted(); + 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(); + } + + // 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 UpdateBackButton() => BackButton.IsVisible = CanGoBack; + + private void Back_OnClick(object? sender, RoutedEventArgs e) => GoBack(); + + private void GoBack() + { + if (!CanGoBack) + return; + + 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) + { + _busy = busy; + IsEnabled = !busy || _step is Step.DolphinInstalling or Step.WiiCompiledInstalling; + 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); + } + + #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); + StartInstall(InstallManagedDolphinAsync); + } + + private async Task InstallManagedDolphinAsync(CancellationToken cancellationToken) + { + ShowProgress(0, t("onboarding.dolphin_downloading")); + // 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 + { + // A Flatpak install cannot be stopped halfway. + SetInstallUncancellable(_managedDolphin.Kind == ManagedDolphinKind.Flatpak); + var progress = new Progress(value => + { + if (!cancellationToken.IsCancellationRequested) + ShowProgress(value.Percent, value.Message); + }); + result = await _managedDolphin.InstallAsync(progress, cancellationToken); + } + + cancellationToken.ThrowIfCancellationRequested(); + if (result.IsFailure) + { + ShowStepError($"{t("onboarding.dolphin_install_failed")} {result.Error.Message}", allowOwnDolphin: true); + return; + } + + // Replacing the install step keeps it out of the history, so Back returns to where Dolphin was picked. + if (_detectedData is not null) + { + ImportPathText.Text = _detectedData.UserFolderPath; + Show(Step.DolphinImport); + } + else + { + Show(Step.GameFile); + } + } + + 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); + } + + // 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) + { + 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); + StartInstall(InstallWiiCompiledAsync); + } + + private async Task InstallWiiCompiledAsync(CancellationToken cancellationToken) + { + if (_retroRewind.State != BackgroundInstallState.Completed) + { + 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(_retroRewind.ErrorMessage ?? t("onboarding.rr_failed"), allowOwnDolphin: false); + return; + } + } + + ShowProgress(0, t("onboarding.wiicompiled_title")); + var progress = new InlineProgress(value => + { + // 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 + + 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) + StartInstall(InstallManagedDolphinAsync); + else if (_step == Step.WiiCompiledInstalling) + { + if (_retroRewind.State == BackgroundInstallState.Failed) + _ = _retroRewind.RetryAsync(); + StartInstall(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..b425d635b 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,46 @@ public OperationResult ApplyNandToRecompConfig() } } + 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"); + 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 +253,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/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 9e9957145..16c56c0eb 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 @@ -37,6 +40,9 @@ 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 ONBOARDING_STARTED { get; } Setting FORCE_WIIMOTE { get; } Setting LAUNCH_WITH_DOLPHIN { get; } Setting LAUNCH_RR_ON_STARTUP { get; } @@ -66,6 +72,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 @@ -85,6 +92,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 d796c5bdc..d31f5520a 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,17 @@ 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); + 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); @@ -226,7 +234,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); @@ -258,6 +271,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 +328,9 @@ 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 ONBOARDING_STARTED { get; } public Setting FORCE_WIIMOTE { get; } public Setting LAUNCH_WITH_DOLPHIN { get; } public Setting LAUNCH_RR_ON_STARTUP { get; } @@ -342,6 +361,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 @@ -359,7 +379,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; } @@ -382,6 +402,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) @@ -419,16 +441,27 @@ 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 - ); + _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) + private WhWzSetting RegisterWhWz(string name, T defaultValue, Func? validation = null, bool retainUnavailable = false) { var setting = new WhWzSetting( name, @@ -437,25 +470,30 @@ 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, 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); + 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..52d57619e 100644 --- a/WheelWizard/Features/Settings/Types/Setting.cs +++ b/WheelWizard/Features/Settings/Types/Setting.cs @@ -35,6 +35,20 @@ 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 => _hasUnavailable ? _unavailable : default; + + // 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; @@ -43,12 +57,18 @@ public bool Set(T newValue, bool skipSave = false) ArgumentNullException.ThrowIfNull(newValue); SaveError = null; 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); @@ -56,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; } @@ -94,4 +115,20 @@ 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) + return; + _unavailable = value; + _hasUnavailable = true; + } } 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/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,117 @@ 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; + // 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; + 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(); + } + + if (result.IsFailure && !wasManaged) + { + ManagedDolphin.Deactivate(); + SettingsService.Set(SettingsService.DOLPHIN_LOCATION, previousLocation); + SettingsService.Set(SettingsService.USER_FOLDER_PATH, previousUserFolder); + } + + 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 +664,7 @@ private async Task MoveWheelWizardDataAsync(string targetPath) if (success) { await HandleSuccessfulAppdataMoveAsync(details, errorMessage); + FollowRelocatedDataFolder(); } else { 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) { diff --git a/WheelWizard/Resources/Languages/en.yml b/WheelWizard/Resources/Languages/en.yml index 0c01296bf..ed776395e 100644 --- a/WheelWizard/Resources/Languages/en.yml +++ b/WheelWizard/Resources/Languages/en.yml @@ -743,6 +743,78 @@ en: remove: "Remove %{option}" 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" mod_browser: title: "Browse mods" 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/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 62a7e9ce0..da7869280 100644 --- a/WheelWizard/Views/Shell/Layout.axaml.cs +++ b/WheelWizard/Views/Shell/Layout.axaml.cs @@ -12,6 +12,8 @@ using WheelWizard.MiiImages.Views; using WheelWizard.Mods; using WheelWizard.Mods.Views; +using WheelWizard.Onboarding; +using WheelWizard.Onboarding.Views; using WheelWizard.RrRooms; using WheelWizard.RrRooms.Views; using WheelWizard.Settings; @@ -80,6 +82,8 @@ public partial class Layout : BaseWindow, IPollingListener private IModManager ModManagerService { get; } + private IOnboardingService Onboarding { get; } + public Layout( INavigationService navigation, LiveRoomsService liveRooms, @@ -89,9 +93,11 @@ public Layout( ISettingsManager settingsService, ISettingsSignalBus settingsSignalBus, IModManager modManagerService, - IAutoUpdaterSingletonService autoUpdater + IAutoUpdaterSingletonService autoUpdater, + IOnboardingService onboarding ) { + Onboarding = onboarding; Navigation = navigation; LiveRooms = liveRooms; LiveStatus = liveStatus; @@ -144,6 +150,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(); @@ -332,6 +340,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 e4f03a815..578b8325c 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 00f8d31d2..2603b467d 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");