1
0
mirror of https://github.com/ppy/osu.git synced 2026-05-17 18:13:18 +08:00
Files
osu-lazer/osu.Game/Skinning/SkinImporter.cs
T
Bartłomiej Dach db93c2ad6f Write new name to skin.ini when renaming skin via settings
Reported at https://osu.ppy.sh/comments/3681620, with appropriate levels
of rage bait (DID ANYONE TEST THIS?!?!?!?!?!?!?!?!?!111!!)

Reasoning for this is that without this, users' skin names can be
dropped after an external edit because they're never persisted anywhere
outside of realm.

The only other choice I see is to stop re-populating skin metadata from
the `.ini` upon completing an external edit, which is very doable but
seems worse than this. Dunno.
2025-07-11 15:18:20 +02:00

284 lines
12 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.Platform;
using osu.Game.Beatmaps;
using osu.Game.Database;
using osu.Game.Extensions;
using osu.Game.IO;
using osu.Game.IO.Archives;
using osu.Game.Overlays.Notifications;
using Realms;
namespace osu.Game.Skinning
{
public class SkinImporter : RealmArchiveModelImporter<SkinInfo>
{
private const string skin_info_file = "skininfo.json";
private readonly IStorageResourceProvider skinResources;
private readonly ModelManager<SkinInfo> modelManager;
public SkinImporter(Storage storage, RealmAccess realm, IStorageResourceProvider skinResources)
: base(storage, realm)
{
this.skinResources = skinResources;
modelManager = new ModelManager<SkinInfo>(storage, realm);
}
public override IEnumerable<string> HandledExtensions => new[] { ".osk" };
protected override string[] HashableFileTypes => new[] { ".ini", ".json" };
protected override bool ShouldDeleteArchive(string path) => string.Equals(Path.GetExtension(path), @".osk", StringComparison.OrdinalIgnoreCase);
protected override SkinInfo CreateModel(ArchiveReader archive, ImportParameters parameters) => new SkinInfo { Name = archive.Name ?? @"No name" };
private const string unknown_creator_string = @"Unknown";
/// <summary>
/// Update an existing skin with the contents of a path
/// </summary>
/// <param name="notification">The progress notification</param>
/// <param name="task">The <see cref="ImportTask"/> to update the <paramref name="original"/> with</param>
/// <param name="original">The <see cref="SkinInfo"/> to update</param>
/// <returns></returns>
public override async Task<Live<SkinInfo>?> ImportAsUpdate(ProgressNotification notification, ImportTask task, SkinInfo original)
{
return await Realm.WriteAsync<Live<SkinInfo>?>(r =>
{
var skinInfo = r.Find<SkinInfo>(original.ID)!;
skinInfo.Files.Clear();
string[] filesInMountedDirectory = Directory.EnumerateFiles(task.Path, "*.*", SearchOption.AllDirectories).Select(f => Path.GetRelativePath(task.Path, f)).ToArray();
foreach (string file in filesInMountedDirectory)
{
using var stream = File.OpenRead(Path.Combine(task.Path, file));
modelManager.AddFile(skinInfo, stream, file, r);
}
string skinIniPath = Path.Combine(task.Path, "skin.ini");
if (File.Exists(skinIniPath))
{
using (var stream = File.OpenRead(skinIniPath))
using (var lineReader = new LineBufferedReader(stream))
{
var decodedSkinIni = new LegacySkinDecoder().Decode(lineReader);
if (!string.IsNullOrEmpty(decodedSkinIni.SkinInfo.Name))
skinInfo.Name = decodedSkinIni.SkinInfo.Name;
if (!string.IsNullOrEmpty(decodedSkinIni.SkinInfo.Creator))
skinInfo.Creator = decodedSkinIni.SkinInfo.Creator;
}
}
return skinInfo.ToLive(Realm);
}).ConfigureAwait(false);
}
protected override void Populate(SkinInfo model, ArchiveReader? archive, Realm realm, CancellationToken cancellationToken = default)
{
var skinInfoFile = model.GetFile(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);
}
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
// lazer exports use this format
// GetValidFilename accounts for skins with non-ASCII characters in the name that have been exported by lazer.
&& archiveName != item.GetDisplayString().GetValidFilename())
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);
}
public void UpdateSkinIniMetadata(SkinInfo item, Realm realm)
{
string nameLine = @$"Name: {item.Name}";
string authorLine = @$"Author: {item.Creator}";
List<string> newLines = new List<string>
{
@"// The following content was automatically added by osu! in order to use metadata that more closely matches user expectations.",
@"[General]",
nameLine,
authorLine,
};
var existingFile = item.GetFile(@"skin.ini");
if (existingFile == null)
{
// skins without a skin.ini are supposed to import using the "latest version" spec.
// see https://github.com/peppy/osu-stable-reference/blob/1531237b63392e82c003c712faa028406073aa8f/osu!/Graphics/Skinning/SkinManager.cs#L297-L298
newLines.Add(FormattableString.Invariant($"Version: {SkinConfiguration.LATEST_VERSION}"));
// 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);
}
modelManager.ReplaceFile(existingFile, stream, realm);
}
}
// 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);
}
modelManager.AddFile(item, stream, @"skin.ini", realm);
}
item.Hash = ComputeHash(item);
}
}
private Skin createInstance(SkinInfo item) => item.CreateInstance(skinResources);
/// <summary>
/// Save a skin, serialising any changes to skin layouts to relevant JSON structures.
/// </summary>
/// <returns>Whether any change actually occurred.</returns>
public bool Save(Skin skin)
{
bool hadChanges = false;
skin.SkinInfo.PerformWrite(s =>
{
// Update for safety
s.InstantiationInfo = skin.GetType().GetInvariantInstantiationInfo();
// Serialise out the SkinInfo itself.
string skinInfoJson = JsonConvert.SerializeObject(s, new JsonSerializerSettings { Formatting = Formatting.Indented });
using (var streamContent = new MemoryStream(Encoding.UTF8.GetBytes(skinInfoJson)))
{
modelManager.AddFile(s, streamContent, skin_info_file, s.Realm!);
}
// Then serialise each of the drawable component groups into respective files.
foreach (var drawableInfo in skin.LayoutInfos)
{
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.GetFile(filename);
if (oldFile != null)
modelManager.ReplaceFile(oldFile, streamContent, s.Realm!);
else
modelManager.AddFile(s, streamContent, filename, s.Realm!);
}
}
string newHash = ComputeHash(s);
hadChanges = newHash != s.Hash;
s.Hash = newHash;
});
return hadChanges;
}
}
}