|
| 1 | +// Copyright (c) ppy Pty Ltd <[email protected]>. Licensed under the MIT Licence. |
| 2 | +// See the LICENCE file in the repository root for full licence text. |
| 3 | + |
| 4 | +using System; |
| 5 | +using System.IO; |
| 6 | +using System.Runtime.InteropServices; |
| 7 | +using System.Runtime.Versioning; |
| 8 | +using Microsoft.Win32; |
| 9 | +using osu.Framework.Localisation; |
| 10 | +using osu.Framework.Logging; |
| 11 | +using osu.Game.Localisation; |
| 12 | + |
| 13 | +namespace osu.Desktop.Windows |
| 14 | +{ |
| 15 | + [SupportedOSPlatform("windows")] |
| 16 | + public static class WindowsAssociationManager |
| 17 | + { |
| 18 | + private const string software_classes = @"Software\Classes"; |
| 19 | + |
| 20 | + /// <summary> |
| 21 | + /// Sub key for setting the icon. |
| 22 | + /// https://learn.microsoft.com/en-us/windows/win32/com/defaulticon |
| 23 | + /// </summary> |
| 24 | + private const string default_icon = @"DefaultIcon"; |
| 25 | + |
| 26 | + /// <summary> |
| 27 | + /// Sub key for setting the command line that the shell invokes. |
| 28 | + /// https://learn.microsoft.com/en-us/windows/win32/com/shell |
| 29 | + /// </summary> |
| 30 | + internal const string SHELL_OPEN_COMMAND = @"Shell\Open\Command"; |
| 31 | + |
| 32 | + private static readonly string exe_path = Path.ChangeExtension(typeof(WindowsAssociationManager).Assembly.Location, ".exe").Replace('/', '\\'); |
| 33 | + |
| 34 | + /// <summary> |
| 35 | + /// Program ID prefix used for file associations. Should be relatively short since the full program ID has a 39 character limit, |
| 36 | + /// see https://learn.microsoft.com/en-us/windows/win32/com/-progid--key. |
| 37 | + /// </summary> |
| 38 | + private const string program_id_prefix = "osu.File"; |
| 39 | + |
| 40 | + private static readonly FileAssociation[] file_associations = |
| 41 | + { |
| 42 | + new FileAssociation(@".osz", WindowsAssociationManagerStrings.OsuBeatmap, Icons.Lazer), |
| 43 | + new FileAssociation(@".olz", WindowsAssociationManagerStrings.OsuBeatmap, Icons.Lazer), |
| 44 | + new FileAssociation(@".osr", WindowsAssociationManagerStrings.OsuReplay, Icons.Lazer), |
| 45 | + new FileAssociation(@".osk", WindowsAssociationManagerStrings.OsuSkin, Icons.Lazer), |
| 46 | + }; |
| 47 | + |
| 48 | + private static readonly UriAssociation[] uri_associations = |
| 49 | + { |
| 50 | + new UriAssociation(@"osu", WindowsAssociationManagerStrings.OsuProtocol, Icons.Lazer), |
| 51 | + new UriAssociation(@"osump", WindowsAssociationManagerStrings.OsuMultiplayer, Icons.Lazer), |
| 52 | + }; |
| 53 | + |
| 54 | + /// <summary> |
| 55 | + /// Installs file and URI associations. |
| 56 | + /// </summary> |
| 57 | + /// <remarks> |
| 58 | + /// Call <see cref="UpdateDescriptions"/> in a timely fashion to keep descriptions up-to-date and localised. |
| 59 | + /// </remarks> |
| 60 | + public static void InstallAssociations() |
| 61 | + { |
| 62 | + try |
| 63 | + { |
| 64 | + updateAssociations(); |
| 65 | + updateDescriptions(null); // write default descriptions in case `UpdateDescriptions()` is not called. |
| 66 | + NotifyShellUpdate(); |
| 67 | + } |
| 68 | + catch (Exception e) |
| 69 | + { |
| 70 | + Logger.Error(e, @$"Failed to install file and URI associations: {e.Message}"); |
| 71 | + } |
| 72 | + } |
| 73 | + |
| 74 | + /// <summary> |
| 75 | + /// Updates associations with latest definitions. |
| 76 | + /// </summary> |
| 77 | + /// <remarks> |
| 78 | + /// Call <see cref="UpdateDescriptions"/> in a timely fashion to keep descriptions up-to-date and localised. |
| 79 | + /// </remarks> |
| 80 | + public static void UpdateAssociations() |
| 81 | + { |
| 82 | + try |
| 83 | + { |
| 84 | + updateAssociations(); |
| 85 | + |
| 86 | + // TODO: Remove once UpdateDescriptions() is called as specified in the xmldoc. |
| 87 | + updateDescriptions(null); // always write default descriptions, in case of updating from an older version in which file associations were not implemented/installed |
| 88 | + |
| 89 | + NotifyShellUpdate(); |
| 90 | + } |
| 91 | + catch (Exception e) |
| 92 | + { |
| 93 | + Logger.Error(e, @"Failed to update file and URI associations."); |
| 94 | + } |
| 95 | + } |
| 96 | + |
| 97 | + public static void UpdateDescriptions(LocalisationManager localisationManager) |
| 98 | + { |
| 99 | + try |
| 100 | + { |
| 101 | + updateDescriptions(localisationManager); |
| 102 | + NotifyShellUpdate(); |
| 103 | + } |
| 104 | + catch (Exception e) |
| 105 | + { |
| 106 | + Logger.Error(e, @"Failed to update file and URI association descriptions."); |
| 107 | + } |
| 108 | + } |
| 109 | + |
| 110 | + public static void UninstallAssociations() |
| 111 | + { |
| 112 | + try |
| 113 | + { |
| 114 | + foreach (var association in file_associations) |
| 115 | + association.Uninstall(); |
| 116 | + |
| 117 | + foreach (var association in uri_associations) |
| 118 | + association.Uninstall(); |
| 119 | + |
| 120 | + NotifyShellUpdate(); |
| 121 | + } |
| 122 | + catch (Exception e) |
| 123 | + { |
| 124 | + Logger.Error(e, @"Failed to uninstall file and URI associations."); |
| 125 | + } |
| 126 | + } |
| 127 | + |
| 128 | + public static void NotifyShellUpdate() => SHChangeNotify(EventId.SHCNE_ASSOCCHANGED, Flags.SHCNF_IDLIST, IntPtr.Zero, IntPtr.Zero); |
| 129 | + |
| 130 | + /// <summary> |
| 131 | + /// Installs or updates associations. |
| 132 | + /// </summary> |
| 133 | + private static void updateAssociations() |
| 134 | + { |
| 135 | + foreach (var association in file_associations) |
| 136 | + association.Install(); |
| 137 | + |
| 138 | + foreach (var association in uri_associations) |
| 139 | + association.Install(); |
| 140 | + } |
| 141 | + |
| 142 | + private static void updateDescriptions(LocalisationManager? localisation) |
| 143 | + { |
| 144 | + foreach (var association in file_associations) |
| 145 | + association.UpdateDescription(getLocalisedString(association.Description)); |
| 146 | + |
| 147 | + foreach (var association in uri_associations) |
| 148 | + association.UpdateDescription(getLocalisedString(association.Description)); |
| 149 | + |
| 150 | + string getLocalisedString(LocalisableString s) |
| 151 | + { |
| 152 | + if (localisation == null) |
| 153 | + return s.ToString(); |
| 154 | + |
| 155 | + var b = localisation.GetLocalisedBindableString(s); |
| 156 | + b.UnbindAll(); |
| 157 | + return b.Value; |
| 158 | + } |
| 159 | + } |
| 160 | + |
| 161 | + #region Native interop |
| 162 | + |
| 163 | + [DllImport("Shell32.dll")] |
| 164 | + private static extern void SHChangeNotify(EventId wEventId, Flags uFlags, IntPtr dwItem1, IntPtr dwItem2); |
| 165 | + |
| 166 | + private enum EventId |
| 167 | + { |
| 168 | + /// <summary> |
| 169 | + /// A file type association has changed. <see cref="Flags.SHCNF_IDLIST"/> must be specified in the uFlags parameter. |
| 170 | + /// dwItem1 and dwItem2 are not used and must be <see cref="IntPtr.Zero"/>. This event should also be sent for registered protocols. |
| 171 | + /// </summary> |
| 172 | + SHCNE_ASSOCCHANGED = 0x08000000 |
| 173 | + } |
| 174 | + |
| 175 | + private enum Flags : uint |
| 176 | + { |
| 177 | + SHCNF_IDLIST = 0x0000 |
| 178 | + } |
| 179 | + |
| 180 | + #endregion |
| 181 | + |
| 182 | + private record FileAssociation(string Extension, LocalisableString Description, string IconPath) |
| 183 | + { |
| 184 | + private string programId => $@"{program_id_prefix}{Extension}"; |
| 185 | + |
| 186 | + /// <summary> |
| 187 | + /// Installs a file extension association in accordance with https://learn.microsoft.com/en-us/windows/win32/com/-progid--key |
| 188 | + /// </summary> |
| 189 | + public void Install() |
| 190 | + { |
| 191 | + using var classes = Registry.CurrentUser.OpenSubKey(software_classes, true); |
| 192 | + if (classes == null) return; |
| 193 | + |
| 194 | + // register a program id for the given extension |
| 195 | + using (var programKey = classes.CreateSubKey(programId)) |
| 196 | + { |
| 197 | + using (var defaultIconKey = programKey.CreateSubKey(default_icon)) |
| 198 | + defaultIconKey.SetValue(null, IconPath); |
| 199 | + |
| 200 | + using (var openCommandKey = programKey.CreateSubKey(SHELL_OPEN_COMMAND)) |
| 201 | + openCommandKey.SetValue(null, $@"""{exe_path}"" ""%1"""); |
| 202 | + } |
| 203 | + |
| 204 | + using (var extensionKey = classes.CreateSubKey(Extension)) |
| 205 | + { |
| 206 | + // set ourselves as the default program |
| 207 | + extensionKey.SetValue(null, programId); |
| 208 | + |
| 209 | + // add to the open with dialog |
| 210 | + // https://learn.microsoft.com/en-us/windows/win32/shell/how-to-include-an-application-on-the-open-with-dialog-box |
| 211 | + using (var openWithKey = extensionKey.CreateSubKey(@"OpenWithProgIds")) |
| 212 | + openWithKey.SetValue(programId, string.Empty); |
| 213 | + } |
| 214 | + } |
| 215 | + |
| 216 | + public void UpdateDescription(string description) |
| 217 | + { |
| 218 | + using var classes = Registry.CurrentUser.OpenSubKey(software_classes, true); |
| 219 | + if (classes == null) return; |
| 220 | + |
| 221 | + using (var programKey = classes.OpenSubKey(programId, true)) |
| 222 | + programKey?.SetValue(null, description); |
| 223 | + } |
| 224 | + |
| 225 | + /// <summary> |
| 226 | + /// 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 |
| 227 | + /// </summary> |
| 228 | + public void Uninstall() |
| 229 | + { |
| 230 | + using var classes = Registry.CurrentUser.OpenSubKey(software_classes, true); |
| 231 | + if (classes == null) return; |
| 232 | + |
| 233 | + using (var extensionKey = classes.OpenSubKey(Extension, true)) |
| 234 | + { |
| 235 | + // clear our default association so that Explorer doesn't show the raw programId to users |
| 236 | + // the null/(Default) entry is used for both ProdID association and as a fallback friendly name, for legacy reasons |
| 237 | + if (extensionKey?.GetValue(null) is string s && s == programId) |
| 238 | + extensionKey.SetValue(null, string.Empty); |
| 239 | + |
| 240 | + using (var openWithKey = extensionKey?.CreateSubKey(@"OpenWithProgIds")) |
| 241 | + openWithKey?.DeleteValue(programId, throwOnMissingValue: false); |
| 242 | + } |
| 243 | + |
| 244 | + classes.DeleteSubKeyTree(programId, throwOnMissingSubKey: false); |
| 245 | + } |
| 246 | + } |
| 247 | + |
| 248 | + private record UriAssociation(string Protocol, LocalisableString Description, string IconPath) |
| 249 | + { |
| 250 | + /// <summary> |
| 251 | + /// "The <c>URL Protocol</c> string value indicates that this key declares a custom pluggable protocol handler." |
| 252 | + /// See https://learn.microsoft.com/en-us/previous-versions/windows/internet-explorer/ie-developer/platform-apis/aa767914(v=vs.85). |
| 253 | + /// </summary> |
| 254 | + public const string URL_PROTOCOL = @"URL Protocol"; |
| 255 | + |
| 256 | + /// <summary> |
| 257 | + /// 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). |
| 258 | + /// </summary> |
| 259 | + public void Install() |
| 260 | + { |
| 261 | + using var classes = Registry.CurrentUser.OpenSubKey(software_classes, true); |
| 262 | + if (classes == null) return; |
| 263 | + |
| 264 | + using (var protocolKey = classes.CreateSubKey(Protocol)) |
| 265 | + { |
| 266 | + protocolKey.SetValue(URL_PROTOCOL, string.Empty); |
| 267 | + |
| 268 | + using (var defaultIconKey = protocolKey.CreateSubKey(default_icon)) |
| 269 | + defaultIconKey.SetValue(null, IconPath); |
| 270 | + |
| 271 | + using (var openCommandKey = protocolKey.CreateSubKey(SHELL_OPEN_COMMAND)) |
| 272 | + openCommandKey.SetValue(null, $@"""{exe_path}"" ""%1"""); |
| 273 | + } |
| 274 | + } |
| 275 | + |
| 276 | + public void UpdateDescription(string description) |
| 277 | + { |
| 278 | + using var classes = Registry.CurrentUser.OpenSubKey(software_classes, true); |
| 279 | + if (classes == null) return; |
| 280 | + |
| 281 | + using (var protocolKey = classes.OpenSubKey(Protocol, true)) |
| 282 | + protocolKey?.SetValue(null, $@"URL:{description}"); |
| 283 | + } |
| 284 | + |
| 285 | + public void Uninstall() |
| 286 | + { |
| 287 | + using var classes = Registry.CurrentUser.OpenSubKey(software_classes, true); |
| 288 | + classes?.DeleteSubKeyTree(Protocol, throwOnMissingSubKey: false); |
| 289 | + } |
| 290 | + } |
| 291 | + } |
| 292 | +} |
0 commit comments