// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.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 { private const string skin_info_file = "skininfo.json"; private readonly IStorageResourceProvider skinResources; private readonly ModelManager modelManager; public SkinImporter(Storage storage, RealmAccess realm, IStorageResourceProvider skinResources) : base(storage, realm) { this.skinResources = skinResources; modelManager = new ModelManager(storage, realm); } public override IEnumerable 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"; /// /// Update an existing skin with the contents of a path /// /// The progress notification /// The to update the with /// The to update /// public override async Task?> ImportAsUpdate(ProgressNotification notification, ImportTask task, SkinInfo original) { return await Realm.WriteAsync?>(r => { var skinInfo = r.Find(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(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 newLines = new List { @"// 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); /// /// Save a skin, serialising any changes to skin layouts to relevant JSON structures. /// /// Whether any change actually occurred. 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; } } }