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
Max Katz 1 year ago
parent
commit
32c2f08200
34 changed files with 1370 additions and 649 deletions
  1. 4 4
      native/Avalonia.Native/src/OSX/Avalonia.Native.OSX.xcodeproj/project.pbxproj
  2. 95 40
      native/Avalonia.Native/src/OSX/StorageProvider.mm
  3. 1 1
      native/Avalonia.Native/src/OSX/common.h
  4. 2 2
      native/Avalonia.Native/src/OSX/main.mm
  5. 34 16
      samples/ControlCatalog/Pages/DialogsPage.xaml.cs
  6. 3 1
      src/Android/Avalonia.Android/Platform/Storage/AndroidStorageItem.cs
  7. 18 4
      src/Android/Avalonia.Android/Platform/Storage/AndroidStorageProvider.cs
  8. 4 109
      src/Avalonia.Base/Platform/Storage/FileIO/BclStorageFile.cs
  9. 11 117
      src/Avalonia.Base/Platform/Storage/FileIO/BclStorageFolder.cs
  10. 141 0
      src/Avalonia.Base/Platform/Storage/FileIO/BclStorageItem.cs
  11. 40 16
      src/Avalonia.Base/Platform/Storage/FileIO/BclStorageProvider.cs
  12. 116 0
      src/Avalonia.Base/Platform/Storage/FileIO/SecurityScopedStream.cs
  13. 153 0
      src/Avalonia.Base/Platform/Storage/FileIO/StorageBookmarkHelper.cs
  14. 19 11
      src/Avalonia.Base/Platform/Storage/FileIO/StorageProviderHelpers.cs
  15. 7 1
      src/Avalonia.Base/Platform/Storage/IStorageBookmarkItem.cs
  16. 5 15
      src/Avalonia.Base/Platform/Storage/StorageProviderExtensions.cs
  17. 1 15
      src/Avalonia.Diagnostics/Diagnostics/Conventions.cs
  18. 11 4
      src/Avalonia.Diagnostics/Diagnostics/Screenshots/FilePickerHandler.cs
  19. 8 4
      src/Avalonia.Native/AvaloniaNativeApplicationPlatform.cs
  20. 3 3
      src/Avalonia.Native/AvaloniaNativePlatform.cs
  21. 7 0
      src/Avalonia.Native/AvaloniaNativePlatformExtensions.cs
  22. 10 1
      src/Avalonia.Native/ClipboardImpl.cs
  23. 152 0
      src/Avalonia.Native/StorageItem.cs
  24. 289 0
      src/Avalonia.Native/StorageProviderApi.cs
  25. 63 0
      src/Avalonia.Native/StorageProviderImpl.cs
  26. 0 181
      src/Avalonia.Native/SystemDialogs.cs
  27. 0 7
      src/Avalonia.Native/TopLevelImpl.cs
  28. 13 4
      src/Avalonia.Native/avn.idl
  29. 28 10
      src/Browser/Avalonia.Browser/Storage/BrowserStorageProvider.cs
  30. 0 68
      src/iOS/Avalonia.iOS/Storage/IOSSecurityScopedStream.cs
  31. 24 5
      src/iOS/Avalonia.iOS/Storage/IOSStorageItem.cs
  32. 33 9
      src/iOS/Avalonia.iOS/Storage/IOSStorageProvider.cs
  33. 1 1
      tests/Avalonia.Base.UnitTests/Utilities/UriExtensionsTests.cs
  34. 74 0
      tests/Avalonia.Controls.UnitTests/Platform/StorageProviderHelperTests.cs

+ 4 - 4
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 */,

+ 95 - 40
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();
 }

+ 1 - 1
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*);

+ 2 - 2
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;
         }
     }

+ 34 - 16
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();

+ 3 - 1
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()

+ 18 - 4
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)

+ 4 - 109
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));
 }

+ 11 - 117
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 - 0
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);
+}

+ 40 - 16
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 - 0
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 - 0
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;
+    }
+}

+ 19 - 11
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;
         }
     }
     

+ 7 - 1
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
 {

+ 5 - 15
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);
     }
 }

+ 1 - 15
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();
     }

+ 11 - 4
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
             {

+ 8 - 4
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);
                             }

+ 3 - 3
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 - 0
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

+ 10 - 1
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 - 0
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 - 0
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 - 0
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);
+    }
+}

+ 0 - 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);
-        }
-    }
-}

+ 0 - 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;

+ 13 - 4
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)]

+ 28 - 10
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()

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

+ 24 - 5
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;
     }
 }
 

+ 33 - 9
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)

+ 1 - 1
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 - 0
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);
+    }
+}