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:
commit
cf34b3f70e
@ -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));
|
||||
|
||||
|
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
|
@ -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")
|
||||
{
|
||||
}
|
||||
}
|
||||
|
@ -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)));
|
||||
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));
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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]
|
||||
|
@ -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());
|
||||
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
39
osu.Game.Tests/Visual/Navigation/TestSceneEditDefaultSkin.cs
Normal file
39
osu.Game.Tests/Visual/Navigation/TestSceneEditDefaultSkin.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
@ -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; }
|
||||
}
|
||||
|
148
osu.Game/Database/EFToRealmMigrator.cs
Normal file
148
osu.Game/Database/EFToRealmMigrator.cs
Normal 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;
|
||||
}
|
||||
}
|
@ -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 });
|
||||
|
||||
|
@ -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++)
|
||||
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
@ -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 =>
|
||||
{
|
||||
|
@ -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;
|
||||
|
@ -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));
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
@ -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)
|
||||
{
|
||||
|
@ -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()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
{
|
||||
}
|
||||
|
||||
|
61
osu.Game/Skinning/EFSkinInfo.cs
Normal file
61
osu.Game/Skinning/EFSkinInfo.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
49
osu.Game/Skinning/LegacyDatabasedSkinResourceStore.cs
Normal file
49
osu.Game/Skinning/LegacyDatabasedSkinResourceStore.cs
Normal 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));
|
||||
}
|
||||
}
|
@ -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")
|
||||
{
|
||||
}
|
||||
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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,18 +54,21 @@ namespace osu.Game.Skinning
|
||||
else
|
||||
Configuration = new SkinConfiguration();
|
||||
|
||||
// skininfo files may be null for default skin.
|
||||
SkinInfo.PerformRead(s =>
|
||||
{
|
||||
// we may want to move this to some kind of async operation in the future.
|
||||
foreach (SkinnableTarget skinnableTarget in Enum.GetValues(typeof(SkinnableTarget)))
|
||||
{
|
||||
string filename = $"{skinnableTarget}.json";
|
||||
|
||||
// skininfo files may be null for default skin.
|
||||
var fileInfo = SkinInfo.Files.FirstOrDefault(f => f.Filename == filename);
|
||||
var fileInfo = s.Files.FirstOrDefault(f => f.Filename == filename);
|
||||
|
||||
if (fileInfo == null)
|
||||
continue;
|
||||
|
||||
byte[] bytes = resources?.Files.Get(fileInfo.FileInfo.GetStoragePath());
|
||||
byte[] bytes = resources?.Files.Get(fileInfo.File.GetStoragePath());
|
||||
|
||||
if (bytes == null)
|
||||
continue;
|
||||
@ -84,6 +88,7 @@ namespace osu.Game.Skinning
|
||||
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;
|
||||
|
@ -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; }
|
||||
|
@ -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();
|
||||
}
|
||||
|
||||
|
@ -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()
|
||||
{
|
||||
using (var context = contextFactory.CreateContext())
|
||||
{
|
||||
// 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();
|
||||
var randomChoices = context.All<SkinInfo>().Where(s => !s.DeletePending && s.ID != CurrentSkinInfo.Value.ID).ToArray();
|
||||
|
||||
if (randomChoices.Length == 0)
|
||||
{
|
||||
CurrentSkinInfo.Value = SkinInfo.Default;
|
||||
CurrentSkinInfo.Value = Skinning.DefaultSkin.CreateInfo().ToLive();
|
||||
return;
|
||||
}
|
||||
|
||||
var chosen = randomChoices.ElementAt(RNG.Next(0, randomChoices.Length));
|
||||
CurrentSkinInfo.Value = skinStore.ConsumableItems.Single(i => i.ID == chosen.ID);
|
||||
|
||||
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;
|
||||
CurrentSkinInfo.Value.PerformRead(s =>
|
||||
{
|
||||
if (!s.Protected)
|
||||
return;
|
||||
|
||||
// if the user is attempting to save one of the default skin implementations, create a copy first.
|
||||
CurrentSkinInfo.Value = skinModelManager.Import(new SkinInfo
|
||||
var result = skinModelManager.Import(new SkinInfo
|
||||
{
|
||||
Name = skin.SkinInfo.Name + @" (modified)",
|
||||
Creator = skin.SkinInfo.Creator,
|
||||
InstantiationInfo = skin.SkinInfo.InstantiationInfo,
|
||||
}).Result.Value;
|
||||
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);
|
||||
skinModelManager.Delete(items.ToList(), silent);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
@ -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,17 +107,17 @@ namespace osu.Game.Skinning
|
||||
{
|
||||
// In the case a skin doesn't have a skin.ini yet, let's create one.
|
||||
writeNewSkinIni();
|
||||
return;
|
||||
}
|
||||
|
||||
else
|
||||
{
|
||||
using (Stream stream = new MemoryStream())
|
||||
{
|
||||
using (var sw = new StreamWriter(stream, Encoding.UTF8, 1024, true))
|
||||
{
|
||||
using (var existingStream = Files.Storage.GetStream(existingFile.FileInfo.GetStoragePath()))
|
||||
using (var existingStream = Files.Storage.GetStream(existingFile.File.GetStoragePath()))
|
||||
using (var sr = new StreamReader(existingStream))
|
||||
{
|
||||
string line;
|
||||
string? line;
|
||||
while ((line = sr.ReadLine()) != null)
|
||||
sw.WriteLine(line);
|
||||
}
|
||||
@ -116,17 +128,25 @@ namespace osu.Game.Skinning
|
||||
sw.WriteLine(line);
|
||||
}
|
||||
|
||||
ReplaceFile(item, existingFile, stream);
|
||||
ReplaceFile(item, existingFile, stream, realm);
|
||||
|
||||
// can be removed 20220502.
|
||||
if (!ensureIniWasUpdated(item))
|
||||
{
|
||||
Logger.Log($"Skin {item}'s skin.ini had issues and has been removed. Please report this and provide the problematic skin.", LoggingTarget.Database, LogLevel.Important);
|
||||
|
||||
DeleteFile(item, item.Files.SingleOrDefault(f => f.Filename.Equals(@"skin.ini", StringComparison.OrdinalIgnoreCase)));
|
||||
var existingIni = item.Files.SingleOrDefault(f => f.Filename.Equals(@"skin.ini", StringComparison.OrdinalIgnoreCase));
|
||||
if (existingIni != null)
|
||||
item.Files.Remove(existingIni);
|
||||
|
||||
writeNewSkinIni();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The hash is already populated at this point in import.
|
||||
// As we have changed files, it needs to be recomputed.
|
||||
item.Hash = ComputeHash(item);
|
||||
|
||||
void writeNewSkinIni()
|
||||
{
|
||||
@ -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,21 +176,11 @@ 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();
|
||||
using (var realm = ContextFactory.CreateContext())
|
||||
{
|
||||
var skinsWithoutHashes = realm.All<SkinInfo>().Where(i => string.IsNullOrEmpty(i.Hash)).ToArray();
|
||||
|
||||
foreach (SkinInfo skin in skinsWithoutHashes)
|
||||
{
|
||||
@ -183,7 +195,33 @@ namespace osu.Game.Skinning
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
|
188
osu.Game/Stores/RealmArchiveModelManager.cs
Normal file
188
osu.Game/Stores/RealmArchiveModelManager.cs
Normal 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)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
@ -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
|
||||
|
@ -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),
|
||||
},
|
||||
|
Loading…
Reference in New Issue
Block a user