diff --git a/src/Artemis.Core/Extensions/SKPaintExtensions.cs b/src/Artemis.Core/Extensions/SKPaintExtensions.cs index eea179e7a..87844de0b 100644 --- a/src/Artemis.Core/Extensions/SKPaintExtensions.cs +++ b/src/Artemis.Core/Extensions/SKPaintExtensions.cs @@ -2,9 +2,16 @@ namespace Artemis.Core; -internal static class SKPaintExtensions +/// +/// A static class providing extensions +/// +public static class SKPaintExtensions { - internal static void DisposeSelfAndProperties(this SKPaint paint) + /// + /// Disposes the paint and its disposable properties such as shaders and filters. + /// + /// The pain to dispose. + public static void DisposeSelfAndProperties(this SKPaint paint) { paint.ImageFilter?.Dispose(); paint.ColorFilter?.Dispose(); diff --git a/src/Artemis.Core/Models/ProfileConfiguration/ProfileConfigurationIcon.cs b/src/Artemis.Core/Models/ProfileConfiguration/ProfileConfigurationIcon.cs index 058e95687..ed9646e7a 100644 --- a/src/Artemis.Core/Models/ProfileConfiguration/ProfileConfigurationIcon.cs +++ b/src/Artemis.Core/Models/ProfileConfiguration/ProfileConfigurationIcon.cs @@ -40,15 +40,6 @@ public class ProfileConfigurationIcon : CorePropertyChanged, IStorageModel private set => SetAndNotify(ref _iconName, value); } - /// - /// Gets the original file name of the icon (if applicable) - /// - public string? OriginalFileName - { - get => _originalFileName; - private set => SetAndNotify(ref _originalFileName, value); - } - /// /// Gets or sets a boolean indicating whether or not this icon should be filled. /// @@ -69,7 +60,6 @@ public class ProfileConfigurationIcon : CorePropertyChanged, IStorageModel _iconStream?.Dispose(); IconName = iconName; - OriginalFileName = null; IconType = ProfileConfigurationIconType.MaterialIcon; OnIconUpdated(); @@ -78,21 +68,19 @@ public class ProfileConfigurationIcon : CorePropertyChanged, IStorageModel /// /// Updates the stream returned by to the provided stream /// - /// The original file name backing the stream, should include the extension /// The stream to copy - public void SetIconByStream(string originalFileName, Stream stream) + public void SetIconByStream(Stream stream) { - if (originalFileName == null) throw new ArgumentNullException(nameof(originalFileName)); if (stream == null) throw new ArgumentNullException(nameof(stream)); _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); IconName = null; - OriginalFileName = originalFileName; IconType = ProfileConfigurationIconType.BitmapImage; OnIconUpdated(); } 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 98e0bc322..099138b92 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; @@ -110,17 +112,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 @@ -128,8 +130,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 ba1f496e7..b01e6bc56 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; @@ -311,7 +315,7 @@ internal class ProfileService : IProfileService using Stream? stream = _profileCategoryRepository.GetProfileIconStream(profileConfiguration.Entity.FileIconId); if (stream != null) - profileConfiguration.Icon.SetIconByStream(profileConfiguration.Entity.IconOriginalFileName, stream); + profileConfiguration.Icon.SetIconByStream(stream); } public void SaveProfileConfigurationIcon(ProfileConfiguration profileConfiguration) @@ -320,9 +324,8 @@ internal class ProfileService : IProfileService return; using Stream? stream = profileConfiguration.Icon.GetIconStream(); - if (stream != null && profileConfiguration.Icon.OriginalFileName != null) + if (stream != null) { - profileConfiguration.Entity.IconOriginalFileName = profileConfiguration.Icon.OriginalFileName; _profileCategoryRepository.SaveProfileIconStream(profileConfiguration.Entity, stream); } } @@ -378,7 +381,7 @@ internal class ProfileService : IProfileService OnProfileDeactivated(new ProfileConfigurationEventArgs(profileConfiguration)); } - public void RequestDeactivation(ProfileConfiguration profileConfiguration) + private void RequestDeactivation(ProfileConfiguration profileConfiguration) { if (profileConfiguration.IsBeingEdited) throw new ArtemisCoreException("Cannot disable a profile that is being edited, that's rude"); @@ -480,34 +483,83 @@ internal class ProfileService : IProfileService _profileRepository.Save(profile.ProfileEntity); } - 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}"; @@ -519,25 +571,19 @@ internal class ProfileService : IProfileService else throw new ArtemisCoreException($"Cannot import this profile without {nameof(makeUnique)} being true"); - ProfileConfiguration profileConfiguration; - if (exportModel.ProfileConfigurationEntity != 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"); - } + // 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 (exportModel.ProfileImage != null && exportModel.ProfileConfigurationEntity?.IconOriginalFileName != null) - profileConfiguration.Icon.SetIconByStream(exportModel.ProfileConfigurationEntity.IconOriginalFileName, exportModel.ProfileImage); + // If an icon was provided, import that as well + if (iconEntry != null) + { + await using Stream iconStream = iconEntry.Open(); + profileConfiguration.Icon.SetIconByStream(iconStream); + SaveProfileConfigurationIcon(profileConfiguration); + } profileConfiguration.Entity.ProfileId = profileEntity.Id; category.AddProfileConfiguration(profileConfiguration, 0); diff --git a/src/Artemis.Storage/Entities/Profile/ProfileConfigurationEntity.cs b/src/Artemis.Storage/Entities/Profile/ProfileConfigurationEntity.cs index e91ddcc96..276b6ec8d 100644 --- a/src/Artemis.Storage/Entities/Profile/ProfileConfigurationEntity.cs +++ b/src/Artemis.Storage/Entities/Profile/ProfileConfigurationEntity.cs @@ -7,7 +7,6 @@ public class ProfileConfigurationEntity { public string Name { get; set; } public string MaterialIcon { get; set; } - public string IconOriginalFileName { get; set; } public Guid FileIconId { get; set; } public int IconType { get; set; } public bool IconFill { get; set; } diff --git a/src/Artemis.Storage/Repositories/ProfileCategoryRepository.cs b/src/Artemis.Storage/Repositories/ProfileCategoryRepository.cs index 573b50a19..71517929f 100644 --- a/src/Artemis.Storage/Repositories/ProfileCategoryRepository.cs +++ b/src/Artemis.Storage/Repositories/ProfileCategoryRepository.cs @@ -70,6 +70,6 @@ internal class ProfileCategoryRepository : IProfileCategoryRepository if (stream == null && _profileIcons.Exists(profileConfigurationEntity.FileIconId)) _profileIcons.Delete(profileConfigurationEntity.FileIconId); - _profileIcons.Upload(profileConfigurationEntity.FileIconId, profileConfigurationEntity.IconOriginalFileName, stream); + _profileIcons.Upload(profileConfigurationEntity.FileIconId, profileConfigurationEntity.FileIconId + ".png", stream); } } \ No newline at end of file diff --git a/src/Artemis.UI.Linux/App.axaml b/src/Artemis.UI.Linux/App.axaml index 56b423b9a..a63d617b9 100644 --- a/src/Artemis.UI.Linux/App.axaml +++ b/src/Artemis.UI.Linux/App.axaml @@ -14,7 +14,7 @@ - + diff --git a/src/Artemis.UI.MacOS/App.axaml b/src/Artemis.UI.MacOS/App.axaml index f17dcf30d..6ef3efd97 100644 --- a/src/Artemis.UI.MacOS/App.axaml +++ b/src/Artemis.UI.MacOS/App.axaml @@ -14,7 +14,7 @@ - + diff --git a/src/Artemis.UI.Shared/Routing/Router/IRouter.cs b/src/Artemis.UI.Shared/Routing/Router/IRouter.cs index 1778d9f20..44d77bb5c 100644 --- a/src/Artemis.UI.Shared/Routing/Router/IRouter.cs +++ b/src/Artemis.UI.Shared/Routing/Router/IRouter.cs @@ -59,4 +59,9 @@ public interface IRouter /// The type of the root screen. It must be a class. /// The type of the parameters for the root screen. It must have a parameterless constructor. void SetRoot(RoutableScreen root) where TScreen : class where TParam : new(); + + /// + /// Clears the route used by the previous window, so that it is not restored when the main window opens. + /// + void ClearPreviousWindowRoute(); } \ No newline at end of file diff --git a/src/Artemis.UI.Shared/Routing/Router/Navigation.cs b/src/Artemis.UI.Shared/Routing/Router/Navigation.cs index 567a7b719..d04fded6f 100644 --- a/src/Artemis.UI.Shared/Routing/Router/Navigation.cs +++ b/src/Artemis.UI.Shared/Routing/Router/Navigation.cs @@ -35,11 +35,12 @@ internal class Navigation public async Task Navigate(NavigationArguments args) { - _logger.Information("Navigating to {Path}", _resolution.Path); + if (_options.EnableLogging) + _logger.Information("Navigating to {Path}", _resolution.Path); _cts = new CancellationTokenSource(); await NavigateResolution(_resolution, args, _root); - if (!Cancelled) + if (!Cancelled && _options.EnableLogging) _logger.Information("Navigated to {Path}", _resolution.Path); } @@ -48,7 +49,8 @@ internal class Navigation if (Cancelled || Completed) return; - _logger.Information("Cancelled navigation to {Path}", _resolution.Path); + if (_options.EnableLogging) + _logger.Information("Cancelled navigation to {Path}", _resolution.Path); _cts.Cancel(); } diff --git a/src/Artemis.UI.Shared/Routing/Router/Router.cs b/src/Artemis.UI.Shared/Routing/Router/Router.cs index b3b8a88c1..028d7be02 100644 --- a/src/Artemis.UI.Shared/Routing/Router/Router.cs +++ b/src/Artemis.UI.Shared/Routing/Router/Router.cs @@ -3,27 +3,34 @@ using System.Collections.Generic; using System.Reactive.Subjects; using System.Threading.Tasks; using Artemis.Core; +using Artemis.UI.Shared.Services.MainWindow; using Avalonia.Threading; using Serilog; namespace Artemis.UI.Shared.Routing; -internal class Router : CorePropertyChanged, IRouter +internal class Router : CorePropertyChanged, IRouter, IDisposable { private readonly Stack _backStack = new(); private readonly BehaviorSubject _currentRouteSubject; private readonly Stack _forwardStack = new(); private readonly Func _getNavigation; private readonly ILogger _logger; + private readonly IMainWindowService _mainWindowService; private Navigation? _currentNavigation; private IRoutableScreen? _root; + private string? _previousWindowRoute; - public Router(ILogger logger, Func getNavigation) + public Router(ILogger logger, IMainWindowService mainWindowService, Func getNavigation) { _logger = logger; + _mainWindowService = mainWindowService; _getNavigation = getNavigation; _currentRouteSubject = new BehaviorSubject(null); + + mainWindowService.MainWindowOpened += MainWindowServiceOnMainWindowOpened; + mainWindowService.MainWindowClosed += MainWindowServiceOnMainWindowClosed; } private RouteResolution Resolve(string path) @@ -128,7 +135,7 @@ internal class Router : CorePropertyChanged, IRouter await Navigate(path, new RouterNavigationOptions {AddToHistory = false}); if (previousPath != null) _forwardStack.Push(previousPath); - + return true; } @@ -142,7 +149,7 @@ internal class Router : CorePropertyChanged, IRouter await Navigate(path, new RouterNavigationOptions {AddToHistory = false}); if (previousPath != null) _backStack.Push(previousPath); - + return true; } @@ -164,4 +171,29 @@ internal class Router : CorePropertyChanged, IRouter { _root = root; } + + /// + public void ClearPreviousWindowRoute() + { + _previousWindowRoute = null; + } + + public void Dispose() + { + _currentRouteSubject.Dispose(); + _mainWindowService.MainWindowOpened -= MainWindowServiceOnMainWindowOpened; + _mainWindowService.MainWindowClosed -= MainWindowServiceOnMainWindowClosed; + } + + private void MainWindowServiceOnMainWindowOpened(object? sender, EventArgs e) + { + if (_previousWindowRoute != null && _currentRouteSubject.Value == "blank") + Dispatcher.UIThread.InvokeAsync(async () => await Navigate(_previousWindowRoute, new RouterNavigationOptions {AddToHistory = false, EnableLogging = false})); + } + + private void MainWindowServiceOnMainWindowClosed(object? sender, EventArgs e) + { + _previousWindowRoute = _currentRouteSubject.Value; + Dispatcher.UIThread.InvokeAsync(async () => await Navigate("blank", new RouterNavigationOptions {AddToHistory = false, EnableLogging = false})); + } } \ No newline at end of file diff --git a/src/Artemis.UI.Shared/Routing/Router/RouterNavigationOptions.cs b/src/Artemis.UI.Shared/Routing/Router/RouterNavigationOptions.cs index de4f25b10..a5c63ab88 100644 --- a/src/Artemis.UI.Shared/Routing/Router/RouterNavigationOptions.cs +++ b/src/Artemis.UI.Shared/Routing/Router/RouterNavigationOptions.cs @@ -20,4 +20,10 @@ public class RouterNavigationOptions /// /// If set to true, a route change from page/subpage1/subpage2 to page/subpage1 will be ignored. public bool IgnoreOnPartialMatch { get; set; } = false; + + /// + /// Gets or sets a boolean value indicating whether logging should be enabled. + /// Errors and warnings are always logged. + /// + public bool EnableLogging { get; set; } = true; } \ No newline at end of file diff --git a/src/Artemis.UI.Windows/App.axaml b/src/Artemis.UI.Windows/App.axaml index da6064c9c..836d6a11f 100644 --- a/src/Artemis.UI.Windows/App.axaml +++ b/src/Artemis.UI.Windows/App.axaml @@ -14,13 +14,13 @@ - + - + diff --git a/src/Artemis.UI/ArtemisBootstrapper.cs b/src/Artemis.UI/ArtemisBootstrapper.cs index dbed7a0f4..fda2661ae 100644 --- a/src/Artemis.UI/ArtemisBootstrapper.cs +++ b/src/Artemis.UI/ArtemisBootstrapper.cs @@ -16,6 +16,7 @@ using Artemis.WebClient.Workshop.DryIoc; using Avalonia; using Avalonia.Controls; using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Logging; using Avalonia.Styling; using DryIoc; using ReactiveUI; @@ -32,9 +33,9 @@ public static class ArtemisBootstrapper { if (_application != null || _container != null) throw new ArtemisUIException("UI already bootstrapped"); - + Utilities.PrepareFirstLaunch(); - + application.RequestedThemeVariant = ThemeVariant.Dark; _application = application; _container = new Container(rules => rules @@ -51,6 +52,8 @@ public static class ArtemisBootstrapper configureServices?.Invoke(_container); _container.UseDryIocDependencyResolver(); + + Logger.Sink = _container.Resolve(); return _container; } diff --git a/src/Artemis.UI/Routing/Routes.cs b/src/Artemis.UI/Routing/Routes.cs index d2709888a..728daa1e5 100644 --- a/src/Artemis.UI/Routing/Routes.cs +++ b/src/Artemis.UI/Routing/Routes.cs @@ -1,6 +1,7 @@ using System.Collections.Generic; using Artemis.UI.Screens.Home; using Artemis.UI.Screens.ProfileEditor; +using Artemis.UI.Screens.Root; using Artemis.UI.Screens.Settings; using Artemis.UI.Screens.Settings.Updating; using Artemis.UI.Screens.SurfaceEditor; @@ -15,6 +16,7 @@ public static class Routes { public static List ArtemisRoutes = new() { + new RouteRegistration("blank"), new RouteRegistration("home"), #if DEBUG new RouteRegistration("workshop") diff --git a/src/Artemis.UI/Screens/ProfileEditor/Panels/MenuBar/MenuBarViewModel.cs b/src/Artemis.UI/Screens/ProfileEditor/Panels/MenuBar/MenuBarViewModel.cs index 7578952da..98b248c5d 100644 --- a/src/Artemis.UI/Screens/ProfileEditor/Panels/MenuBar/MenuBarViewModel.cs +++ b/src/Artemis.UI/Screens/ProfileEditor/Panels/MenuBar/MenuBarViewModel.cs @@ -70,7 +70,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); @@ -195,32 +195,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/Root/BlankView.axaml b/src/Artemis.UI/Screens/Root/BlankView.axaml new file mode 100644 index 000000000..37f0a544c --- /dev/null +++ b/src/Artemis.UI/Screens/Root/BlankView.axaml @@ -0,0 +1,7 @@ + + diff --git a/src/Artemis.UI/Screens/Root/BlankView.axaml.cs b/src/Artemis.UI/Screens/Root/BlankView.axaml.cs new file mode 100644 index 000000000..c4c8b84be --- /dev/null +++ b/src/Artemis.UI/Screens/Root/BlankView.axaml.cs @@ -0,0 +1,13 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Markup.Xaml; + +namespace Artemis.UI.Screens.Root; + +public partial class BlankView : UserControl +{ + public BlankView() + { + InitializeComponent(); + } +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Root/BlankViewModel.cs b/src/Artemis.UI/Screens/Root/BlankViewModel.cs new file mode 100644 index 000000000..fe445a6ee --- /dev/null +++ b/src/Artemis.UI/Screens/Root/BlankViewModel.cs @@ -0,0 +1,9 @@ +using Artemis.UI.Shared; + +namespace Artemis.UI.Screens.Root; + +public class BlankViewModel : ViewModelBase, IMainScreenViewModel +{ + /// + public ViewModelBase? TitleBarViewModel => null; +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Root/RootViewModel.cs b/src/Artemis.UI/Screens/Root/RootViewModel.cs index 828b080ae..9b2a46ddf 100644 --- a/src/Artemis.UI/Screens/Root/RootViewModel.cs +++ b/src/Artemis.UI/Screens/Root/RootViewModel.cs @@ -58,7 +58,7 @@ public class RootViewModel : RoutableScreen, IMainWindowPr mainWindowService.ConfigureMainWindowProvider(this); DisplayAccordingToSettings(); - OpenScreen = ReactiveCommand.Create(ExecuteOpenScreen); + OpenScreen = ReactiveCommand.Create(ExecuteOpenScreen); OpenDebugger = ReactiveCommand.CreateFromTask(ExecuteOpenDebugger); Exit = ReactiveCommand.CreateFromTask(ExecuteExit); this.WhenAnyValue(vm => vm.Screen).Subscribe(UpdateTitleBarViewModel); @@ -72,11 +72,13 @@ public class RootViewModel : RoutableScreen, IMainWindowPr registrationService.RegisterBuiltInDataModelDisplays(); registrationService.RegisterBuiltInDataModelInputs(); registrationService.RegisterBuiltInPropertyEditors(); + + _router.Navigate("home"); }); } public SidebarViewModel SidebarViewModel { get; } - public ReactiveCommand OpenScreen { get; } + public ReactiveCommand OpenScreen { get; } public ReactiveCommand OpenDebugger { get; } public ReactiveCommand Exit { get; } @@ -133,8 +135,11 @@ public class RootViewModel : RoutableScreen, IMainWindowPr #region Tray commands - private void ExecuteOpenScreen(string path) + private void ExecuteOpenScreen(string? path) { + if (path != null) + _router.ClearPreviousWindowRoute(); + // The window will open on the UI thread at some point, respond to that to select the chosen screen MainWindowOpened += OnEventHandler; OpenMainWindow(); @@ -143,7 +148,8 @@ public class RootViewModel : RoutableScreen, IMainWindowPr { MainWindowOpened -= OnEventHandler; // Avoid threading issues by running this on the UI thread - Dispatcher.UIThread.InvokeAsync(async () => await _router.Navigate(path)); + if (path != null) + Dispatcher.UIThread.InvokeAsync(async () => await _router.Navigate(path)); } } diff --git a/src/Artemis.UI/Screens/Sidebar/Dialogs/ProfileConfigurationEditViewModel.cs b/src/Artemis.UI/Screens/Sidebar/Dialogs/ProfileConfigurationEditViewModel.cs index c0d130ce6..0e5e5e8d4 100644 --- a/src/Artemis.UI/Screens/Sidebar/Dialogs/ProfileConfigurationEditViewModel.cs +++ b/src/Artemis.UI/Screens/Sidebar/Dialogs/ProfileConfigurationEditViewModel.cs @@ -231,7 +231,7 @@ public class ProfileConfigurationEditViewModel : DialogViewModelBase(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(); + updater.AddRange(profileCategory.ProfileConfigurations); + }); + _isCollapsed = ProfileCategory.WhenAnyValue(vm => vm.IsCollapsed).ToProperty(this, vm => vm.IsCollapsed).DisposeWith(d); _isSuspended = ProfileCategory.WhenAnyValue(vm => vm.IsSuspended).ToProperty(this, vm => vm.IsSuspended).DisposeWith(d); }); @@ -155,32 +163,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 +242,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 4fa5728a5..2d7a7712e 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) diff --git a/src/Artemis.UI/Screens/Sidebar/SidebarView.axaml b/src/Artemis.UI/Screens/Sidebar/SidebarView.axaml index 900e4d666..c02ad3d60 100644 --- a/src/Artemis.UI/Screens/Sidebar/SidebarView.axaml +++ b/src/Artemis.UI/Screens/Sidebar/SidebarView.axaml @@ -24,7 +24,8 @@ Margin="10 2" ItemsSource="{CompiledBinding SidebarScreen.Screens}" SelectedItem="{CompiledBinding SelectedScreen}" - ItemContainerTheme="{StaticResource MenuTreeViewItem}"> + ItemContainerTheme="{StaticResource MenuTreeViewItem}" + PointerReleased="InputElement_OnPointerReleased">