2021-11-25 14:14:43 +08:00
// 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 ;
2021-11-29 17:07:32 +08:00
using Newtonsoft.Json ;
2021-11-25 14:14:43 +08:00
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 ;
2021-11-29 17:07:32 +08:00
using osu.Game.Stores ;
using Realms ;
#nullable enable
2021-11-25 14:14:43 +08:00
namespace osu.Game.Skinning
{
2021-11-29 17:07:32 +08:00
public class SkinModelManager : RealmArchiveModelManager < SkinInfo >
2021-11-25 14:14:43 +08:00
{
2021-12-02 16:43:54 +08:00
private const string skin_info_file = "skininfo.json" ;
2021-11-25 14:14:43 +08:00
private readonly IStorageResourceProvider skinResources ;
2021-11-29 17:07:32 +08:00
public SkinModelManager ( Storage storage , RealmContextFactory contextFactory , GameHost host , IStorageResourceProvider skinResources )
: base ( storage , contextFactory )
2021-11-25 14:14:43 +08:00
{
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 ;
2021-11-29 17:07:32 +08:00
protected override Task Populate ( SkinInfo model , ArchiveReader ? archive , Realm realm , CancellationToken cancellationToken = default )
{
2021-12-02 16:43:54 +08:00
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 ( ) ;
2021-11-29 17:07:32 +08:00
checkSkinIniMetadata ( model , realm ) ;
return Task . CompletedTask ;
}
private void checkSkinIniMetadata ( SkinInfo item , Realm realm )
2021-11-25 14:14:43 +08:00
{
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 ) ;
2021-11-29 17:07:32 +08:00
bool isImport = ! item . IsManaged ;
2021-11-25 14:14:43 +08:00
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 )
2021-11-29 17:07:32 +08:00
updateSkinIniMetadata ( item , realm ) ;
2021-11-25 14:14:43 +08:00
}
2021-11-29 17:07:32 +08:00
private void updateSkinIniMetadata ( SkinInfo item , Realm realm )
2021-11-25 14:14:43 +08:00
{
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 ( ) ;
}
2021-11-29 17:07:32 +08:00
else
2021-11-25 14:14:43 +08:00
{
2021-11-29 17:07:32 +08:00
using ( Stream stream = new MemoryStream ( ) )
2021-11-25 14:14:43 +08:00
{
2021-11-29 17:07:32 +08:00
using ( var sw = new StreamWriter ( stream , Encoding . UTF8 , 1024 , true ) )
2021-11-25 14:14:43 +08:00
{
2021-11-29 17:07:32 +08:00
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 )
2021-11-25 14:14:43 +08:00
sw . WriteLine ( line ) ;
}
2021-12-02 16:19:53 +08:00
ReplaceFile ( existingFile , stream , realm ) ;
2021-11-25 14:14:43 +08:00
2021-11-29 17:07:32 +08:00
// 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 ) ;
2021-11-25 14:14:43 +08:00
2021-11-29 17:07:32 +08:00
var existingIni = item . Files . SingleOrDefault ( f = > f . Filename . Equals ( @"skin.ini" , StringComparison . OrdinalIgnoreCase ) ) ;
if ( existingIni ! = null )
item . Files . Remove ( existingIni ) ;
2021-11-25 14:14:43 +08:00
2021-11-29 17:07:32 +08:00
writeNewSkinIni ( ) ;
}
2021-11-25 14:14:43 +08:00
}
}
2021-11-29 17:07:32 +08:00
// The hash is already populated at this point in import.
// As we have changed files, it needs to be recomputed.
item . Hash = ComputeHash ( item ) ;
2021-11-25 14:14:43 +08:00
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 ) ;
}
2021-11-29 17:07:32 +08:00
AddFile ( item , stream , @"skin.ini" , realm ) ;
2021-11-25 14:14:43 +08:00
}
2021-11-29 17:07:32 +08:00
item . Hash = ComputeHash ( item ) ;
2021-11-25 14:14:43 +08:00
}
}
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 ( )
{
2021-11-29 17:07:32 +08:00
using ( var realm = ContextFactory . CreateContext ( ) )
2021-11-25 14:14:43 +08:00
{
2021-11-29 17:07:32 +08:00
var skinsWithoutHashes = realm . All < SkinInfo > ( ) . Where ( i = > string . IsNullOrEmpty ( i . Hash ) ) . ToArray ( ) ;
foreach ( SkinInfo skin in skinsWithoutHashes )
2021-11-25 14:14:43 +08:00
{
2021-11-29 17:07:32 +08:00
try
{
Update ( skin ) ;
}
catch ( Exception e )
{
Delete ( skin ) ;
Logger . Error ( e , $"Existing skin {skin} has been deleted during hash recomputation due to being invalid" ) ;
}
2021-11-25 14:14:43 +08:00
}
}
}
private Skin createInstance ( SkinInfo item ) = > item . CreateInstance ( skinResources ) ;
2021-11-29 17:07:32 +08:00
public void Save ( Skin skin )
{
skin . SkinInfo . PerformWrite ( s = >
{
2021-12-02 16:43:54 +08:00
// 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.
2021-11-29 17:07:32 +08:00
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 )
2021-12-02 16:19:53 +08:00
ReplaceFile ( oldFile , streamContent , s . Realm ) ;
2021-11-29 17:07:32 +08:00
else
AddFile ( s , streamContent , filename , s . Realm ) ;
}
}
s . Hash = ComputeHash ( s ) ;
} ) ;
}
2022-01-10 12:59:46 +08:00
public override bool IsAvailableLocally ( SkinInfo model ) = > false ;
2021-11-25 14:14:43 +08:00
}
}