diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b05227d5a..7a5fd72ed 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -321,6 +321,12 @@ jobs: create-release: name: Create GitHub Release needs: [release, macos-arm64, macos-x64] + # The Windows/Linux build is the one that must succeed. A failed macOS job (signing, + # notarization) must not hold the other platforms hostage: the release goes out without the + # DMGs, and the in-app updater already picks the newest release that has an asset for the + # user's platform, so macOS users simply stay on their current version until a later release + # carries DMGs again. + if: ${{ !cancelled() && needs.release.result == 'success' }} runs-on: ubuntu-latest @@ -332,14 +338,19 @@ jobs: run: | mkdir -p final-artifacts - # Linux & Windows + # Linux & Windows: required. cp artifacts-linux-windows/WheelWizardWindows.exe final-artifacts/ cp artifacts-linux-windows/WheelWizard_Linux final-artifacts/ cp artifacts-linux-windows/WheelWizard_arm64_Linux final-artifacts/ - # macOS DMGs - cp artifacts-macos-arm64/WheelWizard-macOSarm64.dmg final-artifacts/ - cp artifacts-macos-x64/WheelWizard-macOSintel.dmg final-artifacts/ + # macOS DMGs: attached when their job produced them, skipped with a warning otherwise. + for dmg in artifacts-macos-arm64/WheelWizard-macOSarm64.dmg artifacts-macos-x64/WheelWizard-macOSintel.dmg; do + if [ -f "$dmg" ]; then + cp "$dmg" final-artifacts/ + else + echo "::warning::$(basename "$dmg") was not produced; publishing this release without it." + fi + done chmod +x final-artifacts/WheelWizard_Linux final-artifacts/WheelWizard_arm64_Linux ls -la final-artifacts/ diff --git a/.gitignore b/.gitignore index 635b27bb3..1400b9e3f 100644 --- a/.gitignore +++ b/.gitignore @@ -489,3 +489,4 @@ $RECYCLE.BIN/ # Vim temporary swap files *.swp AGENTS.md +/.dotnet diff --git a/Flatpak/io.github.TeamWheelWizard.WheelWizard.metainfo.xml b/Flatpak/io.github.TeamWheelWizard.WheelWizard.metainfo.xml index 33f4531e0..7733d1b0e 100644 --- a/Flatpak/io.github.TeamWheelWizard.WheelWizard.metainfo.xml +++ b/Flatpak/io.github.TeamWheelWizard.WheelWizard.metainfo.xml @@ -55,6 +55,8 @@ + + diff --git a/WheelWizard.Test/Features/MiiRepositoryServiceTests.cs b/WheelWizard.Test/Features/MiiRepositoryServiceTests.cs new file mode 100644 index 000000000..284f3d40d --- /dev/null +++ b/WheelWizard.Test/Features/MiiRepositoryServiceTests.cs @@ -0,0 +1,147 @@ +using Testably.Abstractions.Testing; +using WheelWizard.Recomp; +using WheelWizard.Services; +using WheelWizard.Settings; +using WheelWizard.Settings.Types; +using WheelWizard.Test.Features.Settings; +using WheelWizard.WiiManagement.MiiManagement; + +namespace WheelWizard.Test.Features; + +[Collection("SettingsFeature")] +public sealed class MiiRepositoryServiceTests : IDisposable +{ + private readonly MockFileSystem _fileSystem = new(); + private readonly ISettingsManager _settings; + private readonly RecompDolphinDataService _dolphinData; + private readonly MiiRepositoryServiceService _repository; + private readonly string _sourceNand = Path.GetFullPath("MiiTests/Dolphin/Wii"); + private bool _recompEnabled; + private bool _copyEnabled; + private bool _useDolphinData = true; + + public MiiRepositoryServiceTests() + { + _settings = SettingsTestUtils.InitializeSettingsRuntime(Path.GetDirectoryName(_sourceNand)!); + var nandSetting = new WhWzSetting(typeof(string), "NandRoot", _sourceNand); + var copySetting = new WhWzSetting(typeof(bool), "CopyNand", false); + var useSetting = new WhWzSetting(typeof(bool), "UseDolphinData", true); + _settings.NAND_ROOT_PATH.Returns(nandSetting); + _settings.RECOMP_COPY_DOLPHIN_NAND.Returns(copySetting); + _settings.RECOMP_USE_DOLPHIN_DATA.Returns(useSetting); + _settings.Get(nandSetting).Returns(_sourceNand); + _settings.Get(copySetting).Returns(_ => _copyEnabled); + _settings.Get(useSetting).Returns(_ => _useDolphinData); + _settings.IsRecompModeActive().Returns(_ => _recompEnabled); + _fileSystem.Directory.CreateDirectory(_sourceNand); + _dolphinData = new RecompDolphinDataService(_settings, Substitute.For(), _fileSystem); + _repository = new MiiRepositoryServiceService(_fileSystem, _settings, _dolphinData); + } + + [Fact] + public void CopiedNand_ReadsAndEditsCopy_AndSwitchesBackWithoutRestart() + { + Assert.True(_repository.ForceCreateDatabase().IsSuccess); + Assert.True(_repository.AddMiiToBlocks(Block(1)).IsSuccess); + var sourceBytes = _fileSystem.File.ReadAllBytes(DbPath(_sourceNand)); + Assert.True(_dolphinData.CopyNandForRecomp().IsSuccess); + _copyEnabled = true; + _useDolphinData = false; + _recompEnabled = true; + + Assert.NotNull(_repository.GetRawBlockByAvatarId(1)); + Assert.True(_repository.UpdateBlockByClientId(1, Block(2)).IsSuccess); + Assert.Null(_repository.GetRawBlockByAvatarId(1)); + Assert.NotNull(_repository.GetRawBlockByAvatarId(2)); + Assert.Equal(sourceBytes, _fileSystem.File.ReadAllBytes(DbPath(_sourceNand))); + + _copyEnabled = false; + _useDolphinData = true; + Assert.NotNull(_repository.GetRawBlockByAvatarId(1)); + Assert.Null(_repository.GetRawBlockByAvatarId(2)); + + _copyEnabled = true; + _recompEnabled = false; + Assert.NotNull(_repository.GetRawBlockByAvatarId(1)); + Assert.Null(_repository.GetRawBlockByAvatarId(2)); + } + + [Fact] + public void DirectNand_UsesCustomDolphinNand_ForCreationAndEdits() + { + _recompEnabled = true; + Assert.False(_repository.Exists()); + Assert.True(_repository.ForceCreateDatabase().IsSuccess); + Assert.True(_repository.AddMiiToBlocks(Block(3)).IsSuccess); + Assert.True(_fileSystem.File.Exists(DbPath(_sourceNand))); + + _recompEnabled = false; + Assert.NotNull(_repository.GetRawBlockByAvatarId(3)); + } + + [Fact] + public void UpdateBlockByClientId_UsesOneResolvedPathForReadAndWrite() + { + Assert.True(_repository.ForceCreateDatabase().IsSuccess); + Assert.True(_repository.AddMiiToBlocks(Block(1)).IsSuccess); + + _recompEnabled = true; + var alternateNand = Path.GetFullPath("MiiTests/Alternate/Wii"); + var alternateData = Substitute.For(); + alternateData.NandFolderPath.Returns(alternateNand); + var alternateRepository = new MiiRepositoryServiceService(_fileSystem, _settings, alternateData); + Assert.True(alternateRepository.ForceCreateDatabase().IsSuccess); + Assert.True(alternateRepository.AddMiiToBlocks(Block(2)).IsSuccess); + + // If the repository resolves the property again while loading, it will see the alternate + // NAND and miss Mii 1 before it gets a chance to write anything. + var changingData = Substitute.For(); + changingData.NandFolderPath.Returns(_sourceNand, alternateNand, _sourceNand); + var repository = new MiiRepositoryServiceService(_fileSystem, _settings, changingData); + + Assert.True(repository.UpdateBlockByClientId(1, Block(3)).IsSuccess); + Assert.Equal(3, _fileSystem.File.ReadAllBytes(DbPath(_sourceNand))[0x04 + 0x1B]); + Assert.Equal(2, _fileSystem.File.ReadAllBytes(DbPath(alternateNand))[0x04 + 0x1B]); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void UnavailableLinkedNand_CreatesPrivateDatabase_WithoutTouchingDolphin(bool missingCopy) + { + Assert.True(_repository.ForceCreateDatabase().IsSuccess); + Assert.True(_repository.AddMiiToBlocks(Block(4)).IsSuccess); + var sourceBytes = _fileSystem.File.ReadAllBytes(DbPath(_sourceNand)); + _recompEnabled = true; + _copyEnabled = missingCopy; + _useDolphinData = false; + + Assert.False(_repository.Exists()); + Assert.Empty(_repository.LoadAllBlocks()); + Assert.True(_repository.ForceCreateDatabase().IsSuccess); + Assert.True(_repository.AddMiiToBlocks(Block(5)).IsSuccess); + Assert.NotNull(_repository.GetRawBlockByAvatarId(5)); + Assert.Null(_repository.GetRawBlockByAvatarId(4)); + Assert.True(_fileSystem.File.Exists(DbPath(Path.Combine(PathManager.RecompUserDataFolderPath, "NAND")))); + Assert.Equal(sourceBytes, _fileSystem.File.ReadAllBytes(DbPath(_sourceNand))); + } + + [Fact] + public void DolphinMode_DoesNotRequireRecompServices() + { + var repository = new MiiRepositoryServiceService(_fileSystem, _settings); + Assert.True(repository.ForceCreateDatabase().IsSuccess); + Assert.True(_fileSystem.File.Exists(DbPath(_sourceNand))); + } + + private static byte[] Block(byte id) + { + var block = new byte[74]; + block[0x1B] = id; + return block; + } + + private static string DbPath(string nand) => Path.Combine(nand, "shared2", "menu", "FaceLib", "RFL_DB.dat"); + + public void Dispose() => SettingsTestUtils.ResetSettingsRuntime(); +} diff --git a/WheelWizard.Test/Features/Recomp/RecompLinuxTests.cs b/WheelWizard.Test/Features/Recomp/RecompLinuxTests.cs new file mode 100644 index 000000000..6d1587518 --- /dev/null +++ b/WheelWizard.Test/Features/Recomp/RecompLinuxTests.cs @@ -0,0 +1,274 @@ +using System.IO.Abstractions; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Text; +using Microsoft.Extensions.Logging.Abstractions; +using Testably.Abstractions.Testing; +using WheelWizard.GitHub.Domain; +using WheelWizard.Models.Enums; +using WheelWizard.Recomp; +using WheelWizard.Recomp.Domain; + +namespace WheelWizard.Test.Features.Recomp; + +/// +/// The Linux AppImage speaks a different dialect than the Windows setup: subcommands, no install +/// directory of Wheel Wizard's choosing, and no product report on stdout. These tests pin the contracts +/// Wheel Wizard relies on there: the command lines, the release asset, and the product state it +/// reconstructs from the files the AppImage writes. +/// +public class RecompLinuxTests +{ + [Fact] + public void Install_PassesTheGameAndRetroRewindTheWayTheAppImageExpects() + { + var arguments = RecompLinuxSetupCommandBuilder.BuildInstallArguments( + "/home/user/Games/Mario Kart Wii.rvz", + "/home/user/.local/share/WheelWizard/RiivolutionWW/RetroRewind6" + ); + + Assert.Equal( + [ + "install", + "--game", + "/home/user/Games/Mario Kart Wii.rvz", + "--retro-dir", + "/home/user/.local/share/WheelWizard/RiivolutionWW/RetroRewind6", + "--download-retro-wfc-payload", + "--progress-json", + ], + arguments + ); + } + + [Fact] + public void Install_WithoutAGameReusesTheExtractedAssets() + { + var arguments = RecompLinuxSetupCommandBuilder.BuildInstallArguments(null, "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/rr/RetroRewind6", RecompRetroWfcPayloadMode.Skip); + + Assert.Equal(["install", "--retro-dir", "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/rr/RetroRewind6", "--skip-retro-wfc-payload", "--progress-json"], arguments); + Assert.DoesNotContain("--game", arguments); + } + + [Fact] + public void Install_BaseOnlyPassesNoPayloadOption() + { + Assert.Equal( + ["install", "--game", "/g.iso", "--progress-json"], + RecompLinuxSetupCommandBuilder.BuildInstallArguments("/g.iso", null) + ); + } + + [Fact] + public void ReleaseAsset_FollowsTheMachineArchitecture() + { + Assert.Equal("WiiCompiled-Setup-x86_64.AppImage", RecompPlatform.LinuxReleaseAssetName(Architecture.X64)); + Assert.Equal("WiiCompiled-Setup-aarch64.AppImage", RecompPlatform.LinuxReleaseAssetName(Architecture.Arm64)); + Assert.Null(RecompPlatform.LinuxReleaseAssetName(Architecture.X86)); + } + + [Fact] + public void ReleaseResolver_PicksTheAssetItIsAskedFor() + { + var release = new GithubRelease + { + TagName = "v0.2.27", + Assets = + [ + new GithubAsset { Name = "WiiCompiled-Setup.exe", BrowserDownloadUrl = "https://example.com/exe" }, + new GithubAsset { Name = "WiiCompiled-Setup-x86_64.AppImage", BrowserDownloadUrl = "https://example.com/x86_64" }, + new GithubAsset { Name = "WiiCompiled-Setup-aarch64.AppImage", BrowserDownloadUrl = "https://example.com/aarch64" }, + ], + }; + var windowsOnly = new GithubRelease + { + TagName = "v0.3.0", + Assets = [new GithubAsset { Name = "WiiCompiled-Setup.exe", BrowserDownloadUrl = "https://example.com/newer-exe" }], + }; + + var linux = RecompReleaseResolver.FindLatest([release, windowsOnly], "WiiCompiled-Setup-x86_64.AppImage"); + Assert.NotNull(linux); + Assert.Equal("v0.2.27", linux.TagName); + Assert.Equal("https://example.com/x86_64", linux.SetupDownloadUrl); + + Assert.Equal("v0.3.0", RecompReleaseResolver.FindLatest([release, windowsOnly], "WiiCompiled-Setup.exe")?.TagName); + Assert.Null(RecompReleaseResolver.FindLatest([windowsOnly], "WiiCompiled-Setup-aarch64.AppImage")); + } + + [Fact] + public void Inspector_ReportsBothProductsAbsentWhenTheAppImageNeverInstalled() + { + var fixture = new BackendFixture(); + + var products = fixture.Inspect(); + + Assert.Equal(RecompProductState.Absent, products.Base.State); + Assert.Equal(RecompProductState.Absent, products.RetroRewind.State); + Assert.False(products.ActionRequired); + Assert.True(products.ProtocolValid); + Assert.Equal("0.2.27", products.SetupVersion); + } + + [Fact] + public void Inspector_ReportsCurrentWhenTheExecutablesExistAndCodePulMatches() + { + var fixture = new BackendFixture().WithBase().WithRetroRewind(codePulMatches: true); + + var products = fixture.Inspect(); + + Assert.True(products.Base.IsCurrent); + Assert.True(products.RetroRewind.IsCurrent); + Assert.Equal(WheelWizardStatus.Ready, RecompStatusResolver.Resolve(true, "0.2.27", "v0.2.27", products)); + } + + [Fact] + public void Inspector_ReportsCodePulChangedWhenRetroRewindWasUpdated() + { + var fixture = new BackendFixture().WithBase().WithRetroRewind(codePulMatches: false); + + var products = fixture.Inspect(); + + Assert.True(products.Base.IsCurrent); + Assert.Equal(RecompProductState.CodePulChanged, products.RetroRewind.State); + Assert.True(products.RetroRewind.RequiresCompile); + Assert.True(products.ActionRequired); + Assert.Equal(WheelWizardStatus.OutOfDate, RecompStatusResolver.Resolve(true, "0.2.27", "v0.2.27", products)); + } + + [Fact] + public void Inspector_FailsClosedWhenTheBuildRecordIsMissing() + { + var fixture = new BackendFixture().WithBase().WithRetroRewind(codePulMatches: true, writeBuildRecord: false); + + Assert.Equal(RecompProductState.CodePulChanged, fixture.Inspect().RetroRewind.State); + } + + [Fact] + public void Inspector_ReportsBrokenWhenAnExecutableVanished() + { + var fixture = new BackendFixture().WithBase().WithRetroRewind(codePulMatches: true); + fixture.FileSystem.File.Delete(fixture.FileSystem.Path.Combine(fixture.BaseFolder, "WiiCompiled")); + + var products = fixture.Inspect(); + + Assert.Equal(RecompProductState.Broken, products.Base.State); + Assert.True(products.IsBlocked); + } + + [Fact] + public void Inspector_OnlyChecksPresenceWithoutARetroRewindSource() + { + var fixture = new BackendFixture().WithBase().WithRetroRewind(codePulMatches: false); + + Assert.True(fixture.Inspect(retroRewindFolder: null).RetroRewind.IsCurrent); + } + + [Fact] + public void Inspector_ReadsANullProductListAsNothingBuilt() + { + var fixture = new BackendFixture(); + fixture.FileSystem.File.WriteAllText(fixture.StateFile, """{"SchemaVersion":1,"Workspace":"w","Products":null}"""); + + var products = fixture.Inspect(); + + Assert.True(products.ProtocolValid); + Assert.Equal(RecompProductState.Absent, products.Base.State); + Assert.Equal(RecompProductState.Absent, products.RetroRewind.State); + } + + [Fact] + public void Inspector_TreatsAMalformedStateAsUnverifiable() + { + var fixture = new BackendFixture(); + fixture.FileSystem.File.WriteAllText(fixture.StateFile, "not json"); + + var products = fixture.Inspect(); + + Assert.False(products.ProtocolValid); + Assert.True(products.IsBlocked); + } + + /// + /// The files the AppImage leaves behind after an install, laid out the way its install-state.json, + /// local-build.json and Wheel Wizard's RetroRewind6 folder relate on a real machine. + /// + private sealed class BackendFixture + { + private const string CodePulContent = "kamek code"; + + public MockFileSystem FileSystem { get; } = new(); + public string Root { get; } + public string StateFile { get; } + public string BaseFolder { get; } + public string RetroFolder { get; } + public string RetroRewindSource { get; } + + private readonly List _productRecords = []; + + public BackendFixture() + { + Root = FileSystem.Path.Combine(FileSystem.Directory.GetCurrentDirectory(), "WiiCompiled"); + StateFile = FileSystem.Path.Combine(Root, "install-state.json"); + BaseFolder = FileSystem.Path.Combine(Root, "Install", "Base"); + RetroFolder = FileSystem.Path.Combine(Root, "Install", "RetroRewind"); + RetroRewindSource = FileSystem.Path.Combine(FileSystem.Directory.GetCurrentDirectory(), "RetroRewind6"); + FileSystem.Directory.CreateDirectory(Root); + FileSystem.Directory.CreateDirectory(FileSystem.Path.Combine(RetroRewindSource, "Binaries")); + FileSystem.File.WriteAllText(FileSystem.Path.Combine(RetroRewindSource, "Binaries", "Code.pul"), CodePulContent); + } + + public BackendFixture WithBase() + { + FileSystem.Directory.CreateDirectory(BaseFolder); + FileSystem.File.WriteAllText(FileSystem.Path.Combine(BaseFolder, "WiiCompiled"), "elf"); + _productRecords.Add(Record("base", BaseFolder, "WiiCompiled")); + WriteState(); + return this; + } + + public BackendFixture WithRetroRewind(bool codePulMatches, bool writeBuildRecord = true) + { + FileSystem.Directory.CreateDirectory(RetroFolder); + FileSystem.File.WriteAllText(FileSystem.Path.Combine(RetroFolder, "RetroRewind"), "elf"); + if (writeBuildRecord) + { + var sha = codePulMatches ? Sha256(CodePulContent) : Sha256("an older Code.pul"); + FileSystem.File.WriteAllText( + FileSystem.Path.Combine(RetroFolder, "local-build.json"), + $$$"""{"SchemaVersion":1,"Profile":"retro-rewind","CodePulSha256":"{{{sha}}}"}""" + ); + } + _productRecords.Add(Record("retro-rewind", RetroFolder, "RetroRewind")); + WriteState(); + return this; + } + + public RecompProductsEvent Inspect(string? retroRewindFolder = "") + { + var inspector = new RecompLinuxProductInspector(FileSystem, NullLogger.Instance); + return inspector.Inspect(StateFile, "0.2.27", Root, retroRewindFolder == "" ? RetroRewindSource : retroRewindFolder); + } + + private void WriteState() + { + FileSystem.Directory.CreateDirectory(Root); + FileSystem.File.WriteAllText( + StateFile, + $$$"""{"SchemaVersion":1,"Workspace":"w","Products":[{{{string.Join(',', _productRecords)}}}]}""" + ); + } + + private static string Record(string profile, string folder, string executable) => + System.Text.Json.JsonSerializer.Serialize( + new + { + Profile = profile, + InstallDirectory = folder, + ExecutableName = executable, + } + ); + + private static string Sha256(string content) => + Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(content))).ToLowerInvariant(); + } +} diff --git a/WheelWizard.Test/Features/Recomp/RecompTests.cs b/WheelWizard.Test/Features/Recomp/RecompTests.cs index 1b4764047..f92c24363 100644 --- a/WheelWizard.Test/Features/Recomp/RecompTests.cs +++ b/WheelWizard.Test/Features/Recomp/RecompTests.cs @@ -25,12 +25,120 @@ public void SilentInstall_BuildsTheCommandLineTheSetupExecutableExpects() ); Assert.Equal( - "--silent --game \"D:\\Games\\Mario Kart Wii.rvz\" --install-dir \"D:\\WheelWizard\\Recomp\\Install\" --portable " - + "--progress-json --retro-dir \"D:\\WheelWizard\\RetroRewind6\" --download-retro-wfc-payload", + [ + "--silent", + "--game", + @"D:\Games\Mario Kart Wii.rvz", + "--install-dir", + @"D:\WheelWizard\Recomp\Install", + "--portable", + "--progress-json", + "--retro-dir", + @"D:\WheelWizard\RetroRewind6", + "--download-retro-wfc-payload", + ], arguments ); } + [Fact] + public void SilentInstall_SkipsThePayloadOnlyWhenAskedTo() + { + var arguments = RecompSetupCommandBuilder.BuildSilentInstallArguments( + new() + { + GameFilePath = @"D:\Games\Mario Kart Wii.rvz", + InstallFolderPath = @"D:\WheelWizard\Recomp\Install", + RetroRewindFolderPath = @"D:\WheelWizard\RetroRewind6", + RetroWfcPayloadMode = RecompRetroWfcPayloadMode.Skip, + } + ); + + Assert.Equal(["--retro-dir", @"D:\WheelWizard\RetroRewind6", "--skip-retro-wfc-payload"], arguments.TakeLast(3)); + Assert.DoesNotContain("--download-retro-wfc-payload", arguments); + } + + [Fact] + public void RepairProducts_PassesExactlyOnePayloadOption() + { + Assert.Equal( + [ + "--repair-products", + "--install-dir", + @"D:\Recomp", + "--retro-dir", + @"D:\RetroRewind6", + "--download-retro-wfc-payload", + "--progress-json", + ], + RecompSetupCommandBuilder.BuildRepairProductsArguments(@"D:\Recomp", @"D:\RetroRewind6") + ); + Assert.Equal( + [ + "--repair-products", + "--install-dir", + @"D:\Recomp", + "--retro-dir", + @"D:\RetroRewind6", + "--skip-retro-wfc-payload", + "--progress-json", + ], + RecompSetupCommandBuilder.BuildRepairProductsArguments(@"D:\Recomp", @"D:\RetroRewind6", RecompRetroWfcPayloadMode.Skip) + ); + } + + [Fact] + public void InstallState_ReadsThePayloadModeTheSetupHostWrites() + { + var state = System.Text.Json.JsonSerializer.Deserialize( + """{"SchemaVersion":1,"SetupVersion":"0.3.0","InstallDir":"D:\\Recomp","RetroWfcPayloadMode":"skipped"}""", + new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true } + ); + + Assert.NotNull(state); + Assert.True(state.IsRetroWfcPayloadSkipped); + } + + [Fact] + public void PayloadPolicy_LegacyStateWithoutModeKeepsDownloadingWhenTheServiceIsDown() + { + // Written by a host that predates the mode field: Retro Rewind is installed, so the host owns a + // verified payload copy and must be asked to download, never to skip or to bother the user. + var legacy = new RecompInstallState + { + SchemaVersion = 1, + SetupVersion = "0.2.25", + InstallDir = @"D:\Recomp", + RetroRewindInstalled = true, + }; + + Assert.False(RecompRetroWfcPayloadPolicy.NeedsServiceProbe(legacy, hasRetroRewindSource: true)); + Assert.Equal(RecompRetroWfcPayloadDecision.Download, RecompRetroWfcPayloadPolicy.Decide(legacy, true, serviceReachable: false)); + } + + [Fact] + public void PayloadPolicy_OnlyAFreshRetroRewindBuildAsksTheUser() + { + var downloaded = new RecompInstallState { RetroWfcPayloadMode = "downloaded", RetroRewindInstalled = true }; + var skipped = new RecompInstallState { RetroWfcPayloadMode = "skipped", RetroRewindInstalled = true }; + var baseOnly = new RecompInstallState { RetroWfcPayloadMode = "", RetroRewindInstalled = false }; + + // Nothing to decide without a Retro Rewind source, and never a probe for a payload-bearing install. + Assert.False(RecompRetroWfcPayloadPolicy.NeedsServiceProbe(null, hasRetroRewindSource: false)); + Assert.False(RecompRetroWfcPayloadPolicy.NeedsServiceProbe(downloaded, hasRetroRewindSource: true)); + Assert.Equal(RecompRetroWfcPayloadDecision.Download, RecompRetroWfcPayloadPolicy.Decide(downloaded, true, serviceReachable: false)); + + // A skipped install stays offline while the service is down and upgrades when it is back. + Assert.True(RecompRetroWfcPayloadPolicy.NeedsServiceProbe(skipped, hasRetroRewindSource: true)); + Assert.Equal(RecompRetroWfcPayloadDecision.Skip, RecompRetroWfcPayloadPolicy.Decide(skipped, true, serviceReachable: false)); + Assert.Equal(RecompRetroWfcPayloadDecision.Download, RecompRetroWfcPayloadPolicy.Decide(skipped, true, serviceReachable: true)); + + // A fresh install and a base-only install both need a brand new Retro Rewind build. + Assert.Equal(RecompRetroWfcPayloadDecision.AskUser, RecompRetroWfcPayloadPolicy.Decide(null, true, serviceReachable: false)); + Assert.Equal(RecompRetroWfcPayloadDecision.AskUser, RecompRetroWfcPayloadPolicy.Decide(baseOnly, true, serviceReachable: false)); + Assert.Equal(RecompRetroWfcPayloadDecision.Download, RecompRetroWfcPayloadPolicy.Decide(null, true, serviceReachable: true)); + } + [Fact] public void ProductsLine_IsReadBackAsTheProductStateItReports() { diff --git a/WheelWizard/Features/Localization/LocalizationLanguageCatalog.cs b/WheelWizard/Features/Localization/LocalizationLanguageCatalog.cs index 9b70b93d0..a7e5b3f7d 100644 --- a/WheelWizard/Features/Localization/LocalizationLanguageCatalog.cs +++ b/WheelWizard/Features/Localization/LocalizationLanguageCatalog.cs @@ -15,6 +15,7 @@ public static class LocalizationLanguageCatalog new("ja", "value.language.japanese"), new("es", "value.language.spanish"), new("it", "value.language.italian"), + new("pl", "value.language.polish"), new("pt", "value.language.portuguese"), new("ru", "value.language.russian"), new("ko", "value.language.korean"), diff --git a/WheelWizard/Features/Recomp/Domain/RecompInstallModels.cs b/WheelWizard/Features/Recomp/Domain/RecompInstallModels.cs index 6ab4c91f8..bd2fb7560 100644 --- a/WheelWizard/Features/Recomp/Domain/RecompInstallModels.cs +++ b/WheelWizard/Features/Recomp/Domain/RecompInstallModels.cs @@ -24,6 +24,33 @@ public class RecompInstallState public int? SchemaVersion { get; set; } public string? SetupVersion { get; set; } public string? InstallDir { get; set; } + + /// Whether the setup host has produced the Retro Rewind product, as opposed to only the base game. + public bool RetroRewindInstalled { get; set; } + + /// + /// How the installed Retro Rewind product was built: downloaded when it embeds a Retro-WFC + /// payload, skipped when it was deliberately built without one, or empty for a base-only install. + /// A state written before this field existed leaves it ; see + /// for how that is read. + /// + public string? RetroWfcPayloadMode { get; set; } + + /// Whether the installed Retro Rewind product was built without a Retro-WFC payload. + public bool IsRetroWfcPayloadSkipped => string.Equals(RetroWfcPayloadMode, "skipped", StringComparison.OrdinalIgnoreCase); +} + +/// +/// The Retro-WFC payload choice handed to the setup executable. The contract requires exactly one whenever a +/// Retro Rewind source directory is passed. +/// +public enum RecompRetroWfcPayloadMode +{ + /// Let the setup host download the shared payload (--download-retro-wfc-payload). + Download, + + /// Build Retro Rewind without the payload (--skip-retro-wfc-payload); online play is unavailable. + Skip, } /// @@ -47,4 +74,10 @@ public sealed record RecompInstallRequest /// an installation that predates portable mode keeps its machine-local layout. /// public bool Portable { get; init; } + + /// + /// The Retro-WFC payload choice for the Retro Rewind product. Only meaningful together with + /// . + /// + public RecompRetroWfcPayloadMode RetroWfcPayloadMode { get; init; } = RecompRetroWfcPayloadMode.Download; } diff --git a/WheelWizard/Features/Recomp/Domain/RecompLinuxBackendModels.cs b/WheelWizard/Features/Recomp/Domain/RecompLinuxBackendModels.cs new file mode 100644 index 000000000..6b174cd04 --- /dev/null +++ b/WheelWizard/Features/Recomp/Domain/RecompLinuxBackendModels.cs @@ -0,0 +1,45 @@ +namespace WheelWizard.Recomp.Domain; + +/// +/// The install-state.json the Linux AppImage keeps in its own data directory. It is a flat list of +/// the products it built and where; it does not record the setup version, which is why Wheel Wizard +/// writes its own next to the installed AppImage copy. +/// +public sealed class RecompLinuxBackendState +{ + private List _products = []; + + public int? SchemaVersion { get; set; } + public string? Workspace { get; set; } + + /// Never null: an explicit JSON null reads as "no products built". + public List Products + { + get => _products; + set => _products = value ?? []; + } +} + +/// One product the AppImage built. Profile is base or retro-rewind. +public sealed class RecompLinuxProductRecord +{ + public string? Profile { get; set; } + public string? InstallDirectory { get; set; } + public string? ExecutableName { get; set; } + public string? DolSha256 { get; set; } + public string? RelSha256 { get; set; } + public string? BuiltUtc { get; set; } +} + +/// +/// The local-build.json the AppImage's build script leaves beside each product. Its +/// CodePulSha256 is what tells whether the Retro Rewind product still matches the installed Code.pul. +/// +public sealed class RecompLinuxBuildRecord +{ + public int? SchemaVersion { get; set; } + public string? Profile { get; set; } + public string? DolSha256 { get; set; } + public string? RelSha256 { get; set; } + public string? CodePulSha256 { get; set; } +} diff --git a/WheelWizard/Features/Recomp/RecompDolphinDataService.cs b/WheelWizard/Features/Recomp/RecompDolphinDataService.cs index 9d8264e2f..3395d5c0c 100644 --- a/WheelWizard/Features/Recomp/RecompDolphinDataService.cs +++ b/WheelWizard/Features/Recomp/RecompDolphinDataService.cs @@ -6,9 +6,9 @@ namespace WheelWizard.Recomp; public interface IRecompDolphinDataService { - bool IsEnabled { get; } + bool IsSharingEnabled { get; } - /// Whether the recomp uses a copy of the Dolphin NAND instead of sharing it in place. + /// Whether private mode uses a copy originally imported from Dolphin. bool CopyEnabled { get; } string? LinkedUserFolderPath { get; } @@ -20,7 +20,7 @@ public interface IRecompDolphinDataService string? FindCandidateUserFolder(); OperationResult Link(string userFolderPath); - void SetEnabled(bool enabled); + void SetSharingEnabled(bool enabled); void SetCopyEnabled(bool enabled); OperationResult CopyNandForRecomp(); @@ -31,7 +31,7 @@ public interface IRecompDolphinDataService public sealed class RecompDolphinDataService(ISettingsManager settings, IRecompSettingManager recompSettings, IFileSystem fileSystem) : IRecompDolphinDataService { - public bool IsEnabled => settings.Get(settings.RECOMP_USE_DOLPHIN_DATA); + public bool IsSharingEnabled => settings.Get(settings.RECOMP_USE_DOLPHIN_DATA); public bool CopyEnabled => settings.Get(settings.RECOMP_COPY_DOLPHIN_NAND); @@ -41,15 +41,17 @@ public string? NandFolderPath { get { - if (!IsEnabled) - return null; + // Sharing always means Dolphin's live NAND, even when a private clone is kept for the + // next time sharing is disabled. + if (IsSharingEnabled) + return SourceNandFolderPath; // A missing copy never falls back to the Dolphin NAND: the user chose the copy exactly // so that Dolphin's own data is left alone, and a private NAND is the safe default. if (CopyEnabled) return ValidateNandFolder(PathManager.RecompNandCopyFolderPath); - return SourceNandFolderPath; + return null; } } @@ -84,7 +86,7 @@ public OperationResult Link(string userFolderPath) return Ok(); } - public void SetEnabled(bool enabled) => settings.Set(settings.RECOMP_USE_DOLPHIN_DATA, enabled); + public void SetSharingEnabled(bool enabled) => settings.Set(settings.RECOMP_USE_DOLPHIN_DATA, enabled); public void SetCopyEnabled(bool enabled) => settings.Set(settings.RECOMP_COPY_DOLPHIN_NAND, enabled); diff --git a/WheelWizard/Features/Recomp/RecompEnvironment.cs b/WheelWizard/Features/Recomp/RecompEnvironment.cs index 5e234dff8..25e880b28 100644 --- a/WheelWizard/Features/Recomp/RecompEnvironment.cs +++ b/WheelWizard/Features/Recomp/RecompEnvironment.cs @@ -30,9 +30,16 @@ public interface IRecompEnvironment /// The portable.txt marker that makes the recomp folder a portable root. string PortableMarkerFilePath { get; } - /// The install-state.json the recomp writes to mark itself installed. + /// The install-state.json Wheel Wizard reads to know which setup release is installed. string InstallStateFilePath { get; } + /// + /// The backend's own install-state.json. On Windows the setup writes the same file Wheel Wizard + /// reads, so this equals . On Linux the AppImage keeps its own record, + /// in its own schema, in its own directory, and Wheel Wizard writes itself. + /// + string BackendStateFilePath { get; } + /// The launcher copy of the setup executable inside the install directory. string InstalledSetupFilePath { get; } @@ -63,6 +70,8 @@ public sealed class RecompEnvironment(IFileSystem fileSystem) : IRecompEnvironme public string InstallStateFilePath => PathManager.RecompInstallStateFilePath; + public string BackendStateFilePath => PathManager.RecompInstallStateFilePath; + public string InstalledSetupFilePath => PathManager.RecompSetupFilePath; public string? RetroRewindFolderPath => ExistingFolderOrNull(PathManager.RetroRewind6FolderPath); @@ -77,3 +86,35 @@ public sealed class RecompEnvironment(IFileSystem fileSystem) : IRecompEnvironme return fileSystem.Directory.Exists(path) ? path : null; } } + +/// +/// The Linux layout. The AppImage owns (products, +/// state, Config.toml, workspace); Wheel Wizard's own Recomp folder only holds the download cache, +/// the installed copy of the AppImage and the state file Wheel Wizard writes about it. +/// +public sealed class RecompLinuxEnvironment(IFileSystem fileSystem) : IRecompEnvironment +{ + public string GameFilePath => PathManager.GameFilePath; + + public string InstallFolderPath => PathManager.RecompLinuxBackendFolderPath; + + public bool IsPortableInstall => false; + + public string CacheFolderPath => PathManager.RecompCacheFolderPath; + + // Config.toml, logs and the private NAND all live in the backend folder on Linux. + public string UserDataFolderPath => PathManager.RecompLinuxBackendFolderPath; + + public string PortableMarkerFilePath => PathManager.RecompPortableMarkerFilePath; + + public string InstallStateFilePath => PathManager.RecompInstallStateFilePath; + + public string BackendStateFilePath => PathManager.RecompLinuxBackendStateFilePath; + + public string InstalledSetupFilePath => PathManager.RecompSetupFilePath; + + public string? RetroRewindFolderPath => + fileSystem.Directory.Exists(PathManager.RetroRewind6FolderPath) ? PathManager.RetroRewind6FolderPath : null; + + public string NandCopyFolderPath => PathManager.RecompNandCopyFolderPath; +} diff --git a/WheelWizard/Features/Recomp/RecompExtensions.cs b/WheelWizard/Features/Recomp/RecompExtensions.cs index f6452de41..ab8620a4b 100644 --- a/WheelWizard/Features/Recomp/RecompExtensions.cs +++ b/WheelWizard/Features/Recomp/RecompExtensions.cs @@ -7,12 +7,14 @@ public static class RecompExtensions { /// /// Registers the Mario Kart Wii recomp frontend. - /// The recomp only ships for Windows, so on every other platform nothing is registered at all; - /// ISettingsManager.IsRecompModeActive() is false there, so nothing ever resolves these. + /// The recomp only ships for Windows and Linux, so on every other platform nothing is registered at + /// all; ISettingsManager.IsRecompModeActive() is false there, so nothing ever resolves these. + /// Both platforms share how a setup release is found and downloaded; what that setup is then told to + /// do differs, so each gets its own environment and install service. /// public static IServiceCollection AddRecomp(this IServiceCollection services) { - if (!OperatingSystem.IsWindows()) + if (!RecompPlatform.IsSupported) return services; services @@ -28,10 +30,23 @@ public static IServiceCollection AddRecomp(this IServiceCollection services) ); services.AddSingleton(); - services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + if (RecompPlatform.IsLinux) + { + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + } + else + { + services.AddSingleton(); + services.AddSingleton(); + } + services.AddTransient(); return services; diff --git a/WheelWizard/Features/Recomp/RecompInstallService.cs b/WheelWizard/Features/Recomp/RecompInstallService.cs index b2522bc9c..688ea1810 100644 --- a/WheelWizard/Features/Recomp/RecompInstallService.cs +++ b/WheelWizard/Features/Recomp/RecompInstallService.cs @@ -1,7 +1,6 @@ using System.IO.Abstractions; using System.Text.Json; using Microsoft.Extensions.Logging; -using WheelWizard.GitHub; using WheelWizard.Models.Enums; using WheelWizard.Recomp.Domain; @@ -22,6 +21,12 @@ public interface IRecompInstallService : IDisposable /// bool IsInstalled { get; } + /// + /// Runs the installed setup host with --version and returns its semantic version, or + /// when nothing is installed or the host does not answer. + /// + Task GetInstalledVersionAsync(CancellationToken cancellationToken = default); + /// /// Reads install-state.json, compares it against the latest GitHub release, and asks /// --check-products whether the installed executables are still fresh. @@ -39,7 +44,17 @@ public interface IRecompInstallService : IDisposable /// Installs a missing/newer setup release, or asks the installed setup host what needs doing and /// repairs only that. An asset-only Retro Rewind change reports current and does no work. /// - Task InstallAsync(IProgress? progress = null, CancellationToken cancellationToken = default); + /// + /// Consulted only when a fresh Retro Rewind build is needed and the Retro-WFC payload service is + /// unreachable. Returning true builds without online play; false or fails the + /// install with an explanation instead. An installation that already embeds a payload never asks: the + /// setup host falls back to its own verified copy. + /// + Task InstallAsync( + IProgress? progress = null, + Func>? confirmOfflineInstall = null, + CancellationToken cancellationToken = default + ); /// /// Verifies product health immediately before launch and repairs only what the check demands. @@ -67,9 +82,8 @@ Task ReconcileForLaunchAsync( /// public sealed class RecompInstallService : IRecompInstallService { - // How the three phases of an install divide up the 0-100 progress bar. - private const int DownloadPercentFloor = 5; - private const int SetupPercentFloor = 35; + // Where the setup phase starts on the 0-100 progress bar; the download phase before it lives in the acquirer. + private const int SetupPercentFloor = RecompSetupHostAcquirer.SetupPercentFloor; private const int CurrentInstallStateSchemaVersion = 1; @@ -86,12 +100,15 @@ public sealed class RecompInstallService : IRecompInstallService // installation is probed by pattern rather than by recomputing the backend's own path. private const string OperationLockSearchPattern = ".mkwc-operation-*.lock"; + private const string RetroWfcUnavailableMessage = + "The Retro WFC servers are not responding, so WiiCompiled cannot set up online play right now. Try again later, or install without online play."; + private static readonly JsonSerializerOptions InstallStateJsonOptions = new() { PropertyNameCaseInsensitive = true }; private readonly IRecompEnvironment environment; private readonly IRecompProcessRunner processRunner; - private readonly IRecompSetupDownloader downloader; - private readonly IGitHubSingletonService gitHubService; + private readonly RecompSetupHostAcquirer hosts; + private readonly IRecompRetroWfcPayloadProbe payloadProbe; private readonly IFileSystem fileSystem; private readonly ILogger logger; @@ -104,16 +121,16 @@ public sealed class RecompInstallService : IRecompInstallService public RecompInstallService( IRecompEnvironment environment, IRecompProcessRunner processRunner, - IRecompSetupDownloader downloader, - IGitHubSingletonService gitHubService, + RecompSetupHostAcquirer hosts, + IRecompRetroWfcPayloadProbe payloadProbe, IFileSystem fileSystem, ILogger logger ) { this.environment = environment; this.processRunner = processRunner; - this.downloader = downloader; - this.gitHubService = gitHubService; + this.hosts = hosts; + this.payloadProbe = payloadProbe; this.fileSystem = fileSystem; this.logger = logger; } @@ -124,6 +141,27 @@ ILogger logger public bool IsInstalled => fileSystem.File.Exists(environment.InstalledSetupFilePath); + public async Task GetInstalledVersionAsync(CancellationToken cancellationToken = default) + { + if (!OperatingSystem.IsWindows() || !IsInstalled) + return null; + + string? versionText = null; + var runResult = await processRunner.RunAsync( + environment.InstalledSetupFilePath, + RecompSetupCommandBuilder.BuildVersionArguments(), + workingDirectory: null, + line => + { + if (RecompVersion.TryParse(line, out var version)) + versionText = version.ToString(); + }, + cancellationToken + ); + + return runResult.IsSuccess && runResult.Value == 0 ? versionText : null; + } + public async Task GetCurrentStatusAsync(CancellationToken cancellationToken = default) { var state = ReadInstalledState(); @@ -149,13 +187,60 @@ public async Task GetCurrentStatusAsync(CancellationToken can if (installationBusy) logger.LogInformation("The WiiCompiled product check could not run because the installation is busy"); - return RecompStatusResolver.Resolve( + var status = RecompStatusResolver.Resolve( IsGameFileConfigured(), hasInstalledHost ? installedVersion : null, latestRelease?.TagName, products?.IsSuccess == true ? products.Value : null, installationBusy ); + + // A build that skipped the payload is healthy as far as the host is concerned, but it cannot + // play online. Once the payload service is back, that is an update worth offering. + if ( + status is (WheelWizardStatus.Ready or WheelWizardStatus.NoServerButInstalled) + && await RetroWfcUpgradeAvailableAsync(state, cancellationToken) + ) + return WheelWizardStatus.OutOfDate; + + return status; + } + + /// + /// Whether the installed Retro Rewind product was built without a Retro-WFC payload while the payload + /// service is reachable again. Only a skipped installation ever probes, so a normal one costs nothing. + /// + private async Task RetroWfcUpgradeAvailableAsync(RecompInstallState? state, CancellationToken cancellationToken) => + state is { IsRetroWfcPayloadSkipped: true } && await payloadProbe.IsReachableAsync(cancellationToken); + + /// + /// Decides which payload option the next setup operation receives. The rules live in + /// ; this only supplies the probe and the user's answer. + /// + private async Task> ResolveRetroWfcPayloadModeAsync( + RecompInstallState? state, + Func>? confirmOfflineInstall, + CancellationToken cancellationToken + ) + { + var hasRetroRewindSource = !string.IsNullOrWhiteSpace(environment.RetroRewindFolderPath); + var serviceReachable = + !RecompRetroWfcPayloadPolicy.NeedsServiceProbe(state, hasRetroRewindSource) + || await payloadProbe.IsReachableAsync(cancellationToken); + + switch (RecompRetroWfcPayloadPolicy.Decide(state, hasRetroRewindSource, serviceReachable)) + { + case RecompRetroWfcPayloadDecision.Download: + return Ok(RecompRetroWfcPayloadMode.Download); + case RecompRetroWfcPayloadDecision.Skip: + return Ok(RecompRetroWfcPayloadMode.Skip); + default: + if (confirmOfflineInstall is null || !await confirmOfflineInstall()) + return Fail(RetroWfcUnavailableMessage); + + logger.LogInformation("The Retro-WFC payload service is unreachable; installing WiiCompiled without online play"); + return Ok(RecompRetroWfcPayloadMode.Skip); + } } /// @@ -265,6 +350,7 @@ private async Task> CheckProductsCoreAsync( public async Task InstallAsync( IProgress? progress = null, + Func>? confirmOfflineInstall = null, CancellationToken cancellationToken = default ) { @@ -273,7 +359,7 @@ public async Task InstallAsync( try { - return await InstallCoreAsync(progress, cancellationToken); + return await InstallCoreAsync(progress, confirmOfflineInstall, cancellationToken); } finally { @@ -281,7 +367,11 @@ public async Task InstallAsync( } } - private async Task InstallCoreAsync(IProgress? progress, CancellationToken cancellationToken) + private async Task InstallCoreAsync( + IProgress? progress, + Func>? confirmOfflineInstall, + CancellationToken cancellationToken + ) { // No platform guard here on purpose: AddRecomp() is the single gate, so this service only ever // exists on Windows in the first place. @@ -289,28 +379,40 @@ private async Task InstallCoreAsync(IProgress ReconcileForLaunchAsync( @@ -346,7 +448,19 @@ CancellationToken cancellationToken if (!await SetupMatchesVersionAsync(environment.InstalledSetupFilePath, state.SetupVersion, cancellationToken)) return Fail("The installed WiiCompiled host does not match its current install state."); - var repairResult = await RepairWhatTheCheckDemandsCoreAsync(progress, reportCompletion: false, cancellationToken); + // A launch never asks about offline play and never forces the payload upgrade: the user pressed + // Play, not Update. A repair the check demands anyway still gains the payload when it is reachable. + var payloadModeResult = await ResolveRetroWfcPayloadModeAsync(state, confirmOfflineInstall: null, cancellationToken); + if (payloadModeResult.IsFailure) + return payloadModeResult.Error; + + var repairResult = await RepairWhatTheCheckDemandsCoreAsync( + progress, + payloadModeResult.Value, + forceRetroRebuild: false, + reportCompletion: false, + cancellationToken + ); if (repairResult.IsFailure) return repairResult; @@ -447,78 +561,6 @@ private void DeleteFolderIfPresent(string folderPath) fileSystem.Directory.Delete(folderPath, recursive: true); } - private async Task> EnsureSetupDownloadedAsync( - RecompRelease release, - IProgress? progress, - CancellationToken cancellationToken - ) - { - var cachedSetupPath = fileSystem.Path.Combine(environment.CacheFolderPath, BuildCachedSetupFileName(release.TagName)); - if (IsUsableFile(cachedSetupPath) && await SetupMatchesVersionAsync(cachedSetupPath, release.TagName, cancellationToken)) - { - PruneCachedSetupsExcept(cachedSetupPath); - return Ok(cachedSetupPath); - } - - var downloadMessage = t("progress.recomp_downloading_setup"); - Report(progress, downloadMessage, DownloadPercentFloor); - - var downloadProgress = new DelegateProgress(percent => - Report(progress, downloadMessage, DownloadPercentFloor + (percent * (SetupPercentFloor - DownloadPercentFloor) / 100)) - ); - - var downloadResult = await downloader.DownloadAsync(release.SetupDownloadUrl, cachedSetupPath, downloadProgress, cancellationToken); - if (downloadResult.IsFailure) - return downloadResult.Error; - - if (!IsUsableFile(cachedSetupPath)) - return Fail("The downloaded WiiCompiled setup is missing or empty."); - - if (!await SetupMatchesVersionAsync(cachedSetupPath, release.TagName, cancellationToken)) - { - var removed = DeleteInvalidSetup(cachedSetupPath); - return removed - ? Fail($"The downloaded WiiCompiled setup did not report release {release.TagName}.") - : Fail($"The downloaded WiiCompiled setup did not report release {release.TagName} and could not be removed."); - } - - PruneCachedSetupsExcept(cachedSetupPath); - return Ok(cachedSetupPath); - } - - /// - /// Drops setup executables cached for other releases. Each one is around 380 MB and the cache is - /// only ever read for the release currently being installed, so keeping them meant every update - /// permanently cost the user another installer's worth of disk. Failure here is deliberately - /// silent: it is disk hygiene, never a reason to fail an install that has already succeeded. - /// - private void PruneCachedSetupsExcept(string keepFilePath) - { - try - { - if (!fileSystem.Directory.Exists(environment.CacheFolderPath)) - return; - foreach (var candidate in fileSystem.Directory.EnumerateFiles(environment.CacheFolderPath, "WiiCompiled-Setup-*.exe")) - { - if (string.Equals(candidate, keepFilePath, StringComparison.OrdinalIgnoreCase)) - continue; - try - { - fileSystem.File.Delete(candidate); - logger.LogInformation("Removed the superseded cached WiiCompiled setup {Path}", candidate); - } - catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) - { - logger.LogDebug(exception, "Could not remove the cached WiiCompiled setup {Path}", candidate); - } - } - } - catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) - { - logger.LogDebug(exception, "Could not enumerate the WiiCompiled setup cache for pruning."); - } - } - private async Task InstalledHostCanRepairAsync(string? expectedVersion, CancellationToken cancellationToken) { if (!fileSystem.File.Exists(environment.InstalledSetupFilePath)) @@ -536,52 +578,14 @@ private async Task InstalledHostCanRepairAsync(string? expectedVersion, Ca return await SetupMatchesVersionAsync(environment.InstalledSetupFilePath, state.SetupVersion, cancellationToken); } - private async Task SetupMatchesVersionAsync(string setupFilePath, string? expectedVersion, CancellationToken cancellationToken) - { - if (!RecompVersion.TryParse(expectedVersion, out var expected)) - return false; + private Task SetupMatchesVersionAsync(string setupFilePath, string? expectedVersion, CancellationToken cancellationToken) => + hosts.SetupMatchesVersionAsync(setupFilePath, expectedVersion, cancellationToken); - string? versionText = null; - var runResult = await processRunner.RunAsync( - setupFilePath, - RecompSetupCommandBuilder.BuildVersionArguments(), - workingDirectory: null, - line => - { - if (RecompVersion.TryParse(line, out var version)) - versionText = version.ToString(); - }, - cancellationToken - ); - - return runResult.IsSuccess - && runResult.Value == 0 - && RecompVersion.TryParse(versionText, out var cachedVersion) - && cachedVersion.ComparePrecedenceTo(expected) == 0; - } - - private bool VersionsMatch(string? first, string? second) => - RecompVersion.TryParse(first, out var parsedFirst) - && RecompVersion.TryParse(second, out var parsedSecond) - && parsedFirst.ComparePrecedenceTo(parsedSecond) == 0; - - private bool DeleteInvalidSetup(string setupFilePath) - { - try - { - if (fileSystem.File.Exists(setupFilePath)) - fileSystem.File.Delete(setupFilePath); - return true; - } - catch (Exception exception) - { - logger.LogError(exception, "Could not remove invalid recomp setup at {Path}", setupFilePath); - return false; - } - } + private static bool VersionsMatch(string? first, string? second) => RecompSetupHostAcquirer.VersionsMatch(first, second); private async Task RunSilentInstallAsync( string setupFilePath, + RecompRetroWfcPayloadMode payloadMode, IProgress? progress, CancellationToken cancellationToken ) @@ -592,10 +596,11 @@ CancellationToken cancellationToken InstallFolderPath = environment.InstallFolderPath, RetroRewindFolderPath = environment.RetroRewindFolderPath, Portable = environment.IsPortableInstall, + RetroWfcPayloadMode = payloadMode, }; var arguments = RecompSetupCommandBuilder.BuildSilentInstallArguments(request); - logger.LogInformation("Running the recomp setup: {Setup} {Arguments}", setupFilePath, arguments); + logger.LogInformation("Running the recomp setup: {Setup} {Arguments}", setupFilePath, string.Join(' ', arguments)); Report(progress, t("progress.recomp_running_setup"), SetupPercentFloor); @@ -621,11 +626,20 @@ CancellationToken cancellationToken /// private async Task RepairWhatTheCheckDemandsAsync( IProgress? progress, + RecompRetroWfcPayloadMode payloadMode, + bool forceRetroRebuild, CancellationToken cancellationToken - ) => await RepairWhatTheCheckDemandsCoreAsync(progress, reportCompletion: true, cancellationToken); + ) => await RepairWhatTheCheckDemandsCoreAsync(progress, payloadMode, forceRetroRebuild, reportCompletion: true, cancellationToken); + /// + /// Runs the repair even when the check reports every product current. The host decides for itself + /// what that repair rebuilds; this exists so a payload choice that changed, which the check does + /// not see, still reaches it. + /// private async Task RepairWhatTheCheckDemandsCoreAsync( IProgress? progress, + RecompRetroWfcPayloadMode payloadMode, + bool forceRetroRebuild, bool reportCompletion, CancellationToken cancellationToken ) @@ -634,7 +648,7 @@ CancellationToken cancellationToken if (checkResult.IsFailure) return checkResult.Error; - if (!NeedsRepair(checkResult.Value)) + if (!NeedsRepair(checkResult.Value) && !forceRetroRebuild) { if (reportCompletion) Report(progress, t("progress.recomp_finished"), 100); @@ -642,13 +656,14 @@ CancellationToken cancellationToken } logger.LogInformation( - "Repairing WiiCompiled products (base: {BaseStatus}, retro: {RetroStatus}, compile required: {CompileRequired})", + "Repairing WiiCompiled products (base: {BaseStatus}, retro: {RetroStatus}, compile required: {CompileRequired}, payload: {PayloadMode})", checkResult.Value.Base.State, checkResult.Value.RetroRewind.State, - checkResult.Value.Base.RequiresCompile || checkResult.Value.RetroRewind.RequiresCompile + checkResult.Value.Base.RequiresCompile || checkResult.Value.RetroRewind.RequiresCompile, + payloadMode ); - var repairResult = await RunTargetedRepairAsync(progress, cancellationToken); + var repairResult = await RunTargetedRepairAsync(progress, payloadMode, cancellationToken); if (repairResult.IsFailure) return repairResult; @@ -667,6 +682,7 @@ CancellationToken cancellationToken private async Task RunTargetedRepairAsync( IProgress? progress, + RecompRetroWfcPayloadMode payloadMode, CancellationToken cancellationToken ) { @@ -676,7 +692,11 @@ CancellationToken cancellationToken if (string.IsNullOrWhiteSpace(retroRewindFolderPath)) return Fail("Retro Rewind must be installed before WiiCompiled can repair or launch."); - var arguments = RecompSetupCommandBuilder.BuildRepairProductsArguments(environment.InstallFolderPath, retroRewindFolderPath); + var arguments = RecompSetupCommandBuilder.BuildRepairProductsArguments( + environment.InstallFolderPath, + retroRewindFolderPath, + payloadMode + ); Report(progress, t("progress.recomp_running_setup"), SetupPercentFloor); var resultHolder = new EventHolder(); @@ -766,29 +786,8 @@ private OperationResult FinishSetupRun( return Ok(); } - private async Task TryGetLatestReleaseAsync(CancellationToken cancellationToken) - { - cancellationToken.ThrowIfCancellationRequested(); - - var releasesResult = await gitHubService.GetReleasesAsync( - RecompReleaseResolver.RepositoryOwner, - RecompReleaseResolver.RepositoryName, - count: 100 - ); - if (releasesResult.IsFailure) - { - logger.LogWarning("Could not retrieve the recomp releases: {Message}", releasesResult.Error.Message); - return null; - } - - return RecompReleaseResolver.FindLatest(releasesResult.Value); - } - - private string? ReadInstalledVersion() - { - var state = ReadInstalledState(); - return string.IsNullOrWhiteSpace(state?.SetupVersion) ? null : state.SetupVersion; - } + private Task TryGetLatestReleaseAsync(CancellationToken cancellationToken) => + hosts.TryGetLatestReleaseAsync(cancellationToken); private RecompInstallState? ReadInstalledState() { @@ -849,27 +848,8 @@ private bool IsGameFileConfigured() return !string.IsNullOrWhiteSpace(gameFilePath) && fileSystem.File.Exists(gameFilePath); } - private bool IsUsableFile(string filePath) - { - try - { - return fileSystem.File.Exists(filePath) && fileSystem.FileInfo.New(filePath).Length > 0; - } - catch (Exception exception) - { - logger.LogWarning(exception, "Failed to inspect the cached recomp setup at {Path}", filePath); - return false; - } - } - - private static string BuildCachedSetupFileName(string tagName) - { - var sanitized = new string(tagName.Select(character => char.IsLetterOrDigit(character) ? character : '-').ToArray()); - return $"WiiCompiled-Setup-{sanitized}.exe"; - } - private static void Report(IProgress? progress, string message, int percent) => - progress?.Report(new(message, Math.Clamp(percent, 0, 100))); + RecompSetupHostAcquirer.Report(progress, message, percent); public void Dispose() { @@ -880,35 +860,27 @@ public void Dispose() // and a SemaphoreSlim whose wait handle was never touched holds no OS resources anyway. GC.SuppressFinalize(this); } +} - /// - /// Forwards progress synchronously, so the single the launcher owns stays the - /// only place where marshalling to the UI thread happens. - /// - private sealed class DelegateProgress(Action handler) : IProgress - { - public void Report(T value) => handler(value); - } +/// +/// NDJSON lines arrive on a process output thread, so publish the ones we keep with a memory barrier. +/// Shared by both platform install services. +/// +internal sealed class EventHolder + where T : class +{ + private T? _value; - /// - /// NDJSON lines arrive on a process output thread, so publish the ones we keep with a memory barrier. - /// - private sealed class EventHolder - where T : class + public T? Value { - private T? _value; - - public T? Value + get => Volatile.Read(ref _value); + set { - get => Volatile.Read(ref _value); - set - { - Volatile.Write(ref _value, value); - Interlocked.Increment(ref _count); - } + Volatile.Write(ref _value, value); + Interlocked.Increment(ref _count); } - - private int _count; - public int Count => Volatile.Read(ref _count); } + + private int _count; + public int Count => Volatile.Read(ref _count); } diff --git a/WheelWizard/Features/Recomp/RecompLauncher.cs b/WheelWizard/Features/Recomp/RecompLauncher.cs index a44796cb5..130f004a3 100644 --- a/WheelWizard/Features/Recomp/RecompLauncher.cs +++ b/WheelWizard/Features/Recomp/RecompLauncher.cs @@ -4,6 +4,7 @@ using WheelWizard.Recomp.Domain; using WheelWizard.Services; using WheelWizard.Services.Launcher; +using WheelWizard.Shared.MessageTranslations; using WheelWizard.Views.Popups.Generic; namespace WheelWizard.Recomp; @@ -57,10 +58,12 @@ public async Task Launch() progressWindow.Show(); var reconciliation = await installService.ReconcileForLaunchAsync(progress, cancellationTokenSource.Token); if (reconciliation.IsFailure) - return reconciliation; + return IsCancellationRequested(progressWindow, cancellationTokenSource) + ? CancellationWarning("WiiCompiled launch preparation was cancelled.") + : reconciliation; - if (progressWindow.WasCancellationRequested || cancellationTokenSource.IsCancellationRequested) - return Fail("WiiCompiled launch preparation was cancelled."); + if (IsCancellationRequested(progressWindow, cancellationTokenSource)) + return CancellationWarning("WiiCompiled launch preparation was cancelled."); // Reconciliation is the only cancellable progress phase. The launch call is // awaited through game exit. @@ -70,7 +73,7 @@ public async Task Launch() } catch (OperationCanceledException) { - return Fail("WiiCompiled launch preparation was cancelled."); + return CancellationWarning("WiiCompiled launch preparation was cancelled."); } finally { @@ -160,7 +163,7 @@ private async Task AskForDolphinNandChoiceAsync() if (sourceNand is null) { // There is no Dolphin data to offer, so the recomp simply starts with its own. - dolphinData.SetEnabled(false); + dolphinData.SetSharingEnabled(false); dolphinData.SetCopyEnabled(false); return Ok(); } @@ -171,12 +174,11 @@ private async Task AskForDolphinNandChoiceAsync() .AwaitAnswer(); if (!useDolphinData) { - dolphinData.SetEnabled(false); + dolphinData.SetSharingEnabled(false); dolphinData.SetCopyEnabled(false); return Ok(); } - dolphinData.SetEnabled(true); var copyNand = await new YesNoWindow() .SetMainText(t("question.recomp_nand_mode.title")) .SetExtraText(t("question.recomp_nand_mode.extra")) @@ -185,6 +187,7 @@ private async Task AskForDolphinNandChoiceAsync() if (!copyNand) { dolphinData.SetCopyEnabled(false); + dolphinData.SetSharingEnabled(true); return Ok(); } @@ -204,6 +207,7 @@ private async Task AskForDolphinNandChoiceAsync() // 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(); } @@ -229,11 +233,13 @@ private async Task RunSetupAsync(string goal) var retroRewindResult = await EnsureRetroRewindCurrentAsync(progressWindow, cancellationTokenSource.Token); if (retroRewindResult.IsFailure) - return retroRewindResult.Error; + return IsCancellationRequested(progressWindow, cancellationTokenSource) + ? CancellationWarning("WiiCompiled installation was cancelled.") + : retroRewindResult.Error; var retroRewindCommitted = retroRewindResult.Value; - if (!retroRewindCommitted && (progressWindow.WasCancellationRequested || cancellationTokenSource.IsCancellationRequested)) - return Fail("WiiCompiled update was cancelled."); + if (!retroRewindCommitted && IsCancellationRequested(progressWindow, cancellationTokenSource)) + return CancellationWarning("WiiCompiled installation was cancelled."); if (retroRewindCommitted) { @@ -247,14 +253,21 @@ private async Task RunSetupAsync(string goal) // The Retro Rewind commit is a barrier: once it is durable, the matching recomp // check (and any repair it demands) must run to completion, so it is deliberately // handed an uncancellable token. Whatever the backend reports is the truth. - return await installService.InstallAsync( + var installResult = await installService.InstallAsync( progress, + ConfirmOfflineInstallAsync, retroRewindCommitted ? CancellationToken.None : cancellationTokenSource.Token ); + + // A successful terminal result wins a race with Cancel. Otherwise, a cancellation that + // was still allowed at this point is a user-controlled warning, not an unknown error. + return installResult.IsFailure && !retroRewindCommitted && IsCancellationRequested(progressWindow, cancellationTokenSource) + ? CancellationWarning("WiiCompiled installation was cancelled.") + : installResult; } catch (OperationCanceledException) { - return Fail("WiiCompiled update was cancelled."); + return CancellationWarning("WiiCompiled installation was cancelled."); } finally { @@ -262,6 +275,23 @@ private async Task RunSetupAsync(string goal) } } + private static bool IsCancellationRequested(ProgressWindow progressWindow, CancellationTokenSource cancellationTokenSource) => + progressWindow.WasCancellationRequested || cancellationTokenSource.IsCancellationRequested; + + private static OperationError CancellationWarning(string message) => Fail(message, MessageTranslation.Warning_RecompOperationCancelled); + + /// + /// Asked by the install service only when a Retro Rewind build is needed and the Retro-WFC payload + /// service is down. Offline-only is a real choice, not a silent downgrade: the game plays, online + /// does not, and the next update after the service returns rebuilds with online play automatically. + /// + private static Task ConfirmOfflineInstallAsync() => + new YesNoWindow() + .SetButtonText(t("action.recomp_install_offline"), t("action.cancel")) + .SetMainText(t("question.recomp_retro_wfc_unavailable.title")) + .SetExtraText(t("question.recomp_retro_wfc_unavailable.extra")) + .AwaitAnswer(); + private async Task> EnsureRetroRewindCurrentAsync( ProgressWindow progressWindow, CancellationToken cancellationToken diff --git a/WheelWizard/Features/Recomp/RecompLinuxInstallService.cs b/WheelWizard/Features/Recomp/RecompLinuxInstallService.cs new file mode 100644 index 000000000..c89594750 --- /dev/null +++ b/WheelWizard/Features/Recomp/RecompLinuxInstallService.cs @@ -0,0 +1,779 @@ +using System.IO.Abstractions; +using System.Text.Json; +using Microsoft.Extensions.Logging; +using WheelWizard.Models.Enums; +using WheelWizard.Recomp.Domain; + +namespace WheelWizard.Recomp; + +/// +/// The Linux implementation of , driving the recomp's AppImage. +/// +/// The AppImage does not speak the Windows setup's contract: it takes subcommands, installs into its own +/// XDG data directory, keeps an install-state.json of its own shape that never records the setup +/// version, and only prints a human-readable product table. So this service keeps the pieces the +/// AppImage does not provide itself: it copies the downloaded AppImage beside a +/// it writes (the "installed host"), and it reconstructs the product +/// report from the files the AppImage leaves behind (). +/// +/// +/// A repair is install without --game: the AppImage then reuses the disc assets it already +/// extracted and its own translation cache, so only what actually changed (typically a new Code.pul) +/// is retranslated and recompiled. A new setup release is a full install --game with the new +/// AppImage, exactly like a fresh install. +/// +/// +public sealed class RecompLinuxInstallService : IRecompInstallService +{ + // Where the setup phase starts on the 0-100 progress bar; the download phase before it lives in the acquirer. + private const int SetupPercentFloor = RecompSetupHostAcquirer.SetupPercentFloor; + + private const int CurrentInstallStateSchemaVersion = 1; + + // A launch holds the gate for the whole play session, so "busy" almost always means "the game is running". + private const string OperationAlreadyRunningMessage = "Another WiiCompiled operation is already running."; + + private const string NotInstalledMessage = "WiiCompiled is not installed yet."; + private const string StaleStateMessage = "WiiCompiled requires a current install-state.json before it can continue."; + + private const string RetroWfcUnavailableMessage = + "The Retro WFC servers are not responding, so WiiCompiled cannot set up online play right now. Try again later, or install without online play."; + + private static readonly JsonSerializerOptions InstallStateJsonOptions = new() + { + PropertyNameCaseInsensitive = true, + WriteIndented = true, + }; + + private readonly IRecompEnvironment environment; + private readonly IRecompProcessRunner processRunner; + private readonly RecompSetupHostAcquirer hosts; + private readonly RecompLinuxProductInspector inspector; + private readonly IRecompRetroWfcPayloadProbe payloadProbe; + private readonly IFileSystem fileSystem; + private readonly ILogger logger; + + // Set by a successful pre-launch reconciliation and consumed by the launch that follows it. + // Both run under _operationGate, which is also what keeps the handoff a single-use token. + private bool _launchReconciled; + private int _disposed; + private readonly SemaphoreSlim _operationGate = new(1, 1); + + public RecompLinuxInstallService( + IRecompEnvironment environment, + IRecompProcessRunner processRunner, + RecompSetupHostAcquirer hosts, + RecompLinuxProductInspector inspector, + IRecompRetroWfcPayloadProbe payloadProbe, + IFileSystem fileSystem, + ILogger logger + ) + { + this.environment = environment; + this.processRunner = processRunner; + this.hosts = hosts; + this.inspector = inspector; + this.payloadProbe = payloadProbe; + this.fileSystem = fileSystem; + this.logger = logger; + } + + public bool OperationInFlight => _operationGate.CurrentCount == 0; + + public bool IsInstalled => HasInstalledHost && ReadCurrentInstallState() is not null; + + private bool HasInstalledHost => fileSystem.File.Exists(environment.InstalledSetupFilePath); + + public async Task GetInstalledVersionAsync(CancellationToken cancellationToken = default) + { + if (!IsInstalled) + return null; + + string? versionText = null; + var runResult = await processRunner.RunAsync( + environment.InstalledSetupFilePath, + RecompLinuxSetupCommandBuilder.BuildVersionArguments(), + workingDirectory: null, + line => + { + if (RecompVersion.TryParse(line, out var version)) + versionText = version.ToString(); + }, + cancellationToken + ); + + return runResult.IsSuccess && runResult.Value == 0 ? versionText : null; + } + + public async Task GetCurrentStatusAsync(CancellationToken cancellationToken = default) + { + var state = ReadInstalledState(); + var hasInstalledHost = HasInstalledHost; + if (hasInstalledHost && !IsCurrentInstallState(state)) + return IsGameFileConfigured() ? WheelWizardStatus.OutOfDate : WheelWizardStatus.ConfigNotFinished; + + var installedVersion = IsCurrentInstallState(state) ? state!.SetupVersion : null; + var latestRelease = await hosts.TryGetLatestReleaseAsync(cancellationToken); + var setupUpgradeRequired = + RecompVersion.TryParse(installedVersion, out var installed) + && latestRelease is not null + && latestRelease.Version.ComparePrecedenceTo(installed) > 0; + var products = + installedVersion is not null && hasInstalledHost && !setupUpgradeRequired ? await CheckProductsAsync(cancellationToken) : null; + + // The inspection only reads files, so the one thing that can refuse it is this service's own gate: + // an install, a reconciliation or a play session in progress. That is busy, not stale. + var installationBusy = products is { IsFailure: true } && products.Error.Message == OperationAlreadyRunningMessage; + + var status = RecompStatusResolver.Resolve( + IsGameFileConfigured(), + hasInstalledHost ? installedVersion : null, + latestRelease?.TagName, + products?.IsSuccess == true ? products.Value : null, + installationBusy + ); + + // A build that skipped the payload is healthy as far as the host is concerned, but it cannot + // play online. Once the payload service is back, that is an update worth offering. + if ( + status is (WheelWizardStatus.Ready or WheelWizardStatus.NoServerButInstalled) + && state is { IsRetroWfcPayloadSkipped: true } + && await payloadProbe.IsReachableAsync(cancellationToken) + ) + return WheelWizardStatus.OutOfDate; + + return status; + } + + public async Task> CheckProductsAsync(CancellationToken cancellationToken = default) + { + if (!await _operationGate.WaitAsync(0, cancellationToken)) + return Fail(OperationAlreadyRunningMessage); + try + { + return CheckProductsCore(); + } + finally + { + _operationGate.Release(); + } + } + + private OperationResult CheckProductsCore() + { + if (!HasInstalledHost) + return Fail(NotInstalledMessage); + + var state = ReadCurrentInstallState(); + if (state is null) + return Fail(StaleStateMessage); + + return Ok( + inspector.Inspect( + environment.BackendStateFilePath, + state.SetupVersion, + environment.InstallFolderPath, + environment.RetroRewindFolderPath + ) + ); + } + + public async Task InstallAsync( + IProgress? progress = null, + Func>? confirmOfflineInstall = null, + CancellationToken cancellationToken = default + ) + { + if (!await _operationGate.WaitAsync(0, cancellationToken)) + return Fail(OperationAlreadyRunningMessage); + + try + { + return await InstallCoreAsync(progress, confirmOfflineInstall, cancellationToken); + } + finally + { + _operationGate.Release(); + } + } + + private async Task InstallCoreAsync( + IProgress? progress, + Func>? confirmOfflineInstall, + CancellationToken cancellationToken + ) + { + if (!IsGameFileConfigured()) + return Fail(t("message_warning.not_find_game.extra")); + + Report(progress, t("progress.recomp_checking_release"), 0); + var state = ReadInstalledState(); + var release = await hosts.TryGetLatestReleaseAsync(cancellationToken); + + // Decided up front, before any download or build, so the user is asked while nothing has started + // yet rather than after a multi-minute build has already failed on the payload. + var payloadModeResult = await ResolveRetroWfcPayloadModeAsync(state, confirmOfflineInstall, cancellationToken); + if (payloadModeResult.IsFailure) + return payloadModeResult.Error; + var payloadMode = payloadModeResult.Value; + + // A skipped installation that can download again is an update in its own right: the inspection + // cannot see the payload choice, so the rebuild has to be forced. + var forceRetroRebuild = state is { IsRetroWfcPayloadSkipped: true } && payloadMode == RecompRetroWfcPayloadMode.Download; + + // The installed host repairs on its own, offline included, as long as no newer release exists. + if (await InstalledHostIsCurrentAsync(state, release, cancellationToken)) + return await RepairWhatTheCheckDemandsAsync( + progress, + payloadMode, + forceRetroRebuild, + reportCompletion: true, + cancellationToken + ); + + if (release is null) + return Fail("Could not verify a current WiiCompiled setup release or installed repair host."); + + var setupResult = await hosts.EnsureSetupDownloadedAsync(release, environment.CacheFolderPath, progress, cancellationToken); + if (setupResult.IsFailure) + return setupResult.Error; + + return await RunFullInstallAsync(setupResult.Value, release, payloadMode, progress, cancellationToken); + } + + /// + /// Whether the installed AppImage is the release to keep using: its state is current, it is the newest + /// release (or GitHub is unreachable), and the file itself still reports that version. + /// + private async Task InstalledHostIsCurrentAsync( + RecompInstallState? state, + RecompRelease? release, + CancellationToken cancellationToken + ) + { + if (!HasInstalledHost || !IsCurrentInstallState(state)) + return false; + if (release is not null && !RecompSetupHostAcquirer.VersionsMatch(state!.SetupVersion, release.TagName)) + return false; + + return await hosts.SetupMatchesVersionAsync(environment.InstalledSetupFilePath, state!.SetupVersion, cancellationToken); + } + + private async Task RunFullInstallAsync( + string setupFilePath, + RecompRelease release, + RecompRetroWfcPayloadMode payloadMode, + IProgress? progress, + CancellationToken cancellationToken + ) + { + var arguments = RecompLinuxSetupCommandBuilder.BuildInstallArguments( + environment.GameFilePath, + environment.RetroRewindFolderPath, + payloadMode + ); + logger.LogInformation("Running the recomp setup: {Setup} {Arguments}", setupFilePath, string.Join(' ', arguments)); + Report(progress, t("progress.recomp_running_setup"), SetupPercentFloor); + + var resultHolder = new EventHolder(); + OperationResult runResult; + try + { + runResult = await processRunner.RunAsync( + setupFilePath, + arguments, + workingDirectory: null, + line => HandleSetupOutput(line, progress, resultHolder), + cancellationToken + ); + } + finally + { + _launchReconciled = false; + } + + var setupOutcome = FinishSetupRun(runResult, resultHolder); + if (setupOutcome.IsFailure) + return setupOutcome; + + // The AppImage reports the version it actually is; the release tag is only the fallback. + var installedVersion = RecompVersion.TryParse(resultHolder.Value?.Version, out var reported) + ? reported.ToString() + : release.Version.ToString(); + var recordResult = RecordInstalledHost(setupFilePath, installedVersion, payloadMode); + if (recordResult.IsFailure) + return recordResult; + + Report(progress, t("progress.recomp_finished"), 100); + return Ok(); + } + + /// + /// Makes the AppImage that just installed the game the installed host: a copy beside a state file + /// that says which release it is. Only after this does the installation count as present. + /// + private OperationResult RecordInstalledHost(string setupFilePath, string installedVersion, RecompRetroWfcPayloadMode payloadMode) + { + return TryCatch( + () => + { + var hostFolderPath = fileSystem.Path.GetDirectoryName(environment.InstalledSetupFilePath); + if (!string.IsNullOrWhiteSpace(hostFolderPath)) + fileSystem.Directory.CreateDirectory(hostFolderPath); + if (!PathsMatch(setupFilePath, environment.InstalledSetupFilePath)) + fileSystem.File.Copy(setupFilePath, environment.InstalledSetupFilePath, overwrite: true); + hosts.MakeExecutable(environment.InstalledSetupFilePath); + + var retroRewindInstalled = environment.RetroRewindFolderPath is not null; + WriteInstallState( + new() + { + SchemaVersion = CurrentInstallStateSchemaVersion, + SetupVersion = installedVersion, + InstallDir = environment.InstallFolderPath, + RetroRewindInstalled = retroRewindInstalled, + RetroWfcPayloadMode = retroRewindInstalled ? PayloadModeName(payloadMode) : string.Empty, + } + ); + }, + errorMessage: "WiiCompiled was built, but Wheel Wizard could not record the installation." + ); + } + + public async Task ReconcileForLaunchAsync( + IProgress? progress = null, + CancellationToken cancellationToken = default + ) + { + if (!await _operationGate.WaitAsync(0, cancellationToken)) + return Fail(OperationAlreadyRunningMessage); + + try + { + return await ReconcileForLaunchCoreAsync(progress, cancellationToken); + } + finally + { + _operationGate.Release(); + } + } + + private async Task ReconcileForLaunchCoreAsync( + IProgress? progress, + CancellationToken cancellationToken + ) + { + _launchReconciled = false; + if (!HasInstalledHost) + return Fail(NotInstalledMessage); + + var state = ReadCurrentInstallState(); + if (state is null) + return Fail(StaleStateMessage); + if (!await hosts.SetupMatchesVersionAsync(environment.InstalledSetupFilePath, state.SetupVersion, cancellationToken)) + return Fail("The installed WiiCompiled host does not match its current install state."); + + // A launch never asks about offline play and never forces the payload upgrade: the user pressed + // Play, not Update. A repair the check demands anyway still gains the payload when it is reachable. + var payloadModeResult = await ResolveRetroWfcPayloadModeAsync(state, confirmOfflineInstall: null, cancellationToken); + if (payloadModeResult.IsFailure) + return payloadModeResult.Error; + + var repairResult = await RepairWhatTheCheckDemandsAsync( + progress, + payloadModeResult.Value, + forceRetroRebuild: false, + reportCompletion: false, + cancellationToken + ); + if (repairResult.IsFailure) + return repairResult; + + cancellationToken.ThrowIfCancellationRequested(); + _launchReconciled = true; + Report(progress, t("progress.recomp_finished"), 100); + return Ok(); + } + + public async Task LaunchAsync(CancellationToken cancellationToken = default) + { + if (!await _operationGate.WaitAsync(0, cancellationToken)) + return Fail(OperationAlreadyRunningMessage); + + try + { + return await LaunchCoreAsync(cancellationToken); + } + finally + { + _operationGate.Release(); + } + } + + private async Task LaunchCoreAsync(CancellationToken cancellationToken) + { + // The authorization is single use: it is consumed here whether or not the rest succeeds, so a + // second launch always has to go through pre-launch reconciliation again. + var reconciled = _launchReconciled; + _launchReconciled = false; + + if (!HasInstalledHost) + return Fail(NotInstalledMessage); + if (ReadCurrentInstallState() is null) + return Fail(StaleStateMessage); + if (!reconciled) + return Fail("WiiCompiled must complete its current pre-launch reconciliation before it can launch."); + + // The AppImage starts the product from the product's own directory and waits for it to exit. + var launchResult = await processRunner.RunAsync( + environment.InstalledSetupFilePath, + RecompLinuxSetupCommandBuilder.BuildLaunchArguments(retroRewind: true), + workingDirectory: null, + onStandardOutputLine: null, + cancellationToken + ); + if (launchResult.IsFailure) + return launchResult.Error; + return launchResult.Value == 0 ? Ok() : Fail($"WiiCompiled exited with code {launchResult.Value}."); + } + + public async Task UninstallAsync(CancellationToken cancellationToken = default) + { + if (!await _operationGate.WaitAsync(0, cancellationToken)) + return Fail(OperationAlreadyRunningMessage); + + try + { + return await UninstallCoreAsync(cancellationToken); + } + finally + { + _operationGate.Release(); + } + } + + private async Task UninstallCoreAsync(CancellationToken cancellationToken) + { + _launchReconciled = false; + + // Let the AppImage remove what it registered outside its folder (the application-menu entries) + // and whatever it installed. Its folder is removed below either way, so a failure here is logged, + // not fatal. + if (HasInstalledHost) + { + var uninstallResult = await processRunner.RunAsync( + environment.InstalledSetupFilePath, + RecompLinuxSetupCommandBuilder.BuildUninstallArguments(), + workingDirectory: null, + onStandardOutputLine: null, + cancellationToken + ); + if (uninstallResult.IsFailure || uninstallResult.Value != 0) + logger.LogWarning("The WiiCompiled AppImage did not uninstall cleanly; removing its folder anyway"); + } + + // The backend folder holds the products, the build workspace, Config.toml and the private NAND: + // everything the Windows uninstall removes as Install and UserData. The shared Retro Rewind + // installation lives elsewhere and survives on purpose. + return TryCatch( + () => + { + DeleteFolderIfPresent(environment.InstallFolderPath); + DeleteFolderIfPresent(fileSystem.Path.GetDirectoryName(environment.InstalledSetupFilePath)); + DeleteFolderIfPresent(environment.CacheFolderPath); + DeleteFolderIfPresent(environment.NandCopyFolderPath); + logger.LogInformation("Uninstalled WiiCompiled from {InstallFolder}", environment.InstallFolderPath); + }, + errorMessage: "Could not remove the WiiCompiled installation." + ); + } + + private void DeleteFolderIfPresent(string? folderPath) + { + if (!string.IsNullOrWhiteSpace(folderPath) && fileSystem.Directory.Exists(folderPath)) + fileSystem.Directory.Delete(folderPath, recursive: true); + } + + /// + /// Asks the inspector what is stale, rebuilds only when it says so, then confirms the rebuild + /// produced healthy products. An asset-only Retro Rewind change reports current, so the + /// expensive half never runs. + /// + /// + /// Runs the rebuild even when every product is current, so a payload choice that changed, which + /// the inspection cannot see, still reaches the AppImage. + /// + private async Task RepairWhatTheCheckDemandsAsync( + IProgress? progress, + RecompRetroWfcPayloadMode payloadMode, + bool forceRetroRebuild, + bool reportCompletion, + CancellationToken cancellationToken + ) + { + var checkResult = CheckProductsCore(); + if (checkResult.IsFailure) + return checkResult.Error; + + if (!NeedsRepair(checkResult.Value) && !forceRetroRebuild) + { + if (reportCompletion) + Report(progress, t("progress.recomp_finished"), 100); + return Ok(); + } + + logger.LogInformation( + "Rebuilding WiiCompiled products (base: {BaseStatus}, retro: {RetroStatus}, payload: {PayloadMode})", + checkResult.Value.Base.State, + checkResult.Value.RetroRewind.State, + payloadMode + ); + + var repairResult = await RunIncrementalInstallAsync(progress, payloadMode, cancellationToken); + if (repairResult.IsFailure) + return repairResult; + + var recheckResult = CheckProductsCore(); + if (recheckResult.IsFailure) + return recheckResult.Error; + if (NeedsRepair(recheckResult.Value)) + return Fail(BuildUnrepairedProductsMessage(recheckResult.Value)); + + if (reportCompletion) + Report(progress, t("progress.recomp_finished"), 100); + return Ok(); + } + + /// + /// install without --game: the AppImage keeps the extracted disc and its translation + /// cache, so this only redoes what a changed Code.pul or payload choice requires. + /// + private async Task RunIncrementalInstallAsync( + IProgress? progress, + RecompRetroWfcPayloadMode payloadMode, + CancellationToken cancellationToken + ) + { + var retroRewindFolderPath = environment.RetroRewindFolderPath; + if (string.IsNullOrWhiteSpace(retroRewindFolderPath)) + return Fail("Retro Rewind must be installed before WiiCompiled can repair or launch."); + + var arguments = RecompLinuxSetupCommandBuilder.BuildInstallArguments(gameFilePath: null, retroRewindFolderPath, payloadMode); + logger.LogInformation( + "Running the recomp setup: {Setup} {Arguments}", + environment.InstalledSetupFilePath, + string.Join(' ', arguments) + ); + Report(progress, t("progress.recomp_running_setup"), SetupPercentFloor); + + var resultHolder = new EventHolder(); + OperationResult runResult; + try + { + runResult = await processRunner.RunAsync( + environment.InstalledSetupFilePath, + arguments, + workingDirectory: null, + line => HandleSetupOutput(line, progress, resultHolder), + cancellationToken + ); + } + finally + { + // The AppImage may have replaced product bytes even when it later reports a failure or + // observes cancellation, so any earlier launch authorization is void. + _launchReconciled = false; + } + + var setupOutcome = FinishSetupRun(runResult, resultHolder); + if (setupOutcome.IsFailure) + return setupOutcome; + + // The product now embeds this payload choice; remember it so the next status read can tell + // an offline-only build from a normal one. Losing this only costs a redundant rebuild later. + try + { + var state = ReadInstalledState(); + if (state is not null) + { + state.RetroRewindInstalled = true; + state.RetroWfcPayloadMode = PayloadModeName(payloadMode); + WriteInstallState(state); + } + } + catch (Exception exception) + { + logger.LogWarning(exception, "Could not record the Retro-WFC payload mode of the rebuilt WiiCompiled products"); + } + + return Ok(); + } + + /// + /// Whether the AppImage has work to do. `absent` normally requires nothing, but WheelWizard is the + /// Retro Rewind frontend: when a Retro Rewind source is present, a missing Retro Rewind product is + /// something the rebuild must actually install, not a state to report as healthy forever. + /// + private bool NeedsRepair(RecompProductsEvent products) => + products.ActionRequired + || (products.RetroRewind.State == RecompProductState.Absent && !string.IsNullOrWhiteSpace(environment.RetroRewindFolderPath)); + + private static string BuildUnrepairedProductsMessage(RecompProductsEvent products) + { + var detail = products.RetroRewind.ActionRequired ? products.RetroRewind.Detail : products.Base.Detail; + return string.IsNullOrWhiteSpace(detail) + ? "The WiiCompiled rebuild finished, but the installed products are still not current." + : $"The WiiCompiled rebuild finished, but the installed products are still not current: {detail}"; + } + + private static void HandleSetupOutput( + string line, + IProgress? progress, + EventHolder resultHolder + ) + { + switch (RecompSetupOutputParser.Parse(line)) + { + case RecompSetupProgressEvent progressEvent: + Report( + progress, + string.IsNullOrWhiteSpace(progressEvent.Message) ? progressEvent.Stage : progressEvent.Message, + SetupPercentFloor + (progressEvent.Percent * (100 - SetupPercentFloor) / 100) + ); + break; + case RecompSetupResultEvent resultEvent: + resultHolder.Value = resultEvent; + break; + } + } + + private static OperationResult FinishSetupRun(OperationResult runResult, EventHolder resultHolder) + { + if (runResult.IsFailure) + return runResult.Error; + + var setupResult = resultHolder.Value; + if (setupResult is { Success: false }) + return Fail(string.IsNullOrWhiteSpace(setupResult.Error) ? "The recomp installer reported a failure." : setupResult.Error); + + if (runResult.Value != 0) + return Fail($"The recomp installer exited with code {runResult.Value}."); + + // Every operation emits exactly one terminal result record. + if (setupResult is null || resultHolder.Count != 1) + return Fail("The recomp installer did not report exactly one terminal result."); + + return Ok(); + } + + /// + /// Decides which payload option the next setup operation receives. The rules live in + /// ; this only supplies the probe and the user's answer. + /// + private async Task> ResolveRetroWfcPayloadModeAsync( + RecompInstallState? state, + Func>? confirmOfflineInstall, + CancellationToken cancellationToken + ) + { + var hasRetroRewindSource = !string.IsNullOrWhiteSpace(environment.RetroRewindFolderPath); + var serviceReachable = + !RecompRetroWfcPayloadPolicy.NeedsServiceProbe(state, hasRetroRewindSource) + || await payloadProbe.IsReachableAsync(cancellationToken); + + switch (RecompRetroWfcPayloadPolicy.Decide(state, hasRetroRewindSource, serviceReachable)) + { + case RecompRetroWfcPayloadDecision.Download: + return Ok(RecompRetroWfcPayloadMode.Download); + case RecompRetroWfcPayloadDecision.Skip: + return Ok(RecompRetroWfcPayloadMode.Skip); + default: + if (confirmOfflineInstall is null || !await confirmOfflineInstall()) + return Fail(RetroWfcUnavailableMessage); + + logger.LogInformation("The Retro-WFC payload service is unreachable; installing WiiCompiled without online play"); + return Ok(RecompRetroWfcPayloadMode.Skip); + } + } + + // The same spellings the Windows setup writes, so RecompRetroWfcPayloadPolicy reads both alike. + private static string PayloadModeName(RecompRetroWfcPayloadMode mode) => + mode == RecompRetroWfcPayloadMode.Skip ? "skipped" : "downloaded"; + + private RecompInstallState? ReadInstalledState() + { + try + { + if (!fileSystem.File.Exists(environment.InstallStateFilePath)) + return null; + + var json = fileSystem.File.ReadAllText(environment.InstallStateFilePath); + return JsonSerializer.Deserialize(json, InstallStateJsonOptions); + } + catch (Exception exception) + { + logger.LogWarning(exception, "Failed to read the recomp install state"); + return null; + } + } + + private void WriteInstallState(RecompInstallState state) + { + var folderPath = fileSystem.Path.GetDirectoryName(environment.InstallStateFilePath); + if (!string.IsNullOrWhiteSpace(folderPath)) + fileSystem.Directory.CreateDirectory(folderPath); + fileSystem.File.WriteAllText(environment.InstallStateFilePath, JsonSerializer.Serialize(state, InstallStateJsonOptions)); + } + + private RecompInstallState? ReadCurrentInstallState() + { + var state = ReadInstalledState(); + return IsCurrentInstallState(state) ? state : null; + } + + // Wheel Wizard's state is only as good as the AppImage's own: a backend folder the user wiped by + // hand leaves nothing to check or launch, so the installation reads as stale and the next Update + // runs a full install again. + private bool IsCurrentInstallState(RecompInstallState? state) => + state is { SchemaVersion: CurrentInstallStateSchemaVersion } + && RecompVersion.TryParse(state.SetupVersion, out _) + && PathsMatch(state.InstallDir, environment.InstallFolderPath) + && fileSystem.File.Exists(environment.BackendStateFilePath); + + private bool PathsMatch(string? first, string? second) + { + if (string.IsNullOrWhiteSpace(first) || string.IsNullOrWhiteSpace(second)) + return false; + + try + { + var normalizedFirst = Path.TrimEndingDirectorySeparator(fileSystem.Path.GetFullPath(first)); + var normalizedSecond = Path.TrimEndingDirectorySeparator(fileSystem.Path.GetFullPath(second)); + return normalizedFirst.Equals(normalizedSecond, StringComparison.Ordinal); + } + catch (Exception exception) + { + logger.LogWarning(exception, "Could not compare recomp installation paths"); + return false; + } + } + + private bool IsGameFileConfigured() + { + var gameFilePath = environment.GameFilePath; + return !string.IsNullOrWhiteSpace(gameFilePath) && fileSystem.File.Exists(gameFilePath); + } + + private static void Report(IProgress? progress, string message, int percent) => + RecompSetupHostAcquirer.Report(progress, message, percent); + + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + return; + + // The gate is deliberately left undisposed: an in-flight operation still has to Release() it, + // and a SemaphoreSlim whose wait handle was never touched holds no OS resources anyway. + GC.SuppressFinalize(this); + } +} diff --git a/WheelWizard/Features/Recomp/RecompLinuxProductInspector.cs b/WheelWizard/Features/Recomp/RecompLinuxProductInspector.cs new file mode 100644 index 000000000..1edc8a1bf --- /dev/null +++ b/WheelWizard/Features/Recomp/RecompLinuxProductInspector.cs @@ -0,0 +1,137 @@ +using System.IO.Abstractions; +using System.Security.Cryptography; +using System.Text.Json; +using Microsoft.Extensions.Logging; +using WheelWizard.Recomp.Domain; + +namespace WheelWizard.Recomp; + +/// +/// The Linux stand-in for the Windows setup's --check-products. The AppImage only prints a +/// human-readable table, so Wheel Wizard reconstructs the same from +/// what the AppImage leaves on disk: its install-state.json (which products exist and where) and +/// each product's local-build.json (which Code.pul it was compiled against). Anything that cannot +/// be verified reads as needing action, never as current. +/// +public sealed class RecompLinuxProductInspector(IFileSystem fileSystem, ILogger logger) +{ + public const string BaseProfile = "base"; + public const string RetroRewindProfile = "retro-rewind"; + public const string BuildRecordFileName = "local-build.json"; + + private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true }; + + /// + /// Reports the state of both products. + /// + /// The AppImage's own install-state.json. + /// The installed setup version Wheel Wizard recorded, echoed into the report. + /// The backend folder, echoed into the report as its install directory. + /// + /// Wheel Wizard's RetroRewind6 folder, whose Binaries/Code.pul the Retro Rewind product must + /// have been built from. when Retro Rewind is not installed, in which case the + /// Retro Rewind product is only checked for presence. + /// + public RecompProductsEvent Inspect(string backendStateFilePath, string? setupVersion, string installFolderPath, string? retroRewindFolderPath) + { + var state = ReadBackendState(backendStateFilePath, out var stateMalformed); + if (stateMalformed) + { + var malformed = new RecompProductStatus(RecompProductState.Unknown, "The WiiCompiled install state could not be read.", ProtocolValid: false); + return new(setupVersion, installFolderPath, RebuildRequired: false, malformed, malformed, ProtocolValid: false); + } + + var @base = InspectPresence(state, BaseProfile); + var retroRewind = InspectPresence(state, RetroRewindProfile); + if (retroRewind.IsCurrent && retroRewindFolderPath is not null) + retroRewind = InspectCodePul(FindRecord(state, RetroRewindProfile)!, retroRewindFolderPath); + + return new(setupVersion, installFolderPath, RebuildRequired: false, @base, retroRewind); + } + + private RecompProductStatus InspectPresence(RecompLinuxBackendState? state, string profile) + { + var record = FindRecord(state, profile); + if (record is null) + return new(RecompProductState.Absent, $"The {profile} product has not been built."); + + if (string.IsNullOrWhiteSpace(record.InstallDirectory) || string.IsNullOrWhiteSpace(record.ExecutableName)) + return new(RecompProductState.Broken, $"The {profile} product record does not say where its executable is."); + + var executablePath = fileSystem.Path.Combine(record.InstallDirectory, record.ExecutableName); + return fileSystem.File.Exists(executablePath) + ? new(RecompProductState.Current, "ok") + : new(RecompProductState.Broken, $"The {profile} executable is missing at {executablePath}."); + } + + /// + /// Retro Rewind is compiled together with its Code.pul, so a new Code.pul means the product on disk + /// no longer is Retro Rewind as installed. A build whose provenance cannot be read is treated the + /// same way: rebuilding is the only way to know. + /// + private RecompProductStatus InspectCodePul(RecompLinuxProductRecord record, string retroRewindFolderPath) + { + var codePulPath = fileSystem.Path.Combine(retroRewindFolderPath, "Binaries", "Code.pul"); + if (!fileSystem.File.Exists(codePulPath)) + return new(RecompProductState.InputsMissing, $"Retro Rewind has no Code.pul at {codePulPath}."); + + var buildRecord = ReadBuildRecord(fileSystem.Path.Combine(record.InstallDirectory!, BuildRecordFileName)); + if (string.IsNullOrWhiteSpace(buildRecord?.CodePulSha256)) + return new(RecompProductState.CodePulChanged, "The Retro Rewind product does not record which Code.pul it was built from."); + + string currentSha; + try + { + using var stream = fileSystem.File.OpenRead(codePulPath); + currentSha = Convert.ToHexString(SHA256.HashData(stream)); + } + catch (Exception exception) + { + logger.LogWarning(exception, "Could not hash the Retro Rewind Code.pul at {Path}", codePulPath); + return new(RecompProductState.InputsMissing, "The Retro Rewind Code.pul could not be read."); + } + + return string.Equals(currentSha, buildRecord.CodePulSha256, StringComparison.OrdinalIgnoreCase) + ? new(RecompProductState.Current, "ok") + : new(RecompProductState.CodePulChanged, "Code.pul changed since the Retro Rewind product was built."); + } + + private static RecompLinuxProductRecord? FindRecord(RecompLinuxBackendState? state, string profile) => + state?.Products.FirstOrDefault(record => string.Equals(record.Profile, profile, StringComparison.OrdinalIgnoreCase)); + + private RecompLinuxBackendState? ReadBackendState(string path, out bool malformed) + { + malformed = false; + try + { + if (!fileSystem.File.Exists(path)) + return null; + + var state = JsonSerializer.Deserialize(fileSystem.File.ReadAllText(path), JsonOptions); + malformed = state is null; + return state; + } + catch (Exception exception) + { + logger.LogWarning(exception, "Could not read the WiiCompiled install state at {Path}", path); + malformed = true; + return null; + } + } + + private RecompLinuxBuildRecord? ReadBuildRecord(string path) + { + try + { + if (!fileSystem.File.Exists(path)) + return null; + + return JsonSerializer.Deserialize(fileSystem.File.ReadAllText(path), JsonOptions); + } + catch (Exception exception) + { + logger.LogWarning(exception, "Could not read the WiiCompiled build record at {Path}", path); + return null; + } + } +} diff --git a/WheelWizard/Features/Recomp/RecompLinuxSetupCommandBuilder.cs b/WheelWizard/Features/Recomp/RecompLinuxSetupCommandBuilder.cs new file mode 100644 index 000000000..caa58496e --- /dev/null +++ b/WheelWizard/Features/Recomp/RecompLinuxSetupCommandBuilder.cs @@ -0,0 +1,56 @@ +using WheelWizard.Recomp.Domain; + +namespace WheelWizard.Recomp; + +/// +/// Builds the command lines of the recomp's Linux AppImage. Unlike the Windows setup, the AppImage takes +/// a subcommand (install, launch-retro, ...) and owns its install locations, so Wheel Wizard +/// never passes --install-dir or --portable: the products, install-state.json and +/// Config.toml all live where the AppImage puts them, under the user's XDG data directory. +/// Every method returns the argument vector as separate values, handed to +/// without any quoting. +/// +public static class RecompLinuxSetupCommandBuilder +{ + /// + /// Builds the install command. With the AppImage validates and + /// extracts the disc first; without it, it reuses the assets it already extracted and only + /// retranslates and recompiles what changed, which is how a repair or a Retro Rewind update is run. + /// + public static IReadOnlyList BuildInstallArguments( + string? gameFilePath, + string? retroRewindFolderPath, + RecompRetroWfcPayloadMode retroWfcPayloadMode = RecompRetroWfcPayloadMode.Download + ) + { + var arguments = new List { "install" }; + if (!string.IsNullOrWhiteSpace(gameFilePath)) + { + arguments.Add("--game"); + arguments.Add(gameFilePath); + } + + // The AppImage requires exactly one payload option whenever a Retro Rewind source is passed, + // and rejects either option without one. + if (!string.IsNullOrWhiteSpace(retroRewindFolderPath)) + { + arguments.Add("--retro-dir"); + arguments.Add(retroRewindFolderPath); + arguments.Add( + retroWfcPayloadMode == RecompRetroWfcPayloadMode.Skip ? "--skip-retro-wfc-payload" : "--download-retro-wfc-payload" + ); + } + + arguments.Add("--progress-json"); + return arguments; + } + + /// Starts an installed product. Wheel Wizard is the Retro Rewind frontend, so it launches that one. + public static IReadOnlyList BuildLaunchArguments(bool retroRewind) => [retroRewind ? "launch-retro" : "launch-base"]; + + /// Removes every product the AppImage installed, plus their application-menu entries. + public static IReadOnlyList BuildUninstallArguments() => ["uninstall"]; + + /// Makes the AppImage print its own semantic version. + public static IReadOnlyList BuildVersionArguments() => ["--version"]; +} diff --git a/WheelWizard/Features/Recomp/RecompPlatform.cs b/WheelWizard/Features/Recomp/RecompPlatform.cs new file mode 100644 index 000000000..9414e866e --- /dev/null +++ b/WheelWizard/Features/Recomp/RecompPlatform.cs @@ -0,0 +1,51 @@ +using System.Runtime.InteropServices; + +namespace WheelWizard.Recomp; + +/// +/// Which platforms the WiiCompiled integration runs on, and which setup asset each of them downloads. +/// Windows drives WiiCompiled-Setup.exe through the v1 command-line contract. Linux drives the +/// AppImage through its own subcommand interface (see ). Every +/// other platform is unsupported, so ISettingsManager.IsRecompModeActive() is false there and +/// nothing recomp-related is ever registered or shown. +/// +public static class RecompPlatform +{ + /// + /// Whether this is a Linux build on an architecture the recomp publishes an AppImage for. The AppImage + /// is always run unpacked (see ), so this holds inside a Flatpak + /// sandbox too: the AppImage then installs into the sandbox's own XDG data directory. + /// + public static bool IsLinux { get; } = OperatingSystem.IsLinux() && LinuxReleaseAssetName(RuntimeInformation.OSArchitecture) is not null; + + public static bool IsSupported => OperatingSystem.IsWindows() || IsLinux; + + /// The name of the installed host copy inside Wheel Wizard's recomp install directory. + public static string SetupFileName => IsLinux ? "WiiCompiled-Setup.AppImage" : RecompSetupCommandBuilder.SetupFileName; + + /// The release asset to download on this machine. + public static string ReleaseAssetName => + IsLinux ? LinuxReleaseAssetName(RuntimeInformation.OSArchitecture)! : RecompSetupCommandBuilder.SetupFileName; + + /// + /// The AppImage the recomp publishes for a Linux architecture, or when it + /// publishes none. The names are fixed by the recomp's packaging workflow. + /// + public static string? LinuxReleaseAssetName(Architecture architecture) => + architecture switch + { + Architecture.X64 => "WiiCompiled-Setup-x86_64.AppImage", + Architecture.Arm64 => "WiiCompiled-Setup-aarch64.AppImage", + _ => null, + }; + + /// The file name a downloaded release is cached under; the tag keeps releases apart. + public static string CachedSetupFileName(string tagName) + { + var sanitized = new string(tagName.Select(character => char.IsLetterOrDigit(character) ? character : '-').ToArray()); + return $"WiiCompiled-Setup-{sanitized}{Path.GetExtension(SetupFileName)}"; + } + + /// The pattern that matches every cached setup of this platform, for pruning superseded ones. + public static string CachedSetupSearchPattern => $"WiiCompiled-Setup-*{Path.GetExtension(SetupFileName)}"; +} diff --git a/WheelWizard/Features/Recomp/RecompProcessRunner.cs b/WheelWizard/Features/Recomp/RecompProcessRunner.cs index 513c22573..db017e898 100644 --- a/WheelWizard/Features/Recomp/RecompProcessRunner.cs +++ b/WheelWizard/Features/Recomp/RecompProcessRunner.cs @@ -1,4 +1,5 @@ using System.Diagnostics; +using System.Runtime.InteropServices; using Microsoft.Extensions.Logging; namespace WheelWizard.Recomp; @@ -12,10 +13,11 @@ public interface IRecompProcessRunner /// /// Runs a process to completion, forwarding every stdout line to . /// Stderr is captured for diagnostics only, since the contract keeps it out of the NDJSON stream. + /// are passed as separate values, never through a shell or a quoted string. /// Task> RunAsync( string fileName, - string arguments, + IReadOnlyList arguments, string? workingDirectory, Action? onStandardOutputLine, CancellationToken cancellationToken = default @@ -26,12 +28,19 @@ Task> RunAsync( public sealed class RecompProcessRunner(ILogger logger) : IRecompProcessRunner { private const string CancellationEventEnvironmentVariable = "MKWCOMPILED_CANCEL_EVENT"; + + // The AppImage runtime honours this by unpacking itself to a temporary directory instead of + // mounting through FUSE. It is always set for an AppImage: FUSE is not something to rely on (it + // is missing on many distributions and inside a Flatpak sandbox), and the unpack costs well under + // a second. + private const string AppImageExtractAndRunEnvironmentVariable = "APPIMAGE_EXTRACT_AND_RUN"; + private static readonly TimeSpan CancellationGracePeriod = TimeSpan.FromSeconds(10); private static readonly TimeSpan ForcedExitGracePeriod = TimeSpan.FromSeconds(5); public async Task> RunAsync( string fileName, - string arguments, + IReadOnlyList arguments, string? workingDirectory, Action? onStandardOutputLine, CancellationToken cancellationToken = default @@ -50,6 +59,8 @@ public async Task> RunAsync( var startInfo = CreateStartInfo(fileName, arguments, workingDirectory); if (cancellationEvent is not null) startInfo.Environment[CancellationEventEnvironmentVariable] = cancellationEventName; + if (fileName.EndsWith(".AppImage", StringComparison.OrdinalIgnoreCase)) + startInfo.Environment[AppImageExtractAndRunEnvironmentVariable] = "1"; using var process = new Process { StartInfo = startInfo, EnableRaisingEvents = true }; @@ -79,9 +90,10 @@ public async Task> RunAsync( } catch (OperationCanceledException) { - var exited = cancellationEvent is not null - ? await CancelAndWaitForExitAsync(process, cancellationEvent) - : await KillAndWaitForExitAsync(process); + var exited = + cancellationEvent is not null ? await CancelAndWaitForExitAsync(process, cancellationEvent) + : OperatingSystem.IsWindows() ? await KillAndWaitForExitAsync(process) + : await TerminateAndWaitForExitAsync(process); if (!exited) throw; @@ -104,11 +116,11 @@ public async Task> RunAsync( } } - private static ProcessStartInfo CreateStartInfo(string fileName, string arguments, string? workingDirectory) => - new() + private static ProcessStartInfo CreateStartInfo(string fileName, IReadOnlyList arguments, string? workingDirectory) + { + var startInfo = new ProcessStartInfo { FileName = fileName, - Arguments = arguments, WorkingDirectory = string.IsNullOrWhiteSpace(workingDirectory) ? string.Empty : workingDirectory, UseShellExecute = false, // The recomp setup is CLI-only. Wheel Wizard supplies the UI, including during launch, @@ -118,6 +130,14 @@ private static ProcessStartInfo CreateStartInfo(string fileName, string argument RedirectStandardError = true, }; + // ArgumentList hands each value over as one argv entry: verbatim on Unix, and quoted the way + // CommandLineToArgvW expects on Windows. Neither builder has to know about quoting. + foreach (var argument in arguments) + startInfo.ArgumentList.Add(argument); + + return startInfo; + } + private async Task CancelAndWaitForExitAsync(Process process, EventWaitHandle cancellationEvent) { try @@ -129,6 +149,107 @@ private async Task CancelAndWaitForExitAsync(Process process, EventWaitHan logger.LogWarning(exception, "Failed to signal cooperative cancellation to the recomp setup process"); } + return await WaitForCooperativeExitAsync(process); + } + + /// + /// The Unix equivalent of the named event: the AppImage's setup handles SIGTERM by stopping the + /// build it spawned and writing its terminal result line, so it gets the same grace period. + /// The process Wheel Wizard started is the AppImage runtime, which runs the setup in a child and + /// does not forward signals to it, so every descendant is signalled, not just the root. The list is + /// taken before signalling: once the runtime dies its children are reparented and no longer found, + /// which is also why the forced stop below cannot rely on alone. + /// + private async Task TerminateAndWaitForExitAsync(Process process) + { + var processIds = await ProcessTreeIdsAsync(process); + foreach (var processId in processIds) + { + if (Kill(processId, Sigterm) != 0) + logger.LogDebug("Failed to send SIGTERM to recomp process {Pid} (errno {Errno})", processId, Marshal.GetLastPInvokeError()); + } + + try + { + // The redirected streams only close once every descendant holding them has exited, so this + // waits for the setup's own terminal result line, not merely for the runtime. + await process.WaitForExitAsync().WaitAsync(CancellationGracePeriod); + process.WaitForExit(); + return true; + } + catch (TimeoutException) + { + logger.LogWarning( + "Recomp setup did not exit within {GracePeriodSeconds} seconds after cooperative cancellation; stopping it", + CancellationGracePeriod.TotalSeconds + ); + } + + // Descendants the runtime has already orphaned are out of reach of Kill(entireProcessTree), + // so they are stopped by the ids recorded above; the root's own tree is handled the usual way. + foreach (var processId in processIds.Skip(1)) + { + try + { + using var descendant = Process.GetProcessById(processId); + descendant.Kill(); + } + catch (Exception) + { + // Already gone, or reused by an unrelated process that is not ours to stop. + } + } + + return await KillAndWaitForExitAsync(process); + } + + /// + /// The started process and all of its descendants, root first. .NET exposes no parent process id, + /// so the tree is read through ps, which prints it the same way on Linux and macOS. + /// + private async Task> ProcessTreeIdsAsync(Process root) + { + var childrenByParent = new Dictionary>(); + try + { + var startInfo = new ProcessStartInfo("ps") + { + ArgumentList = { "-e", "-o", "pid=,ppid=" }, + RedirectStandardOutput = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + using var ps = Process.Start(startInfo) ?? throw new InvalidOperationException("ps did not start"); + var output = await ps.StandardOutput.ReadToEndAsync(); + await ps.WaitForExitAsync(); + + foreach (var line in output.Split('\n', StringSplitOptions.RemoveEmptyEntries)) + { + var fields = line.Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (fields.Length < 2 || !int.TryParse(fields[0], out var processId) || !int.TryParse(fields[1], out var parentId)) + continue; + if (!childrenByParent.TryGetValue(parentId, out var children)) + childrenByParent[parentId] = children = []; + children.Add(processId); + } + } + catch (Exception exception) + { + logger.LogWarning(exception, "Could not enumerate the recomp setup's process tree"); + } + + var result = new List { root.Id }; + for (var index = 0; index < result.Count; index++) + { + if (childrenByParent.TryGetValue(result[index], out var children)) + result.AddRange(children); + } + + return result; + } + + private async Task WaitForCooperativeExitAsync(Process process) + { try { await process.WaitForExitAsync().WaitAsync(CancellationGracePeriod); @@ -182,4 +303,10 @@ private bool TryKill(Process process) return false; } } + + private const int Sigterm = 15; + + // .NET can only SIGKILL another process; SIGTERM, the cooperative request, has to go to libc directly. + [DllImport("libc", EntryPoint = "kill", SetLastError = true)] + private static extern int Kill(int pid, int signal); } diff --git a/WheelWizard/Features/Recomp/RecompReleaseResolver.cs b/WheelWizard/Features/Recomp/RecompReleaseResolver.cs index cde21dc56..7e421e5e1 100644 --- a/WheelWizard/Features/Recomp/RecompReleaseResolver.cs +++ b/WheelWizard/Features/Recomp/RecompReleaseResolver.cs @@ -23,11 +23,16 @@ public static class RecompReleaseResolver /// /// Returns the newest usable release, or when the listing contains none. /// - public static RecompRelease? FindLatest(IEnumerable? releases) + /// + /// The setup asset a release must carry to count, by + /// default. A release that only ships another platform's setup is skipped, not treated as newest. + /// + public static RecompRelease? FindLatest(IEnumerable? releases, string? assetName = null) { if (releases is null) return null; + assetName ??= RecompPlatform.ReleaseAssetName; RecompRelease? best = null; foreach (var release in releases) { @@ -37,9 +42,7 @@ public static class RecompReleaseResolver if (!RecompVersion.TryParse(release.TagName, out var version)) continue; - var asset = release.Assets.FirstOrDefault(candidate => - string.Equals(candidate.Name, RecompSetupCommandBuilder.SetupFileName, StringComparison.OrdinalIgnoreCase) - ); + var asset = release.Assets.FirstOrDefault(candidate => string.Equals(candidate.Name, assetName, StringComparison.OrdinalIgnoreCase)); if (asset is null || string.IsNullOrWhiteSpace(asset.BrowserDownloadUrl)) continue; diff --git a/WheelWizard/Features/Recomp/RecompRetroWfcPayloadPolicy.cs b/WheelWizard/Features/Recomp/RecompRetroWfcPayloadPolicy.cs new file mode 100644 index 000000000..0a0d01265 --- /dev/null +++ b/WheelWizard/Features/Recomp/RecompRetroWfcPayloadPolicy.cs @@ -0,0 +1,61 @@ +using WheelWizard.Recomp.Domain; + +namespace WheelWizard.Recomp; + +/// +/// What the install service should do about the Retro-WFC payload for the next setup operation. +/// +public enum RecompRetroWfcPayloadDecision +{ + /// Pass --download-retro-wfc-payload. + Download, + + /// Pass --skip-retro-wfc-payload without asking: the installation is already offline-only. + Skip, + + /// A fresh Retro Rewind build cannot get its payload; only the user can choose offline-only. + AskUser, +} + +/// +/// The pure decision behind the payload option, kept free of I/O so the cases can be tested directly. +/// An installation that already embeds a payload always asks the host to download, because the host +/// falls back to its own verified copy when the service is down. A skipped installation upgrades when +/// the service is back and otherwise stays as it is. Only a Retro Rewind build with nothing to fall +/// back to is the user's call. +/// +public static class RecompRetroWfcPayloadPolicy +{ + /// + /// Whether the decision depends on the payload service at all. When it does not, no probe should run: + /// a normal installation must never wait on a dead endpoint just to be told what it already knows. + /// + public static bool NeedsServiceProbe(RecompInstallState? state, bool hasRetroRewindSource) => + hasRetroRewindSource && !HasVerifiedPayload(state); + + /// + /// Decides the payload option. is only consulted when + /// says so; pass any value otherwise. + /// + public static RecompRetroWfcPayloadDecision Decide(RecompInstallState? state, bool hasRetroRewindSource, bool serviceReachable) + { + if (!NeedsServiceProbe(state, hasRetroRewindSource) || serviceReachable) + return RecompRetroWfcPayloadDecision.Download; + + return state is { IsRetroWfcPayloadSkipped: true } ? RecompRetroWfcPayloadDecision.Skip : RecompRetroWfcPayloadDecision.AskUser; + } + + /// + /// Whether the installed Retro Rewind product carries a payload the host can fall back to. A state + /// that predates the mode field but reports Retro Rewind installed counts: every host so far has + /// only ever built Retro Rewind with a downloaded payload, so absent means downloaded, never skipped. + /// + private static bool HasVerifiedPayload(RecompInstallState? state) + { + if (state is null) + return false; + if (string.Equals(state.RetroWfcPayloadMode, "downloaded", StringComparison.OrdinalIgnoreCase)) + return true; + return string.IsNullOrWhiteSpace(state.RetroWfcPayloadMode) && state.RetroRewindInstalled; + } +} diff --git a/WheelWizard/Features/Recomp/RecompRetroWfcPayloadProbe.cs b/WheelWizard/Features/Recomp/RecompRetroWfcPayloadProbe.cs new file mode 100644 index 000000000..90e60c4f6 --- /dev/null +++ b/WheelWizard/Features/Recomp/RecompRetroWfcPayloadProbe.cs @@ -0,0 +1,89 @@ +using Microsoft.Extensions.Logging; + +namespace WheelWizard.Recomp; + +/// +/// Answers whether the shared Retro-WFC payload can currently be downloaded. The setup host is the only +/// thing that ever downloads and verifies the payload; this probe exists so WheelWizard can decide, before +/// starting a long install, whether to ask the user for an offline-only build instead of failing later. +/// +public interface IRecompRetroWfcPayloadProbe +{ + /// + /// True when the payload endpoint answered with a success status. Never throws: any failure, including + /// a timeout, simply reads as unreachable. + /// + Task IsReachableAsync(CancellationToken cancellationToken = default); +} + +/// +public sealed class RecompRetroWfcPayloadProbe(IHttpClientFactory httpClientFactory, ILogger logger) + : IRecompRetroWfcPayloadProbe +{ + /// + /// The fixed payload endpoint the setup host downloads from. It must stay identical to the value pinned + /// in the recomp's RetroWfcPayload.CurrentRetroWfcPayloadUri, since probing anything else says + /// nothing about whether the host's own download will succeed. Plain HTTP is what the service offers + /// and what the host pins; that is safe here because this probe decides nothing about trust. The host + /// verifies the payload's RSA signature itself, so a forged answer can at worst make the host attempt + /// a download that its own verification then rejects. + /// + public const string PayloadUri = "http://nas.play.rwfc.net/payload?g=RMCPD00"; + + // A dead endpoint times out rather than refusing, so a short cap is what keeps the home page and the + // install flow from stalling on every status refresh while the service is down. + private static readonly TimeSpan ProbeTimeout = TimeSpan.FromSeconds(6); + + // A status refresh and the install that follows it should not each pay for their own probe. + private static readonly TimeSpan ResultLifetime = TimeSpan.FromSeconds(60); + + private readonly object _cacheLock = new(); + private DateTimeOffset _cachedAtUtc = DateTimeOffset.MinValue; + private bool _cachedResult; + + public async Task IsReachableAsync(CancellationToken cancellationToken = default) + { + lock (_cacheLock) + { + if (DateTimeOffset.UtcNow - _cachedAtUtc < ResultLifetime) + return _cachedResult; + } + + var reachable = await ProbeAsync(cancellationToken); + + lock (_cacheLock) + { + _cachedResult = reachable; + _cachedAtUtc = DateTimeOffset.UtcNow; + } + + return reachable; + } + + private async Task ProbeAsync(CancellationToken cancellationToken) + { + try + { + using var timeoutSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutSource.CancelAfter(ProbeTimeout); + + // Headers only: the payload is a multi-megabyte binary and its bytes are the host's business. + var client = httpClientFactory.CreateClient(RecompSetupDownloader.HttpClientName); + using var response = await client.GetAsync(PayloadUri, HttpCompletionOption.ResponseHeadersRead, timeoutSource.Token); + if (response.IsSuccessStatusCode) + return true; + + logger.LogWarning("The Retro-WFC payload endpoint answered {StatusCode}", (int)response.StatusCode); + return false; + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception exception) + { + logger.LogWarning("The Retro-WFC payload endpoint is unreachable: {Message}", exception.Message); + return false; + } + } +} diff --git a/WheelWizard/Features/Recomp/RecompSetupCommandBuilder.cs b/WheelWizard/Features/Recomp/RecompSetupCommandBuilder.cs index 34499c6fd..15c3c0979 100644 --- a/WheelWizard/Features/Recomp/RecompSetupCommandBuilder.cs +++ b/WheelWizard/Features/Recomp/RecompSetupCommandBuilder.cs @@ -5,6 +5,8 @@ namespace WheelWizard.Recomp; /// /// Builds the command lines described by the WheelWizard-Recomp integration contract. /// The recomp owns the actual behaviour; WheelWizard only decides which of its own paths to hand over. +/// Every method returns the argument vector as separate values: the process runner hands them to +/// , so no quoting happens here. /// public static class RecompSetupCommandBuilder { @@ -16,7 +18,7 @@ public static class RecompSetupCommandBuilder /// /// Builds the arguments for a silent install, which doubles as the in-place upgrade command. /// - public static string BuildSilentInstallArguments(RecompInstallRequest request) + public static IReadOnlyList BuildSilentInstallArguments(RecompInstallRequest request) { ArgumentNullException.ThrowIfNull(request); if (string.IsNullOrWhiteSpace(request.GameFilePath)) @@ -24,14 +26,7 @@ public static string BuildSilentInstallArguments(RecompInstallRequest request) if (string.IsNullOrWhiteSpace(request.InstallFolderPath)) throw new ArgumentException("An install directory is required.", nameof(request)); - var arguments = new List - { - "--silent", - "--game", - Quote(request.GameFilePath), - "--install-dir", - Quote(request.InstallFolderPath), - }; + var arguments = new List { "--silent", "--game", request.GameFilePath, "--install-dir", request.InstallFolderPath }; // Only a silent install may request the portable layout, and only when WheelWizard actually // handed over its own portable location. An installation that predates portable mode keeps @@ -44,11 +39,11 @@ public static string BuildSilentInstallArguments(RecompInstallRequest request) if (!string.IsNullOrWhiteSpace(request.RetroRewindFolderPath)) { arguments.Add("--retro-dir"); - arguments.Add(Quote(request.RetroRewindFolderPath)); - arguments.Add(RetroWfcPayloadArgument); + arguments.Add(request.RetroRewindFolderPath); + arguments.Add(RetroWfcPayloadArgument(request.RetroWfcPayloadMode)); } - return string.Join(' ', arguments); + return arguments; } /// @@ -56,72 +51,62 @@ public static string BuildSilentInstallArguments(RecompInstallRequest request) /// toolkit the installation already owns instead of an embedded payload. Every repair receives the /// Retro Rewind source and exactly one Retro-WFC payload option, as the contract requires. /// - public static string BuildRepairProductsArguments(string installFolderPath, string retroRewindFolderPath) + public static IReadOnlyList BuildRepairProductsArguments( + string installFolderPath, + string retroRewindFolderPath, + RecompRetroWfcPayloadMode retroWfcPayloadMode = RecompRetroWfcPayloadMode.Download + ) { if (string.IsNullOrWhiteSpace(installFolderPath)) throw new ArgumentException("An install directory is required.", nameof(installFolderPath)); if (string.IsNullOrWhiteSpace(retroRewindFolderPath)) throw new ArgumentException("A Retro Rewind source directory is required.", nameof(retroRewindFolderPath)); - return string.Join( - ' ', + return + [ "--repair-products", "--install-dir", - Quote(installFolderPath), + installFolderPath, "--retro-dir", - Quote(retroRewindFolderPath), - RetroWfcPayloadArgument, - "--progress-json" - ); + retroRewindFolderPath, + RetroWfcPayloadArgument(retroWfcPayloadMode), + "--progress-json", + ]; } /// /// Builds the arguments used to start the game. WheelWizard is the Retro Rewind frontend, so it starts /// Retro Rewind whenever that product is installed and only falls back to the unmodded game otherwise. /// - public static string BuildLaunchArguments(bool retroRewind) => retroRewind ? "--launch-retro" : "--launch-base"; + public static IReadOnlyList BuildLaunchArguments(bool retroRewind) => [retroRewind ? "--launch-retro" : "--launch-base"]; /// /// Builds the arguments that report, without building anything, whether the installed executables are /// still the output of the installed toolkit and the installed Code.pul. /// - public static string BuildCheckProductsArguments(string installFolderPath, string? retroRewindFolderPath = null) + public static IReadOnlyList BuildCheckProductsArguments(string installFolderPath, string? retroRewindFolderPath = null) { if (string.IsNullOrWhiteSpace(installFolderPath)) throw new ArgumentException("An install directory is required.", nameof(installFolderPath)); - var arguments = new List { "--check-products", "--install-dir", Quote(installFolderPath) }; + var arguments = new List { "--check-products", "--install-dir", installFolderPath }; if (!string.IsNullOrWhiteSpace(retroRewindFolderPath)) { arguments.Add("--retro-dir"); - arguments.Add(Quote(retroRewindFolderPath)); + arguments.Add(retroRewindFolderPath); } arguments.Add("--progress-json"); - return string.Join(' ', arguments); + return arguments; } /// /// Builds the arguments that make the setup executable print its own semantic version. /// - public static string BuildVersionArguments() => "--version"; + public static IReadOnlyList BuildVersionArguments() => ["--version"]; - // The contract requires exactly one payload option (--download-retro-wfc-payload or - // --skip-retro-wfc-payload) whenever --retro-dir is passed; WheelWizard always downloads. - private const string RetroWfcPayloadArgument = "--download-retro-wfc-payload"; - - // The recomp is Windows only, so quoting is always Windows quoting (never the POSIX form EnvHelper would pick). - private static string Quote(string path) - { - var value = path.Trim(); - - // A trailing backslash would escape the closing quote for CommandLineToArgvW, so drop redundant - // separators and double the one that has to stay (a drive root such as "D:\"). - while (value.Length > 1 && value[^1] == '\\' && !value.EndsWith(@":\", StringComparison.Ordinal)) - value = value[..^1]; - if (value.EndsWith('\\')) - value += '\\'; - - return $"\"{value}\""; - } + // The contract requires exactly one payload option whenever --retro-dir is passed. WheelWizard + // downloads unless the payload service is unreachable and the user chose an offline-only build. + private static string RetroWfcPayloadArgument(RecompRetroWfcPayloadMode mode) => + mode == RecompRetroWfcPayloadMode.Skip ? "--skip-retro-wfc-payload" : "--download-retro-wfc-payload"; } diff --git a/WheelWizard/Features/Recomp/RecompSetupHostAcquirer.cs b/WheelWizard/Features/Recomp/RecompSetupHostAcquirer.cs new file mode 100644 index 000000000..8b160acd6 --- /dev/null +++ b/WheelWizard/Features/Recomp/RecompSetupHostAcquirer.cs @@ -0,0 +1,214 @@ +using System.IO.Abstractions; +using Microsoft.Extensions.Logging; +using WheelWizard.GitHub; +using WheelWizard.Recomp.Domain; + +namespace WheelWizard.Recomp; + +/// +/// Gets a setup host onto disk: finds the newest release for this platform, downloads its setup into the +/// cache, and proves the file is that release by asking it for its version. This is the part of the +/// install flow that is the same on every platform; what the setup is then told to do is not, and lives +/// in the per-platform install services. +/// +public sealed class RecompSetupHostAcquirer( + IRecompProcessRunner processRunner, + IRecompSetupDownloader downloader, + IGitHubSingletonService gitHubService, + IFileSystem fileSystem, + ILogger logger +) +{ + // How the phases of an install divide up the 0-100 progress bar. + public const int DownloadPercentFloor = 5; + public const int SetupPercentFloor = 35; + + public async Task TryGetLatestReleaseAsync(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + var releasesResult = await gitHubService.GetReleasesAsync( + RecompReleaseResolver.RepositoryOwner, + RecompReleaseResolver.RepositoryName, + count: 100 + ); + if (releasesResult.IsFailure) + { + logger.LogWarning("Could not retrieve the recomp releases: {Message}", releasesResult.Error.Message); + return null; + } + + return RecompReleaseResolver.FindLatest(releasesResult.Value); + } + + /// + /// Returns the path of a cached setup that reports 's version, downloading it + /// first when the cache has none. Setups of other releases are pruned once this one is known good. + /// + public async Task> EnsureSetupDownloadedAsync( + RecompRelease release, + string cacheFolderPath, + IProgress? progress, + CancellationToken cancellationToken + ) + { + var cachedSetupPath = fileSystem.Path.Combine(cacheFolderPath, RecompPlatform.CachedSetupFileName(release.TagName)); + if (IsUsableFile(cachedSetupPath) && await SetupMatchesVersionAsync(cachedSetupPath, release.TagName, cancellationToken)) + { + PruneCachedSetupsExcept(cacheFolderPath, cachedSetupPath); + return Ok(cachedSetupPath); + } + + var downloadMessage = t("progress.recomp_downloading_setup"); + Report(progress, downloadMessage, DownloadPercentFloor); + + var downloadProgress = new DelegateProgress(percent => + Report(progress, downloadMessage, DownloadPercentFloor + (percent * (SetupPercentFloor - DownloadPercentFloor) / 100)) + ); + + var downloadResult = await downloader.DownloadAsync(release.SetupDownloadUrl, cachedSetupPath, downloadProgress, cancellationToken); + if (downloadResult.IsFailure) + return downloadResult.Error; + + if (!IsUsableFile(cachedSetupPath)) + return Fail("The downloaded WiiCompiled setup is missing or empty."); + + MakeExecutable(cachedSetupPath); + if (!await SetupMatchesVersionAsync(cachedSetupPath, release.TagName, cancellationToken)) + { + var removed = DeleteInvalidSetup(cachedSetupPath); + return removed + ? Fail($"The downloaded WiiCompiled setup did not report release {release.TagName}.") + : Fail($"The downloaded WiiCompiled setup did not report release {release.TagName} and could not be removed."); + } + + PruneCachedSetupsExcept(cacheFolderPath, cachedSetupPath); + return Ok(cachedSetupPath); + } + + /// + /// Whether the setup at reports exactly . + /// Both setups answer --version with a bare semantic version on stdout. + /// + public async Task SetupMatchesVersionAsync(string setupFilePath, string? expectedVersion, CancellationToken cancellationToken) + { + if (!RecompVersion.TryParse(expectedVersion, out var expected)) + return false; + + string? versionText = null; + var runResult = await processRunner.RunAsync( + setupFilePath, + RecompSetupCommandBuilder.BuildVersionArguments(), + workingDirectory: null, + line => + { + if (RecompVersion.TryParse(line, out var version)) + versionText = version.ToString(); + }, + cancellationToken + ); + + return runResult.IsSuccess + && runResult.Value == 0 + && RecompVersion.TryParse(versionText, out var cachedVersion) + && cachedVersion.ComparePrecedenceTo(expected) == 0; + } + + public static bool VersionsMatch(string? first, string? second) => + RecompVersion.TryParse(first, out var parsedFirst) + && RecompVersion.TryParse(second, out var parsedSecond) + && parsedFirst.ComparePrecedenceTo(parsedSecond) == 0; + + public bool IsUsableFile(string filePath) + { + try + { + return fileSystem.File.Exists(filePath) && fileSystem.FileInfo.New(filePath).Length > 0; + } + catch (Exception exception) + { + logger.LogWarning(exception, "Failed to inspect the cached recomp setup at {Path}", filePath); + return false; + } + } + + /// + /// A downloaded AppImage arrives without its execute bit. Windows has no such bit, and a failure here + /// is left for the version check to surface as "the setup did not report its version". + /// + public void MakeExecutable(string filePath) + { + if (OperatingSystem.IsWindows()) + return; + + try + { + var mode = fileSystem.File.GetUnixFileMode(filePath); + fileSystem.File.SetUnixFileMode(filePath, mode | UnixFileMode.UserExecute | UnixFileMode.GroupExecute | UnixFileMode.OtherExecute); + } + catch (Exception exception) + { + logger.LogWarning(exception, "Could not mark the recomp setup at {Path} as executable", filePath); + } + } + + public static void Report(IProgress? progress, string message, int percent) => + progress?.Report(new(message, Math.Clamp(percent, 0, 100))); + + /// + /// Drops setups cached for other releases. Each one is hundreds of megabytes and the cache is only + /// ever read for the release currently being installed, so keeping them meant every update + /// permanently cost the user another installer's worth of disk. Failure here is deliberately + /// silent: it is disk hygiene, never a reason to fail an install that has already succeeded. + /// + private void PruneCachedSetupsExcept(string cacheFolderPath, string keepFilePath) + { + try + { + if (!fileSystem.Directory.Exists(cacheFolderPath)) + return; + foreach (var candidate in fileSystem.Directory.EnumerateFiles(cacheFolderPath, RecompPlatform.CachedSetupSearchPattern)) + { + if (string.Equals(candidate, keepFilePath, StringComparison.OrdinalIgnoreCase)) + continue; + try + { + fileSystem.File.Delete(candidate); + logger.LogInformation("Removed the superseded cached WiiCompiled setup {Path}", candidate); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + logger.LogDebug(exception, "Could not remove the cached WiiCompiled setup {Path}", candidate); + } + } + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + logger.LogDebug(exception, "Could not enumerate the WiiCompiled setup cache for pruning."); + } + } + + private bool DeleteInvalidSetup(string setupFilePath) + { + try + { + if (fileSystem.File.Exists(setupFilePath)) + fileSystem.File.Delete(setupFilePath); + return true; + } + catch (Exception exception) + { + logger.LogError(exception, "Could not remove invalid recomp setup at {Path}", setupFilePath); + return false; + } + } + + /// + /// Forwards progress synchronously, so the single the launcher owns stays the + /// only place where marshalling to the UI thread happens. + /// + private sealed class DelegateProgress(Action handler) : IProgress + { + public void Report(T value) => handler(value); + } +} diff --git a/WheelWizard/Features/Recomp/RecompVideoConfig.cs b/WheelWizard/Features/Recomp/RecompVideoConfig.cs index aadfa3c3c..b90b019e1 100644 --- a/WheelWizard/Features/Recomp/RecompVideoConfig.cs +++ b/WheelWizard/Features/Recomp/RecompVideoConfig.cs @@ -11,10 +11,11 @@ public static class RecompVideoConfig /// /// The graphics APIs WheelWizard offers, in the order they are shown. The backend enumerates more - /// (auto, d3d11, opengl, ...), but only these two actually run the game reliably, so nothing else - /// is ever offered. A value outside this list stays untouched until the user picks one of these. + /// (auto, d3d11, opengl, ...), but only these actually run the game reliably, so nothing else is + /// ever offered; Linux has no DirectX, so only Vulkan remains there. A value outside this list + /// stays untouched until the user picks one of these. /// - public static IReadOnlyList OfferedGraphicsApis { get; } = ["d3d12", "vulkan"]; + public static IReadOnlyList OfferedGraphicsApis { get; } = RecompPlatform.IsLinux ? ["vulkan"] : ["d3d12", "vulkan"]; /// The label for a graphics API value, for example DirectX 12 for d3d12. public static string DescribeGraphicsApi(string api) => diff --git a/WheelWizard/Features/Settings/ISettingsServices.cs b/WheelWizard/Features/Settings/ISettingsServices.cs index a07bcc95a..d756d0062 100644 --- a/WheelWizard/Features/Settings/ISettingsServices.cs +++ b/WheelWizard/Features/Settings/ISettingsServices.cs @@ -48,7 +48,6 @@ public interface ISettingsProperties Setting ENABLE_ANIMATIONS { get; } Setting TESTING_MODE_ENABLED { get; } Setting SAVED_WINDOW_SCALE { get; } - Setting REMOVE_BLUR { get; } Setting RR_REGION { get; } Setting WW_LANGUAGE { get; } Setting NAND_ROOT_PATH { get; } @@ -78,7 +77,7 @@ public interface ISettingsManager : ISettingsProperties /// /// Whether WiiCompiled is the active frontend instead of Dolphin/Retro Rewind. This is the single - /// definition of that mode: it carries the Windows-only guard, so a stale EnableRecomp + /// definition of that mode: it carries the platform guard, so a stale EnableRecomp /// flag can never activate recomp behavior on a platform the recomp does not run on. /// bool IsRecompModeActive(); diff --git a/WheelWizard/Features/Settings/SettingsManager.cs b/WheelWizard/Features/Settings/SettingsManager.cs index ae911eb96..c391cc84a 100644 --- a/WheelWizard/Features/Settings/SettingsManager.cs +++ b/WheelWizard/Features/Settings/SettingsManager.cs @@ -3,6 +3,7 @@ using WheelWizard.DolphinInstaller; using WheelWizard.Helpers; using WheelWizard.Models.Enums; +using WheelWizard.Recomp; using WheelWizard.Services; using WheelWizard.Settings.Types; @@ -40,11 +41,10 @@ IFileSystem fileSystem // Register this first because the path validators use the active frontend mode when deciding // whether Dolphin-only locations may be left blank. ENABLE_RECOMP = RegisterWhWz("EnableRecomp", false); - // A recomp install should continue using the Dolphin NAND Wheel Wizard already manages. - // Users can still opt out in Recomp Settings when they deliberately want private data. - RECOMP_USE_DOLPHIN_DATA = RegisterWhWz("RecompUseDolphinData", true); - // Dolphin advises against other programs touching its NAND in place, so the first install - // offers to copy it instead; this remembers that the user chose the copy. + // Whether WiiCompiled directly shares Dolphin's live NAND. Disabled means private mode; + // private mode uses the imported clone below when one exists, otherwise the runtime default. + RECOMP_USE_DOLPHIN_DATA = RegisterWhWz("RecompUseDolphinData", false); + // Whether private mode was initialized from the Wheel Wizard-owned Dolphin clone. RECOMP_COPY_DOLPHIN_NAND = RegisterWhWz("RecompCopyDolphinNand", false); DOLPHIN_LOCATION = RegisterWhWz( "DolphinLocation", @@ -125,7 +125,6 @@ IFileSystem fileSystem ENABLE_ANIMATIONS = RegisterWhWz("EnableAnimations", true); TESTING_MODE_ENABLED = RegisterWhWz("TestingModeEnabled", false); SAVED_WINDOW_SCALE = RegisterWhWz("WindowScale", 1.0, SettingValues.IsValidWindowScale); - REMOVE_BLUR = RegisterWhWz("REMOVE_BLUR", true); RR_REGION = RegisterWhWz("RR_Region", MarioKartWiiEnums.Regions.None); WW_LANGUAGE = RegisterWhWz("WW_Language", "en", value => SettingValues.WhWzLanguages.ContainsKey((string)value!)); #endregion @@ -229,7 +228,6 @@ IFileSystem fileSystem public Setting ENABLE_ANIMATIONS { get; } public Setting TESTING_MODE_ENABLED { get; } public Setting SAVED_WINDOW_SCALE { get; } - public Setting REMOVE_BLUR { get; } public Setting RR_REGION { get; } public Setting WW_LANGUAGE { get; } @@ -286,7 +284,7 @@ public OperationResult ValidateCorePathSettings() private OperationResult ValidateDolphinPathSettings() => ValidatePathSettings(requireDolphin: true); - public bool IsRecompModeActive() => OperatingSystem.IsWindows() && Get(ENABLE_RECOMP); + public bool IsRecompModeActive() => RecompPlatform.IsSupported && Get(ENABLE_RECOMP); private OperationResult ValidatePathSettings(bool requireDolphin) { diff --git a/WheelWizard/Features/WiiManagement/MiiManagement/MiiRepositoryService.cs b/WheelWizard/Features/WiiManagement/MiiManagement/MiiRepositoryService.cs index 2f3792337..8e5928ea3 100644 --- a/WheelWizard/Features/WiiManagement/MiiManagement/MiiRepositoryService.cs +++ b/WheelWizard/Features/WiiManagement/MiiManagement/MiiRepositoryService.cs @@ -1,6 +1,8 @@ using System.IO.Abstractions; using WheelWizard.Helpers; +using WheelWizard.Recomp; using WheelWizard.Services; +using WheelWizard.Settings; using WheelWizard.Shared.MessageTranslations; namespace WheelWizard.WiiManagement.MiiManagement; @@ -52,20 +54,40 @@ public interface IMiiRepositoryService OperationResult ForceCreateDatabase(); } -public class MiiRepositoryServiceService(IFileSystem fileSystem) : IMiiRepositoryService +public class MiiRepositoryServiceService( + IFileSystem fileSystem, + ISettingsManager settings, + IRecompDolphinDataService? recompDolphinData = null +) : IMiiRepositoryService { private const int MiiLength = 74; private const int MaxMiiSlots = 100; private const int CrcOffset = 0x1F1DE; private const int HeaderOffset = 0x04; private static readonly byte[] EmptyMii = Enumerable.Repeat((byte)0x00, MiiLength).ToArray(); - private string _miiDbFilePath => PathManager.MiiDbFile; + + private string ResolveMiiDbFilePath() + { + if (!settings.IsRecompModeActive()) + return PathManager.MiiDbFile; + + // Match the launcher's selection, including the runtime's private NAND when no linked + // NAND is available. A missing copy must never send Mii edits to Dolphin's source NAND. + var nandFolder = recompDolphinData?.NandFolderPath ?? PathManager.RecompPrivateNandFolderPath; + return PathManager.GetMiiDbFilePath(nandFolder); + } public List LoadAllBlocks() + { + var databasePath = ResolveMiiDbFilePath(); + return LoadAllBlocks(databasePath); + } + + private List LoadAllBlocks(string databasePath) { var result = new List(); - var database = ReadDatabase(); + var database = ReadDatabase(databasePath); if (database.Length < HeaderOffset) return result; @@ -87,10 +109,16 @@ public List LoadAllBlocks() public OperationResult SaveAllBlocks(List blocks) { - if (!fileSystem.File.Exists(_miiDbFilePath)) + var databasePath = ResolveMiiDbFilePath(); + return SaveAllBlocks(blocks, databasePath); + } + + private OperationResult SaveAllBlocks(List blocks, string databasePath) + { + if (!fileSystem.File.Exists(databasePath)) return Fail("RFL_DB.dat not found.", MessageTranslation.Error_UpdateMiiDb_RFLdbNotFound); - var db = ReadDatabase(); + var db = ReadDatabase(databasePath); if (db.Length >= CrcOffset + 2) { // compute CRC over everything before CrcOffset @@ -126,15 +154,16 @@ public OperationResult SaveAllBlocks(List blocks) db[CrcOffset + 1] = (byte)(crc & 0xFF); } - return fileSystem.WriteAllBytesAtomic(_miiDbFilePath, db, "Failed to save RFL_DB.dat."); + return fileSystem.WriteAllBytesAtomic(databasePath, db, "Failed to save RFL_DB.dat."); } public byte[]? GetRawBlockByAvatarId(uint clientId) { + var databasePath = ResolveMiiDbFilePath(); if (clientId == 0) return null; - var blocks = LoadAllBlocks(); + var blocks = LoadAllBlocks(databasePath); foreach (var block in blocks) { if (block.Length != MiiLength) @@ -148,11 +177,16 @@ public OperationResult SaveAllBlocks(List blocks) return null; } - public bool Exists() => fileSystem.File.Exists(_miiDbFilePath); + public bool Exists() + { + var databasePath = ResolveMiiDbFilePath(); + return fileSystem.File.Exists(databasePath); + } public OperationResult ForceCreateDatabase() { - if (fileSystem.File.Exists(_miiDbFilePath)) + var databasePath = ResolveMiiDbFilePath(); + if (fileSystem.File.Exists(databasePath)) return Fail("Database already exists.", MessageTranslation.Error_MiiDBAlreadyExists); var db = new byte[779_968]; @@ -178,19 +212,20 @@ public OperationResult ForceCreateDatabase() db[CrcOffset] = (byte)(crc >> 8); db[CrcOffset + 1] = (byte)(crc & 0xFF); - return fileSystem.WriteAllBytesAtomic(_miiDbFilePath, db, "Failed to create RFL_DB.dat."); + return fileSystem.WriteAllBytesAtomic(databasePath, db, "Failed to create RFL_DB.dat."); } public OperationResult UpdateBlockByClientId(uint clientId, byte[] newBlock) { + var databasePath = ResolveMiiDbFilePath(); if (clientId == 0) return Fail("Invalid Client ID.", MessageTranslation.Error_UpdateMiiDb_InvalidClId); if (newBlock.Length != MiiLength) return Fail("Mii block size invalid.", MessageTranslation.Error_UpdateMiiDb_BlockSizeInvalid); - if (!fileSystem.File.Exists(_miiDbFilePath)) + if (!fileSystem.File.Exists(databasePath)) return Fail("RFL_DB.dat not found.", MessageTranslation.Error_UpdateMiiDb_RFLdbNotFound); - var allBlocks = LoadAllBlocks(); + var allBlocks = LoadAllBlocks(databasePath); var updated = false; foreach (var t in allBlocks) @@ -208,14 +243,14 @@ public OperationResult UpdateBlockByClientId(uint clientId, byte[] newBlock) break; } - return !updated ? Fail("Mii not found.", MessageTranslation.Error_UpdateMiiDb_MiiNotFound) : SaveAllBlocks(allBlocks); + return !updated ? Fail("Mii not found.", MessageTranslation.Error_UpdateMiiDb_MiiNotFound) : SaveAllBlocks(allBlocks, databasePath); } - private byte[] ReadDatabase() + private byte[] ReadDatabase(string databasePath) { try { - return Exists() ? fileSystem.File.ReadAllBytes(_miiDbFilePath) : []; + return fileSystem.File.Exists(databasePath) ? fileSystem.File.ReadAllBytes(databasePath) : []; } catch { @@ -225,11 +260,12 @@ private byte[] ReadDatabase() public OperationResult AddMiiToBlocks(byte[]? rawMiiData) { + var databasePath = ResolveMiiDbFilePath(); if (rawMiiData is not { Length: MiiLength }) return Fail("Invalid Mii block size."); // Load all 100 blocks. - var blocks = LoadAllBlocks(); + var blocks = LoadAllBlocks(databasePath); var inserted = false; // Look for an empty slot. @@ -243,6 +279,6 @@ public OperationResult AddMiiToBlocks(byte[]? rawMiiData) break; } - return !inserted ? Fail("No empty Mii slot available.") : SaveAllBlocks(blocks); + return !inserted ? Fail("No empty Mii slot available.") : SaveAllBlocks(blocks, databasePath); } } diff --git a/WheelWizard/Helpers/HttpClientHelper.cs b/WheelWizard/Helpers/HttpClientHelper.cs index 76e1d5c47..7fa432170 100644 --- a/WheelWizard/Helpers/HttpClientHelper.cs +++ b/WheelWizard/Helpers/HttpClientHelper.cs @@ -1,6 +1,7 @@ using System.Text.Json; using System.Text.Json.Serialization; using WheelWizard.Models; +using WheelWizard.Shared; namespace WheelWizard.Helpers; @@ -34,7 +35,7 @@ public static class HttpClientHelper public static async Task> PostAsync(string url, HttpContent? body, JsonSerializerOptions? options = null) { #if DEBUG - if (!FakeConnectionToInternet) + if (DevelopmentMode.IsEnabled && !FakeConnectionToInternet) return GetErrorResult(new("No internet connection")); #endif @@ -70,7 +71,7 @@ public static async Task> PostAsync(string url, HttpConte public static async Task> GetAsync(string url, JsonSerializerOptions? options = null) { #if DEBUG - if (!FakeConnectionToInternet) + if (DevelopmentMode.IsEnabled && !FakeConnectionToInternet) return GetErrorResult(new("No internet connection")); #endif diff --git a/WheelWizard/Logging.cs b/WheelWizard/Logging.cs index a1b4b8a50..4f2a01e7c 100644 --- a/WheelWizard/Logging.cs +++ b/WheelWizard/Logging.cs @@ -55,15 +55,11 @@ public static void RecreateStaticLogger() private static void LogPlatformInformation() { // ReSharper disable once RedundantAssignment - var modeCheck = "release"; + var modeCheck = Shared.DevelopmentMode.IsEnabled ? "debug" : "release"; // ReSharper disable once ConvertToConstant.Local var osCheck = "unknown"; -#if DEBUG - modeCheck = "debug"; -#endif - #if WINDOWS osCheck = "windows"; #elif LINUX diff --git a/WheelWizard/Resources/Languages/cs.yml b/WheelWizard/Resources/Languages/cs.yml index b0a3f4407..054493771 100644 --- a/WheelWizard/Resources/Languages/cs.yml +++ b/WheelWizard/Resources/Languages/cs.yml @@ -161,7 +161,7 @@ cs: rooms: "Místnosti" miis: "Miiy" text: - made_by_string: | + made_by_string: |- Vytvořili: {$1} a {$2} thanks_translators: "Díky moc všem překladatelům:" @@ -202,16 +202,16 @@ cs: time: days: 1: "1 den" - n: "($1) dny" + n: "{$1} dny" hours: 1: "1 hodina" - n: "($1) hodiny" + n: "{$1} hodiny" minutes: 1: "1 minuta" - n: "($1) minuty" + n: "{$1} minuty" seconds: 1: "1 sekunda" - n: "($1) sekundy" + n: "{$1} sekundy" progress: estimated_time_remaining: "Předpokládaný zbývající čas:" downloading_mb: "Stahování {$1} MB" @@ -362,12 +362,13 @@ cs: dolphin_user_path: "Uživatelská Složka Dolphin" mario_kart_game: "Soubor Hry Mario Kart Wii" dolphin_emulator_exe: "Spustitelný soubor Dolphin Emulator" - remove_blur: "Odstranit Rozmazání" enable_animations: "Zapnout Animace" reinstall: "Reinstalovat RR" open_game_folder: "Otevřít Složku Hry" open_save_folder: "Otevřít Složku Uložení" section: + graphics: "Grafika" + general: "Obecné" language: "Jazyk" wii: "Nastavení Wii" resolution: "Rozlišení" @@ -384,14 +385,13 @@ cs: end_with_exe: "Cesta musí končit .exe" enable_animations: "To aktivuje animace v aplikaci Wheel Wizard" end_with_x: "Cesta může končit:" - wh_wz_language: "Tím se změní pouze jazyk pro Wheel Wizard. Chceš-li změnit jazyk ve hře, musíš to udělat v nastavení hry." optional: "Volitelný" must_set_paths: "Musíš nastavit tyto 3 cesty než začneš hrát Retro Rewind." category: - video: "Video" about: "O tomto" other: "Ostatní" general: "Obecný" + video: "Video" value: language: france: "Francouzština" diff --git a/WheelWizard/Resources/Languages/de.yml b/WheelWizard/Resources/Languages/de.yml index 42de2ea90..84aa1804e 100644 --- a/WheelWizard/Resources/Languages/de.yml +++ b/WheelWizard/Resources/Languages/de.yml @@ -101,7 +101,7 @@ de: no_license: "Keine Lizenz" no_profiles: "Keine Profile" custom: "Benutzerdefiniert" - installed: "Installiert " + installed: "Installiert" action: play: "Spielen" play_offline: "Offline spielen" @@ -167,7 +167,7 @@ de: mii_editor: "Mii Maker" mii_carousel: "Mii Preview" text: - made_by_string: | + made_by_string: |- Erstellt von: {$1} und {$2} thanks_translators: "Vielen Dank an alle Übersetzer:" @@ -244,21 +244,18 @@ de: extra: "Diese Änderung wird in {$1} zurückgesetzt, außer du wendest die Änderung an." move_data: title: "Wheel Wizard Daten verschieben?" - extra: | - Wheel Wizard wird seine Dateien nach - + extra: |- + Wheel Wizard wird seine Datein nach {$1} - verschieben. Das könnte eine Weile dauern. dolphin_found: title: "Dolphin Emulator Ordner gefunden" - extra: | + extra: |- Wenn du nicht weißt, was das alles bedeutet, klick einfach auf „Ja“ :) - Dolphin Emulator-Ordner gefunden. Möchtest du diesen Ordner verwenden? dolphin_program_found: title: "Dolphin Emulator gefunden" - extra: | + extra: |- Wenn du nicht weißt, was das alles bedeutet, klick einfach auf „Ja“ :) Dolphin Emulator gefunden. Möchtest du Folgendes verwenden? @@ -283,8 +280,6 @@ de: dolphin_flatpak: title: "Dolphin Flatpak Installation" extra: "Die Flatpak Dolphin Version scheint nicht installiert zu sein. Möchtest du sie von uns installieren lassen (für deinen eigenen Benutzer)?" - install_mod: - title: "Du bist dabei das Spiel ohne Mods zu starten. Möchtest du deinen My-Stuff Ordner leeren?" search_mod: "Nach Mods suchen..." enter_new_name: title: "Einen neuen Namen eingeben" @@ -317,13 +312,11 @@ de: extra: "Es ist nicht möglich gerade nach Updates zu suchen. Du bist vielleicht nicht mit dem Internet verbunden oder der Server hat gerade Schwierigkeiten." unable_update_wh_wz: extra: - reason_network: | + reason_network: |- Es kann nicht überprüft werden, ob Wheel Wizard auf dem neuesten Stand ist. - Möglicherweise treten derzeit Netzwerkprobleme auf. - reason_location: | + reason_location: |- Wheel Wizard kann nicht aktualisiert werden. Bitte stelle sicher, dass sich die Anwendung in einem Ordner befindet, in den geschrieben werden kann. - Der aktuelle Ordner konnte nicht gefunden werden. title: "Wheel Wizard konnte nicht aktualisiert werden." no_connect_server: @@ -362,9 +355,8 @@ de: extra: failed_update_delete: "Dateien für das Update konnten nicht gelöscht werden. Abbruch." failed_update_apply: "Das Update konnte nicht installiert werden. Abbruch." - invalid_file_path: | + invalid_file_path: |- Ungültiger Dateipfad wurde erkannt. Bitte kontaktiere die Entwickler. - Server-Fehler: {$1} title: "Über das RR Update" restart_admin_fail: @@ -384,15 +376,14 @@ de: title: "Es ist ein Fehler beim abrufen der Mod-Informationen aufgetreten." mod_download_fail: title: "Mod Download ist schiefgelaufen." - extra: "Ein Fehler ist während dem Dowload aufgetreten." + extra: "Ein Fehler ist während dem Dowload aufgetreten: {$1}" message_success: settings_saved: title: "Einstellungen erfolgreich gespeichert!" data_folder_moved: title: "Datenordner aktualisiert." - extra: | - Wheel Wizard's Daten sind jetzt gespeichert in: - + extra: |- + Wheel WIzard's Daten sind jetzt gespeichert in: {$1} rr_up_to_date: title: "Retro Rewind ist auf dem neuesten Stand." @@ -439,12 +430,13 @@ de: mario_kart_game: "Mario Kart Wii Spiel-Datei" dolphin_emulator_exe: "Dolphin Emulator Programm-Datei" make_primary: "Hauptprofil" - remove_blur: "Unschärfe entfernen" enable_animations: "Aktiviere Animationen" reinstall: "RR neu installieren" open_game_folder: "Spielordner öffnen" open_save_folder: "Speicherdatenordner öffnen" section: + graphics: "Grafik" + general: "Allgemein" language: "Sprache" wii: "Wii Einstellungen" resolution: "Auflösung" @@ -465,17 +457,15 @@ de: end_with_exe: "Dateipfad muss mit .exe enden." enable_animations: "Dies aktiviert die Animationen in der Wheel Wizard App." end_with_x: "Pfad kann enden mit:" - wh_wz_language: "Dies ändert nur die Sprache innerhalb von Wheel Wizard. Um die Sprache innerhalb des Spiels zu ändern musst du das in den Spieleinstellungen vornehmen" optional: "Optional" must_set_paths: "Du musst erst diese 3 Dateipfade bestimmen um Retro Rewind spielen zu können." category: - video: "Video" about: "Über" other: "Sonstiges" general: "Allgemein" + video: "Video" status: data_folder: - default: "Standard-Speicherort wird verwendet." custom: "Verwendet benutzerdefinierten Speicherort" moving: "Dateien werden verschoben, bitte warten..." value: diff --git a/WheelWizard/Resources/Languages/en.yml b/WheelWizard/Resources/Languages/en.yml index d527a45eb..4a9160000 100644 --- a/WheelWizard/Resources/Languages/en.yml +++ b/WheelWizard/Resources/Languages/en.yml @@ -107,11 +107,13 @@ en: installed: "Installed" action: play: "Play" + open: "Open" play_offline: "Play Offline" play_anyway: "Play anyway" install: "Install" update: "Update" import: "Import" + clone: "Clone" convert_to_patches: "Convert to patches" enable_all: "Enable All" disable_all: "Disable All" @@ -131,6 +133,7 @@ en: revert: "Revert" maybe_later: "Maybe Later" browse: "Browse" + change: "Change" view_mod: "View Mod" save_external_mii: "Add Mii to \"My Miis\"" view_mii: "View Mii" @@ -140,7 +143,8 @@ en: favorite: "Favorite" view_room: "View Room" recomp_nand_copy: "Make a copy" - recomp_nand_share: "Use directly" + recomp_nand_share: "Share directly" + recomp_install_offline: "Install offline-only" link: discord: "Talk to us!" github: "Source code" @@ -174,9 +178,9 @@ en: mii_editor: "Mii Editor" mii_carousel: "Mii Carousel" text: - made_by_string: | + made_by_string: |- Made by: {$1} - and {$2} + And {$2} thanks_translators: "Thanks a lot to all the translators:" wh_wz_translation_percentage: "Translations for this language are {$1}% complete" powered_gamebanana: "Powered By GameBanana" @@ -256,6 +260,7 @@ en: preparing_files_count: "Preparing {$1} file(s)" installing_recomp: "Installing WiiCompiled" updating_recomp: "Updating WiiCompiled" + uninstalling_recomp: "Uninstalling WiiCompiled" recomp_checking_release: "Looking for the latest WiiCompiled release" recomp_downloading_setup: "Downloading the WiiCompiled installer" recomp_running_setup: "Running the WiiCompiled installer" @@ -270,9 +275,18 @@ en: recomp_use_dolphin_nand: title: "Use your Dolphin save data?" extra: "WiiCompiled can play with the saves, Miis and friend codes you already use in Dolphin. If not, it starts fresh with its own Wii data." + recomp_retro_wfc_unavailable: + title: "Retro WFC is unreachable" + extra: "WiiCompiled needs the Retro WFC servers to set up online play, and they are not responding right now. You can install without online play now; WiiCompiled will add it automatically the next time you update once the servers are back." recomp_nand_mode: title: "Copy your Dolphin data, or use it directly?" extra: "Dolphin recommends against other programs using its Wii data (NAND) directly, since two programs writing to it at once can corrupt it. A copy keeps WiiCompiled completely separate, but progress made in one will not appear in the other." + recomp_share_dolphin_data: + title: "Share NAND data with Dolphin?" + extra: "Cloning gives WiiCompiled the same current saves, Miis, and Wii settings, but keeps its copy separate and is safer. Direct sharing lets Dolphin and WiiCompiled write to the same NAND, which can corrupt it if both run at once. Continue only if you understand the risk." + recomp_overwrite_dolphin_clone: + title: "Replace the private Dolphin clone?" + extra: "This replaces the current clone with Dolphin's data. Saves, Miis, and Wii settings changed only in WiiCompiled will be lost." launch_clear_mods_found: title: "Mods found" extra: "You are about to launch the game without any mods. Do you want to clear your my-stuff folder?" @@ -285,27 +299,24 @@ en: extra: "This change will revert in {$1} unless you decide to keep the change." move_data: title: "Move Wheel Wizard data?" - extra: | + extra: |- Wheel Wizard will move its files to: - {$1} - This may take a while depending on the amount of data. dolphin_found: title: "Dolphin Emulator Folder Found" - extra: | - If you don't know what all of this means, just click yes :) - + extra: |- + If you dont know what all of this means, just click yes :) Dolphin Emulator folder found. Would you like to use this folder? dolphin_outdated: title: "Your Dolphin Emulator is out of date" - extra: | + extra: |- This version of Dolphin ({$1}) has a known security issue where malicious game files, mods, or cheating online players can infect your PC with malware. Update to {$2} or newer to be safe. dolphin_program_found: title: "Dolphin Emulator Found" - extra: | + extra: |- If you don't know what all of this means, just click yes :) Dolphin Emulator found. Would you like to use this? @@ -344,6 +355,9 @@ en: title: "This will close the current window and open a new one with the new language settings." extra: "Do you want to apply the new language settings?" message_warning: + recomp_operation_cancelled: + title: "WiiCompiled operation cancelled" + extra: "The operation was stopped before it finished. You can start it again whenever you're ready." not_find_dolphin: extra: "Could not find Dolphin Emulator, please set the path in settings" not_find_game: @@ -362,7 +376,7 @@ en: extra: "Dolphin Emulator folder not automatically found. Please try and find the folder manually, click 'help' for more information." dolphin_version_unverified: title: "Could not verify your Dolphin Emulator version" - extra: | + extra: |- Wheel Wizard could not read the version of the Dolphin Emulator you have configured, so it cannot tell whether it has the security fixes. Please check yourself that you are on {$1} or newer. Older versions have a known security issue where malicious game files, mods, or cheating online players can infect your PC with malware. @@ -374,13 +388,11 @@ en: extra: "Can't check for updates right now. You might not be connected to the internet or the server might be down." unable_update_wh_wz: extra: - reason_network: | + reason_network: |- Unable to check if Wheel Wizard is up to date. - You might be experiencing network issues. - reason_location: | + reason_location: |- Unable to update Wheel Wizard. Please ensure the application is located in a folder that can be written to. - Could not find current folder. title: "Unable to update Wheel Wizard." no_connect_server: @@ -421,10 +433,9 @@ en: extra: failed_update_delete: "Failed to delete files for the update. Aborting." failed_update_apply: "Failed to apply an update. Aborting." - invalid_file_path: | + invalid_file_path: |- Invalid file path detected. Please contact the developers. - - Server error: {$1} + Server error: {$1} title: "Aborting RR Update" restart_admin_fail: extra: "Failed to restart with administrator rights." @@ -449,9 +460,8 @@ en: title: "Settings saved successfully!" data_folder_moved: title: "Data folder updated" - extra: | + extra: |- Wheel Wizard data is now stored in: - {$1} rr_up_to_date: title: "Retro Rewind is up to date." @@ -517,6 +527,10 @@ en: force_disable_wiimote: "Force Disable Wiimote" launch_with_dolphin: "Launch Game With Dolphin Window" launch_rr_on_startup: "Launch Retro Rewind On Startup" + retro_rewind_installation: "Retro Rewind Installation" + save_folder: "Save Folder" + installation_folder: "Installation Folder" + recomp_installation: "WiiCompiled Installation" v_sync: "VSync" recommended: "Recommended Settings" show_fps: "Show FPS" @@ -524,22 +538,26 @@ en: window_scale: "Window Scale" open_config: "Open Config Folder" wheel_wizard_data_folder: "Wheel Wizard data folder" - reset_data_folder: "Use default location" + retro_rewind_folder: "Retro Rewind Folder" + reset_data_folder: "Use Default" dolphin_user_path: "Dolphin User Folder" mario_kart_game: "Mario Kart Wii Game File" dolphin_emulator_exe: "Dolphin Emulator executable" make_primary: "Make Primary" - remove_blur: "Remove Blur" enable_animations: "Enable Animations" reinstall: "Reinstall RR" open_game_folder: "Open Game Folder" open_save_folder: "Open Save Folder" enable_recomp: "Enable WiiCompiled (beta)" - recomp_use_dolphin_data: "Share Dolphin save data" + recomp_share_dolphin_data: "Share NAND data with Dolphin" + recomp_clone_dolphin_data: "Clone Dolphin NAND" recomp_resolution: "Resolution" recomp_graphics_api: "Graphics API" recomp_prevent_stutters: "Prevent stutters" section: + graphics: "Graphics" + general: "General" + retro_rewind: "Retro Rewind" language: "Language" wii: "Wii Settings" resolution: "Resolution" @@ -548,28 +566,34 @@ en: location_paths: "Location Settings" wh_wz_appearance: "Wheel Wizard appearance" installation: "Installation" + setup: "Setup" recomp: "WiiCompiled (beta)" - recomp_dolphin_data: "Dolphin Data" recomp_video: "Video" helper_text: topbar: location_warning: "Settings are disabled, complete the location settings first" - enable_recomp: "Uses WiiCompiled as the home-page launcher. Turn this off to use Dolphin and Retro Rewind instead. Windows only." - recomp_use_dolphin_data: "Use Dolphin's existing Wii folder in place so WiiCompiled shares saves, Miis, and Wii settings. A change takes effect the next time WiiCompiled launches." - recomp_graphics_api: "Only DirectX 12 and Vulkan run WiiCompiled reliably, so nothing else is offered." + enable_recomp: "Uses WiiCompiled as the home-page launcher. Turn this off to use Dolphin and Retro Rewind instead. Windows and Linux only." + recomp_share_dolphin_data: "WiiCompiled and Dolphin use the same saves, Miis, and Wii settings." + recomp_shared_nand_warning: "Do not run Dolphin and WiiCompiled at the same time while sharing NAND data." + recomp_clone_dolphin_data: "Copies Dolphin's current saves, Miis, and Wii settings into private WiiCompiled data." + recomp_graphics_api: "WiiCompiled works reliably with DirectX 12 and Vulkan. On Linux only Vulkan is available." recomp_dolphin_data_not_found: "Set an existing Dolphin User Folder in Location Settings first. It must contain a Wii folder." recomp_prevent_stutters: "While a shader is still compiling, the objects that need it are skipped for a few frames instead of the whole game stalling until it is ready. You get a tiny bit of pop-in instead of a stutter." launch_with_dolphin: "Will launch dolphins main window along with the game" launch_rr_on_startup: "Automatically launches Retro Rewind when Wheel Wizard starts" + installed_version: "Installed version: {$1}" force_disable_wiimote: "This setting disables Wiimote ingame, but enables it for the Wii channel" resolution_scale: "1x is the native resolution" + renderer: "Selects the graphics backend Dolphin uses to render the game." + v_sync: "Synchronizes the game's frame rate with your display to prevent screen tearing." recommended: "This setting will change certain dolphin settings to reduce stuttering and lag spikes" 30_fps: "This setting will force the game to run in 30 FPS (default is 60)" wheel_wizard_data_folder: "Choose where Wheel Wizard stores its files. Moving may take a while." + retro_rewind_folder: "Opens the folder containing the installed Retro Rewind files." end_with_exe: "Path must end with .exe" enable_animations: "This enables the animations in the Wheel Wizard app" end_with_x: "Path can end with:" - wh_wz_language: "This only changes the language for Wheel Wizard. To change the in-game language, you’ll need to do that within the game settings." + wh_wz_language: "Changes the language used by Wheel Wizard, not the in-game language." optional: "Optional" must_set_paths: "You must set these 3 paths before you can start playing Retro Rewind" must_set_game_path: "You must select your Mario Kart Wii game file before you can start playing WiiCompiled" @@ -578,15 +602,15 @@ en: about: "About" other: "Other" general: "General" + video: "Video" status: - recomp_dolphin_data_not_linked: "Not linked. WiiCompiled will use its private Wii data." - recomp_dolphin_data_linked: "Sharing Dolphin data from {$1}" - recomp_dolphin_data_copied: "Using a copy of your Dolphin data at {$1}" + recomp_dolphin_clone_available: "Private clone: {$1}" + recomp_dolphin_clone_missing: "No private clone has been created." + recomp_dolphin_data_cloned: "Dolphin data cloned for WiiCompiled" recomp_dolphin_data_not_found: "Dolphin data not found" recomp_not_installed: "WiiCompiled is not installed yet. Install it from the home page first." recomp_uninstalled: "WiiCompiled has been uninstalled" data_folder: - default: "Using default location." custom: "Using custom location." moving: "Moving data, please wait..." value: @@ -606,5 +630,6 @@ en: czech: "Czech" norwegian: "Norwegian" chinese: "Chinese" + polish: "Polish" portuguese: "Portuguese" z_translators: "-" diff --git a/WheelWizard/Resources/Languages/es.yml b/WheelWizard/Resources/Languages/es.yml index 2ae4e778c..e57cc7995 100644 --- a/WheelWizard/Resources/Languages/es.yml +++ b/WheelWizard/Resources/Languages/es.yml @@ -155,9 +155,7 @@ es: rooms: "Salas" miis: "Miis" text: - made_by_string: | - Hecho por: {$1} - y {$2} + made_by_string: "Hecho por: {$1} y {$2}" thanks_translators: "Muchas gracias a todos los traductores:" wh_wz_translation_percentage: "La traducción de este idioma está a un {$1}%." hover: @@ -224,10 +222,9 @@ es: extra: "Este cambio se revertira en {$1} a menos de que decidas de quedarte con este cambio." dolphin_found: title: "Carpeta del Emulador Dolphin encontrado" - extra: | - Ante la duda, solo marque si. - - Carpeta del emulador Dolphin encontrado. ¿Desea usar esta carpeta? + extra: |- + Ante la duda, solo marque si + . Carpeta del emulador Dolphin encontrado. ¿Desea usar esta carpeta? new_version_wh_wz: title: "Actualización de Wheel Wizard disponible" extra: "La version {$1} de WheelWizzard está disponible (Actualmente en {$2}). ¿Desea actualizar ahora?" @@ -268,13 +265,11 @@ es: extra: "No se pueden buscar actualizaciones en este momento. Es posible que no tengas conexión a internet o que el servidor esté inactivo." unable_update_wh_wz: extra: - reason_network: | + reason_network: |- Incapaz de confirmar la actualidad de su versión de Wheel Wizard. - Podrías estar experimentando problemas de conexión. - reason_location: | + reason_location: |- Incapaz de actualizar Wheel Wizard. Asegurate que la aplicación esta ubicada en una carpeta que se puede editar. - No se encontró la carpeta actual. title: "No se pudo actualizar Wheel Wizard." no_connect_server: @@ -300,9 +295,8 @@ es: extra: failed_update_delete: "Error al borrar archivos para la actualización. Cancelando" failed_update_apply: "Error al aplicar una actualización. Cancelando." - invalid_file_path: | + invalid_file_path: |- Se detectó una ruta de archivo no válida. Contacte a los desarrolladores. - Error del servidor: {$1} title: "Abortando actualización de RR" restart_admin_fail: @@ -361,12 +355,13 @@ es: mario_kart_game: "Archivo del juego Mario Kart Wii" dolphin_emulator_exe: "Emulador Dolphin Ejecutable" make_primary: "Hacer primaria" - remove_blur: "Quitar Blur" enable_animations: "Activar Animaciones" reinstall: "Reinstalar RR" open_game_folder: "Abrir carpeta de juegos" open_save_folder: "Abrir carpeta de archivos de juego guardados" section: + graphics: "Gráficos" + general: "General" language: "Idioma" wii: "Ajustes de Wii" resolution: "Resolución" @@ -386,10 +381,10 @@ es: end_with_exe: "La ruta debe terminar en un .exe" enable_animations: "Esto activa las animaciones en la aplicación Wheel Wizard" category: - video: "Video" about: "Acerca de" other: "Otro" general: "General" + video: "Video" value: language: france: "Francés" diff --git a/WheelWizard/Resources/Languages/fi.yml b/WheelWizard/Resources/Languages/fi.yml index 6cc031b19..432cdfa60 100644 --- a/WheelWizard/Resources/Languages/fi.yml +++ b/WheelWizard/Resources/Languages/fi.yml @@ -3,15 +3,18 @@ fi: home: "Etusivu" settings: "Asetukset" mods: "My Stuff" + patches: "Paikkaukset" my_profiles: "Omat profiilit" rooms: "Huoneet" friends: "Kaverit" + leaderboard: "Tulostaulu" room_details: "Huoneen tiedot" my_miis: "Omat Miit" attribute: status_online: "Online" enabled: "Käytössä" title: "Otsikko" + all: "Kaikki" status_offline: "Offline" name: "Nimi" id: "ID" @@ -108,6 +111,7 @@ fi: install: "Asenna" update: "Päivitä" import: "Tuo" + convert_to_patches: "Muunna paikkauksiksi" enable_all: "Ota kaikki käyttöön" disable_all: "Poista kaikki" rename: "Nimeä uudelleen" @@ -167,13 +171,15 @@ fi: mii_editor: "Mii-editori" mii_carousel: "Miin katselu" text: - made_by_string: | + made_by_string: |- Tehnyt: {$1} ja {$2} thanks_translators: "Suuret kiitokset kaikille kääntäjille:" wh_wz_translation_percentage: "Tämän kielen käännökset ovat {$1} % valmiita." powered_gamebanana: "Palvelun tarjoaa GameBanana" language_translated_by: "Tämän kielen käännökset tarjoaa: {$1}" + external_reference: "Ulkoinen viite" + external_reference_with_path: "Ulkoinen viite: {$1}" hover: players_online: 0: "Ei pelaajia online-tilassa" @@ -192,7 +198,7 @@ fi: Liittyäksesi tiettyyn huoneeseen, se tulee tehdä joko kaverin kautta tai toivoa että pääset siihen liittymällä verkkopeliin. friends_page_disclaimer: "Kavereiden lisääminen täytyy tehdä pelin sisällä." region_user_selection: "Näyttää vain alueet, joilla itse olet pelannut" - primary_profile: "Pääprofiilia käytetään referenssinä Wheel Wizardissa" + primary_profile: "Pääprofiilia käytetään viitteenä Wheel Wizardissa" empty_content: no_profiles: "Sinun on pelattava peliä vähintään kerran nähdäksesi profiilisi listalla" no_friends_title: "Ei kavereita vielä." @@ -223,7 +229,7 @@ fi: downloading_mb: "Ladataan {$1} Mt" installing_mods: "Asennetaan modeja" installing_mods_count: "Asennetaan {$1} modia" - processing_xof_y: "Käsitellään {$1}/{$1} tiedostoa..." + processing_xof_y: "Käsitellään {$1}/{$2} tiedostoa..." update_rr: "Päivitetään Retro Rewindia" latest_wh_wz_github: "Hankitaan uusinta Wheel Wizard -versiota GitHubista" update_wh_wz: "Päivitetään Wheel Wizardia" @@ -231,6 +237,14 @@ fi: installing_rr_first_time: "Asennetaan Retro Rewindia ensimmäistä kertaa" installing_dolphin: "Asennetaan Dolphin-emulaattoria" this_may_take_a_while: "Tässä voi kestää jonkin aikaa riippuen internet-yhteydestäsi." + applying_converted_mod: "Sovelletaan muunnettu modi" + combining_files: "Yhdistetään tiedostoja" + converting_file: "Muunnetaan {$1}" + converting_files_count: "Muunnetaan {$1} tiedostoa" + converting_mod_to_patches: "Muunnetaan modi paikkauksiksi" + downloading_mod: "Ladataan {$1}" + installing_mod: "Asennetaan modia" + preparing_files_count: "Valmistellaan {$1} tiedostoa" question: launch_clear_mods_found: title: "Modeja löytyi" @@ -244,11 +258,16 @@ fi: apply_scale: title: "Haluatko ottaa uuden skaalan käyttöön?" extra: "Muutos perutaan {$1} kuluttua, ellet aio pitää muutosta." + move_data: + title: "Siirretäänkö Wheel Wizardin tiedot?" + extra: |- + Wheel Wizardin tiedostot siirretään sijaintiin: + {$1} + Tässä saattaa mennä hetki riippuen tietojen määrästä. dolphin_found: title: "Dolphin-emulaattorin kansio löytyi" - extra: | + extra: |- Jos et tiedä, mitä kaikki tarkoittaa, paina vain 'Kyllä' :) - Dolphin-emulaattorin kansio löytyi. Haluatko käyttää tätä kansiota? new_version_wh_wz: title: "Wheel Wizard -päivitys saatavilla" @@ -279,6 +298,13 @@ fi: enter_new_name: title: "Anna uusi nimi" extra: "Muutetaan nimeä: {$1}" + enter_mod_name: + title: "Modin nimi:" + launch_clear_patches_found: + extra: "Olet käynnistämässä pelin ilman modeja. Haluatko tyhjentää paikkauskansiosi? Pelin käynnistyy muuten nykyisen paikkauskansion ollessa aktiivinen." + apply_language_settings: + title: "Tämä sulkee ikkunan ja avaa uuden valitulla kieliasetuksella." + extra: "Haluatko ottaa uuden kieliasetuksen käyttöön?" message_warning: not_find_dolphin: extra: "Dolphin-emulaattoria ei löytynyt. Määritä sijaintipolku asetuksissa." @@ -296,18 +322,19 @@ fi: dolphin_not_found: title: "Dolphin-emulaattorin kansiota ei löytynyt" extra: "Dolphin-emulaattorin kansiota ei löytynyt automaattisesti. Yritä etsiä kansio mauaalisesti. Lisätietoa saat napsauttamalla 'Ohje'." + dolphin_tool_selected: + title: "Kelvoton Dolphinin käynnistystiedosto valittuna" + extra: "Sinulla on DolphinTool.exe valittuna. Ole hyvä ja valitse Dolphin.exe (Dolphin-emulaattorin käynnistystiedosto) sen sijaan." fail_check_updates: title: "Päivitysten tarkistus epäonnistui" extra: "Päivityksiä ei voi tarkistaa juuri nyt. Internet-yhteyttä ei ole tai palvelimessa on ongelma." unable_update_wh_wz: extra: - reason_network: | + reason_network: |- Ei voitu tarkistaa, onko Wheel Wizard ajan tasalla. - Syynä saattaa olla verkkoyhteysongelmat. - reason_location: | + reason_location: |- Wheel Wizardia ei voi päivittää. Varmista, että sovellus sijaitsee kansiossa, johon voi kirjoittaa tietoja. - Nykyistä kansiota ei löytynyt. title: "Wheel Wizardia ei voi päivittää." no_connect_server: @@ -330,6 +357,8 @@ fi: unable_download_mod: title: "Modia ei voi ladata" extra: "Tiedostoja ei löytynyt." + could_not_convert_mod: + title: "Modia ei voitu muuntaa" placeholder: enter_mod_name: "Anna modin nimi..." search_mod: "Hae modeja..." @@ -340,13 +369,14 @@ fi: message_error: no_mod_folder: extra: "Modikansiota ei ole olemassa" + data_folder_move: + title: "Tietojen siirto epäonnistui" abort_rr: extra: failed_update_delete: "Tiedostojen poistaminen päivitystä varten epäonnistui. Perutaan." failed_update_apply: "Päivityksen käyttöönotto epäonnistui. Perutaan." - invalid_file_path: | + invalid_file_path: |- Virheellinen polku. Ota yhteyttä kehittäjiin. - Palvelinvirhe: {$1} title: "Perutaan RR-päivitys" restart_admin_fail: @@ -370,9 +400,22 @@ fi: message_success: settings_saved: title: "Asetukset tallennettu!" + data_folder_moved: + title: "Tietokansio päivitetty" + extra: |- + Wheel Wizardin tiedot sijaitsevat nyt kohteessa: + {$1} rr_up_to_date: title: "Retro Rewind on ajan tasalla." mii_changed: "Mii vaihdettu!" + mod_converted_to_patches: + title: "Muunnettu paikkauksiksi" + mod_installed: + extra: "Modi '{$1}' asennettu." + title: "Modi asennettu!" + patch_conversion_result: "{$1} tiedostoa muunnettu {$2} paikkaustiedostoksi." + patch_conversion_skipped: "{$1} tiedostoa ei muutettu, koska niitä ei voitu muuntaa." + patch_conversion_notes: "Huomautus: {$1}" snackbar_warning: cant_save_mii: "Miitä ei voi tallentaa" no_mii_export: "Vietäviä Miitä ei ole" @@ -397,27 +440,38 @@ fi: mii_failure_duplicate: "Miin '{$1}' kopioiminen epäonnistui" helper_note: name_must_between: "Nimien täytyy olla 3–10 merkkiä pitkä." + creator_name_less11: "Tekijän nimen täytyy olla alle 11 merkkiä pitkä." creator_name_less10: "Tekijän nimen täytyy olla alle 10 merkkiä pitkä." + file_picker: + select_mod_file: "Valittu moditiedosto" + patch: + incompatible_mod: + message: "Tämä modi on yhteensopimaton muiden modien kanssa. Pelin käynnistäminen tämän modin kanssa voi aiheuttaa virheellistä toimintaa tai kaatumisia. Harkitse modin muuntamista paikkauksiksi napsauttamalla sitä hiiren oikealla painikkeella." + title: "YHTEENSOPIMATON MODI" option: wh_wz_language: "Wheel Wizardin kieli" force_disable_wiimote: "Pakota Wiimote pois käytöstä" launch_with_dolphin: "Käynnistä peli Dolphin-ikkunassa" + launch_rr_on_startup: "Käynnistä Retro Rewind heti" v_sync: "Pystytahdistus (V-Sync)" recommended: "Suositellut asetukset" show_fps: "Näytä kuvalaskuri" renderer: "Renderöijä" window_scale: "Ikkunan koko" open_config: "Avaa asetuskansio" + wheel_wizard_data_folder: "Wheel Wizardinin tietokansio" + reset_data_folder: "Käytä oletussijaintia" dolphin_user_path: "Dolphin-käyttäjäkansio" mario_kart_game: "Mario Kart Wii -pelitiedosto" dolphin_emulator_exe: "Dolphin-emulaattorin käynnistystiedosto" make_primary: "Aseta ensisijaiseksi" - remove_blur: "Poista sumennus" enable_animations: "Animaatiot" reinstall: "Asenna RR uudelleen" open_game_folder: "Avaa pelikansio" open_save_folder: "Avaa tallennuskansio" section: + graphics: "Grafiikka" + general: "Yleiset" language: "Kieli" wii: "Wii-asetukset" resolution: "Resoluutio" @@ -430,21 +484,26 @@ fi: topbar: location_warning: "Asetukset on lukittu, suorita sijaintipolkujen asetukset ensin" launch_with_dolphin: "Käynnistää Dolphinin pääikkunan pelin mukana" + launch_rr_on_startup: "Retro Rewind käynnistetään heti kun Wheel Wizard käynnistyy" force_disable_wiimote: "Poistaa Wiimoten käytöstä pelissä, mutta ottaa sen käyttöön Wii-valikossa" resolution_scale: "1x on alkuperäinen resoluutio" recommended: "Muuttaa joitakin Dolphinin asetuksia pätkimisen ja lagipiikkien vähentämiseksi" 30_fps: "Pakottaa pelin toimimaan 30 FPS -tilassa (oletus on 60)" + wheel_wizard_data_folder: "Valitse Wheel Wizardin tiedostosijainti. Siirtäminen voi kestää hetken." end_with_exe: "Polun täytyy loppua päätteeseen .exe" enable_animations: "Ottaa käyttöön animaatiot Wheel Wizard -sovelluksessa" end_with_x: "Polku voi loppua:" - wh_wz_language: "Vaihtaa kielen vain Wheel Wizard -sovelluksessa. Pelin kielen saa vaihdettua itse pelin sisällä." optional: "Valinnainen" must_set_paths: "Nämä 3 polkua on määritettävä ennen kuin voit pelata Retro Rewindia" category: - video: "Grafiikka" about: "Tietoa" other: "Muut" general: "Yleiset" + video: "Grafiikka" + status: + data_folder: + custom: "Käytetään mukautettua sijaintia." + moving: "Siirretään tietoja, odota hetki..." value: language: france: "Ranska" @@ -463,4 +522,4 @@ fi: norwegian: "Norja" chinese: "Kiina" portuguese: "Portugali" - z_translators: "Clavey" + z_translators: "Clavey, Alokas" diff --git a/WheelWizard/Resources/Languages/fr.yml b/WheelWizard/Resources/Languages/fr.yml index 2ea9a36f5..390e18997 100644 --- a/WheelWizard/Resources/Languages/fr.yml +++ b/WheelWizard/Resources/Languages/fr.yml @@ -9,7 +9,7 @@ fr: room_details: "Détail de la salle" my_miis: "Mes Miis" attribute: - status_online: "En Ligne " + status_online: "En Ligne" enabled: "Activer" title: "Titre" status_offline: "Hors-Ligne" @@ -32,7 +32,7 @@ fr: gender: "Genre" gender_female: "Femme" vertical_position: "Position verticale" - size: "Taille " + size: "Taille" nose_type: "Type de nez" horizontal_position: "Position horizontale" mouth_type: "Type de bouches" @@ -61,7 +61,7 @@ fr: status: "Status" priority: "Priorité" sort_by: "Trier par" - total_games_won: "Nombre de courses gagnées " + total_games_won: "Nombre de courses gagnées" total_games_played: "Nombre de courses faites" vr_abbreviation: "PTS.C" br_abbreviation: "PTS.B" @@ -90,14 +90,14 @@ fr: loading: "Chargement..." installing: "Installation..." updating: "Mise à jour..." - config_not_finished: "Configuration non terminée " - no_server: "Aucun Seveurs " + config_not_finished: "Configuration non terminée" + no_server: "Aucun Seveurs" unknown: "Inconnu" error: "Erreur" warning: "Avertisemment" - success: "Succès " + success: "Succès" extracting: "Extraction des fichers..." - no_name: "Aucun nom " + no_name: "Aucun nom" no_license: "Aucun Permis" no_profiles: "Aucun Profil" custom: "Personnalisé" @@ -105,14 +105,14 @@ fr: action: play: "Jouer" play_offline: "Jouer en Hors-Ligne" - install: "Installer " + install: "Installer" update: "Mettre à Jour" import: "Importer" enable_all: "Tous Activer" disable_all: "Tous Désactiver" rename: "Renommer" delete: "Supprimer" - open_folder: "Ouvrir le dossier " + open_folder: "Ouvrir le dossier" edit: "Modifer" cancel: "Annuler" save: "Sauvegarder" @@ -123,13 +123,13 @@ fr: no: "Non" keep: "Garder" ok: "Ok" - revert: "Revenir en arrière " - maybe_later: "Peut être plus tard " + revert: "Revenir en arrière" + maybe_later: "Peut être plus tard" browse: "Chercher" view_mod: "Détail du Mod" save_external_mii: "Ajouter un Mii dans \"Mes Miis\"" view_mii: "Voir le Mii" - duplicate: "Dupliquer " + duplicate: "Dupliquer" back: "Retour" unfavorite: "Retirer des favoris" favorite: "Favoris" @@ -167,7 +167,7 @@ fr: mii_editor: "Éditeur de Mii" mii_carousel: "Vue d'ensemble du Mii" text: - made_by_string: | + made_by_string: |- Fait par: {$1} et {$2} thanks_translators: "Merci à tous les traducteurs:" @@ -207,7 +207,7 @@ fr: no_mod_selected: "Sélectionner un mod dans la liste pour voir ces détails" time: days: - 1: "1 Jour " + 1: "1 Jour" n: "{$1} Jours" hours: 1: "1 heure" @@ -219,7 +219,7 @@ fr: 1: "1 seconde" n: "{$1} secondes" progress: - estimated_time_remaining: "Temps restant estimé: " + estimated_time_remaining: "Temps restant estimé:" downloading_mb: "Téléchargement de {$1} MB" installing_mods: "Installation de mods" installing_mods_count: "Installation de {$1} mods" @@ -227,51 +227,50 @@ fr: update_rr: "Mise à jour de Retro Rewind en cours..." latest_wh_wz_github: "Obtenez la dernière version de Wheel Wizard depuis Github" update_wh_wz: "Mise à jour de Wheel Wizard en cours..." - installing_rr: "Installation de Retro Rewind " + installing_rr: "Installation de Retro Rewind" installing_rr_first_time: "Installation de Retro Rewind pour la première fois" installing_dolphin: "Installation de Dolphin Emulator" this_may_take_a_while: "Cela peut prendre plus de temps en fonction de votre connexion internet." question: launch_clear_mods_found: - title: "Mods trouvés " + title: "Mods trouvés" extra: |- Vous êtes sur le point de lancer le jeu sans aucun mods. - Voulez vous supprimer votre dossier My-Stuff ? + Voulez vous supprimer votre dossier My-Stuff ? sure_delete: title: "Êtes-vous sûr de vouloir supprimer {$1} ?" extra: "La suppresion est permanente et ne peut pas être inversée." title_miis: "Êtes-vous sûr de vouloir supprimer {$1} Miis ?" apply_scale: - title: "Voulez-vous appliquez la nouvelle échelle ? " + title: "Voulez-vous appliquez la nouvelle échelle ?" extra: "Cette modification va être rétablie dans {$1} sauf si vous choisissez de garder cette modification." dolphin_found: title: "Dossier Dolphin Emulator trouvé" - extra: | + extra: |- Si vous ne savez pas ce que tous cela veut dire, juste cliquez sur oui :) - Dossier Dolphin Emulator trouvé. Voulez-vous utilser ce dossier ? new_version_wh_wz: title: "Mise à jour Wheel Wizard disponible !" - extra: "La version {$1} de Wheel Wizard est disponible (Version actuelle: {$2}). Voulez-vous mettre à jour maintenant ? " + extra: "La version {$1} de Wheel Wizard est disponible (Version actuelle: {$2}). Voulez-vous mettre à jour maintenant ?" update_admin: title: "Mettre à jour en Administrateur" - extra: "Des fois, une mise à jour demande les droits d'admin. Voulez-vous les activer pour cette mise à jour ? " + extra: "Des fois, une mise à jour demande les droits d'admin. Voulez-vous les activer pour cette mise à jour ?" reinstall_rr: - title: "Réinstaller Retro Rewind " + title: "Réinstaller Retro Rewind" extra: "Êtes-vous sûr de vouloir réinstaller Retro Rewind ?" old_rksys_found: - title: "Ancien rksys.dat trouvé " - extra: "Une ancienne sauvegarde a été trouvée. Voulez-vous l'utiliser ? (Recommandé) " + title: "Ancien rksys.dat trouvé" + extra: "Une ancienne sauvegarde a été trouvée. Voulez-vous l'utiliser ? (Recommandé)" rr_to_old: extra: |- Votre version de Retro Rewind est trop vieille pour être mis à jour. - Voulez-vous réinstaller Retro Rewind ? + Voulez-vous réinstaller Retro Rewind ? title: "Votre version de Retro Rewind est trop vieille." rr_not_determined: - title: "Télécharger Retro Rewind " + title: "Télécharger Retro Rewind" extra: |- Votre version de Retro Rewind n'a pas pu être déterminée. - Voulez-vous installer Retro Rewind ? + Voulez-vous installer Retro Rewind ? dolphin_flatpak: title: "Installation de Dolphin Flatpak..." extra: "La version Flatpak de Dolphin Emulator ne semble pas être installée. Veuillez-vous qu'on vous l'installe ?" @@ -296,22 +295,20 @@ fr: extra: "Vérifiez bien si tous les chemins sont corrects et essayez à nouveau." title: "Chemin invalide" dolphin_not_found: - title: "Dossier Dolphin Emulator non trouvé " + title: "Dossier Dolphin Emulator non trouvé" extra: "Le dossier Dolphin Emulator n'a pas été automatiquement trouvé. Veuillez essayé de trouver le dossier manuellement, cliquez sur \"Aide\" pour plus d'information." fail_check_updates: title: "Échec de la vérification des mises à jour" extra: "Impossibilité de vérifier les mises à jour actuellement. Vous n'êtes probablement pas connecté à internet ou le serveur est probablement en maintenance" unable_update_wh_wz: extra: - reason_network: | + reason_network: |- Impossible de vérifier si Wheel Wizard est mis à jour. - Vous pourrez expérimenter des problèmes d'internet. - reason_location: | + reason_location: |- Impossible de mettre à jour Wheel Wizard. Veuillez bien vérifier que l'application se trouve dans un dossier qui peut être écrit. - N'a pas pu trouver le dossier actuel. - title: "Impossible de mettre à jour Wheel Wizard. " + title: "Impossible de mettre à jour Wheel Wizard." no_connect_server: extra: "N'a pas pu de se connecter au serveur. Veuillez réessayer plus tard." title: "N'a pas pu se connecter au serveur" @@ -346,10 +343,9 @@ fr: extra: failed_update_delete: "Échec de la désinstallation de fichiers pour la mise à jour. Annulation" failed_update_apply: "Échec d'application de la mise à jour. Annulation." - invalid_file_path: | + invalid_file_path: |- Chemin vers un fichier incorrect. Veuillez contacter les développeurs de Wheel Wizard. - - Erreur Serveur: {$1} + Erreur Serveur: {$1} title: "Anuller la mise à jour de RR" restart_admin_fail: extra: "Échec du redémarrage avec les droits d'administrateur." @@ -360,7 +356,7 @@ fr: failed_change_name: title: "Échec du changement de nom" failed_change_mii: - title: "Échec du changement de Mii " + title: "Échec du changement de Mii" failed_install_dolphin: title: "Échec de l'installation de Dolphin" extra: "L'installation de Dolphin Emulator a échoué. Veuillez essayer d'installer Flatpak Dolphin." @@ -378,13 +374,13 @@ fr: snackbar_warning: cant_save_mii: "Impossible de savegarder le Mii" no_mii_export: "Il semblerait qu'aucun Miis peuvent être exporter" - no_mii_delete: "Il semblerait qu'aucun Miis peuvent être supprimer " + no_mii_delete: "Il semblerait qu'aucun Miis peuvent être supprimer" snackbar_success: - mii_added: "Le Mii a été ajouté a vos Miis " + mii_added: "Le Mii a été ajouté a vos Miis" copied_fc: "Code Ami copié dans le presse-papier" name_change: "Changement du nom en '{$1}' avec succès" saved_mii: "Mii '{$1}' sauvegardé au chemin suivant '{$2}'" - deleted: "Suppression de '{$1}' " + deleted: "Suppression de '{$1}'" deleted_miis: "Suppression de {$1} Miis" created_duplicate: "Clonage de '{$1}'" created_duplicates_miis: "Clonage de {$1} Miis" @@ -404,7 +400,7 @@ fr: option: wh_wz_language: "Langue de Wheel Wizard" force_disable_wiimote: "Forcer la désactivation des Wiimotes" - launch_with_dolphin: "Lancer le jeu avec la fennêtre de Dolphin " + launch_with_dolphin: "Lancer le jeu avec la fennêtre de Dolphin" v_sync: "Synchronisation Verticale" recommended: "Paramètres Recommandés" show_fps: "Montrer les FPS" @@ -415,17 +411,18 @@ fr: mario_kart_game: "ISO Mario Kart Wii" dolphin_emulator_exe: "Éxécutable Dolphin Emulator" make_primary: "Défini par défaut" - remove_blur: "Retirer le Blur" enable_animations: "Activer les animations" - reinstall: "Réinstaller Retro Rewind " + reinstall: "Réinstaller Retro Rewind" open_game_folder: "Ouvrir le dossier du jeu" open_save_folder: "Ouvrir le dossier de sauvegarde" section: + graphics: "Graphismes" + general: "Général" language: "Langue" wii: "Paramètres Wii" resolution: "Résolution" performance: "Paramètres de perfomance" - renderer: "Paramètres de rendu " + renderer: "Paramètres de rendu" location_paths: "Paramètres de localisation" wh_wz_appearance: "Apparence de Wheel Wizard" installation: "Installation" @@ -435,19 +432,18 @@ fr: launch_with_dolphin: "Ira lancer la fenêtre d'accueil de Dolphin en plus du jeu" force_disable_wiimote: "Ce paramètre ira désactiver les Wiimotes dans le jeu, mais les activés dans le menu Wii" resolution_scale: "1x est la résolution native" - recommended: "Ce paramètre va changer certain paramètre Dolphin pour réduire les saccades et lag spikes " + recommended: "Ce paramètre va changer certain paramètre Dolphin pour réduire les saccades et lag spikes" 30_fps: "Ce paramètre va forcer le jeu à tourner en 30 FPS (60 est par défaut)" end_with_exe: "Le chemin doit se finir en .exe" enable_animations: "Ceci active les animations dans l'application Wheel Wizard" end_with_x: "Le chemin doit se finir avec:" - wh_wz_language: "Ceci change uniquement la langue dans Wheel Wizard. Pour changer la langue dans le jeu. Veuillez le faire depuis les paramètres de Retro Rewind." optional: "Optionnel" must_set_paths: "Vous devez impérativement définir ces 3 chemins afin de jouer à Retro Rewind" category: - video: "Vidéo" about: "À propos" other: "Autre" general: "Général" + video: "Vidéo" value: language: france: "Français" @@ -464,6 +460,6 @@ fr: arabic: "Arabe" czech: "Tchèque" norwegian: "Norvégien" - chinese: "Chinois " + chinese: "Chinois" portuguese: "Portugais" z_translators: "Eppe, Yawshi" diff --git a/WheelWizard/Resources/Languages/it.yml b/WheelWizard/Resources/Languages/it.yml index 7dd4dbfa8..58faf16c7 100644 --- a/WheelWizard/Resources/Languages/it.yml +++ b/WheelWizard/Resources/Languages/it.yml @@ -167,7 +167,7 @@ it: mii_editor: "Creatore Mii" mii_carousel: "Visualizzatore Mii" text: - made_by_string: | + made_by_string: |- Fatto da: {$1} e {$2} thanks_translators: "Grazie a tutti i traduttori:" @@ -246,9 +246,8 @@ it: extra: "Questa modifica si ripristinerà in {$1} a meno che tu decida di mantenerla." dolphin_found: title: "Cartella dell'Emulatore Dolphin trovata" - extra: | + extra: |- Se non sai cosa ciò vuol dire, clicca su sì :) - Cartella dell'Emulatore Dolphin trovata. Vuoi usare questa cartella? new_version_wh_wz: title: "Aggiornamento di Wheel Wizard disponibile" @@ -299,13 +298,11 @@ it: extra: "Impossibile verificare gli aggiornamenti in questo momento. Potresti non avere una connessione ad internet oppure il server potrebbe essere offline." unable_update_wh_wz: extra: - reason_network: | + reason_network: |- Impossibile verificare se Wheel Wizard è aggiornato. - Potresti avere problemi di connessione. - reason_location: | + reason_location: |- Impossibile aggiornare Wheel Wizard. Assicurati che l'applicazione si trovi in una cartella con diritti di scrittura. - Non è possibile trovare la cartella attuale. title: "Impossibile aggiornare Wheel Wizard." no_connect_server: @@ -342,9 +339,8 @@ it: extra: failed_update_delete: "Fallito il cancellamento dei file dell'aggiornamento. Annullamento." failed_update_apply: "Fallita l'applicazione di un aggiornamento. Annullamento." - invalid_file_path: | + invalid_file_path: |- Incontrato un percorso file invalido. Contatta gli sviluppatori. - Errore server: {$1} title: "Annullando l'aggiornamento di RR" restart_admin_fail: @@ -411,12 +407,13 @@ it: mario_kart_game: "File di gioco di Mario Kart Wii" dolphin_emulator_exe: "Eseguibile Emulatore Dolphin" make_primary: "Rendi di default" - remove_blur: "Rimuovere la sfocatura (Blur)" enable_animations: "Attiva le animazioni" reinstall: "Reinstalla Retro Rewind" open_game_folder: "Apri la cartella del gioco" open_save_folder: "Apri la cartella del salvataggio" section: + graphics: "Grafica" + general: "Generali" language: "Lingua" wii: "Impostazioni Wii" resolution: "Risoluzione" @@ -436,14 +433,13 @@ it: end_with_exe: "L'indirizzo deve terminare con .exe" enable_animations: "Questa impostazione abiliterà le animazioni nell'applicazione Wheel Wizard" end_with_x: "L'indirizzo può terminare con:" - wh_wz_language: "Questo cambia soltanto la lingua di Wheel Wizard. Per cambiare la lingua all'interno del gioco, dovrai farlo dalle impostazioni del gioco stesso." optional: "Opzionale" must_set_paths: "Devi impostare questi 3 indirizzi prima di poter iniziare a giocare a Retro Rewind" category: - video: "Video" about: "A riguardo" other: "Altro" general: "Generale" + video: "Video" value: language: france: "Francese" diff --git a/WheelWizard/Resources/Languages/ja.yml b/WheelWizard/Resources/Languages/ja.yml index 4cf52a9c7..9e86364b9 100644 --- a/WheelWizard/Resources/Languages/ja.yml +++ b/WheelWizard/Resources/Languages/ja.yml @@ -167,7 +167,7 @@ ja: mii_editor: "Miiエディター" mii_carousel: "Miiスライダー" text: - made_by_string: | + made_by_string: |- 作成者: {$1} と {$2} thanks_translators: "すべての翻訳者さんに感謝します:" @@ -246,9 +246,8 @@ ja: extra: "変更の保存を選択しないと、この変更は{$1}で変更前に戻ります" dolphin_found: title: "Dolphinエミュレーターのフォルダーが見つかりました" - extra: | + extra: |- 意味がよく分からなければ、「はい」をクリックしてください。 - Dolphinエミュレーターのフォルダーが見つかりました。このフォルダーを使用しますか? new_version_wh_wz: title: "Wheel Wizardのアップデートがあります" @@ -301,13 +300,11 @@ ja: extra: "現在アップデートを確認できません。インターネットに接続されていないか、サーバーがダウンしている可能性があります" unable_update_wh_wz: extra: - reason_network: | + reason_network: |- Wheel Wizardが最新かどうか確認できません。 - ネットワークの問題が発生している可能性があります。 - reason_location: | + reason_location: |- Wheel Wizardが更新できません。アプリが書き込み可能なフォルダーにあることを確認してください。 - 現在のフォルダーが見つかりません。 title: "Wheel Wizardのアップデートを無効にする" no_connect_server: @@ -344,10 +341,9 @@ ja: extra: failed_update_delete: "アップデートファイルの削除に失敗しました。中止しています。" failed_update_apply: "アップデートの適用に失敗しました。中止しています。" - invalid_file_path: | + invalid_file_path: |- 無効なファイルパスが検知されました。開発者に連絡してください。 - - サーバーエラー: {$1} + サーバーエラー: {$1} title: "Retro Rewindのアップデートを中止" restart_admin_fail: extra: "管理者権限で再起動するのに失敗しました。" @@ -413,12 +409,13 @@ ja: mario_kart_game: "マリオカートWiiのゲームファイル" dolphin_emulator_exe: "Dolphinエミュレーターを実行可能にする" make_primary: "このライセンスに切り替える" - remove_blur: "ブラー効果の無効化" enable_animations: "アニメーションを有効にする" reinstall: "Retro Rewindの再インストール" open_game_folder: "ゲームフォルダーを開く" open_save_folder: "セーブフォルダーを開く" section: + graphics: "グラフィックス" + general: "一般" language: "言語" wii: "Wii設定" resolution: "解像度" @@ -438,14 +435,13 @@ ja: end_with_exe: "パスの拡張子は必ず .exe にしてください" enable_animations: "Wheel Wizard内のアニメーションを有効にします" end_with_x: "パスは次のもので終わります:" - wh_wz_language: "これはWheel Wizardの言語のみ変更します。ゲーム内の言語を変更したい場合、ゲーム設定内で変更する必要があります" optional: "任意" must_set_paths: "Retro Rewindをプレイする前に、これらの3つのパスを設定する必要があります" category: - video: "映像" about: "情報" other: "その他" general: "一般" + video: "映像" value: language: france: "フランス語" diff --git a/WheelWizard/Resources/Languages/ko.yml b/WheelWizard/Resources/Languages/ko.yml index 900254dd5..5b077a2d8 100644 --- a/WheelWizard/Resources/Languages/ko.yml +++ b/WheelWizard/Resources/Languages/ko.yml @@ -167,7 +167,7 @@ ko: mii_editor: "Mii 에디터" mii_carousel: "Mii 슬라이더" text: - made_by_string: | + made_by_string: |- 만든이: {$1} 과 {$2} thanks_translators: "모든 번역가 분들에게 감사드립니다:" @@ -238,16 +238,15 @@ ko: sure_delete: title: "{$1}을/를 지우시겠습니까?" extra: "삭제는 영구적이며, 되돌릴 수 없습니다" - title_miis: "{1}Mii를 삭제하시겠습니까?" + title_miis: "{$1}Mii를 삭제하시겠습니까?" apply_scale: title: "새로운 해상도를 적용하시겠습니까?" extra: "변경 사항 저장을 선택하지 않으면 변경 전인 {$1}로 돌아갑니다" dolphin_found: title: "돌핀 에뮬레이터 폴더를 찾았습니다" - extra: | + extra: |- 돌핀 에뮬레이터 폴더를 찾았습니다. 해당 폴더를 사용하시겠습니까? - - 뭔 소린지 모르겠다면 그냥 \"네\"를 누르세요 ^_^ + 뭔 소린지 모르겠다면 그냥 "네"를 누르세요 ^_^ new_version_wh_wz: title: "휠 위저드의 새로운 업데이트가 있습니다." extra: "휠 위저드 최신 버전 {$1}이 있습니다.(현재 버전 {$2}). 업데이트 하시겠습니까?" @@ -297,13 +296,11 @@ ko: extra: "업데이트를 확인 할 수 없습니다. 인터넷 연결이 끊어졌거나 서버가 다운된 상태일 수 있습니다" unable_update_wh_wz: extra: - reason_network: | + reason_network: |- 휠 위저드의 버전을 확인할 수 없습니다. - 네트워크 문제가 발생했을 수 있습니다. - reason_location: | + reason_location: |- 휠 위저드를 업데이트할 수 없습니다. 프로그램이 쓰기 가능한 폴더에 있는지 확인하십시오. - 현재 폴더를 찾을 수 없습니다. title: "휠 위저드를 업데이트 할 수 없습니다" no_connect_server: @@ -340,10 +337,9 @@ ko: extra: failed_update_delete: "업데이트 파일 삭제 실패, 중단합니다." failed_update_apply: "업데이트 적용 실패, 중단합니다." - invalid_file_path: | + invalid_file_path: |- 잘못된 파일 경로가 감지됐습니다. 개발자에게 연락하세요. - - 서버 에러: {$1} + 서버 에러: {$1} title: "레트로 리와인드 업데이트 중단" restart_admin_fail: extra: "관리자 권한으로 재시작하는 데 실패했습니다." @@ -362,7 +358,7 @@ ko: title: "모드 정보 불러오기 실패" mod_download_fail: title: "모드 다운로드 실패" - extra: "다운로드 중 오류가 발생했습니다: ($1)" + extra: "다운로드 중 오류가 발생했습니다: {$1}" message_success: settings_saved: title: "설정을 저장했습니다!" @@ -409,12 +405,13 @@ ko: mario_kart_game: "마리오 카트 Wii 게임 파일" dolphin_emulator_exe: "Dolphin 에뮬레이터 실행 가능" make_primary: "이 프로필을 우선으로" - remove_blur: "블러 제거" enable_animations: "애니메이션 활성" reinstall: "레트로 리와인드 재설치" open_game_folder: "게임 폴더 열기" open_save_folder: "세이브 폴더 열기" section: + graphics: "그래픽" + general: "일반" language: "언어" wii: "Wii 설정" resolution: "해상도" @@ -434,14 +431,13 @@ ko: end_with_exe: "파일 경로는 \".exe\"로 끝나야 합니다." enable_animations: "Wheel Wizard 앱의 애니메이션을 활성화 합니다." end_with_x: "파일 경로는 다음과 같이 끝날 수 있습니다:" - wh_wz_language: "이 설정은 Wheel Wizard의 언어만 변경합니다. 게임의 언어를 변경하기 위해서는 게임 설정에서 변경해야 합니다." optional: "옵션" must_set_paths: "Retro Rewind 실행 전 3개의 경로를 설정해야 합니다." category: - video: "비디오" about: "정보" other: "기타" general: "일반" + video: "비디오" value: language: france: "프랑스어" diff --git a/WheelWizard/Resources/Languages/nl.yml b/WheelWizard/Resources/Languages/nl.yml index 6b3525f19..7ecbf8b3c 100644 --- a/WheelWizard/Resources/Languages/nl.yml +++ b/WheelWizard/Resources/Languages/nl.yml @@ -167,7 +167,7 @@ nl: mii_editor: "Mii-editor" mii_carousel: "Mii-carrousel" text: - made_by_string: | + made_by_string: |- Gemaakt door {$1} en {$2} thanks_translators: "Hartelijk dank aan alle vertalers:" @@ -244,9 +244,8 @@ nl: extra: "Deze wijziging wordt over {$1} teruggedraaid, tenzij je ervoor kiest om de wijziging te behouden." dolphin_found: title: "Dolphin-emulator-map gevonden" - extra: | + extra: |- Als je niet weet wat dit allemaal betekent, druk op ja :) - Dolphin-emulator-map gevonden. Wil je deze gebruiken? new_version_wh_wz: title: "Wheel Wizard-update beschikbaar" @@ -297,13 +296,11 @@ nl: extra: "Kan momenteel niet op updates controleren. Mogelijk heb je geen internetverbinding of de server is offline." unable_update_wh_wz: extra: - reason_network: | + reason_network: |- Kan niet controleren of Wheel Wizard up-to-date is. - Er zijn mogelijk netwerkproblemen. - reason_location: | + reason_location: |- Wheel Wizard kan niet worden geüpdatet. Zorg ervoor dat de applicatie zich in een map bevindt waarnaar kan worden geschreven. - Kan de huidige map niet vinden. title: "Wheel Wizard kan niet worden geüpdatet." no_connect_server: @@ -340,9 +337,8 @@ nl: extra: failed_update_delete: "Het is niet gelukt om bestanden te verwijderen voor de update. Proces word afgebroken." failed_update_apply: "Het is niet gelukt om te updaten. Proces word afgebroken." - invalid_file_path: | + invalid_file_path: |- Ongeldige bestandslocatie gedetecteerd. Neem contact op met de ontwikkelaars. - Serverfout: {$1} title: "RR-update afgebroken." restart_admin_fail: @@ -409,12 +405,13 @@ nl: mario_kart_game: "Mario Kart Wii-spelbestand" dolphin_emulator_exe: "Dolphin-emulator-executable" make_primary: "Maak hoofdprofiel" - remove_blur: "Verwijder waas" enable_animations: "Animaties inschakelen" reinstall: "Herinstalleer RR" open_game_folder: "Open spelmap" open_save_folder: "Open opslagmap" section: + graphics: "Grafisch" + general: "Algemeen" language: "Taal" wii: "Wii-Instellingen" resolution: "Resolutie" @@ -434,14 +431,13 @@ nl: end_with_exe: "Locatie moet eindigen met \".exe\"." enable_animations: "Dit schakelt de animaties in de Wheel Wizard-app in." end_with_x: "Locatie kan eindigen met:" - wh_wz_language: "Dit verandert alleen de taal van Wheel Wizard. Om de taal in het spel zelf te wijzigen, moet je dit doen via de instellingen van het spel." optional: "Optioneel" must_set_paths: "Je moet deze 3 locaties instellen voordat je Retro Rewind kunt spelen." category: - video: "Video" about: "Over" other: "Overig" general: "Algemeen" + video: "Video" value: language: france: "Frans" diff --git a/WheelWizard/Resources/Languages/pl.yml b/WheelWizard/Resources/Languages/pl.yml new file mode 100644 index 000000000..b493d7e36 --- /dev/null +++ b/WheelWizard/Resources/Languages/pl.yml @@ -0,0 +1,477 @@ +pl: + page_title: + home: "Strona główna" + settings: "Ustawienia" + mods: "My Stuff" + patches: "Poprawki" + my_profiles: "Moje Profile" + rooms: "Pokoje" + friends: "Przyjaciele" + leaderboard: "Tabela Wyników" + room_details: "Szczegóły Pokoju" + my_miis: "Moje Mii" + attribute: + status_online: "Online" + enabled: "Włączone" + title: "Tytuł" + all: "Wszystko" + status_offline: "Offline" + name: "Nazwa" + id: "ID" + type: "Typ" + speed: "Prędkość" + mod_name: "Nazwa Moda" + average_room_vr: "Średni OV*" + losses: "Przegrane" + wins: "Wygrane" + races_played: "Wyścigi zagrane" + friend_code: "Kod Przyjaciela" + player_count: "Graczy" + time_online: "Czas Online" + message: "Wiadomość" + is_online: "Jest Online" + mii: + gender_male: "Mężczyzna" + gender: "Płeć" + gender_female: "Kobieta" + vertical_position: "Pionowa Pozycja (Góra/Dół)" + size: "Rozmiar" + nose_type: "Rodzaj Nosa" + horizontal_position: "Pozioma Pozycja (Lewo/Prawo)" + mouth_type: "Rodzaj Ust" + hair_color: "Kolor Włosów" + hair_type: "Rodzaj Włosów" + mirror_hair: "Odwrócone Włosy" + glasses_type: "Rodzaj Okularów" + glasses_color: "Kolor Okularów" + name: "Imię Mii" + creator_name: "Imię Twórcy" + height: "Wysokość" + width: "Szerokość" + fav_color: "Ulubiony Kolor" + skin_color: "Kolor Skóry" + head_shape: "Kształt Głowy" + irish_color: "Kolor Oczu" + space_between: "Miejsce Pomiędzy" + rotation: "Obrót (Obróć Lewo/Prawo)" + eyebrow_type: "Rodzaj Brwi" + mustache_type: "Rodzaj Wąsa" + mustache_vertical_position: "Pionowa Pozycja Wąsa (Góra/Dół)" + mustache_size: "Rozmiar Wąsa" + beard_type: "Rodzaj Brody" + default_name: "Nowy Mii" + status: "Status" + priority: "Priorytet" + sort_by: "Sortuj Według" + total_games_won: "Ilość wygranych gier" + total_games_played: "Ilość zagranych gier" + vr_abbreviation: "OV*" + br_abbreviation: "OB" + br_full: "Ocena Bitewna" + vr_full: "Ocena Versus*" + description: "Opis" + images: "Obrazy" + likes: "Polubienia" + views: "Wyświetlenia" + downloads: "Pobrania" + mii_section: + general: "Ogólne" + face: "Twarz" + hair: "Włosy" + eyebrows: "Brwi" + eyes: "Oczy" + nose: "Nos" + lips: "Usta" + glasses: "Okulary" + facial_hair: "Zarost" + mole: "Pieprzyk" + gender: "Płeć" + gender_female: "Kobieta" + gender_male: "Mężczyzna" + state: + loading: "Ładowanie..." + installing: "Instalowanie..." + updating: "Aktualizowanie..." + config_not_finished: "Konfiguracja nie zakończona" + no_server: "Brak Serwera" + unknown: "Nieznany" + error: "Błąd" + warning: "Ostrzeżenie" + success: "Sukces" + extracting: "Wyodrębnianie plików..." + no_name: "Brak Nazwy" + no_license: "Brak Licencji" + no_profiles: "Brak Profili" + custom: "Niestandardowe" + installed: "Zainstalowane" + action: + play: "Graj" + play_offline: "Graj Offline" + install: "Instaluj" + update: "Aktualizuj" + import: "Importuj" + convert_to_patches: "Konwertuj na poprawki" + enable_all: "Włącz wszystkie" + disable_all: "Wyłącz wszystkie" + rename: "Zmień Nazwe" + delete: "Usuń" + open_folder: "Otwórz Folder" + edit: "Edytuj" + cancel: "Anuluj" + save: "Zapisz" + copy_friend_code: "Kopiuj Kod Przyjaciela" + export: "Eksportuj" + apply: "Zastosuj" + yes: "Tak" + no: "Nie" + keep: "Zostaw" + ok: "Okej" + revert: "Przywróć" + maybe_later: "Może Później" + browse: "Przeglądaj" + view_mod: "Zobacz Moda" + save_external_mii: "Dodaj Mii do \"Moje Mii\"" + view_mii: "Zobacz Mii" + duplicate: "Duplikuj" + back: "Wróć" + unfavorite: "Odulub" + favorite: "Ulubione" + view_room: "Zobacz Pokój" + link: + discord: "Pogadaj z nami!" + github: "Kod źródłowy" + support: "Wesprzyj nas!" + launch_dolphin: "Uruchom Dolphin" + randomize: "Losuj" + do_manually: "Wykonaj ręcznie" + gamebanana_page: "Strona Gamebanana" + report: "Zgłoś" + un_install: "Odinstaluj" + download_and_install: "Pobierz i Instaluj" + view_custom_chars: "Zobacz Niestandardowe Znaki" + submit: "Potwierdź" + region: + japan: "Japonia" + europe: "Europa" + china: "Chiny" + south_korea: "Korea Południowa" + taiwan: "Tajwan" + america: "Ameryka" + australia: "Australia" + list_title: + friends: "Przyjaciele" + players: "Gracze" + rooms: "Pokoje" + miis: "Mii" + popup_title: + mod_details: "Szczegóły Moda" + mod_browser: "Przeglądacz Modów" + mii_selector: "Selektor Mii" + mii_editor: "Edytor Mii" + text: + made_by_string: |- + Stworzone przez: {$1} + i {$2} + thanks_translators: "Dziękujemy bardzo wszystkim tłumaczą:" + wh_wz_translation_percentage: "Tłumaczenie dla tego języka jest skończone w {$1}%" + powered_gamebanana: "Wsparci Przez Gamebanana" + language_translated_by: "Ten język jest przetłumaczony przez: {$1}" + hover: + players_online: + 0: "Nie ma żadnych graczy online" + 1: "Jest 1 gracz online" + n: "jest teraz {$1} graczy online" + rooms_online: + 0: "Nie ma żadnych aktywnych pokoi" + 1: "Jest 1 aktywny pokój" + n: "jest teraz {$1} aktywnych pokoi" + friends_online: + 0: "żaden z twoich przyjaciół nie jest onllne" + 1: "Jest teraz 1 przyjaciel online" + n: "Jest teraz {$1} przyjaciół online" + rooms_page_disclaimer: "Prosze pamiętaj, że ta strona nie daje możliwości dołączenia prosto do pokoju. By dołączyć do specyficznego pokoju musisz dołączyć poprzez przyjaciela lub mieć nadzieje, że uda ci się połączyć poprzez losowy serwer." + friends_page_disclaimer: "By dodać przyjaciół musisz ich dodać w grze" + region_user_selection: "To tylko pokazuje regiony, w których TY grałeś." + empty_content: + no_profiles: "Musisz zagrać w gre przynajmniej raz by zobaczyć tu swoje profle" + no_friends_title: "Brak przyjaciół!" + no_friends: "Możesz dodać przyjaciół w grze" + no_mods: "Mody mogą zmienić jak gra działa. Zacznij importować swój pierwszy mod klikając przycisk poniżej." + no_mods_title: "Nie znaleziono modów" + no_rooms: "Możesz nie mieć połączenia z internetem lub nikt teraz nie gra." + no_rooms_title: "Nie znaleziono pokoi" + no_miis_title: "Brak Mii!" + no_miis: "Nie możemy odczytać danych Mii z twojego systemu. Upewnij sie, że uruchomiłeś gre przynajmniej raz" + no_mod_selected_title: "Nie wybrano moda" + no_mod_selected: "Wybierz moda na liście by zobaczyć jego szczegóły" + time: + days: + 1: "1 dzień" + n: "{$1} dni" + hours: + 1: "1 godz" + n: "{$1} godz" + minutes: + 1: "1 min" + n: "{$1} min" + seconds: + 1: "1 sek" + n: "{$1} sek" + progress: + estimated_time_remaining: "Oszacowany pozostały czas." + downloading_mb: "Pobieranie {$1} MB" + installing_mods: "Instalowanie modów" + installing_mods_count: "Instalowanie {$1} modów" + processing_xof_y: "Procesowanie {$1} z {$2} Plików" + update_rr: "Aktualizowanie Retro Rewind" + update_wh_wz: "Aktualizowanie Wheel Wizard" + installing_rr: "Instalowanie Retro Rewind" + installing_rr_first_time: "Instalowanie Retro Rewind pierwszy raz" + installing_dolphin: "Instalowanie Emulatora Dolphin" + this_may_take_a_while: "Może to zająć chwile zależnie od twojego połączenia z internetem." + applying_converted_mod: "Zastosowywanie skonwertowanych modów" + combining_files: "Łączenie plików" + converting_file: "Konwertowanie {$1}" + converting_files_count: "Konwertowanie {$1} Pliku(ów)" + converting_mod_to_patches: "Konwertowanie modów na poprawki" + downloading_mod: "Pobieranie {$1}" + installing_mod: "Instalowanie Moda" + preparing_files_count: "Przygotowywanie {$1} Pliku(ów)" + question: + launch_clear_mods_found: + title: "Znaleziono Mody" + extra: "Zaraz uruchomisz grę bez żadnych modów. Czy chcesz wyczyścić twój my-stuff folder?" + sure_delete: + title: "Jesteś pewien, że chcesz usunąć {$1}?" + extra: "Usuwanie jest nie odwracalne." + title_miis: "Jesteś pewien, że chcesz usunąć {$1} Mii?" + apply_scale: + title: "Czy chcesz zastosować nową skalę?" + extra: "Ta zmiana zostanie cofnięta za {$1} może, że postanowisz ją zastosować." + move_data: + title: "Przenieść dane z Wheel Wizard?" + extra: |- + Wheel Wizard przeniesie swoje pliki do: + {$1} + to może zająć chwile zależnie od ilości danych. + dolphin_found: + title: "Znaleziono folder Emulatora Dolphin" + extra: |- + Jeżeli nie wiesz co to wszystko znaczy poprostu naciśnij tak :) + Znaleziono folder Emulatora Dolphin. Chciałbyś użyć ten folder? + new_version_wh_wz: + title: "Aktualizacja do Wheel Wizard dostępna" + extra: "Wersja {$1} Wheel Wizard jest dostępna (obecnie na {$2}). Chcesz aktualizować teraz?" + update_admin: + title: "Aktualizuj używając uprawnień administratora" + extra: "Czasami aktualizacja wymaga uprawnień administratora czy chcesz je aktywować dla tej aktualizacji?" + reinstall_rr: + title: "Zainstaluj Retro Rewind ponownie" + extra: "Jesteś pewien, że chcesz zainstalować Retro Rewind ponownie?" + old_rksys_found: + title: "Znaleziono stary plik rksys.dat" + extra: "Znaleziono stary zapis. Chciałbyś go użyć? (Zalecane)" + rr_to_old: + extra: "Twoja wersja Retro Rewind jest zbyt stara by ją aktualizować. Chcesz zainstalować Retro Rewind ponownie?" + title: "Wersja Retro Rewind jest zbyt stara." + install_mod: + title: "Czy chcesz pobrać i zainstalować mod: {$1}?" + search_mod: "Szukaj modów..." + enter_new_name: + title: "Wpisz nowe imię" + extra: "Zmienianie imienia z: {$1}" + enter_mod_name: + title: "Nazwa Moda:" + launch_clear_patches_found: + extra: "Zaraz uruchomisz grę bez modów. Czy chcesz wyczyścić swój folder poprawek? Uruchamianie gry tak czy siak uruchomi ją z aktualnym folderem poprawek dalej aktywnym!" + apply_language_settings: + title: "To zamknie obecne okno i otworzy nowe z nowymi ustawieniami językowymi." + extra: "Czy chcesz zastosować nowe ustawienia językowe?" + message_warning: + not_find_dolphin: + extra: "Nie można było znaleść Emulatora Dolphin prosze ustaw ścieżkę w ustawieniach" + not_find_game: + extra: "Nie można było znaleść gry prosze ustaw ścieżkę w ustawieniach" + multiple_files_selected: + title: "Wybrano Wiele Plików" + invalid_name: + extra: + mod_name_exists: "Mod z nazwą \"{$1}\" już istnieje" + title: "Nazwa niedozwolona" + invalid_paths: + extra: "Proszę się upewnij, że wszystkie ścieżki są poprawnę i spróbuj ponownie." + title: "Niepoprawne ścieżki" + dolphin_not_found: + title: "Nie znaleziono folderu Emulatora Dolphin" + extra: "Folder Emulatora Dolphin nie został znaleziony automatycznie. Proszę spróbuj znaleść folder manualnie kliknij \"pomoc\" po więcej informacji" + dolphin_tool_selected: + title: "Wybrano zły plik wykonywalny Dolphina" + extra: "Wybrałeś DolphinTool.exe. Proszę wybierz Dolphin.exe (Plik wykonywalny Emulatora Dolphin) zamiast tego" + fail_check_updates: + title: "Nie udało się sprawdzić dostępności aktualizacji" + extra: "Nie udało się sprawdzić dostępności aktualizacji w tej chwili. Możesz nie być podłączony do internetu lub serwer może teraz nie działać." + unable_update_wh_wz: + extra: + reason_network: |- + Nie można sprawdzić czy Wheel Wizard jest na najnowszej wersji. + Możesz mieć problem z internetem. + reason_location: |- + Nie można zaktualizować Wheel Wizard. Proszę upewnij się, że aplikacja jest zlokalizowana w folderze, który ma możliwość zapisu. + Nie można znaleść obecnego folderu. + title: "Nie można zaktualizować Wheel Wizard." + no_connect_server: + extra: "Nie można połączyć się ze serwerem. Spróbuj ponownie później." + title: "Nie można połączyć się ze serwerem" + cant_view_mod: + title: "Nie można zobaczyć tego moda." + extra: + something_else: "Coś poszło nie tak próbując otworzyć wybrany mod." + not_from_browser: "Nie można zobaczyć moda, który nie był zainstalowany poprzez Przeglądacza Modów." + cannot_delete_fav_mii: + title: "Nie można usunąć ulubionych Mii" + extra: "Jeden lub więcej z zaznaczonych Mii jest ulubiony. Mii mogą być tylko usunięte jeżeli nie są z listy ulubionych by zapobiec przed przypadkowym usunięciem." + mod_name_invalid: + title: "Nieprawidłowa nazwa moda." + extra: "Nazwa moda zawiera nieprawidłowe znaki." + mod_name_empty: + title: "Nazwa moda nie może być pusta." + extra: "Proszę podaj nazwe moda." + unable_download_mod: + title: "Nie można pobrać moda" + extra: "Nie można znaleść plików." + could_not_convert_mod: + title: "Nie można skonwertować moda" + placeholder: + enter_mod_name: "Wpisz nazwe moda..." + search_mod: "Szukaj modów..." + enter_mii_name: "Wpisz imię Mii..." + enter_text_here: "Wpisz tutaj tekst..." + search_for_players: "Szukaj Graczy..." + enter_path: "Podaj chcianą ścieżkę tutaj..." + message_error: + no_mod_folder: + extra: "Folder modów nie istnieje" + data_folder_move: + title: "Nie udało się przenieść danych" + abort_rr: + extra: + failed_update_delete: "Nie udało się usunąć plików dla aktualizacji. Abortowanie." + failed_update_apply: "Nie udało się zastosować aktualizacji. Abortowanie" + invalid_file_path: |- + Wykryto nieprawidłową ścieżkę do pliku. Proszę skontaktuj się z deweloperami. + Błąd Serwera: {$1} + title: "Abortowanie aktualizacji Retro Rewind" + restart_admin_fail: + extra: "Nie udało się zrestartować z uprawnieniami administracyjnymi." + generic_error: + title: "Wystąpił błąd." + failed_create_mii_db: + title: "Nie udało się stworzyć bazy danych dla Mii" + failed_change_name: + title: "Nie udało się zmienić imienia." + failed_change_mii: + title: "Nie udało się zmienić Mii" + failed_install_dolphin: + title: "Nie udało się zainstalować Dolphina" + extra: "Instalacja Emulatora Dolphin się nie powiodła. Proszę spróbuj manualnie zainstalować Dolphina poprzez Flatpak." + failed_retrieve_mod: + title: "Nie udało się odzyskać informacji moda" + mod_download_fail: + title: "Nie udało się pobrać moda." + extra: "Wystąpił błąd podczas pobierania: {$1}" + message_success: + settings_saved: + title: "Ustawienia zapisane pomyślnie!" + data_folder_moved: + title: "Zaktualizowano folder danych" + extra: |- + Dane Wheel Wizard są teraz przechowywane w: + {$1} + rr_up_to_date: + title: "Retro Rewind jest na najnowszej wersji." + mii_changed: "Zmieniono Mii pomyślnie" + mod_converted_to_patches: + title: "Konwertuj na poprawki" + mod_installed: + extra: "Mod \"{$1}\" zainstalowany pomyślnie." + title: "Zainstalowano moda pomyślnie!" + patch_conversion_skipped: "Zostawiono {$1} plik(i/ów) niezmienionych bo nie mogły być skonwertowane." + snackbar_warning: + cant_save_mii: "Nie można zapisać Mii" + no_mii_export: "Wygląda na to, że nie było żadnych Mii do eksportowania" + no_mii_delete: "Wygląda na to, że nie było żadnych Mii do usunięcia" + snackbar_success: + mii_added: "Mii został dodany do twoich Mii" + copied_fc: "Skopiowano kod przyjaciela do schowka" + name_change: "Pomyślnie zmieniono imię na \"{$1}\"" + saved_mii: "Zapisano Mii \"{$1}\" do pliku \"{$2}\"" + deleted: "Usunięto \"{$1}\"" + deleted_miis: "Usunięto {$1} Mii" + created_duplicate: "Stworzono kopie \"{$1}\"" + created_duplicates_miis: "Stworzono {$1} kopi Mii" + profile_set_primary: "Ustaw jako główny profil" + snackbar_error: + mii_failure_update: "Nie udało się zaktualizować Mii \"{$1}\"" + mii_failure_save: "Nie udało się zapisać Mii \"{$1}\"" + mii_failure_get: "Nie udało się zdobyć Mii \"{$1}\"" + mii_failure_create: "Nie udało się stworzyć Mii \"{$1}\"" + mii_failure_duplicate: "Nie udało się skopiować Mii \"{$1}\"" + helper_note: + name_must_between: "Nazwy muszą mieć od 3 do 10 znaków." + creator_name_less11: "Nazwa twórcy musi mieć mniej niż 11 znaków." + creator_name_less10: "Nazwa twórcy musi mieć mniej niż 10 znaków." + file_picker: + select_mod_file: "Wybierz Plik Moda" + patch: + incompatible_mod: + message: "Ten mod jest niekompatybilny z innymi modami. Uruchamianie gry z tym modem może spowodować niepoprawne zachowanie albo nagłe wyłączenie gry. Rozważ skonwertowanie go na poprawki poprzez klikanie na moda prawym przyciskiem myszy." + title: "MOD NIEKOMPATYBILNY" + option: + wh_wz_language: "Język Wheel Wizard" + force_disable_wiimote: "Zmuś Wyłączenie Wiimota" + launch_with_dolphin: "Uruchom grę razem z oknem Dolphina" + launch_rr_on_startup: "Uruchom Retro Rewind razem z uruchomieniem Wheel Wizard." + v_sync: "VSync" + recommended: "Polecane ustawienia" + show_fps: "Pokaż FPS" + window_scale: "Skala Okna" + open_config: "Otwórz folder konfiguracji" + wheel_wizard_data_folder: "Folder danych Wheel Wizard" + reset_data_folder: "Użyj domyślnej lokacji" + dolphin_user_path: "Folder Użytkownika Dolphina" + mario_kart_game: "Plik gry Mario Kart Wii" + open_game_folder: "Otwórz Folder Gry" + open_save_folder: "Otwórz Folder Zapisów" + section: + graphics: "Grafika" + general: "Ogólne" + language: "Język" + wii: "Ustawienia Wii" + resolution: "Rezolucja" + performance: "Ustawienia Wydajności" + location_paths: "Ustawienia Lokacji" + wh_wz_appearance: "Wygląd Wheel Wizard" + installation: "Instalacja" + category: + other: "Inne" + video: "Wideo" + value: + language: + france: "Francuski" + dutch: "Holenderski" + english: "Angielski" + german: "Niemiecki" + italian: "Włoski" + japanese: "Japoński" + turkish: "Turecki" + spanish: "Hiszpański" + finnish: "Fiński" + korean: "Koreański" + russian: "Rosyjski" + arabic: "Arabski" + czech: "Czeski" + norwegian: "Norweski" + chinese: "Chinski" + polish: "Polski" + portuguese: "Portugalski" + z_translators: "tim13." diff --git a/WheelWizard/Resources/Languages/port_script.py b/WheelWizard/Resources/Languages/port_script.py index 08598e616..da214fdb1 100644 --- a/WheelWizard/Resources/Languages/port_script.py +++ b/WheelWizard/Resources/Languages/port_script.py @@ -2,6 +2,7 @@ import csv import datetime as dt +import re import sys from pathlib import Path @@ -9,7 +10,7 @@ LANGUAGE_DIR = Path(__file__).resolve().parent TRANSLATOR_KEY = "value.language.z_translators" TRANSLATOR_EXPORT_HINT = "Put your name here if you contributed." -NEWLINE_NORMALIZED_IMPORT_KEYS = {"hover.rooms_page_disclaimer"} +TRANSLATION_KEY_PATTERN = re.compile(r"^[a-z0-9_]+(?:\.[a-z0-9_]+)*$") def parse_yaml_file(path: Path) -> tuple[str, dict]: @@ -46,7 +47,7 @@ def parse_yaml_file(path: Path) -> tuple[str, dict]: root_code = key continue - if raw_value == "|-": + if raw_value in {"|", "|-", "|+"}: block_lines: list[str] = [] block_indent = indent + 2 while index < len(lines): @@ -61,7 +62,7 @@ def parse_yaml_file(path: Path) -> tuple[str, dict]: else: block_lines.append("") - current[key] = "\n".join(block_lines) + current[key] = normalize_newlines("\n".join(block_lines)) continue current[key] = unquote_yaml_value(raw_value) @@ -91,7 +92,7 @@ def unquote_yaml_value(value: str) -> str: result.append(char) index += 1 - return "".join(result) + return normalize_newlines("".join(result)) def write_yaml_file(path: Path, language_code: str, values: dict) -> None: @@ -112,7 +113,7 @@ def append_yaml_dict(lines: list[str], values: dict, indent: int) -> None: if "\n" in text or "\r" in text: lines.append(f"{prefix}{key}: |-") for block_line in text.replace("\r\n", "\n").replace("\r", "\n").split("\n"): - lines.append(" " * (indent + 2) + block_line) + lines.append(" " * (indent + 2) + block_line if block_line else "") continue lines.append(f'{prefix}{key}: "{quote_yaml_value(text)}"') @@ -179,6 +180,8 @@ def ordered_translation_keys(languages: dict[str, dict[str, str]]) -> list[str]: for key in languages[language_code]: if key in seen: continue + if not TRANSLATION_KEY_PATTERN.fullmatch(key): + continue seen.add(key) keys.append(key) @@ -203,7 +206,7 @@ def export_csv() -> Path: if code == "en" and key == TRANSLATOR_KEY: row_values.append(TRANSLATOR_EXPORT_HINT) else: - row_values.append(languages[code].get(key, "")) + row_values.append(normalize_export_value(languages[code].get(key, ""))) writer.writerow([key, *row_values]) @@ -322,10 +325,23 @@ def ask_for_csv_path() -> Path | None: def normalize_import_value(key: str, value: str) -> str: - if key not in NEWLINE_NORMALIZED_IMPORT_KEYS: - return value + return normalize_newlines(value) + + +def normalize_export_value(value: str) -> str: + normalized = value.replace("\r\n", "\n").replace("\r", "\n") + return normalized.replace("\n", "\\n") + + +def normalize_newlines(value: str) -> str: + normalized = value.replace("\\r\\n", "\n").replace("\\n", "\n") + return "\n".join(line.strip() for line in normalized.split("\n")) - return value.replace("\\r\\n", "\n").replace("\\n", "\n") + +def normalize_yaml_files() -> None: + for path in sorted(LANGUAGE_DIR.glob("*.yml")): + language_code, values = parse_yaml_file(path) + write_yaml_file(path, language_code, values) def ask_mode() -> str: @@ -350,6 +366,11 @@ def main() -> int: import_csv() return 0 + if mode in {"n", "normalize"}: + normalize_yaml_files() + print("Normalized translation YAML files") + return 0 + print("Nothing selected.") return 1 except Exception as ex: diff --git a/WheelWizard/Resources/Languages/pt.yml b/WheelWizard/Resources/Languages/pt.yml index 6cd134178..e9b4fbc79 100644 --- a/WheelWizard/Resources/Languages/pt.yml +++ b/WheelWizard/Resources/Languages/pt.yml @@ -3,15 +3,18 @@ pt: home: "Casa" settings: "Definições" mods: "My stuff" + patches: "Patches" my_profiles: "Os meus perfis" rooms: "Salas" friends: "Amigos" + leaderboard: "Tabela de Ranking" room_details: "Detalhes da sala" my_miis: "Meus Miis" attribute: status_online: "Online" enabled: "Ativado" title: "Título" + all: "Tudo" status_offline: "Offline" name: "Nome" id: "ID" @@ -32,7 +35,7 @@ pt: gender: "Género" gender_female: "Mulher" vertical_position: "Posição Vertical (Cima/Baixo)" - size: "Tamanho " + size: "Tamanho" nose_type: "Tipo de Nariz" horizontal_position: "Posição Horizontal (Esquerda/Direita)" mouth_type: "Tipo de Boca" @@ -62,8 +65,8 @@ pt: priority: "Prioridade" sort_by: "Organizar por" total_games_won: "Total de jogos ganhos" - total_games_played: "Total de jogos jogados " - vr_abbreviation: "CV " + total_games_played: "Total de jogos jogados" + vr_abbreviation: "CV" br_abbreviation: "CB Médio" br_full: "Classificação de Batalha" vr_full: "Classificação Versus" @@ -108,6 +111,7 @@ pt: install: "Instalar" update: "Atualizar" import: "Importar" + convert_to_patches: "Converter para patches" enable_all: "Ativar tudo" disable_all: "Desativar tudo" rename: "Mudar o nome" @@ -149,7 +153,7 @@ pt: submit: "Submeter" region: japan: "Japão" - europe: "Europa " + europe: "Europa" china: "China" south_korea: "Coreia do Sul" taiwan: "Taiwan" @@ -167,13 +171,20 @@ pt: mii_editor: "Editor Mii" mii_carousel: "Carrosel Mii" text: - made_by_string: | + made_by_string: |- Feito por: {$1} e {$2} thanks_translators: "Muito obrigado a todos os tradutores:" wh_wz_translation_percentage: "Traduções para esta língua estão {$1}% completas" powered_gamebanana: "Contribuido pelo GameBanana" language_translated_by: "Esta língua é traduzida por: {$1}" + brsar_entry: "{$1} entrada {$2}" + brsar_entry_with_rwar: "{$1} entrada {$2} + RWAR" + external_reference: "Referência externa" + external_reference_with_path: "Referência externa: {$1}" + modified_archive_member: "Item do arquivo modificado" + new_archive_member: "Novo item do arquivo modificado" + whole_file_override: "Substituição do arquivo inteiro" hover: players_online: 0: "Não há jogadores online" @@ -212,7 +223,7 @@ pt: n: "{$1} horas" minutes: 1: "1 minuto" - n: "{$1} minutos " + n: "{$1} minutos" seconds: 1: "1 segundo" n: "{$1} segundos" @@ -229,22 +240,35 @@ pt: installing_rr_first_time: "Instalando Retro Rewind pela primeira vez" installing_dolphin: "A instalar o Dolphin Emulator" this_may_take_a_while: "Isto pode demorar um pouco, dependendo na sua conexão de internet." + applying_converted_mod: "A aplicar o mod convertido" + combining_files: "Combinando ficheiros" + converting_file: "A converter {$1}" + converting_files_count: "A converter {$1} ficheiro(s)" + converting_mod_to_patches: "A converter o mod para patches" + downloading_mod: "A descarregar {$1}" + installing_mod: "A instalar o mod" + preparing_files_count: "A preparar {$1} ficheiro(s)" question: launch_clear_mods_found: - title: "Mods não encontrados" + title: "Mods encontrados" extra: "Tu estás prestes a abrir o jogo sem nenhum mod. Queres limpar a pasta my-stuff?" sure_delete: - title: "Tens a certeza que desejas apagar ($1)?" + title: "Tens a certeza que desejas apagar {$1}?" extra: "A eliminação é permanente e não pode ser revertida." title_miis: "Tem a certeza de que deseja apagar {$1} Miis?" apply_scale: title: "Gostarias de aplicar a nova escala?" extra: "Esta alteração será revertida em {$1} a menos que decida manter a alteração." + move_data: + title: "Mover a data do Wheel Wizard?" + extra: |- + O Wheel Wizard vai mover os seus ficheiros para : + {$1} + Pode demorar bastante tempo, dependendo da quantidade de dados. dolphin_found: title: "Pasta Do Dolphin Emulator Encontrada" - extra: | + extra: |- Se não sabes o que isto significa, clica só que sim :) - Pasta do Dolphin Emulator encontrada. Gostarias de usar esta pasta? new_version_wh_wz: title: "Atualização do Wheel Wizard Disponível" @@ -273,6 +297,13 @@ pt: enter_new_name: title: "Introduza um novo nome" extra: "Alterando o nome de: {$1}" + enter_mod_name: + title: "Nome do mod:" + launch_clear_patches_found: + extra: "Está prestes a iniciar o jogo sem mods. Pretende limpar a pasta de Patches? Iniciar o jogo assim vai iniciá-lo com a atual pasta de patches ainda ativa!" + apply_language_settings: + title: "Isto vai fechar a janela atual e abrir uma nova com as novas definições de linguagem." + extra: "Pretende aplicar as novas definições de linguagem?" message_warning: not_find_dolphin: extra: "O Dolphin Emulator não foi encontrado, por favor defina o caminho nas definições" @@ -288,21 +319,22 @@ pt: extra: "Por favor verifica que todos os percursos estão corretos e tenta outra vez." title: "Caminhos inválidos." dolphin_not_found: - title: "Pasta Do Emulador Dolphin Não Encontrada " + title: "Pasta Do Emulador Dolphin Não Encontrada" extra: "A pasta Do Emulador Dolphin não foi automáticamente encontrada. Por favor tente encontrar a pasta manualmente, clica em 'ajuda' para obter mais informação-" + dolphin_tool_selected: + title: "Executável incorreto do Dolphin selecionado." + extra: "Selecionou DolphinTool.exe. Por favor escolha Dolphin.exe (o executável do Dolphin Emulator)." fail_check_updates: title: "Falha ao procurar atualizações" extra: "Não é possível verificar se há atualizações no momento. Pode não estar conectado à internet ou o servidor pode estar indisponível ou em baixo." unable_update_wh_wz: extra: - reason_network: | + reason_network: |- Impossível verificar se o Wheel Wizard está atualizado. - - Talvez estejas a experienciar problemas de net. - reason_location: | + Talvez estejas a experienciar problemas de net + reason_location: |- Incapaz de atualizar o Wheel Wizard. Garante que a aplicação se encontra numa pasta que pode ser localizada. - - Falha a encontar a pasta atual. + Falha a encontar a pasta atual title: "Não é possível atualizar o Wheel Wizard" no_connect_server: extra: "Falha ao conectar ao servidor. Tente de novo mais tarde" @@ -324,6 +356,8 @@ pt: unable_download_mod: title: "Não é possível descarregar o mod" extra: "Os ficheiros não foram encontrados." + could_not_convert_mod: + title: "Não foi possível converter o mod." placeholder: enter_mod_name: "Introduzir nome do mod..." search_mod: "Pesquisa por mods..." @@ -334,13 +368,14 @@ pt: message_error: no_mod_folder: extra: "A pasta do mod não existe" + data_folder_move: + title: "Não foi possível mover os dados" abort_rr: extra: failed_update_delete: "Falha a apagar ficheiros para a atualização. Abortando." failed_update_apply: "Falha a aplicar a atualização. Abortando." - invalid_file_path: | + invalid_file_path: |- Caminho de arquivo inválido detectado. Entra em contato com os desenvolvedores. - Erro do servidor: {$1} title: "A abortar o Update do RR." restart_admin_fail: @@ -360,17 +395,30 @@ pt: title: "Falha ao obter informações do mod" mod_download_fail: title: "O download do mod falhou." - extra: "Ocorreu um erro durante o download: {$1} " + extra: "Ocorreu um erro durante o download: {$1}" message_success: settings_saved: title: "Definições guardadas com sucesso" + data_folder_moved: + title: "Pasta de dados atualizada." + extra: |- + Dados do Wheel Wizard são agora guardados em: + {$1} rr_up_to_date: title: "A tua versão do Retro Rewind é a mais recente" mii_changed: "Mii mudado com sucesso" + mod_converted_to_patches: + title: "Convertido para patches" + mod_installed: + extra: "Mod '{$1}' instalado com sucesso." + title: "Mod instalado com sucesso!" + patch_conversion_result: "{$1} Ficheiro(s) convertidos para {$2} ficheiro(s) de patch." + patch_conversion_skipped: "{$1} Ficheiro(s) inalterados pois não era possível serem convertidos." + patch_conversion_notes: "Nota(s): {$1}" snackbar_warning: cant_save_mii: "Não é possível guardar o Mii" no_mii_export: "Parece que não havia Miis para exportar" - no_mii_delete: "Parece que não havia Miis para apagar " + no_mii_delete: "Parece que não havia Miis para apagar" snackbar_success: mii_added: "Mii foi adicionado aos teus Miis" copied_fc: "Código de amigo copiado para a área de transferência" @@ -378,7 +426,7 @@ pt: saved_mii: "Mii '{$1}' salvado para o ficheiro '{$2}'" deleted: "Apagado '{$1}'" deleted_miis: "{$1} Miis apagados" - created_duplicate: "Duplica de '{$1}' criada " + created_duplicate: "Duplica de '{$1}' criada" created_duplicates_miis: "Criados '{$1}' Miis duplicados" profile_set_primary: "Definir perfil como principal" snackbar_error: @@ -406,12 +454,13 @@ pt: mario_kart_game: "Ficheiro de Jogo do Mario Kart Wii" dolphin_emulator_exe: "Executável do Dolphin Emulator" make_primary: "Tornar Primário" - remove_blur: "Remover Blur" enable_animations: "Ativar Animações" reinstall: "Reinstalar RR" open_game_folder: "Abrir a Pasta do Jogo" open_save_folder: "Abrir a Pasta de Ficheiros de Gravação" section: + graphics: "Gráficos" + general: "Geral" language: "Linguagem" wii: "Definições da Wii" resolution: "Resolução" @@ -419,7 +468,7 @@ pt: renderer: "Definições de Renderização" location_paths: "Definições da Localização" wh_wz_appearance: "Aparência do Wheel Wizard" - installation: "Instalação " + installation: "Instalação" helper_text: topbar: location_warning: "As definições estão desativadas, completa as definições de localização primeiro" @@ -431,14 +480,16 @@ pt: end_with_exe: "O caminho deve acabar com .exe" enable_animations: "Isto ativa as animações na app Wheel Wizard" end_with_x: "O caminho pode acabar com:" - wh_wz_language: "Isto só muda o idioma do Wheel Wizard. Para mudar o idioma dentro do jogo, precisas de fazer isso nas configurações do próprio jogo." optional: "Opcional" must_set_paths: "Precisas de configurar estes 3 caminhos antes de começar a jogar Retro Rewind." category: - video: "Video " - about: "Acerca de " + about: "Acerca de" other: "Outro" general: "Geral" + video: "Video" + status: + data_folder: + moving: "A mover dados, por favor aguarde..." value: language: france: "Francês" @@ -457,4 +508,4 @@ pt: norwegian: "Norueguês" chinese: "Chinês" portuguese: "Português" - z_translators: "JMM, Gui_C__Tuga" + z_translators: "JMM, Gui_C__Tuga, bubble" diff --git a/WheelWizard/Resources/Languages/ru.yml b/WheelWizard/Resources/Languages/ru.yml index 20a8faffa..162c2b3b2 100644 --- a/WheelWizard/Resources/Languages/ru.yml +++ b/WheelWizard/Resources/Languages/ru.yml @@ -167,7 +167,7 @@ ru: mii_editor: "Редактор Mii" mii_carousel: "Карусель Mii" text: - made_by_string: | + made_by_string: |- Создатели: {$1} и {$2} thanks_translators: "Низкий поклон и спасибо переводчикам:" @@ -217,7 +217,7 @@ ru: estimated_time_remaining: "Осталось примерно:" downloading_mb: "Загружено {$1} МБ" installing_mods: "Устанавливаем моды" - installing_mods_count: "Устанавливаем {$!} модов" + installing_mods_count: "Устанавливаем {$1} модов" processing_xof_y: "Обработка {$1} из {$2} файлов..." update_rr: "Обновляем Retro Rewind" latest_wh_wz_github: "Получаем последнюю версию Wheel Wizard из GitHub" @@ -237,9 +237,8 @@ ru: title: "Хотите применить новый масштаб?" dolphin_found: title: "Папка эмулятора Dolphin найдена" - extra: | + extra: |- Если ты понятия не имеешь что это значит, просто нажми да :D - Папка эмулятора Dolphin найдена. Хотите её использовать? new_version_wh_wz: title: "Доступно обновление Wheel Wizard" @@ -283,13 +282,11 @@ ru: title: "Не удалось проверить обновления" unable_update_wh_wz: extra: - reason_network: | + reason_network: |- Не удалось проверить, обновлён ли Wheel Wizard до последней версии? - Вы возможно испытываете проблемы с подключеним. - reason_location: | + reason_location: |- Не удалось обновить Wheel Wizard. Пожалуйста убедитесь, что исполняемый файл находится в папке куда можно добавлять файлы. - Не удалось найти текущую папку. title: "Не удалось обновить Wheel Wizard." no_connect_server: @@ -365,12 +362,13 @@ ru: mario_kart_game: "Файл Mario Kart Wii" dolphin_emulator_exe: "Исполняемый файл (.exe) эмулятора Dolphin" make_primary: "Сделать основным" - remove_blur: "Отключить блюр" enable_animations: "Включить анимации" reinstall: "Переустановить Retro Rewind" open_game_folder: "Открыть папку с игрой" open_save_folder: "Открыть папку сохранения" section: + graphics: "Графика" + general: "Общие" language: "Язык" wii: "Настройки Wii" resolution: "Разрешение" @@ -391,10 +389,10 @@ ru: enable_animations: "Эта настройка включает анимации в лаунчере Wheel Wizard." end_with_x: "Путь может заканчиваться на:" category: - video: "Видео" about: "О нас" other: "Другое" general: "Основное" + video: "Видео" value: language: france: "Французский" diff --git a/WheelWizard/Resources/Languages/tr.yml b/WheelWizard/Resources/Languages/tr.yml index 6d13e2f6a..c20ed329b 100644 --- a/WheelWizard/Resources/Languages/tr.yml +++ b/WheelWizard/Resources/Languages/tr.yml @@ -167,9 +167,9 @@ tr: mii_editor: "Mii Düzenleyicisi" mii_carousel: "Mii Görüntüleyicisi" text: - made_by_string: | + made_by_string: |- Yapımcı: {$1} - ve {$2} + Ve {$2} thanks_translators: "Tüm çevirmenlere çok teşekkür ederiz:" wh_wz_translation_percentage: "Bu dilin çevirileri {$1}% tamamlandı" powered_gamebanana: "GameBanana tarafından" @@ -244,9 +244,8 @@ tr: extra: "Bu değişiklik onaylamadığınız takdirde {$1} içinde eski haline dönecek." dolphin_found: title: "Dolphin Emulator Klasörü Bulundu" - extra: | + extra: |- Eğer bunların ne anlama geldiğini bilmiyorsanız, evet diyebilirsiniz :) - Dolphin Emulator klasörü bulundu. Bu klasörü kullanmak ister misiniz? new_version_wh_wz: title: "Wheel Wizard Güncellemesi Mevcut" @@ -297,13 +296,11 @@ tr: extra: "Şu an güncellemeler kontrol edilemiyor. İnternete bağlı olmayabilirsiniz veya sunucular çalışmıyor olabilir." unable_update_wh_wz: extra: - reason_network: | + reason_network: |- Wheel Wizard'ın güncel olup olmadığını kontrol edilemedi. - Ağ sorunları yaşıyor olabilirsiniz. - reason_location: | + reason_location: |- Wheel Wizard güncellenemedi. Uygulamanın erişilebilir bir klasörde bulunduğundan emin olun. - İstenilen klasör bulunamadı. title: "Wheel Wizard güncellenemedi." no_connect_server: @@ -340,9 +337,8 @@ tr: extra: failed_update_delete: "Güncelleme dosyalarını silmek başarısız oldu. İşlem iptal ediliyor." failed_update_apply: "Güncellemeyi uygulamak başarısız oldu. İşlem iptal ediliyor." - invalid_file_path: | + invalid_file_path: |- Yanlış dosya yolu tespit edildi. Lütfen geliştiricilere bildirin. - Sunucu hatası: {$1} title: "Retro Rewind güncellemesi iptal ediliyor" restart_admin_fail: @@ -409,12 +405,13 @@ tr: mario_kart_game: "Mario Kart Wii Oyun Dosyası" dolphin_emulator_exe: "Dolphin Emulator yürütülebilir dosyası" make_primary: "Birincil Yap" - remove_blur: "Bulanıklığı Kaldır" enable_animations: "Animasyonları Etkinleştir" reinstall: "Retro Rewind'ı Tekrar Kur" open_game_folder: "Oyun Klasörünü Aç" open_save_folder: "Kayıt Klasörünü Aç" section: + graphics: "Grafik" + general: "Genel" language: "Dil" wii: "Wii Ayarları" resolution: "Çözünürlük" @@ -434,14 +431,13 @@ tr: end_with_exe: "Yol, .exe ile bitmelidir." enable_animations: "Bu, Wheel Wizard uygulamasındaki animasyonları etkinleştirir." end_with_x: "Yol, bunlar ile bitebilir:" - wh_wz_language: "Bu sadece Wheel Wizard'ın dilini değiştirir. Oyun içi dili değiştirmek için oyundaki ayarları kullanın." optional: "Opsiyonel" must_set_paths: "Retro Rewind'ı oynayabilmek için önce bu üç yolu ayarlamanız gerekiyor" category: - video: "Video" about: "Hakkında" other: "Diğer" general: "Genel" + video: "Video" value: language: france: "Fransızca" diff --git a/WheelWizard/Services/Launcher/LauncherProvider.cs b/WheelWizard/Services/Launcher/LauncherProvider.cs index 3c264b793..0854c417c 100644 --- a/WheelWizard/Services/Launcher/LauncherProvider.cs +++ b/WheelWizard/Services/Launcher/LauncherProvider.cs @@ -4,7 +4,7 @@ namespace WheelWizard.Services.Launcher; /// -/// Resolves the launcher the Home page should drive. The recomp is a Windows-only beta that, when +/// Resolves the launcher the Home page should drive. The recomp is a beta (Windows and Linux) that, when /// opted into, replaces the Dolphin/Retro Rewind frontend entirely; which launcher that decision /// selects lives here, so no view has to re-derive it. /// diff --git a/WheelWizard/Services/PathManager.cs b/WheelWizard/Services/PathManager.cs index f6b75ed3d..9ce179f87 100644 --- a/WheelWizard/Services/PathManager.cs +++ b/WheelWizard/Services/PathManager.cs @@ -3,6 +3,7 @@ using Serilog; using WheelWizard.Helpers; using WheelWizard.Settings; +using WheelWizard.Recomp; #if WINDOWS using Microsoft.Win32; #endif @@ -92,26 +93,51 @@ public static bool IsUsingCustomWheelWizardAppdataPath /// The backend install directory inside Wheel Wizard's portable recomp root. public static string RecompInstallFolderPath => PortableRecompInstallFolderPath; - /// Whether the recomp uses the portable layout, which is what earns the setup's --portable flag. - public static bool IsRecompInstallPortable => true; + /// + /// Whether the recomp uses the portable layout, which is what earns the setup's --portable flag. + /// The Linux AppImage has no portable mode: it always installs into the user's XDG data directory. + /// + public static bool IsRecompInstallPortable => !RecompPlatform.IsLinux; public static string RecompCacheFolderPath => Path.Combine(RecompFolderPath, "Cache"); public static string RecompInstallStateFilePath => Path.Combine(RecompInstallFolderPath, RecompInstallStateFileName); - public static string RecompSetupFilePath => Path.Combine(RecompInstallFolderPath, "WiiCompiled-Setup.exe"); + public static string RecompSetupFilePath => Path.Combine(RecompInstallFolderPath, RecompPlatform.SetupFileName); /// The backend-owned runtime user state (Config.toml, private NAND, caches) inside the portable root. public static string RecompUserDataFolderPath => Path.Combine(RecompFolderPath, "UserData"); + /// + /// On Linux the AppImage owns this directory under $XDG_DATA_HOME (the same value .NET reports as + /// local application data): its install-state.json, the built products under Install/, + /// Config.toml, logs and the multi-gigabyte build workspace. Wheel Wizard reads it and removes it + /// on uninstall, but never chooses it: a manual AppImage run installs to the very same place. + /// + public static string RecompLinuxBackendFolderPath => Path.Combine(LocalAppDataFolder, "WiiCompiled"); + + /// The AppImage's own record of what it installed and where. Not the same schema as the Windows state file. + public static string RecompLinuxBackendStateFilePath => Path.Combine(RecompLinuxBackendFolderPath, RecompInstallStateFileName); + /// The recomp's own settings file, shared between Wheel Wizard and the in-game settings bar. - public static string RecompConfigFilePath => Path.Combine(RecompUserDataFolderPath, "Config.toml"); + public static string RecompConfigFilePath => + RecompPlatform.IsLinux + ? Path.Combine(RecompLinuxBackendFolderPath, "Config.toml") + : Path.Combine(RecompUserDataFolderPath, "Config.toml"); /// The Wheel Wizard-owned copy of the Dolphin NAND, used when the user chose copying over sharing it in place. public static string RecompNandCopyFolderPath => Path.Combine(RecompFolderPath, "Nand"); /// The marker file whose presence makes a portable root. public static string RecompPortableMarkerFilePath => Path.Combine(RecompFolderPath, "portable.txt"); - public static string WiiDbFolder => Path.Combine(WiiFolderPath, "shared2", "menu", "FaceLib"); - public static string MiiDbFile => Path.Combine(WiiDbFolder, "RFL_DB.dat"); + + /// The recomp runtime's private NAND, used when no Dolphin NAND is linked. + public static string RecompPrivateNandFolderPath => Path.Combine(RecompUserDataFolderPath, "NAND"); + + public static string GetWiiDbFolderPath(string nandFolderPath) => Path.Combine(nandFolderPath, "shared2", "menu", "FaceLib"); + + public static string GetMiiDbFilePath(string nandFolderPath) => Path.Combine(GetWiiDbFolderPath(nandFolderPath), "RFL_DB.dat"); + + public static string WiiDbFolder => GetWiiDbFolderPath(WiiFolderPath); + public static string MiiDbFile => GetMiiDbFilePath(WiiFolderPath); public static string RRratingFilePath => Path.Combine(WiiFolderPath, "shared2", "Pulsar", "RetroRewind6", "RRRating.pul"); /// The file the recomp setup writes to mark a directory as one of its installations. diff --git a/WheelWizard/Shared/DevelopmentMode.cs b/WheelWizard/Shared/DevelopmentMode.cs new file mode 100644 index 000000000..c850863e9 --- /dev/null +++ b/WheelWizard/Shared/DevelopmentMode.cs @@ -0,0 +1,14 @@ +namespace WheelWizard.Shared; + +public static class DevelopmentMode +{ +#if DEBUG + public static bool IsEnabled { get; private set; } = true; + + public static void Hide() => IsEnabled = false; +#else + public static bool IsEnabled => false; + + public static void Hide() { } +#endif +} diff --git a/WheelWizard/Shared/MessageTranslations/MessageTranslationHelper.cs b/WheelWizard/Shared/MessageTranslations/MessageTranslationHelper.cs index ffd724e8a..b604a87fc 100644 --- a/WheelWizard/Shared/MessageTranslations/MessageTranslationHelper.cs +++ b/WheelWizard/Shared/MessageTranslations/MessageTranslationHelper.cs @@ -43,6 +43,7 @@ public enum MessageTranslation Warning_ModNameInvalid = 1010, Warning_UnableToDownloadMod_Files = 1011, Warning_DolphinToolSelected = 1012, + Warning_RecompOperationCancelled = 2013, #endregion @@ -146,6 +147,10 @@ public static (string, string?) GetTranslationText(MessageTranslation msg) t("message_warning.unable_download_mod.title"), t("message_warning.unable_download_mod.extra") ), + MessageTranslation.Warning_RecompOperationCancelled => ( + t("message_warning.recomp_operation_cancelled.title"), + t("message_warning.recomp_operation_cancelled.extra") + ), #endregion diff --git a/WheelWizard/Views/Components/Button.axaml b/WheelWizard/Views/Components/Button.axaml index 56f5222ed..9e911d22d 100644 --- a/WheelWizard/Views/Components/Button.axaml +++ b/WheelWizard/Views/Components/Button.axaml @@ -9,6 +9,7 @@ + + @@ -56,6 +58,13 @@ + + - - - - - - - - - 5x (3200x2640) - - - 2x (1280x1056) - - - 6x (3840x3168) - - - 3x (1920x1584) - - - 7x (4480x3696) - - - 4x (2560x2112) - - - 8x (5120x4224) - - - - + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - + + + + + + + + + - - - - - - - - + + + + + + + + + - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - \ No newline at end of file + diff --git a/WheelWizard/Views/Pages/Settings/VideoSettings.axaml.cs b/WheelWizard/Views/Pages/Settings/VideoSettings.axaml.cs index 0a7fce39f..b7808a6c6 100644 --- a/WheelWizard/Views/Pages/Settings/VideoSettings.axaml.cs +++ b/WheelWizard/Views/Pages/Settings/VideoSettings.axaml.cs @@ -9,6 +9,18 @@ namespace WheelWizard.Views.Pages.Settings; public partial class VideoSettings : UserControlBase { + private static readonly string[] ResolutionOptions = + [ + "1x (640x528)", + "2x (1280x1056)", + "3x (1920x1584)", + "4x (2560x2112)", + "5x (3200x2640)", + "6x (3840x3168)", + "7x (4480x3696)", + "8x (5120x4224)", + ]; + private readonly bool _settingsAreDisabled; [Inject] @@ -21,20 +33,18 @@ public VideoSettings() DisabledWarningText.IsVisible = _settingsAreDisabled; VideoBorder.IsEnabled = !_settingsAreDisabled; + foreach (var resolution in ResolutionOptions) + ResolutionDropdown.Items.Add(resolution); + if (!_settingsAreDisabled) LoadSettings(); ForceLoadSettings(); // Attach event handlers after loading settings to avoid unwanted triggers - foreach (RadioButton rb in ResolutionStackPanel.Children) - { - rb.Checked += UpdateResolution; - } - + ResolutionDropdown.SelectionChanged += ResolutionDropdown_OnSelectionChanged; VSyncButton.IsCheckedChanged += VSync_OnClick; RecommendedButton.IsCheckedChanged += Recommended_OnClick; ShowFPSButton.IsCheckedChanged += ShowFPS_OnClick; - RemoveBlurButton.IsCheckedChanged += RemoveBlur_OnClick; RendererDropdown.SelectionChanged += RendererDropdown_OnSelectionChanged; DisableForce.IsCheckedChanged += ClickForceWiimote; LaunchWithDolphin.IsCheckedChanged += ClickLaunchWithDolphinWindow; @@ -56,16 +66,11 @@ private void LoadSettings() VSyncButton.IsChecked = SettingsService.Get(SettingsService.VSYNC); RecommendedButton.IsChecked = SettingsService.Get(SettingsService.RECOMMENDED_SETTINGS); ShowFPSButton.IsChecked = SettingsService.Get(SettingsService.SHOW_FPS); - RemoveBlurButton.IsChecked = SettingsService.Get(SettingsService.REMOVE_BLUR); DisableForce.IsChecked = SettingsService.Get(SettingsService.FORCE_WIIMOTE); LaunchWithDolphin.IsChecked = SettingsService.Get(SettingsService.LAUNCH_WITH_DOLPHIN); - var finalResolution = SettingsService.Get(SettingsService.INTERNAL_RESOLUTION); - foreach (RadioButton radioButton in ResolutionStackPanel.Children) - { - var tag = radioButton.Tag?.ToString(); - radioButton.IsChecked = string.Equals(tag, finalResolution.ToString(), StringComparison.Ordinal); - } + var resolution = SettingsService.Get(SettingsService.INTERNAL_RESOLUTION); + ResolutionDropdown.SelectedIndex = resolution is >= 1 and <= 8 ? resolution - 1 : -1; } private void ForceLoadSettings() @@ -84,13 +89,10 @@ private void ForceLoadSettings() } } - private void UpdateResolution(object? sender, RoutedEventArgs e) + private void ResolutionDropdown_OnSelectionChanged(object? sender, SelectionChangedEventArgs e) { - if (sender is RadioButton radioButton && radioButton.IsChecked == true) - { - if (int.TryParse(radioButton.Tag?.ToString(), out var resolution)) - SettingsService.Set(SettingsService.INTERNAL_RESOLUTION, resolution); - } + if (ResolutionDropdown.SelectedIndex >= 0) + SettingsService.Set(SettingsService.INTERNAL_RESOLUTION, ResolutionDropdown.SelectedIndex + 1); } private void VSync_OnClick(object? sender, RoutedEventArgs e) @@ -108,11 +110,6 @@ private void ShowFPS_OnClick(object? sender, RoutedEventArgs e) SettingsService.Set(SettingsService.SHOW_FPS, ShowFPSButton.IsChecked == true); } - private void RemoveBlur_OnClick(object? sender, RoutedEventArgs e) - { - SettingsService.Set(SettingsService.REMOVE_BLUR, RemoveBlurButton.IsChecked == true); - } - private void RendererDropdown_OnSelectionChanged(object? sender, SelectionChangedEventArgs e) { var selectedDisplayName = RendererDropdown.SelectedItem?.ToString(); diff --git a/WheelWizard/Views/Pages/Settings/WhWzSettings.axaml b/WheelWizard/Views/Pages/Settings/WhWzSettings.axaml index 7002753dd..618f13c08 100644 --- a/WheelWizard/Views/Pages/Settings/WhWzSettings.axaml +++ b/WheelWizard/Views/Pages/Settings/WhWzSettings.axaml @@ -8,210 +8,287 @@ x:Class="WheelWizard.Views.Pages.Settings.WhWzSettings"> - - + - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - + - + - - - + - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/WheelWizard/Views/Pages/Settings/WhWzSettings.axaml.cs b/WheelWizard/Views/Pages/Settings/WhWzSettings.axaml.cs index 1712481d9..ad19baa3e 100644 --- a/WheelWizard/Views/Pages/Settings/WhWzSettings.axaml.cs +++ b/WheelWizard/Views/Pages/Settings/WhWzSettings.axaml.cs @@ -1,11 +1,9 @@ using System.Runtime.InteropServices; using Avalonia.Controls; using Avalonia.Interactivity; -using Avalonia.Media; using Avalonia.Platform.Storage; using Avalonia.Threading; using Serilog; -using WheelWizard.DolphinInstaller; using WheelWizard.Helpers; using WheelWizard.Services; using WheelWizard.Settings; @@ -13,7 +11,7 @@ using WheelWizard.Shared.DependencyInjection; using WheelWizard.Shared.MessageTranslations; using WheelWizard.Views.Popups.Generic; -using Button = WheelWizard.Views.Components.Button; +using SettingsButton = WheelWizard.Views.Components.Button; namespace WheelWizard.Views.Pages.Settings; @@ -38,15 +36,11 @@ private sealed record LanguageDropdownItem(string Key, string DisplayName) [Inject] private IDolphinSettingManager DolphinSettingsService { get; set; } = null!; - [Inject] - private ILinuxDolphinInstaller LinuxDolphinInstallerService { get; set; } = null!; - public WhWzSettings() { InitializeComponent(); ConfigureLocationFieldsForActiveFrontend(); - AutoFillPaths(); - TogglePathSettings(false); + UpdateLocationRows(); LoadSettings(); UpdateAppDataLocationUi(); _pageLoaded = true; @@ -58,6 +52,7 @@ private void ConfigureLocationFieldsForActiveFrontend() { var recompEnabled = SettingsService.IsRecompModeActive(); DolphinExecutableField.IsVisible = !recompEnabled; + GameLocationBorder.CornerRadius = recompEnabled ? new Avalonia.CornerRadius(12, 12, 5, 5) : new Avalonia.CornerRadius(5); DolphinUserFolderLabel.Text = recompEnabled ? $"{t("option.dolphin_user_path")} ({t("helper_text.optional")})" : t("option.dolphin_user_path"); @@ -115,7 +110,7 @@ private void RefreshLanguageDropdown() private void RefreshLocalizedCodeText() { - MKGameFieldLabel.TipText = t("helper_text.end_with_x") + " .iso/.gcm/.gcz/.ciso/.wbfs/.wia/.rvz"; + MarioKartHelperText.Text = t("helper_text.end_with_x") + " .iso/.gcm/.gcz/.ciso/.wbfs/.wia/.rvz"; TranslationsPercentageText.Text = t("text.language_translated_by", t("value.language.z_translators")); TranslationsPercentageText.IsVisible = t("value.language.z_translators") != "-"; } @@ -129,26 +124,6 @@ private static string ScaleToString(double scale) return t("state.custom") + ": " + percentageString; } - private void AutoFillPaths() - { - // Recomp first-install owns Dolphin-data discovery so it can ask before sharing saves. - // Dolphin mode keeps the existing convenience auto-fill behavior. - if (SettingsService.IsRecompModeActive()) - return; - - if (DolphinExeInput.Text != "") - return; - - var folderPath = PathManager.TryFindUserFolderPath(); - if (!string.IsNullOrEmpty(folderPath)) - DolphinUserPathInput.Text = folderPath; - } - - private void AssignWrappedDolphinExeInput(string inputText) - { - DolphinExeInput.Text = EnvHelper.SingleQuotePath(inputText); - } - private async void DolphinExeBrowse_OnClick(object sender, RoutedEventArgs e) { var executableFileType = new FilePickerFileType("Executable files") @@ -164,61 +139,22 @@ private async void DolphinExeBrowse_OnClick(object sender, RoutedEventArgs e) if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) { - // Made this more extensible for future support for e.g. snap - var dolphinPaths = new (Func IsInstalled, string Path)[] - { - (IsFlatpakDolphinInstalled, "flatpak run org.DolphinEmu.dolphin-emu"), - (IsNativeDolphinInstalled, "dolphin-emu"), - }; + const string selectFile = "Select executable file"; + const string enterCommand = "Enter launch command"; + var choice = await new OptionsWindow() + .SetWindowTitle("Change Dolphin executable") + .AddOption("FileImport", selectFile, () => { }) + .AddOption("Code", enterCommand, () => { }) + .AwaitAnswer(); - foreach (var (isInstalled, path) in dolphinPaths) + if (choice == enterCommand) { - if (isInstalled()) - { - var result = await new YesNoWindow() - .SetMainText(t("question.dolphin_program_found.title")) - .SetExtraText($"{t("question.dolphin_program_found.extra")}\n{path}") - .AwaitAnswer(); - - if (result) - { - DolphinExeInput.Text = path; - return; - } - } + await EnterLinuxDolphinCommandAsync(); + return; } - if (!IsFlatpakDolphinInstalled()) - { - var wantsAutomaticInstall = await new YesNoWindow() - .SetMainText(t("question.dolphin_flatpak.title")) - .SetExtraText(t("question.dolphin_flatpak.extra")) - .SetButtonText(t("action.install"), t("action.do_manually")) - .AwaitAnswer(); - if (wantsAutomaticInstall) - { - var progressWindow = new ProgressWindow() - .SetGoal(t("progress.installing_dolphin")) - .SetExtraText(t("progress.this_may_take_a_while")); - TogglePathSettings(true); - progressWindow.Show(); - var progress = new Progress(progressWindow.UpdateProgress); - var installResult = await LinuxDolphinInstallerService.InstallFlatpakDolphin(progress); - progressWindow.Close(); - if (installResult.IsFailure) - { - await new MessageBoxWindow() - .SetMessageType(MessageBoxWindow.MessageType.Error) - .SetTitleText("Failed to install Dolphin") - .SetInfoText(installResult.Error.Message) - .ShowDialog(); - return; - } - - DolphinExeInput.Text = "flatpak run org.DolphinEmu.dolphin-emu"; - return; - } - } + if (choice != selectFile) + return; } if (EnvHelper.IsFlatpakSandboxed()) @@ -239,7 +175,7 @@ private async void DolphinExeBrowse_OnClick(object sender, RoutedEventArgs e) if (result) { - AssignWrappedDolphinExeInput(dolphinAppPath); + await ApplyLocationSettingAsync(SettingsService.DOLPHIN_LOCATION, EnvHelper.SingleQuotePath(dolphinAppPath)); return; } } @@ -257,7 +193,7 @@ private async void DolphinExeBrowse_OnClick(object sender, RoutedEventArgs e) return; var executablePath = Path.Combine(resolvedFolder, "Contents", "MacOS", "Dolphin"); - AssignWrappedDolphinExeInput(executablePath); + await ApplyLocationSettingAsync(SettingsService.DOLPHIN_LOCATION, EnvHelper.SingleQuotePath(executablePath)); } return; // do not do normal selection for MacOS @@ -275,23 +211,29 @@ private async void DolphinExeBrowse_OnClick(object sender, RoutedEventArgs e) } // On Windows, the file path is directly used as the executable, not in some command - DolphinExeInput.Text = filePath; + await ApplyLocationSettingAsync(SettingsService.DOLPHIN_LOCATION, filePath); } else { - AssignWrappedDolphinExeInput(filePath); + await ApplyLocationSettingAsync(SettingsService.DOLPHIN_LOCATION, EnvHelper.SingleQuotePath(filePath)); } } } - private bool IsFlatpakDolphinInstalled() + private async Task EnterLinuxDolphinCommandAsync() { - return LinuxDolphinInstallerService.IsDolphinInstalledInFlatpak(); - } + var currentValue = PathManager.DolphinFilePath; + var command = await new TextInputWindow() + .SetMainText("Enter Dolphin launch command") + .SetExtraText("Enter the terminal command Wheel Wizard should use to launch Dolphin.") + .SetPlaceholderText("Example: flatpak run org.DolphinEmu.dolphin-emu") + .SetInitialText(IsConfiguredExecutableFile(currentValue) ? string.Empty : currentValue) + .SetButtonText(t("action.cancel"), t("action.save")) + .SetValidation((_, value) => string.IsNullOrWhiteSpace(value) ? Fail("Enter a launch command.") : Ok()) + .ShowDialog(); - private bool IsNativeDolphinInstalled() - { - return LinuxDolphinInstallerService.IsDolphinInstalledNative(); + if (command != null) + await ApplyLocationSettingAsync(SettingsService.DOLPHIN_LOCATION, command.Trim()); } private async void GameLocationBrowse_OnClick(object sender, RoutedEventArgs e) @@ -304,15 +246,13 @@ private async void GameLocationBrowse_OnClick(object sender, RoutedEventArgs e) var filePath = await FilePickerHelper.OpenSingleFileAsync("Select Mario Kart Wii Game File", [fileType]); if (!string.IsNullOrEmpty(filePath)) { - MarioKartInput.Text = filePath; + await ApplyLocationSettingAsync(SettingsService.GAME_LOCATION, filePath); } } private async void DolphinUserPathBrowse_OnClick(object sender, RoutedEventArgs e) { - // Detect Data folder based on the Dolphin path currently in the input field - // (which may have been edited but not yet saved) - var currentDolphinPath = DolphinExeInput.Text; + var currentDolphinPath = PathManager.DolphinFilePath; var folderPath = string.IsNullOrWhiteSpace(currentDolphinPath) ? PathManager.TryFindUserFolderPath() : PathManager.TryFindUserFolderPath(currentDolphinPath); @@ -327,7 +267,7 @@ private async void DolphinUserPathBrowse_OnClick(object sender, RoutedEventArgs if (result) { - DolphinUserPathInput.Text = folderPath; + await ApplyLocationSettingAsync(SettingsService.USER_FOLDER_PATH, folderPath); return; } } @@ -348,7 +288,7 @@ private async void DolphinUserPathBrowse_OnClick(object sender, RoutedEventArgs { var resolvedFolder = await ResolveSelectedFolderPathAsync(folders[0]); if (!string.IsNullOrWhiteSpace(resolvedFolder)) - DolphinUserPathInput.Text = resolvedFolder; + await ApplyLocationSettingAsync(SettingsService.USER_FOLDER_PATH, resolvedFolder); } return; } @@ -361,44 +301,58 @@ private async void DolphinUserPathBrowse_OnClick(object sender, RoutedEventArgs { var resolvedFolder = await ResolveSelectedFolderPathAsync(manualFolders[0]); if (!string.IsNullOrWhiteSpace(resolvedFolder)) - DolphinUserPathInput.Text = resolvedFolder; + await ApplyLocationSettingAsync(SettingsService.USER_FOLDER_PATH, resolvedFolder); } } } - private async void SaveButton_OnClick(object sender, RoutedEventArgs e) + private async Task ApplyLocationSettingAsync(Setting setting, string path) { - var oldPath1 = (string)SettingsService.DOLPHIN_LOCATION.Get(); - var oldPath2 = (string)SettingsService.GAME_LOCATION.Get(); - var oldPath3 = (string)SettingsService.USER_FOLDER_PATH.Get(); - - var dolphinPath = DolphinExeInput.Text ?? string.Empty; - var gamePath = MarioKartInput.Text ?? string.Empty; - var userPathInput = DolphinUserPathInput.Text; - var userPath = string.IsNullOrWhiteSpace(userPathInput) ? string.Empty : userPathInput.TrimEnd(Path.DirectorySeparatorChar); - - var path1 = SettingsService.DOLPHIN_LOCATION.Set(dolphinPath); - var path2 = SettingsService.GAME_LOCATION.Set(gamePath); - var path3 = SettingsService.USER_FOLDER_PATH.Set(userPath); - // These 3 lines is only saving the settings - TogglePathSettings(false); - if (!(SettingsService.PathsSetupCorrectly() && path1 && path2 && path3)) - await MessageTranslationHelper.AwaitMessageAsync(MessageTranslation.Warning_InvalidPathSettings); - else - { - await MessageTranslationHelper.AwaitMessageAsync(MessageTranslation.Success_PathSettingsSaved); + var normalizedPath = + setting == SettingsService.USER_FOLDER_PATH ? path.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) : path; + var previousPath = (string)setting.Get(); - // This is not really the best approach, but it works for now - if (oldPath1 + oldPath2 + oldPath3 != DolphinExeInput.Text + MarioKartInput.Text + DolphinUserPathInput.Text) - DolphinSettingsService.ReloadSettings(); + if (!setting.Set(normalizedPath)) + { + await MessageTranslationHelper.AwaitMessageAsync(MessageTranslation.Warning_InvalidPathSettings); + UpdateLocationRows(); + return false; } + + UpdateLocationRows(); + if (!string.Equals(previousPath, normalizedPath, StringComparison.Ordinal) && SettingsService.PathsSetupCorrectly()) + DolphinSettingsService.ReloadSettings(); + + await MessageTranslationHelper.AwaitMessageAsync(MessageTranslation.Success_PathSettingsSaved); + return true; } - private void CancelButton_OnClick(object sender, RoutedEventArgs e) => TogglePathSettings(false); + private void DolphinExecutableOpen_OnClick(object sender, RoutedEventArgs e) => OpenContainingFolder(PathManager.DolphinFilePath); + + private void GameLocationOpen_OnClick(object sender, RoutedEventArgs e) => OpenContainingFolder(PathManager.GameFilePath); - private void EditButton_OnClick(object sender, RoutedEventArgs e) => TogglePathSettings(true); + private void DolphinUserFolderOpen_OnClick(object sender, RoutedEventArgs e) + { + if (Directory.Exists(PathManager.UserFolderPath)) + FilePickerHelper.OpenFolderInFileManager(PathManager.UserFolderPath); + } + + private static void OpenContainingFolder(string filePath) + { + try + { + var unquotedPath = filePath.Trim().Trim('\'', '"'); + var folderPath = Path.GetDirectoryName(Path.GetFullPath(unquotedPath)); + if (!string.IsNullOrWhiteSpace(folderPath) && Directory.Exists(folderPath)) + FilePickerHelper.OpenFolderInFileManager(folderPath); + } + catch + { + // Commands such as Flatpak launch strings do not have a folder to open. + } + } - private void WhWzFolder_Click(object sender, RoutedEventArgs e) + private void AppDataLocationOpen_OnClick(object sender, RoutedEventArgs e) { if (!Directory.Exists(PathManager.WheelWizardAppdataPath)) Directory.CreateDirectory(PathManager.WheelWizardAppdataPath); @@ -406,72 +360,87 @@ private void WhWzFolder_Click(object sender, RoutedEventArgs e) FilePickerHelper.OpenFolderInFileManager(PathManager.WheelWizardAppdataPath); } - private void GameFileFolder_Click(object? sender, RoutedEventArgs e) + private void UpdateLocationRows() { - if (!Directory.Exists(PathManager.RiivolutionWhWzFolderPath)) - return; //Button should be disabled - FilePickerHelper.OpenFolderInFileManager(PathManager.RiivolutionWhWzFolderPath); + SetLocationRowState( + DolphinExecutableCompleteIcon, + DolphinExecutableWarningIcon, + DolphinExecutableChangeButton, + SettingsService.DOLPHIN_LOCATION.IsValid() && !string.IsNullOrWhiteSpace(PathManager.DolphinFilePath) + ); + SetLocationRowState( + GameLocationCompleteIcon, + GameLocationWarningIcon, + GameLocationChangeButton, + SettingsService.GAME_LOCATION.IsValid() && !string.IsNullOrWhiteSpace(PathManager.GameFilePath) + ); + SetLocationRowState( + DolphinUserFolderCompleteIcon, + DolphinUserFolderWarningIcon, + DolphinUserFolderChangeButton, + SettingsService.USER_FOLDER_PATH.IsValid() + ); + + LocationWarningIcon.IsVisible = !SettingsService.PathsSetupCorrectly(); + DolphinExecutableValueText.Text = string.IsNullOrWhiteSpace(PathManager.DolphinFilePath) + ? t("helper_text.end_with_exe") + : PathManager.DolphinFilePath; + DolphinExecutableOpenButton.IsVisible = !OperatingSystem.IsLinux() || IsConfiguredExecutableFile(PathManager.DolphinFilePath); + DolphinExecutableOpenButton.IsEnabled = + DolphinExecutableOpenButton.IsVisible && CanOpenContainingFolder(PathManager.DolphinFilePath); + GameLocationOpenButton.IsEnabled = CanOpenContainingFolder(PathManager.GameFilePath); + DolphinUserFolderOpenButton.IsEnabled = Directory.Exists(PathManager.UserFolderPath); } - private void TogglePathSettings(bool enable) + private static void SetLocationRowState(PathIcon completeIcon, PathIcon warningIcon, SettingsButton changeButton, bool isValid) { - if (enable) - { - LocationBorder.BorderBrush = new SolidColorBrush(ViewUtils.Colors.Neutral900); - } - else if (!SettingsService.PathsSetupCorrectly()) + completeIcon.IsVisible = isValid; + warningIcon.IsVisible = !isValid; + changeButton.Variant = isValid ? SettingsButton.ButtonsVariantType.Default : SettingsButton.ButtonsVariantType.Warning; + } + + private static bool CanOpenContainingFolder(string filePath) + { + try { - LocationBorder.BorderBrush = new SolidColorBrush(ViewUtils.Colors.Warning400); - LocationEditButton.Variant = Button.ButtonsVariantType.Warning; - LocationWarningIcon.IsVisible = true; - LocationBorderBlur.Background = new SolidColorBrush(ViewUtils.Colors.Warning600); + var unquotedPath = filePath.Trim().Trim('\'', '"'); + var folderPath = Path.GetDirectoryName(Path.GetFullPath(unquotedPath)); + return !string.IsNullOrWhiteSpace(folderPath) && Directory.Exists(folderPath); } - else + catch { - LocationBorder.BorderBrush = new SolidColorBrush(ViewUtils.Colors.Primary400); - LocationEditButton.Variant = Button.ButtonsVariantType.Primary; - LocationWarningIcon.IsVisible = false; - LocationBorderBlur.Background = new SolidColorBrush(ViewUtils.Colors.Primary600); + return false; } - - LocationBorderBlur.IsVisible = !enable; - LocationInputFields.IsEnabled = enable; - LocationEditButton.IsVisible = !enable; - LocationSaveButton.IsVisible = enable; - LocationCancelButton.IsVisible = enable; - - DolphinExeInput.Text = PathManager.DolphinFilePath; - MarioKartInput.Text = PathManager.GameFilePath; - DolphinUserPathInput.Text = PathManager.UserFolderPath; - OpenGameFolderButton.IsEnabled = Directory.Exists(PathManager.RiivolutionWhWzFolderPath); - UpdateAppDataLocationUi(); } + private static bool IsConfiguredExecutableFile(string value) => File.Exists(value.Trim().Trim('\'', '"')); + private void UpdateAppDataLocationUi() { - var currentPath = PathManager.WheelWizardAppdataPath; - AppDataLocationInput.Text = currentPath; - AppDataLocationInput.CaretIndex = currentPath.Length; - ToolTip.SetTip(AppDataLocationInput, currentPath); - var statusText = _isMovingAppData ? t("status.data_folder.moving") : PathManager.IsUsingCustomWheelWizardAppdataPath ? t("status.data_folder.custom") - : t("status.data_folder.default"); + : string.Empty; AppDataLocationStatus.Text = statusText; - AppDataLocationBrowseButton.IsEnabled = !_isMovingAppData; + AppDataLocationStatus.IsVisible = !string.IsNullOrEmpty(statusText); + AppDataLocationChangeButton.IsEnabled = !_isMovingAppData; AppDataLocationResetButton.IsEnabled = !_isMovingAppData && PathManager.IsUsingCustomWheelWizardAppdataPath; + AppDataLocationOpenButton.IsEnabled = !_isMovingAppData; ToolTip.SetTip(AppDataLocationResetButton, PathManager.DefaultWheelWizardAppdataFolderPath); } private void SetAppDataLocationBusyState(bool isBusy) { _isMovingAppData = isBusy; - AppDataLocationBrowseButton.IsEnabled = !isBusy; + AppDataLocationChangeButton.IsEnabled = !isBusy; AppDataLocationResetButton.IsEnabled = !isBusy && PathManager.IsUsingCustomWheelWizardAppdataPath; + AppDataLocationOpenButton.IsEnabled = !isBusy; if (isBusy) + { AppDataLocationStatus.Text = t("status.data_folder.moving"); + AppDataLocationStatus.IsVisible = true; + } } private async Task ConfirmAndMoveAppDataAsync(string targetPath) @@ -682,7 +651,7 @@ private async Task HandleFailedAppdataMoveAsync(DirectoryMoveContentsResult move } } - private async void AppDataLocationBrowse_OnClick(object sender, RoutedEventArgs e) + private async void AppDataLocationChange_OnClick(object sender, RoutedEventArgs e) { if (_isMovingAppData) return; @@ -698,12 +667,9 @@ private async void AppDataLocationBrowse_OnClick(object sender, RoutedEventArgs if (folders == null || folders.Count == 0) return; - var selected = folders[0]; - var resolvedPath = await ResolveSelectedFolderPathAsync(selected); - if (string.IsNullOrWhiteSpace(resolvedPath)) - return; - - await ConfirmAndMoveAppDataAsync(resolvedPath); + var resolvedPath = await ResolveSelectedFolderPathAsync(folders[0]); + if (!string.IsNullOrWhiteSpace(resolvedPath)) + await ConfirmAndMoveAppDataAsync(resolvedPath); } private async void AppDataLocationReset_OnClick(object sender, RoutedEventArgs e) diff --git a/WheelWizard/Views/Pages/SettingsPage.axaml.cs b/WheelWizard/Views/Pages/SettingsPage.axaml.cs index bf52dd0df..36ee8f0dd 100644 --- a/WheelWizard/Views/Pages/SettingsPage.axaml.cs +++ b/WheelWizard/Views/Pages/SettingsPage.axaml.cs @@ -1,6 +1,7 @@ using Avalonia.Controls; using Avalonia.Interactivity; using WheelWizard.Settings; +using WheelWizard.Shared; using WheelWizard.Shared.DependencyInjection; using WheelWizard.Views.Pages.Settings; using WheelWizard.Views.Popups; @@ -26,9 +27,7 @@ public SettingsPage(UserControl initialSettingsPage) UpdateTabVisibility(); _settingsSignalSubscription = SettingsSignalBus.Subscribe(OnSettingChanged); -#if DEBUG - DevButton.IsVisible = true; -#endif + DevButton.IsVisible = DevelopmentMode.IsEnabled; SettingsContent.Content = initialSettingsPage; SetCheckedTopBarButton(initialSettingsPage); @@ -98,4 +97,11 @@ private void SetCheckedTopBarButton(UserControl settingsPage) } private void DevButton_OnClick(object? sender, RoutedEventArgs e) => new DevToolWindow().Show(); + + public void HideDevelopmentFeatures() + { + DevButton.IsVisible = false; + if (SettingsContent.Content is AppInfo) + SettingsContent.Content = new AppInfo(); + } } diff --git a/WheelWizard/Views/Popups/DevToolWindow.axaml b/WheelWizard/Views/Popups/DevToolWindow.axaml index 3e8367215..ebd155a8a 100644 --- a/WheelWizard/Views/Popups/DevToolWindow.axaml +++ b/WheelWizard/Views/Popups/DevToolWindow.axaml @@ -47,6 +47,9 @@ + @@ -72,4 +75,4 @@ - \ No newline at end of file + diff --git a/WheelWizard/Views/Popups/DevToolWindow.axaml.cs b/WheelWizard/Views/Popups/DevToolWindow.axaml.cs index 26e644ac1..0a3722d0d 100644 --- a/WheelWizard/Views/Popups/DevToolWindow.axaml.cs +++ b/WheelWizard/Views/Popups/DevToolWindow.axaml.cs @@ -4,6 +4,7 @@ using WheelWizard.Helpers; using WheelWizard.Services.Launcher.Helpers; using WheelWizard.Services.LiveData; +using WheelWizard.Shared; using WheelWizard.Shared.DependencyInjection; using WheelWizard.Shared.MessageTranslations; using WheelWizard.Utilities; @@ -59,6 +60,14 @@ private void HttpHelperOff_OnClick(object sender, RoutedEventArgs e) => private void ClearCache_OnClick(object sender, RoutedEventArgs e) => ((MemoryCache)Cache).Clear(); + private void HideDevelopmentFeatures_OnClick(object sender, RoutedEventArgs e) + { + DevelopmentMode.Hide(); + HttpClientHelper.FakeConnectionToInternet = true; + ViewUtils.GetLayout().HideDevelopmentFeatures(); + Close(); + } + private async void MiiChannel_OnClick(object? sender, RoutedEventArgs e) => await DolphinLaunchHelper.LaunchDolphin(" -b -n 0001000248414341"); diff --git a/WheelWizard/Views/Popups/Generic/MiiRenderingSetupPopup.axaml b/WheelWizard/Views/Popups/Generic/MiiRenderingSetupPopup.axaml index 3e6382d01..ce39fe888 100644 --- a/WheelWizard/Views/Popups/Generic/MiiRenderingSetupPopup.axaml +++ b/WheelWizard/Views/Popups/Generic/MiiRenderingSetupPopup.axaml @@ -49,7 +49,7 @@ + Text="Skip opens Wheel Wizard without offline 3D Mii rendering." /> - 0 ? $"{FormatMegabytes(progress.BytesDownloaded)} downloaded" : "Working..."; } - private void CloseButton_OnClick(object? sender, RoutedEventArgs e) - { - _downloadCancellationTokenSource?.Cancel(); - Close(); - } - private void SkipButton_OnClick(object? sender, RoutedEventArgs e) { if (_downloadCancellationTokenSource != null) @@ -115,8 +109,6 @@ private void SetBusyState(bool busy, string statusText) StatusTextBlock.Text = statusText; DownloadButton.IsEnabled = !busy; SkipButton.IsEnabled = !busy; - CloseButton.IsEnabled = true; - CloseButton.Text = busy ? "Close and Exit" : "Close"; } private void ShowError(string message) diff --git a/WheelWizard/Views/Popups/Generic/ProgressWindow.axaml b/WheelWizard/Views/Popups/Generic/ProgressWindow.axaml index c2813749f..4fc553d50 100644 --- a/WheelWizard/Views/Popups/Generic/ProgressWindow.axaml +++ b/WheelWizard/Views/Popups/Generic/ProgressWindow.axaml @@ -17,19 +17,19 @@ - + - - + + diff --git a/WheelWizard/Views/Popups/Generic/ProgressWindow.axaml.cs b/WheelWizard/Views/Popups/Generic/ProgressWindow.axaml.cs index b501a3a63..bf4349255 100644 --- a/WheelWizard/Views/Popups/Generic/ProgressWindow.axaml.cs +++ b/WheelWizard/Views/Popups/Generic/ProgressWindow.axaml.cs @@ -7,11 +7,19 @@ namespace WheelWizard.Views.Popups.Generic; public partial class ProgressWindow : PopupContent { - private Stopwatch _stopwatch = new(); + private const double EtaWarmupSeconds = 5; + private const double EtaIncreaseSmoothingSeconds = 5; + private const double EtaDecreaseSmoothingSeconds = 30; + private const double InitialSecondsPerRemainingPercent = 2; + + private readonly Stopwatch _stopwatch = new(); + private readonly object _progressLock = new(); private int _progress = 0; private double? _totalMb = null; - private DispatcherTimer _updateTimer; + private readonly DispatcherTimer _updateTimer; private CancellationTokenSource? _downloadCancellationTokenSource; + private double? _smoothedRemainingSeconds; + private double _lastEstimateUpdateSeconds; public bool WasCancellationRequested { get; private set; } @@ -29,7 +37,13 @@ public ProgressWindow(string windowTitle) protected override void BeforeOpen() { - _stopwatch.Start(); + lock (_progressLock) + { + _smoothedRemainingSeconds = null; + _lastEstimateUpdateSeconds = 0; + } + + _stopwatch.Restart(); _updateTimer.Start(); } @@ -47,20 +61,26 @@ private void UpdateTimer_Tick(object? sender, EventArgs e) private void InternalUpdate() { var elapsedSeconds = _stopwatch.Elapsed.TotalSeconds; - var remainingSeconds = (100 - _progress) / (_progress / elapsedSeconds); + int progress; + double? remainingSeconds; + lock (_progressLock) + { + remainingSeconds = EstimateRemainingSeconds(elapsedSeconds); + progress = _progress; + } - var remainingText = _progress <= 0 ? t("state.unknown") : tTime((int)remainingSeconds); + var remainingText = remainingSeconds is null ? t("state.unknown") : tTime((int)Math.Ceiling(remainingSeconds.Value)); var bottomText = $"{t("progress.estimated_time_remaining")} {remainingText}"; - if (_totalMb != null) + if (_totalMb != null && elapsedSeconds > 0) { - var downloadedMb = (_progress / 100.0) * (double)_totalMb; + var downloadedMb = (progress / 100.0) * (double)_totalMb; bottomText = $"{t("attribute.speed")}: {downloadedMb / elapsedSeconds:F2} MB/s | {bottomText}"; } LiveTextBlock.Text = bottomText; - ProgressBar.Value = _progress; + ProgressBar.Value = progress; } public ProgressWindow SetExtraText(string mainText) @@ -83,12 +103,54 @@ public ProgressWindow SetGoal(double megaBytes) return this; } + public ProgressWindow SetIndeterminate(bool isIndeterminate = true) + { + ProgressBar.IsIndeterminate = isIndeterminate; + LiveTextBlock.IsVisible = !isIndeterminate; + return this; + } + public void UpdateProgress(int progress) { - _progress = progress; + var clampedProgress = Math.Clamp(progress, 0, 100); + lock (_progressLock) + _progress = clampedProgress; // No need to call InternalUpdate directly, it's handled by the timer } + private double? EstimateRemainingSeconds(double elapsedSeconds) + { + if (_progress >= 100) + return 0; + if (elapsedSeconds < EtaWarmupSeconds || _progress <= 0) + return null; + + // Whole-operation progress remains useful when a backend reports only a few, unevenly + // spaced updates. The small uncertainty floor prevents an early jump to a few seconds when + // a setup phase begins at an already-weighted percentage. + var remainingProgress = 100 - _progress; + var averageRemainingSeconds = elapsedSeconds * remainingProgress / _progress; + var rawRemainingSeconds = Math.Max(averageRemainingSeconds, remainingProgress * InitialSecondsPerRemainingPercent); + if (!double.IsFinite(rawRemainingSeconds) || rawRemainingSeconds < 0) + return null; + + if (_smoothedRemainingSeconds is null) + { + _smoothedRemainingSeconds = rawRemainingSeconds; + } + else + { + var updateSeconds = Math.Max(0, elapsedSeconds - _lastEstimateUpdateSeconds); + var smoothingSeconds = + rawRemainingSeconds > _smoothedRemainingSeconds ? EtaIncreaseSmoothingSeconds : EtaDecreaseSmoothingSeconds; + var weight = 1 - Math.Exp(-updateSeconds / smoothingSeconds); + _smoothedRemainingSeconds += weight * (rawRemainingSeconds - _smoothedRemainingSeconds.Value); + } + + _lastEstimateUpdateSeconds = elapsedSeconds; + return _smoothedRemainingSeconds; + } + public ProgressWindow SetCancellationTokenSource(CancellationTokenSource? cancellationTokenSource) { _downloadCancellationTokenSource = cancellationTokenSource; diff --git a/WheelWizard/Views/Popups/Generic/TextInputWindow.axaml b/WheelWizard/Views/Popups/Generic/TextInputWindow.axaml index aec0743c4..32f8560d4 100644 --- a/WheelWizard/Views/Popups/Generic/TextInputWindow.axaml +++ b/WheelWizard/Views/Popups/Generic/TextInputWindow.axaml @@ -12,12 +12,13 @@ - - + + - \ No newline at end of file + diff --git a/WheelWizard/Views/Popups/Generic/YesNoWindow.axaml b/WheelWizard/Views/Popups/Generic/YesNoWindow.axaml index 77bee9e47..ba5af6b58 100644 --- a/WheelWizard/Views/Popups/Generic/YesNoWindow.axaml +++ b/WheelWizard/Views/Popups/Generic/YesNoWindow.axaml @@ -10,13 +10,13 @@ - - + + - \ No newline at end of file + diff --git a/WheelWizard/Views/Styles/Styles/DropdownStyles.axaml b/WheelWizard/Views/Styles/Styles/DropdownStyles.axaml index 4fd15f541..bffc404d6 100644 --- a/WheelWizard/Views/Styles/Styles/DropdownStyles.axaml +++ b/WheelWizard/Views/Styles/Styles/DropdownStyles.axaml @@ -16,9 +16,9 @@ - - - + + + + + + + + + + +