diff --git a/osu.Desktop/OsuGameDesktop.cs b/osu.Desktop/OsuGameDesktop.cs index 5b5f5c2167..2b232db274 100644 --- a/osu.Desktop/OsuGameDesktop.cs +++ b/osu.Desktop/OsuGameDesktop.cs @@ -92,8 +92,8 @@ namespace osu.Desktop [SupportedOSPlatform("windows")] private string? getStableInstallPathFromRegistry() { - using (RegistryKey? key = Registry.ClassesRoot.OpenSubKey("osu")) - return key?.OpenSubKey(@"shell\open\command")?.GetValue(string.Empty)?.ToString()?.Split('"')[1].Replace("osu!.exe", ""); + using (RegistryKey? key = Registry.ClassesRoot.OpenSubKey("osu!")) + return key?.OpenSubKey(WindowsAssociationManager.SHELL_OPEN_COMMAND)?.GetValue(string.Empty)?.ToString()?.Split('"')[1].Replace("osu!.exe", ""); } protected override UpdateManager CreateUpdateManager() diff --git a/osu.Desktop/Program.cs b/osu.Desktop/Program.cs index 2201502e39..73670adc49 100644 --- a/osu.Desktop/Program.cs +++ b/osu.Desktop/Program.cs @@ -5,6 +5,7 @@ using System; using System.IO; using System.Runtime.Versioning; using osu.Desktop.LegacyIpc; +using osu.Desktop.Windows; using osu.Framework; using osu.Framework.Development; using osu.Framework.Logging; @@ -173,13 +174,16 @@ namespace osu.Desktop { tools.CreateShortcutForThisExe(); tools.CreateUninstallerRegistryEntry(); + WindowsAssociationManager.InstallAssociations(); }, onAppUpdate: (_, tools) => { tools.CreateUninstallerRegistryEntry(); + WindowsAssociationManager.UpdateAssociations(); }, onAppUninstall: (_, tools) => { tools.RemoveShortcutForThisExe(); tools.RemoveUninstallerRegistryEntry(); + WindowsAssociationManager.UninstallAssociations(); }, onEveryRun: (_, _, _) => { // While setting the `ProcessAppUserModelId` fixes duplicate icons/shortcuts on the taskbar, it currently diff --git a/osu.Desktop/Windows/Icons.cs b/osu.Desktop/Windows/Icons.cs new file mode 100644 index 0000000000..67915c101a --- /dev/null +++ b/osu.Desktop/Windows/Icons.cs @@ -0,0 +1,17 @@ +// 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; + +namespace osu.Desktop.Windows +{ + public static class Icons + { + /// + /// Fully qualified path to the directory that contains icons (in the installation folder). + /// + private static readonly string icon_directory = Path.GetDirectoryName(typeof(Icons).Assembly.Location)!; + + public static string Lazer => Path.Join(icon_directory, "lazer.ico"); + } +} diff --git a/osu.Desktop/Windows/WindowsAssociationManager.cs b/osu.Desktop/Windows/WindowsAssociationManager.cs new file mode 100644 index 0000000000..181403d287 --- /dev/null +++ b/osu.Desktop/Windows/WindowsAssociationManager.cs @@ -0,0 +1,288 @@ +// 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.IO; +using System.Runtime.InteropServices; +using System.Runtime.Versioning; +using Microsoft.Win32; +using osu.Framework.Localisation; +using osu.Framework.Logging; +using osu.Game.Localisation; + +namespace osu.Desktop.Windows +{ + [SupportedOSPlatform("windows")] + public static class WindowsAssociationManager + { + private const string software_classes = @"Software\Classes"; + + /// + /// Sub key for setting the icon. + /// https://learn.microsoft.com/en-us/windows/win32/com/defaulticon + /// + private 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 + /// + internal const string SHELL_OPEN_COMMAND = @"Shell\Open\Command"; + + private static readonly string exe_path = Path.ChangeExtension(typeof(WindowsAssociationManager).Assembly.Location, ".exe").Replace('/', '\\'); + + /// + /// 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. + /// + private const string program_id_prefix = "osu.File"; + + 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), + }; + + /// + /// Installs file and URI associations. + /// + /// + /// Call in a timely fashion to keep descriptions up-to-date and localised. + /// + public static void InstallAssociations() + { + try + { + updateAssociations(); + updateDescriptions(null); // write default descriptions in case `UpdateDescriptions()` is not called. + NotifyShellUpdate(); + } + catch (Exception e) + { + Logger.Error(e, @$"Failed to install file and URI associations: {e.Message}"); + } + } + + /// + /// Updates associations with latest definitions. + /// + /// + /// Call in a timely fashion to keep descriptions up-to-date and localised. + /// + public static void UpdateAssociations() + { + try + { + updateAssociations(); + NotifyShellUpdate(); + } + catch (Exception e) + { + Logger.Error(e, @"Failed to update file and URI associations."); + } + } + + public static void UpdateDescriptions(LocalisationManager localisationManager) + { + try + { + updateDescriptions(localisationManager); + NotifyShellUpdate(); + } + catch (Exception e) + { + Logger.Error(e, @"Failed to update file and URI association descriptions."); + } + } + + public static void UninstallAssociations() + { + try + { + foreach (var association in file_associations) + association.Uninstall(); + + foreach (var association in uri_associations) + association.Uninstall(); + + NotifyShellUpdate(); + } + catch (Exception e) + { + Logger.Error(e, @"Failed to uninstall file and URI associations."); + } + } + + public static void NotifyShellUpdate() => SHChangeNotify(EventId.SHCNE_ASSOCCHANGED, Flags.SHCNF_IDLIST, IntPtr.Zero, IntPtr.Zero); + + /// + /// Installs or updates associations. + /// + private static void updateAssociations() + { + foreach (var association in file_associations) + association.Install(); + + foreach (var association in uri_associations) + association.Install(); + } + + private static void updateDescriptions(LocalisationManager? localisation) + { + foreach (var association in file_associations) + association.UpdateDescription(getLocalisedString(association.Description)); + + foreach (var association in uri_associations) + association.UpdateDescription(getLocalisedString(association.Description)); + + string getLocalisedString(LocalisableString s) + { + if (localisation == null) + return s.ToString(); + + var b = localisation.GetLocalisedBindableString(s); + b.UnbindAll(); + return b.Value; + } + } + + #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, string IconPath) + { + private string programId => $@"{program_id_prefix}{Extension}"; + + /// + /// Installs a file extension association in accordance with https://learn.microsoft.com/en-us/windows/win32/com/-progid--key + /// + public void Install() + { + using var classes = Registry.CurrentUser.OpenSubKey(software_classes, true); + if (classes == null) return; + + // register a program id for the given extension + using (var programKey = classes.CreateSubKey(programId)) + { + using (var defaultIconKey = programKey.CreateSubKey(default_icon)) + defaultIconKey.SetValue(null, IconPath); + + using (var openCommandKey = programKey.CreateSubKey(SHELL_OPEN_COMMAND)) + openCommandKey.SetValue(null, $@"""{exe_path}"" ""%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(string description) + { + using var classes = Registry.CurrentUser.OpenSubKey(software_classes, true); + if (classes == null) return; + + using (var programKey = classes.OpenSubKey(programId, true)) + programKey?.SetValue(null, description); + } + + /// + /// Uninstalls the file extension association in accordance with https://learn.microsoft.com/en-us/windows/win32/shell/fa-file-types#deleting-registry-information-during-uninstallation + /// + public void Uninstall() + { + using var classes = Registry.CurrentUser.OpenSubKey(software_classes, true); + if (classes == null) return; + + using (var extensionKey = classes.OpenSubKey(Extension, true)) + { + // clear our default association so that Explorer doesn't show the raw programId to users + // the null/(Default) entry is used for both ProdID association and as a fallback friendly name, for legacy reasons + if (extensionKey?.GetValue(null) is string s && s == programId) + extensionKey.SetValue(null, string.Empty); + + using (var openWithKey = extensionKey?.CreateSubKey(@"OpenWithProgIds")) + openWithKey?.DeleteValue(programId, throwOnMissingValue: false); + } + + classes.DeleteSubKeyTree(programId, throwOnMissingSubKey: false); + } + } + + private record UriAssociation(string Protocol, LocalisableString Description, string IconPath) + { + /// + /// "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() + { + using var classes = Registry.CurrentUser.OpenSubKey(software_classes, true); + if (classes == null) return; + + using (var protocolKey = classes.CreateSubKey(Protocol)) + { + protocolKey.SetValue(URL_PROTOCOL, string.Empty); + + using (var defaultIconKey = protocolKey.CreateSubKey(default_icon)) + defaultIconKey.SetValue(null, IconPath); + + using (var openCommandKey = protocolKey.CreateSubKey(SHELL_OPEN_COMMAND)) + openCommandKey.SetValue(null, $@"""{exe_path}"" ""%1"""); + } + } + + public void UpdateDescription(string description) + { + using var classes = Registry.CurrentUser.OpenSubKey(software_classes, true); + if (classes == null) return; + + using (var protocolKey = classes.OpenSubKey(Protocol, true)) + protocolKey?.SetValue(null, $@"URL:{description}"); + } + + public void Uninstall() + { + using var classes = Registry.CurrentUser.OpenSubKey(software_classes, true); + classes?.DeleteSubKeyTree(Protocol, throwOnMissingSubKey: false); + } + } + } +} diff --git a/osu.Desktop/osu.Desktop.csproj b/osu.Desktop/osu.Desktop.csproj index cf2ec6e681..e7a63bd921 100644 --- a/osu.Desktop/osu.Desktop.csproj +++ b/osu.Desktop/osu.Desktop.csproj @@ -31,4 +31,7 @@ + + + diff --git a/osu.Desktop/osu.nuspec b/osu.Desktop/osu.nuspec index f85698680e..66b3970351 100644 --- a/osu.Desktop/osu.nuspec +++ b/osu.Desktop/osu.nuspec @@ -20,6 +20,7 @@ + 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.sln.DotSettings b/osu.sln.DotSettings index ef557cbbfc..452f90ecea 100644 --- a/osu.sln.DotSettings +++ b/osu.sln.DotSettings @@ -1005,6 +1005,7 @@ private void load() True True True + True True True True