From 2bfc47368952dce7ef01f914dc06551be784a445 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Thu, 25 Nov 2021 15:14:43 +0900 Subject: [PATCH] Split out `SkinModelManager` from `SkinManager` --- osu.Game/Skinning/SkinManager.cs | 295 +++++++++++--------------- osu.Game/Skinning/SkinModelManager.cs | 191 +++++++++++++++++ 2 files changed, 317 insertions(+), 169 deletions(-) create mode 100644 osu.Game/Skinning/SkinModelManager.cs diff --git a/osu.Game/Skinning/SkinManager.cs b/osu.Game/Skinning/SkinManager.cs index 6e0a8e0a11..a840e17ed1 100644 --- a/osu.Game/Skinning/SkinManager.cs +++ b/osu.Game/Skinning/SkinManager.cs @@ -18,15 +18,14 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.OpenGL.Textures; using osu.Framework.Graphics.Textures; using osu.Framework.IO.Stores; -using osu.Framework.Logging; using osu.Framework.Platform; using osu.Framework.Testing; using osu.Framework.Utils; using osu.Game.Audio; using osu.Game.Database; -using osu.Game.Extensions; using osu.Game.IO; using osu.Game.IO.Archives; +using osu.Game.Overlays.Notifications; namespace osu.Game.Skinning { @@ -38,7 +37,7 @@ namespace osu.Game.Skinning /// For gameplay components, see which adds extra legacy and toggle logic that may affect the lookup process. /// [ExcludeFromDynamicCompile] - public class SkinManager : ArchiveModelManager, ISkinSource, IStorageResourceProvider + public class SkinManager : ISkinSource, IStorageResourceProvider, IModelImporter, IModelManager { private readonly AudioManager audio; @@ -49,11 +48,11 @@ namespace osu.Game.Skinning public readonly Bindable CurrentSkin = new Bindable(); public readonly Bindable CurrentSkinInfo = new Bindable(SkinInfo.Default) { Default = SkinInfo.Default }; - public override IEnumerable HandledExtensions => new[] { ".osk" }; + private readonly SkinModelManager skinModelManager; - protected override string[] HashableFileTypes => new[] { ".ini", ".json" }; + private readonly SkinStore skinStore; - protected override string ImportFromStablePath => "Skins"; + private readonly IResourceStore userFiles; /// /// The default skin. @@ -66,12 +65,16 @@ namespace osu.Game.Skinning public Skin DefaultLegacySkin { get; } public SkinManager(Storage storage, DatabaseContextFactory contextFactory, GameHost host, IResourceStore resources, AudioManager audio) - : base(storage, contextFactory, new SkinStore(contextFactory, storage), host) { this.audio = audio; this.host = host; this.resources = resources; + skinStore = new SkinStore(contextFactory, storage); + userFiles = new FileStore(contextFactory, storage).Store; + + skinModelManager = new SkinModelManager(storage, contextFactory, skinStore, host, this); + DefaultLegacySkin = new DefaultLegacySkin(this); DefaultSkin = new DefaultSkin(this); @@ -85,31 +88,8 @@ namespace osu.Game.Skinning SourceChanged?.Invoke(); }; - - // can be removed 20220420. - populateMissingHashes(); } - private void populateMissingHashes() - { - var skinsWithoutHashes = ModelStore.ConsumableItems.Where(i => i.Hash == null).ToArray(); - - foreach (SkinInfo skin in skinsWithoutHashes) - { - try - { - Update(skin); - } - catch (Exception e) - { - Delete(skin); - Logger.Error(e, $"Existing skin {skin} has been deleted during hash recomputation due to being invalid"); - } - } - } - - protected override bool ShouldDeleteArchive(string path) => Path.GetExtension(path)?.ToLowerInvariant() == @".osk"; - /// /// Returns a list of all usable s. Includes the special default skin plus all skins from . /// @@ -129,15 +109,15 @@ namespace osu.Game.Skinning public List GetAllUserSkins(bool includeFiles = false) { if (includeFiles) - return ModelStore.ConsumableItems.Where(s => !s.DeletePending).ToList(); + return skinStore.ConsumableItems.Where(s => !s.DeletePending).ToList(); - return ModelStore.Items.Where(s => !s.DeletePending).ToList(); + return skinStore.Items.Where(s => !s.DeletePending).ToList(); } public void SelectRandomSkin() { // choose from only user skins, removing the current selection to ensure a new one is chosen. - var randomChoices = ModelStore.Items.Where(s => !s.DeletePending && s.ID != CurrentSkinInfo.Value.ID).ToArray(); + var randomChoices = skinStore.Items.Where(s => !s.DeletePending && s.ID != CurrentSkinInfo.Value.ID).ToArray(); if (randomChoices.Length == 0) { @@ -146,137 +126,7 @@ namespace osu.Game.Skinning } var chosen = randomChoices.ElementAt(RNG.Next(0, randomChoices.Length)); - CurrentSkinInfo.Value = ModelStore.ConsumableItems.Single(i => i.ID == chosen.ID); - } - - protected override SkinInfo CreateModel(ArchiveReader archive) => new SkinInfo { Name = archive.Name ?? @"No name" }; - - private const string unknown_creator_string = @"Unknown"; - - protected override bool HasCustomHashFunction => true; - - protected override string ComputeHash(SkinInfo item) - { - var instance = GetSkin(item); - - // This function can be run on fresh import or save. The logic here ensures a skin.ini file is in a good state for both operations. - - // `Skin` will parse the skin.ini and populate `Skin.Configuration` during construction above. - string skinIniSourcedName = instance.Configuration.SkinInfo.Name; - string skinIniSourcedCreator = instance.Configuration.SkinInfo.Creator; - string archiveName = item.Name.Replace(@".osk", string.Empty, StringComparison.OrdinalIgnoreCase); - - bool isImport = item.ID == 0; - - if (isImport) - { - item.Name = !string.IsNullOrEmpty(skinIniSourcedName) ? skinIniSourcedName : archiveName; - item.Creator = !string.IsNullOrEmpty(skinIniSourcedCreator) ? skinIniSourcedCreator : unknown_creator_string; - - // For imports, we want to use the archive or folder name as part of the metadata, in addition to any existing skin.ini metadata. - // In an ideal world, skin.ini would be the only source of metadata, but a lot of skin creators and users don't update it when making modifications. - // In both of these cases, the expectation from the user is that the filename or folder name is displayed somewhere to identify the skin. - if (archiveName != item.Name) - item.Name = @$"{item.Name} [{archiveName}]"; - } - - // By this point, the metadata in SkinInfo will be correct. - // Regardless of whether this is an import or not, let's write the skin.ini if non-existing or non-matching. - // This is (weirdly) done inside ComputeHash to avoid adding a new method to handle this case. After switching to realm it can be moved into another place. - if (skinIniSourcedName != item.Name) - updateSkinIniMetadata(item); - - return base.ComputeHash(item); - } - - private void updateSkinIniMetadata(SkinInfo item) - { - string nameLine = @$"Name: {item.Name}"; - string authorLine = @$"Author: {item.Creator}"; - - string[] newLines = - { - @"// The following content was automatically added by osu! during import, based on filename / folder metadata.", - @"[General]", - nameLine, - authorLine, - }; - - var existingFile = item.Files.SingleOrDefault(f => f.Filename.Equals(@"skin.ini", StringComparison.OrdinalIgnoreCase)); - - if (existingFile == null) - { - // In the case a skin doesn't have a skin.ini yet, let's create one. - writeNewSkinIni(); - return; - } - - using (Stream stream = new MemoryStream()) - { - using (var sw = new StreamWriter(stream, Encoding.UTF8, 1024, true)) - { - using (var existingStream = Files.Storage.GetStream(existingFile.FileInfo.GetStoragePath())) - using (var sr = new StreamReader(existingStream)) - { - string line; - while ((line = sr.ReadLine()) != null) - sw.WriteLine(line); - } - - sw.WriteLine(); - - foreach (string line in newLines) - sw.WriteLine(line); - } - - ReplaceFile(item, existingFile, stream); - - // can be removed 20220502. - if (!ensureIniWasUpdated(item)) - { - Logger.Log($"Skin {item}'s skin.ini had issues and has been removed. Please report this and provide the problematic skin.", LoggingTarget.Database, LogLevel.Important); - - DeleteFile(item, item.Files.SingleOrDefault(f => f.Filename.Equals(@"skin.ini", StringComparison.OrdinalIgnoreCase))); - writeNewSkinIni(); - } - } - - void writeNewSkinIni() - { - using (Stream stream = new MemoryStream()) - { - using (var sw = new StreamWriter(stream, Encoding.UTF8, 1024, true)) - { - foreach (string line in newLines) - sw.WriteLine(line); - } - - AddFile(item, stream, @"skin.ini"); - } - } - } - - private bool ensureIniWasUpdated(SkinInfo item) - { - // This is a final consistency check to ensure that hash computation doesn't enter an infinite loop. - // With other changes to the surrounding code this should never be hit, but until we are 101% sure that there - // are no other cases let's avoid a hard startup crash by bailing and alerting. - - var instance = GetSkin(item); - - return instance.Configuration.SkinInfo.Name == item.Name; - } - - protected override Task Populate(SkinInfo model, ArchiveReader archive, CancellationToken cancellationToken = default) - { - var instance = GetSkin(model); - - model.InstantiationInfo ??= instance.GetType().GetInvariantInstantiationInfo(); - - model.Name = instance.Configuration.SkinInfo.Name; - model.Creator = instance.Configuration.SkinInfo.Creator; - - return Task.CompletedTask; + CurrentSkinInfo.Value = skinStore.ConsumableItems.Single(i => i.ID == chosen.ID); } /// @@ -297,7 +147,7 @@ namespace osu.Game.Skinning var skin = CurrentSkin.Value; // if the user is attempting to save one of the default skin implementations, create a copy first. - CurrentSkinInfo.Value = Import(new SkinInfo + CurrentSkinInfo.Value = skinModelManager.Import(new SkinInfo { Name = skin.SkinInfo.Name + @" (modified)", Creator = skin.SkinInfo.Creator, @@ -321,9 +171,9 @@ namespace osu.Game.Skinning var oldFile = skin.SkinInfo.Files.FirstOrDefault(f => f.Filename == filename); if (oldFile != null) - ReplaceFile(skin.SkinInfo, oldFile, streamContent, oldFile.Filename); + skinModelManager.ReplaceFile(skin.SkinInfo, oldFile, streamContent, oldFile.Filename); else - AddFile(skin.SkinInfo, streamContent, filename); + skinModelManager.AddFile(skin.SkinInfo, streamContent, filename); } } } @@ -333,7 +183,7 @@ namespace osu.Game.Skinning /// /// The query. /// The first result for the provided query, or null if no results were found. - public SkinInfo Query(Expression> query) => ModelStore.ConsumableItems.AsNoTracking().FirstOrDefault(query); + public SkinInfo Query(Expression> query) => skinStore.ConsumableItems.AsNoTracking().FirstOrDefault(query); public event Action SourceChanged; @@ -386,9 +236,116 @@ namespace osu.Game.Skinning AudioManager IStorageResourceProvider.AudioManager => audio; IResourceStore IStorageResourceProvider.Resources => resources; - IResourceStore IStorageResourceProvider.Files => Files.Store; + IResourceStore IStorageResourceProvider.Files => userFiles; IResourceStore IStorageResourceProvider.CreateTextureLoaderStore(IResourceStore underlyingStore) => host.CreateTextureLoaderStore(underlyingStore); #endregion + + #region Implementation of IModelImporter + + public Action PostNotification + { + set => skinModelManager.PostNotification = value; + } + + public Action>> PostImport + { + set => skinModelManager.PostImport = value; + } + + public Task Import(params string[] paths) + { + return skinModelManager.Import(paths); + } + + public Task Import(params ImportTask[] tasks) + { + return skinModelManager.Import(tasks); + } + + public IEnumerable HandledExtensions => skinModelManager.HandledExtensions; + + public Task>> Import(ProgressNotification notification, params ImportTask[] tasks) + { + return skinModelManager.Import(notification, tasks); + } + + public Task> Import(ImportTask task, bool lowPriority = false, CancellationToken cancellationToken = default) + { + return skinModelManager.Import(task, lowPriority, cancellationToken); + } + + public Task> Import(ArchiveReader archive, bool lowPriority = false, CancellationToken cancellationToken = default) + { + return skinModelManager.Import(archive, lowPriority, cancellationToken); + } + + public Task> Import(SkinInfo item, ArchiveReader archive = null, bool lowPriority = false, CancellationToken cancellationToken = default) + { + return skinModelManager.Import(item, archive, lowPriority, cancellationToken); + } + + #endregion + + #region Implementation of IModelManager + + public event Action ItemUpdated + { + add => skinModelManager.ItemUpdated += value; + remove => skinModelManager.ItemUpdated -= value; + } + + public event Action ItemRemoved + { + add => skinModelManager.ItemRemoved += value; + remove => skinModelManager.ItemRemoved -= value; + } + + public Task ImportFromStableAsync(StableStorage stableStorage) + { + return skinModelManager.ImportFromStableAsync(stableStorage); + } + + public void Export(SkinInfo item) + { + skinModelManager.Export(item); + } + + public void ExportModelTo(SkinInfo model, Stream outputStream) + { + skinModelManager.ExportModelTo(model, outputStream); + } + + public void Update(SkinInfo item) + { + skinModelManager.Update(item); + } + + public bool Delete(SkinInfo item) + { + return skinModelManager.Delete(item); + } + + public void Delete(List items, bool silent = false) + { + skinModelManager.Delete(items, silent); + } + + public void Undelete(List items, bool silent = false) + { + skinModelManager.Undelete(items, silent); + } + + public void Undelete(SkinInfo item) + { + skinModelManager.Undelete(item); + } + + public bool IsAvailableLocally(SkinInfo model) + { + return skinModelManager.IsAvailableLocally(model); + } + + #endregion } } diff --git a/osu.Game/Skinning/SkinModelManager.cs b/osu.Game/Skinning/SkinModelManager.cs new file mode 100644 index 0000000000..f28b0c066b --- /dev/null +++ b/osu.Game/Skinning/SkinModelManager.cs @@ -0,0 +1,191 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using osu.Framework.Logging; +using osu.Framework.Platform; +using osu.Game.Database; +using osu.Game.Extensions; +using osu.Game.IO; +using osu.Game.IO.Archives; + +namespace osu.Game.Skinning +{ + public class SkinModelManager : ArchiveModelManager + { + private readonly IStorageResourceProvider skinResources; + + public SkinModelManager(Storage storage, DatabaseContextFactory contextFactory, SkinStore skinStore, GameHost host, IStorageResourceProvider skinResources) + : base(storage, contextFactory, skinStore, host) + { + this.skinResources = skinResources; + + // can be removed 20220420. + populateMissingHashes(); + } + + public override IEnumerable HandledExtensions => new[] { ".osk" }; + + protected override string[] HashableFileTypes => new[] { ".ini", ".json" }; + + protected override string ImportFromStablePath => "Skins"; + + protected override bool ShouldDeleteArchive(string path) => Path.GetExtension(path)?.ToLowerInvariant() == @".osk"; + + protected override SkinInfo CreateModel(ArchiveReader archive) => new SkinInfo { Name = archive.Name ?? @"No name" }; + + private const string unknown_creator_string = @"Unknown"; + + protected override bool HasCustomHashFunction => true; + + protected override string ComputeHash(SkinInfo item) + { + var instance = createInstance(item); + + // This function can be run on fresh import or save. The logic here ensures a skin.ini file is in a good state for both operations. + + // `Skin` will parse the skin.ini and populate `Skin.Configuration` during construction above. + string skinIniSourcedName = instance.Configuration.SkinInfo.Name; + string skinIniSourcedCreator = instance.Configuration.SkinInfo.Creator; + string archiveName = item.Name.Replace(@".osk", string.Empty, StringComparison.OrdinalIgnoreCase); + + bool isImport = item.ID == 0; + + if (isImport) + { + item.Name = !string.IsNullOrEmpty(skinIniSourcedName) ? skinIniSourcedName : archiveName; + item.Creator = !string.IsNullOrEmpty(skinIniSourcedCreator) ? skinIniSourcedCreator : unknown_creator_string; + + // For imports, we want to use the archive or folder name as part of the metadata, in addition to any existing skin.ini metadata. + // In an ideal world, skin.ini would be the only source of metadata, but a lot of skin creators and users don't update it when making modifications. + // In both of these cases, the expectation from the user is that the filename or folder name is displayed somewhere to identify the skin. + if (archiveName != item.Name) + item.Name = @$"{item.Name} [{archiveName}]"; + } + + // By this point, the metadata in SkinInfo will be correct. + // Regardless of whether this is an import or not, let's write the skin.ini if non-existing or non-matching. + // This is (weirdly) done inside ComputeHash to avoid adding a new method to handle this case. After switching to realm it can be moved into another place. + if (skinIniSourcedName != item.Name) + updateSkinIniMetadata(item); + + return base.ComputeHash(item); + } + + private void updateSkinIniMetadata(SkinInfo item) + { + string nameLine = @$"Name: {item.Name}"; + string authorLine = @$"Author: {item.Creator}"; + + string[] newLines = + { + @"// The following content was automatically added by osu! during import, based on filename / folder metadata.", + @"[General]", + nameLine, + authorLine, + }; + + var existingFile = item.Files.SingleOrDefault(f => f.Filename.Equals(@"skin.ini", StringComparison.OrdinalIgnoreCase)); + + if (existingFile == null) + { + // In the case a skin doesn't have a skin.ini yet, let's create one. + writeNewSkinIni(); + return; + } + + using (Stream stream = new MemoryStream()) + { + using (var sw = new StreamWriter(stream, Encoding.UTF8, 1024, true)) + { + using (var existingStream = Files.Storage.GetStream(existingFile.FileInfo.GetStoragePath())) + using (var sr = new StreamReader(existingStream)) + { + string line; + while ((line = sr.ReadLine()) != null) + sw.WriteLine(line); + } + + sw.WriteLine(); + + foreach (string line in newLines) + sw.WriteLine(line); + } + + ReplaceFile(item, existingFile, stream); + + // can be removed 20220502. + if (!ensureIniWasUpdated(item)) + { + Logger.Log($"Skin {item}'s skin.ini had issues and has been removed. Please report this and provide the problematic skin.", LoggingTarget.Database, LogLevel.Important); + + DeleteFile(item, item.Files.SingleOrDefault(f => f.Filename.Equals(@"skin.ini", StringComparison.OrdinalIgnoreCase))); + writeNewSkinIni(); + } + } + + void writeNewSkinIni() + { + using (Stream stream = new MemoryStream()) + { + using (var sw = new StreamWriter(stream, Encoding.UTF8, 1024, true)) + { + foreach (string line in newLines) + sw.WriteLine(line); + } + + AddFile(item, stream, @"skin.ini"); + } + } + } + + private bool ensureIniWasUpdated(SkinInfo item) + { + // This is a final consistency check to ensure that hash computation doesn't enter an infinite loop. + // With other changes to the surrounding code this should never be hit, but until we are 101% sure that there + // are no other cases let's avoid a hard startup crash by bailing and alerting. + + var instance = createInstance(item); + + return instance.Configuration.SkinInfo.Name == item.Name; + } + + protected override Task Populate(SkinInfo model, ArchiveReader archive, CancellationToken cancellationToken = default) + { + var instance = createInstance(model); + + model.InstantiationInfo ??= instance.GetType().GetInvariantInstantiationInfo(); + + model.Name = instance.Configuration.SkinInfo.Name; + model.Creator = instance.Configuration.SkinInfo.Creator; + + return Task.CompletedTask; + } + + private void populateMissingHashes() + { + var skinsWithoutHashes = ModelStore.ConsumableItems.Where(i => i.Hash == null).ToArray(); + + foreach (SkinInfo skin in skinsWithoutHashes) + { + try + { + Update(skin); + } + catch (Exception e) + { + Delete(skin); + Logger.Error(e, $"Existing skin {skin} has been deleted during hash recomputation due to being invalid"); + } + } + } + + private Skin createInstance(SkinInfo item) => item.CreateInstance(skinResources); + } +}