From 6ac39614572621e6ce72e36d491043f0c332d6d8 Mon Sep 17 00:00:00 2001 From: Martin Zikmund Date: Mon, 15 Mar 2021 18:16:21 +0100 Subject: [PATCH] feat: StorageFolder/File operations iOS --- .../Resources/Strings/cs-CZ/Resources.resw | 5 +- .../Resources/Strings/en/Resources.resw | 5 +- .../Storage/Internal/StorageProviders.cs | 4 + .../Storage/Pickers/FileOpenPicker.iOS.cs | 4 +- .../Storage/Pickers/FolderPicker.iOS.cs | 2 +- src/Uno.UWP/Storage/StorageFile.iOS.cs | 66 +++-- src/Uno.UWP/Storage/StorageFolder.iOS.cs | 267 ++++++++++++++++-- .../Streams/FileRandomAccessStream.iOS.cs | 7 + .../Internal/SecuredStoreStream.iOS.cs | 182 ++++++++++++ 9 files changed, 495 insertions(+), 47 deletions(-) create mode 100644 src/Uno.UWP/Storage/Streams/FileRandomAccessStream.iOS.cs create mode 100644 src/Uno.UWP/Storage/Streams/Internal/SecuredStoreStream.iOS.cs diff --git a/src/Uno.UI/Resources/Strings/cs-CZ/Resources.resw b/src/Uno.UI/Resources/Strings/cs-CZ/Resources.resw index d6a106114979..02667a887832 100644 --- a/src/Uno.UI/Resources/Strings/cs-CZ/Resources.resw +++ b/src/Uno.UI/Resources/Strings/cs-CZ/Resources.resw @@ -123,10 +123,13 @@ Android Storage Access Framework + + iOS Security Scoped + Tento počítač JS File Access API - \ No newline at end of file + diff --git a/src/Uno.UI/Resources/Strings/en/Resources.resw b/src/Uno.UI/Resources/Strings/en/Resources.resw index 52668f20b005..039095c001bf 100644 --- a/src/Uno.UI/Resources/Strings/en/Resources.resw +++ b/src/Uno.UI/Resources/Strings/en/Resources.resw @@ -123,10 +123,13 @@ Android Storage Access Framework + + iOS Security Scoped + This PC JS File Access API - \ No newline at end of file + diff --git a/src/Uno.UWP/Storage/Internal/StorageProviders.cs b/src/Uno.UWP/Storage/Internal/StorageProviders.cs index 66d4294bc207..67aecf4e3791 100644 --- a/src/Uno.UWP/Storage/Internal/StorageProviders.cs +++ b/src/Uno.UWP/Storage/Internal/StorageProviders.cs @@ -13,5 +13,9 @@ internal static class StorageProviders #if __ANDROID__ public static StorageProvider AndroidSaf { get; } = new StorageProvider("androidsaf", "StorageProviderAndroidSafDisplayName"); #endif + +#if __IOS__ + public static StorageProvider IosSecurityScoped { get; } = new StorageProvider("iossecurityscoped", "StorageProviderIosSecurityScopedDisplayName"); +#endif } } diff --git a/src/Uno.UWP/Storage/Pickers/FileOpenPicker.iOS.cs b/src/Uno.UWP/Storage/Pickers/FileOpenPicker.iOS.cs index 461e55d2aee8..92fd25ab92d0 100644 --- a/src/Uno.UWP/Storage/Pickers/FileOpenPicker.iOS.cs +++ b/src/Uno.UWP/Storage/Pickers/FileOpenPicker.iOS.cs @@ -53,7 +53,9 @@ private async Task PickFilesAsync(bool multiple, C return FilePickerSelectedFilesArray.Empty; } - var files = nsUrls.Select(nsUrl => StorageFile.GetFromSecurityScopedUrl(nsUrl)).ToArray(); + var files = nsUrls + .Where(url => url != null) + .Select(nsUrl => StorageFile.GetFromSecurityScopedUrl(nsUrl!, null)).ToArray(); return new FilePickerSelectedFilesArray(files); } diff --git a/src/Uno.UWP/Storage/Pickers/FolderPicker.iOS.cs b/src/Uno.UWP/Storage/Pickers/FolderPicker.iOS.cs index fcfb2e0b57f1..efc736c044fe 100644 --- a/src/Uno.UWP/Storage/Pickers/FolderPicker.iOS.cs +++ b/src/Uno.UWP/Storage/Pickers/FolderPicker.iOS.cs @@ -40,7 +40,7 @@ public partial class FolderPicker return null; } - return StorageFolder.GetFromSecurityScopedUrl(nsUrl); + return StorageFolder.GetFromSecurityScopedUrl(nsUrl, null); } private class FolderPickerDelegate : UIDocumentPickerDelegate diff --git a/src/Uno.UWP/Storage/StorageFile.iOS.cs b/src/Uno.UWP/Storage/StorageFile.iOS.cs index 5d196db162fb..3d6d105a8b86 100644 --- a/src/Uno.UWP/Storage/StorageFile.iOS.cs +++ b/src/Uno.UWP/Storage/StorageFile.iOS.cs @@ -1,4 +1,6 @@ -using System; +#nullable enable + +using System; using System.Threading; using System.Threading.Tasks; using Foundation; @@ -6,20 +8,23 @@ using Windows.Storage.FileProperties; using Windows.Storage.Streams; using Uno.Storage.Internal; +using System.IO; namespace Windows.Storage { - public partial class StorageFile - { - internal static StorageFile GetFromSecurityScopedUrl(NSUrl nsUrl) => - new StorageFile(new SecurityScopedFile(nsUrl)); + public partial class StorageFile + { + internal static StorageFile GetFromSecurityScopedUrl(NSUrl nsUrl, StorageFolder? parent) => + new StorageFile(new SecurityScopedFile(nsUrl, parent)); internal class SecurityScopedFile : ImplementationBase { private readonly NSUrl _nsUrl; + private readonly StorageFolder? _parent; private readonly UIDocument _document; + private DateTimeOffset? _dateCreated; - public SecurityScopedFile(NSUrl nsUrl) + public SecurityScopedFile(NSUrl nsUrl, StorageFolder? parent) { if (nsUrl is null) { @@ -27,13 +32,14 @@ public SecurityScopedFile(NSUrl nsUrl) } _nsUrl = nsUrl; + _parent = parent; _document = new UIDocument(_nsUrl); Path = _document.FileUrl?.Path ?? string.Empty; } - public override StorageProvider Provider => new StorageProvider("iOSSecurityScopedUrl", "iOS Security Scoped URL"); + public override StorageProvider Provider => StorageProviders.IosSecurityScoped; - public override DateTimeOffset DateCreated => throw new NotImplementedException(); + public override DateTimeOffset DateCreated => _dateCreated ?? (_dateCreated = GetDateCreated()).Value; public override async Task DeleteAsync(CancellationToken ct, StorageDeleteOption options) { @@ -42,31 +48,39 @@ public override async Task DeleteAsync(CancellationToken ct, StorageDeleteOption using var coordinator = new NSFileCoordinator(); await coordinator.CoordinateAsync(new[] { intent }, new NSOperationQueue(), () => { - using (_nsUrl.BeginSecurityScopedAccess()) + using var _ = _nsUrl.BeginSecurityScopedAccess(); + NSError deleteError; + + NSFileManager.DefaultManager.Remove(_nsUrl, out deleteError); + + if (deleteError != null) { - NSError deleteError; - if (options == StorageDeleteOption.Default) - { - NSFileManager.DefaultManager.TrashItem(_nsUrl, out var _, out deleteError); - } - else - { - NSFileManager.DefaultManager.Remove(_nsUrl, out deleteError); - } - - if (deleteError != null) - { - throw new UnauthorizedAccessException($"Can't delete file. {deleteError}"); - } + throw new UnauthorizedAccessException($"Can't delete file. {deleteError}"); } }); } - public override Task GetBasicPropertiesAsync(CancellationToken ct) => throw new NotImplementedException(); - public override Task GetParentAsync(CancellationToken ct) => throw new NotImplementedException(); + public override Task GetBasicPropertiesAsync(CancellationToken ct) + { + using var _ = _nsUrl.BeginSecurityScopedAccess(); + var fileInfo = new FileInfo(Path); + return Task.FromResult(new BasicProperties(0UL, fileInfo.LastWriteTimeUtc)); + } + + private DateTimeOffset GetDateCreated() + { + using var _ = _nsUrl.BeginSecurityScopedAccess(); + return new FileInfo(Path).CreationTimeUtc; + } + + public override Task GetParentAsync(CancellationToken ct) => Task.FromResult(_parent); + public override Task OpenAsync(CancellationToken ct, FileAccessMode accessMode, StorageOpenOptions options) => throw new NotImplementedException(); + public override Task OpenTransactedWriteAsync(CancellationToken ct, StorageOpenOptions option) => throw new NotImplementedException(); - protected override bool IsEqual(ImplementationBase implementation) => throw new NotImplementedException(); + + protected override bool IsEqual(ImplementationBase implementation) => + implementation is SecurityScopedFile file && file._nsUrl.FilePathUrl.Path == _nsUrl.FilePathUrl.Path; } } } diff --git a/src/Uno.UWP/Storage/StorageFolder.iOS.cs b/src/Uno.UWP/Storage/StorageFolder.iOS.cs index 9f5516b937f6..dc04ee288a40 100644 --- a/src/Uno.UWP/Storage/StorageFolder.iOS.cs +++ b/src/Uno.UWP/Storage/StorageFolder.iOS.cs @@ -1,23 +1,31 @@ -using System; +#nullable enable + +using System; using System.Collections.Generic; +using System.IO; using System.Threading; using System.Threading.Tasks; using Foundation; +using MobileCoreServices; using UIKit; +using Uno.Storage.Internal; +using Windows.Storage.FileProperties; +using IOPath = System.IO.Path; namespace Windows.Storage { public partial class StorageFolder { - internal static StorageFolder GetFromSecurityScopedUrl(NSUrl nsUrl) => - new StorageFolder(new SecurityScopedFolder(nsUrl)); + internal static StorageFolder GetFromSecurityScopedUrl(NSUrl nsUrl, StorageFolder? parent) => + new StorageFolder(new SecurityScopedFolder(nsUrl, parent)); internal class SecurityScopedFolder : ImplementationBase { private readonly NSUrl _nsUrl; + private readonly StorageFolder? _parent; private readonly UIDocument _document; - public SecurityScopedFolder(NSUrl nsUrl) + public SecurityScopedFolder(NSUrl nsUrl, StorageFolder? parent) { if (nsUrl is null) { @@ -25,6 +33,7 @@ public SecurityScopedFolder(NSUrl nsUrl) } _nsUrl = nsUrl; + _parent = parent; _document = new UIDocument(_nsUrl); Path = _document.FileUrl?.Path ?? string.Empty; } @@ -33,31 +42,255 @@ public SecurityScopedFolder(NSUrl nsUrl) public override string DisplayName => _document.LocalizedName ?? Name; - public override StorageProvider Provider => new StorageProvider("iOSSecurityScopedUrl", "iOS Security Scoped URL"); + public override StorageProvider Provider => StorageProviders.IosSecurityScoped; + + public override Task GetBasicPropertiesAsync(CancellationToken ct) + { + using var _ = _nsUrl.BeginSecurityScopedAccess(); + var directoryInfo = new DirectoryInfo(Path); + return Task.FromResult(new BasicProperties(0UL, directoryInfo.LastWriteTimeUtc)); + } + + public override async Task CreateFileAsync(string desiredName, CreationCollisionOption options, CancellationToken cancellationToken) + { + using var _ = _nsUrl.BeginSecurityScopedAccess(); + var path = IOPath.Combine(Path, desiredName); + var actualName = desiredName; + + switch (options) + { + case CreationCollisionOption.FailIfExists: + if (Directory.Exists(path) || File.Exists(path)) + { + throw new UnauthorizedAccessException("There is already an item with the same name."); + } + break; + + case CreationCollisionOption.GenerateUniqueName: + actualName = await FindAvailableNumberedFileNameAsync(desiredName); + break; + + case CreationCollisionOption.OpenIfExists: + if (Directory.Exists(path)) + { + throw new UnauthorizedAccessException("There is already a folder with the same name."); + } + break; + + case CreationCollisionOption.ReplaceExisting: + if (Directory.Exists(path)) + { + throw new UnauthorizedAccessException("There is already a folder with the same name."); + } + + if (File.Exists(path)) + { + File.Delete(path); + } + break; + + default: + throw new ArgumentOutOfRangeException(nameof(options)); + } + + var actualPath = IOPath.Combine(Path, actualName); + if (!File.Exists(actualPath)) + { + File.Create(actualPath).Close(); + } + + return StorageFile.GetFromSecurityScopedUrl(_nsUrl.Append(actualName, false), Owner); + } + + public override async Task CreateFolderAsync(string folderName, CreationCollisionOption option, CancellationToken token) + { + using (_nsUrl.BeginSecurityScopedAccess()) + { + var path = IOPath.Combine(Path, folderName); + var actualName = folderName; + + switch (option) + { + case CreationCollisionOption.FailIfExists: + if (Directory.Exists(path) || File.Exists(path)) + { + throw new UnauthorizedAccessException("There is already an item with the same name."); + } + break; + + case CreationCollisionOption.GenerateUniqueName: + actualName = await FindAvailableNumberedFolderNameAsync(folderName); + break; + + case CreationCollisionOption.OpenIfExists: + if (File.Exists(path)) + { + throw new UnauthorizedAccessException("There is already a file with the same name."); + } + break; + + case CreationCollisionOption.ReplaceExisting: + if (Directory.Exists(path)) + { + Directory.Delete(path, true); + } + + if (File.Exists(path)) + { + throw new UnauthorizedAccessException("There is already a file with the same name."); + } + break; + + default: + throw new ArgumentOutOfRangeException(nameof(option)); + } + + var actualPath = IOPath.Combine(Path, actualName); + if (!Directory.Exists(actualPath)) + { + Directory.CreateDirectory(actualPath); + } + + return GetFromSecurityScopedUrl(_nsUrl.Append(actualName, true), Owner); + } + } + + public override async Task DeleteAsync(StorageDeleteOption options, CancellationToken ct) + { + var intent = NSFileAccessIntent.CreateWritingIntent(_nsUrl, NSFileCoordinatorWritingOptions.ForDeleting); + + using var coordinator = new NSFileCoordinator(); + await coordinator.CoordinateAsync(new[] { intent }, new NSOperationQueue(), () => + { + using var _ = _nsUrl.BeginSecurityScopedAccess(); + NSError deleteError; + + NSFileManager.DefaultManager.Remove(_nsUrl, out deleteError); + + if (deleteError != null) + { + throw new UnauthorizedAccessException($"Can't delete file. {deleteError}"); + } + }); + } + + public override Task GetFileAsync(string name, CancellationToken token) + { + using var _ = _nsUrl.BeginSecurityScopedAccess(); + var itemPath = IOPath.Combine(Path, name); + + var directoryExists = File.Exists(itemPath); - public override Task CreateFileAsync(string desiredName, CreationCollisionOption options, CancellationToken cancellationToken) => throw new global::System.NotImplementedException(); + if (!directoryExists) + { + throw new FileNotFoundException(itemPath); + } - public override Task CreateFolderAsync(string folderName, CreationCollisionOption option, CancellationToken token) => throw new global::System.NotImplementedException(); + return Task.FromResult(StorageFile.GetFromSecurityScopedUrl(_nsUrl.Append(name, false), Owner)); + } - public override Task DeleteAsync(StorageDeleteOption options, CancellationToken ct) => throw new global::System.NotImplementedException(); + public override Task> GetFilesAsync(CancellationToken ct) + { + using var _ = _nsUrl.BeginSecurityScopedAccess(); + var items = new List(); - public override Task GetFileAsync(string name, CancellationToken token) => throw new global::System.NotImplementedException(); + foreach (var file in Directory.EnumerateFiles(Path)) + { + var fileUrl = _nsUrl.Append(IOPath.GetFileName(file), false); + items.Add(StorageFile.GetFromSecurityScopedUrl(fileUrl, Owner)); + } - public override Task> GetFilesAsync(CancellationToken ct) => throw new global::System.NotImplementedException(); + return Task.FromResult>(items.AsReadOnly()); + } - public override Task GetFolderAsync(string name, CancellationToken token) => throw new global::System.NotImplementedException(); + public override Task GetFolderAsync(string name, CancellationToken token) + { + using var _ = _nsUrl.BeginSecurityScopedAccess(); + var itemPath = IOPath.Combine(Path, name); - public override Task> GetFoldersAsync(CancellationToken ct) => throw new global::System.NotImplementedException(); + var directoryExists = Directory.Exists(itemPath); - public override Task GetItemAsync(string name, CancellationToken token) => throw new global::System.NotImplementedException(); + if (!directoryExists) + { + throw new FileNotFoundException(itemPath); + } - public override Task> GetItemsAsync(CancellationToken ct) => throw new global::System.NotImplementedException(); + return Task.FromResult(GetFromSecurityScopedUrl(_nsUrl.Append(name, true), Owner)); + } + + public override Task> GetFoldersAsync(CancellationToken ct) + { + using var _ = _nsUrl.BeginSecurityScopedAccess(); + var items = new List(); + + foreach (var folder in Directory.EnumerateDirectories(Path)) + { + var info = new DirectoryInfo(folder); + var folderUrl = _nsUrl.Append(info.Name, true); + items.Add(GetFromSecurityScopedUrl(folderUrl, Owner)); + } - public override Task GetParentAsync(CancellationToken token) => throw new global::System.NotImplementedException(); + return Task.FromResult>(items.AsReadOnly()); + } - public override Task TryGetItemAsync(string name, CancellationToken token) => throw new global::System.NotImplementedException(); + public override async Task GetItemAsync(string name, CancellationToken token) + { + using var _ = _nsUrl.BeginSecurityScopedAccess(); + var item = await TryGetItemAsync(name, token); + + if (item == null) + { + throw new FileNotFoundException($"There is no folder or file with name '{name}'."); + } + + return item; + } + + public override Task> GetItemsAsync(CancellationToken ct) + { + using var _ = _nsUrl.BeginSecurityScopedAccess(); + var items = new List(); + + foreach (var folder in Directory.EnumerateDirectories(Path)) + { + var info = new DirectoryInfo(folder); + var folderUrl = _nsUrl.Append(info.Name, true); + items.Add(GetFromSecurityScopedUrl(folderUrl, Owner)); + } + + foreach (var file in Directory.EnumerateFiles(Path)) + { + var fileUrl = _nsUrl.Append(IOPath.GetFileName(file), false); + items.Add(StorageFile.GetFromSecurityScopedUrl(fileUrl, Owner)); + } + + return Task.FromResult>(items.AsReadOnly()); + } + + public override Task GetParentAsync(CancellationToken token) => Task.FromResult(_parent); + + public override Task TryGetItemAsync(string name, CancellationToken token) + { + using var _ = _nsUrl.BeginSecurityScopedAccess(); + var itemUrl = _nsUrl.Append(name, false); + if (itemUrl.CheckPromisedItemIsReachable(out var _)) + { + var document = new UIDocument(itemUrl); + if (document.FileType == UTType.Folder) + { + return Task.FromResult(GetFromSecurityScopedUrl(itemUrl, Owner)); + } + else + { + return Task.FromResult(StorageFile.GetFromSecurityScopedUrl(itemUrl, Owner)); + } + } + + return Task.FromResult(null); + } - protected override bool IsEqual(ImplementationBase implementation) => throw new global::System.NotImplementedException(); + protected override bool IsEqual(ImplementationBase implementation) => + implementation is SecurityScopedFolder otherFolder && otherFolder._nsUrl.FilePathUrl.Path == _nsUrl.FilePathUrl.Path; } } } diff --git a/src/Uno.UWP/Storage/Streams/FileRandomAccessStream.iOS.cs b/src/Uno.UWP/Storage/Streams/FileRandomAccessStream.iOS.cs new file mode 100644 index 000000000000..368f55bfe4ff --- /dev/null +++ b/src/Uno.UWP/Storage/Streams/FileRandomAccessStream.iOS.cs @@ -0,0 +1,7 @@ +namespace Windows.Storage.Streams +{ + public class FileRandomAccessStream + { + + } +} diff --git a/src/Uno.UWP/Storage/Streams/Internal/SecuredStoreStream.iOS.cs b/src/Uno.UWP/Storage/Streams/Internal/SecuredStoreStream.iOS.cs new file mode 100644 index 000000000000..3c67f13d4894 --- /dev/null +++ b/src/Uno.UWP/Storage/Streams/Internal/SecuredStoreStream.iOS.cs @@ -0,0 +1,182 @@ +#nullable enable + +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Uno.Disposables; +using Windows.Storage; + +namespace Uno.Storage.Streams.Internal +{ + internal class SecuredStoreStream : Stream, IRentableStream + { + private const string CacheFolderName = "SafCache"; + + private readonly StorageFile _cacheFile; + private readonly Stream _cacheStream; + private bool _pendingChanges = false; + private RefCountDisposable _refCountDisposable; + + public override bool CanRead => _cacheStream.CanRead; + + public override bool CanSeek => _cacheStream.CanSeek; + + public override bool CanWrite => _cacheStream.CanWrite; + + public override long Length => _cacheStream.Length; + + public override long Position + { + get => _cacheStream.Position; + set => _cacheStream.Position = value; + } + + public StreamAccessScope AccessScope { get; } = new StreamAccessScope(); + + private SecuredStoreStream(Stream innerStream, ) + { + _cacheFile = cacheFile; + _cacheStream = cacheStream; + _targetUri = targetUri; + _refCountDisposable = new RefCountDisposable(Disposable.Create(() => Dispose())); + } + + public RentedStream Rent() + { + var rentedStream = new RentedStream(this, _refCountDisposable.GetDisposable()); + _refCountDisposable.Dispose(); + return rentedStream; + } + + public static async Task CreateAsync(Android.Net.Uri uri) + { + var cacheFolder = await ApplicationData.Current.TemporaryFolder.CreateFolderAsync(CacheFolderName, CreationCollisionOption.OpenIfExists); + var cacheFile = await cacheFolder.CreateFileAsync(Guid.NewGuid().ToString()); + var cacheStream = await cacheFile.OpenStream(CancellationToken.None, FileAccessMode.ReadWrite, StorageOpenOptions.None); + var inputStream = Application.Context.ContentResolver.OpenInputStream(uri); + await inputStream.CopyToAsync(cacheStream); + cacheStream.Seek(0, SeekOrigin.Begin); + return new SafOutputStream(cacheFile, cacheStream, uri); + } + + public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + return _cacheStream.ReadAsync(buffer, offset, count, cancellationToken); + } + + private void CopyToTarget() + { + TruncateTarget(); + using var targetStream = CreateTargetStream(); + _cacheStream.Flush(); + _cacheStream.Seek(0, SeekOrigin.Begin); + _cacheStream.CopyTo(targetStream); + targetStream.Flush(); + } + + private async Task CopyToTargetAsync() + { + await TruncateTargetAsync(); + using var targetStream = CreateTargetStream(); + await _cacheStream.FlushAsync(); + _cacheStream.Seek(0, SeekOrigin.Begin); + await _cacheStream.CopyToAsync(targetStream); + await targetStream.FlushAsync(); + } + + private Stream CreateTargetStream() => Application.Context.ContentResolver.OpenOutputStream(_targetUri, "wt"); + + private ParcelFileDescriptor CreateTargetDescriptor() => Application.Context.ContentResolver.OpenFileDescriptor(_targetUri, "wt"); + + private async Task TruncateTargetAsync() + { + using var descriptor = CreateTargetDescriptor(); + using var targetStream = new FileOutputStream(descriptor.FileDescriptor); + await targetStream.Channel.TruncateAsync(0); + } + + private void TruncateTarget() + { + using var descriptor = CreateTargetDescriptor(); + using var targetStream = new FileOutputStream(descriptor.FileDescriptor); + targetStream.Channel.Truncate(0); + } + + public override void Flush() + { + if (_pendingChanges) + { + _cacheStream.Flush(); + CopyToTarget(); + _pendingChanges = false; + } + } + + public override async Task FlushAsync(CancellationToken cancellationToken) + { + if (_pendingChanges) + { + await _cacheStream.FlushAsync(); + await CopyToTargetAsync(); + _pendingChanges = false; + } + } + + public override long Seek(long offset, SeekOrigin origin) + { + return _cacheStream.Seek(offset, origin); + } + + public override void SetLength(long value) + { + _cacheStream.SetLength(value); + _pendingChanges = true; + } + + public override int Read(byte[] buffer, int offset, int count) + { + return _cacheStream.Read(buffer, offset, count); + } + + public override void Write(byte[] buffer, int offset, int count) + { + _cacheStream.Write(buffer, offset, count); + _pendingChanges = true; + } + + public override async Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + await _cacheStream.WriteAsync(buffer, offset, count, cancellationToken); + _pendingChanges = true; + } + + public override void Close() + { + if (_pendingChanges) + { + CopyToTarget(); + } + } + + protected override void Dispose(bool disposing) + { + if (_pendingChanges) + { + CopyToTarget(); + } + _cacheStream.Dispose(); + System.IO.File.Delete(_cacheFile.Path); + } + + public override async ValueTask DisposeAsync() + { + if (_pendingChanges) + { + await CopyToTargetAsync(); + } + await _cacheStream.DisposeAsync(); + await _cacheFile.DeleteAsync(); + } + } +}