using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.Diagnostics; using System.IO; using System.IO.Compression; using System.Linq; using System.Text; using System.Text.Json; using System.Text.Json.Nodes; using System.Threading.Tasks; using Artemis.Core.Modules; using Artemis.Storage.Entities.Profile; using Artemis.Storage.Migrations; using Artemis.Storage.Repositories.Interfaces; using Serilog; using SkiaSharp; namespace Artemis.Core.Services; internal class ProfileService : IProfileService { private readonly ILogger _logger; private readonly IProfileCategoryRepository _profileCategoryRepository; private readonly IProfileRepository _profileRepository; private readonly IPluginManagementService _pluginManagementService; private readonly IDeviceService _deviceService; private readonly List _pendingKeyboardEvents = new(); private readonly List _profileMigrators; private readonly List _renderExceptions = new(); private readonly List _updateExceptions = new(); private DateTime _lastRenderExceptionLog; private DateTime _lastUpdateExceptionLog; public ProfileService(ILogger logger, IProfileCategoryRepository profileCategoryRepository, IProfileRepository profileRepository, IPluginManagementService pluginManagementService, IInputService inputService, IDeviceService deviceService, List profileMigrators) { _logger = logger; _profileCategoryRepository = profileCategoryRepository; _profileRepository = profileRepository; _pluginManagementService = pluginManagementService; _deviceService = deviceService; _profileMigrators = profileMigrators; ProfileCategories = new ReadOnlyCollection(_profileCategoryRepository.GetAll().Select(c => new ProfileCategory(c)).OrderBy(c => c.Order).ToList()); _deviceService.LedsChanged += DeviceServiceOnLedsChanged; _pluginManagementService.PluginFeatureEnabled += PluginManagementServiceOnPluginFeatureToggled; _pluginManagementService.PluginFeatureDisabled += PluginManagementServiceOnPluginFeatureToggled; inputService.KeyboardKeyUp += InputServiceOnKeyboardKeyUp; if (!ProfileCategories.Any()) CreateDefaultProfileCategories(); UpdateModules(); } public ProfileConfiguration? FocusProfile { get; set; } public ProfileElement? FocusProfileElement { get; set; } public bool UpdateFocusProfile { get; set; } public bool ProfileRenderingDisabled { get; set; } /// public void UpdateProfiles(double deltaTime) { // If there is a focus profile update only that, and only if UpdateFocusProfile is true if (FocusProfile != null) { if (UpdateFocusProfile) FocusProfile.Profile?.Update(deltaTime); return; } // Iterate the children in reverse because the first category must be rendered last to end up on top for (int i = ProfileCategories.Count - 1; i > -1; i--) { ProfileCategory profileCategory = ProfileCategories[i]; for (int j = profileCategory.ProfileConfigurations.Count - 1; j > -1; j--) { ProfileConfiguration profileConfiguration = profileCategory.ProfileConfigurations[j]; // Process hotkeys that where pressed since this profile last updated ProcessPendingKeyEvents(profileConfiguration); bool shouldBeActive = profileConfiguration.ShouldBeActive(false); if (shouldBeActive) { profileConfiguration.Update(); shouldBeActive = profileConfiguration.ActivationConditionMet; } try { // Make sure the profile is active or inactive according to the parameters above if (shouldBeActive && profileConfiguration.Profile == null && profileConfiguration.BrokenState != "Failed to activate profile") profileConfiguration.TryOrBreak(() => ActivateProfile(profileConfiguration), "Failed to activate profile"); if (shouldBeActive && profileConfiguration.Profile != null && !profileConfiguration.Profile.ShouldDisplay) profileConfiguration.Profile.ShouldDisplay = true; else if (!shouldBeActive && profileConfiguration.Profile != null) { if (!profileConfiguration.FadeInAndOut) DeactivateProfile(profileConfiguration); else if (!profileConfiguration.Profile.ShouldDisplay && profileConfiguration.Profile.Opacity <= 0) DeactivateProfile(profileConfiguration); else if (profileConfiguration.Profile.Opacity > 0) RequestDeactivation(profileConfiguration); } profileConfiguration.Profile?.Update(deltaTime); } catch (Exception e) { _updateExceptions.Add(e); } } } LogProfileUpdateExceptions(); _pendingKeyboardEvents.Clear(); } /// public void RenderProfiles(SKCanvas canvas) { // If there is a focus profile, render only that if (FocusProfile != null) { FocusProfile.Profile?.Render(canvas, SKPointI.Empty, FocusProfileElement); return; } // Iterate the children in reverse because the first category must be rendered last to end up on top for (int i = ProfileCategories.Count - 1; i > -1; i--) { ProfileCategory profileCategory = ProfileCategories[i]; for (int j = profileCategory.ProfileConfigurations.Count - 1; j > -1; j--) { try { ProfileConfiguration profileConfiguration = profileCategory.ProfileConfigurations[j]; // Ensure all criteria are met before rendering bool fadingOut = profileConfiguration.Profile?.ShouldDisplay == false && profileConfiguration.Profile?.Opacity > 0; if (!profileConfiguration.IsSuspended && !profileConfiguration.IsMissingModule && (profileConfiguration.ActivationConditionMet || fadingOut)) profileConfiguration.Profile?.Render(canvas, SKPointI.Empty, null); } catch (Exception e) { _renderExceptions.Add(e); } } } LogProfileRenderExceptions(); } /// public ReadOnlyCollection ProfileCategories { get; private set; } /// public ProfileConfiguration CloneProfileConfiguration(ProfileConfiguration profileConfiguration) { return new ProfileConfiguration(profileConfiguration.Category, profileConfiguration.Entity); } /// public Profile ActivateProfile(ProfileConfiguration profileConfiguration) { if (profileConfiguration.Profile != null) { profileConfiguration.Profile.ShouldDisplay = true; return profileConfiguration.Profile; } Profile profile = new(profileConfiguration, profileConfiguration.Entity.Profile); profile.PopulateLeds(_deviceService.EnabledDevices); if (profile.IsFreshImport) { _logger.Debug("Profile is a fresh import, adapting to surface - {profile}", profile); AdaptProfile(profile); } profileConfiguration.Profile = profile; OnProfileActivated(new ProfileConfigurationEventArgs(profileConfiguration)); return profile; } /// public void DeactivateProfile(ProfileConfiguration profileConfiguration) { if (FocusProfile == profileConfiguration) throw new ArtemisCoreException("Cannot disable a profile that is being edited, that's rude"); if (profileConfiguration.Profile == null) return; Profile profile = profileConfiguration.Profile; profileConfiguration.Profile = null; profile.Dispose(); OnProfileDeactivated(new ProfileConfigurationEventArgs(profileConfiguration)); } private void RequestDeactivation(ProfileConfiguration profileConfiguration) { if (FocusProfile == profileConfiguration) throw new ArtemisCoreException("Cannot disable a profile that is being edited, that's rude"); if (profileConfiguration.Profile == null) return; profileConfiguration.Profile.ShouldDisplay = false; } /// public ProfileCategory CreateProfileCategory(string name, bool addToTop = false) { ProfileCategory profileCategory; if (addToTop) { profileCategory = new ProfileCategory(name, 1); foreach (ProfileCategory category in ProfileCategories) { category.Order++; category.Save(); } _profileCategoryRepository.SaveRange(ProfileCategories.Select(c => c.Entity).ToList()); } else { profileCategory = new ProfileCategory(name, ProfileCategories.Count + 1); } _profileCategoryRepository.Add(profileCategory.Entity); ProfileCategories = new ReadOnlyCollection([..ProfileCategories, profileCategory]); OnProfileCategoryAdded(new ProfileCategoryEventArgs(profileCategory)); return profileCategory; } /// public void DeleteProfileCategory(ProfileCategory profileCategory) { foreach (ProfileConfiguration profileConfiguration in profileCategory.ProfileConfigurations.ToList()) RemoveProfileConfiguration(profileConfiguration); ProfileCategories = new ReadOnlyCollection(ProfileCategories.Where(c => c != profileCategory).ToList()); _profileCategoryRepository.Remove(profileCategory.Entity); OnProfileCategoryRemoved(new ProfileCategoryEventArgs(profileCategory)); } /// public ProfileConfiguration CreateProfileConfiguration(ProfileCategory category, string name, string icon) { ProfileConfiguration configuration = new(category, name, icon); category.AddProfileConfiguration(configuration, category.ProfileConfigurations.FirstOrDefault()); SaveProfileCategory(category); return configuration; } /// public void RemoveProfileConfiguration(ProfileConfiguration profileConfiguration) { DeactivateProfile(profileConfiguration); ProfileCategory category = profileConfiguration.Category; category.RemoveProfileConfiguration(profileConfiguration); category.Save(); _profileRepository.Remove(profileConfiguration.Entity); _profileCategoryRepository.Save(category.Entity); } /// public void SaveProfileCategory(ProfileCategory profileCategory) { profileCategory.Save(); _profileCategoryRepository.Save(profileCategory.Entity); ProfileCategories = new ReadOnlyCollection(ProfileCategories.OrderBy(c => c.Order).ToList()); } /// public void SaveProfile(Profile profile, bool includeChildren) { _logger.Debug("Updating profile - Saving {Profile}", profile); profile.Save(); if (includeChildren) { foreach (RenderProfileElement child in profile.GetAllRenderElements()) child.Save(); } // At this point the user made actual changes, save that profile.IsFreshImport = false; profile.ProfileEntity.IsFreshImport = false; _profileRepository.Save(profile.Configuration.Entity); // If the provided profile is external (cloned or from the workshop?) but it is loaded locally too, reload the local instance // A bit dodge but it ensures local instances always represent the latest stored version ProfileConfiguration? localInstance = ProfileCategories .SelectMany(c => c.ProfileConfigurations) .FirstOrDefault(p => p.Profile != null && p.Profile != profile && p.ProfileId == profile.ProfileEntity.Id); if (localInstance == null) return; DeactivateProfile(localInstance); ActivateProfile(localInstance); } /// public async Task ExportProfile(ProfileConfiguration profileConfiguration) { string configurationJson = CoreJson.Serialize(profileConfiguration.Entity.ProfileConfiguration); string profileJson = CoreJson.Serialize(profileConfiguration.Entity.Profile); MemoryStream archiveStream = new(); // 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 (profileConfiguration.Icon.IconBytes != null) { ZipArchiveEntry iconEntry = archive.CreateEntry("icon.png"); await using Stream entryStream = iconEntry.Open(); await entryStream.WriteAsync(profileConfiguration.Icon.IconBytes); } } archiveStream.Seek(0, SeekOrigin.Begin); return archiveStream; } /// public async Task ImportProfile(Stream archiveStream, ProfileCategory category, bool makeUnique, bool markAsFreshImport, string? nameAffix, ProfileConfiguration? target) { using ZipArchive archive = new(archiveStream, ZipArchiveMode.Read, true); // 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"); // Deserialize profile configuration to JObject await using Stream configurationStream = configurationEntry.Open(); using StreamReader configurationReader = new(configurationStream); JsonObject? configurationJson = CoreJson.Deserialize(await configurationReader.ReadToEndAsync()); // Deserialize profile to JObject await using Stream profileStream = profileEntry.Open(); using StreamReader profileReader = new(profileStream); JsonObject? profileJson = CoreJson.Deserialize(await profileReader.ReadToEndAsync()); // Before deserializing, apply any pending migrations _profileRepository.MigrateProfile(configurationJson, profileJson); // Deserialize profile configuration to ProfileConfigurationEntity ProfileConfigurationEntity? configurationEntity = configurationJson?.Deserialize(Constants.JsonConvertSettings); if (configurationEntity == null) throw new ArtemisCoreException("Could not import profile, failed to deserialize configuration.json"); // Deserialize profile to ProfileEntity ProfileEntity? profileEntity = profileJson?.Deserialize(Constants.JsonConvertSettings); 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}"; if (markAsFreshImport) profileEntity.IsFreshImport = true; if (makeUnique && ProfileCategories.SelectMany(c => c.ProfileConfigurations).Any(c => c.ProfileId == profileEntity.Id)) throw new ArtemisCoreException($"Cannot import this profile without {nameof(makeUnique)} being true"); ProfileContainerEntity containerEntity = new() {ProfileConfiguration = configurationEntity, Profile = profileEntity}; // If an icon was provided, import that as well if (iconEntry != null) { await using Stream iconStream = iconEntry.Open(); using MemoryStream ms = new(); await iconStream.CopyToAsync(ms); containerEntity.Icon = ms.ToArray(); } // A new GUID will be given on save ProfileConfiguration profileConfiguration = new(category, containerEntity); if (nameAffix != null) profileConfiguration.Name = $"{profileConfiguration.Name} - {nameAffix}"; profileConfiguration.Entity.ProfileConfiguration.ProfileId = profileEntity.Id; category.AddProfileConfiguration(profileConfiguration, target); List modules = _pluginManagementService.GetFeaturesOfType(); profileConfiguration.LoadModules(modules); SaveProfileCategory(category); return profileConfiguration; } /// public async Task OverwriteProfile(MemoryStream archiveStream, ProfileConfiguration profileConfiguration) { ProfileConfiguration imported = await ImportProfile(archiveStream, profileConfiguration.Category, true, true, null, profileConfiguration); RemoveProfileConfiguration(profileConfiguration); SaveProfileCategory(imported.Category); return imported; } /// public void AdaptProfile(Profile profile) { List devices = _deviceService.EnabledDevices.ToList(); foreach (Layer layer in profile.GetAllLayers()) layer.Adapter.Adapt(devices); profile.Save(); foreach (RenderProfileElement renderProfileElement in profile.GetAllRenderElements()) renderProfileElement.Save(); _logger.Debug("Adapt profile - Saving " + profile); SaveProfileCategory(profile.Configuration.Category); } private void InputServiceOnKeyboardKeyUp(object? sender, ArtemisKeyboardKeyEventArgs e) { _pendingKeyboardEvents.Add(e); } /// /// Populates all missing LEDs on all currently active profiles /// private void ActiveProfilesPopulateLeds() { foreach (ProfileCategory profileCategory in ProfileCategories) { foreach (ProfileConfiguration profileConfiguration in profileCategory.ProfileConfigurations) { if (profileConfiguration.Profile == null) continue; profileConfiguration.Profile.PopulateLeds(_deviceService.EnabledDevices); if (!profileConfiguration.Profile.IsFreshImport) continue; _logger.Debug("Profile is a fresh import, adapting to surface - {profile}", profileConfiguration.Profile); AdaptProfile(profileConfiguration.Profile); } } } private void UpdateModules() { List modules = _pluginManagementService.GetFeaturesOfType(); foreach (ProfileCategory profileCategory in ProfileCategories) { foreach (ProfileConfiguration profileConfiguration in profileCategory.ProfileConfigurations) profileConfiguration.LoadModules(modules); } } private void DeviceServiceOnLedsChanged(object? sender, EventArgs e) { ActiveProfilesPopulateLeds(); } private void PluginManagementServiceOnPluginFeatureToggled(object? sender, PluginFeatureEventArgs e) { if (e.PluginFeature is Module) UpdateModules(); } private void ProcessPendingKeyEvents(ProfileConfiguration profileConfiguration) { if (profileConfiguration.HotkeyMode == ProfileConfigurationHotkeyMode.None) return; bool before = profileConfiguration.IsSuspended; foreach (ArtemisKeyboardKeyEventArgs e in _pendingKeyboardEvents) { if (profileConfiguration.HotkeyMode == ProfileConfigurationHotkeyMode.Toggle) { if (profileConfiguration.EnableHotkey != null && profileConfiguration.EnableHotkey.MatchesEventArgs(e)) profileConfiguration.IsSuspended = !profileConfiguration.IsSuspended; } else { if (profileConfiguration.IsSuspended && profileConfiguration.EnableHotkey != null && profileConfiguration.EnableHotkey.MatchesEventArgs(e)) profileConfiguration.IsSuspended = false; else if (!profileConfiguration.IsSuspended && profileConfiguration.DisableHotkey != null && profileConfiguration.DisableHotkey.MatchesEventArgs(e)) profileConfiguration.IsSuspended = true; } } // If suspension was changed, save the category if (before != profileConfiguration.IsSuspended) SaveProfileCategory(profileConfiguration.Category); } private void CreateDefaultProfileCategories() { foreach (DefaultCategoryName defaultCategoryName in Enum.GetValues()) CreateProfileCategory(defaultCategoryName.ToString()); } private void LogProfileUpdateExceptions() { // Only log update exceptions every 10 seconds to avoid spamming the logs if (DateTime.Now - _lastUpdateExceptionLog < TimeSpan.FromSeconds(10)) return; _lastUpdateExceptionLog = DateTime.Now; if (!_updateExceptions.Any()) return; // Group by stack trace, that should gather up duplicate exceptions foreach (IGrouping exceptions in _updateExceptions.GroupBy(e => e.StackTrace)) { _logger.Warning(exceptions.First(), "Exception was thrown {count} times during profile update in the last 10 seconds", exceptions.Count()); } // When logging is finished start with a fresh slate _updateExceptions.Clear(); } private void LogProfileRenderExceptions() { // Only log update exceptions every 10 seconds to avoid spamming the logs if (DateTime.Now - _lastRenderExceptionLog < TimeSpan.FromSeconds(10)) return; _lastRenderExceptionLog = DateTime.Now; if (!_renderExceptions.Any()) return; // Group by stack trace, that should gather up duplicate exceptions foreach (IGrouping exceptions in _renderExceptions.GroupBy(e => e.StackTrace)) { _logger.Warning(exceptions.First(), "Exception was thrown {count} times during profile render in the last 10 seconds", exceptions.Count()); } // When logging is finished start with a fresh slate _renderExceptions.Clear(); } #region Events public event EventHandler? ProfileActivated; public event EventHandler? ProfileDeactivated; public event EventHandler? ProfileCategoryAdded; public event EventHandler? ProfileCategoryRemoved; protected virtual void OnProfileActivated(ProfileConfigurationEventArgs e) { ProfileActivated?.Invoke(this, e); } protected virtual void OnProfileDeactivated(ProfileConfigurationEventArgs e) { ProfileDeactivated?.Invoke(this, e); } protected virtual void OnProfileCategoryAdded(ProfileCategoryEventArgs e) { ProfileCategoryAdded?.Invoke(this, e); } protected virtual void OnProfileCategoryRemoved(ProfileCategoryEventArgs e) { ProfileCategoryRemoved?.Invoke(this, e); } #endregion }