Initial commit: FluidExplorer:从零实现的 WinUI 3 文件资源管理器替代品(Mica、命令栏、面包屑)
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
|
||||
namespace FluidExplorer.Services.Shell;
|
||||
|
||||
/// <summary>
|
||||
/// 用外壳自己的 API(SHGetKnownFolderPath)解析系统文件夹,
|
||||
/// 保证和资源管理器指向同一批真实位置(含 OneDrive 重定向后的"桌面/文档"等)。
|
||||
/// </summary>
|
||||
public static class KnownFolders
|
||||
{
|
||||
public static string Profile { get; } = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
||||
public static string Desktop { get; } = Get(FolderId.Desktop);
|
||||
public static string Documents { get; } = Get(FolderId.Documents);
|
||||
public static string Downloads { get; } = Get(FolderId.Downloads);
|
||||
public static string Pictures { get; } = Get(FolderId.Pictures);
|
||||
public static string Music { get; } = Get(FolderId.Music);
|
||||
public static string Videos { get; } = Get(FolderId.Videos);
|
||||
public static string RecycleBin { get; } = @"shell:RecycleBinFolder";
|
||||
|
||||
/// <summary>此电脑 / 回收站等虚拟外壳对象的解析名,交给外壳取图标与打开。</summary>
|
||||
public const string ThisPcParsingName = "::{20D04FE0-3AEA-1069-A2D8-08002B30309D}";
|
||||
public const string RecycleBinParsingName = "::{645FF040-5081-101B-9F08-00AA002F954E}";
|
||||
public const string NetworkParsingName = "::{F02C1A0D-BE21-4350-88B0-7367FC96EF3C}";
|
||||
public const string HomeParsingName = "::{F874310E-B6B7-47DC-BC84-B9E6B38F5903}"; // 主页
|
||||
public const string GalleryParsingName = "::{E88865EA-0E1C-4E20-9AA6-EDCD0212C87C}"; // 图库
|
||||
|
||||
private static string Get(Guid id)
|
||||
{
|
||||
try
|
||||
{
|
||||
var hr = SHGetKnownFolderPath(ref id, 0, IntPtr.Zero, out var ptr);
|
||||
if (hr != 0 || ptr == IntPtr.Zero) return string.Empty;
|
||||
try { return Marshal.PtrToStringUni(ptr) ?? string.Empty; }
|
||||
finally { Marshal.FreeCoTaskMem(ptr); }
|
||||
}
|
||||
catch
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
[DllImport("shell32.dll", CharSet = CharSet.Unicode, ExactSpelling = true)]
|
||||
private static extern int SHGetKnownFolderPath(ref Guid rfid, uint dwFlags, IntPtr hToken, out IntPtr ppszPath);
|
||||
|
||||
private static class FolderId
|
||||
{
|
||||
public static Guid Desktop = new("B4BFCC3A-DB2C-424C-B029-7FE99A87C641");
|
||||
public static Guid Documents = new("FDD39AD0-238F-46AF-ADB4-6C85480369C7");
|
||||
public static Guid Downloads = new("374DE290-123F-4565-9164-39C4925E467B");
|
||||
public static Guid Pictures = new("33E28130-4E1E-4676-835A-98395C3BC3BB");
|
||||
public static Guid Music = new("4BD8D571-6D19-48D3-BE97-422220080E43");
|
||||
public static Guid Videos = new("18989B1D-99B5-455B-841C-AB7C74E4DDFC");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 卷信息(用于侧边栏"此电脑"和状态栏):显示名、总容量、可用空间、就绪状态。
|
||||
/// </summary>
|
||||
public sealed class DriveItem
|
||||
{
|
||||
public required string RootPath { get; init; }
|
||||
public required string DisplayName { get; init; }
|
||||
public required string VolumeLabel { get; init; }
|
||||
public required string FileSystem { get; init; }
|
||||
public long TotalBytes { get; init; }
|
||||
public long FreeBytes { get; init; }
|
||||
public int DriveType { get; init; }
|
||||
public bool IsReady { get; init; }
|
||||
|
||||
public double UsedRatio => TotalBytes <= 0 ? 0 : 1.0 - (double)FreeBytes / TotalBytes;
|
||||
|
||||
public string CapacityText => !IsReady || TotalBytes <= 0
|
||||
? "不可用"
|
||||
: $"{FluidExplorer.Models.FileEntry.FormatSize(FreeBytes)} 可用,共 {FluidExplorer.Models.FileEntry.FormatSize(TotalBytes)}";
|
||||
|
||||
public string Glyph => DriveType switch
|
||||
{
|
||||
2 => "\uE88E", // 可移动磁盘
|
||||
3 => "\uEDA2", // 本地磁盘
|
||||
4 => "\uE8CE", // 网络驱动器
|
||||
5 => "\uE958", // 光驱
|
||||
_ => "\uEDA2"
|
||||
};
|
||||
|
||||
public static IReadOnlyList<DriveItem> Enumerate()
|
||||
{
|
||||
var list = new List<DriveItem>();
|
||||
foreach (var drive in DriveInfo.GetDrives())
|
||||
{
|
||||
try
|
||||
{
|
||||
var label = drive.IsReady ? drive.VolumeLabel : string.Empty;
|
||||
var display = string.IsNullOrWhiteSpace(label)
|
||||
? (drive.Name.TrimEnd('\\') is { Length: > 0 } letter ? $"本地磁盘 ({letter})" : drive.Name)
|
||||
: $"{label} ({drive.Name.TrimEnd('\\')})";
|
||||
list.Add(new DriveItem
|
||||
{
|
||||
RootPath = drive.Name,
|
||||
DisplayName = display,
|
||||
VolumeLabel = label,
|
||||
FileSystem = drive.IsReady ? drive.DriveFormat : string.Empty,
|
||||
TotalBytes = drive.IsReady ? drive.TotalSize : 0,
|
||||
FreeBytes = drive.IsReady ? drive.TotalFreeSpace : 0,
|
||||
DriveType = (int)drive.DriveType,
|
||||
IsReady = drive.IsReady
|
||||
});
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 未就绪的驱动器(空读卡器等)直接跳过
|
||||
}
|
||||
}
|
||||
return list;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace FluidExplorer.Services.Shell;
|
||||
|
||||
/// <summary>
|
||||
/// 资源管理器"名称"列用的排序:调用系统 shlwapi 的 StrCmpLogicalW(自然排序,数字按数值比较)。
|
||||
/// 这样 "文件2" 会排在 "文件10" 前面,和原版体验一致。
|
||||
/// </summary>
|
||||
public sealed class NaturalStringComparer : IComparer<string>
|
||||
{
|
||||
public static NaturalStringComparer Instance { get; } = new();
|
||||
|
||||
public int Compare(string? x, string? y)
|
||||
{
|
||||
if (ReferenceEquals(x, y)) return 0;
|
||||
if (x is null) return -1;
|
||||
if (y is null) return 1;
|
||||
try
|
||||
{
|
||||
return StrCmpLogicalW(x, y);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return string.Compare(x, y, StringComparison.CurrentCultureIgnoreCase);
|
||||
}
|
||||
}
|
||||
|
||||
[DllImport("shlwapi.dll", CharSet = CharSet.Unicode, ExactSpelling = true)]
|
||||
private static extern int StrCmpLogicalW(string psz1, string psz2);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Text;
|
||||
|
||||
namespace FluidExplorer.Services.Shell;
|
||||
|
||||
/// <summary>回收站里的一条记录(从 $I 元数据文件解析,含原始路径)。</summary>
|
||||
public sealed record RecycleBinEntry(
|
||||
string RecyclePath,
|
||||
string OriginalPath,
|
||||
long Size,
|
||||
DateTime DeletedUtc,
|
||||
string VolumeRoot)
|
||||
{
|
||||
public string Name => Services.FileSystem.PathHelper.GetName(OriginalPath);
|
||||
public string OriginalDirectory => Services.FileSystem.PathHelper.GetParent(OriginalPath);
|
||||
public bool IsDirectory => Size == 0 && OriginalPath.Length > 0 && !Path.HasExtension(OriginalPath);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 直接读取各卷的 $Recycle.Bin\<SID>\$I* 元数据文件,得到回收站内容与原始路径。
|
||||
/// 这样"还原/彻底删除"都能由本程序完成(不依赖系统弹窗),也支持一步撤销。
|
||||
/// </summary>
|
||||
public static class RecycleBinView
|
||||
{
|
||||
public static async Task<IReadOnlyList<RecycleBinEntry>> EnumerateAsync(CancellationToken cancellationToken)
|
||||
=> await Task.Run(() => Enumerate(cancellationToken), cancellationToken).ConfigureAwait(false);
|
||||
|
||||
public static IReadOnlyList<RecycleBinEntry> Enumerate(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var results = new List<RecycleBinEntry>();
|
||||
foreach (var drive in DriveInfo.GetDrives())
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
try
|
||||
{
|
||||
if (!drive.IsReady || drive.DriveType != DriveType.Fixed) continue;
|
||||
var binRoot = Path.Combine(drive.Name, "$Recycle.Bin");
|
||||
if (!Directory.Exists(binRoot)) continue;
|
||||
|
||||
foreach (var sidDir in Directory.EnumerateDirectories(binRoot))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
try
|
||||
{
|
||||
foreach (var metaFile in Directory.EnumerateFiles(sidDir, "$I*"))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var entry = TryParse(metaFile, drive.Name);
|
||||
if (entry is not null) results.Add(entry);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 其他用户的回收站目录通常无权限,跳过
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 卷不可读时跳过
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <summary>解析单个 $I 元数据文件。</summary>
|
||||
public static RecycleBinEntry? TryParse(string metaFilePath, string volumeRoot)
|
||||
{
|
||||
try
|
||||
{
|
||||
var bytes = File.ReadAllBytes(metaFilePath);
|
||||
if (bytes.Length < 24) return null;
|
||||
|
||||
var version = BinaryPrimitives.ReadInt64LittleEndian(bytes.AsSpan(0, 8));
|
||||
var size = BinaryPrimitives.ReadInt64LittleEndian(bytes.AsSpan(8, 8));
|
||||
var fileTime = BinaryPrimitives.ReadInt64LittleEndian(bytes.AsSpan(16, 8));
|
||||
var deleted = fileTime > 0 ? DateTime.FromFileTimeUtc(fileTime) : DateTime.MinValue;
|
||||
|
||||
string originalPath;
|
||||
if (version >= 2 && bytes.Length >= 28)
|
||||
{
|
||||
var length = BinaryPrimitives.ReadInt32LittleEndian(bytes.AsSpan(24, 4));
|
||||
if (length <= 0 || 28 + length * 2 > bytes.Length) return null;
|
||||
originalPath = Encoding.Unicode.GetString(bytes, 28, length * 2).TrimEnd('\0');
|
||||
}
|
||||
else if (version == 1)
|
||||
{
|
||||
// 旧格式:路径固定在 0x2C 偏移处的 260 个宽字符
|
||||
if (bytes.Length < 0x2C + 520) return null;
|
||||
originalPath = Encoding.Unicode.GetString(bytes, 0x2C, 520).TrimEnd('\0');
|
||||
}
|
||||
else
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(originalPath)) return null;
|
||||
|
||||
var fileName = Path.GetFileName(metaFilePath);
|
||||
var recyclePath = Path.Combine(Path.GetDirectoryName(metaFilePath)!, "$R" + fileName[2..]);
|
||||
return new RecycleBinEntry(recyclePath, originalPath, Math.Max(0, size), deleted, volumeRoot);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
|
||||
namespace FluidExplorer.Services.Shell;
|
||||
|
||||
/// <summary>
|
||||
/// 交给 Windows 外壳去做的动作(打开、属性、打开方式、在资源管理器中显示…)。
|
||||
/// 全部走系统原版行为,保证和资源管理器完全一致。
|
||||
/// </summary>
|
||||
public static class ShellActions
|
||||
{
|
||||
/// <summary>用默认程序/默认动作打开(= 双击)。</summary>
|
||||
public static bool Open(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
Process.Start(new ProcessStartInfo(path) { UseShellExecute = true });
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>打开"打开方式"选择器。</summary>
|
||||
public static bool OpenWith(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
Process.Start(new ProcessStartInfo("rundll32.exe", $"shell32.dll,OpenAs_RunDLL {path}") { UseShellExecute = true });
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>调用系统原版"属性"对话框(含只读/隐藏复选框、磁盘清理等)。</summary>
|
||||
public static bool ShowProperties(IntPtr ownerHwnd, IReadOnlyList<string> paths)
|
||||
{
|
||||
if (paths.Count == 0) return false;
|
||||
var info = new SHELLEXECUTEINFO
|
||||
{
|
||||
cbSize = Marshal.SizeOf<SHELLEXECUTEINFO>(),
|
||||
fMask = SEE_MASK_INVOKEIDLIST | SEE_MASK_FLAG_NO_UI,
|
||||
hwnd = ownerHwnd,
|
||||
lpVerb = "properties",
|
||||
lpFile = paths[0],
|
||||
nShow = 5
|
||||
};
|
||||
|
||||
if (paths.Count == 1) return ShellExecuteEx(ref info);
|
||||
|
||||
// 多选时用外壳的多文件属性对话框
|
||||
try
|
||||
{
|
||||
var files = string.Join('\0', paths) + "\0\0";
|
||||
var ptr = Marshal.StringToHGlobalUni(files);
|
||||
try
|
||||
{
|
||||
info.lpFile = null;
|
||||
var psi = new SHFILEINFO();
|
||||
var hwnd = SHMultiFileProperties(new DataObjectNative { pFiles = ptr }, 0);
|
||||
_ = hwnd;
|
||||
_ = psi;
|
||||
// SHMultiFileProperties 需要 IDataObject 实现,较繁琐;退化为逐项打开第一个的属性
|
||||
info.lpFile = paths[0];
|
||||
return ShellExecuteEx(ref info);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Marshal.FreeHGlobal(ptr);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>在系统资源管理器中定位并选中该文件(用于"在资源管理器中显示")。</summary>
|
||||
public static bool RevealInExplorer(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
Process.Start(new ProcessStartInfo("explorer.exe", $"/select,\"{path}\"") { UseShellExecute = true });
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>运行对话框式的"运行"入口(用于 shell: 位置)。</summary>
|
||||
public static bool OpenShellLocation(string parsingName)
|
||||
{
|
||||
try
|
||||
{
|
||||
Process.Start(new ProcessStartInfo("explorer.exe", parsingName) { UseShellExecute = true });
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>把"打开"当作动词执行(用于右键菜单的"打开")。</summary>
|
||||
public static bool Execute(string path, string verb)
|
||||
{
|
||||
var info = new SHELLEXECUTEINFO
|
||||
{
|
||||
cbSize = Marshal.SizeOf<SHELLEXECUTEINFO>(),
|
||||
fMask = SEE_MASK_INVOKEIDLIST | SEE_MASK_FLAG_NO_UI,
|
||||
lpVerb = verb,
|
||||
lpFile = path,
|
||||
nShow = 1
|
||||
};
|
||||
return ShellExecuteEx(ref info);
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
|
||||
private struct SHELLEXECUTEINFO
|
||||
{
|
||||
public int cbSize;
|
||||
public uint fMask;
|
||||
public IntPtr hwnd;
|
||||
[MarshalAs(UnmanagedType.LPWStr)] public string? lpVerb;
|
||||
[MarshalAs(UnmanagedType.LPWStr)] public string? lpFile;
|
||||
[MarshalAs(UnmanagedType.LPWStr)] public string? lpParameters;
|
||||
[MarshalAs(UnmanagedType.LPWStr)] public string? lpDirectory;
|
||||
public int nShow;
|
||||
public IntPtr hInstApp;
|
||||
public IntPtr lpIDList;
|
||||
[MarshalAs(UnmanagedType.LPWStr)] public string? lpClass;
|
||||
public IntPtr hkeyClass;
|
||||
public uint dwHotKey;
|
||||
public IntPtr hIcon;
|
||||
public IntPtr hProcess;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct SHFILEINFO
|
||||
{
|
||||
public IntPtr hIcon;
|
||||
public int iIcon;
|
||||
public uint dwAttributes;
|
||||
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 260)] public string szDisplayName;
|
||||
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 80)] public string szTypeName;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct DataObjectNative
|
||||
{
|
||||
public IntPtr pFiles;
|
||||
}
|
||||
|
||||
private const uint SEE_MASK_INVOKEIDLIST = 0x0000000C;
|
||||
private const uint SEE_MASK_FLAG_NO_UI = 0x00000400;
|
||||
|
||||
[DllImport("shell32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
|
||||
private static extern bool ShellExecuteEx(ref SHELLEXECUTEINFO lpExecInfo);
|
||||
|
||||
[DllImport("shell32.dll", CharSet = CharSet.Unicode)]
|
||||
private static extern IntPtr SHMultiFileProperties(DataObjectNative pdtobj, uint dwFlags);
|
||||
|
||||
/// <summary>把文件放入剪贴板(CF_HDROP,与其他程序互通)。</summary>
|
||||
public static bool SetClipboardFiles(IReadOnlyList<string> paths, bool cut)
|
||||
{
|
||||
try
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
foreach (var p in paths) sb.Append(p).Append('\0');
|
||||
sb.Append('\0');
|
||||
var bytes = Encoding.Unicode.GetBytes(sb.ToString());
|
||||
var hGlobal = Marshal.AllocHGlobal(bytes.Length + 20);
|
||||
if (hGlobal == IntPtr.Zero) return false;
|
||||
|
||||
// DROPFILES 结构 + 文件名列表
|
||||
var dropFiles = new byte[20 + bytes.Length];
|
||||
BitConverter.GetBytes(20).CopyTo(dropFiles, 0); // pFiles 偏移
|
||||
BitConverter.GetBytes(0).CopyTo(dropFiles, 4); // pt.x
|
||||
BitConverter.GetBytes(0).CopyTo(dropFiles, 8); // pt.y
|
||||
BitConverter.GetBytes(0).CopyTo(dropFiles, 12); // fNC
|
||||
BitConverter.GetBytes(1).CopyTo(dropFiles, 16); // fWide = TRUE
|
||||
bytes.CopyTo(dropFiles, 20);
|
||||
Marshal.Copy(dropFiles, 0, hGlobal, dropFiles.Length);
|
||||
|
||||
var format = RegisterClipboardFormat(cut ? "Preferred DropEffect" : "Preferred DropEffect");
|
||||
var effect = new byte[4];
|
||||
BitConverter.GetBytes(cut ? 2 : 5).CopyTo(effect, 0); // DROPEFFECT_MOVE=2 / COPY=5
|
||||
var hEffect = Marshal.AllocHGlobal(4);
|
||||
Marshal.Copy(effect, 0, hEffect, 4);
|
||||
|
||||
if (!OpenClipboard(IntPtr.Zero)) { Marshal.FreeHGlobal(hGlobal); Marshal.FreeHGlobal(hEffect); return false; }
|
||||
try
|
||||
{
|
||||
EmptyClipboard();
|
||||
SetClipboardData(15 /*CF_HDROP*/, hGlobal);
|
||||
SetClipboardData(format, hEffect);
|
||||
}
|
||||
finally
|
||||
{
|
||||
CloseClipboard();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)] private static extern bool OpenClipboard(IntPtr hWndNewOwner);
|
||||
[DllImport("user32.dll", SetLastError = true)] private static extern bool CloseClipboard();
|
||||
[DllImport("user32.dll", SetLastError = true)] private static extern bool EmptyClipboard();
|
||||
[DllImport("user32.dll", SetLastError = true)] private static extern IntPtr SetClipboardData(uint uFormat, IntPtr hMem);
|
||||
[DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Unicode)] private static extern uint RegisterClipboardFormat(string lpszFormat);
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Runtime.InteropServices.ComTypes;
|
||||
using System.Text;
|
||||
|
||||
namespace FluidExplorer.Services.Shell;
|
||||
|
||||
/// <summary>
|
||||
/// Windows 系统原版右键菜单(shell 的 IContextMenu):
|
||||
/// 就是资源管理器"显示更多选项"里那一套(含第三方扩展、发送到、打开方式、Windows Terminal 等),
|
||||
/// 直接用系统实现,不自己造菜单项。
|
||||
/// </summary>
|
||||
public static class ShellContextMenu
|
||||
{
|
||||
public static Task ShowAsync(IntPtr ownerHwnd, IReadOnlyList<string> paths, Windows.Graphics.PointInt32 screenPoint)
|
||||
{
|
||||
var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var dispatcher = Microsoft.UI.Dispatching.DispatcherQueue.GetForCurrentThread();
|
||||
|
||||
void Run()
|
||||
{
|
||||
try
|
||||
{
|
||||
ShowCore(ownerHwnd, paths, screenPoint.X, screenPoint.Y);
|
||||
tcs.TrySetResult();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
tcs.TrySetException(ex);
|
||||
}
|
||||
}
|
||||
|
||||
if (dispatcher is null || dispatcher.HasThreadAccess) Run();
|
||||
else dispatcher.TryEnqueue(Run);
|
||||
return tcs.Task;
|
||||
}
|
||||
|
||||
private static void ShowCore(IntPtr hwnd, IReadOnlyList<string> paths, int x, int y)
|
||||
{
|
||||
if (paths.Count == 0) return;
|
||||
|
||||
var pidls = new List<IntPtr>();
|
||||
var allocated = new List<IntPtr>();
|
||||
IShellFolder? parentFolder = null;
|
||||
|
||||
try
|
||||
{
|
||||
// 取第一条的父文件夹,作为构建 IContextMenu 的宿主(多选时要求同一父目录)
|
||||
var firstHr = SHParseDisplayName(paths[0], IntPtr.Zero, out var firstPidl, 0, out _);
|
||||
if (firstHr != 0 || firstPidl == IntPtr.Zero) return;
|
||||
allocated.Add(firstPidl);
|
||||
|
||||
var bindHr = SHBindToParent(firstPidl, typeof(IShellFolder).GUID, out var folderObj, out var childPidl);
|
||||
if (bindHr != 0 || folderObj is null) return;
|
||||
parentFolder = (IShellFolder)folderObj;
|
||||
|
||||
var childPidls = new List<IntPtr> { childPidl };
|
||||
for (var i = 1; i < paths.Count; i++)
|
||||
{
|
||||
if (SHParseDisplayName(paths[i], IntPtr.Zero, out var pidl, 0, out _) != 0 || pidl == IntPtr.Zero) continue;
|
||||
allocated.Add(pidl);
|
||||
var hr = SHBindToParent(pidl, typeof(IShellFolder).GUID, out var folder, out var child);
|
||||
if (hr != 0 || folder is null) continue;
|
||||
if (folder != parentFolder) continue; // 不同目录的多选:忽略额外项(由上层逐个处理)
|
||||
childPidls.Add(child);
|
||||
}
|
||||
|
||||
var iid = typeof(IContextMenu).GUID;
|
||||
var uiObjectHr = parentFolder.GetUIObjectOf(hwnd, (uint)childPidls.Count, childPidls.ToArray(), ref iid, IntPtr.Zero, out var contextMenuObj);
|
||||
if (uiObjectHr != 0 || contextMenuObj is null) return;
|
||||
|
||||
var contextMenu = (IContextMenu)contextMenuObj;
|
||||
var contextMenu2 = contextMenuObj as IContextMenu2;
|
||||
var contextMenu3 = contextMenuObj as IContextMenu3;
|
||||
|
||||
var hMenu = CreatePopupMenu();
|
||||
if (hMenu == IntPtr.Zero) return;
|
||||
|
||||
try
|
||||
{
|
||||
const uint CMF_NORMAL = 0x00000000;
|
||||
const uint CMF_EXTENDEDVERBS = 0x00000100;
|
||||
var queryHr = contextMenu.QueryContextMenu(hMenu, 0, 1, 0x7FFF, CMF_NORMAL | CMF_EXTENDEDVERBS);
|
||||
if (queryHr < 0) return;
|
||||
|
||||
// 系统菜单里"打开方式/发送到"等子菜单需要转发 owner-draw 消息
|
||||
using var hook = contextMenu2 is null && contextMenu3 is null
|
||||
? null
|
||||
: new MenuMessageHook(hwnd, contextMenu2, contextMenu3);
|
||||
|
||||
const uint TPM_RETURNCMD = 0x0100;
|
||||
const uint TPM_RIGHTBUTTON = 0x0002;
|
||||
var command = TrackPopupMenuEx(hMenu, TPM_RETURNCMD | TPM_RIGHTBUTTON, x, y, hwnd, IntPtr.Zero);
|
||||
if (command <= 0) return;
|
||||
|
||||
var info = new CMINVOKECOMMANDINFOEX
|
||||
{
|
||||
cbSize = Marshal.SizeOf<CMINVOKECOMMANDINFOEX>(),
|
||||
fMask = 0x00004000 /*CMIC_MASK_UNICODE*/,
|
||||
hwnd = hwnd,
|
||||
lpVerb = (IntPtr)(command - 1),
|
||||
lpVerbW = (IntPtr)(command - 1),
|
||||
nShow = 1
|
||||
};
|
||||
|
||||
var invokeHr = contextMenu.InvokeCommand(ref info);
|
||||
_ = invokeHr;
|
||||
}
|
||||
finally
|
||||
{
|
||||
DestroyMenu(hMenu);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
foreach (var pidl in allocated) Marshal.FreeCoTaskMem(pidl);
|
||||
if (parentFolder is not null) Marshal.ReleaseComObject(parentFolder);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>把菜单的 owner-draw / init 消息转发给 IContextMenu2/3(子菜单才能正常展开)。</summary>
|
||||
private sealed class MenuMessageHook : IDisposable
|
||||
{
|
||||
private readonly IntPtr _hwnd;
|
||||
private readonly IContextMenu2? _menu2;
|
||||
private readonly IContextMenu3? _menu3;
|
||||
private readonly SubclassProc _proc;
|
||||
private readonly IntPtr _oldProc;
|
||||
|
||||
public MenuMessageHook(IntPtr hwnd, IContextMenu2? menu2, IContextMenu3? menu3)
|
||||
{
|
||||
_hwnd = hwnd;
|
||||
_menu2 = menu2;
|
||||
_menu3 = menu3;
|
||||
_proc = HookProc;
|
||||
_oldProc = SetWindowLongPtr(hwnd, GWLP_WNDPROC, Marshal.GetFunctionPointerForDelegate(_proc));
|
||||
}
|
||||
|
||||
private IntPtr HookProc(IntPtr hwnd, uint msg, IntPtr wParam, IntPtr lParam)
|
||||
{
|
||||
switch (msg)
|
||||
{
|
||||
case WM_INITMENUPOPUP:
|
||||
case WM_DRAWITEM:
|
||||
case WM_MEASUREITEM:
|
||||
case WM_MENUCHAR:
|
||||
if (_menu3 is not null)
|
||||
{
|
||||
var handled = IntPtr.Zero;
|
||||
if (_menu3.HandleMenuMsg2(msg, wParam, lParam, out handled) == 0 && handled != IntPtr.Zero) return handled;
|
||||
}
|
||||
else if (_menu2 is not null)
|
||||
{
|
||||
if (_menu2.HandleMenuMsg(msg, wParam, lParam) == 0) return IntPtr.Zero;
|
||||
}
|
||||
break;
|
||||
}
|
||||
return CallWindowProc(_oldProc, hwnd, msg, wParam, lParam);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_oldProc != IntPtr.Zero) SetWindowLongPtr(_hwnd, GWLP_WNDPROC, _oldProc);
|
||||
}
|
||||
}
|
||||
|
||||
private const int GWLP_WNDPROC = -4;
|
||||
private const uint WM_INITMENUPOPUP = 0x0117;
|
||||
private const uint WM_DRAWITEM = 0x002B;
|
||||
private const uint WM_MEASUREITEM = 0x002C;
|
||||
private const uint WM_MENUCHAR = 0x0120;
|
||||
|
||||
private delegate IntPtr SubclassProc(IntPtr hwnd, uint msg, IntPtr wParam, IntPtr lParam);
|
||||
|
||||
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
|
||||
private struct CMINVOKECOMMANDINFOEX
|
||||
{
|
||||
public int cbSize;
|
||||
public uint fMask;
|
||||
public IntPtr hwnd;
|
||||
public IntPtr lpVerb;
|
||||
public IntPtr lpParameters;
|
||||
public IntPtr lpDirectory;
|
||||
public int nShow;
|
||||
public uint dwHotKey;
|
||||
public IntPtr hIcon;
|
||||
public IntPtr lpTitle;
|
||||
public IntPtr lpVerbW;
|
||||
public IntPtr lpParametersW;
|
||||
public IntPtr lpDirectoryW;
|
||||
public IntPtr lpTitleW;
|
||||
public POINT ptInvoke;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct POINT
|
||||
{
|
||||
public int X;
|
||||
public int Y;
|
||||
}
|
||||
|
||||
[ComImport]
|
||||
[Guid("000214E6-0000-0000-C000-000000000046")]
|
||||
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
||||
private interface IShellFolder
|
||||
{
|
||||
[PreserveSig] int ParseDisplayName(IntPtr hwnd, IntPtr pbc, [MarshalAs(UnmanagedType.LPWStr)] string pszDisplayName, out uint pchEaten, out IntPtr ppidl, ref uint pdwAttributes);
|
||||
[PreserveSig] int EnumObjects(IntPtr hwnd, uint grfFlags, out IntPtr ppenumIDList);
|
||||
[PreserveSig] int BindToObject(IntPtr pidl, IntPtr pbc, ref Guid riid, out IntPtr ppv);
|
||||
[PreserveSig] int BindToStorage(IntPtr pidl, IntPtr pbc, ref Guid riid, out IntPtr ppv);
|
||||
[PreserveSig] int CompareIDs(IntPtr lParam, IntPtr pidl1, IntPtr pidl2);
|
||||
[PreserveSig] int CreateViewObject(IntPtr hwndOwner, ref Guid riid, out IntPtr ppv);
|
||||
[PreserveSig] int GetAttributesOf(uint cidl, IntPtr[] apidl, ref uint rgfInOut);
|
||||
[PreserveSig] int GetUIObjectOf(IntPtr hwndOwner, uint cidl, IntPtr[] apidl, ref Guid riid, IntPtr rgfReserved, out object ppv);
|
||||
[PreserveSig] int GetDisplayNameOf(IntPtr pidl, uint uFlags, out IntPtr pName);
|
||||
[PreserveSig] int SetNameOf(IntPtr hwnd, IntPtr pidl, [MarshalAs(UnmanagedType.LPWStr)] string pszName, uint uFlags, out IntPtr ppidlOut);
|
||||
}
|
||||
|
||||
[ComImport]
|
||||
[Guid("000214E4-0000-0000-C000-000000000046")]
|
||||
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
||||
private interface IContextMenu
|
||||
{
|
||||
[PreserveSig] int QueryContextMenu(IntPtr hmenu, uint indexMenu, uint idCmdFirst, uint idCmdLast, uint uFlags);
|
||||
[PreserveSig] int InvokeCommand(ref CMINVOKECOMMANDINFOEX pici);
|
||||
[PreserveSig] int GetCommandString(IntPtr idCmd, uint uType, IntPtr pReserved, StringBuilder pszName, uint cchMax);
|
||||
}
|
||||
|
||||
[ComImport]
|
||||
[Guid("000214F4-0000-0000-C000-000000000046")]
|
||||
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
||||
private interface IContextMenu2
|
||||
{
|
||||
[PreserveSig] int QueryContextMenu(IntPtr hmenu, uint indexMenu, uint idCmdFirst, uint idCmdLast, uint uFlags);
|
||||
[PreserveSig] int InvokeCommand(ref CMINVOKECOMMANDINFOEX pici);
|
||||
[PreserveSig] int GetCommandString(IntPtr idCmd, uint uType, IntPtr pReserved, StringBuilder pszName, uint cchMax);
|
||||
[PreserveSig] int HandleMenuMsg(uint uMsg, IntPtr wParam, IntPtr lParam);
|
||||
}
|
||||
|
||||
[ComImport]
|
||||
[Guid("BCFCE0A0-EC17-11D0-8D10-00A0C90F2719")]
|
||||
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
||||
private interface IContextMenu3
|
||||
{
|
||||
[PreserveSig] int QueryContextMenu(IntPtr hmenu, uint indexMenu, uint idCmdFirst, uint idCmdLast, uint uFlags);
|
||||
[PreserveSig] int InvokeCommand(ref CMINVOKECOMMANDINFOEX pici);
|
||||
[PreserveSig] int GetCommandString(IntPtr idCmd, uint uType, IntPtr pReserved, StringBuilder pszName, uint cchMax);
|
||||
[PreserveSig] int HandleMenuMsg(uint uMsg, IntPtr wParam, IntPtr lParam);
|
||||
[PreserveSig] int HandleMenuMsg2(uint uMsg, IntPtr wParam, IntPtr lParam, out IntPtr plResult);
|
||||
}
|
||||
|
||||
[DllImport("shell32.dll", CharSet = CharSet.Unicode)]
|
||||
private static extern int SHParseDisplayName(string pszName, IntPtr pbc, out IntPtr ppidl, uint sfgaoIn, out uint psfgaoOut);
|
||||
|
||||
[DllImport("shell32.dll", CharSet = CharSet.Unicode)]
|
||||
private static extern int SHBindToParent(IntPtr pidl, Guid riid, out object ppv, out IntPtr ppidlLast);
|
||||
|
||||
[DllImport("user32.dll")] private static extern IntPtr CreatePopupMenu();
|
||||
[DllImport("user32.dll")] private static extern bool DestroyMenu(IntPtr hMenu);
|
||||
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
|
||||
private static extern int TrackPopupMenuEx(IntPtr hMenu, uint fuFlags, int x, int y, IntPtr hwnd, IntPtr lptpm);
|
||||
|
||||
[DllImport("user32.dll", EntryPoint = "SetWindowLongPtrW")]
|
||||
private static extern IntPtr SetWindowLongPtr(IntPtr hWnd, int nIndex, IntPtr dwNewLong);
|
||||
|
||||
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
|
||||
private static extern IntPtr CallWindowProc(IntPtr lpPrevWndFunc, IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace FluidExplorer.Services.Shell;
|
||||
|
||||
/// <summary>
|
||||
/// 用注册表把扩展名映射成资源管理器里显示的友好类型名(例如 "PDF 文档"、"文本文档")。
|
||||
/// 结果缓存;失败时回退到 "XXX 文件"。
|
||||
/// </summary>
|
||||
public static class TypeNameResolver
|
||||
{
|
||||
private static readonly ConcurrentDictionary<string, string> Cache = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public static string GetTypeName(string extension, bool isDirectory)
|
||||
{
|
||||
if (isDirectory) return "文件夹";
|
||||
if (string.IsNullOrEmpty(extension)) return "文件";
|
||||
return Cache.GetOrAdd(extension, static ext =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var key = "." + ext;
|
||||
using var extKey = Registry.ClassesRoot.OpenSubKey(key);
|
||||
if (extKey is null) return $"{ext.ToUpperInvariant()} 文件";
|
||||
|
||||
var progId = extKey.GetValue(null) as string;
|
||||
if (!string.IsNullOrEmpty(progId) && (progId.StartsWith("AppX", StringComparison.OrdinalIgnoreCase)
|
||||
|| progId.Contains("_", StringComparison.Ordinal)))
|
||||
{
|
||||
// AppX/UWP 关联:优先用 "FriendlyTypeName"(本地化资源,直接读字符串)
|
||||
var friendly = extKey.GetValue("FriendlyTypeName") as string;
|
||||
if (!string.IsNullOrWhiteSpace(friendly)) return friendly;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(progId))
|
||||
{
|
||||
using var progKey = Registry.ClassesRoot.OpenSubKey(progId);
|
||||
if (progKey is not null)
|
||||
{
|
||||
var name = progKey.GetValue("FriendlyTypeName") as string ?? progKey.GetValue(null) as string;
|
||||
if (!string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
// 间接字符串(@dll,-id)无法直接解析,交回扩展名兜底
|
||||
return name.StartsWith('@') ? $"{ext.ToUpperInvariant()} 文件" : name;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var extFriendly = extKey.GetValue("FriendlyTypeName") as string;
|
||||
if (!string.IsNullOrWhiteSpace(extFriendly) && !extFriendly.StartsWith('@')) return extFriendly;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 注册表不可读(权限/损坏)时静默回退
|
||||
}
|
||||
|
||||
return $"{ext.ToUpperInvariant()} 文件";
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user