Files

691 lines
27 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Buffers;
namespace FluidExplorer.Services.Operations;
/// <summary>单个条目(文件/目录)处理后的结果。</summary>
internal enum EntryOutcome
{
Success,
Skipped,
Failed,
Cancelled
}
/// <summary>
/// 拷贝引擎与作业运行器之间的回调边界。
/// 引擎本身不认识 FileOperationJob / UI,只通过这个接口上报进度、询问冲突、请求取消,
/// 因此可以脱离队列单独测试与复用。
/// </summary>
internal interface IJobSink
{
CancellationToken Token { get; }
bool IsCancellationRequested { get; }
/// <summary>若作业处于暂停态则挂起,直到继续或取消。每个拷贝块之间调用。</summary>
Task WaitIfPausedAsync();
void AddBytes(long delta);
void AddCompletedItems(int delta);
void SetCurrentItem(string path);
/// <summary>记录一条非致命警告(重解析点跳过、时间戳设置失败等),进 job.Error。</summary>
void Warn(string message);
/// <summary>记录一个失败条目;引擎会继续处理其余文件,绝不中止整批。</summary>
void AddFailed(string path, string reason);
void AddSkipped(int delta = 1);
/// <summary>
/// 记录一次真实的"搬运"以便撤销。
/// 语义:<paramref name="newPath"/> 是搬运后的当前位置,<paramref name="originalPath"/> 是原位置;
/// 撤销时把 newPath 搬回 originalPath。(同卷移动是 1 条;目录合并移动会产生多条。)
/// </summary>
void RecordMoveForUndo(string newPath, string originalPath);
/// <summary>向 UI 询问冲突处理方式。未设置回调 / 非 Ask 策略时由运行器按 Policy 直接决定,不阻塞。</summary>
Task<ConflictResolution> ResolveConflictAsync(ConflictInfo info);
/// <summary>用户选择了"取消",终止整个作业(已完成的部分保留,不回滚)。</summary>
void RequestCancel();
}
/// <summary>
/// 文件复制 / 移动 / 删除的核心实现。
///
/// 关键设计:
/// - 所有 Win32 文件 IO 走 \\?\ 长路径前缀(见 <see cref="PathHelper"/>);
/// - 1MB 缓冲 + SequentialScan + 异步 IO,块与块之间检查暂停/取消;
/// - 单文件失败只重试 3 次(100/300/900ms)后计入失败列表并继续,绝不因单个文件中断整批;
/// - 同卷 Move 走 File.Move/Directory.Move(瞬时、不搬字节),跨卷才 Copy+Delete;
/// 之所以不用 MoveFileEx/直接调 API:那样虽然也能跨卷搬,但拿不到字节级进度,
/// 而"精确进度 + 可暂停/取消"是本引擎的核心诉求。
/// </summary>
internal static class CopyEngine
{
/// <summary>拷贝块大小:1MB。</summary>
internal const int BufferSize = 1024 * 1024;
/// <summary>重试间隔(毫秒):共重试 3 次。</summary>
private static readonly int[] RetryDelaysMs = [100, 300, 900];
// ---------------------------------------------------------------- 测量
/// <summary>
/// 递归统计总字节数与总条目数(目录本身也算 1 个条目,和执行阶段的计数口径一致)。
/// 不跟随重解析点;无法访问的项跳过。可取消。
/// </summary>
internal static (long Bytes, int Items) Measure(IReadOnlyList<string> paths, CancellationToken cancellationToken, Action<string>? onWarning = null)
{
long bytes = 0;
var items = 0;
foreach (var path in paths)
{
if (cancellationToken.IsCancellationRequested) return (bytes, items);
var attrs = PathHelper.TryGetAttributes(path);
if (attrs is null)
{
onWarning?.Invoke($"测量时跳过无法访问的项:{path}");
continue;
}
if ((attrs & FileAttributes.ReparsePoint) != 0)
{
onWarning?.Invoke($"测量时跳过重解析点:{path}");
continue;
}
if ((attrs & FileAttributes.Directory) == 0)
{
bytes += PathHelper.TryGetLength(path);
items++;
continue;
}
var stack = new Stack<string>();
stack.Push(path);
while (stack.Count > 0)
{
if (cancellationToken.IsCancellationRequested) return (bytes, items);
var dir = stack.Pop();
items++;
foreach (var child in PathHelper.EnumerateChildrenSafe(dir, onWarning))
{
var childAttrs = PathHelper.TryGetAttributes(child);
if (childAttrs is null) continue;
if ((childAttrs & FileAttributes.ReparsePoint) != 0) continue; // 不跟随,避免无限递归
if ((childAttrs & FileAttributes.Directory) != 0) stack.Push(child);
else
{
bytes += PathHelper.TryGetLength(child);
items++;
}
}
}
}
return (bytes, items);
}
// ---------------------------------------------------------------- 复制
/// <summary>复制一个条目(文件或目录),内部处理冲突策略。</summary>
internal static async Task<EntryOutcome> CopyEntryAsync(string source, string destination, IJobSink sink)
{
if (sink.IsCancellationRequested) return EntryOutcome.Cancelled;
var attrs = PathHelper.TryGetAttributes(source);
if (attrs is null)
{
sink.AddFailed(source, "源不存在或无法访问。");
return EntryOutcome.Failed;
}
if ((attrs & FileAttributes.ReparsePoint) != 0)
{
sink.Warn($"跳过重解析点(符号链接 / Junction),不跟随:{source}");
sink.AddSkipped();
return EntryOutcome.Skipped;
}
var sourceIsDir = (attrs & FileAttributes.Directory) != 0;
if (PathHelper.Exists(destination))
{
var resolution = await sink.ResolveConflictAsync(BuildConflictInfo(source, destination)).ConfigureAwait(false);
switch (resolution)
{
case ConflictResolution.Cancel:
sink.RequestCancel();
return EntryOutcome.Cancelled;
case ConflictResolution.Skip:
sink.AddSkipped();
return EntryOutcome.Skipped;
case ConflictResolution.KeepBoth:
destination = PathHelper.MakeUniquePath(destination);
break;
default: // Replace:目录对目录 = 合并;文件对文件 = 先删目标再复制
var destinationIsDir = PathHelper.DirectoryExists(destination);
if (sourceIsDir && destinationIsDir) break; // 合并,保留目标目录
if (sourceIsDir != destinationIsDir)
{
sink.AddFailed(source, sourceIsDir
? "目标位置存在同名文件,无法用文件夹替换文件。"
: "目标位置存在同名文件夹,无法用文件替换文件夹。");
return EntryOutcome.Failed;
}
var removed = await DeletePermanentAsync(destination, sink, countAsItem: false).ConfigureAwait(false);
if (removed != EntryOutcome.Success) return removed;
break;
}
}
return sourceIsDir
? await CopyDirectoryAsync(source, destination, sink).ConfigureAwait(false)
: await CopyFileAsync(source, destination, sink).ConfigureAwait(false);
}
private static async Task<EntryOutcome> CopyFileAsync(string source, string destination, IJobSink sink)
{
long attemptBytes = 0;
var ok = await RetryAsync(
async () =>
{
attemptBytes = 0;
sink.SetCurrentItem(source);
PathHelper.EnsureParentDirectory(destination);
await CopyFileCoreAsync(source, destination, sink,
n =>
{
attemptBytes += n; // 重试时用于回退已上报的字节数
sink.AddBytes(n); // 真正的进度上报(限频由 sink 负责)
}).ConfigureAwait(false);
},
source,
sink,
onRetry: () => { if (attemptBytes > 0) sink.AddBytes(-attemptBytes); }).ConfigureAwait(false);
if (ok)
{
sink.AddCompletedItems(1);
return EntryOutcome.Success;
}
return sink.IsCancellationRequested ? EntryOutcome.Cancelled : EntryOutcome.Failed;
}
private static async Task CopyFileCoreAsync(string source, string destination, IJobSink sink, Action<long> reportBytes)
{
var sourceExtended = PathHelper.ToExtended(source);
var destinationExtended = PathHelper.ToExtended(destination);
// 目标已存在且只读:必须先去只读,否则 Create 会抛 UnauthorizedAccessException。
PathHelper.ClearReadOnly(destination);
var buffer = ArrayPool<byte>.Shared.Rent(BufferSize);
try
{
await using (var input = new FileStream(sourceExtended, FileMode.Open, FileAccess.Read, FileShare.Read,
bufferSize: 1, FileOptions.Asynchronous | FileOptions.SequentialScan))
await using (var output = new FileStream(destinationExtended, FileMode.Create, FileAccess.Write, FileShare.None,
bufferSize: 1, FileOptions.Asynchronous | FileOptions.SequentialScan))
{
while (true)
{
await sink.WaitIfPausedAsync().ConfigureAwait(false);
if (sink.IsCancellationRequested) throw new OperationCanceledException(sink.Token);
var read = await input.ReadAsync(buffer.AsMemory(0, BufferSize), sink.Token).ConfigureAwait(false);
if (read <= 0) break;
await output.WriteAsync(buffer.AsMemory(0, read), sink.Token).ConfigureAwait(false);
reportBytes(read);
}
await output.FlushAsync(sink.Token).ConfigureAwait(false);
}
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
}
// 保留时间戳与属性(只读属性最后设置)。
try
{
var sourceAttrs = PathHelper.TryGetAttributes(source) ?? FileAttributes.Normal;
File.SetLastWriteTimeUtc(destinationExtended, File.GetLastWriteTimeUtc(sourceExtended));
File.SetCreationTimeUtc(destinationExtended, File.GetCreationTimeUtc(sourceExtended));
File.SetAttributes(destinationExtended,
sourceAttrs & ~(FileAttributes.Directory | FileAttributes.ReparsePoint | FileAttributes.Device));
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
sink.Warn($"已复制但无法保留时间戳/属性:{destination}({ex.Message})");
}
}
private static async Task<EntryOutcome> CopyDirectoryAsync(string source, string destination, IJobSink sink)
{
if (IsSameOrSubPathOf(destination, source))
{
sink.AddFailed(source, "目标路径位于源目录内部,已拒绝执行(会造成无限递归)。");
return EntryOutcome.Failed;
}
// 用显式栈做迭代式递归,避免极深目录树耗尽调用栈。
var stack = new Stack<(string Source, string Destination)>();
stack.Push((source, destination));
while (stack.Count > 0)
{
await sink.WaitIfPausedAsync().ConfigureAwait(false);
if (sink.IsCancellationRequested) return EntryOutcome.Cancelled;
var (currentSource, currentDestination) = stack.Pop();
var created = await RetryAsync(
() =>
{
PathHelper.EnsureParentDirectory(currentDestination);
Directory.CreateDirectory(PathHelper.ToExtended(currentDestination));
return Task.CompletedTask;
},
currentDestination,
sink).ConfigureAwait(false);
if (!created) return EntryOutcome.Failed;
sink.SetCurrentItem(currentDestination);
sink.AddCompletedItems(1);
foreach (var child in PathHelper.EnumerateChildrenSafe(currentSource, sink.Warn))
{
await sink.WaitIfPausedAsync().ConfigureAwait(false);
if (sink.IsCancellationRequested) return EntryOutcome.Cancelled;
var childAttrs = PathHelper.TryGetAttributes(child);
if (childAttrs is null)
{
sink.AddFailed(child, "无法读取属性(可能已被删除或拒绝访问)。");
continue;
}
if ((childAttrs & FileAttributes.ReparsePoint) != 0)
{
sink.Warn($"跳过重解析点(符号链接 / Junction),不跟随:{child}");
sink.AddSkipped();
continue;
}
var target = PathHelper.Combine(currentDestination, PathHelper.GetFileName(child));
if ((childAttrs & FileAttributes.Directory) != 0)
{
stack.Push((child, target));
}
else
{
var outcome = await CopyEntryAsync(child, target, sink).ConfigureAwait(false);
if (outcome == EntryOutcome.Cancelled) return EntryOutcome.Cancelled;
}
}
}
return EntryOutcome.Success;
}
// ---------------------------------------------------------------- 移动
/// <summary>移动一个条目,内部处理冲突策略。</summary>
internal static async Task<EntryOutcome> MoveEntryAsync(string source, string destination, IJobSink sink)
{
if (sink.IsCancellationRequested) return EntryOutcome.Cancelled;
var attrs = PathHelper.TryGetAttributes(source);
if (attrs is null)
{
sink.AddFailed(source, "源不存在或无法访问。");
return EntryOutcome.Failed;
}
if ((attrs & FileAttributes.ReparsePoint) != 0)
{
sink.Warn($"跳过重解析点(符号链接 / Junction),不跟随:{source}");
sink.AddSkipped();
return EntryOutcome.Skipped;
}
var sourceIsDir = (attrs & FileAttributes.Directory) != 0;
if (PathHelper.Exists(destination))
{
var resolution = await sink.ResolveConflictAsync(BuildConflictInfo(source, destination)).ConfigureAwait(false);
switch (resolution)
{
case ConflictResolution.Cancel:
sink.RequestCancel();
return EntryOutcome.Cancelled;
case ConflictResolution.Skip:
sink.AddSkipped();
return EntryOutcome.Skipped;
case ConflictResolution.KeepBoth:
destination = PathHelper.MakeUniquePath(destination);
break;
default:
var destinationIsDir = PathHelper.DirectoryExists(destination);
if (sourceIsDir && destinationIsDir) break; // 目录对目录:合并(递归搬运子项)
if (sourceIsDir != destinationIsDir)
{
sink.AddFailed(source, sourceIsDir
? "目标位置存在同名文件,无法用文件夹替换文件。"
: "目标位置存在同名文件夹,无法用文件替换文件夹。");
return EntryOutcome.Failed;
}
var removed = await DeletePermanentAsync(destination, sink, countAsItem: false).ConfigureAwait(false);
if (removed != EntryOutcome.Success) return removed;
break;
}
}
if (sourceIsDir && PathHelper.DirectoryExists(destination))
return await MoveDirectoryMergedAsync(source, destination, sink).ConfigureAwait(false);
return await MoveSingleAsync(source, destination, sourceIsDir, sink).ConfigureAwait(false);
}
/// <summary>
/// 不做冲突询问的移动(重命名、撤销还原用):
/// 调用方必须已经保证目标路径不冲突,或已经自行决定好冲突处理方式。
/// </summary>
internal static async Task<EntryOutcome> MoveDirectAsync(string source, string destination, IJobSink sink)
{
if (sink.IsCancellationRequested) return EntryOutcome.Cancelled;
var attrs = PathHelper.TryGetAttributes(source);
if (attrs is null)
{
sink.AddFailed(source, "源不存在或无法访问。");
return EntryOutcome.Failed;
}
if ((attrs & FileAttributes.ReparsePoint) != 0)
{
sink.Warn($"跳过重解析点(符号链接 / Junction),不跟随:{source}");
sink.AddSkipped();
return EntryOutcome.Skipped;
}
var sourceIsDir = (attrs & FileAttributes.Directory) != 0;
if (sourceIsDir && PathHelper.DirectoryExists(destination))
return await MoveDirectoryMergedAsync(source, destination, sink).ConfigureAwait(false);
return await MoveSingleAsync(source, destination, sourceIsDir, sink).ConfigureAwait(false);
}
private static async Task<EntryOutcome> MoveSingleAsync(string source, string destination, bool sourceIsDir, IJobSink sink)
{
// 同卷:File.Move / Directory.Move 是纯元数据操作,瞬时完成,不产生任何字节流量。
if (PathHelper.SameVolume(source, destination))
{
var moved = await RetryAsync(
() =>
{
sink.SetCurrentItem(source);
PathHelper.EnsureParentDirectory(destination);
var s = PathHelper.ToExtended(source);
var d = PathHelper.ToExtended(destination);
if (sourceIsDir) Directory.Move(s, d);
else File.Move(s, d);
return Task.CompletedTask;
},
source,
sink).ConfigureAwait(false);
if (moved)
{
sink.AddCompletedItems(1);
sink.RecordMoveForUndo(destination, source);
return EntryOutcome.Success;
}
if (sink.IsCancellationRequested) return EntryOutcome.Cancelled;
// 同卷判定失败(例如跨卷挂载点/Junction)时退化:复制成功后删源,仍有字节级进度。
sink.Warn($"同卷移动失败,自动改用“复制后删除”:{source}");
}
var copied = sourceIsDir
? await CopyDirectoryAsync(source, destination, sink).ConfigureAwait(false)
: await CopyEntryAsync(source, destination, sink).ConfigureAwait(false);
if (copied != EntryOutcome.Success) return copied;
sink.RecordMoveForUndo(destination, source);
var deleted = await DeletePermanentAsync(source, sink, countAsItem: false).ConfigureAwait(false);
return deleted == EntryOutcome.Success ? EntryOutcome.Success : deleted;
}
/// <summary>
/// 目录合并移动:目标目录已存在时,逐个搬子项。
/// 同卷时每个子项都是瞬时的 File.Move;同名子项按冲突策略处理。
/// </summary>
private static async Task<EntryOutcome> MoveDirectoryMergedAsync(string source, string destination, IJobSink sink)
{
Directory.CreateDirectory(PathHelper.ToExtended(destination));
foreach (var child in PathHelper.EnumerateChildrenSafe(source, sink.Warn))
{
await sink.WaitIfPausedAsync().ConfigureAwait(false);
if (sink.IsCancellationRequested) return EntryOutcome.Cancelled;
var target = PathHelper.Combine(destination, PathHelper.GetFileName(child));
var outcome = await MoveEntryAsync(child, target, sink).ConfigureAwait(false);
if (outcome == EntryOutcome.Cancelled) return EntryOutcome.Cancelled;
}
TryDeleteEmptyDirectory(source);
return EntryOutcome.Success;
}
// ---------------------------------------------------------------- 删除
/// <summary>永久删除(不进回收站)。目录采用"后序迭代删除",逐个条目上报进度。</summary>
internal static async Task<EntryOutcome> DeletePermanentAsync(string path, IJobSink sink, bool countAsItem)
{
var attrs = PathHelper.TryGetAttributes(path);
if (attrs is null)
{
if (countAsItem) sink.AddFailed(path, "路径不存在或无法访问。");
return countAsItem ? EntryOutcome.Failed : EntryOutcome.Success;
}
if ((attrs & FileAttributes.Directory) == 0)
{
var ok = await RetryAsync(
() =>
{
sink.SetCurrentItem(path);
PathHelper.ClearReadOnly(path);
File.Delete(PathHelper.ToExtended(path));
return Task.CompletedTask;
},
path,
sink).ConfigureAwait(false);
if (!ok) return sink.IsCancellationRequested ? EntryOutcome.Cancelled : EntryOutcome.Failed;
if (countAsItem) sink.AddCompletedItems(1);
return EntryOutcome.Success;
}
var stack = new Stack<(string Path, bool Expanded)>();
stack.Push((path, false));
while (stack.Count > 0)
{
await sink.WaitIfPausedAsync().ConfigureAwait(false);
if (sink.IsCancellationRequested) return EntryOutcome.Cancelled;
var (current, expanded) = stack.Pop();
if (!expanded)
{
stack.Push((current, true));
foreach (var child in PathHelper.EnumerateChildrenSafe(current, sink.Warn))
{
var childAttrs = PathHelper.TryGetAttributes(child);
if (childAttrs is null) continue;
// 重解析点:只删链接本身,绝不递归进去(否则会删掉链接目标的内容)。
if ((childAttrs & FileAttributes.ReparsePoint) != 0 || (childAttrs & FileAttributes.Directory) == 0)
stack.Push((child, true));
else
stack.Push((child, false));
}
continue;
}
var isDirectory = PathHelper.DirectoryExists(current);
var deleted = await RetryAsync(
() =>
{
sink.SetCurrentItem(current);
PathHelper.ClearReadOnly(current);
var extended = PathHelper.ToExtended(current);
if (isDirectory) Directory.Delete(extended, recursive: false);
else File.Delete(extended);
return Task.CompletedTask;
},
current,
sink).ConfigureAwait(false);
if (deleted && countAsItem) sink.AddCompletedItems(1);
}
return EntryOutcome.Success;
}
// ---------------------------------------------------------------- 通用
/// <summary>重试包装:IOException / UnauthorizedAccessException 重试 3 次(100/300/900ms),
/// 仍失败则计入失败列表并返回 false,由调用方继续处理其余文件。</summary>
private static async Task<bool> RetryAsync(Func<Task> action, string path, IJobSink sink, Action? onRetry = null)
{
for (var attempt = 0; ; attempt++)
{
try
{
await action().ConfigureAwait(false);
return true;
}
catch (OperationCanceledException)
{
return false;
}
catch (Exception ex) when (attempt < RetryDelaysMs.Length && IsTransient(ex))
{
onRetry?.Invoke();
try
{
await Task.Delay(RetryDelaysMs[attempt], sink.Token).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
return false;
}
}
catch (Exception ex)
{
onRetry?.Invoke();
sink.AddFailed(path, Describe(ex));
return false;
}
}
}
private static bool IsTransient(Exception ex) => ex is IOException or UnauthorizedAccessException;
private static string Describe(Exception ex) => ex switch
{
UnauthorizedAccessException => "拒绝访问(文件可能被占用或权限不足)。",
DirectoryNotFoundException => "目录不存在(可能已被移动或删除)。",
FileNotFoundException => "文件不存在(可能已被移动或删除)。",
PathTooLongException => "路径过长。",
_ => ex.Message
};
internal static ConflictInfo BuildConflictInfo(string source, string destination)
{
var sourceAttrs = PathHelper.TryGetAttributes(source) ?? 0;
var destinationAttrs = PathHelper.TryGetAttributes(destination) ?? 0;
var sourceIsDir = (sourceAttrs & FileAttributes.Directory) != 0;
var destinationIsDir = (destinationAttrs & FileAttributes.Directory) != 0;
return new ConflictInfo
{
SourcePath = source,
DestinationPath = destination,
SourceIsDirectory = sourceIsDir,
SourceSize = sourceIsDir ? 0 : PathHelper.TryGetLength(source),
DestinationSize = destinationIsDir ? 0 : PathHelper.TryGetLength(destination),
SourceModifiedUtc = TryGetModifiedUtc(source),
DestinationModifiedUtc = TryGetModifiedUtc(destination)
};
}
private static DateTime TryGetModifiedUtc(string path)
{
try
{
return File.GetLastWriteTimeUtc(PathHelper.ToExtended(path));
}
catch (Exception)
{
return DateTime.MinValue;
}
}
/// <summary>candidate 是否等于 root 或位于 root 之内(用于拒绝"复制到自身内部")。</summary>
internal static bool IsSameOrSubPathOf(string candidate, string root)
{
var c = (PathHelper.TryGetFullPath(candidate) ?? candidate)
.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
var r = (PathHelper.TryGetFullPath(root) ?? root)
.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
if (string.Equals(c, r, StringComparison.OrdinalIgnoreCase)) return true;
return c.StartsWith(r + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase);
}
private static void TryDeleteEmptyDirectory(string path)
{
try
{
var extended = PathHelper.ToExtended(path);
if (Directory.Exists(extended)) Directory.Delete(extended, recursive: false);
}
catch (Exception)
{
// 目录里还有没搬走的项(例如同名冲突被 Skip 了):保留,不算失败。
}
}
}