Browse Source

MacOS file type filter in native file dialog (#12899)

* Introduce IFilePickerFileTypes to have more control over file types in the native backend

* Update samples page for dialogs

* Rename to IAvnFilePickerFileTypes

* WIP

* Fix disabled popup

* Explicitly dispose AvnString and AvnStringArray + GetNSArrayOfStringsAndRelease

* Fix potential crash

---------

Co-authored-by: Jumar Macato <[email protected]>
Max Katz 2 years ago
parent
commit
cd4bf7a02b

+ 2 - 0
native/Avalonia.Native/src/OSX/AvnString.h

@@ -15,4 +15,6 @@ extern IAvnStringArray* CreateAvnStringArray(NSArray<NSURL*>* array);
 extern IAvnStringArray* CreateAvnStringArray(NSString* string);
 extern IAvnString* CreateByteArray(void* data, int len);
 extern NSString* GetNSStringAndRelease(IAvnString* s);
+extern NSString* GetNSStringWithoutRelease(IAvnString* s);
+extern NSArray<NSString*>* GetNSArrayOfStringsAndRelease(IAvnStringArray* array);
 #endif /* AvnString_h */

+ 32 - 0
native/Avalonia.Native/src/OSX/AvnString.mm

@@ -169,3 +169,35 @@ NSString* GetNSStringAndRelease(IAvnString* s)
     
     return result;
 }
+
+NSString* GetNSStringWithoutRelease(IAvnString* s)
+{
+    NSString* result = nil;
+    
+    if (s != nullptr)
+    {
+        char* p;
+        if (s->Pointer((void**)&p) == S_OK && p != nullptr)
+            result = [NSString stringWithUTF8String:p];
+    }
+    
+    return result;
+}
+
+NSArray<NSString*>* GetNSArrayOfStringsAndRelease(IAvnStringArray* array)
+{
+    auto output = [NSMutableArray array];
+    if (array)
+    {
+        IAvnString* arrayItem;
+        for (int i = 0; i < array->GetCount(); i++)
+        {
+            if (array->Get(i, &arrayItem) == 0) {
+                NSString* ext = GetNSStringAndRelease(arrayItem);
+                [output addObject:ext];
+            }
+        }
+        array->Release();
+    }
+    return output;
+}

+ 277 - 62
native/Avalonia.Native/src/OSX/SystemDialogs.mm

@@ -1,9 +1,73 @@
 #include "common.h"
+#include "AvnString.h"
 #include "INSWindowHolder.h"
 #import <UniformTypeIdentifiers/UniformTypeIdentifiers.h>
 
+const int kFileTypePopupTag = 10975;
+
+// Target for NSPopupButton control in file dialog's accessory view.
+// ExtensionDropdownHandler is copied from Chromium MIT code of select_file_dialog_bridge
+@interface ExtensionDropdownHandler : NSObject {
+ @private
+  // The file dialog to which this target object corresponds. Weak reference
+  // since the dialog_ will stay alive longer than this object.
+  NSSavePanel* _dialog;
+
+  // Two ivars serving the same purpose. While `_fileTypeLists` is for pre-macOS
+  // 11, and contains NSStrings with UTType identifiers, `_fileUTTypeLists` is
+  // for macOS 11 and later, and contains UTTypes.
+  NSArray<NSArray<NSString*>*>* __strong _fileTypeLists;
+  NSArray<NSArray<UTType*>*>* __strong _fileUTTypeLists
+      API_AVAILABLE(macos(11.0));
+}
+
+- (instancetype)initWithDialog:(NSSavePanel*)dialog
+                 fileTypeLists:(NSArray<NSArray<NSString*>*>*)fileTypeLists;
+
+- (instancetype)initWithDialog:(NSSavePanel*)dialog
+               fileUTTypeLists:(NSArray<NSArray<UTType*>*>*)fileUTTypeLists
+    API_AVAILABLE(macos(11.0));
+
+- (void)popupAction:(id)sender;
+@end
+
+
+@implementation ExtensionDropdownHandler
+
+- (instancetype)initWithDialog:(NSSavePanel*)dialog
+                 fileTypeLists:(NSArray<NSArray<NSString*>*>*)fileTypeLists {
+  if ((self = [super init])) {
+    _dialog = dialog;
+    _fileTypeLists = fileTypeLists;
+  }
+  return self;
+}
+
+- (instancetype)initWithDialog:(NSSavePanel*)dialog
+               fileUTTypeLists:(NSArray<NSArray<UTType*>*>*)fileUTTypeLists
+    API_AVAILABLE(macos(11.0)) {
+  if ((self = [super init])) {
+    _dialog = dialog;
+    _fileUTTypeLists = fileUTTypeLists;
+  }
+  return self;
+}
+
+- (void)popupAction:(id)sender {
+  NSUInteger index = [sender indexOfSelectedItem];
+  if (@available(macOS 11, *)) {
+      _dialog.allowedContentTypes = [_fileUTTypeLists objectAtIndex:index];
+  } else {
+      _dialog.allowedFileTypes = [_fileTypeLists objectAtIndex:index];
+  }
+}
+
+@end
+
 class SystemDialogs : public ComSingleObject<IAvnSystemDialogs, &IID_IAvnSystemDialogs>
 {
+    ExtensionDropdownHandler* __strong _extension_dropdown_handler;
+    
 public:
     FORWARD_IUNKNOWN()
     virtual void SelectFolderDialog (IAvnWindow* parentWindowHandle,
@@ -88,7 +152,7 @@ public:
                                  const char* title,
                                  const char* initialDirectory,
                                  const char* initialFile,
-                                 const char* filters) override
+                                 IAvnFilePickerFileTypes* filters) override
     {
         @autoreleasepool
         {
@@ -113,25 +177,7 @@ public:
                 panel.nameFieldStringValue = [NSString stringWithUTF8String:initialFile];
             }
             
-            if(filters != nullptr)
-            {
-                auto filtersString = [NSString stringWithUTF8String:filters];
-                
-                if(filtersString.length > 0)
-                {
-                    auto allowedTypes = [filtersString componentsSeparatedByString:@";"];
-                    
-                    // Prefer allowedContentTypes if available
-                    if (@available(macOS 11.0, *))
-                    {
-                        panel.allowedContentTypes = ConvertToUTType(allowedTypes);
-                    }
-                    else
-                    {
-                        panel.allowedFileTypes = allowedTypes;
-                    }
-                }
-            }
+            SetAccessoryView(panel, filters, false);
             
             auto handler = ^(NSModalResponse result) {
                 if(result == NSFileHandlingPanelOKButton)
@@ -187,7 +233,7 @@ public:
                                  const char* title,
                                  const char* initialDirectory,
                                  const char* initialFile,
-                                 const char* filters) override
+                                 IAvnFilePickerFileTypes* filters) override
     {
         @autoreleasepool
         {
@@ -210,28 +256,7 @@ public:
                 panel.nameFieldStringValue = [NSString stringWithUTF8String:initialFile];
             }
             
-            if(filters != nullptr)
-            {
-                auto filtersString = [NSString stringWithUTF8String:filters];
-                
-                if(filtersString.length > 0)
-                {
-                    auto allowedTypes = [filtersString componentsSeparatedByString:@";"];
-                    
-                    // Prefer allowedContentTypes if available
-                    if (@available(macOS 11.0, *))
-                    {
-                        panel.allowedContentTypes = ConvertToUTType(allowedTypes);
-                    }
-                    else
-                    {
-                        panel.allowedFileTypes = allowedTypes;
-                    }
-                    
-                    panel.allowsOtherFileTypes = false;
-                    panel.extensionHidden = false;
-                }
-            }
+            SetAccessoryView(panel, filters, true);
             
             auto handler = ^(NSModalResponse result) {
                 if(result == NSFileHandlingPanelOKButton)
@@ -240,9 +265,9 @@ public:
                     
                     auto url = [panel URL];
                     
-                    auto string = [url path];   
+                    auto string = [url path];
                     strings[0] = (void*)[string UTF8String];
-               
+                    
                     events->OnCompleted(1, &strings[0]);
                     
                     [panel orderOut:panel];
@@ -274,31 +299,221 @@ public:
     }
     
 private:
-    NSMutableArray* ConvertToUTType(NSArray<NSString*>* allowedTypes)
+    NSView* CreateAccessoryView() {
+        // The label. Add attributes per-OS to match the labels that macOS uses.
+        NSTextField* label = [NSTextField labelWithString:@"File format"];
+        label.translatesAutoresizingMaskIntoConstraints = NO;
+        label.textColor = NSColor.secondaryLabelColor;
+        if (@available(macOS 11.0, *)) {
+            label.font = [NSFont systemFontOfSize:[NSFont smallSystemFontSize]];
+        }
+        
+        // The popup.
+        NSPopUpButton* popup = [[NSPopUpButton alloc] initWithFrame:NSZeroRect
+                                                          pullsDown:NO];
+        popup.translatesAutoresizingMaskIntoConstraints = NO;
+        popup.tag = kFileTypePopupTag;
+        [popup setAutoenablesItems:NO];
+        
+        // A view to group the label and popup together. The top-level view used as
+        // the accessory view will be stretched horizontally to match the width of
+        // the dialog, and the label and popup need to be grouped together as one
+        // view to do centering within it, so use a view to group the label and
+        // popup.
+        NSView* group = [[NSView alloc] initWithFrame:NSZeroRect];
+        group.translatesAutoresizingMaskIntoConstraints = NO;
+        [group addSubview:label];
+        [group addSubview:popup];
+        
+        // This top-level view will be forced by the system to have the width of the
+        // save dialog.
+        NSView* view = [[NSView alloc] initWithFrame:NSZeroRect];
+        view.translatesAutoresizingMaskIntoConstraints = NO;
+        [view addSubview:group];
+        
+        NSMutableArray* constraints = [NSMutableArray array];
+        
+        // The required constraints for the group, instantiated top-to-bottom:
+        // ┌───────────────────┐
+        // │             ↕︎     │
+        // │ ↔︎ label ↔︎ popup ↔︎ │
+        // │             ↕︎     │
+        // └───────────────────┘
+        
+        // Top.
+        [constraints
+         addObject:[popup.topAnchor constraintEqualToAnchor:group.topAnchor
+                                                   constant:10]];
+        
+        // Leading.
+        [constraints
+         addObject:[label.leadingAnchor constraintEqualToAnchor:group.leadingAnchor
+                                                       constant:10]];
+        
+        // Horizontal and vertical baseline between the label and popup.
+        CGFloat labelPopupPadding;
+        if (@available(macOS 11.0, *)) {
+            labelPopupPadding = 8;
+        } else {
+            labelPopupPadding = 5;
+        }
+        [constraints addObject:[popup.leadingAnchor
+                                constraintEqualToAnchor:label.trailingAnchor
+                                constant:labelPopupPadding]];
+        [constraints
+         addObject:[popup.firstBaselineAnchor
+                    constraintEqualToAnchor:label.firstBaselineAnchor]];
+        
+        // Trailing.
+        [constraints addObject:[group.trailingAnchor
+                                constraintEqualToAnchor:popup.trailingAnchor
+                                constant:10]];
+        
+        // Bottom.
+        [constraints
+         addObject:[group.bottomAnchor constraintEqualToAnchor:popup.bottomAnchor
+                                                      constant:10]];
+        
+        // Then the constraints centering the group in the accessory view. Vertical
+        // spacing is fully specified, but as the horizontal size of the accessory
+        // view will be forced to conform to the save dialog, only specify horizontal
+        // centering.
+        // ┌──────────────┐
+        // │      ↕︎       │
+        // │   ↔group↔︎    │
+        // │      ↕︎       │
+        // └──────────────┘
+        
+        // Top.
+        [constraints
+         addObject:[group.topAnchor constraintEqualToAnchor:view.topAnchor]];
+        
+        // Centering.
+        [constraints addObject:[group.centerXAnchor
+                                constraintEqualToAnchor:view.centerXAnchor]];
+        
+        // Bottom.
+        [constraints
+         addObject:[view.bottomAnchor constraintEqualToAnchor:group.bottomAnchor]];
+        
+        [NSLayoutConstraint activateConstraints:constraints];
+        
+        return view;
+    }
+    
+    void SetAccessoryView(NSSavePanel* panel,
+                          IAvnFilePickerFileTypes* filters,
+                          bool is_save_panel)
     {
-        auto originalCount = [allowedTypes count];
-        auto mapped = [[NSMutableArray alloc] init];
+        NSView* accessory_view = CreateAccessoryView();
+        
+        NSPopUpButton* popup = [accessory_view viewWithTag:kFileTypePopupTag];
         
-        if (@available(macOS 11.0, *))
+        NSMutableArray<NSArray<NSString*>*>* file_type_lists = [NSMutableArray array];
+        NSMutableArray* file_uttype_lists = [NSMutableArray array];
+        int default_extension_index = -1;
+        
+        for (int i = 0; i < filters->GetCount(); i++)
         {
-            for (int i = 0; i < originalCount; i++)
-            {
-                auto utTypeStr = allowedTypes[i];
-                auto utType = [UTType typeWithIdentifier:utTypeStr];
-                if (utType == nil)
-                {
-                    utType = [UTType typeWithMIMEType:utTypeStr];
+            NSString* type_description = GetNSStringAndRelease(filters->GetName(i));
+            [popup addItemWithTitle:type_description];
+
+            // If any type is included, enable allowsOtherFileTypes, and skip this filter on save panel.
+            if (filters->IsAnyType(i)) {
+                panel.allowsOtherFileTypes = YES;
+            }
+            // If default extension is specified, auto select it later.
+            if (filters->IsDefaultType(i)) {
+                default_extension_index = i;
+            }
+
+            IAvnStringArray* array;
+
+            // Prefer types priority of: file ext -> apple type id -> mime.
+            // On macOS 10 we only support file extensions.
+            if (@available(macOS 11, *)) {
+                NSMutableArray* file_uttype_array = [NSMutableArray array];
+                bool typeCompleted = false;
+
+                if (filters->IsAnyType(i)) {
+                    UTType* type = [UTType typeWithIdentifier:@"public.item"];
+                    [file_uttype_array addObject:type];
+                    typeCompleted = true;
                 }
-                if (utType != nil)
-                {
-                    [mapped addObject:utType];
+                if (!typeCompleted && filters->GetExtensions(i, &array) == 0) {
+                    for (NSString* ext in GetNSArrayOfStringsAndRelease(array))
+                    {
+                        UTType* type = [UTType typeWithFilenameExtension:ext];
+                        if (type && ![file_uttype_array containsObject:type]) {
+                            [file_uttype_array addObject:type];
+                            typeCompleted = true;
+                        }
+                    }
+                }
+                if (!typeCompleted && filters->GetAppleUniformTypeIdentifiers(i, &array) == 0) {
+                    for (NSString* ext in GetNSArrayOfStringsAndRelease(array))
+                    {
+                        UTType* type = [UTType typeWithIdentifier:ext];
+                        if (type && ![file_uttype_array containsObject:type]) {
+                            [file_uttype_array addObject:type];
+                            typeCompleted = true;
+                        }
+                    }
+                }
+                if (!typeCompleted && filters->GetMimeTypes(i, &array) == 0) {
+                    for (NSString* ext in GetNSArrayOfStringsAndRelease(array))
+                    {
+                        UTType* type = [UTType typeWithMIMEType:ext];
+                        if (type && ![file_uttype_array containsObject:type]) {
+                            [file_uttype_array addObject:type];
+                            typeCompleted = true;
+                        }
+                    }
                 }
+                
+                [file_uttype_lists addObject:file_uttype_array];
+            } else {
+                NSMutableArray<NSString*>* file_type_array = [NSMutableArray array];
+                if (filters->IsAnyType(i)) {
+                    [file_type_array addObject:@"*.*"];
+                }
+                else if (filters->GetExtensions(i, &array) == 0) {
+                    for (NSString* ext in GetNSArrayOfStringsAndRelease(array))
+                    {
+                        if (![file_type_array containsObject:ext]) {
+                            [file_type_array addObject:ext];
+                        }
+                    }
+                }
+                [file_type_lists addObject:file_type_array];
             }
         }
+        
+        if ([file_uttype_lists count] == 0 && [file_type_lists count] == 0)
+            return;
 
-        return mapped;
-    }
-
+        if (@available(macOS 11, *))
+            _extension_dropdown_handler = [[ExtensionDropdownHandler alloc] initWithDialog:panel
+                                                                          fileUTTypeLists:file_uttype_lists];
+        else
+            _extension_dropdown_handler = [[ExtensionDropdownHandler alloc] initWithDialog:panel
+                                                                            fileTypeLists:file_type_lists];
+        
+        [popup setTarget: _extension_dropdown_handler];
+        [popup setAction: @selector(popupAction:)];
+        
+        if (default_extension_index != -1) {
+            [popup selectItemAtIndex:default_extension_index];
+        } else {
+            // Select the first item.
+            [popup selectItemAtIndex:0];
+        }
+        [_extension_dropdown_handler popupAction:popup];
+        
+        if (popup.numberOfItems > 0) {
+            panel.accessoryView = accessory_view;
+        }
+    };
 };
 
 extern IAvnSystemDialogs* CreateSystemDialogs()

+ 9 - 1
samples/ControlCatalog/Pages/DialogsPage.xaml

@@ -22,7 +22,15 @@
 
     <TextBlock Margin="0,20,0,0" Text="Pickers:" />
 
-    <CheckBox Name="UseFilters">Use filters</CheckBox>
+    <Label Target="FilterSelector" Content="Filter" />
+    <ComboBox Name="FilterSelector" SelectedIndex="0">
+      <ComboBoxItem>None</ComboBoxItem>
+      <ComboBoxItem>All + TXT + BinLog</ComboBoxItem>
+      <ComboBoxItem>Binlog</ComboBoxItem>
+      <ComboBoxItem>TXT extension only</ComboBoxItem>
+      <ComboBoxItem>TXT mime only</ComboBoxItem>
+      <ComboBoxItem>TXT apple type id only</ComboBoxItem>
+    </ComboBox>
     <Expander Header="FilePicker API">
       <StackPanel Spacing="4">
         <CheckBox Name="ForceManaged">Force managed dialog</CheckBox>

+ 39 - 27
samples/ControlCatalog/Pages/DialogsPage.xaml.cs

@@ -59,37 +59,49 @@ namespace ControlCatalog.Pages
 
             List<FileDialogFilter> GetFilters()
             {
-                if (this.Get<CheckBox>("UseFilters").IsChecked != true)
-                    return new List<FileDialogFilter>();
-                return new List<FileDialogFilter>
-                            {
-                                new FileDialogFilter
-                                {
-                                    Name = "Text files (.txt)", Extensions = new List<string> {"txt"}
-                                },
-                                new FileDialogFilter
-                                {
-                                    Name = "All files",
-                                    Extensions = new List<string> {"*"}
-                                }
-                            };
+                return GetFileTypes()?.Select(f => new FileDialogFilter
+                {
+                    Name = f.Name, Extensions = f.Patterns!.ToList()
+                }).ToList() ?? new List<FileDialogFilter>();
             }
 
             List<FilePickerFileType>? GetFileTypes()
             {
-                if (this.Get<CheckBox>("UseFilters").IsChecked != true)
-                    return null;
-                return new List<FilePickerFileType>
-                            {
-                                FilePickerFileTypes.All,
-                                FilePickerFileTypes.TextPlain,
-                                new("Binary Log")
-                                {
-                                    Patterns = new[] { "*.binlog", "*.buildlog" },
-                                    MimeTypes = new[] { "application/binlog", "application/buildlog" },
-                                    AppleUniformTypeIdentifiers = new []{ "public.data" }
-                                }
-                            };
+                var selectedItem = (this.Get<ComboBox>("FilterSelector").SelectedItem as ComboBoxItem)?.Content
+                    ?? "None";
+
+                var binLogType = new FilePickerFileType("Binary Log")
+                {
+                    Patterns = new[] { "*.binlog", "*.buildlog" },
+                    MimeTypes = new[] { "application/binlog", "application/buildlog" },
+                    AppleUniformTypeIdentifiers = new[] { "public.data" }
+                };
+
+                return selectedItem switch
+                {
+                    "All + TXT + BinLog" => new List<FilePickerFileType>
+                    {
+                        FilePickerFileTypes.All, FilePickerFileTypes.TextPlain, binLogType
+                    },
+                    "Binlog" => new List<FilePickerFileType> { binLogType },
+                    "TXT extension only" => new List<FilePickerFileType>
+                    {
+                        new("TXT") { Patterns = FilePickerFileTypes.TextPlain.Patterns }
+                    },
+                    "TXT mime only" => new List<FilePickerFileType>
+                    {
+                        new("TXT") { MimeTypes = FilePickerFileTypes.TextPlain.MimeTypes }
+                    },
+                    "TXT apple type id only" => new List<FilePickerFileType>
+                    {
+                        new("TXT")
+                        {
+                            AppleUniformTypeIdentifiers =
+                                FilePickerFileTypes.TextPlain.AppleUniformTypeIdentifiers
+                        }
+                    },
+                    _ => null
+                };
             }
 
             this.Get<Button>("OpenFile").Click += async delegate

+ 2 - 1
src/Avalonia.Base/Platform/Storage/FilePickerFileType.cs

@@ -47,10 +47,11 @@ public sealed class FilePickerFileType
     internal IReadOnlyList<string>? TryGetExtensions()
     {
         // Converts random glob pattern to a simple extension name.
-        // GetExtension should be sufficient here.
+        // Path.GetExtension should be sufficient here,
         // Only exception is "*.*proj" patterns that should be filtered as well.
         return Patterns?.Select(Path.GetExtension)
             .Where(e => !string.IsNullOrEmpty(e) && !e.Contains('*') && e.StartsWith("."))
+            .Select(e => e!.TrimStart('.'))
             .ToArray()!;
     }
 }

+ 1 - 0
src/Avalonia.Base/Platform/Storage/FilePickerFileTypes.cs

@@ -8,6 +8,7 @@ public static class FilePickerFileTypes
     public static FilePickerFileType All { get; } = new("All")
     {
         Patterns = new[] { "*.*" },
+        AppleUniformTypeIdentifiers = new[] { "public.item" },
         MimeTypes = new[] { "*/*" }
     };
 

+ 26 - 1
src/Avalonia.Native/AvnString.cs

@@ -1,4 +1,6 @@
 using System;
+using System.Collections.Generic;
+using System.Linq;
 using System.Runtime.InteropServices;
 using System.Text;
 
@@ -15,7 +17,7 @@ namespace Avalonia.Native.Interop
         string[] ToStringArray();
     }
 
-    internal class AvnString : NativeCallbackBase, IAvnString
+    internal sealed class AvnString : NativeCallbackBase, IAvnString
     {
         private IntPtr _native;
         private int _nativeLen;
@@ -61,6 +63,29 @@ namespace Avalonia.Native.Interop
             }
         }
     }
+
+    internal sealed class AvnStringArray : NativeCallbackBase, IAvnStringArray
+    {
+        private readonly IAvnString[] _items;
+
+        public AvnStringArray(IEnumerable<string> items)
+        {
+            _items = items.Select(s => s.ToAvnString()).ToArray();
+        }
+
+        public string[] ToStringArray() => _items.Select(n => n.String).ToArray();
+
+        public uint Count => (uint)_items.Length;
+        public IAvnString Get(uint index) => _items[(int)index];
+
+        protected override void Destroyed()
+        {
+            foreach (var item in _items)
+            {
+                item.Dispose();
+            }
+        }
+    }
 }
 namespace Avalonia.Native.Interop.Impl
 {

+ 65 - 20
src/Avalonia.Native/SystemDialogs.cs

@@ -32,6 +32,7 @@ namespace Avalonia.Native
         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;
 
@@ -41,7 +42,7 @@ namespace Avalonia.Native
                                     options.Title ?? string.Empty,
                                     suggestedDirectory,
                                     string.Empty,
-                                    PrepareFilterParameter(options.FileTypeFilter));
+                                    fileTypes);
 
             var result = await events.Task.ConfigureAwait(false);
 
@@ -52,6 +53,7 @@ namespace Avalonia.Native
         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;
 
@@ -60,7 +62,7 @@ namespace Avalonia.Native
                         options.Title ?? string.Empty,
                         suggestedDirectory,
                         options.SuggestedFileName ?? string.Empty,
-                        PrepareFilterParameter(options.FileTypeChoices));
+                        fileTypes);
 
             var result = await events.Task.ConfigureAwait(false);
             return result.FirstOrDefault() is string file
@@ -80,26 +82,69 @@ namespace Avalonia.Native
             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;
 
-        private static string PrepareFilterParameter(IReadOnlyList<FilePickerFileType>? fileTypes)
+        public FilePickerFileTypesWrapper(
+            IReadOnlyList<FilePickerFileType>? types,
+            string? defaultExtension)
         {
-            return string.Join(";",
-                fileTypes?.SelectMany(f =>
-                {
-                    // On the native side we will try to parse identifiers or mimetypes.
-                    if (f.AppleUniformTypeIdentifiers?.Any() == true)
-                    {
-                        return f.AppleUniformTypeIdentifiers;
-                    }
-                    else if (f.MimeTypes?.Any() == true)
-                    {
-                        // MacOS doesn't accept "all" type, so it's pointless to pass it.
-                        return f.MimeTypes.Where(t => t != "*/*");
-                    }
-
-                    return Array.Empty<string>();
-                }) ??
-                Array.Empty<string>());
+            _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;
         }
     }
 

+ 15 - 2
src/Avalonia.Native/avn.idl

@@ -871,14 +871,27 @@ interface IAvnSystemDialogs : IUnknown
                                  [const] char* title,
                                  [const] char* initialDirectory,
                                  [const] char* initialFile,
-                                 [const] char* filters);
+                                 IAvnFilePickerFileTypes* filters);
     
      void SaveFileDialog(IAvnWindow* parentWindowHandle,
                                  IAvnSystemDialogEvents* events,
                                  [const] char* title,
                                  [const] char* initialDirectory,
                                  [const] char* initialFile,
-                                 [const] char* filters);
+                                 IAvnFilePickerFileTypes* filters);
+}
+
+[uuid(4d7ab7db-a111-406f-abeb-11cb6aa033d5)]
+interface IAvnFilePickerFileTypes : IUnknown
+{
+    int GetCount(); 
+    bool IsDefaultType(int index);
+    bool IsAnyType(int index);
+    IAvnString* GetName(int index);
+    HRESULT GetPatterns(int index, IAvnStringArray**ppv);
+    HRESULT GetExtensions(int index, IAvnStringArray**ppv);
+    HRESULT GetMimeTypes(int index, IAvnStringArray**ppv);
+    HRESULT GetAppleUniformTypeIdentifiers(int index, IAvnStringArray**ppv);
 }
 
 [uuid(9a52bc7a-d8c7-4230-8d34-704a0b70a933)]

+ 1 - 1
src/Browser/Avalonia.Browser/Storage/BrowserStorageProvider.cs

@@ -147,7 +147,7 @@ internal class BrowserStorageProvider : IStorageProvider
     {
         var types = input?
             .Where(t => t.MimeTypes?.Any() == true && t != FilePickerFileTypes.All)
-            .Select(t => StorageHelper.CreateAcceptType(t.Name, t.MimeTypes!.ToArray(), t.TryGetExtensions()?.ToArray()))
+            .Select(t => StorageHelper.CreateAcceptType(t.Name, t.MimeTypes!.ToArray(), t.TryGetExtensions()?.Select(e => "." + e).ToArray()))
             .ToArray();
         if (types?.Length == 0)
         {

+ 5 - 5
src/iOS/Avalonia.iOS/Storage/IOSStorageProvider.cs

@@ -39,17 +39,17 @@ internal class IOSStorageProvider : IStorageProvider
                 {
                     // We check for OS version outside of the lambda, it's safe.
 #pragma warning disable CA1416
-                    if (f.AppleUniformTypeIdentifiers?.Any() == true)
+                    if (f.TryGetExtensions() is { } extensions && extensions.Any())
                     {
-                        return f.AppleUniformTypeIdentifiers.Select(id => UTType.CreateFromIdentifier(id));
+                        return extensions.Select(UTType.CreateFromExtension);
                     }
-                    if (f.TryGetExtensions() is { } extensions && extensions.Any())
+                    if (f.AppleUniformTypeIdentifiers?.Any() == true)
                     {
-                        return extensions.Select(id => UTType.CreateFromExtension(id.TrimStart('.')));
+                        return f.AppleUniformTypeIdentifiers.Select(UTType.CreateFromIdentifier);
                     }
                     if (f.MimeTypes?.Any() == true)
                     {
-                        return f.MimeTypes.Select(id => UTType.CreateFromMimeType(id));
+                        return f.MimeTypes.Select(UTType.CreateFromMimeType);
                     }
                     return Array.Empty<UTType>();
 #pragma warning restore CA1416