Browse Source

MacOS sandboxing feature (#16090)

* 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 changes
pull/16373/head
Max Katz 2 years ago
committed by GitHub
parent
commit
32c2f08200
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 8
      native/Avalonia.Native/src/OSX/Avalonia.Native.OSX.xcodeproj/project.pbxproj
  2. 135
      native/Avalonia.Native/src/OSX/StorageProvider.mm
  3. 2
      native/Avalonia.Native/src/OSX/common.h
  4. 4
      native/Avalonia.Native/src/OSX/main.mm
  5. 50
      samples/ControlCatalog/Pages/DialogsPage.xaml.cs
  6. 4
      src/Android/Avalonia.Android/Platform/Storage/AndroidStorageItem.cs
  7. 22
      src/Android/Avalonia.Android/Platform/Storage/AndroidStorageProvider.cs
  8. 113
      src/Avalonia.Base/Platform/Storage/FileIO/BclStorageFile.cs
  9. 128
      src/Avalonia.Base/Platform/Storage/FileIO/BclStorageFolder.cs
  10. 141
      src/Avalonia.Base/Platform/Storage/FileIO/BclStorageItem.cs
  11. 56
      src/Avalonia.Base/Platform/Storage/FileIO/BclStorageProvider.cs
  12. 116
      src/Avalonia.Base/Platform/Storage/FileIO/SecurityScopedStream.cs
  13. 153
      src/Avalonia.Base/Platform/Storage/FileIO/StorageBookmarkHelper.cs
  14. 30
      src/Avalonia.Base/Platform/Storage/FileIO/StorageProviderHelpers.cs
  15. 8
      src/Avalonia.Base/Platform/Storage/IStorageBookmarkItem.cs
  16. 20
      src/Avalonia.Base/Platform/Storage/StorageProviderExtensions.cs
  17. 16
      src/Avalonia.Diagnostics/Diagnostics/Conventions.cs
  18. 15
      src/Avalonia.Diagnostics/Diagnostics/Screenshots/FilePickerHandler.cs
  19. 12
      src/Avalonia.Native/AvaloniaNativeApplicationPlatform.cs
  20. 6
      src/Avalonia.Native/AvaloniaNativePlatform.cs
  21. 7
      src/Avalonia.Native/AvaloniaNativePlatformExtensions.cs
  22. 11
      src/Avalonia.Native/ClipboardImpl.cs
  23. 152
      src/Avalonia.Native/StorageItem.cs
  24. 289
      src/Avalonia.Native/StorageProviderApi.cs
  25. 63
      src/Avalonia.Native/StorageProviderImpl.cs
  26. 181
      src/Avalonia.Native/SystemDialogs.cs
  27. 7
      src/Avalonia.Native/TopLevelImpl.cs
  28. 17
      src/Avalonia.Native/avn.idl
  29. 38
      src/Browser/Avalonia.Browser/Storage/BrowserStorageProvider.cs
  30. 68
      src/iOS/Avalonia.iOS/Storage/IOSSecurityScopedStream.cs
  31. 29
      src/iOS/Avalonia.iOS/Storage/IOSStorageItem.cs
  32. 42
      src/iOS/Avalonia.iOS/Storage/IOSStorageProvider.cs
  33. 2
      tests/Avalonia.Base.UnitTests/Utilities/UriExtensionsTests.cs
  34. 74
      tests/Avalonia.Controls.UnitTests/Platform/StorageProviderHelperTests.cs

8
native/Avalonia.Native/src/OSX/Avalonia.Native.OSX.xcodeproj/project.pbxproj

@ -34,7 +34,7 @@
1AFD334123E03C4F0042899B /* controlhost.mm in Sources */ = {isa = PBXBuildFile; fileRef = 1AFD334023E03C4F0042899B /* controlhost.mm */; };
37155CE4233C00EB0034DCE9 /* menu.h in Headers */ = {isa = PBXBuildFile; fileRef = 37155CE3233C00EB0034DCE9 /* menu.h */; };
37A517B32159597E00FBA241 /* Screens.mm in Sources */ = {isa = PBXBuildFile; fileRef = 37A517B22159597E00FBA241 /* Screens.mm */; };
37C09D8821580FE4006A6758 /* SystemDialogs.mm in Sources */ = {isa = PBXBuildFile; fileRef = 37C09D8721580FE4006A6758 /* SystemDialogs.mm */; };
37C09D8821580FE4006A6758 /* StorageProvider.mm in Sources */ = {isa = PBXBuildFile; fileRef = 37C09D8721580FE4006A6758 /* StorageProvider.mm */; };
37DDA9B0219330F8002E132B /* AvnString.mm in Sources */ = {isa = PBXBuildFile; fileRef = 37DDA9AF219330F8002E132B /* AvnString.mm */; };
37E2330F21583241000CB7E2 /* KeyTransform.mm in Sources */ = {isa = PBXBuildFile; fileRef = 37E2330E21583241000CB7E2 /* KeyTransform.mm */; };
520624B322973F4100C4DCEF /* menu.mm in Sources */ = {isa = PBXBuildFile; fileRef = 520624B222973F4100C4DCEF /* menu.mm */; };
@ -95,7 +95,7 @@
379860FE214DA0C000CD0246 /* KeyTransform.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KeyTransform.h; sourceTree = "<group>"; };
37A4E71A2178846A00EACBCD /* headers */ = {isa = PBXFileReference; lastKnownFileType = folder; name = headers; path = ../../inc; sourceTree = "<group>"; };
37A517B22159597E00FBA241 /* Screens.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = Screens.mm; sourceTree = "<group>"; };
37C09D8721580FE4006A6758 /* SystemDialogs.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = SystemDialogs.mm; sourceTree = "<group>"; };
37C09D8721580FE4006A6758 /* StorageProvider.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = StorageProvider.mm; sourceTree = "<group>"; };
37DDA9AF219330F8002E132B /* AvnString.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = AvnString.mm; sourceTree = "<group>"; };
37DDA9B121933371002E132B /* AvnString.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = AvnString.h; sourceTree = "<group>"; };
37E2330E21583241000CB7E2 /* KeyTransform.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.objcpp; path = KeyTransform.mm; sourceTree = "<group>"; };
@ -202,7 +202,7 @@
523484CB26EA68AA00EA0C2C /* trayicon.h */,
1A3E5EA723E9E83B00EDE661 /* rendertarget.mm */,
37A517B22159597E00FBA241 /* Screens.mm */,
37C09D8721580FE4006A6758 /* SystemDialogs.mm */,
37C09D8721580FE4006A6758 /* StorageProvider.mm */,
EDF8CDCC2964CB01001EE34F /* PlatformSettings.mm */,
AB7A61F02147C815003C5833 /* Products */,
AB661C1C2148230E00291242 /* Frameworks */,
@ -339,7 +339,7 @@
1AFD334123E03C4F0042899B /* controlhost.mm in Sources */,
1A465D10246AB61600C5858B /* dnd.mm in Sources */,
AB00E4F72147CA920032A60A /* main.mm in Sources */,
37C09D8821580FE4006A6758 /* SystemDialogs.mm in Sources */,
37C09D8821580FE4006A6758 /* StorageProvider.mm in Sources */,
1839179A55FC1421BEE83330 /* WindowBaseImpl.mm in Sources */,
F10084862BFF1FB40024303E /* TopLevelImpl.mm in Sources */,
1839125F057B0A4EB1760058 /* WindowImpl.mm in Sources */,

135
native/Avalonia.Native/src/OSX/SystemDialogs.mm → native/Avalonia.Native/src/OSX/StorageProvider.mm

@ -64,12 +64,91 @@ const int kFileTypePopupTag = 10975;
@end
class SystemDialogs : public ComSingleObject<IAvnSystemDialogs, &IID_IAvnSystemDialogs>
class StorageProvider : public ComSingleObject<IAvnStorageProvider, &IID_IAvnStorageProvider>
{
ExtensionDropdownHandler* __strong _extension_dropdown_handler;
public:
FORWARD_IUNKNOWN()
virtual HRESULT SaveBookmarkToBytes (
IAvnString* fileUriStr,
void** err,
IAvnString** ppv
) override
{
@autoreleasepool
{
if(ppv == nullptr)
return E_POINTER;
NSError* error;
auto fileUri = [NSURL URLWithString: GetNSStringAndRelease(fileUriStr)];
auto bookmarkData = [fileUri bookmarkDataWithOptions:NSURLBookmarkCreationWithSecurityScope includingResourceValuesForKeys:nil relativeToURL:nil error:&error];
if (bookmarkData)
{
*ppv = CreateByteArray((void*)bookmarkData.bytes, (int)bookmarkData.length);
}
if (error != nil)
{
*err = CreateAvnString([error localizedDescription]);
}
return S_OK;
}
}
virtual HRESULT ReadBookmarkFromBytes (
void* ptr,
int len,
IAvnString** ppv
) override {
@autoreleasepool
{
if(ppv == nullptr)
return E_POINTER;
auto bookmarkData = [[NSData alloc] initWithBytes:ptr length:len];
auto fileUri = [NSURL URLByResolvingBookmarkData: bookmarkData
options:NSURLBookmarkResolutionWithSecurityScope|NSURLBookmarkResolutionWithoutUI
relativeToURL:nil
bookmarkDataIsStale:nil
error:nil];
if (fileUri)
{
*ppv = CreateAvnString([fileUri absoluteString]);
}
return S_OK;
}
}
virtual void ReleaseBookmark (
IAvnString* fileUriStr
) override {
// no-op
}
virtual bool OpenSecurityScope (
IAvnString* fileUriStr
) override {
@autoreleasepool
{
auto fileUri = [NSURL URLWithString: GetNSStringAndRelease(fileUriStr)];
auto success = [fileUri startAccessingSecurityScopedResource];
return success;
}
}
virtual void CloseSecurityScope (
IAvnString* fileUriStr
) override {
@autoreleasepool
{
auto fileUri = [NSURL URLWithString: GetNSStringAndRelease(fileUriStr)];
[fileUri stopAccessingSecurityScopedResource];
}
}
virtual void SelectFolderDialog (IAvnWindow* parentWindowHandle,
IAvnSystemDialogEvents* events,
bool allowMultiple,
@ -105,19 +184,9 @@ public:
if(urls.count > 0)
{
void* strings[urls.count];
for(int i = 0; i < urls.count; i++)
{
auto url = [urls objectAtIndex:i];
auto string = [url path];
strings[i] = (void*)[string UTF8String];
}
events->OnCompleted((int)urls.count, &strings[0]);
auto uriStrings = CreateAvnStringArray(urls);
events->OnCompleted(uriStrings);
[panel orderOut:panel];
if(parentWindowHandle != nullptr)
@ -130,7 +199,7 @@ public:
}
}
events->OnCompleted(0, nullptr);
events->OnCompleted(nullptr);
};
@ -188,19 +257,9 @@ public:
if(urls.count > 0)
{
void* strings[urls.count];
for(int i = 0; i < urls.count; i++)
{
auto url = [urls objectAtIndex:i];
auto string = [url path];
strings[i] = (void*)[string UTF8String];
}
events->OnCompleted((int)urls.count, &strings[0]);
auto uriStrings = CreateAvnStringArray(urls);
events->OnCompleted(uriStrings);
[panel orderOut:panel];
if(parentWindowHandle != nullptr)
@ -213,7 +272,7 @@ public:
}
}
events->OnCompleted(0, nullptr);
events->OnCompleted(nullptr);
};
@ -264,15 +323,11 @@ public:
auto handler = ^(NSModalResponse result) {
if(result == NSFileHandlingPanelOKButton)
{
void* strings[1];
auto url = [panel URL];
auto string = [url path];
strings[0] = (void*)[string UTF8String];
events->OnCompleted(1, &strings[0]);
auto urls = [NSArray<NSURL*> arrayWithObject:url];
auto uriStrings = CreateAvnStringArray(urls);
events->OnCompleted(uriStrings);
[panel orderOut:panel];
if(parentWindowHandle != nullptr)
@ -284,7 +339,7 @@ public:
return;
}
events->OnCompleted(0, nullptr);
events->OnCompleted(nullptr);
};
@ -519,7 +574,7 @@ private:
};
};
extern IAvnSystemDialogs* CreateSystemDialogs()
extern IAvnStorageProvider* CreateStorageProvider()
{
return new SystemDialogs();
return new StorageProvider();
}

2
native/Avalonia.Native/src/OSX/common.h

@ -14,7 +14,7 @@ extern void PostDispatcherCallback(IAvnActionCallback* cb);
extern IAvnTopLevel* CreateAvnTopLevel(IAvnTopLevelEvents* events);
extern IAvnWindow* CreateAvnWindow(IAvnWindowEvents*events);
extern IAvnPopup* CreateAvnPopup(IAvnWindowEvents*events);
extern IAvnSystemDialogs* CreateSystemDialogs();
extern IAvnStorageProvider* CreateStorageProvider();
extern IAvnScreens* CreateScreens(IAvnScreenEvents* cb);
extern IAvnClipboard* CreateClipboard(NSPasteboard*, NSPasteboardItem*);
extern NSPasteboardItem* TryGetPasteboardItem(IAvnClipboard*);

4
native/Avalonia.Native/src/OSX/main.mm

@ -276,13 +276,13 @@ public:
}
}
virtual HRESULT CreateSystemDialogs(IAvnSystemDialogs** ppv) override
virtual HRESULT CreateStorageProvider(IAvnStorageProvider** ppv) override
{
START_COM_CALL;
@autoreleasepool
{
*ppv = ::CreateSystemDialogs();
*ppv = ::CreateStorageProvider();
return S_OK;
}
}

50
samples/ControlCatalog/Pages/DialogsPage.xaml.cs

@ -254,17 +254,24 @@ namespace ControlCatalog.Pages
if (file is not null)
{
// Sync disposal of StreamWriter is not supported on WASM
try
{
// Sync disposal of StreamWriter is not supported on WASM
#if NET6_0_OR_GREATER
await using var stream = await file.OpenWriteAsync();
await using var writer = new System.IO.StreamWriter(stream);
await using var stream = await file.OpenWriteAsync();
await using var writer = new System.IO.StreamWriter(stream);
#else
using var stream = await file.OpenWriteAsync();
using var writer = new System.IO.StreamWriter(stream);
using var stream = await file.OpenWriteAsync();
using var writer = new System.IO.StreamWriter(stream);
#endif
await writer.WriteLineAsync(openedFileContent.Text);
await writer.WriteLineAsync(openedFileContent.Text);
SetFolder(await file.GetParentAsync());
SetFolder(await file.GetParentAsync());
}
catch (Exception ex)
{
openedFileContent.Text = ex.ToString();
}
}
await SetPickerResult(file is null ? null : new[] { file });
@ -280,8 +287,6 @@ namespace ControlCatalog.Pages
});
await SetPickerResult(folders);
SetFolder(folders.FirstOrDefault());
};
this.Get<Button>("OpenFileFromBookmark").Click += async delegate
{
@ -298,7 +303,6 @@ namespace ControlCatalog.Pages
: null;
await SetPickerResult(folder is null ? null : new[] { folder });
SetFolder(folder);
};
this.Get<Button>("LaunchUri").Click += async delegate
@ -360,16 +364,30 @@ namespace ControlCatalog.Pages
Content:
";
resultText += await ReadTextFromFile(file, 500);
try
{
resultText += await ReadTextFromFile(file, 500);
}
catch (Exception ex)
{
resultText += ex.ToString();
}
}
openedFileContent.Text = resultText;
var parent = await item.GetParentAsync();
SetFolder(parent);
if (parent is not null)
if (item is IStorageFolder storageFolder)
{
mappedResults.Add(FullPathOrName(parent));
SetFolder(storageFolder);
}
else
{
var parent = await item.GetParentAsync();
SetFolder(parent);
if (parent is not null)
{
mappedResults.Add(FullPathOrName(parent));
}
}
foreach (var selectedItem in items)
@ -391,7 +409,7 @@ namespace ControlCatalog.Pages
}
}
public static async Task<string> ReadTextFromFile(IStorageFile file, int length)
internal static async Task<string> ReadTextFromFile(IStorageFile file, int length)
{
#if NET6_0_OR_GREATER
await using var stream = await file.OpenReadAsync();

4
src/Android/Avalonia.Android/Platform/Storage/AndroidStorageItem.cs

@ -10,6 +10,7 @@ using Android.Provider;
using Android.Webkit;
using Avalonia.Logging;
using Avalonia.Platform.Storage;
using Avalonia.Platform.Storage.FileIO;
using Java.Lang;
using AndroidUri = Android.Net.Uri;
using Exception = System.Exception;
@ -53,7 +54,8 @@ internal abstract class AndroidStorageItem : IStorageBookmarkItem
}
Activity.ContentResolver?.TakePersistableUriPermission(Uri, ActivityFlags.GrantWriteUriPermission | ActivityFlags.GrantReadUriPermission);
return Uri.ToString();
return StorageBookmarkHelper.EncodeBookmark(AndroidStorageProvider.AndroidKey, Uri.ToString()!);
}
public async Task ReleaseBookmarkAsync()

22
src/Android/Avalonia.Android/Platform/Storage/AndroidStorageProvider.cs

@ -1,12 +1,14 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Android;
using Android.App;
using Android.Content;
using Android.Provider;
using Avalonia.Platform.Storage;
using Avalonia.Platform.Storage.FileIO;
using AndroidUri = Android.Net.Uri;
using Exception = System.Exception;
using JavaFile = Java.IO.File;
@ -15,6 +17,7 @@ namespace Avalonia.Android.Platform.Storage;
internal class AndroidStorageProvider : IStorageProvider
{
public static ReadOnlySpan<byte> AndroidKey => "android"u8;
private readonly Activity _activity;
public AndroidStorageProvider(Activity activity)
@ -30,8 +33,8 @@ internal class AndroidStorageProvider : IStorageProvider
public Task<IStorageBookmarkFolder?> OpenFolderBookmarkAsync(string bookmark)
{
var uri = AndroidUri.Parse(bookmark) ?? throw new ArgumentException("Couldn't parse Bookmark value", nameof(bookmark));
return Task.FromResult<IStorageBookmarkFolder?>(new AndroidStorageFolder(_activity, uri, false));
var uri = DecodeUriFromBookmark(bookmark);
return Task.FromResult<IStorageBookmarkFolder?>(uri is null ? null : new AndroidStorageFolder(_activity, uri, false));
}
public async Task<IStorageFile?> TryGetFileFromPathAsync(Uri filePath)
@ -129,8 +132,19 @@ internal class AndroidStorageProvider : IStorageProvider
public Task<IStorageBookmarkFile?> OpenFileBookmarkAsync(string bookmark)
{
var uri = AndroidUri.Parse(bookmark) ?? throw new ArgumentException("Couldn't parse Bookmark value", nameof(bookmark));
return Task.FromResult<IStorageBookmarkFile?>(new AndroidStorageFile(_activity, uri));
var uri = DecodeUriFromBookmark(bookmark);
return Task.FromResult<IStorageBookmarkFile?>(uri is null ? null : new AndroidStorageFile(_activity, uri));
}
private static AndroidUri? DecodeUriFromBookmark(string bookmark)
{
return StorageBookmarkHelper.TryDecodeBookmark(AndroidKey, bookmark, out var bytes) switch
{
StorageBookmarkHelper.DecodeResult.Success => AndroidUri.Parse(Encoding.UTF8.GetString(bytes!)),
// Attempt to decode 11.0 android bookmarks
StorageBookmarkHelper.DecodeResult.InvalidFormat => AndroidUri.Parse(bookmark),
_ => null
};
}
public async Task<IReadOnlyList<IStorageFile>> OpenFilePickerAsync(FilePickerOpenOptions options)

113
src/Avalonia.Base/Platform/Storage/FileIO/BclStorageFile.cs

@ -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));
}

128
src/Avalonia.Base/Platform/Storage/FileIO/BclStorageFolder.cs

@ -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)));
}

141
src/Avalonia.Base/Platform/Storage/FileIO/BclStorageItem.cs

@ -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);
}

56
src/Avalonia.Base/Platform/Storage/FileIO/BclStorageProvider.cs

@ -4,6 +4,7 @@ using System.IO;
using System.Runtime.InteropServices;
using System.Threading.Tasks;
using Avalonia.Compatibility;
using Avalonia.Logging;
namespace Avalonia.Platform.Storage.FileIO;
@ -20,18 +21,12 @@ internal abstract class BclStorageProvider : IStorageProvider
public virtual Task<IStorageBookmarkFile?> OpenFileBookmarkAsync(string bookmark)
{
var file = new FileInfo(bookmark);
return file.Exists
? Task.FromResult<IStorageBookmarkFile?>(new BclStorageFile(file))
: Task.FromResult<IStorageBookmarkFile?>(null);
return Task.FromResult(OpenBookmark(bookmark) as IStorageBookmarkFile);
}
public virtual Task<IStorageBookmarkFolder?> OpenFolderBookmarkAsync(string bookmark)
{
var folder = new DirectoryInfo(bookmark);
return folder.Exists
? Task.FromResult<IStorageBookmarkFolder?>(new BclStorageFolder(folder))
: Task.FromResult<IStorageBookmarkFolder?>(null);
return Task.FromResult(OpenBookmark(bookmark) as IStorageBookmarkFolder);
}
public virtual Task<IStorageFile?> TryGetFileFromPathAsync(Uri filePath)
@ -63,6 +58,16 @@ internal abstract class BclStorageProvider : IStorageProvider
}
public virtual Task<IStorageFolder?> TryGetWellKnownFolderAsync(WellKnownFolder wellKnownFolder)
{
if (TryGetWellKnownFolderCore(wellKnownFolder) is { } directoryInfo)
{
return Task.FromResult<IStorageFolder?>(new BclStorageFolder(directoryInfo));
}
return Task.FromResult<IStorageFolder?>(null);
}
internal static DirectoryInfo? TryGetWellKnownFolderCore(WellKnownFolder wellKnownFolder)
{
// Note, this BCL API returns different values depending on the .NET version.
// We should also document it.
@ -82,16 +87,16 @@ internal abstract class BclStorageProvider : IStorageProvider
if (folderPath is null)
{
return Task.FromResult<IStorageFolder?>(null);
return null;
}
var directory = new DirectoryInfo(folderPath);
if (!directory.Exists)
{
return Task.FromResult<IStorageFolder?>(null);
return null;
}
return Task.FromResult<IStorageFolder?>(new BclStorageFolder(directory));
return directory;
string GetFromSpecialFolder(Environment.SpecialFolder folder) =>
Environment.GetFolderPath(folder, Environment.SpecialFolderOption.Create);
@ -104,7 +109,7 @@ internal abstract class BclStorageProvider : IStorageProvider
if (OperatingSystemEx.IsWindows())
{
return Environment.OSVersion.Version.Major < 6 ? null :
SHGetKnownFolderPath(s_folderDownloads, 0, IntPtr.Zero);
Marshal.PtrToStringUni(SHGetKnownFolderPath(s_folderDownloads, 0, IntPtr.Zero));
}
if (OperatingSystemEx.IsLinux())
@ -123,8 +128,27 @@ internal abstract class BclStorageProvider : IStorageProvider
return null;
}
private IStorageBookmarkItem? OpenBookmark(string bookmark)
{
try
{
if (StorageBookmarkHelper.TryDecodeBclBookmark(bookmark, out var localPath))
{
return StorageProviderHelpers.TryCreateBclStorageItem(localPath);
}
return null;
}
catch (Exception ex)
{
Logger.TryGet(LogEventLevel.Information, LogArea.Platform)?
.Log(this, "Unable to read file bookmark: {Exception}", ex);
return null;
}
}
private static readonly Guid s_folderDownloads = new Guid("374DE290-123F-4565-9164-39C4925E467B");
[DllImport("shell32.dll", CharSet = CharSet.Unicode, ExactSpelling = true, PreserveSig = false)]
private static extern string SHGetKnownFolderPath([MarshalAs(UnmanagedType.LPStruct)] Guid id, int flags, IntPtr token);
[DllImport("shell32.dll")]
private static extern IntPtr SHGetKnownFolderPath([MarshalAs(UnmanagedType.LPStruct)] Guid id, int flags, IntPtr token);
}

116
src/Avalonia.Base/Platform/Storage/FileIO/SecurityScopedStream.cs

@ -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
}

153
src/Avalonia.Base/Platform/Storage/FileIO/StorageBookmarkHelper.cs

@ -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;
}
}

30
src/Avalonia.Base/Platform/Storage/FileIO/StorageProviderHelpers.cs

@ -8,7 +8,7 @@ namespace Avalonia.Platform.Storage.FileIO;
internal static class StorageProviderHelpers
{
public static IStorageItem? TryCreateBclStorageItem(string path)
public static BclStorageItem? TryCreateBclStorageItem(string path)
{
if (!string.IsNullOrWhiteSpace(path))
{
@ -28,28 +28,36 @@ internal static class StorageProviderHelpers
return null;
}
public static Uri FilePathToUri(string path)
public static string? TryGetPathFromFileUri(Uri? uri)
{
// android "content:", browser and ios relative links are ignored.
return uri is { IsAbsoluteUri: true, Scheme: "file" } ? uri.LocalPath : null;
}
public static Uri UriFromFilePath(string path, bool isDirectory)
{
var uriPath = new StringBuilder(path)
.Replace("%", $"%{(int)'%':X2}")
.Replace("[", $"%{(int)'[':X2}")
.Replace("]", $"%{(int)']':X2}")
.ToString();
.Replace("]", $"%{(int)']':X2}");
if (!path.EndsWith('/') && isDirectory)
{
uriPath.Append('/');
}
return new UriBuilder("file", string.Empty) { Path = uriPath }.Uri;
return new UriBuilder("file", string.Empty) { Path = uriPath.ToString() }.Uri;
}
public static bool TryFilePathToUri(string path, [NotNullWhen(true)] out Uri? uri)
public static Uri? TryGetUriFromFilePath(string path, bool isDirectory)
{
try
{
uri = FilePathToUri(path);
return true;
return UriFromFilePath(path, isDirectory);
}
catch
{
uri = null;
return false;
return null;
}
}

8
src/Avalonia.Base/Platform/Storage/IStorageBookmarkItem.cs

@ -1,8 +1,14 @@
using System.Threading.Tasks;
using System.IO;
using System.Threading.Tasks;
using Avalonia.Metadata;
namespace Avalonia.Platform.Storage;
internal interface IStorageItemWithFileSystemInfo : IStorageItem
{
FileSystemInfo FileSystemInfo { get; }
}
[NotClientImplementable]
public interface IStorageBookmarkItem : IStorageItem
{

20
src/Avalonia.Base/Platform/Storage/StorageProviderExtensions.cs

@ -17,7 +17,7 @@ public static class StorageProviderExtensions
return Task.FromResult(StorageProviderHelpers.TryCreateBclStorageItem(filePath) as IStorageFile);
}
if (StorageProviderHelpers.TryFilePathToUri(filePath, out var uri))
if (StorageProviderHelpers.TryGetUriFromFilePath(filePath, false) is { } uri)
{
return provider.TryGetFileFromPathAsync(uri);
}
@ -34,7 +34,7 @@ public static class StorageProviderExtensions
return Task.FromResult(StorageProviderHelpers.TryCreateBclStorageItem(folderPath) as IStorageFolder);
}
if (StorageProviderHelpers.TryFilePathToUri(folderPath, out var uri))
if (StorageProviderHelpers.TryGetUriFromFilePath(folderPath, true) is { } uri)
{
return provider.TryGetFolderFromPathAsync(uri);
}
@ -56,21 +56,11 @@ public static class StorageProviderExtensions
{
// We can avoid double escaping of the path by checking for BclStorageFolder.
// Ideally, `folder.Path.LocalPath` should also work, as that's only available way for the users.
if (item is BclStorageFolder storageFolder)
if (item is IStorageItemWithFileSystemInfo storageItem)
{
return storageFolder.DirectoryInfo.FullName;
}
if (item is BclStorageFile storageFile)
{
return storageFile.FileInfo.FullName;
}
if (item.Path is { IsAbsoluteUri: true, Scheme: "file" } absolutePath)
{
return absolutePath.LocalPath;
return storageItem.FileSystemInfo.FullName;
}
// android "content:", browser and ios relative links go here.
return null;
return StorageProviderHelpers.TryGetPathFromFileUri(item.Path);
}
}

16
src/Avalonia.Diagnostics/Diagnostics/Conventions.cs

@ -1,21 +1,7 @@
using System;
using System.IO;
namespace Avalonia.Diagnostics
namespace Avalonia.Diagnostics
{
internal static class Conventions
{
public static string DefaultScreenshotsRoot
{
get
{
var dir = System.IO.Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.MyPictures), "Screenshots");
Directory.CreateDirectory(dir);
return dir;
}
}
public static IScreenshotHandler DefaultScreenshotHandler { get; } =
new Screenshots.FilePickerHandler();
}

15
src/Avalonia.Diagnostics/Diagnostics/Screenshots/FilePickerHandler.cs

@ -15,7 +15,7 @@ namespace Avalonia.Diagnostics.Screenshots
public sealed class FilePickerHandler : BaseRenderToStreamHandler
{
private readonly string _title;
private readonly string _screenshotRoot;
private readonly string? _screenshotRoot;
/// <summary>
/// Instance FilePickerHandler
@ -35,7 +35,7 @@ namespace Avalonia.Diagnostics.Screenshots
string? screenshotRoot = default)
{
_title = title ?? "Save Screenshot to ...";
_screenshotRoot = screenshotRoot ?? Conventions.DefaultScreenshotsRoot;
_screenshotRoot = screenshotRoot;
}
private static TopLevel GetTopLevel(Control control)
@ -54,8 +54,15 @@ namespace Avalonia.Diagnostics.Screenshots
protected override async Task<Stream?> GetStream(Control control)
{
var storageProvider = GetTopLevel(control).StorageProvider;
var defaultFolder = await storageProvider.TryGetFolderFromPathAsync(_screenshotRoot)
?? await storageProvider.TryGetWellKnownFolderAsync(WellKnownFolder.Pictures);
IStorageFolder? defaultFolder = null;
if (_screenshotRoot is not null)
{
defaultFolder = await storageProvider.TryGetFolderFromPathAsync(_screenshotRoot);
}
if (defaultFolder is null)
{
defaultFolder = await storageProvider.TryGetWellKnownFolderAsync(WellKnownFolder.Pictures);
}
var result = await storageProvider.SaveFilePickerAsync(new FilePickerSaveOptions
{

12
src/Avalonia.Native/AvaloniaNativeApplicationPlatform.cs

@ -2,6 +2,7 @@ using System;
using System.Collections.Generic;
using System.ComponentModel;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Controls.Platform;
using Avalonia.Native.Interop;
using Avalonia.Platform;
using Avalonia.Platform.Storage;
@ -17,13 +18,15 @@ namespace Avalonia.Native
{
((IApplicationPlatformEvents)Application.Current)?.RaiseUrlsOpened(urls.ToStringArray());
if (AvaloniaLocator.Current.GetService<IActivatableLifetime>() is ActivatableLifetimeBase lifetime)
if (AvaloniaLocator.Current.GetService<IActivatableLifetime>() is ActivatableLifetimeBase lifetime
&& AvaloniaLocator.Current.GetService<IStorageProviderFactory>() is StorageProviderApi storageApi)
{
var filePaths = urls.ToStringArray();
var files = new List<IStorageItem>(filePaths.Length);
foreach (var filePath in filePaths)
{
if (StorageProviderHelpers.TryCreateBclStorageItem(filePath) is { } file)
if (StorageProviderHelpers.TryGetUriFromFilePath(filePath, false) is { } fileUri
&& storageApi.TryGetStorageItem(fileUri) is { } file)
{
files.Add(file);
}
@ -41,7 +44,8 @@ namespace Avalonia.Native
// Raise the urls opened event to be compatible with legacy behavior.
((IApplicationPlatformEvents)Application.Current)?.RaiseUrlsOpened(urls.ToStringArray());
if (AvaloniaLocator.Current.GetService<IActivatableLifetime>() is ActivatableLifetimeBase lifetime)
if (AvaloniaLocator.Current.GetService<IActivatableLifetime>() is ActivatableLifetimeBase lifetime
&& AvaloniaLocator.Current.GetService<IStorageProviderFactory>() is StorageProviderApi storageApi)
{
var files = new List<IStorageItem>();
var uris = new List<Uri>();
@ -51,7 +55,7 @@ namespace Avalonia.Native
{
if (uri.Scheme == Uri.UriSchemeFile)
{
if (StorageProviderHelpers.TryCreateBclStorageItem(uri.LocalPath) is { } file)
if (storageApi.TryGetStorageItem(uri) is { } file)
{
files.Add(file);
}

6
src/Avalonia.Native/AvaloniaNativePlatform.cs

@ -107,8 +107,7 @@ namespace Avalonia.Native
}
AvaloniaLocator.CurrentMutable
.Bind<IDispatcherImpl>()
.ToConstant(new DispatcherImpl(_factory.CreatePlatformThreadingInterface()))
.Bind<IDispatcherImpl>().ToConstant(new DispatcherImpl(_factory.CreatePlatformThreadingInterface()))
.Bind<ICursorFactory>().ToConstant(new CursorFactory(_factory.CreateCursorFactory()))
.Bind<IScreenImpl>().ToConstant(new ScreenImpl(_factory.CreateScreens))
.Bind<IPlatformIconLoader>().ToSingleton<IconLoader>()
@ -121,7 +120,8 @@ namespace Avalonia.Native
.Bind<IPlatformDragSource>().ToConstant(new AvaloniaNativeDragSource(_factory))
.Bind<IPlatformLifetimeEventsImpl>().ToConstant(applicationPlatform)
.Bind<INativeApplicationCommands>().ToConstant(new MacOSNativeMenuCommands(_factory.CreateApplicationCommands()))
.Bind<IActivatableLifetime>().ToSingleton<MacOSActivatableLifetime>();
.Bind<IActivatableLifetime>().ToSingleton<MacOSActivatableLifetime>()
.Bind<IStorageProviderFactory>().ToConstant(new StorageProviderApi(_factory.CreateStorageProvider(), options.AppSandboxEnabled));
var hotkeys = new PlatformHotkeyConfiguration(KeyModifiers.Meta, wholeWordTextActionModifiers: KeyModifiers.Alt);
hotkeys.MoveCursorToTheStartOfLine.Add(new KeyGesture(Key.Left, hotkeys.CommandModifiers));

7
src/Avalonia.Native/AvaloniaNativePlatformExtensions.cs

@ -80,6 +80,13 @@ namespace Avalonia
/// and make your Avalonia app run with it. The default value is null.
/// </summary>
public string AvaloniaNativeLibraryPath { get; set; }
/// <summary>
/// If you distribute your app in App Store - it should be with sandbox enabled.
/// This parameter enables <see cref="Avalonia.Platform.Storage.IStorageItem.SaveBookmarkAsync"/> and related APIs,
/// as well as wrapping all storage related calls in secure context. The default value is true.
/// </summary>
public bool AppSandboxEnabled { get; set; } = true;
}
// ReSharper disable once InconsistentNaming

11
src/Avalonia.Native/ClipboardImpl.cs

@ -2,18 +2,21 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Avalonia.Controls.Platform;
using Avalonia.Input;
using Avalonia.Input.Platform;
using Avalonia.Logging;
using Avalonia.Native.Interop;
using Avalonia.Platform.Storage;
using Avalonia.Platform.Storage.FileIO;
using MicroCom.Runtime;
namespace Avalonia.Native
{
class ClipboardImpl : IClipboard, IDisposable
{
private IAvnClipboard _native;
// TODO hide native types behind IAvnClipboard abstraction, so managed side won't depend on macOS.
private const string NSPasteboardTypeString = "public.utf8-plain-text";
private const string NSFilenamesPboardType = "NSFilenamesPboardType";
@ -86,7 +89,13 @@ namespace Avalonia.Native
public IEnumerable<IStorageItem> GetFiles()
{
return GetFileNames()?.Select(f => StorageProviderHelpers.TryCreateBclStorageItem(f)!)
var storageApi = (StorageProviderApi)AvaloniaLocator.Current.GetRequiredService<IStorageProviderFactory>();
// TODO: use non-deprecated AppKit API to get NSUri instead of file names.
return GetFileNames()?
.Select(f => StorageProviderHelpers.TryGetUriFromFilePath(f, false) is { } uri
? storageApi.TryGetStorageItem(uri)
: null)
.Where(f => f is not null);
}

152
src/Avalonia.Native/StorageItem.cs

@ -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));
}
}

289
src/Avalonia.Native/StorageProviderApi.cs

@ -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() ?? []);
}
}
}
}

63
src/Avalonia.Native/StorageProviderImpl.cs

@ -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);
}
}

181
src/Avalonia.Native/SystemDialogs.cs

@ -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);
}
}
}

7
src/Avalonia.Native/TopLevelImpl.cs

@ -65,7 +65,6 @@ internal class TopLevelImpl : ITopLevelImpl, IFramebufferPlatformSurface
{
protected IInputRoot? _inputRoot;
private NativeControlHostImpl? _nativeControlHost;
private IStorageProvider? _storageProvider;
private PlatformBehaviorInhibition? _platformBehaviorInhibition;
private readonly MouseDevice? _mouse;
@ -98,7 +97,6 @@ internal class TopLevelImpl : ITopLevelImpl, IFramebufferPlatformSurface
_savedLogicalSize = ClientSize;
_savedScaling = RenderScaling;
_nativeControlHost = new NativeControlHostImpl(Native!.CreateNativeControlHost());
_storageProvider = new SystemDialogs(this, Factory.CreateSystemDialogs());
_platformBehaviorInhibition = new PlatformBehaviorInhibition(Factory.CreatePlatformBehaviorInhibition());
_surfaces = new object[] { new GlPlatformSurface(Native), new MetalPlatformSurface(Native), this };
InputMethod = new AvaloniaNativeTextInputMethod(Native);
@ -338,11 +336,6 @@ internal class TopLevelImpl : ITopLevelImpl, IFramebufferPlatformSurface
return _nativeControlHost;
}
if (featureType == typeof(IStorageProvider))
{
return _storageProvider;
}
if (featureType == typeof(IPlatformBehaviorInhibition))
{
return _platformBehaviorInhibition;

17
src/Avalonia.Native/avn.idl

@ -677,6 +677,7 @@ interface IAvaloniaNativeFactory : IUnknown
HRESULT CreateWindow(IAvnWindowEvents* cb, IAvnWindow** ppv);
HRESULT CreatePopup(IAvnWindowEvents* cb, IAvnPopup** ppv);
HRESULT CreatePlatformThreadingInterface(IAvnPlatformThreadingInterface** ppv);
HRESULT CreateStorageProvider(IAvnStorageProvider** ppv);
HRESULT CreateSystemDialogs(IAvnSystemDialogs** ppv);
HRESULT CreateScreens(IAvnScreenEvents* cb, IAvnScreens** ppv);
HRESULT CreateClipboard(IAvnClipboard** ppv);
@ -888,18 +889,18 @@ interface IAvnPlatformThreadingInterface : IUnknown
[uuid(6c621a6e-e4c1-4ae3-9749-83eeeffa09b6)]
interface IAvnSystemDialogEvents : IUnknown
{
void OnCompleted(int numResults, void* ptrFirstResult);
void OnCompleted(IAvnStringArray*array);
}
[uuid(4d7a47db-a944-4061-abe7-62cb6aa0ffd5)]
interface IAvnSystemDialogs : IUnknown
interface IAvnStorageProvider : IUnknown
{
void SelectFolderDialog(IAvnWindow* parentWindowHandle,
IAvnSystemDialogEvents* events,
bool allowMultiple,
[const] char* title,
[const] char* initialPath);
void OpenFileDialog(IAvnWindow* parentWindowHandle,
IAvnSystemDialogEvents* events,
bool allowMultiple,
@ -907,13 +908,21 @@ interface IAvnSystemDialogs : IUnknown
[const] char* initialDirectory,
[const] char* initialFile,
IAvnFilePickerFileTypes* filters);
void SaveFileDialog(IAvnWindow* parentWindowHandle,
IAvnSystemDialogEvents* events,
[const] char* title,
[const] char* initialDirectory,
[const] char* initialFile,
IAvnFilePickerFileTypes* filters);
HRESULT SaveBookmarkToBytes(IAvnString*fileUri, void**err, IAvnString**ppv);
HRESULT ReadBookmarkFromBytes(void* ptr, int len, IAvnString**ppv);
void ReleaseBookmark(IAvnString*fileUri);
bool OpenSecurityScope(IAvnString*fileUri);
void CloseSecurityScope(IAvnString*fileUri);
}
[uuid(4d7ab7db-a111-406f-abeb-11cb6aa033d5)]

38
src/Browser/Avalonia.Browser/Storage/BrowserStorageProvider.cs

@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices.JavaScript;
using System.Text;
using System.Threading.Tasks;
using Avalonia.Browser.Interop;
using Avalonia.Platform.Storage;
@ -12,6 +13,7 @@ namespace Avalonia.Browser.Storage;
internal class BrowserStorageProvider : IStorageProvider
{
internal static ReadOnlySpan<byte> BrowserBookmarkKey => "browser"u8;
internal const string PickerCancelMessage = "The user aborted a request";
internal const string NoPermissionsMessage = "Permissions denied";
@ -104,16 +106,12 @@ internal class BrowserStorageProvider : IStorageProvider
public async Task<IStorageBookmarkFile?> OpenFileBookmarkAsync(string bookmark)
{
await AvaloniaModule.ImportStorage();
var item = await StorageHelper.OpenBookmark(bookmark);
return item is not null ? new JSStorageFile(item) : null;
return await DecodeBookmark(bookmark) as IStorageBookmarkFile;
}
public async Task<IStorageBookmarkFolder?> OpenFolderBookmarkAsync(string bookmark)
{
await AvaloniaModule.ImportStorage();
var item = await StorageHelper.OpenBookmark(bookmark);
return item is not null ? new JSStorageFolder(item) : null;
return await DecodeBookmark(bookmark) as IStorageBookmarkFolder;
}
public Task<IStorageFile?> TryGetFileFromPathAsync(Uri filePath)
@ -158,6 +156,24 @@ internal class BrowserStorageProvider : IStorageProvider
return (types, !includeAll);
}
private async Task<IStorageBookmarkItem?> DecodeBookmark(string bookmark)
{
await AvaloniaModule.ImportStorage();
var item = StorageBookmarkHelper.TryDecodeBookmark(BrowserBookmarkKey, bookmark, out var bytes) switch
{
StorageBookmarkHelper.DecodeResult.Success => await StorageHelper.OpenBookmark(Encoding.UTF8.GetString(bytes!)),
// Attempt to decode 11.0 browser bookmarks
StorageBookmarkHelper.DecodeResult.InvalidFormat => await StorageHelper.OpenBookmark(bookmark),
_ => null
};
return item?.GetPropertyAsString("kind") switch
{
"directory" => new JSStorageFolder(item),
"file" => new JSStorageFile(item),
_ => null
};
}
}
internal abstract class JSStorageItem : IStorageBookmarkItem
@ -188,14 +204,16 @@ internal abstract class JSStorageItem : IStorageBookmarkItem
public bool CanBookmark => StorageHelper.HasNativeFilePicker();
public Task<string?> SaveBookmarkAsync()
public async Task<string?> SaveBookmarkAsync()
{
if (!CanBookmark)
{
return Task.FromResult<string?>(null);
return null;
}
return StorageHelper.SaveBookmark(FileHandle);
var nativeBookmark = await StorageHelper.SaveBookmark(FileHandle);
return nativeBookmark is null ? null
: StorageBookmarkHelper.EncodeBookmark(BrowserStorageProvider.BrowserBookmarkKey, nativeBookmark);
}
public Task<IStorageFolder?> GetParentAsync()

68
src/iOS/Avalonia.iOS/Storage/IOSSecurityScopedStream.cs

@ -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();
}
}
}

29
src/iOS/Avalonia.iOS/Storage/IOSStorageItem.cs

@ -5,6 +5,8 @@ using System.Linq;
using System.Threading.Tasks;
using Avalonia.Logging;
using Avalonia.Platform.Storage;
using Avalonia.Platform.Storage.FileIO;
using Avalonia.Reactive;
using Foundation;
using UIKit;
@ -132,7 +134,7 @@ internal abstract class IOSStorageItem : IStorageBookmarkItem
return Task.CompletedTask;
}
public Task<string?> SaveBookmarkAsync()
public unsafe Task<string?> SaveBookmarkAsync()
{
try
{
@ -141,7 +143,7 @@ internal abstract class IOSStorageItem : IStorageBookmarkItem
return Task.FromResult<string?>(null);
}
var newBookmark = Url.CreateBookmarkData(NSUrlBookmarkCreationOptions.SuitableForBookmarkFile, Array.Empty<string>(), null, out var bookmarkError);
using var newBookmark = Url.CreateBookmarkData(NSUrlBookmarkCreationOptions.SuitableForBookmarkFile, [], null, out var bookmarkError);
if (bookmarkError is not null)
{
Logger.TryGet(LogEventLevel.Error, LogArea.IOSPlatform)?.
@ -149,8 +151,9 @@ internal abstract class IOSStorageItem : IStorageBookmarkItem
return Task.FromResult<string?>(null);
}
var bytes = new Span<byte>((void*)newBookmark.Bytes, (int)newBookmark.Length);
return Task.FromResult<string?>(
newBookmark.GetBase64EncodedString(NSDataBase64EncodingOptions.None));
StorageBookmarkHelper.EncodeBookmark(IOSStorageProvider.PlatformKey, bytes));
}
finally
{
@ -171,12 +174,28 @@ internal sealed class IOSStorageFile : IOSStorageItem, IStorageBookmarkFile
public Task<Stream> OpenReadAsync()
{
return Task.FromResult<Stream>(new IOSSecurityScopedStream(Url, SecurityScopedAncestorUrl, FileAccess.Read));
return Task.FromResult(CreateStream(FileAccess.Read));
}
public Task<Stream> OpenWriteAsync()
{
return Task.FromResult<Stream>(new IOSSecurityScopedStream(Url, SecurityScopedAncestorUrl, FileAccess.Write));
return Task.FromResult(CreateStream(FileAccess.Write));
}
private Stream CreateStream(FileAccess fileAccess)
{
var document = new UIDocument(Url);
var path = document.FileUrl.Path!;
var scopeCreated = SecurityScopedAncestorUrl.StartAccessingSecurityScopedResource();
var stream = File.Open(path, FileMode.Open, fileAccess);
return scopeCreated ?
new SecurityScopedStream(stream, Disposable.Create(() =>
{
document.Dispose();
SecurityScopedAncestorUrl.StopAccessingSecurityScopedResource();
})) :
stream;
}
}

42
src/iOS/Avalonia.iOS/Storage/IOSStorageProvider.cs

@ -10,11 +10,14 @@ using UniformTypeIdentifiers;
using UTTypeLegacy = MobileCoreServices.UTType;
using UTType = UniformTypeIdentifiers.UTType;
using System.Runtime.Versioning;
using Avalonia.Platform.Storage.FileIO;
namespace Avalonia.iOS.Storage;
internal class IOSStorageProvider : IStorageProvider
{
public static ReadOnlySpan<byte> PlatformKey => "ios"u8;
private readonly AvaloniaView _view;
public IOSStorageProvider(AvaloniaView view)
{
@ -217,20 +220,41 @@ internal class IOSStorageProvider : IStorageProvider
return tcs.Task;
}
private NSUrl? GetBookmarkedUrl(string bookmark)
private unsafe NSUrl? GetBookmarkedUrl(string bookmark)
{
var url = NSUrl.FromBookmarkData(new NSData(bookmark, NSDataBase64DecodingOptions.None),
NSUrlBookmarkResolutionOptions.WithoutUI, null, out var isStale, out var error);
if (isStale)
return StorageBookmarkHelper.TryDecodeBookmark(PlatformKey, bookmark, out var bytes) switch
{
StorageBookmarkHelper.DecodeResult.Success => DecodeFromBytes(bytes!),
// Attempt to decode 11.0 ios bookmarks
StorageBookmarkHelper.DecodeResult.InvalidFormat => DecodeFromNSData(new NSData(bookmark, NSDataBase64DecodingOptions.None)),
_ => null
};
NSUrl DecodeFromBytes(byte[] bytes)
{
Logger.TryGet(LogEventLevel.Warning, LogArea.IOSPlatform)?.Log(this, "Stale bookmark detected");
fixed (byte* ptr = bytes)
{
using var data = new NSData(new IntPtr(ptr), new UIntPtr((uint)bytes.Length), null);
return DecodeFromNSData(data);
}
}
if (error != null)
NSUrl DecodeFromNSData(NSData nsData)
{
throw new NSErrorException(error);
var url = NSUrl.FromBookmarkData(nsData,
NSUrlBookmarkResolutionOptions.WithoutUI, null, out var isStale, out var error);
if (isStale)
{
Logger.TryGet(LogEventLevel.Warning, LogArea.IOSPlatform)?.Log(this, "Stale bookmark detected");
}
if (error != null)
{
throw new NSErrorException(error);
}
return url;
}
return url;
}
private static string[] FileTypesToUTTypeLegacy(IReadOnlyList<FilePickerFileType>? filePickerFileTypes)

2
tests/Avalonia.Base.UnitTests/Utilities/UriExtensionsTests.cs

@ -36,7 +36,7 @@ public class UriExtensionsTests
[InlineData("C:\\\\Work\\Projects.txt")]
public void Should_Convert_File_Path_To_Uri_And_Back(string path)
{
var uri = StorageProviderHelpers.FilePathToUri(path);
var uri = StorageProviderHelpers.UriFromFilePath(path, false);
Assert.Equal(path, uri.LocalPath);
}

74
tests/Avalonia.Controls.UnitTests/Platform/StorageProviderHelperTests.cs

@ -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…
Cancel
Save