mirror of https://github.com/ppy/osu
Merge pull request #27001 from Susko3/windows-file-uri-association
Associate with files and URIs on windows
This commit is contained in:
commit
91483bdc2f
|
@ -92,8 +92,8 @@ public OsuGameDesktop(string[]? args = null)
|
|||
[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()
|
||||
|
|
|
@ -5,6 +5,7 @@
|
|||
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 @@ private static void setupSquirrel()
|
|||
{
|
||||
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
|
||||
|
|
|
@ -0,0 +1,17 @@
|
|||
// 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.IO;
|
||||
|
||||
namespace osu.Desktop.Windows
|
||||
{
|
||||
public static class Icons
|
||||
{
|
||||
/// <summary>
|
||||
/// Fully qualified path to the directory that contains icons (in the installation folder).
|
||||
/// </summary>
|
||||
private static readonly string icon_directory = Path.GetDirectoryName(typeof(Icons).Assembly.Location)!;
|
||||
|
||||
public static string Lazer => Path.Join(icon_directory, "lazer.ico");
|
||||
}
|
||||
}
|
|
@ -0,0 +1,288 @@
|
|||
// 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;
|
||||
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";
|
||||
|
||||
/// <summary>
|
||||
/// Sub key for setting the icon.
|
||||
/// https://learn.microsoft.com/en-us/windows/win32/com/defaulticon
|
||||
/// </summary>
|
||||
private const string default_icon = @"DefaultIcon";
|
||||
|
||||
/// <summary>
|
||||
/// Sub key for setting the command line that the shell invokes.
|
||||
/// https://learn.microsoft.com/en-us/windows/win32/com/shell
|
||||
/// </summary>
|
||||
internal const string SHELL_OPEN_COMMAND = @"Shell\Open\Command";
|
||||
|
||||
private static readonly string exe_path = Path.ChangeExtension(typeof(WindowsAssociationManager).Assembly.Location, ".exe").Replace('/', '\\');
|
||||
|
||||
/// <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>
|
||||
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),
|
||||
};
|
||||
|
||||
/// <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()
|
||||
{
|
||||
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}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <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();
|
||||
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);
|
||||
|
||||
/// <summary>
|
||||
/// Installs or updates associations.
|
||||
/// </summary>
|
||||
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
|
||||
{
|
||||
/// <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
|
||||
{
|
||||
SHCNF_IDLIST = 0x0000
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private record FileAssociation(string Extension, LocalisableString Description, string IconPath)
|
||||
{
|
||||
private string programId => $@"{program_id_prefix}{Extension}";
|
||||
|
||||
/// <summary>
|
||||
/// Installs a file extension association in accordance with https://learn.microsoft.com/en-us/windows/win32/com/-progid--key
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// </summary>
|
||||
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)
|
||||
{
|
||||
/// <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>
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -31,4 +31,7 @@
|
|||
<ItemGroup Label="Resources">
|
||||
<EmbeddedResource Include="lazer.ico" />
|
||||
</ItemGroup>
|
||||
<ItemGroup Label="Windows Icons">
|
||||
<Content Include="*.ico" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
|
|
@ -20,6 +20,7 @@
|
|||
<file src="**.dll" target="lib\net45\"/>
|
||||
<file src="**.config" target="lib\net45\"/>
|
||||
<file src="**.json" target="lib\net45\"/>
|
||||
<file src="**.ico" target="lib\net45\"/>
|
||||
<file src="icon.png" target=""/>
|
||||
</files>
|
||||
</package>
|
||||
|
|
|
@ -0,0 +1,39 @@
|
|||
// 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 osu.Framework.Localisation;
|
||||
|
||||
namespace osu.Game.Localisation
|
||||
{
|
||||
public static class WindowsAssociationManagerStrings
|
||||
{
|
||||
private const string prefix = @"osu.Game.Resources.Localisation.WindowsAssociationManager";
|
||||
|
||||
/// <summary>
|
||||
/// "osu! Beatmap"
|
||||
/// </summary>
|
||||
public static LocalisableString OsuBeatmap => new TranslatableString(getKey(@"osu_beatmap"), @"osu! Beatmap");
|
||||
|
||||
/// <summary>
|
||||
/// "osu! Replay"
|
||||
/// </summary>
|
||||
public static LocalisableString OsuReplay => new TranslatableString(getKey(@"osu_replay"), @"osu! Replay");
|
||||
|
||||
/// <summary>
|
||||
/// "osu! Skin"
|
||||
/// </summary>
|
||||
public static LocalisableString OsuSkin => new TranslatableString(getKey(@"osu_skin"), @"osu! Skin");
|
||||
|
||||
/// <summary>
|
||||
/// "osu!"
|
||||
/// </summary>
|
||||
public static LocalisableString OsuProtocol => new TranslatableString(getKey(@"osu_protocol"), @"osu!");
|
||||
|
||||
/// <summary>
|
||||
/// "osu! Multiplayer"
|
||||
/// </summary>
|
||||
public static LocalisableString OsuMultiplayer => new TranslatableString(getKey(@"osu_multiplayer"), @"osu! Multiplayer");
|
||||
|
||||
private static string getKey(string key) => $@"{prefix}:{key}";
|
||||
}
|
||||
}
|
|
@ -1005,6 +1005,7 @@ private void load()
|
|||
<s:Boolean x:Key="/Default/UserDictionary/Words/=Migratable/@EntryIndexedValue">True</s:Boolean>
|
||||
<s:Boolean x:Key="/Default/UserDictionary/Words/=Nightcore/@EntryIndexedValue">True</s:Boolean>
|
||||
<s:Boolean x:Key="/Default/UserDictionary/Words/=Omni/@EntryIndexedValue">True</s:Boolean>
|
||||
<s:Boolean x:Key="/Default/UserDictionary/Words/=osump/@EntryIndexedValue">True</s:Boolean>
|
||||
<s:Boolean x:Key="/Default/UserDictionary/Words/=Overlined/@EntryIndexedValue">True</s:Boolean>
|
||||
<s:Boolean x:Key="/Default/UserDictionary/Words/=Pausable/@EntryIndexedValue">True</s:Boolean>
|
||||
<s:Boolean x:Key="/Default/UserDictionary/Words/=Pippidon/@EntryIndexedValue">True</s:Boolean>
|
||||
|
|
Loading…
Reference in New Issue