1
0
mirror of https://github.com/ppy/osu.git synced 2025-01-14 03:25:11 +08:00

Merge pull request #15859 from peppy/realm-integration/skins-rebase

Use realm for skins
This commit is contained in:
Dean Herbert 2021-12-06 17:12:23 +09:00 committed by GitHub
commit cf34b3f70e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
34 changed files with 896 additions and 417 deletions

View File

@ -174,7 +174,7 @@ namespace osu.Game.Rulesets.Catch.Tests
private Drawable setupSkinHierarchy(Drawable child, ISkin skin)
{
var legacySkinProvider = new SkinProvidingContainer(skins.GetSkin(DefaultLegacySkin.Info));
var legacySkinProvider = new SkinProvidingContainer(skins.GetSkin(DefaultLegacySkin.CreateInfo()));
var testSkinProvider = new SkinProvidingContainer(skin);
var legacySkinTransformer = new SkinProvidingContainer(new CatchLegacySkinTransformer(testSkinProvider));

View File

@ -8,6 +8,7 @@ using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Testing;
using osu.Game.Database;
using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Mania.Beatmaps;
using osu.Game.Screens.Edit;
@ -55,13 +56,13 @@ namespace osu.Game.Rulesets.Mania.Tests.Editor
[Test]
public void TestDefaultSkin()
{
AddStep("set default skin", () => skins.CurrentSkinInfo.Value = SkinInfo.Default);
AddStep("set default skin", () => skins.CurrentSkinInfo.Value = DefaultSkin.CreateInfo().ToLive());
}
[Test]
public void TestLegacySkin()
{
AddStep("set legacy skin", () => skins.CurrentSkinInfo.Value = DefaultLegacySkin.Info);
AddStep("set legacy skin", () => skins.CurrentSkinInfo.Value = DefaultLegacySkin.CreateInfo().ToLive());
}
}
}

View File

@ -67,7 +67,7 @@ namespace osu.Game.Rulesets.Osu.Tests
AddStep("create slider", () =>
{
var tintingSkin = skinManager.GetSkin(DefaultLegacySkin.Info);
var tintingSkin = skinManager.GetSkin(DefaultLegacySkin.CreateInfo());
tintingSkin.Configuration.ConfigDictionary["AllowSliderBallTint"] = "1";
Child = new SkinProvidingContainer(tintingSkin)

View File

@ -167,7 +167,7 @@ namespace osu.Game.Tests.Gameplay
private class TestSkin : LegacySkin
{
public TestSkin(string resourceName, IStorageResourceProvider resources)
: base(DefaultLegacySkin.Info, new TestResourceStore(resourceName), resources, "skin.ini")
: base(DefaultLegacySkin.CreateInfo(), new TestResourceStore(resourceName), resources, "skin.ini")
{
}
}

View File

@ -9,6 +9,7 @@ using System.Threading.Tasks;
using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Platform;
using osu.Game.Database;
using osu.Game.IO;
using osu.Game.IO.Archives;
using osu.Game.Skinning;
@ -165,30 +166,39 @@ namespace osu.Game.Tests.Skins.IO
#endregion
private void assertCorrectMetadata(SkinInfo import1, string name, string creator, OsuGameBase osu)
private void assertCorrectMetadata(ILive<SkinInfo> import1, string name, string creator, OsuGameBase osu)
{
Assert.That(import1.Name, Is.EqualTo(name));
Assert.That(import1.Creator, Is.EqualTo(creator));
import1.PerformRead(i =>
{
Assert.That(i.Name, Is.EqualTo(name));
Assert.That(i.Creator, Is.EqualTo(creator));
// for extra safety let's reconstruct the skin, reading from the skin.ini.
var instance = import1.CreateInstance((IStorageResourceProvider)osu.Dependencies.Get(typeof(SkinManager)));
// for extra safety let's reconstruct the skin, reading from the skin.ini.
var instance = i.CreateInstance((IStorageResourceProvider)osu.Dependencies.Get(typeof(SkinManager)));
Assert.That(instance.Configuration.SkinInfo.Name, Is.EqualTo(name));
Assert.That(instance.Configuration.SkinInfo.Creator, Is.EqualTo(creator));
Assert.That(instance.Configuration.SkinInfo.Name, Is.EqualTo(name));
Assert.That(instance.Configuration.SkinInfo.Creator, Is.EqualTo(creator));
});
}
private void assertImportedBoth(SkinInfo import1, SkinInfo import2)
private void assertImportedBoth(ILive<SkinInfo> import1, ILive<SkinInfo> import2)
{
Assert.That(import2.ID, Is.Not.EqualTo(import1.ID));
Assert.That(import2.Hash, Is.Not.EqualTo(import1.Hash));
Assert.That(import2.Files.Select(f => f.FileInfoID), Is.Not.EquivalentTo(import1.Files.Select(f => f.FileInfoID)));
import1.PerformRead(i1 => import2.PerformRead(i2 =>
{
Assert.That(i2.ID, Is.Not.EqualTo(i1.ID));
Assert.That(i2.Hash, Is.Not.EqualTo(i1.Hash));
Assert.That(i2.Files.First(), Is.Not.EqualTo(i1.Files.First()));
}));
}
private void assertImportedOnce(SkinInfo import1, SkinInfo import2)
private void assertImportedOnce(ILive<SkinInfo> import1, ILive<SkinInfo> import2)
{
Assert.That(import2.ID, Is.EqualTo(import1.ID));
Assert.That(import2.Hash, Is.EqualTo(import1.Hash));
Assert.That(import2.Files.Select(f => f.FileInfoID), Is.EquivalentTo(import1.Files.Select(f => f.FileInfoID)));
import1.PerformRead(i1 => import2.PerformRead(i2 =>
{
Assert.That(i2.ID, Is.EqualTo(i1.ID));
Assert.That(i2.Hash, Is.EqualTo(i1.Hash));
Assert.That(i2.Files.First(), Is.EqualTo(i1.Files.First()));
}));
}
private MemoryStream createEmptyOsk()
@ -255,10 +265,10 @@ namespace osu.Game.Tests.Skins.IO
}
}
private async Task<SkinInfo> loadSkinIntoOsu(OsuGameBase osu, ArchiveReader archive = null)
private async Task<ILive<SkinInfo>> loadSkinIntoOsu(OsuGameBase osu, ArchiveReader archive = null)
{
var skinManager = osu.Dependencies.Get<SkinManager>();
return (await skinManager.Import(archive)).Value;
return await skinManager.Import(archive);
}
}
}

View File

@ -24,7 +24,7 @@ namespace osu.Game.Tests.Skins
private void load()
{
var imported = skins.Import(new ZipArchiveReader(TestResources.OpenResource("Archives/ogg-skin.osk"))).Result;
skin = skins.GetSkin(imported.Value);
skin = imported.PerformRead(skinInfo => skins.GetSkin(skinInfo));
}
[Test]

View File

@ -8,6 +8,7 @@ using osu.Framework.Allocation;
using osu.Framework.Screens;
using osu.Framework.Testing;
using osu.Game.Configuration;
using osu.Game.Database;
using osu.Game.Graphics.Backgrounds;
using osu.Game.Online.API;
using osu.Game.Online.API.Requests.Responses;
@ -122,7 +123,7 @@ namespace osu.Game.Tests.Visual.Background
private void setCustomSkin()
{
// feign a skin switch. this doesn't do anything except force CurrentSkin to become a LegacySkin.
AddStep("set custom skin", () => skins.CurrentSkinInfo.Value = new SkinInfo { ID = 5 });
AddStep("set custom skin", () => skins.CurrentSkinInfo.Value = new SkinInfo().ToLive());
}
private void setDefaultSkin() => AddStep("set default skin", () => skins.CurrentSkinInfo.SetDefault());

View File

@ -12,6 +12,7 @@ using osu.Framework.Testing;
using osu.Framework.Timing;
using osu.Framework.Utils;
using osu.Game.Beatmaps;
using osu.Game.Database;
using osu.Game.Extensions;
using osu.Game.Rulesets;
using osu.Game.Rulesets.Osu;
@ -41,7 +42,7 @@ namespace osu.Game.Tests.Visual.Gameplay
[Test]
public void TestEmptyLegacyBeatmapSkinFallsBack()
{
CreateSkinTest(SkinInfo.Default, () => new LegacyBeatmapSkin(new BeatmapInfo(), null, null));
CreateSkinTest(DefaultSkin.CreateInfo(), () => new LegacyBeatmapSkin(new BeatmapInfo(), null, null));
AddUntilStep("wait for hud load", () => Player.ChildrenOfType<SkinnableTargetContainer>().All(c => c.ComponentsLoaded));
AddAssert("hud from default skin", () => AssertComponentsFromExpectedSource(SkinnableTarget.MainHUDComponents, skinManager.CurrentSkin.Value));
}
@ -52,7 +53,7 @@ namespace osu.Game.Tests.Visual.Gameplay
{
AddStep("setup skins", () =>
{
skinManager.CurrentSkinInfo.Value = gameCurrentSkin;
skinManager.CurrentSkinInfo.Value = gameCurrentSkin.ToLive();
currentBeatmapSkin = getBeatmapSkin();
});
});

View File

@ -0,0 +1,39 @@
// 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.Linq;
using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Testing;
using osu.Game.Overlays.Settings.Sections;
using osu.Game.Skinning;
using osu.Game.Skinning.Editor;
namespace osu.Game.Tests.Visual.Navigation
{
public class TestSceneEditDefaultSkin : OsuGameTestScene
{
private SkinManager skinManager => Game.Dependencies.Get<SkinManager>();
private SkinEditorOverlay skinEditor => Game.Dependencies.Get<SkinEditorOverlay>();
[Test]
public void TestEditDefaultSkin()
{
AddAssert("is default skin", () => skinManager.CurrentSkinInfo.Value.ID == SkinInfo.DEFAULT_SKIN);
AddStep("open settings", () => { Game.Settings.Show(); });
// Until step requires as settings has a delayed load.
AddUntilStep("export button disabled", () => Game.Settings.ChildrenOfType<SkinSection.ExportSkinButton>().SingleOrDefault()?.Enabled.Value == false);
// Will create a mutable skin.
AddStep("open skin editor", () => skinEditor.Show());
// Until step required as the skin editor may take time to load (and an extra scheduled frame for the mutable part).
AddUntilStep("is modified default skin", () => skinManager.CurrentSkinInfo.Value.ID != SkinInfo.DEFAULT_SKIN);
AddAssert("is not protected", () => skinManager.CurrentSkinInfo.Value.PerformRead(s => !s.Protected));
AddUntilStep("export button enabled", () => Game.Settings.ChildrenOfType<SkinSection.ExportSkinButton>().SingleOrDefault()?.Enabled.Value == true);
}
}
}

View File

@ -17,6 +17,7 @@ using osu.Game.Overlays;
using osu.Game.Rulesets.Scoring;
using osu.Game.Screens.Select;
using osu.Game.Screens.Select.Filter;
using osu.Game.Skinning;
namespace osu.Game.Configuration
{
@ -27,7 +28,7 @@ namespace osu.Game.Configuration
{
// UI/selection defaults
SetDefault(OsuSetting.Ruleset, string.Empty);
SetDefault(OsuSetting.Skin, 0, -1, int.MaxValue);
SetDefault(OsuSetting.Skin, SkinInfo.DEFAULT_SKIN.ToString());
SetDefault(OsuSetting.BeatmapDetailTab, PlayBeatmapDetailArea.TabType.Details);
SetDefault(OsuSetting.BeatmapDetailModsFilter, false);
@ -210,9 +211,12 @@ namespace osu.Game.Configuration
value: scalingMode.GetLocalisableDescription()
)
),
new TrackedSetting<int>(OsuSetting.Skin, skin =>
new TrackedSetting<string>(OsuSetting.Skin, skin =>
{
string skinName = LookupSkinName(skin) ?? string.Empty;
string skinName = string.Empty;
if (Guid.TryParse(skin, out var id))
skinName = LookupSkinName(id) ?? string.Empty;
return new SettingDescription(
rawValue: skinName,
@ -233,7 +237,7 @@ namespace osu.Game.Configuration
};
}
public Func<int, string> LookupSkinName { private get; set; }
public Func<Guid, string> LookupSkinName { private get; set; }
public Func<GlobalAction, LocalisableString> LookupKeyBindings { get; set; }
}

View File

@ -0,0 +1,148 @@
// 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.Linq;
using Microsoft.EntityFrameworkCore;
using osu.Game.Configuration;
using osu.Game.Models;
using osu.Game.Skinning;
#nullable enable
namespace osu.Game.Database
{
internal class EFToRealmMigrator
{
private readonly DatabaseContextFactory efContextFactory;
private readonly RealmContextFactory realmContextFactory;
private readonly OsuConfigManager config;
public EFToRealmMigrator(DatabaseContextFactory efContextFactory, RealmContextFactory realmContextFactory, OsuConfigManager config)
{
this.efContextFactory = efContextFactory;
this.realmContextFactory = realmContextFactory;
this.config = config;
}
public void Run()
{
using (var db = efContextFactory.GetForWrite())
{
migrateSettings(db);
migrateSkins(db);
}
}
private void migrateSkins(DatabaseWriteUsage db)
{
var userSkinChoice = config.GetBindable<string>(OsuSetting.Skin);
int.TryParse(userSkinChoice.Value, out int userSkinInt);
switch (userSkinInt)
{
case EFSkinInfo.DEFAULT_SKIN:
userSkinChoice.Value = SkinInfo.DEFAULT_SKIN.ToString();
break;
case EFSkinInfo.CLASSIC_SKIN:
userSkinChoice.Value = SkinInfo.CLASSIC_SKIN.ToString();
break;
}
// migrate ruleset settings. can be removed 20220530.
var existingSkins = db.Context.SkinInfo
.Include(s => s.Files)
.ThenInclude(f => f.FileInfo)
.ToList();
// previous entries in EF are removed post migration.
if (!existingSkins.Any())
return;
using (var realm = realmContextFactory.CreateContext())
using (var transaction = realm.BeginWrite())
{
// only migrate data if the realm database is empty.
// note that this cannot be written as: `realm.All<SkinInfo>().All(s => s.Protected)`, because realm does not support `.All()`.
if (!realm.All<SkinInfo>().Any(s => !s.Protected))
{
foreach (var skin in existingSkins)
{
var realmSkin = new SkinInfo
{
Name = skin.Name,
Creator = skin.Creator,
Hash = skin.Hash,
Protected = false,
InstantiationInfo = skin.InstantiationInfo,
};
foreach (var file in skin.Files)
{
var realmFile = realm.Find<RealmFile>(file.FileInfo.Hash);
if (realmFile == null)
realm.Add(realmFile = new RealmFile { Hash = file.FileInfo.Hash });
realmSkin.Files.Add(new RealmNamedFileUsage(realmFile, file.Filename));
}
realm.Add(realmSkin);
if (skin.ID == userSkinInt)
userSkinChoice.Value = realmSkin.ID.ToString();
}
}
db.Context.RemoveRange(existingSkins);
// Intentionally don't clean up the files, so they don't get purged by EF.
transaction.Commit();
}
}
private void migrateSettings(DatabaseWriteUsage db)
{
// migrate ruleset settings. can be removed 20220315.
var existingSettings = db.Context.DatabasedSetting;
// previous entries in EF are removed post migration.
if (!existingSettings.Any())
return;
using (var realm = realmContextFactory.CreateContext())
using (var transaction = realm.BeginWrite())
{
// only migrate data if the realm database is empty.
if (!realm.All<RealmRulesetSetting>().Any())
{
foreach (var dkb in existingSettings)
{
if (dkb.RulesetID == null)
continue;
string? shortName = getRulesetShortNameFromLegacyID(dkb.RulesetID.Value);
if (string.IsNullOrEmpty(shortName))
continue;
realm.Add(new RealmRulesetSetting
{
Key = dkb.Key,
Value = dkb.StringValue,
RulesetName = shortName,
Variant = dkb.Variant ?? 0,
});
}
}
db.Context.RemoveRange(existingSettings);
transaction.Commit();
}
}
private string? getRulesetShortNameFromLegacyID(long rulesetId) =>
efContextFactory.Get().RulesetInfo.FirstOrDefault(r => r.ID == rulesetId)?.ShortName;
}
}

View File

@ -25,7 +25,7 @@ namespace osu.Game.Database
public DbSet<BeatmapSetInfo> BeatmapSetInfo { get; set; }
public DbSet<FileInfo> FileInfo { get; set; }
public DbSet<RulesetInfo> RulesetInfo { get; set; }
public DbSet<SkinInfo> SkinInfo { get; set; }
public DbSet<EFSkinInfo> SkinInfo { get; set; }
public DbSet<ScoreInfo> ScoreInfo { get; set; }
// migrated to realm
@ -133,8 +133,9 @@ namespace osu.Game.Database
modelBuilder.Entity<BeatmapSetInfo>().HasIndex(b => b.DeletePending);
modelBuilder.Entity<BeatmapSetInfo>().HasIndex(b => b.Hash).IsUnique();
modelBuilder.Entity<SkinInfo>().HasIndex(b => b.Hash).IsUnique();
modelBuilder.Entity<SkinInfo>().HasIndex(b => b.DeletePending);
modelBuilder.Entity<EFSkinInfo>().HasIndex(b => b.Hash).IsUnique();
modelBuilder.Entity<EFSkinInfo>().HasIndex(b => b.DeletePending);
modelBuilder.Entity<EFSkinInfo>().HasMany(s => s.Files).WithOne(f => f.SkinInfo);
modelBuilder.Entity<DatabasedSetting>().HasIndex(b => new { b.RulesetID, b.Variant });

View File

@ -14,6 +14,7 @@ using osu.Framework.Statistics;
using osu.Game.Configuration;
using osu.Game.Input.Bindings;
using osu.Game.Models;
using osu.Game.Skinning;
using osu.Game.Stores;
using Realms;
@ -101,10 +102,6 @@ namespace osu.Game.Database
// This method triggers the first `CreateContext` call, which will implicitly run realm migrations and bring the schema up-to-date.
cleanupPendingDeletions();
// Data migration is handled separately from schema migrations.
// This is required as the user may be initialising realm for the first time ever, which would result in no schema migrations running.
migrateDataFromEF();
}
private void cleanupPendingDeletions()
@ -122,6 +119,11 @@ namespace osu.Game.Database
realm.Remove(s);
}
var pendingDeleteSkins = realm.All<SkinInfo>().Where(s => s.DeletePending);
foreach (var s in pendingDeleteSkins)
realm.Remove(s);
transaction.Commit();
}
@ -193,53 +195,6 @@ namespace osu.Game.Database
};
}
private void migrateDataFromEF()
{
if (efContextFactory == null)
return;
using (var db = efContextFactory.GetForWrite())
{
// migrate ruleset settings. can be removed 20220315.
var existingSettings = db.Context.DatabasedSetting;
// previous entries in EF are removed post migration.
if (!existingSettings.Any())
return;
using (var realm = CreateContext())
using (var transaction = realm.BeginWrite())
{
// only migrate data if the realm database is empty.
if (!realm.All<RealmRulesetSetting>().Any())
{
foreach (var dkb in existingSettings)
{
if (dkb.RulesetID == null)
continue;
string? shortName = getRulesetShortNameFromLegacyID(dkb.RulesetID.Value);
if (string.IsNullOrEmpty(shortName))
continue;
realm.Add(new RealmRulesetSetting
{
Key = dkb.Key,
Value = dkb.StringValue,
RulesetName = shortName,
Variant = dkb.Variant ?? 0,
});
}
}
db.Context.RemoveRange(existingSettings);
transaction.Commit();
}
}
}
private void onMigration(Migration migration, ulong lastSchemaVersion)
{
for (ulong i = lastSchemaVersion + 1; i <= schema_version; i++)

View File

@ -103,5 +103,7 @@ namespace osu.Game.Database
}
public bool Equals(ILive<T>? other) => ID == other?.ID;
public override string ToString() => PerformRead(i => i.ToString());
}
}

View File

@ -161,7 +161,7 @@ namespace osu.Game
private Bindable<float> uiScale;
private Bindable<int> configSkin;
private Bindable<string> configSkin;
private readonly string[] args;
@ -243,27 +243,22 @@ namespace osu.Game
Ruleset.ValueChanged += r => configRuleset.Value = r.NewValue.ShortName;
// bind config int to database SkinInfo
configSkin = LocalConfig.GetBindable<int>(OsuSetting.Skin);
SkinManager.CurrentSkinInfo.ValueChanged += skin => configSkin.Value = skin.NewValue.ID;
configSkin = LocalConfig.GetBindable<string>(OsuSetting.Skin);
SkinManager.CurrentSkinInfo.ValueChanged += skin => configSkin.Value = skin.NewValue.ID.ToString();
configSkin.ValueChanged += skinId =>
{
var skinInfo = SkinManager.Query(s => s.ID == skinId.NewValue);
ILive<SkinInfo> skinInfo = null;
if (Guid.TryParse(skinId.NewValue, out var guid))
skinInfo = SkinManager.Query(s => s.ID == guid);
if (skinInfo == null)
{
switch (skinId.NewValue)
{
case -1:
skinInfo = DefaultLegacySkin.Info;
break;
default:
skinInfo = SkinInfo.Default;
break;
}
if (guid == SkinInfo.CLASSIC_SKIN)
skinInfo = DefaultLegacySkin.CreateInfo().ToLive();
}
SkinManager.CurrentSkinInfo.Value = skinInfo;
SkinManager.CurrentSkinInfo.Value = skinInfo ?? DefaultSkin.CreateInfo().ToLive();
};
configSkin.TriggerChange();
@ -664,7 +659,7 @@ namespace osu.Game
// make config aware of how to lookup skins for on-screen display purposes.
// if this becomes a more common thing, tracked settings should be reconsidered to allow local DI.
LocalConfig.LookupSkinName = id => SkinManager.GetAllUsableSkins().FirstOrDefault(s => s.ID == id)?.ToString() ?? "Unknown";
LocalConfig.LookupSkinName = id => SkinManager.Query(s => s.ID == id)?.ToString() ?? "Unknown";
LocalConfig.LookupKeyBindings = l =>
{

View File

@ -200,6 +200,8 @@ namespace osu.Game
dependencies.Cache(realmFactory = new RealmContextFactory(Storage, "client", contextFactory));
new EFToRealmMigrator(contextFactory, realmFactory, LocalConfig).Run();
dependencies.CacheAs(Storage);
var largeStore = new LargeTextureStore(Host.CreateTextureLoaderStore(new NamespacedResourceStore<byte[]>(Resources, @"Textures")));
@ -213,17 +215,9 @@ namespace osu.Game
Audio.Samples.PlaybackConcurrency = SAMPLE_CONCURRENCY;
dependencies.Cache(SkinManager = new SkinManager(Storage, contextFactory, Host, Resources, Audio));
dependencies.Cache(SkinManager = new SkinManager(Storage, realmFactory, Host, Resources, Audio, Scheduler));
dependencies.CacheAs<ISkinSource>(SkinManager);
// needs to be done here rather than inside SkinManager to ensure thread safety of CurrentSkinInfo.
SkinManager.ItemRemoved += item => Schedule(() =>
{
// check the removed skin is not the current user choice. if it is, switch back to default.
if (item.Equals(SkinManager.CurrentSkinInfo.Value))
SkinManager.CurrentSkinInfo.Value = SkinInfo.Default;
});
EndpointConfiguration endpoints = UseDevelopmentServer ? (EndpointConfiguration)new DevelopmentEndpointConfiguration() : new ProductionEndpointConfiguration();
MessageFormatter.WebsiteRootUrl = endpoints.WebsiteRootUrl;

View File

@ -106,7 +106,10 @@ namespace osu.Game.Overlays.Settings.Sections.Maintenance
dialogOverlay?.Push(new MassDeleteConfirmationDialog(() =>
{
deleteSkinsButton.Enabled.Value = false;
Task.Run(() => skins.Delete(skins.GetAllUserSkins())).ContinueWith(t => Schedule(() => deleteSkinsButton.Enabled.Value = true));
Task.Run(() =>
{
skins.Delete();
}).ContinueWith(t => Schedule(() => deleteSkinsButton.Enabled.Value = true));
}));
}
});

View File

@ -32,32 +32,26 @@ namespace osu.Game.Overlays.Settings.Sections
Icon = FontAwesome.Solid.PaintBrush
};
private readonly Bindable<SkinInfo> dropdownBindable = new Bindable<SkinInfo> { Default = SkinInfo.Default };
private readonly Bindable<int> configBindable = new Bindable<int>();
private readonly Bindable<ILive<SkinInfo>> dropdownBindable = new Bindable<ILive<SkinInfo>> { Default = DefaultSkin.CreateInfo().ToLive() };
private readonly Bindable<string> configBindable = new Bindable<string>();
private static readonly SkinInfo random_skin_info = new SkinInfo
private static readonly ILive<SkinInfo> random_skin_info = new SkinInfo
{
ID = SkinInfo.RANDOM_SKIN,
Name = "<Random Skin>",
};
}.ToLive();
private List<SkinInfo> skinItems;
private int firstNonDefaultSkinIndex
{
get
{
int index = skinItems.FindIndex(s => s.ID > 0);
if (index < 0)
index = skinItems.Count;
return index;
}
}
private List<ILive<SkinInfo>> skinItems;
[Resolved]
private SkinManager skins { get; set; }
[Resolved]
private RealmContextFactory realmFactory { get; set; }
private IDisposable realmSubscription;
private IQueryable<SkinInfo> realmSkins;
[BackgroundDependencyLoader(permitNulls: true)]
private void load(OsuConfigManager config, [CanBeNull] SkinEditorOverlay skinEditor)
{
@ -75,96 +69,95 @@ namespace osu.Game.Overlays.Settings.Sections
new ExportSkinButton(),
};
skins.ItemUpdated += itemUpdated;
skins.ItemRemoved += itemRemoved;
config.BindWith(OsuSetting.Skin, configBindable);
}
protected override void LoadComplete()
{
base.LoadComplete();
skinDropdown.Current = dropdownBindable;
realmSkins = realmFactory.Context.All<SkinInfo>()
.Where(s => !s.DeletePending)
.OrderByDescending(s => s.Protected) // protected skins should be at the top.
.ThenBy(s => s.Name, StringComparer.OrdinalIgnoreCase);
realmSubscription = realmSkins
.QueryAsyncWithNotifications((sender, changes, error) =>
{
if (changes == null)
return;
// Eventually this should be handling the individual changes rather than refreshing the whole dropdown.
updateItems();
});
updateItems();
// Todo: This should not be necessary when OsuConfigManager is databased
if (skinDropdown.Items.All(s => s.ID != configBindable.Value))
configBindable.Value = 0;
configBindable.BindValueChanged(id => Scheduler.AddOnce(updateSelectedSkinFromConfig));
updateSelectedSkinFromConfig();
configBindable.BindValueChanged(id => Scheduler.AddOnce(updateSelectedSkinFromConfig), true);
dropdownBindable.BindValueChanged(skin =>
{
if (skin.NewValue == random_skin_info)
if (skin.NewValue.Equals(random_skin_info))
{
var skinBefore = skins.CurrentSkinInfo.Value;
skins.SelectRandomSkin();
if (skinBefore == skins.CurrentSkinInfo.Value)
{
// the random selection didn't change the skin, so we should manually update the dropdown to match.
dropdownBindable.Value = skins.CurrentSkinInfo.Value;
}
return;
}
configBindable.Value = skin.NewValue.ID;
configBindable.Value = skin.NewValue.ID.ToString();
});
}
private void updateSelectedSkinFromConfig()
{
int id = configBindable.Value;
ILive<SkinInfo> skin = null;
var skin = skinDropdown.Items.FirstOrDefault(s => s.ID == id);
if (Guid.TryParse(configBindable.Value, out var configId))
skin = skinDropdown.Items.FirstOrDefault(s => s.ID == configId);
if (skin == null)
{
// there may be a thread race condition where an item is selected that hasn't yet been added to the dropdown.
// to avoid adding complexity, let's just ensure the item is added so we can perform the selection.
skin = skins.Query(s => s.ID == id);
addItem(skin);
}
dropdownBindable.Value = skin;
dropdownBindable.Value = skin ?? skinDropdown.Items.First();
}
private void updateItems()
{
skinItems = skins.GetAllUsableSkins();
skinItems.Insert(firstNonDefaultSkinIndex, random_skin_info);
sortUserSkins(skinItems);
int protectedCount = realmSkins.Count(s => s.Protected);
skinItems = realmSkins.ToLive();
skinItems.Insert(protectedCount, random_skin_info);
skinDropdown.Items = skinItems;
}
private void itemUpdated(SkinInfo item) => Schedule(() => addItem(item));
private void addItem(SkinInfo item)
{
List<SkinInfo> newDropdownItems = skinDropdown.Items.Where(i => !i.Equals(item)).Append(item).ToList();
sortUserSkins(newDropdownItems);
skinDropdown.Items = newDropdownItems;
}
private void itemRemoved(SkinInfo item) => Schedule(() => skinDropdown.Items = skinDropdown.Items.Where(i => !i.Equals(item)).ToArray());
private void sortUserSkins(List<SkinInfo> skinsList)
{
// Sort user skins separately from built-in skins
skinsList.Sort(firstNonDefaultSkinIndex, skinsList.Count - firstNonDefaultSkinIndex,
Comparer<SkinInfo>.Create((a, b) => string.Compare(a.Name, b.Name, StringComparison.OrdinalIgnoreCase)));
}
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);
if (skins != null)
{
skins.ItemUpdated -= itemUpdated;
skins.ItemRemoved -= itemRemoved;
}
realmSubscription?.Dispose();
}
private class SkinSettingsDropdown : SettingsDropdown<SkinInfo>
private class SkinSettingsDropdown : SettingsDropdown<ILive<SkinInfo>>
{
protected override OsuDropdown<SkinInfo> CreateDropdown() => new SkinDropdownControl();
protected override OsuDropdown<ILive<SkinInfo>> CreateDropdown() => new SkinDropdownControl();
private class SkinDropdownControl : DropdownControl
{
protected override LocalisableString GenerateItemText(SkinInfo item) => item.ToString();
protected override LocalisableString GenerateItemText(ILive<SkinInfo> item) => item.ToString();
}
}
private class ExportSkinButton : SettingsButton
public class ExportSkinButton : SettingsButton
{
[Resolved]
private SkinManager skins { get; set; }
@ -179,16 +172,21 @@ namespace osu.Game.Overlays.Settings.Sections
{
Text = SkinSettingsStrings.ExportSkinButton;
Action = export;
}
protected override void LoadComplete()
{
base.LoadComplete();
currentSkin = skins.CurrentSkin.GetBoundCopy();
currentSkin.BindValueChanged(skin => Enabled.Value = skin.NewValue.SkinInfo.ID > 0, true);
currentSkin.BindValueChanged(skin => Enabled.Value = skin.NewValue.SkinInfo.PerformRead(s => !s.Protected), true);
}
private void export()
{
try
{
new LegacySkinExporter(storage).Export(currentSkin.Value.SkinInfo);
currentSkin.Value.SkinInfo.PerformRead(s => new LegacySkinExporter(storage).Export(s));
}
catch (Exception e)
{

View File

@ -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()
};
}
}

View File

@ -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)
{
}

View File

@ -0,0 +1,61 @@
// 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();
}
}
}

View File

@ -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 };
}
}

View File

@ -0,0 +1,49 @@
// 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.Linq;
using osu.Framework.Extensions;
using osu.Framework.IO.Stores;
using osu.Game.Database;
using osu.Game.Extensions;
using Realms;
namespace osu.Game.Skinning
{
public class LegacyDatabasedSkinResourceStore : ResourceStore<byte[]>
{
private readonly ILive<SkinInfo> source;
public LegacyDatabasedSkinResourceStore(SkinInfo source, IResourceStore<byte[]> underlyingStore)
: base(underlyingStore)
{
this.source = source.ToLive();
}
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) =>
source.PerformRead(s =>
{
if (s.IsManaged)
{
// avoid enumerating all files if this is a managed realm instance.
return s.Files.Filter(@"Filename ==[c] $0", filename).FirstOrDefault()?.File.GetStoragePath();
}
return s.Files.FirstOrDefault(f => string.Equals(f.Filename, filename, StringComparison.OrdinalIgnoreCase))?.File.GetStoragePath();
});
public override IEnumerable<string> GetAvailableResources() => source.PerformRead(s => s.Files.Select(f => f.Filename));
}
}

View File

@ -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")
{
}

View File

@ -11,12 +11,11 @@ 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;
@ -33,7 +32,7 @@ namespace osu.Game.Skinning
}
private string getPathForFile(string filename) =>
source.Files.Find(f => string.Equals(f.Filename, filename, StringComparison.OrdinalIgnoreCase))?.FileInfo.GetStoragePath();
source.Files.FirstOrDefault(f => string.Equals(f.Filename, filename, StringComparison.OrdinalIgnoreCase))?.File.GetStoragePath();
public override IEnumerable<string> GetAvailableResources() => source.Files.Select(f => f.Filename);
}

View File

@ -15,6 +15,7 @@ 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;
@ -23,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; }
@ -42,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();
@ -53,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.GetStoragePath());
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)
@ -94,7 +99,7 @@ namespace osu.Game.Skinning
private Stream getConfigurationStream()
{
string path = SkinInfo.Files.SingleOrDefault(f => f.Filename.Equals(@"skin.ini", StringComparison.OrdinalIgnoreCase))?.FileInfo.GetStoragePath();
string path = SkinInfo.PerformRead(s => s.Files.SingleOrDefault(f => f.Filename.Equals(@"skin.ini", StringComparison.OrdinalIgnoreCase))?.File.GetStoragePath());
if (string.IsNullOrEmpty(path))
return null;

View File

@ -13,6 +13,8 @@ namespace osu.Game.Skinning
public int SkinInfoID { get; set; }
public EFSkinInfo SkinInfo { get; set; }
public int FileInfoID { get; set; }
public FileInfo FileInfo { get; set; }

View File

@ -1,30 +1,39 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// 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 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, IHasNamedFiles
[ExcludeFromDynamicCompile]
[MapTo("Skin")]
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; }
[PrimaryKey]
public Guid ID { get; set; } = Guid.NewGuid();
public string Name { get; set; } = string.Empty;
public string Creator { get; set; } = string.Empty;
public string Hash { get; set; }
public string Hash { get; set; } = string.Empty;
public string InstantiationInfo { get; set; }
public bool Protected { get; set; }
public string InstantiationInfo { get; set; } = string.Empty;
public virtual Skin CreateInstance(IStorageResourceProvider resources)
{
@ -36,23 +45,21 @@ namespace osu.Game.Skinning
return (Skin)Activator.CreateInstance(type, this, resources);
}
public List<SkinFileInfo> Files { get; } = 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();
}

View File

@ -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;
@ -20,6 +17,7 @@ using osu.Framework.Graphics.Textures;
using osu.Framework.IO.Stores;
using osu.Framework.Platform;
using osu.Framework.Testing;
using osu.Framework.Threading;
using osu.Framework.Utils;
using osu.Game.Audio;
using osu.Game.Database;
@ -37,20 +35,25 @@ 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 : ISkinSource, IStorageResourceProvider, IModelImporter<SkinInfo>, IModelManager<SkinInfo>
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 readonly Bindable<ILive<SkinInfo>> CurrentSkinInfo = new Bindable<ILive<SkinInfo>>(Skinning.DefaultSkin.CreateInfo().ToLive())
{
Default = Skinning.DefaultSkin.CreateInfo().ToLive()
};
private readonly SkinModelManager skinModelManager;
private readonly SkinStore skinStore;
private readonly RealmContextFactory contextFactory;
private readonly IResourceStore<byte[]> userFiles;
@ -64,69 +67,66 @@ namespace osu.Game.Skinning
/// </summary>
public Skin DefaultLegacySkin { get; }
public SkinManager(Storage storage, DatabaseContextFactory contextFactory, GameHost host, IResourceStore<byte[]> resources, AudioManager audio)
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;
skinStore = new SkinStore(contextFactory, storage);
userFiles = new FileStore(contextFactory, storage).Store;
userFiles = new StorageBackedResourceStore(storage.GetStorageForDirectory("files"));
skinModelManager = new SkinModelManager(storage, contextFactory, skinStore, host, this);
skinModelManager = new SkinModelManager(storage, contextFactory, host, this);
DefaultLegacySkin = new DefaultLegacySkin(this);
DefaultSkin = new DefaultSkin(this);
var defaultSkins = new[]
{
DefaultLegacySkin = new DefaultLegacySkin(this),
DefaultSkin = new DefaultSkin(this),
};
CurrentSkinInfo.ValueChanged += skin => CurrentSkin.Value = GetSkin(skin.NewValue);
// 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();
};
}
/// <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 skinStore.ConsumableItems.Where(s => !s.DeletePending).ToList();
return skinStore.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 = skinStore.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 = skinStore.ConsumableItems.Single(i => i.ID == chosen.ID);
if (randomChoices.Length == 0)
{
CurrentSkinInfo.Value = Skinning.DefaultSkin.CreateInfo().ToLive();
return;
}
var chosen = randomChoices.ElementAt(RNG.Next(0, randomChoices.Length));
CurrentSkinInfo.Value = chosen.ToLive();
}
}
/// <summary>
@ -142,40 +142,30 @@ 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 = skinModelManager.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)
CurrentSkinInfo.Value = result;
});
}
public void Save(Skin skin)
{
if (skin.SkinInfo.ID <= 0)
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)
skinModelManager.ReplaceFile(skin.SkinInfo, oldFile, streamContent);
else
skinModelManager.AddFile(skin.SkinInfo, streamContent, filename);
}
}
skinModelManager.Save(skin);
}
/// <summary>
@ -183,7 +173,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) => skinStore.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;
@ -289,46 +283,23 @@ namespace osu.Game.Skinning
#region Implementation of IModelManager<SkinInfo>
public event Action<SkinInfo> ItemUpdated
public void Delete([CanBeNull] Expression<Func<SkinInfo, bool>> filter = null, bool silent = false)
{
add => skinModelManager.ItemUpdated += value;
remove => skinModelManager.ItemUpdated -= value;
}
using (var context = contextFactory.CreateContext())
{
var items = context.All<SkinInfo>()
.Where(s => !s.Protected && !s.DeletePending);
if (filter != null)
items = items.Where(filter);
public event Action<SkinInfo> ItemRemoved
{
add => skinModelManager.ItemRemoved += value;
remove => skinModelManager.ItemRemoved -= value;
}
// check the removed skin is not the current user choice. if it is, switch back to default.
Guid currentUserSkin = CurrentSkinInfo.Value.ID;
public void Update(SkinInfo item)
{
skinModelManager.Update(item);
}
if (items.Any(s => s.ID == currentUserSkin))
scheduler.Add(() => CurrentSkinInfo.Value = Skinning.DefaultSkin.CreateInfo().ToLive());
public bool Delete(SkinInfo item)
{
return skinModelManager.Delete(item);
}
public void Delete(List<SkinInfo> items, bool silent = false)
{
skinModelManager.Delete(items, silent);
}
public void Undelete(List<SkinInfo> items, bool silent = false)
{
skinModelManager.Undelete(items, silent);
}
public void Undelete(SkinInfo item)
{
skinModelManager.Undelete(item);
}
public bool IsAvailableLocally(SkinInfo model)
{
return skinModelManager.IsAvailableLocally(model);
skinModelManager.Delete(items.ToList(), silent);
}
}
#endregion

View File

@ -8,21 +8,26 @@ 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 : ArchiveModelManager<SkinInfo, SkinFileInfo>
public class SkinModelManager : RealmArchiveModelManager<SkinInfo>
{
private readonly IStorageResourceProvider skinResources;
public SkinModelManager(Storage storage, DatabaseContextFactory contextFactory, SkinStore skinStore, GameHost host, IStorageResourceProvider skinResources)
: base(storage, contextFactory, skinStore, host)
public SkinModelManager(Storage storage, RealmContextFactory contextFactory, GameHost host, IStorageResourceProvider skinResources)
: base(storage, contextFactory)
{
this.skinResources = skinResources;
@ -42,18 +47,27 @@ namespace osu.Game.Skinning
protected override bool HasCustomHashFunction => true;
protected override string ComputeHash(SkinInfo item)
protected override Task Populate(SkinInfo model, ArchiveReader? archive, Realm realm, CancellationToken cancellationToken = default)
{
if (string.IsNullOrEmpty(model.InstantiationInfo))
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.ID == 0;
bool isImport = !item.IsManaged;
if (isImport)
{
@ -71,12 +85,10 @@ namespace osu.Game.Skinning
// 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);
updateSkinIniMetadata(item, realm);
}
private void updateSkinIniMetadata(SkinInfo item)
private void updateSkinIniMetadata(SkinInfo item, Realm realm)
{
string nameLine = @$"Name: {item.Name}";
string authorLine = @$"Author: {item.Creator}";
@ -95,39 +107,47 @@ namespace osu.Game.Skinning
{
// In the case a skin doesn't have a skin.ini yet, let's create one.
writeNewSkinIni();
return;
}
using (Stream stream = new MemoryStream())
else
{
using (var sw = new StreamWriter(stream, Encoding.UTF8, 1024, true))
using (Stream stream = new MemoryStream())
{
using (var existingStream = Files.Storage.GetStream(existingFile.FileInfo.GetStoragePath()))
using (var sr = new StreamReader(existingStream))
using (var sw = new StreamWriter(stream, Encoding.UTF8, 1024, true))
{
string line;
while ((line = sr.ReadLine()) != null)
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);
}
sw.WriteLine();
ReplaceFile(item, existingFile, stream, realm);
foreach (string line in newLines)
sw.WriteLine(line);
}
// 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);
ReplaceFile(item, existingFile, stream);
var existingIni = item.Files.SingleOrDefault(f => f.Filename.Equals(@"skin.ini", StringComparison.OrdinalIgnoreCase));
if (existingIni != null)
item.Files.Remove(existingIni);
// 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();
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())
@ -138,8 +158,10 @@ namespace osu.Game.Skinning
sw.WriteLine(line);
}
AddFile(item, stream, @"skin.ini");
AddFile(item, stream, @"skin.ini", realm);
}
item.Hash = ComputeHash(item);
}
}
@ -154,36 +176,52 @@ namespace osu.Game.Skinning
return instance.Configuration.SkinInfo.Name == item.Name;
}
protected override Task Populate(SkinInfo model, ArchiveReader archive, CancellationToken cancellationToken = default)
{
var instance = createInstance(model);
model.InstantiationInfo ??= instance.GetType().GetInvariantInstantiationInfo();
model.Name = instance.Configuration.SkinInfo.Name;
model.Creator = instance.Configuration.SkinInfo.Creator;
return Task.CompletedTask;
}
private void populateMissingHashes()
{
var skinsWithoutHashes = ModelStore.ConsumableItems.Where(i => i.Hash == null).ToArray();
foreach (SkinInfo skin in skinsWithoutHashes)
using (var realm = ContextFactory.CreateContext())
{
try
var skinsWithoutHashes = realm.All<SkinInfo>().Where(i => string.IsNullOrEmpty(i.Hash)).ToArray();
foreach (SkinInfo skin in skinsWithoutHashes)
{
Update(skin);
}
catch (Exception e)
{
Delete(skin);
Logger.Error(e, $"Existing skin {skin} has been deleted during hash recomputation due to being invalid");
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 =>
{
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(s, oldFile, streamContent, s.Realm);
else
AddFile(s, streamContent, filename, s.Realm);
}
}
s.Hash = ComputeHash(s);
});
}
}
}

View File

@ -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)

View File

@ -0,0 +1,188 @@
// 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.Threading;
using System.Threading.Tasks;
using osu.Framework.Platform;
using osu.Game.Database;
using osu.Game.IO.Archives;
using osu.Game.Models;
using osu.Game.Overlays.Notifications;
using Realms;
#nullable enable
namespace osu.Game.Stores
{
/// <summary>
/// Class which adds all the missing pieces bridging the gap between <see cref="RealmArchiveModelImporter{TModel}"/> and <see cref="ArchiveModelManager{TModel,TFileModel}"/>.
/// </summary>
public abstract class RealmArchiveModelManager<TModel> : RealmArchiveModelImporter<TModel>, IModelManager<TModel>, IModelFileManager<TModel, RealmNamedFileUsage>
where TModel : RealmObject, IHasRealmFiles, IHasGuidPrimaryKey, ISoftDelete
{
public event Action<TModel>? ItemUpdated
{
// This may be brought back for beatmaps to ease integration.
// The eventual goal would be not requiring this and using realm subscriptions in its place.
add => throw new NotImplementedException();
remove => throw new NotImplementedException();
}
public event Action<TModel>? ItemRemoved
{
// This may be brought back for beatmaps to ease integration.
// The eventual goal would be not requiring this and using realm subscriptions in its place.
add => throw new NotImplementedException();
remove => throw new NotImplementedException();
}
private readonly RealmFileStore realmFileStore;
protected RealmArchiveModelManager(Storage storage, RealmContextFactory contextFactory)
: base(storage, contextFactory)
{
realmFileStore = new RealmFileStore(contextFactory, storage);
}
public void DeleteFile(TModel item, RealmNamedFileUsage file) =>
item.Realm.Write(() => DeleteFile(item, file, item.Realm));
public void ReplaceFile(TModel item, RealmNamedFileUsage file, Stream contents)
=> item.Realm.Write(() => ReplaceFile(item, file, contents, item.Realm));
public void AddFile(TModel item, Stream stream, string filename)
=> item.Realm.Write(() => AddFile(item, stream, filename, item.Realm));
/// <summary>
/// Delete a file from within an ongoing realm transaction.
/// </summary>
protected void DeleteFile(TModel item, RealmNamedFileUsage file, Realm realm)
{
item.Files.Remove(file);
}
/// <summary>
/// Replace a file from within an ongoing realm transaction.
/// </summary>
protected void ReplaceFile(TModel model, RealmNamedFileUsage file, Stream contents, Realm realm)
{
file.File = realmFileStore.Add(contents, realm);
}
/// <summary>
/// Add a file from within an ongoing realm transaction.
/// </summary>
protected void AddFile(TModel item, Stream stream, string filename, Realm realm)
{
var file = realmFileStore.Add(stream, realm);
var namedUsage = new RealmNamedFileUsage(file, filename);
item.Files.Add(namedUsage);
}
public override async Task<ILive<TModel>?> Import(TModel item, ArchiveReader? archive = null, bool lowPriority = false, CancellationToken cancellationToken = default)
{
return await base.Import(item, archive, lowPriority, cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Delete multiple items.
/// This will post notifications tracking progress.
/// </summary>
public void Delete(List<TModel> items, bool silent = false)
{
if (items.Count == 0) return;
var notification = new ProgressNotification
{
Progress = 0,
Text = $"Preparing to delete all {HumanisedModelName}s...",
CompletionText = $"Deleted all {HumanisedModelName}s!",
State = ProgressNotificationState.Active,
};
if (!silent)
PostNotification?.Invoke(notification);
int i = 0;
foreach (var b in items)
{
if (notification.State == ProgressNotificationState.Cancelled)
// user requested abort
return;
notification.Text = $"Deleting {HumanisedModelName}s ({++i} of {items.Count})";
Delete(b);
notification.Progress = (float)i / items.Count;
}
notification.State = ProgressNotificationState.Completed;
}
/// <summary>
/// Restore multiple items that were previously deleted.
/// This will post notifications tracking progress.
/// </summary>
public void Undelete(List<TModel> items, bool silent = false)
{
if (!items.Any()) return;
var notification = new ProgressNotification
{
CompletionText = "Restored all deleted items!",
Progress = 0,
State = ProgressNotificationState.Active,
};
if (!silent)
PostNotification?.Invoke(notification);
int i = 0;
foreach (var item in items)
{
if (notification.State == ProgressNotificationState.Cancelled)
// user requested abort
return;
notification.Text = $"Restoring ({++i} of {items.Count})";
Undelete(item);
notification.Progress = (float)i / items.Count;
}
notification.State = ProgressNotificationState.Completed;
}
public bool Delete(TModel item)
{
if (item.DeletePending)
return false;
item.Realm.Write(r => item.DeletePending = true);
return true;
}
public void Undelete(TModel item)
{
if (!item.DeletePending)
return;
item.Realm.Write(r => item.DeletePending = false);
}
public virtual bool IsAvailableLocally(TModel model) => false; // Not relevant for skins since they can't be downloaded yet.
public void Update(TModel skin)
{
}
}
}

View File

@ -15,6 +15,7 @@ using osu.Framework.Timing;
using osu.Game.Beatmaps;
using osu.Game.Beatmaps.Formats;
using osu.Game.IO;
using osu.Game.Models;
using osu.Game.Rulesets;
using osu.Game.Rulesets.Objects;
using osu.Game.Screens.Ranking;
@ -88,11 +89,7 @@ namespace osu.Game.Tests.Beatmaps
AddStep("setup skins", () =>
{
userSkinInfo.Files.Clear();
userSkinInfo.Files.Add(new SkinFileInfo
{
Filename = userFile,
FileInfo = new IO.FileInfo { Hash = userFile }
});
userSkinInfo.Files.Add(new RealmNamedFileUsage(new RealmFile { Hash = userFile }, userFile));
beatmapInfo.BeatmapSet.Files.Clear();
beatmapInfo.BeatmapSet.Files.Add(new BeatmapSetFileInfo

View File

@ -95,7 +95,7 @@ namespace osu.Game.Tests.Visual
},
new OsuSpriteText
{
Text = skin?.SkinInfo?.Name ?? "none",
Text = skin?.SkinInfo?.Value.Name ?? "none",
Scale = new Vector2(1.5f),
Padding = new MarginPadding(5),
},