diff --git a/WheelWizard.Test/Features/CloudSync/CloudConflictResolverTests.cs b/WheelWizard.Test/Features/CloudSync/CloudConflictResolverTests.cs new file mode 100644 index 000000000..750cead05 --- /dev/null +++ b/WheelWizard.Test/Features/CloudSync/CloudConflictResolverTests.cs @@ -0,0 +1,84 @@ +using WheelWizard.CloudSync; +using WheelWizard.CloudSync.Conflict; + +namespace WheelWizard.Test.Features.CloudSync; + +public sealed class CloudConflictResolverTests +{ + private readonly CloudConflictResolver _resolver = new(); + + [Fact] + public async Task CompareAsync_IndependentChanges_ReturnsConflict() + { + var local = new CloudSyncSnapshot(Manifest(3, "local"), 2, "base"); + var remote = new CloudSyncSnapshot(Manifest(3, "remote"), 2, "base"); + + var result = await _resolver.CompareAsync(local, remote); + + Assert.Equal(ConflictKind.Conflict, result.Kind); + } + + [Fact] + public async Task CompareAsync_NewLinkedDevice_PullsSelectedCloudProfile() + { + var local = new CloudSyncSnapshot(Manifest(0, "new-device"), 0, string.Empty); + var remote = new CloudSyncSnapshot(Manifest(7, "cloud"), 0, string.Empty); + + var result = await _resolver.CompareAsync(local, remote); + + Assert.Equal(ConflictKind.SafePull, result.Kind); + } + + [Fact] + public async Task CompareAsync_UnchangedRemoteWithLocalChange_PushesSafely() + { + var local = new CloudSyncSnapshot(Manifest(2, "local-change"), 2, "base"); + var remote = new CloudSyncSnapshot(Manifest(2, "base"), 2, "base"); + + var result = await _resolver.CompareAsync(local, remote); + + Assert.Equal(ConflictKind.SafePush, result.Kind); + } + + [Fact] + public async Task CompareAsync_RemoteProgressWithAnUnchangedOlderLocalCopy_PullsSafely() + { + var local = new CloudSyncSnapshot(Manifest(4, "base"), 4, "base"); + var remote = new CloudSyncSnapshot(Manifest(5, "newer-cloud-progress"), 4, "base"); + + var result = await _resolver.CompareAsync(local, remote); + + Assert.Equal(ConflictKind.SafePull, result.Kind); + } + + [Fact] + public async Task CompareAsync_EqualHashes_ReturnsNoOpEvenWhenRevisionsDiffer() + { + var local = new CloudSyncSnapshot(Manifest(2, "same"), 1, "base"); + var remote = new CloudSyncSnapshot(Manifest(3, "same"), 1, "base"); + + var result = await _resolver.CompareAsync(local, remote); + + Assert.Equal(ConflictKind.NoOp, result.Kind); + } + + [Theory] + [InlineData(ConflictResolutionStrategy.Cancel)] + [InlineData(ConflictResolutionStrategy.UseLocal)] + [InlineData(ConflictResolutionStrategy.UseRemote)] + [InlineData(ConflictResolutionStrategy.PreserveBoth)] + public async Task ResolveAsync_NeverAppliesDestructiveResolution(ConflictResolutionStrategy strategy) + { + var result = await _resolver.ResolveAsync(new ConflictResult(ConflictKind.Conflict, "Both changed"), strategy); + + Assert.False(result.Applied); + } + + private static CloudProfileManifest Manifest(long revision, string hash) => + new() + { + ProfileId = Guid.NewGuid(), + Revision = revision, + ContentHash = hash, + }; +} diff --git a/WheelWizard.Test/Features/CloudSync/CloudProfileLibraryServiceTests.cs b/WheelWizard.Test/Features/CloudSync/CloudProfileLibraryServiceTests.cs new file mode 100644 index 000000000..0bb265620 --- /dev/null +++ b/WheelWizard.Test/Features/CloudSync/CloudProfileLibraryServiceTests.cs @@ -0,0 +1,248 @@ +using System.Text.Json; +using WheelWizard.CloudSync; +using WheelWizard.CloudSync.ProfileLibrary; +using WheelWizard.Settings; +using WheelWizard.Settings.Types; +using WheelWizard.Test.Features.Settings; +using WheelWizard.WiiManagement.GameLicense; +using WheelWizard.WiiManagement.GameLicense.Domain; +using WheelWizard.WiiManagement.MiiManagement.Domain.Mii; + +namespace WheelWizard.Test.Features.CloudSync; + +[Collection("SettingsFeature")] +public sealed class CloudProfileLibraryServiceTests : IDisposable +{ + [Fact] + public void GetVisible_WithoutStoredSelection_ShowsAtMostFourLocalOrVaultProfiles() + { + var (service, _) = CreateService("", ""); + var profiles = Profiles(); + + var visible = service.GetVisible(profiles); + + Assert.Equal(["local:0", "vault:one", "local:1", "vault:two"], visible.Select(profile => profile.Key)); + Assert.DoesNotContain(visible, profile => profile.Source == ProfileLibrarySource.Cloud); + } + + [Fact] + public void GetVisible_ExplicitEmptySelection_RemainsEmpty() + { + var (service, _) = CreateService("[]", ""); + + var visible = service.GetVisible(Profiles()); + + Assert.Empty(visible); + } + + [Fact] + public void GetVisible_StoredSelection_KeepsOrderAndNeverExceedsFour() + { + var (service, _) = CreateService("[\"cloud:only\",\"local:1\",\"vault:one\",\"local:0\",\"vault:two\"]", ""); + + var visible = service.GetVisible(Profiles()); + + Assert.Equal(["cloud:only", "local:1", "vault:one", "local:0"], visible.Select(profile => profile.Key)); + } + + [Fact] + public void SaveVisible_DeduplicatesAndCapsSelectionAtFour() + { + var (service, settings) = CreateService("[]", ""); + + service.SaveVisible(["local:0", "local:0", "vault:one", "cloud:only", "local:1", "vault:two"]); + + settings + .Received(1) + .Set( + settings.CLOUD_VISIBLE_PROFILE_IDS, + Arg.Is(value => + JsonSerializer + .Deserialize>(value)! + .SequenceEqual(new[] { "local:0", "vault:one", "cloud:only", "local:1" }) + ) + ); + } + + [Fact] + public void GetSyncSelected_ExcludesCloudOnlyProfiles() + { + var (service, _) = CreateService("[]", "[\"cloud:only\",\"local:1\",\"vault:one\"]"); + + var selected = service.GetSyncSelected(Profiles()); + + Assert.Equal(["local:1", "vault:one"], selected.Select(profile => profile.Key)); + } + + [Fact] + public async Task GetAllAsync_EmptyGameSlotsAreNeverShownInPickers() + { + var runtimeSettings = SettingsTestUtils.InitializeSettingsRuntime(Path.GetTempPath()); + runtimeSettings.LOAD_PATH.Returns(new TestSetting("load")); + var settings = Substitute.For(); + var enabledSetting = new TestSetting("enabled", typeof(bool)); + settings.CLOUD_SYNC_ENABLED.Returns(enabledSetting); + settings.Get(enabledSetting).Returns(false); + var licenses = Substitute.For(); + licenses.LicenseCollection.Returns(new LicenseCollection { Users = Enumerable.Range(0, 4).Select(_ => EmptyLicense()).ToList() }); + var service = new CloudProfileLibraryService( + licenses, + Substitute.For(), + settings, + Substitute.For(), + Substitute.For() + ); + + var profiles = await service.GetAllAsync(); + + Assert.Empty(profiles); + } + + [Fact] + public async Task GetAllAsync_MatchedCloudProfile_BindsThePhysicalSlotToItsRemoteId() + { + var runtimeSettings = SettingsTestUtils.InitializeSettingsRuntime(Path.GetTempPath()); + runtimeSettings.LOAD_PATH.Returns(new TestSetting("load")); + var settings = Substitute.For(); + var enabledSetting = new TestSetting("enabled", typeof(bool)); + settings.CLOUD_SYNC_ENABLED.Returns(enabledSetting); + settings.Get(enabledSetting).Returns(true); + var licenses = Substitute.For(); + licenses.LicenseCollection.Returns(new LicenseCollection { Users = [ActiveLicense()] }); + var cloudSync = Substitute.For(); + var remoteId = Guid.NewGuid(); + cloudSync + .GetAvailableProfilesAsync() + .Returns( + [ + new CloudProfileManifest + { + ProfileId = remoteId, + ProfileName = "Alex", + LicensePreviews = [new CloudLicensePreview(0, "Alex", "1234-5678-9012", 5000, 5000, true)], + }, + ] + ); + var bindings = Substitute.For(); + var service = new CloudProfileLibraryService( + licenses, + cloudSync, + settings, + Substitute.For(), + bindings + ); + + var profiles = await service.GetAllAsync(); + + Assert.Single(profiles); + Assert.Equal(ProfileStorageState.CloudAndLocal, profiles[0].StorageState); + await bindings.Received(1).BindAsync(0, Arg.Any(), remoteId); + } + + [Fact] + public async Task GetAllAsync_NameOnlyCloudMatch_IsRenderedWithoutBindingThePhysicalSlot() + { + var runtimeSettings = SettingsTestUtils.InitializeSettingsRuntime(Path.GetTempPath()); + runtimeSettings.LOAD_PATH.Returns(new TestSetting("load")); + var settings = Substitute.For(); + var enabledSetting = new TestSetting("enabled", typeof(bool)); + settings.CLOUD_SYNC_ENABLED.Returns(enabledSetting); + settings.Get(enabledSetting).Returns(true); + var licenses = Substitute.For(); + licenses.LicenseCollection.Returns(new LicenseCollection { Users = [ActiveLicense()] }); + var cloudSync = Substitute.For(); + cloudSync.GetAvailableProfilesAsync().Returns([new CloudProfileManifest { ProfileId = Guid.NewGuid(), ProfileName = "Alex" }]); + var bindings = Substitute.For(); + var service = new CloudProfileLibraryService( + licenses, + cloudSync, + settings, + Substitute.For(), + bindings + ); + + var profiles = await service.GetAllAsync(); + + Assert.Single(profiles); + Assert.Equal(ProfileStorageState.CloudAndLocal, profiles[0].StorageState); + await bindings.DidNotReceive().BindAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + + public void Dispose() => SettingsTestUtils.ResetSettingsRuntime(); + + private static (CloudProfileLibraryService Service, ISettingsManager Settings) CreateService(string visible, string sync) + { + var settings = Substitute.For(); + var visibleSetting = new TestSetting("visible"); + var syncSetting = new TestSetting("sync"); + settings.CLOUD_VISIBLE_PROFILE_IDS.Returns(visibleSetting); + settings.CLOUD_SYNC_PROFILE_IDS.Returns(syncSetting); + settings.Get(visibleSetting).Returns(visible); + settings.Get(syncSetting).Returns(sync); + settings.Set(Arg.Any(), Arg.Any()).Returns(true); + return ( + new CloudProfileLibraryService( + Substitute.For(), + Substitute.For(), + settings, + Substitute.For(), + Substitute.For() + ), + settings + ); + } + + private static IReadOnlyList Profiles() => + [ + Entry("local:0", ProfileLibrarySource.Local), + Entry("vault:one", ProfileLibrarySource.Vault), + Entry("cloud:only", ProfileLibrarySource.Cloud), + Entry("local:1", ProfileLibrarySource.Local), + Entry("vault:two", ProfileLibrarySource.Vault), + ]; + + private static ProfileLibraryEntry Entry(string key, ProfileLibrarySource source) => + new( + key, + source, + source == ProfileLibrarySource.Cloud ? ProfileStorageState.CloudOnly : ProfileStorageState.LocalOnly, + key, + "", + null, + null + ); + + private static LicenseProfile EmptyLicense() => + new() + { + FriendCode = "0000-0000-0000", + Mii = new Mii { Name = new MiiName(SettingValues.NoLicense) }, + Vr = 5000, + Br = 5000, + RegionId = 10, + TotalRaceCount = 0, + TotalWinCount = 0, + }; + + private static LicenseProfile ActiveLicense() => + new() + { + FriendCode = "1234-5678-9012", + Mii = new Mii { Name = new MiiName("Alex") }, + Vr = 5000, + Br = 5000, + RegionId = 10, + TotalRaceCount = 1, + TotalWinCount = 1, + }; + + private sealed class TestSetting(string name, Type? type = null) + : Setting(type ?? typeof(string), name, type == typeof(bool) ? false : string.Empty) + { + protected override bool SetInternal(object newValue, bool skipSave = false) => true; + + public override object Get() => string.Empty; + + public override bool IsValid() => true; + } +} diff --git a/WheelWizard.Test/Features/CloudSync/CloudProfileServiceTests.cs b/WheelWizard.Test/Features/CloudSync/CloudProfileServiceTests.cs new file mode 100644 index 000000000..c610f89f2 --- /dev/null +++ b/WheelWizard.Test/Features/CloudSync/CloudProfileServiceTests.cs @@ -0,0 +1,116 @@ +using WheelWizard.CloudSync; +using WheelWizard.CloudSync.Backup; +using WheelWizard.CloudSync.Mii; +using WheelWizard.CloudSync.Profile; +using WheelWizard.CustomDistributions; +using WheelWizard.Settings; + +namespace WheelWizard.Test.Features.CloudSync; + +public sealed class CloudProfileServiceTests +{ + private readonly CloudProfileService _service = new( + Substitute.For(), + Substitute.For(), + Substitute.For(), + Substitute.For() + ); + + [Fact] + public async Task WriteAndReadPackageAsync_RoundTripsOnlyPortableProfileFiles() + { + var folder = Path.Combine(Path.GetTempPath(), $"wheelwizard-cloud-test-{Guid.NewGuid():N}"); + var archivePath = Path.Combine(folder, "profile.zip"); + var package = CreatePackage(); + + try + { + await _service.WritePackageAsync(package, archivePath); + var restored = await _service.ReadPackageAsync(archivePath); + + await _service.ValidateProfileAsync(restored); + Assert.Equal(package.ProfileId, restored.ProfileId); + Assert.Equal(package.RksysData, restored.RksysData); + Assert.Equal(package.MiiData, restored.MiiData); + Assert.Equal(package.RrRatingData, restored.RrRatingData); + Assert.Equal(package.RrSettingsData, restored.RrSettingsData); + Assert.Equal(package.RrGameSettingsData, restored.RrGameSettingsData); + Assert.Equal(package.GhostData["race.rkg"], restored.GhostData["race.rkg"]); + Assert.Equal(package.GhostData["folder/ghost.rkg"], restored.GhostData["folder/ghost.rkg"]); + Assert.DoesNotContain("NAND", restored.Manifest.Files.Select(file => file.LogicalName), StringComparer.OrdinalIgnoreCase); + } + finally + { + if (Directory.Exists(folder)) + Directory.Delete(folder, recursive: true); + } + } + + [Fact] + public async Task ValidateProfileAsync_RejectsTamperedContentBeforeApply() + { + var package = CreatePackage(); + package.RksysData[0] ^= 0xff; + + await Assert.ThrowsAsync(() => _service.ValidateProfileAsync(package)); + } + + [Fact] + public async Task ValidateProfileAsync_RejectsAmbiguousMissingMii() + { + var package = CreatePackage(includeMii: false, miiMissingOnSource: false); + + await Assert.ThrowsAsync(() => _service.ValidateProfileAsync(package)); + } + + [Fact] + public async Task ValidateProfileAsync_RejectsUnsafeGhostPath() + { + var package = CreatePackage(ghosts: new Dictionary { ["../outside.rkg"] = [0x01] }); + + await Assert.ThrowsAsync(() => _service.ValidateProfileAsync(package)); + } + + [Fact] + public void CreateManifest_UsesStableHashRegardlessOfGhostDictionaryOrder() + { + var first = CreatePackage(ghosts: new Dictionary { ["b.rkg"] = [0x02], ["a.rkg"] = [0x01] }); + var second = CreatePackage( + ghosts: new Dictionary { ["a.rkg"] = [0x01], ["b.rkg"] = [0x02] }, + profileId: first.ProfileId + ); + + var deviceId = Guid.NewGuid(); + var firstManifest = CloudProfileService.CreateManifest(first, first.ProfileId, deviceId, 1, []); + var secondManifest = CloudProfileService.CreateManifest(second, second.ProfileId, deviceId, 1, []); + + Assert.Equal(firstManifest.ContentHash, secondManifest.ContentHash); + Assert.Equal(firstManifest.Files.Select(file => file.LogicalName), secondManifest.Files.Select(file => file.LogicalName)); + } + + private static CloudProfilePackage CreatePackage( + byte[]? mii = null, + bool includeMii = true, + bool miiMissingOnSource = false, + IReadOnlyDictionary? ghosts = null, + Guid? profileId = null + ) + { + var id = profileId ?? Guid.NewGuid(); + var package = new CloudProfilePackage + { + ProfileId = id, + ProfileName = "Test license", + RksysData = [0x52, 0x4b, 0x53, 0x59, 0x53], + MiiData = includeMii ? mii ?? new byte[74] : null, + MiiMissingOnSource = miiMissingOnSource, + RrRatingData = [0x10], + RrSettingsData = [0x11], + RrGameSettingsData = [0x12], + GhostData = ghosts ?? new Dictionary { ["race.rkg"] = [0x20], ["folder/ghost.rkg"] = [0x21] }, + Manifest = new CloudProfileManifest { ProfileId = id }, + }; + package.Manifest = CloudProfileService.CreateManifest(package, id, Guid.NewGuid(), 3, []); + return package; + } +} diff --git a/WheelWizard/Features/CloudSync/Backup/IProfileBackupService.cs b/WheelWizard/Features/CloudSync/Backup/IProfileBackupService.cs new file mode 100644 index 000000000..9b8c087d7 --- /dev/null +++ b/WheelWizard/Features/CloudSync/Backup/IProfileBackupService.cs @@ -0,0 +1,7 @@ +namespace WheelWizard.CloudSync.Backup; + +public interface IProfileBackupService +{ + Task CreateBackupAsync(string? rksysPath = null); + Task RestoreBackupAsync(BackupInfo backup); +} diff --git a/WheelWizard/Features/CloudSync/Backup/ProfileBackupService.cs b/WheelWizard/Features/CloudSync/Backup/ProfileBackupService.cs new file mode 100644 index 000000000..03671a24e --- /dev/null +++ b/WheelWizard/Features/CloudSync/Backup/ProfileBackupService.cs @@ -0,0 +1,46 @@ +using WheelWizard.CustomDistributions; +using WheelWizard.Services; + +namespace WheelWizard.CloudSync.Backup; + +public sealed class ProfileBackupService(ICustomDistributionSingletonService distributions) : IProfileBackupService +{ + public Task CreateBackupAsync(string? rksysPath = null) + { + var now = DateTime.UtcNow; + var target = Path.Combine(PathManager.CloudBackupFolderPath, now.ToString("yyyyMMdd-HHmmssfff")); + Directory.CreateDirectory(target); + var nand = PathManager.GetActiveNandPath(); + var rksys = rksysPath ?? distributions.RetroRewind.FindExistingRksysPath() ?? PathManager.GetRetroWfcSavePath(); + CopyIfPresent(rksys, Path.Combine(target, "MarioKart", "rksys.dat")); + CopyIfPresent(PathManager.GetMiiDatabasePath(nand), Path.Combine(target, "Mii", "RFL_DB.dat")); + CopyIfPresent(PathManager.GetRetroRewindRatingPath(nand), Path.Combine(target, "RetroRewind", "RRRating.pul")); + CopyIfPresent(PathManager.GetRetroRewindSettingsPath(nand), Path.Combine(target, "RetroRewind", "RRSettings.pul")); + CopyIfPresent(PathManager.GetRetroRewindGameSettingsPath(nand), Path.Combine(target, "RetroRewind", "RRGameSettings.pul")); + return Task.FromResult(new BackupInfo(target, now)); + } + + public Task RestoreBackupAsync(BackupInfo backup) + { + var nand = PathManager.GetActiveNandPath(); + var rksys = distributions.RetroRewind.FindExistingRksysPath(); + if (rksys is not null) + CopyIfPresent(Path.Combine(backup.FolderPath, "MarioKart", "rksys.dat"), rksys); + CopyIfPresent(Path.Combine(backup.FolderPath, "Mii", "RFL_DB.dat"), PathManager.GetMiiDatabasePath(nand)); + CopyIfPresent(Path.Combine(backup.FolderPath, "RetroRewind", "RRRating.pul"), PathManager.GetRetroRewindRatingPath(nand)); + CopyIfPresent(Path.Combine(backup.FolderPath, "RetroRewind", "RRSettings.pul"), PathManager.GetRetroRewindSettingsPath(nand)); + CopyIfPresent( + Path.Combine(backup.FolderPath, "RetroRewind", "RRGameSettings.pul"), + PathManager.GetRetroRewindGameSettingsPath(nand) + ); + return Task.CompletedTask; + } + + private static void CopyIfPresent(string? source, string destination) + { + if (string.IsNullOrWhiteSpace(source) || !File.Exists(source)) + return; + Directory.CreateDirectory(Path.GetDirectoryName(destination)!); + File.Copy(source, destination, overwrite: true); + } +} diff --git a/WheelWizard/Features/CloudSync/CloudSyncExtensions.cs b/WheelWizard/Features/CloudSync/CloudSyncExtensions.cs new file mode 100644 index 000000000..06bd9fa8c --- /dev/null +++ b/WheelWizard/Features/CloudSync/CloudSyncExtensions.cs @@ -0,0 +1,37 @@ +using WheelWizard.CloudSync.Backup; +using WheelWizard.CloudSync.Conflict; +using WheelWizard.CloudSync.Credentials; +using WheelWizard.CloudSync.Enrollment; +using WheelWizard.CloudSync.Mii; +using WheelWizard.CloudSync.Profile; +using WheelWizard.CloudSync.ProfileLibrary; +using WheelWizard.CloudSync.Providers; + +namespace WheelWizard.CloudSync; + +public static class CloudSyncExtensions +{ + public static IServiceCollection AddCloudSync(this IServiceCollection services) + { + services.AddHttpClient("WheelWizard.CloudSync.WebDav"); + services.AddHttpClient("WheelWizard.CloudSync.OAuth"); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + return services; + } +} diff --git a/WheelWizard/Features/CloudSync/CloudSyncModels.cs b/WheelWizard/Features/CloudSync/CloudSyncModels.cs new file mode 100644 index 000000000..d0bcfa60d --- /dev/null +++ b/WheelWizard/Features/CloudSync/CloudSyncModels.cs @@ -0,0 +1,141 @@ +using System.Security.Cryptography; + +namespace WheelWizard.CloudSync; + +public enum CloudProviderType +{ + WebDav, + Nextcloud, + GoogleDrive, + OneDrive, +} + +public enum EnrollmentState +{ + Pending, + Verified, + Blocked, +} + +public enum CloudSyncAction +{ + Disabled, + NoOp, + Pulled, + Pushed, + PendingEnrollment, + Conflict, + Failed, +} + +public enum ConflictResolutionStrategy +{ + UseLocal, + UseRemote, + PreserveBoth, + Cancel, +} + +public enum ConflictKind +{ + NoOp, + SafePush, + SafePull, + Conflict, +} + +public sealed class CloudProfilePackage +{ + public Guid ProfileId { get; init; } + public string ProfileName { get; init; } = "Mario Kart Wii"; + public required byte[] RksysData { get; init; } + public byte[]? MiiData { get; init; } + + /// + /// The source save references a Mii which is not available in its local RFL_DB.dat. + /// This is kept explicit so a receiving device never substitutes an unrelated Mii. + /// + public bool MiiMissingOnSource { get; init; } + public IReadOnlyList LicensePreviews { get; init; } = []; + public byte[]? RrRatingData { get; init; } + public byte[]? RrSettingsData { get; init; } + public byte[]? RrGameSettingsData { get; init; } + public IReadOnlyDictionary GhostData { get; init; } = new Dictionary(); + public required CloudProfileManifest Manifest { get; set; } +} + +public sealed class CloudProfileManifest +{ + public const int CurrentSchemaVersion = 1; + public int SchemaVersion { get; init; } = CurrentSchemaVersion; + public Guid ProfileId { get; init; } + public string ProfileName { get; init; } = "Mario Kart Wii"; + public long Revision { get; init; } + public DateTime LastModifiedUtc { get; init; } + public Guid LastDeviceId { get; init; } + public string ContentHash { get; init; } = string.Empty; + public bool MiiMissingOnSource { get; init; } + + /// Optional 74-byte Mii preview for displaying the selected license in a picker. + public byte[]? MiiPreviewData { get; init; } + public List LicensePreviews { get; init; } = []; + public List Devices { get; init; } = []; + public List Files { get; init; } = []; +} + +public sealed record CloudDeviceRecord( + Guid DeviceId, + string DeviceName, + string Platform, + DateTime FirstSeenUtc, + DateTime? LastPlayedUtc, + EnrollmentState EnrollmentState +); + +public sealed record FileManifestEntry(string LogicalName, string Hash, long Size, bool Optional); + +public sealed record CloudProviderConfig(CloudProviderType ProviderType, string DisplayName, string RemoteRoot); + +public sealed record MiiIdentifier(uint Value); + +/// Non-sensitive metadata used to choose a cloud profile before downloading it. +public sealed record CloudLicensePreview(int Slot, string Name, string FriendCode, uint Vr, uint Br, bool IsPresent); + +public sealed record ProfileMiiExtraction(MiiIdentifier? Identifier, byte[]? Data); + +public sealed record BackupInfo(string FolderPath, DateTime CreatedUtc); + +public sealed record CloudSyncResult(bool Success, CloudSyncAction Action, string Message, ConflictKind? Conflict = null) +{ + public static CloudSyncResult Ok(CloudSyncAction action, string message) => new(true, action, message); + + public static CloudSyncResult Fail(string message) => new(false, CloudSyncAction.Failed, message); +} + +public sealed record CloudSyncStatus( + bool Enabled, + string Provider, + Guid? ProfileId, + Guid DeviceId, + EnrollmentState EnrollmentState, + string Message +); + +public sealed record ConflictResult(ConflictKind Kind, string Message); + +public sealed record ResolutionResult(bool Applied, string Message); + +public sealed record ProbeResult(bool Successful, bool IsIdentityLinkError, string Message); + +internal sealed record CloudSyncLocalState +{ + public long LastKnownCloudRevision { get; init; } + public string LastKnownCloudHash { get; init; } = string.Empty; + public string LastLocalHash { get; init; } = string.Empty; + public EnrollmentState EnrollmentState { get; init; } = EnrollmentState.Pending; +} + +internal static class CloudHash +{ + public static string Of(byte[] bytes) => Convert.ToHexString(SHA256.HashData(bytes)); +} diff --git a/WheelWizard/Features/CloudSync/CloudSyncService.cs b/WheelWizard/Features/CloudSync/CloudSyncService.cs new file mode 100644 index 000000000..f2ede585d --- /dev/null +++ b/WheelWizard/Features/CloudSync/CloudSyncService.cs @@ -0,0 +1,358 @@ +using System.Text.Json; +using WheelWizard.CloudSync.Conflict; +using WheelWizard.CloudSync.Enrollment; +using WheelWizard.CloudSync.Profile; +using WheelWizard.CloudSync.ProfileLibrary; +using WheelWizard.CloudSync.Providers; +using WheelWizard.Services; +using WheelWizard.Settings; +using WheelWizard.WiiManagement.GameLicense; + +namespace WheelWizard.CloudSync; + +public sealed class CloudSyncService( + ICloudProfileService profiles, + ICloudProviderResolver providers, + ICloudConflictResolver conflicts, + IRetroWfcEnrollmentService enrollment, + ISettingsManager settings, + IVirtualProfileCloudService vaultProfiles, + IProfileCloudBindingService profileBindings, + IGameLicenseSingletonService gameLicenses +) : ICloudSyncService +{ + private readonly SemaphoreSlim _syncGate = new(1, 1); + + public async Task SyncNowAsync() + { + var pull = await SynchronizeAsync(preLaunch: true, respectPhaseSetting: false); + if (!pull.Success || pull.Action is CloudSyncAction.Pulled or CloudSyncAction.Conflict or CloudSyncAction.Disabled) + return pull; + + // The settings page is only usable after the game window has closed. A manual sync first + // protects against a newer cloud copy, then performs the same post-exit push pipeline. + return await SynchronizeAsync(preLaunch: false, respectPhaseSetting: false); + } + + public Task PreLaunchSyncAsync() => SynchronizeAsync(preLaunch: true, respectPhaseSetting: true); + + public Task PostLaunchSyncAsync() => SynchronizeAsync(preLaunch: false, respectPhaseSetting: true); + + public Task DownloadProfileToVaultAsync(Guid profileId) => vaultProfiles.DownloadToVaultAsync(profileId); + + public async Task GetStatusAsync() + { + var profileId = GetOrCreateProfileId(); + var deviceId = GetDeviceId(); + var enrollmentState = await enrollment.GetStateAsync(); + return new CloudSyncStatus( + settings.Get(settings.CLOUD_SYNC_ENABLED), + settings.Get(settings.CLOUD_PROVIDER_TYPE), + profileId == Guid.Empty ? null : profileId, + deviceId, + enrollmentState, + "Cloud saves support both online and offline licenses. NAND and console identity remain local." + ); + } + + public async Task> GetAvailableProfilesAsync() + { + var provider = providers.Resolve(GetProviderType()); + var profileIds = (await provider.ListAsync("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/WheelWizard/CloudSaves")).SelectMany(ExtractProfileIds).Distinct().ToList(); + var manifests = new List(); + foreach (var profileId in profileIds) + { + var manifest = await ReadRemoteManifestAsync(provider, profileId); + if (manifest is not null && manifest.ProfileId == profileId) + manifests.Add(manifest); + } + return manifests.OrderByDescending(manifest => manifest.LastModifiedUtc).ToList(); + } + + private async Task SynchronizeAsync(bool preLaunch, bool respectPhaseSetting) + { + if (!settings.Get(settings.CLOUD_SYNC_ENABLED)) + return CloudSyncResult.Ok(CloudSyncAction.Disabled, "Cloud saves are disabled."); + if (respectPhaseSetting && !settings.Get(preLaunch ? settings.SYNC_BEFORE_LAUNCH : settings.SYNC_AFTER_LAUNCH)) + return CloudSyncResult.Ok(CloudSyncAction.Disabled, "Cloud sync is disabled for this launch phase."); + var profileId = GetOrCreateProfileId(); + + await _syncGate.WaitAsync(); + try + { + var selection = ReadSelectedProfiles(); + if (selection.HasExplicitSelection) + { + CloudSyncResult? selectedResult = null; + foreach (var localSlot in selection.LocalSlots) + { + if (localSlot < 0 || localSlot >= gameLicenses.LicenseCollection.Users.Count) + return CloudSyncResult.Fail("The selected local Mario Kart license no longer exists."); + var selectedProfileId = await profileBindings.GetProfileIdAsync( + localSlot, + ProfileCloudBindingIdentity.Create(gameLicenses.LicenseCollection.Users[localSlot]) + ); + var result = preLaunch + ? await vaultProfiles.PullLocalSlotAsync(selectedProfileId, localSlot) + : await vaultProfiles.PushLocalSlotAsync(selectedProfileId, localSlot); + if (!result.Success || result.Action == CloudSyncAction.Conflict) + return result; + if (result.Action is CloudSyncAction.Pulled or CloudSyncAction.Pushed) + selectedResult = result; + } + foreach (var vaultProfileId in selection.VaultProfiles) + { + var result = preLaunch ? await vaultProfiles.PullAsync(vaultProfileId) : await vaultProfiles.PushAsync(vaultProfileId); + if (!result.Success || result.Action == CloudSyncAction.Conflict) + return result; + if (result.Action is CloudSyncAction.Pulled or CloudSyncAction.Pushed) + selectedResult = result; + } + return selectedResult ?? CloudSyncResult.Ok(CloudSyncAction.NoOp, "Selected cloud profiles are already up to date."); + } + + var provider = providers.Resolve(GetProviderType()); + var primary = preLaunch ? await PullBeforeLaunchAsync(provider, profileId) : await PushAfterLaunchAsync(provider, profileId); + if (!primary.Success || primary.Action == CloudSyncAction.Conflict) + return primary; + + return primary; + } + catch (Exception ex) + { + return CloudSyncResult.Fail($"Cloud sync failed without changing the local profile: {ex.Message}"); + } + finally + { + _syncGate.Release(); + } + } + + private async Task PullBeforeLaunchAsync(ICloudProvider provider, Guid profileId) + { + var deviceId = GetDeviceId(); + var state = await ReadStateAsync(profileId, deviceId); + var local = await profiles.CaptureProfileAsync(); + var remote = await ReadRemoteManifestAsync(provider, profileId); + if (remote is null) + return CloudSyncResult.Ok(CloudSyncAction.NoOp, "No cloud profile exists yet; local profile was left unchanged."); + if (remote.ProfileId != profileId) + return CloudSyncResult.Fail("Cloud manifest profile ID does not match the selected profile."); + + var comparison = await conflicts.CompareAsync( + new CloudSyncSnapshot(local.Manifest, state.LastKnownCloudRevision, state.LastKnownCloudHash), + new CloudSyncSnapshot(remote, state.LastKnownCloudRevision, state.LastKnownCloudHash) + ); + if (comparison.Kind == ConflictKind.Conflict) + return new CloudSyncResult(false, CloudSyncAction.Conflict, comparison.Message, comparison.Kind); + if (comparison.Kind == ConflictKind.NoOp) + { + await WriteStateAsync( + profileId, + deviceId, + state with + { + LastKnownCloudRevision = remote.Revision, + LastKnownCloudHash = remote.ContentHash, + LastLocalHash = local.Manifest.ContentHash, + } + ); + return CloudSyncResult.Ok(CloudSyncAction.NoOp, comparison.Message); + } + if (comparison.Kind == ConflictKind.SafePush) + return CloudSyncResult.Ok(CloudSyncAction.NoOp, "Local profile is newer; upload is deferred until WiiCompiled exits."); + + var temporary = Path.Combine(Path.GetTempPath(), $"wheelwizard-cloud-{profileId:N}.zip"); + try + { + await provider.DownloadAsync(RemotePath(profileId, "profile.zip"), temporary); + var downloaded = await profiles.ReadPackageAsync(temporary); + await profiles.ValidateProfileAsync(downloaded); + if ( + downloaded.Manifest.ProfileId != remote.ProfileId + || downloaded.Manifest.ContentHash != remote.ContentHash + || downloaded.Manifest.Revision != remote.Revision + ) + return CloudSyncResult.Fail("Cloud package does not match its manifest; local profile was not applied."); + + await profiles.ApplyProfileAsync(downloaded); // Profile service creates the mandatory local backup immediately before writing. + await WriteStateAsync( + profileId, + deviceId, + state with + { + LastKnownCloudRevision = remote.Revision, + LastKnownCloudHash = remote.ContentHash, + LastLocalHash = remote.ContentHash, + } + ); + return CloudSyncResult.Ok(CloudSyncAction.Pulled, "Cloud profile was validated, backed up locally, and applied."); + } + finally + { + if (File.Exists(temporary)) + File.Delete(temporary); + } + } + + private async Task PushAfterLaunchAsync(ICloudProvider provider, Guid profileId) + { + var deviceId = GetDeviceId(); + var state = await ReadStateAsync(profileId, deviceId); + var local = await profiles.CaptureProfileAsync(); + var remote = await ReadRemoteManifestAsync(provider, profileId); + if (remote is not null) + { + var comparison = await conflicts.CompareAsync( + new CloudSyncSnapshot(local.Manifest, state.LastKnownCloudRevision, state.LastKnownCloudHash), + new CloudSyncSnapshot(remote, state.LastKnownCloudRevision, state.LastKnownCloudHash) + ); + if (comparison.Kind == ConflictKind.Conflict) + return new CloudSyncResult(false, CloudSyncAction.Conflict, comparison.Message, comparison.Kind); + if (comparison.Kind == ConflictKind.NoOp) + return CloudSyncResult.Ok(CloudSyncAction.NoOp, comparison.Message); + if (comparison.Kind == ConflictKind.SafePull) + return new CloudSyncResult( + false, + CloudSyncAction.Conflict, + "Cloud changed while this device did not; pull it on the next launch instead.", + ConflictKind.SafePull + ); + } + + var revision = (remote?.Revision ?? 0) + 1; + var devices = remote?.Devices ?? []; + devices = devices + .Where(device => device.DeviceId != deviceId) + .Append( + new CloudDeviceRecord( + deviceId, + Environment.MachineName, + Environment.OSVersion.Platform.ToString(), + DateTime.UtcNow, + DateTime.UtcNow, + await enrollment.GetStateAsync() + ) + ) + .ToList(); + local.Manifest = CloudProfileService.CreateManifest(local, profileId, deviceId, revision, devices); + var temporary = Path.Combine(Path.GetTempPath(), $"wheelwizard-cloud-{profileId:N}-upload.zip"); + try + { + await profiles.WritePackageAsync(local, temporary); + // Publish package first and manifest last: readers never apply a revision whose data has not uploaded. + await provider.UploadAsync(temporary, RemotePath(profileId, "profile.zip")); + await File.WriteAllTextAsync(temporary + ".json", JsonSerializer.Serialize(local.Manifest)); + await provider.UploadAsync(temporary + ".json", RemotePath(profileId, "manifest.json")); + await WriteStateAsync( + profileId, + deviceId, + state with + { + LastKnownCloudRevision = revision, + LastKnownCloudHash = local.Manifest.ContentHash, + LastLocalHash = local.Manifest.ContentHash, + EnrollmentState = await enrollment.GetStateAsync(), + } + ); + return CloudSyncResult.Ok(CloudSyncAction.Pushed, "Local profile was uploaded after WiiCompiled exited."); + } + finally + { + if (File.Exists(temporary)) + File.Delete(temporary); + if (File.Exists(temporary + ".json")) + File.Delete(temporary + ".json"); + } + } + + private async Task ReadRemoteManifestAsync(ICloudProvider provider, Guid profileId) + { + var remotePath = RemotePath(profileId, "manifest.json"); + if (!await provider.ExistsAsync(remotePath)) + return null; + var temporary = Path.Combine(Path.GetTempPath(), $"wheelwizard-cloud-{profileId:N}-manifest.json"); + try + { + await provider.DownloadAsync(remotePath, temporary); + return JsonSerializer.Deserialize(await File.ReadAllTextAsync(temporary)) + ?? throw new InvalidDataException("Cloud manifest is empty."); + } + finally + { + if (File.Exists(temporary)) + File.Delete(temporary); + } + } + + private static string RemotePath(Guid profileId, string name) => $"/WheelWizard/CloudSaves/{profileId:D}/{name}"; + + private static IEnumerable ExtractProfileIds(string path) + { + var segments = (Uri.TryCreate(path, UriKind.Absolute, out var absolute) ? absolute.AbsolutePath : path).Split( + '/', + StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries + ); + return segments.Select(Uri.UnescapeDataString).Where(segment => Guid.TryParse(segment, out _)).Select(Guid.Parse); + } + + private Guid GetOrCreateProfileId() + { + if (Guid.TryParse(settings.Get(settings.CLOUD_PROFILE_ID), out var id) && id != Guid.Empty) + return id; + + id = Guid.NewGuid(); + settings.Set(settings.CLOUD_PROFILE_ID, id.ToString("D")); + return id; + } + + private Guid GetDeviceId() => Guid.TryParse(settings.Get(settings.CLOUD_DEVICE_ID), out var id) ? id : Guid.Empty; + + private CloudProviderType GetProviderType() => + Enum.TryParse(settings.Get(settings.CLOUD_PROVIDER_TYPE), true, out var type) + ? type + : CloudProviderType.WebDav; + + private ProfileSyncSelection ReadSelectedProfiles() + { + try + { + var keys = JsonSerializer.Deserialize>(settings.Get(settings.CLOUD_SYNC_PROFILE_IDS)) ?? []; + var localSlots = keys.Where(key => key.StartsWith("local:", StringComparison.Ordinal)) + .Select(key => int.TryParse(key[6..], out var slot) ? slot : -1) + .Where(slot => slot is >= 0 and < 4) + .Distinct() + .ToList(); + var vaultProfiles = keys.Where(key => key.StartsWith("vault:", StringComparison.Ordinal)) + .Select(key => Guid.TryParse(key[6..], out var id) ? id : Guid.Empty) + .Where(id => id != Guid.Empty) + .Distinct() + .ToList(); + return new ProfileSyncSelection(keys.Count > 0, localSlots, vaultProfiles); + } + catch (JsonException) + { + return new ProfileSyncSelection(false, [], []); + } + } + + private sealed record ProfileSyncSelection(bool HasExplicitSelection, IReadOnlyList LocalSlots, IReadOnlyList VaultProfiles); + + private static string StatePath(Guid profile, Guid device) => + Path.Combine(PathManager.CloudSyncStateFolderPath, $"{profile:D}-{device:D}.json"); + + private static async Task ReadStateAsync(Guid profile, Guid device) + { + var path = StatePath(profile, device); + return !File.Exists(path) + ? new CloudSyncLocalState() + : JsonSerializer.Deserialize(await File.ReadAllTextAsync(path)) ?? new CloudSyncLocalState(); + } + + private static async Task WriteStateAsync(Guid profile, Guid device, CloudSyncLocalState state) + { + var path = StatePath(profile, device); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + await File.WriteAllTextAsync(path, JsonSerializer.Serialize(state)); + } +} diff --git a/WheelWizard/Features/CloudSync/Conflict/CloudConflictResolver.cs b/WheelWizard/Features/CloudSync/Conflict/CloudConflictResolver.cs new file mode 100644 index 000000000..ad5d53ab5 --- /dev/null +++ b/WheelWizard/Features/CloudSync/Conflict/CloudConflictResolver.cs @@ -0,0 +1,37 @@ +namespace WheelWizard.CloudSync.Conflict; + +/// Classifies changes only. Resolution is deliberately a user decision, never an overwrite. +public sealed class CloudConflictResolver : ICloudConflictResolver +{ + public Task CompareAsync(CloudSyncSnapshot local, CloudSyncSnapshot remote) + { + if (string.Equals(local.Manifest.ContentHash, remote.Manifest.ContentHash, StringComparison.Ordinal)) + return Task.FromResult(new ConflictResult(ConflictKind.NoOp, "Local and cloud profiles are identical.")); + + // A newly linked device has no common base revision. The selected cloud profile is its + // explicit bootstrap source; ApplyProfileAsync creates a local backup before touching it. + if (local.LastKnownCloudRevision == 0 && string.IsNullOrEmpty(local.LastKnownCloudHash)) + return Task.FromResult( + new ConflictResult(ConflictKind.SafePull, "Applying the selected cloud profile to a newly linked device.") + ); + + var localChanged = !string.Equals(local.Manifest.ContentHash, local.LastKnownCloudHash, StringComparison.Ordinal); + var remoteChanged = + remote.Manifest.Revision != local.LastKnownCloudRevision + || !string.Equals(remote.Manifest.ContentHash, local.LastKnownCloudHash, StringComparison.Ordinal); + if (localChanged && remoteChanged) + return Task.FromResult(new ConflictResult(ConflictKind.Conflict, "Both this device and the cloud profile changed.")); + return Task.FromResult( + remoteChanged + ? new ConflictResult(ConflictKind.SafePull, "Cloud profile is newer.") + : new ConflictResult(ConflictKind.SafePush, "Local profile changed while cloud is unchanged.") + ); + } + + public Task ResolveAsync(ConflictResult conflict, ConflictResolutionStrategy strategy) => + Task.FromResult( + strategy == ConflictResolutionStrategy.Cancel || conflict.Kind != ConflictKind.Conflict + ? new ResolutionResult(false, "No destructive conflict resolution was performed.") + : new ResolutionResult(false, "Conflict resolution must be applied explicitly by the settings UI.") + ); +} diff --git a/WheelWizard/Features/CloudSync/Conflict/ICloudConflictResolver.cs b/WheelWizard/Features/CloudSync/Conflict/ICloudConflictResolver.cs new file mode 100644 index 000000000..823f53e8b --- /dev/null +++ b/WheelWizard/Features/CloudSync/Conflict/ICloudConflictResolver.cs @@ -0,0 +1,9 @@ +namespace WheelWizard.CloudSync.Conflict; + +public sealed record CloudSyncSnapshot(CloudProfileManifest Manifest, long LastKnownCloudRevision, string LastKnownCloudHash); + +public interface ICloudConflictResolver +{ + Task CompareAsync(CloudSyncSnapshot local, CloudSyncSnapshot remote); + Task ResolveAsync(ConflictResult conflict, ConflictResolutionStrategy strategy); +} diff --git a/WheelWizard/Features/CloudSync/Credentials/ISecureCredentialStore.cs b/WheelWizard/Features/CloudSync/Credentials/ISecureCredentialStore.cs new file mode 100644 index 000000000..1cf2f8b80 --- /dev/null +++ b/WheelWizard/Features/CloudSync/Credentials/ISecureCredentialStore.cs @@ -0,0 +1,10 @@ +namespace WheelWizard.CloudSync.Credentials; + +public sealed record Secret(string Value); + +public interface ISecureCredentialStore +{ + Task SaveAsync(string key, Secret value); + Task GetAsync(string key); + Task DeleteAsync(string key); +} diff --git a/WheelWizard/Features/CloudSync/Credentials/SecureCredentialStore.cs b/WheelWizard/Features/CloudSync/Credentials/SecureCredentialStore.cs new file mode 100644 index 000000000..022faac30 --- /dev/null +++ b/WheelWizard/Features/CloudSync/Credentials/SecureCredentialStore.cs @@ -0,0 +1,165 @@ +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Text; + +namespace WheelWizard.CloudSync.Credentials; + +/// +/// The common, fail-closed credential boundary. Secrets are deliberately never serialized into +/// Wheel Wizard settings, manifests or profile archives. Windows uses Credential Manager and Linux +/// uses the user's libsecret service via secret-tool; a missing keychain is an error, not an +/// insecure file fallback. +/// +public sealed class SecureCredentialStore : ISecureCredentialStore +{ + public Task SaveAsync(string key, Secret value) => + OperatingSystem.IsWindows() ? Task.Run(() => WriteWindows(key, value.Value)) + : OperatingSystem.IsLinux() ? RunSecretToolAsync("store", key, value.Value) + : throw Unsupported(); + + public Task GetAsync(string key) => + OperatingSystem.IsWindows() ? Task.Run(() => ReadWindows(key)) + : OperatingSystem.IsLinux() ? ReadSecretToolAsync(key) + : throw Unsupported(); + + public Task DeleteAsync(string key) => + OperatingSystem.IsWindows() ? Task.Run(() => DeleteWindows(key)) + : OperatingSystem.IsLinux() ? RunSecretToolAsync("clear", key, null) + : throw Unsupported(); + + private static Exception Unsupported() => + new PlatformNotSupportedException("No supported OS credential store is available for cloud credentials."); + + private static string Target(string key) => "WheelWizard.CloudSync/" + key; + + private static async Task RunSecretToolAsync(string command, string key, string? secret) + { + using var process = StartSecretTool(command, key); + if (secret is not null) + await process.StandardInput.WriteAsync(secret); + process.StandardInput.Close(); + var error = await process.StandardError.ReadToEndAsync(); + await process.WaitForExitAsync(); + if (process.ExitCode != 0) + throw new InvalidOperationException($"The Linux credential store rejected the request: {error.Trim()}"); + } + + private static async Task ReadSecretToolAsync(string key) + { + using var process = StartSecretTool("lookup", key); + var output = await process.StandardOutput.ReadToEndAsync(); + var error = await process.StandardError.ReadToEndAsync(); + await process.WaitForExitAsync(); + if (process.ExitCode == 1) + return null; + if (process.ExitCode != 0) + throw new InvalidOperationException($"The Linux credential store rejected the request: {error.Trim()}"); + return string.IsNullOrEmpty(output) ? null : new Secret(output.TrimEnd('\r', '\n')); + } + + private static Process StartSecretTool(string command, string key) + { + var start = new ProcessStartInfo("secret-tool") + { + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + }; + start.ArgumentList.Add(command); + if (command == "store") + start.ArgumentList.Add("--label=WheelWizard Cloud Sync"); + start.ArgumentList.Add("service"); + start.ArgumentList.Add("wheelwizard-cloud"); + start.ArgumentList.Add("key"); + start.ArgumentList.Add(key); + return Process.Start(start) ?? throw new InvalidOperationException("Could not start the Linux credential store."); + } + + private static void WriteWindows(string key, string secret) + { + var bytes = Encoding.UTF8.GetBytes(secret); + var blob = Marshal.AllocCoTaskMem(bytes.Length); + try + { + Marshal.Copy(bytes, 0, blob, bytes.Length); + var credential = new NativeCredential + { + Type = 1, + TargetName = Target(key), + CredentialBlobSize = (uint)bytes.Length, + CredentialBlob = blob, + Persist = 2, + UserName = "WheelWizard", + }; + if (!CredWrite(ref credential, 0)) + throw new InvalidOperationException("Windows Credential Manager could not save the cloud credential."); + } + finally + { + Marshal.FreeCoTaskMem(blob); + } + } + + private static Secret? ReadWindows(string key) + { + if (!CredRead(Target(key), 1, 0, out var pointer)) + return null; + try + { + var credential = Marshal.PtrToStructure(pointer); + var bytes = new byte[checked((int)credential.CredentialBlobSize)]; + if (bytes.Length > 0) + Marshal.Copy(credential.CredentialBlob, bytes, 0, bytes.Length); + return new Secret(Encoding.UTF8.GetString(bytes)); + } + finally + { + CredFree(pointer); + } + } + + private static void DeleteWindows(string key) + { + if (!CredDelete(Target(key), 1, 0)) + { /* absent is a valid disconnect */ + } + } + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + private struct NativeCredential + { + public uint Flags; + public uint Type; + + [MarshalAs(UnmanagedType.LPWStr)] + public string TargetName; + + [MarshalAs(UnmanagedType.LPWStr)] + public string? Comment; + public long LastWritten; + public uint CredentialBlobSize; + public IntPtr CredentialBlob; + public uint Persist; + public uint AttributeCount; + public IntPtr Attributes; + + [MarshalAs(UnmanagedType.LPWStr)] + public string? TargetAlias; + + [MarshalAs(UnmanagedType.LPWStr)] + public string UserName; + } + + [DllImport("advapi32", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern bool CredWrite([In] ref NativeCredential credential, uint flags); + + [DllImport("advapi32", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern bool CredRead(string target, uint type, uint flags, out IntPtr credential); + + [DllImport("advapi32", SetLastError = true)] + private static extern bool CredDelete(string target, uint type, uint flags); + + [DllImport("advapi32", SetLastError = true)] + private static extern void CredFree(IntPtr buffer); +} diff --git a/WheelWizard/Features/CloudSync/Enrollment/IRetroWfcEnrollmentService.cs b/WheelWizard/Features/CloudSync/Enrollment/IRetroWfcEnrollmentService.cs new file mode 100644 index 000000000..2866e4d43 --- /dev/null +++ b/WheelWizard/Features/CloudSync/Enrollment/IRetroWfcEnrollmentService.cs @@ -0,0 +1,8 @@ +namespace WheelWizard.CloudSync.Enrollment; + +public interface IRetroWfcEnrollmentService +{ + Task GetStateAsync(); + Task ProbeOnlineProfileAsync(); + Task MarkVerifiedAsync(); +} diff --git a/WheelWizard/Features/CloudSync/Enrollment/RetroWfcEnrollmentService.cs b/WheelWizard/Features/CloudSync/Enrollment/RetroWfcEnrollmentService.cs new file mode 100644 index 000000000..09bf175d6 --- /dev/null +++ b/WheelWizard/Features/CloudSync/Enrollment/RetroWfcEnrollmentService.cs @@ -0,0 +1,63 @@ +using System.Text.Json; +using WheelWizard.Services; +using WheelWizard.Settings; + +namespace WheelWizard.CloudSync.Enrollment; + +/// +/// A local safety latch, not a Retro WFC integration. It never changes server data or console +/// identity; only a successful, independently observed login may mark a device verified. +/// +public sealed class RetroWfcEnrollmentService(ISettingsManager settings) : IRetroWfcEnrollmentService +{ + public async Task GetStateAsync() => (await ReadAsync()).EnrollmentState; + + public async Task ProbeOnlineProfileAsync() + { + // 22005 is a server-side identity/linking condition. Detecting it only blocks uploads; + // it is never an instruction to copy a serial, device ID, or NAND. + var logsRoot = PathManager.RecompFolderPath; + if ( + Directory.Exists(logsRoot) + && Directory + .EnumerateFiles(logsRoot, "*.log", SearchOption.AllDirectories) + .Take(100) + .Any(file => new FileInfo(file).Length < 10_000_000 && File.ReadAllText(file).Contains("22005", StringComparison.Ordinal)) + ) + { + await WriteAsync((await ReadAsync()) with { EnrollmentState = EnrollmentState.Blocked }); + return new ProbeResult( + false, + true, + "Retro WFC reported Error 22005. Upload remains blocked; use the official linking/support process." + ); + } + + return new ProbeResult(false, false, "No verified online-login evidence is available yet; upload remains blocked."); + } + + public async Task MarkVerifiedAsync() + { + var current = await ReadAsync(); + if (current.EnrollmentState == EnrollmentState.Blocked) + throw new InvalidOperationException("A blocked device cannot be verified until the official linking issue is resolved."); + await WriteAsync(current with { EnrollmentState = EnrollmentState.Verified }); + } + + private string StatePath => Path.Combine(PathManager.CloudSyncStateFolderPath, $"enrollment-{ProfileId:D}-{DeviceId:D}.json"); + private Guid ProfileId => Guid.TryParse(settings.Get(settings.CLOUD_PROFILE_ID), out var id) ? id : Guid.Empty; + private Guid DeviceId => Guid.TryParse(settings.Get(settings.CLOUD_DEVICE_ID), out var id) ? id : Guid.Empty; + + private async Task ReadAsync() + { + if (!File.Exists(StatePath)) + return new CloudSyncLocalState(); + return JsonSerializer.Deserialize(await File.ReadAllTextAsync(StatePath)) ?? new CloudSyncLocalState(); + } + + private async Task WriteAsync(CloudSyncLocalState state) + { + Directory.CreateDirectory(Path.GetDirectoryName(StatePath)!); + await File.WriteAllTextAsync(StatePath, JsonSerializer.Serialize(state)); + } +} diff --git a/WheelWizard/Features/CloudSync/ICloudSyncService.cs b/WheelWizard/Features/CloudSync/ICloudSyncService.cs new file mode 100644 index 000000000..0fe47658d --- /dev/null +++ b/WheelWizard/Features/CloudSync/ICloudSyncService.cs @@ -0,0 +1,13 @@ +namespace WheelWizard.CloudSync; + +public interface ICloudSyncService +{ + Task PreLaunchSyncAsync(); + Task PostLaunchSyncAsync(); + Task SyncNowAsync(); + Task GetStatusAsync(); + Task> GetAvailableProfilesAsync(); + + /// Downloads one cloud-only profile into the local vault without replacing rksys.dat. + Task DownloadProfileToVaultAsync(Guid profileId); +} diff --git a/WheelWizard/Features/CloudSync/Mii/IMiiProfileService.cs b/WheelWizard/Features/CloudSync/Mii/IMiiProfileService.cs new file mode 100644 index 000000000..e4d8877a8 --- /dev/null +++ b/WheelWizard/Features/CloudSync/Mii/IMiiProfileService.cs @@ -0,0 +1,9 @@ +namespace WheelWizard.CloudSync.Mii; + +public interface IMiiProfileService +{ + Task ExtractMiiAsync(MiiIdentifier id); + Task EnsureMiiPresentAsync(byte[] mii); + Task IsMiiPresentAsync(MiiIdentifier id); + Task ExtractProfileMiiAsync(byte[] rksysData); +} diff --git a/WheelWizard/Features/CloudSync/Mii/MiiProfileService.cs b/WheelWizard/Features/CloudSync/Mii/MiiProfileService.cs new file mode 100644 index 000000000..5971bea45 --- /dev/null +++ b/WheelWizard/Features/CloudSync/Mii/MiiProfileService.cs @@ -0,0 +1,57 @@ +using System.Buffers.Binary; +using WheelWizard.Settings; +using WheelWizard.WiiManagement.MiiManagement; + +namespace WheelWizard.CloudSync.Mii; + +/// Moves one raw 74-byte Mii block only; it never copies RFL_DB.dat between devices. +public sealed class MiiProfileService(IMiiRepositoryService repository, ISettingsManager settings) : IMiiProfileService +{ + public Task ExtractMiiAsync(MiiIdentifier id) + { + var raw = + repository.GetRawBlockByAvatarId(id.Value) + ?? throw new InvalidDataException("The profile Mii is not present in the local RFL_DB.dat."); + return Task.FromResult(raw); + } + + public Task IsMiiPresentAsync(MiiIdentifier id) => Task.FromResult(repository.GetRawBlockByAvatarId(id.Value) is not null); + + public Task ExtractProfileMiiAsync(byte[] rksysData) + { + // Mario Kart Wii stores the selected Mii's Avatar ID in the RKPD header. It does + // not embed a full Mii block in rksys.dat, so matching arbitrary byte sequences can + // select a wrong local Mii. + const int rksysHeaderSize = 0x08; + const int rkpdSize = 0x8CC0; + const int avatarIdOffset = 0x28; + var userIndex = Math.Clamp(settings.Get(settings.FOCUSED_USER), 0, 3); + var offset = rksysHeaderSize + userIndex * rkpdSize + avatarIdOffset; + if (rksysData.Length < offset + sizeof(uint)) + throw new InvalidDataException("rksys.dat is too small to read the selected license Mii."); + + var id = BinaryPrimitives.ReadUInt32BigEndian(rksysData.AsSpan(offset, sizeof(uint))); + if (id == 0) + return Task.FromResult(new ProfileMiiExtraction(null, null)); + + var identifier = new MiiIdentifier(id); + return Task.FromResult(new ProfileMiiExtraction(identifier, repository.GetRawBlockByAvatarId(id))); + } + + public Task EnsureMiiPresentAsync(byte[] mii) + { + if (mii.Length != MiiSerializer.MiiBlockSize) + throw new InvalidDataException("Cloud profile contains an invalid Mii block."); + + var id = BinaryPrimitives.ReadUInt32BigEndian(mii.AsSpan(0x18, 4)); + if (id == 0) + throw new InvalidDataException("Cloud profile Mii has no identifier."); + + var existing = repository.GetRawBlockByAvatarId(id); + OperationResult result = existing is null ? repository.AddMiiToBlocks(mii) : repository.UpdateBlockByClientId(id, mii); + if (result.IsFailure) + throw new InvalidDataException(result.Error.Message); + + return Task.CompletedTask; + } +} diff --git a/WheelWizard/Features/CloudSync/Profile/CloudProfileService.cs b/WheelWizard/Features/CloudSync/Profile/CloudProfileService.cs new file mode 100644 index 000000000..b8e772e61 --- /dev/null +++ b/WheelWizard/Features/CloudSync/Profile/CloudProfileService.cs @@ -0,0 +1,311 @@ +using System.Buffers.Binary; +using System.IO.Compression; +using System.Text; +using System.Text.Json; +using WheelWizard.CloudSync.Backup; +using WheelWizard.CloudSync.Mii; +using WheelWizard.CustomDistributions; +using WheelWizard.Services; +using WheelWizard.Settings; +using WheelWizard.Utilities.Generators; + +namespace WheelWizard.CloudSync.Profile; + +public sealed class CloudProfileService( + IMiiProfileService miis, + IProfileBackupService backups, + ICustomDistributionSingletonService distributions, + ISettingsManager settings +) : ICloudProfileService +{ + private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true }; + + public async Task CaptureProfileAsync() + { + var rksysPath = distributions.RetroRewind.FindExistingRksysPath(); + if (rksysPath is null || !File.Exists(rksysPath)) + throw new FileNotFoundException("RetroWFC rksys.dat was not found by the existing Retro Rewind save locator."); + + var profileId = GetOrCreateProfileId(); + var deviceId = Guid.TryParse(settings.Get(settings.CLOUD_DEVICE_ID), out var configuredDevice) + ? configuredDevice + : Guid.Empty; + var nand = PathManager.GetActiveNandPath(); + var rksys = await File.ReadAllBytesAsync(rksysPath); + var extractedMii = await miis.ExtractProfileMiiAsync(rksys); + var licensePreviews = ReadLicensePreviews(rksys); + var focusedSlot = Math.Clamp(settings.Get(settings.FOCUSED_USER), 0, 3); + var package = new CloudProfilePackage + { + ProfileId = profileId, + ProfileName = licensePreviews[focusedSlot].Name, + RksysData = rksys, + MiiData = extractedMii.Data, + MiiMissingOnSource = extractedMii.Identifier is not null && extractedMii.Data is null, + LicensePreviews = licensePreviews, + RrRatingData = ReadOptional(PathManager.GetRetroRewindRatingPath(nand)), + RrSettingsData = ReadOptional(PathManager.GetRetroRewindSettingsPath(nand)), + RrGameSettingsData = ReadOptional(PathManager.GetRetroRewindGameSettingsPath(nand)), + GhostData = ReadGhosts(), + Manifest = new CloudProfileManifest + { + ProfileId = profileId, + LastDeviceId = deviceId, + LastModifiedUtc = DateTime.UtcNow, + }, + }; + package.Manifest = CreateManifest(package, profileId, deviceId, 0, []); + return package; + } + + public async Task ApplyProfileAsync(CloudProfilePackage profile) + { + await ValidateProfileAsync(profile); + // This is deliberately inside the apply boundary, rather than left to callers to remember. + await backups.CreateBackupAsync(); + + var nand = PathManager.GetActiveNandPath(); + if (profile.MiiData is not null) + await miis.EnsureMiiPresentAsync(profile.MiiData); + + var rksysPath = distributions.RetroRewind.FindExistingRksysPath() ?? PathManager.GetRetroWfcSavePath(); + WriteAtomic(rksysPath, profile.RksysData); + WriteOptional(PathManager.GetRetroRewindRatingPath(nand), profile.RrRatingData); + WriteOptional(PathManager.GetRetroRewindSettingsPath(nand), profile.RrSettingsData); + WriteOptional(PathManager.GetRetroRewindGameSettingsPath(nand), profile.RrGameSettingsData); + foreach (var (relativePath, data) in profile.GhostData) + { + if (!IsSafeRelativePath(relativePath)) + throw new InvalidDataException("Cloud package contains an unsafe ghost path."); + WriteAtomic(Path.Combine(PathManager.RetroRewind6FolderPath, "Ghosts", relativePath), data); + } + } + + public Task ValidateProfileAsync(CloudProfilePackage profile) + { + if (profile.ProfileId == Guid.Empty || profile.Manifest.ProfileId != profile.ProfileId) + throw new InvalidDataException("Cloud profile has no valid profile ID."); + if (profile.Manifest.SchemaVersion != CloudProfileManifest.CurrentSchemaVersion || profile.RksysData.Length == 0) + throw new InvalidDataException("Cloud profile schema or rksys.dat is invalid."); + if (profile.MiiData is not null && profile.MiiData.Length != 74) + throw new InvalidDataException("Cloud profile contains an invalid Mii block."); + if (profile.MiiData is null && !profile.MiiMissingOnSource) + throw new InvalidDataException("Cloud profile has no Mii state; refusing to apply an ambiguous license."); + if (profile.MiiData is not null && profile.MiiMissingOnSource) + throw new InvalidDataException("Cloud profile has contradictory Mii state."); + if (profile.Manifest.MiiMissingOnSource != profile.MiiMissingOnSource) + throw new InvalidDataException("Cloud profile Mii state does not match its manifest."); + if (profile.GhostData.Keys.Any(path => !IsSafeRelativePath(path))) + throw new InvalidDataException("Cloud profile contains an unsafe ghost path."); + if (CloudHash.Of(BuildContentBytes(profile)) != profile.Manifest.ContentHash) + throw new InvalidDataException("Cloud profile hash does not match its manifest."); + return Task.CompletedTask; + } + + public async Task WritePackageAsync(CloudProfilePackage profile, string zipPath) + { + await ValidateProfileAsync(profile); + Directory.CreateDirectory(Path.GetDirectoryName(zipPath)!); + if (File.Exists(zipPath)) + File.Delete(zipPath); + using var archive = ZipFile.Open(zipPath, ZipArchiveMode.Create); + WriteEntry(archive, "MarioKart/rksys.dat", profile.RksysData); + WriteEntry(archive, "Mii/profile.mii", profile.MiiData); + WriteEntry(archive, "RetroRewind/RRRating.pul", profile.RrRatingData); + WriteEntry(archive, "RetroRewind/RRSettings.pul", profile.RrSettingsData); + WriteEntry(archive, "RetroRewind/RRGameSettings.pul", profile.RrGameSettingsData); + foreach (var (name, data) in profile.GhostData) + WriteEntry(archive, $"Ghosts/{name.Replace('\\', '/')}", data); + WriteEntry(archive, "manifest.json", JsonSerializer.SerializeToUtf8Bytes(profile.Manifest, JsonOptions)); + } + + public Task ReadPackageAsync(string zipPath) + { + using var archive = ZipFile.OpenRead(zipPath); + var manifest = + JsonSerializer.Deserialize(ReadRequired(archive, "manifest.json"), JsonOptions) + ?? throw new InvalidDataException("Cloud package manifest is missing."); + var package = new CloudProfilePackage + { + ProfileId = manifest.ProfileId, + ProfileName = manifest.ProfileName, + RksysData = ReadRequired(archive, "MarioKart/rksys.dat"), + MiiData = ReadOptional(archive, "Mii/profile.mii"), + MiiMissingOnSource = manifest.MiiMissingOnSource, + LicensePreviews = manifest.LicensePreviews, + RrRatingData = ReadOptional(archive, "RetroRewind/RRRating.pul"), + RrSettingsData = ReadOptional(archive, "RetroRewind/RRSettings.pul"), + RrGameSettingsData = ReadOptional(archive, "RetroRewind/RRGameSettings.pul"), + GhostData = ReadGhostEntries(archive), + Manifest = manifest, + }; + return Task.FromResult(package); + } + + public static CloudProfileManifest CreateManifest( + CloudProfilePackage package, + Guid profileId, + Guid deviceId, + long revision, + List devices + ) + { + var files = BuildFileEntries(package); + return new CloudProfileManifest + { + ProfileId = profileId, + ProfileName = package.ProfileName, + Revision = revision, + LastDeviceId = deviceId, + LastModifiedUtc = DateTime.UtcNow, + ContentHash = CloudHash.Of(BuildContentBytes(package)), + MiiMissingOnSource = package.MiiMissingOnSource, + MiiPreviewData = package.MiiData, + LicensePreviews = package.LicensePreviews.ToList(), + Devices = devices, + Files = files, + }; + } + + private static byte[] BuildContentBytes(CloudProfilePackage package) => + BuildFileEntries(package) + .SelectMany(entry => System.Text.Encoding.UTF8.GetBytes($"{entry.LogicalName}:{entry.Hash}\n")) + .Concat(System.Text.Encoding.UTF8.GetBytes($"mii-missing-on-source:{package.MiiMissingOnSource}\n")) + .ToArray(); + + private static List BuildFileEntries(CloudProfilePackage package) + { + var entries = new List(); + Add(entries, "MarioKart/rksys.dat", package.RksysData, false); + Add(entries, "Mii/profile.mii", package.MiiData, true); + Add(entries, "RetroRewind/RRRating.pul", package.RrRatingData, true); + Add(entries, "RetroRewind/RRSettings.pul", package.RrSettingsData, true); + Add(entries, "RetroRewind/RRGameSettings.pul", package.RrGameSettingsData, true); + foreach (var (name, data) in package.GhostData.OrderBy(pair => pair.Key, StringComparer.Ordinal)) + Add(entries, $"Ghosts/{name}", data, true); + return entries; + } + + private static void Add(List entries, string name, byte[]? data, bool optional) + { + if (data is not null) + entries.Add(new FileManifestEntry(name, CloudHash.Of(data), data.LongLength, optional)); + } + + private static byte[]? ReadOptional(string path) => File.Exists(path) ? File.ReadAllBytes(path) : null; + + private static IReadOnlyDictionary ReadGhosts() + { + var root = Path.Combine(PathManager.RetroRewind6FolderPath, "Ghosts"); + return !Directory.Exists(root) + ? new Dictionary() + : Directory + .GetFiles(root, "*", SearchOption.AllDirectories) + .ToDictionary(file => Path.GetRelativePath(root, file), File.ReadAllBytes); + } + + private static IReadOnlyList ReadLicensePreviews(byte[] rksys) + { + const int rksysHeaderSize = 0x08; + const int rkpdSize = 0x8CC0; + const int miiLicenseNameOffset = 0x14; + const int profileIdOffset = 0x5C; + const int vrOffset = 0xB0; + const int brOffset = 0xB2; + const int licenseNameLength = 10; + const string rkpdMagic = "RKPD"; + + var previews = new List(4); + for (var slot = 0; slot < 4; slot++) + { + var offset = rksysHeaderSize + slot * rkpdSize; + var present = rksys.Length >= offset + rkpdSize && Encoding.ASCII.GetString(rksys, offset, rkpdMagic.Length) == rkpdMagic; + if (!present) + { + previews.Add(new CloudLicensePreview(slot, "No license", "", 0, 0, false)); + continue; + } + + var name = Encoding + .BigEndianUnicode.GetString(rksys, offset + miiLicenseNameOffset, licenseNameLength * sizeof(char)) + .TrimEnd('\0') + .Trim(); + var profileId = BinaryPrimitives.ReadUInt32BigEndian(rksys.AsSpan(offset + profileIdOffset, sizeof(uint))); + previews.Add( + new CloudLicensePreview( + slot, + string.IsNullOrWhiteSpace(name) ? $"License {slot + 1}" : name, + profileId == 0 ? "" : FriendCodeGenerator.GetFriendCode(rksys, offset + profileIdOffset), + BinaryPrimitives.ReadUInt16BigEndian(rksys.AsSpan(offset + vrOffset, sizeof(ushort))), + BinaryPrimitives.ReadUInt16BigEndian(rksys.AsSpan(offset + brOffset, sizeof(ushort))), + true + ) + ); + } + return previews; + } + + private Guid GetOrCreateProfileId() + { + if (Guid.TryParse(settings.Get(settings.CLOUD_PROFILE_ID), out var configuredId) && configuredId != Guid.Empty) + return configuredId; + + var profileId = Guid.NewGuid(); + settings.Set(settings.CLOUD_PROFILE_ID, profileId.ToString("D")); + return profileId; + } + + private static void WriteAtomic(string path, byte[] data) + { + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + var temporary = path + ".cloudsync.tmp"; + File.WriteAllBytes(temporary, data); + File.Move(temporary, path, overwrite: true); + } + + private static void WriteOptional(string path, byte[]? data) + { + if (data is not null) + WriteAtomic(path, data); + } + + private static void WriteEntry(ZipArchive archive, string path, byte[]? data) + { + if (data is null) + return; + using var stream = archive.CreateEntry(path, CompressionLevel.Optimal).Open(); + stream.Write(data); + } + + private static byte[] ReadRequired(ZipArchive archive, string name) => + ReadOptional(archive, name) ?? throw new InvalidDataException($"Cloud package is missing {name}."); + + private static byte[]? ReadOptional(ZipArchive archive, string name) => archive.GetEntry(name) is { } entry ? ReadEntry(entry) : null; + + private static byte[] ReadEntry(ZipArchiveEntry entry) + { + using var stream = entry.Open(); + using var memory = new MemoryStream(); + stream.CopyTo(memory); + return memory.ToArray(); + } + + private static IReadOnlyDictionary ReadGhostEntries(ZipArchive archive) + { + var entries = archive.Entries.Where(entry => + entry.FullName.StartsWith("Ghosts/", StringComparison.Ordinal) && !entry.FullName.EndsWith('/') + ); + var result = new Dictionary(StringComparer.Ordinal); + foreach (var entry in entries) + { + var relativePath = entry.FullName[7..]; + if (!IsSafeRelativePath(relativePath) || !result.TryAdd(relativePath, ReadEntry(entry))) + throw new InvalidDataException("Cloud package contains an unsafe or duplicate ghost path."); + } + return result; + } + + private static bool IsSafeRelativePath(string path) => + !Path.IsPathRooted(path) + && path.Split(['/', '\\'], StringSplitOptions.RemoveEmptyEntries).All(segment => segment is not "." and not ".."); +} diff --git a/WheelWizard/Features/CloudSync/Profile/ICloudProfileService.cs b/WheelWizard/Features/CloudSync/Profile/ICloudProfileService.cs new file mode 100644 index 000000000..d9db93e62 --- /dev/null +++ b/WheelWizard/Features/CloudSync/Profile/ICloudProfileService.cs @@ -0,0 +1,10 @@ +namespace WheelWizard.CloudSync.Profile; + +public interface ICloudProfileService +{ + Task CaptureProfileAsync(); + Task ApplyProfileAsync(CloudProfilePackage profile); + Task ValidateProfileAsync(CloudProfilePackage profile); + Task WritePackageAsync(CloudProfilePackage profile, string zipPath); + Task ReadPackageAsync(string zipPath); +} diff --git a/WheelWizard/Features/CloudSync/ProfileLibrary/CloudProfileLibraryService.cs b/WheelWizard/Features/CloudSync/ProfileLibrary/CloudProfileLibraryService.cs new file mode 100644 index 000000000..f5ee0c29e --- /dev/null +++ b/WheelWizard/Features/CloudSync/ProfileLibrary/CloudProfileLibraryService.cs @@ -0,0 +1,285 @@ +using System.Text.Json; +using WheelWizard.Services; +using WheelWizard.Settings; +using WheelWizard.Settings.Types; +using WheelWizard.WiiManagement.GameLicense; +using WheelWizard.WiiManagement.GameLicense.Domain; +using WheelWizard.WiiManagement.MiiManagement; + +namespace WheelWizard.CloudSync.ProfileLibrary; + +public sealed class CloudProfileLibraryService( + IGameLicenseSingletonService gameLicenses, + ICloudSyncService cloudSync, + ISettingsManager settings, + IVirtualProfileVaultService vault, + IProfileCloudBindingService bindings +) : ICloudProfileLibraryService +{ + public async Task> GetAllAsync() + { + var result = new List(); + var localUpdatedUtc = File.Exists(PathManager.GetRetroWfcSavePath()) + ? File.GetLastWriteTimeUtc(PathManager.GetRetroWfcSavePath()) + : (DateTime?)null; + foreach (var (license, slot) in gameLicenses.LicenseCollection.Users.Select((license, slot) => (license, slot))) + { + if (!IsUsableLocalLicense(license)) + continue; + result.Add( + new ProfileLibraryEntry( + $"local:{slot}", + ProfileLibrarySource.Local, + ProfileStorageState.LocalOnly, + license.NameOfMii, + license.FriendCode, + license.Mii, + slot, + license.Vr, + license.Br, + localUpdatedUtc + ) + ); + } + + foreach (var virtualProfile in await vault.GetAllAsync()) + { + var miiResult = MiiSerializer.Deserialize(virtualProfile.MiiData); + result.Add( + new ProfileLibraryEntry( + $"vault:{virtualProfile.ProfileId:D}", + ProfileLibrarySource.Vault, + ProfileStorageState.LocalOnly, + virtualProfile.Name, + virtualProfile.FriendCode, + miiResult.IsSuccess ? miiResult.Value : null, + null, + virtualProfile.Vr, + virtualProfile.Br, + virtualProfile.UpdatedUtc + ) + ); + } + + if (!settings.Get(settings.CLOUD_SYNC_ENABLED)) + return result; + + try + { + var cloudProfiles = await cloudSync.GetAvailableProfilesAsync(); + var displayedCloudIdentities = new HashSet(StringComparer.Ordinal); + foreach (var profile in cloudProfiles) + { + var license = SelectProfileLicensePreview(profile); + var miiResult = MiiSerializer.Deserialize(profile.MiiPreviewData); + var cloudMii = miiResult.IsSuccess ? miiResult.Value : null; + var cloudIdentity = GetCloudIdentity(profile.ProfileName, license?.FriendCode, cloudMii?.MiiId); + + // GetAvailableProfilesAsync orders newest first. Multiple records can exist after a profile + // was synced before profile bindings were introduced, so retain the newest one and never make + // the same Mario Kart license look like several cloud-only profiles. + if (cloudIdentity is not null && !displayedCloudIdentities.Add(cloudIdentity)) + continue; + + var localMatch = FindMatchingLocalProfile( + result, + profile.ProfileName, + [license?.FriendCode ?? string.Empty], + cloudMii?.MiiId + ); + if (localMatch is not null) + { + // Keep the local key and slot: choosing this card must continue to select the + // existing physical license when WiiCompiled is launched. + var matchingLocalIndex = localMatch.Index; + result[matchingLocalIndex] = result[matchingLocalIndex] with + { + StorageState = ProfileStorageState.CloudAndLocal, + LastUpdatedUtc = Max(result[matchingLocalIndex].LastUpdatedUtc, profile.LastModifiedUtc), + }; + // A name alone is only a presentation hint: two independent licenses can + // share it. Persist a cloud identity only after a strong friend-code or Mii + // match, so a name collision can never redirect future sync operations. + if (localMatch.IsStrong && result[matchingLocalIndex].LocalSlot is int localSlot) + await bindings.BindAsync( + localSlot, + ProfileCloudBindingIdentity.Create(result[matchingLocalIndex]), + profile.ProfileId + ); + continue; + } + + result.Add( + new ProfileLibraryEntry( + $"cloud:{profile.ProfileId:D}", + ProfileLibrarySource.Cloud, + ProfileStorageState.CloudOnly, + profile.ProfileName, + license?.FriendCode ?? string.Empty, + cloudMii, + null, + license?.Vr ?? 0, + license?.Br ?? 0, + profile.LastModifiedUtc + ) + ); + } + } + catch + { + // The local library remains usable if the provider is disconnected or temporarily unavailable. + } + + return result; + } + + private static CloudLicensePreview? SelectProfileLicensePreview(CloudProfileManifest profile) + { + var matchingNamePreviews = profile + .LicensePreviews.Where(preview => + preview.IsPresent && string.Equals(preview.Name, profile.ProfileName, StringComparison.Ordinal) + ) + .ToList(); + return matchingNamePreviews.Count == 1 + ? matchingNamePreviews[0] + : profile.LicensePreviews.FirstOrDefault(preview => preview.IsPresent); + } + + private static LocalProfileMatch? FindMatchingLocalProfile( + IReadOnlyList localProfiles, + string cloudProfileName, + IEnumerable cloudFriendCodes, + uint? cloudMiiId + ) + { + var localIndexes = Enumerable + .Range(0, localProfiles.Count) + .Where(index => + localProfiles[index].Source is ProfileLibrarySource.Local or ProfileLibrarySource.Vault + && localProfiles[index].StorageState == ProfileStorageState.LocalOnly + ) + .ToList(); + + // The cloud Mii preview belongs to the focused profile, so it is the strongest identity when present. + var miiMatches = cloudMiiId is > 0 ? localIndexes.Where(index => localProfiles[index].Mii?.MiiId == cloudMiiId).ToList() : []; + if (miiMatches.Count == 1) + return new LocalProfileMatch(miiMatches[0], IsStrong: true); + + // A standard cloud package contains the entire rksys.dat, not only the focused profile. Check every + // license preview instead of assuming the first present slot is the profile represented by the card. + var normalizedCloudFriendCodes = cloudFriendCodes.Select(NormalizeFriendCode).Where(code => code is not null).ToHashSet(); + var friendCodeMatches = localIndexes + .Where(index => normalizedCloudFriendCodes.Contains(NormalizeFriendCode(localProfiles[index].FriendCode))) + .ToList(); + if (friendCodeMatches.Count == 1) + return new LocalProfileMatch(friendCodeMatches[0], IsStrong: true); + + // Offline profiles have neither a usable friend code nor a reliably persisted Mii. A unique name is a + // safe fallback; when several local profiles share a name, leave the cloud entry separate rather than + // potentially merging two distinct licenses. + var nameMatches = localIndexes + .Where(index => string.Equals(localProfiles[index].Name, cloudProfileName, StringComparison.Ordinal)) + .ToList(); + return nameMatches.Count == 1 ? new LocalProfileMatch(nameMatches[0], IsStrong: false) : null; + } + + private sealed record LocalProfileMatch(int Index, bool IsStrong); + + private static string? NormalizeFriendCode(string? friendCode) + { + if (string.IsNullOrWhiteSpace(friendCode)) + return null; + + var digits = new string(friendCode.Where(char.IsDigit).ToArray()); + return digits.Length == 12 && digits != "000000000000" ? digits : null; + } + + private static string? GetCloudIdentity(string profileName, string? friendCode, uint? miiId) + { + var normalizedFriendCode = NormalizeFriendCode(friendCode); + if (normalizedFriendCode is not null) + return $"friend:{normalizedFriendCode}"; + if (miiId is > 0) + return $"mii:{miiId}"; + + return string.IsNullOrWhiteSpace(profileName) ? null : $"name:{profileName.Trim()}"; + } + + /// GameLicenseService represents an empty RKPD slot as a dummy Mii named "no license". + private static bool IsUsableLocalLicense(LicenseProfile license) => + license.Mii is not null + && !string.IsNullOrWhiteSpace(license.NameOfMii) + && !string.Equals(license.NameOfMii, SettingValues.NoLicense, StringComparison.OrdinalIgnoreCase); + + private static DateTime? Max(DateTime? first, DateTime second) => first is null || second > first.Value ? second : first; + + public IReadOnlyList GetVisible(IReadOnlyList profiles) + { + if (string.IsNullOrWhiteSpace(settings.Get(settings.CLOUD_VISIBLE_PROFILE_IDS))) + return profiles.Where(profile => profile.Source is ProfileLibrarySource.Local or ProfileLibrarySource.Vault).Take(4).ToList(); + + var keys = ReadVisibleKeys(); + return keys.Select(key => profiles.FirstOrDefault(profile => profile.Key == key)) + .Where(profile => profile is not null) + .Take(4) + .Cast() + .ToList(); + } + + public void SaveVisible(IEnumerable profileKeys) + { + var keys = profileKeys.Where(key => !string.IsNullOrWhiteSpace(key)).Distinct(StringComparer.Ordinal).Take(4).ToList(); + settings.Set(settings.CLOUD_VISIBLE_PROFILE_IDS, JsonSerializer.Serialize(keys)); + } + + public IReadOnlyList GetSyncSelected(IReadOnlyList profiles) + { + var storedValue = settings.Get(settings.CLOUD_SYNC_PROFILE_IDS); + if (string.IsNullOrWhiteSpace(storedValue)) + return profiles.Where(profile => profile.Source is ProfileLibrarySource.Local or ProfileLibrarySource.Vault).ToList(); + + var keys = ReadKeys(storedValue); + return keys.Select(key => + profiles.FirstOrDefault(profile => + profile.Key == key && profile.Source is ProfileLibrarySource.Local or ProfileLibrarySource.Vault + ) + ) + .Where(profile => profile is not null) + .Cast() + .ToList(); + } + + public void SaveSyncSelected(IEnumerable profileKeys) + { + var keys = profileKeys + .Where(key => key.StartsWith("local:", StringComparison.Ordinal) || key.StartsWith("vault:", StringComparison.Ordinal)) + .Where(key => !string.IsNullOrWhiteSpace(key)) + .Distinct(StringComparer.Ordinal) + .ToList(); + settings.Set(settings.CLOUD_SYNC_PROFILE_IDS, JsonSerializer.Serialize(keys)); + } + + private IReadOnlyList ReadVisibleKeys() + { + try + { + return ReadKeys(settings.Get(settings.CLOUD_VISIBLE_PROFILE_IDS)); + } + catch (JsonException) + { + return []; + } + } + + private static IReadOnlyList ReadKeys(string value) + { + try + { + return JsonSerializer.Deserialize>(value) ?? []; + } + catch (JsonException) + { + return []; + } + } +} diff --git a/WheelWizard/Features/CloudSync/ProfileLibrary/ICloudProfileLibraryService.cs b/WheelWizard/Features/CloudSync/ProfileLibrary/ICloudProfileLibraryService.cs new file mode 100644 index 000000000..cafa0c634 --- /dev/null +++ b/WheelWizard/Features/CloudSync/ProfileLibrary/ICloudProfileLibraryService.cs @@ -0,0 +1,41 @@ +using MiiModel = WheelWizard.WiiManagement.MiiManagement.Domain.Mii.Mii; + +namespace WheelWizard.CloudSync.ProfileLibrary; + +public enum ProfileLibrarySource +{ + Local, + Vault, + Cloud, +} + +/// Where the profile can currently be used from. +public enum ProfileStorageState +{ + LocalOnly, + CloudOnly, + CloudAndLocal, +} + +/// A display-only profile reference. Selecting it never modifies a save or applies cloud data. +public sealed record ProfileLibraryEntry( + string Key, + ProfileLibrarySource Source, + ProfileStorageState StorageState, + string Name, + string FriendCode, + MiiModel? Mii, + int? LocalSlot, + uint Vr = 0, + uint Br = 0, + DateTime? LastUpdatedUtc = null +); + +public interface ICloudProfileLibraryService +{ + Task> GetAllAsync(); + IReadOnlyList GetVisible(IReadOnlyList profiles); + void SaveVisible(IEnumerable profileKeys); + IReadOnlyList GetSyncSelected(IReadOnlyList profiles); + void SaveSyncSelected(IEnumerable profileKeys); +} diff --git a/WheelWizard/Features/CloudSync/ProfileLibrary/IProfileCloudBindingService.cs b/WheelWizard/Features/CloudSync/ProfileLibrary/IProfileCloudBindingService.cs new file mode 100644 index 000000000..3f71ca4f8 --- /dev/null +++ b/WheelWizard/Features/CloudSync/ProfileLibrary/IProfileCloudBindingService.cs @@ -0,0 +1,10 @@ +namespace WheelWizard.CloudSync.ProfileLibrary; + +/// Assigns each physical Mario Kart license its own persistent cloud profile ID. +public interface IProfileCloudBindingService +{ + Task GetProfileIdAsync(int localSlot, string licenseIdentity); + + /// Binds an already matched physical slot to its canonical remote profile ID. + Task BindAsync(int localSlot, string licenseIdentity, Guid profileId); +} diff --git a/WheelWizard/Features/CloudSync/ProfileLibrary/IVirtualProfileCloudService.cs b/WheelWizard/Features/CloudSync/ProfileLibrary/IVirtualProfileCloudService.cs new file mode 100644 index 000000000..25c815af6 --- /dev/null +++ b/WheelWizard/Features/CloudSync/ProfileLibrary/IVirtualProfileCloudService.cs @@ -0,0 +1,15 @@ +namespace WheelWizard.CloudSync.ProfileLibrary; + +/// +/// Synchronizes an individual vault license. A vault profile deliberately has its +/// own cloud ID and state file, so it never shares revisions or conflicts with a +/// different Mario Kart license. +/// +public interface IVirtualProfileCloudService +{ + Task PullAsync(Guid profileId); + Task PushAsync(Guid profileId); + Task DownloadToVaultAsync(Guid profileId); + Task PullLocalSlotAsync(Guid profileId, int localSlot); + Task PushLocalSlotAsync(Guid profileId, int localSlot); +} diff --git a/WheelWizard/Features/CloudSync/ProfileLibrary/IVirtualProfileVaultService.cs b/WheelWizard/Features/CloudSync/ProfileLibrary/IVirtualProfileVaultService.cs new file mode 100644 index 000000000..730110055 --- /dev/null +++ b/WheelWizard/Features/CloudSync/ProfileLibrary/IVirtualProfileVaultService.cs @@ -0,0 +1,31 @@ +using WheelWizard.WiiManagement.MiiManagement.Domain.Mii; + +namespace WheelWizard.CloudSync.ProfileLibrary; + +/// +/// Stores additional local licences outside the four physical Mario Kart Wii slots. Records are +/// materialised into a temporary rksys.dat view only while WiiCompiled is running. +/// +public sealed record VirtualProfileRecord( + Guid ProfileId, + byte[] LicenseData, + byte[]? MiiData, + string Name, + string FriendCode, + uint Vr, + uint Br, + DateTime UpdatedUtc, + bool IsLocalMirror = false +); + +public interface IVirtualProfileVaultService +{ + Task> GetAllAsync(); + Task GetAsync(Guid profileId); + Task CreateAsync(byte[] licenseData); + Task UpdateAsync(Guid profileId, byte[] licenseData); + + /// Imports a cloud profile without assigning it to one of the four physical save slots. + Task ImportAsync(Guid profileId, byte[] licenseData, byte[]? miiData, bool isLocalMirror = false); + Task EnsureMiiPresentAsync(VirtualProfileRecord profile); +} diff --git a/WheelWizard/Features/CloudSync/ProfileLibrary/IVisibleProfileLaunchService.cs b/WheelWizard/Features/CloudSync/ProfileLibrary/IVisibleProfileLaunchService.cs new file mode 100644 index 000000000..eb4562974 --- /dev/null +++ b/WheelWizard/Features/CloudSync/ProfileLibrary/IVisibleProfileLaunchService.cs @@ -0,0 +1,8 @@ +namespace WheelWizard.CloudSync.ProfileLibrary; + +/// Creates a reversible, per-launch rksys.dat view containing only the chosen local slots. +public interface IVisibleProfileLaunchService +{ + Task PrepareAsync(); + Task RestoreAsync(); +} diff --git a/WheelWizard/Features/CloudSync/ProfileLibrary/ProfileCloudBindingIdentity.cs b/WheelWizard/Features/CloudSync/ProfileLibrary/ProfileCloudBindingIdentity.cs new file mode 100644 index 000000000..c83485007 --- /dev/null +++ b/WheelWizard/Features/CloudSync/ProfileLibrary/ProfileCloudBindingIdentity.cs @@ -0,0 +1,16 @@ +using WheelWizard.WiiManagement.GameLicense.Domain; + +namespace WheelWizard.CloudSync.ProfileLibrary; + +public static class ProfileCloudBindingIdentity +{ + public static string Create(LicenseProfile profile) => Create(profile.FriendCode, profile.Mii?.MiiId, profile.NameOfMii); + + public static string Create(ProfileLibraryEntry profile) => Create(profile.FriendCode, profile.Mii?.MiiId, profile.Name); + + private static string Create(string? friendCode, uint? miiId, string? name) + { + var friendCodeDigits = string.IsNullOrWhiteSpace(friendCode) ? string.Empty : new string(friendCode.Where(char.IsDigit).ToArray()); + return $"friend:{friendCodeDigits}|mii:{miiId ?? 0}|name:{name?.Trim() ?? string.Empty}"; + } +} diff --git a/WheelWizard/Features/CloudSync/ProfileLibrary/ProfileCloudBindingService.cs b/WheelWizard/Features/CloudSync/ProfileLibrary/ProfileCloudBindingService.cs new file mode 100644 index 000000000..7e5ede769 --- /dev/null +++ b/WheelWizard/Features/CloudSync/ProfileLibrary/ProfileCloudBindingService.cs @@ -0,0 +1,89 @@ +using System.Text.Json; +using WheelWizard.Services; + +namespace WheelWizard.CloudSync.ProfileLibrary; + +public sealed class ProfileCloudBindingService : IProfileCloudBindingService +{ + private readonly SemaphoreSlim _gate = new(1, 1); + private string Path => System.IO.Path.Combine(PathManager.CloudSyncStateFolderPath, "local-profile-cloud-bindings.json"); + + public async Task GetProfileIdAsync(int localSlot, string licenseIdentity) + { + if (localSlot is < 0 or >= 4) + throw new ArgumentOutOfRangeException(nameof(localSlot)); + ArgumentException.ThrowIfNullOrWhiteSpace(licenseIdentity); + await _gate.WaitAsync(); + try + { + var bindings = await ReadAsync(); + if ( + bindings.TryGetValue(localSlot, out var binding) + && binding.LicenseIdentity == licenseIdentity + && binding.ProfileId != Guid.Empty + ) + return binding.ProfileId; + + var profileId = Guid.NewGuid(); + bindings[localSlot] = new LocalProfileBinding(licenseIdentity, profileId); + await WriteAsync(bindings); + return profileId; + } + finally + { + _gate.Release(); + } + } + + public async Task BindAsync(int localSlot, string licenseIdentity, Guid profileId) + { + if (localSlot is < 0 or >= 4) + throw new ArgumentOutOfRangeException(nameof(localSlot)); + ArgumentException.ThrowIfNullOrWhiteSpace(licenseIdentity); + if (profileId == Guid.Empty) + throw new ArgumentException("A cloud profile binding requires a profile ID.", nameof(profileId)); + + await _gate.WaitAsync(); + try + { + var bindings = await ReadAsync(); + if ( + bindings.TryGetValue(localSlot, out var current) + && current.ProfileId == profileId + && current.LicenseIdentity == licenseIdentity + ) + return; + bindings[localSlot] = new LocalProfileBinding(licenseIdentity, profileId); + await WriteAsync(bindings); + } + finally + { + _gate.Release(); + } + } + + private async Task> ReadAsync() + { + if (!File.Exists(Path)) + return []; + try + { + return JsonSerializer.Deserialize>(await File.ReadAllTextAsync(Path)) ?? []; + } + catch (JsonException) + { + // A malformed binding file must never make us repurpose an existing cloud profile. + return []; + } + } + + private async Task WriteAsync(Dictionary bindings) + { + Directory.CreateDirectory(System.IO.Path.GetDirectoryName(Path)!); + var temporary = Path + ".tmp"; + await File.WriteAllTextAsync(temporary, JsonSerializer.Serialize(bindings)); + File.Move(temporary, Path, overwrite: true); + } + + private sealed record LocalProfileBinding(string LicenseIdentity, Guid ProfileId); +} diff --git a/WheelWizard/Features/CloudSync/ProfileLibrary/ProfileLibraryChangeNotifier.cs b/WheelWizard/Features/CloudSync/ProfileLibrary/ProfileLibraryChangeNotifier.cs new file mode 100644 index 000000000..698e6aeb7 --- /dev/null +++ b/WheelWizard/Features/CloudSync/ProfileLibrary/ProfileLibraryChangeNotifier.cs @@ -0,0 +1,9 @@ +namespace WheelWizard.CloudSync.ProfileLibrary; + +/// Signals that WiiCompiled has changed the local license library. +public static class ProfileLibraryChangeNotifier +{ + public static event EventHandler? Changed; + + public static void NotifyChanged() => Changed?.Invoke(null, EventArgs.Empty); +} diff --git a/WheelWizard/Features/CloudSync/ProfileLibrary/VirtualProfileCloudService.cs b/WheelWizard/Features/CloudSync/ProfileLibrary/VirtualProfileCloudService.cs new file mode 100644 index 000000000..45cfb23fd --- /dev/null +++ b/WheelWizard/Features/CloudSync/ProfileLibrary/VirtualProfileCloudService.cs @@ -0,0 +1,458 @@ +using System.Text.Json; +using WheelWizard.CloudSync.Backup; +using WheelWizard.CloudSync.Conflict; +using WheelWizard.CloudSync.Enrollment; +using WheelWizard.CloudSync.Profile; +using WheelWizard.CloudSync.Providers; +using WheelWizard.CustomDistributions; +using WheelWizard.Services; +using WheelWizard.Settings; +using WheelWizard.WiiManagement.GameLicense; + +namespace WheelWizard.CloudSync.ProfileLibrary; + +/// +/// Cloud transport for one license held in the local vault. The package is a +/// complete, valid rksys container because the existing package format requires +/// one, but all other license slots are blanked before it leaves the device. +/// +public sealed class VirtualProfileCloudService( + IVirtualProfileVaultService vault, + ICloudProfileService packages, + ICloudProviderResolver providers, + ICloudConflictResolver conflicts, + IProfileBackupService backups, + IRetroWfcEnrollmentService enrollment, + ICustomDistributionSingletonService distributions, + ISettingsManager settings +) : IVirtualProfileCloudService +{ + private const int RksysHeaderSize = 0x08; + private const int RkpdSize = 0x8CC0; + private const int SlotCount = 4; + private readonly SemaphoreSlim _gate = new(1, 1); + + public Task PullAsync(Guid profileId) => SynchronizeAsync(profileId, pull: true); + + public Task PushAsync(Guid profileId) => SynchronizeAsync(profileId, pull: false); + + public async Task PullLocalSlotAsync(Guid profileId, int localSlot) + { + try + { + await CaptureLocalSlotAsync(profileId, localSlot); + // Do not record the downloaded revision until it was successfully written into the + // physical save slot. Otherwise a failed apply would make the next sync believe an + // older local license is a new change and allow it to overwrite the cloud copy. + return await SynchronizeAsync( + profileId, + pull: true, + beforePullStateCommit: () => ApplyVaultProfileToLocalSlotAsync(profileId, localSlot) + ); + } + catch (Exception ex) + { + return CloudSyncResult.Fail($"Local profile could not be prepared for cloud sync: {ex.Message}"); + } + } + + public async Task PushLocalSlotAsync(Guid profileId, int localSlot) + { + try + { + await CaptureLocalSlotAsync(profileId, localSlot); + return await PushAsync(profileId); + } + catch (Exception ex) + { + return CloudSyncResult.Fail($"Local profile could not be prepared for cloud sync: {ex.Message}"); + } + } + + public async Task DownloadToVaultAsync(Guid profileId) + { + await _gate.WaitAsync(); + try + { + var provider = providers.Resolve(GetProviderType()); + var remote = await ReadManifestAsync(provider, profileId); + if (remote is null) + return CloudSyncResult.Fail("The selected cloud profile no longer exists."); + + return await DownloadAndStoreAsync(provider, remote, profileId, updateState: true); + } + catch (Exception ex) + { + return CloudSyncResult.Fail($"Cloud profile could not be downloaded without changing the local save: {ex.Message}"); + } + finally + { + _gate.Release(); + } + } + + private async Task SynchronizeAsync(Guid profileId, bool pull, Func? beforePullStateCommit = null) + { + await _gate.WaitAsync(); + try + { + var localRecord = await vault.GetAsync(profileId); + if (pull && localRecord is null) + return await DownloadToVaultCoreAsync(profileId); + if (localRecord is null) + return CloudSyncResult.Fail("The selected local profile is no longer available in the profile vault."); + + var provider = providers.Resolve(GetProviderType()); + var local = await CreatePackageAsync(localRecord); + var deviceId = DeviceId; + var state = await ReadStateAsync(profileId, deviceId); + var remote = await ReadManifestAsync(provider, profileId); + if (remote is null) + return pull + ? CloudSyncResult.Ok(CloudSyncAction.NoOp, "No cloud copy exists for this profile yet.") + : await UploadAsync(provider, local, profileId, state, null); + + var comparison = await conflicts.CompareAsync( + new CloudSyncSnapshot(local.Manifest, state.LastKnownCloudRevision, state.LastKnownCloudHash), + new CloudSyncSnapshot(remote, state.LastKnownCloudRevision, state.LastKnownCloudHash) + ); + if (comparison.Kind == ConflictKind.Conflict) + return new CloudSyncResult(false, CloudSyncAction.Conflict, comparison.Message, comparison.Kind); + if (comparison.Kind == ConflictKind.NoOp) + { + await WriteStateAsync( + profileId, + deviceId, + state with + { + LastKnownCloudRevision = remote.Revision, + LastKnownCloudHash = remote.ContentHash, + LastLocalHash = local.Manifest.ContentHash, + } + ); + return CloudSyncResult.Ok(CloudSyncAction.NoOp, comparison.Message); + } + + if (pull) + { + if (comparison.Kind == ConflictKind.SafePush) + return CloudSyncResult.Ok(CloudSyncAction.NoOp, "Local changes will be uploaded after WiiCompiled exits."); + return await DownloadAndStoreAsync( + provider, + remote, + profileId, + updateState: true, + beforeStateCommit: beforePullStateCommit + ); + } + + if (comparison.Kind == ConflictKind.SafePull) + return new CloudSyncResult( + false, + CloudSyncAction.Conflict, + "Cloud changed while this profile was unchanged locally. Pull it before playing.", + comparison.Kind + ); + return await UploadAsync(provider, local, profileId, state, remote); + } + catch (Exception ex) + { + return CloudSyncResult.Fail($"Cloud sync failed without replacing a local license: {ex.Message}"); + } + finally + { + _gate.Release(); + } + } + + // Called only while the semaphore is held by SynchronizeAsync. + private async Task DownloadToVaultCoreAsync(Guid profileId) + { + var provider = providers.Resolve(GetProviderType()); + var remote = await ReadManifestAsync(provider, profileId); + return remote is null + ? CloudSyncResult.Fail("The selected cloud profile no longer exists.") + : await DownloadAndStoreAsync(provider, remote, profileId, updateState: true); + } + + private async Task DownloadAndStoreAsync( + ICloudProvider provider, + CloudProfileManifest remote, + Guid profileId, + bool updateState, + Func? beforeStateCommit = null + ) + { + if (remote.ProfileId != profileId) + return CloudSyncResult.Fail("Cloud manifest profile ID does not match the selected profile."); + + var temporary = Path.Combine(Path.GetTempPath(), $"wheelwizard-vault-{profileId:N}.zip"); + try + { + var packagePath = await ResolvePackagePathAsync(provider, profileId, remote.Revision); + await provider.DownloadAsync(packagePath, temporary); + var downloaded = await packages.ReadPackageAsync(temporary); + await packages.ValidateProfileAsync(downloaded); + if ( + downloaded.Manifest.ProfileId != remote.ProfileId + || downloaded.Manifest.ContentHash != remote.ContentHash + || downloaded.Manifest.Revision != remote.Revision + ) + return CloudSyncResult.Fail("Cloud package does not match its manifest; no local license was replaced."); + + var license = ExtractOnlyLicense(downloaded.RksysData, downloaded.Manifest); + // The backup is taken before the Mii database is changed as well as before the vault item is written. + await backups.CreateBackupAsync(); + var existing = await vault.GetAsync(profileId); + await vault.ImportAsync(profileId, license, downloaded.MiiData, existing?.IsLocalMirror == true); + EnsureVaultProfileIsSelectedForSync(profileId); + if (updateState) + { + if (beforeStateCommit is not null) + await beforeStateCommit(); + var deviceId = DeviceId; + var state = await ReadStateAsync(profileId, deviceId); + await WriteStateAsync( + profileId, + deviceId, + state with + { + LastKnownCloudRevision = remote.Revision, + LastKnownCloudHash = remote.ContentHash, + LastLocalHash = remote.ContentHash, + } + ); + } + return CloudSyncResult.Ok( + CloudSyncAction.Pulled, + "Cloud license was validated, backed up locally, and added to the profile vault." + ); + } + finally + { + if (File.Exists(temporary)) + File.Delete(temporary); + } + } + + private async Task UploadAsync( + ICloudProvider provider, + CloudProfilePackage local, + Guid profileId, + CloudSyncLocalState state, + CloudProfileManifest? remote + ) + { + var revision = (remote?.Revision ?? 0) + 1; + var deviceId = DeviceId; + var devices = (remote?.Devices ?? []) + .Where(device => device.DeviceId != deviceId) + .Append( + new CloudDeviceRecord( + deviceId, + Environment.MachineName, + Environment.OSVersion.Platform.ToString(), + DateTime.UtcNow, + DateTime.UtcNow, + await enrollment.GetStateAsync() + ) + ) + .ToList(); + local.Manifest = CloudProfileService.CreateManifest(local, profileId, deviceId, revision, devices); + + var temporary = Path.Combine(Path.GetTempPath(), $"wheelwizard-vault-{profileId:N}-upload.zip"); + try + { + var expectedManifest = await provider.GetFileInfoAsync(RemotePath(profileId, "manifest.json")); + if (remote is not null && (expectedManifest is null || string.IsNullOrWhiteSpace(expectedManifest.ETag))) + return new CloudSyncResult( + false, + CloudSyncAction.Conflict, + "Cloud manifest changed before this upload started.", + ConflictKind.Conflict + ); + await packages.WritePackageAsync(local, temporary); + await provider.UploadAsync(temporary, PackageRemotePath(profileId, revision)); + var manifestPath = temporary + ".json"; + await File.WriteAllTextAsync(manifestPath, JsonSerializer.Serialize(local.Manifest)); + if (!await provider.UploadIfMatchAsync(manifestPath, RemotePath(profileId, "manifest.json"), expectedManifest?.ETag)) + return new CloudSyncResult( + false, + CloudSyncAction.Conflict, + "Cloud changed while this profile was being uploaded.", + ConflictKind.Conflict + ); + await WriteStateAsync( + profileId, + deviceId, + state with + { + LastKnownCloudRevision = revision, + LastKnownCloudHash = local.Manifest.ContentHash, + LastLocalHash = local.Manifest.ContentHash, + EnrollmentState = await enrollment.GetStateAsync(), + } + ); + return CloudSyncResult.Ok(CloudSyncAction.Pushed, "Profile vault license was uploaded after WiiCompiled exited."); + } + finally + { + if (File.Exists(temporary)) + File.Delete(temporary); + if (File.Exists(temporary + ".json")) + File.Delete(temporary + ".json"); + } + } + + private async Task CreatePackageAsync(VirtualProfileRecord record) + { + var sourcePath = distributions.RetroRewind.FindExistingRksysPath() ?? PathManager.GetRetroWfcSavePath(); + if (!File.Exists(sourcePath)) + throw new FileNotFoundException("RetroWFC rksys.dat was not found for the profile vault."); + var rksys = await File.ReadAllBytesAsync(sourcePath); + if (rksys.Length < 0x2BC000) + throw new InvalidDataException("rksys.dat is too small to create a profile-vault package."); + for (var slot = 0; slot < SlotCount; slot++) + Array.Clear(rksys, RksysHeaderSize + slot * RkpdSize, RkpdSize); + Buffer.BlockCopy(record.LicenseData, 0, rksys, RksysHeaderSize, RkpdSize); + GameLicenseSingletonService.FixRksysCrc(rksys); + var preview = new CloudLicensePreview(0, record.Name, record.FriendCode, record.Vr, record.Br, true); + var package = new CloudProfilePackage + { + ProfileId = record.ProfileId, + ProfileName = record.Name, + RksysData = rksys, + MiiData = record.MiiData, + MiiMissingOnSource = record.MiiData is null, + LicensePreviews = [preview], + Manifest = new CloudProfileManifest { ProfileId = record.ProfileId, LastDeviceId = DeviceId }, + }; + package.Manifest = CloudProfileService.CreateManifest(package, record.ProfileId, DeviceId, 0, []); + return package; + } + + private async Task CaptureLocalSlotAsync(Guid profileId, int localSlot) + { + if (localSlot is < 0 or >= SlotCount) + throw new ArgumentOutOfRangeException(nameof(localSlot)); + var sourcePath = distributions.RetroRewind.FindExistingRksysPath() ?? PathManager.GetRetroWfcSavePath(); + if (!File.Exists(sourcePath)) + throw new FileNotFoundException("RetroWFC rksys.dat was not found for the selected local profile."); + var rksys = await File.ReadAllBytesAsync(sourcePath); + var offset = RksysHeaderSize + localSlot * RkpdSize; + if (rksys.Length < offset + RkpdSize || !rksys.AsSpan(offset, 4).SequenceEqual("RKPD"u8)) + throw new InvalidDataException("The selected local Mario Kart license no longer exists."); + await vault.ImportAsync(profileId, rksys.AsSpan(offset, RkpdSize).ToArray(), null, isLocalMirror: true); + } + + private async Task ApplyVaultProfileToLocalSlotAsync(Guid profileId, int localSlot) + { + if (localSlot is < 0 or >= SlotCount) + throw new ArgumentOutOfRangeException(nameof(localSlot)); + var profile = + await vault.GetAsync(profileId) + ?? throw new InvalidDataException("The downloaded cloud profile is missing from the local vault."); + var targetPath = distributions.RetroRewind.FindExistingRksysPath() ?? PathManager.GetRetroWfcSavePath(); + if (!File.Exists(targetPath)) + throw new FileNotFoundException("RetroWFC rksys.dat was not found for the selected local profile."); + var rksys = await File.ReadAllBytesAsync(targetPath); + var offset = RksysHeaderSize + localSlot * RkpdSize; + if (rksys.Length < 0x2BC000 || rksys.Length < offset + RkpdSize) + throw new InvalidDataException("rksys.dat is too small to apply the cloud license."); + + await backups.CreateBackupAsync(); + await vault.EnsureMiiPresentAsync(profile); + Buffer.BlockCopy(profile.LicenseData, 0, rksys, offset, RkpdSize); + GameLicenseSingletonService.FixRksysCrc(rksys); + var temporary = targetPath + ".cloud-profile.tmp"; + await File.WriteAllBytesAsync(temporary, rksys); + File.Move(temporary, targetPath, overwrite: true); + } + + private static byte[] ExtractOnlyLicense(byte[] rksys, CloudProfileManifest manifest) + { + var present = manifest.LicensePreviews.Where(preview => preview.IsPresent && preview.Slot is >= 0 and < SlotCount).ToList(); + var named = present.Where(preview => string.Equals(preview.Name, manifest.ProfileName, StringComparison.Ordinal)).ToList(); + var selected = + named.Count == 1 ? named[0] + : named.Count == 0 && present.Count == 1 ? present[0] + : null; + if (selected is null) + throw new InvalidDataException("The cloud package does not identify one unambiguous Mario Kart license."); + var offset = RksysHeaderSize + selected.Slot * RkpdSize; + if (rksys.Length < offset + RkpdSize || !rksys.AsSpan(offset, 4).SequenceEqual("RKPD"u8)) + throw new InvalidDataException("The cloud package license preview does not match its rksys.dat slot."); + return rksys.AsSpan(offset, RkpdSize).ToArray(); + } + + private async Task ReadManifestAsync(ICloudProvider provider, Guid profileId) + { + var remotePath = RemotePath(profileId, "manifest.json"); + if (!await provider.ExistsAsync(remotePath)) + return null; + var temporary = Path.Combine(Path.GetTempPath(), $"wheelwizard-vault-{profileId:N}-manifest.json"); + try + { + await provider.DownloadAsync(remotePath, temporary); + return JsonSerializer.Deserialize(await File.ReadAllTextAsync(temporary)) + ?? throw new InvalidDataException("Cloud manifest is empty."); + } + finally + { + if (File.Exists(temporary)) + File.Delete(temporary); + } + } + + private CloudProviderType GetProviderType() => + Enum.TryParse(settings.Get(settings.CLOUD_PROVIDER_TYPE), true, out var type) + ? type + : CloudProviderType.WebDav; + + private Guid DeviceId => Guid.TryParse(settings.Get(settings.CLOUD_DEVICE_ID), out var id) ? id : Guid.Empty; + + private static string RemotePath(Guid profileId, string name) => $"/WheelWizard/CloudSaves/{profileId:D}/{name}"; + + private static string PackageRemotePath(Guid profileId, long revision) => RemotePath(profileId, $"revisions/{revision}/profile.zip"); + + private static async Task ResolvePackagePathAsync(ICloudProvider provider, Guid profileId, long revision) + { + var revisionPath = PackageRemotePath(profileId, revision); + return await provider.ExistsAsync(revisionPath) ? revisionPath : RemotePath(profileId, "profile.zip"); + } + + private static string StatePath(Guid profileId, Guid deviceId) => + Path.Combine(PathManager.CloudSyncStateFolderPath, $"{profileId:D}-{deviceId:D}.json"); + + private static async Task ReadStateAsync(Guid profileId, Guid deviceId) + { + var path = StatePath(profileId, deviceId); + return !File.Exists(path) + ? new CloudSyncLocalState() + : JsonSerializer.Deserialize(await File.ReadAllTextAsync(path)) ?? new CloudSyncLocalState(); + } + + private static async Task WriteStateAsync(Guid profileId, Guid deviceId, CloudSyncLocalState state) + { + var path = StatePath(profileId, deviceId); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + await File.WriteAllTextAsync(path, JsonSerializer.Serialize(state)); + } + + private void EnsureVaultProfileIsSelectedForSync(Guid profileId) + { + try + { + var keys = JsonSerializer.Deserialize>(settings.Get(settings.CLOUD_SYNC_PROFILE_IDS)) ?? []; + var key = $"vault:{profileId:D}"; + if (keys.Contains(key, StringComparer.Ordinal)) + return; + keys.Add(key); + settings.Set(settings.CLOUD_SYNC_PROFILE_IDS, JsonSerializer.Serialize(keys)); + } + catch (JsonException) + { + settings.Set(settings.CLOUD_SYNC_PROFILE_IDS, JsonSerializer.Serialize(new[] { $"vault:{profileId:D}" })); + } + } +} diff --git a/WheelWizard/Features/CloudSync/ProfileLibrary/VirtualProfileVaultService.cs b/WheelWizard/Features/CloudSync/ProfileLibrary/VirtualProfileVaultService.cs new file mode 100644 index 000000000..7541af1f3 --- /dev/null +++ b/WheelWizard/Features/CloudSync/ProfileLibrary/VirtualProfileVaultService.cs @@ -0,0 +1,136 @@ +using System.Buffers.Binary; +using System.Text; +using System.Text.Json; +using WheelWizard.CloudSync.Mii; +using WheelWizard.Services; +using WheelWizard.Utilities.Generators; + +namespace WheelWizard.CloudSync.ProfileLibrary; + +public sealed class VirtualProfileVaultService(IMiiProfileService miis) : IVirtualProfileVaultService +{ + private const int RkpdSize = 0x8CC0; + private const int MiiNameOffset = 0x14; + private const int AvatarIdOffset = 0x28; + private const int ProfileIdOffset = 0x5C; + private const int VrOffset = 0xB0; + private const int BrOffset = 0xB2; + private const int LicenseNameLength = 10; + + private string Folder => Path.Combine(PathManager.CloudSyncStateFolderPath, "virtual-profiles"); + + private string PathFor(Guid profileId) => Path.Combine(Folder, $"{profileId:D}.json"); + + public async Task> GetAllAsync() + { + if (!Directory.Exists(Folder)) + return []; + + var profiles = new List(); + foreach (var path in Directory.EnumerateFiles(Folder, "*.json")) + { + try + { + var profile = JsonSerializer.Deserialize(await File.ReadAllTextAsync(path)); + if (profile is not null && !profile.IsLocalMirror && IsValidLicense(profile.LicenseData)) + profiles.Add(profile); + } + catch (JsonException) + { + // A malformed vault item is ignored rather than making the rest of the library unavailable. + } + } + + return profiles.OrderByDescending(profile => profile.UpdatedUtc).ToList(); + } + + public async Task GetAsync(Guid profileId) + { + var path = PathFor(profileId); + if (!File.Exists(path)) + return null; + try + { + return JsonSerializer.Deserialize(await File.ReadAllTextAsync(path)); + } + catch (JsonException) + { + return null; + } + } + + public async Task CreateAsync(byte[] licenseData) + { + var profile = await BuildAsync(Guid.NewGuid(), licenseData); + await WriteAsync(profile); + return profile; + } + + public async Task UpdateAsync(Guid profileId, byte[] licenseData) + { + var profile = await BuildAsync(profileId, licenseData); + await WriteAsync(profile); + } + + public async Task ImportAsync(Guid profileId, byte[] licenseData, byte[]? miiData, bool isLocalMirror = false) + { + if (miiData is not null) + await miis.EnsureMiiPresentAsync(miiData); + + var profile = await BuildAsync(profileId, licenseData, miiData, isLocalMirror); + await WriteAsync(profile); + } + + public Task EnsureMiiPresentAsync(VirtualProfileRecord profile) => + profile.MiiData is null ? Task.CompletedTask : miis.EnsureMiiPresentAsync(profile.MiiData); + + private async Task BuildAsync( + Guid profileId, + byte[] licenseData, + byte[]? miiOverride = null, + bool isLocalMirror = false + ) + { + if (!IsValidLicense(licenseData)) + throw new InvalidDataException("A virtual profile does not contain a valid Mario Kart license block."); + + var avatarId = BinaryPrimitives.ReadUInt32BigEndian(licenseData.AsSpan(AvatarIdOffset, sizeof(uint))); + byte[]? mii = miiOverride; + if (mii is null && avatarId != 0) + { + try + { + mii = await miis.ExtractMiiAsync(new MiiIdentifier(avatarId)); + } + catch (InvalidDataException) + { + // Keep the missing-Mii state explicit; never substitute another local Mii. + } + } + + var name = Encoding.BigEndianUnicode.GetString(licenseData, MiiNameOffset, LicenseNameLength * sizeof(char)).TrimEnd('\0').Trim(); + var pid = BinaryPrimitives.ReadUInt32BigEndian(licenseData.AsSpan(ProfileIdOffset, sizeof(uint))); + return new VirtualProfileRecord( + profileId, + licenseData.ToArray(), + mii, + string.IsNullOrWhiteSpace(name) ? "Mario Kart license" : name, + pid == 0 ? string.Empty : FriendCodeGenerator.GetFriendCode(licenseData, ProfileIdOffset), + BinaryPrimitives.ReadUInt16BigEndian(licenseData.AsSpan(VrOffset, sizeof(ushort))), + BinaryPrimitives.ReadUInt16BigEndian(licenseData.AsSpan(BrOffset, sizeof(ushort))), + DateTime.UtcNow, + isLocalMirror + ); + } + + private async Task WriteAsync(VirtualProfileRecord profile) + { + Directory.CreateDirectory(Folder); + var path = PathFor(profile.ProfileId); + var temporary = path + ".tmp"; + await File.WriteAllTextAsync(temporary, JsonSerializer.Serialize(profile)); + File.Move(temporary, path, overwrite: true); + } + + private static bool IsValidLicense(byte[] data) => data.Length == RkpdSize && data.AsSpan(0, 4).SequenceEqual("RKPD"u8); +} diff --git a/WheelWizard/Features/CloudSync/ProfileLibrary/VisibleProfileLaunchService.cs b/WheelWizard/Features/CloudSync/ProfileLibrary/VisibleProfileLaunchService.cs new file mode 100644 index 000000000..efea62703 --- /dev/null +++ b/WheelWizard/Features/CloudSync/ProfileLibrary/VisibleProfileLaunchService.cs @@ -0,0 +1,267 @@ +using System.Text.Json; +using WheelWizard.CloudSync.Backup; +using WheelWizard.CustomDistributions; +using WheelWizard.Services; +using WheelWizard.Settings; +using WheelWizard.WiiManagement.GameLicense; + +namespace WheelWizard.CloudSync.ProfileLibrary; + +public sealed class VisibleProfileLaunchService( + ICustomDistributionSingletonService distributions, + IProfileBackupService backups, + ISettingsManager settings, + IGameLicenseSingletonService gameLicenses, + IVirtualProfileVaultService vault, + ICloudSyncService cloudSync, + ICloudProfileLibraryService profileLibrary +) : IVisibleProfileLaunchService +{ + private const int RksysHeaderSize = 0x08; + private const int RkpdSize = 0x8CC0; + private const int SlotCount = 4; + + private string SessionFolder => Path.Combine(PathManager.CloudSyncStateFolderPath, "visible-profile-session"); + private string OriginalPath => Path.Combine(SessionFolder, "original-rksys.dat"); + private string StatePath => Path.Combine(SessionFolder, "state.json"); + + public async Task PrepareAsync() + { + await RestoreAsync(); // Recover before sync/capture can observe an unfinished temporary view. + if (!settings.Get(settings.CLOUD_SYNC_ENABLED)) + return; + + var selected = ReadSelection(); + if (selected.Count == 0 && string.IsNullOrWhiteSpace(settings.Get(settings.CLOUD_VISIBLE_PROFILE_IDS))) + { + var profiles = await profileLibrary.GetAllAsync(); + selected = profileLibrary.GetVisible(profiles).Select(profile => profile.Key).ToList(); + } + if (selected.Count == 0) + return; + // A cloud-only entry is materialized in the vault first. This preserves every + // physical save slot and lets a fifth cloud profile be selected for this launch. + var requestedCloudProfiles = selected + .Select(ParseCloudProfileId) + .Where(profileId => profileId is not null) + .Select(profileId => profileId!.Value); + foreach (var profileId in requestedCloudProfiles) + { + var downloaded = await cloudSync.DownloadProfileToVaultAsync(profileId); + if (!downloaded.Success) + throw new InvalidOperationException(downloaded.Message); + } + + if (selected.Count > SlotCount) + throw new InvalidOperationException("WiiCompiled can display at most four selected profiles at once."); + + var rksysPath = distributions.RetroRewind.FindExistingRksysPath() ?? PathManager.GetRetroWfcSavePath(); + if (!File.Exists(rksysPath)) + throw new FileNotFoundException("RetroWFC rksys.dat was not found for the visible-profile launch view."); + var original = await File.ReadAllBytesAsync(rksysPath); + if (original.Length < RksysHeaderSize + SlotCount * RkpdSize) + throw new InvalidDataException("rksys.dat is too small to filter its license slots."); + + await backups.CreateBackupAsync(rksysPath); + Directory.CreateDirectory(SessionFolder); + await File.WriteAllBytesAsync(OriginalPath, original); + var view = original.ToArray(); + for (var slot = 0; slot < SlotCount; slot++) + Array.Clear(view, RksysHeaderSize + slot * RkpdSize, RkpdSize); + var maps = new List(); + foreach (var key in selected.Distinct(StringComparer.Ordinal)) + { + var targetSlot = maps.Count; + if (ParseLocalSlot(key) is int sourceSlot && sourceSlot is >= 0 and < SlotCount) + { + Buffer.BlockCopy( + original, + RksysHeaderSize + sourceSlot * RkpdSize, + view, + RksysHeaderSize + targetSlot * RkpdSize, + RkpdSize + ); + maps.Add(new SlotMap(targetSlot, sourceSlot, null)); + continue; + } + var profileId = ParseVaultProfileId(key) ?? ParseCloudProfileId(key); + if (profileId is null) + continue; + var virtualProfile = + await vault.GetAsync(profileId.Value) + ?? throw new InvalidDataException("A selected virtual profile is no longer available in the local profile vault."); + await vault.EnsureMiiPresentAsync(virtualProfile); + Buffer.BlockCopy(virtualProfile.LicenseData, 0, view, RksysHeaderSize + targetSlot * RkpdSize, RkpdSize); + maps.Add(new SlotMap(targetSlot, null, virtualProfile.ProfileId)); + } + if (maps.Count == 0) + return; + // Mario Kart validates a CRC32 at 0x27FFC. The temporary slot view is still a complete + // rksys.dat and needs the same checksum repair as every normal save write. + GameLicenseSingletonService.FixRksysCrc(view); + + var state = new VisibleProfileSession(rksysPath, maps, Installed: false); + await File.WriteAllTextAsync(StatePath, JsonSerializer.Serialize(state)); + await WriteAtomicAsync(rksysPath, view); + await File.WriteAllTextAsync(StatePath, JsonSerializer.Serialize(state with { Installed = true })); + } + + public async Task RestoreAsync() + { + if (!File.Exists(StatePath) || !File.Exists(OriginalPath)) + return; + var state = JsonSerializer.Deserialize(await File.ReadAllTextAsync(StatePath)); + if (state is null || !File.Exists(state.RksysPath)) + return; + + if (!state.Installed) + { + Directory.Delete(SessionFolder, recursive: true); + return; + } + + var original = await File.ReadAllBytesAsync(OriginalPath); + var session = await File.ReadAllBytesAsync(state.RksysPath); + if (original.Length < RksysHeaderSize + SlotCount * RkpdSize || session.Length < RksysHeaderSize + SlotCount * RkpdSize) + throw new InvalidDataException("The visible-profile launch session is corrupt; the original save was left in its backup."); + + var validMaps = state.Maps.Where(map => map.TargetSlot is >= 0 and < SlotCount).ToList(); + var recoveredVaultProfiles = state.RecoveredVaultProfiles ?? []; + foreach (var map in validMaps.Where(map => map.SourceSlot is >= 0 and < SlotCount)) + Buffer.BlockCopy( + session, + RksysHeaderSize + map.TargetSlot * RkpdSize, + original, + RksysHeaderSize + map.SourceSlot!.Value * RkpdSize, + RkpdSize + ); + foreach (var map in validMaps.Where(map => map.VaultProfileId is not null)) + { + var licenseData = session.AsSpan(RksysHeaderSize + map.TargetSlot * RkpdSize, RkpdSize).ToArray(); + if (IsLicenseData(licenseData)) + await vault.UpdateAsync(map.VaultProfileId!.Value, licenseData); + } + + // WiiCompiled may create a license in one of the intentionally blank view slots. Preserve + // that new license by assigning it to an actually unused slot in the complete local save. + // Existing unselected licenses are never overwritten. + var mappedTargets = validMaps.Select(map => map.TargetSlot).ToHashSet(); + var freeOriginalSlots = new Queue(Enumerable.Range(0, SlotCount).Where(slot => !HasLicense(original, slot))); + foreach (var targetSlot in Enumerable.Range(0, SlotCount).Where(slot => !mappedTargets.Contains(slot))) + { + if (!HasLicense(session, targetSlot)) + continue; + var licenseData = session.AsSpan(RksysHeaderSize + targetSlot * RkpdSize, RkpdSize).ToArray(); + if (freeOriginalSlots.Count > 0) + { + var destinationSlot = freeOriginalSlots.Dequeue(); + Buffer.BlockCopy(licenseData, 0, original, RksysHeaderSize + destinationSlot * RkpdSize, RkpdSize); + EnsureProfileIsVisible($"local:{destinationSlot}"); + } + else + { + // All four physical slots are occupied. Preserve a newly created license in the + // vault rather than overwriting an unselected local profile. + if (!recoveredVaultProfiles.TryGetValue(targetSlot, out var profileId)) + { + var existing = (await vault.GetAllAsync()).FirstOrDefault(profile => profile.LicenseData.SequenceEqual(licenseData)); + profileId = existing?.ProfileId ?? (await vault.CreateAsync(licenseData)).ProfileId; + recoveredVaultProfiles[targetSlot] = profileId; + state = state with { RecoveredVaultProfiles = recoveredVaultProfiles }; + await File.WriteAllTextAsync(StatePath, JsonSerializer.Serialize(state)); + } + EnsureVaultProfileIsSelectedForSync(profileId); + EnsureProfileIsVisible($"vault:{profileId:D}"); + } + } + GameLicenseSingletonService.FixRksysCrc(original); + await WriteAtomicAsync(state.RksysPath, original); + Directory.Delete(SessionFolder, recursive: true); + // The singleton otherwise keeps the pre-launch rksys.dat cached, so a newly created + // license would not appear in WheelWizard until a later unrelated reload. + gameLicenses.LoadLicense(); + ProfileLibraryChangeNotifier.NotifyChanged(); + } + + private IReadOnlyList ReadSelection() + { + try + { + return JsonSerializer.Deserialize>(settings.Get(settings.CLOUD_VISIBLE_PROFILE_IDS)) ?? []; + } + catch (JsonException) + { + return []; + } + } + + private static int? ParseLocalSlot(string key) => + key.StartsWith("local:", StringComparison.Ordinal) && int.TryParse(key[6..], out var slot) ? slot : null; + + private static Guid? ParseVaultProfileId(string key) => + key.StartsWith("vault:", StringComparison.Ordinal) && Guid.TryParse(key[6..], out var profileId) ? profileId : null; + + private static Guid? ParseCloudProfileId(string key) => + key.StartsWith("cloud:", StringComparison.Ordinal) && Guid.TryParse(key[6..], out var profileId) ? profileId : null; + + private static bool HasLicense(byte[] rksys, int slot) + { + var offset = RksysHeaderSize + slot * RkpdSize; + return rksys.Length >= offset + 4 && rksys.AsSpan(offset, 4).SequenceEqual("RKPD"u8); + } + + private static bool IsLicenseData(byte[] licenseData) => + licenseData.Length == RkpdSize && licenseData.AsSpan(0, 4).SequenceEqual("RKPD"u8); + + private void EnsureVaultProfileIsSelectedForSync(Guid profileId) + { + try + { + var stored = settings.Get(settings.CLOUD_SYNC_PROFILE_IDS); + if (string.IsNullOrWhiteSpace(stored)) + return; + var keys = JsonSerializer.Deserialize>(stored) ?? []; + var key = $"vault:{profileId:D}"; + if (keys.Contains(key, StringComparer.Ordinal)) + return; + keys.Add(key); + settings.Set(settings.CLOUD_SYNC_PROFILE_IDS, JsonSerializer.Serialize(keys)); + } + catch (JsonException) + { + settings.Set(settings.CLOUD_SYNC_PROFILE_IDS, JsonSerializer.Serialize(new[] { $"vault:{profileId:D}" })); + } + } + + private void EnsureProfileIsVisible(string profileKey) + { + try + { + var keys = JsonSerializer.Deserialize>(settings.Get(settings.CLOUD_VISIBLE_PROFILE_IDS)) ?? []; + if (keys.Contains(profileKey, StringComparer.Ordinal) || keys.Count >= SlotCount) + return; + keys.Add(profileKey); + settings.Set(settings.CLOUD_VISIBLE_PROFILE_IDS, JsonSerializer.Serialize(keys)); + } + catch (JsonException) + { + settings.Set(settings.CLOUD_VISIBLE_PROFILE_IDS, JsonSerializer.Serialize(new[] { profileKey })); + } + } + + private static async Task WriteAtomicAsync(string path, byte[] data) + { + var temporary = path + ".visible-profiles.tmp"; + await File.WriteAllBytesAsync(temporary, data); + File.Move(temporary, path, overwrite: true); + } + + private sealed record VisibleProfileSession( + string RksysPath, + List Maps, + bool Installed, + Dictionary? RecoveredVaultProfiles = null + ); + + private sealed record SlotMap(int TargetSlot, int? SourceSlot, Guid? VaultProfileId); +} diff --git a/WheelWizard/Features/CloudSync/Providers/CloudProviderResolver.cs b/WheelWizard/Features/CloudSync/Providers/CloudProviderResolver.cs new file mode 100644 index 000000000..c07ba90c0 --- /dev/null +++ b/WheelWizard/Features/CloudSync/Providers/CloudProviderResolver.cs @@ -0,0 +1,8 @@ +namespace WheelWizard.CloudSync.Providers; + +public sealed class CloudProviderResolver(IEnumerable providers) : ICloudProviderResolver +{ + public ICloudProvider Resolve(CloudProviderType providerType) => + providers.FirstOrDefault(provider => provider.ProviderType == providerType) + ?? throw new NotSupportedException($"Cloud provider '{providerType}' is not available."); +} diff --git a/WheelWizard/Features/CloudSync/Providers/ICloudProvider.cs b/WheelWizard/Features/CloudSync/Providers/ICloudProvider.cs new file mode 100644 index 000000000..a754478c2 --- /dev/null +++ b/WheelWizard/Features/CloudSync/Providers/ICloudProvider.cs @@ -0,0 +1,33 @@ +namespace WheelWizard.CloudSync.Providers; + +public sealed record RemoteFileInfo(string Path, long Size, DateTimeOffset? LastModifiedUtc, string? ETag); + +public interface ICloudProvider +{ + CloudProviderType ProviderType { get; } + Task AuthenticateAsync(); + + /// Checks saved credentials without starting an interactive sign-in flow. + Task IsAuthenticatedAsync(); + Task DisconnectAsync(); + Task GetFileInfoAsync(string path); + Task DownloadAsync(string remotePath, string localPath); + Task UploadAsync(string localPath, string remotePath); + + /// Publishes a replacement only when the currently observed ETag still matches. + async Task UploadIfMatchAsync(string localPath, string remotePath, string? expectedETag) + { + var current = await GetFileInfoAsync(remotePath); + if (expectedETag is null ? current is not null : current?.ETag != expectedETag) + return false; + await UploadAsync(localPath, remotePath); + return true; + } + Task ExistsAsync(string path); + Task> ListAsync(string path); +} + +public interface ICloudProviderResolver +{ + ICloudProvider Resolve(CloudProviderType providerType); +} diff --git a/WheelWizard/Features/CloudSync/Providers/NextcloudProvider.cs b/WheelWizard/Features/CloudSync/Providers/NextcloudProvider.cs new file mode 100644 index 000000000..d6c303017 --- /dev/null +++ b/WheelWizard/Features/CloudSync/Providers/NextcloudProvider.cs @@ -0,0 +1,91 @@ +using System.Diagnostics; +using System.Net; +using System.Text.Json; + +namespace WheelWizard.CloudSync.Providers; + +/// Uses Nextcloud's browser-based Login Flow v2 and stores only its generated app password. +public sealed class NextcloudProvider( + IHttpClientFactory clients, + WheelWizard.Settings.ISettingsManager settings, + WheelWizard.CloudSync.Credentials.ISecureCredentialStore credentials +) : WebDavProvider(clients, settings, credentials) +{ + private static readonly TimeSpan PollTimeout = TimeSpan.FromMinutes(20); + private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true }; + public override CloudProviderType ProviderType => CloudProviderType.Nextcloud; + protected override string CredentialKey => "cloud-nextcloud"; + + public override async Task AuthenticateAsync() + { + var server = Settings.Get(Settings.CLOUD_NEXTCLOUD_SERVER).Trim().TrimEnd('/'); + if (!Uri.TryCreate(server, UriKind.Absolute, out var serverUri) || serverUri.Scheme is not ("https" or "http")) + throw new InvalidOperationException("Enter a valid Nextcloud server URL, for example https://cloud.example.com."); + + using var startResponse = await Client.PostAsync(new Uri($"{server}/index.php/login/v2"), content: null); + startResponse.EnsureSuccessStatusCode(); + var start = + await JsonSerializer.DeserializeAsync(await startResponse.Content.ReadAsStreamAsync(), JsonOptions) + ?? throw new InvalidDataException("Nextcloud returned an invalid Login Flow v2 response."); + if ( + string.IsNullOrWhiteSpace(start.Login) + || string.IsNullOrWhiteSpace(start.Poll?.Endpoint) + || string.IsNullOrWhiteSpace(start.Poll.Token) + ) + throw new InvalidDataException("Nextcloud Login Flow v2 response is incomplete."); + + var browser = Process.Start(new ProcessStartInfo(start.Login) { UseShellExecute = true }); + if (browser is null) + throw new InvalidOperationException("The default browser could not be opened for Nextcloud login."); + + var deadline = DateTime.UtcNow + PollTimeout; + while (DateTime.UtcNow < deadline) + { + await Task.Delay(TimeSpan.FromSeconds(1)); + using var pollContent = new FormUrlEncodedContent([new KeyValuePair("token", start.Poll.Token)]); + using var pollResponse = await Client.PostAsync(start.Poll.Endpoint, pollContent); + if (pollResponse.StatusCode == HttpStatusCode.NotFound) + continue; + pollResponse.EnsureSuccessStatusCode(); + var completed = + await JsonSerializer.DeserializeAsync(await pollResponse.Content.ReadAsStreamAsync(), JsonOptions) + ?? throw new InvalidDataException("Nextcloud returned an invalid login result."); + if ( + string.IsNullOrWhiteSpace(completed.Server) + || string.IsNullOrWhiteSpace(completed.LoginName) + || string.IsNullOrWhiteSpace(completed.AppPassword) + ) + throw new InvalidDataException("Nextcloud login did not return an app password."); + + var canonicalServer = completed.Server.TrimEnd('/'); + await Credentials.SaveAsync( + CredentialKey, + new WheelWizard.CloudSync.Credentials.Secret($"{completed.LoginName}:{completed.AppPassword}") + ); + Settings.Set(Settings.CLOUD_NEXTCLOUD_SERVER, canonicalServer); + Settings.Set(Settings.CLOUD_REMOTE_ROOT, $"{canonicalServer}/remote.php/dav/files/{Uri.EscapeDataString(completed.LoginName)}"); + return; + } + + throw new TimeoutException("Nextcloud authorization timed out after 20 minutes. Please try again."); + } + + private sealed class LoginStart + { + public string Login { get; init; } = string.Empty; + public PollData? Poll { get; init; } + } + + private sealed class PollData + { + public string Token { get; init; } = string.Empty; + public string Endpoint { get; init; } = string.Empty; + } + + private sealed class LoginResult + { + public string Server { get; init; } = string.Empty; + public string LoginName { get; init; } = string.Empty; + public string AppPassword { get; init; } = string.Empty; + } +} diff --git a/WheelWizard/Features/CloudSync/Providers/OAuthProviders.cs b/WheelWizard/Features/CloudSync/Providers/OAuthProviders.cs new file mode 100644 index 000000000..4e3fd07fb --- /dev/null +++ b/WheelWizard/Features/CloudSync/Providers/OAuthProviders.cs @@ -0,0 +1,669 @@ +using System.Diagnostics; +using System.Net; +using System.Net.Http.Headers; +using System.Net.Sockets; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using WheelWizard.CloudSync.Credentials; +using WheelWizard.Settings; +using WheelWizard.Settings.Types; + +namespace WheelWizard.CloudSync.Providers; + +/// +/// Common OAuth 2.0 authorization-code-with-PKCE support for public desktop clients. Client ids +/// are settings (and therefore non-secret); access and refresh tokens never leave the OS credential store. +/// +public abstract class OAuthCloudProvider(IHttpClientFactory clients, ISettingsManager settings, ISecureCredentialStore credentials) + : ICloudProvider +{ + private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true }; + + public abstract CloudProviderType ProviderType { get; } + protected abstract string CredentialKey { get; } + protected abstract Setting ClientIdSetting { get; } + protected abstract string AuthorizationEndpoint { get; } + protected abstract string TokenEndpoint { get; } + protected abstract IReadOnlyList Scopes { get; } + + protected HttpClient Client => clients.CreateClient("WheelWizard.CloudSync.OAuth"); + protected string ClientId => settings.Get(ClientIdSetting).Trim(); + + public async Task AuthenticateAsync() + { + if (string.IsNullOrWhiteSpace(ClientId)) + throw new InvalidOperationException($"Enter the public {ProviderDisplayName} OAuth client ID before signing in."); + + using var listener = LoopbackListener.Start(); + var state = Base64Url(RandomNumberGenerator.GetBytes(32)); + var verifier = Base64Url(RandomNumberGenerator.GetBytes(64)); + var challenge = Base64Url(SHA256.HashData(Encoding.ASCII.GetBytes(verifier))); + var authorizationUri = BuildAuthorizationUri(listener.RedirectUri, state, challenge); + if (Process.Start(new ProcessStartInfo(authorizationUri) { UseShellExecute = true }) is null) + throw new InvalidOperationException("The default browser could not be opened for cloud sign-in."); + + var callback = await listener.WaitForCallbackAsync(TimeSpan.FromMinutes(10)); + var callbackParameters = ParseQuery(callback.Url?.Query); + var error = callbackParameters.GetValueOrDefault("error"); + if (!string.IsNullOrWhiteSpace(error)) + throw new InvalidOperationException($"{ProviderDisplayName} sign-in was cancelled or denied: {error}."); + if ( + !CryptographicOperations.FixedTimeEquals( + Encoding.UTF8.GetBytes(state), + Encoding.UTF8.GetBytes(callbackParameters.GetValueOrDefault("state") ?? string.Empty) + ) + ) + throw new InvalidOperationException("The OAuth sign-in response did not match the request. Please try again."); + var code = callbackParameters.GetValueOrDefault("code"); + if (string.IsNullOrWhiteSpace(code)) + throw new InvalidOperationException($"{ProviderDisplayName} sign-in did not return an authorization code."); + + var values = new Dictionary + { + ["client_id"] = ClientId, + ["grant_type"] = "authorization_code", + ["code"] = code, + ["redirect_uri"] = listener.RedirectUri, + ["code_verifier"] = verifier, + }; + var token = await RequestTokenAsync(values); + if (string.IsNullOrWhiteSpace(token.AccessToken) || string.IsNullOrWhiteSpace(token.RefreshToken)) + throw new InvalidDataException( + $"{ProviderDisplayName} did not return a reusable refresh token. Remove this app from the provider account and sign in again." + ); + await SaveTokenAsync(token); + } + + public async Task IsAuthenticatedAsync() + { + try + { + _ = await GetAccessTokenAsync(); + return true; + } + catch + { + return false; + } + } + + public Task DisconnectAsync() => credentials.DeleteAsync(CredentialKey); + + protected async Task SendAsync(HttpRequestMessage request, bool retryUnauthorized = true) + { + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", await GetAccessTokenAsync()); + var response = await Client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); + if (response.StatusCode != HttpStatusCode.Unauthorized || !retryUnauthorized) + return response; + + response.Dispose(); + // Requests with bodies are deliberately not replayed. A failed resumable upload can safely be retried by the caller. + throw new UnauthorizedAccessException($"{ProviderDisplayName} rejected the saved authorization. Sign in again."); + } + + protected async Task GetAccessTokenAsync() + { + var stored = + await credentials.GetAsync(CredentialKey) ?? throw new InvalidOperationException($"Sign in to {ProviderDisplayName} first."); + var token = + JsonSerializer.Deserialize(stored.Value, JsonOptions) + ?? throw new InvalidDataException($"The saved {ProviderDisplayName} authorization is invalid."); + if (!string.IsNullOrWhiteSpace(token.AccessToken) && token.ExpiresUtc > DateTimeOffset.UtcNow.AddMinutes(2)) + return token.AccessToken; + if (string.IsNullOrWhiteSpace(token.RefreshToken)) + throw new InvalidOperationException($"The saved {ProviderDisplayName} authorization has expired. Sign in again."); + + var refreshed = await RequestTokenAsync( + new Dictionary + { + ["client_id"] = ClientId, + ["grant_type"] = "refresh_token", + ["refresh_token"] = token.RefreshToken, + } + ); + if (string.IsNullOrWhiteSpace(refreshed.AccessToken)) + throw new InvalidOperationException($"{ProviderDisplayName} could not refresh its authorization. Sign in again."); + refreshed = refreshed with + { + RefreshToken = string.IsNullOrWhiteSpace(refreshed.RefreshToken) ? token.RefreshToken : refreshed.RefreshToken, + }; + await SaveTokenAsync(refreshed); + return refreshed.AccessToken; + } + + private async Task RequestTokenAsync(IReadOnlyDictionary values) + { + using var response = await Client.PostAsync(TokenEndpoint, new FormUrlEncodedContent(values)); + if (!response.IsSuccessStatusCode) + { + var details = await response.Content.ReadAsStringAsync(); + throw new HttpRequestException($"{ProviderDisplayName} authorization failed ({(int)response.StatusCode}): {details}"); + } + var responseToken = + await JsonSerializer.DeserializeAsync(await response.Content.ReadAsStreamAsync(), JsonOptions) + ?? throw new InvalidDataException($"{ProviderDisplayName} returned an invalid token response."); + return new OAuthToken( + responseToken.AccessToken, + responseToken.RefreshToken, + DateTimeOffset.UtcNow.AddSeconds(Math.Max(responseToken.ExpiresIn, 60)) + ); + } + + private Task SaveTokenAsync(OAuthToken token) => credentials.SaveAsync(CredentialKey, new Secret(JsonSerializer.Serialize(token))); + + private string BuildAuthorizationUri(string redirectUri, string state, string challenge) + { + var query = new Dictionary + { + ["client_id"] = ClientId, + ["response_type"] = "code", + ["redirect_uri"] = redirectUri, + ["scope"] = string.Join(' ', Scopes), + ["state"] = state, + ["code_challenge"] = challenge, + ["code_challenge_method"] = "S256", + }; + AddAuthorizationParameters(query); + return AuthorizationEndpoint + + "?" + + string.Join('&', query.Select(pair => $"{Uri.EscapeDataString(pair.Key)}={Uri.EscapeDataString(pair.Value)}")); + } + + protected virtual void AddAuthorizationParameters(IDictionary query) { } + + protected virtual string ProviderDisplayName => ProviderType == CloudProviderType.GoogleDrive ? "Google Drive" : "OneDrive"; + + private static string Base64Url(byte[] bytes) => Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_'); + + private static IReadOnlyDictionary ParseQuery(string? query) => + query + ?.TrimStart('?') + .Split('&', StringSplitOptions.RemoveEmptyEntries) + .Select(pair => pair.Split('=', 2)) + .Where(parts => parts.Length > 0) + .ToDictionary( + parts => Uri.UnescapeDataString(parts[0].Replace('+', ' ')), + parts => parts.Length == 2 ? Uri.UnescapeDataString(parts[1].Replace('+', ' ')) : string.Empty, + StringComparer.Ordinal + ) ?? new Dictionary(StringComparer.Ordinal); + + public abstract Task GetFileInfoAsync(string path); + public abstract Task DownloadAsync(string remotePath, string localPath); + public abstract Task UploadAsync(string localPath, string remotePath); + + public async Task ExistsAsync(string path) => await GetFileInfoAsync(path) is not null; + + public abstract Task> ListAsync(string path); + + private sealed record TokenResponse(string AccessToken, string? RefreshToken, int ExpiresIn); + + private sealed record OAuthToken(string AccessToken, string? RefreshToken, DateTimeOffset ExpiresUtc); + + private sealed class LoopbackListener : IDisposable + { + private readonly HttpListener _listener = new(); + + private LoopbackListener(int port) + { + RedirectUri = $"http://127.0.0.1:{port}/callback/"; + _listener.Prefixes.Add(RedirectUri); + _listener.Start(); + } + + public string RedirectUri { get; } + + public static LoopbackListener Start() + { + using var probe = new TcpListener(IPAddress.Loopback, 0); + probe.Start(); + var port = ((IPEndPoint)probe.LocalEndpoint).Port; + return new LoopbackListener(port); + } + + public async Task WaitForCallbackAsync(TimeSpan timeout) + { + using var cancel = new CancellationTokenSource(timeout); + try + { + var context = await _listener.GetContextAsync().WaitAsync(cancel.Token); + const string page = + "

WheelWizard sign-in complete

You can return to WheelWizard and close this tab.

"; + var bytes = Encoding.UTF8.GetBytes(page); + context.Response.ContentType = "text/html; charset=utf-8"; + context.Response.ContentLength64 = bytes.Length; + await context.Response.OutputStream.WriteAsync(bytes); + context.Response.Close(); + return context.Request; + } + catch (OperationCanceledException) + { + throw new TimeoutException("Cloud browser sign-in timed out after 10 minutes. Please try again."); + } + } + + public void Dispose() + { + _listener.Stop(); + _listener.Close(); + } + } +} + +/// Google Drive transport confined to the OAuth application's private appDataFolder. +public sealed class GoogleDriveProvider : OAuthCloudProvider +{ + private const string DriveApi = "https://www.googleapis.com/drive/v3"; + private const string UploadApi = "https://www.googleapis.com/upload/drive/v3"; + private const string FolderMimeType = "application/vnd.google-apps.folder"; + private readonly ISettingsManager _settings; + + public GoogleDriveProvider(IHttpClientFactory clients, ISettingsManager settings, ISecureCredentialStore credentials) + : base(clients, settings, credentials) => _settings = settings; + + public override CloudProviderType ProviderType => CloudProviderType.GoogleDrive; + protected override string CredentialKey => "cloud-google-drive"; + protected override Setting ClientIdSetting => _settings.CLOUD_GOOGLE_CLIENT_ID; + protected override string AuthorizationEndpoint => "https://accounts.google.com/o/oauth2/v2/auth"; + protected override string TokenEndpoint => "https://oauth2.googleapis.com/token"; + protected override IReadOnlyList Scopes => ["https://www.googleapis.com/auth/drive.appdata"]; + + protected override void AddAuthorizationParameters(IDictionary query) + { + query["access_type"] = "offline"; + // Google otherwise omits a refresh token when the account has approved this app before. + query["prompt"] = "consent"; + } + + public override async Task GetFileInfoAsync(string path) + { + var item = await FindItemAsync(path); + return item is null ? null : new RemoteFileInfo(path, item.Size, item.ModifiedUtc, item.ETag); + } + + public override async Task DownloadAsync(string remotePath, string localPath) + { + var item = + await FindItemAsync(remotePath) ?? throw new FileNotFoundException("The Google Drive cloud file does not exist.", remotePath); + using var response = await SendAsync(new HttpRequestMessage(HttpMethod.Get, $"{DriveApi}/files/{item.Id}?alt=media")); + response.EnsureSuccessStatusCode(); + Directory.CreateDirectory(Path.GetDirectoryName(localPath)!); + await using var output = File.Create(localPath); + await response.Content.CopyToAsync(output); + } + + public override async Task UploadAsync(string localPath, string remotePath) + { + var segments = Segments(remotePath); + if (segments.Length == 0) + throw new InvalidOperationException("A Google Drive cloud file path is required."); + var parent = await EnsureFoldersAsync(segments[..^1]); + var existing = await FindChildAsync(parent, segments[^1]); + var metadata = JsonSerializer.Serialize(new { name = segments[^1], parents = existing is null ? new[] { parent } : null }); + await using var input = File.OpenRead(localPath); + using var create = new HttpRequestMessage( + existing is null ? HttpMethod.Post : HttpMethod.Patch, + existing is null ? $"{UploadApi}/files?uploadType=resumable" : $"{UploadApi}/files/{existing.Id}?uploadType=resumable" + ); + create.Content = new StringContent(metadata, Encoding.UTF8, "application/json"); + create.Headers.TryAddWithoutValidation("X-Upload-Content-Type", "application/octet-stream"); + create.Headers.TryAddWithoutValidation("X-Upload-Content-Length", input.Length.ToString()); + using var session = await SendAsync(create); + session.EnsureSuccessStatusCode(); + var uploadUri = session.Headers.Location ?? throw new InvalidDataException("Google Drive did not create an upload session."); + await UploadChunksAsync(uploadUri, input, input.Length); + } + + public override async Task> ListAsync(string path) + { + var folder = await FindItemAsync(path); + if (folder is null) + return []; + var children = await FindChildrenAsync(folder.Id); + var prefix = "/" + string.Join('/', Segments(path)); + return children.Select(child => $"{prefix}/{Uri.EscapeDataString(child.Name)}").ToList(); + } + + private async Task UploadChunksAsync(Uri uploadUri, Stream input, long length) + { + const int chunkSize = 256 * 1024; + var buffer = new byte[chunkSize]; + long position = 0; + while (position < length) + { + var read = await input.ReadAsync(buffer.AsMemory(0, (int)Math.Min(chunkSize, length - position))); + if (read == 0) + throw new EndOfStreamException("The local upload file ended unexpectedly."); + using var request = new HttpRequestMessage(HttpMethod.Put, uploadUri); + request.Content = new ByteArrayContent(buffer, 0, read); + request.Content.Headers.ContentRange = new ContentRangeHeaderValue(position, position + read - 1, length); + using var response = await Client.SendAsync(request); + if (position + read < length && response.StatusCode != HttpStatusCode.PermanentRedirect) + response.EnsureSuccessStatusCode(); + if (position + read == length) + response.EnsureSuccessStatusCode(); + position += read; + } + } + + private async Task FindItemAsync(string path) + { + var parent = "appDataFolder"; + DriveItem? result = null; + foreach (var segment in Segments(path)) + { + result = await FindChildAsync(parent, segment); + if (result is null) + return null; + parent = result.Id; + } + return result; + } + + private async Task EnsureFoldersAsync(IReadOnlyList segments) + { + var parent = "appDataFolder"; + foreach (var segment in segments) + { + var existing = await FindChildAsync(parent, segment); + if (existing is not null) + { + if (!string.Equals(existing.MimeType, FolderMimeType, StringComparison.Ordinal)) + throw new IOException($"Google Drive item '{segment}' is a file, not a folder."); + parent = existing.Id; + continue; + } + using var request = new HttpRequestMessage(HttpMethod.Post, $"{DriveApi}/files") + { + Content = new StringContent( + JsonSerializer.Serialize( + new + { + name = segment, + mimeType = FolderMimeType, + parents = new[] { parent }, + } + ), + Encoding.UTF8, + "application/json" + ), + }; + using var response = await SendAsync(request); + response.EnsureSuccessStatusCode(); + parent = (await ReadItemAsync(response))?.Id ?? throw new InvalidDataException("Google Drive did not create a folder."); + } + return parent; + } + + private async Task FindChildAsync(string parent, string name) + { + var escapedName = name.Replace("'", "\\'", StringComparison.Ordinal); + var query = Uri.EscapeDataString($"name = '{escapedName}' and '{parent}' in parents and trashed = false"); + using var response = await SendAsync( + new HttpRequestMessage( + HttpMethod.Get, + $"{DriveApi}/files?q={query}&spaces=appDataFolder&fields=files(id,name,mimeType,size,modifiedTime,etag)&pageSize=2" + ) + ); + response.EnsureSuccessStatusCode(); + using var document = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync()); + return document.RootElement.TryGetProperty("files", out var files) && files.GetArrayLength() > 0 ? ReadItem(files[0]) : null; + } + + private async Task> FindChildrenAsync(string parent) + { + var query = Uri.EscapeDataString($"'{parent}' in parents and trashed = false"); + using var response = await SendAsync( + new HttpRequestMessage( + HttpMethod.Get, + $"{DriveApi}/files?q={query}&spaces=appDataFolder&fields=files(id,name,mimeType,size,modifiedTime,etag)&pageSize=1000" + ) + ); + response.EnsureSuccessStatusCode(); + using var document = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync()); + return document.RootElement.TryGetProperty("files", out var files) ? files.EnumerateArray().Select(ReadItem).ToList() : []; + } + + private static async Task ReadItemAsync(HttpResponseMessage response) + { + using var document = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync()); + return ReadItem(document.RootElement); + } + + private static DriveItem ReadItem(JsonElement element) => + new( + element.GetProperty("id").GetString() ?? throw new InvalidDataException("Google Drive item has no id."), + element.TryGetProperty("name", out var name) ? name.GetString() ?? string.Empty : string.Empty, + element.TryGetProperty("mimeType", out var type) ? type.GetString() ?? string.Empty : string.Empty, + element.TryGetProperty("size", out var size) && long.TryParse(size.GetString(), out var parsedSize) ? parsedSize : 0, + element.TryGetProperty("modifiedTime", out var modified) + && DateTimeOffset.TryParse(modified.GetString(), out var parsedModified) + ? parsedModified + : null, + element.TryGetProperty("etag", out var etag) ? etag.GetString() : null + ); + + private static string[] Segments(string path) => + path.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).Select(Uri.UnescapeDataString).ToArray(); + + private sealed record DriveItem(string Id, string Name, string MimeType, long Size, DateTimeOffset? ModifiedUtc, string? ETag); +} + +/// OneDrive transport confined to the OAuth application's special approot folder. +public sealed class OneDriveProvider : OAuthCloudProvider +{ + private const string Graph = "https://graph.microsoft.com/v1.0"; + private readonly ISettingsManager _settings; + + public OneDriveProvider(IHttpClientFactory clients, ISettingsManager settings, ISecureCredentialStore credentials) + : base(clients, settings, credentials) => _settings = settings; + + public override CloudProviderType ProviderType => CloudProviderType.OneDrive; + protected override string CredentialKey => "cloud-onedrive"; + protected override Setting ClientIdSetting => _settings.CLOUD_ONEDRIVE_CLIENT_ID; + protected override string AuthorizationEndpoint => "https://login.microsoftonline.com/consumers/oauth2/v2.0/authorize"; + protected override string TokenEndpoint => "https://login.microsoftonline.com/consumers/oauth2/v2.0/token"; + protected override IReadOnlyList Scopes => ["offline_access", "Files.ReadWrite.AppFolder"]; + + public override async Task GetFileInfoAsync(string path) + { + var item = await FindItemAsync(path); + return item is null ? null : new RemoteFileInfo(path, item.Size, item.ModifiedUtc, item.ETag); + } + + public override async Task DownloadAsync(string remotePath, string localPath) + { + var item = + await FindItemAsync(remotePath) ?? throw new FileNotFoundException("The OneDrive cloud file does not exist.", remotePath); + using var response = await SendAsync(new HttpRequestMessage(HttpMethod.Get, $"{Graph}/me/drive/items/{item.Id}/content")); + response.EnsureSuccessStatusCode(); + Directory.CreateDirectory(Path.GetDirectoryName(localPath)!); + await using var output = File.Create(localPath); + await response.Content.CopyToAsync(output); + } + + public override async Task UploadAsync(string localPath, string remotePath) + { + var segments = Segments(remotePath); + if (segments.Length == 0) + throw new InvalidOperationException("A OneDrive cloud file path is required."); + var parent = await EnsureFoldersAsync(segments[..^1]); + var existing = await FindChildAsync(parent, segments[^1]); + var info = new FileInfo(localPath); + if (info.Length <= 4 * 1024 * 1024) + { + await using var stream = File.OpenRead(localPath); + using var request = new HttpRequestMessage( + HttpMethod.Put, + $"{Graph}/me/drive/items/{parent}:/{EscapeSegment(segments[^1])}:/content" + ) + { + Content = new StreamContent(stream), + }; + using var response = await SendAsync(request); + response.EnsureSuccessStatusCode(); + return; + } + + using var start = new HttpRequestMessage( + HttpMethod.Post, + existing is null + ? $"{Graph}/me/drive/items/{parent}:/{EscapeSegment(segments[^1])}:/createUploadSession" + : $"{Graph}/me/drive/items/{existing.Id}/createUploadSession" + ) + { + Content = new StringContent("{}", Encoding.UTF8, "application/json"), + }; + using var session = await SendAsync(start); + session.EnsureSuccessStatusCode(); + using var document = await JsonDocument.ParseAsync(await session.Content.ReadAsStreamAsync()); + var uploadUrl = + document.RootElement.GetProperty("uploadUrl").GetString() + ?? throw new InvalidDataException("OneDrive did not create an upload session."); + await using var input = File.OpenRead(localPath); + await UploadChunksAsync(new Uri(uploadUrl), input, input.Length); + } + + public override async Task> ListAsync(string path) + { + var folder = await FindItemAsync(path); + if (folder is null) + return []; + var children = await ListChildrenAsync(folder.Id); + var prefix = "/" + string.Join('/', Segments(path)); + return children.Select(child => $"{prefix}/{Uri.EscapeDataString(child.Name)}").ToList(); + } + + private async Task UploadChunksAsync(Uri uploadUri, Stream input, long length) + { + const int chunkSize = 320 * 1024; + var buffer = new byte[chunkSize]; + long position = 0; + while (position < length) + { + var read = await input.ReadAsync(buffer.AsMemory(0, (int)Math.Min(chunkSize, length - position))); + if (read == 0) + throw new EndOfStreamException("The local upload file ended unexpectedly."); + using var request = new HttpRequestMessage(HttpMethod.Put, uploadUri) { Content = new ByteArrayContent(buffer, 0, read) }; + request.Content.Headers.ContentRange = new ContentRangeHeaderValue(position, position + read - 1, length); + using var response = await Client.SendAsync(request); + if (position + read < length && response.StatusCode != HttpStatusCode.Accepted) + response.EnsureSuccessStatusCode(); + if (position + read == length) + response.EnsureSuccessStatusCode(); + position += read; + } + } + + private async Task FindItemAsync(string path) + { + var segments = Segments(path); + if (segments.Length == 0) + return await GetRootAsync(); + var itemPath = string.Join('/', segments.Select(EscapeSegment)); + using var response = await SendAsync( + new HttpRequestMessage( + HttpMethod.Get, + $"{Graph}/me/drive/special/approot:/{itemPath}:?$select=id,name,size,lastModifiedDateTime,eTag,folder" + ) + ); + if (response.StatusCode == HttpStatusCode.NotFound) + return null; + response.EnsureSuccessStatusCode(); + return await ReadItemAsync(response); + } + + private async Task GetRootAsync() + { + using var response = await SendAsync( + new HttpRequestMessage( + HttpMethod.Get, + $"{Graph}/me/drive/special/approot?$select=id,name,size,lastModifiedDateTime,eTag,folder" + ) + ); + response.EnsureSuccessStatusCode(); + return await ReadItemAsync(response) ?? throw new InvalidDataException("OneDrive app folder has no id."); + } + + private async Task EnsureFoldersAsync(IReadOnlyList segments) + { + var parent = (await GetRootAsync()).Id; + foreach (var segment in segments) + { + var child = await FindChildAsync(parent, segment); + if (child is not null) + { + if (!child.IsFolder) + throw new IOException($"OneDrive item '{segment}' is a file, not a folder."); + parent = child.Id; + continue; + } + using var request = new HttpRequestMessage(HttpMethod.Post, $"{Graph}/me/drive/items/{parent}/children") + { + Content = new StringContent( + JsonSerializer.Serialize( + new Dictionary + { + ["name"] = segment, + ["folder"] = new Dictionary(), + ["@microsoft.graph.conflictBehavior"] = "fail", + } + ), + Encoding.UTF8, + "application/json" + ), + }; + using var response = await SendAsync(request); + response.EnsureSuccessStatusCode(); + parent = (await ReadItemAsync(response))?.Id ?? throw new InvalidDataException("OneDrive did not create a folder."); + } + return parent; + } + + private async Task FindChildAsync(string parentId, string name) + { + var children = await ListChildrenAsync(parentId); + return children.FirstOrDefault(child => string.Equals(child.Name, name, StringComparison.Ordinal)); + } + + private async Task> ListChildrenAsync(string parentId) + { + using var response = await SendAsync( + new HttpRequestMessage( + HttpMethod.Get, + $"{Graph}/me/drive/items/{parentId}/children?$select=id,name,size,lastModifiedDateTime,eTag,folder&$top=999" + ) + ); + if (response.StatusCode == HttpStatusCode.NotFound) + return []; + response.EnsureSuccessStatusCode(); + using var document = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync()); + return document.RootElement.TryGetProperty("value", out var values) ? values.EnumerateArray().Select(ReadItem).ToList() : []; + } + + private static async Task ReadItemAsync(HttpResponseMessage response) + { + using var document = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync()); + return ReadItem(document.RootElement); + } + + private static OneDriveItem ReadItem(JsonElement element) => + new( + element.GetProperty("id").GetString() ?? throw new InvalidDataException("OneDrive item has no id."), + element.TryGetProperty("name", out var name) ? name.GetString() ?? string.Empty : string.Empty, + element.TryGetProperty("size", out var size) ? size.GetInt64() : 0, + element.TryGetProperty("lastModifiedDateTime", out var modified) + && DateTimeOffset.TryParse(modified.GetString(), out var parsedModified) + ? parsedModified + : null, + element.TryGetProperty("eTag", out var etag) ? etag.GetString() : null, + element.TryGetProperty("folder", out _) + ); + + private static string EscapeSegment(string segment) => Uri.EscapeDataString(segment); + + private static string[] Segments(string path) => + path.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).Select(Uri.UnescapeDataString).ToArray(); + + private sealed record OneDriveItem(string Id, string Name, long Size, DateTimeOffset? ModifiedUtc, string? ETag, bool IsFolder); +} diff --git a/WheelWizard/Features/CloudSync/Providers/WebDavProvider.cs b/WheelWizard/Features/CloudSync/Providers/WebDavProvider.cs new file mode 100644 index 000000000..5c23c68e3 --- /dev/null +++ b/WheelWizard/Features/CloudSync/Providers/WebDavProvider.cs @@ -0,0 +1,173 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Text; +using System.Xml.Linq; +using WheelWizard.CloudSync.Credentials; +using WheelWizard.Settings; + +namespace WheelWizard.CloudSync.Providers; + +/// WebDAV/Nextcloud transport. The URL is non-sensitive; the Basic/app password is a secret. +public class WebDavProvider(IHttpClientFactory clients, ISettingsManager settings, ISecureCredentialStore credentials) : ICloudProvider +{ + public virtual CloudProviderType ProviderType => CloudProviderType.WebDav; + protected virtual string CredentialKey => "cloud-webdav"; + + public virtual async Task AuthenticateAsync() + { + if (string.IsNullOrWhiteSpace(RemoteRoot)) + throw new InvalidOperationException("Set the WebDAV remote root before connecting."); + if (await credentials.GetAsync(CredentialKey) is null) + throw new InvalidOperationException("No WebDAV credential is available in the secure credential store."); + + await ValidateConnectionAsync(); + } + + public virtual async Task IsAuthenticatedAsync() + { + if (string.IsNullOrWhiteSpace(RemoteRoot) || await credentials.GetAsync(CredentialKey) is null) + return false; + + try + { + await ValidateConnectionAsync(); + return true; + } + catch + { + return false; + } + } + + private async Task ValidateConnectionAsync() + { + using var request = await CreateRequestAsync(HttpMethod.Options, string.Empty); + using var response = await Client.SendAsync(request); + if (!response.IsSuccessStatusCode && response.StatusCode != HttpStatusCode.NotFound) + throw new HttpRequestException($"WebDAV authentication failed ({(int)response.StatusCode})."); + } + + public Task DisconnectAsync() => credentials.DeleteAsync(CredentialKey); + + public async Task GetFileInfoAsync(string path) + { + using var request = await CreateRequestAsync(HttpMethod.Head, path); + using var response = await Client.SendAsync(request); + if (response.StatusCode == HttpStatusCode.NotFound) + return null; + response.EnsureSuccessStatusCode(); + return new RemoteFileInfo( + path, + response.Content.Headers.ContentLength ?? 0, + response.Content.Headers.LastModified, + response.Headers.ETag?.Tag + ); + } + + public async Task DownloadAsync(string remotePath, string localPath) + { + using var request = await CreateRequestAsync(HttpMethod.Get, remotePath); + using var response = await Client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); + response.EnsureSuccessStatusCode(); + Directory.CreateDirectory(Path.GetDirectoryName(localPath)!); + await using var output = File.Create(localPath); + await response.Content.CopyToAsync(output); + } + + public async Task UploadAsync(string localPath, string remotePath) + { + await UploadCoreAsync(localPath, remotePath, null, null); + } + + public async Task UploadIfMatchAsync(string localPath, string remotePath, string? expectedETag) + { + await EnsureParentCollectionsAsync(remotePath); + await using var input = File.OpenRead(localPath); + using var content = new StreamContent(input); + using var request = await CreateRequestAsync(HttpMethod.Put, remotePath); + request.Content = content; + if (expectedETag is null) + request.Headers.TryAddWithoutValidation("If-None-Match", "*"); + else + request.Headers.TryAddWithoutValidation("If-Match", expectedETag); + using var response = await Client.SendAsync(request); + if (response.StatusCode is HttpStatusCode.PreconditionFailed or HttpStatusCode.Conflict) + return false; + response.EnsureSuccessStatusCode(); + return true; + } + + private async Task UploadCoreAsync(string localPath, string remotePath, string? ifMatch, string? ifNoneMatch) + { + await EnsureParentCollectionsAsync(remotePath); + await using var input = File.OpenRead(localPath); + using var content = new StreamContent(input); + using var request = await CreateRequestAsync(HttpMethod.Put, remotePath); + request.Content = content; + using var response = await Client.SendAsync(request); + response.EnsureSuccessStatusCode(); + } + + public async Task ExistsAsync(string path) => await GetFileInfoAsync(path) is not null; + + public async Task> ListAsync(string path) + { + using var request = await CreateRequestAsync(new HttpMethod("PROPFIND"), path); + request.Headers.TryAddWithoutValidation("Depth", "1"); + request.Content = new StringContent( + "", + Encoding.UTF8, + "application/xml" + ); + using var response = await Client.SendAsync(request); + if (response.StatusCode == HttpStatusCode.NotFound) + return []; + response.EnsureSuccessStatusCode(); + + var document = XDocument.Parse(await response.Content.ReadAsStringAsync()); + XNamespace dav = "DAV:"; + return document + .Descendants(dav + "response") + .Select(node => node.Element(dav + "href")?.Value) + .Where(href => !string.IsNullOrWhiteSpace(href)) + .Select(href => href!) + .ToList(); + } + + private async Task EnsureParentCollectionsAsync(string remotePath) + { + var segments = remotePath.Trim('/').Split('/', StringSplitOptions.RemoveEmptyEntries); + if (segments.Length < 2) + return; + var current = string.Empty; + foreach (var segment in segments[..^1]) + { + current += "/" + segment; + using var request = await CreateRequestAsync(new HttpMethod("MKCOL"), current); + using var response = await Client.SendAsync(request); + // 201 = created, 405 = already exists. Servers may return 301/204 for their + // configured root; any other response is a real upload precondition failure. + if (response.StatusCode is not (HttpStatusCode.Created or HttpStatusCode.MethodNotAllowed or HttpStatusCode.NoContent)) + response.EnsureSuccessStatusCode(); + } + } + + protected HttpClient Client => clients.CreateClient("WheelWizard.CloudSync.WebDav"); + protected ISettingsManager Settings => settings; + protected ISecureCredentialStore Credentials => credentials; + protected string RemoteRoot => settings.Get(settings.CLOUD_REMOTE_ROOT).Trim(); + + protected async Task CreateRequestAsync(HttpMethod method, string path) + { + if (!Uri.TryCreate(RemoteRoot.TrimEnd('/') + "/" + path.TrimStart('/'), UriKind.Absolute, out var uri)) + throw new InvalidOperationException("The WebDAV remote root is not a valid absolute URI."); + var request = new HttpRequestMessage(method, uri); + var secret = await credentials.GetAsync(CredentialKey); + if (secret is not null) + request.Headers.Authorization = new AuthenticationHeaderValue( + "Basic", + Convert.ToBase64String(Encoding.UTF8.GetBytes(secret.Value)) + ); + return request; + } +} diff --git a/WheelWizard/Features/CustomDistributions/RetroRewind.cs b/WheelWizard/Features/CustomDistributions/RetroRewind.cs index f030f642e..7cdad8cc9 100644 --- a/WheelWizard/Features/CustomDistributions/RetroRewind.cs +++ b/WheelWizard/Features/CustomDistributions/RetroRewind.cs @@ -194,6 +194,19 @@ private bool HasOldRksys() return !string.IsNullOrWhiteSpace(GetOldRksys()); } + /// + /// Resolves the same RetroWFC save that the existing install/migration flow uses. Consumers + /// must use this rather than guessing a second rksys.dat location. + /// + public string? FindExistingRksysPath() + { + var saveRoot = GetOldRksys(); + if (string.IsNullOrWhiteSpace(saveRoot) || !_fileSystem.Directory.Exists(saveRoot)) + return null; + + return _fileSystem.Directory.GetFiles(saveRoot, "rksys.dat", SearchOption.AllDirectories).FirstOrDefault(); + } + private string GetOldRksys() { // todo, maybe we should check for the existence of the file instead of the folder? and also find the oldest one? diff --git a/WheelWizard/Features/Recomp/RecompDolphinDataService.cs b/WheelWizard/Features/Recomp/RecompDolphinDataService.cs index 3395d5c0c..0877ea206 100644 --- a/WheelWizard/Features/Recomp/RecompDolphinDataService.cs +++ b/WheelWizard/Features/Recomp/RecompDolphinDataService.cs @@ -16,6 +16,9 @@ public interface IRecompDolphinDataService /// The NAND directory handed to the recomp, honoring both the sharing and the copy choice. string? NandFolderPath { get; } + /// Returns the NAND currently used by WiiCompiled as a local source only. + string ActiveNandPath { get; } + string? SourceNandFolderPath { get; } string? FindCandidateUserFolder(); @@ -55,6 +58,8 @@ public string? NandFolderPath } } + public string ActiveNandPath => PathManager.GetActiveNandPath(); + public string? SourceNandFolderPath { get diff --git a/WheelWizard/Features/Recomp/RecompLauncher.cs b/WheelWizard/Features/Recomp/RecompLauncher.cs index 130f004a3..b10717f77 100644 --- a/WheelWizard/Features/Recomp/RecompLauncher.cs +++ b/WheelWizard/Features/Recomp/RecompLauncher.cs @@ -1,3 +1,5 @@ +using WheelWizard.CloudSync; +using WheelWizard.CloudSync.ProfileLibrary; using WheelWizard.CustomDistributions; using WheelWizard.Models.Enums; using WheelWizard.Mods; @@ -19,7 +21,9 @@ public class RecompLauncher( IRecompInstallService installService, ICustomDistributionSingletonService customDistributions, IModsLaunchService modsLaunchService, - IRecompDolphinDataService dolphinData + IRecompDolphinDataService dolphinData, + ICloudSyncService cloudSync, + IVisibleProfileLaunchService visibleProfiles ) : ILauncher { public string GameTitle { get; } = "WiiCompiled"; @@ -69,12 +73,38 @@ public async Task Launch() // awaited through game exit. progressWindow.SetCancellationTokenSource(null); progressWindow.Close(); - return await installService.LaunchAsync(CancellationToken.None); + // Always recover an interrupted temporary profile view before cloud sync reads the save. + await visibleProfiles.RestoreAsync(); + var preSync = await cloudSync.PreLaunchSyncAsync(); + if (!preSync.Success) + return Fail(preSync.Message); + + await visibleProfiles.PrepareAsync(); + OperationResult launchResult; + try + { + // LaunchAsync completes only after WiiCompiled's process exits. This makes the + // post-launch capture safe even on platforms where the setup host spawns the game. + launchResult = await installService.LaunchAsync(CancellationToken.None); + } + finally + { + // Merge only the visible slots back into the complete local rksys.dat before cloud capture. + await visibleProfiles.RestoreAsync(); + } + var postSync = await cloudSync.PostLaunchSyncAsync(); + return launchResult.IsFailure ? launchResult + : postSync.Success ? launchResult + : Fail(postSync.Message); } catch (OperationCanceledException) { return CancellationWarning("WiiCompiled launch preparation was cancelled."); } + catch (Exception exception) + { + return Fail($"WiiCompiled could not start with the selected profiles: {exception.Message}"); + } finally { progressWindow.Close(); diff --git a/WheelWizard/Features/Settings/ISettingsServices.cs b/WheelWizard/Features/Settings/ISettingsServices.cs index d756d0062..07c2d28d3 100644 --- a/WheelWizard/Features/Settings/ISettingsServices.cs +++ b/WheelWizard/Features/Settings/ISettingsServices.cs @@ -64,6 +64,22 @@ public interface ISettingsProperties Setting RECOMP_SHOW_FPS { get; } Setting RECOMP_PREVENT_STUTTERS { get; } Setting RECOMP_NAND_ROOT { get; } + Setting CLOUD_SYNC_ENABLED { get; } + Setting CLOUD_PROVIDER_TYPE { get; } + Setting CLOUD_PROFILE_ID { get; } + Setting CLOUD_DEVICE_ID { get; } + Setting SYNC_BEFORE_LAUNCH { get; } + Setting SYNC_AFTER_LAUNCH { get; } + Setting CLOUD_REMOTE_ROOT { get; } + Setting CLOUD_NEXTCLOUD_SERVER { get; } + + /// Public OAuth application identifier; never a client secret. + Setting CLOUD_GOOGLE_CLIENT_ID { get; } + + /// Public OAuth application identifier; never a client secret. + Setting CLOUD_ONEDRIVE_CLIENT_ID { get; } + Setting CLOUD_VISIBLE_PROFILE_IDS { get; } + Setting CLOUD_SYNC_PROFILE_IDS { get; } } public interface ISettingsManager : ISettingsProperties diff --git a/WheelWizard/Features/Settings/SettingsManager.cs b/WheelWizard/Features/Settings/SettingsManager.cs index 21bf16dd6..13c778a9d 100644 --- a/WheelWizard/Features/Settings/SettingsManager.cs +++ b/WheelWizard/Features/Settings/SettingsManager.cs @@ -189,6 +189,22 @@ IFileSystem fileSystem SAVED_WINDOW_SCALE = RegisterWhWz("WindowScale", 1.0, SettingValues.IsValidWindowScale); RR_REGION = RegisterWhWz("RR_Region", MarioKartWiiEnums.Regions.None); WW_LANGUAGE = RegisterWhWz("WW_Language", "en", value => SettingValues.WhWzLanguages.ContainsKey((string)value!)); + // Cloud credentials are intentionally not settings. Only non-sensitive identifiers and + // switches are persisted in config.json; secrets belong to ISecureCredentialStore. + CLOUD_SYNC_ENABLED = RegisterWhWz("CloudSyncEnabled", false); + CLOUD_PROVIDER_TYPE = RegisterWhWz("CloudProviderType", "WebDav"); + CLOUD_PROFILE_ID = RegisterWhWz("CloudProfileId", ""); + CLOUD_DEVICE_ID = RegisterWhWz("CloudDeviceId", Guid.NewGuid().ToString("D")); + SYNC_BEFORE_LAUNCH = RegisterWhWz("SyncBeforeLaunch", true); + SYNC_AFTER_LAUNCH = RegisterWhWz("SyncAfterLaunch", true); + CLOUD_REMOTE_ROOT = RegisterWhWz("CloudRemoteRoot", ""); + CLOUD_NEXTCLOUD_SERVER = RegisterWhWz("CloudNextcloudServer", ""); + // OAuth refresh/access tokens go in ISecureCredentialStore. These are only the public + // application ids from the user's Google Cloud / Microsoft Entra registration. + CLOUD_GOOGLE_CLIENT_ID = RegisterWhWz("CloudGoogleClientId", ""); + CLOUD_ONEDRIVE_CLIENT_ID = RegisterWhWz("CloudOneDriveClientId", ""); + CLOUD_VISIBLE_PROFILE_IDS = RegisterWhWz("CloudVisibleProfileIds", ""); + CLOUD_SYNC_PROFILE_IDS = RegisterWhWz("CloudSyncProfileIds", ""); #endregion #region Dolphin settings @@ -307,6 +323,18 @@ IFileSystem fileSystem public Setting RECOMP_SHOW_FPS { get; } public Setting RECOMP_PREVENT_STUTTERS { get; } public Setting RECOMP_NAND_ROOT { get; } + public Setting CLOUD_SYNC_ENABLED { get; } + public Setting CLOUD_PROVIDER_TYPE { get; } + public Setting CLOUD_PROFILE_ID { get; } + public Setting CLOUD_DEVICE_ID { get; } + public Setting SYNC_BEFORE_LAUNCH { get; } + public Setting SYNC_AFTER_LAUNCH { get; } + public Setting CLOUD_REMOTE_ROOT { get; } + public Setting CLOUD_NEXTCLOUD_SERVER { get; } + public Setting CLOUD_GOOGLE_CLIENT_ID { get; } + public Setting CLOUD_ONEDRIVE_CLIENT_ID { get; } + public Setting CLOUD_VISIBLE_PROFILE_IDS { get; } + public Setting CLOUD_SYNC_PROFILE_IDS { get; } #endregion #region Public API diff --git a/WheelWizard/Services/PathManager.cs b/WheelWizard/Services/PathManager.cs index 9c2eec0aa..c8ff5e06b 100644 --- a/WheelWizard/Services/PathManager.cs +++ b/WheelWizard/Services/PathManager.cs @@ -142,6 +142,49 @@ public static bool IsUsingCustomWheelWizardAppdataPath public static string MiiDbFile => GetMiiDbFilePath(WiiFolderPath); public static string RRratingFilePath => Path.Combine(WiiFolderPath, "shared2", "Pulsar", "RetroRewind6", "RRRating.pul"); + // Cloud saves deliberately resolve individual profile files. They must never use a NAND + // directory as a package source: a NAND contains the console identity and other device state. + public static string GetRetroWfcSavePath() => Path.Combine(SaveFolderPath, "rksys.dat"); + + public static string GetMiiDatabasePath(string activeNandPath) => GetMiiDbFilePath(activeNandPath); + + public static string GetRetroRewindRatingPath(string activeNandPath) => + Path.Combine(activeNandPath, "shared2", "Pulsar", "RetroRewind6", "RRRating.pul"); + + public static string GetRetroRewindSettingsPath(string activeNandPath) => + Path.Combine(activeNandPath, "shared2", "Pulsar", "RetroRewind6", "RRSettings.pul"); + + public static string GetRetroRewindGameSettingsPath(string activeNandPath) => + Path.Combine(activeNandPath, "shared2", "Pulsar", "RetroRewind6", "RRGameSettings.pul"); + + /// + /// Resolves the NAND selected for the current frontend. This is a local file source for + /// profile files only; callers must never enumerate or upload this directory. + /// + public static string GetActiveNandPath() + { + if (!Settings.IsRecompModeActive()) + return WiiFolderPath; + + if (Settings.Get(Settings.RECOMP_USE_DOLPHIN_DATA)) + { + var configured = Settings.Get(Settings.NAND_ROOT_PATH); + if (!string.IsNullOrWhiteSpace(configured) && Directory.Exists(configured)) + return configured; + var userFolder = Directory.Exists(UserFolderPath) ? UserFolderPath : TryFindUserFolderPath(); + return string.IsNullOrWhiteSpace(userFolder) ? WiiFolderPath : Path.Combine(userFolder, "Wii"); + } + + if (Settings.Get(Settings.RECOMP_COPY_DOLPHIN_NAND) && Directory.Exists(RecompNandCopyFolderPath)) + return RecompNandCopyFolderPath; + + return RecompPrivateNandFolderPath; + } + + public static string CloudSavesFolderPath => Path.Combine(WheelWizardAppdataPath, "CloudSaves"); + public static string CloudSyncStateFolderPath => Path.Combine(CloudSavesFolderPath, "State"); + public static string CloudBackupFolderPath => Path.Combine(CloudSavesFolderPath, "Backups"); + /// The file the recomp setup writes to mark a directory as one of its installations. public const string RecompInstallStateFileName = "install-state.json"; diff --git a/WheelWizard/SetupExtensions.cs b/WheelWizard/SetupExtensions.cs index 05c45b142..2e3e3c2f7 100644 --- a/WheelWizard/SetupExtensions.cs +++ b/WheelWizard/SetupExtensions.cs @@ -4,6 +4,7 @@ using Testably.Abstractions; using WheelWizard.AutoUpdating; using WheelWizard.Branding; +using WheelWizard.CloudSync; using WheelWizard.CustomCharacters; using WheelWizard.CustomDistributions; using WheelWizard.DolphinInstaller; @@ -51,6 +52,7 @@ public static void AddWheelWizardServices(this IServiceCollection services) services.AddPatches(); services.AddMods(); services.AddRecomp(); + services.AddCloudSync(); // IO Abstractions services.AddSingleton(); diff --git a/WheelWizard/Views/Components/Button.axaml b/WheelWizard/Views/Components/Button.axaml index 9e911d22d..3494f2171 100644 --- a/WheelWizard/Views/Components/Button.axaml +++ b/WheelWizard/Views/Components/Button.axaml @@ -46,14 +46,25 @@ BorderBrush="{TemplateBinding BorderBrush}" BorderThickness="{TemplateBinding BorderThickness}" CornerRadius="{TemplateBinding CornerRadius}"> - + + + + + + + + diff --git a/WheelWizard/Views/Components/Button.axaml.cs b/WheelWizard/Views/Components/Button.axaml.cs index 2d55134dd..00548d178 100644 --- a/WheelWizard/Views/Components/Button.axaml.cs +++ b/WheelWizard/Views/Components/Button.axaml.cs @@ -21,6 +21,8 @@ public class Button : Avalonia.Controls.Button // Change to TemplatedControl public static readonly StyledProperty TextProperty = AvaloniaProperty.Register(nameof(Text)); + public static readonly StyledProperty IsLoadingProperty = AvaloniaProperty.Register(nameof(IsLoading)); + public enum ButtonsVariantType { Primary, @@ -75,6 +77,12 @@ public string Text set => SetValue(TextProperty, value); } + public bool IsLoading + { + get => GetValue(IsLoadingProperty); + set => SetValue(IsLoadingProperty, value); + } + // UpdateStyleClasses remains the same private void UpdateStyleClasses(ButtonsVariantType variant) { diff --git a/WheelWizard/Views/Layout.axaml b/WheelWizard/Views/Layout.axaml index 4d08fc28f..6d4cd4ea9 100644 --- a/WheelWizard/Views/Layout.axaml +++ b/WheelWizard/Views/Layout.axaml @@ -113,6 +113,9 @@ Text="{loc:T page_title.my_miis}" /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/WheelWizard/Views/Pages/CloudPage.axaml.cs b/WheelWizard/Views/Pages/CloudPage.axaml.cs new file mode 100644 index 000000000..5ac1e9e23 --- /dev/null +++ b/WheelWizard/Views/Pages/CloudPage.axaml.cs @@ -0,0 +1,165 @@ +using System.Collections.ObjectModel; +using System.ComponentModel; +using Avalonia; +using Avalonia.Interactivity; +using Serilog; +using WheelWizard.CloudSync; +using WheelWizard.CloudSync.ProfileLibrary; +using WheelWizard.Shared.DependencyInjection; +using WheelWizard.Views.Popups; +using WheelWizard.WiiManagement.MiiManagement.Domain.Mii; + +namespace WheelWizard.Views.Pages; + +public sealed class CloudSyncProfileChoice(ProfileLibraryEntry entry, bool selected) : INotifyPropertyChanged +{ + private bool _isSelected = selected; + + public ProfileLibraryEntry Entry { get; } = entry; + public string Name => Entry.Name; + public string FriendCode => string.IsNullOrWhiteSpace(Entry.FriendCode) ? "Offline license" : Entry.FriendCode; + public string LastUpdated => Entry.LastUpdatedUtc is { } time ? $"Last updated: {time.ToLocalTime():g}" : "Last updated: unavailable"; + public Mii? Mii => Entry.Mii; + public bool CanSync => Entry.Source is ProfileLibrarySource.Local or ProfileLibrarySource.Vault; + public bool IsLocalOnly => Entry.StorageState == ProfileStorageState.LocalOnly; + public bool IsCloudAndLocal => Entry.StorageState == ProfileStorageState.CloudAndLocal; + public string StorageStatus => + Entry.StorageState switch + { + ProfileStorageState.CloudOnly => "Available in cloud only", + ProfileStorageState.LocalOnly => "Available locally only", + ProfileStorageState.CloudAndLocal => "Available in cloud and on this device", + _ => string.Empty, + }; + + public bool IsSelected + { + get => _isSelected; + set + { + if (_isSelected == value) + return; + _isSelected = value; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IsSelected))); + } + } + + public event PropertyChangedEventHandler? PropertyChanged; +} + +public partial class CloudPage : UserControlBase +{ + private bool _syncInProgress; + private bool _profilesLoaded; + + [Inject] + private ICloudProfileLibraryService ProfileLibraryService { get; set; } = null!; + + [Inject] + private ICloudSyncService CloudSync { get; set; } = null!; + + public ObservableCollection Profiles { get; } = []; + + public CloudPage() + { + InitializeComponent(); + DataContext = this; + _ = LoadProfilesAsync(); + } + + private async Task LoadProfilesAsync() + { + Status.Text = "Loading local and cloud profiles..."; + _profilesLoaded = false; + try + { + var profiles = await ProfileLibraryService.GetAllAsync(); + var selected = ProfileLibraryService.GetSyncSelected(profiles).Select(profile => profile.Key).ToHashSet(StringComparer.Ordinal); + Profiles.Clear(); + foreach (var profile in profiles) + Profiles.Add(new CloudSyncProfileChoice(profile, selected.Contains(profile.Key))); + _profilesLoaded = true; + Status.Text = + Profiles.Count == 0 + ? "No Mario Kart licenses were found on this device." + : "Select the local profiles you want to sync, then save your selection."; + } + catch (Exception exception) + { + Log.Error(exception, "Could not load cloud profiles"); + Status.Text = $"Could not load cloud profiles: {exception.Message}"; + } + } + + private void SaveSelection_OnClick(object? sender, RoutedEventArgs e) + { + Status.Text = SaveSelection() ? "Cloud profile selection saved." : "Profiles are still loading; the selection was not changed."; + } + + private async void ChooseVisibleProfiles_OnClick(object? sender, RoutedEventArgs e) + { + try + { + var profiles = (await ProfileLibraryService.GetAllAsync()).ToList(); + var visible = ProfileLibraryService.GetVisible(profiles).Select(profile => profile.Key); + var selected = await new ProfileVisibilityWindow().SetProfiles(profiles, visible).AwaitAnswer(); + if (selected is null) + return; + ProfileLibraryService.SaveVisible(selected); + await LoadProfilesAsync(); + Status.Text = "Visible profiles updated."; + } + catch (Exception exception) + { + Log.Error(exception, "Could not choose visible cloud profiles"); + Status.Text = $"Could not update visible profiles: {exception.Message}"; + } + } + + private async void SyncSelected_OnClick(object? sender, RoutedEventArgs e) + { + if (_syncInProgress) + return; + _syncInProgress = true; + if (!SaveSelection()) + { + Status.Text = "Profiles are still loading; synchronization was not started."; + _syncInProgress = false; + return; + } + Status.Text = "Synchronizing selected profiles..."; + SyncButton.Text = "Syncing profiles..."; + SyncButton.IsLoading = true; + SyncButton.Variant = WheelWizard.Views.Components.Button.ButtonsVariantType.Primary; + try + { + var result = await CloudSync.SyncNowAsync(); + await LoadProfilesAsync(); + Status.Text = result.Message; + } + catch (Exception exception) + { + Log.Error(exception, "Could not synchronize cloud profiles"); + Status.Text = $"Cloud synchronization failed: {exception.Message}"; + } + finally + { + _syncInProgress = false; + SyncButton.IsLoading = false; + SyncButton.Text = "Sync selected profiles"; + SyncButton.Variant = WheelWizard.Views.Components.Button.ButtonsVariantType.Default; + } + } + + private bool SaveSelection() + { + if (!_profilesLoaded) + return false; + var selected = Profiles.Where(profile => profile.CanSync && profile.IsSelected).Select(profile => profile.Entry.Key); + ProfileLibraryService.SaveSyncSelected(selected); + return true; + } + + private void CloudSettings_OnClick(object? sender, RoutedEventArgs e) => + NavigationManager.NavigateTo(new Settings.CloudSaveSettings()); +} diff --git a/WheelWizard/Views/Pages/Settings/CloudSaveSettings.axaml b/WheelWizard/Views/Pages/Settings/CloudSaveSettings.axaml new file mode 100644 index 000000000..e276e7f6e --- /dev/null +++ b/WheelWizard/Views/Pages/Settings/CloudSaveSettings.axaml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/WheelWizard/Views/Pages/Settings/CloudSaveSettings.axaml.cs b/WheelWizard/Views/Pages/Settings/CloudSaveSettings.axaml.cs new file mode 100644 index 000000000..e1e0f6268 --- /dev/null +++ b/WheelWizard/Views/Pages/Settings/CloudSaveSettings.axaml.cs @@ -0,0 +1,240 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Media; +using WheelWizard.CloudSync; +using WheelWizard.CloudSync.Credentials; +using WheelWizard.CloudSync.Providers; +using WheelWizard.Settings; +using WheelWizard.Shared.DependencyInjection; + +namespace WheelWizard.Views.Pages.Settings; + +public partial class CloudSaveSettings : UserControlBase +{ + private static readonly string[] ProviderItems = ["WebDav", "Nextcloud", "Google Drive", "OneDrive"]; + private bool _loading; + private bool _signedIn; + + [Inject] + private ISettingsManager Settings { get; set; } = null!; + + [Inject] + private ICloudSyncService CloudSync { get; set; } = null!; + + [Inject] + private ICloudProviderResolver Providers { get; set; } = null!; + + [Inject] + private ISecureCredentialStore Credentials { get; set; } = null!; + + public CloudSaveSettings() + { + InitializeComponent(); + _loading = true; + Enabled.IsChecked = Settings.Get(Settings.CLOUD_SYNC_ENABLED); + BeforeLaunch.IsChecked = Settings.Get(Settings.SYNC_BEFORE_LAUNCH); + AfterLaunch.IsChecked = Settings.Get(Settings.SYNC_AFTER_LAUNCH); + Provider.SelectedIndex = Math.Max(0, Array.IndexOf(ProviderItems, Settings.Get(Settings.CLOUD_PROVIDER_TYPE))); + ConfigureProviderFields(); + _loading = false; + _ = RefreshStatusAsync(); + _ = RefreshAuthorizationStateAsync(); + } + + private CloudProviderType SelectedProvider => + Provider.SelectedIndex is >= 0 and < 4 + ? Enum.Parse(ProviderItems[Provider.SelectedIndex]) + : CloudProviderType.WebDav; + + private void Provider_OnSelectionChanged(object? sender, SelectionChangedEventArgs e) + { + if (_loading) + return; + ConfigureProviderFields(); + _ = RefreshAuthorizationStateAsync(); + } + + private void ConfigureProviderFields() + { + var provider = SelectedProvider; + ServerSettings.IsVisible = provider is CloudProviderType.WebDav or CloudProviderType.Nextcloud; + WebDavCredentials.IsVisible = provider == CloudProviderType.WebDav; + OAuthClientSettings.IsVisible = provider is CloudProviderType.GoogleDrive or CloudProviderType.OneDrive; + BrowserAuthorizationHint.IsVisible = + provider is CloudProviderType.Nextcloud or CloudProviderType.GoogleDrive or CloudProviderType.OneDrive; + ConnectButton.IsVisible = true; + + switch (provider) + { + case CloudProviderType.WebDav: + ServerUrlLabel.Text = "WebDAV collection URL"; + ServerUrlHint.Text = "Enter the WebDAV collection URL, for example https://server.example/remote.php/dav/files/your-user."; + RemoteRoot.PlaceholderText = "https://server.example/remote.php/dav/files/your-user"; + RemoteRoot.Text = Settings.Get(Settings.CLOUD_REMOTE_ROOT); + BrowserAuthorizationHint.Text = string.Empty; + SetSignInButton(false); + break; + case CloudProviderType.Nextcloud: + ServerUrlLabel.Text = "Nextcloud server URL"; + ServerUrlHint.Text = "Enter only the base server URL, for example https://cloud.example.com. Do not enter a WebDAV path."; + RemoteRoot.PlaceholderText = "https://cloud.example.com"; + var savedServer = Settings.Get(Settings.CLOUD_NEXTCLOUD_SERVER); + var legacyRoot = Settings.Get(Settings.CLOUD_REMOTE_ROOT); + RemoteRoot.Text = + string.IsNullOrWhiteSpace(savedServer) && !legacyRoot.Contains("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/remote.php/dav/", StringComparison.OrdinalIgnoreCase) + ? legacyRoot + : savedServer; + BrowserAuthorizationHint.Text = + "Click Sign in. Nextcloud opens in your default browser, including any 2FA prompt. WheelWizard never asks for or stores your account password."; + SetSignInButton(false); + break; + case CloudProviderType.GoogleDrive: + OAuthClientIdLabel.Text = "Google OAuth client ID"; + OAuthClientIdHint.Text = + "Create a Desktop OAuth client in Google Cloud and paste its public client ID here. Desktop clients support the secure loopback return used by WheelWizard; no client secret is used."; + OAuthClientId.Text = Settings.Get(Settings.CLOUD_GOOGLE_CLIENT_ID); + BrowserAuthorizationHint.Text = + "Click Sign in to authorize WheelWizard in your browser. The refresh token is stored only in your OS credential store and Drive access is limited to WheelWizard's app data folder."; + SetSignInButton(false); + break; + case CloudProviderType.OneDrive: + OAuthClientIdLabel.Text = "Microsoft application (client) ID"; + OAuthClientIdHint.Text = + "Create a public desktop client application in Microsoft Entra and paste its Application (client) ID here. Enable its loopback redirect for desktop apps; no client secret is used."; + OAuthClientId.Text = Settings.Get(Settings.CLOUD_ONEDRIVE_CLIENT_ID); + BrowserAuthorizationHint.Text = + "Click Sign in to authorize WheelWizard in your browser. The refresh token is stored only in your OS credential store and files are kept in WheelWizard's OneDrive app folder."; + SetSignInButton(false); + break; + } + } + + private void SaveSettings() + { + var provider = SelectedProvider; + Settings.Set(Settings.CLOUD_SYNC_ENABLED, Enabled.IsChecked == true); + Settings.Set(Settings.SYNC_BEFORE_LAUNCH, BeforeLaunch.IsChecked == true); + Settings.Set(Settings.SYNC_AFTER_LAUNCH, AfterLaunch.IsChecked == true); + Settings.Set(Settings.CLOUD_PROVIDER_TYPE, provider.ToString()); + if (provider == CloudProviderType.WebDav) + Settings.Set(Settings.CLOUD_REMOTE_ROOT, RemoteRoot.Text?.Trim() ?? string.Empty); + else if (provider == CloudProviderType.Nextcloud) + Settings.Set(Settings.CLOUD_NEXTCLOUD_SERVER, (RemoteRoot.Text ?? string.Empty).Trim().TrimEnd('/')); + else if (provider == CloudProviderType.GoogleDrive) + Settings.Set(Settings.CLOUD_GOOGLE_CLIENT_ID, OAuthClientId.Text?.Trim() ?? string.Empty); + else if (provider == CloudProviderType.OneDrive) + Settings.Set(Settings.CLOUD_ONEDRIVE_CLIENT_ID, OAuthClientId.Text?.Trim() ?? string.Empty); + } + + private async void Connect_OnClick(object? sender, RoutedEventArgs e) + { + SaveSettings(); + try + { + var provider = SelectedProvider; + if (_signedIn) + { + await Providers.Resolve(provider).DisconnectAsync(); + SetSignInButton(false); + Status.Text = "Signed out."; + return; + } + if (provider == CloudProviderType.WebDav) + { + var hasUserName = !string.IsNullOrWhiteSpace(UserName.Text); + var hasPassword = !string.IsNullOrWhiteSpace(AppPassword.Text); + if (hasUserName != hasPassword) + throw new InvalidOperationException("Enter both the WebDAV user name and app password."); + if (hasUserName) + { + await Credentials.SaveAsync("cloud-webdav", new Secret($"{UserName.Text}:{AppPassword.Text}")); + AppPassword.Text = string.Empty; + } + } + else if (provider == CloudProviderType.Nextcloud) + { + Status.Text = + "Opening Nextcloud in your browser. Log in there and grant WheelWizard access; this page will finish automatically."; + } + else + { + Status.Text = "Opening your browser. Complete the sign-in there; this page will finish automatically."; + } + + await Providers.Resolve(provider).AuthenticateAsync(); + ConfigureProviderFields(); + SetSignInButton(true); + Status.Text = provider switch + { + CloudProviderType.Nextcloud => "Nextcloud authorization succeeded.", + CloudProviderType.GoogleDrive => "Google Drive authorization succeeded.", + CloudProviderType.OneDrive => "OneDrive authorization succeeded.", + _ => "WebDAV connection succeeded.", + }; + } + catch (Exception exception) + { + Status.Text = exception.Message; + } + } + + private async void SyncNow_OnClick(object? sender, RoutedEventArgs e) + { + SaveSettings(); + var result = await CloudSync.SyncNowAsync(); + Status.Text = result.Message; + } + + private async Task RefreshStatusAsync() + { + var status = await CloudSync.GetStatusAsync(); + Status.Text = status.Message; + } + + private async Task RefreshAuthorizationStateAsync() + { + var provider = SelectedProvider; + SetSignInButton(false); + try + { + var signedIn = await Providers.Resolve(provider).IsAuthenticatedAsync(); + if (provider == SelectedProvider) + SetSignInButton(signedIn); + } + catch + { + if (provider == SelectedProvider) + SetSignInButton(false); + } + } + + private void SetSignInButton(bool signedIn) + { + _signedIn = signedIn; + ConnectButton.Text = signedIn ? "Signed in" : "Sign in"; + ConnectButton.Variant = signedIn + ? WheelWizard.Views.Components.Button.ButtonsVariantType.Primary + : WheelWizard.Views.Components.Button.ButtonsVariantType.Default; + ConnectButton.IconData = signedIn ? Icon("CheckMark") : null!; + } + + private void ConnectButton_OnPointerEntered(object? sender, PointerEventArgs e) + { + if (!_signedIn) + return; + + ConnectButton.Text = "Sign out"; + ConnectButton.IconData = Icon("SignOut"); + ConnectButton.Variant = WheelWizard.Views.Components.Button.ButtonsVariantType.Danger; + } + + private void ConnectButton_OnPointerExited(object? sender, PointerEventArgs e) + { + if (_signedIn) + SetSignInButton(true); + } + + private static Geometry Icon(string key) => (Geometry)Application.Current!.FindResource(key)!; +} diff --git a/WheelWizard/Views/Pages/SettingsPage.axaml b/WheelWizard/Views/Pages/SettingsPage.axaml index cda7ad5d9..235440bdb 100644 --- a/WheelWizard/Views/Pages/SettingsPage.axaml +++ b/WheelWizard/Views/Pages/SettingsPage.axaml @@ -29,6 +29,8 @@ Click="TopBarRadio_OnClick" Content="{loc:T category.dolphin}"/> + diff --git a/WheelWizard/Views/Pages/UserProfilePage.axaml b/WheelWizard/Views/Pages/UserProfilePage.axaml index 0a41a6b81..400bbaa7a 100644 --- a/WheelWizard/Views/Pages/UserProfilePage.axaml +++ b/WheelWizard/Views/Pages/UserProfilePage.axaml @@ -107,23 +107,29 @@ HorizontalAlignment="Stretch" VerticalAlignment="Bottom" /> - + + - - - - - - + + + + + + + - - + + + + @@ -210,7 +216,7 @@ - + @@ -322,7 +328,7 @@ - diff --git a/WheelWizard/Views/Pages/UserProfilePage.axaml.cs b/WheelWizard/Views/Pages/UserProfilePage.axaml.cs index 53305a7bb..609e02c22 100644 --- a/WheelWizard/Views/Pages/UserProfilePage.axaml.cs +++ b/WheelWizard/Views/Pages/UserProfilePage.axaml.cs @@ -3,6 +3,8 @@ using Avalonia.Input.Platform; using Avalonia.Interactivity; using Avalonia.Media; +using Avalonia.Threading; +using WheelWizard.CloudSync.ProfileLibrary; using WheelWizard.Models.Enums; using WheelWizard.Services.LiveData; using WheelWizard.Services.Other; @@ -11,6 +13,7 @@ using WheelWizard.Shared.DependencyInjection; using WheelWizard.Shared.MessageTranslations; using WheelWizard.Views.Components; +using WheelWizard.Views.Popups; using WheelWizard.Views.Popups.Generic; using WheelWizard.Views.Popups.MiiManagement; using WheelWizard.WheelWizardData; @@ -34,6 +37,9 @@ public partial class UserProfilePage : UserControlBase, INotifyPropertyChanged private bool _hasProfileInfo; private string _currentFriendCode = string.Empty; private int _activeInfoSlideIndex; + private List _allLibraryProfiles = []; + private List _visibleLibraryProfiles = []; + private ProfileLibraryEntry? _cloudProfileBeingViewed; [Inject] private IGameLicenseSingletonService GameLicenseService { get; set; } = null!; @@ -47,6 +53,9 @@ public partial class UserProfilePage : UserControlBase, INotifyPropertyChanged [Inject] private ISettingsManager SettingsService { get; set; } = null!; + [Inject] + private ICloudProfileLibraryService ProfileLibraryService { get; set; } = null!; + public Mii? CurrentMii { get => _currentMii; @@ -124,10 +133,32 @@ public UserProfilePage() UpdatePage(); DataContext = this; UpdateCarouselIndicators(); + _ = RefreshVisibleProfileLibraryAsync(); + ProfileLibraryChangeNotifier.Changed += ProfileLibraryChanged_OnChanged; + DetachedFromVisualTree += (_, _) => DeactivateProfileLibrarySubscription(); // Make sure this action gets subscribed AFTER the PopulateRegions method RegionDropdown.SelectionChanged += RegionDropdown_SelectionChanged; } + internal void DeactivateProfileLibrarySubscription() => ProfileLibraryChangeNotifier.Changed -= ProfileLibraryChanged_OnChanged; + + private void ProfileLibraryChanged_OnChanged(object? sender, EventArgs e) + { + // The game process has exited at this point. Reload the same singleton used by the + // sidebar and redraw this existing page, rather than requiring a complete app restart. + Dispatcher.UIThread.Post(async () => + { + if (GameLicenseService.LoadLicense().IsFailure) + return; + _currentUserIndex = Math.Clamp(_currentUserIndex, 0, 3); + UpdatePage(); + await RefreshVisibleProfileLibraryAsync(); + var layout = ViewUtils.GetLayout(); + layout.UpdateFriendCount(); + layout.UpdateSidebarProfile(); + }); + } + private void PopulateRegions() { var validRegions = RRRegionManager.GetValidRegions(); @@ -197,6 +228,9 @@ private void ResetMiiTopBar() radioButton.Content = TrimProfileSelectorText(displayName); } + if (SettingsService.Get(SettingsService.CLOUD_SYNC_ENABLED) && _visibleLibraryProfiles.Count > 0) + ApplyVisibleProfileSelectors(); + UpdateCarouselIndicators(); } @@ -210,6 +244,7 @@ private static string TrimProfileSelectorText(string? text) private void UpdatePage() { + _cloudProfileBeingViewed = null; PrimaryCheckBox.IsChecked = FocusedUser == _currentUserIndex; currentPlayer = GameLicenseService.GetUserData(_currentUserIndex); @@ -222,6 +257,7 @@ private void UpdatePage() CurrentMii = currentPlayer.Mii; IsOnline = currentPlayer.IsOnline; HasCurrentUserRoom = IsUserInLiveRoom(currentPlayer.FriendCode); + SetLocalProfileControlsVisible(true); UpdateOnlineBorders(); ProfileAttribTotalRaces.Text = currentPlayer.Statistics.RaceTotals.AllRacesCount.ToString(); @@ -296,6 +332,22 @@ private void TopBarRadio_OnClick(object? sender, RoutedEventArgs e) { var oldIndex = _currentUserIndex; + if (sender is RadioButton { Tag: ProfileLibraryEntry profile }) + { + if (profile.Source is ProfileLibrarySource.Cloud or ProfileLibrarySource.Vault) + { + ShowCloudProfile(profile); + return; + } + + if (profile.LocalSlot is not int localSlot) + return; + _currentUserIndex = localSlot; + if (oldIndex != _currentUserIndex || _cloudProfileBeingViewed is not null) + UpdatePage(); + return; + } + if (sender is not RadioButton button || !int.TryParse((string?)button.Tag, out _currentUserIndex)) return; if (oldIndex == _currentUserIndex) @@ -416,6 +468,104 @@ private void UpdateSidebarProfileIfCurrentUser() ViewUtils.GetLayout().UpdateSidebarProfile(); } + private async Task RefreshVisibleProfileLibraryAsync() + { + VisibleProfilesButton.IsVisible = SettingsService.Get(SettingsService.CLOUD_SYNC_ENABLED); + if (!VisibleProfilesButton.IsVisible) + return; + + _allLibraryProfiles = (await ProfileLibraryService.GetAllAsync()).ToList(); + _visibleLibraryProfiles = ProfileLibraryService.GetVisible(_allLibraryProfiles).ToList(); + ApplyVisibleProfileSelectors(); + } + + private void ApplyVisibleProfileSelectors() + { + for (var slot = 0; slot < 4; slot++) + { + if (RadioButtons.Children[slot] is not RadioButton radio) + continue; + + if (slot >= _visibleLibraryProfiles.Count) + { + radio.Tag = null; + radio.Content = t("state.no_license"); + radio.IsEnabled = false; + radio.IsChecked = false; + continue; + } + + var profile = _visibleLibraryProfiles[slot]; + radio.Tag = profile; + radio.Content = TrimProfileSelectorText(profile.Name); + radio.IsEnabled = true; + radio.IsChecked = + profile == _cloudProfileBeingViewed || (profile.LocalSlot == _currentUserIndex && _cloudProfileBeingViewed is null); + } + } + + private async void ChooseVisibleProfiles_OnClick(object? sender, RoutedEventArgs e) + { + _allLibraryProfiles = (await ProfileLibraryService.GetAllAsync()).ToList(); + var selected = await new ProfileVisibilityWindow() + .SetProfiles(_allLibraryProfiles, _visibleLibraryProfiles.Select(profile => profile.Key)) + .AwaitAnswer(); + if (selected is null) + return; + + ProfileLibraryService.SaveVisible(selected); + _visibleLibraryProfiles = ProfileLibraryService.GetVisible(_allLibraryProfiles).ToList(); + ApplyVisibleProfileSelectors(); + if ( + _visibleLibraryProfiles.Count > 0 + && !_visibleLibraryProfiles.Any(profile => + profile.LocalSlot == _currentUserIndex && profile.Source == ProfileLibrarySource.Local + ) + ) + { + var first = _visibleLibraryProfiles[0]; + if (first.Source is ProfileLibrarySource.Cloud or ProfileLibrarySource.Vault) + ShowCloudProfile(first); + else if (first.LocalSlot is int localSlot) + { + _currentUserIndex = localSlot; + UpdatePage(); + } + } + } + + private void ShowCloudProfile(ProfileLibraryEntry profile) + { + _cloudProfileBeingViewed = profile; + SetLocalProfileControlsVisible(false); + CurrentMii = profile.Mii; + CurrentFriendCode = profile.FriendCode; + ProfileAttribUserName.Text = profile.Name; + ProfileAttribFriendCode.Text = profile.FriendCode; + ProfileAttribFriendCode.IsVisible = !string.IsNullOrWhiteSpace(profile.FriendCode); + ProfileAttribVr.Text = profile.Vr == 0 ? "-" : profile.Vr.ToString(); + ProfileAttribBr.Text = profile.Br == 0 ? "-" : profile.Br.ToString(); + ProfileAttribTotalRaces.Text = "-"; + ProfileAttribTotalWins.Text = "-"; + BadgeContainer.Children.Clear(); + IsOnline = false; + HasCurrentUserRoom = false; + HasProfileInfo = true; + CurrentUserProfile.IsVisible = true; + ProfileCarouselContainer.IsVisible = true; + UpdateOnlineBorders(); + ApplyVisibleProfileSelectors(); + } + + private void SetLocalProfileControlsVisible(bool visible) + { + EditMiiName.IsVisible = visible; + MiiSelectButton.IsVisible = visible; + PrimaryCheckBox.IsVisible = visible; + CopyFcButton.IsVisible = visible; + ViewRoomButton.IsVisible = visible && HasCurrentUserRoom; + } + private void MoveCarouselPage(int offset) { ActiveInfoSlideIndex += offset; diff --git a/WheelWizard/Views/Popups/ProfileVisibilityWindow.axaml b/WheelWizard/Views/Popups/ProfileVisibilityWindow.axaml new file mode 100644 index 000000000..ee91066bb --- /dev/null +++ b/WheelWizard/Views/Popups/ProfileVisibilityWindow.axaml @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/WheelWizard/Views/Popups/ProfileVisibilityWindow.axaml.cs b/WheelWizard/Views/Popups/ProfileVisibilityWindow.axaml.cs new file mode 100644 index 000000000..cdee04e15 --- /dev/null +++ b/WheelWizard/Views/Popups/ProfileVisibilityWindow.axaml.cs @@ -0,0 +1,149 @@ +using System.Collections.ObjectModel; +using System.ComponentModel; +using Avalonia.Interactivity; +using WheelWizard.CloudSync.ProfileLibrary; +using WheelWizard.Views.Popups.Base; +using WheelWizard.WiiManagement.MiiManagement.Domain.Mii; + +namespace WheelWizard.Views.Popups; + +public sealed class ProfileVisibilityChoice : INotifyPropertyChanged +{ + private bool _isSelected; + private bool _canSelect = true; + + public ProfileVisibilityChoice(ProfileLibraryEntry entry, bool isSelected) + { + Entry = entry; + _isSelected = isSelected; + } + + public ProfileLibraryEntry Entry { get; } + public string Name => Entry.Name; + public string FriendCode => string.IsNullOrWhiteSpace(Entry.FriendCode) ? "Offline license" : Entry.FriendCode; + public string LastUpdated => Entry.LastUpdatedUtc is { } time ? $"Last updated: {time.ToLocalTime():g}" : "Last updated: unavailable"; + public Mii? Mii => Entry.Mii; + public bool IsCloudOnly => Entry.StorageState == ProfileStorageState.CloudOnly; + public bool IsLocalOnly => Entry.StorageState == ProfileStorageState.LocalOnly; + public bool IsCloudAndLocal => Entry.StorageState == ProfileStorageState.CloudAndLocal; + public string StorageStatus => + Entry.StorageState switch + { + ProfileStorageState.CloudOnly => "Available in cloud only", + ProfileStorageState.LocalOnly => "Available locally only", + ProfileStorageState.CloudAndLocal => "Available in cloud and on this device", + _ => string.Empty, + }; + + public bool IsSelected + { + get => _isSelected; + set + { + if (_isSelected == value) + return; + _isSelected = value; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IsSelected))); + } + } + + public bool CanSelect + { + get => _canSelect; + internal set + { + if (_canSelect == value) + return; + _canSelect = value; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(CanSelect))); + } + } + + public event PropertyChangedEventHandler? PropertyChanged; +} + +public partial class ProfileVisibilityWindow : PopupContent +{ + private readonly TaskCompletionSource?> _result = new(); + private List _selectedKeys = []; + + public ObservableCollection Choices { get; } = []; + + public ProfileVisibilityWindow() + : base(true, false, true, "Visible profiles") + { + InitializeComponent(); + DataContext = this; + Choices.CollectionChanged += (_, _) => UpdateSelectionAvailability(); + } + + public ProfileVisibilityWindow SetProfiles(IEnumerable profiles, IEnumerable selectedKeys) + { + _selectedKeys = selectedKeys.Distinct(StringComparer.Ordinal).ToList(); + var selected = _selectedKeys.ToHashSet(StringComparer.Ordinal); + Choices.Clear(); + foreach (var profile in profiles) + { + var choice = new ProfileVisibilityChoice(profile, selected.Contains(profile.Key)); + choice.PropertyChanged += Choice_OnPropertyChanged; + Choices.Add(choice); + } + UpdateSelectionAvailability(); + return this; + } + + public Task?> AwaitAnswer() + { + Show(); + return _result.Task; + } + + private void Apply_OnClick(object? sender, RoutedEventArgs e) + { + var selectedSet = Choices.Where(choice => choice.IsSelected).Select(choice => choice.Entry.Key).ToHashSet(StringComparer.Ordinal); + var selected = _selectedKeys + .Where(selectedSet.Contains) + .Concat( + Choices + .Where(choice => choice.IsSelected && !_selectedKeys.Contains(choice.Entry.Key, StringComparer.Ordinal)) + .Select(choice => choice.Entry.Key) + ) + .ToList(); + if (selected.Count > 4) + { + Validation.Text = "Select at most four profiles."; + return; + } + _result.TrySetResult(selected); + Close(); + } + + private void Choice_OnPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName != nameof(ProfileVisibilityChoice.IsSelected)) + return; + if (Choices.Count(choice => choice.IsSelected) > 4 && sender is ProfileVisibilityChoice choice) + { + choice.IsSelected = false; + Validation.Text = "Only four profiles can be visible in WiiCompiled at once."; + } + else + Validation.Text = string.Empty; + UpdateSelectionAvailability(); + } + + private void UpdateSelectionAvailability() + { + var atLimit = Choices.Count(choice => choice.IsSelected) >= 4; + foreach (var choice in Choices) + choice.CanSelect = choice.IsSelected || !atLimit; + } + + private void Cancel_OnClick(object? sender, RoutedEventArgs e) + { + _result.TrySetResult(null); + Close(); + } + + protected override void BeforeClose() => _result.TrySetResult(null); +} diff --git a/WheelWizard/Views/Styles/Resources/Icons.axaml b/WheelWizard/Views/Styles/Resources/Icons.axaml index cab692d40..cceb198a8 100644 --- a/WheelWizard/Views/Styles/Resources/Icons.axaml +++ b/WheelWizard/Views/Styles/Resources/Icons.axaml @@ -191,4 +191,6 @@ M160 0a48 48 0 1 1 0 96 48 48 0 1 1 0-96zM88 384l-17.8 0c-10.9 0-18.6-10.7-15.2-21.1L93.3 248.1 59.4 304.5c-9.1 15.1-28.8 20-43.9 10.9s-20-28.8-10.9-43.9l53.6-89.2c20.3-33.7 56.7-54.3 96-54.3l11.6 0c39.3 0 75.7 20.6 96 54.3l53.6 89.2c9.1 15.1 4.2 34.8-10.9 43.9s-34.8 4.2-43.9-10.9l-33.9-56.3L265 362.9c3.5 10.4-4.3 21.1-15.2 21.1L232 384l0 96c0 17.7-14.3 32-32 32s-32-14.3-32-32l0-96-16 0 0 96c0 17.7-14.3 32-32 32s-32-14.3-32-32l0-96z M23 23v466h466v-18H41v-82.184l85.854-57.234 70.023 70.022 65.133-260.536L387.28 203.7l67.79-107.97 19.317 11.858 6.102-71.1-60.644 37.616 19.884 12.207-59.01 93.99-130.732-65.366-62.865 251.462-57.98-57.978L41 367.184V23H23z M352 160C352 142.3 366.3 128 384 128C401.7 128 416 142.3 416 160C416 177.7 430.3 192 448 192C465.7 192 480 177.7 480 160C480 107 437 64 384 64C331 64 288 107 288 160L288 352L224 352L224 312C224 281.1 198.9 256 168 256L120 256C106.7 256 96 266.7 96 280C96 293.3 106.7 304 120 304L168 304C172.4 304 176 307.6 176 312L176 352L96 352C78.3 352 64 366.3 64 384C64 401.7 78.3 416 96 416L96 480C96 533 139 576 192 576L448 576C501 576 544 533 544 480L544 416C561.7 416 576 401.7 576 384C576 366.3 561.7 352 544 352L464 352L464 312C464 307.6 467.6 304 472 304L528 304C541.3 304 552 293.3 552 280C552 266.7 541.3 256 528 256L472 256C441.1 256 416 281.1 416 312L416 352L352 352L352 160z + M0 336c0 79.5 64.5 144 144 144l288 0c70.7 0 128-57.3 128-128c0-61.9-44-113.5-102.5-125.4C442.7 135.4 368.5 64 280 64c-82.5 0-152 62.3-162.8 142.8C49.9 219.7 0 271.7 0 336z + M64 32h224v64H128v320h160v64H64V32zm256 96 128 128-128 128v-80H192v-96h128v-80z