using System.Runtime.InteropServices; using System.Security.Principal; namespace FluidExplorer.Services.Operations; /// 回收站里一条 $I 索引记录解析出来的信息。 internal sealed record RecycleBinItem(string IndexPath, string DataPath, string OriginalPath, long Size, DateTime DeletedUtc); /// /// 回收站定位器: /// 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 截断,比依赖该字段更稳。 /// 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); // ------------------------------------------------------------ 删除到回收站 /// 把一批路径送进回收站。必须一次调用完成一批(Shell 语义)。 internal static (bool Success, bool Aborted, int Code) DeleteToRecycleBin(IReadOnlyList 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); } } /// /// 在专用 STA 线程上执行 SHFileOperationW。 /// Shell 函数在内部会做 COM/OLE 相关工作,用 STA 线程调用最稳妥; /// 该线程是后台线程,不会阻塞 UI,也不会阻止进程退出。 /// internal static Task<(bool Success, bool Aborted, int Code)> DeleteToRecycleBinAsync(IReadOnlyList 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 定位 /// 取当前进程用户的 SID 字符串(回收站目录名)。 internal static string? TryGetCurrentUserSid() { try { using var identity = WindowsIdentity.GetCurrent(); return identity.User?.Value; } catch (Exception) { return null; } } /// 某个卷的回收站目录:<卷>:\$Recycle.Bin\<SID>\(不存在返回 null)。 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; } /// /// 快照:卷 → 该卷回收站里所有 $I 文件的完整路径集合。 /// 主体扫描 <卷>:\$Recycle.Bin\<当前用户 SID>\,同时兜底扫描其它 SID 目录 /// (进程可能以别的账户删除过文件)。 /// internal static Dictionary> CaptureState(IReadOnlyList paths) { var volumes = new List(); 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>(StringComparer.OrdinalIgnoreCase); foreach (var volume in volumes) result[volume] = EnumerateIndexFiles(volume); return result; } private static HashSet EnumerateIndexFiles(string volumeRoot) { var set = new HashSet(StringComparer.OrdinalIgnoreCase); var binRoot = PathHelper.Combine(PathHelper.GetVolumeRoot(volumeRoot), "$Recycle.Bin"); if (!PathHelper.DirectoryExists(binRoot)) return set; var directories = new List(); 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; } /// /// 用"操作前后目录快照差集"找出本次新增的回收站条目,并用 $I 里的原始路径做二次确认, /// 产出 (回收站内 $R 数据路径, 原始路径) 列表,可直接填入 。 /// 定位失败的项也会产出记录(回收站路径为空串),撤销时按"无法还原"计入失败,不会崩溃。 /// internal static (List<(string RecyclePath, string OriginalPath)> Items, List Diagnostics) ResolveDeletedItems( IReadOnlyList deletedPaths, Dictionary> before) { var items = new List<(string RecyclePath, string OriginalPath)>(); var diagnostics = new List(); var remaining = new List(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); } /// 解析单个 $I 索引文件。 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(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); } /// 由 $R 数据路径反推对应的 $I 索引路径。 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); } }