diff --git a/osu.Game.Tests/Database/RealmTest.cs b/osu.Game.Tests/Database/RealmTest.cs index c2339dd9ad..838759c991 100644 --- a/osu.Game.Tests/Database/RealmTest.cs +++ b/osu.Game.Tests/Database/RealmTest.cs @@ -114,7 +114,7 @@ namespace osu.Game.Tests.Database } protected static RulesetInfo CreateRuleset() => - new RulesetInfo(0, "osu!", "osu", true); + new RulesetInfo("osu", "osu!", string.Empty, 0) { Available = true }; private class RealmTestGame : Framework.Game { diff --git a/osu.Game.Tests/Visual/Editing/TestSceneEditorSaving.cs b/osu.Game.Tests/Visual/Editing/TestSceneEditorSaving.cs index 58daab1ce2..adaa24d542 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneEditorSaving.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneEditorSaving.cs @@ -4,11 +4,13 @@ using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; +using osu.Framework.Screens; using osu.Framework.Testing; using osu.Framework.Utils; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Screens.Edit; using osu.Game.Screens.Edit.Compose.Components.Timeline; +using osu.Game.Screens.Select; using osuTK.Input; namespace osu.Game.Tests.Visual.Editing @@ -116,5 +118,24 @@ namespace osu.Game.Tests.Visual.Editing EditorBeatmap.HitObjects[0].SampleControlPoint != SampleControlPoint.DEFAULT && EditorBeatmap.HitObjects[0].DifficultyControlPoint != DifficultyControlPoint.DEFAULT); } + + [Test] + public void TestExitWithoutSaveFromExistingBeatmap() + { + const string tags_to_save = "these tags will be saved"; + const string tags_to_discard = "these tags should be discarded"; + + AddStep("Set tags", () => EditorBeatmap.BeatmapInfo.Metadata.Tags = tags_to_save); + SaveEditor(); + AddAssert("Tags saved correctly", () => EditorBeatmap.BeatmapInfo.Metadata.Tags == tags_to_save); + + ReloadEditorToSameBeatmap(); + AddAssert("Tags saved correctly", () => EditorBeatmap.BeatmapInfo.Metadata.Tags == tags_to_save); + AddStep("Set tags again", () => EditorBeatmap.BeatmapInfo.Metadata.Tags = tags_to_discard); + + AddStep("Exit editor", () => Editor.Exit()); + AddUntilStep("Wait for song select", () => Game.ScreenStack.CurrentScreen is PlaySongSelect); + AddAssert("Tags reverted correctly", () => Game.Beatmap.Value.BeatmapInfo.Metadata.Tags == tags_to_save); + } } } diff --git a/osu.Game.Tests/Visual/Editing/TestSceneEditorTestGameplay.cs b/osu.Game.Tests/Visual/Editing/TestSceneEditorTestGameplay.cs index 2b827bee74..79ea866efe 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneEditorTestGameplay.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneEditorTestGameplay.cs @@ -70,7 +70,11 @@ namespace osu.Game.Tests.Visual.Editing AddUntilStep("current screen is editor", () => Stack.CurrentScreen is Editor); AddUntilStep("background has correct params", () => { - var background = this.ChildrenOfType().Single(); + // the test gameplay player's beatmap may be the "same" beatmap as the one being edited, *but* the `BeatmapInfo` references may differ + // due to the beatmap refetch logic ran on editor suspend. + // this test cares about checking the background belonging to the editor specifically, so check that using reference equality + // (as `.Equals()` cannot discern between the two, as they technically share the same database GUID). + var background = this.ChildrenOfType().Single(b => ReferenceEquals(b.Beatmap.BeatmapInfo, EditorBeatmap.BeatmapInfo)); return background.Colour == Color4.DarkGray && background.BlurAmount.Value == 0; }); AddAssert("no mods selected", () => SelectedMods.Value.Count == 0); @@ -99,7 +103,11 @@ namespace osu.Game.Tests.Visual.Editing AddUntilStep("current screen is editor", () => Stack.CurrentScreen is Editor); AddUntilStep("background has correct params", () => { - var background = this.ChildrenOfType().Single(); + // the test gameplay player's beatmap may be the "same" beatmap as the one being edited, *but* the `BeatmapInfo` references may differ + // due to the beatmap refetch logic ran on editor suspend. + // this test cares about checking the background belonging to the editor specifically, so check that using reference equality + // (as `.Equals()` cannot discern between the two, as they technically share the same database GUID). + var background = this.ChildrenOfType().Single(b => ReferenceEquals(b.Beatmap.BeatmapInfo, EditorBeatmap.BeatmapInfo)); return background.Colour == Color4.DarkGray && background.BlurAmount.Value == 0; }); diff --git a/osu.Game/Database/EFToRealmMigrator.cs b/osu.Game/Database/EFToRealmMigrator.cs index 96baad35ec..639bf10e19 100644 --- a/osu.Game/Database/EFToRealmMigrator.cs +++ b/osu.Game/Database/EFToRealmMigrator.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.IO; using System.Linq; using System.Threading.Tasks; using Microsoft.EntityFrameworkCore; @@ -9,17 +10,24 @@ using osu.Framework.Development; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Logging; +using osu.Framework.Platform; using osu.Game.Beatmaps; using osu.Game.Configuration; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; using osu.Game.Models; +using osu.Game.Overlays; +using osu.Game.Overlays.Notifications; using osu.Game.Rulesets; using osu.Game.Scoring; using osu.Game.Skinning; using osuTK; using Realms; +using SharpCompress.Archives; +using SharpCompress.Archives.Zip; +using SharpCompress.Common; +using SharpCompress.Writers.Zip; #nullable enable @@ -40,6 +48,15 @@ namespace osu.Game.Database [Resolved] private OsuConfigManager config { get; set; } = null!; + [Resolved] + private NotificationOverlay notificationOverlay { get; set; } = null!; + + [Resolved] + private OsuGame game { get; set; } = null!; + + [Resolved] + private Storage storage { get; set; } = null!; + private readonly OsuSpriteText currentOperationText; public EFToRealmMigrator() @@ -96,7 +113,11 @@ namespace osu.Game.Database protected override void LoadComplete() { base.LoadComplete(); + beginMigration(); + } + private void beginMigration() + { Task.Factory.StartNew(() => { using (var ef = efContextFactory.Get()) @@ -117,21 +138,67 @@ namespace osu.Game.Database migrateBeatmaps(ef); migrateScores(ef); } - - // Delete the database permanently. - // Will cause future startups to not attempt migration. - log("Migration successful, deleting EF database"); - efContextFactory.ResetDatabase(); - - if (DebugUtils.IsDebugBuild) - Logger.Log("Your development database has been fully migrated to realm. If you switch back to a pre-realm branch and need your previous database, rename the backup file back to \"client.db\".\n\nNote that doing this can potentially leave your file store in a bad state.", level: LogLevel.Important); }, TaskCreationOptions.LongRunning).ContinueWith(t => { + if (t.Exception == null) + { + log("Migration successful!"); + + if (DebugUtils.IsDebugBuild) + Logger.Log("Your development database has been fully migrated to realm. If you switch back to a pre-realm branch and need your previous database, rename the backup file back to \"client.db\".\n\nNote that doing this can potentially leave your file store in a bad state.", level: LogLevel.Important); + } + else + { + log("Migration failed!"); + Logger.Log(t.Exception.ToString(), LoggingTarget.Database); + + notificationOverlay.Post(new SimpleErrorNotification + { + Text = "IMPORTANT: During data migration, some of your data could not be successfully migrated. The previous version has been backed up.\n\nFor further assistance, please open a discussion on github and attach your backup files (click to get started).", + Activated = () => + { + game.OpenUrlExternally($@"https://github.com/ppy/osu/discussions/new?title=Realm%20migration%20issue ({t.Exception.Message})&body=Please%20drag%20the%20""attach_me.zip""%20file%20here!&category=q-a", true); + + const string attachment_filename = "attach_me.zip"; + const string backup_folder = "backups"; + + var backupStorage = storage.GetStorageForDirectory(backup_folder); + + backupStorage.Delete(attachment_filename); + + try + { + using (var zip = ZipArchive.Create()) + { + zip.AddAllFromDirectory(backupStorage.GetFullPath(string.Empty)); + zip.SaveTo(Path.Combine(backupStorage.GetFullPath(string.Empty), attachment_filename), new ZipWriterOptions(CompressionType.Deflate)); + } + } + catch { } + + backupStorage.PresentFileExternally(attachment_filename); + + return true; + } + }); + } + + // Regardless of success, since the game is going to continue with startup let's move the ef database out of the way. + // If we were to not do this, the migration would run another time the next time the user starts the game. + deletePreRealmData(); + migrationCompleted.SetResult(true); efContextFactory.SetMigrationCompletion(); }); } + private void deletePreRealmData() + { + // Delete the database permanently. + // Will cause future startups to not attempt migration. + efContextFactory.ResetDatabase(); + } + private void log(string message) { Logger.Log(message, LoggingTarget.Database); diff --git a/osu.Game/Online/Chat/ExternalLinkOpener.cs b/osu.Game/Online/Chat/ExternalLinkOpener.cs index 8407e2ca6a..328b43c4e8 100644 --- a/osu.Game/Online/Chat/ExternalLinkOpener.cs +++ b/osu.Game/Online/Chat/ExternalLinkOpener.cs @@ -27,9 +27,9 @@ namespace osu.Game.Online.Chat externalLinkWarning = config.GetBindable(OsuSetting.ExternalLinkWarning); } - public void OpenUrlExternally(string url) + public void OpenUrlExternally(string url, bool bypassWarning = false) { - if (externalLinkWarning.Value) + if (!bypassWarning && externalLinkWarning.Value) dialogOverlay.Push(new ExternalLinkDialog(url, () => host.OpenUrlExternally(url))); else host.OpenUrlExternally(url); diff --git a/osu.Game/OsuGame.cs b/osu.Game/OsuGame.cs index c2e1b25d94..5b58dec0c3 100644 --- a/osu.Game/OsuGame.cs +++ b/osu.Game/OsuGame.cs @@ -357,12 +357,12 @@ namespace osu.Game } }); - public void OpenUrlExternally(string url) => waitForReady(() => externalLinkOpener, _ => + public void OpenUrlExternally(string url, bool bypassExternalUrlWarning = false) => waitForReady(() => externalLinkOpener, _ => { if (url.StartsWith('/')) url = $"{API.APIEndpointUrl}{url}"; - externalLinkOpener.OpenUrlExternally(url); + externalLinkOpener.OpenUrlExternally(url, bypassExternalUrlWarning); }); /// diff --git a/osu.Game/OsuGameBase.cs b/osu.Game/OsuGameBase.cs index 09ca4e450d..8363c41437 100644 --- a/osu.Game/OsuGameBase.cs +++ b/osu.Game/OsuGameBase.cs @@ -202,16 +202,18 @@ namespace osu.Game // See https://github.com/ppy/osu/pull/16547 for more discussion. if (EFContextFactory != null) { + const string backup_folder = "backups"; + string migration = $"before_final_migration_{DateTimeOffset.UtcNow.ToUnixTimeSeconds()}"; - EFContextFactory.CreateBackup($"client.{migration}.db"); - realm.CreateBackup($"client.{migration}.realm"); + EFContextFactory.CreateBackup(Path.Combine(backup_folder, $"client.{migration}.db")); + realm.CreateBackup(Path.Combine(backup_folder, $"client.{migration}.realm")); using (var source = Storage.GetStream("collection.db")) { if (source != null) { - using (var destination = Storage.GetStream($"collection.{migration}.db", FileAccess.Write, FileMode.CreateNew)) + using (var destination = Storage.GetStream(Path.Combine(backup_folder, $"collection.{migration}.db"), FileAccess.Write, FileMode.CreateNew)) source.CopyTo(destination); } } diff --git a/osu.Game/Rulesets/Ruleset.cs b/osu.Game/Rulesets/Ruleset.cs index d279f6d6ee..616540b59c 100644 --- a/osu.Game/Rulesets/Ruleset.cs +++ b/osu.Game/Rulesets/Ruleset.cs @@ -37,7 +37,7 @@ namespace osu.Game.Rulesets [ExcludeFromDynamicCompile] public abstract class Ruleset { - public RulesetInfo RulesetInfo { get; internal set; } + public RulesetInfo RulesetInfo { get; } private static readonly ConcurrentDictionary mod_reference_cache = new ConcurrentDictionary(); diff --git a/osu.Game/Rulesets/RulesetInfo.cs b/osu.Game/Rulesets/RulesetInfo.cs index e2007c910e..f9fa8966a8 100644 --- a/osu.Game/Rulesets/RulesetInfo.cs +++ b/osu.Game/Rulesets/RulesetInfo.cs @@ -37,14 +37,6 @@ namespace osu.Game.Rulesets { } - public RulesetInfo(int? onlineID, string name, string shortName, bool available) - { - OnlineID = onlineID ?? -1; - Name = name; - ShortName = shortName; - Available = available; - } - public bool Available { get; set; } public bool Equals(RulesetInfo? other) diff --git a/osu.Game/Screens/Edit/Editor.cs b/osu.Game/Screens/Edit/Editor.cs index 55b01bd8ab..4812da98f4 100644 --- a/osu.Game/Screens/Edit/Editor.cs +++ b/osu.Game/Screens/Edit/Editor.cs @@ -574,7 +574,9 @@ namespace osu.Game.Screens.Edit // To update the game-wide beatmap with any changes, perform a re-fetch on exit/suspend. // This is required as the editor makes its local changes via EditorBeatmap // (which are not propagated outwards to a potentially cached WorkingBeatmap). - var refetchedBeatmap = beatmapManager.GetWorkingBeatmap(Beatmap.Value.BeatmapInfo); + ((IWorkingBeatmapCache)beatmapManager).Invalidate(Beatmap.Value.BeatmapInfo); + var refetchedBeatmapInfo = beatmapManager.QueryBeatmap(b => b.ID == Beatmap.Value.BeatmapInfo.ID); + var refetchedBeatmap = beatmapManager.GetWorkingBeatmap(refetchedBeatmapInfo); if (!(refetchedBeatmap is DummyWorkingBeatmap)) {