Initial commit: FluidExplorer:从零实现的 WinUI 3 文件资源管理器替代品(Mica、命令栏、面包屑)
This commit is contained in:
@@ -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. 通过"操作前后 <卷>:\$Recycle.Bin\<SID>\ 目录里 $I* 文件的差集"定位本次新增的回收站条目,
|
||||
/// 解析 $I 结构拿到原始路径,并把 $I 前缀换成 $R 得到回收站内的真实数据路径,
|
||||
/// 从而支持 Ctrl+Z 一步还原。
|
||||
///
|
||||
/// $I 文件结构(Win10+ 为版本 2):
|
||||
/// offset 0 8B 版本号(Win10+ = 2)
|
||||
/// offset 8 8B 原始文件大小
|
||||
/// offset 16 8B 删除时间(FILETIME)
|
||||
/// offset 24 4B 文件名长度(仅版本 >= 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>某个卷的回收站目录:<卷>:\$Recycle.Bin\<SID>\(不存在返回 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 文件的完整路径集合。
|
||||
/// 主体扫描 <卷>:\$Recycle.Bin\<当前用户 SID>\,同时兜底扫描其它 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user