diff --git a/src/Artemis.Core/Models/ProfileConfiguration/ProfileConfigurationIcon.cs b/src/Artemis.Core/Models/ProfileConfiguration/ProfileConfigurationIcon.cs index 10a500cba..5a477b5fd 100644 --- a/src/Artemis.Core/Models/ProfileConfiguration/ProfileConfigurationIcon.cs +++ b/src/Artemis.Core/Models/ProfileConfiguration/ProfileConfigurationIcon.cs @@ -74,7 +74,8 @@ public class ProfileConfigurationIcon : CorePropertyChanged, IStorageModel _iconStream?.Dispose(); _iconStream = new MemoryStream(); - stream.Seek(0, SeekOrigin.Begin); + if (stream.CanSeek) + stream.Seek(0, SeekOrigin.Begin); stream.CopyTo(_iconStream); _iconStream.Seek(0, SeekOrigin.Begin); diff --git a/src/Artemis.Core/Services/ModuleService.cs b/src/Artemis.Core/Services/ModuleService.cs index e3d6cbaef..1050484c0 100644 --- a/src/Artemis.Core/Services/ModuleService.cs +++ b/src/Artemis.Core/Services/ModuleService.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Threading.Tasks; using System.Timers; using Artemis.Core.Modules; using Newtonsoft.Json; @@ -31,8 +32,8 @@ internal class ModuleService : IModuleService pluginManagementService.PluginFeatureEnabled += PluginManagementServiceOnPluginFeatureEnabled; pluginManagementService.PluginFeatureDisabled += PluginManagementServiceOnPluginFeatureDisabled; _modules = pluginManagementService.GetFeaturesOfType().ToList(); - foreach (Module module in _modules) - ImportDefaultProfiles(module); + + Task.Run(ImportDefaultProfiles); } protected virtual void OnModuleActivated(ModuleEventArgs e) @@ -96,7 +97,7 @@ internal class ModuleService : IModuleService { if (e.PluginFeature is Module module && !_modules.Contains(module)) { - ImportDefaultProfiles(module); + Task.Run(() => ImportDefaultProfiles(module)); _modules.Add(module); } } @@ -111,24 +112,21 @@ internal class ModuleService : IModuleService } } - private void ImportDefaultProfiles(Module module) + private async Task ImportDefaultProfiles() + { + foreach (Module module in _modules) + await ImportDefaultProfiles(module); + } + + private async Task ImportDefaultProfiles(Module module) { try { - List profileConfigurations = _profileService.ProfileCategories.SelectMany(c => c.ProfileConfigurations).ToList(); foreach ((DefaultCategoryName categoryName, string profilePath) in module.DefaultProfilePaths) { - ProfileConfigurationExportModel? profileConfigurationExportModel = - JsonConvert.DeserializeObject(File.ReadAllText(profilePath), IProfileService.ExportSettings); - if (profileConfigurationExportModel?.ProfileEntity == null) - throw new ArtemisCoreException($"Default profile at path {profilePath} contains no valid profile data"); - if (profileConfigurations.Any(p => p.Entity.ProfileId == profileConfigurationExportModel.ProfileEntity.Id)) - continue; - - ProfileCategory category = _profileService.ProfileCategories.FirstOrDefault(c => c.Name == categoryName.ToString()) ?? - _profileService.CreateProfileCategory(categoryName.ToString()); - - _profileService.ImportProfile(category, profileConfigurationExportModel, false, true, null); + ProfileCategory category = _profileService.ProfileCategories.FirstOrDefault(c => c.Name == categoryName.ToString()) ?? _profileService.CreateProfileCategory(categoryName.ToString()); + await using FileStream fileStream = File.OpenRead(profilePath); + await _profileService.ImportProfile(fileStream, category, false, true, null); } } catch (Exception e) diff --git a/src/Artemis.Core/Services/Storage/Interfaces/IProfileService.cs b/src/Artemis.Core/Services/Storage/Interfaces/IProfileService.cs index 1744e5335..1f1eccf8c 100644 --- a/src/Artemis.Core/Services/Storage/Interfaces/IProfileService.cs +++ b/src/Artemis.Core/Services/Storage/Interfaces/IProfileService.cs @@ -1,5 +1,7 @@ using System; using System.Collections.ObjectModel; +using System.IO; +using System.Threading.Tasks; using Newtonsoft.Json; using SkiaSharp; @@ -118,17 +120,17 @@ public interface IProfileService : IArtemisService void SaveProfile(Profile profile, bool includeChildren); /// - /// Exports the profile described in the given into an export model. + /// Exports the profile described in the given into a zip archive. /// /// The profile configuration of the profile to export. - /// The resulting export model. - ProfileConfigurationExportModel ExportProfile(ProfileConfiguration profileConfiguration); + /// The resulting zip archive. + Task ExportProfile(ProfileConfiguration profileConfiguration); /// /// Imports the provided base64 encoded GZIPed JSON as a profile configuration. /// + /// The zip archive containing the profile to import. /// The in which to import the profile. - /// The model containing the profile to import. /// Whether or not to give the profile a new GUID, making it unique. /// /// Whether or not to mark the profile as a fresh import, causing it to be adapted until @@ -136,8 +138,7 @@ public interface IProfileService : IArtemisService /// /// Text to add after the name of the profile (separated by a dash). /// The resulting profile configuration. - ProfileConfiguration ImportProfile(ProfileCategory category, ProfileConfigurationExportModel exportModel, bool makeUnique = true, bool markAsFreshImport = true, - string? nameAffix = "imported"); + Task ImportProfile(Stream archiveStream, ProfileCategory category, bool makeUnique, bool markAsFreshImport, string? nameAffix = "imported"); /// /// Adapts a given profile to the currently active devices. diff --git a/src/Artemis.Core/Services/Storage/ProfileService.cs b/src/Artemis.Core/Services/Storage/ProfileService.cs index 3a09a8c06..4ab6424e4 100644 --- a/src/Artemis.Core/Services/Storage/ProfileService.cs +++ b/src/Artemis.Core/Services/Storage/ProfileService.cs @@ -2,7 +2,11 @@ using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.IO; +using System.IO.Compression; using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; using Artemis.Core.Modules; using Artemis.Storage.Entities.Profile; using Artemis.Storage.Repositories.Interfaces; @@ -260,8 +264,7 @@ internal class ProfileService : IProfileService OnProfileDeactivated(new ProfileConfigurationEventArgs(profileConfiguration)); } - /// - public void RequestDeactivation(ProfileConfiguration profileConfiguration) + private void RequestDeactivation(ProfileConfiguration profileConfiguration) { if (FocusProfile == profileConfiguration) throw new ArtemisCoreException("Cannot disable a profile that is being edited, that's rude"); @@ -395,35 +398,82 @@ internal class ProfileService : IProfileService } /// - public ProfileConfigurationExportModel ExportProfile(ProfileConfiguration profileConfiguration) + public async Task ExportProfile(ProfileConfiguration profileConfiguration) { - // The profile may not be active and in that case lets activate it real quick - Profile profile = profileConfiguration.Profile ?? ActivateProfile(profileConfiguration); + ProfileEntity? profileEntity = _profileRepository.Get(profileConfiguration.Entity.ProfileId); + if (profileEntity == null) + throw new ArtemisCoreException("Could not locate profile entity"); - return new ProfileConfigurationExportModel + string configurationJson = JsonConvert.SerializeObject(profileConfiguration.Entity, IProfileService.ExportSettings); + string profileJson = JsonConvert.SerializeObject(profileEntity, IProfileService.ExportSettings); + + MemoryStream archiveStream = new(); + + // Create a ZIP archive + using (ZipArchive archive = new(archiveStream, ZipArchiveMode.Create, true)) { - ProfileConfigurationEntity = profileConfiguration.Entity, - ProfileEntity = profile.ProfileEntity, - ProfileImage = profileConfiguration.Icon.GetIconStream() - }; + ZipArchiveEntry configurationEntry = archive.CreateEntry("configuration.json"); + await using (Stream entryStream = configurationEntry.Open()) + { + await entryStream.WriteAsync(Encoding.Default.GetBytes(configurationJson)); + } + + ZipArchiveEntry profileEntry = archive.CreateEntry("profile.json"); + await using (Stream entryStream = profileEntry.Open()) + { + await entryStream.WriteAsync(Encoding.Default.GetBytes(profileJson)); + } + + await using Stream? iconStream = profileConfiguration.Icon.GetIconStream(); + if (iconStream != null) + { + ZipArchiveEntry iconEntry = archive.CreateEntry("icon.png"); + await using Stream entryStream = iconEntry.Open(); + await iconStream.CopyToAsync(entryStream); + } + } + + archiveStream.Seek(0, SeekOrigin.Begin); + return archiveStream; } /// - public ProfileConfiguration ImportProfile(ProfileCategory category, ProfileConfigurationExportModel exportModel, - bool makeUnique, bool markAsFreshImport, string? nameAffix) + public async Task ImportProfile(Stream archiveStream, ProfileCategory category, bool makeUnique, bool markAsFreshImport, string? nameAffix) { - if (exportModel.ProfileEntity == null) - throw new ArtemisCoreException("Cannot import a profile without any data"); + using ZipArchive archive = new(archiveStream, ZipArchiveMode.Read, true); - // Create a copy of the entity because we'll be using it from now on - ProfileEntity profileEntity = JsonConvert.DeserializeObject( - JsonConvert.SerializeObject(exportModel.ProfileEntity, IProfileService.ExportSettings), - IProfileService.ExportSettings - )!; + // There should be a configuration.json and profile.json + ZipArchiveEntry? configurationEntry = archive.Entries.FirstOrDefault(e => e.Name.EndsWith("configuration.json")); + ZipArchiveEntry? profileEntry = archive.Entries.FirstOrDefault(e => e.Name.EndsWith("profile.json")); + ZipArchiveEntry? iconEntry = archive.Entries.FirstOrDefault(e => e.Name.EndsWith("icon.png")); + + if (configurationEntry == null) + throw new ArtemisCoreException("Could not import profile, configuration.json missing"); + if (profileEntry == null) + throw new ArtemisCoreException("Could not import profile, profile.json missing"); + + await using Stream configurationStream = configurationEntry.Open(); + using StreamReader configurationReader = new(configurationStream); + ProfileConfigurationEntity? configurationEntity = JsonConvert.DeserializeObject(await configurationReader.ReadToEndAsync(), IProfileService.ExportSettings); + if (configurationEntity == null) + throw new ArtemisCoreException("Could not import profile, failed to deserialize configuration.json"); + + await using Stream profileStream = profileEntry.Open(); + using StreamReader profileReader = new(profileStream); + ProfileEntity? profileEntity = JsonConvert.DeserializeObject(await profileReader.ReadToEndAsync(), IProfileService.ExportSettings); + if (profileEntity == null) + throw new ArtemisCoreException("Could not import profile, failed to deserialize profile.json"); // Assign a new GUID to make sure it is unique in case of a previous import of the same content if (makeUnique) profileEntity.UpdateGuid(Guid.NewGuid()); + else + { + // If the profile already exists and this one is not to be made unique, return the existing profile + ProfileConfiguration? existing = ProfileCategories.SelectMany(c => c.ProfileConfigurations).FirstOrDefault(p => p.ProfileId == profileEntity.Id); + if (existing != null) + return existing; + } if (nameAffix != null) profileEntity.Name = $"{profileEntity.Name} - {nameAffix}"; @@ -435,21 +485,18 @@ internal class ProfileService : IProfileService else throw new ArtemisCoreException($"Cannot import this profile without {nameof(makeUnique)} being true"); - ProfileConfiguration profileConfiguration; - if (exportModel.ProfileConfigurationEntity != null) + // A new GUID will be given on save + configurationEntity.FileIconId = Guid.Empty; + ProfileConfiguration profileConfiguration = new(category, configurationEntity); + if (nameAffix != null) + profileConfiguration.Name = $"{profileConfiguration.Name} - {nameAffix}"; + + // If an icon was provided, import that as well + if (iconEntry != null) { - ProfileConfigurationEntity profileConfigurationEntity = JsonConvert.DeserializeObject( - JsonConvert.SerializeObject(exportModel.ProfileConfigurationEntity, IProfileService.ExportSettings), IProfileService.ExportSettings - )!; - // A new GUID will be given on save - profileConfigurationEntity.FileIconId = Guid.Empty; - profileConfiguration = new ProfileConfiguration(category, profileConfigurationEntity); - if (nameAffix != null) - profileConfiguration.Name = $"{profileConfiguration.Name} - {nameAffix}"; - } - else - { - profileConfiguration = new ProfileConfiguration(category, profileEntity.Name, "Import"); + await using Stream iconStream = iconEntry.Open(); + profileConfiguration.Icon.SetIconByStream(iconStream); + SaveProfileConfigurationIcon(profileConfiguration); } if (exportModel.ProfileImage != null) diff --git a/src/Artemis.UI/Screens/ProfileEditor/Panels/MenuBar/MenuBarViewModel.cs b/src/Artemis.UI/Screens/ProfileEditor/Panels/MenuBar/MenuBarViewModel.cs index 60a0b548d..9209789a1 100644 --- a/src/Artemis.UI/Screens/ProfileEditor/Panels/MenuBar/MenuBarViewModel.cs +++ b/src/Artemis.UI/Screens/ProfileEditor/Panels/MenuBar/MenuBarViewModel.cs @@ -69,7 +69,7 @@ public class MenuBarViewModel : ActivatableViewModelBase ToggleSuspended = ReactiveCommand.Create(ExecuteToggleSuspended, this.WhenAnyValue(vm => vm.ProfileConfiguration).Select(c => c != null)); DeleteProfile = ReactiveCommand.CreateFromTask(ExecuteDeleteProfile, this.WhenAnyValue(vm => vm.ProfileConfiguration).Select(c => c != null)); ExportProfile = ReactiveCommand.CreateFromTask(ExecuteExportProfile, this.WhenAnyValue(vm => vm.ProfileConfiguration).Select(c => c != null)); - DuplicateProfile = ReactiveCommand.Create(ExecuteDuplicateProfile, this.WhenAnyValue(vm => vm.ProfileConfiguration).Select(c => c != null)); + DuplicateProfile = ReactiveCommand.CreateFromTask(ExecuteDuplicateProfile, this.WhenAnyValue(vm => vm.ProfileConfiguration).Select(c => c != null)); ToggleSuspendedEditing = ReactiveCommand.Create(ExecuteToggleSuspendedEditing); OpenUri = ReactiveCommand.Create(s => Process.Start(new ProcessStartInfo(s) {UseShellExecute = true, Verb = "open"})); ToggleBooleanSetting = ReactiveCommand.Create>(ExecuteToggleBooleanSetting); @@ -194,32 +194,31 @@ public class MenuBarViewModel : ActivatableViewModelBase // Might not cover everything but then the dialog will complain and that's good enough string fileName = Path.GetInvalidFileNameChars().Aggregate(ProfileConfiguration.Name, (current, c) => current.Replace(c, '-')); string? result = await _windowService.CreateSaveFileDialog() - .HavingFilter(f => f.WithExtension("json").WithName("Artemis profile")) + .HavingFilter(f => f.WithExtension("zip").WithName("Artemis profile")) .WithInitialFileName(fileName) .ShowAsync(); if (result == null) return; - ProfileConfigurationExportModel export = _profileService.ExportProfile(ProfileConfiguration); - string json = JsonConvert.SerializeObject(export, IProfileService.ExportSettings); try { - await File.WriteAllTextAsync(result, json); + await using Stream stream = await _profileService.ExportProfile(ProfileConfiguration); + await using FileStream fileStream = File.OpenWrite(result); + await stream.CopyToAsync(fileStream); } catch (Exception e) { _windowService.ShowExceptionDialog("Failed to export profile", e); } } - - private void ExecuteDuplicateProfile() + + private async Task ExecuteDuplicateProfile() { if (ProfileConfiguration == null) return; - - ProfileConfigurationExportModel export = _profileService.ExportProfile(ProfileConfiguration); - _profileService.ImportProfile(ProfileConfiguration.Category, export, true, false, "copy"); + await using Stream export = await _profileService.ExportProfile(ProfileConfiguration); + await _profileService.ImportProfile(export, ProfileConfiguration.Category, true, false, "copy"); } private void ExecuteToggleSuspendedEditing() diff --git a/src/Artemis.UI/Screens/Sidebar/SidebarCategoryViewModel.cs b/src/Artemis.UI/Screens/Sidebar/SidebarCategoryViewModel.cs index 8911989d7..13121207b 100644 --- a/src/Artemis.UI/Screens/Sidebar/SidebarCategoryViewModel.cs +++ b/src/Artemis.UI/Screens/Sidebar/SidebarCategoryViewModel.cs @@ -1,11 +1,13 @@ -using System; +using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.IO; +using System.IO.Compression; using System.Linq; using System.Reactive; using System.Reactive.Disposables; using System.Reactive.Linq; +using System.Text; using System.Threading.Tasks; using Artemis.Core; using Artemis.Core.Services; @@ -77,7 +79,7 @@ public class SidebarCategoryViewModel : ActivatableViewModelBase Observable.FromEventPattern(x => profileCategory.ProfileConfigurationRemoved += x, x => profileCategory.ProfileConfigurationRemoved -= x) .Subscribe(e => profileConfigurations.RemoveMany(profileConfigurations.Items.Where(c => c == e.EventArgs.ProfileConfiguration))) .DisposeWith(d); - + profileConfigurations.Edit(updater => { updater.Clear(); @@ -155,32 +157,32 @@ public class SidebarCategoryViewModel : ActivatableViewModelBase private async Task ExecuteImportProfile() { string[]? result = await _windowService.CreateOpenFileDialog() - .HavingFilter(f => f.WithExtension("json").WithName("Artemis profile")) + .HavingFilter(f => f.WithExtension("zip").WithExtension("json").WithName("Artemis profile")) .ShowAsync(); if (result == null) return; - string json = await File.ReadAllTextAsync(result[0]); - ProfileConfigurationExportModel? profileConfigurationExportModel = null; try { - profileConfigurationExportModel = JsonConvert.DeserializeObject(json, IProfileService.ExportSettings); - } - catch (JsonException e) - { - _windowService.ShowExceptionDialog("Import profile failed", e); - } + // Removing this at some point in the future + if (result[0].EndsWith("json")) + { + ProfileConfigurationExportModel? exportModel = JsonConvert.DeserializeObject(await File.ReadAllTextAsync(result[0]), IProfileService.ExportSettings); + if (exportModel == null) + { + await _windowService.ShowConfirmContentDialog("Import profile", "Failed to import this profile, make sure it is a valid Artemis profile.", "Confirm", null); + return; + } - if (profileConfigurationExportModel == null) - { - await _windowService.ShowConfirmContentDialog("Import profile", "Failed to import this profile, make sure it is a valid Artemis profile.", "Confirm", null); - return; - } - - try - { - _profileService.ImportProfile(ProfileCategory, profileConfigurationExportModel); + await using Stream convertedFileStream = await ConvertLegacyExport(exportModel); + await _profileService.ImportProfile(convertedFileStream, ProfileCategory, true, true); + } + else + { + await using FileStream fileStream = File.OpenRead(result[0]); + await _profileService.ImportProfile(fileStream, ProfileCategory, true, true); + } } catch (Exception e) { @@ -234,4 +236,38 @@ public class SidebarCategoryViewModel : ActivatableViewModelBase _profileService.SaveProfileCategory(categories[i]); } } + + private async Task ConvertLegacyExport(ProfileConfigurationExportModel exportModel) + { + MemoryStream archiveStream = new(); + + string configurationJson = JsonConvert.SerializeObject(exportModel.ProfileConfigurationEntity, IProfileService.ExportSettings); + string profileJson = JsonConvert.SerializeObject(exportModel.ProfileEntity, IProfileService.ExportSettings); + + // Create a ZIP archive + using (ZipArchive archive = new(archiveStream, ZipArchiveMode.Create, true)) + { + ZipArchiveEntry configurationEntry = archive.CreateEntry("configuration.json"); + await using (Stream entryStream = configurationEntry.Open()) + { + await entryStream.WriteAsync(Encoding.Default.GetBytes(configurationJson)); + } + + ZipArchiveEntry profileEntry = archive.CreateEntry("profile.json"); + await using (Stream entryStream = profileEntry.Open()) + { + await entryStream.WriteAsync(Encoding.Default.GetBytes(profileJson)); + } + + if (exportModel.ProfileImage != null) + { + ZipArchiveEntry iconEntry = archive.CreateEntry("icon.png"); + await using Stream entryStream = iconEntry.Open(); + await exportModel.ProfileImage.CopyToAsync(entryStream); + } + } + + archiveStream.Seek(0, SeekOrigin.Begin); + return archiveStream; + } } \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Sidebar/SidebarProfileConfigurationViewModel.cs b/src/Artemis.UI/Screens/Sidebar/SidebarProfileConfigurationViewModel.cs index 8d12623b5..bfc179095 100644 --- a/src/Artemis.UI/Screens/Sidebar/SidebarProfileConfigurationViewModel.cs +++ b/src/Artemis.UI/Screens/Sidebar/SidebarProfileConfigurationViewModel.cs @@ -10,8 +10,6 @@ using Artemis.Core.Services; using Artemis.UI.Shared; using Artemis.UI.Shared.Routing; using Artemis.UI.Shared.Services; -using Artemis.UI.Shared.Services.ProfileEditor; -using Newtonsoft.Json; using ReactiveUI; namespace Artemis.UI.Screens.Sidebar; @@ -36,7 +34,7 @@ public class SidebarProfileConfigurationViewModel : ActivatableViewModelBase SuspendAll = ReactiveCommand.Create(ExecuteSuspendAll); DeleteProfile = ReactiveCommand.CreateFromTask(ExecuteDeleteProfile); ExportProfile = ReactiveCommand.CreateFromTask(ExecuteExportProfile); - DuplicateProfile = ReactiveCommand.Create(ExecuteDuplicateProfile); + DuplicateProfile = ReactiveCommand.CreateFromTask(ExecuteDuplicateProfile); this.WhenActivated(d => _isDisabled = ProfileConfiguration.WhenAnyValue(c => c.Profile) .Select(p => p == null) @@ -116,18 +114,18 @@ public class SidebarProfileConfigurationViewModel : ActivatableViewModelBase // Might not cover everything but then the dialog will complain and that's good enough string fileName = Path.GetInvalidFileNameChars().Aggregate(ProfileConfiguration.Name, (current, c) => current.Replace(c, '-')); string? result = await _windowService.CreateSaveFileDialog() - .HavingFilter(f => f.WithExtension("json").WithName("Artemis profile")) + .HavingFilter(f => f.WithExtension("zip").WithName("Artemis profile")) .WithInitialFileName(fileName) .ShowAsync(); if (result == null) return; - ProfileConfigurationExportModel export = _profileService.ExportProfile(ProfileConfiguration); - string json = JsonConvert.SerializeObject(export, IProfileService.ExportSettings); try { - await File.WriteAllTextAsync(result, json); + await using Stream stream = await _profileService.ExportProfile(ProfileConfiguration); + await using FileStream fileStream = File.OpenWrite(result); + await stream.CopyToAsync(fileStream); } catch (Exception e) { @@ -135,10 +133,10 @@ public class SidebarProfileConfigurationViewModel : ActivatableViewModelBase } } - private void ExecuteDuplicateProfile() + private async Task ExecuteDuplicateProfile() { - ProfileConfigurationExportModel export = _profileService.ExportProfile(ProfileConfiguration); - _profileService.ImportProfile(ProfileConfiguration.Category, export, true, false, "copy"); + await using Stream export = await _profileService.ExportProfile(ProfileConfiguration); + await _profileService.ImportProfile(export, ProfileConfiguration.Category, true, false, "copy"); } public bool Matches(string s)