diff --git a/CollapseLauncher/Classes/FileMigrationProcess/Events.cs b/CollapseLauncher/Classes/FileMigrationProcess/Events.cs new file mode 100644 index 000000000..6c0802e75 --- /dev/null +++ b/CollapseLauncher/Classes/FileMigrationProcess/Events.cs @@ -0,0 +1,67 @@ +using Hi3Helper; +using Hi3Helper.Data; +using System; +using System.Threading.Tasks; + +namespace CollapseLauncher +{ + internal partial class FileMigrationProcess + { + private void UpdateCountProcessed(FileMigrationProcessUIRef uiRef, string currentPathProcessed) + { + lock (this) { this.CurrentFileCountMoved++; } + + string fileCountProcessedString = string.Format(Locale.Lang._Misc.PerFromTo, + this.CurrentFileCountMoved, + this.TotalFileCount); + + lock (uiRef.fileCountIndicatorSubtitle) + { + this.parentUI.DispatcherQueue.TryEnqueue(() => + { + uiRef.fileCountIndicatorSubtitle.Text = fileCountProcessedString; + uiRef.pathActivitySubtitle.Text = currentPathProcessed; + }); + } + } + + private async void UpdateSizeProcessed(FileMigrationProcessUIRef uiRef, long currentRead) + { + lock (this) { this.CurrentSizeMoved += currentRead; } + + if (await CheckIfNeedRefreshStopwatch()) + { + double percentage = Math.Round((double)this.CurrentSizeMoved / this.TotalFileSize * 100d, 2); + double speed = this.CurrentSizeMoved / this.ProcessStopwatch.Elapsed.TotalSeconds; + + lock (uiRef.progressBarIndicator) + { + this.parentUI.DispatcherQueue.TryEnqueue(() => + { + string speedString = string.Format(Locale.Lang._Misc.SpeedPerSec, ConverterTool.SummarizeSizeSimple(speed)); + string sizeProgressString = string.Format(Locale.Lang._Misc.PerFromTo, + ConverterTool.SummarizeSizeSimple(this.CurrentSizeMoved), + ConverterTool.SummarizeSizeSimple(this.TotalFileSize)); + + uiRef.speedIndicatorSubtitle.Text = speedString; + uiRef.fileSizeIndicatorSubtitle.Text = sizeProgressString; + uiRef.progressBarIndicator.Value = percentage; + uiRef.progressBarIndicator.IsIndeterminate = false; + }); + } + } + } + + private async ValueTask CheckIfNeedRefreshStopwatch() + { + if (this.EventsStopwatch.ElapsedMilliseconds > _refreshInterval) + { + this.EventsStopwatch.Restart(); + return true; + } + + await Task.Delay(_refreshInterval); + return false; + } + } +} diff --git a/CollapseLauncher/Classes/FileMigrationProcess/FileMigrationProcess.cs b/CollapseLauncher/Classes/FileMigrationProcess/FileMigrationProcess.cs new file mode 100644 index 000000000..a431c299b --- /dev/null +++ b/CollapseLauncher/Classes/FileMigrationProcess/FileMigrationProcess.cs @@ -0,0 +1,160 @@ +using Microsoft.UI.Xaml; +using System; +using System.Diagnostics; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace CollapseLauncher +{ + internal partial class FileMigrationProcess + { + private const int _refreshInterval = 100; // 100ms UI refresh interval + + private string dialogTitle { get; set; } + private string inputPath { get; set; } + private string outputPath { get; set; } + private bool isFileTransfer { get; set; } + private UIElement parentUI { get; set; } + private CancellationTokenSource tokenSource { get; set; } + + private long CurrentSizeMoved; + private long CurrentFileCountMoved; + private long TotalFileSize; + private long TotalFileCount; + private bool IsSameOutputDrive; + private Stopwatch ProcessStopwatch; + private Stopwatch EventsStopwatch; + + private FileMigrationProcess(UIElement parentUI, string dialogTitle, string inputPath, string outputPath, bool isFileTransfer, CancellationTokenSource tokenSource) + { + this.dialogTitle = dialogTitle; + this.inputPath = inputPath; + this.outputPath = outputPath; + this.isFileTransfer = isFileTransfer; + this.parentUI = parentUI; + this.tokenSource = tokenSource; + } + + internal async Task StartRoutine() + { + bool isSuccess = false; + this.CurrentSizeMoved = 0; + this.CurrentFileCountMoved = 0; + this.TotalFileSize = 0; + this.TotalFileCount = 0; + FileMigrationProcessUIRef? uiRef = null; + + try + { + if (!await IsOutputPathSpaceSufficient(this.inputPath, this.outputPath)) + throw new OperationCanceledException($"Disk space is not sufficient. Cancelling!"); + + uiRef = BuildMainMigrationUI(); + string outputPath = await StartRoutineInner(uiRef.Value); + uiRef.Value.mainDialogWindow.Hide(); + isSuccess = true; + + return outputPath; + } + catch when (!isSuccess) // Throw if the isSuccess is not set to true + { + if (uiRef.HasValue && uiRef.Value.mainDialogWindow != null) + { + uiRef.Value.mainDialogWindow.Hide(); + await Task.Delay(500); // Give artificial delay to give main dialog window thread to close first + } + throw; + } + finally + { + if (ProcessStopwatch != null) ProcessStopwatch.Stop(); + if (EventsStopwatch != null) EventsStopwatch.Stop(); + } + } + + private async Task StartRoutineInner(FileMigrationProcessUIRef uiRef) + { + this.ProcessStopwatch = Stopwatch.StartNew(); + this.EventsStopwatch = Stopwatch.StartNew(); + return this.isFileTransfer ? await MoveFile(uiRef) : await MoveDirectory(uiRef); + } + + private async Task MoveFile(FileMigrationProcessUIRef uiRef) + { + FileInfo inputPathInfo = new FileInfo(this.inputPath); + FileInfo outputPathInfo = new FileInfo(this.outputPath); + + string inputPathDir = Path.GetDirectoryName(inputPathInfo.FullName); + string outputPathDir = Path.GetDirectoryName(outputPathInfo.FullName); + + if (!Directory.Exists(outputPathDir)) + Directory.CreateDirectory(outputPathDir); + + // Update path display + string inputFileBasePath = inputPathInfo.FullName.Substring(inputPathDir.Length + 1); + UpdateCountProcessed(uiRef, inputFileBasePath); + + if (this.IsSameOutputDrive) + { + // Logger.LogWriteLine($"[FileMigrationProcess::MoveFile()] Moving file in the same drive from: {inputPathInfo.FullName} to {outputPathInfo.FullName}", LogType.Default, true); + inputPathInfo.MoveTo(outputPathInfo.FullName); + UpdateSizeProcessed(uiRef, inputPathInfo.Length); + } + else + { + // Logger.LogWriteLine($"[FileMigrationProcess::MoveFile()] Moving file across different drives from: {inputPathInfo.FullName} to {outputPathInfo.FullName}", LogType.Default, true); + await MoveWriteFile(uiRef, inputPathInfo, outputPathInfo, this.tokenSource == null ? default : this.tokenSource.Token); + } + + return outputPathInfo.FullName; + } + + private async Task MoveDirectory(FileMigrationProcessUIRef uiRef) + { + DirectoryInfo inputPathInfo = new DirectoryInfo(this.inputPath); + if (!Directory.Exists(this.outputPath)) + Directory.CreateDirectory(this.outputPath); + + DirectoryInfo outputPathInfo = new DirectoryInfo(this.outputPath); + + int parentInputPathLength = inputPathInfo.Parent.FullName.Length + 1; + string outputDirBaseNamePath = inputPathInfo.FullName.Substring(parentInputPathLength); + string outputDirPath = Path.Combine(this.outputPath, outputDirBaseNamePath); + + await Parallel.ForEachAsync( + inputPathInfo.EnumerateFiles("*", SearchOption.AllDirectories), + this.tokenSource?.Token ?? default, + async (inputFileInfo, cancellationToken) => + { + int parentInputPathLength = inputPathInfo.Parent.FullName.Length + 1; + string inputFileBasePath = inputFileInfo.FullName.Substring(parentInputPathLength); + + // Update path display + UpdateCountProcessed(uiRef, inputFileBasePath); + + string outputTargetPath = Path.Combine(outputPathInfo.FullName, inputFileBasePath); + string outputTargetDirPath = Path.GetDirectoryName(outputTargetPath); + + if (!Directory.Exists(outputTargetDirPath)) + Directory.CreateDirectory(outputTargetDirPath); + + if (this.IsSameOutputDrive) + { + // Logger.LogWriteLine($"[FileMigrationProcess::MoveDirectory()] Moving directory content in the same drive from: {inputFileInfo.FullName} to {outputTargetPath}", LogType.Default, true); + inputFileInfo.MoveTo(outputTargetPath); + UpdateSizeProcessed(uiRef, inputFileInfo.Length); + } + else + { + // Logger.LogWriteLine($"[FileMigrationProcess::MoveDirectory()] Moving directory content across different drives from: {inputFileInfo.FullName} to {outputTargetPath}", LogType.Default, true); + FileInfo outputFileInfo = new FileInfo(outputTargetPath); + await MoveWriteFile(uiRef, inputFileInfo, outputFileInfo, cancellationToken); + } + }); + + inputPathInfo.Delete(true); + return outputDirPath; + } + } +} diff --git a/CollapseLauncher/Classes/FileMigrationProcess/FileMigrationProcessRef.cs b/CollapseLauncher/Classes/FileMigrationProcess/FileMigrationProcessRef.cs new file mode 100644 index 000000000..9e8b7cb02 --- /dev/null +++ b/CollapseLauncher/Classes/FileMigrationProcess/FileMigrationProcessRef.cs @@ -0,0 +1,16 @@ +using CollapseLauncher.CustomControls; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Documents; + +namespace CollapseLauncher +{ + internal struct FileMigrationProcessUIRef + { + internal ContentDialogCollapse mainDialogWindow; + internal TextBlock pathActivitySubtitle; + internal Run speedIndicatorSubtitle; + internal Run fileCountIndicatorSubtitle; + internal Run fileSizeIndicatorSubtitle; + internal ProgressBar progressBarIndicator; + } +} diff --git a/CollapseLauncher/Classes/FileMigrationProcess/IO.cs b/CollapseLauncher/Classes/FileMigrationProcess/IO.cs new file mode 100644 index 000000000..38e8fe750 --- /dev/null +++ b/CollapseLauncher/Classes/FileMigrationProcess/IO.cs @@ -0,0 +1,140 @@ +using CollapseLauncher.CustomControls; +using CollapseLauncher.Dialogs; +using Hi3Helper; +using Hi3Helper.Data; +using Microsoft.UI.Xaml.Controls; +using System; +using System.Buffers; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace CollapseLauncher +{ + internal partial class FileMigrationProcess + { + private async Task MoveWriteFile(FileMigrationProcessUIRef uiRef, FileInfo inputFile, FileInfo outputFile, CancellationToken token) + { + int bufferSize = 1 << 18; // 256 kB Buffer + + if (inputFile.Length < bufferSize) + bufferSize = (int)inputFile.Length; + + bool isUseArrayPool = Environment.ProcessorCount * bufferSize > 2 << 20; + byte[] buffer = isUseArrayPool ? ArrayPool.Shared.Rent(bufferSize) : new byte[bufferSize]; + + try + { + await using (FileStream inputStream = inputFile.OpenRead()) + await using (FileStream outputStream = outputFile.Exists && outputFile.Length <= inputFile.Length ? outputFile.Open(FileMode.Open) : outputFile.Create()) + { + // Just in-case if the previous move is incomplete, then update and seek to the last position. + if (outputFile.Length <= inputStream.Length && outputFile.Length >= bufferSize) + { + // Do check by comparing the first and last 128K data of the file + Memory firstCompareInputBytes = new byte[bufferSize]; + Memory firstCompareOutputBytes = new byte[bufferSize]; + Memory lastCompareInputBytes = new byte[bufferSize]; + Memory lastCompareOutputBytes = new byte[bufferSize]; + + // Seek to the first data + inputStream.Position = 0; + await inputStream.ReadExactlyAsync(firstCompareInputBytes); + outputStream.Position = 0; + await outputStream.ReadExactlyAsync(firstCompareOutputBytes); + + // Seek to the last data + long lastPos = outputStream.Length - bufferSize; + inputStream.Position = lastPos; + await inputStream.ReadExactlyAsync(lastCompareInputBytes); + outputStream.Position = lastPos; + await outputStream.ReadExactlyAsync(lastCompareOutputBytes); + + bool isMatch = firstCompareInputBytes.Span.SequenceEqual(firstCompareOutputBytes.Span) + && lastCompareInputBytes.Span.SequenceEqual(lastCompareOutputBytes.Span); + + // If the buffers don't match, then start the copy from the beginning + if (!isMatch) + { + inputStream.Position = 0; + outputStream.Position = 0; + } + else + { + UpdateSizeProcessed(uiRef, outputStream.Length); + } + } + + await MoveWriteFileInner(uiRef, inputStream, outputStream, buffer, token); + } + + inputFile.IsReadOnly = false; + inputFile.Delete(); + } + catch { throw; } // Re-throw to + finally + { + if (isUseArrayPool) ArrayPool.Shared.Return(buffer); + } + } + + private async Task MoveWriteFileInner(FileMigrationProcessUIRef uiRef, FileStream inputStream, FileStream outputStream, byte[] buffer, CancellationToken token) + { + int read; + while ((read = await inputStream.ReadAsync(buffer, 0, buffer.Length, token)) > 0) + { + await outputStream.WriteAsync(buffer, 0, read, token); + UpdateSizeProcessed(uiRef, read); + } + } + + private async ValueTask IsOutputPathSpaceSufficient(string inputPath, string outputPath) + { + DriveInfo inputDriveInfo = new DriveInfo(Path.GetPathRoot(inputPath)); + DriveInfo outputDriveInfo = new DriveInfo(Path.GetPathRoot(outputPath)); + + this.TotalFileSize = await Task.Run(() => + { + if (this.isFileTransfer) + { + FileInfo fileInfo = new FileInfo(inputPath); + return fileInfo.Length; + } + + DirectoryInfo directoryInfo = new DirectoryInfo(inputPath); + long returnSize = directoryInfo.EnumerateFiles("*", SearchOption.AllDirectories).Sum(x => + { + this.TotalFileCount++; + return x.Length; + }); + return returnSize; + }); + + if (IsSameOutputDrive = inputDriveInfo.Name == outputDriveInfo.Name) + return true; + + bool isSpaceSufficient = outputDriveInfo.TotalFreeSpace < this.TotalFileSize; + if (!isSpaceSufficient) + { + string errStr = $"Free Space on {outputDriveInfo.Name} is not sufficient! (Free space: {outputDriveInfo.TotalFreeSpace}, Req. Space: {this.TotalFileSize}, Drive: {outputDriveInfo.Name})"; + Logger.LogWriteLine(errStr, LogType.Error, true); + await SimpleDialogs.SpawnDialog( + string.Format(Locale.Lang._Dialogs.OperationErrorDiskSpaceInsufficientTitle, outputDriveInfo.Name), + string.Format(Locale.Lang._Dialogs.OperationErrorDiskSpaceInsufficientMsg, + ConverterTool.SummarizeSizeSimple(outputDriveInfo.TotalFreeSpace), + ConverterTool.SummarizeSizeSimple(this.TotalFileSize), + outputDriveInfo.Name), + parentUI, + null, + Locale.Lang._Misc.Okay, + null, + ContentDialogButton.Primary, + ContentDialogTheme.Error + ); + } + + return isSpaceSufficient; + } + } +} diff --git a/CollapseLauncher/Classes/FileMigrationProcess/Statics.cs b/CollapseLauncher/Classes/FileMigrationProcess/Statics.cs new file mode 100644 index 000000000..3add3c2bd --- /dev/null +++ b/CollapseLauncher/Classes/FileMigrationProcess/Statics.cs @@ -0,0 +1,55 @@ +using CollapseLauncher.Dialogs; +using Hi3Helper.Data; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace CollapseLauncher +{ + internal partial class FileMigrationProcess + { + internal static async Task CreateJob(UIElement parentUI, string dialogTitle, string inputPath, string outputPath = null, CancellationTokenSource token = default, bool showWarningMessage = true) + { + // Normalize Path (also normalize path from '/' separator) + inputPath = ConverterTool.NormalizePath(inputPath); + + // Check whether the input is a file or not. + bool isFileTransfer = File.Exists(inputPath) && !inputPath.StartsWith('\\'); + outputPath = await InitializeAndCheckOutputPath(parentUI, dialogTitle, inputPath, outputPath, isFileTransfer); + if (outputPath == null) return null; + + if (showWarningMessage) + if (await ShowNotCancellableProcedureMessage(parentUI) == ContentDialogResult.None) + return null; + + return new FileMigrationProcess(parentUI, dialogTitle, inputPath, outputPath, isFileTransfer, token); + } + + private static async ValueTask InitializeAndCheckOutputPath(UIElement parentUI, string dialogTitle, string inputPath, string outputPath, bool isFileTransfer) + { + if (!string.IsNullOrEmpty(outputPath) + && ConverterTool.IsUserHasPermission(outputPath) + && !IsOutputPathSameAsInput(inputPath, outputPath, isFileTransfer)) + return outputPath; + + return await BuildCheckOutputPathUI(parentUI, dialogTitle, inputPath, outputPath, isFileTransfer); + } + + private static bool IsOutputPathSameAsInput(string inputPath, string outputPath, bool isFilePath) + { + bool isStringEmpty = string.IsNullOrEmpty(outputPath); + + if (!isFilePath) inputPath = Path.GetDirectoryName(inputPath); + bool isPathEqual = inputPath.AsSpan().TrimEnd('\\').SequenceEqual(outputPath.AsSpan().TrimEnd('\\')); + + if (isStringEmpty) return true; + return isPathEqual; + } + + private static async Task ShowNotCancellableProcedureMessage(UIElement parentUI) => await SimpleDialogs.Dialog_WarningOperationNotCancellable(parentUI); + + } +} diff --git a/CollapseLauncher/Classes/FileMigrationProcess/UIBuilder.cs b/CollapseLauncher/Classes/FileMigrationProcess/UIBuilder.cs new file mode 100644 index 000000000..2f7d21023 --- /dev/null +++ b/CollapseLauncher/Classes/FileMigrationProcess/UIBuilder.cs @@ -0,0 +1,235 @@ +using CollapseLauncher.CustomControls; +using CollapseLauncher.FileDialogCOM; +using Hi3Helper; +using Hi3Helper.Data; +using Microsoft.UI.Text; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Data; +using Microsoft.UI.Xaml.Documents; +using Microsoft.UI.Xaml.Media; +using System; +using System.IO; +using System.Threading.Tasks; + +namespace CollapseLauncher +{ + internal partial class FileMigrationProcess + { + private static async ValueTask BuildCheckOutputPathUI(UIElement parentUI, string dialogTitle, string inputPath, string outputPath, bool isFileTransfer) + { + ContentDialogCollapse mainDialogWindow = new ContentDialogCollapse(ContentDialogTheme.Informational) + { + Title = dialogTitle, + CloseButtonText = Locale.Lang._Misc.Cancel, + PrimaryButtonText = null, + SecondaryButtonText = null, + DefaultButton = ContentDialogButton.Primary, + XamlRoot = parentUI.XamlRoot + }; + + Grid mainGrid = new Grid(); + mainGrid.AddGridRows(3); + mainGrid.AddGridColumns(1, new GridLength(1.0, GridUnitType.Star)); + mainGrid.AddGridColumns(1); + + TextBlock locateFolderSubtitle = mainGrid.AddElementToGridColumn(new TextBlock + { + FontSize = 16d, + HorizontalAlignment = HorizontalAlignment.Stretch, + TextWrapping = TextWrapping.Wrap, + Text = Locale.Lang._FileMigrationProcess.LocateFolderSubtitle + }, 0, 2); + + TextBox choosePathTextBox = mainGrid.AddElementToGridRow(new TextBox + { + Margin = new Thickness(0d, 12d, 0d, 0d), + IsSpellCheckEnabled = false, + IsRightTapEnabled = false, + Width = 500, + PlaceholderText = Locale.Lang._FileMigrationProcess.ChoosePathTextBoxPlaceholder, + Text = string.IsNullOrEmpty(outputPath) ? null : outputPath + }, 1); + + Button choosePathButton = mainGrid + .AddElementToGridRowColumn(UIElementExtensions + .CreateButtonWithIcon