Files
Explorer-Workbench/src/Explorer.Windows/WindowsShellContextMenu.cs

703 lines
22 KiB
C#

using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Text;
using Explorer.Application;
using Explorer.Domain;
using Explorer.Domain.Abstractions;
using Microsoft.Extensions.Logging;
using Microsoft.Win32;
namespace Explorer.Windows;
public sealed class WindowsShellContextMenu : IShellContextMenu
{
private const uint IdCmdFirst = 1;
private const uint IdCmdLast = 0x7FFF;
private const uint CmfNormal = 0;
private const uint CmfExplore = 4;
private const uint CmfCanRename = 0x10;
private const uint CmfExtendedVerbs = 0x100;
private const uint CmfItemMenu = 0x80;
private const uint TpmLeftAlign = 0x0000;
private const uint TpmRightButton = 0x0002;
private const uint TpmReturnCmd = 0x0100;
private const uint WmNull = 0x0000;
private const uint WmInitMenuPopup = 0x0117;
private const uint WmDrawItem = 0x002B;
private const uint WmMeasureItem = 0x002C;
private const uint WmMenuChar = 0x0120;
private const uint WmLButtonDown = 0x0201;
private const uint WmLButtonUp = 0x0202;
private const uint WmRButtonDown = 0x0204;
private const uint WmRButtonUp = 0x0205;
private const uint WsPopup = 0x80000000;
private const uint WsExToolwindow = 0x00000080;
private const uint WsExNoActivate = 0x08000000;
private const uint PmRemove = 0x0001;
private const int GwlpWndProc = -4;
private const int SwShowNormal = 1;
private const int SwShowNoActivate = 8;
private const int VkLButton = 0x01;
private const int VkRButton = 0x02;
private static readonly Guid ShellItemId = new("43826d1e-e718-42ee-bc55-a1e261c37bfe");
private static readonly Guid ContextMenuId = new("000214e4-0000-0000-c000-000000000046");
private static readonly Guid SfuiObject = new("3981e224-f559-11d3-8e3a-00c04f6837d5");
private readonly ILogger<WindowsShellContextMenu> _logger;
private Dictionary<string, string>? _staticCommands;
private IReadOnlyList<string> _paths = [];
private nint _oldWndProc;
private WndProc? _subclassProc;
private IContextMenu2? _menu2;
private IContextMenu3? _menu3;
public WindowsShellContextMenu(ILogger<WindowsShellContextMenu> logger) => _logger = logger;
public IReadOnlyList<ShellContextVerb> Query(IReadOnlyList<string> paths)
{
_paths = Normalize(paths);
if (_paths.Count == 0)
{
_staticCommands = null;
return [];
}
try
{
return ShellContextVerbFilter.Prune(QueryRegistryVerbs(_paths));
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Registry shell verbs failed");
_staticCommands = null;
return [];
}
}
public bool TryInvoke(string id, out string? error)
{
error = null;
try
{
if (id.StartsWith("static:", StringComparison.Ordinal)
&& _staticCommands is not null
&& _staticCommands.TryGetValue(id, out var command)
&& ShellVerbCommand.TrySplit(command, out var exe, out var args))
{
foreach (var invocation in ShellVerbCommand.ExpandInvocations(args, _paths))
{
Process.Start(new ProcessStartInfo
{
FileName = exe,
Arguments = invocation,
UseShellExecute = false
});
}
return true;
}
}
catch (Exception ex)
{
error = ex.Message;
_logger.LogDebug(ex, "Shell verb invoke failed {Id}", id);
return false;
}
error = "Command is no longer available.";
return false;
}
public bool TryShowFullMenu(IReadOnlyList<string> paths, int screenX, int screenY, nint ownerHwnd, out string? error)
{
error = null;
var normalized = Normalize(paths);
if (normalized.Count == 0)
{
error = "Select a file first.";
return false;
}
nint hMenu = 0;
nint host = 0;
IContextMenu? menu = null;
try
{
if (!TryCreateShellMenu(normalized, out menu) || menu is null)
{
error = "Windows could not build the full menu.";
return false;
}
hMenu = CreatePopupMenu();
var hr = menu.QueryContextMenu(
hMenu,
0,
IdCmdFirst,
IdCmdLast,
CmfNormal | CmfExplore | CmfCanRename | CmfItemMenu | CmfExtendedVerbs);
if (hr < 0)
{
error = "Windows could not fill the full menu.";
return false;
}
DrainPendingMouseClicks();
WaitForMouseButtonsUp();
// Message-only windows cannot host a visible TrackPopupMenu. Own a tiny popup
// from the Workbench HWND so IContextMenu2/3 messages stay off the WPF wndproc.
host = CreateWindowEx(
WsExToolwindow | WsExNoActivate,
"Static",
"",
WsPopup,
screenX,
screenY,
1,
1,
ownerHwnd,
0,
GetModuleHandle(null),
0);
var hwnd = host != 0 ? host : ownerHwnd;
if (hwnd == 0)
{
error = "Windows could not open the full menu.";
return false;
}
_menu2 = QueryInterface<IContextMenu2>(menu);
_menu3 = QueryInterface<IContextMenu3>(menu);
if (host != 0)
{
ShowWindow(host, SwShowNoActivate);
Subclass(host);
}
SetForegroundWindow(ownerHwnd != 0 ? ownerHwnd : hwnd);
var cmd = TrackPopupMenuEx(
hMenu,
TpmLeftAlign | TpmRightButton | TpmReturnCmd,
screenX,
screenY,
hwnd,
0);
PostMessage(hwnd, WmNull, 0, 0);
if (cmd < IdCmdFirst)
{
return true;
}
return InvokeOffset(menu, cmd - IdCmdFirst, ownerHwnd != 0 ? ownerHwnd : hwnd, out error);
}
catch (Exception ex)
{
error = ex.Message;
_logger.LogDebug(ex, "Full shell menu failed");
return false;
}
finally
{
if (host != 0)
{
Unsubclass(host);
DestroyWindow(host);
}
_menu2 = null;
_menu3 = null;
if (hMenu != 0)
{
DestroyMenu(hMenu);
}
if (menu is not null)
{
Marshal.ReleaseComObject(menu);
}
}
}
private static IReadOnlyList<string> Normalize(IReadOnlyList<string> paths)
=> paths
.Where(path => !string.IsNullOrWhiteSpace(path) && !LocationRoots.IsVirtual(path))
.Select(PathRules.FromExtended)
.Distinct(StringComparer.OrdinalIgnoreCase)
.Take(32)
.ToList();
private bool InvokeOffset(IContextMenu menu, uint offset, nint hwnd, out string? error)
{
error = null;
var info = new Cminvokecommandinfo
{
cbSize = Marshal.SizeOf<Cminvokecommandinfo>(),
fMask = 0,
hwnd = hwnd,
lpVerb = (nint)offset,
lpParameters = null,
lpDirectory = null,
nShow = SwShowNormal
};
var hr = menu.InvokeCommand(ref info);
if (hr < 0)
{
error = "The Windows command failed.";
_logger.LogDebug("IContextMenu.InvokeCommand offset={Offset} hr={Hr}", offset, hr);
return false;
}
return true;
}
private static bool TryCreateShellMenu(IReadOnlyList<string> paths, out IContextMenu? menu)
=> (paths.Count == 1 && TryCreateItemMenu(paths[0], out menu))
|| TryCreateItemArrayMenu(paths, out menu);
private static bool TryCreateItemMenu(string path, out IContextMenu? menu)
{
menu = null;
var iidItem = ShellItemId;
if (SHCreateItemFromParsingName(path, 0, ref iidItem, out var item) != 0 || item is null)
{
return false;
}
try
{
return TryBindContextMenu(item, out menu);
}
finally
{
Marshal.ReleaseComObject(item);
}
}
private static bool TryCreateItemArrayMenu(IReadOnlyList<string> paths, out IContextMenu? menu)
{
menu = null;
var pidls = new List<nint>();
try
{
foreach (var path in paths)
{
if (SHParseDisplayName(path, 0, out var pidl, 0, out _) != 0 || pidl == 0)
{
return false;
}
pidls.Add(pidl);
}
if (SHCreateShellItemArrayFromIDLists((uint)pidls.Count, pidls.ToArray(), out var array) != 0
|| array is null)
{
return false;
}
try
{
return TryBindContextMenu(array, out menu);
}
finally
{
Marshal.ReleaseComObject(array);
}
}
catch
{
return false;
}
finally
{
foreach (var pidl in pidls)
{
ILFree(pidl);
}
}
}
private static bool TryBindContextMenu(IShellItem item, out IContextMenu? menu)
{
var bhid = SfuiObject;
var iid = ContextMenuId;
return TryWrapContextMenu(item.BindToHandler(0, ref bhid, ref iid, out var unk), unk, out menu);
}
private static bool TryBindContextMenu(IShellItemArray array, out IContextMenu? menu)
{
var bhid = SfuiObject;
var iid = ContextMenuId;
return TryWrapContextMenu(array.BindToHandler(0, ref bhid, ref iid, out var unk), unk, out menu);
}
private static bool TryWrapContextMenu(int hr, nint unk, out IContextMenu? menu)
{
menu = null;
if (hr != 0 || unk == 0)
{
return false;
}
try
{
menu = (IContextMenu)Marshal.GetObjectForIUnknown(unk);
return menu is not null;
}
finally
{
Marshal.Release(unk);
}
}
private static void DrainPendingMouseClicks()
{
while (PeekMessage(out _, 0, WmLButtonDown, WmLButtonUp, PmRemove))
{
}
while (PeekMessage(out _, 0, WmRButtonDown, WmRButtonUp, PmRemove))
{
}
}
private static void WaitForMouseButtonsUp()
{
var start = Environment.TickCount64;
while (Environment.TickCount64 - start < 250
&& ((GetAsyncKeyState(VkLButton) & 0x8000) != 0 || (GetAsyncKeyState(VkRButton) & 0x8000) != 0))
{
DrainPendingMouseClicks();
Thread.Sleep(10);
}
}
private IReadOnlyList<ShellContextVerb> QueryRegistryVerbs(IReadOnlyList<string> paths)
{
_staticCommands = new Dictionary<string, string>(StringComparer.Ordinal);
var allFiles = paths.All(File.Exists);
var allDirs = paths.All(Directory.Exists);
var keys = new List<string>();
if (allFiles)
{
keys.Add(@"*\shell");
var exts = paths.Select(path => Path.GetExtension(path)).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
if (exts.Count == 1 && !string.IsNullOrEmpty(exts[0]))
{
keys.AddRange(ProgIdShellKeys(exts[0]));
}
}
else if (allDirs)
{
keys.Add(@"Directory\shell");
keys.Add(@"Folder\shell");
}
var items = new List<ShellContextVerb>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var key in keys)
{
foreach (var hive in new[] { Registry.ClassesRoot, Registry.CurrentUser })
{
var path = hive == Registry.CurrentUser ? @"Software\Classes\" + key : key;
using var shell = hive.OpenSubKey(path);
if (shell is null)
{
continue;
}
foreach (var name in shell.GetSubKeyNames())
{
using var verbKey = shell.OpenSubKey(name);
if (verbKey is null || verbKey.GetValue("LegacyDisable") is not null
|| verbKey.GetValue("ProgrammaticAccessOnly") is not null)
{
continue;
}
using var commandKey = verbKey.OpenSubKey("command");
var command = commandKey?.GetValue(null) as string;
if (string.IsNullOrWhiteSpace(command) || commandKey?.GetValue("DelegateExecute") is not null)
{
continue;
}
var label = (verbKey.GetValue("MUIVerb") as string) ?? (verbKey.GetValue(null) as string) ?? name;
if (label.StartsWith('@'))
{
label = name;
}
var canonical = ShellContextVerbFilter.CanonicalLabel(label);
if (!seen.Add(canonical) || !ShellContextVerbFilter.ShouldInclude(name, label))
{
continue;
}
var id = "static:" + items.Count;
_staticCommands[id] = command;
items.Add(new ShellContextVerb(id, label));
}
}
}
return items;
}
private static IEnumerable<string> ProgIdShellKeys(string extension)
{
yield return @"SystemFileAssociations\" + extension + @"\shell";
using var extKey = Registry.ClassesRoot.OpenSubKey(extension);
var progId = extKey?.GetValue(null) as string;
if (!string.IsNullOrWhiteSpace(progId))
{
yield return progId + @"\shell";
}
}
private void Subclass(nint hwnd)
{
_subclassProc = OnSubclass;
_oldWndProc = SetWindowLongPtr(hwnd, GwlpWndProc, Marshal.GetFunctionPointerForDelegate(_subclassProc));
}
private void Unsubclass(nint hwnd)
{
if (hwnd == 0 || _oldWndProc == 0)
{
return;
}
SetWindowLongPtr(hwnd, GwlpWndProc, _oldWndProc);
_oldWndProc = 0;
_subclassProc = null;
}
private nint OnSubclass(nint hWnd, uint msg, nint wParam, nint lParam)
{
if (_menu3 is not null
&& msg is WmInitMenuPopup or WmDrawItem or WmMeasureItem or WmMenuChar
&& _menu3.HandleMenuMsg2(msg, wParam, lParam, out var result) == 0)
{
return result;
}
if (_menu2 is not null
&& msg is WmInitMenuPopup or WmDrawItem or WmMeasureItem or WmMenuChar)
{
_menu2.HandleMenuMsg(msg, wParam, lParam);
return 0;
}
return CallWindowProc(_oldWndProc, hWnd, msg, wParam, lParam);
}
private static T? QueryInterface<T>(object com) where T : class
{
var unk = Marshal.GetIUnknownForObject(com);
try
{
var iid = typeof(T).GUID;
if (Marshal.QueryInterface(unk, in iid, out var ptr) != 0 || ptr == 0)
{
return null;
}
try
{
return (T)Marshal.GetObjectForIUnknown(ptr);
}
finally
{
Marshal.Release(ptr);
}
}
finally
{
Marshal.Release(unk);
}
}
private delegate nint WndProc(nint hWnd, uint msg, nint wParam, nint lParam);
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool SetForegroundWindow(nint hWnd);
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool ShowWindow(nint hWnd, int nCmdShow);
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool PostMessage(nint hWnd, uint msg, nint wParam, nint lParam);
[DllImport("user32.dll")]
private static extern short GetAsyncKeyState(int vKey);
[DllImport("user32.dll", CharSet = CharSet.Unicode, EntryPoint = "PeekMessageW")]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool PeekMessage(out NativeMsg msg, nint hWnd, uint msgMin, uint msgMax, uint remove);
[DllImport("user32.dll")]
private static extern uint TrackPopupMenuEx(nint hmenu, uint flags, int x, int y, nint hwnd, nint paramsEx);
[DllImport("user32.dll", EntryPoint = "SetWindowLongPtrW")]
private static extern nint SetWindowLongPtr64(nint hWnd, int nIndex, nint dwNewLong);
[DllImport("user32.dll", EntryPoint = "SetWindowLongW")]
private static extern int SetWindowLong32(nint hWnd, int nIndex, int dwNewLong);
private static nint SetWindowLongPtr(nint hWnd, int nIndex, nint value)
=> nint.Size == 8
? SetWindowLongPtr64(hWnd, nIndex, value)
: SetWindowLong32(hWnd, nIndex, (int)value);
[DllImport("user32.dll")]
private static extern nint CallWindowProc(nint prev, nint hWnd, uint msg, nint wParam, nint lParam);
[DllImport("user32.dll", CharSet = CharSet.Unicode, EntryPoint = "CreateWindowExW")]
private static extern nint CreateWindowEx(
uint exStyle,
string className,
string windowName,
uint style,
int x,
int y,
int width,
int height,
nint parent,
nint menu,
nint instance,
nint param);
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool DestroyWindow(nint hWnd);
[DllImport("kernel32.dll", CharSet = CharSet.Unicode)]
private static extern nint GetModuleHandle(string? module);
[DllImport("shell32.dll", CharSet = CharSet.Unicode)]
private static extern int SHParseDisplayName(string name, nint bindCtx, out nint pidl, uint sfgaoIn, out uint sfgaoOut);
[DllImport("shell32.dll", CharSet = CharSet.Unicode)]
private static extern int SHCreateItemFromParsingName(
string path,
nint bindCtx,
[In] ref Guid riid,
[MarshalAs(UnmanagedType.Interface)] out IShellItem item);
[DllImport("shell32.dll")]
private static extern int SHCreateShellItemArrayFromIDLists(uint cidl, [In] nint[] pidls, out IShellItemArray array);
[DllImport("shell32.dll")]
private static extern void ILFree(nint pidl);
[DllImport("user32.dll")]
private static extern nint CreatePopupMenu();
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool DestroyMenu(nint hMenu);
[ComImport]
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
[Guid("43826d1e-e718-42ee-bc55-a1e261c37bfe")]
private interface IShellItem
{
[PreserveSig]
int BindToHandler(nint pbc, ref Guid bhid, ref Guid riid, out nint ppv);
}
[ComImport]
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
[Guid("b63ea76d-1f85-456f-a19c-48159efa858b")]
private interface IShellItemArray
{
[PreserveSig]
int BindToHandler(nint pbc, ref Guid bhid, ref Guid riid, out nint ppv);
}
[ComImport]
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
[Guid("000214e4-0000-0000-c000-000000000046")]
private interface IContextMenu
{
[PreserveSig]
int QueryContextMenu(nint hmenu, uint indexMenu, uint idCmdFirst, uint idCmdLast, uint uFlags);
[PreserveSig]
int InvokeCommand(ref Cminvokecommandinfo info);
[PreserveSig]
int GetCommandString(UIntPtr idCmd, uint uType, nint reserved, nint name, uint cchMax);
}
[ComImport]
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
[Guid("000214f4-0000-0000-c000-000000000046")]
private interface IContextMenu2
{
[PreserveSig]
int QueryContextMenu(nint hmenu, uint indexMenu, uint idCmdFirst, uint idCmdLast, uint uFlags);
[PreserveSig]
int InvokeCommand(ref Cminvokecommandinfo info);
[PreserveSig]
int GetCommandString(UIntPtr idCmd, uint uType, nint reserved, nint name, uint cchMax);
[PreserveSig]
int HandleMenuMsg(uint uMsg, nint wParam, nint lParam);
}
[ComImport]
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
[Guid("000214fc-0000-0000-c000-000000000046")]
private interface IContextMenu3
{
[PreserveSig]
int QueryContextMenu(nint hmenu, uint indexMenu, uint idCmdFirst, uint idCmdLast, uint uFlags);
[PreserveSig]
int InvokeCommand(ref Cminvokecommandinfo info);
[PreserveSig]
int GetCommandString(UIntPtr idCmd, uint uType, nint reserved, nint name, uint cchMax);
[PreserveSig]
int HandleMenuMsg(uint uMsg, nint wParam, nint lParam);
[PreserveSig]
int HandleMenuMsg2(uint uMsg, nint wParam, nint lParam, out nint result);
}
[StructLayout(LayoutKind.Sequential)]
private struct NativeMsg
{
public nint hwnd;
public uint message;
public nint wParam;
public nint lParam;
public uint time;
public int ptX;
public int ptY;
}
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Ansi)]
private struct Cminvokecommandinfo
{
public int cbSize;
public int fMask;
public nint hwnd;
public nint lpVerb;
public string? lpParameters;
public string? lpDirectory;
public int nShow;
public int dwHotKey;
public nint hIcon;
}
}