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