diff --git a/src/Files.App/Data/Contracts/ICommonDialogService.cs b/src/Files.App/Data/Contracts/ICommonDialogService.cs
index b0be310f1a14..cdd2f9490289 100644
--- a/src/Files.App/Data/Contracts/ICommonDialogService.cs
+++ b/src/Files.App/Data/Contracts/ICommonDialogService.cs
@@ -35,6 +35,24 @@ public interface ICommonDialogService
/// True if the 'Open' button was clicked; otherwise, false.
bool Open_FileSaveDialog(nint hWnd, bool pickFoldersOnly, string[] filters, Environment.SpecialFolder defaultFolder, out string filePath);
+ ///
+ /// Asynchronous ; implementations that can should avoid blocking the calling thread.
+ ///
+ Task<(bool Result, string FilePath)> OpenFileOpenDialogAsync(nint hWnd, bool pickFoldersOnly, string[] filters, Environment.SpecialFolder defaultFolder, Guid? clientGuid = null)
+ {
+ var result = Open_FileOpenDialog(hWnd, pickFoldersOnly, filters, defaultFolder, out var filePath, clientGuid);
+ return Task.FromResult((result, filePath));
+ }
+
+ ///
+ /// Asynchronous ; implementations that can should avoid blocking the calling thread.
+ ///
+ Task<(bool Result, string FilePath)> OpenFileSaveDialogAsync(nint hWnd, bool pickFoldersOnly, string[] filters, Environment.SpecialFolder defaultFolder)
+ {
+ var result = Open_FileSaveDialog(hWnd, pickFoldersOnly, filters, defaultFolder, out var filePath);
+ return Task.FromResult((result, filePath));
+ }
+
///
/// Opens a common dialog called NetworkConnectionDialog through native Win32API.
///
diff --git a/src/Files.App/Platforms/Desktop/Services/DesktopStubServices.cs b/src/Files.App/Platforms/Desktop/Services/DesktopStubServices.cs
index 1664d6f0ddab..827c4ccd818e 100644
--- a/src/Files.App/Platforms/Desktop/Services/DesktopStubServices.cs
+++ b/src/Files.App/Platforms/Desktop/Services/DesktopStubServices.cs
@@ -1,7 +1,10 @@
// Copyright (c) Files Community
// Licensed under the MIT License.
+using Files.Platform.Abstractions.FileChooser;
+using Microsoft.Extensions.Logging;
using System.Collections.Specialized;
+using System.Threading;
namespace Files.App.Services.Desktop
{
@@ -59,22 +62,74 @@ internal sealed class DesktopJumpListService : IWindowsJumpListService
public Task> GetFoldersAsync() => Task.FromResult>([]);
}
- // LINUX-TODO(pickers): open file/folder choosers through the xdg-desktop-portal FileChooser; until then pickers report "cancelled"
+ // Prefer the Async methods: the synchronous ones block the calling thread while the chooser is open.
internal sealed class DesktopCommonDialogService : ICommonDialogService
{
+ private readonly IFileChooserService _fileChooser;
+ private int _unavailableLogged;
+
+ public DesktopCommonDialogService(IFileChooserService fileChooser)
+ {
+ _fileChooser = fileChooser;
+ }
+
public bool Open_FileOpenDialog(nint hWnd, bool pickFoldersOnly, string[] filters, Environment.SpecialFolder defaultFolder, out string filePath, Guid? clientGuid = null)
{
- filePath = string.Empty;
- return false;
+ // Synchronous fallback for callers that cannot await; it blocks the calling thread
+ var (result, path) = Task.Run(() => OpenFileOpenDialogAsync(hWnd, pickFoldersOnly, filters, defaultFolder, clientGuid)).GetAwaiter().GetResult();
+ filePath = path;
+ return result;
}
public bool Open_FileSaveDialog(nint hWnd, bool pickFoldersOnly, string[] filters, Environment.SpecialFolder defaultFolder, out string filePath)
{
- filePath = string.Empty;
- return false;
+ var (result, path) = Task.Run(() => OpenFileSaveDialogAsync(hWnd, pickFoldersOnly, filters, defaultFolder)).GetAwaiter().GetResult();
+ filePath = path;
+ return result;
+ }
+
+ public Task<(bool Result, string FilePath)> OpenFileOpenDialogAsync(nint hWnd, bool pickFoldersOnly, string[] filters, Environment.SpecialFolder defaultFolder, Guid? clientGuid = null)
+ => ChooseAsync(new FileChooserRequest { PickFolder = pickFoldersOnly, Filters = ParseFilters(filters), ParentWindowId = (ulong)hWnd });
+
+ public Task<(bool Result, string FilePath)> OpenFileSaveDialogAsync(nint hWnd, bool pickFoldersOnly, string[] filters, Environment.SpecialFolder defaultFolder)
+ {
+ var folder = Environment.GetFolderPath(defaultFolder);
+ return ChooseAsync(new FileChooserRequest
+ {
+ Save = true,
+ PickFolder = pickFoldersOnly,
+ Filters = ParseFilters(filters),
+ CurrentFolder = string.IsNullOrEmpty(folder) ? null : folder,
+ ParentWindowId = (ulong)hWnd,
+ });
}
public bool Open_NetworkConnectionDialog(nint hWnd, bool hideRestoreConnectionCheckBox = false, bool persistConnectionAtLogon = false, bool readOnlyPath = false, string? remoteNetworkName = null, bool useMostRecentPath = false) => false;
+
+ private async Task<(bool Result, string FilePath)> ChooseAsync(FileChooserRequest request)
+ {
+ var result = await _fileChooser.ChooseAsync(request).ConfigureAwait(true);
+ if (result.Status == FileChooserStatus.Unavailable && Interlocked.Exchange(ref _unavailableLogged, 1) == 0)
+ App.Logger.LogWarning("The xdg-desktop-portal FileChooser is not available; file pickers will report cancelled.");
+
+ return result.Status == FileChooserStatus.Selected && result.Paths.Count > 0
+ ? (true, result.Paths[0])
+ : (false, string.Empty);
+ }
+
+ // Filters come as [name, "*.a;*.b", name, ...] pairs
+ private static List ParseFilters(string[] filters)
+ {
+ var list = new List();
+ for (var i = 0; i + 1 < filters.Length; i += 2)
+ {
+ var patterns = filters[i + 1].Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+ if (patterns.Length > 0)
+ list.Add(new FileChooserFilter(filters[i], patterns));
+ }
+
+ return list;
+ }
}
// Linux has no Start Menu pins
diff --git a/src/Files.App/ViewModels/Dialogs/DecompressArchiveDialogViewModel.cs b/src/Files.App/ViewModels/Dialogs/DecompressArchiveDialogViewModel.cs
index b5705d9988b4..2141b5a1a12c 100644
--- a/src/Files.App/ViewModels/Dialogs/DecompressArchiveDialogViewModel.cs
+++ b/src/Files.App/ViewModels/Dialogs/DecompressArchiveDialogViewModel.cs
@@ -136,12 +136,11 @@ private string DefaultDestinationFolderPath()
return string.IsNullOrEmpty(directory) ? fileName : Path.Combine(directory, fileName);
}
- private Task SelectDestinationAsync()
+ private async Task SelectDestinationAsync()
{
- bool result = CommonDialogService.Open_FileOpenDialog(MainWindow.Instance.WindowHandle, true, [], Environment.SpecialFolder.Desktop, out var filePath);
+ var (result, filePath) = await CommonDialogService.OpenFileOpenDialogAsync(MainWindow.Instance.WindowHandle, true, [], Environment.SpecialFolder.Desktop);
if (result)
DestinationFolderPath = filePath;
- return Task.CompletedTask;
}
private void RefreshEncodingOptions()
diff --git a/src/Files.App/ViewModels/Properties/BasePropertiesPage.cs b/src/Files.App/ViewModels/Properties/BasePropertiesPage.cs
index 5f3e06d68f4c..7f6c8b270d6a 100644
--- a/src/Files.App/ViewModels/Properties/BasePropertiesPage.cs
+++ b/src/Files.App/ViewModels/Properties/BasePropertiesPage.cs
@@ -111,7 +111,7 @@ protected override void OnNavigatedTo(NavigationEventArgs e)
"PNG", "*.png",
];
- var result = CommonDialogService.Open_FileOpenDialog(hWnd, false, extensions, Environment.SpecialFolder.Desktop, out var filePath);
+ var (result, filePath) = await CommonDialogService.OpenFileOpenDialogAsync(hWnd, false, extensions, Environment.SpecialFolder.Desktop);
if (result)
{
ViewModel.IsAblumCoverModified = true;
diff --git a/src/Files.App/ViewModels/Properties/HashesViewModel.cs b/src/Files.App/ViewModels/Properties/HashesViewModel.cs
index d6ef66e08e85..27e404c451f1 100644
--- a/src/Files.App/ViewModels/Properties/HashesViewModel.cs
+++ b/src/Files.App/ViewModels/Properties/HashesViewModel.cs
@@ -195,12 +195,7 @@ private async Task OnCompareFileAsync()
{
var hWnd = Microsoft.UI.Win32Interop.GetWindowFromWindowId(_appWindow.Id);
- var result = CommonDialogService.Open_FileOpenDialog(
- hWnd,
- false,
- [],
- Environment.SpecialFolder.Desktop,
- out var filePath);
+ var (result, filePath) = await CommonDialogService.OpenFileOpenDialogAsync(hWnd, false, [], Environment.SpecialFolder.Desktop);
HashInput = result && filePath != null
? await CalculateFileHashAsync(filePath)
diff --git a/src/Files.App/ViewModels/Settings/AdvancedViewModel.cs b/src/Files.App/ViewModels/Settings/AdvancedViewModel.cs
index 4e9893468a3a..9d4e135e5daa 100644
--- a/src/Files.App/ViewModels/Settings/AdvancedViewModel.cs
+++ b/src/Files.App/ViewModels/Settings/AdvancedViewModel.cs
@@ -165,7 +165,7 @@ private async Task SetAsOpenFileDialogAsync()
private async Task ImportSettingsAsync()
{
string[] extensions = [Strings.ZipFileCapitalized.GetLocalizedResource(), "*.zip"];
- bool result = CommonDialogService.Open_FileOpenDialog(MainWindow.Instance.WindowHandle, false, extensions, Environment.SpecialFolder.Desktop, out var filePath);
+ var (result, filePath) = await CommonDialogService.OpenFileOpenDialogAsync(MainWindow.Instance.WindowHandle, false, extensions, Environment.SpecialFolder.Desktop);
if (!result)
return;
@@ -212,7 +212,7 @@ private async Task ImportSettingsAsync()
private async Task ExportSettingsAsync()
{
string[] extensions = [Strings.ZipFileCapitalized.GetLocalizedResource(), "*.zip"];
- bool result = CommonDialogService.Open_FileSaveDialog(MainWindow.Instance.WindowHandle, false, extensions, Environment.SpecialFolder.Desktop, out var filePath);
+ var (result, filePath) = await CommonDialogService.OpenFileSaveDialogAsync(MainWindow.Instance.WindowHandle, false, extensions, Environment.SpecialFolder.Desktop);
if (!result)
return;
diff --git a/src/Files.App/ViewModels/Settings/AppearanceViewModel.cs b/src/Files.App/ViewModels/Settings/AppearanceViewModel.cs
index c16209aa5277..b352f5fffef5 100644
--- a/src/Files.App/ViewModels/Settings/AppearanceViewModel.cs
+++ b/src/Files.App/ViewModels/Settings/AppearanceViewModel.cs
@@ -96,7 +96,7 @@ public AppearanceViewModel(IUserSettingsService userSettingsService, IResourcesS
LoadAppThemeFontFamilyOptions();
- SelectImageCommand = new RelayCommand(SelectBackgroundImage);
+ SelectImageCommand = new AsyncRelayCommand(SelectBackgroundImageAsync);
RemoveImageCommand = new RelayCommand(RemoveBackgroundImage);
CustomizeToolbarCommand = new AsyncRelayCommand(() => CommandManager.CustomizeToolbar.ExecuteAsync());
}
@@ -104,7 +104,7 @@ public AppearanceViewModel(IUserSettingsService userSettingsService, IResourcesS
///
/// Opens a file picker to select a background image
///
- private void SelectBackgroundImage()
+ private async Task SelectBackgroundImageAsync()
{
string[] extensions =
[
@@ -118,7 +118,7 @@ private void SelectBackgroundImage()
"WEBP", "*.webp",
];
- var result = CommonDialogService.Open_FileOpenDialog(MainWindow.Instance.WindowHandle, false, extensions, Environment.SpecialFolder.MyPictures, out var filePath, _backgroundImagePickerClientGuid);
+ var (result, filePath) = await CommonDialogService.OpenFileOpenDialogAsync(MainWindow.Instance.WindowHandle, false, extensions, Environment.SpecialFolder.MyPictures, _backgroundImagePickerClientGuid);
if (result)
AppThemeBackgroundImageSource = filePath;
}
diff --git a/src/Files.App/ViewModels/Settings/GeneralViewModel.cs b/src/Files.App/ViewModels/Settings/GeneralViewModel.cs
index f009ef3ac4bc..2f58a8538561 100644
--- a/src/Files.App/ViewModels/Settings/GeneralViewModel.cs
+++ b/src/Files.App/ViewModels/Settings/GeneralViewModel.cs
@@ -18,7 +18,7 @@ public sealed partial class GeneralViewModel : ObservableObject, IDisposable
private ReadOnlyCollection? addFlyoutItemsSource;
- public RelayCommand ChangePageCommand { get; }
+ public AsyncRelayCommand ChangePageCommand { get; }
public RelayCommand RemovePageCommand { get; }
public RelayCommand AddPageCommand { get; }
public RelayCommand RestartCommand { get; }
@@ -86,7 +86,7 @@ public int SelectedAppLanguageIndex
public GeneralViewModel()
{
- ChangePageCommand = new RelayCommand(ChangePageAsync);
+ ChangePageCommand = new AsyncRelayCommand(ChangePageAsync);
RemovePageCommand = new RelayCommand(RemovePage);
AddPageCommand = new RelayCommand(async (path) => await AddPageAsync(path));
RestartCommand = new RelayCommand(DoRestartAsync);
@@ -346,9 +346,9 @@ public int SelectedTabScrollDirectionIndex
}
}
- private void ChangePageAsync()
+ private async Task ChangePageAsync()
{
- var result = CommonDialogService.Open_FileOpenDialog(MainWindow.Instance.WindowHandle, true, [], Environment.SpecialFolder.Desktop, out var filePath);
+ var (result, filePath) = await CommonDialogService.OpenFileOpenDialogAsync(MainWindow.Instance.WindowHandle, true, [], Environment.SpecialFolder.Desktop);
if (result && SelectedPageIndex >= 0)
PagesOnStartupList[SelectedPageIndex] = new PageOnStartupViewModel(filePath);
}
@@ -363,7 +363,7 @@ private async Task AddPageAsync(string? path = null)
{
if (string.IsNullOrWhiteSpace(path))
{
- bool result = CommonDialogService.Open_FileOpenDialog(MainWindow.Instance.WindowHandle, true, [], Environment.SpecialFolder.Desktop, out var filePath);
+ var (result, filePath) = await CommonDialogService.OpenFileOpenDialogAsync(MainWindow.Instance.WindowHandle, true, [], Environment.SpecialFolder.Desktop);
if (!result)
return;
diff --git a/src/Files.App/Views/Properties/LibraryPage.xaml.cs b/src/Files.App/Views/Properties/LibraryPage.xaml.cs
index 738bb0f963e2..e44f0faba191 100644
--- a/src/Files.App/Views/Properties/LibraryPage.xaml.cs
+++ b/src/Files.App/Views/Properties/LibraryPage.xaml.cs
@@ -91,7 +91,7 @@ protected override void Properties_Loaded(object sender, RoutedEventArgs e)
private async Task AddLocationAsync()
{
- var result = CommonDialogService.Open_FileOpenDialog(MainWindow.Instance.WindowHandle, true, [], Environment.SpecialFolder.Desktop, out var filePath);
+ var (result, filePath) = await CommonDialogService.OpenFileOpenDialogAsync(MainWindow.Instance.WindowHandle, true, [], Environment.SpecialFolder.Desktop);
if (!result)
return;
diff --git a/src/Files.Platform.Abstractions/FileChooser/IFileChooserService.cs b/src/Files.Platform.Abstractions/FileChooser/IFileChooserService.cs
new file mode 100644
index 000000000000..213aa64fef95
--- /dev/null
+++ b/src/Files.Platform.Abstractions/FileChooser/IFileChooserService.cs
@@ -0,0 +1,76 @@
+// Copyright (c) Files Community
+// Licensed under the MIT License.
+
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Files.Platform.Abstractions.FileChooser
+{
+ ///
+ /// One entry of the file type list. Each pattern is a glob such as *.png.
+ ///
+ public sealed record FileChooserFilter(string Name, IReadOnlyList Patterns);
+
+ ///
+ /// What to ask the user for.
+ ///
+ public sealed record FileChooserRequest
+ {
+ /// Pick a folder instead of a file.
+ public bool PickFolder { get; init; }
+
+ /// Ask for a destination to write to instead of existing items.
+ public bool Save { get; init; }
+
+ /// Allow several selections (open only).
+ public bool Multiple { get; init; }
+
+ /// The dialog title; the desktop picks a default when empty.
+ public string Title { get; init; } = string.Empty;
+
+ public IReadOnlyList Filters { get; init; } = [];
+
+ /// The suggested file name (save only).
+ public string? CurrentName { get; init; }
+
+ /// The folder to start in (save only; the portal ignores it for open).
+ public string? CurrentFolder { get; init; }
+
+ /// The X11 window id of the parent window, or zero when unknown.
+ public ulong ParentWindowId { get; init; }
+ }
+
+ ///
+ /// How the request ended.
+ ///
+ public enum FileChooserStatus
+ {
+ /// The user chose something.
+ Selected,
+
+ /// The user dismissed the dialog or it failed.
+ Cancelled,
+
+ /// No file chooser is available on this desktop.
+ Unavailable,
+ }
+
+ ///
+ /// The outcome of a chooser request; are absolute local paths.
+ ///
+ public sealed record FileChooserResult(FileChooserStatus Status, IReadOnlyList Paths)
+ {
+ public static FileChooserResult Cancelled { get; } = new(FileChooserStatus.Cancelled, []);
+
+ public static FileChooserResult Unavailable { get; } = new(FileChooserStatus.Unavailable, []);
+ }
+
+ ///
+ /// Shows the desktop's own file and folder chooser.
+ ///
+ public interface IFileChooserService
+ {
+ Task ChooseAsync(FileChooserRequest request, CancellationToken cancellationToken = default);
+ }
+}
diff --git a/src/Files.Platform.Linux/FileChooser/PortalFileChooserService.cs b/src/Files.Platform.Linux/FileChooser/PortalFileChooserService.cs
new file mode 100644
index 000000000000..0eddc1075d22
--- /dev/null
+++ b/src/Files.Platform.Linux/FileChooser/PortalFileChooserService.cs
@@ -0,0 +1,303 @@
+// Copyright (c) Files Community
+// Licensed under the MIT License.
+
+using Files.Platform.Abstractions.FileChooser;
+using Files.Platform.Linux.DBus;
+using System;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.IO;
+using System.Globalization;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using Tmds.DBus.Protocol;
+
+namespace Files.Platform.Linux.FileChooser
+{
+ ///
+ /// Shows the chooser through org.freedesktop.portal.FileChooser (xdg-desktop-portal).
+ /// Without the portal every request reports .
+ ///
+ public sealed class PortalFileChooserService : IFileChooserService, IDisposable
+ {
+ private const string Service = "org.freedesktop.portal.Desktop";
+ private const string ObjectPath = "/org/freedesktop/portal/desktop";
+ private const string FileChooserInterface = "org.freedesktop.portal.FileChooser";
+ private const string RequestInterface = "org.freedesktop.portal.Request";
+ private static readonly TimeSpan CallTimeout = TimeSpan.FromSeconds(5);
+ private static readonly TimeSpan ChooseTimeout = TimeSpan.FromHours(1);
+
+ private readonly string? busAddress;
+ private readonly SemaphoreSlim gate = new(1, 1);
+ private readonly ConcurrentDictionary> pending = new();
+ private DBusConnection? connection;
+ private IDisposable? responseSubscription;
+
+ ///
+ /// Creates the service. null means the user's session bus.
+ ///
+ public PortalFileChooserService(string? busAddress = null)
+ {
+ this.busAddress = busAddress;
+ }
+
+ ///
+ public async Task ChooseAsync(FileChooserRequest request, CancellationToken cancellationToken = default)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ string? handlePath = null;
+ DBusConnection? bus = null;
+ try
+ {
+ bus = await GetConnectionAsync().ConfigureAwait(false);
+ if (bus is null)
+ return FileChooserResult.Unavailable;
+
+ var token = "files" + Guid.NewGuid().ToString("N");
+ var response = new TaskCompletionSource<(uint Code, string[] Uris)>(TaskCreationOptions.RunContinuationsAsynchronously);
+ pending[token] = response;
+
+ try
+ {
+ var parent = request.ParentWindowId == 0 ? string.Empty : "x11:" + request.ParentWindowId.ToString("x", CultureInfo.InvariantCulture);
+ MessageBuffer message;
+ var writer = bus.GetMessageWriter();
+ try
+ {
+ writer.WriteMethodCallHeader(Service, ObjectPath, FileChooserInterface, request.Save && !request.PickFolder ? "SaveFile" : "OpenFile", "ssa{sv}");
+ writer.WriteString(parent);
+ writer.WriteString(request.Title ?? string.Empty);
+ WriteOptions(ref writer, request, token);
+ message = writer.CreateMessage();
+ }
+ finally
+ {
+ writer.Dispose();
+ }
+
+ handlePath = await bus.CallMethodAsync(message, static (Message m, object? _) => m.GetBodyReader().ReadObjectPathAsString(), null).WaitAsync(CallTimeout, cancellationToken).ConfigureAwait(false);
+
+ // Older portals may not honor handle_token; the returned handle is authoritative
+ var returnedToken = handlePath[(handlePath.LastIndexOf('/') + 1)..];
+ if (returnedToken.Length > 0 && returnedToken != token)
+ pending[returnedToken] = response;
+
+ var (code, uris) = await response.Task.WaitAsync(ChooseTimeout, cancellationToken).ConfigureAwait(false);
+ if (code != 0)
+ return FileChooserResult.Cancelled;
+
+ var paths = new List();
+ foreach (var uri in uris)
+ {
+ if (TryGetLocalPath(uri, out var path))
+ paths.Add(path);
+ }
+
+ return paths.Count == 0 ? FileChooserResult.Cancelled : new FileChooserResult(FileChooserStatus.Selected, paths);
+ }
+ finally
+ {
+ pending.TryRemove(token, out _);
+ if (handlePath is not null)
+ pending.TryRemove(handlePath[(handlePath.LastIndexOf('/') + 1)..], out _);
+
+ // An unanswered request means the dialog may still be open; ask the portal to close it
+ if (handlePath is not null && !response.Task.IsCompleted)
+ CloseRequest(bus, handlePath);
+ }
+ }
+ catch (Exception ex) when (IsBackendFailure(ex))
+ {
+ return ex is DBusErrorReplyException dbus && dbus.ErrorName is "org.freedesktop.DBus.Error.ServiceUnknown" or "org.freedesktop.DBus.Error.UnknownMethod" or "org.freedesktop.DBus.Error.UnknownObject"
+ ? FileChooserResult.Unavailable
+ : FileChooserResult.Cancelled;
+ }
+ }
+
+ private static void CloseRequest(DBusConnection bus, string handlePath)
+ {
+ try
+ {
+ using var writer = bus.GetMessageWriter();
+ writer.WriteMethodCallHeader(Service, handlePath, RequestInterface, "Close");
+ bus.TrySendMessage(writer.CreateMessage());
+ }
+ catch (Exception ex) when (IsBackendFailure(ex))
+ {
+ }
+ }
+
+ private static void WriteOptions(ref MessageWriter writer, FileChooserRequest request, string token)
+ {
+ var start = writer.WriteDictionaryStart();
+
+ writer.WriteDictionaryEntryStart();
+ writer.WriteString("handle_token");
+ writer.WriteVariantString(token);
+
+ if (!request.Save)
+ {
+ writer.WriteDictionaryEntryStart();
+ writer.WriteString("multiple");
+ writer.WriteVariantBool(request.Multiple && !request.PickFolder);
+ }
+
+ if (request.PickFolder)
+ {
+ writer.WriteDictionaryEntryStart();
+ writer.WriteString("directory");
+ writer.WriteVariantBool(true);
+ }
+ else
+ {
+ if (request.Filters.Count > 0)
+ {
+ writer.WriteDictionaryEntryStart();
+ writer.WriteString("filters");
+ writer.WriteSignature("a(sa(us))");
+ var filters = writer.WriteArrayStart(DBusType.Struct);
+ foreach (var filter in request.Filters)
+ {
+ writer.WriteStructureStart();
+ writer.WriteString(filter.Name);
+ var globs = writer.WriteArrayStart(DBusType.Struct);
+ foreach (var pattern in filter.Patterns)
+ {
+ writer.WriteStructureStart();
+ writer.WriteUInt32(0);
+ writer.WriteString(pattern);
+ }
+
+ writer.WriteArrayEnd(globs);
+ }
+
+ writer.WriteArrayEnd(filters);
+ }
+
+ if (request.Save && !string.IsNullOrEmpty(request.CurrentName))
+ {
+ writer.WriteDictionaryEntryStart();
+ writer.WriteString("current_name");
+ writer.WriteVariantString(request.CurrentName);
+ }
+
+ if (request.Save && !string.IsNullOrEmpty(request.CurrentFolder) && !request.CurrentFolder.Contains('\0'))
+ {
+ writer.WriteDictionaryEntryStart();
+ writer.WriteString("current_folder");
+ writer.WriteSignature("ay");
+ writer.WriteArray(Encoding.UTF8.GetBytes(request.CurrentFolder + "\0"));
+ }
+ }
+
+ writer.WriteDictionaryEnd(start);
+ }
+
+ ///
+ /// Decodes a file:// URI (empty or localhost authority) to an absolute path; anything else is rejected.
+ ///
+ public static bool TryGetLocalPath(string uri, out string path)
+ {
+ path = string.Empty;
+ const string Prefix = "file://";
+ if (uri is null || !uri.StartsWith(Prefix, StringComparison.OrdinalIgnoreCase))
+ return false;
+
+ var rest = uri[Prefix.Length..];
+ var slash = rest.IndexOf('/');
+ if (slash < 0)
+ return false;
+
+ var authority = rest[..slash];
+ if (authority.Length != 0 && !authority.Equals("localhost", StringComparison.OrdinalIgnoreCase))
+ return false;
+
+ var encoded = rest[slash..];
+ var cut = encoded.IndexOfAny(['?', '#']);
+ if (cut >= 0)
+ encoded = encoded[..cut];
+
+ string decoded;
+ try
+ {
+ decoded = Uri.UnescapeDataString(encoded);
+ }
+ catch (UriFormatException)
+ {
+ return false;
+ }
+
+ if (decoded.Contains('\0'))
+ return false;
+
+ path = decoded;
+ return true;
+ }
+
+ private static bool IsBackendFailure(Exception ex)
+ => ex is TimeoutException or DBusExceptionBase or ObjectDisposedException or InvalidOperationException or IOException or UnauthorizedAccessException;
+
+ private async Task GetConnectionAsync()
+ {
+ await gate.WaitAsync().ConfigureAwait(false);
+ try
+ {
+ if (connection is not null)
+ return connection;
+
+ var bus = await DBusSession.TryConnectAsync(busAddress, CallTimeout).ConfigureAwait(false);
+ if (bus is null)
+ return null;
+
+ var rule = new MatchRule
+ {
+ Type = MessageType.Signal,
+ Sender = Service,
+ Interface = RequestInterface,
+ Member = "Response",
+ };
+
+ responseSubscription = await bus.AddMatchAsync(rule, static (Message m, object? _) =>
+ {
+ var reader = m.GetBodyReader();
+ var code = reader.ReadUInt32();
+ var uris = Array.Empty();
+ var results = reader.ReadDictionaryOfStringToVariantValue();
+ if (results.TryGetValue("uris", out var value) && value.Type == VariantValueType.Array)
+ uris = value.GetArray();
+
+ return (Path: m.PathAsString ?? "", Code: code, Uris: uris);
+ }, static (Exception? ex, (string Path, uint Code, string[] Uris) value, object? _, object? state) =>
+ {
+ if (ex is not null)
+ return;
+
+ var self = (PortalFileChooserService)state!;
+ var token = value.Path[(value.Path.LastIndexOf('/') + 1)..];
+ if (self.pending.TryGetValue(token, out var waiter))
+ waiter.TrySetResult((value.Code, value.Uris));
+ }, null, this, false, ObserverFlags.None).ConfigureAwait(false);
+
+ connection = bus;
+ return bus;
+ }
+ catch (Exception ex) when (IsBackendFailure(ex))
+ {
+ return null;
+ }
+ finally
+ {
+ gate.Release();
+ }
+ }
+
+ ///
+ public void Dispose()
+ {
+ responseSubscription?.Dispose();
+ connection?.Dispose();
+ connection = null;
+ }
+ }
+}
diff --git a/src/Files.Platform.Linux/SystemIntegrationServiceCollectionExtensions.cs b/src/Files.Platform.Linux/SystemIntegrationServiceCollectionExtensions.cs
index 81672accfa6f..82728ada3af0 100644
--- a/src/Files.Platform.Linux/SystemIntegrationServiceCollectionExtensions.cs
+++ b/src/Files.Platform.Linux/SystemIntegrationServiceCollectionExtensions.cs
@@ -2,6 +2,7 @@
// Licensed under the MIT License.
using Files.Platform.Abstractions.Elevation;
+using Files.Platform.Abstractions.FileChooser;
using Files.Platform.Abstractions.Fonts;
using Files.Platform.Abstractions.Instance;
using Files.Platform.Abstractions.Notifications;
@@ -10,6 +11,7 @@
using Files.Platform.Abstractions.Wallpaper;
using Files.Platform.Linux.DBus;
using Files.Platform.Linux.Elevation;
+using Files.Platform.Linux.FileChooser;
using Files.Platform.Linux.Fonts;
using Files.Platform.Linux.Instance;
using Files.Platform.Linux.Launching;
@@ -44,6 +46,7 @@ public static IServiceCollection AddLinuxSystemIntegration(this IServiceCollecti
services.TryAddSingleton();
services.TryAddSingleton(sp => new UserFontInstallService(UserFontInstallService.DefaultFontsDirectory(System.Environment.GetEnvironmentVariable), sp.GetRequiredService(), sp.GetRequiredService()));
services.TryAddSingleton(_ => new PortalWallpaperService());
+ services.TryAddSingleton(_ => new PortalFileChooserService());
return services;
}
}
diff --git a/tests/Files.Platform.Tests/SystemIntegration/PortalFileChooserServiceTests.cs b/tests/Files.Platform.Tests/SystemIntegration/PortalFileChooserServiceTests.cs
new file mode 100644
index 000000000000..a4bc2c24f8d1
--- /dev/null
+++ b/tests/Files.Platform.Tests/SystemIntegration/PortalFileChooserServiceTests.cs
@@ -0,0 +1,265 @@
+// Copyright (c) Files Community
+// Licensed under the MIT License.
+
+using Files.Platform.Abstractions.FileChooser;
+using Files.Platform.Linux.DBus;
+using Files.Platform.Linux.FileChooser;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+using Tmds.DBus.Protocol;
+
+namespace Files.Platform.Tests.SystemIntegration
+{
+ ///
+ /// Talks to a fake FileChooser portal on a private bus. The real session bus and real dialogs are never touched.
+ ///
+ [TestClass]
+ public sealed class PortalFileChooserServiceTests
+ {
+ private sealed record Call(string Method, string Parent, string Title, Dictionary Options);
+
+ private sealed class FakePortal : IPathMethodHandler, IDisposable
+ {
+ private readonly DBusConnection connection;
+
+ public List Calls { get; } = new();
+
+ public uint ResponseCode { get; set; }
+
+ public string[] Uris { get; set; } = [];
+
+ public string? HandleToken { get; set; }
+
+ public string Path => "/org/freedesktop/portal/desktop";
+
+ public bool HandlesChildPaths => false;
+
+ private FakePortal(DBusConnection connection) => this.connection = connection;
+
+ public static async Task StartAsync(string address)
+ {
+ var bus = await DBusSession.TryConnectAsync(address, TimeSpan.FromSeconds(5)) ?? throw new InvalidOperationException("no bus");
+ var fake = new FakePortal(bus);
+ bus.AddMethodHandler(fake);
+ await bus.TryRequestNameAsync("org.freedesktop.portal.Desktop", RequestNameOptions.None);
+ return fake;
+ }
+
+ public ValueTask HandleMethodAsync(MethodContext context)
+ {
+ var request = context.Request;
+ if (context.IsDBusIntrospectRequest)
+ {
+ context.ReplyIntrospectXml([Encoding.UTF8.GetBytes("")]);
+ return ValueTask.CompletedTask;
+ }
+
+ var reader = request.GetBodyReader();
+ var parent = reader.ReadString();
+ var title = reader.ReadString();
+ var options = reader.ReadDictionaryOfStringToVariantValue();
+ var token = options["handle_token"].GetString();
+ lock (Calls)
+ Calls.Add(new Call(request.MemberAsString ?? "", parent, title, options));
+
+ var handle = "/org/freedesktop/portal/desktop/request/fake/" + (HandleToken ?? token);
+ using (var reply = context.CreateReplyWriter("o"))
+ {
+ reply.WriteObjectPath(handle);
+ context.Reply(reply.CreateMessage());
+ }
+
+ var code = ResponseCode;
+ var uris = Uris;
+ _ = Task.Run(async () =>
+ {
+ await Task.Delay(100);
+ using var writer = connection.GetMessageWriter();
+ writer.WriteSignalHeader(null, handle, "org.freedesktop.portal.Request", "Response", "ua{sv}");
+ writer.WriteUInt32(code);
+ var start = writer.WriteDictionaryStart();
+ if (code == 0)
+ {
+ writer.WriteDictionaryEntryStart();
+ writer.WriteString("uris");
+ writer.WriteSignature("as");
+ var array = writer.WriteArrayStart(DBusType.String);
+ foreach (var uri in uris)
+ writer.WriteString(uri);
+ writer.WriteArrayEnd(array);
+ }
+
+ writer.WriteDictionaryEnd(start);
+ connection.TrySendMessage(writer.CreateMessage());
+ });
+ return ValueTask.CompletedTask;
+ }
+
+ public void Dispose() => connection.Dispose();
+ }
+
+ [TestMethod]
+ public async Task OpensASingleFileWithFiltersAndParentWindow()
+ {
+ using var bus = PrivateBus.Start();
+ using var portal = await FakePortal.StartAsync(bus.Address);
+ using var service = new PortalFileChooserService(bus.Address);
+ portal.Uris = ["file:///tmp/a%20b%25.png"];
+
+ var result = await service.ChooseAsync(new FileChooserRequest
+ {
+ Title = "Pick",
+ ParentWindowId = 0x1a2b,
+ Filters = [new FileChooserFilter("Images", ["*.png", "*.jpg"]), new FileChooserFilter("All", ["*"])],
+ });
+
+ Assert.AreEqual(FileChooserStatus.Selected, result.Status);
+ CollectionAssert.AreEqual(new[] { "/tmp/a b%.png" }, result.Paths.ToArray());
+ var call = portal.Calls.Single();
+ Assert.AreEqual("OpenFile", call.Method);
+ Assert.AreEqual("x11:1a2b", call.Parent);
+ Assert.AreEqual("Pick", call.Title);
+ Assert.IsFalse(call.Options["multiple"].GetBool());
+ Assert.IsFalse(call.Options.ContainsKey("directory"));
+
+ var filters = call.Options["filters"];
+ Assert.AreEqual(2, filters.Count);
+ Assert.AreEqual("Images", filters.GetItem(0).GetItem(0).GetString());
+ var globs = filters.GetItem(0).GetItem(1);
+ Assert.AreEqual(2, globs.Count);
+ Assert.AreEqual(0u, globs.GetItem(0).GetItem(0).GetUInt32());
+ Assert.AreEqual("*.jpg", globs.GetItem(1).GetItem(1).GetString());
+ }
+
+ [TestMethod]
+ public async Task OpensSeveralFilesAndAnEmptyParentWhenUnknown()
+ {
+ using var bus = PrivateBus.Start();
+ using var portal = await FakePortal.StartAsync(bus.Address);
+ using var service = new PortalFileChooserService(bus.Address);
+ portal.Uris = ["file:///tmp/one", "file://localhost/tmp/two", "https://example.com/x", "file://otherhost/tmp/three", "file:///tmp/%00bad"];
+
+ var result = await service.ChooseAsync(new FileChooserRequest { Multiple = true });
+
+ Assert.AreEqual(FileChooserStatus.Selected, result.Status);
+ CollectionAssert.AreEqual(new[] { "/tmp/one", "/tmp/two" }, result.Paths.ToArray());
+ var call = portal.Calls.Single();
+ Assert.AreEqual("", call.Parent);
+ Assert.IsTrue(call.Options["multiple"].GetBool());
+ Assert.IsFalse(call.Options.ContainsKey("filters"));
+ }
+
+ [TestMethod]
+ public async Task PicksADirectory()
+ {
+ using var bus = PrivateBus.Start();
+ using var portal = await FakePortal.StartAsync(bus.Address);
+ using var service = new PortalFileChooserService(bus.Address);
+ portal.Uris = ["file:///home/user/Documents"];
+
+ var result = await service.ChooseAsync(new FileChooserRequest
+ {
+ PickFolder = true,
+ Multiple = true,
+ Filters = [new FileChooserFilter("Ignored", ["*"])],
+ });
+
+ CollectionAssert.AreEqual(new[] { "/home/user/Documents" }, result.Paths.ToArray());
+ var call = portal.Calls.Single();
+ Assert.AreEqual("OpenFile", call.Method);
+ Assert.IsTrue(call.Options["directory"].GetBool());
+ Assert.IsFalse(call.Options["multiple"].GetBool());
+ Assert.IsFalse(call.Options.ContainsKey("filters"));
+ }
+
+ [TestMethod]
+ public async Task SavesWithNameFolderAndFilters()
+ {
+ using var bus = PrivateBus.Start();
+ using var portal = await FakePortal.StartAsync(bus.Address);
+ using var service = new PortalFileChooserService(bus.Address);
+ portal.Uris = ["file:///tmp/export.zip"];
+
+ var result = await service.ChooseAsync(new FileChooserRequest
+ {
+ Save = true,
+ CurrentName = "export.zip",
+ CurrentFolder = "/tmp",
+ Filters = [new FileChooserFilter("Zip", ["*.zip"])],
+ });
+
+ CollectionAssert.AreEqual(new[] { "/tmp/export.zip" }, result.Paths.ToArray());
+ var call = portal.Calls.Single();
+ Assert.AreEqual("SaveFile", call.Method);
+ Assert.AreEqual("export.zip", call.Options["current_name"].GetString());
+ CollectionAssert.AreEqual(Encoding.UTF8.GetBytes("/tmp\0"), call.Options["current_folder"].GetArray());
+ Assert.AreEqual(1, call.Options["filters"].Count);
+ Assert.IsFalse(call.Options.ContainsKey("multiple"));
+ }
+
+ [TestMethod]
+ public async Task ReportsCancelAndUnusableSelections()
+ {
+ using var bus = PrivateBus.Start();
+ using var portal = await FakePortal.StartAsync(bus.Address);
+ using var service = new PortalFileChooserService(bus.Address);
+
+ portal.ResponseCode = 1;
+ var cancelled = await service.ChooseAsync(new FileChooserRequest());
+ Assert.AreEqual(FileChooserStatus.Cancelled, cancelled.Status);
+ Assert.AreEqual(0, cancelled.Paths.Count);
+
+ portal.ResponseCode = 0;
+ portal.Uris = ["https://example.com/x"];
+ Assert.AreEqual(FileChooserStatus.Cancelled, (await service.ChooseAsync(new FileChooserRequest())).Status);
+ }
+
+ [TestMethod]
+ public async Task FollowsTheHandleTheMethodReturned()
+ {
+ using var bus = PrivateBus.Start();
+ using var portal = await FakePortal.StartAsync(bus.Address);
+ using var service = new PortalFileChooserService(bus.Address);
+ portal.HandleToken = "differenttoken";
+ portal.Uris = ["file:///tmp/x"];
+
+ var result = await service.ChooseAsync(new FileChooserRequest());
+
+ CollectionAssert.AreEqual(new[] { "/tmp/x" }, result.Paths.ToArray());
+ }
+
+ [TestMethod]
+ public async Task AnAlreadyCancelledTokenSendsNothing()
+ {
+ using var bus = PrivateBus.Start();
+ using var portal = await FakePortal.StartAsync(bus.Address);
+ using var service = new PortalFileChooserService(bus.Address);
+ using var cts = new System.Threading.CancellationTokenSource();
+ cts.Cancel();
+
+ await Assert.ThrowsAsync(() => service.ChooseAsync(new FileChooserRequest(), cts.Token));
+ Assert.AreEqual(0, portal.Calls.Count);
+ }
+
+ [TestMethod]
+ public async Task IsUnavailableWithoutAPortal()
+ {
+ using var bus = PrivateBus.Start();
+ using var service = new PortalFileChooserService(bus.Address);
+
+ Assert.AreEqual(FileChooserStatus.Unavailable, (await service.ChooseAsync(new FileChooserRequest())).Status);
+ }
+
+ [TestMethod]
+ public async Task IsUnavailableWithoutABus()
+ {
+ using var service = new PortalFileChooserService("unix:path=/nonexistent/files-test-bus");
+
+ Assert.AreEqual(FileChooserStatus.Unavailable, (await service.ChooseAsync(new FileChooserRequest())).Status);
+ }
+ }
+}