mirror of
https://github.com/ppy/osu.git
synced 2024-11-18 18:12:59 +08:00
cdf2fa9930
Until now, skins were always imported using the `LegacySkin` instantiation type. For cases where a user has edited the lazer or classic default (via the new skin editor), which would result in incorrect fallback paths after exporting and importing the edited skin.
267 lines
11 KiB
C#
267 lines
11 KiB
C#
// 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);
|
|
});
|
|
}
|
|
}
|
|
}
|