2025-12-28 09:53:40 -05:00
|
|
|
// Licensed to the .NET Foundation under one or more agreements.
|
|
|
|
|
// The .NET Foundation licenses this file to you under the MIT license.
|
|
|
|
|
|
|
|
|
|
using Microsoft.Maui.Storage;
|
|
|
|
|
using System.Diagnostics;
|
|
|
|
|
using System.Text;
|
|
|
|
|
|
|
|
|
|
namespace Microsoft.Maui.Platform.Linux.Services;
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// File picker service using xdg-desktop-portal for native dialogs.
|
|
|
|
|
/// Falls back to zenity/kdialog if portal is unavailable.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public class PortalFilePickerService : IFilePicker
|
|
|
|
|
{
|
|
|
|
|
private bool _portalAvailable = true;
|
|
|
|
|
private string? _fallbackTool;
|
|
|
|
|
|
|
|
|
|
public PortalFilePickerService()
|
|
|
|
|
{
|
|
|
|
|
DetectAvailableTools();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private void DetectAvailableTools()
|
|
|
|
|
{
|
|
|
|
|
// Check if portal is available
|
|
|
|
|
_portalAvailable = CheckPortalAvailable();
|
|
|
|
|
|
|
|
|
|
if (!_portalAvailable)
|
|
|
|
|
{
|
|
|
|
|
// Check for fallback tools
|
|
|
|
|
if (IsCommandAvailable("zenity"))
|
|
|
|
|
_fallbackTool = "zenity";
|
|
|
|
|
else if (IsCommandAvailable("kdialog"))
|
|
|
|
|
_fallbackTool = "kdialog";
|
|
|
|
|
else if (IsCommandAvailable("yad"))
|
|
|
|
|
_fallbackTool = "yad";
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private bool CheckPortalAvailable()
|
|
|
|
|
{
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
// Check if xdg-desktop-portal is running
|
|
|
|
|
var output = RunCommand("busctl", "--user list | grep -q org.freedesktop.portal.Desktop && echo yes");
|
|
|
|
|
return output.Trim() == "yes";
|
|
|
|
|
}
|
|
|
|
|
catch
|
|
|
|
|
{
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private bool IsCommandAvailable(string command)
|
|
|
|
|
{
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
var output = RunCommand("which", command);
|
|
|
|
|
return !string.IsNullOrWhiteSpace(output);
|
|
|
|
|
}
|
|
|
|
|
catch
|
|
|
|
|
{
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async Task<FileResult?> PickAsync(PickOptions? options = null)
|
|
|
|
|
{
|
|
|
|
|
options ??= new PickOptions();
|
|
|
|
|
var results = await PickFilesAsync(options, allowMultiple: false);
|
|
|
|
|
return results.FirstOrDefault();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async Task<IEnumerable<FileResult>> PickMultipleAsync(PickOptions? options = null)
|
|
|
|
|
{
|
|
|
|
|
options ??= new PickOptions();
|
|
|
|
|
return await PickFilesAsync(options, allowMultiple: true);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async Task<IEnumerable<FileResult>> PickFilesAsync(PickOptions options, bool allowMultiple)
|
|
|
|
|
{
|
|
|
|
|
if (_portalAvailable)
|
|
|
|
|
{
|
|
|
|
|
return await PickWithPortalAsync(options, allowMultiple);
|
|
|
|
|
}
|
|
|
|
|
else if (_fallbackTool != null)
|
|
|
|
|
{
|
|
|
|
|
return await PickWithFallbackAsync(options, allowMultiple);
|
|
|
|
|
}
|
|
|
|
|
else
|
|
|
|
|
{
|
|
|
|
|
// No file picker available
|
2026-03-06 22:06:08 -05:00
|
|
|
DiagnosticLog.Warn("PortalFilePickerService", "No file picker available (install xdg-desktop-portal, zenity, or kdialog)");
|
2025-12-28 09:53:40 -05:00
|
|
|
return Enumerable.Empty<FileResult>();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async Task<IEnumerable<FileResult>> PickWithPortalAsync(PickOptions options, bool allowMultiple)
|
|
|
|
|
{
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
// Use gdbus to call the portal
|
|
|
|
|
var filterArgs = BuildPortalFilterArgs(options.FileTypes);
|
|
|
|
|
var multipleArg = allowMultiple ? "true" : "false";
|
|
|
|
|
var title = options.PickerTitle ?? "Open File";
|
|
|
|
|
|
|
|
|
|
// Build the D-Bus call
|
|
|
|
|
var args = new StringBuilder();
|
|
|
|
|
args.Append("call --session ");
|
|
|
|
|
args.Append("--dest org.freedesktop.portal.Desktop ");
|
|
|
|
|
args.Append("--object-path /org/freedesktop/portal/desktop ");
|
|
|
|
|
args.Append("--method org.freedesktop.portal.FileChooser.OpenFile ");
|
|
|
|
|
args.Append("\"\" "); // Parent window (empty for no parent)
|
|
|
|
|
args.Append($"\"{EscapeForShell(title)}\" "); // Title
|
|
|
|
|
|
|
|
|
|
// Options dictionary
|
|
|
|
|
args.Append("@a{sv} {");
|
|
|
|
|
args.Append($"'multiple': <{multipleArg}>");
|
|
|
|
|
if (filterArgs != null)
|
|
|
|
|
{
|
|
|
|
|
args.Append($", 'filters': <{filterArgs}>");
|
|
|
|
|
}
|
|
|
|
|
args.Append("}");
|
|
|
|
|
|
|
|
|
|
var output = await Task.Run(() => RunCommand("gdbus", args.ToString()));
|
|
|
|
|
|
|
|
|
|
// Parse the response to get the request path
|
|
|
|
|
// Response format: (objectpath '/org/freedesktop/portal/desktop/request/...',)
|
|
|
|
|
var requestPath = ParseRequestPath(output);
|
|
|
|
|
if (string.IsNullOrEmpty(requestPath))
|
|
|
|
|
{
|
|
|
|
|
return Enumerable.Empty<FileResult>();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Wait for the response signal (simplified - in production use D-Bus signal subscription)
|
|
|
|
|
await Task.Delay(100);
|
|
|
|
|
|
|
|
|
|
// For now, fall back to synchronous zenity if portal response parsing is complex
|
|
|
|
|
if (_fallbackTool != null)
|
|
|
|
|
{
|
|
|
|
|
return await PickWithFallbackAsync(options, allowMultiple);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return Enumerable.Empty<FileResult>();
|
|
|
|
|
}
|
|
|
|
|
catch (Exception ex)
|
|
|
|
|
{
|
2026-03-06 22:06:08 -05:00
|
|
|
DiagnosticLog.Error("PortalFilePickerService", $"Portal error: {ex.Message}");
|
2025-12-28 09:53:40 -05:00
|
|
|
// Fall back to zenity/kdialog
|
|
|
|
|
if (_fallbackTool != null)
|
|
|
|
|
{
|
|
|
|
|
return await PickWithFallbackAsync(options, allowMultiple);
|
|
|
|
|
}
|
|
|
|
|
return Enumerable.Empty<FileResult>();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async Task<IEnumerable<FileResult>> PickWithFallbackAsync(PickOptions options, bool allowMultiple)
|
|
|
|
|
{
|
|
|
|
|
return _fallbackTool switch
|
|
|
|
|
{
|
|
|
|
|
"zenity" => await PickWithZenityAsync(options, allowMultiple),
|
|
|
|
|
"kdialog" => await PickWithKdialogAsync(options, allowMultiple),
|
|
|
|
|
"yad" => await PickWithYadAsync(options, allowMultiple),
|
|
|
|
|
_ => Enumerable.Empty<FileResult>()
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async Task<IEnumerable<FileResult>> PickWithZenityAsync(PickOptions options, bool allowMultiple)
|
|
|
|
|
{
|
|
|
|
|
var args = new StringBuilder();
|
|
|
|
|
args.Append("--file-selection ");
|
|
|
|
|
|
|
|
|
|
if (!string.IsNullOrEmpty(options.PickerTitle))
|
|
|
|
|
{
|
|
|
|
|
args.Append($"--title=\"{EscapeForShell(options.PickerTitle)}\" ");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (allowMultiple)
|
|
|
|
|
{
|
|
|
|
|
args.Append("--multiple --separator=\"|\" ");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Add file filters from FilePickerFileType
|
|
|
|
|
var extensions = GetExtensionsFromFileType(options.FileTypes);
|
|
|
|
|
if (extensions.Count > 0)
|
|
|
|
|
{
|
|
|
|
|
var filterPattern = string.Join(" ", extensions.Select(e => $"*{e}"));
|
|
|
|
|
args.Append($"--file-filter=\"Files | {filterPattern}\" ");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var output = await Task.Run(() => RunCommand("zenity", args.ToString()));
|
|
|
|
|
|
|
|
|
|
if (string.IsNullOrWhiteSpace(output))
|
|
|
|
|
{
|
|
|
|
|
return Enumerable.Empty<FileResult>();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var files = output.Trim().Split('|', StringSplitOptions.RemoveEmptyEntries);
|
|
|
|
|
return files.Select(f => new FileResult(f.Trim())).ToList();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async Task<IEnumerable<FileResult>> PickWithKdialogAsync(PickOptions options, bool allowMultiple)
|
|
|
|
|
{
|
|
|
|
|
var args = new StringBuilder();
|
|
|
|
|
args.Append("--getopenfilename ");
|
|
|
|
|
|
|
|
|
|
// Start directory
|
|
|
|
|
args.Append(". ");
|
|
|
|
|
|
|
|
|
|
// Add file filters
|
|
|
|
|
var extensions = GetExtensionsFromFileType(options.FileTypes);
|
|
|
|
|
if (extensions.Count > 0)
|
|
|
|
|
{
|
|
|
|
|
var filterPattern = string.Join(" ", extensions.Select(e => $"*{e}"));
|
|
|
|
|
args.Append($"\"Files ({filterPattern})\" ");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!string.IsNullOrEmpty(options.PickerTitle))
|
|
|
|
|
{
|
|
|
|
|
args.Append($"--title \"{EscapeForShell(options.PickerTitle)}\" ");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (allowMultiple)
|
|
|
|
|
{
|
|
|
|
|
args.Append("--multiple --separate-output ");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var output = await Task.Run(() => RunCommand("kdialog", args.ToString()));
|
|
|
|
|
|
|
|
|
|
if (string.IsNullOrWhiteSpace(output))
|
|
|
|
|
{
|
|
|
|
|
return Enumerable.Empty<FileResult>();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var files = output.Trim().Split('\n', StringSplitOptions.RemoveEmptyEntries);
|
|
|
|
|
return files.Select(f => new FileResult(f.Trim())).ToList();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async Task<IEnumerable<FileResult>> PickWithYadAsync(PickOptions options, bool allowMultiple)
|
|
|
|
|
{
|
|
|
|
|
// YAD is similar to zenity
|
|
|
|
|
var args = new StringBuilder();
|
|
|
|
|
args.Append("--file ");
|
|
|
|
|
|
|
|
|
|
if (!string.IsNullOrEmpty(options.PickerTitle))
|
|
|
|
|
{
|
|
|
|
|
args.Append($"--title=\"{EscapeForShell(options.PickerTitle)}\" ");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (allowMultiple)
|
|
|
|
|
{
|
|
|
|
|
args.Append("--multiple --separator=\"|\" ");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var extensions = GetExtensionsFromFileType(options.FileTypes);
|
|
|
|
|
if (extensions.Count > 0)
|
|
|
|
|
{
|
|
|
|
|
var filterPattern = string.Join(" ", extensions.Select(e => $"*{e}"));
|
|
|
|
|
args.Append($"--file-filter=\"Files | {filterPattern}\" ");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var output = await Task.Run(() => RunCommand("yad", args.ToString()));
|
|
|
|
|
|
|
|
|
|
if (string.IsNullOrWhiteSpace(output))
|
|
|
|
|
{
|
|
|
|
|
return Enumerable.Empty<FileResult>();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var files = output.Trim().Split('|', StringSplitOptions.RemoveEmptyEntries);
|
|
|
|
|
return files.Select(f => new FileResult(f.Trim())).ToList();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Extracts file extensions from a MAUI FilePickerFileType.
|
|
|
|
|
/// </summary>
|
|
|
|
|
private List<string> GetExtensionsFromFileType(FilePickerFileType? fileType)
|
|
|
|
|
{
|
|
|
|
|
var extensions = new List<string>();
|
|
|
|
|
if (fileType == null) return extensions;
|
|
|
|
|
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
// FilePickerFileType.Value is IEnumerable<string> for the current platform
|
|
|
|
|
var value = fileType.Value;
|
|
|
|
|
if (value == null) return extensions;
|
|
|
|
|
|
|
|
|
|
foreach (var ext in value)
|
|
|
|
|
{
|
|
|
|
|
// Skip MIME types, only take file extensions
|
|
|
|
|
if (ext.StartsWith(".") || (!ext.Contains('/') && !ext.Contains('*')))
|
|
|
|
|
{
|
|
|
|
|
var normalized = ext.StartsWith(".") ? ext : $".{ext}";
|
|
|
|
|
if (!extensions.Contains(normalized))
|
|
|
|
|
{
|
|
|
|
|
extensions.Add(normalized);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
catch
|
|
|
|
|
{
|
|
|
|
|
// Silently fail if we can't parse the file type
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return extensions;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private string? BuildPortalFilterArgs(FilePickerFileType? fileType)
|
|
|
|
|
{
|
|
|
|
|
var extensions = GetExtensionsFromFileType(fileType);
|
|
|
|
|
if (extensions.Count == 0)
|
|
|
|
|
return null;
|
|
|
|
|
|
|
|
|
|
var patterns = string.Join(", ", extensions.Select(e => $"(uint32 0, '*{e}')"));
|
|
|
|
|
return $"[('Files', [{patterns}])]";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private string? ParseRequestPath(string output)
|
|
|
|
|
{
|
|
|
|
|
// Parse D-Bus response like: (objectpath '/org/freedesktop/portal/desktop/request/...',)
|
|
|
|
|
var start = output.IndexOf("'/");
|
|
|
|
|
var end = output.IndexOf("',", start);
|
|
|
|
|
if (start >= 0 && end > start)
|
|
|
|
|
{
|
|
|
|
|
return output.Substring(start + 1, end - start - 1);
|
|
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private string EscapeForShell(string input)
|
|
|
|
|
{
|
|
|
|
|
return input.Replace("\"", "\\\"").Replace("'", "\\'");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private string RunCommand(string command, string arguments)
|
|
|
|
|
{
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
using var process = new Process
|
|
|
|
|
{
|
|
|
|
|
StartInfo = new ProcessStartInfo
|
|
|
|
|
{
|
|
|
|
|
FileName = command,
|
|
|
|
|
Arguments = arguments,
|
|
|
|
|
RedirectStandardOutput = true,
|
|
|
|
|
RedirectStandardError = true,
|
|
|
|
|
UseShellExecute = false,
|
|
|
|
|
CreateNoWindow = true
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
process.Start();
|
|
|
|
|
var output = process.StandardOutput.ReadToEnd();
|
|
|
|
|
process.WaitForExit(30000);
|
|
|
|
|
return output;
|
|
|
|
|
}
|
|
|
|
|
catch (Exception ex)
|
|
|
|
|
{
|
2026-03-06 22:06:08 -05:00
|
|
|
DiagnosticLog.Error("PortalFilePickerService", $"Command error: {ex.Message}");
|
2025-12-28 09:53:40 -05:00
|
|
|
return "";
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|