Browse Source
* Set isDirectory:true explicitly to help [NSURL fileURLWithPath] method Might solve some rare/random issues with initial directory not being applied * Fix dialogs page incorrectly setting parent folder * Move SecurityScopedStream out of iOS project and share it with macOS project * Refactor BclStorageItem to be more reusable across platforms * [Breaking] Set BclStorageItem.CanBookmark to false, as it never was supposed to be true. Plain BCL doesn't provide files bookmarking. * Reimplement storage provider support on macOS, support (optional) sandboxing * Fix build * Fix AppSandboxEnabled=false usage * Re-enable BCL bookmarks, keep them base64 * Fix nullable error * Prefix all bookmarks with a platform key * Fix devtools breaking sandboxed app * Try to read errors after saving bookmark * Don't crash sample app if has no access * Add internal IStorageItemWithFileSystemInfo abstraction * Log information if OpenSecurityScope returned false * Fix build * Prefix bookmarks with "ava.v1." * Support opening old-style bookmarks to avoid breaking changespull/16373/head
committed by
GitHub
34 changed files with 1370 additions and 649 deletions
@ -1,115 +1,10 @@ |
|||
using System; |
|||
using System.IO; |
|||
using System.Security; |
|||
using System.IO; |
|||
using System.Threading.Tasks; |
|||
|
|||
namespace Avalonia.Platform.Storage.FileIO; |
|||
|
|||
internal class BclStorageFile : IStorageBookmarkFile |
|||
internal sealed class BclStorageFile(FileInfo fileInfo) : BclStorageItem(fileInfo), IStorageBookmarkFile |
|||
{ |
|||
public BclStorageFile(FileInfo fileInfo) |
|||
{ |
|||
FileInfo = fileInfo ?? throw new ArgumentNullException(nameof(fileInfo)); |
|||
} |
|||
|
|||
public FileInfo FileInfo { get; } |
|||
|
|||
public string Name => FileInfo.Name; |
|||
|
|||
public virtual bool CanBookmark => true; |
|||
|
|||
public Uri Path |
|||
{ |
|||
get |
|||
{ |
|||
try |
|||
{ |
|||
if (FileInfo.Directory is not null) |
|||
{ |
|||
return StorageProviderHelpers.FilePathToUri(FileInfo.FullName); |
|||
} |
|||
} |
|||
catch (SecurityException) |
|||
{ |
|||
} |
|||
return new Uri(FileInfo.Name, UriKind.Relative); |
|||
} |
|||
} |
|||
|
|||
public Task<StorageItemProperties> GetBasicPropertiesAsync() |
|||
{ |
|||
if (FileInfo.Exists) |
|||
{ |
|||
return Task.FromResult(new StorageItemProperties( |
|||
(ulong)FileInfo.Length, |
|||
FileInfo.CreationTimeUtc, |
|||
FileInfo.LastAccessTimeUtc)); |
|||
} |
|||
return Task.FromResult(new StorageItemProperties()); |
|||
} |
|||
|
|||
public Task<IStorageFolder?> GetParentAsync() |
|||
{ |
|||
if (FileInfo.Directory is { } directory) |
|||
{ |
|||
return Task.FromResult<IStorageFolder?>(new BclStorageFolder(directory)); |
|||
} |
|||
return Task.FromResult<IStorageFolder?>(null); |
|||
} |
|||
|
|||
public Task<Stream> OpenReadAsync() |
|||
{ |
|||
return Task.FromResult<Stream>(FileInfo.OpenRead()); |
|||
} |
|||
|
|||
public Task<Stream> OpenWriteAsync() |
|||
{ |
|||
var stream = new FileStream(FileInfo.FullName, FileMode.Create, FileAccess.Write, FileShare.Write); |
|||
return Task.FromResult<Stream>(stream); |
|||
} |
|||
|
|||
public virtual Task<string?> SaveBookmarkAsync() |
|||
{ |
|||
return Task.FromResult<string?>(FileInfo.FullName); |
|||
} |
|||
|
|||
public Task ReleaseBookmarkAsync() |
|||
{ |
|||
// No-op
|
|||
return Task.CompletedTask; |
|||
} |
|||
|
|||
protected virtual void Dispose(bool disposing) |
|||
{ |
|||
} |
|||
|
|||
~BclStorageFile() |
|||
{ |
|||
Dispose(disposing: false); |
|||
} |
|||
|
|||
public void Dispose() |
|||
{ |
|||
Dispose(disposing: true); |
|||
GC.SuppressFinalize(this); |
|||
} |
|||
|
|||
public Task DeleteAsync() |
|||
{ |
|||
FileInfo.Delete(); |
|||
return Task.CompletedTask; |
|||
} |
|||
|
|||
public Task<IStorageItem?> MoveAsync(IStorageFolder destination) |
|||
{ |
|||
if (destination is BclStorageFolder storageFolder) |
|||
{ |
|||
var newPath = System.IO.Path.Combine(storageFolder.DirectoryInfo.FullName, FileInfo.Name); |
|||
FileInfo.MoveTo(newPath); |
|||
|
|||
return Task.FromResult<IStorageItem?>(new BclStorageFile(new FileInfo(newPath))); |
|||
} |
|||
|
|||
return Task.FromResult<IStorageItem?>(null); |
|||
} |
|||
public Task<Stream> OpenReadAsync() => Task.FromResult<Stream>(OpenReadCore(fileInfo)); |
|||
public Task<Stream> OpenWriteAsync() => Task.FromResult<Stream>(OpenWriteCore(fileInfo)); |
|||
} |
|||
|
|||
@ -1,128 +1,22 @@ |
|||
using System; |
|||
using System.Collections.Generic; |
|||
using System.Collections.Generic; |
|||
using System.IO; |
|||
using System.Linq; |
|||
using System.Security; |
|||
using System.Threading.Tasks; |
|||
using Avalonia.Utilities; |
|||
|
|||
namespace Avalonia.Platform.Storage.FileIO; |
|||
|
|||
internal class BclStorageFolder : IStorageBookmarkFolder |
|||
internal sealed class BclStorageFolder(DirectoryInfo directoryInfo) |
|||
: BclStorageItem(directoryInfo), IStorageBookmarkFolder |
|||
{ |
|||
public BclStorageFolder(DirectoryInfo directoryInfo) |
|||
{ |
|||
DirectoryInfo = directoryInfo ?? throw new ArgumentNullException(nameof(directoryInfo)); |
|||
if (!DirectoryInfo.Exists) |
|||
{ |
|||
throw new ArgumentException("Directory must exist", nameof(directoryInfo)); |
|||
} |
|||
} |
|||
public IAsyncEnumerable<IStorageItem> GetItemsAsync() => GetItemsCore(directoryInfo) |
|||
.Select(WrapFileSystemInfo) |
|||
.Where(f => f is not null) |
|||
.AsAsyncEnumerable()!; |
|||
|
|||
public string Name => DirectoryInfo.Name; |
|||
public Task<IStorageFile?> CreateFileAsync(string name) => Task.FromResult( |
|||
(IStorageFile?)WrapFileSystemInfo(CreateFileCore(directoryInfo, name))); |
|||
|
|||
public DirectoryInfo DirectoryInfo { get; } |
|||
|
|||
public bool CanBookmark => true; |
|||
|
|||
public Uri Path |
|||
{ |
|||
get |
|||
{ |
|||
try |
|||
{ |
|||
return StorageProviderHelpers.FilePathToUri(DirectoryInfo.FullName); |
|||
} |
|||
catch (SecurityException) |
|||
{ |
|||
return new Uri(DirectoryInfo.Name, UriKind.Relative); |
|||
} |
|||
} |
|||
} |
|||
|
|||
public Task<StorageItemProperties> GetBasicPropertiesAsync() |
|||
{ |
|||
var props = new StorageItemProperties( |
|||
null, |
|||
DirectoryInfo.CreationTimeUtc, |
|||
DirectoryInfo.LastAccessTimeUtc); |
|||
return Task.FromResult(props); |
|||
} |
|||
|
|||
public Task<IStorageFolder?> GetParentAsync() |
|||
{ |
|||
if (DirectoryInfo.Parent is { } directory) |
|||
{ |
|||
return Task.FromResult<IStorageFolder?>(new BclStorageFolder(directory)); |
|||
} |
|||
return Task.FromResult<IStorageFolder?>(null); |
|||
} |
|||
|
|||
public IAsyncEnumerable<IStorageItem> GetItemsAsync() |
|||
=> DirectoryInfo.EnumerateDirectories() |
|||
.Select(d => (IStorageItem)new BclStorageFolder(d)) |
|||
.Concat(DirectoryInfo.EnumerateFiles().Select(f => new BclStorageFile(f))) |
|||
.AsAsyncEnumerable(); |
|||
|
|||
public virtual Task<string?> SaveBookmarkAsync() |
|||
{ |
|||
return Task.FromResult<string?>(DirectoryInfo.FullName); |
|||
} |
|||
|
|||
public Task ReleaseBookmarkAsync() |
|||
{ |
|||
// No-op
|
|||
return Task.CompletedTask; |
|||
} |
|||
|
|||
protected virtual void Dispose(bool disposing) |
|||
{ |
|||
} |
|||
|
|||
~BclStorageFolder() |
|||
{ |
|||
Dispose(disposing: false); |
|||
} |
|||
|
|||
public void Dispose() |
|||
{ |
|||
Dispose(disposing: true); |
|||
GC.SuppressFinalize(this); |
|||
} |
|||
|
|||
public Task DeleteAsync() |
|||
{ |
|||
DirectoryInfo.Delete(true); |
|||
return Task.CompletedTask; |
|||
} |
|||
|
|||
public Task<IStorageItem?> MoveAsync(IStorageFolder destination) |
|||
{ |
|||
if (destination is BclStorageFolder storageFolder) |
|||
{ |
|||
var newPath = System.IO.Path.Combine(storageFolder.DirectoryInfo.FullName, DirectoryInfo.Name); |
|||
DirectoryInfo.MoveTo(newPath); |
|||
|
|||
return Task.FromResult<IStorageItem?>(new BclStorageFolder(new DirectoryInfo(newPath))); |
|||
} |
|||
|
|||
return Task.FromResult<IStorageItem?>(null); |
|||
} |
|||
|
|||
public Task<IStorageFile?> CreateFileAsync(string name) |
|||
{ |
|||
var fileName = System.IO.Path.Combine(DirectoryInfo.FullName, name); |
|||
var newFile = new FileInfo(fileName); |
|||
|
|||
using var stream = newFile.Create(); |
|||
|
|||
return Task.FromResult<IStorageFile?>(new BclStorageFile(newFile)); |
|||
} |
|||
|
|||
public Task<IStorageFolder?> CreateFolderAsync(string name) |
|||
{ |
|||
var newFolder = DirectoryInfo.CreateSubdirectory(name); |
|||
|
|||
return Task.FromResult<IStorageFolder?>(new BclStorageFolder(newFolder)); |
|||
} |
|||
public Task<IStorageFolder?> CreateFolderAsync(string name) => Task.FromResult( |
|||
(IStorageFolder?)WrapFileSystemInfo(CreateFolderCore(directoryInfo, name))); |
|||
} |
|||
|
|||
@ -0,0 +1,141 @@ |
|||
using System; |
|||
using System.Collections.Generic; |
|||
using System.Diagnostics.CodeAnalysis; |
|||
using System.IO; |
|||
using System.Linq; |
|||
using System.Security; |
|||
using System.Threading.Tasks; |
|||
|
|||
namespace Avalonia.Platform.Storage.FileIO; |
|||
|
|||
internal abstract class BclStorageItem(FileSystemInfo fileSystemInfo) : IStorageBookmarkItem, IStorageItemWithFileSystemInfo |
|||
{ |
|||
public FileSystemInfo FileSystemInfo { get; } = fileSystemInfo switch |
|||
{ |
|||
null => throw new ArgumentNullException(nameof(fileSystemInfo)), |
|||
DirectoryInfo { Exists: false } => throw new ArgumentException("Directory must exist", nameof(fileSystemInfo)), |
|||
_ => fileSystemInfo |
|||
}; |
|||
|
|||
public string Name => FileSystemInfo.Name; |
|||
|
|||
public bool CanBookmark => true; |
|||
|
|||
public Uri Path => GetPathCore(FileSystemInfo); |
|||
|
|||
public Task<StorageItemProperties> GetBasicPropertiesAsync() |
|||
{ |
|||
return Task.FromResult(GetBasicPropertiesAsyncCore(FileSystemInfo)); |
|||
} |
|||
|
|||
public Task<IStorageFolder?> GetParentAsync() => Task.FromResult( |
|||
(IStorageFolder?)WrapFileSystemInfo(GetParentCore(FileSystemInfo))); |
|||
|
|||
public Task DeleteAsync() |
|||
{ |
|||
DeleteCore(FileSystemInfo); |
|||
return Task.CompletedTask; |
|||
} |
|||
|
|||
public Task<IStorageItem?> MoveAsync(IStorageFolder destination) => Task.FromResult( |
|||
WrapFileSystemInfo(MoveCore(FileSystemInfo, destination))); |
|||
|
|||
public Task<string?> SaveBookmarkAsync() |
|||
{ |
|||
var path = FileSystemInfo.FullName; |
|||
return Task.FromResult<string?>(StorageBookmarkHelper.EncodeBclBookmark(path)); |
|||
} |
|||
|
|||
public Task ReleaseBookmarkAsync() => Task.CompletedTask; |
|||
|
|||
public void Dispose() { } |
|||
|
|||
[return: NotNullIfNotNull(nameof(fileSystemInfo))] |
|||
protected IStorageItem? WrapFileSystemInfo(FileSystemInfo? fileSystemInfo) => fileSystemInfo switch |
|||
{ |
|||
DirectoryInfo directoryInfo => new BclStorageFolder(directoryInfo), |
|||
FileInfo fileInfo => new BclStorageFile(fileInfo), |
|||
_ => null |
|||
}; |
|||
|
|||
internal static void DeleteCore(FileSystemInfo fileSystemInfo) => fileSystemInfo.Delete(); |
|||
|
|||
internal static Uri GetPathCore(FileSystemInfo fileSystemInfo) |
|||
{ |
|||
try |
|||
{ |
|||
if (fileSystemInfo is DirectoryInfo { Parent: not null } or FileInfo { Directory: not null }) |
|||
{ |
|||
return StorageProviderHelpers.UriFromFilePath(fileSystemInfo.FullName, fileSystemInfo is DirectoryInfo); |
|||
} |
|||
} |
|||
catch (SecurityException) |
|||
{ |
|||
} |
|||
|
|||
return new Uri(fileSystemInfo.Name, UriKind.Relative); |
|||
} |
|||
|
|||
internal static StorageItemProperties GetBasicPropertiesAsyncCore(FileSystemInfo fileSystemInfo) |
|||
{ |
|||
if (fileSystemInfo.Exists) |
|||
{ |
|||
return new StorageItemProperties( |
|||
fileSystemInfo is FileInfo fileInfo ? (ulong)fileInfo.Length : 0, |
|||
fileSystemInfo.CreationTimeUtc, |
|||
fileSystemInfo.LastAccessTimeUtc); |
|||
} |
|||
|
|||
return new StorageItemProperties(); |
|||
} |
|||
|
|||
internal static DirectoryInfo? GetParentCore(FileSystemInfo fileSystemInfo) => fileSystemInfo switch |
|||
{ |
|||
FileInfo { Directory: { } directory } => directory, |
|||
DirectoryInfo { Parent: { } parent } => parent, |
|||
_ => null |
|||
}; |
|||
|
|||
internal static FileSystemInfo? MoveCore(FileSystemInfo fileSystemInfo, IStorageFolder destination) |
|||
{ |
|||
if (destination?.TryGetLocalPath() is { } destinationPath) |
|||
{ |
|||
var newPath = System.IO.Path.Combine(destinationPath, fileSystemInfo.Name); |
|||
if (fileSystemInfo is DirectoryInfo directoryInfo) |
|||
{ |
|||
directoryInfo.MoveTo(newPath); |
|||
return new DirectoryInfo(newPath); |
|||
} |
|||
|
|||
if (fileSystemInfo is FileInfo fileInfo) |
|||
{ |
|||
fileInfo.MoveTo(newPath); |
|||
return new FileInfo(newPath); |
|||
} |
|||
} |
|||
|
|||
return null; |
|||
} |
|||
|
|||
internal static FileStream OpenReadCore(FileInfo fileInfo) => fileInfo.OpenRead(); |
|||
|
|||
internal static FileStream OpenWriteCore(FileInfo fileInfo) => |
|||
new(fileInfo.FullName, FileMode.Create, FileAccess.Write, FileShare.Write); |
|||
|
|||
internal static IEnumerable<FileSystemInfo> GetItemsCore(DirectoryInfo directoryInfo) => directoryInfo |
|||
.EnumerateDirectories() |
|||
.OfType<FileSystemInfo>() |
|||
.Concat(directoryInfo.EnumerateFiles()); |
|||
|
|||
internal static FileInfo CreateFileCore(DirectoryInfo directoryInfo, string name) |
|||
{ |
|||
var fileName = System.IO.Path.Combine(directoryInfo.FullName, name); |
|||
var newFile = new FileInfo(fileName); |
|||
|
|||
using var stream = newFile.Create(); |
|||
return newFile; |
|||
} |
|||
|
|||
internal static DirectoryInfo CreateFolderCore(DirectoryInfo directoryInfo, string name) => |
|||
directoryInfo.CreateSubdirectory(name); |
|||
} |
|||
@ -0,0 +1,116 @@ |
|||
using System; |
|||
using System.IO; |
|||
using System.Threading; |
|||
using System.Threading.Tasks; |
|||
|
|||
namespace Avalonia.Platform.Storage.FileIO; |
|||
|
|||
/// <summary>
|
|||
/// Stream wrapper currently used by Apple platforms,
|
|||
/// where in sandboxed scenario it's advised to call [NSUri startAccessingSecurityScopedResource].
|
|||
/// </summary>
|
|||
internal sealed class SecurityScopedStream(FileStream _stream, IDisposable _securityScope) : Stream |
|||
{ |
|||
public override bool CanRead => _stream.CanRead; |
|||
|
|||
public override bool CanSeek => _stream.CanSeek; |
|||
|
|||
public override bool CanWrite => _stream.CanWrite; |
|||
|
|||
public override long Length => _stream.Length; |
|||
|
|||
public override long Position |
|||
{ |
|||
get => _stream.Position; |
|||
set => _stream.Position = value; |
|||
} |
|||
|
|||
public override void Flush() => |
|||
_stream.Flush(); |
|||
|
|||
public override Task FlushAsync(CancellationToken cancellationToken) => |
|||
_stream.FlushAsync(cancellationToken); |
|||
|
|||
public override int ReadByte() => |
|||
_stream.ReadByte(); |
|||
|
|||
public override int Read(byte[] buffer, int offset, int count) => |
|||
_stream.Read(buffer, offset, count); |
|||
|
|||
public override Task<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) => |
|||
_stream.ReadAsync(buffer, offset, count, cancellationToken); |
|||
|
|||
#if NET6_0_OR_GREATER
|
|||
public override int Read(Span<byte> buffer) => _stream.Read(buffer); |
|||
|
|||
public override ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default) => |
|||
_stream.ReadAsync(buffer, cancellationToken); |
|||
#endif
|
|||
|
|||
public override void Write(byte[] buffer, int offset, int count) => |
|||
_stream.Write(buffer, offset, count); |
|||
|
|||
public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) => |
|||
_stream.WriteAsync(buffer, offset, count, cancellationToken); |
|||
|
|||
#if NET6_0_OR_GREATER
|
|||
public override void Write(ReadOnlySpan<byte> buffer) => _stream.Write(buffer); |
|||
|
|||
public override ValueTask WriteAsync(ReadOnlyMemory<byte> buffer, CancellationToken cancellationToken = default) => |
|||
_stream.WriteAsync(buffer, cancellationToken); |
|||
#endif
|
|||
|
|||
public override void WriteByte(byte value) => _stream.WriteByte(value); |
|||
|
|||
public override long Seek(long offset, SeekOrigin origin) => |
|||
_stream.Seek(offset, origin); |
|||
|
|||
public override void SetLength(long value) => |
|||
_stream.SetLength(value); |
|||
|
|||
#if NET6_0_OR_GREATER
|
|||
public override void CopyTo(Stream destination, int bufferSize) => _stream.CopyTo(destination, bufferSize); |
|||
#endif
|
|||
|
|||
public override Task CopyToAsync(Stream destination, int bufferSize, CancellationToken cancellationToken) => |
|||
_stream.CopyToAsync(destination, bufferSize, cancellationToken); |
|||
|
|||
public override IAsyncResult BeginRead(byte[] buffer, int offset, int count, AsyncCallback? callback, object? state) => |
|||
_stream.BeginRead(buffer, offset, count, callback, state); |
|||
|
|||
public override int EndRead(IAsyncResult asyncResult) => _stream.EndRead(asyncResult); |
|||
|
|||
public override IAsyncResult BeginWrite(byte[] buffer, int offset, int count, AsyncCallback? callback, object? state) => |
|||
_stream.BeginWrite(buffer, offset, count, callback, state); |
|||
|
|||
public override void EndWrite(IAsyncResult asyncResult) => _stream.EndWrite(asyncResult); |
|||
|
|||
protected override void Dispose(bool disposing) |
|||
{ |
|||
try |
|||
{ |
|||
if (disposing) |
|||
{ |
|||
_stream.Dispose(); |
|||
} |
|||
} |
|||
finally |
|||
{ |
|||
_securityScope.Dispose(); |
|||
} |
|||
} |
|||
|
|||
#if NET6_0_OR_GREATER
|
|||
public override async ValueTask DisposeAsync() |
|||
{ |
|||
try |
|||
{ |
|||
await _stream.DisposeAsync(); |
|||
} |
|||
finally |
|||
{ |
|||
_securityScope.Dispose(); |
|||
} |
|||
} |
|||
#endif
|
|||
} |
|||
@ -0,0 +1,153 @@ |
|||
using System; |
|||
using System.Buffers; |
|||
using System.Diagnostics.CodeAnalysis; |
|||
using System.IO; |
|||
using System.Text; |
|||
|
|||
namespace Avalonia.Platform.Storage.FileIO; |
|||
|
|||
/// <summary>
|
|||
/// In order to have unique bookmarks across platforms, we prepend a platform specific suffix before native bookmark.
|
|||
/// And always encoding them in base64 before returning to the user.
|
|||
/// </summary>
|
|||
/// <remarks>
|
|||
/// Bookmarks are encoded as:
|
|||
/// 0-6 - avalonia prefix with version number
|
|||
/// 7-15 - platform key
|
|||
/// 16+ - native bookmark value
|
|||
/// Which is then encoded in Base64.
|
|||
/// </remarks>
|
|||
internal static class StorageBookmarkHelper |
|||
{ |
|||
private const int HeaderLength = 16; |
|||
private static ReadOnlySpan<byte> AvaHeaderPrefix => "ava.v1."u8; |
|||
private static ReadOnlySpan<byte> FakeBclBookmarkPlatform => "bcl"u8; |
|||
|
|||
[return: NotNullIfNotNull(nameof(nativeBookmark))] |
|||
public static string? EncodeBookmark(ReadOnlySpan<byte> platform, string? nativeBookmark) => |
|||
nativeBookmark is null ? null : EncodeBookmark(platform, Encoding.UTF8.GetBytes(nativeBookmark)); |
|||
|
|||
public static string? EncodeBookmark(ReadOnlySpan<byte> platform, ReadOnlySpan<byte> nativeBookmarkBytes) |
|||
{ |
|||
if (nativeBookmarkBytes.Length == 0) |
|||
{ |
|||
return null; |
|||
} |
|||
|
|||
if (platform.Length > HeaderLength) |
|||
{ |
|||
throw new ArgumentException($"Platform name should not be longer than {HeaderLength} bytes", nameof(platform)); |
|||
} |
|||
|
|||
var arrayLength = HeaderLength + nativeBookmarkBytes.Length; |
|||
var arrayPool = ArrayPool<byte>.Shared.Rent(arrayLength); |
|||
try |
|||
{ |
|||
// Write platform into first 16 bytes.
|
|||
var arraySpan = arrayPool.AsSpan(0, arrayLength); |
|||
AvaHeaderPrefix.CopyTo(arraySpan); |
|||
platform.CopyTo(arraySpan.Slice(AvaHeaderPrefix.Length)); |
|||
|
|||
// Write bookmark bytes.
|
|||
nativeBookmarkBytes.CopyTo(arraySpan.Slice(HeaderLength)); |
|||
|
|||
// We must use span overload because ArrayPool might return way too big array.
|
|||
#if NET6_0_OR_GREATER
|
|||
return Convert.ToBase64String(arraySpan); |
|||
#else
|
|||
return Convert.ToBase64String(arraySpan.ToArray(), Base64FormattingOptions.None); |
|||
#endif
|
|||
} |
|||
finally |
|||
{ |
|||
ArrayPool<byte>.Shared.Return(arrayPool); |
|||
} |
|||
} |
|||
|
|||
public enum DecodeResult |
|||
{ |
|||
Success = 0, |
|||
InvalidFormat, |
|||
InvalidPlatform |
|||
} |
|||
|
|||
public static DecodeResult TryDecodeBookmark(ReadOnlySpan<byte> platform, string? base64bookmark, out byte[]? nativeBookmark) |
|||
{ |
|||
if (platform.Length > HeaderLength |
|||
|| platform.Length == 0 |
|||
|| base64bookmark is null |
|||
|| base64bookmark.Length % 4 != 0) |
|||
{ |
|||
nativeBookmark = null; |
|||
return DecodeResult.InvalidFormat; |
|||
} |
|||
|
|||
Span<byte> decodedBookmark; |
|||
#if NET6_0_OR_GREATER
|
|||
// Each base64 character represents 6 bits, but to be safe,
|
|||
var arrayPool = ArrayPool<byte>.Shared.Rent(HeaderLength + base64bookmark.Length * 6); |
|||
if (Convert.TryFromBase64Chars(base64bookmark, arrayPool, out int bytesWritten)) |
|||
{ |
|||
decodedBookmark = arrayPool.AsSpan().Slice(0, bytesWritten); |
|||
} |
|||
else |
|||
{ |
|||
nativeBookmark = null; |
|||
return DecodeResult.InvalidFormat; |
|||
} |
|||
#else
|
|||
decodedBookmark = Convert.FromBase64String(base64bookmark).AsSpan(); |
|||
#endif
|
|||
try |
|||
{ |
|||
if (decodedBookmark.Length < HeaderLength |
|||
// Check if decoded string starts with the correct prefix, checking v1 at the same time.
|
|||
&& !AvaHeaderPrefix.SequenceEqual(decodedBookmark.Slice(0, AvaHeaderPrefix.Length))) |
|||
{ |
|||
nativeBookmark = null; |
|||
return DecodeResult.InvalidFormat; |
|||
} |
|||
|
|||
var actualPlatform = decodedBookmark.Slice(AvaHeaderPrefix.Length, platform.Length); |
|||
if (!actualPlatform.SequenceEqual(platform)) |
|||
{ |
|||
nativeBookmark = null; |
|||
return DecodeResult.InvalidPlatform; |
|||
} |
|||
|
|||
nativeBookmark = decodedBookmark.Slice(HeaderLength).ToArray(); |
|||
return DecodeResult.Success; |
|||
} |
|||
finally |
|||
{ |
|||
#if NET6_0_OR_GREATER
|
|||
ArrayPool<byte>.Shared.Return(arrayPool); |
|||
#endif
|
|||
} |
|||
} |
|||
|
|||
public static string EncodeBclBookmark(string localPath) => EncodeBookmark(FakeBclBookmarkPlatform, localPath); |
|||
|
|||
public static bool TryDecodeBclBookmark(string nativeBookmark, [NotNullWhen(true)] out string? localPath) |
|||
{ |
|||
var decodeResult = TryDecodeBookmark(FakeBclBookmarkPlatform, nativeBookmark, out var bytes); |
|||
if (decodeResult == DecodeResult.Success) |
|||
{ |
|||
localPath = Encoding.UTF8.GetString(bytes!); |
|||
return true; |
|||
} |
|||
if (decodeResult == DecodeResult.InvalidFormat |
|||
&& nativeBookmark.IndexOfAny(Path.GetInvalidPathChars()) < 0 |
|||
&& !string.IsNullOrEmpty(Path.GetDirectoryName(nativeBookmark))) |
|||
{ |
|||
// Attempt to restore old BCL bookmarks.
|
|||
// Don't check for File.Exists here, as it will be done at later point in TryGetStorageItem.
|
|||
// Just validate if it looks like a valid file path.
|
|||
localPath = nativeBookmark; |
|||
return true; |
|||
} |
|||
|
|||
localPath = null; |
|||
return false; |
|||
} |
|||
} |
|||
@ -0,0 +1,152 @@ |
|||
#nullable enable |
|||
|
|||
using System; |
|||
using System.Collections.Generic; |
|||
using System.Diagnostics.CodeAnalysis; |
|||
using System.IO; |
|||
using System.Threading.Tasks; |
|||
using Avalonia.Platform.Storage; |
|||
using Avalonia.Platform.Storage.FileIO; |
|||
using Avalonia.Utilities; |
|||
|
|||
namespace Avalonia.Native; |
|||
|
|||
internal class StorageItem : IStorageBookmarkItem, IStorageItemWithFileSystemInfo |
|||
{ |
|||
private readonly StorageProviderApi _storageProviderApi; |
|||
private readonly FileSystemInfo _fileSystemInfo; |
|||
|
|||
protected StorageItem(StorageProviderApi storageProviderApi, FileSystemInfo fileSystemInfo, Uri uri, Uri scopeOwnerUri) |
|||
{ |
|||
_storageProviderApi = storageProviderApi; |
|||
Path = uri; |
|||
_fileSystemInfo = fileSystemInfo; |
|||
ScopeOwnerUri = scopeOwnerUri; |
|||
} |
|||
|
|||
public string Name => _fileSystemInfo.Name; |
|||
public Uri Path { get; } |
|||
public Uri ScopeOwnerUri { get; } |
|||
|
|||
public Task<StorageItemProperties> GetBasicPropertiesAsync() |
|||
{ |
|||
using var scope = OpenScope(); |
|||
return Task.FromResult( |
|||
BclStorageItem.GetBasicPropertiesAsyncCore(_fileSystemInfo)); |
|||
} |
|||
|
|||
public bool CanBookmark => true; |
|||
public FileSystemInfo FileSystemInfo => _fileSystemInfo; |
|||
|
|||
protected IDisposable? OpenScope() |
|||
{ |
|||
return _storageProviderApi.OpenSecurityScope(ScopeOwnerUri.AbsoluteUri); |
|||
} |
|||
|
|||
public Task<string?> SaveBookmarkAsync() |
|||
{ |
|||
using var scope = OpenScope(); |
|||
return Task.FromResult(_storageProviderApi.SaveBookmark(Path)); |
|||
} |
|||
|
|||
public Task ReleaseBookmarkAsync() |
|||
{ |
|||
_storageProviderApi.ReleaseBookmark(Path); |
|||
return Task.CompletedTask; |
|||
} |
|||
|
|||
public Task<IStorageFolder?> GetParentAsync() |
|||
{ |
|||
using var scope = OpenScope(); |
|||
var parent = BclStorageItem.GetParentCore(_fileSystemInfo); |
|||
return Task.FromResult((IStorageFolder?)WrapFileSystemInfo(parent, null)); |
|||
} |
|||
|
|||
public Task DeleteAsync() |
|||
{ |
|||
using var scope = OpenScope(); |
|||
BclStorageItem.DeleteCore(_fileSystemInfo); |
|||
return Task.CompletedTask; |
|||
} |
|||
|
|||
public Task<IStorageItem?> MoveAsync(IStorageFolder destination) |
|||
{ |
|||
using var destinationScope = (destination as StorageItem)?.OpenScope(); |
|||
using var scope = OpenScope(); |
|||
var item = WrapFileSystemInfo(BclStorageItem.MoveCore(_fileSystemInfo, destination), null); |
|||
return Task.FromResult(item); |
|||
} |
|||
|
|||
[return: NotNullIfNotNull(nameof(fileSystemInfo))] |
|||
protected IStorageItem? WrapFileSystemInfo(FileSystemInfo? fileSystemInfo, Uri? scopedOwner) |
|||
{ |
|||
if (fileSystemInfo is null) return null; |
|||
|
|||
// It might not be always correct to assume NSUri from the file path, but that's the best we have here without using native API directly.
|
|||
var fileUri = BclStorageItem.GetPathCore(fileSystemInfo); |
|||
return fileSystemInfo switch |
|||
{ |
|||
DirectoryInfo directoryInfo => new StorageFolder( |
|||
_storageProviderApi, directoryInfo, fileUri, scopedOwner ?? fileUri), |
|||
FileInfo fileInfo => new StorageFile( |
|||
_storageProviderApi, fileInfo, fileUri, scopedOwner ?? fileUri), |
|||
_ => throw new ArgumentOutOfRangeException(nameof(fileSystemInfo), fileSystemInfo, null) |
|||
}; |
|||
} |
|||
|
|||
public void Dispose() |
|||
{ |
|||
} |
|||
} |
|||
|
|||
internal class StorageFile( |
|||
StorageProviderApi storageProviderApi, FileInfo fileInfo, Uri uri, Uri scopeOwnerUri) |
|||
: StorageItem(storageProviderApi, fileInfo, uri, scopeOwnerUri), IStorageBookmarkFile |
|||
{ |
|||
public Task<Stream> OpenReadAsync() |
|||
{ |
|||
var scope = OpenScope(); |
|||
var innerStream = BclStorageItem.OpenReadCore(fileInfo); |
|||
return Task.FromResult<Stream>(scope is not null ? new SecurityScopedStream(innerStream, scope) : innerStream); |
|||
} |
|||
|
|||
public Task<Stream> OpenWriteAsync() |
|||
{ |
|||
var scope = OpenScope(); |
|||
var innerStream = BclStorageItem.OpenWriteCore(fileInfo); |
|||
return Task.FromResult<Stream>(scope is not null ? new SecurityScopedStream(innerStream, scope) : innerStream); |
|||
} |
|||
} |
|||
|
|||
internal class StorageFolder( |
|||
StorageProviderApi storageProviderApi, DirectoryInfo directoryInfo, Uri uri, Uri scopeOwnerUri) |
|||
: StorageItem(storageProviderApi, directoryInfo, uri, scopeOwnerUri), IStorageBookmarkFolder |
|||
{ |
|||
public IAsyncEnumerable<IStorageItem> GetItemsAsync() |
|||
{ |
|||
return GetItems().AsAsyncEnumerable(); |
|||
|
|||
IEnumerable<IStorageItem> GetItems() |
|||
{ |
|||
using var scope = OpenScope(); |
|||
foreach (var item in BclStorageItem.GetItemsCore(directoryInfo)) |
|||
{ |
|||
yield return WrapFileSystemInfo(item, ScopeOwnerUri); |
|||
} |
|||
} |
|||
} |
|||
|
|||
public Task<IStorageFile?> CreateFileAsync(string name) |
|||
{ |
|||
using var scope = OpenScope(); |
|||
var file = BclStorageItem.CreateFileCore(directoryInfo, name); |
|||
return Task.FromResult((IStorageFile?)WrapFileSystemInfo(file, ScopeOwnerUri)); |
|||
} |
|||
|
|||
public Task<IStorageFolder?> CreateFolderAsync(string name) |
|||
{ |
|||
using var scope = OpenScope(); |
|||
var folder = BclStorageItem.CreateFolderCore(directoryInfo, name); |
|||
return Task.FromResult((IStorageFolder?)WrapFileSystemInfo(folder, ScopeOwnerUri)); |
|||
} |
|||
} |
|||
@ -0,0 +1,289 @@ |
|||
#nullable enable |
|||
|
|||
using System; |
|||
using System.Collections.Generic; |
|||
using System.IO; |
|||
using System.Linq; |
|||
using System.Threading.Tasks; |
|||
using Avalonia.Controls; |
|||
using Avalonia.Controls.Platform; |
|||
using Avalonia.Logging; |
|||
using Avalonia.Native.Interop; |
|||
using Avalonia.Platform.Storage; |
|||
using Avalonia.Platform.Storage.FileIO; |
|||
using Avalonia.Reactive; |
|||
using MicroCom.Runtime; |
|||
|
|||
namespace Avalonia.Native; |
|||
|
|||
internal class StorageProviderApi(IAvnStorageProvider native, bool sandboxEnabled) : IStorageProviderFactory, IDisposable |
|||
{ |
|||
private readonly Dictionary<string, int> _openScopes = new(); |
|||
private readonly IAvnStorageProvider _native = native; |
|||
|
|||
public IStorageProvider CreateProvider(TopLevel topLevel) |
|||
{ |
|||
return new StorageProviderImpl((TopLevelImpl)topLevel.PlatformImpl!, this); |
|||
} |
|||
|
|||
public IStorageItem? TryGetStorageItem(Uri? itemUri, bool create = false) |
|||
{ |
|||
if (itemUri is not null && StorageProviderHelpers.TryGetPathFromFileUri(itemUri) is { } itemPath) |
|||
{ |
|||
if (new FileInfo(itemPath) is { } fileInfo |
|||
&& (create || fileInfo.Exists)) |
|||
{ |
|||
return sandboxEnabled |
|||
? new StorageFile(this, fileInfo, itemUri, itemUri) |
|||
: new BclStorageFile(fileInfo); |
|||
} |
|||
if (new DirectoryInfo(itemPath) is { } directoryInfo |
|||
&& (create || directoryInfo.Exists)) |
|||
{ |
|||
return sandboxEnabled |
|||
? new StorageFolder(this, directoryInfo, itemUri, itemUri) |
|||
: new BclStorageFolder(directoryInfo); |
|||
} |
|||
} |
|||
|
|||
return null; |
|||
} |
|||
|
|||
public IDisposable? OpenSecurityScope(string uriString) |
|||
{ |
|||
// Multiple entries are possible.
|
|||
// For example, user might open OpenRead stream, and read file properties before closing the file.
|
|||
// If we don't check for nested scopes, inner closing scope will break access of the outer scope.
|
|||
if (AddUse(this, uriString) == 1) |
|||
{ |
|||
using var nsUriString = new AvnString(uriString); |
|||
var scopeOpened = _native.OpenSecurityScope(nsUriString).FromComBool(); |
|||
if (!scopeOpened) |
|||
{ |
|||
RemoveUse(this, uriString); |
|||
Logger.TryGet(LogEventLevel.Information, LogArea.macOSPlatform)? |
|||
.Log(this, "OpenSecurityScope returned false for the {Uri}", uriString); |
|||
return null; |
|||
} |
|||
} |
|||
|
|||
return Disposable.Create((api: this, uriString), static state => |
|||
{ |
|||
if (RemoveUse(state.api, state.uriString) == 0) |
|||
{ |
|||
using var nsUriString = new AvnString(state.uriString); |
|||
state.api._native.CloseSecurityScope(nsUriString); |
|||
} |
|||
}); |
|||
|
|||
static int AddUse(StorageProviderApi api, string uriString) |
|||
{ |
|||
lock (api) |
|||
{ |
|||
api._openScopes.TryGetValue(uriString, out var useValue); |
|||
api._openScopes[uriString] = ++useValue; |
|||
return useValue; |
|||
} |
|||
} |
|||
static int RemoveUse(StorageProviderApi api, string uriString) |
|||
{ |
|||
lock (api) |
|||
{ |
|||
api._openScopes.TryGetValue(uriString, out var useValue); |
|||
useValue--; |
|||
if (useValue == 0) |
|||
api._openScopes.Remove(uriString); |
|||
else |
|||
api._openScopes[uriString] = useValue; |
|||
return useValue; |
|||
} |
|||
} |
|||
} |
|||
|
|||
// Avalonia.Native technically can be used for more than just macOS,
|
|||
// In which case we should provide different bookmark platform keys, and parse accordingly.
|
|||
private static ReadOnlySpan<byte> MacOSKey => "macOS"u8; |
|||
public unsafe string? SaveBookmark(Uri uri) |
|||
{ |
|||
void* error = null; |
|||
using var uriString = new AvnString(uri.AbsoluteUri); |
|||
using var bookmarkStr = _native.SaveBookmarkToBytes(uriString, &error); |
|||
|
|||
if (error != null) |
|||
{ |
|||
using var errorStr = MicroComRuntime.CreateProxyOrNullFor<IAvnString>(error, true); |
|||
Logger.TryGet(LogEventLevel.Warning, LogArea.macOSPlatform)? |
|||
.Log(this, "SaveBookmark for {Uri} failed with an error\r\n{Error}", uri, errorStr.String); |
|||
return null; |
|||
} |
|||
|
|||
return StorageBookmarkHelper.EncodeBookmark(MacOSKey, bookmarkStr?.Bytes); |
|||
} |
|||
|
|||
// Support both kinds of bookmarks when reading.
|
|||
// Since "save bookmark" implementation will be different depending on the configuration.
|
|||
public unsafe Uri? ReadBookmark(string bookmark, bool isDirectory) |
|||
{ |
|||
if (StorageBookmarkHelper.TryDecodeBookmark(MacOSKey, bookmark, out var bytes) == StorageBookmarkHelper.DecodeResult.Success) |
|||
{ |
|||
fixed (byte* ptr = bytes) |
|||
{ |
|||
using var uriString = _native.ReadBookmarkFromBytes(ptr, bytes.Length); |
|||
return uriString is not null && Uri.TryCreate(uriString.String, UriKind.Absolute, out var uri) ? |
|||
uri : |
|||
null; |
|||
} |
|||
} |
|||
if (StorageBookmarkHelper.TryDecodeBclBookmark(bookmark, out var path)) |
|||
{ |
|||
return StorageProviderHelpers.UriFromFilePath(path, isDirectory); |
|||
} |
|||
|
|||
return null; |
|||
} |
|||
|
|||
public void ReleaseBookmark(Uri uri) |
|||
{ |
|||
using var uriString = new AvnString(uri.AbsoluteUri); |
|||
_native.ReleaseBookmark(uriString); |
|||
} |
|||
|
|||
public void Dispose() |
|||
{ |
|||
_native.Dispose(); |
|||
} |
|||
|
|||
public async Task<IReadOnlyList<IStorageFile>> OpenFileDialog(TopLevelImpl? topLevel, FilePickerOpenOptions options) |
|||
{ |
|||
using var fileTypes = new FilePickerFileTypesWrapper(options.FileTypeFilter, null); |
|||
var suggestedDirectory = options.SuggestedStartLocation?.Path.AbsoluteUri ?? string.Empty; |
|||
|
|||
var results = await OpenDialogAsync(events => |
|||
{ |
|||
_native.OpenFileDialog((IAvnWindow?)topLevel?.Native, |
|||
events, |
|||
options.AllowMultiple.AsComBool(), |
|||
options.Title ?? string.Empty, |
|||
suggestedDirectory, |
|||
options.SuggestedFileName ?? string.Empty, |
|||
fileTypes); |
|||
}); |
|||
|
|||
return results.OfType<IStorageFile>().ToArray(); |
|||
} |
|||
|
|||
public async Task<IStorageFile?> SaveFileDialog(TopLevelImpl? topLevel, FilePickerSaveOptions options) |
|||
{ |
|||
using var fileTypes = new FilePickerFileTypesWrapper(options.FileTypeChoices, options.DefaultExtension); |
|||
var suggestedDirectory = options.SuggestedStartLocation?.Path.AbsoluteUri ?? string.Empty; |
|||
|
|||
var results = await OpenDialogAsync(events => |
|||
{ |
|||
_native.SaveFileDialog((IAvnWindow?)topLevel?.Native, |
|||
events, |
|||
options.Title ?? string.Empty, |
|||
suggestedDirectory, |
|||
options.SuggestedFileName ?? string.Empty, |
|||
fileTypes); |
|||
}, create: true); |
|||
|
|||
return results.OfType<IStorageFile>().FirstOrDefault(); |
|||
} |
|||
|
|||
public async Task<IReadOnlyList<IStorageFolder>> SelectFolderDialog(TopLevelImpl? topLevel, FolderPickerOpenOptions options) |
|||
{ |
|||
var suggestedDirectory = options.SuggestedStartLocation?.Path.AbsoluteUri ?? string.Empty; |
|||
|
|||
var results = await OpenDialogAsync(events => |
|||
{ |
|||
_native.SelectFolderDialog((IAvnWindow?)topLevel?.Native, |
|||
events, |
|||
options.AllowMultiple.AsComBool(), |
|||
options.Title ?? "", |
|||
suggestedDirectory); |
|||
}); |
|||
|
|||
return results.OfType<IStorageFolder>().ToArray(); |
|||
} |
|||
|
|||
public async Task<IEnumerable<IStorageItem>> OpenDialogAsync(Action<SystemDialogEvents> runDialog, bool create = false) |
|||
{ |
|||
using var events = new SystemDialogEvents(); |
|||
runDialog(events); |
|||
var result = await events.Task.ConfigureAwait(false); |
|||
return (result? |
|||
.Select(f => Uri.TryCreate(f, UriKind.Absolute, out var uri) ? TryGetStorageItem(uri, create) : null) |
|||
.Where(f => f is not null) ?? [])!; |
|||
} |
|||
|
|||
internal class FilePickerFileTypesWrapper( |
|||
IReadOnlyList<FilePickerFileType>? types, |
|||
string? defaultExtension) |
|||
: NativeCallbackBase, IAvnFilePickerFileTypes |
|||
{ |
|||
private readonly List<IDisposable> _disposables = new(); |
|||
|
|||
public int Count => types?.Count ?? 0; |
|||
|
|||
public int IsDefaultType(int index) => (defaultExtension is not null && |
|||
types![index].TryGetExtensions()?.Any(defaultExtension.EndsWith) == true).AsComBool(); |
|||
|
|||
public int IsAnyType(int index) => |
|||
(types![index].Patterns?.Contains("*.*") == true || types[index].MimeTypes?.Contains("*.*") == true) |
|||
.AsComBool(); |
|||
|
|||
public IAvnString GetName(int index) |
|||
{ |
|||
return EnsureDisposable(types![index].Name.ToAvnString()); |
|||
} |
|||
|
|||
public IAvnStringArray GetPatterns(int index) |
|||
{ |
|||
return EnsureDisposable(new AvnStringArray(types![index].Patterns ?? Array.Empty<string>())); |
|||
} |
|||
|
|||
public IAvnStringArray GetExtensions(int index) |
|||
{ |
|||
return EnsureDisposable(new AvnStringArray(types![index].TryGetExtensions() ?? Array.Empty<string>())); |
|||
} |
|||
|
|||
public IAvnStringArray GetMimeTypes(int index) |
|||
{ |
|||
return EnsureDisposable(new AvnStringArray(types![index].MimeTypes ?? Array.Empty<string>())); |
|||
} |
|||
|
|||
public IAvnStringArray GetAppleUniformTypeIdentifiers(int index) |
|||
{ |
|||
return EnsureDisposable(new AvnStringArray(types![index].AppleUniformTypeIdentifiers ?? Array.Empty<string>())); |
|||
} |
|||
|
|||
protected override void Destroyed() |
|||
{ |
|||
foreach (var disposable in _disposables) |
|||
{ |
|||
disposable.Dispose(); |
|||
} |
|||
} |
|||
|
|||
private T EnsureDisposable<T>(T input) where T : IDisposable |
|||
{ |
|||
_disposables.Add(input); |
|||
return input; |
|||
} |
|||
} |
|||
|
|||
internal class SystemDialogEvents : NativeCallbackBase, IAvnSystemDialogEvents |
|||
{ |
|||
private readonly TaskCompletionSource<string[]> _tcs = new(); |
|||
|
|||
public Task<string[]> Task => _tcs.Task; |
|||
|
|||
public void OnCompleted(IAvnStringArray? ppv) |
|||
{ |
|||
using (ppv) |
|||
{ |
|||
_tcs.SetResult(ppv?.ToStringArray() ?? []); |
|||
} |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,63 @@ |
|||
#nullable enable |
|||
|
|||
using System; |
|||
using System.Collections.Generic; |
|||
using System.Threading.Tasks; |
|||
using Avalonia.Platform.Storage; |
|||
using Avalonia.Platform.Storage.FileIO; |
|||
|
|||
namespace Avalonia.Native; |
|||
|
|||
internal sealed class StorageProviderImpl(TopLevelImpl topLevel, StorageProviderApi native) : IStorageProvider |
|||
{ |
|||
public bool CanOpen => true; |
|||
|
|||
public bool CanSave => true; |
|||
|
|||
public bool CanPickFolder => true; |
|||
|
|||
public Task<IReadOnlyList<IStorageFile>> OpenFilePickerAsync(FilePickerOpenOptions options) |
|||
{ |
|||
return native.OpenFileDialog(topLevel, options); |
|||
} |
|||
|
|||
public Task<IStorageFile?> SaveFilePickerAsync(FilePickerSaveOptions options) |
|||
{ |
|||
return native.SaveFileDialog(topLevel, options); |
|||
} |
|||
|
|||
public Task<IReadOnlyList<IStorageFolder>> OpenFolderPickerAsync(FolderPickerOpenOptions options) |
|||
{ |
|||
return native.SelectFolderDialog(topLevel, options); |
|||
} |
|||
|
|||
public Task<IStorageBookmarkFile?> OpenFileBookmarkAsync(string bookmark) |
|||
{ |
|||
return Task.FromResult(native.TryGetStorageItem(native.ReadBookmark(bookmark, false)) as IStorageBookmarkFile); |
|||
} |
|||
|
|||
public Task<IStorageBookmarkFolder?> OpenFolderBookmarkAsync(string bookmark) |
|||
{ |
|||
return Task.FromResult(native.TryGetStorageItem(native.ReadBookmark(bookmark, true)) as IStorageBookmarkFolder); |
|||
} |
|||
|
|||
public Task<IStorageFile?> TryGetFileFromPathAsync(Uri fileUri) |
|||
{ |
|||
return Task.FromResult(native.TryGetStorageItem(fileUri) as IStorageFile); |
|||
} |
|||
|
|||
public Task<IStorageFolder?> TryGetFolderFromPathAsync(Uri folderPath) |
|||
{ |
|||
return Task.FromResult(native.TryGetStorageItem(folderPath) as IStorageFolder); |
|||
} |
|||
|
|||
public Task<IStorageFolder?> TryGetWellKnownFolderAsync(WellKnownFolder wellKnownFolder) |
|||
{ |
|||
if (BclStorageProvider.TryGetWellKnownFolderCore(wellKnownFolder) is { } directoryInfo) |
|||
{ |
|||
return Task.FromResult<IStorageFolder?>(new BclStorageFolder(directoryInfo)); |
|||
} |
|||
|
|||
return Task.FromResult<IStorageFolder?>(null); |
|||
} |
|||
} |
|||
@ -1,181 +0,0 @@ |
|||
#nullable enable |
|||
|
|||
using System; |
|||
using System.Collections.Generic; |
|||
using System.IO; |
|||
using System.Linq; |
|||
using System.Runtime.InteropServices; |
|||
using System.Threading.Tasks; |
|||
using Avalonia.Native.Interop; |
|||
using Avalonia.Platform.Storage; |
|||
using Avalonia.Platform.Storage.FileIO; |
|||
|
|||
namespace Avalonia.Native |
|||
{ |
|||
internal class SystemDialogs : BclStorageProvider |
|||
{ |
|||
private readonly TopLevelImpl _topLevel; |
|||
private readonly IAvnSystemDialogs _native; |
|||
|
|||
public SystemDialogs(TopLevelImpl topLevel, IAvnSystemDialogs native) |
|||
{ |
|||
_topLevel = topLevel; |
|||
_native = native; |
|||
} |
|||
|
|||
public override bool CanOpen => true; |
|||
|
|||
public override bool CanSave => true; |
|||
|
|||
public override bool CanPickFolder => true; |
|||
|
|||
public override async Task<IReadOnlyList<IStorageFile>> OpenFilePickerAsync(FilePickerOpenOptions options) |
|||
{ |
|||
using var events = new SystemDialogEvents(); |
|||
using var fileTypes = new FilePickerFileTypesWrapper(options.FileTypeFilter, null); |
|||
|
|||
var suggestedDirectory = options.SuggestedStartLocation?.TryGetLocalPath() ?? string.Empty; |
|||
|
|||
_native.OpenFileDialog((IAvnWindow)_topLevel.Native, |
|||
events, |
|||
options.AllowMultiple.AsComBool(), |
|||
options.Title ?? string.Empty, |
|||
suggestedDirectory, |
|||
options.SuggestedFileName ?? string.Empty, |
|||
fileTypes); |
|||
|
|||
var result = await events.Task.ConfigureAwait(false); |
|||
|
|||
return result?.Select(f => new BclStorageFile(new FileInfo(f))).ToArray() |
|||
?? Array.Empty<IStorageFile>(); |
|||
} |
|||
|
|||
public override async Task<IStorageFile?> SaveFilePickerAsync(FilePickerSaveOptions options) |
|||
{ |
|||
using var events = new SystemDialogEvents(); |
|||
using var fileTypes = new FilePickerFileTypesWrapper(options.FileTypeChoices, options.DefaultExtension); |
|||
|
|||
var suggestedDirectory = options.SuggestedStartLocation?.TryGetLocalPath() ?? string.Empty; |
|||
|
|||
_native.SaveFileDialog((IAvnWindow)_topLevel.Native, |
|||
events, |
|||
options.Title ?? string.Empty, |
|||
suggestedDirectory, |
|||
options.SuggestedFileName ?? string.Empty, |
|||
fileTypes); |
|||
|
|||
var result = await events.Task.ConfigureAwait(false); |
|||
return result.FirstOrDefault() is string file |
|||
? new BclStorageFile(new FileInfo(file)) |
|||
: null; |
|||
} |
|||
|
|||
public override async Task<IReadOnlyList<IStorageFolder>> OpenFolderPickerAsync(FolderPickerOpenOptions options) |
|||
{ |
|||
using var events = new SystemDialogEvents(); |
|||
|
|||
var suggestedDirectory = options.SuggestedStartLocation?.TryGetLocalPath() ?? string.Empty; |
|||
|
|||
_native.SelectFolderDialog((IAvnWindow)_topLevel.Native, events, options.AllowMultiple.AsComBool(), options.Title ?? "", suggestedDirectory); |
|||
|
|||
var result = await events.Task.ConfigureAwait(false); |
|||
return result?.Select(f => new BclStorageFolder(new DirectoryInfo(f))).ToArray() |
|||
?? Array.Empty<IStorageFolder>(); |
|||
} |
|||
} |
|||
|
|||
internal class FilePickerFileTypesWrapper : NativeCallbackBase, IAvnFilePickerFileTypes |
|||
{ |
|||
private readonly IReadOnlyList<FilePickerFileType>? _types; |
|||
private readonly string? _defaultExtension; |
|||
private readonly List<IDisposable> _disposables; |
|||
|
|||
public FilePickerFileTypesWrapper( |
|||
IReadOnlyList<FilePickerFileType>? types, |
|||
string? defaultExtension) |
|||
{ |
|||
_types = types; |
|||
_defaultExtension = defaultExtension; |
|||
_disposables = new List<IDisposable>(); |
|||
} |
|||
|
|||
public int Count => _types?.Count ?? 0; |
|||
|
|||
public int IsDefaultType(int index) => (_defaultExtension is not null && |
|||
_types![index].TryGetExtensions()?.Any(ext => _defaultExtension.EndsWith(ext)) == true).AsComBool(); |
|||
|
|||
public int IsAnyType(int index) => |
|||
(_types![index].Patterns?.Contains("*.*") == true || _types[index].MimeTypes?.Contains("*.*") == true) |
|||
.AsComBool(); |
|||
|
|||
public IAvnString GetName(int index) |
|||
{ |
|||
return EnsureDisposable(_types![index].Name.ToAvnString()); |
|||
} |
|||
|
|||
public IAvnStringArray GetPatterns(int index) |
|||
{ |
|||
return EnsureDisposable(new AvnStringArray(_types![index].Patterns ?? Array.Empty<string>())); |
|||
} |
|||
|
|||
public IAvnStringArray GetExtensions(int index) |
|||
{ |
|||
return EnsureDisposable(new AvnStringArray(_types![index].TryGetExtensions() ?? Array.Empty<string>())); |
|||
} |
|||
|
|||
public IAvnStringArray GetMimeTypes(int index) |
|||
{ |
|||
return EnsureDisposable(new AvnStringArray(_types![index].MimeTypes ?? Array.Empty<string>())); |
|||
} |
|||
|
|||
public IAvnStringArray GetAppleUniformTypeIdentifiers(int index) |
|||
{ |
|||
return EnsureDisposable(new AvnStringArray(_types![index].AppleUniformTypeIdentifiers ?? Array.Empty<string>())); |
|||
} |
|||
|
|||
protected override void Destroyed() |
|||
{ |
|||
foreach (var disposable in _disposables) |
|||
{ |
|||
disposable.Dispose(); |
|||
} |
|||
} |
|||
|
|||
private T EnsureDisposable<T>(T input) where T : IDisposable |
|||
{ |
|||
_disposables.Add(input); |
|||
return input; |
|||
} |
|||
} |
|||
|
|||
internal unsafe class SystemDialogEvents : NativeCallbackBase, IAvnSystemDialogEvents |
|||
{ |
|||
private readonly TaskCompletionSource<string[]> _tcs; |
|||
|
|||
public SystemDialogEvents() |
|||
{ |
|||
_tcs = new TaskCompletionSource<string[]>(); |
|||
} |
|||
|
|||
public Task<string[]> Task => _tcs.Task; |
|||
|
|||
public void OnCompleted(int numResults, void* trFirstResultRef) |
|||
{ |
|||
string[] results = new string[numResults]; |
|||
|
|||
unsafe |
|||
{ |
|||
var ptr = (IntPtr*)trFirstResultRef; |
|||
|
|||
for (int i = 0; i < numResults; i++) |
|||
{ |
|||
results[i] = Marshal.PtrToStringAnsi(*ptr) ?? string.Empty; |
|||
|
|||
ptr++; |
|||
} |
|||
} |
|||
|
|||
_tcs.SetResult(results); |
|||
} |
|||
} |
|||
} |
|||
@ -1,68 +0,0 @@ |
|||
using System.IO; |
|||
|
|||
using Foundation; |
|||
|
|||
using UIKit; |
|||
|
|||
#nullable enable |
|||
|
|||
namespace Avalonia.iOS.Storage; |
|||
|
|||
internal sealed class IOSSecurityScopedStream : Stream |
|||
{ |
|||
private readonly UIDocument _document; |
|||
private readonly FileStream _stream; |
|||
private readonly NSUrl _url; |
|||
private readonly NSUrl _securityScopedAncestorUrl; |
|||
|
|||
internal IOSSecurityScopedStream(NSUrl url, NSUrl securityScopedAncestorUrl, FileAccess access) |
|||
{ |
|||
_document = new UIDocument(url); |
|||
var path = _document.FileUrl.Path!; |
|||
_url = url; |
|||
_securityScopedAncestorUrl = securityScopedAncestorUrl; |
|||
_securityScopedAncestorUrl.StartAccessingSecurityScopedResource(); |
|||
_stream = File.Open(path, FileMode.Open, access); |
|||
} |
|||
|
|||
public override bool CanRead => _stream.CanRead; |
|||
|
|||
public override bool CanSeek => _stream.CanSeek; |
|||
|
|||
public override bool CanWrite => _stream.CanWrite; |
|||
|
|||
public override long Length => _stream.Length; |
|||
|
|||
public override long Position |
|||
{ |
|||
get => _stream.Position; |
|||
set => _stream.Position = value; |
|||
} |
|||
|
|||
public override void Flush() => |
|||
_stream.Flush(); |
|||
|
|||
public override int Read(byte[] buffer, int offset, int count) => |
|||
_stream.Read(buffer, offset, count); |
|||
|
|||
public override long Seek(long offset, SeekOrigin origin) => |
|||
_stream.Seek(offset, origin); |
|||
|
|||
public override void SetLength(long value) => |
|||
_stream.SetLength(value); |
|||
|
|||
public override void Write(byte[] buffer, int offset, int count) => |
|||
_stream.Write(buffer, offset, count); |
|||
|
|||
protected override void Dispose(bool disposing) |
|||
{ |
|||
base.Dispose(disposing); |
|||
|
|||
if (disposing) |
|||
{ |
|||
_stream.Dispose(); |
|||
_document.Dispose(); |
|||
_securityScopedAncestorUrl.StopAccessingSecurityScopedResource(); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,74 @@ |
|||
using System; |
|||
using System.Linq; |
|||
using System.Text; |
|||
using Avalonia.Platform.Storage.FileIO; |
|||
using Xunit; |
|||
|
|||
namespace Avalonia.Controls.UnitTests.Platform; |
|||
|
|||
public class StorageProviderHelperTests |
|||
{ |
|||
[Fact] |
|||
public void Can_Encode_And_Decode_Bookmark() |
|||
{ |
|||
var platform = "test"u8; |
|||
var nativeBookmark = "bookmark"u8; |
|||
|
|||
var bookmark = StorageBookmarkHelper.EncodeBookmark(platform, nativeBookmark); |
|||
|
|||
Assert.NotNull(bookmark); |
|||
|
|||
Assert.Equal( |
|||
StorageBookmarkHelper.DecodeResult.Success, |
|||
StorageBookmarkHelper.TryDecodeBookmark(platform, bookmark, out var nativeBookmarkRet)); |
|||
|
|||
Assert.NotNull(nativeBookmarkRet); |
|||
|
|||
Assert.True(nativeBookmark.SequenceEqual(nativeBookmarkRet)); |
|||
} |
|||
|
|||
[Theory] |
|||
[InlineData("C://file.txt", "YXZhLnYxLnRlc3QAAAAAAEM6Ly9maWxlLnR4dA==")] |
|||
public void Can_Encode_Bookmark(string nativeBookmark, string expectedEncodedBookmark) |
|||
{ |
|||
var platform = "test"u8; |
|||
|
|||
var bookmark = StorageBookmarkHelper.EncodeBookmark(platform, nativeBookmark); |
|||
|
|||
Assert.Equal(expectedEncodedBookmark, bookmark); |
|||
Assert.NotNull(bookmark); |
|||
} |
|||
|
|||
[Theory] |
|||
[InlineData("YXZhLnYxLnRlc3QAAAAAAEM6Ly9maWxlLnR4dA==", "C://file.txt")] |
|||
public void Can_Decode_Bookmark(string encodedBookmark, string expectedNativeBookmark) |
|||
{ |
|||
var platform = "test"u8; |
|||
var expectedNativeBookmarkBytes = Encoding.UTF8.GetBytes(expectedNativeBookmark); |
|||
|
|||
Assert.Equal( |
|||
StorageBookmarkHelper.DecodeResult.Success, |
|||
StorageBookmarkHelper.TryDecodeBookmark(platform, encodedBookmark, out var nativeBookmark)); |
|||
|
|||
Assert.Equal(expectedNativeBookmarkBytes, nativeBookmark); |
|||
} |
|||
|
|||
[Theory] |
|||
[InlineData("YXZhLnYxLmJjbAAAAAAAAEM6Ly9maWxlLnR4dA==", "C://file.txt")] |
|||
[InlineData("C://file.txt", "C://file.txt")] |
|||
public void Can_Decode_Bcl_Bookmarks(string bookmark, string expected) |
|||
{ |
|||
var a = StorageBookmarkHelper.EncodeBclBookmark(expected); |
|||
Assert.True(StorageBookmarkHelper.TryDecodeBclBookmark(bookmark, out var localPath)); |
|||
Assert.Equal(expected, localPath); |
|||
} |
|||
|
|||
[Theory] |
|||
[InlineData("YXZhLnYxLnRlc3QAAAAAAEM6Ly9maWxlLnR4dA==")] // "test" platform passed instead of "bcl"
|
|||
[InlineData("ZYXasHKJASd87124")] |
|||
public void Fails_To_Decode_Invalid_Bcl_Bookmarks(string bookmark) |
|||
{ |
|||
Assert.False(StorageBookmarkHelper.TryDecodeBclBookmark(bookmark, out var localPath)); |
|||
Assert.Null(localPath); |
|||
} |
|||
} |
|||
Loading…
Reference in new issue