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-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" ;
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>
2024-02-27 20:47:19 +08:00
private const string program_id_prefix = "osu.File" ;
2024-02-05 21:16:35 +08:00
2024-02-03 23:59:48 +08:00
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 ) ,
} ;
2024-02-08 05:17:13 +08:00
/// <summary>
/// Installs file and URI associations.
/// </summary>
/// <remarks>
/// Call <see cref="UpdateDescriptions"/> in a timely fashion to keep descriptions up-to-date and localised.
/// </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:17:13 +08:00
updateDescriptions ( null ) ; // write default descriptions in case `UpdateDescriptions()` is not called.
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>
/// Call <see cref="UpdateDescriptions"/> in a timely fashion to keep descriptions up-to-date and localised.
/// </remarks>
public static void UpdateAssociations ( )
{
try
{
updateAssociations ( ) ;
2024-03-01 23:06:30 +08:00
// TODO: Remove once UpdateDescriptions() is called as specified in the xmldoc.
updateDescriptions ( null ) ; // always write default descriptions, in case of updating from an older version in which file associations were not implemented/installed
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 associations." ) ;
2024-02-08 05:17:13 +08:00
}
}
public static void UpdateDescriptions ( LocalisationManager localisationManager )
{
try
{
updateDescriptions ( localisationManager ) ;
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
{
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 ( )
{
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 ( ) ;
2024-02-08 05:06:09 +08:00
}
2024-02-08 04:45:36 +08:00
private static void updateDescriptions ( LocalisationManager ? localisation )
2024-02-03 23:59:48 +08:00
{
2024-02-08 05:03:16 +08:00
foreach ( var association in file_associations )
2024-02-26 20:10:37 +08:00
association . UpdateDescription ( getLocalisedString ( association . Description ) ) ;
2024-02-03 23:59:48 +08:00
2024-02-08 05:03:16 +08:00
foreach ( var association in uri_associations )
2024-02-26 20:10:37 +08:00
association . UpdateDescription ( getLocalisedString ( association . Description ) ) ;
2024-02-03 23:59:48 +08:00
2024-02-08 04:45:36 +08:00
string getLocalisedString ( LocalisableString s )
{
if ( localisation = = null )
return s . ToString ( ) ;
var b = localisation . GetLocalisedBindableString ( s ) ;
b . UnbindAll ( ) ;
return b . Value ;
}
}
2024-02-04 00:56:14 +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-01 22:17:50 +08:00
// ReSharper disable 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
}
private enum Flags : uint
{
2024-05-01 22:17:50 +08:00
// ReSharper disable once InconsistentNaming
2024-02-03 23:59:48 +08:00
SHCNF_IDLIST = 0x0000
}
2024-05-01 22:17:50 +08:00
// ReSharper restore InconsistentNaming
2024-02-03 23:59:48 +08:00
#endregion
2024-02-08 04:33:23 +08:00
private record FileAssociation ( string Extension , LocalisableString Description , string IconPath )
2024-02-03 23:59:48 +08:00
{
2024-02-27 20:47:19 +08:00
private string programId = > $@"{program_id_prefix}{Extension}" ;
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
using ( var programKey = classes . CreateSubKey ( programId ) )
{
2024-02-27 20:47:19 +08:00
using ( var defaultIconKey = programKey . CreateSubKey ( default_icon ) )
2024-02-08 04:33:23 +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 ) )
{
// 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 ) ;
}
}
2024-02-26 20:10:37 +08:00
public void UpdateDescription ( string description )
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-08 05:23:59 +08:00
using ( var programKey = classes . OpenSubKey ( programId , true ) )
2024-02-03 23:59:48 +08:00
programKey ? . SetValue ( null , description ) ;
}
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 ) )
{
// 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 ) ;
2024-02-03 23:59:48 +08:00
2024-02-26 19:27:02 +08:00
using ( var openWithKey = extensionKey ? . CreateSubKey ( @"OpenWithProgIds" ) )
openWithKey ? . DeleteValue ( programId , throwOnMissingValue : false ) ;
}
2024-02-03 23:59:48 +08:00
classes . DeleteSubKeyTree ( programId , throwOnMissingSubKey : false ) ;
}
}
2024-02-08 04:33:23 +08:00
private record UriAssociation ( string Protocol , LocalisableString Description , string IconPath )
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>
public const string URL_PROTOCOL = @"URL Protocol" ;
/// <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 ) )
{
protocolKey . SetValue ( URL_PROTOCOL , string . Empty ) ;
2024-02-27 20:47:19 +08:00
using ( var defaultIconKey = protocolKey . CreateSubKey ( default_icon ) )
2024-02-08 04:33:23 +08:00
defaultIconKey . SetValue ( null , IconPath ) ;
2024-02-03 23:59:48 +08:00
using ( var openCommandKey = protocolKey . 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
}
}
2024-02-26 20:10:37 +08:00
public void UpdateDescription ( string description )
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 ) )
protocolKey ? . SetValue ( null , $@"URL:{description}" ) ;
}
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
classes ? . DeleteSubKeyTree ( Protocol , throwOnMissingSubKey : false ) ;
2024-02-03 23:59:48 +08:00
}
}
}
}