354 lines
15 KiB
C#
354 lines
15 KiB
C#
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);
|
||
}
|
||
}
|