maui-linux/Services/PortalFilePickerService.cs

480 lines
15 KiB
C#

// 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
Console.WriteLine("[FilePickerService] No file picker available (install xdg-desktop-portal, zenity, or kdialog)");
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)
{
Console.WriteLine($"[FilePickerService] Portal error: {ex.Message}");
// 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)
{
Console.WriteLine($"[FilePickerService] Command error: {ex.Message}");
return "";
}
}
}
/// <summary>
/// Folder picker service using xdg-desktop-portal for native dialogs.
/// </summary>
public class PortalFolderPickerService
{
public async Task<FolderPickerResult> PickAsync(FolderPickerOptions? options = null, CancellationToken cancellationToken = default)
{
options ??= new FolderPickerOptions();
// Use zenity/kdialog for folder selection (simpler than portal)
string? selectedFolder = null;
if (IsCommandAvailable("zenity"))
{
var args = $"--file-selection --directory --title=\"{options.Title ?? "Select Folder"}\"";
selectedFolder = await Task.Run(() => RunCommand("zenity", args)?.Trim());
}
else if (IsCommandAvailable("kdialog"))
{
var args = $"--getexistingdirectory . --title \"{options.Title ?? "Select Folder"}\"";
selectedFolder = await Task.Run(() => RunCommand("kdialog", args)?.Trim());
}
if (!string.IsNullOrEmpty(selectedFolder) && Directory.Exists(selectedFolder))
{
return new FolderPickerResult(new FolderResult(selectedFolder));
}
return new FolderPickerResult(null);
}
public async Task<FolderPickerResult> PickAsync(CancellationToken cancellationToken = default)
{
return await PickAsync(null, cancellationToken);
}
private bool IsCommandAvailable(string command)
{
try
{
var output = RunCommand("which", command);
return !string.IsNullOrWhiteSpace(output);
}
catch
{
return false;
}
}
private string? RunCommand(string command, string arguments)
{
try
{
using var process = new Process
{
StartInfo = new ProcessStartInfo
{
FileName = command,
Arguments = arguments,
RedirectStandardOutput = true,
UseShellExecute = false,
CreateNoWindow = true
}
};
process.Start();
var output = process.StandardOutput.ReadToEnd();
process.WaitForExit(30000);
return output;
}
catch
{
return null;
}
}
}
/// <summary>
/// Result of a folder picker operation.
/// </summary>
public class FolderResult
{
public string Path { get; }
public string Name => System.IO.Path.GetFileName(Path) ?? Path;
public FolderResult(string path)
{
Path = path;
}
}
/// <summary>
/// Result wrapper for folder picker.
/// </summary>
public class FolderPickerResult
{
public FolderResult? Folder { get; }
public bool WasSuccessful => Folder != null;
public FolderPickerResult(FolderResult? folder)
{
Folder = folder;
}
}
/// <summary>
/// Options for folder picker.
/// </summary>
public class FolderPickerOptions
{
public string? Title { get; set; }
public string? InitialDirectory { get; set; }
}