Merge branch 'master' into primary-key-consistency

This commit is contained in:
Dean Herbert
2021-12-08 21:34:32 +09:00
443 changed files with 8463 additions and 4491 deletions

View File

@ -12,8 +12,17 @@ namespace osu.Game.Skinning
{
public class DefaultLegacySkin : LegacySkin
{
public static SkinInfo CreateInfo() => new SkinInfo
{
ID = Skinning.SkinInfo.CLASSIC_SKIN, // this is temporary until database storage is decided upon.
Name = "osu!classic",
Creator = "team osu!",
Protected = true,
InstantiationInfo = typeof(DefaultLegacySkin).GetInvariantInstantiationInfo()
};
public DefaultLegacySkin(IStorageResourceProvider resources)
: this(Info, resources)
: this(CreateInfo(), resources)
{
}
@ -25,7 +34,7 @@ namespace osu.Game.Skinning
resources,
// A default legacy skin may still have a skin.ini if it is modified by the user.
// We must specify the stream directly as we are redirecting storage to the osu-resources location for other files.
new LegacySkinResourceStore<SkinFileInfo>(skin, resources.Files).GetStream("skin.ini")
new LegacyDatabasedSkinResourceStore(skin, resources.Files).GetStream("skin.ini")
)
{
Configuration.CustomColours["SliderBall"] = new Color4(2, 170, 255, 255);
@ -39,13 +48,5 @@ namespace osu.Game.Skinning
Configuration.LegacyVersion = 2.7m;
}
public static SkinInfo Info { get; } = new SkinInfo
{
ID = SkinInfo.CLASSIC_SKIN, // this is temporary until database storage is decided upon.
Name = "osu!classic",
Creator = "team osu!",
InstantiationInfo = typeof(DefaultLegacySkin).GetInvariantInstantiationInfo()
};
}
}

View File

@ -23,10 +23,19 @@ namespace osu.Game.Skinning
{
public class DefaultSkin : Skin
{
public static SkinInfo CreateInfo() => new SkinInfo
{
ID = osu.Game.Skinning.SkinInfo.DEFAULT_SKIN,
Name = "osu! (triangles)",
Creator = "team osu!",
Protected = true,
InstantiationInfo = typeof(DefaultSkin).GetInvariantInstantiationInfo()
};
private readonly IStorageResourceProvider resources;
public DefaultSkin(IStorageResourceProvider resources)
: this(SkinInfo.Default, resources)
: this(CreateInfo(), resources)
{
}

View File

@ -0,0 +1,63 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. 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.ComponentModel.DataAnnotations.Schema;
using osu.Framework.Extensions.ObjectExtensions;
using osu.Game.Database;
using osu.Game.Extensions;
using osu.Game.IO;
namespace osu.Game.Skinning
{
[Table(@"SkinInfo")]
public class EFSkinInfo : IHasFiles<SkinFileInfo>, IEquatable<EFSkinInfo>, IHasPrimaryKey, ISoftDelete
{
internal const int DEFAULT_SKIN = 0;
internal const int CLASSIC_SKIN = -1;
internal const int RANDOM_SKIN = -2;
public int ID { get; set; }
public string Name { get; set; } = string.Empty;
public string Creator { get; set; } = string.Empty;
public string Hash { get; set; }
public string InstantiationInfo { get; set; }
public virtual Skin CreateInstance(IStorageResourceProvider resources)
{
var type = string.IsNullOrEmpty(InstantiationInfo)
// handle the case of skins imported before InstantiationInfo was added.
? typeof(LegacySkin)
: Type.GetType(InstantiationInfo).AsNonNull();
return (Skin)Activator.CreateInstance(type, this, resources);
}
public List<SkinFileInfo> Files { get; set; } = new List<SkinFileInfo>();
public bool DeletePending { get; set; }
public static EFSkinInfo Default { get; } = new EFSkinInfo
{
ID = DEFAULT_SKIN,
Name = "osu! (triangles)",
Creator = "team osu!",
InstantiationInfo = typeof(DefaultSkin).GetInvariantInstantiationInfo()
};
public bool Equals(EFSkinInfo other) => other != null && ID == other.ID;
public override string ToString()
{
string author = Creator == null ? string.Empty : $"({Creator})";
return $"{Name} {author}".Trim();
}
public bool IsManaged => ID > 0;
}
}

View File

@ -3,13 +3,11 @@
using System.Diagnostics;
using JetBrains.Annotations;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Input.Bindings;
using osu.Framework.Input.Events;
using osu.Game.Graphics;
using osu.Game.Graphics.Containers;
using osu.Game.Input.Bindings;
@ -28,9 +26,6 @@ namespace osu.Game.Skinning.Editor
public const float VISIBLE_TARGET_SCALE = 0.8f;
[Resolved]
private OsuColour colours { get; set; }
public SkinEditorOverlay(ScalingContainer target)
{
this.target = target;

View File

@ -1,10 +1,8 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Game.Graphics.Sprites;
using osu.Game.Screens.Play;
using osu.Game.Screens.Play.HUD;
using osuTK;
@ -23,9 +21,6 @@ namespace osu.Game.Skinning
Margin = new MarginPadding(10);
}
[Resolved(canBeNull: true)]
private HUDOverlay hud { get; set; }
protected sealed override OsuSpriteText CreateSpriteText() => new LegacySpriteText(LegacyFont.Score)
{
Anchor = Anchor.TopRight,

View File

@ -21,7 +21,7 @@ namespace osu.Game.Skinning
protected override bool UseCustomSampleBanks => true;
public LegacyBeatmapSkin(BeatmapInfo beatmapInfo, IResourceStore<byte[]> storage, IStorageResourceProvider resources)
: base(createSkinInfo(beatmapInfo), new LegacySkinResourceStore<BeatmapSetFileInfo>(beatmapInfo.BeatmapSet, storage), resources, beatmapInfo.Path)
: base(createSkinInfo(beatmapInfo), new LegacySkinResourceStore(beatmapInfo.BeatmapSet, storage), resources, beatmapInfo.Path)
{
// Disallow default colours fallback on beatmap skins to allow using parent skin combo colours. (via SkinProvidingContainer)
Configuration.AllowDefaultComboColoursFallback = false;
@ -77,6 +77,6 @@ namespace osu.Game.Skinning
}
private static SkinInfo createSkinInfo(BeatmapInfo beatmapInfo) =>
new SkinInfo { Name = beatmapInfo.ToString(), Creator = beatmapInfo.Metadata?.Author.Username };
new SkinInfo { Name = beatmapInfo.ToString(), Creator = beatmapInfo.Metadata?.Author.Username ?? string.Empty };
}
}

View File

@ -0,0 +1,43 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.Collections.Generic;
using osu.Framework.Extensions;
using osu.Framework.IO.Stores;
using osu.Game.Extensions;
namespace osu.Game.Skinning
{
public class LegacyDatabasedSkinResourceStore : ResourceStore<byte[]>
{
private readonly Dictionary<string, string> fileToStoragePathMapping = new Dictionary<string, string>();
public LegacyDatabasedSkinResourceStore(SkinInfo source, IResourceStore<byte[]> underlyingStore)
: base(underlyingStore)
{
initialiseFileCache(source);
}
private void initialiseFileCache(SkinInfo source)
{
fileToStoragePathMapping.Clear();
foreach (var f in source.Files)
fileToStoragePathMapping[f.Filename.ToLowerInvariant()] = f.File.GetStoragePath();
}
protected override IEnumerable<string> GetFilenames(string name)
{
foreach (string filename in base.GetFilenames(name))
{
string path = getPathForFile(filename.ToStandardisedPath());
if (path != null)
yield return path;
}
}
private string getPathForFile(string filename) =>
fileToStoragePathMapping.TryGetValue(filename.ToLower(), out string path) ? path : null;
public override IEnumerable<string> GetAvailableResources() => fileToStoragePathMapping.Keys;
}
}

View File

@ -51,7 +51,7 @@ namespace osu.Game.Skinning
[UsedImplicitly(ImplicitUseKindFlags.InstantiatedWithFixedConstructorSignature)]
public LegacySkin(SkinInfo skin, IStorageResourceProvider resources)
: this(skin, new LegacySkinResourceStore<SkinFileInfo>(skin, resources.Files), resources, "skin.ini")
: this(skin, new LegacyDatabasedSkinResourceStore(skin, resources.Files), resources, "skin.ini")
{
}

View File

@ -7,15 +7,15 @@ using System.Linq;
using osu.Framework.Extensions;
using osu.Framework.IO.Stores;
using osu.Game.Database;
using osu.Game.Extensions;
namespace osu.Game.Skinning
{
public class LegacySkinResourceStore<T> : ResourceStore<byte[]>
where T : INamedFileInfo
public class LegacySkinResourceStore : ResourceStore<byte[]>
{
private readonly IHasFiles<T> source;
private readonly IHasNamedFiles source;
public LegacySkinResourceStore(IHasFiles<T> source, IResourceStore<byte[]> underlyingStore)
public LegacySkinResourceStore(IHasNamedFiles source, IResourceStore<byte[]> underlyingStore)
: base(underlyingStore)
{
this.source = source;
@ -23,9 +23,6 @@ namespace osu.Game.Skinning
protected override IEnumerable<string> GetFilenames(string name)
{
if (source.Files == null)
yield break;
foreach (string filename in base.GetFilenames(name))
{
string path = getPathForFile(filename.ToStandardisedPath());
@ -35,7 +32,7 @@ namespace osu.Game.Skinning
}
private string getPathForFile(string filename) =>
source.Files.Find(f => string.Equals(f.Filename, filename, StringComparison.OrdinalIgnoreCase))?.FileInfo.StoragePath;
source.Files.FirstOrDefault(f => string.Equals(f.Filename, filename, StringComparison.OrdinalIgnoreCase))?.File.GetStoragePath();
public override IEnumerable<string> GetAvailableResources() => source.Files.Select(f => f.Filename);
}

View File

@ -4,7 +4,6 @@
using System;
using System.Linq;
using JetBrains.Annotations;
using osu.Framework.Allocation;
using osu.Framework.Audio;
using osu.Framework.Audio.Sample;
using osu.Framework.Bindables;
@ -30,9 +29,6 @@ namespace osu.Game.Skinning
private ISampleInfo sampleInfo;
private SampleChannel activeChannel;
[Resolved]
private ISampleStore sampleStore { get; set; }
/// <summary>
/// Creates a new <see cref="PoolableSkinnableSample"/> with no applied <see cref="ISampleInfo"/>.
/// An <see cref="ISampleInfo"/> can be applied later via <see cref="Apply"/>.

View File

@ -15,6 +15,8 @@ using osu.Framework.Graphics.OpenGL.Textures;
using osu.Framework.Graphics.Textures;
using osu.Framework.Logging;
using osu.Game.Audio;
using osu.Game.Database;
using osu.Game.Extensions;
using osu.Game.IO;
using osu.Game.Screens.Play.HUD;
@ -22,7 +24,7 @@ namespace osu.Game.Skinning
{
public abstract class Skin : IDisposable, ISkin
{
public readonly SkinInfo SkinInfo;
public readonly ILive<SkinInfo> SkinInfo;
private readonly IStorageResourceProvider resources;
public SkinConfiguration Configuration { get; set; }
@ -41,7 +43,7 @@ namespace osu.Game.Skinning
protected Skin(SkinInfo skin, IStorageResourceProvider resources, [CanBeNull] Stream configurationStream = null)
{
SkinInfo = skin;
SkinInfo = skin.ToLive();
this.resources = resources;
configurationStream ??= getConfigurationStream();
@ -52,37 +54,41 @@ namespace osu.Game.Skinning
else
Configuration = new SkinConfiguration();
// we may want to move this to some kind of async operation in the future.
foreach (SkinnableTarget skinnableTarget in Enum.GetValues(typeof(SkinnableTarget)))
// skininfo files may be null for default skin.
SkinInfo.PerformRead(s =>
{
string filename = $"{skinnableTarget}.json";
// skininfo files may be null for default skin.
var fileInfo = SkinInfo.Files?.FirstOrDefault(f => f.Filename == filename);
if (fileInfo == null)
continue;
byte[] bytes = resources?.Files.Get(fileInfo.FileInfo.StoragePath);
if (bytes == null)
continue;
try
// we may want to move this to some kind of async operation in the future.
foreach (SkinnableTarget skinnableTarget in Enum.GetValues(typeof(SkinnableTarget)))
{
string jsonContent = Encoding.UTF8.GetString(bytes);
var deserializedContent = JsonConvert.DeserializeObject<IEnumerable<SkinnableInfo>>(jsonContent);
string filename = $"{skinnableTarget}.json";
if (deserializedContent == null)
// skininfo files may be null for default skin.
var fileInfo = s.Files.FirstOrDefault(f => f.Filename == filename);
if (fileInfo == null)
continue;
DrawableComponentInfo[skinnableTarget] = deserializedContent.ToArray();
byte[] bytes = resources?.Files.Get(fileInfo.File.GetStoragePath());
if (bytes == null)
continue;
try
{
string jsonContent = Encoding.UTF8.GetString(bytes);
var deserializedContent = JsonConvert.DeserializeObject<IEnumerable<SkinnableInfo>>(jsonContent);
if (deserializedContent == null)
continue;
DrawableComponentInfo[skinnableTarget] = deserializedContent.ToArray();
}
catch (Exception ex)
{
Logger.Error(ex, "Failed to load skin configuration.");
}
}
catch (Exception ex)
{
Logger.Error(ex, "Failed to load skin configuration.");
}
}
});
}
protected virtual void ParseConfigurationStream(Stream stream)
@ -93,7 +99,7 @@ namespace osu.Game.Skinning
private Stream getConfigurationStream()
{
string path = SkinInfo.Files.SingleOrDefault(f => f.Filename.Equals(@"skin.ini", StringComparison.OrdinalIgnoreCase))?.FileInfo.StoragePath;
string path = SkinInfo.PerformRead(s => s.Files.SingleOrDefault(f => f.Filename.Equals(@"skin.ini", StringComparison.OrdinalIgnoreCase))?.File.GetStoragePath());
if (string.IsNullOrEmpty(path))
return null;

View File

@ -7,7 +7,7 @@ using osu.Game.IO;
namespace osu.Game.Skinning
{
public class SkinFileInfo : INamedFileInfo, IHasPrimaryKey
public class SkinFileInfo : INamedFileInfo, IHasPrimaryKey, INamedFileUsage
{
public int ID { get; set; }
@ -15,11 +15,15 @@ namespace osu.Game.Skinning
public int SkinInfoID { get; set; }
public EFSkinInfo SkinInfo { get; set; }
public int FileInfoID { get; set; }
public FileInfo FileInfo { get; set; }
[Required]
public string Filename { get; set; }
IFileInfo INamedFileUsage.File => FileInfo;
}
}

View File

@ -3,30 +3,43 @@
using System;
using System.Collections.Generic;
using Newtonsoft.Json;
using osu.Framework.Extensions.ObjectExtensions;
using osu.Framework.Testing;
using osu.Game.Database;
using osu.Game.Extensions;
using osu.Game.IO;
using osu.Game.Models;
using Realms;
#nullable enable
namespace osu.Game.Skinning
{
public class SkinInfo : IHasFiles<SkinFileInfo>, IEquatable<SkinInfo>, IHasPrimaryKey, ISoftDelete
[ExcludeFromDynamicCompile]
[MapTo("Skin")]
[JsonObject(MemberSerialization.OptIn)]
public class SkinInfo : RealmObject, IHasRealmFiles, IEquatable<SkinInfo>, IHasGuidPrimaryKey, ISoftDelete, IHasNamedFiles
{
internal const int DEFAULT_SKIN = 0;
internal const int CLASSIC_SKIN = -1;
internal const int RANDOM_SKIN = -2;
internal static readonly Guid DEFAULT_SKIN = new Guid("2991CFD8-2140-469A-BCB9-2EC23FBCE4AD");
internal static readonly Guid CLASSIC_SKIN = new Guid("81F02CD3-EEC6-4865-AC23-FAE26A386187");
internal static readonly Guid RANDOM_SKIN = new Guid("D39DFEFB-477C-4372-B1EA-2BCEA5FB8908");
public int ID { get; set; }
public bool IsManaged => ID > 0;
[PrimaryKey]
[JsonProperty]
public Guid ID { get; set; } = Guid.NewGuid();
[JsonProperty]
public string Name { get; set; } = string.Empty;
[JsonProperty]
public string Creator { get; set; } = string.Empty;
public string Hash { get; set; }
[JsonProperty]
public string InstantiationInfo { get; set; } = string.Empty;
public string InstantiationInfo { get; set; }
public string Hash { get; set; } = string.Empty;
public bool Protected { get; set; }
public virtual Skin CreateInstance(IStorageResourceProvider resources)
{
@ -38,24 +51,24 @@ namespace osu.Game.Skinning
return (Skin)Activator.CreateInstance(type, this, resources);
}
public List<SkinFileInfo> Files { get; set; } = new List<SkinFileInfo>();
public IList<RealmNamedFileUsage> Files { get; } = null!;
public bool DeletePending { get; set; }
public static SkinInfo Default { get; } = new SkinInfo
public bool Equals(SkinInfo? other)
{
ID = DEFAULT_SKIN,
Name = "osu! (triangles)",
Creator = "team osu!",
InstantiationInfo = typeof(DefaultSkin).GetInvariantInstantiationInfo()
};
if (ReferenceEquals(this, other)) return true;
if (other == null) return false;
public bool Equals(SkinInfo other) => other != null && ID == other.ID;
return ID == other.ID;
}
public override string ToString()
{
string author = Creator == null ? string.Empty : $"({Creator})";
string author = string.IsNullOrEmpty(Creator) ? string.Empty : $"({Creator})";
return $"{Name} {author}".Trim();
}
IEnumerable<INamedFileUsage> IHasNamedFiles.Files => Files;
}
}

View File

@ -3,14 +3,11 @@
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 JetBrains.Annotations;
using osu.Framework.Audio;
using osu.Framework.Audio.Sample;
using osu.Framework.Bindables;
@ -18,15 +15,15 @@ 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.Threading;
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,22 +35,27 @@ namespace osu.Game.Skinning
/// For gameplay components, see <see cref="RulesetSkinProvidingContainer"/> which adds extra legacy and toggle logic that may affect the lookup process.
/// </remarks>
[ExcludeFromDynamicCompile]
public class SkinManager : ArchiveModelManager<SkinInfo, SkinFileInfo>, ISkinSource, IStorageResourceProvider
public class SkinManager : ISkinSource, IStorageResourceProvider, IModelImporter<SkinInfo>
{
private readonly AudioManager audio;
private readonly Scheduler scheduler;
private readonly GameHost host;
private readonly IResourceStore<byte[]> resources;
public readonly Bindable<Skin> CurrentSkin = new Bindable<Skin>();
public readonly Bindable<SkinInfo> CurrentSkinInfo = new Bindable<SkinInfo>(SkinInfo.Default) { Default = SkinInfo.Default };
public override IEnumerable<string> HandledExtensions => new[] { ".osk" };
public readonly Bindable<ILive<SkinInfo>> CurrentSkinInfo = new Bindable<ILive<SkinInfo>>(Skinning.DefaultSkin.CreateInfo().ToLive())
{
Default = Skinning.DefaultSkin.CreateInfo().ToLive()
};
protected override string[] HashableFileTypes => new[] { ".ini", ".json" };
private readonly SkinModelManager skinModelManager;
private readonly RealmContextFactory contextFactory;
protected override string ImportFromStablePath => "Skins";
private readonly IResourceStore<byte[]> userFiles;
/// <summary>
/// The default skin.
@ -65,218 +67,66 @@ namespace osu.Game.Skinning
/// </summary>
public Skin DefaultLegacySkin { get; }
public SkinManager(Storage storage, DatabaseContextFactory contextFactory, GameHost host, IResourceStore<byte[]> resources, AudioManager audio)
: base(storage, contextFactory, new SkinStore(contextFactory, storage), host)
public SkinManager(Storage storage, RealmContextFactory contextFactory, GameHost host, IResourceStore<byte[]> resources, AudioManager audio, Scheduler scheduler)
{
this.contextFactory = contextFactory;
this.audio = audio;
this.scheduler = scheduler;
this.host = host;
this.resources = resources;
DefaultLegacySkin = new DefaultLegacySkin(this);
DefaultSkin = new DefaultSkin(this);
userFiles = new StorageBackedResourceStore(storage.GetStorageForDirectory("files"));
CurrentSkinInfo.ValueChanged += skin => CurrentSkin.Value = GetSkin(skin.NewValue);
skinModelManager = new SkinModelManager(storage, contextFactory, host, this);
var defaultSkins = new[]
{
DefaultLegacySkin = new DefaultLegacySkin(this),
DefaultSkin = new DefaultSkin(this),
};
// Ensure the default entries are present.
using (var context = contextFactory.CreateContext())
using (var transaction = context.BeginWrite())
{
foreach (var skin in defaultSkins)
{
if (context.Find<SkinInfo>(skin.SkinInfo.ID) == null)
context.Add(skin.SkinInfo.Value);
}
transaction.Commit();
}
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();
};
// 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";
/// <summary>
/// Returns a list of all usable <see cref="SkinInfo"/>s. Includes the special default skin plus all skins from <see cref="GetAllUserSkins"/>.
/// </summary>
/// <returns>A newly allocated list of available <see cref="SkinInfo"/>.</returns>
public List<SkinInfo> GetAllUsableSkins()
{
var userSkins = GetAllUserSkins();
userSkins.Insert(0, DefaultSkin.SkinInfo);
userSkins.Insert(1, DefaultLegacySkin.SkinInfo);
return userSkins;
}
/// <summary>
/// Returns a list of all usable <see cref="SkinInfo"/>s that have been loaded by the user.
/// </summary>
/// <returns>A newly allocated list of available <see cref="SkinInfo"/>.</returns>
public List<SkinInfo> GetAllUserSkins(bool includeFiles = false)
{
if (includeFiles)
return ModelStore.ConsumableItems.Where(s => !s.DeletePending).ToList();
return ModelStore.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();
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<SkinInfo>().Where(s => !s.DeletePending && s.ID != CurrentSkinInfo.Value.ID).ToArray();
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))
if (randomChoices.Length == 0)
{
using (var existingStream = Files.Storage.GetStream(existingFile.FileInfo.StoragePath))
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);
CurrentSkinInfo.Value = Skinning.DefaultSkin.CreateInfo().ToLive();
return;
}
ReplaceFile(item, existingFile, stream);
var chosen = randomChoices.ElementAt(RNG.Next(0, randomChoices.Length));
// 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();
}
CurrentSkinInfo.Value = chosen.ToLive();
}
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;
}
/// <summary>
@ -292,17 +142,28 @@ namespace osu.Game.Skinning
/// </summary>
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 = Import(new SkinInfo
CurrentSkinInfo.Value.PerformRead(s =>
{
Name = skin.SkinInfo.Name + @" (modified)",
Creator = skin.SkinInfo.Creator,
InstantiationInfo = skin.SkinInfo.InstantiationInfo,
}).Result.Value;
if (!s.Protected)
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)
{
// save once to ensure the required json content is populated.
// currently this only happens on save.
result.PerformRead(skin => Save(skin.CreateInstance(this)));
CurrentSkinInfo.Value = result;
}
});
}
public void Save(Skin skin)
@ -310,22 +171,7 @@ namespace osu.Game.Skinning
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)
ReplaceFile(skin.SkinInfo, oldFile, streamContent, oldFile.Filename);
else
AddFile(skin.SkinInfo, streamContent, filename);
}
}
skinModelManager.Save(skin);
}
/// <summary>
@ -333,7 +179,11 @@ namespace osu.Game.Skinning
/// </summary>
/// <param name="query">The query.</param>
/// <returns>The first result for the provided query, or null if no results were found.</returns>
public SkinInfo Query(Expression<Func<SkinInfo, bool>> query) => ModelStore.ConsumableItems.AsNoTracking().FirstOrDefault(query);
public ILive<SkinInfo> Query(Expression<Func<SkinInfo, bool>> query)
{
using (var context = contextFactory.CreateContext())
return context.All<SkinInfo>().FirstOrDefault(query)?.ToLive();
}
public event Action SourceChanged;
@ -386,9 +236,78 @@ namespace osu.Game.Skinning
AudioManager IStorageResourceProvider.AudioManager => audio;
IResourceStore<byte[]> IStorageResourceProvider.Resources => resources;
IResourceStore<byte[]> IStorageResourceProvider.Files => Files.Store;
IResourceStore<byte[]> IStorageResourceProvider.Files => userFiles;
IResourceStore<TextureUpload> IStorageResourceProvider.CreateTextureLoaderStore(IResourceStore<byte[]> underlyingStore) => host.CreateTextureLoaderStore(underlyingStore);
#endregion
#region Implementation of IModelImporter<SkinInfo>
public Action<Notification> PostNotification
{
set => skinModelManager.PostNotification = value;
}
public Action<IEnumerable<ILive<SkinInfo>>> 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<string> HandledExtensions => skinModelManager.HandledExtensions;
public Task<IEnumerable<ILive<SkinInfo>>> Import(ProgressNotification notification, params ImportTask[] tasks)
{
return skinModelManager.Import(notification, tasks);
}
public Task<ILive<SkinInfo>> Import(ImportTask task, bool lowPriority = false, CancellationToken cancellationToken = default)
{
return skinModelManager.Import(task, lowPriority, cancellationToken);
}
public Task<ILive<SkinInfo>> Import(ArchiveReader archive, bool lowPriority = false, CancellationToken cancellationToken = default)
{
return skinModelManager.Import(archive, lowPriority, cancellationToken);
}
public Task<ILive<SkinInfo>> Import(SkinInfo item, ArchiveReader archive = null, bool lowPriority = false, CancellationToken cancellationToken = default)
{
return skinModelManager.Import(item, archive, lowPriority, cancellationToken);
}
#endregion
#region Implementation of IModelManager<SkinInfo>
public void Delete([CanBeNull] Expression<Func<SkinInfo, bool>> filter = null, bool silent = false)
{
using (var context = contextFactory.CreateContext())
{
var items = context.All<SkinInfo>()
.Where(s => !s.Protected && !s.DeletePending);
if (filter != null)
items = items.Where(filter);
// check the removed skin is not the current user choice. if it is, switch back to default.
Guid currentUserSkin = CurrentSkinInfo.Value.ID;
if (items.Any(s => s.ID == currentUserSkin))
scheduler.Add(() => CurrentSkinInfo.Value = Skinning.DefaultSkin.CreateInfo().ToLive());
skinModelManager.Delete(items.ToList(), silent);
}
}
#endregion
}
}

View File

@ -0,0 +1,266 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. 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 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 : RealmArchiveModelManager<SkinInfo>
{
private const string skin_info_file = "skininfo.json";
private readonly IStorageResourceProvider skinResources;
public SkinModelManager(Storage storage, RealmContextFactory contextFactory, GameHost host, IStorageResourceProvider skinResources)
: base(storage, contextFactory)
{
this.skinResources = skinResources;
// can be removed 20220420.
populateMissingHashes();
}
public override IEnumerable<string> HandledExtensions => new[] { ".osk" };
protected override string[] HashableFileTypes => new[] { ".ini", ".json" };
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 Task Populate(SkinInfo model, ArchiveReader? archive, Realm realm, CancellationToken cancellationToken = default)
{
var skinInfoFile = model.Files.SingleOrDefault(f => f.Filename == skin_info_file);
if (skinInfoFile != null)
{
try
{
using (var existingStream = Files.Storage.GetStream(skinInfoFile.File.GetStoragePath()))
using (var reader = new StreamReader(existingStream))
{
var deserialisedSkinInfo = JsonConvert.DeserializeObject<SkinInfo>(reader.ReadToEnd());
if (deserialisedSkinInfo != null)
{
// for now we only care about the instantiation info.
// eventually we probably want to transfer everything across.
model.InstantiationInfo = deserialisedSkinInfo.InstantiationInfo;
}
}
}
catch (Exception e)
{
LogForModel(model, $"Error during {skin_info_file} parsing, falling back to default", e);
// Not sure if we should still run the import in the case of failure here, but let's do so for now.
model.InstantiationInfo = string.Empty;
}
}
// Always rewrite instantiation info (even after parsing in from the skin json) for sanity.
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.IsManaged;
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, realm);
}
private void updateSkinIniMetadata(SkinInfo item, Realm realm)
{
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();
}
else
{
using (Stream stream = new MemoryStream())
{
using (var sw = new StreamWriter(stream, Encoding.UTF8, 1024, true))
{
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);
}
ReplaceFile(existingFile, stream, realm);
// 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);
var existingIni = item.Files.SingleOrDefault(f => f.Filename.Equals(@"skin.ini", StringComparison.OrdinalIgnoreCase));
if (existingIni != null)
item.Files.Remove(existingIni);
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())
{
using (var sw = new StreamWriter(stream, Encoding.UTF8, 1024, true))
{
foreach (string line in newLines)
sw.WriteLine(line);
}
AddFile(item, stream, @"skin.ini", realm);
}
item.Hash = ComputeHash(item);
}
}
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;
}
private void populateMissingHashes()
{
using (var realm = ContextFactory.CreateContext())
{
var skinsWithoutHashes = realm.All<SkinInfo>().Where(i => string.IsNullOrEmpty(i.Hash)).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);
public void Save(Skin skin)
{
skin.SkinInfo.PerformWrite(s =>
{
// Serialise out the SkinInfo itself.
string skinInfoJson = JsonConvert.SerializeObject(s, new JsonSerializerSettings { Formatting = Formatting.Indented });
using (var streamContent = new MemoryStream(Encoding.UTF8.GetBytes(skinInfoJson)))
{
AddFile(s, streamContent, skin_info_file, s.Realm);
}
// Then serialise each of the drawable component groups into respective files.
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(oldFile, streamContent, s.Realm);
else
AddFile(s, streamContent, filename, s.Realm);
}
}
s.Hash = ComputeHash(s);
});
}
}
}

View File

@ -6,7 +6,7 @@ using osu.Game.Database;
namespace osu.Game.Skinning
{
public class SkinStore : MutableDatabaseBackedStoreWithFileIncludes<SkinInfo, SkinFileInfo>
public class SkinStore : MutableDatabaseBackedStoreWithFileIncludes<EFSkinInfo, SkinFileInfo>
{
public SkinStore(DatabaseContextFactory contextFactory, Storage storage = null)
: base(contextFactory, storage)

View File

@ -7,7 +7,6 @@ using System.Linq;
using JetBrains.Annotations;
using osu.Framework.Allocation;
using osu.Framework.Audio;
using osu.Framework.Audio.Sample;
using osu.Framework.Bindables;
using osu.Framework.Extensions.IEnumerableExtensions;
using osu.Framework.Graphics;
@ -43,9 +42,6 @@ namespace osu.Game.Skinning
private readonly AudioContainer<PoolableSkinnableSample> samplesContainer;
[Resolved]
private ISampleStore sampleStore { get; set; }
[Resolved(CanBeNull = true)]
private IPooledSampleProvider samplePool { get; set; }