diff --git a/osu.Desktop/OsuGameDesktop.cs b/osu.Desktop/OsuGameDesktop.cs index e04376e559..314a03a73e 100644 --- a/osu.Desktop/OsuGameDesktop.cs +++ b/osu.Desktop/OsuGameDesktop.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System; using System.Collections.Generic; using System.Diagnostics; @@ -26,6 +24,7 @@ using osu.Framework.Input.Handlers.Mouse; using osu.Framework.Input.Handlers.Tablet; using osu.Framework.Threading; using osu.Game.IO; +using osu.Game.IPC; using osu.Game.Overlays.Settings; using osu.Game.Overlays.Settings.Sections.Input; @@ -33,18 +32,20 @@ namespace osu.Desktop { internal class OsuGameDesktop : OsuGame { - public OsuGameDesktop(string[] args = null) + private OsuSchemeLinkIPCChannel? osuSchemeLinkIPCChannel; + + public OsuGameDesktop(string[]? args = null) : base(args) { } - public override StableStorage GetStorageForStableInstall() + public override StableStorage? GetStorageForStableInstall() { try { if (Host is DesktopGameHost desktopHost) { - string stablePath = getStableInstallPath(); + string? stablePath = getStableInstallPath(); if (!string.IsNullOrEmpty(stablePath)) return new StableStorage(stablePath, desktopHost); } @@ -57,11 +58,11 @@ namespace osu.Desktop return null; } - private string getStableInstallPath() + private string? getStableInstallPath() { static bool checkExists(string p) => Directory.Exists(Path.Combine(p, "Songs")) || File.Exists(Path.Combine(p, "osu!.cfg")); - string stableInstallPath; + string? stableInstallPath; if (OperatingSystem.IsWindows()) { @@ -89,15 +90,15 @@ namespace osu.Desktop } [SupportedOSPlatform("windows")] - private string getStableInstallPathFromRegistry() + private string? getStableInstallPathFromRegistry() { - using (RegistryKey key = Registry.ClassesRoot.OpenSubKey("osu")) + using (RegistryKey? key = Registry.ClassesRoot.OpenSubKey("osu")) return key?.OpenSubKey(@"shell\open\command")?.GetValue(string.Empty)?.ToString()?.Split('"')[1].Replace("osu!.exe", ""); } protected override UpdateManager CreateUpdateManager() { - string packageManaged = Environment.GetEnvironmentVariable("OSU_EXTERNAL_UPDATE_PROVIDER"); + string? packageManaged = Environment.GetEnvironmentVariable("OSU_EXTERNAL_UPDATE_PROVIDER"); if (!string.IsNullOrEmpty(packageManaged)) return new NoActionUpdateManager(); @@ -124,6 +125,8 @@ namespace osu.Desktop LoadComponentAsync(new GameplayWinKeyBlocker(), Add); LoadComponentAsync(new ElevatedPrivilegesChecker(), Add); + + osuSchemeLinkIPCChannel = new OsuSchemeLinkIPCChannel(Host, this); } public override void SetHost(GameHost host) @@ -159,7 +162,7 @@ namespace osu.Desktop } private readonly List importableFiles = new List(); - private ScheduledDelegate importSchedule; + private ScheduledDelegate? importSchedule; private void fileDrop(string[] filePaths) { @@ -192,5 +195,11 @@ namespace osu.Desktop Task.Factory.StartNew(() => Import(paths), TaskCreationOptions.LongRunning); } } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + osuSchemeLinkIPCChannel?.Dispose(); + } } } diff --git a/osu.Desktop/Program.cs b/osu.Desktop/Program.cs index 4ba9cc6a90..7a5809e915 100644 --- a/osu.Desktop/Program.cs +++ b/osu.Desktop/Program.cs @@ -11,6 +11,7 @@ using osu.Framework; using osu.Framework.Development; using osu.Framework.Logging; using osu.Framework.Platform; +using osu.Game; using osu.Game.IPC; using osu.Game.Tournament; using Squirrel; @@ -65,19 +66,8 @@ namespace osu.Desktop { if (!host.IsPrimaryInstance) { - if (args.Length > 0 && args[0].Contains('.')) // easy way to check for a file import in args - { - var importer = new ArchiveImportIPCChannel(host); - - foreach (string file in args) - { - Console.WriteLine(@"Importing {0}", file); - if (!importer.ImportAsync(Path.GetFullPath(file, cwd)).Wait(3000)) - throw new TimeoutException(@"IPC took too long to send"); - } - + if (trySendIPCMessage(host, cwd, args)) return; - } // we want to allow multiple instances to be started when in debug. if (!DebugUtils.IsDebugBuild) @@ -108,6 +98,34 @@ namespace osu.Desktop } } + private static bool trySendIPCMessage(IIpcHost host, string cwd, string[] args) + { + if (args.Length == 1 && args[0].StartsWith(OsuGameBase.OSU_PROTOCOL, StringComparison.Ordinal)) + { + var osuSchemeLinkHandler = new OsuSchemeLinkIPCChannel(host); + if (!osuSchemeLinkHandler.HandleLinkAsync(args[0]).Wait(3000)) + throw new IPCTimeoutException(osuSchemeLinkHandler.GetType()); + + return true; + } + + if (args.Length > 0 && args[0].Contains('.')) // easy way to check for a file import in args + { + var importer = new ArchiveImportIPCChannel(host); + + foreach (string file in args) + { + Console.WriteLine(@"Importing {0}", file); + if (!importer.ImportAsync(Path.GetFullPath(file, cwd)).Wait(3000)) + throw new IPCTimeoutException(importer.GetType()); + } + + return true; + } + + return false; + } + [SupportedOSPlatform("windows")] private static void setupSquirrel() { diff --git a/osu.Game.Tests/Visual/Navigation/TestSceneInterProcessCommunication.cs b/osu.Game.Tests/Visual/Navigation/TestSceneInterProcessCommunication.cs new file mode 100644 index 0000000000..1c2b1fe37d --- /dev/null +++ b/osu.Game.Tests/Visual/Navigation/TestSceneInterProcessCommunication.cs @@ -0,0 +1,86 @@ +// Copyright (c) ppy Pty Ltd . 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; +using osu.Framework.Allocation; +using osu.Framework.Extensions; +using osu.Framework.Graphics.Containers; +using osu.Framework.Platform; +using osu.Framework.Testing; +using osu.Game.IPC; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Overlays; + +namespace osu.Game.Tests.Visual.Navigation +{ + [TestFixture] + [Ignore("This test cannot be run headless, as it requires the game host running the nested game to have IPC bound.")] + public class TestSceneInterProcessCommunication : OsuGameTestScene + { + private HeadlessGameHost ipcSenderHost = null!; + + private OsuSchemeLinkIPCChannel osuSchemeLinkIPCReceiver = null!; + private OsuSchemeLinkIPCChannel osuSchemeLinkIPCSender = null!; + + private const int requested_beatmap_set_id = 1; + + [Resolved] + private GameHost gameHost { get; set; } = null!; + + public override void SetUpSteps() + { + base.SetUpSteps(); + AddStep("set up request handling", () => + { + ((DummyAPIAccess)API).HandleRequest = request => + { + switch (request) + { + case GetBeatmapSetRequest gbr: + + var apiBeatmapSet = CreateAPIBeatmapSet(); + apiBeatmapSet.OnlineID = requested_beatmap_set_id; + apiBeatmapSet.Beatmaps = apiBeatmapSet.Beatmaps.Append(new APIBeatmap + { + DifficultyName = "Target difficulty", + OnlineID = 75, + }).ToArray(); + gbr.TriggerSuccess(apiBeatmapSet); + return true; + } + + return false; + }; + }); + AddStep("create IPC receiver channel", () => osuSchemeLinkIPCReceiver = new OsuSchemeLinkIPCChannel(gameHost, Game)); + AddStep("create IPC sender channel", () => + { + ipcSenderHost = new HeadlessGameHost(gameHost.Name, new HostOptions { BindIPC = true }); + osuSchemeLinkIPCSender = new OsuSchemeLinkIPCChannel(ipcSenderHost); + }); + } + + [Test] + public void TestOsuSchemeLinkIPCChannel() + { + AddStep("open beatmap via IPC", () => osuSchemeLinkIPCSender.HandleLinkAsync($@"osu://s/{requested_beatmap_set_id}").WaitSafely()); + AddUntilStep("beatmap overlay displayed", () => Game.ChildrenOfType().FirstOrDefault()?.State.Value == Visibility.Visible); + AddUntilStep("beatmap overlay showing content", () => Game.ChildrenOfType().FirstOrDefault()?.Header.BeatmapSet.Value.OnlineID == requested_beatmap_set_id); + } + + public override void TearDownSteps() + { + AddStep("dispose IPC receiver", () => osuSchemeLinkIPCReceiver.Dispose()); + AddStep("dispose IPC sender", () => + { + osuSchemeLinkIPCSender.Dispose(); + ipcSenderHost.Dispose(); + }); + base.TearDownSteps(); + } + } +} diff --git a/osu.Game/Graphics/Containers/LinkFlowContainer.cs b/osu.Game/Graphics/Containers/LinkFlowContainer.cs index a67e5ae66f..bf96695fd3 100644 --- a/osu.Game/Graphics/Containers/LinkFlowContainer.cs +++ b/osu.Game/Graphics/Containers/LinkFlowContainer.cs @@ -13,6 +13,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Localisation; using osu.Framework.Platform; +using osu.Game.Online; using osu.Game.Users; namespace osu.Game.Graphics.Containers @@ -25,7 +26,7 @@ namespace osu.Game.Graphics.Containers } [Resolved(CanBeNull = true)] - private OsuGame game { get; set; } + private ILinkHandler linkHandler { get; set; } [Resolved] private GameHost host { get; set; } @@ -81,8 +82,8 @@ namespace osu.Game.Graphics.Containers { if (action != null) action(); - else if (game != null) - game.HandleLink(link); + else if (linkHandler != null) + linkHandler.HandleLink(link); // fallback to handle cases where OsuGame is not available, ie. tournament client. else if (link.Action == LinkAction.External) host.OpenUrlExternally(link.Argument.ToString()); diff --git a/osu.Game/Graphics/Containers/Markdown/OsuMarkdownLinkText.cs b/osu.Game/Graphics/Containers/Markdown/OsuMarkdownLinkText.cs index dca9e3de6e..3a16eb0a0f 100644 --- a/osu.Game/Graphics/Containers/Markdown/OsuMarkdownLinkText.cs +++ b/osu.Game/Graphics/Containers/Markdown/OsuMarkdownLinkText.cs @@ -8,6 +8,7 @@ using Markdig.Syntax.Inlines; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers.Markdown; +using osu.Game.Online; using osu.Game.Online.Chat; using osu.Game.Overlays; @@ -16,7 +17,7 @@ namespace osu.Game.Graphics.Containers.Markdown public class OsuMarkdownLinkText : MarkdownLinkText { [Resolved(canBeNull: true)] - private OsuGame game { get; set; } + private ILinkHandler linkHandler { get; set; } private readonly string text; private readonly string title; @@ -51,7 +52,7 @@ namespace osu.Game.Graphics.Containers.Markdown }; } - protected override void OnLinkPressed() => game?.HandleLink(Url); + protected override void OnLinkPressed() => linkHandler?.HandleLink(Url); private class OsuMarkdownLinkCompiler : DrawableLinkCompiler { diff --git a/osu.Game/IPC/IPCTimeoutException.cs b/osu.Game/IPC/IPCTimeoutException.cs new file mode 100644 index 0000000000..d820184468 --- /dev/null +++ b/osu.Game/IPC/IPCTimeoutException.cs @@ -0,0 +1,15 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; + +namespace osu.Game.IPC +{ + public class IPCTimeoutException : TimeoutException + { + public IPCTimeoutException(Type channelType) + : base($@"IPC took too long to send message via channel {channelType}") + { + } + } +} diff --git a/osu.Game/IPC/OsuSchemeLinkIPCChannel.cs b/osu.Game/IPC/OsuSchemeLinkIPCChannel.cs new file mode 100644 index 0000000000..33318e329c --- /dev/null +++ b/osu.Game/IPC/OsuSchemeLinkIPCChannel.cs @@ -0,0 +1,49 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Diagnostics; +using System.Threading.Tasks; +using osu.Framework.Platform; +using osu.Game.Online; + +namespace osu.Game.IPC +{ + public class OsuSchemeLinkIPCChannel : IpcChannel + { + private readonly ILinkHandler? linkHandler; + + public OsuSchemeLinkIPCChannel(IIpcHost host, ILinkHandler? linkHandler = null) + : base(host) + { + this.linkHandler = linkHandler; + + MessageReceived += msg => + { + Debug.Assert(linkHandler != null); + linkHandler.HandleLink(msg.Link); + return null; + }; + } + + public async Task HandleLinkAsync(string url) + { + if (linkHandler == null) + { + await SendMessageAsync(new OsuSchemeLinkMessage(url)).ConfigureAwait(false); + return; + } + + linkHandler.HandleLink(url); + } + } + + public class OsuSchemeLinkMessage + { + public string Link { get; } + + public OsuSchemeLinkMessage(string link) + { + Link = link; + } + } +} diff --git a/osu.Game/Online/ILinkHandler.cs b/osu.Game/Online/ILinkHandler.cs new file mode 100644 index 0000000000..1b8fad4bd9 --- /dev/null +++ b/osu.Game/Online/ILinkHandler.cs @@ -0,0 +1,30 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Game.Online.Chat; + +namespace osu.Game.Online +{ + /// + /// Handle an arbitrary URL. Displays via in-game overlays where possible. + /// Methods can be called from a non-thread-safe non-game-loaded state. + /// + [Cached] + public interface ILinkHandler + { + /// + /// Handle an arbitrary URL. Displays via in-game overlays where possible. + /// This can be called from a non-thread-safe non-game-loaded state. + /// + /// The URL to load. + void HandleLink(string url); + + /// + /// Handle a specific . + /// This can be called from a non-thread-safe non-game-loaded state. + /// + /// The link to load. + void HandleLink(LinkDetails link); + } +} diff --git a/osu.Game/OsuGame.cs b/osu.Game/OsuGame.cs index 4732a991a2..f51a18b36f 100644 --- a/osu.Game/OsuGame.cs +++ b/osu.Game/OsuGame.cs @@ -39,6 +39,7 @@ using osu.Game.Input; using osu.Game.Input.Bindings; using osu.Game.IO; using osu.Game.Localisation; +using osu.Game.Online; using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Chat; using osu.Game.Overlays; @@ -69,7 +70,7 @@ namespace osu.Game /// The full osu! experience. Builds on top of to add menus and binding logic /// for initial components that are generally retrieved via DI. /// - public class OsuGame : OsuGameBase, IKeyBindingHandler, ILocalUserPlayInfo, IPerformFromScreenRunner, IOverlayManager + public class OsuGame : OsuGameBase, IKeyBindingHandler, ILocalUserPlayInfo, IPerformFromScreenRunner, IOverlayManager, ILinkHandler { /// /// The amount of global offset to apply when a left/right anchored overlay is displayed (ie. settings or notifications). diff --git a/osu.Game/Tests/Visual/OsuGameTestScene.cs b/osu.Game/Tests/Visual/OsuGameTestScene.cs index df0bdb0a50..b9f6183869 100644 --- a/osu.Game/Tests/Visual/OsuGameTestScene.cs +++ b/osu.Game/Tests/Visual/OsuGameTestScene.cs @@ -75,7 +75,7 @@ namespace osu.Game.Tests.Visual } [TearDownSteps] - public void TearDownSteps() + public virtual void TearDownSteps() { if (DebugUtils.IsNUnitRunning && Game != null) {