diff --git a/osu.Game.Tests/Visual/Updater/TestSceneWindowsAssociationManager.cs b/osu.Game.Tests/Visual/Updater/TestSceneWindowsAssociationManager.cs new file mode 100644 index 0000000000..72256860fd --- /dev/null +++ b/osu.Game.Tests/Visual/Updater/TestSceneWindowsAssociationManager.cs @@ -0,0 +1,106 @@ +// 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.Diagnostics; +using System.IO; +using System.Runtime.Versioning; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Platform; +using osu.Game.Graphics.Sprites; +using osu.Game.Tests.Resources; +using osu.Game.Updater; + +namespace osu.Game.Tests.Visual.Updater +{ + [SupportedOSPlatform("windows")] + [Ignore("These tests modify the windows registry and open programs")] + public partial class TestSceneWindowsAssociationManager : OsuTestScene + { + private static readonly string exe_path = Path.ChangeExtension(typeof(TestSceneWindowsAssociationManager).Assembly.Location, ".exe"); + + [Resolved] + private GameHost host { get; set; } = null!; + + private readonly WindowsAssociationManager associationManager; + + public TestSceneWindowsAssociationManager() + { + Children = new Drawable[] + { + new OsuSpriteText { Text = Environment.CommandLine }, + associationManager = new WindowsAssociationManager(exe_path, "osu.Test"), + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + if (Environment.CommandLine.Contains(".osz", StringComparison.Ordinal)) + ChangeBackgroundColour(ColourInfo.SingleColour(Colour4.DarkOliveGreen)); + + if (Environment.CommandLine.Contains("osu://", StringComparison.Ordinal)) + ChangeBackgroundColour(ColourInfo.SingleColour(Colour4.DarkBlue)); + + if (Environment.CommandLine.Contains("osump://", StringComparison.Ordinal)) + ChangeBackgroundColour(ColourInfo.SingleColour(Colour4.DarkRed)); + } + + [Test] + public void TestInstall() + { + AddStep("install", () => associationManager.InstallAssociations()); + } + + [Test] + public void TestOpenBeatmap() + { + string beatmapPath = null!; + AddStep("create temp beatmap", () => beatmapPath = TestResources.GetTestBeatmapForImport()); + AddAssert("beatmap path ends with .osz", () => beatmapPath, () => Does.EndWith(".osz")); + AddStep("open beatmap", () => host.OpenFileExternally(beatmapPath)); + AddUntilStep("wait for focus", () => host.IsActive.Value); + AddStep("delete temp beatmap", () => File.Delete(beatmapPath)); + } + + /// + /// To check that the icon is correct + /// + [Test] + public void TestPresentBeatmap() + { + string beatmapPath = null!; + AddStep("create temp beatmap", () => beatmapPath = TestResources.GetTestBeatmapForImport()); + AddAssert("beatmap path ends with .osz", () => beatmapPath, () => Does.EndWith(".osz")); + AddStep("show beatmap in explorer", () => host.PresentFileExternally(beatmapPath)); + AddUntilStep("wait for focus", () => host.IsActive.Value); + AddStep("delete temp beatmap", () => File.Delete(beatmapPath)); + } + + [TestCase("osu://s/1")] + [TestCase("osump://123")] + public void TestUrl(string url) + { + AddStep($"open {url}", () => Process.Start(new ProcessStartInfo(url) { UseShellExecute = true })); + } + + [Test] + public void TestUninstall() + { + AddStep("uninstall", () => associationManager.UninstallAssociations()); + } + + /// + /// Useful when testing things out and manually changing the registry. + /// + [Test] + public void TestNotifyShell() + { + AddStep("notify shell of changes", () => associationManager.NotifyShellUpdate()); + } + } +} diff --git a/osu.Game/Localisation/WindowsAssociationManagerStrings.cs b/osu.Game/Localisation/WindowsAssociationManagerStrings.cs new file mode 100644 index 0000000000..95a6decdd6 --- /dev/null +++ b/osu.Game/Localisation/WindowsAssociationManagerStrings.cs @@ -0,0 +1,39 @@ +// 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.Localisation; + +namespace osu.Game.Localisation +{ + public static class WindowsAssociationManagerStrings + { + private const string prefix = @"osu.Game.Resources.Localisation.WindowsAssociationManager"; + + /// + /// "osu! Beatmap" + /// + public static LocalisableString OsuBeatmap => new TranslatableString(getKey(@"osu_beatmap"), @"osu! Beatmap"); + + /// + /// "osu! Replay" + /// + public static LocalisableString OsuReplay => new TranslatableString(getKey(@"osu_replay"), @"osu! Replay"); + + /// + /// "osu! Skin" + /// + public static LocalisableString OsuSkin => new TranslatableString(getKey(@"osu_skin"), @"osu! Skin"); + + /// + /// "osu!" + /// + public static LocalisableString OsuProtocol => new TranslatableString(getKey(@"osu_protocol"), @"osu!"); + + /// + /// "osu! Multiplayer" + /// + public static LocalisableString OsuMultiplayer => new TranslatableString(getKey(@"osu_multiplayer"), @"osu! Multiplayer"); + + private static string getKey(string key) => $@"{prefix}:{key}"; + } +} \ No newline at end of file diff --git a/osu.Game/Updater/WindowsAssociationManager.cs b/osu.Game/Updater/WindowsAssociationManager.cs new file mode 100644 index 0000000000..8949d88362 --- /dev/null +++ b/osu.Game/Updater/WindowsAssociationManager.cs @@ -0,0 +1,268 @@ +// 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.Runtime.InteropServices; +using System.Runtime.Versioning; +using Microsoft.Win32; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Localisation; +using osu.Framework.Logging; +using osu.Game.Resources.Icons; +using osu.Game.Localisation; + +namespace osu.Game.Updater +{ + [SupportedOSPlatform("windows")] + public partial class WindowsAssociationManager : Component + { + public const string SOFTWARE_CLASSES = @"Software\Classes"; + + /// + /// Sub key for setting the icon. + /// https://learn.microsoft.com/en-us/windows/win32/com/defaulticon + /// + public const string DEFAULT_ICON = @"DefaultIcon"; + + /// + /// Sub key for setting the command line that the shell invokes. + /// https://learn.microsoft.com/en-us/windows/win32/com/shell + /// + public const string SHELL_OPEN_COMMAND = @"Shell\Open\Command"; + + private static readonly FileAssociation[] file_associations = + { + new FileAssociation(@".osz", WindowsAssociationManagerStrings.OsuBeatmap, Icons.Lazer), + new FileAssociation(@".olz", WindowsAssociationManagerStrings.OsuBeatmap, Icons.Lazer), + new FileAssociation(@".osr", WindowsAssociationManagerStrings.OsuReplay, Icons.Lazer), + new FileAssociation(@".osk", WindowsAssociationManagerStrings.OsuSkin, Icons.Lazer), + }; + + private static readonly UriAssociation[] uri_associations = + { + new UriAssociation(@"osu", WindowsAssociationManagerStrings.OsuProtocol, Icons.Lazer), + new UriAssociation(@"osump", WindowsAssociationManagerStrings.OsuMultiplayer, Icons.Lazer), + }; + + [Resolved] + private LocalisationManager localisation { get; set; } = null!; + + private IBindable localisationParameters = null!; + + private readonly string exePath; + private readonly string programIdPrefix; + + /// Path to the executable to register. + /// + /// Program ID prefix used for file associations. Should be relatively short since the full program ID has a 39 character limit, + /// see https://learn.microsoft.com/en-us/windows/win32/com/-progid--key. + /// + public WindowsAssociationManager(string exePath, string programIdPrefix) + { + this.exePath = exePath; + this.programIdPrefix = programIdPrefix; + } + + [BackgroundDependencyLoader] + private void load() + { + localisationParameters = localisation.CurrentParameters.GetBoundCopy(); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + localisationParameters.ValueChanged += _ => updateDescriptions(); + } + + internal void InstallAssociations() + { + try + { + using (var classes = Registry.CurrentUser.OpenSubKey(SOFTWARE_CLASSES, writable: true)) + { + if (classes == null) + return; + + foreach (var association in file_associations) + association.Install(classes, exePath, programIdPrefix); + + foreach (var association in uri_associations) + association.Install(classes, exePath); + } + + updateDescriptions(); + } + catch (Exception e) + { + Logger.Log(@$"Failed to install file and URI associations: {e.Message}"); + } + } + + private void updateDescriptions() + { + try + { + using var classes = Registry.CurrentUser.OpenSubKey(SOFTWARE_CLASSES, true); + if (classes == null) + return; + + foreach (var association in file_associations) + { + var b = localisation.GetLocalisedBindableString(association.Description); + association.UpdateDescription(classes, programIdPrefix, b.Value); + b.UnbindAll(); + } + + foreach (var association in uri_associations) + { + var b = localisation.GetLocalisedBindableString(association.Description); + association.UpdateDescription(classes, b.Value); + b.UnbindAll(); + } + + NotifyShellUpdate(); + } + catch (Exception e) + { + Logger.Log($@"Failed to update file and URI associations: {e.Message}"); + } + } + + internal void UninstallAssociations() + { + try + { + using var classes = Registry.CurrentUser.OpenSubKey(SOFTWARE_CLASSES, true); + if (classes == null) + return; + + foreach (var association in file_associations) + association.Uninstall(classes, programIdPrefix); + + foreach (var association in uri_associations) + association.Uninstall(classes); + + NotifyShellUpdate(); + } + catch (Exception e) + { + Logger.Log($@"Failed to uninstall file and URI associations: {e.Message}"); + } + } + + internal void NotifyShellUpdate() => SHChangeNotify(EventId.SHCNE_ASSOCCHANGED, Flags.SHCNF_IDLIST, IntPtr.Zero, IntPtr.Zero); + + #region Native interop + + [DllImport("Shell32.dll")] + private static extern void SHChangeNotify(EventId wEventId, Flags uFlags, IntPtr dwItem1, IntPtr dwItem2); + + private enum EventId + { + /// + /// A file type association has changed. must be specified in the uFlags parameter. + /// dwItem1 and dwItem2 are not used and must be . This event should also be sent for registered protocols. + /// + SHCNE_ASSOCCHANGED = 0x08000000 + } + + private enum Flags : uint + { + SHCNF_IDLIST = 0x0000 + } + + #endregion + + private record FileAssociation(string Extension, LocalisableString Description, Win32Icon Icon) + { + private string getProgramId(string prefix) => $@"{prefix}.File{Extension}"; + + /// + /// Installs a file extenstion association in accordance with https://learn.microsoft.com/en-us/windows/win32/com/-progid--key + /// + public void Install(RegistryKey classes, string exePath, string programIdPrefix) + { + string programId = getProgramId(programIdPrefix); + + // register a program id for the given extension + using (var programKey = classes.CreateSubKey(programId)) + { + using (var defaultIconKey = programKey.CreateSubKey(DEFAULT_ICON)) + defaultIconKey.SetValue(null, Icon.RegistryString); + + using (var openCommandKey = programKey.CreateSubKey(SHELL_OPEN_COMMAND)) + openCommandKey.SetValue(null, $@"""{exePath}"" ""%1"""); + } + + using (var extensionKey = classes.CreateSubKey(Extension)) + { + // set ourselves as the default program + extensionKey.SetValue(null, programId); + + // add to the open with dialog + // https://learn.microsoft.com/en-us/windows/win32/shell/how-to-include-an-application-on-the-open-with-dialog-box + using (var openWithKey = extensionKey.CreateSubKey(@"OpenWithProgIds")) + openWithKey.SetValue(programId, string.Empty); + } + } + + public void UpdateDescription(RegistryKey classes, string programIdPrefix, string description) + { + using (var programKey = classes.OpenSubKey(getProgramId(programIdPrefix), true)) + programKey?.SetValue(null, description); + } + + public void Uninstall(RegistryKey classes, string programIdPrefix) + { + string programId = getProgramId(programIdPrefix); + + // importantly, we don't delete the default program entry because some other program could have taken it. + + using (var extensionKey = classes.OpenSubKey($@"{Extension}\OpenWithProgIds", true)) + extensionKey?.DeleteValue(programId, throwOnMissingValue: false); + + classes.DeleteSubKeyTree(programId, throwOnMissingSubKey: false); + } + } + + private record UriAssociation(string Protocol, LocalisableString Description, Win32Icon Icon) + { + /// + /// "The URL Protocol string value indicates that this key declares a custom pluggable protocol handler." + /// See https://learn.microsoft.com/en-us/previous-versions/windows/internet-explorer/ie-developer/platform-apis/aa767914(v=vs.85). + /// + public const string URL_PROTOCOL = @"URL Protocol"; + + /// + /// Registers an URI protocol handler in accordance with https://learn.microsoft.com/en-us/previous-versions/windows/internet-explorer/ie-developer/platform-apis/aa767914(v=vs.85). + /// + public void Install(RegistryKey classes, string exePath) + { + using (var protocolKey = classes.CreateSubKey(Protocol)) + { + protocolKey.SetValue(URL_PROTOCOL, string.Empty); + + using (var defaultIconKey = protocolKey.CreateSubKey(DEFAULT_ICON)) + defaultIconKey.SetValue(null, Icon.RegistryString); + + using (var openCommandKey = protocolKey.CreateSubKey(SHELL_OPEN_COMMAND)) + openCommandKey.SetValue(null, $@"""{exePath}"" ""%1"""); + } + } + + public void UpdateDescription(RegistryKey classes, string description) + { + using (var protocolKey = classes.OpenSubKey(Protocol, true)) + protocolKey?.SetValue(null, $@"URL:{description}"); + } + + public void Uninstall(RegistryKey classes) + { + classes.DeleteSubKeyTree(Protocol, throwOnMissingSubKey: false); + } + } + } +} diff --git a/osu.sln.DotSettings b/osu.sln.DotSettings index 1bf8aa7b0b..e2e28c38ec 100644 --- a/osu.sln.DotSettings +++ b/osu.sln.DotSettings @@ -1005,6 +1005,7 @@ private void load() True True True + True True True True