Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Associate with files and URIs on windows #27001

Merged
merged 32 commits into from
Mar 1, 2024
Merged
Show file tree
Hide file tree
Changes from 31 commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
498d93b
Add way to associate with files and URIs on Windows
Susko3 Feb 3, 2024
0357882
Associate on startup
Susko3 Feb 3, 2024
2bac09e
Only associate on a deployed build
Susko3 Feb 3, 2024
cdcf5bd
Uninstall associations when uninstalling from squirrel
Susko3 Feb 3, 2024
2f42112
Use cleaner way to specify .exe path
Susko3 Feb 5, 2024
01efd1b
Move `WindowsAssociationManager` to `osu.Desktop`
Susko3 Feb 5, 2024
7789cc0
Copy .ico files when publishing
Susko3 Feb 5, 2024
4ec9d26
Inline constants
Susko3 Feb 5, 2024
0168ade
Remove tests
Susko3 Feb 5, 2024
17033e0
Change to class to satisfy CFS hopefully
Susko3 Feb 5, 2024
57d5717
Remove `Win32Icon` class and use plain strings instead
Susko3 Feb 7, 2024
eeba937
Make `WindowsAssociationManager` `static`
Susko3 Feb 7, 2024
f9d257b
Install associations as part of initial squirrel install
Susko3 Feb 7, 2024
0563507
Remove duplicate try-catch and move `NotifyShellUpdate()` to less hid…
Susko3 Feb 7, 2024
6bdb076
Move update/install logic into helper
Susko3 Feb 7, 2024
738c287
Refactor public methods
Susko3 Feb 7, 2024
ffdefbc
Move public methods closer together
Susko3 Feb 7, 2024
da8c454
Use `Logger.Error`
Susko3 Feb 7, 2024
7f5dedc
Refactor ProgID logic so it's more visible
Susko3 Feb 7, 2024
3419b8f
Standardise using declaration
Susko3 Feb 7, 2024
139072f
Standardise using declaration
Susko3 Feb 7, 2024
bf47221
Make things testable via 'Run static method' in Rider
Susko3 Feb 7, 2024
8049270
Remove unused param
Susko3 Feb 7, 2024
7864810
Merge branch 'master' into windows-file-uri-association
Susko3 Feb 7, 2024
dfa0c51
Copy icons to nuget and install package
Susko3 Feb 7, 2024
1dc54d6
Fix stable install path lookup
Susko3 Feb 7, 2024
6ded79c
Make `NotifyShellUpdate()` `public` to ease testing
Susko3 Feb 8, 2024
6dbba70
Refine uninstall logic to account for legacy windows features
Susko3 Feb 26, 2024
f280747
Inline `EXE_PATH` usage
Susko3 Feb 26, 2024
9b3ec64
Inline `HKCU\Software\Classes` usage
Susko3 Feb 26, 2024
87509fb
Privatise registry-related constants
bdach Feb 27, 2024
61cc5d6
Fix typos in xmldoc
peppy Mar 1, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions osu.Desktop/OsuGameDesktop.cs
Original file line number Diff line number Diff line change
Expand Up @@ -86,8 +86,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
bdach marked this conversation as resolved.
Show resolved Hide resolved
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 extenstion 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 extenstion 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
Loading