diff --git a/osu.Desktop/OsuGameDesktop.cs b/osu.Desktop/OsuGameDesktop.cs index 21cea3ba76..efd3d358b7 100644 --- a/osu.Desktop/OsuGameDesktop.cs +++ b/osu.Desktop/OsuGameDesktop.cs @@ -17,6 +17,7 @@ using osu.Game.Updater; using osu.Desktop.Windows; using osu.Game.IO; using osu.Game.IPC; +using osu.Game.Online.Multiplayer; using osu.Game.Utils; using SDL2; @@ -108,6 +109,25 @@ namespace osu.Desktop } } + public override bool RestartAppWhenExited() + { + switch (RuntimeInfo.OS) + { + case RuntimeInfo.Platform.Windows: + Debug.Assert(OperatingSystem.IsWindows()); + + // Of note, this is an async method in squirrel that adds an arbitrary delay before returning + // likely to ensure the external process is in a good state. + // + // We're not waiting on that here, but the outro playing before the actual exit should be enough + // to cover this. + Squirrel.UpdateManager.RestartAppWhenExited().FireAndForget(); + return true; + } + + return base.RestartAppWhenExited(); + } + protected override void LoadComplete() { base.LoadComplete(); diff --git a/osu.Game.Tests/Visual/Menus/TestSceneToolbar.cs b/osu.Game.Tests/Visual/Menus/TestSceneToolbar.cs index 22c7bb64b2..ce9f80a84f 100644 --- a/osu.Game.Tests/Visual/Menus/TestSceneToolbar.cs +++ b/osu.Game.Tests/Visual/Menus/TestSceneToolbar.cs @@ -3,6 +3,7 @@ #nullable disable +using System.Collections.Generic; using System.Linq; using JetBrains.Annotations; using Moq; @@ -250,6 +251,8 @@ namespace osu.Game.Tests.Visual.Menus } public virtual IBindable UnreadCount => null; + + public IEnumerable AllNotifications => Enumerable.Empty(); } } } diff --git a/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs b/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs index 18aef99ccd..c3b668f591 100644 --- a/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs +++ b/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs @@ -22,6 +22,7 @@ using osu.Game.Online.Leaderboards; using osu.Game.Overlays; using osu.Game.Overlays.BeatmapListing; using osu.Game.Overlays.Mods; +using osu.Game.Overlays.Notifications; using osu.Game.Overlays.Toolbar; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu.Mods; @@ -683,6 +684,44 @@ namespace osu.Game.Tests.Visual.Navigation AddStep("center cursor", () => InputManager.MoveMouseTo(Game.ScreenSpaceDrawQuad.Centre)); } + [Test] + public void TestExitWithOperationInProgress() + { + AddUntilStep("wait for dialog overlay", () => Game.ChildrenOfType().SingleOrDefault() != null); + + ProgressNotification progressNotification = null!; + + AddStep("start ongoing operation", () => + { + progressNotification = new ProgressNotification + { + Text = "Something is still running", + Progress = 0.5f, + State = ProgressNotificationState.Active, + }; + Game.Notifications.Post(progressNotification); + }); + + AddStep("Hold escape", () => InputManager.PressKey(Key.Escape)); + AddUntilStep("confirmation dialog shown", () => Game.ChildrenOfType().Single().CurrentDialog is ConfirmExitDialog); + AddStep("Release escape", () => InputManager.ReleaseKey(Key.Escape)); + + AddStep("cancel exit", () => InputManager.Key(Key.Escape)); + AddAssert("dialog dismissed", () => Game.ChildrenOfType().Single().CurrentDialog == null); + + AddStep("complete operation", () => + { + progressNotification.Progress = 100; + progressNotification.State = ProgressNotificationState.Completed; + }); + + AddStep("Hold escape", () => InputManager.PressKey(Key.Escape)); + AddUntilStep("Wait for intro", () => Game.ScreenStack.CurrentScreen is IntroScreen); + AddStep("Release escape", () => InputManager.ReleaseKey(Key.Escape)); + + AddUntilStep("Wait for game exit", () => Game.ScreenStack.CurrentScreen == null); + } + [Test] public void TestExitGameFromSongSelect() { @@ -699,7 +738,7 @@ namespace osu.Game.Tests.Visual.Navigation } [Test] - public void TestRapidBackButtonExit() + public void TestExitWithHoldDisabled() { AddStep("set hold delay to 0", () => Game.LocalConfig.SetValue(OsuSetting.UIHoldActivationDelay, 0.0)); @@ -711,7 +750,7 @@ namespace osu.Game.Tests.Visual.Navigation pushEscape(); - AddAssert("exit dialog is shown", () => Game.Dependencies.Get().CurrentDialog != null); + AddAssert("exit dialog is shown", () => Game.Dependencies.Get().CurrentDialog is ConfirmExitDialog); } private Func playToResults() diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneFirstRunSetupOverlay.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneFirstRunSetupOverlay.cs index 77ed97e3ed..9275f9755f 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneFirstRunSetupOverlay.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneFirstRunSetupOverlay.cs @@ -214,6 +214,8 @@ namespace osu.Game.Tests.Visual.UserInterface } public virtual IBindable UnreadCount => null; + + public IEnumerable AllNotifications => Enumerable.Empty(); } // interface mocks break hot reload, mocking this stub implementation instead works around it. diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneHoldToConfirmOverlay.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneHoldToExitGameOverlay.cs similarity index 88% rename from osu.Game.Tests/Visual/UserInterface/TestSceneHoldToConfirmOverlay.cs rename to osu.Game.Tests/Visual/UserInterface/TestSceneHoldToExitGameOverlay.cs index 91c1f58155..df423268b6 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneHoldToConfirmOverlay.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneHoldToExitGameOverlay.cs @@ -8,11 +8,11 @@ using osu.Game.Screens.Menu; namespace osu.Game.Tests.Visual.UserInterface { - public partial class TestSceneHoldToConfirmOverlay : OsuTestScene + public partial class TestSceneHoldToExitGameOverlay : OsuTestScene { protected override double TimePerAction => 100; // required for the early exit test, since hold-to-confirm delay is 200ms - public TestSceneHoldToConfirmOverlay() + public TestSceneHoldToExitGameOverlay() { bool fired = false; @@ -25,7 +25,7 @@ namespace osu.Game.Tests.Visual.UserInterface Alpha = 0, }; - var overlay = new TestHoldToConfirmOverlay + var overlay = new TestHoldToExitGameOverlay { Action = () => { @@ -57,7 +57,7 @@ namespace osu.Game.Tests.Visual.UserInterface AddUntilStep("wait until fired again", () => overlay.Fired); } - private partial class TestHoldToConfirmOverlay : ExitConfirmOverlay + private partial class TestHoldToExitGameOverlay : HoldToExitGameOverlay { public void Begin() => BeginConfirm(); } diff --git a/osu.Game.Tournament/Screens/Setup/TournamentSwitcher.cs b/osu.Game.Tournament/Screens/Setup/TournamentSwitcher.cs index 7a8b03a7aa..01d86274d7 100644 --- a/osu.Game.Tournament/Screens/Setup/TournamentSwitcher.cs +++ b/osu.Game.Tournament/Screens/Setup/TournamentSwitcher.cs @@ -28,7 +28,11 @@ namespace osu.Game.Tournament.Screens.Setup dropdown.Items = storage.ListTournaments(); dropdown.Current.BindValueChanged(v => Button.Enabled.Value = v.NewValue != startupTournament, true); - Action = () => game.AttemptExit(); + Action = () => + { + game.RestartAppWhenExited(); + game.AttemptExit(); + }; folderButton.Action = () => storage.PresentExternally(); ButtonText = "Close osu!"; diff --git a/osu.Game/OsuGame.cs b/osu.Game/OsuGame.cs index a80639d4ff..160dc26790 100644 --- a/osu.Game/OsuGame.cs +++ b/osu.Game/OsuGame.cs @@ -83,7 +83,7 @@ namespace osu.Game /// protected const float SIDE_OVERLAY_OFFSET_RATIO = 0.05f; - public Toolbar Toolbar; + public Toolbar Toolbar { get; private set; } private ChatOverlay chatOverlay; @@ -778,8 +778,8 @@ namespace osu.Game public override void AttemptExit() { - // Using PerformFromScreen gives the user a chance to interrupt the exit process if needed. - PerformFromScreen(menu => menu.Exit()); + // The main menu exit implementation gives the user a chance to interrupt the exit process if needed. + PerformFromScreen(menu => menu.Exit(), new[] { typeof(MainMenu) }); } /// diff --git a/osu.Game/OsuGameBase.cs b/osu.Game/OsuGameBase.cs index 63efe0e2c8..6737caa5f9 100644 --- a/osu.Game/OsuGameBase.cs +++ b/osu.Game/OsuGameBase.cs @@ -515,6 +515,12 @@ namespace osu.Game Scheduler.AddDelayed(AttemptExit, 2000); } + /// + /// If supported by the platform, the game will automatically restart after the next exit. + /// + /// Whether a restart operation was queued. + public virtual bool RestartAppWhenExited() => false; + public bool Migrate(string path) { Logger.Log($@"Migrating osu! data from ""{Storage.GetFullPath(string.Empty)}"" to ""{path}""..."); diff --git a/osu.Game/Overlays/INotificationOverlay.cs b/osu.Game/Overlays/INotificationOverlay.cs index 93346cb0ee..6a1b66bbd2 100644 --- a/osu.Game/Overlays/INotificationOverlay.cs +++ b/osu.Game/Overlays/INotificationOverlay.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Collections.Generic; +using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Game.Overlays.Notifications; @@ -28,5 +30,20 @@ namespace osu.Game.Overlays /// Current number of unread notifications. /// IBindable UnreadCount { get; } + + /// + /// Whether there are any ongoing operations, such as imports or downloads. + /// + public bool HasOngoingOperations => OngoingOperations.Any(); + + /// + /// All current displayed notifications, whether in the toast tray or a section. + /// + IEnumerable AllNotifications { get; } + + /// + /// All ongoing operations (ie. any not in a completed state). + /// + public IEnumerable OngoingOperations => AllNotifications.OfType().Where(p => p.State != ProgressNotificationState.Completed); } } diff --git a/osu.Game/Overlays/NotificationOverlay.cs b/osu.Game/Overlays/NotificationOverlay.cs index beebc9daaf..21027b0931 100644 --- a/osu.Game/Overlays/NotificationOverlay.cs +++ b/osu.Game/Overlays/NotificationOverlay.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; +using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Audio; @@ -33,6 +35,9 @@ namespace osu.Game.Overlays public const float TRANSITION_LENGTH = 600; + public IEnumerable AllNotifications => + IsLoaded ? toastTray.Notifications.Concat(sections.SelectMany(s => s.Notifications)) : Array.Empty(); + private FlowContainer sections = null!; [Resolved] diff --git a/osu.Game/Overlays/NotificationOverlayToastTray.cs b/osu.Game/Overlays/NotificationOverlayToastTray.cs index 7a793ee092..0ebaff9437 100644 --- a/osu.Game/Overlays/NotificationOverlayToastTray.cs +++ b/osu.Game/Overlays/NotificationOverlayToastTray.cs @@ -28,6 +28,11 @@ namespace osu.Game.Overlays public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => toastFlow.ReceivePositionalInputAt(screenSpacePos); + /// + /// All notifications currently being displayed by the toast tray. + /// + public IEnumerable Notifications => toastFlow; + public bool IsDisplayingToasts => toastFlow.Count > 0; private FillFlowContainer toastFlow = null!; diff --git a/osu.Game/Overlays/Notifications/NotificationSection.cs b/osu.Game/Overlays/Notifications/NotificationSection.cs index de4c72e473..4e28ade802 100644 --- a/osu.Game/Overlays/Notifications/NotificationSection.cs +++ b/osu.Game/Overlays/Notifications/NotificationSection.cs @@ -19,6 +19,11 @@ namespace osu.Game.Overlays.Notifications { public partial class NotificationSection : AlwaysUpdateFillFlowContainer { + /// + /// All notifications currently being displayed in this section. + /// + public IEnumerable Notifications => notifications; + private OsuSpriteText countDrawable = null!; private FlowContainer notifications = null!; diff --git a/osu.Game/Overlays/Settings/Sections/Graphics/RendererSettings.cs b/osu.Game/Overlays/Settings/Sections/Graphics/RendererSettings.cs index a1f728ca87..d4cef3f4d1 100644 --- a/osu.Game/Overlays/Settings/Sections/Graphics/RendererSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Graphics/RendererSettings.cs @@ -67,10 +67,17 @@ namespace osu.Game.Overlays.Settings.Sections.Graphics if (r.NewValue == RendererType.Automatic && automaticRendererInUse) return; - dialogOverlay?.Push(new ConfirmDialog(GraphicsSettingsStrings.ChangeRendererConfirmation, () => game?.AttemptExit(), () => + if (game?.RestartAppWhenExited() == true) { - renderer.Value = automaticRendererInUse ? RendererType.Automatic : host.ResolvedRenderer; - })); + game.AttemptExit(); + } + else + { + dialogOverlay?.Push(new ConfirmDialog(GraphicsSettingsStrings.ChangeRendererConfirmation, () => game?.AttemptExit(), () => + { + renderer.Value = automaticRendererInUse ? RendererType.Automatic : host.ResolvedRenderer; + })); + } }); // TODO: remove this once we support SDL+android. diff --git a/osu.Game/Screens/Menu/ConfirmExitDialog.cs b/osu.Game/Screens/Menu/ConfirmExitDialog.cs index 4906232d21..0041d047bd 100644 --- a/osu.Game/Screens/Menu/ConfirmExitDialog.cs +++ b/osu.Game/Screens/Menu/ConfirmExitDialog.cs @@ -2,38 +2,80 @@ // See the LICENCE file in the repository root for full licence text. using System; +using osu.Framework.Allocation; using osu.Framework.Graphics.Sprites; +using osu.Game.Localisation; +using osu.Game.Overlays; using osu.Game.Overlays.Dialog; namespace osu.Game.Screens.Menu { public partial class ConfirmExitDialog : PopupDialog { + private readonly Action onConfirm; + private readonly Action? onCancel; + /// /// Construct a new exit confirmation dialog. /// /// An action to perform on confirmation. /// An optional action to perform on cancel. public ConfirmExitDialog(Action onConfirm, Action? onCancel = null) + { + this.onConfirm = onConfirm; + this.onCancel = onCancel; + } + + [BackgroundDependencyLoader] + private void load(INotificationOverlay notifications) { HeaderText = "Are you sure you want to exit osu!?"; - BodyText = "Last chance to turn back"; Icon = FontAwesome.Solid.ExclamationTriangle; - Buttons = new PopupDialogButton[] + if (notifications.HasOngoingOperations) { - new PopupDialogOkButton + string text = "There are currently some background operations which will be aborted if you continue:\n\n"; + + foreach (var n in notifications.OngoingOperations) + text += $"{n.Text} ({n.Progress:0%})\n"; + + text += "\nLast chance to turn back"; + + BodyText = text; + + Buttons = new PopupDialogButton[] { - Text = @"Let me out!", - Action = onConfirm - }, - new PopupDialogCancelButton + new PopupDialogDangerousButton + { + Text = @"Let me out!", + Action = onConfirm + }, + new PopupDialogCancelButton + { + Text = CommonStrings.Back, + Action = onCancel + }, + }; + } + else + { + BodyText = "Last chance to turn back"; + + Buttons = new PopupDialogButton[] { - Text = @"Just a little more...", - Action = onCancel - }, - }; + new PopupDialogOkButton + { + Text = @"Let me out!", + Action = onConfirm + }, + new PopupDialogCancelButton + { + Text = @"Just a little more...", + Action = onCancel + }, + }; + } } } } diff --git a/osu.Game/Screens/Menu/ExitConfirmOverlay.cs b/osu.Game/Screens/Menu/HoldToExitGameOverlay.cs similarity index 87% rename from osu.Game/Screens/Menu/ExitConfirmOverlay.cs rename to osu.Game/Screens/Menu/HoldToExitGameOverlay.cs index 64b9bd52e8..f3642f2175 100644 --- a/osu.Game/Screens/Menu/ExitConfirmOverlay.cs +++ b/osu.Game/Screens/Menu/HoldToExitGameOverlay.cs @@ -8,13 +8,13 @@ using osu.Game.Overlays; namespace osu.Game.Screens.Menu { - public partial class ExitConfirmOverlay : HoldToConfirmOverlay, IKeyBindingHandler + public partial class HoldToExitGameOverlay : HoldToConfirmOverlay, IKeyBindingHandler { protected override bool AllowMultipleFires => true; public void Abort() => AbortConfirm(); - public ExitConfirmOverlay() + public HoldToExitGameOverlay() : base(0.7f) { } diff --git a/osu.Game/Screens/Menu/MainMenu.cs b/osu.Game/Screens/Menu/MainMenu.cs index 69b8596474..f34a1954a5 100644 --- a/osu.Game/Screens/Menu/MainMenu.cs +++ b/osu.Game/Screens/Menu/MainMenu.cs @@ -54,14 +54,20 @@ namespace osu.Game.Screens.Menu private GameHost host { get; set; } [Resolved] - private MusicController musicController { get; set; } + private INotificationOverlay notifications { get; set; } - [Resolved(canBeNull: true)] - private LoginOverlay login { get; set; } + [Resolved] + private MusicController musicController { get; set; } [Resolved] private IAPIProvider api { get; set; } + [Resolved] + private Storage storage { get; set; } + + [Resolved(canBeNull: true)] + private LoginOverlay login { get; set; } + [Resolved(canBeNull: true)] private IDialogOverlay dialogOverlay { get; set; } @@ -72,7 +78,10 @@ namespace osu.Game.Screens.Menu private Bindable holdDelay; private Bindable loginDisplayed; - private ExitConfirmOverlay exitConfirmOverlay; + private HoldToExitGameOverlay holdToExitGameOverlay; + + private bool exitConfirmedViaDialog; + private bool exitConfirmedViaHoldOrClick; private ParallaxContainer buttonsContainer; private SongTicker songTicker; @@ -85,14 +94,12 @@ namespace osu.Game.Screens.Menu if (host.CanExit) { - AddInternal(exitConfirmOverlay = new ExitConfirmOverlay + AddInternal(holdToExitGameOverlay = new HoldToExitGameOverlay { Action = () => { - if (holdDelay.Value > 0) - confirmAndExit(); - else - this.Exit(); + exitConfirmedViaHoldOrClick = holdDelay.Value > 0; + this.Exit(); } }); } @@ -114,7 +121,11 @@ namespace osu.Game.Screens.Menu OnSolo = loadSoloSongSelect, OnMultiplayer = () => this.Push(new Multiplayer()), OnPlaylists = () => this.Push(new Playlists()), - OnExit = confirmAndExit, + OnExit = () => + { + exitConfirmedViaHoldOrClick = true; + this.Exit(); + } } } }, @@ -125,7 +136,7 @@ namespace osu.Game.Screens.Menu Origin = Anchor.TopRight, Margin = new MarginPadding { Right = 15, Top = 5 } }, - exitConfirmOverlay?.CreateProxy() ?? Empty() + holdToExitGameOverlay?.CreateProxy() ?? Empty() }); Buttons.StateChanged += state => @@ -149,19 +160,8 @@ namespace osu.Game.Screens.Menu preloadSongSelect(); } - [Resolved(canBeNull: true)] - private IPerformFromScreenRunner performer { get; set; } - public void ReturnToOsuLogo() => Buttons.State = ButtonSystemState.Initial; - private void confirmAndExit() - { - if (exitConfirmed) return; - - exitConfirmed = true; - performer?.PerformFromScreen(menu => menu.Exit()); - } - private void preloadSongSelect() { if (songSelect == null) @@ -177,9 +177,6 @@ namespace osu.Game.Screens.Menu return s; } - [Resolved] - private Storage storage { get; set; } - public override void OnEntering(ScreenTransitionEvent e) { base.OnEntering(e); @@ -201,8 +198,6 @@ namespace osu.Game.Screens.Menu dialogOverlay?.Push(new StorageErrorDialog(osuStorage, osuStorage.Error)); } - private bool exitConfirmed; - protected override void LogoArriving(OsuLogo logo, bool resuming) { base.LogoArriving(logo, resuming); @@ -279,12 +274,29 @@ namespace osu.Game.Screens.Menu public override bool OnExiting(ScreenExitEvent e) { - if (!exitConfirmed && dialogOverlay != null) + bool requiresConfirmation = + // we need to have a dialog overlay to confirm in the first place. + dialogOverlay != null + // if the dialog has already displayed and been accepted by the user, we are good. + && !exitConfirmedViaDialog + // Only require confirmation if there is either an ongoing operation or the user exited via a non-hold escape press. + && (notifications.HasOngoingOperations || !exitConfirmedViaHoldOrClick); + + if (requiresConfirmation) { if (dialogOverlay.CurrentDialog is ConfirmExitDialog exitDialog) exitDialog.PerformOkAction(); else - dialogOverlay.Push(new ConfirmExitDialog(confirmAndExit, () => exitConfirmOverlay.Abort())); + { + dialogOverlay.Push(new ConfirmExitDialog(() => + { + exitConfirmedViaDialog = true; + this.Exit(); + }, () => + { + holdToExitGameOverlay.Abort(); + })); + } return true; }