From 29d074bdb8629ff480207498c0eeec61b010a530 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Mon, 29 Nov 2021 18:07:32 +0900 Subject: [PATCH] Implement missing behaviours required for skin file operations via `RealmArchiveModelManager` --- osu.Game/OsuGameBase.cs | 10 +- osu.Game/Skinning/SkinManager.cs | 163 ++++++++----------- osu.Game/Skinning/SkinModelManager.cs | 144 ++++++++++------ osu.Game/Stores/RealmArchiveModelManager.cs | 172 ++++++++++++++++++++ 4 files changed, 332 insertions(+), 157 deletions(-) create mode 100644 osu.Game/Stores/RealmArchiveModelManager.cs diff --git a/osu.Game/OsuGameBase.cs b/osu.Game/OsuGameBase.cs index 88c9ab370c..4bae6f3c1d 100644 --- a/osu.Game/OsuGameBase.cs +++ b/osu.Game/OsuGameBase.cs @@ -211,17 +211,9 @@ namespace osu.Game Audio.Samples.PlaybackConcurrency = SAMPLE_CONCURRENCY; - dependencies.Cache(SkinManager = new SkinManager(Storage, contextFactory, Host, Resources, Audio)); + dependencies.Cache(SkinManager = new SkinManager(Storage, realmFactory, Host, Resources, Audio, Scheduler)); dependencies.CacheAs(SkinManager); - // needs to be done here rather than inside SkinManager to ensure thread safety of CurrentSkinInfo. - SkinManager.ItemRemoved += item => Schedule(() => - { - // check the removed skin is not the current user choice. if it is, switch back to default. - if (item.Equals(SkinManager.CurrentSkinInfo.Value)) - SkinManager.CurrentSkinInfo.Value = SkinInfo.Default; - }); - EndpointConfiguration endpoints = UseDevelopmentServer ? (EndpointConfiguration)new DevelopmentEndpointConfiguration() : new ProductionEndpointConfiguration(); MessageFormatter.WebsiteRootUrl = endpoints.WebsiteRootUrl; diff --git a/osu.Game/Skinning/SkinManager.cs b/osu.Game/Skinning/SkinManager.cs index 24ec454276..2ec934ac32 100644 --- a/osu.Game/Skinning/SkinManager.cs +++ b/osu.Game/Skinning/SkinManager.cs @@ -3,14 +3,10 @@ using System; using System.Collections.Generic; -using System.IO; using System.Linq; using System.Linq.Expressions; -using System.Text; using System.Threading; using System.Threading.Tasks; -using Microsoft.EntityFrameworkCore; -using Newtonsoft.Json; using osu.Framework.Audio; using osu.Framework.Audio.Sample; using osu.Framework.Bindables; @@ -20,6 +16,7 @@ using osu.Framework.Graphics.Textures; using osu.Framework.IO.Stores; using osu.Framework.Platform; using osu.Framework.Testing; +using osu.Framework.Threading; using osu.Framework.Utils; using osu.Game.Audio; using osu.Game.Database; @@ -37,7 +34,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 : ISkinSource, IStorageResourceProvider, IModelImporter, IModelManager + public class SkinManager : ISkinSource, IStorageResourceProvider, IModelImporter { private readonly AudioManager audio; @@ -49,8 +46,7 @@ namespace osu.Game.Skinning public readonly Bindable> CurrentSkinInfo = new Bindable>(SkinInfo.Default.ToLive()) { Default = SkinInfo.Default.ToLive() }; private readonly SkinModelManager skinModelManager; - - private readonly SkinStore skinStore; + private readonly RealmContextFactory contextFactory; private readonly IResourceStore userFiles; @@ -64,69 +60,73 @@ namespace osu.Game.Skinning /// public Skin DefaultLegacySkin { get; } - public SkinManager(Storage storage, DatabaseContextFactory contextFactory, GameHost host, IResourceStore resources, AudioManager audio) + public SkinManager(Storage storage, RealmContextFactory contextFactory, GameHost host, IResourceStore resources, AudioManager audio, Scheduler scheduler) { + this.contextFactory = contextFactory; this.audio = audio; this.host = host; this.resources = resources; - skinStore = new SkinStore(contextFactory, storage); - userFiles = new FileStore(contextFactory, storage).Store; + userFiles = new StorageBackedResourceStore(storage.GetStorageForDirectory("files")); - skinModelManager = new SkinModelManager(storage, contextFactory, skinStore, host, this); + skinModelManager = new SkinModelManager(storage, contextFactory, host, this); DefaultLegacySkin = new DefaultLegacySkin(this); DefaultSkin = new DefaultSkin(this); - CurrentSkinInfo.ValueChanged += skin => CurrentSkin.Value = GetSkin(skin.NewValue); + CurrentSkinInfo.ValueChanged += skin => CurrentSkin.Value = skin.NewValue.PerformRead(GetSkin); CurrentSkin.Value = DefaultSkin; CurrentSkin.ValueChanged += skin => { - if (skin.NewValue.SkinInfo != CurrentSkinInfo.Value) + if (!skin.NewValue.SkinInfo.Equals(CurrentSkinInfo.Value)) throw new InvalidOperationException($"Setting {nameof(CurrentSkin)}'s value directly is not supported. Use {nameof(CurrentSkinInfo)} instead."); SourceChanged?.Invoke(); }; + + // needs to be done here rather than inside SkinManager to ensure thread safety of CurrentSkinInfo. + ItemRemoved += item => scheduler.Add(() => + { + // TODO: fix. + // check the removed skin is not the current user choice. if it is, switch back to default. + // if (item.Equals(CurrentSkinInfo.Value)) + // CurrentSkinInfo.Value = SkinInfo.Default; + }); } /// - /// Returns a list of all usable s. Includes the special default skin plus all skins from . + /// Returns a list of all usable s. Includes the non-databased default skins. /// /// A newly allocated list of available . - public List GetAllUsableSkins() + public List> GetAllUsableSkins() { - var userSkins = GetAllUserSkins(); - userSkins.Insert(0, DefaultSkin.SkinInfo); - userSkins.Insert(1, DefaultLegacySkin.SkinInfo); - return userSkins; - } - - /// - /// Returns a list of all usable s that have been loaded by the user. - /// - /// A newly allocated list of available . - public List GetAllUserSkins(bool includeFiles = false) - { - if (includeFiles) - return skinStore.ConsumableItems.Where(s => !s.DeletePending).ToList(); - - return skinStore.Items.Where(s => !s.DeletePending).ToList(); + using (var context = contextFactory.CreateContext()) + { + var userSkins = context.All().Where(s => !s.DeletePending).ToLive(); + userSkins.Insert(0, DefaultSkin.SkinInfo); + userSkins.Insert(1, DefaultLegacySkin.SkinInfo); + return userSkins; + } } public void SelectRandomSkin() { - // choose from only user skins, removing the current selection to ensure a new one is chosen. - var randomChoices = skinStore.Items.Where(s => !s.DeletePending && s.ID != CurrentSkinInfo.Value.ID).ToArray(); - - if (randomChoices.Length == 0) + using (var context = contextFactory.CreateContext()) { - CurrentSkinInfo.Value = SkinInfo.Default; - return; - } + // choose from only user skins, removing the current selection to ensure a new one is chosen. + var randomChoices = context.All().Where(s => !s.DeletePending && s.ID != CurrentSkinInfo.Value.ID).ToArray(); - var chosen = randomChoices.ElementAt(RNG.Next(0, randomChoices.Length)); - CurrentSkinInfo.Value = skinStore.ConsumableItems.Single(i => i.ID == chosen.ID); + if (randomChoices.Length == 0) + { + CurrentSkinInfo.Value = SkinInfo.Default.ToLive(); + return; + } + + var chosen = randomChoices.ElementAt(RNG.Next(0, randomChoices.Length)); + + CurrentSkinInfo.Value = chosen.ToLive(); + } } /// @@ -142,40 +142,30 @@ namespace osu.Game.Skinning /// public void EnsureMutableSkin() { - if (CurrentSkinInfo.Value.ID >= 1) return; - - var skin = CurrentSkin.Value; - - // if the user is attempting to save one of the default skin implementations, create a copy first. - CurrentSkinInfo.Value = skinModelManager.Import(new SkinInfo + CurrentSkinInfo.Value.PerformRead(s => { - Name = skin.SkinInfo.Name + @" (modified)", - Creator = skin.SkinInfo.Creator, - InstantiationInfo = skin.SkinInfo.InstantiationInfo, - }).Result.Value; + if (s.IsManaged) + return; + + // if the user is attempting to save one of the default skin implementations, create a copy first. + var result = skinModelManager.Import(new SkinInfo + { + Name = s.Name + @" (modified)", + Creator = s.Creator, + InstantiationInfo = s.InstantiationInfo, + }).Result; + + if (result != null) + CurrentSkinInfo.Value = result; + }); } public void Save(Skin skin) { - if (skin.SkinInfo.ID <= 0) + if (!skin.SkinInfo.IsManaged) throw new InvalidOperationException($"Attempting to save a skin which is not yet tracked. Call {nameof(EnsureMutableSkin)} first."); - foreach (var drawableInfo in skin.DrawableComponentInfo) - { - string json = JsonConvert.SerializeObject(drawableInfo.Value, new JsonSerializerSettings { Formatting = Formatting.Indented }); - - using (var streamContent = new MemoryStream(Encoding.UTF8.GetBytes(json))) - { - string filename = @$"{drawableInfo.Key}.json"; - - var oldFile = skin.SkinInfo.Files.FirstOrDefault(f => f.Filename == filename); - - if (oldFile != null) - skinModelManager.ReplaceFile(skin.SkinInfo, oldFile, streamContent); - else - skinModelManager.AddFile(skin.SkinInfo, streamContent, filename); - } - } + skinModelManager.Save(skin); } /// @@ -183,7 +173,11 @@ 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) => skinStore.ConsumableItems.AsNoTracking().FirstOrDefault(query); + public ILive Query(Expression> query) + { + using (var context = contextFactory.CreateContext()) + return context.All().FirstOrDefault(query)?.ToLive(); + } public event Action SourceChanged; @@ -301,34 +295,13 @@ namespace osu.Game.Skinning remove => skinModelManager.ItemRemoved -= value; } - public void Update(SkinInfo item) + public void Delete(Expression> filter, bool silent = false) { - 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); + using (var context = contextFactory.CreateContext()) + { + var items = context.All().Where(filter).ToList(); + skinModelManager.Delete(items, silent); + } } #endregion diff --git a/osu.Game/Skinning/SkinModelManager.cs b/osu.Game/Skinning/SkinModelManager.cs index 572ae5cbfc..059345f9bc 100644 --- a/osu.Game/Skinning/SkinModelManager.cs +++ b/osu.Game/Skinning/SkinModelManager.cs @@ -8,21 +8,26 @@ using System.Linq; using System.Text; using System.Threading; using System.Threading.Tasks; +using Newtonsoft.Json; 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; +using osu.Game.Stores; +using Realms; + +#nullable enable namespace osu.Game.Skinning { - public class SkinModelManager : ArchiveModelManager + public class SkinModelManager : RealmArchiveModelManager { private readonly IStorageResourceProvider skinResources; - public SkinModelManager(Storage storage, DatabaseContextFactory contextFactory, SkinStore skinStore, GameHost host, IStorageResourceProvider skinResources) - : base(storage, contextFactory, skinStore, host) + public SkinModelManager(Storage storage, RealmContextFactory contextFactory, GameHost host, IStorageResourceProvider skinResources) + : base(storage, contextFactory) { this.skinResources = skinResources; @@ -42,18 +47,27 @@ namespace osu.Game.Skinning protected override bool HasCustomHashFunction => true; - protected override string ComputeHash(SkinInfo item) + protected override Task Populate(SkinInfo model, ArchiveReader? archive, Realm realm, CancellationToken cancellationToken = default) + { + if (string.IsNullOrEmpty(model.InstantiationInfo)) + model.InstantiationInfo = createInstance(model).GetType().GetInvariantInstantiationInfo(); + + checkSkinIniMetadata(model, realm); + + return Task.CompletedTask; + } + + private void checkSkinIniMetadata(SkinInfo item, Realm realm) { 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; + bool isImport = !item.IsManaged; if (isImport) { @@ -71,12 +85,10 @@ namespace osu.Game.Skinning // 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); + updateSkinIniMetadata(item, realm); } - private void updateSkinIniMetadata(SkinInfo item) + private void updateSkinIniMetadata(SkinInfo item, Realm realm) { string nameLine = @$"Name: {item.Name}"; string authorLine = @$"Author: {item.Creator}"; @@ -95,39 +107,47 @@ namespace osu.Game.Skinning { // In the case a skin doesn't have a skin.ini yet, let's create one. writeNewSkinIni(); - return; } - - using (Stream stream = new MemoryStream()) + else { - using (var sw = new StreamWriter(stream, Encoding.UTF8, 1024, true)) + using (Stream stream = new MemoryStream()) { - using (var existingStream = Files.Storage.GetStream(existingFile.FileInfo.GetStoragePath())) - using (var sr = new StreamReader(existingStream)) + using (var sw = new StreamWriter(stream, Encoding.UTF8, 1024, true)) { - string line; - while ((line = sr.ReadLine()) != null) + using (var existingStream = Files.Storage.GetStream(existingFile.File.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); } - sw.WriteLine(); + ReplaceFile(item, existingFile, stream, realm); - foreach (string line in newLines) - sw.WriteLine(line); - } + // 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); - ReplaceFile(item, existingFile, stream); + var existingIni = item.Files.SingleOrDefault(f => f.Filename.Equals(@"skin.ini", StringComparison.OrdinalIgnoreCase)); + if (existingIni != null) + item.Files.Remove(existingIni); - // 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(); + writeNewSkinIni(); + } } } + // The hash is already populated at this point in import. + // As we have changed files, it needs to be recomputed. + item.Hash = ComputeHash(item); + void writeNewSkinIni() { using (Stream stream = new MemoryStream()) @@ -138,8 +158,10 @@ namespace osu.Game.Skinning sw.WriteLine(line); } - AddFile(item, stream, @"skin.ini"); + AddFile(item, stream, @"skin.ini", realm); } + + item.Hash = ComputeHash(item); } } @@ -154,36 +176,52 @@ namespace osu.Game.Skinning 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) + using (var realm = ContextFactory.CreateContext()) { - try + var skinsWithoutHashes = realm.All().Where(i => string.IsNullOrEmpty(i.Hash)).ToArray(); + + foreach (SkinInfo skin in skinsWithoutHashes) { - Update(skin); - } - catch (Exception e) - { - Delete(skin); - Logger.Error(e, $"Existing skin {skin} has been deleted during hash recomputation due to being invalid"); + 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); + + public void Save(Skin skin) + { + skin.SkinInfo.PerformWrite(s => + { + foreach (var drawableInfo in skin.DrawableComponentInfo) + { + string json = JsonConvert.SerializeObject(drawableInfo.Value, new JsonSerializerSettings { Formatting = Formatting.Indented }); + + using (var streamContent = new MemoryStream(Encoding.UTF8.GetBytes(json))) + { + string filename = @$"{drawableInfo.Key}.json"; + + var oldFile = s.Files.FirstOrDefault(f => f.Filename == filename); + + if (oldFile != null) + ReplaceFile(s, oldFile, streamContent, s.Realm); + else + AddFile(s, streamContent, filename, s.Realm); + } + } + + s.Hash = ComputeHash(s); + }); + } } } diff --git a/osu.Game/Stores/RealmArchiveModelManager.cs b/osu.Game/Stores/RealmArchiveModelManager.cs new file mode 100644 index 0000000000..14b7077dd2 --- /dev/null +++ b/osu.Game/Stores/RealmArchiveModelManager.cs @@ -0,0 +1,172 @@ +// 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.Threading; +using System.Threading.Tasks; +using osu.Framework.Platform; +using osu.Game.Database; +using osu.Game.IO.Archives; +using osu.Game.Models; +using osu.Game.Overlays.Notifications; +using Realms; + +#nullable enable + +namespace osu.Game.Stores +{ + /// + /// Class which adds all the missing pieces bridging the gap between and . + /// + public abstract class RealmArchiveModelManager : RealmArchiveModelImporter, IModelManager, IModelFileManager + where TModel : RealmObject, IHasRealmFiles, IHasGuidPrimaryKey, ISoftDelete + { + public event Action? ItemUpdated; + public event Action? ItemRemoved; + + private readonly RealmFileStore realmFileStore; + + protected RealmArchiveModelManager(Storage storage, RealmContextFactory contextFactory) + : base(storage, contextFactory) + { + realmFileStore = new RealmFileStore(contextFactory, storage); + } + + public void DeleteFile(TModel item, RealmNamedFileUsage file) => + item.Realm.Write(() => DeleteFile(item, file, item.Realm)); + + public void ReplaceFile(TModel item, RealmNamedFileUsage file, Stream contents) + => item.Realm.Write(() => ReplaceFile(item, file, contents, item.Realm)); + + public void AddFile(TModel item, Stream stream, string filename) + => item.Realm.Write(() => AddFile(item, stream, filename, item.Realm)); + + public void DeleteFile(TModel item, RealmNamedFileUsage file, Realm realm) + { + item.Files.Remove(file); + } + + public void ReplaceFile(TModel model, RealmNamedFileUsage file, Stream contents, Realm realm) + { + file.File = realmFileStore.Add(contents, realm); + } + + public void AddFile(TModel item, Stream stream, string filename, Realm realm) + { + var file = realmFileStore.Add(stream, realm); + var namedUsage = new RealmNamedFileUsage(file, filename); + + item.Files.Add(namedUsage); + } + + public override async Task?> Import(TModel item, ArchiveReader? archive = null, bool lowPriority = false, CancellationToken cancellationToken = default) + { + var imported = await base.Import(item, archive, lowPriority, cancellationToken).ConfigureAwait(false); + + imported?.PerformRead(i => ItemUpdated?.Invoke(i.Detach())); + + return imported; + } + + /// + /// Delete multiple items. + /// This will post notifications tracking progress. + /// + public void Delete(List items, bool silent = false) + { + if (items.Count == 0) return; + + var notification = new ProgressNotification + { + Progress = 0, + Text = $"Preparing to delete all {HumanisedModelName}s...", + CompletionText = $"Deleted all {HumanisedModelName}s!", + State = ProgressNotificationState.Active, + }; + + if (!silent) + PostNotification?.Invoke(notification); + + int i = 0; + + foreach (var b in items) + { + if (notification.State == ProgressNotificationState.Cancelled) + // user requested abort + return; + + notification.Text = $"Deleting {HumanisedModelName}s ({++i} of {items.Count})"; + + Delete(b); + + notification.Progress = (float)i / items.Count; + } + + notification.State = ProgressNotificationState.Completed; + } + + /// + /// Restore multiple items that were previously deleted. + /// This will post notifications tracking progress. + /// + public void Undelete(List items, bool silent = false) + { + if (!items.Any()) return; + + var notification = new ProgressNotification + { + CompletionText = "Restored all deleted items!", + Progress = 0, + State = ProgressNotificationState.Active, + }; + + if (!silent) + PostNotification?.Invoke(notification); + + int i = 0; + + foreach (var item in items) + { + if (notification.State == ProgressNotificationState.Cancelled) + // user requested abort + return; + + notification.Text = $"Restoring ({++i} of {items.Count})"; + + Undelete(item); + + notification.Progress = (float)i / items.Count; + } + + notification.State = ProgressNotificationState.Completed; + } + + public bool Delete(TModel item) + { + if (item.DeletePending) + return false; + + item.Realm.Write(r => item.DeletePending = true); + ItemRemoved?.Invoke(item.Detach()); + return true; + } + + public void Undelete(TModel item) + { + if (!item.DeletePending) + return; + + item.Realm.Write(r => item.DeletePending = false); + ItemUpdated?.Invoke(item); + } + + public virtual bool IsAvailableLocally(TModel model) => false; // TODO: implement + + public void Update(TModel skin) + { + } + } +}