Skip to content

Commit

Permalink
Merge pull request #27001 from Susko3/windows-file-uri-association
Browse files Browse the repository at this point in the history
Associate with files and URIs on windows
  • Loading branch information
peppy authored Mar 1, 2024
2 parents 4b3a5bd + 61cc5d6 commit 91483bd
Show file tree
Hide file tree
Showing 8 changed files with 355 additions and 2 deletions.
4 changes: 2 additions & 2 deletions osu.Desktop/OsuGameDesktop.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
4 changes: 4 additions & 0 deletions osu.Desktop/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions osu.Desktop/Windows/Icons.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// Copyright (c) ppy Pty Ltd <[email protected]>. 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");
}
}
288 changes: 288 additions & 0 deletions osu.Desktop/Windows/WindowsAssociationManager.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,288 @@
// Copyright (c) ppy Pty Ltd <[email protected]>. 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);
}
}
}
}
3 changes: 3 additions & 0 deletions osu.Desktop/osu.Desktop.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -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>
1 change: 1 addition & 0 deletions osu.Desktop/osu.nuspec
Original file line number Diff line number Diff line change
Expand Up @@ -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>
39 changes: 39 additions & 0 deletions osu.Game/Localisation/WindowsAssociationManagerStrings.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// Copyright (c) ppy Pty Ltd <[email protected]>. 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}";
}
}
Loading

0 comments on commit 91483bd

Please sign in to comment.