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 10 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
3 changes: 3 additions & 0 deletions osu.Desktop/OsuGameDesktop.cs
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,9 @@ protected override void LoadComplete()
if (RuntimeInfo.OS == RuntimeInfo.Platform.Windows)
LoadComponentAsync(new GameplayWinKeyBlocker(), Add);

if (OperatingSystem.IsWindows() && IsDeployedBuild)
LoadComponentAsync(new WindowsAssociationManager(), Add);

LoadComponentAsync(new ElevatedPrivilegesChecker(), Add);

osuSchemeLinkIPCChannel = new OsuSchemeLinkIPCChannel(Host, this);
Expand Down
2 changes: 2 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 @@ -180,6 +181,7 @@ private static void setupSquirrel()
{
tools.RemoveShortcutForThisExe();
tools.RemoveUninstallerRegistryEntry();
WindowsAssociationManager.UninstallAssociations(@"osu");
}, onEveryRun: (_, _, _) =>
{
// While setting the `ProcessAppUserModelId` fixes duplicate icons/shortcuts on the taskbar, it currently
Expand Down
10 changes: 10 additions & 0 deletions osu.Desktop/Windows/Icons.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
// Copyright (c) ppy Pty Ltd <[email protected]>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.

namespace osu.Desktop.Windows
{
public static class Icons
{
public static Win32Icon Lazer => new Win32Icon(@"lazer.ico");
}
}
16 changes: 16 additions & 0 deletions osu.Desktop/Windows/Win32Icon.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// Copyright (c) ppy Pty Ltd <[email protected]>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.

namespace osu.Desktop.Windows
{
public class Win32Icon
{
public readonly string Path;

internal Win32Icon(string name)
{
string dir = System.IO.Path.GetDirectoryName(typeof(Win32Icon).Assembly.Location)!;
Path = System.IO.Path.Join(dir, name);
}
}
}
265 changes: 265 additions & 0 deletions osu.Desktop/Windows/WindowsAssociationManager.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,265 @@
// 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.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Localisation;
using osu.Framework.Logging;
using osu.Game.Localisation;

namespace osu.Desktop.Windows
{
[SupportedOSPlatform("windows")]
public partial class WindowsAssociationManager : Component
{
public const string SOFTWARE_CLASSES = @"Software\Classes";

/// <summary>
/// Sub key for setting the icon.
/// https://learn.microsoft.com/en-us/windows/win32/com/defaulticon
/// </summary>
public 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>
public const string SHELL_OPEN_COMMAND = @"Shell\Open\Command";

public static readonly string EXE_PATH = Path.ChangeExtension(typeof(WindowsAssociationManager).Assembly.Location, ".exe");

/// <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>
public const string PROGRAM_ID_PREFIX = "osu";

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),
};

[Resolved]
private LocalisationManager localisation { get; set; } = null!;

private IBindable<LocalisationParameters> localisationParameters = null!;

[BackgroundDependencyLoader]
private void load()
{
localisationParameters = localisation.CurrentParameters.GetBoundCopy();
InstallAssociations();
}

protected override void LoadComplete()
{
base.LoadComplete();
localisationParameters.ValueChanged += _ => updateDescriptions();
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is a bit veiled, but what this ends up implying is that every single change of user language, or even the "show metadata in original language" toggle in settings, is going to cause writes to the windows registry. I am not comfortable with this and do not think that is sane.

I think there are two paths I'd rather take here:

  • not bother with localising descriptions at all
  • localise them, but on a one-shot basis, at point of installation.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

localise them, but on a one-shot basis, at point of installation.

This relies too heavily on the automatic language selection being correct. How about updating the localised description once the user selects and confirms their language in the first-run setup wizard?

Copy link
Collaborator

@bdach bdach Feb 7, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This relies too heavily on the automatic language selection being correct

What do you mean by "too heavily"? I'm not sure I've known that to fail that much. If there's one place where that should work perfectly it's windows.

How about updating the localised description once the user selects and confirms their language in the first-run setup wizard?

Eeeeehhhh I don't know. I'd rather not touch the registry outside of squirrel flows / manual intervention via settings button.

Copy link
Member Author

@Susko3 Susko3 Feb 8, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ppy/team-client I'm happy with the code and this PR is ready for review. The above conversation and stable in 'open with' remain the only open questions.

}

public void InstallAssociations()
{
try
{
using (var classes = Registry.CurrentUser.OpenSubKey(SOFTWARE_CLASSES, writable: true))
{
if (classes == null)
return;

foreach (var association in file_associations)
association.Install(classes, EXE_PATH, PROGRAM_ID_PREFIX);

foreach (var association in uri_associations)
association.Install(classes, EXE_PATH);
}

updateDescriptions();
}
catch (Exception e)
{
Logger.Log(@$"Failed to install file and URI associations: {e.Message}");
}
}

private void updateDescriptions()
{
try
{
using var classes = Registry.CurrentUser.OpenSubKey(SOFTWARE_CLASSES, true);
if (classes == null)
return;

foreach (var association in file_associations)
{
var b = localisation.GetLocalisedBindableString(association.Description);
association.UpdateDescription(classes, PROGRAM_ID_PREFIX, b.Value);
b.UnbindAll();
}

foreach (var association in uri_associations)
{
var b = localisation.GetLocalisedBindableString(association.Description);
association.UpdateDescription(classes, b.Value);
b.UnbindAll();
}

NotifyShellUpdate();
}
catch (Exception e)
{
Logger.Log($@"Failed to update file and URI associations: {e.Message}");
}
}

public void UninstallAssociations() => UninstallAssociations(PROGRAM_ID_PREFIX);

public static void UninstallAssociations(string programIdPrefix)
{
try
{
using var classes = Registry.CurrentUser.OpenSubKey(SOFTWARE_CLASSES, true);
if (classes == null)
return;

foreach (var association in file_associations)
association.Uninstall(classes, programIdPrefix);

foreach (var association in uri_associations)
association.Uninstall(classes);

NotifyShellUpdate();
}
catch (Exception e)
{
Logger.Log($@"Failed to uninstall file and URI associations: {e.Message}");
}
}

internal static void NotifyShellUpdate() => SHChangeNotify(EventId.SHCNE_ASSOCCHANGED, Flags.SHCNF_IDLIST, IntPtr.Zero, IntPtr.Zero);

#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, Win32Icon Icon)
{
private string getProgramId(string prefix) => $@"{prefix}.File{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(RegistryKey classes, string exePath, string programIdPrefix)
{
string programId = getProgramId(programIdPrefix);

// register a program id for the given extension
using (var programKey = classes.CreateSubKey(programId))
{
using (var defaultIconKey = programKey.CreateSubKey(DEFAULT_ICON))
defaultIconKey.SetValue(null, Icon.Path);

using (var openCommandKey = programKey.CreateSubKey(SHELL_OPEN_COMMAND))
openCommandKey.SetValue(null, $@"""{exePath}"" ""%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(RegistryKey classes, string programIdPrefix, string description)
{
using (var programKey = classes.OpenSubKey(getProgramId(programIdPrefix), true))
programKey?.SetValue(null, description);
}

public void Uninstall(RegistryKey classes, string programIdPrefix)
{
string programId = getProgramId(programIdPrefix);

// importantly, we don't delete the default program entry because some other program could have taken it.

using (var extensionKey = classes.OpenSubKey($@"{Extension}\OpenWithProgIds", true))
extensionKey?.DeleteValue(programId, throwOnMissingValue: false);

classes.DeleteSubKeyTree(programId, throwOnMissingSubKey: false);
}
}

private record UriAssociation(string Protocol, LocalisableString Description, Win32Icon Icon)
{
/// <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(RegistryKey classes, string exePath)
{
using (var protocolKey = classes.CreateSubKey(Protocol))
{
protocolKey.SetValue(URL_PROTOCOL, string.Empty);

using (var defaultIconKey = protocolKey.CreateSubKey(DEFAULT_ICON))
defaultIconKey.SetValue(null, Icon.Path);

using (var openCommandKey = protocolKey.CreateSubKey(SHELL_OPEN_COMMAND))
openCommandKey.SetValue(null, $@"""{exePath}"" ""%1""");
}
}

public void UpdateDescription(RegistryKey classes, string description)
{
using (var protocolKey = classes.OpenSubKey(Protocol, true))
protocolKey?.SetValue(null, $@"URL:{description}");
}

public void Uninstall(RegistryKey classes)
{
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" Condition="$(RuntimeIdentifier.StartsWith('win'))">
<Content Include="*.ico" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
</ItemGroup>
</Project>
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}";
}
}
1 change: 1 addition & 0 deletions osu.sln.DotSettings
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down
Loading