2024-02-03 23:59:48 +08:00
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System ;
2024-05-02 02:18:56 +08:00
using System.Diagnostics.CodeAnalysis ;
2024-02-05 21:16:35 +08:00
using System.IO ;
2024-02-03 23:59:48 +08:00
using System.Runtime.InteropServices ;
using System.Runtime.Versioning ;
using Microsoft.Win32 ;
using osu.Framework.Localisation ;
using osu.Framework.Logging ;
using osu.Game.Localisation ;
2024-02-05 20:34:03 +08:00
namespace osu.Desktop.Windows
2024-02-03 23:59:48 +08:00
{
[SupportedOSPlatform("windows")]
2024-02-08 04:45:36 +08:00
public static class WindowsAssociationManager
2024-02-03 23:59:48 +08:00
{
2024-02-27 20:47:19 +08:00
private const string software_classes = @"Software\Classes" ;
2025-01-07 07:41:44 +08:00
private const string software_registered_applications = @"Software\RegisteredApplications" ;
2024-02-03 23:59:48 +08:00
/// <summary>
/// Sub key for setting the icon.
/// https://learn.microsoft.com/en-us/windows/win32/com/defaulticon
/// </summary>
2024-02-27 20:47:19 +08:00
private const string default_icon = @"DefaultIcon" ;
2024-02-03 23:59:48 +08:00
/// <summary>
/// Sub key for setting the command line that the shell invokes.
/// https://learn.microsoft.com/en-us/windows/win32/com/shell
/// </summary>
2024-02-27 20:47:19 +08:00
internal const string SHELL_OPEN_COMMAND = @"Shell\Open\Command" ;
2024-02-03 23:59:48 +08:00
2024-02-27 20:47:19 +08:00
private static readonly string exe_path = Path . ChangeExtension ( typeof ( WindowsAssociationManager ) . Assembly . Location , ".exe" ) . Replace ( '/' , '\\' ) ;
2024-02-05 21:16:35 +08:00
/// <summary>
/// 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.
/// </summary>
2025-01-07 07:59:52 +08:00
private const string program_id_file_prefix = "osu.File" ;
private const string program_id_protocol_prefix = "osu.Uri" ;
2024-02-05 21:16:35 +08:00
2025-01-07 07:41:44 +08:00
private static readonly ApplicationCapability application_capability = new ApplicationCapability ( @"osu" , @"Software\ppy\osu\Capabilities" , "osu!(lazer)" ) ;
2024-02-03 23:59:48 +08:00
private static readonly FileAssociation [ ] file_associations =
{
2024-09-09 15:04:16 +08:00
new FileAssociation ( @".osz" , WindowsAssociationManagerStrings . OsuBeatmap , Icons . Beatmap ) ,
new FileAssociation ( @".olz" , WindowsAssociationManagerStrings . OsuBeatmap , Icons . Beatmap ) ,
2024-09-14 01:47:19 +08:00
new FileAssociation ( @".osr" , WindowsAssociationManagerStrings . OsuReplay , Icons . Beatmap ) ,
new FileAssociation ( @".osk" , WindowsAssociationManagerStrings . OsuSkin , Icons . Beatmap ) ,
2024-02-03 23:59:48 +08:00
} ;
private static readonly UriAssociation [ ] uri_associations =
{
new UriAssociation ( @"osu" , WindowsAssociationManagerStrings . OsuProtocol , Icons . Lazer ) ,
new UriAssociation ( @"osump" , WindowsAssociationManagerStrings . OsuMultiplayer , Icons . Lazer ) ,
} ;
2024-02-08 05:17:13 +08:00
/// <summary>
/// Installs file and URI associations.
/// </summary>
/// <remarks>
2025-01-08 14:55:04 +08:00
/// Call <see cref="LocaliseDescriptions"/> in a timely fashion to keep descriptions up-to-date and localised.
2024-02-08 05:17:13 +08:00
/// </remarks>
public static void InstallAssociations ( )
2024-02-03 23:59:48 +08:00
{
try
{
2024-02-08 05:06:09 +08:00
updateAssociations ( ) ;
2024-02-08 05:03:16 +08:00
NotifyShellUpdate ( ) ;
2024-02-03 23:59:48 +08:00
}
catch ( Exception e )
{
2024-02-08 05:21:04 +08:00
Logger . Error ( e , @ $"Failed to install file and URI associations: {e.Message}" ) ;
2024-02-03 23:59:48 +08:00
}
}
2024-02-08 05:17:13 +08:00
/// <summary>
/// Updates associations with latest definitions.
/// </summary>
/// <remarks>
2025-01-08 14:55:04 +08:00
/// Call <see cref="LocaliseDescriptions"/> in a timely fashion to keep descriptions up-to-date and localised.
2024-02-08 05:17:13 +08:00
/// </remarks>
public static void UpdateAssociations ( )
{
try
{
updateAssociations ( ) ;
NotifyShellUpdate ( ) ;
}
catch ( Exception e )
{
2024-02-08 05:21:04 +08:00
Logger . Error ( e , @"Failed to update file and URI associations." ) ;
2024-02-08 05:17:13 +08:00
}
}
2025-01-08 14:55:04 +08:00
// TODO: call this sometime.
public static void LocaliseDescriptions ( LocalisationManager localisationManager )
2024-02-08 05:17:13 +08:00
{
try
{
2025-01-09 00:15:22 +08:00
application_capability . LocaliseDescription ( localisationManager ) ;
2025-01-08 14:55:04 +08:00
foreach ( var association in file_associations )
association . LocaliseDescription ( localisationManager ) ;
foreach ( var association in uri_associations )
association . LocaliseDescription ( localisationManager ) ;
2024-02-08 05:17:13 +08:00
NotifyShellUpdate ( ) ;
}
catch ( Exception e )
{
2024-02-08 05:21:04 +08:00
Logger . Error ( e , @"Failed to update file and URI association descriptions." ) ;
2024-02-08 05:17:13 +08:00
}
}
2024-02-08 05:18:12 +08:00
public static void UninstallAssociations ( )
{
try
{
2025-01-07 07:41:44 +08:00
application_capability . Uninstall ( ) ;
2024-02-08 05:18:12 +08:00
foreach ( var association in file_associations )
2024-02-26 20:10:37 +08:00
association . Uninstall ( ) ;
2024-02-08 05:18:12 +08:00
foreach ( var association in uri_associations )
2024-02-26 20:10:37 +08:00
association . Uninstall ( ) ;
2024-02-08 05:18:12 +08:00
NotifyShellUpdate ( ) ;
}
catch ( Exception e )
{
2024-02-08 05:21:04 +08:00
Logger . Error ( e , @"Failed to uninstall file and URI associations." ) ;
2024-02-08 05:18:12 +08:00
}
}
2024-02-08 08:15:37 +08:00
public static void NotifyShellUpdate ( ) = > SHChangeNotify ( EventId . SHCNE_ASSOCCHANGED , Flags . SHCNF_IDLIST , IntPtr . Zero , IntPtr . Zero ) ;
2024-02-08 05:06:09 +08:00
/// <summary>
/// Installs or updates associations.
/// </summary>
private static void updateAssociations ( )
{
2025-01-07 07:41:44 +08:00
application_capability . Install ( ) ;
2024-02-08 05:26:21 +08:00
foreach ( var association in file_associations )
2024-02-26 20:10:37 +08:00
association . Install ( ) ;
2024-02-08 05:06:09 +08:00
2024-02-08 05:26:21 +08:00
foreach ( var association in uri_associations )
2024-02-26 20:10:37 +08:00
association . Install ( ) ;
2025-01-07 07:41:44 +08:00
application_capability . RegisterFileAssociations ( file_associations ) ;
2025-01-07 07:59:52 +08:00
application_capability . RegisterUriAssociations ( uri_associations ) ;
2024-02-08 05:06:09 +08:00
}
2024-02-03 23:59:48 +08:00
#region Native interop
[DllImport("Shell32.dll")]
private static extern void SHChangeNotify ( EventId wEventId , Flags uFlags , IntPtr dwItem1 , IntPtr dwItem2 ) ;
2024-05-02 02:18:56 +08:00
[SuppressMessage("ReSharper", "InconsistentNaming")]
2024-02-03 23:59:48 +08:00
private enum EventId
{
/// <summary>
/// A file type association has changed. <see cref="Flags.SHCNF_IDLIST"/> must be specified in the uFlags parameter.
/// dwItem1 and dwItem2 are not used and must be <see cref="IntPtr.Zero"/>. This event should also be sent for registered protocols.
/// </summary>
SHCNE_ASSOCCHANGED = 0x08000000
}
2024-05-02 02:18:56 +08:00
[SuppressMessage("ReSharper", "InconsistentNaming")]
2024-02-03 23:59:48 +08:00
private enum Flags : uint
{
SHCNF_IDLIST = 0x0000
}
#endregion
2025-01-09 00:15:22 +08:00
private class ApplicationCapability
2025-01-07 07:41:44 +08:00
{
2025-01-09 00:15:22 +08:00
private string uniqueName { get ; }
private string capabilityPath { get ; }
private LocalisableString description { get ; }
public ApplicationCapability ( string uniqueName , string capabilityPath , LocalisableString description )
{
this . uniqueName = uniqueName ;
this . capabilityPath = capabilityPath ;
this . description = description ;
}
2025-01-07 07:41:44 +08:00
/// <summary>
/// Registers an application capability according to <see href="https://learn.microsoft.com/en-us/windows/win32/shell/default-programs#registering-an-application-for-use-with-default-programs">
/// Registering an Application for Use with Default Programs</see>.
/// </summary>
public void Install ( )
{
2025-01-09 00:15:22 +08:00
using ( var capability = Registry . CurrentUser . CreateSubKey ( capabilityPath ) )
2025-01-07 07:41:44 +08:00
{
2025-01-09 00:15:22 +08:00
capability . SetValue ( @"ApplicationDescription" , description . ToString ( ) ) ;
2025-01-07 07:41:44 +08:00
}
using ( var registeredApplications = Registry . CurrentUser . OpenSubKey ( software_registered_applications , true ) )
2025-01-09 00:15:22 +08:00
registeredApplications ? . SetValue ( uniqueName , capabilityPath ) ;
2025-01-07 07:41:44 +08:00
}
public void RegisterFileAssociations ( FileAssociation [ ] associations )
{
2025-01-09 00:15:22 +08:00
using var capability = Registry . CurrentUser . OpenSubKey ( capabilityPath , true ) ;
2025-01-07 07:41:44 +08:00
if ( capability = = null ) return ;
using var fileAssociations = capability . CreateSubKey ( @"FileAssociations" ) ;
foreach ( var association in associations )
fileAssociations . SetValue ( association . Extension , association . ProgramId ) ;
}
2025-01-07 07:59:52 +08:00
public void RegisterUriAssociations ( UriAssociation [ ] associations )
{
2025-01-09 00:15:22 +08:00
using var capability = Registry . CurrentUser . OpenSubKey ( capabilityPath , true ) ;
2025-01-07 07:59:52 +08:00
if ( capability = = null ) return ;
using var urlAssociations = capability . CreateSubKey ( @"UrlAssociations" ) ;
foreach ( var association in associations )
urlAssociations . SetValue ( association . Protocol , association . ProgramId ) ;
}
2025-01-09 00:15:22 +08:00
public void LocaliseDescription ( LocalisationManager localisationManager )
2025-01-07 07:41:44 +08:00
{
2025-01-09 00:15:22 +08:00
using ( var capability = Registry . CurrentUser . OpenSubKey ( capabilityPath , true ) )
2025-01-07 07:41:44 +08:00
{
2025-01-09 00:15:22 +08:00
capability ? . SetValue ( @"ApplicationDescription" , localisationManager . GetLocalisedString ( description ) ) ;
2025-01-07 07:41:44 +08:00
}
}
public void Uninstall ( )
{
using ( var registeredApplications = Registry . CurrentUser . OpenSubKey ( software_registered_applications , true ) )
2025-01-09 00:15:22 +08:00
registeredApplications ? . DeleteValue ( uniqueName , throwOnMissingValue : false ) ;
2025-01-07 07:41:44 +08:00
2025-01-09 00:15:22 +08:00
Registry . CurrentUser . DeleteSubKeyTree ( capabilityPath , throwOnMissingSubKey : false ) ;
2025-01-07 07:41:44 +08:00
}
}
2025-01-08 14:42:30 +08:00
private class FileAssociation
2024-02-03 23:59:48 +08:00
{
2025-01-07 07:59:52 +08:00
public string ProgramId = > $@"{program_id_file_prefix}{Extension}" ;
2024-02-03 23:59:48 +08:00
2025-01-09 00:15:22 +08:00
public string Extension { get ; }
2025-01-08 14:42:30 +08:00
private LocalisableString description { get ; }
private string iconPath { get ; }
public FileAssociation ( string extension , LocalisableString description , string iconPath )
{
2025-01-09 00:15:22 +08:00
Extension = extension ;
2025-01-08 14:42:30 +08:00
this . description = description ;
this . iconPath = iconPath ;
}
2024-02-03 23:59:48 +08:00
/// <summary>
2024-03-01 11:24:12 +08:00
/// Installs a file extension association in accordance with https://learn.microsoft.com/en-us/windows/win32/com/-progid--key
2024-02-03 23:59:48 +08:00
/// </summary>
2024-02-26 20:10:37 +08:00
public void Install ( )
2024-02-03 23:59:48 +08:00
{
2024-02-27 20:47:19 +08:00
using var classes = Registry . CurrentUser . OpenSubKey ( software_classes , true ) ;
2024-02-26 20:10:37 +08:00
if ( classes = = null ) return ;
2024-02-03 23:59:48 +08:00
// register a program id for the given extension
2025-01-07 07:34:17 +08:00
using ( var programKey = classes . CreateSubKey ( ProgramId ) )
2024-02-03 23:59:48 +08:00
{
2025-01-08 19:19:38 +08:00
programKey . SetValue ( null , description . ToString ( ) ) ;
2025-01-08 14:55:04 +08:00
2024-02-27 20:47:19 +08:00
using ( var defaultIconKey = programKey . CreateSubKey ( default_icon ) )
2025-01-08 14:42:30 +08:00
defaultIconKey . SetValue ( null , iconPath ) ;
2024-02-03 23:59:48 +08:00
using ( var openCommandKey = programKey . CreateSubKey ( SHELL_OPEN_COMMAND ) )
2024-02-27 20:47:19 +08:00
openCommandKey . SetValue ( null , $@"""{exe_path}"" ""%1""" ) ;
2024-02-03 23:59:48 +08:00
}
using ( var extensionKey = classes . CreateSubKey ( Extension ) )
{
2025-01-07 07:55:35 +08:00
// Clear out our existing default ProgramID. Default programs in Windows are handled internally by Explorer,
// so having it here is just confusing and may override user preferences.
if ( extensionKey . GetValue ( null ) is string s & & s = = ProgramId )
extensionKey . SetValue ( null , string . Empty ) ;
2024-02-03 23:59:48 +08:00
// 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" ) )
2025-01-07 07:34:17 +08:00
openWithKey . SetValue ( ProgramId , string . Empty ) ;
2024-02-03 23:59:48 +08:00
}
}
2025-01-08 14:55:04 +08:00
public void LocaliseDescription ( LocalisationManager localisationManager )
2024-02-03 23:59:48 +08:00
{
2024-02-27 20:47:19 +08:00
using var classes = Registry . CurrentUser . OpenSubKey ( software_classes , true ) ;
2024-02-26 20:10:37 +08:00
if ( classes = = null ) return ;
2025-01-07 07:34:17 +08:00
using ( var programKey = classes . OpenSubKey ( ProgramId , true ) )
2025-01-08 14:55:04 +08:00
programKey ? . SetValue ( null , localisationManager . GetLocalisedString ( description ) ) ;
2024-02-03 23:59:48 +08:00
}
2024-02-26 19:27:02 +08:00
/// <summary>
2024-03-01 11:24:12 +08:00
/// 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
2024-02-26 19:27:02 +08:00
/// </summary>
2024-02-26 20:10:37 +08:00
public void Uninstall ( )
2024-02-03 23:59:48 +08:00
{
2024-02-27 20:47:19 +08:00
using var classes = Registry . CurrentUser . OpenSubKey ( software_classes , true ) ;
2024-02-26 20:10:37 +08:00
if ( classes = = null ) return ;
2024-02-26 19:27:02 +08:00
using ( var extensionKey = classes . OpenSubKey ( Extension , true ) )
{
using ( var openWithKey = extensionKey ? . CreateSubKey ( @"OpenWithProgIds" ) )
2025-01-07 07:34:17 +08:00
openWithKey ? . DeleteValue ( ProgramId , throwOnMissingValue : false ) ;
2024-02-26 19:27:02 +08:00
}
2024-02-03 23:59:48 +08:00
2025-01-07 07:34:17 +08:00
classes . DeleteSubKeyTree ( ProgramId , throwOnMissingSubKey : false ) ;
2024-02-03 23:59:48 +08:00
}
}
2025-01-08 14:42:30 +08:00
private class UriAssociation
2024-02-03 23:59:48 +08:00
{
/// <summary>
/// "The <c>URL Protocol</c> 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).
/// </summary>
2025-01-08 14:42:30 +08:00
private const string url_protocol = @"URL Protocol" ;
2025-01-09 00:15:22 +08:00
public string Protocol { get ; }
2025-01-08 14:42:30 +08:00
private LocalisableString description { get ; }
private string iconPath { get ; }
public UriAssociation ( string protocol , LocalisableString description , string iconPath )
{
2025-01-09 00:15:22 +08:00
Protocol = protocol ;
2025-01-08 14:42:30 +08:00
this . description = description ;
this . iconPath = iconPath ;
}
2024-02-03 23:59:48 +08:00
2025-01-07 07:59:52 +08:00
public string ProgramId = > $@"{program_id_protocol_prefix}.{Protocol}" ;
2024-02-03 23:59:48 +08:00
/// <summary>
/// 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).
/// </summary>
2024-02-26 20:10:37 +08:00
public void Install ( )
2024-02-03 23:59:48 +08:00
{
2024-02-27 20:47:19 +08:00
using var classes = Registry . CurrentUser . OpenSubKey ( software_classes , true ) ;
2024-02-26 20:10:37 +08:00
if ( classes = = null ) return ;
2024-02-03 23:59:48 +08:00
using ( var protocolKey = classes . CreateSubKey ( Protocol ) )
{
2025-01-08 14:55:04 +08:00
protocolKey . SetValue ( null , $@"URL:{description}" ) ;
2025-01-08 14:42:30 +08:00
protocolKey . SetValue ( url_protocol , string . Empty ) ;
2024-02-03 23:59:48 +08:00
2025-01-07 08:07:04 +08:00
// clear out old data
protocolKey . DeleteSubKeyTree ( default_icon , throwOnMissingSubKey : false ) ;
protocolKey . DeleteSubKeyTree ( @"Shell" , throwOnMissingSubKey : false ) ;
2024-02-03 23:59:48 +08:00
}
2025-01-07 07:59:52 +08:00
// register a program id for the given protocol
using ( var programKey = classes . CreateSubKey ( ProgramId ) )
{
using ( var defaultIconKey = programKey . CreateSubKey ( default_icon ) )
2025-01-08 14:42:30 +08:00
defaultIconKey . SetValue ( null , iconPath ) ;
2025-01-07 07:59:52 +08:00
using ( var openCommandKey = programKey . CreateSubKey ( SHELL_OPEN_COMMAND ) )
openCommandKey . SetValue ( null , $@"""{exe_path}"" ""%1""" ) ;
}
2024-02-03 23:59:48 +08:00
}
2025-01-08 14:55:04 +08:00
public void LocaliseDescription ( LocalisationManager localisationManager )
2024-02-03 23:59:48 +08:00
{
2024-02-27 20:47:19 +08:00
using var classes = Registry . CurrentUser . OpenSubKey ( software_classes , true ) ;
2024-02-26 20:10:37 +08:00
if ( classes = = null ) return ;
2024-02-03 23:59:48 +08:00
using ( var protocolKey = classes . OpenSubKey ( Protocol , true ) )
2025-01-08 14:55:04 +08:00
protocolKey ? . SetValue ( null , $@"URL:{localisationManager.GetLocalisedString(description)}" ) ;
2024-02-03 23:59:48 +08:00
}
2024-02-26 20:10:37 +08:00
public void Uninstall ( )
2024-02-03 23:59:48 +08:00
{
2024-02-27 20:47:19 +08:00
using var classes = Registry . CurrentUser . OpenSubKey ( software_classes , true ) ;
2025-01-07 07:59:52 +08:00
classes ? . DeleteSubKeyTree ( ProgramId , throwOnMissingSubKey : false ) ;
2024-02-03 23:59:48 +08:00
}
}
}
}