Initial commit: FluidExplorer:从零实现的 WinUI 3 文件资源管理器替代品(Mica、命令栏、面包屑)

This commit is contained in:
WpyQwq
2026-09-19 11:54:03 +08:00
commit 5780fde61a
60 changed files with 15035 additions and 0 deletions
+353
View File
@@ -0,0 +1,353 @@
using System.Runtime.InteropServices;
using System.Security.Principal;
namespace FluidExplorer.Services.Operations;
/// <summary>回收站里一条 $I 索引记录解析出来的信息。</summary>
internal sealed record RecycleBinItem(string IndexPath, string DataPath, string OriginalPath, long Size, DateTime DeletedUtc);
/// <summary>
/// 回收站定位器:
/// 1. 用 shell32!SHFileOperationW(FO_DELETE + FOF_ALLOWUNDO) 把一批路径送进回收站(一次调用完成一批);
/// 2. 通过"操作前后 &lt;卷&gt;:\$Recycle.Bin\&lt;SID&gt;\ 目录里 $I* 文件的差集"定位本次新增的回收站条目,
/// 解析 $I 结构拿到原始路径,并把 $I 前缀换成 $R 得到回收站内的真实数据路径,
/// 从而支持 Ctrl+Z 一步还原。
///
/// $I 文件结构(Win10+ 为版本 2):
/// offset 0 8B 版本号(Win10+ = 2)
/// offset 8 8B 原始文件大小
/// offset 16 8B 删除时间(FILETIME)
/// offset 24 4B 文件名长度(仅版本 &gt;= 2 存在)
/// offset 24/28 UTF-16LE 的原始完整路径,以 \0 结尾
/// 路径长度字段在不同 Windows 版本上语义有歧义(字符数 / 字节数两种实现都有),
/// 因此这里直接读到缓冲区末尾并按第一个 \0 截断,比依赖该字段更稳。
/// </summary>
internal static partial class RecycleBinLocator
{
private const uint FO_DELETE = 0x0003;
private const ushort FOF_SILENT = 0x0004;
private const ushort FOF_NOCONFIRMATION = 0x0010;
private const ushort FOF_ALLOWUNDO = 0x0040;
private const ushort FOF_NOERRORUI = 0x0400;
private const ushort FOF_WANTNUKEWARNING = 0x4000;
private const ushort DeleteFlags = FOF_ALLOWUNDO | FOF_NOCONFIRMATION | FOF_SILENT | FOF_NOERRORUI | FOF_WANTNUKEWARNING;
[StructLayout(LayoutKind.Sequential)]
private struct SHFILEOPSTRUCTW
{
public IntPtr hwnd;
public uint wFunc;
public IntPtr pFrom;
public IntPtr pTo;
public ushort fFlags;
public int fAnyOperationsAborted;
public IntPtr hNameMappings;
public IntPtr lpszProgressTitle;
}
// 用传统 DllImport:结构体全是 blittable 字段,不需要 LibraryImport 的 unsafe 代码生成,
// 这样本层不引入 AllowUnsafeBlocks 依赖,任何项目链接这些源码都能直接编译。
[DllImport("shell32.dll", EntryPoint = "SHFileOperationW", CharSet = CharSet.Unicode, ExactSpelling = true, SetLastError = true)]
private static extern int SHFileOperation(ref SHFILEOPSTRUCTW lpFileOp);
// ------------------------------------------------------------ 删除到回收站
/// <summary>把一批路径送进回收站。必须一次调用完成一批(Shell 语义)。</summary>
internal static (bool Success, bool Aborted, int Code) DeleteToRecycleBin(IReadOnlyList<string> paths)
{
if (paths.Count == 0) return (true, false, 0);
// 注意:Shell API 只接受普通路径,绝不能带 \\?\ 前缀。
var from = PathHelper.AllocDoubleNullList(paths.Select(PathHelper.ToShellPath));
var op = new SHFILEOPSTRUCTW
{
hwnd = IntPtr.Zero,
wFunc = FO_DELETE,
pFrom = from,
pTo = IntPtr.Zero,
fFlags = DeleteFlags,
fAnyOperationsAborted = 0,
hNameMappings = IntPtr.Zero,
lpszProgressTitle = IntPtr.Zero
};
try
{
var code = SHFileOperation(ref op);
var aborted = op.fAnyOperationsAborted != 0;
return (code == 0 && !aborted, aborted, code);
}
finally
{
Marshal.FreeHGlobal(from);
}
}
/// <summary>
/// 在专用 STA 线程上执行 SHFileOperationW。
/// Shell 函数在内部会做 COM/OLE 相关工作,用 STA 线程调用最稳妥;
/// 该线程是后台线程,不会阻塞 UI,也不会阻止进程退出。
/// </summary>
internal static Task<(bool Success, bool Aborted, int Code)> DeleteToRecycleBinAsync(IReadOnlyList<string> paths)
{
var tcs = new TaskCompletionSource<(bool Success, bool Aborted, int Code)>(TaskCreationOptions.RunContinuationsAsynchronously);
var thread = new Thread(() =>
{
try
{
tcs.TrySetResult(DeleteToRecycleBin(paths));
}
catch (Exception ex)
{
tcs.TrySetException(ex);
}
})
{
IsBackground = true,
Name = "FluidExplorer-RecycleBin"
};
try
{
thread.SetApartmentState(ApartmentState.STA);
}
catch (PlatformNotSupportedException)
{
// 非 Windows 平台(本引擎实际只跑 Windows):直接以 MTA 启动。
}
thread.Start();
return tcs.Task;
}
internal static string DescribeResult(int code) => code switch
{
0 => "成功",
2 => "找不到指定的文件。",
3 => "找不到指定的路径。",
5 => "拒绝访问。",
0x20 => "共享冲突(文件正被其它进程使用)。",
0x71 => "源与目标是同一个文件。",
0x72 => "多个源文件对应单个目标(目录)。",
0x73 => "源与目标处于不同目录。",
0x74 => "不能对根目录执行该操作。",
0x75 => "操作已被取消。",
0x76 => "目标位于源的子树中。",
0x78 => "访问源文件被拒绝。",
0x79 => "路径层级过深。",
0x7A => "目标过多。",
0x7C => "存在无效文件名。",
0x7D => "目标与源在同一目录树内。",
0x7E => "目标为文件,但源为文件夹。",
0x80 => "目标为文件夹,但源为文件。",
0x81 => "文件名过长。",
0x82 => "目标磁盘为 CD-ROM。",
0x83 => "目标磁盘为 DVD。",
0x84 => "目标磁盘为可刻录光盘。",
0x85 => "文件过大。",
0x86 => "源磁盘为 CD-ROM。",
0x87 => "源磁盘为 DVD。",
0x88 => "源磁盘为可刻录光盘。",
0xB7 => "超过文件名/路径长度上限。",
0x10000 => "目标上发生未指明的错误。",
_ => $"SHFileOperation 返回错误码 0x{code:X}。"
};
// ------------------------------------------------------------ $I / $R 定位
/// <summary>取当前进程用户的 SID 字符串(回收站目录名)。</summary>
internal static string? TryGetCurrentUserSid()
{
try
{
using var identity = WindowsIdentity.GetCurrent();
return identity.User?.Value;
}
catch (Exception)
{
return null;
}
}
/// <summary>某个卷的回收站目录:&lt;卷&gt;:\$Recycle.Bin\&lt;SID&gt;\(不存在返回 null)。</summary>
internal static string? GetRecycleBinDirectory(string volumeRoot)
{
var sid = TryGetCurrentUserSid();
if (string.IsNullOrEmpty(sid)) return null;
var dir = PathHelper.Combine(PathHelper.Combine(PathHelper.GetVolumeRoot(volumeRoot), "$Recycle.Bin"), sid);
return PathHelper.DirectoryExists(dir) ? dir : null;
}
/// <summary>
/// 快照:卷 → 该卷回收站里所有 $I 文件的完整路径集合。
/// 主体扫描 &lt;卷&gt;:\$Recycle.Bin\&lt;当前用户 SID&gt;\,同时兜底扫描其它 SID 目录
/// (进程可能以别的账户删除过文件)。
/// </summary>
internal static Dictionary<string, HashSet<string>> CaptureState(IReadOnlyList<string> paths)
{
var volumes = new List<string>();
foreach (var path in paths)
{
var root = PathHelper.GetVolumeRoot(path);
if (root.Length < 2) continue;
if (!volumes.Contains(root, StringComparer.OrdinalIgnoreCase)) volumes.Add(root);
}
var result = new Dictionary<string, HashSet<string>>(StringComparer.OrdinalIgnoreCase);
foreach (var volume in volumes) result[volume] = EnumerateIndexFiles(volume);
return result;
}
private static HashSet<string> EnumerateIndexFiles(string volumeRoot)
{
var set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var binRoot = PathHelper.Combine(PathHelper.GetVolumeRoot(volumeRoot), "$Recycle.Bin");
if (!PathHelper.DirectoryExists(binRoot)) return set;
var directories = new List<string>();
var ownSidDirectory = GetRecycleBinDirectory(volumeRoot);
if (ownSidDirectory is not null) directories.Add(ownSidDirectory);
foreach (var sub in PathHelper.EnumerateChildrenSafe(binRoot))
{
if (!PathHelper.DirectoryExists(sub)) continue;
if (!directories.Contains(sub, StringComparer.OrdinalIgnoreCase)) directories.Add(sub);
}
foreach (var directory in directories)
{
foreach (var file in PathHelper.EnumerateChildrenSafe(directory))
{
var name = PathHelper.GetFileName(file);
if (name.StartsWith("$I", StringComparison.OrdinalIgnoreCase)) set.Add(file);
}
}
return set;
}
/// <summary>
/// 用"操作前后目录快照差集"找出本次新增的回收站条目,并用 $I 里的原始路径做二次确认,
/// 产出 (回收站内 $R 数据路径, 原始路径) 列表,可直接填入 <see cref="UndoEntry.Deleted"/>。
/// 定位失败的项也会产出记录(回收站路径为空串),撤销时按"无法还原"计入失败,不会崩溃。
/// </summary>
internal static (List<(string RecyclePath, string OriginalPath)> Items, List<string> Diagnostics) ResolveDeletedItems(
IReadOnlyList<string> deletedPaths,
Dictionary<string, HashSet<string>> before)
{
var items = new List<(string RecyclePath, string OriginalPath)>();
var diagnostics = new List<string>();
var remaining = new List<string>(deletedPaths);
foreach (var (volume, previous) in before)
{
var current = EnumerateIndexFiles(volume);
var added = current.Where(f => !previous.Contains(f)).OrderBy(f => f, StringComparer.OrdinalIgnoreCase).ToList();
if (added.Count == 0) continue;
foreach (var indexFile in added)
{
var parsed = TryParseIndexFile(indexFile);
if (parsed is null)
{
diagnostics.Add($"$I 解析失败(长度/格式异常):{indexFile}");
continue;
}
// 匹配策略:先按原始完整路径精确匹配,再退化为"同名文件"匹配
// (一次 SHFileOperation 调用内完成的条目,时间窗天然一致)。
var match = remaining.FirstOrDefault(p => SamePath(p, parsed.OriginalPath))
?? remaining.FirstOrDefault(p => string.Equals(
PathHelper.GetFileName(p), PathHelper.GetFileName(parsed.OriginalPath), StringComparison.OrdinalIgnoreCase));
if (match is null)
{
diagnostics.Add($"回收站新增条目未能匹配本次删除路径:{indexFile}(原始路径 {parsed.OriginalPath})");
continue;
}
if (!PathHelper.Exists(parsed.DataPath))
diagnostics.Add($"找到 $I 记录但缺少对应的 $R 数据文件:{parsed.DataPath}");
items.Add((parsed.DataPath, match));
remaining.Remove(match);
}
}
foreach (var path in remaining)
{
diagnostics.Add($"未能在回收站定位到“{path}”的 $I/$R 记录(可能被永久删除或回收站不可用),撤销时该项将按“无法还原”处理。");
items.Add((string.Empty, path));
}
return (items, diagnostics);
}
/// <summary>解析单个 $I 索引文件。</summary>
internal static RecycleBinItem? TryParseIndexFile(string indexFilePath)
{
byte[] bytes;
try
{
bytes = File.ReadAllBytes(PathHelper.ToExtended(indexFilePath));
}
catch (Exception)
{
return null;
}
if (bytes.Length < 28) return null;
var version = BitConverter.ToInt64(bytes, 0);
var size = BitConverter.ToInt64(bytes, 8);
var fileTime = BitConverter.ToInt64(bytes, 16);
DateTime deletedUtc;
try
{
deletedUtc = fileTime > 0 ? DateTime.FromFileTimeUtc(fileTime) : DateTime.MinValue;
}
catch (ArgumentOutOfRangeException)
{
deletedUtc = DateTime.MinValue;
}
// 版本 1(Vista/7)无文件名长度字段;版本 2(Win10+)多 4 字节。
var pathOffset = version >= 2 ? 28 : 24;
if (bytes.Length <= pathOffset) return null;
var payload = bytes.AsSpan(pathOffset);
if ((payload.Length & 1) == 1) payload = payload[..^1]; // UTF-16 按 2 字节对齐
var chars = MemoryMarshal.Cast<byte, char>(payload);
var terminator = chars.IndexOf('\0');
if (terminator >= 0) chars = chars[..terminator];
if (chars.Length == 0) return null;
var originalPath = new string(chars);
var name = PathHelper.GetFileName(indexFilePath);
if (name.Length < 3 || !name.StartsWith("$I", StringComparison.OrdinalIgnoreCase)) return null;
// $R 对应文件:把 $I 换成 $R 前缀即为回收站内的实际数据路径(目录同样适用)。
var dataPath = PathHelper.Combine(PathHelper.GetDirectoryName(indexFilePath), "$R" + name[2..]);
return new RecycleBinItem(indexFilePath, dataPath, originalPath, size, deletedUtc);
}
/// <summary>由 $R 数据路径反推对应的 $I 索引路径。</summary>
internal static string? GetIndexPathFromDataPath(string recycleDataPath)
{
var name = PathHelper.GetFileName(recycleDataPath);
if (name.Length < 3 || !name.StartsWith("$R", StringComparison.OrdinalIgnoreCase)) return null;
return PathHelper.Combine(PathHelper.GetDirectoryName(recycleDataPath), "$I" + name[2..]);
}
private static bool SamePath(string a, string b)
{
var na = (PathHelper.TryGetFullPath(a) ?? a).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
var nb = (PathHelper.TryGetFullPath(b) ?? b).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
return string.Equals(na, nb, StringComparison.OrdinalIgnoreCase);
}
}