mirror of
https://github.com/osukey/osukey.git
synced 2025-08-05 15:44:04 +09:00
Merge branch 'master' into primary-key-consistency
This commit is contained in:
@ -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()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
{
|
||||
}
|
||||
|
||||
|
63
osu.Game/Skinning/EFSkinInfo.cs
Normal file
63
osu.Game/Skinning/EFSkinInfo.cs
Normal 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;
|
||||
}
|
||||
}
|
@ -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;
|
||||
|
@ -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,
|
||||
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
43
osu.Game/Skinning/LegacyDatabasedSkinResourceStore.cs
Normal file
43
osu.Game/Skinning/LegacyDatabasedSkinResourceStore.cs
Normal 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;
|
||||
}
|
||||
}
|
@ -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")
|
||||
{
|
||||
}
|
||||
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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"/>.
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
|
266
osu.Game/Skinning/SkinModelManager.cs
Normal file
266
osu.Game/Skinning/SkinModelManager.cs
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
@ -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)
|
||||
|
@ -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; }
|
||||
|
||||
|
Reference in New Issue
Block a user