Update with recovered code from VM binaries (Jan 1)

Recovered from decompiled OpenMaui.Controls.Linux.dll:
- SkiaShell.cs: FlyoutHeader, FlyoutFooter, scroll support (918 -> 1325 lines)
- X11Window.cs: Cursor support (XCreateFontCursor, XDefineCursor)
- All handlers with dark mode support
- All services with latest implementations
- LinuxApplication with theme change handling
This commit is contained in:
2026-01-01 06:22:48 -05:00
parent 1e84c6168a
commit 1f096c38dc
254 changed files with 49359 additions and 38457 deletions

View File

@@ -0,0 +1,25 @@
using System.Collections.Generic;
namespace Microsoft.Maui.Platform.Linux.Services;
internal class _003CFontFallbackManager_003EFAC9D2911A2850E174CCA7662C668F37C2FBBA325CAF5C11AFE3FA59C16CC64ED__StringBuilder
{
private readonly List<char> _chars = new List<char>();
public int Length => _chars.Count;
public void Append(string s)
{
_chars.AddRange(s);
}
public void Clear()
{
_chars.Clear();
}
public override string ToString()
{
return new string(_chars.ToArray());
}
}

View File

@@ -0,0 +1,52 @@
using System;
namespace Microsoft.Maui.Platform.Linux.Services;
public static class AccessibilityServiceFactory
{
private static IAccessibilityService? _instance;
private static readonly object _lock = new object();
public static IAccessibilityService Instance
{
get
{
if (_instance == null)
{
lock (_lock)
{
if (_instance == null)
{
_instance = CreateService();
}
}
}
return _instance;
}
}
private static IAccessibilityService CreateService()
{
try
{
AtSpi2AccessibilityService atSpi2AccessibilityService = new AtSpi2AccessibilityService();
atSpi2AccessibilityService.Initialize();
return atSpi2AccessibilityService;
}
catch (Exception ex)
{
Console.WriteLine("AccessibilityServiceFactory: Failed to create AT-SPI2 service - " + ex.Message);
return new NullAccessibilityService();
}
}
public static void Reset()
{
lock (_lock)
{
_instance?.Shutdown();
_instance = null;
}
}
}

View File

@@ -0,0 +1,10 @@
namespace Microsoft.Maui.Platform.Linux.Services;
public class AccessibleAction
{
public string Name { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public string? KeyBinding { get; set; }
}

View File

@@ -0,0 +1,11 @@
namespace Microsoft.Maui.Platform.Linux.Services;
public enum AccessibleProperty
{
Name,
Description,
Role,
Value,
Parent,
Children
}

View File

@@ -0,0 +1,20 @@
namespace Microsoft.Maui.Platform.Linux.Services;
public struct AccessibleRect
{
public int X { get; set; }
public int Y { get; set; }
public int Width { get; set; }
public int Height { get; set; }
public AccessibleRect(int x, int y, int width, int height)
{
X = x;
Y = y;
Width = width;
Height = height;
}
}

View File

@@ -0,0 +1,49 @@
namespace Microsoft.Maui.Platform.Linux.Services;
public enum AccessibleRole
{
Unknown,
Window,
Application,
Panel,
Frame,
Button,
CheckBox,
RadioButton,
ComboBox,
Entry,
Label,
List,
ListItem,
Menu,
MenuBar,
MenuItem,
ScrollBar,
Slider,
SpinButton,
StatusBar,
Tab,
TabPanel,
Text,
ToggleButton,
ToolBar,
ToolTip,
Tree,
TreeItem,
Image,
ProgressBar,
Separator,
Link,
Table,
TableCell,
TableRow,
TableColumnHeader,
TableRowHeader,
PageTab,
PageTabList,
Dialog,
Alert,
Filler,
Icon,
Canvas
}

View File

@@ -0,0 +1,38 @@
namespace Microsoft.Maui.Platform.Linux.Services;
public enum AccessibleState
{
Active,
Armed,
Busy,
Checked,
Collapsed,
Defunct,
Editable,
Enabled,
Expandable,
Expanded,
Focusable,
Focused,
Horizontal,
Iconified,
Modal,
MultiLine,
Opaque,
Pressed,
Resizable,
Selectable,
Selected,
Sensitive,
Showing,
SingleLine,
Stale,
Transient,
Vertical,
Visible,
ManagesDescendants,
Indeterminate,
Required,
InvalidEntry,
ReadOnly
}

View File

@@ -0,0 +1,50 @@
using System;
namespace Microsoft.Maui.Platform.Linux.Services;
[Flags]
public enum AccessibleStates : long
{
None = 0L,
Active = 1L,
Armed = 2L,
Busy = 4L,
Checked = 8L,
Collapsed = 0x10L,
Defunct = 0x20L,
Editable = 0x40L,
Enabled = 0x80L,
Expandable = 0x100L,
Expanded = 0x200L,
Focusable = 0x400L,
Focused = 0x800L,
HasToolTip = 0x1000L,
Horizontal = 0x2000L,
Iconified = 0x4000L,
Modal = 0x8000L,
MultiLine = 0x10000L,
MultiSelectable = 0x20000L,
Opaque = 0x40000L,
Pressed = 0x80000L,
Resizable = 0x100000L,
Selectable = 0x200000L,
Selected = 0x400000L,
Sensitive = 0x800000L,
Showing = 0x1000000L,
SingleLine = 0x2000000L,
Stale = 0x4000000L,
Transient = 0x8000000L,
Vertical = 0x10000000L,
Visible = 0x20000000L,
ManagesDescendants = 0x40000000L,
Indeterminate = 0x80000000L,
Required = 0x100000000L,
Truncated = 0x200000000L,
Animated = 0x400000000L,
InvalidEntry = 0x800000000L,
SupportsAutocompletion = 0x1000000000L,
SelectableText = 0x2000000000L,
IsDefault = 0x4000000000L,
Visited = 0x8000000000L,
ReadOnly = 0x10000000000L
}

View File

@@ -0,0 +1,7 @@
namespace Microsoft.Maui.Platform.Linux.Services;
public enum AnnouncementPriority
{
Polite,
Assertive
}

View File

@@ -1,147 +1,149 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Microsoft.Maui.ApplicationModel;
namespace Microsoft.Maui.Platform.Linux.Services;
/// <summary>
/// Linux app actions implementation using desktop file actions.
/// </summary>
public class AppActionsService : IAppActions
{
private readonly List<AppAction> _actions = new();
private static readonly string DesktopFilesPath;
private readonly List<AppAction> _actions = new List<AppAction>();
static AppActionsService()
{
DesktopFilesPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"applications");
}
private static readonly string DesktopFilesPath;
public bool IsSupported => true;
public bool IsSupported => true;
public event EventHandler<AppActionEventArgs>? AppActionActivated;
public event EventHandler<AppActionEventArgs>? AppActionActivated;
public Task<IEnumerable<AppAction>> GetAsync()
{
return Task.FromResult<IEnumerable<AppAction>>(_actions.AsReadOnly());
}
static AppActionsService()
{
DesktopFilesPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "applications");
}
public Task SetAsync(IEnumerable<AppAction> actions)
{
_actions.Clear();
_actions.AddRange(actions);
public Task<IEnumerable<AppAction>> GetAsync()
{
return Task.FromResult((IEnumerable<AppAction>)_actions.AsReadOnly());
}
// On Linux, app actions can be exposed via .desktop file Actions
// This would require modifying the application's .desktop file
UpdateDesktopActions();
public Task SetAsync(IEnumerable<AppAction> actions)
{
_actions.Clear();
_actions.AddRange(actions);
UpdateDesktopActions();
return Task.CompletedTask;
}
return Task.CompletedTask;
}
private void UpdateDesktopActions()
{
}
private void UpdateDesktopActions()
{
// Desktop actions are defined in the .desktop file
// Example:
// [Desktop Action new-window]
// Name=New Window
// Exec=myapp --action=new-window
public void HandleActionArgument(string actionId)
{
//IL_0035: Unknown result type (might be due to invalid IL or missing references)
//IL_003f: Expected O, but got Unknown
AppAction val = ((IEnumerable<AppAction>)_actions).FirstOrDefault((Func<AppAction, bool>)((AppAction a) => a.Id == actionId));
if (val != null)
{
this.AppActionActivated?.Invoke(this, new AppActionEventArgs(val));
}
}
// For a proper implementation, we would need to:
// 1. Find or create the application's .desktop file
// 2. Add [Desktop Action] sections for each action
// 3. The actions would then appear in the dock/launcher right-click menu
public void CreateDesktopFile(string appName, string execPath, string? iconPath = null)
{
try
{
if (!Directory.Exists(DesktopFilesPath))
{
Directory.CreateDirectory(DesktopFilesPath);
}
string contents = GenerateDesktopFileContent(appName, execPath, iconPath);
string path = Path.Combine(DesktopFilesPath, appName.ToLowerInvariant().Replace(" ", "-") + ".desktop");
File.WriteAllText(path, contents);
File.SetUnixFileMode(path, UnixFileMode.OtherRead | UnixFileMode.GroupRead | UnixFileMode.UserExecute | UnixFileMode.UserWrite | UnixFileMode.UserRead);
}
catch
{
}
}
// This is a simplified implementation that logs actions
// A full implementation would require more system integration
}
/// <summary>
/// Call this method to handle command-line action arguments.
/// </summary>
public void HandleActionArgument(string actionId)
{
var action = _actions.FirstOrDefault(a => a.Id == actionId);
if (action != null)
{
AppActionActivated?.Invoke(this, new AppActionEventArgs(action));
}
}
/// <summary>
/// Creates a .desktop file for the application with the defined actions.
/// </summary>
public void CreateDesktopFile(string appName, string execPath, string? iconPath = null)
{
try
{
if (!Directory.Exists(DesktopFilesPath))
{
Directory.CreateDirectory(DesktopFilesPath);
}
var desktopContent = GenerateDesktopFileContent(appName, execPath, iconPath);
var desktopFilePath = Path.Combine(DesktopFilesPath, $"{appName.ToLowerInvariant().Replace(" ", "-")}.desktop");
File.WriteAllText(desktopFilePath, desktopContent);
// Make it executable
File.SetUnixFileMode(desktopFilePath,
UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute |
UnixFileMode.GroupRead | UnixFileMode.OtherRead);
}
catch
{
// Silently fail - desktop file creation is optional
}
}
private string GenerateDesktopFileContent(string appName, string execPath, string? iconPath)
{
var content = new System.Text.StringBuilder();
content.AppendLine("[Desktop Entry]");
content.AppendLine("Type=Application");
content.AppendLine($"Name={appName}");
content.AppendLine($"Exec={execPath} %U");
if (!string.IsNullOrEmpty(iconPath) && File.Exists(iconPath))
{
content.AppendLine($"Icon={iconPath}");
}
content.AppendLine("Terminal=false");
content.AppendLine("Categories=Utility;");
// Add actions list
if (_actions.Count > 0)
{
var actionIds = string.Join(";", _actions.Select(a => a.Id));
content.AppendLine($"Actions={actionIds};");
content.AppendLine();
// Add each action section
foreach (var action in _actions)
{
content.AppendLine($"[Desktop Action {action.Id}]");
content.AppendLine($"Name={action.Title}");
if (!string.IsNullOrEmpty(action.Subtitle))
{
content.AppendLine($"Comment={action.Subtitle}");
}
content.AppendLine($"Exec={execPath} --action={action.Id}");
{
}
content.AppendLine();
}
}
return content.ToString();
}
private string GenerateDesktopFileContent(string appName, string execPath, string? iconPath)
{
StringBuilder stringBuilder = new StringBuilder();
stringBuilder.AppendLine("[Desktop Entry]");
stringBuilder.AppendLine("Type=Application");
StringBuilder stringBuilder2 = stringBuilder;
StringBuilder stringBuilder3 = stringBuilder2;
StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(5, 1, stringBuilder2);
handler.AppendLiteral("Name=");
handler.AppendFormatted(appName);
stringBuilder3.AppendLine(ref handler);
stringBuilder2 = stringBuilder;
StringBuilder stringBuilder4 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(8, 1, stringBuilder2);
handler.AppendLiteral("Exec=");
handler.AppendFormatted(execPath);
handler.AppendLiteral(" %U");
stringBuilder4.AppendLine(ref handler);
if (!string.IsNullOrEmpty(iconPath) && File.Exists(iconPath))
{
stringBuilder2 = stringBuilder;
StringBuilder stringBuilder5 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(5, 1, stringBuilder2);
handler.AppendLiteral("Icon=");
handler.AppendFormatted(iconPath);
stringBuilder5.AppendLine(ref handler);
}
stringBuilder.AppendLine("Terminal=false");
stringBuilder.AppendLine("Categories=Utility;");
if (_actions.Count > 0)
{
string value = string.Join(";", _actions.Select((AppAction a) => a.Id));
stringBuilder2 = stringBuilder;
StringBuilder stringBuilder6 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(9, 1, stringBuilder2);
handler.AppendLiteral("Actions=");
handler.AppendFormatted(value);
handler.AppendLiteral(";");
stringBuilder6.AppendLine(ref handler);
stringBuilder.AppendLine();
foreach (AppAction action in _actions)
{
stringBuilder2 = stringBuilder;
StringBuilder stringBuilder7 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(17, 1, stringBuilder2);
handler.AppendLiteral("[Desktop Action ");
handler.AppendFormatted(action.Id);
handler.AppendLiteral("]");
stringBuilder7.AppendLine(ref handler);
stringBuilder2 = stringBuilder;
StringBuilder stringBuilder8 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(5, 1, stringBuilder2);
handler.AppendLiteral("Name=");
handler.AppendFormatted(action.Title);
stringBuilder8.AppendLine(ref handler);
if (!string.IsNullOrEmpty(action.Subtitle))
{
stringBuilder2 = stringBuilder;
StringBuilder stringBuilder9 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(8, 1, stringBuilder2);
handler.AppendLiteral("Comment=");
handler.AppendFormatted(action.Subtitle);
stringBuilder9.AppendLine(ref handler);
}
stringBuilder2 = stringBuilder;
StringBuilder stringBuilder10 = stringBuilder2;
handler = new StringBuilder.AppendInterpolatedStringHandler(15, 2, stringBuilder2);
handler.AppendLiteral("Exec=");
handler.AppendFormatted(execPath);
handler.AppendLiteral(" --action=");
handler.AppendFormatted(action.Id);
stringBuilder10.AppendLine(ref handler);
stringBuilder.AppendLine();
}
}
return stringBuilder.ToString();
}
}

147
Services/AppInfoService.cs Normal file
View File

@@ -0,0 +1,147 @@
using System;
using System.Diagnostics;
using System.Reflection;
using Microsoft.Maui.ApplicationModel;
namespace Microsoft.Maui.Platform.Linux.Services;
public class AppInfoService : IAppInfo
{
private static readonly Lazy<AppInfoService> _instance = new Lazy<AppInfoService>(() => new AppInfoService());
private readonly Assembly _entryAssembly;
private readonly string _packageName;
private readonly string _name;
private readonly string _versionString;
private readonly Version _version;
private readonly string _buildString;
public static AppInfoService Instance => _instance.Value;
public string PackageName => _packageName;
public string Name => _name;
public string VersionString => _versionString;
public Version Version => _version;
public string BuildString => _buildString;
public LayoutDirection RequestedLayoutDirection => (LayoutDirection)1;
public AppTheme RequestedTheme
{
get
{
//IL_0042: Unknown result type (might be due to invalid IL or missing references)
//IL_0045: Unknown result type (might be due to invalid IL or missing references)
//IL_003d: Unknown result type (might be due to invalid IL or missing references)
//IL_0039: Unknown result type (might be due to invalid IL or missing references)
//IL_0022: Unknown result type (might be due to invalid IL or missing references)
try
{
string environmentVariable = Environment.GetEnvironmentVariable("GTK_THEME");
if (!string.IsNullOrEmpty(environmentVariable) && environmentVariable.Contains("dark", StringComparison.OrdinalIgnoreCase))
{
return (AppTheme)2;
}
if (GetGnomeColorScheme().Contains("dark", StringComparison.OrdinalIgnoreCase))
{
return (AppTheme)2;
}
return (AppTheme)1;
}
catch
{
return (AppTheme)1;
}
}
}
public AppPackagingModel PackagingModel
{
get
{
if (Environment.GetEnvironmentVariable("FLATPAK_ID") == null)
{
if (Environment.GetEnvironmentVariable("SNAP") == null)
{
if (Environment.GetEnvironmentVariable("APPIMAGE") == null)
{
return (AppPackagingModel)1;
}
return (AppPackagingModel)0;
}
return (AppPackagingModel)0;
}
return (AppPackagingModel)0;
}
}
public AppInfoService()
{
_entryAssembly = Assembly.GetEntryAssembly() ?? Assembly.GetExecutingAssembly();
_packageName = _entryAssembly.GetName().Name ?? "Unknown";
_name = _entryAssembly.GetCustomAttribute<AssemblyTitleAttribute>()?.Title ?? _packageName;
_versionString = (_version = _entryAssembly.GetName().Version ?? new Version(1, 0)).ToString();
_buildString = _entryAssembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion ?? _versionString;
}
private string GetGnomeColorScheme()
{
try
{
using Process process = Process.Start(new ProcessStartInfo
{
FileName = "gsettings",
Arguments = "get org.gnome.desktop.interface color-scheme",
UseShellExecute = false,
RedirectStandardOutput = true,
CreateNoWindow = true
});
if (process != null)
{
string text = process.StandardOutput.ReadToEnd();
process.WaitForExit(1000);
return text.Trim().Trim('\'');
}
}
catch
{
}
return "";
}
public void ShowSettingsUI()
{
try
{
Process.Start(new ProcessStartInfo
{
FileName = "gnome-control-center",
UseShellExecute = true
});
}
catch
{
try
{
Process.Start(new ProcessStartInfo
{
FileName = "xdg-open",
Arguments = "x-settings:",
UseShellExecute = true
});
}
catch
{
}
}
}
}

View File

@@ -1,461 +1,500 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System;
using System.Collections.Concurrent;
using System.Runtime.InteropServices;
namespace Microsoft.Maui.Platform.Linux.Services;
/// <summary>
/// AT-SPI2 accessibility service implementation.
/// Provides screen reader support through the AT-SPI2 D-Bus interface.
/// </summary>
public class AtSpi2AccessibilityService : IAccessibilityService, IDisposable
{
private nint _connection;
private nint _registry;
private bool _isEnabled;
private bool _disposed;
private IAccessible? _focusedAccessible;
private readonly ConcurrentDictionary<string, IAccessible> _registeredObjects = new();
private readonly string _applicationName;
private nint _applicationAccessible;
private IntPtr _connection;
public bool IsEnabled => _isEnabled;
private IntPtr _registry;
public AtSpi2AccessibilityService(string applicationName = "MAUI Application")
{
_applicationName = applicationName;
}
private bool _isEnabled;
public void Initialize()
{
try
{
// Initialize AT-SPI2
int result = atspi_init();
if (result != 0)
{
Console.WriteLine("AtSpi2AccessibilityService: Failed to initialize AT-SPI2");
return;
}
private bool _disposed;
// Check if accessibility is enabled
_isEnabled = CheckAccessibilityEnabled();
private IAccessible? _focusedAccessible;
if (_isEnabled)
{
// Get the desktop (root accessible)
_registry = atspi_get_desktop(0);
private readonly ConcurrentDictionary<string, IAccessible> _registeredObjects = new ConcurrentDictionary<string, IAccessible>();
// Register our application
RegisterApplication();
private readonly string _applicationName;
Console.WriteLine("AtSpi2AccessibilityService: Initialized successfully");
}
else
{
Console.WriteLine("AtSpi2AccessibilityService: Accessibility is not enabled");
}
}
catch (Exception ex)
{
Console.WriteLine($"AtSpi2AccessibilityService: Initialization failed - {ex.Message}");
}
}
private IntPtr _applicationAccessible;
private bool CheckAccessibilityEnabled()
{
// Check if AT-SPI2 registry is available
try
{
nint desktop = atspi_get_desktop(0);
if (desktop != IntPtr.Zero)
{
g_object_unref(desktop);
return true;
}
}
catch
{
// AT-SPI2 not available
}
private const int ATSPI_ROLE_UNKNOWN = 0;
// Also check the gsettings key
var enabled = Environment.GetEnvironmentVariable("GTK_A11Y");
return enabled?.ToLowerInvariant() != "none";
}
private const int ATSPI_ROLE_WINDOW = 22;
private void RegisterApplication()
{
// In a full implementation, we would create an AtspiApplication object
// and register it with the AT-SPI2 registry. For now, we set up the basics.
private const int ATSPI_ROLE_APPLICATION = 75;
// Set application name
atspi_set_main_context(IntPtr.Zero);
}
private const int ATSPI_ROLE_PANEL = 25;
public void Register(IAccessible accessible)
{
if (accessible == null) return;
private const int ATSPI_ROLE_FRAME = 11;
_registeredObjects.TryAdd(accessible.AccessibleId, accessible);
private const int ATSPI_ROLE_PUSH_BUTTON = 31;
// In a full implementation, we would create an AtspiAccessible object
// and register it with AT-SPI2
}
private const int ATSPI_ROLE_CHECK_BOX = 4;
public void Unregister(IAccessible accessible)
{
if (accessible == null) return;
private const int ATSPI_ROLE_RADIO_BUTTON = 33;
_registeredObjects.TryRemove(accessible.AccessibleId, out _);
private const int ATSPI_ROLE_COMBO_BOX = 6;
// Clean up AT-SPI2 resources for this accessible
}
private const int ATSPI_ROLE_ENTRY = 24;
public void NotifyFocusChanged(IAccessible? accessible)
{
_focusedAccessible = accessible;
private const int ATSPI_ROLE_LABEL = 16;
if (!_isEnabled || accessible == null) return;
private const int ATSPI_ROLE_LIST = 17;
// Emit focus event through AT-SPI2
EmitEvent("focus:", accessible);
}
private const int ATSPI_ROLE_LIST_ITEM = 18;
public void NotifyPropertyChanged(IAccessible accessible, AccessibleProperty property)
{
if (!_isEnabled || accessible == null) return;
private const int ATSPI_ROLE_MENU = 19;
string eventName = property switch
{
AccessibleProperty.Name => "object:property-change:accessible-name",
AccessibleProperty.Description => "object:property-change:accessible-description",
AccessibleProperty.Role => "object:property-change:accessible-role",
AccessibleProperty.Value => "object:property-change:accessible-value",
AccessibleProperty.Parent => "object:property-change:accessible-parent",
AccessibleProperty.Children => "object:children-changed",
_ => string.Empty
};
private const int ATSPI_ROLE_MENU_BAR = 20;
if (!string.IsNullOrEmpty(eventName))
{
EmitEvent(eventName, accessible);
}
}
private const int ATSPI_ROLE_MENU_ITEM = 21;
public void NotifyStateChanged(IAccessible accessible, AccessibleState state, bool value)
{
if (!_isEnabled || accessible == null) return;
private const int ATSPI_ROLE_SCROLL_BAR = 40;
string stateName = state.ToString().ToLowerInvariant();
string eventName = $"object:state-changed:{stateName}";
private const int ATSPI_ROLE_SLIDER = 43;
EmitEvent(eventName, accessible, value ? 1 : 0);
}
private const int ATSPI_ROLE_SPIN_BUTTON = 44;
public void Announce(string text, AnnouncementPriority priority = AnnouncementPriority.Polite)
{
if (!_isEnabled || string.IsNullOrEmpty(text)) return;
private const int ATSPI_ROLE_STATUS_BAR = 46;
// Use AT-SPI2 live region to announce text
// Priority maps to: Polite = ATSPI_LIVE_POLITE, Assertive = ATSPI_LIVE_ASSERTIVE
private const int ATSPI_ROLE_PAGE_TAB = 26;
try
{
// In AT-SPI2, announcements are typically done through live regions
// or by emitting "object:announcement" events
private const int ATSPI_ROLE_PAGE_TAB_LIST = 27;
// For now, use a simpler approach with the event system
Console.WriteLine($"[Accessibility Announcement ({priority})]: {text}");
}
catch (Exception ex)
{
Console.WriteLine($"AtSpi2AccessibilityService: Announcement failed - {ex.Message}");
}
}
private const int ATSPI_ROLE_TEXT = 49;
private void EmitEvent(string eventName, IAccessible accessible, int detail1 = 0, int detail2 = 0)
{
// In a full implementation, we would emit the event through D-Bus
// using the org.a11y.atspi.Event interface
private const int ATSPI_ROLE_TOGGLE_BUTTON = 51;
// For now, log the event for debugging
Console.WriteLine($"[AT-SPI2 Event] {eventName}: {accessible.AccessibleName} ({accessible.Role})");
}
private const int ATSPI_ROLE_TOOL_BAR = 52;
/// <summary>
/// Gets the AT-SPI2 role value for the given accessible role.
/// </summary>
public static int GetAtSpiRole(AccessibleRole role)
{
return role switch
{
AccessibleRole.Unknown => ATSPI_ROLE_UNKNOWN,
AccessibleRole.Window => ATSPI_ROLE_WINDOW,
AccessibleRole.Application => ATSPI_ROLE_APPLICATION,
AccessibleRole.Panel => ATSPI_ROLE_PANEL,
AccessibleRole.Frame => ATSPI_ROLE_FRAME,
AccessibleRole.Button => ATSPI_ROLE_PUSH_BUTTON,
AccessibleRole.CheckBox => ATSPI_ROLE_CHECK_BOX,
AccessibleRole.RadioButton => ATSPI_ROLE_RADIO_BUTTON,
AccessibleRole.ComboBox => ATSPI_ROLE_COMBO_BOX,
AccessibleRole.Entry => ATSPI_ROLE_ENTRY,
AccessibleRole.Label => ATSPI_ROLE_LABEL,
AccessibleRole.List => ATSPI_ROLE_LIST,
AccessibleRole.ListItem => ATSPI_ROLE_LIST_ITEM,
AccessibleRole.Menu => ATSPI_ROLE_MENU,
AccessibleRole.MenuBar => ATSPI_ROLE_MENU_BAR,
AccessibleRole.MenuItem => ATSPI_ROLE_MENU_ITEM,
AccessibleRole.ScrollBar => ATSPI_ROLE_SCROLL_BAR,
AccessibleRole.Slider => ATSPI_ROLE_SLIDER,
AccessibleRole.SpinButton => ATSPI_ROLE_SPIN_BUTTON,
AccessibleRole.StatusBar => ATSPI_ROLE_STATUS_BAR,
AccessibleRole.Tab => ATSPI_ROLE_PAGE_TAB,
AccessibleRole.TabPanel => ATSPI_ROLE_PAGE_TAB_LIST,
AccessibleRole.Text => ATSPI_ROLE_TEXT,
AccessibleRole.ToggleButton => ATSPI_ROLE_TOGGLE_BUTTON,
AccessibleRole.ToolBar => ATSPI_ROLE_TOOL_BAR,
AccessibleRole.ToolTip => ATSPI_ROLE_TOOL_TIP,
AccessibleRole.Tree => ATSPI_ROLE_TREE,
AccessibleRole.TreeItem => ATSPI_ROLE_TREE_ITEM,
AccessibleRole.Image => ATSPI_ROLE_IMAGE,
AccessibleRole.ProgressBar => ATSPI_ROLE_PROGRESS_BAR,
AccessibleRole.Separator => ATSPI_ROLE_SEPARATOR,
AccessibleRole.Link => ATSPI_ROLE_LINK,
AccessibleRole.Table => ATSPI_ROLE_TABLE,
AccessibleRole.TableCell => ATSPI_ROLE_TABLE_CELL,
AccessibleRole.TableRow => ATSPI_ROLE_TABLE_ROW,
AccessibleRole.TableColumnHeader => ATSPI_ROLE_TABLE_COLUMN_HEADER,
AccessibleRole.TableRowHeader => ATSPI_ROLE_TABLE_ROW_HEADER,
AccessibleRole.PageTab => ATSPI_ROLE_PAGE_TAB,
AccessibleRole.PageTabList => ATSPI_ROLE_PAGE_TAB_LIST,
AccessibleRole.Dialog => ATSPI_ROLE_DIALOG,
AccessibleRole.Alert => ATSPI_ROLE_ALERT,
AccessibleRole.Filler => ATSPI_ROLE_FILLER,
AccessibleRole.Icon => ATSPI_ROLE_ICON,
AccessibleRole.Canvas => ATSPI_ROLE_CANVAS,
_ => ATSPI_ROLE_UNKNOWN
};
}
private const int ATSPI_ROLE_TOOL_TIP = 53;
/// <summary>
/// Converts accessible states to AT-SPI2 state set.
/// </summary>
public static (uint Low, uint High) GetAtSpiStates(AccessibleStates states)
{
uint low = 0;
uint high = 0;
private const int ATSPI_ROLE_TREE = 54;
if (states.HasFlag(AccessibleStates.Active)) low |= 1 << 0;
if (states.HasFlag(AccessibleStates.Armed)) low |= 1 << 1;
if (states.HasFlag(AccessibleStates.Busy)) low |= 1 << 2;
if (states.HasFlag(AccessibleStates.Checked)) low |= 1 << 3;
if (states.HasFlag(AccessibleStates.Collapsed)) low |= 1 << 4;
if (states.HasFlag(AccessibleStates.Defunct)) low |= 1 << 5;
if (states.HasFlag(AccessibleStates.Editable)) low |= 1 << 6;
if (states.HasFlag(AccessibleStates.Enabled)) low |= 1 << 7;
if (states.HasFlag(AccessibleStates.Expandable)) low |= 1 << 8;
if (states.HasFlag(AccessibleStates.Expanded)) low |= 1 << 9;
if (states.HasFlag(AccessibleStates.Focusable)) low |= 1 << 10;
if (states.HasFlag(AccessibleStates.Focused)) low |= 1 << 11;
if (states.HasFlag(AccessibleStates.Horizontal)) low |= 1 << 13;
if (states.HasFlag(AccessibleStates.Iconified)) low |= 1 << 14;
if (states.HasFlag(AccessibleStates.Modal)) low |= 1 << 15;
if (states.HasFlag(AccessibleStates.MultiLine)) low |= 1 << 16;
if (states.HasFlag(AccessibleStates.MultiSelectable)) low |= 1 << 17;
if (states.HasFlag(AccessibleStates.Opaque)) low |= 1 << 18;
if (states.HasFlag(AccessibleStates.Pressed)) low |= 1 << 19;
if (states.HasFlag(AccessibleStates.Resizable)) low |= 1 << 20;
if (states.HasFlag(AccessibleStates.Selectable)) low |= 1 << 21;
if (states.HasFlag(AccessibleStates.Selected)) low |= 1 << 22;
if (states.HasFlag(AccessibleStates.Sensitive)) low |= 1 << 23;
if (states.HasFlag(AccessibleStates.Showing)) low |= 1 << 24;
if (states.HasFlag(AccessibleStates.SingleLine)) low |= 1 << 25;
if (states.HasFlag(AccessibleStates.Stale)) low |= 1 << 26;
if (states.HasFlag(AccessibleStates.Transient)) low |= 1 << 27;
if (states.HasFlag(AccessibleStates.Vertical)) low |= 1 << 28;
if (states.HasFlag(AccessibleStates.Visible)) low |= 1 << 29;
if (states.HasFlag(AccessibleStates.ManagesDescendants)) low |= 1 << 30;
if (states.HasFlag(AccessibleStates.Indeterminate)) low |= 1u << 31;
private const int ATSPI_ROLE_TREE_ITEM = 55;
// High bits (states 32+)
if (states.HasFlag(AccessibleStates.Required)) high |= 1 << 0;
if (states.HasFlag(AccessibleStates.Truncated)) high |= 1 << 1;
if (states.HasFlag(AccessibleStates.Animated)) high |= 1 << 2;
if (states.HasFlag(AccessibleStates.InvalidEntry)) high |= 1 << 3;
if (states.HasFlag(AccessibleStates.SupportsAutocompletion)) high |= 1 << 4;
if (states.HasFlag(AccessibleStates.SelectableText)) high |= 1 << 5;
if (states.HasFlag(AccessibleStates.IsDefault)) high |= 1 << 6;
if (states.HasFlag(AccessibleStates.Visited)) high |= 1 << 7;
if (states.HasFlag(AccessibleStates.ReadOnly)) high |= 1 << 10;
private const int ATSPI_ROLE_IMAGE = 14;
return (low, high);
}
private const int ATSPI_ROLE_PROGRESS_BAR = 30;
public void Shutdown()
{
Dispose();
}
private const int ATSPI_ROLE_SEPARATOR = 42;
public void Dispose()
{
if (_disposed) return;
_disposed = true;
private const int ATSPI_ROLE_LINK = 83;
_registeredObjects.Clear();
private const int ATSPI_ROLE_TABLE = 47;
if (_applicationAccessible != IntPtr.Zero)
{
g_object_unref(_applicationAccessible);
_applicationAccessible = IntPtr.Zero;
}
private const int ATSPI_ROLE_TABLE_CELL = 48;
if (_registry != IntPtr.Zero)
{
g_object_unref(_registry);
_registry = IntPtr.Zero;
}
private const int ATSPI_ROLE_TABLE_ROW = 89;
// Exit AT-SPI2
atspi_exit();
}
private const int ATSPI_ROLE_TABLE_COLUMN_HEADER = 36;
#region AT-SPI2 Role Constants
private const int ATSPI_ROLE_TABLE_ROW_HEADER = 37;
private const int ATSPI_ROLE_UNKNOWN = 0;
private const int ATSPI_ROLE_WINDOW = 22;
private const int ATSPI_ROLE_APPLICATION = 75;
private const int ATSPI_ROLE_PANEL = 25;
private const int ATSPI_ROLE_FRAME = 11;
private const int ATSPI_ROLE_PUSH_BUTTON = 31;
private const int ATSPI_ROLE_CHECK_BOX = 4;
private const int ATSPI_ROLE_RADIO_BUTTON = 33;
private const int ATSPI_ROLE_COMBO_BOX = 6;
private const int ATSPI_ROLE_ENTRY = 24;
private const int ATSPI_ROLE_LABEL = 16;
private const int ATSPI_ROLE_LIST = 17;
private const int ATSPI_ROLE_LIST_ITEM = 18;
private const int ATSPI_ROLE_MENU = 19;
private const int ATSPI_ROLE_MENU_BAR = 20;
private const int ATSPI_ROLE_MENU_ITEM = 21;
private const int ATSPI_ROLE_SCROLL_BAR = 40;
private const int ATSPI_ROLE_SLIDER = 43;
private const int ATSPI_ROLE_SPIN_BUTTON = 44;
private const int ATSPI_ROLE_STATUS_BAR = 46;
private const int ATSPI_ROLE_PAGE_TAB = 26;
private const int ATSPI_ROLE_PAGE_TAB_LIST = 27;
private const int ATSPI_ROLE_TEXT = 49;
private const int ATSPI_ROLE_TOGGLE_BUTTON = 51;
private const int ATSPI_ROLE_TOOL_BAR = 52;
private const int ATSPI_ROLE_TOOL_TIP = 53;
private const int ATSPI_ROLE_TREE = 54;
private const int ATSPI_ROLE_TREE_ITEM = 55;
private const int ATSPI_ROLE_IMAGE = 14;
private const int ATSPI_ROLE_PROGRESS_BAR = 30;
private const int ATSPI_ROLE_SEPARATOR = 42;
private const int ATSPI_ROLE_LINK = 83;
private const int ATSPI_ROLE_TABLE = 47;
private const int ATSPI_ROLE_TABLE_CELL = 48;
private const int ATSPI_ROLE_TABLE_ROW = 89;
private const int ATSPI_ROLE_TABLE_COLUMN_HEADER = 36;
private const int ATSPI_ROLE_TABLE_ROW_HEADER = 37;
private const int ATSPI_ROLE_DIALOG = 8;
private const int ATSPI_ROLE_ALERT = 2;
private const int ATSPI_ROLE_FILLER = 10;
private const int ATSPI_ROLE_ICON = 13;
private const int ATSPI_ROLE_CANVAS = 3;
private const int ATSPI_ROLE_DIALOG = 8;
#endregion
private const int ATSPI_ROLE_ALERT = 2;
#region AT-SPI2 Interop
private const int ATSPI_ROLE_FILLER = 10;
[DllImport("libatspi.so.0")]
private static extern int atspi_init();
private const int ATSPI_ROLE_ICON = 13;
[DllImport("libatspi.so.0")]
private static extern int atspi_exit();
private const int ATSPI_ROLE_CANVAS = 3;
[DllImport("libatspi.so.0")]
private static extern nint atspi_get_desktop(int i);
public bool IsEnabled => _isEnabled;
[DllImport("libatspi.so.0")]
private static extern void atspi_set_main_context(nint context);
public AtSpi2AccessibilityService(string applicationName = "MAUI Application")
{
_applicationName = applicationName;
}
[DllImport("libgobject-2.0.so.0")]
private static extern void g_object_unref(nint obj);
public void Initialize()
{
try
{
if (atspi_init() != 0)
{
Console.WriteLine("AtSpi2AccessibilityService: Failed to initialize AT-SPI2");
return;
}
_isEnabled = CheckAccessibilityEnabled();
if (_isEnabled)
{
_registry = atspi_get_desktop(0);
RegisterApplication();
Console.WriteLine("AtSpi2AccessibilityService: Initialized successfully");
}
else
{
Console.WriteLine("AtSpi2AccessibilityService: Accessibility is not enabled");
}
}
catch (Exception ex)
{
Console.WriteLine("AtSpi2AccessibilityService: Initialization failed - " + ex.Message);
}
}
#endregion
}
/// <summary>
/// Factory for creating accessibility service instances.
/// </summary>
public static class AccessibilityServiceFactory
{
private static IAccessibilityService? _instance;
private static readonly object _lock = new();
/// <summary>
/// Gets the singleton accessibility service instance.
/// </summary>
public static IAccessibilityService Instance
{
get
{
if (_instance == null)
{
lock (_lock)
{
_instance ??= CreateService();
}
}
return _instance;
}
}
private static IAccessibilityService CreateService()
{
try
{
var service = new AtSpi2AccessibilityService();
service.Initialize();
return service;
}
catch (Exception ex)
{
Console.WriteLine($"AccessibilityServiceFactory: Failed to create AT-SPI2 service - {ex.Message}");
return new NullAccessibilityService();
}
}
/// <summary>
/// Resets the singleton instance.
/// </summary>
public static void Reset()
{
lock (_lock)
{
_instance?.Shutdown();
_instance = null;
}
}
}
/// <summary>
/// Null implementation of accessibility service.
/// </summary>
public class NullAccessibilityService : IAccessibilityService
{
public bool IsEnabled => false;
public void Initialize() { }
public void Register(IAccessible accessible) { }
public void Unregister(IAccessible accessible) { }
public void NotifyFocusChanged(IAccessible? accessible) { }
public void NotifyPropertyChanged(IAccessible accessible, AccessibleProperty property) { }
public void NotifyStateChanged(IAccessible accessible, AccessibleState state, bool value) { }
public void Announce(string text, AnnouncementPriority priority = AnnouncementPriority.Polite) { }
public void Shutdown() { }
private bool CheckAccessibilityEnabled()
{
try
{
IntPtr intPtr = atspi_get_desktop(0);
if (intPtr != IntPtr.Zero)
{
g_object_unref(intPtr);
return true;
}
}
catch
{
}
return Environment.GetEnvironmentVariable("GTK_A11Y")?.ToLowerInvariant() != "none";
}
private void RegisterApplication()
{
atspi_set_main_context(IntPtr.Zero);
}
public void Register(IAccessible accessible)
{
if (accessible != null)
{
_registeredObjects.TryAdd(accessible.AccessibleId, accessible);
}
}
public void Unregister(IAccessible accessible)
{
if (accessible != null)
{
_registeredObjects.TryRemove(accessible.AccessibleId, out IAccessible _);
}
}
public void NotifyFocusChanged(IAccessible? accessible)
{
_focusedAccessible = accessible;
if (_isEnabled && accessible != null)
{
EmitEvent("focus:", accessible);
}
}
public void NotifyPropertyChanged(IAccessible accessible, AccessibleProperty property)
{
if (_isEnabled && accessible != null)
{
string text = property switch
{
AccessibleProperty.Name => "object:property-change:accessible-name",
AccessibleProperty.Description => "object:property-change:accessible-description",
AccessibleProperty.Role => "object:property-change:accessible-role",
AccessibleProperty.Value => "object:property-change:accessible-value",
AccessibleProperty.Parent => "object:property-change:accessible-parent",
AccessibleProperty.Children => "object:children-changed",
_ => string.Empty,
};
if (!string.IsNullOrEmpty(text))
{
EmitEvent(text, accessible);
}
}
}
public void NotifyStateChanged(IAccessible accessible, AccessibleState state, bool value)
{
if (_isEnabled && accessible != null)
{
string text = state.ToString().ToLowerInvariant();
string eventName = "object:state-changed:" + text;
EmitEvent(eventName, accessible, value ? 1 : 0);
}
}
public void Announce(string text, AnnouncementPriority priority = AnnouncementPriority.Polite)
{
if (!_isEnabled || string.IsNullOrEmpty(text))
{
return;
}
try
{
Console.WriteLine($"[Accessibility Announcement ({priority})]: {text}");
}
catch (Exception ex)
{
Console.WriteLine("AtSpi2AccessibilityService: Announcement failed - " + ex.Message);
}
}
private void EmitEvent(string eventName, IAccessible accessible, int detail1 = 0, int detail2 = 0)
{
Console.WriteLine($"[AT-SPI2 Event] {eventName}: {accessible.AccessibleName} ({accessible.Role})");
}
public static int GetAtSpiRole(AccessibleRole role)
{
return role switch
{
AccessibleRole.Unknown => 0,
AccessibleRole.Window => 22,
AccessibleRole.Application => 75,
AccessibleRole.Panel => 25,
AccessibleRole.Frame => 11,
AccessibleRole.Button => 31,
AccessibleRole.CheckBox => 4,
AccessibleRole.RadioButton => 33,
AccessibleRole.ComboBox => 6,
AccessibleRole.Entry => 24,
AccessibleRole.Label => 16,
AccessibleRole.List => 17,
AccessibleRole.ListItem => 18,
AccessibleRole.Menu => 19,
AccessibleRole.MenuBar => 20,
AccessibleRole.MenuItem => 21,
AccessibleRole.ScrollBar => 40,
AccessibleRole.Slider => 43,
AccessibleRole.SpinButton => 44,
AccessibleRole.StatusBar => 46,
AccessibleRole.Tab => 26,
AccessibleRole.TabPanel => 27,
AccessibleRole.Text => 49,
AccessibleRole.ToggleButton => 51,
AccessibleRole.ToolBar => 52,
AccessibleRole.ToolTip => 53,
AccessibleRole.Tree => 54,
AccessibleRole.TreeItem => 55,
AccessibleRole.Image => 14,
AccessibleRole.ProgressBar => 30,
AccessibleRole.Separator => 42,
AccessibleRole.Link => 83,
AccessibleRole.Table => 47,
AccessibleRole.TableCell => 48,
AccessibleRole.TableRow => 89,
AccessibleRole.TableColumnHeader => 36,
AccessibleRole.TableRowHeader => 37,
AccessibleRole.PageTab => 26,
AccessibleRole.PageTabList => 27,
AccessibleRole.Dialog => 8,
AccessibleRole.Alert => 2,
AccessibleRole.Filler => 10,
AccessibleRole.Icon => 13,
AccessibleRole.Canvas => 3,
_ => 0,
};
}
public static (uint Low, uint High) GetAtSpiStates(AccessibleStates states)
{
uint num = 0u;
uint num2 = 0u;
if (states.HasFlag(AccessibleStates.Active))
{
num |= 1;
}
if (states.HasFlag(AccessibleStates.Armed))
{
num |= 2;
}
if (states.HasFlag(AccessibleStates.Busy))
{
num |= 4;
}
if (states.HasFlag(AccessibleStates.Checked))
{
num |= 8;
}
if (states.HasFlag(AccessibleStates.Collapsed))
{
num |= 0x10;
}
if (states.HasFlag(AccessibleStates.Defunct))
{
num |= 0x20;
}
if (states.HasFlag(AccessibleStates.Editable))
{
num |= 0x40;
}
if (states.HasFlag(AccessibleStates.Enabled))
{
num |= 0x80;
}
if (states.HasFlag(AccessibleStates.Expandable))
{
num |= 0x100;
}
if (states.HasFlag(AccessibleStates.Expanded))
{
num |= 0x200;
}
if (states.HasFlag(AccessibleStates.Focusable))
{
num |= 0x400;
}
if (states.HasFlag(AccessibleStates.Focused))
{
num |= 0x800;
}
if (states.HasFlag(AccessibleStates.Horizontal))
{
num |= 0x2000;
}
if (states.HasFlag(AccessibleStates.Iconified))
{
num |= 0x4000;
}
if (states.HasFlag(AccessibleStates.Modal))
{
num |= 0x8000;
}
if (states.HasFlag(AccessibleStates.MultiLine))
{
num |= 0x10000;
}
if (states.HasFlag(AccessibleStates.MultiSelectable))
{
num |= 0x20000;
}
if (states.HasFlag(AccessibleStates.Opaque))
{
num |= 0x40000;
}
if (states.HasFlag(AccessibleStates.Pressed))
{
num |= 0x80000;
}
if (states.HasFlag(AccessibleStates.Resizable))
{
num |= 0x100000;
}
if (states.HasFlag(AccessibleStates.Selectable))
{
num |= 0x200000;
}
if (states.HasFlag(AccessibleStates.Selected))
{
num |= 0x400000;
}
if (states.HasFlag(AccessibleStates.Sensitive))
{
num |= 0x800000;
}
if (states.HasFlag(AccessibleStates.Showing))
{
num |= 0x1000000;
}
if (states.HasFlag(AccessibleStates.SingleLine))
{
num |= 0x2000000;
}
if (states.HasFlag(AccessibleStates.Stale))
{
num |= 0x4000000;
}
if (states.HasFlag(AccessibleStates.Transient))
{
num |= 0x8000000;
}
if (states.HasFlag(AccessibleStates.Vertical))
{
num |= 0x10000000;
}
if (states.HasFlag(AccessibleStates.Visible))
{
num |= 0x20000000;
}
if (states.HasFlag(AccessibleStates.ManagesDescendants))
{
num |= 0x40000000;
}
if (states.HasFlag(AccessibleStates.Indeterminate))
{
num |= 0x80000000u;
}
if (states.HasFlag(AccessibleStates.Required))
{
num2 |= 1;
}
if (states.HasFlag(AccessibleStates.Truncated))
{
num2 |= 2;
}
if (states.HasFlag(AccessibleStates.Animated))
{
num2 |= 4;
}
if (states.HasFlag(AccessibleStates.InvalidEntry))
{
num2 |= 8;
}
if (states.HasFlag(AccessibleStates.SupportsAutocompletion))
{
num2 |= 0x10;
}
if (states.HasFlag(AccessibleStates.SelectableText))
{
num2 |= 0x20;
}
if (states.HasFlag(AccessibleStates.IsDefault))
{
num2 |= 0x40;
}
if (states.HasFlag(AccessibleStates.Visited))
{
num2 |= 0x80;
}
if (states.HasFlag(AccessibleStates.ReadOnly))
{
num2 |= 0x400;
}
return (Low: num, High: num2);
}
public void Shutdown()
{
Dispose();
}
public void Dispose()
{
if (!_disposed)
{
_disposed = true;
_registeredObjects.Clear();
if (_applicationAccessible != IntPtr.Zero)
{
g_object_unref(_applicationAccessible);
_applicationAccessible = IntPtr.Zero;
}
if (_registry != IntPtr.Zero)
{
g_object_unref(_registry);
_registry = IntPtr.Zero;
}
atspi_exit();
}
}
[DllImport("libatspi.so.0")]
private static extern int atspi_init();
[DllImport("libatspi.so.0")]
private static extern int atspi_exit();
[DllImport("libatspi.so.0")]
private static extern IntPtr atspi_get_desktop(int i);
[DllImport("libatspi.so.0")]
private static extern void atspi_set_main_context(IntPtr context);
[DllImport("libgobject-2.0.so.0")]
private static extern void g_object_unref(IntPtr obj);
}

View File

@@ -1,66 +1,68 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System;
using System.Diagnostics;
using System.Threading.Tasks;
using Microsoft.Maui.ApplicationModel;
namespace Microsoft.Maui.Platform.Linux.Services;
/// <summary>
/// Linux browser implementation using xdg-open.
/// </summary>
public class BrowserService : IBrowser
{
public async Task<bool> OpenAsync(string uri)
{
return await OpenAsync(new Uri(uri), BrowserLaunchMode.SystemPreferred);
}
public async Task<bool> OpenAsync(string uri)
{
return await OpenAsync(new Uri(uri), (BrowserLaunchMode)0);
}
public async Task<bool> OpenAsync(string uri, BrowserLaunchMode launchMode)
{
return await OpenAsync(new Uri(uri), launchMode);
}
public async Task<bool> OpenAsync(string uri, BrowserLaunchMode launchMode)
{
//IL_001e: Unknown result type (might be due to invalid IL or missing references)
//IL_001f: Unknown result type (might be due to invalid IL or missing references)
return await OpenAsync(new Uri(uri), launchMode);
}
public async Task<bool> OpenAsync(Uri uri)
{
return await OpenAsync(uri, BrowserLaunchMode.SystemPreferred);
}
public async Task<bool> OpenAsync(Uri uri)
{
return await OpenAsync(uri, (BrowserLaunchMode)0);
}
public async Task<bool> OpenAsync(Uri uri, BrowserLaunchMode launchMode)
{
return await OpenAsync(uri, new BrowserLaunchOptions { LaunchMode = launchMode });
}
public async Task<bool> OpenAsync(Uri uri, BrowserLaunchMode launchMode)
{
//IL_001e: Unknown result type (might be due to invalid IL or missing references)
//IL_001f: Unknown result type (might be due to invalid IL or missing references)
return await this.OpenAsync(uri, new BrowserLaunchOptions
{
LaunchMode = launchMode
});
}
public async Task<bool> OpenAsync(Uri uri, BrowserLaunchOptions options)
{
if (uri == null)
throw new ArgumentNullException(nameof(uri));
try
{
var uriString = uri.AbsoluteUri;
// Use xdg-open which respects user's default browser
var startInfo = new ProcessStartInfo
{
FileName = "xdg-open",
Arguments = $"\"{uriString}\"",
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true
};
using var process = Process.Start(startInfo);
if (process == null)
return false;
await process.WaitForExitAsync();
return process.ExitCode == 0;
}
catch
{
return false;
}
}
public async Task<bool> OpenAsync(Uri uri, BrowserLaunchOptions options)
{
if (uri == null)
{
throw new ArgumentNullException("uri");
}
try
{
string absoluteUri = uri.AbsoluteUri;
ProcessStartInfo startInfo = new ProcessStartInfo
{
FileName = "xdg-open",
Arguments = "\"" + absoluteUri + "\"",
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true
};
using Process process = Process.Start(startInfo);
if (process == null)
{
return false;
}
await process.WaitForExitAsync();
return process.ExitCode == 0;
}
catch
{
return false;
}
}
}

View File

@@ -1,206 +1,195 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System.Runtime.InteropServices;
using System;
using System.Diagnostics;
using System.Threading.Tasks;
using Microsoft.Maui.ApplicationModel.DataTransfer;
namespace Microsoft.Maui.Platform.Linux.Services;
/// <summary>
/// Linux clipboard implementation using xclip/xsel command line tools.
/// </summary>
public class ClipboardService : IClipboard
{
private string? _lastSetText;
private string? _lastSetText;
public bool HasText
{
get
{
try
{
var result = GetTextAsync().GetAwaiter().GetResult();
return !string.IsNullOrEmpty(result);
}
catch
{
return false;
}
}
}
public bool HasText
{
get
{
try
{
return !string.IsNullOrEmpty(GetTextAsync().GetAwaiter().GetResult());
}
catch
{
return false;
}
}
}
public event EventHandler<EventArgs>? ClipboardContentChanged;
public event EventHandler<EventArgs>? ClipboardContentChanged;
public async Task<string?> GetTextAsync()
{
// Try xclip first
var result = await TryGetWithXclip();
if (result != null) return result;
public async Task<string?> GetTextAsync()
{
string text = await TryGetWithXclip();
if (text != null)
{
return text;
}
return await TryGetWithXsel();
}
// Try xsel as fallback
return await TryGetWithXsel();
}
public async Task SetTextAsync(string? text)
{
_lastSetText = text;
if (string.IsNullOrEmpty(text))
{
await ClearClipboard();
return;
}
if (!(await TrySetWithXclip(text)))
{
await TrySetWithXsel(text);
}
this.ClipboardContentChanged?.Invoke(this, EventArgs.Empty);
}
public async Task SetTextAsync(string? text)
{
_lastSetText = text;
private async Task<string?> TryGetWithXclip()
{
_ = 1;
try
{
ProcessStartInfo startInfo = new ProcessStartInfo
{
FileName = "xclip",
Arguments = "-selection clipboard -o",
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true
};
using Process process = Process.Start(startInfo);
if (process == null)
{
return null;
}
string output = await process.StandardOutput.ReadToEndAsync();
await process.WaitForExitAsync();
return (process.ExitCode == 0) ? output : null;
}
catch
{
return null;
}
}
if (string.IsNullOrEmpty(text))
{
await ClearClipboard();
return;
}
private async Task<string?> TryGetWithXsel()
{
_ = 1;
try
{
ProcessStartInfo startInfo = new ProcessStartInfo
{
FileName = "xsel",
Arguments = "--clipboard --output",
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true
};
using Process process = Process.Start(startInfo);
if (process == null)
{
return null;
}
string output = await process.StandardOutput.ReadToEndAsync();
await process.WaitForExitAsync();
return (process.ExitCode == 0) ? output : null;
}
catch
{
return null;
}
}
// Try xclip first
var success = await TrySetWithXclip(text);
if (!success)
{
// Try xsel as fallback
await TrySetWithXsel(text);
}
private async Task<bool> TrySetWithXclip(string text)
{
_ = 1;
try
{
ProcessStartInfo startInfo = new ProcessStartInfo
{
FileName = "xclip",
Arguments = "-selection clipboard",
UseShellExecute = false,
RedirectStandardInput = true,
RedirectStandardError = true,
CreateNoWindow = true
};
using Process process = Process.Start(startInfo);
if (process == null)
{
return false;
}
await process.StandardInput.WriteAsync(text);
process.StandardInput.Close();
await process.WaitForExitAsync();
return process.ExitCode == 0;
}
catch
{
return false;
}
}
ClipboardContentChanged?.Invoke(this, EventArgs.Empty);
}
private async Task<bool> TrySetWithXsel(string text)
{
_ = 1;
try
{
ProcessStartInfo startInfo = new ProcessStartInfo
{
FileName = "xsel",
Arguments = "--clipboard --input",
UseShellExecute = false,
RedirectStandardInput = true,
RedirectStandardError = true,
CreateNoWindow = true
};
using Process process = Process.Start(startInfo);
if (process == null)
{
return false;
}
await process.StandardInput.WriteAsync(text);
process.StandardInput.Close();
await process.WaitForExitAsync();
return process.ExitCode == 0;
}
catch
{
return false;
}
}
private async Task<string?> TryGetWithXclip()
{
try
{
var startInfo = new ProcessStartInfo
{
FileName = "xclip",
Arguments = "-selection clipboard -o",
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true
};
using var process = Process.Start(startInfo);
if (process == null) return null;
var output = await process.StandardOutput.ReadToEndAsync();
await process.WaitForExitAsync();
return process.ExitCode == 0 ? output : null;
}
catch
{
return null;
}
}
private async Task<string?> TryGetWithXsel()
{
try
{
var startInfo = new ProcessStartInfo
{
FileName = "xsel",
Arguments = "--clipboard --output",
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true
};
using var process = Process.Start(startInfo);
if (process == null) return null;
var output = await process.StandardOutput.ReadToEndAsync();
await process.WaitForExitAsync();
return process.ExitCode == 0 ? output : null;
}
catch
{
return null;
}
}
private async Task<bool> TrySetWithXclip(string text)
{
try
{
var startInfo = new ProcessStartInfo
{
FileName = "xclip",
Arguments = "-selection clipboard",
UseShellExecute = false,
RedirectStandardInput = true,
RedirectStandardError = true,
CreateNoWindow = true
};
using var process = Process.Start(startInfo);
if (process == null) return false;
await process.StandardInput.WriteAsync(text);
process.StandardInput.Close();
await process.WaitForExitAsync();
return process.ExitCode == 0;
}
catch
{
return false;
}
}
private async Task<bool> TrySetWithXsel(string text)
{
try
{
var startInfo = new ProcessStartInfo
{
FileName = "xsel",
Arguments = "--clipboard --input",
UseShellExecute = false,
RedirectStandardInput = true,
RedirectStandardError = true,
CreateNoWindow = true
};
using var process = Process.Start(startInfo);
if (process == null) return false;
await process.StandardInput.WriteAsync(text);
process.StandardInput.Close();
await process.WaitForExitAsync();
return process.ExitCode == 0;
}
catch
{
return false;
}
}
private async Task ClearClipboard()
{
try
{
// Try xclip first
var startInfo = new ProcessStartInfo
{
FileName = "xclip",
Arguments = "-selection clipboard",
UseShellExecute = false,
RedirectStandardInput = true,
CreateNoWindow = true
};
using var process = Process.Start(startInfo);
if (process != null)
{
process.StandardInput.Close();
await process.WaitForExitAsync();
}
}
catch
{
// Ignore errors when clearing
}
}
private async Task ClearClipboard()
{
try
{
ProcessStartInfo startInfo = new ProcessStartInfo
{
FileName = "xclip",
Arguments = "-selection clipboard",
UseShellExecute = false,
RedirectStandardInput = true,
CreateNoWindow = true
};
using Process process = Process.Start(startInfo);
if (process != null)
{
process.StandardInput.Close();
await process.WaitForExitAsync();
}
}
catch
{
}
}
}

View File

@@ -0,0 +1,14 @@
namespace Microsoft.Maui.Platform.Linux.Services;
public class ColorDialogResult
{
public bool Accepted { get; init; }
public float Red { get; init; }
public float Green { get; init; }
public float Blue { get; init; }
public float Alpha { get; init; }
}

View File

@@ -0,0 +1,172 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.NetworkInformation;
using System.Net.Sockets;
using Microsoft.Maui.Networking;
namespace Microsoft.Maui.Platform.Linux.Services;
public class ConnectivityService : IConnectivity, IDisposable
{
private static readonly Lazy<ConnectivityService> _instance = new Lazy<ConnectivityService>(() => new ConnectivityService());
private NetworkAccess _networkAccess;
private IEnumerable<ConnectionProfile> _connectionProfiles;
private bool _disposed;
public static ConnectivityService Instance => _instance.Value;
public NetworkAccess NetworkAccess
{
get
{
//IL_0007: Unknown result type (might be due to invalid IL or missing references)
RefreshConnectivity();
return _networkAccess;
}
}
public IEnumerable<ConnectionProfile> ConnectionProfiles
{
get
{
RefreshConnectivity();
return _connectionProfiles;
}
}
public event EventHandler<ConnectivityChangedEventArgs>? ConnectivityChanged;
public ConnectivityService()
{
_connectionProfiles = new List<ConnectionProfile>();
RefreshConnectivity();
NetworkChange.NetworkAvailabilityChanged += OnNetworkAvailabilityChanged;
NetworkChange.NetworkAddressChanged += OnNetworkAddressChanged;
}
private void RefreshConnectivity()
{
//IL_0102: Unknown result type (might be due to invalid IL or missing references)
//IL_0034: Unknown result type (might be due to invalid IL or missing references)
//IL_00f8: Unknown result type (might be due to invalid IL or missing references)
try
{
IEnumerable<NetworkInterface> enumerable = from ni in NetworkInterface.GetAllNetworkInterfaces()
where ni.OperationalStatus == OperationalStatus.Up && ni.NetworkInterfaceType != NetworkInterfaceType.Loopback
select ni;
if (!enumerable.Any())
{
_networkAccess = (NetworkAccess)1;
_connectionProfiles = Enumerable.Empty<ConnectionProfile>();
return;
}
List<ConnectionProfile> list = new List<ConnectionProfile>();
using (IEnumerator<NetworkInterface> enumerator = enumerable.GetEnumerator())
{
while (enumerator.MoveNext())
{
switch (enumerator.Current.NetworkInterfaceType)
{
case NetworkInterfaceType.Ethernet:
case NetworkInterfaceType.FastEthernetT:
case NetworkInterfaceType.FastEthernetFx:
case NetworkInterfaceType.GigabitEthernet:
list.Add((ConnectionProfile)3);
break;
case NetworkInterfaceType.Wireless80211:
list.Add((ConnectionProfile)4);
break;
case NetworkInterfaceType.Ppp:
case NetworkInterfaceType.Slip:
list.Add((ConnectionProfile)2);
break;
default:
list.Add((ConnectionProfile)0);
break;
}
}
}
_connectionProfiles = list.Distinct().ToList();
_networkAccess = (NetworkAccess)(CheckInternetAccess() ? 4 : ((!_connectionProfiles.Any()) ? 1 : 2));
}
catch
{
_networkAccess = (NetworkAccess)0;
_connectionProfiles = (IEnumerable<ConnectionProfile>)(object)new ConnectionProfile[1];
}
}
private bool CheckInternetAccess()
{
try
{
return Dns.GetHostEntry("dns.google").AddressList.Length != 0;
}
catch
{
try
{
foreach (NetworkInterface item in from n in NetworkInterface.GetAllNetworkInterfaces()
where n.OperationalStatus == OperationalStatus.Up
select n)
{
if (item.GetIPProperties().GatewayAddresses.Any((GatewayIPAddressInformation g) => g.Address.AddressFamily == AddressFamily.InterNetwork))
{
return true;
}
}
}
catch
{
}
return false;
}
}
private void OnNetworkAvailabilityChanged(object? sender, NetworkAvailabilityEventArgs e)
{
//IL_0001: Unknown result type (might be due to invalid IL or missing references)
//IL_0019: Unknown result type (might be due to invalid IL or missing references)
//IL_003b: Unknown result type (might be due to invalid IL or missing references)
//IL_0046: Unknown result type (might be due to invalid IL or missing references)
//IL_0050: Expected O, but got Unknown
NetworkAccess networkAccess = _networkAccess;
List<ConnectionProfile> first = _connectionProfiles.ToList();
RefreshConnectivity();
if (networkAccess != _networkAccess || !first.SequenceEqual(_connectionProfiles))
{
this.ConnectivityChanged?.Invoke(this, new ConnectivityChangedEventArgs(_networkAccess, _connectionProfiles));
}
}
private void OnNetworkAddressChanged(object? sender, EventArgs e)
{
//IL_0001: Unknown result type (might be due to invalid IL or missing references)
//IL_0019: Unknown result type (might be due to invalid IL or missing references)
//IL_003b: Unknown result type (might be due to invalid IL or missing references)
//IL_0046: Unknown result type (might be due to invalid IL or missing references)
//IL_0050: Expected O, but got Unknown
NetworkAccess networkAccess = _networkAccess;
List<ConnectionProfile> first = _connectionProfiles.ToList();
RefreshConnectivity();
if (networkAccess != _networkAccess || !first.SequenceEqual(_connectionProfiles))
{
this.ConnectivityChanged?.Invoke(this, new ConnectivityChangedEventArgs(_networkAccess, _connectionProfiles));
}
}
public void Dispose()
{
if (!_disposed)
{
_disposed = true;
NetworkChange.NetworkAvailabilityChanged -= OnNetworkAvailabilityChanged;
NetworkChange.NetworkAddressChanged -= OnNetworkAddressChanged;
}
}
}

View File

@@ -0,0 +1,13 @@
namespace Microsoft.Maui.Platform.Linux.Services;
public enum DesktopEnvironment
{
Unknown,
GNOME,
KDE,
XFCE,
MATE,
Cinnamon,
LXQt,
LXDE
}

View File

@@ -0,0 +1,131 @@
using System;
using System.Diagnostics;
using Microsoft.Maui.Devices;
using Microsoft.Maui.Platform.Linux.Native;
namespace Microsoft.Maui.Platform.Linux.Services;
public class DeviceDisplayService : IDeviceDisplay
{
private static readonly Lazy<DeviceDisplayService> _instance = new Lazy<DeviceDisplayService>(() => new DeviceDisplayService());
private DisplayInfo _mainDisplayInfo;
private bool _keepScreenOn;
public static DeviceDisplayService Instance => _instance.Value;
public bool KeepScreenOn
{
get
{
return _keepScreenOn;
}
set
{
if (_keepScreenOn != value)
{
_keepScreenOn = value;
SetScreenSaverInhibit(value);
}
}
}
public DisplayInfo MainDisplayInfo
{
get
{
//IL_0007: Unknown result type (might be due to invalid IL or missing references)
RefreshDisplayInfo();
return _mainDisplayInfo;
}
}
public event EventHandler<DisplayInfoChangedEventArgs>? MainDisplayInfoChanged;
public DeviceDisplayService()
{
RefreshDisplayInfo();
}
private void RefreshDisplayInfo()
{
//IL_0097: Unknown result type (might be due to invalid IL or missing references)
//IL_009c: Unknown result type (might be due to invalid IL or missing references)
//IL_0067: Unknown result type (might be due to invalid IL or missing references)
//IL_006c: Unknown result type (might be due to invalid IL or missing references)
//IL_0038: Unknown result type (might be due to invalid IL or missing references)
//IL_003d: Unknown result type (might be due to invalid IL or missing references)
try
{
IntPtr intPtr = GdkNative.gdk_screen_get_default();
if (intPtr != IntPtr.Zero)
{
int num = GdkNative.gdk_screen_get_width(intPtr);
int num2 = GdkNative.gdk_screen_get_height(intPtr);
double scaleFactor = GetScaleFactor();
_mainDisplayInfo = new DisplayInfo((double)num, (double)num2, scaleFactor, (DisplayOrientation)((num <= num2) ? 1 : 2), (DisplayRotation)1, GetRefreshRate());
}
else
{
_mainDisplayInfo = new DisplayInfo(1920.0, 1080.0, 1.0, (DisplayOrientation)2, (DisplayRotation)1, 60f);
}
}
catch
{
_mainDisplayInfo = new DisplayInfo(1920.0, 1080.0, 1.0, (DisplayOrientation)2, (DisplayRotation)1, 60f);
}
}
private double GetScaleFactor()
{
string environmentVariable = Environment.GetEnvironmentVariable("GDK_SCALE");
if (!string.IsNullOrEmpty(environmentVariable) && double.TryParse(environmentVariable, out var result))
{
return result;
}
string environmentVariable2 = Environment.GetEnvironmentVariable("QT_SCALE_FACTOR");
if (!string.IsNullOrEmpty(environmentVariable2) && double.TryParse(environmentVariable2, out result))
{
return result;
}
return 1.0;
}
private float GetRefreshRate()
{
return 60f;
}
private void SetScreenSaverInhibit(bool inhibit)
{
try
{
string value = (inhibit ? "suspend" : "resume");
IntPtr intPtr = LinuxApplication.Current?.MainWindow?.Handle ?? IntPtr.Zero;
if (intPtr != IntPtr.Zero)
{
long value2 = intPtr.ToInt64();
Process.Start(new ProcessStartInfo
{
FileName = "xdg-screensaver",
Arguments = $"{value} {value2}",
UseShellExecute = false,
CreateNoWindow = true
});
}
}
catch
{
}
}
public void OnDisplayInfoChanged()
{
//IL_0013: Unknown result type (might be due to invalid IL or missing references)
//IL_0018: Unknown result type (might be due to invalid IL or missing references)
//IL_0022: Expected O, but got Unknown
RefreshDisplayInfo();
this.MainDisplayInfoChanged?.Invoke(this, new DisplayInfoChangedEventArgs(_mainDisplayInfo));
}
}

View File

@@ -0,0 +1,93 @@
using System;
using System.IO;
using Microsoft.Maui.Devices;
namespace Microsoft.Maui.Platform.Linux.Services;
public class DeviceInfoService : IDeviceInfo
{
private static readonly Lazy<DeviceInfoService> _instance = new Lazy<DeviceInfoService>(() => new DeviceInfoService());
private string? _model;
private string? _manufacturer;
private string? _name;
private string? _versionString;
public static DeviceInfoService Instance => _instance.Value;
public string Model => _model ?? "Linux Desktop";
public string Manufacturer => _manufacturer ?? "Unknown";
public string Name => _name ?? Environment.MachineName;
public string VersionString => _versionString ?? Environment.OSVersion.VersionString;
public Version Version
{
get
{
try
{
if (System.Version.TryParse(Environment.OSVersion.Version.ToString(), out Version result))
{
return result;
}
}
catch
{
}
return new Version(1, 0);
}
}
public DevicePlatform Platform => DevicePlatform.Create("Linux");
public DeviceIdiom Idiom => DeviceIdiom.Desktop;
public DeviceType DeviceType => (DeviceType)1;
public DeviceInfoService()
{
LoadDeviceInfo();
}
private void LoadDeviceInfo()
{
try
{
if (File.Exists("/sys/class/dmi/id/product_name"))
{
_model = File.ReadAllText("/sys/class/dmi/id/product_name").Trim();
}
if (File.Exists("/sys/class/dmi/id/sys_vendor"))
{
_manufacturer = File.ReadAllText("/sys/class/dmi/id/sys_vendor").Trim();
}
_name = Environment.MachineName;
_versionString = Environment.OSVersion.VersionString;
}
catch
{
if (_model == null)
{
_model = "Linux Desktop";
}
if (_manufacturer == null)
{
_manufacturer = "Unknown";
}
if (_name == null)
{
_name = "localhost";
}
if (_versionString == null)
{
_versionString = "Linux";
}
}
}
}

View File

@@ -1,274 +1,100 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System.Runtime.InteropServices;
using Microsoft.Maui.Platform.Linux.Window;
using Microsoft.Maui.Platform.Linux.Rendering;
using System;
namespace Microsoft.Maui.Platform.Linux.Services;
/// <summary>
/// Supported display server types.
/// </summary>
public enum DisplayServerType
{
Auto,
X11,
Wayland
}
/// <summary>
/// Factory for creating display server connections.
/// Supports X11 and Wayland display servers.
/// </summary>
public static class DisplayServerFactory
{
private static DisplayServerType? _cachedServerType;
private static DisplayServerType? _cachedServerType;
/// <summary>
/// Detects the current display server type.
/// </summary>
public static DisplayServerType DetectDisplayServer()
{
if (_cachedServerType.HasValue)
return _cachedServerType.Value;
public static DisplayServerType DetectDisplayServer()
{
if (_cachedServerType.HasValue)
{
return _cachedServerType.Value;
}
if (!string.IsNullOrEmpty(Environment.GetEnvironmentVariable("WAYLAND_DISPLAY")))
{
string? environmentVariable = Environment.GetEnvironmentVariable("DISPLAY");
string environmentVariable2 = Environment.GetEnvironmentVariable("MAUI_PREFER_X11");
if (!string.IsNullOrEmpty(environmentVariable) && !string.IsNullOrEmpty(environmentVariable2))
{
Console.WriteLine("[DisplayServer] XWayland detected, using X11 backend (MAUI_PREFER_X11 set)");
_cachedServerType = DisplayServerType.X11;
return DisplayServerType.X11;
}
Console.WriteLine("[DisplayServer] Wayland display detected");
_cachedServerType = DisplayServerType.Wayland;
return DisplayServerType.Wayland;
}
if (!string.IsNullOrEmpty(Environment.GetEnvironmentVariable("DISPLAY")))
{
Console.WriteLine("[DisplayServer] X11 display detected");
_cachedServerType = DisplayServerType.X11;
return DisplayServerType.X11;
}
Console.WriteLine("[DisplayServer] No display server detected, defaulting to X11");
_cachedServerType = DisplayServerType.X11;
return DisplayServerType.X11;
}
// Check for Wayland first (modern default)
var waylandDisplay = Environment.GetEnvironmentVariable("WAYLAND_DISPLAY");
if (!string.IsNullOrEmpty(waylandDisplay))
{
// Check if XWayland is available - prefer it for now until native Wayland is fully tested
var xDisplay = Environment.GetEnvironmentVariable("DISPLAY");
var preferX11 = Environment.GetEnvironmentVariable("MAUI_PREFER_X11");
if (!string.IsNullOrEmpty(xDisplay) && !string.IsNullOrEmpty(preferX11))
{
Console.WriteLine("[DisplayServer] XWayland detected, using X11 backend (MAUI_PREFER_X11 set)");
_cachedServerType = DisplayServerType.X11;
return DisplayServerType.X11;
}
public static IDisplayWindow CreateWindow(string title, int width, int height, DisplayServerType serverType = DisplayServerType.Auto)
{
if (serverType == DisplayServerType.Auto)
{
serverType = DetectDisplayServer();
}
return serverType switch
{
DisplayServerType.X11 => CreateX11Window(title, width, height),
DisplayServerType.Wayland => CreateWaylandWindow(title, width, height),
_ => CreateX11Window(title, width, height),
};
}
Console.WriteLine("[DisplayServer] Wayland display detected");
_cachedServerType = DisplayServerType.Wayland;
return DisplayServerType.Wayland;
}
private static IDisplayWindow CreateX11Window(string title, int width, int height)
{
try
{
Console.WriteLine($"[DisplayServer] Creating X11 window: {title} ({width}x{height})");
return new X11DisplayWindow(title, width, height);
}
catch (Exception ex)
{
Console.WriteLine("[DisplayServer] Failed to create X11 window: " + ex.Message);
throw;
}
}
// Fall back to X11
var x11Display = Environment.GetEnvironmentVariable("DISPLAY");
if (!string.IsNullOrEmpty(x11Display))
{
Console.WriteLine("[DisplayServer] X11 display detected");
_cachedServerType = DisplayServerType.X11;
return DisplayServerType.X11;
}
private static IDisplayWindow CreateWaylandWindow(string title, int width, int height)
{
try
{
Console.WriteLine($"[DisplayServer] Creating Wayland window: {title} ({width}x{height})");
return new WaylandDisplayWindow(title, width, height);
}
catch (Exception ex)
{
Console.WriteLine("[DisplayServer] Failed to create Wayland window: " + ex.Message);
if (!string.IsNullOrEmpty(Environment.GetEnvironmentVariable("DISPLAY")))
{
Console.WriteLine("[DisplayServer] Falling back to X11 (XWayland)");
return CreateX11Window(title, width, height);
}
throw;
}
}
// Default to X11 and let it fail if not available
Console.WriteLine("[DisplayServer] No display server detected, defaulting to X11");
_cachedServerType = DisplayServerType.X11;
return DisplayServerType.X11;
}
/// <summary>
/// Creates a window for the specified or detected display server.
/// </summary>
public static IDisplayWindow CreateWindow(string title, int width, int height, DisplayServerType serverType = DisplayServerType.Auto)
{
if (serverType == DisplayServerType.Auto)
{
serverType = DetectDisplayServer();
}
return serverType switch
{
DisplayServerType.X11 => CreateX11Window(title, width, height),
DisplayServerType.Wayland => CreateWaylandWindow(title, width, height),
_ => CreateX11Window(title, width, height)
};
}
private static IDisplayWindow CreateX11Window(string title, int width, int height)
{
try
{
Console.WriteLine($"[DisplayServer] Creating X11 window: {title} ({width}x{height})");
return new X11DisplayWindow(title, width, height);
}
catch (Exception ex)
{
Console.WriteLine($"[DisplayServer] Failed to create X11 window: {ex.Message}");
throw;
}
}
private static IDisplayWindow CreateWaylandWindow(string title, int width, int height)
{
try
{
Console.WriteLine($"[DisplayServer] Creating Wayland window: {title} ({width}x{height})");
return new WaylandDisplayWindow(title, width, height);
}
catch (Exception ex)
{
Console.WriteLine($"[DisplayServer] Failed to create Wayland window: {ex.Message}");
// Try to fall back to X11 via XWayland
var xDisplay = Environment.GetEnvironmentVariable("DISPLAY");
if (!string.IsNullOrEmpty(xDisplay))
{
Console.WriteLine("[DisplayServer] Falling back to X11 (XWayland)");
return CreateX11Window(title, width, height);
}
throw;
}
}
/// <summary>
/// Gets a human-readable name for the display server.
/// </summary>
public static string GetDisplayServerName(DisplayServerType serverType = DisplayServerType.Auto)
{
if (serverType == DisplayServerType.Auto)
serverType = DetectDisplayServer();
return serverType switch
{
DisplayServerType.X11 => "X11",
DisplayServerType.Wayland => "Wayland",
_ => "Unknown"
};
}
}
/// <summary>
/// Common interface for display server windows.
/// </summary>
public interface IDisplayWindow : IDisposable
{
int Width { get; }
int Height { get; }
bool IsRunning { get; }
void Show();
void Hide();
void SetTitle(string title);
void Resize(int width, int height);
void ProcessEvents();
void Stop();
event EventHandler<KeyEventArgs>? KeyDown;
event EventHandler<KeyEventArgs>? KeyUp;
event EventHandler<TextInputEventArgs>? TextInput;
event EventHandler<PointerEventArgs>? PointerMoved;
event EventHandler<PointerEventArgs>? PointerPressed;
event EventHandler<PointerEventArgs>? PointerReleased;
event EventHandler<ScrollEventArgs>? Scroll;
event EventHandler? Exposed;
event EventHandler<(int Width, int Height)>? Resized;
event EventHandler? CloseRequested;
}
/// <summary>
/// X11 display window wrapper implementing the common interface.
/// </summary>
public class X11DisplayWindow : IDisplayWindow
{
private readonly X11Window _window;
public int Width => _window.Width;
public int Height => _window.Height;
public bool IsRunning => _window.IsRunning;
public event EventHandler<KeyEventArgs>? KeyDown;
public event EventHandler<KeyEventArgs>? KeyUp;
public event EventHandler<TextInputEventArgs>? TextInput;
public event EventHandler<PointerEventArgs>? PointerMoved;
public event EventHandler<PointerEventArgs>? PointerPressed;
public event EventHandler<PointerEventArgs>? PointerReleased;
public event EventHandler<ScrollEventArgs>? Scroll;
public event EventHandler? Exposed;
public event EventHandler<(int Width, int Height)>? Resized;
public event EventHandler? CloseRequested;
public X11DisplayWindow(string title, int width, int height)
{
_window = new X11Window(title, width, height);
_window.KeyDown += (s, e) => KeyDown?.Invoke(this, e);
_window.KeyUp += (s, e) => KeyUp?.Invoke(this, e);
_window.TextInput += (s, e) => TextInput?.Invoke(this, e);
_window.PointerMoved += (s, e) => PointerMoved?.Invoke(this, e);
_window.PointerPressed += (s, e) => PointerPressed?.Invoke(this, e);
_window.PointerReleased += (s, e) => PointerReleased?.Invoke(this, e);
_window.Scroll += (s, e) => Scroll?.Invoke(this, e);
_window.Exposed += (s, e) => Exposed?.Invoke(this, e);
_window.Resized += (s, e) => Resized?.Invoke(this, e);
_window.CloseRequested += (s, e) => CloseRequested?.Invoke(this, e);
}
public void Show() => _window.Show();
public void Hide() => _window.Hide();
public void SetTitle(string title) => _window.SetTitle(title);
public void Resize(int width, int height) => _window.Resize(width, height);
public void ProcessEvents() => _window.ProcessEvents();
public void Stop() => _window.Stop();
public void Dispose() => _window.Dispose();
}
/// <summary>
/// Wayland display window wrapper implementing IDisplayWindow.
/// Uses the full WaylandWindow implementation with xdg-shell protocol.
/// </summary>
public class WaylandDisplayWindow : IDisplayWindow
{
private readonly WaylandWindow _window;
public int Width => _window.Width;
public int Height => _window.Height;
public bool IsRunning => _window.IsRunning;
/// <summary>
/// Gets the pixel data pointer for rendering.
/// </summary>
public IntPtr PixelData => _window.PixelData;
/// <summary>
/// Gets the stride (bytes per row) of the pixel buffer.
/// </summary>
public int Stride => _window.Stride;
public event EventHandler<KeyEventArgs>? KeyDown;
public event EventHandler<KeyEventArgs>? KeyUp;
public event EventHandler<TextInputEventArgs>? TextInput;
public event EventHandler<PointerEventArgs>? PointerMoved;
public event EventHandler<PointerEventArgs>? PointerPressed;
public event EventHandler<PointerEventArgs>? PointerReleased;
public event EventHandler<ScrollEventArgs>? Scroll;
public event EventHandler? Exposed;
public event EventHandler<(int Width, int Height)>? Resized;
public event EventHandler? CloseRequested;
public WaylandDisplayWindow(string title, int width, int height)
{
_window = new WaylandWindow(title, width, height);
// Wire up events
_window.KeyDown += (s, e) => KeyDown?.Invoke(this, e);
_window.KeyUp += (s, e) => KeyUp?.Invoke(this, e);
_window.TextInput += (s, e) => TextInput?.Invoke(this, e);
_window.PointerMoved += (s, e) => PointerMoved?.Invoke(this, e);
_window.PointerPressed += (s, e) => PointerPressed?.Invoke(this, e);
_window.PointerReleased += (s, e) => PointerReleased?.Invoke(this, e);
_window.Scroll += (s, e) => Scroll?.Invoke(this, e);
_window.Exposed += (s, e) => Exposed?.Invoke(this, e);
_window.Resized += (s, e) => Resized?.Invoke(this, e);
_window.CloseRequested += (s, e) => CloseRequested?.Invoke(this, e);
}
public void Show() => _window.Show();
public void Hide() => _window.Hide();
public void SetTitle(string title) => _window.SetTitle(title);
public void Resize(int width, int height) => _window.Resize(width, height);
public void ProcessEvents() => _window.ProcessEvents();
public void Stop() => _window.Stop();
public void CommitFrame() => _window.CommitFrame();
public void Dispose() => _window.Dispose();
public static string GetDisplayServerName(DisplayServerType serverType = DisplayServerType.Auto)
{
if (serverType == DisplayServerType.Auto)
{
serverType = DetectDisplayServer();
}
return serverType switch
{
DisplayServerType.X11 => "X11",
DisplayServerType.Wayland => "Wayland",
_ => "Unknown",
};
}
}

View File

@@ -0,0 +1,8 @@
namespace Microsoft.Maui.Platform.Linux.Services;
public enum DisplayServerType
{
Auto,
X11,
Wayland
}

9
Services/DragAction.cs Normal file
View File

@@ -0,0 +1,9 @@
namespace Microsoft.Maui.Platform.Linux.Services;
public enum DragAction
{
None,
Copy,
Move,
Link
}

16
Services/DragData.cs Normal file
View File

@@ -0,0 +1,16 @@
using System;
namespace Microsoft.Maui.Platform.Linux.Services;
public class DragData
{
public IntPtr SourceWindow { get; set; }
public IntPtr[] SupportedTypes { get; set; } = Array.Empty<IntPtr>();
public string? Text { get; set; }
public string[]? FilePaths { get; set; }
public object? Data { get; set; }
}

View File

@@ -1,516 +1,361 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System;
using System.Collections.Generic;
using System.Runtime.InteropServices;
using System.Text;
namespace Microsoft.Maui.Platform.Linux.Services;
/// <summary>
/// Provides drag and drop functionality using the X11 XDND protocol.
/// </summary>
public class DragDropService : IDisposable
{
private nint _display;
private nint _window;
private bool _isDragging;
private DragData? _currentDragData;
private nint _dragSource;
private nint _dragTarget;
private bool _disposed;
// XDND atoms
private nint _xdndAware;
private nint _xdndEnter;
private nint _xdndPosition;
private nint _xdndStatus;
private nint _xdndLeave;
private nint _xdndDrop;
private nint _xdndFinished;
private nint _xdndSelection;
private nint _xdndActionCopy;
private nint _xdndActionMove;
private nint _xdndActionLink;
private nint _xdndTypeList;
// Common MIME types
private nint _textPlain;
private nint _textUri;
private nint _applicationOctetStream;
/// <summary>
/// Gets whether a drag operation is in progress.
/// </summary>
public bool IsDragging => _isDragging;
/// <summary>
/// Event raised when a drag enters the window.
/// </summary>
public event EventHandler<DragEventArgs>? DragEnter;
/// <summary>
/// Event raised when dragging over the window.
/// </summary>
public event EventHandler<DragEventArgs>? DragOver;
/// <summary>
/// Event raised when a drag leaves the window.
/// </summary>
public event EventHandler? DragLeave;
/// <summary>
/// Event raised when a drop occurs.
/// </summary>
public event EventHandler<DropEventArgs>? Drop;
/// <summary>
/// Initializes the drag drop service for the specified window.
/// </summary>
public void Initialize(nint display, nint window)
{
_display = display;
_window = window;
InitializeAtoms();
SetXdndAware();
}
private void InitializeAtoms()
{
_xdndAware = XInternAtom(_display, "XdndAware", false);
_xdndEnter = XInternAtom(_display, "XdndEnter", false);
_xdndPosition = XInternAtom(_display, "XdndPosition", false);
_xdndStatus = XInternAtom(_display, "XdndStatus", false);
_xdndLeave = XInternAtom(_display, "XdndLeave", false);
_xdndDrop = XInternAtom(_display, "XdndDrop", false);
_xdndFinished = XInternAtom(_display, "XdndFinished", false);
_xdndSelection = XInternAtom(_display, "XdndSelection", false);
_xdndActionCopy = XInternAtom(_display, "XdndActionCopy", false);
_xdndActionMove = XInternAtom(_display, "XdndActionMove", false);
_xdndActionLink = XInternAtom(_display, "XdndActionLink", false);
_xdndTypeList = XInternAtom(_display, "XdndTypeList", false);
_textPlain = XInternAtom(_display, "text/plain", false);
_textUri = XInternAtom(_display, "text/uri-list", false);
_applicationOctetStream = XInternAtom(_display, "application/octet-stream", false);
}
private void SetXdndAware()
{
// Set XdndAware property to indicate we support XDND version 5
int version = 5;
XChangeProperty(_display, _window, _xdndAware, XA_ATOM, 32,
PropModeReplace, ref version, 1);
}
/// <summary>
/// Processes an X11 client message for drag and drop.
/// </summary>
public bool ProcessClientMessage(nint messageType, nint[] data)
{
if (messageType == _xdndEnter)
{
return HandleXdndEnter(data);
}
else if (messageType == _xdndPosition)
{
return HandleXdndPosition(data);
}
else if (messageType == _xdndLeave)
{
return HandleXdndLeave(data);
}
else if (messageType == _xdndDrop)
{
return HandleXdndDrop(data);
}
return false;
}
private bool HandleXdndEnter(nint[] data)
{
_dragSource = data[0];
int version = (int)((data[1] >> 24) & 0xFF);
bool hasTypeList = (data[1] & 1) != 0;
var types = new List<nint>();
if (hasTypeList)
{
// Get types from XdndTypeList property
types = GetTypeList(_dragSource);
}
else
{
// Types are in the message
for (int i = 2; i < 5; i++)
{
if (data[i] != IntPtr.Zero)
{
types.Add(data[i]);
}
}
}
_currentDragData = new DragData
{
SourceWindow = _dragSource,
SupportedTypes = types.ToArray()
};
DragEnter?.Invoke(this, new DragEventArgs(_currentDragData, 0, 0));
return true;
}
private bool HandleXdndPosition(nint[] data)
{
if (_currentDragData == null) return false;
int x = (int)((data[2] >> 16) & 0xFFFF);
int y = (int)(data[2] & 0xFFFF);
nint action = data[4];
var eventArgs = new DragEventArgs(_currentDragData, x, y)
{
AllowedAction = GetDragAction(action)
};
DragOver?.Invoke(this, eventArgs);
// Send XdndStatus reply
SendXdndStatus(eventArgs.Accepted, eventArgs.AcceptedAction);
return true;
}
private bool HandleXdndLeave(nint[] data)
{
_currentDragData = null;
_dragSource = IntPtr.Zero;
DragLeave?.Invoke(this, EventArgs.Empty);
return true;
}
private bool HandleXdndDrop(nint[] data)
{
if (_currentDragData == null) return false;
uint timestamp = (uint)data[2];
// Request the data
string? droppedData = RequestDropData(timestamp);
var eventArgs = new DropEventArgs(_currentDragData, droppedData);
Drop?.Invoke(this, eventArgs);
// Send XdndFinished
SendXdndFinished(eventArgs.Handled);
_currentDragData = null;
_dragSource = IntPtr.Zero;
return true;
}
private List<nint> GetTypeList(nint window)
{
var types = new List<nint>();
nint actualType;
int actualFormat;
nint nitems, bytesAfter;
nint data;
int result = XGetWindowProperty(_display, window, _xdndTypeList, 0, 1024, false,
XA_ATOM, out actualType, out actualFormat, out nitems, out bytesAfter, out data);
if (result == 0 && data != IntPtr.Zero)
{
for (int i = 0; i < (int)nitems; i++)
{
nint atom = Marshal.ReadIntPtr(data, i * IntPtr.Size);
types.Add(atom);
}
XFree(data);
}
return types;
}
private void SendXdndStatus(bool accepted, DragAction action)
{
var ev = new XClientMessageEvent
{
type = ClientMessage,
window = _dragSource,
message_type = _xdndStatus,
format = 32
};
ev.data0 = _window;
ev.data1 = accepted ? 1 : 0;
ev.data2 = 0; // x, y of rectangle
ev.data3 = 0; // width, height of rectangle
ev.data4 = GetActionAtom(action);
XSendEvent(_display, _dragSource, false, 0, ref ev);
XFlush(_display);
}
private void SendXdndFinished(bool accepted)
{
var ev = new XClientMessageEvent
{
type = ClientMessage,
window = _dragSource,
message_type = _xdndFinished,
format = 32
};
ev.data0 = _window;
ev.data1 = accepted ? 1 : 0;
ev.data2 = accepted ? _xdndActionCopy : IntPtr.Zero;
XSendEvent(_display, _dragSource, false, 0, ref ev);
XFlush(_display);
}
private string? RequestDropData(uint timestamp)
{
// Convert selection to get the data
nint targetType = _textPlain;
// Check if text/uri-list is available
if (_currentDragData != null)
{
foreach (var type in _currentDragData.SupportedTypes)
{
if (type == _textUri)
{
targetType = _textUri;
break;
}
}
}
// Request selection conversion
XConvertSelection(_display, _xdndSelection, targetType, _xdndSelection, _window, timestamp);
XFlush(_display);
// In a real implementation, we would wait for SelectionNotify event
// and then get the data. For simplicity, we return null here.
// The actual data retrieval requires an event loop integration.
return null;
}
private DragAction GetDragAction(nint atom)
{
if (atom == _xdndActionCopy) return DragAction.Copy;
if (atom == _xdndActionMove) return DragAction.Move;
if (atom == _xdndActionLink) return DragAction.Link;
return DragAction.None;
}
private nint GetActionAtom(DragAction action)
{
return action switch
{
DragAction.Copy => _xdndActionCopy,
DragAction.Move => _xdndActionMove,
DragAction.Link => _xdndActionLink,
_ => IntPtr.Zero
};
}
/// <summary>
/// Starts a drag operation.
/// </summary>
public void StartDrag(DragData data)
{
if (_isDragging) return;
_isDragging = true;
_currentDragData = data;
// Set the drag cursor and initiate the drag
// This requires integration with the X11 event loop
}
/// <summary>
/// Cancels the current drag operation.
/// </summary>
public void CancelDrag()
{
_isDragging = false;
_currentDragData = null;
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
}
#region X11 Interop
private const int ClientMessage = 33;
private const int PropModeReplace = 0;
private static readonly nint XA_ATOM = (nint)4;
[StructLayout(LayoutKind.Sequential)]
private struct XClientMessageEvent
{
public int type;
public ulong serial;
public bool send_event;
public nint display;
public nint window;
public nint message_type;
public int format;
public nint data0;
public nint data1;
public nint data2;
public nint data3;
public nint data4;
}
[DllImport("libX11.so.6")]
private static extern nint XInternAtom(nint display, string atomName, bool onlyIfExists);
[DllImport("libX11.so.6")]
private static extern int XChangeProperty(nint display, nint window, nint property, nint type,
int format, int mode, ref int data, int nelements);
[DllImport("libX11.so.6")]
private static extern int XGetWindowProperty(nint display, nint window, nint property,
long offset, long length, bool delete, nint reqType,
out nint actualType, out int actualFormat, out nint nitems, out nint bytesAfter, out nint data);
[DllImport("libX11.so.6")]
private static extern int XSendEvent(nint display, nint window, bool propagate, long eventMask, ref XClientMessageEvent xevent);
[DllImport("libX11.so.6")]
private static extern int XConvertSelection(nint display, nint selection, nint target, nint property, nint requestor, uint time);
[DllImport("libX11.so.6")]
private static extern void XFree(nint ptr);
[DllImport("libX11.so.6")]
private static extern void XFlush(nint display);
#endregion
}
/// <summary>
/// Contains data for a drag operation.
/// </summary>
public class DragData
{
/// <summary>
/// Gets or sets the source window.
/// </summary>
public nint SourceWindow { get; set; }
/// <summary>
/// Gets or sets the supported MIME types.
/// </summary>
public nint[] SupportedTypes { get; set; } = Array.Empty<nint>();
/// <summary>
/// Gets or sets the text data.
/// </summary>
public string? Text { get; set; }
/// <summary>
/// Gets or sets the file paths.
/// </summary>
public string[]? FilePaths { get; set; }
/// <summary>
/// Gets or sets custom data.
/// </summary>
public object? Data { get; set; }
}
/// <summary>
/// Event args for drag events.
/// </summary>
public class DragEventArgs : EventArgs
{
/// <summary>
/// Gets the drag data.
/// </summary>
public DragData Data { get; }
/// <summary>
/// Gets the X coordinate.
/// </summary>
public int X { get; }
/// <summary>
/// Gets the Y coordinate.
/// </summary>
public int Y { get; }
/// <summary>
/// Gets or sets whether the drop is accepted.
/// </summary>
public bool Accepted { get; set; }
/// <summary>
/// Gets or sets the allowed action.
/// </summary>
public DragAction AllowedAction { get; set; }
/// <summary>
/// Gets or sets the accepted action.
/// </summary>
public DragAction AcceptedAction { get; set; } = DragAction.Copy;
public DragEventArgs(DragData data, int x, int y)
{
Data = data;
X = x;
Y = y;
}
}
/// <summary>
/// Event args for drop events.
/// </summary>
public class DropEventArgs : EventArgs
{
/// <summary>
/// Gets the drag data.
/// </summary>
public DragData Data { get; }
/// <summary>
/// Gets the dropped data as string.
/// </summary>
public string? DroppedData { get; }
/// <summary>
/// Gets or sets whether the drop was handled.
/// </summary>
public bool Handled { get; set; }
public DropEventArgs(DragData data, string? droppedData)
{
Data = data;
DroppedData = droppedData;
}
}
/// <summary>
/// Drag action types.
/// </summary>
public enum DragAction
{
None,
Copy,
Move,
Link
private struct XClientMessageEvent
{
public int type;
public ulong serial;
public bool send_event;
public IntPtr display;
public IntPtr window;
public IntPtr message_type;
public int format;
public IntPtr data0;
public IntPtr data1;
public IntPtr data2;
public IntPtr data3;
public IntPtr data4;
}
private IntPtr _display;
private IntPtr _window;
private bool _isDragging;
private DragData? _currentDragData;
private IntPtr _dragSource;
private IntPtr _dragTarget;
private bool _disposed;
private IntPtr _xdndAware;
private IntPtr _xdndEnter;
private IntPtr _xdndPosition;
private IntPtr _xdndStatus;
private IntPtr _xdndLeave;
private IntPtr _xdndDrop;
private IntPtr _xdndFinished;
private IntPtr _xdndSelection;
private IntPtr _xdndActionCopy;
private IntPtr _xdndActionMove;
private IntPtr _xdndActionLink;
private IntPtr _xdndTypeList;
private IntPtr _textPlain;
private IntPtr _textUri;
private IntPtr _applicationOctetStream;
private const int ClientMessage = 33;
private const int PropModeReplace = 0;
private static readonly IntPtr XA_ATOM = (IntPtr)4;
public bool IsDragging => _isDragging;
public event EventHandler<DragEventArgs>? DragEnter;
public event EventHandler<DragEventArgs>? DragOver;
public event EventHandler? DragLeave;
public event EventHandler<DropEventArgs>? Drop;
public void Initialize(IntPtr display, IntPtr window)
{
_display = display;
_window = window;
InitializeAtoms();
SetXdndAware();
}
private void InitializeAtoms()
{
_xdndAware = XInternAtom(_display, "XdndAware", onlyIfExists: false);
_xdndEnter = XInternAtom(_display, "XdndEnter", onlyIfExists: false);
_xdndPosition = XInternAtom(_display, "XdndPosition", onlyIfExists: false);
_xdndStatus = XInternAtom(_display, "XdndStatus", onlyIfExists: false);
_xdndLeave = XInternAtom(_display, "XdndLeave", onlyIfExists: false);
_xdndDrop = XInternAtom(_display, "XdndDrop", onlyIfExists: false);
_xdndFinished = XInternAtom(_display, "XdndFinished", onlyIfExists: false);
_xdndSelection = XInternAtom(_display, "XdndSelection", onlyIfExists: false);
_xdndActionCopy = XInternAtom(_display, "XdndActionCopy", onlyIfExists: false);
_xdndActionMove = XInternAtom(_display, "XdndActionMove", onlyIfExists: false);
_xdndActionLink = XInternAtom(_display, "XdndActionLink", onlyIfExists: false);
_xdndTypeList = XInternAtom(_display, "XdndTypeList", onlyIfExists: false);
_textPlain = XInternAtom(_display, "text/plain", onlyIfExists: false);
_textUri = XInternAtom(_display, "text/uri-list", onlyIfExists: false);
_applicationOctetStream = XInternAtom(_display, "application/octet-stream", onlyIfExists: false);
}
private void SetXdndAware()
{
int data = 5;
XChangeProperty(_display, _window, _xdndAware, XA_ATOM, 32, 0, ref data, 1);
}
public bool ProcessClientMessage(IntPtr messageType, IntPtr[] data)
{
if (messageType == _xdndEnter)
{
return HandleXdndEnter(data);
}
if (messageType == _xdndPosition)
{
return HandleXdndPosition(data);
}
if (messageType == _xdndLeave)
{
return HandleXdndLeave(data);
}
if (messageType == _xdndDrop)
{
return HandleXdndDrop(data);
}
return false;
}
private bool HandleXdndEnter(IntPtr[] data)
{
_dragSource = data[0];
_ = data[1];
bool num = ((nint)data[1] & 1) != 0;
List<IntPtr> list = new List<IntPtr>();
if (num)
{
list = GetTypeList(_dragSource);
}
else
{
for (int i = 2; i < 5; i++)
{
if (data[i] != IntPtr.Zero)
{
list.Add(data[i]);
}
}
}
_currentDragData = new DragData
{
SourceWindow = _dragSource,
SupportedTypes = list.ToArray()
};
this.DragEnter?.Invoke(this, new DragEventArgs(_currentDragData, 0, 0));
return true;
}
private bool HandleXdndPosition(IntPtr[] data)
{
if (_currentDragData == null)
{
return false;
}
int x = (int)(((nint)data[2] >> 16) & 0xFFFF);
int y = (int)((nint)data[2] & 0xFFFF);
IntPtr atom = data[4];
DragEventArgs e = new DragEventArgs(_currentDragData, x, y)
{
AllowedAction = GetDragAction(atom)
};
this.DragOver?.Invoke(this, e);
SendXdndStatus(e.Accepted, e.AcceptedAction);
return true;
}
private bool HandleXdndLeave(IntPtr[] data)
{
_currentDragData = null;
_dragSource = IntPtr.Zero;
this.DragLeave?.Invoke(this, EventArgs.Empty);
return true;
}
private bool HandleXdndDrop(IntPtr[] data)
{
if (_currentDragData == null)
{
return false;
}
uint timestamp = (uint)(nint)data[2];
string droppedData = RequestDropData(timestamp);
DropEventArgs e = new DropEventArgs(_currentDragData, droppedData);
this.Drop?.Invoke(this, e);
SendXdndFinished(e.Handled);
_currentDragData = null;
_dragSource = IntPtr.Zero;
return true;
}
private List<IntPtr> GetTypeList(IntPtr window)
{
List<IntPtr> list = new List<IntPtr>();
if (XGetWindowProperty(_display, window, _xdndTypeList, 0L, 1024L, delete: false, XA_ATOM, out var _, out var _, out var nitems, out var _, out var data) == 0 && data != IntPtr.Zero)
{
for (int i = 0; i < (int)(nint)nitems; i++)
{
IntPtr item = Marshal.ReadIntPtr(data, i * IntPtr.Size);
list.Add(item);
}
XFree(data);
}
return list;
}
private void SendXdndStatus(bool accepted, DragAction action)
{
XClientMessageEvent xevent = new XClientMessageEvent
{
type = 33,
window = _dragSource,
message_type = _xdndStatus,
format = 32
};
xevent.data0 = _window;
xevent.data1 = (IntPtr)(accepted ? 1 : 0);
xevent.data2 = (IntPtr)0;
xevent.data3 = (IntPtr)0;
xevent.data4 = GetActionAtom(action);
XSendEvent(_display, _dragSource, propagate: false, 0L, ref xevent);
XFlush(_display);
}
private void SendXdndFinished(bool accepted)
{
XClientMessageEvent xevent = new XClientMessageEvent
{
type = 33,
window = _dragSource,
message_type = _xdndFinished,
format = 32
};
xevent.data0 = _window;
xevent.data1 = (IntPtr)(accepted ? 1 : 0);
xevent.data2 = (accepted ? _xdndActionCopy : IntPtr.Zero);
XSendEvent(_display, _dragSource, propagate: false, 0L, ref xevent);
XFlush(_display);
}
private string? RequestDropData(uint timestamp)
{
IntPtr target = _textPlain;
if (_currentDragData != null)
{
IntPtr[] supportedTypes = _currentDragData.SupportedTypes;
for (int i = 0; i < supportedTypes.Length; i++)
{
if (supportedTypes[i] == _textUri)
{
target = _textUri;
break;
}
}
}
XConvertSelection(_display, _xdndSelection, target, _xdndSelection, _window, timestamp);
XFlush(_display);
return null;
}
private DragAction GetDragAction(IntPtr atom)
{
if (atom == _xdndActionCopy)
{
return DragAction.Copy;
}
if (atom == _xdndActionMove)
{
return DragAction.Move;
}
if (atom == _xdndActionLink)
{
return DragAction.Link;
}
return DragAction.None;
}
private IntPtr GetActionAtom(DragAction action)
{
return action switch
{
DragAction.Copy => _xdndActionCopy,
DragAction.Move => _xdndActionMove,
DragAction.Link => _xdndActionLink,
_ => IntPtr.Zero,
};
}
public void StartDrag(DragData data)
{
if (!_isDragging)
{
_isDragging = true;
_currentDragData = data;
}
}
public void CancelDrag()
{
_isDragging = false;
_currentDragData = null;
}
public void Dispose()
{
if (!_disposed)
{
_disposed = true;
}
}
[DllImport("libX11.so.6")]
private static extern IntPtr XInternAtom(IntPtr display, string atomName, bool onlyIfExists);
[DllImport("libX11.so.6")]
private static extern int XChangeProperty(IntPtr display, IntPtr window, IntPtr property, IntPtr type, int format, int mode, ref int data, int nelements);
[DllImport("libX11.so.6")]
private static extern int XGetWindowProperty(IntPtr display, IntPtr window, IntPtr property, long offset, long length, bool delete, IntPtr reqType, out IntPtr actualType, out int actualFormat, out IntPtr nitems, out IntPtr bytesAfter, out IntPtr data);
[DllImport("libX11.so.6")]
private static extern int XSendEvent(IntPtr display, IntPtr window, bool propagate, long eventMask, ref XClientMessageEvent xevent);
[DllImport("libX11.so.6")]
private static extern int XConvertSelection(IntPtr display, IntPtr selection, IntPtr target, IntPtr property, IntPtr requestor, uint time);
[DllImport("libX11.so.6")]
private static extern void XFree(IntPtr ptr);
[DllImport("libX11.so.6")]
private static extern void XFlush(IntPtr display);
}

25
Services/DragEventArgs.cs Normal file
View File

@@ -0,0 +1,25 @@
using System;
namespace Microsoft.Maui.Platform.Linux.Services;
public class DragEventArgs : EventArgs
{
public DragData Data { get; }
public int X { get; }
public int Y { get; }
public bool Accepted { get; set; }
public DragAction AllowedAction { get; set; }
public DragAction AcceptedAction { get; set; } = DragAction.Copy;
public DragEventArgs(DragData data, int x, int y)
{
Data = data;
X = x;
Y = y;
}
}

18
Services/DropEventArgs.cs Normal file
View File

@@ -0,0 +1,18 @@
using System;
namespace Microsoft.Maui.Platform.Linux.Services;
public class DropEventArgs : EventArgs
{
public DragData Data { get; }
public string? DroppedData { get; }
public bool Handled { get; set; }
public DropEventArgs(DragData data, string? droppedData)
{
Data = data;
DroppedData = droppedData;
}
}

View File

@@ -1,113 +1,98 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Microsoft.Maui.ApplicationModel.Communication;
namespace Microsoft.Maui.Platform.Linux.Services;
/// <summary>
/// Linux email implementation using mailto: URI.
/// </summary>
public class EmailService : IEmail
{
public bool IsComposeSupported => true;
public bool IsComposeSupported => true;
public async Task ComposeAsync()
{
await ComposeAsync(new EmailMessage());
}
public async Task ComposeAsync()
{
await ComposeAsync(new EmailMessage());
}
public async Task ComposeAsync(string subject, string body, params string[] to)
{
var message = new EmailMessage
{
Subject = subject,
Body = body
};
public async Task ComposeAsync(string subject, string body, params string[] to)
{
EmailMessage val = new EmailMessage
{
Subject = subject,
Body = body
};
if (to != null && to.Length != 0)
{
val.To = new List<string>(to);
}
await ComposeAsync(val);
}
if (to != null && to.Length > 0)
{
message.To = new List<string>(to);
}
public async Task ComposeAsync(EmailMessage? message)
{
if (message == null)
{
throw new ArgumentNullException("message");
}
string text = BuildMailtoUri(message);
try
{
ProcessStartInfo startInfo = new ProcessStartInfo
{
FileName = "xdg-open",
Arguments = "\"" + text + "\"",
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true
};
using Process process = Process.Start(startInfo);
if (process != null)
{
await process.WaitForExitAsync();
}
}
catch (Exception innerException)
{
throw new InvalidOperationException("Failed to open email client", innerException);
}
}
await ComposeAsync(message);
}
public async Task ComposeAsync(EmailMessage? message)
{
if (message == null)
throw new ArgumentNullException(nameof(message));
var mailto = BuildMailtoUri(message);
try
{
var startInfo = new ProcessStartInfo
{
FileName = "xdg-open",
Arguments = $"\"{mailto}\"",
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true
};
using var process = Process.Start(startInfo);
if (process != null)
{
await process.WaitForExitAsync();
}
}
catch (Exception ex)
{
throw new InvalidOperationException("Failed to open email client", ex);
}
}
private static string BuildMailtoUri(EmailMessage? message)
{
var sb = new StringBuilder("mailto:");
// Add recipients
if (message.To?.Count > 0)
{
sb.Append(string.Join(",", message.To.Select(Uri.EscapeDataString)));
}
var queryParams = new List<string>();
// Add subject
if (!string.IsNullOrEmpty(message.Subject))
{
queryParams.Add($"subject={Uri.EscapeDataString(message.Subject)}");
}
// Add body
if (!string.IsNullOrEmpty(message.Body))
{
queryParams.Add($"body={Uri.EscapeDataString(message.Body)}");
}
// Add CC
if (message.Cc?.Count > 0)
{
queryParams.Add($"cc={string.Join(",", message.Cc.Select(Uri.EscapeDataString))}");
}
// Add BCC
if (message.Bcc?.Count > 0)
{
queryParams.Add($"bcc={string.Join(",", message.Bcc.Select(Uri.EscapeDataString))}");
}
if (queryParams.Count > 0)
{
sb.Append('?');
sb.Append(string.Join("&", queryParams));
}
return sb.ToString();
}
private static string BuildMailtoUri(EmailMessage? message)
{
StringBuilder stringBuilder = new StringBuilder("mailto:");
List<string> to = message.To;
if (to != null && to.Count > 0)
{
stringBuilder.Append(string.Join(",", message.To.Select(Uri.EscapeDataString)));
}
List<string> list = new List<string>();
if (!string.IsNullOrEmpty(message.Subject))
{
list.Add("subject=" + Uri.EscapeDataString(message.Subject));
}
if (!string.IsNullOrEmpty(message.Body))
{
list.Add("body=" + Uri.EscapeDataString(message.Body));
}
List<string> cc = message.Cc;
if (cc != null && cc.Count > 0)
{
list.Add("cc=" + string.Join(",", message.Cc.Select(Uri.EscapeDataString)));
}
List<string> bcc = message.Bcc;
if (bcc != null && bcc.Count > 0)
{
list.Add("bcc=" + string.Join(",", message.Bcc.Select(Uri.EscapeDataString)));
}
if (list.Count > 0)
{
stringBuilder.Append('?');
stringBuilder.Append(string.Join("&", list));
}
return stringBuilder.ToString();
}
}

View File

@@ -1,326 +1,329 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Text;
using System.IO;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
namespace Microsoft.Maui.Platform.Linux.Services;
/// <summary>
/// Fcitx5 Input Method service using D-Bus interface.
/// Provides IME support for systems using Fcitx5 (common on some distros).
/// </summary>
public class Fcitx5InputMethodService : IInputMethodService, IDisposable
{
private IInputContext? _currentContext;
private string _preEditText = string.Empty;
private int _preEditCursorPosition;
private bool _isActive;
private bool _disposed;
private Process? _dBusMonitor;
private string? _inputContextPath;
private IInputContext? _currentContext;
public bool IsActive => _isActive;
public string PreEditText => _preEditText;
public int PreEditCursorPosition => _preEditCursorPosition;
private string _preEditText = string.Empty;
public event EventHandler<TextCommittedEventArgs>? TextCommitted;
public event EventHandler<PreEditChangedEventArgs>? PreEditChanged;
public event EventHandler? PreEditEnded;
private int _preEditCursorPosition;
public void Initialize(nint windowHandle)
{
try
{
// Create input context via D-Bus
var output = RunDBusCommand(
"call --session " +
"--dest org.fcitx.Fcitx5 " +
"--object-path /org/freedesktop/portal/inputmethod " +
"--method org.fcitx.Fcitx.InputMethod1.CreateInputContext " +
"\"maui-linux\" \"\"");
private bool _isActive;
if (!string.IsNullOrEmpty(output) && output.Contains("/"))
{
// Parse the object path from output like: (objectpath '/org/fcitx/...',)
var start = output.IndexOf("'/");
var end = output.IndexOf("'", start + 1);
if (start >= 0 && end > start)
{
_inputContextPath = output.Substring(start + 1, end - start - 1);
Console.WriteLine($"Fcitx5InputMethodService: Created context at {_inputContextPath}");
StartMonitoring();
}
}
else
{
Console.WriteLine("Fcitx5InputMethodService: Failed to create input context");
}
}
catch (Exception ex)
{
Console.WriteLine($"Fcitx5InputMethodService: Initialization failed - {ex.Message}");
}
}
private bool _disposed;
private void StartMonitoring()
{
if (string.IsNullOrEmpty(_inputContextPath)) return;
private Process? _dBusMonitor;
Task.Run(async () =>
{
try
{
var startInfo = new ProcessStartInfo
{
FileName = "dbus-monitor",
Arguments = $"--session \"path='{_inputContextPath}'\"",
UseShellExecute = false,
RedirectStandardOutput = true,
CreateNoWindow = true
};
private string? _inputContextPath;
_dBusMonitor = Process.Start(startInfo);
if (_dBusMonitor == null) return;
public bool IsActive => _isActive;
var reader = _dBusMonitor.StandardOutput;
while (!_disposed && !_dBusMonitor.HasExited)
{
var line = await reader.ReadLineAsync();
if (line == null) break;
public string PreEditText => _preEditText;
// Parse signals for commit and preedit
if (line.Contains("CommitString"))
{
await ProcessCommitSignal(reader);
}
else if (line.Contains("UpdatePreedit"))
{
await ProcessPreeditSignal(reader);
}
}
}
catch (Exception ex)
{
Console.WriteLine($"Fcitx5InputMethodService: Monitor error - {ex.Message}");
}
});
}
public int PreEditCursorPosition => _preEditCursorPosition;
private async Task ProcessCommitSignal(StreamReader reader)
{
try
{
for (int i = 0; i < 5; i++)
{
var line = await reader.ReadLineAsync();
if (line == null) break;
public event EventHandler<TextCommittedEventArgs>? TextCommitted;
if (line.Contains("string"))
{
var match = System.Text.RegularExpressions.Regex.Match(line, @"string\s+""([^""]*)""");
if (match.Success)
{
var text = match.Groups[1].Value;
_preEditText = string.Empty;
_preEditCursorPosition = 0;
_isActive = false;
public event EventHandler<PreEditChangedEventArgs>? PreEditChanged;
TextCommitted?.Invoke(this, new TextCommittedEventArgs(text));
_currentContext?.OnTextCommitted(text);
break;
}
}
}
}
catch { }
}
public event EventHandler? PreEditEnded;
private async Task ProcessPreeditSignal(StreamReader reader)
{
try
{
for (int i = 0; i < 10; i++)
{
var line = await reader.ReadLineAsync();
if (line == null) break;
public void Initialize(IntPtr windowHandle)
{
try
{
string text = RunDBusCommand("call --session --dest org.fcitx.Fcitx5 --object-path /org/freedesktop/portal/inputmethod --method org.fcitx.Fcitx.InputMethod1.CreateInputContext \"maui-linux\" \"\"");
if (!string.IsNullOrEmpty(text) && text.Contains("/"))
{
int num = text.IndexOf("'/");
int num2 = text.IndexOf("'", num + 1);
if (num >= 0 && num2 > num)
{
_inputContextPath = text.Substring(num + 1, num2 - num - 1);
Console.WriteLine("Fcitx5InputMethodService: Created context at " + _inputContextPath);
StartMonitoring();
}
}
else
{
Console.WriteLine("Fcitx5InputMethodService: Failed to create input context");
}
}
catch (Exception ex)
{
Console.WriteLine("Fcitx5InputMethodService: Initialization failed - " + ex.Message);
}
}
if (line.Contains("string"))
{
var match = System.Text.RegularExpressions.Regex.Match(line, @"string\s+""([^""]*)""");
if (match.Success)
{
_preEditText = match.Groups[1].Value;
_isActive = !string.IsNullOrEmpty(_preEditText);
private void StartMonitoring()
{
if (string.IsNullOrEmpty(_inputContextPath))
{
return;
}
Task.Run(async delegate
{
_ = 2;
try
{
ProcessStartInfo startInfo = new ProcessStartInfo
{
FileName = "dbus-monitor",
Arguments = "--session \"path='" + _inputContextPath + "'\"",
UseShellExecute = false,
RedirectStandardOutput = true,
CreateNoWindow = true
};
_dBusMonitor = Process.Start(startInfo);
if (_dBusMonitor != null)
{
StreamReader reader = _dBusMonitor.StandardOutput;
while (!_disposed && !_dBusMonitor.HasExited)
{
string text = await reader.ReadLineAsync();
if (text == null)
{
break;
}
if (text.Contains("CommitString"))
{
await ProcessCommitSignal(reader);
}
else if (text.Contains("UpdatePreedit"))
{
await ProcessPreeditSignal(reader);
}
}
}
}
catch (Exception ex)
{
Console.WriteLine("Fcitx5InputMethodService: Monitor error - " + ex.Message);
}
});
}
PreEditChanged?.Invoke(this, new PreEditChangedEventArgs(_preEditText, _preEditCursorPosition, new List<PreEditAttribute>()));
_currentContext?.OnPreEditChanged(_preEditText, _preEditCursorPosition);
break;
}
}
}
}
catch { }
}
private async Task ProcessCommitSignal(StreamReader reader)
{
try
{
for (int i = 0; i < 5; i++)
{
string text = await reader.ReadLineAsync();
if (text == null)
{
break;
}
if (text.Contains("string"))
{
Match match = Regex.Match(text, "string\\s+\"([^\"]*)\"");
if (match.Success)
{
string value = match.Groups[1].Value;
_preEditText = string.Empty;
_preEditCursorPosition = 0;
_isActive = false;
this.TextCommitted?.Invoke(this, new TextCommittedEventArgs(value));
_currentContext?.OnTextCommitted(value);
break;
}
}
}
}
catch
{
}
}
public void SetFocus(IInputContext? context)
{
_currentContext = context;
private async Task ProcessPreeditSignal(StreamReader reader)
{
try
{
for (int i = 0; i < 10; i++)
{
string text = await reader.ReadLineAsync();
if (text == null)
{
break;
}
if (text.Contains("string"))
{
Match match = Regex.Match(text, "string\\s+\"([^\"]*)\"");
if (match.Success)
{
_preEditText = match.Groups[1].Value;
_isActive = !string.IsNullOrEmpty(_preEditText);
this.PreEditChanged?.Invoke(this, new PreEditChangedEventArgs(_preEditText, _preEditCursorPosition, new List<PreEditAttribute>()));
_currentContext?.OnPreEditChanged(_preEditText, _preEditCursorPosition);
break;
}
}
}
}
catch
{
}
}
if (!string.IsNullOrEmpty(_inputContextPath))
{
if (context != null)
{
RunDBusCommand(
$"call --session --dest org.fcitx.Fcitx5 " +
$"--object-path {_inputContextPath} " +
$"--method org.fcitx.Fcitx.InputContext1.FocusIn");
}
else
{
RunDBusCommand(
$"call --session --dest org.fcitx.Fcitx5 " +
$"--object-path {_inputContextPath} " +
$"--method org.fcitx.Fcitx.InputContext1.FocusOut");
}
}
}
public void SetFocus(IInputContext? context)
{
_currentContext = context;
if (!string.IsNullOrEmpty(_inputContextPath))
{
if (context != null)
{
RunDBusCommand($"call --session --dest org.fcitx.Fcitx5 --object-path {_inputContextPath} --method org.fcitx.Fcitx.InputContext1.FocusIn");
}
else
{
RunDBusCommand($"call --session --dest org.fcitx.Fcitx5 --object-path {_inputContextPath} --method org.fcitx.Fcitx.InputContext1.FocusOut");
}
}
}
public void SetCursorLocation(int x, int y, int width, int height)
{
if (string.IsNullOrEmpty(_inputContextPath)) return;
public void SetCursorLocation(int x, int y, int width, int height)
{
if (!string.IsNullOrEmpty(_inputContextPath))
{
RunDBusCommand($"call --session --dest org.fcitx.Fcitx5 --object-path {_inputContextPath} --method org.fcitx.Fcitx.InputContext1.SetCursorRect {x} {y} {width} {height}");
}
}
RunDBusCommand(
$"call --session --dest org.fcitx.Fcitx5 " +
$"--object-path {_inputContextPath} " +
$"--method org.fcitx.Fcitx.InputContext1.SetCursorRect " +
$"{x} {y} {width} {height}");
}
public bool ProcessKeyEvent(uint keyCode, KeyModifiers modifiers, bool isKeyDown)
{
if (string.IsNullOrEmpty(_inputContextPath))
{
return false;
}
uint num = ConvertModifiers(modifiers);
if (!isKeyDown)
{
num |= 0x40000000;
}
return RunDBusCommand($"call --session --dest org.fcitx.Fcitx5 --object-path {_inputContextPath} --method org.fcitx.Fcitx.InputContext1.ProcessKeyEvent {keyCode} {keyCode} {num} {(isKeyDown ? "true" : "false")} 0")?.Contains("true") ?? false;
}
public bool ProcessKeyEvent(uint keyCode, KeyModifiers modifiers, bool isKeyDown)
{
if (string.IsNullOrEmpty(_inputContextPath)) return false;
private uint ConvertModifiers(KeyModifiers modifiers)
{
uint num = 0u;
if (modifiers.HasFlag(KeyModifiers.Shift))
{
num |= 1;
}
if (modifiers.HasFlag(KeyModifiers.CapsLock))
{
num |= 2;
}
if (modifiers.HasFlag(KeyModifiers.Control))
{
num |= 4;
}
if (modifiers.HasFlag(KeyModifiers.Alt))
{
num |= 8;
}
if (modifiers.HasFlag(KeyModifiers.Super))
{
num |= 0x40;
}
return num;
}
uint state = ConvertModifiers(modifiers);
if (!isKeyDown) state |= 0x40000000; // Release flag
public void Reset()
{
if (!string.IsNullOrEmpty(_inputContextPath))
{
RunDBusCommand($"call --session --dest org.fcitx.Fcitx5 --object-path {_inputContextPath} --method org.fcitx.Fcitx.InputContext1.Reset");
}
_preEditText = string.Empty;
_preEditCursorPosition = 0;
_isActive = false;
this.PreEditEnded?.Invoke(this, EventArgs.Empty);
_currentContext?.OnPreEditEnded();
}
var result = RunDBusCommand(
$"call --session --dest org.fcitx.Fcitx5 " +
$"--object-path {_inputContextPath} " +
$"--method org.fcitx.Fcitx.InputContext1.ProcessKeyEvent " +
$"{keyCode} {keyCode} {state} {(isKeyDown ? "true" : "false")} 0");
public void Shutdown()
{
Dispose();
}
return result?.Contains("true") == true;
}
private string? RunDBusCommand(string args)
{
try
{
using Process process = Process.Start(new ProcessStartInfo
{
FileName = "gdbus",
Arguments = args,
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true
});
if (process == null)
{
return null;
}
string result = process.StandardOutput.ReadToEnd();
process.WaitForExit(1000);
return result;
}
catch
{
return null;
}
}
private uint ConvertModifiers(KeyModifiers modifiers)
{
uint state = 0;
if (modifiers.HasFlag(KeyModifiers.Shift)) state |= 1;
if (modifiers.HasFlag(KeyModifiers.CapsLock)) state |= 2;
if (modifiers.HasFlag(KeyModifiers.Control)) state |= 4;
if (modifiers.HasFlag(KeyModifiers.Alt)) state |= 8;
if (modifiers.HasFlag(KeyModifiers.Super)) state |= 64;
return state;
}
public void Dispose()
{
if (!_disposed)
{
_disposed = true;
try
{
_dBusMonitor?.Kill();
_dBusMonitor?.Dispose();
}
catch
{
}
if (!string.IsNullOrEmpty(_inputContextPath))
{
RunDBusCommand($"call --session --dest org.fcitx.Fcitx5 --object-path {_inputContextPath} --method org.fcitx.Fcitx.InputContext1.Destroy");
}
}
}
public void Reset()
{
if (!string.IsNullOrEmpty(_inputContextPath))
{
RunDBusCommand(
$"call --session --dest org.fcitx.Fcitx5 " +
$"--object-path {_inputContextPath} " +
$"--method org.fcitx.Fcitx.InputContext1.Reset");
}
_preEditText = string.Empty;
_preEditCursorPosition = 0;
_isActive = false;
PreEditEnded?.Invoke(this, EventArgs.Empty);
_currentContext?.OnPreEditEnded();
}
public void Shutdown()
{
Dispose();
}
private string? RunDBusCommand(string args)
{
try
{
var startInfo = new ProcessStartInfo
{
FileName = "gdbus",
Arguments = args,
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true
};
using var process = Process.Start(startInfo);
if (process == null) return null;
var output = process.StandardOutput.ReadToEnd();
process.WaitForExit(1000);
return output;
}
catch
{
return null;
}
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
try
{
_dBusMonitor?.Kill();
_dBusMonitor?.Dispose();
}
catch { }
if (!string.IsNullOrEmpty(_inputContextPath))
{
RunDBusCommand(
$"call --session --dest org.fcitx.Fcitx5 " +
$"--object-path {_inputContextPath} " +
$"--method org.fcitx.Fcitx.InputContext1.Destroy");
}
}
/// <summary>
/// Checks if Fcitx5 is available on the system.
/// </summary>
public static bool IsAvailable()
{
try
{
var startInfo = new ProcessStartInfo
{
FileName = "gdbus",
Arguments = "introspect --session --dest org.fcitx.Fcitx5 --object-path /org/freedesktop/portal/inputmethod",
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true
};
using var process = Process.Start(startInfo);
if (process == null) return false;
process.WaitForExit(1000);
return process.ExitCode == 0;
}
catch
{
return false;
}
}
public static bool IsAvailable()
{
try
{
using Process process = Process.Start(new ProcessStartInfo
{
FileName = "gdbus",
Arguments = "introspect --session --dest org.fcitx.Fcitx5 --object-path /org/freedesktop/portal/inputmethod",
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true
});
if (process == null)
{
return false;
}
process.WaitForExit(1000);
return process.ExitCode == 0;
}
catch
{
return false;
}
}
}

View File

@@ -0,0 +1,22 @@
using System;
namespace Microsoft.Maui.Platform.Linux.Services;
public class FileDialogResult
{
public bool Accepted { get; init; }
public string[] SelectedFiles { get; init; } = Array.Empty<string>();
public string? SelectedFile
{
get
{
if (SelectedFiles.Length == 0)
{
return null;
}
return SelectedFiles[0];
}
}
}

View File

@@ -1,212 +1,213 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Microsoft.Maui.Storage;
namespace Microsoft.Maui.Platform.Linux.Services;
/// <summary>
/// Linux file picker implementation using zenity or kdialog.
/// </summary>
public class FilePickerService : IFilePicker
{
private enum DialogTool
{
None,
Zenity,
Kdialog
}
private enum DialogTool
{
None,
Zenity,
Kdialog
}
private static DialogTool? _availableTool;
private static DialogTool? _availableTool;
private static DialogTool GetAvailableTool()
{
if (_availableTool.HasValue)
return _availableTool.Value;
private static DialogTool GetAvailableTool()
{
if (_availableTool.HasValue)
{
return _availableTool.Value;
}
if (IsToolAvailable("zenity"))
{
_availableTool = DialogTool.Zenity;
return DialogTool.Zenity;
}
if (IsToolAvailable("kdialog"))
{
_availableTool = DialogTool.Kdialog;
return DialogTool.Kdialog;
}
_availableTool = DialogTool.None;
return DialogTool.None;
}
// Check for zenity first (GNOME/GTK)
if (IsToolAvailable("zenity"))
{
_availableTool = DialogTool.Zenity;
return DialogTool.Zenity;
}
private static bool IsToolAvailable(string tool)
{
try
{
using Process process = Process.Start(new ProcessStartInfo
{
FileName = "which",
Arguments = tool,
UseShellExecute = false,
RedirectStandardOutput = true,
CreateNoWindow = true
});
process?.WaitForExit(1000);
return process != null && process.ExitCode == 0;
}
catch
{
return false;
}
}
// Check for kdialog (KDE)
if (IsToolAvailable("kdialog"))
{
_availableTool = DialogTool.Kdialog;
return DialogTool.Kdialog;
}
public Task<FileResult?> PickAsync(PickOptions? options = null)
{
return PickInternalAsync(options, multiple: false);
}
_availableTool = DialogTool.None;
return DialogTool.None;
}
public Task<IEnumerable<FileResult>> PickMultipleAsync(PickOptions? options = null)
{
return PickMultipleInternalAsync(options);
}
private static bool IsToolAvailable(string tool)
{
try
{
var psi = new ProcessStartInfo
{
FileName = "which",
Arguments = tool,
UseShellExecute = false,
RedirectStandardOutput = true,
CreateNoWindow = true
};
private async Task<FileResult?> PickInternalAsync(PickOptions? options, bool multiple)
{
return (await PickMultipleInternalAsync(options, multiple)).FirstOrDefault();
}
using var process = Process.Start(psi);
process?.WaitForExit(1000);
return process?.ExitCode == 0;
}
catch
{
return false;
}
}
private Task<IEnumerable<FileResult>> PickMultipleInternalAsync(PickOptions? options, bool multiple = true)
{
return Task.Run(delegate
{
DialogTool availableTool = GetAvailableTool();
string arguments;
switch (availableTool)
{
case DialogTool.None:
{
Console.WriteLine("No file dialog available. Please enter file path:");
string text = Console.ReadLine();
if (!string.IsNullOrEmpty(text) && File.Exists(text))
{
return (IEnumerable<FileResult>)(object)new LinuxFileResult[1]
{
new LinuxFileResult(text)
};
}
return Array.Empty<FileResult>();
}
case DialogTool.Zenity:
arguments = BuildZenityArguments(options, multiple);
break;
default:
arguments = BuildKdialogArguments(options, multiple);
break;
}
ProcessStartInfo startInfo = new ProcessStartInfo
{
FileName = ((availableTool == DialogTool.Zenity) ? "zenity" : "kdialog"),
Arguments = arguments,
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true
};
try
{
using Process process = Process.Start(startInfo);
if (process == null)
{
return Array.Empty<FileResult>();
}
string text2 = process.StandardOutput.ReadToEnd().Trim();
process.WaitForExit();
if (process.ExitCode != 0 || string.IsNullOrEmpty(text2))
{
return Array.Empty<FileResult>();
}
char separator = ((availableTool == DialogTool.Zenity) ? '|' : '\n');
return (from p in text2.Split(separator, StringSplitOptions.RemoveEmptyEntries).Where(File.Exists)
select (FileResult)(object)new LinuxFileResult(p)).ToArray();
}
catch
{
return Array.Empty<FileResult>();
}
});
}
public Task<FileResult?> PickAsync(PickOptions? options = null)
{
return PickInternalAsync(options, false);
}
private string BuildZenityArguments(PickOptions? options, bool multiple)
{
StringBuilder stringBuilder = new StringBuilder("--file-selection");
if (multiple)
{
stringBuilder.Append(" --multiple --separator='|'");
}
if (!string.IsNullOrEmpty((options != null) ? options.PickerTitle : null))
{
StringBuilder stringBuilder2 = stringBuilder;
StringBuilder stringBuilder3 = stringBuilder2;
StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(11, 1, stringBuilder2);
handler.AppendLiteral(" --title=\"");
handler.AppendFormatted(EscapeArgument(options.PickerTitle));
handler.AppendLiteral("\"");
stringBuilder3.Append(ref handler);
}
if (((options != null) ? options.FileTypes : null) != null)
{
foreach (string item in options.FileTypes.Value)
{
string value = (item.StartsWith(".") ? item : ("." + item));
StringBuilder stringBuilder2 = stringBuilder;
StringBuilder stringBuilder4 = stringBuilder2;
StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(18, 1, stringBuilder2);
handler.AppendLiteral(" --file-filter='*");
handler.AppendFormatted(value);
handler.AppendLiteral("'");
stringBuilder4.Append(ref handler);
}
}
return stringBuilder.ToString();
}
public Task<IEnumerable<FileResult>> PickMultipleAsync(PickOptions? options = null)
{
return PickMultipleInternalAsync(options);
}
private string BuildKdialogArguments(PickOptions? options, bool multiple)
{
StringBuilder stringBuilder = new StringBuilder("--getopenfilename");
if (multiple)
{
stringBuilder.Insert(0, "--multiple ");
}
stringBuilder.Append(" .");
if (((options != null) ? options.FileTypes : null) != null)
{
string value = string.Join(" ", options.FileTypes.Value.Select((string e) => (!e.StartsWith(".")) ? ("*." + e) : ("*" + e)));
if (!string.IsNullOrEmpty(value))
{
StringBuilder stringBuilder2 = stringBuilder;
StringBuilder stringBuilder3 = stringBuilder2;
StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(3, 1, stringBuilder2);
handler.AppendLiteral(" \"");
handler.AppendFormatted(value);
handler.AppendLiteral("\"");
stringBuilder3.Append(ref handler);
}
}
if (!string.IsNullOrEmpty((options != null) ? options.PickerTitle : null))
{
StringBuilder stringBuilder2 = stringBuilder;
StringBuilder stringBuilder4 = stringBuilder2;
StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(11, 1, stringBuilder2);
handler.AppendLiteral(" --title \"");
handler.AppendFormatted(EscapeArgument(options.PickerTitle));
handler.AppendLiteral("\"");
stringBuilder4.Append(ref handler);
}
return stringBuilder.ToString();
}
private async Task<FileResult?> PickInternalAsync(PickOptions? options, bool multiple)
{
var results = await PickMultipleInternalAsync(options, multiple);
return results.FirstOrDefault();
}
private Task<IEnumerable<FileResult>> PickMultipleInternalAsync(PickOptions? options, bool multiple = true)
{
return Task.Run<IEnumerable<FileResult>>(() =>
{
var tool = GetAvailableTool();
if (tool == DialogTool.None)
{
// Fall back to console path input
Console.WriteLine("No file dialog available. Please enter file path:");
var path = Console.ReadLine();
if (!string.IsNullOrEmpty(path) && File.Exists(path))
{
return new[] { new LinuxFileResult(path) };
}
return Array.Empty<FileResult>();
}
string arguments;
if (tool == DialogTool.Zenity)
{
arguments = BuildZenityArguments(options, multiple);
}
else
{
arguments = BuildKdialogArguments(options, multiple);
}
var psi = new ProcessStartInfo
{
FileName = tool == DialogTool.Zenity ? "zenity" : "kdialog",
Arguments = arguments,
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true
};
try
{
using var process = Process.Start(psi);
if (process == null)
return Array.Empty<FileResult>();
var output = process.StandardOutput.ReadToEnd().Trim();
process.WaitForExit();
if (process.ExitCode != 0 || string.IsNullOrEmpty(output))
return Array.Empty<FileResult>();
// Parse output (paths separated by | for zenity, newlines for kdialog)
var separator = tool == DialogTool.Zenity ? '|' : '\n';
var paths = output.Split(separator, StringSplitOptions.RemoveEmptyEntries);
return paths
.Where(File.Exists)
.Select(p => (FileResult)new LinuxFileResult(p))
.ToArray();
}
catch
{
return Array.Empty<FileResult>();
}
});
}
private string BuildZenityArguments(PickOptions? options, bool multiple)
{
var sb = new StringBuilder("--file-selection");
if (multiple)
sb.Append(" --multiple --separator='|'");
if (!string.IsNullOrEmpty(options?.PickerTitle))
sb.Append($" --title=\"{EscapeArgument(options.PickerTitle)}\"");
if (options?.FileTypes != null)
{
foreach (var ext in options.FileTypes.Value)
{
var extension = ext.StartsWith(".") ? ext : $".{ext}";
sb.Append($" --file-filter='*{extension}'");
}
}
return sb.ToString();
}
private string BuildKdialogArguments(PickOptions? options, bool multiple)
{
var sb = new StringBuilder("--getopenfilename");
if (multiple)
sb.Insert(0, "--multiple ");
sb.Append(" .");
if (options?.FileTypes != null)
{
var extensions = string.Join(" ", options.FileTypes.Value.Select(e =>
e.StartsWith(".") ? $"*{e}" : $"*.{e}"));
if (!string.IsNullOrEmpty(extensions))
{
sb.Append($" \"{extensions}\"");
}
}
if (!string.IsNullOrEmpty(options?.PickerTitle))
sb.Append($" --title \"{EscapeArgument(options.PickerTitle)}\"");
return sb.ToString();
}
private static string EscapeArgument(string arg)
{
return arg.Replace("\"", "\\\"").Replace("'", "\\'");
}
}
/// <summary>
/// Linux-specific FileResult implementation.
/// </summary>
internal class LinuxFileResult : FileResult
{
public LinuxFileResult(string fullPath) : base(fullPath)
{
}
private static string EscapeArgument(string arg)
{
return arg.Replace("\"", "\\\"").Replace("'", "\\'");
}
}

View File

@@ -0,0 +1,8 @@
namespace Microsoft.Maui.Platform.Linux.Services;
public class FolderPickerOptions
{
public string? Title { get; set; }
public string? InitialDirectory { get; set; }
}

View File

@@ -0,0 +1,13 @@
namespace Microsoft.Maui.Platform.Linux.Services;
public class FolderPickerResult
{
public FolderResult? Folder { get; }
public bool WasSuccessful => Folder != null;
public FolderPickerResult(FolderResult? folder)
{
Folder = folder;
}
}

View File

@@ -1,129 +1,121 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System;
using System.Diagnostics;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
namespace Microsoft.Maui.Platform.Linux.Services;
/// <summary>
/// Linux folder picker utility using zenity or kdialog.
/// This is a standalone service as MAUI core does not define IFolderPicker.
/// </summary>
public class FolderPickerService
{
public async Task<string?> PickFolderAsync(string? initialDirectory = null, CancellationToken cancellationToken = default)
{
try
{
// Try zenity first (GNOME)
var result = await TryZenityFolderPicker(initialDirectory, cancellationToken);
if (result != null)
{
return result;
}
public async Task<string?> PickFolderAsync(string? initialDirectory = null, CancellationToken cancellationToken = default(CancellationToken))
{
_ = 1;
try
{
string text = await TryZenityFolderPicker(initialDirectory, cancellationToken);
if (text != null)
{
return text;
}
text = await TryKdialogFolderPicker(initialDirectory, cancellationToken);
if (text != null)
{
return text;
}
return null;
}
catch (OperationCanceledException)
{
return null;
}
catch
{
return null;
}
}
// Fall back to kdialog (KDE)
result = await TryKdialogFolderPicker(initialDirectory, cancellationToken);
if (result != null)
{
return result;
}
private async Task<string?> TryZenityFolderPicker(string? initialDirectory, CancellationToken cancellationToken)
{
_ = 1;
try
{
string text = "--file-selection --directory";
if (!string.IsNullOrEmpty(initialDirectory) && Directory.Exists(initialDirectory))
{
text = text + " --filename=\"" + initialDirectory + "/\"";
}
ProcessStartInfo startInfo = new ProcessStartInfo
{
FileName = "zenity",
Arguments = text,
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true
};
using Process process = Process.Start(startInfo);
if (process == null)
{
return null;
}
string output = await process.StandardOutput.ReadToEndAsync(cancellationToken);
await process.WaitForExitAsync(cancellationToken);
if (process.ExitCode == 0 && !string.IsNullOrWhiteSpace(output))
{
string text2 = output.Trim();
if (Directory.Exists(text2))
{
return text2;
}
}
return null;
}
catch
{
return null;
}
}
return null;
}
catch (OperationCanceledException)
{
return null;
}
catch
{
return null;
}
}
private async Task<string?> TryZenityFolderPicker(string? initialDirectory, CancellationToken cancellationToken)
{
try
{
var args = "--file-selection --directory";
if (!string.IsNullOrEmpty(initialDirectory) && Directory.Exists(initialDirectory))
{
args += $" --filename=\"{initialDirectory}/\"";
}
var startInfo = new ProcessStartInfo
{
FileName = "zenity",
Arguments = args,
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true
};
using var process = Process.Start(startInfo);
if (process == null) return null;
var output = await process.StandardOutput.ReadToEndAsync(cancellationToken);
await process.WaitForExitAsync(cancellationToken);
if (process.ExitCode == 0 && !string.IsNullOrWhiteSpace(output))
{
var path = output.Trim();
if (Directory.Exists(path))
{
return path;
}
}
return null;
}
catch
{
return null;
}
}
private async Task<string?> TryKdialogFolderPicker(string? initialDirectory, CancellationToken cancellationToken)
{
try
{
var args = "--getexistingdirectory";
if (!string.IsNullOrEmpty(initialDirectory) && Directory.Exists(initialDirectory))
{
args += $" \"{initialDirectory}\"";
}
var startInfo = new ProcessStartInfo
{
FileName = "kdialog",
Arguments = args,
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true
};
using var process = Process.Start(startInfo);
if (process == null) return null;
var output = await process.StandardOutput.ReadToEndAsync(cancellationToken);
await process.WaitForExitAsync(cancellationToken);
if (process.ExitCode == 0 && !string.IsNullOrWhiteSpace(output))
{
var path = output.Trim();
if (Directory.Exists(path))
{
return path;
}
}
return null;
}
catch
{
return null;
}
}
private async Task<string?> TryKdialogFolderPicker(string? initialDirectory, CancellationToken cancellationToken)
{
_ = 1;
try
{
string text = "--getexistingdirectory";
if (!string.IsNullOrEmpty(initialDirectory) && Directory.Exists(initialDirectory))
{
text = text + " \"" + initialDirectory + "\"";
}
ProcessStartInfo startInfo = new ProcessStartInfo
{
FileName = "kdialog",
Arguments = text,
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true
};
using Process process = Process.Start(startInfo);
if (process == null)
{
return null;
}
string output = await process.StandardOutput.ReadToEndAsync(cancellationToken);
await process.WaitForExitAsync(cancellationToken);
if (process.ExitCode == 0 && !string.IsNullOrWhiteSpace(output))
{
string text2 = output.Trim();
if (Directory.Exists(text2))
{
return text2;
}
}
return null;
}
catch
{
return null;
}
}
}

15
Services/FolderResult.cs Normal file
View File

@@ -0,0 +1,15 @@
using System.IO;
namespace Microsoft.Maui.Platform.Linux.Services;
public class FolderResult
{
public string Path { get; }
public string Name => System.IO.Path.GetFileName(Path) ?? Path;
public FolderResult(string path)
{
Path = path;
}
}

View File

@@ -1,310 +1,186 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using SkiaSharp;
namespace Microsoft.Maui.Platform.Linux.Services;
/// <summary>
/// Manages font fallback for text rendering when the primary font
/// doesn't contain glyphs for certain characters (emoji, CJK, etc.).
/// </summary>
public class FontFallbackManager
{
private static FontFallbackManager? _instance;
private static readonly object _lock = new();
private static FontFallbackManager? _instance;
/// <summary>
/// Gets the singleton instance of the font fallback manager.
/// </summary>
public static FontFallbackManager Instance
{
get
{
if (_instance == null)
{
lock (_lock)
{
_instance ??= new FontFallbackManager();
}
}
return _instance;
}
}
private static readonly object _lock = new object();
// Fallback font chain ordered by priority
private readonly string[] _fallbackFonts = new[]
{
// Primary sans-serif fonts
"Noto Sans",
"DejaVu Sans",
"Liberation Sans",
"FreeSans",
private readonly string[] _fallbackFonts = new string[27]
{
"Noto Sans", "DejaVu Sans", "Liberation Sans", "FreeSans", "Noto Color Emoji", "Noto Emoji", "Symbola", "Segoe UI Emoji", "Noto Sans CJK SC", "Noto Sans CJK TC",
"Noto Sans CJK JP", "Noto Sans CJK KR", "WenQuanYi Micro Hei", "WenQuanYi Zen Hei", "Droid Sans Fallback", "Noto Sans Arabic", "Noto Naskh Arabic", "DejaVu Sans", "Noto Sans Devanagari", "Noto Sans Tamil",
"Noto Sans Bengali", "Noto Sans Telugu", "Noto Sans Thai", "Loma", "Noto Sans Hebrew", "Sans", "sans-serif"
};
// Emoji fonts
"Noto Color Emoji",
"Noto Emoji",
"Symbola",
"Segoe UI Emoji",
private readonly Dictionary<string, SKTypeface?> _typefaceCache = new Dictionary<string, SKTypeface>();
// CJK fonts (Chinese, Japanese, Korean)
"Noto Sans CJK SC",
"Noto Sans CJK TC",
"Noto Sans CJK JP",
"Noto Sans CJK KR",
"WenQuanYi Micro Hei",
"WenQuanYi Zen Hei",
"Droid Sans Fallback",
private readonly Dictionary<(int codepoint, string preferredFont), SKTypeface?> _glyphCache = new Dictionary<(int, string), SKTypeface>();
// Arabic and RTL scripts
"Noto Sans Arabic",
"Noto Naskh Arabic",
"DejaVu Sans",
public static FontFallbackManager Instance
{
get
{
if (_instance == null)
{
lock (_lock)
{
if (_instance == null)
{
_instance = new FontFallbackManager();
}
}
}
return _instance;
}
}
// Indic scripts
"Noto Sans Devanagari",
"Noto Sans Tamil",
"Noto Sans Bengali",
"Noto Sans Telugu",
private FontFallbackManager()
{
foreach (string item in _fallbackFonts.Take(10))
{
GetCachedTypeface(item);
}
}
// Thai
"Noto Sans Thai",
"Loma",
public SKTypeface GetTypefaceForCodepoint(int codepoint, SKTypeface preferred)
{
(int, string) key = (codepoint, preferred.FamilyName);
if (_glyphCache.TryGetValue(key, out SKTypeface value))
{
return value ?? preferred;
}
if (TypefaceContainsGlyph(preferred, codepoint))
{
_glyphCache[key] = preferred;
return preferred;
}
string[] fallbackFonts = _fallbackFonts;
foreach (string fontFamily in fallbackFonts)
{
SKTypeface cachedTypeface = GetCachedTypeface(fontFamily);
if (cachedTypeface != null && TypefaceContainsGlyph(cachedTypeface, codepoint))
{
_glyphCache[key] = cachedTypeface;
return cachedTypeface;
}
}
_glyphCache[key] = null;
return preferred;
}
// Hebrew
"Noto Sans Hebrew",
public SKTypeface GetTypefaceForText(string text, SKTypeface preferred)
{
if (string.IsNullOrEmpty(text))
{
return preferred;
}
foreach (Rune item in text.EnumerateRunes())
{
if (item.Value > 127)
{
return GetTypefaceForCodepoint(item.Value, preferred);
}
}
return preferred;
}
// System fallbacks
"Sans",
"sans-serif"
};
public List<TextRun> ShapeTextWithFallback(string text, SKTypeface preferred)
{
List<TextRun> list = new List<TextRun>();
if (string.IsNullOrEmpty(text))
{
return list;
}
_003CFontFallbackManager_003EFAC9D2911A2850E174CCA7662C668F37C2FBBA325CAF5C11AFE3FA59C16CC64ED__StringBuilder _003CFontFallbackManager_003EFAC9D2911A2850E174CCA7662C668F37C2FBBA325CAF5C11AFE3FA59C16CC64ED__StringBuilder2 = new _003CFontFallbackManager_003EFAC9D2911A2850E174CCA7662C668F37C2FBBA325CAF5C11AFE3FA59C16CC64ED__StringBuilder();
SKTypeface val = null;
int startIndex = 0;
int num = 0;
foreach (Rune item in text.EnumerateRunes())
{
SKTypeface typefaceForCodepoint = GetTypefaceForCodepoint(item.Value, preferred);
if (val == null)
{
val = typefaceForCodepoint;
}
else if (typefaceForCodepoint.FamilyName != val.FamilyName)
{
if (_003CFontFallbackManager_003EFAC9D2911A2850E174CCA7662C668F37C2FBBA325CAF5C11AFE3FA59C16CC64ED__StringBuilder2.Length > 0)
{
list.Add(new TextRun(_003CFontFallbackManager_003EFAC9D2911A2850E174CCA7662C668F37C2FBBA325CAF5C11AFE3FA59C16CC64ED__StringBuilder2.ToString(), val, startIndex));
}
_003CFontFallbackManager_003EFAC9D2911A2850E174CCA7662C668F37C2FBBA325CAF5C11AFE3FA59C16CC64ED__StringBuilder2.Clear();
val = typefaceForCodepoint;
startIndex = num;
}
_003CFontFallbackManager_003EFAC9D2911A2850E174CCA7662C668F37C2FBBA325CAF5C11AFE3FA59C16CC64ED__StringBuilder2.Append(item.ToString());
num += item.Utf16SequenceLength;
}
if (_003CFontFallbackManager_003EFAC9D2911A2850E174CCA7662C668F37C2FBBA325CAF5C11AFE3FA59C16CC64ED__StringBuilder2.Length > 0 && val != null)
{
list.Add(new TextRun(_003CFontFallbackManager_003EFAC9D2911A2850E174CCA7662C668F37C2FBBA325CAF5C11AFE3FA59C16CC64ED__StringBuilder2.ToString(), val, startIndex));
}
return list;
}
// Cache for typeface lookups
private readonly Dictionary<string, SKTypeface?> _typefaceCache = new();
private readonly Dictionary<(int codepoint, string preferredFont), SKTypeface?> _glyphCache = new();
public bool IsFontAvailable(string fontFamily)
{
SKTypeface cachedTypeface = GetCachedTypeface(fontFamily);
if (cachedTypeface != null)
{
return cachedTypeface.FamilyName.Equals(fontFamily, StringComparison.OrdinalIgnoreCase);
}
return false;
}
private FontFallbackManager()
{
// Pre-cache common fallback fonts
foreach (var fontName in _fallbackFonts.Take(10))
{
GetCachedTypeface(fontName);
}
}
public IEnumerable<string> GetAvailableFallbackFonts()
{
string[] fallbackFonts = _fallbackFonts;
foreach (string text in fallbackFonts)
{
if (IsFontAvailable(text))
{
yield return text;
}
}
}
/// <summary>
/// Gets a typeface that can render the specified codepoint.
/// Falls back through the font chain if the preferred font doesn't support it.
/// </summary>
/// <param name="codepoint">The Unicode codepoint to render.</param>
/// <param name="preferred">The preferred typeface to use.</param>
/// <returns>A typeface that can render the codepoint, or the preferred typeface as fallback.</returns>
public SKTypeface GetTypefaceForCodepoint(int codepoint, SKTypeface preferred)
{
// Check cache first
var cacheKey = (codepoint, preferred.FamilyName);
if (_glyphCache.TryGetValue(cacheKey, out var cached))
{
return cached ?? preferred;
}
private SKTypeface? GetCachedTypeface(string fontFamily)
{
if (_typefaceCache.TryGetValue(fontFamily, out SKTypeface value))
{
return value;
}
SKTypeface val = SKTypeface.FromFamilyName(fontFamily);
if (val != null && !val.FamilyName.Equals(fontFamily, StringComparison.OrdinalIgnoreCase))
{
val = null;
}
_typefaceCache[fontFamily] = val;
return val;
}
// Check if preferred font has the glyph
if (TypefaceContainsGlyph(preferred, codepoint))
{
_glyphCache[cacheKey] = preferred;
return preferred;
}
// Search fallback fonts
foreach (var fontName in _fallbackFonts)
{
var fallback = GetCachedTypeface(fontName);
if (fallback != null && TypefaceContainsGlyph(fallback, codepoint))
{
_glyphCache[cacheKey] = fallback;
return fallback;
}
}
// No fallback found, return preferred (will show tofu)
_glyphCache[cacheKey] = null;
return preferred;
}
/// <summary>
/// Gets a typeface that can render all codepoints in the text.
/// For mixed scripts, use ShapeTextWithFallback instead.
/// </summary>
public SKTypeface GetTypefaceForText(string text, SKTypeface preferred)
{
if (string.IsNullOrEmpty(text))
return preferred;
// Check first non-ASCII character
foreach (var rune in text.EnumerateRunes())
{
if (rune.Value > 127)
{
return GetTypefaceForCodepoint(rune.Value, preferred);
}
}
return preferred;
}
/// <summary>
/// Shapes text with automatic font fallback for mixed scripts.
/// Returns a list of text runs, each with its own typeface.
/// </summary>
public List<TextRun> ShapeTextWithFallback(string text, SKTypeface preferred)
{
var runs = new List<TextRun>();
if (string.IsNullOrEmpty(text))
return runs;
var currentRun = new StringBuilder();
SKTypeface? currentTypeface = null;
int runStart = 0;
int charIndex = 0;
foreach (var rune in text.EnumerateRunes())
{
var typeface = GetTypefaceForCodepoint(rune.Value, preferred);
if (currentTypeface == null)
{
currentTypeface = typeface;
}
else if (typeface.FamilyName != currentTypeface.FamilyName)
{
// Typeface changed - save current run
if (currentRun.Length > 0)
{
runs.Add(new TextRun(currentRun.ToString(), currentTypeface, runStart));
}
currentRun.Clear();
currentTypeface = typeface;
runStart = charIndex;
}
currentRun.Append(rune.ToString());
charIndex += rune.Utf16SequenceLength;
}
// Add final run
if (currentRun.Length > 0 && currentTypeface != null)
{
runs.Add(new TextRun(currentRun.ToString(), currentTypeface, runStart));
}
return runs;
}
/// <summary>
/// Checks if a typeface is available on the system.
/// </summary>
public bool IsFontAvailable(string fontFamily)
{
var typeface = GetCachedTypeface(fontFamily);
return typeface != null && typeface.FamilyName.Equals(fontFamily, StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// Gets a list of available fallback fonts on this system.
/// </summary>
public IEnumerable<string> GetAvailableFallbackFonts()
{
foreach (var fontName in _fallbackFonts)
{
if (IsFontAvailable(fontName))
{
yield return fontName;
}
}
}
private SKTypeface? GetCachedTypeface(string fontFamily)
{
if (_typefaceCache.TryGetValue(fontFamily, out var cached))
{
return cached;
}
var typeface = SKTypeface.FromFamilyName(fontFamily);
// Check if we actually got the requested font or a substitution
if (typeface != null && !typeface.FamilyName.Equals(fontFamily, StringComparison.OrdinalIgnoreCase))
{
// Got a substitution, don't cache it as the requested font
typeface = null;
}
_typefaceCache[fontFamily] = typeface;
return typeface;
}
private bool TypefaceContainsGlyph(SKTypeface typeface, int codepoint)
{
// Use SKFont to check glyph coverage
using var font = new SKFont(typeface, 12);
var glyphs = new ushort[1];
var chars = char.ConvertFromUtf32(codepoint);
font.GetGlyphs(chars, glyphs);
// Glyph ID 0 is the "missing glyph" (tofu)
return glyphs[0] != 0;
}
}
/// <summary>
/// Represents a run of text with a specific typeface.
/// </summary>
public class TextRun
{
/// <summary>
/// The text content of this run.
/// </summary>
public string Text { get; }
/// <summary>
/// The typeface to use for this run.
/// </summary>
public SKTypeface Typeface { get; }
/// <summary>
/// The starting character index in the original string.
/// </summary>
public int StartIndex { get; }
public TextRun(string text, SKTypeface typeface, int startIndex)
{
Text = text;
Typeface = typeface;
StartIndex = startIndex;
}
}
/// <summary>
/// StringBuilder for internal use.
/// </summary>
file class StringBuilder
{
private readonly List<char> _chars = new();
public int Length => _chars.Count;
public void Append(string s)
{
_chars.AddRange(s);
}
public void Clear()
{
_chars.Clear();
}
public override string ToString()
{
return new string(_chars.ToArray());
}
private bool TypefaceContainsGlyph(SKTypeface typeface, int codepoint)
{
//IL_0010: Unknown result type (might be due to invalid IL or missing references)
//IL_0016: Expected O, but got Unknown
SKFont val = new SKFont(typeface, 12f, 1f, 0f);
try
{
ushort[] array = new ushort[1];
string text = char.ConvertFromUtf32(codepoint);
val.GetGlyphs(text, (Span<ushort>)array);
return array[0] != 0;
}
finally
{
((IDisposable)val)?.Dispose();
}
}
}

View File

@@ -1,393 +1,296 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System;
using System.Collections.Concurrent;
using System.Linq;
using System.Runtime.InteropServices;
using System.Threading;
namespace Microsoft.Maui.Platform.Linux.Services;
/// <summary>
/// Provides global hotkey registration and handling using X11.
/// </summary>
public class GlobalHotkeyService : IDisposable
{
private nint _display;
private nint _rootWindow;
private readonly ConcurrentDictionary<int, HotkeyRegistration> _registrations = new();
private int _nextId = 1;
private bool _disposed;
private Thread? _eventThread;
private bool _isListening;
[StructLayout(LayoutKind.Explicit)]
private struct XEvent
{
[FieldOffset(0)]
public int type;
/// <summary>
/// Event raised when a registered hotkey is pressed.
/// </summary>
public event EventHandler<HotkeyEventArgs>? HotkeyPressed;
[FieldOffset(0)]
public XKeyEvent KeyEvent;
}
/// <summary>
/// Initializes the global hotkey service.
/// </summary>
public void Initialize()
{
_display = XOpenDisplay(IntPtr.Zero);
if (_display == IntPtr.Zero)
{
throw new InvalidOperationException("Failed to open X display");
}
private struct XKeyEvent
{
public int type;
_rootWindow = XDefaultRootWindow(_display);
public ulong serial;
// Start listening for hotkeys in background
_isListening = true;
_eventThread = new Thread(ListenForHotkeys)
{
IsBackground = true,
Name = "GlobalHotkeyListener"
};
_eventThread.Start();
}
public bool send_event;
/// <summary>
/// Registers a global hotkey.
/// </summary>
/// <param name="key">The key code.</param>
/// <param name="modifiers">The modifier keys.</param>
/// <returns>A registration ID that can be used to unregister.</returns>
public int Register(HotkeyKey key, HotkeyModifiers modifiers)
{
if (_display == IntPtr.Zero)
{
throw new InvalidOperationException("Service not initialized");
}
public IntPtr display;
int keyCode = XKeysymToKeycode(_display, (nint)key);
if (keyCode == 0)
{
throw new ArgumentException($"Invalid key: {key}");
}
public IntPtr window;
uint modifierMask = GetModifierMask(modifiers);
public IntPtr root;
// Register for all modifier combinations (with/without NumLock, CapsLock)
uint[] masks = GetModifierCombinations(modifierMask);
public IntPtr subwindow;
foreach (var mask in masks)
{
int result = XGrabKey(_display, keyCode, mask, _rootWindow, true, GrabModeAsync, GrabModeAsync);
if (result == 0)
{
Console.WriteLine($"Failed to grab key {key} with modifiers {modifiers}");
}
}
public ulong time;
int id = _nextId++;
_registrations[id] = new HotkeyRegistration
{
Id = id,
KeyCode = keyCode,
Modifiers = modifierMask,
Key = key,
ModifierKeys = modifiers
};
public int x;
XFlush(_display);
return id;
}
public int y;
/// <summary>
/// Unregisters a global hotkey.
/// </summary>
/// <param name="id">The registration ID.</param>
public void Unregister(int id)
{
if (_registrations.TryRemove(id, out var registration))
{
uint[] masks = GetModifierCombinations(registration.Modifiers);
public int x_root;
foreach (var mask in masks)
{
XUngrabKey(_display, registration.KeyCode, mask, _rootWindow);
}
public int y_root;
XFlush(_display);
}
}
public uint state;
/// <summary>
/// Unregisters all global hotkeys.
/// </summary>
public void UnregisterAll()
{
foreach (var id in _registrations.Keys.ToList())
{
Unregister(id);
}
}
public int keycode;
private void ListenForHotkeys()
{
while (_isListening && _display != IntPtr.Zero)
{
try
{
if (XPending(_display) > 0)
{
var xevent = new XEvent();
XNextEvent(_display, ref xevent);
public bool same_screen;
}
if (xevent.type == KeyPress)
{
var keyEvent = xevent.KeyEvent;
ProcessKeyEvent(keyEvent.keycode, keyEvent.state);
}
}
else
{
Thread.Sleep(10);
}
}
catch (Exception ex)
{
Console.WriteLine($"GlobalHotkeyService error: {ex.Message}");
}
}
}
private class HotkeyRegistration
{
public int Id { get; set; }
private void ProcessKeyEvent(int keyCode, uint state)
{
// Remove NumLock and CapsLock from state for comparison
uint cleanState = state & ~(NumLockMask | CapsLockMask | ScrollLockMask);
public int KeyCode { get; set; }
foreach (var registration in _registrations.Values)
{
if (registration.KeyCode == keyCode &&
(registration.Modifiers == cleanState ||
registration.Modifiers == (cleanState & ~Mod2Mask))) // Mod2 is often NumLock
{
OnHotkeyPressed(registration);
break;
}
}
}
public uint Modifiers { get; set; }
private void OnHotkeyPressed(HotkeyRegistration registration)
{
HotkeyPressed?.Invoke(this, new HotkeyEventArgs(
registration.Id,
registration.Key,
registration.ModifierKeys));
}
public HotkeyKey Key { get; set; }
private uint GetModifierMask(HotkeyModifiers modifiers)
{
uint mask = 0;
if (modifiers.HasFlag(HotkeyModifiers.Shift)) mask |= ShiftMask;
if (modifiers.HasFlag(HotkeyModifiers.Control)) mask |= ControlMask;
if (modifiers.HasFlag(HotkeyModifiers.Alt)) mask |= Mod1Mask;
if (modifiers.HasFlag(HotkeyModifiers.Super)) mask |= Mod4Mask;
return mask;
}
public HotkeyModifiers ModifierKeys { get; set; }
}
private uint[] GetModifierCombinations(uint baseMask)
{
// Include combinations with NumLock and CapsLock
return new uint[]
{
baseMask,
baseMask | NumLockMask,
baseMask | CapsLockMask,
baseMask | NumLockMask | CapsLockMask
};
}
private IntPtr _display;
public void Dispose()
{
if (_disposed) return;
_disposed = true;
private IntPtr _rootWindow;
_isListening = false;
private readonly ConcurrentDictionary<int, HotkeyRegistration> _registrations = new ConcurrentDictionary<int, HotkeyRegistration>();
UnregisterAll();
private int _nextId = 1;
if (_display != IntPtr.Zero)
{
XCloseDisplay(_display);
_display = IntPtr.Zero;
}
}
private bool _disposed;
#region X11 Interop
private Thread? _eventThread;
private const int KeyPress = 2;
private const int GrabModeAsync = 1;
private bool _isListening;
private const uint ShiftMask = 1 << 0;
private const uint LockMask = 1 << 1; // CapsLock
private const uint ControlMask = 1 << 2;
private const uint Mod1Mask = 1 << 3; // Alt
private const uint Mod2Mask = 1 << 4; // NumLock
private const uint Mod4Mask = 1 << 6; // Super
private const int KeyPress = 2;
private const uint NumLockMask = Mod2Mask;
private const uint CapsLockMask = LockMask;
private const uint ScrollLockMask = 0; // Usually not used
private const int GrabModeAsync = 1;
[StructLayout(LayoutKind.Explicit)]
private struct XEvent
{
[FieldOffset(0)] public int type;
[FieldOffset(0)] public XKeyEvent KeyEvent;
}
private const uint ShiftMask = 1u;
[StructLayout(LayoutKind.Sequential)]
private struct XKeyEvent
{
public int type;
public ulong serial;
public bool send_event;
public nint display;
public nint window;
public nint root;
public nint subwindow;
public ulong time;
public int x, y;
public int x_root, y_root;
public uint state;
public int keycode;
public bool same_screen;
}
private const uint LockMask = 2u;
[DllImport("libX11.so.6")]
private static extern nint XOpenDisplay(nint display);
private const uint ControlMask = 4u;
[DllImport("libX11.so.6")]
private static extern void XCloseDisplay(nint display);
private const uint Mod1Mask = 8u;
[DllImport("libX11.so.6")]
private static extern nint XDefaultRootWindow(nint display);
private const uint Mod2Mask = 16u;
[DllImport("libX11.so.6")]
private static extern int XKeysymToKeycode(nint display, nint keysym);
private const uint Mod4Mask = 64u;
[DllImport("libX11.so.6")]
private static extern int XGrabKey(nint display, int keycode, uint modifiers, nint grabWindow,
bool ownerEvents, int pointerMode, int keyboardMode);
private const uint NumLockMask = 16u;
[DllImport("libX11.so.6")]
private static extern int XUngrabKey(nint display, int keycode, uint modifiers, nint grabWindow);
private const uint CapsLockMask = 2u;
[DllImport("libX11.so.6")]
private static extern int XPending(nint display);
private const uint ScrollLockMask = 0u;
[DllImport("libX11.so.6")]
private static extern int XNextEvent(nint display, ref XEvent xevent);
public event EventHandler<HotkeyEventArgs>? HotkeyPressed;
[DllImport("libX11.so.6")]
private static extern void XFlush(nint display);
public void Initialize()
{
_display = XOpenDisplay(IntPtr.Zero);
if (_display == IntPtr.Zero)
{
throw new InvalidOperationException("Failed to open X display");
}
_rootWindow = XDefaultRootWindow(_display);
_isListening = true;
_eventThread = new Thread(ListenForHotkeys)
{
IsBackground = true,
Name = "GlobalHotkeyListener"
};
_eventThread.Start();
}
#endregion
public int Register(HotkeyKey key, HotkeyModifiers modifiers)
{
if (_display == IntPtr.Zero)
{
throw new InvalidOperationException("Service not initialized");
}
int num = XKeysymToKeycode(_display, (nint)key);
if (num == 0)
{
throw new ArgumentException($"Invalid key: {key}");
}
uint modifierMask = GetModifierMask(modifiers);
uint[] modifierCombinations = GetModifierCombinations(modifierMask);
foreach (uint modifiers2 in modifierCombinations)
{
if (XGrabKey(_display, num, modifiers2, _rootWindow, ownerEvents: true, 1, 1) == 0)
{
Console.WriteLine($"Failed to grab key {key} with modifiers {modifiers}");
}
}
int num2 = _nextId++;
_registrations[num2] = new HotkeyRegistration
{
Id = num2,
KeyCode = num,
Modifiers = modifierMask,
Key = key,
ModifierKeys = modifiers
};
XFlush(_display);
return num2;
}
private class HotkeyRegistration
{
public int Id { get; set; }
public int KeyCode { get; set; }
public uint Modifiers { get; set; }
public HotkeyKey Key { get; set; }
public HotkeyModifiers ModifierKeys { get; set; }
}
}
/// <summary>
/// Event args for hotkey pressed events.
/// </summary>
public class HotkeyEventArgs : EventArgs
{
/// <summary>
/// Gets the registration ID.
/// </summary>
public int Id { get; }
/// <summary>
/// Gets the key.
/// </summary>
public HotkeyKey Key { get; }
/// <summary>
/// Gets the modifier keys.
/// </summary>
public HotkeyModifiers Modifiers { get; }
public HotkeyEventArgs(int id, HotkeyKey key, HotkeyModifiers modifiers)
{
Id = id;
Key = key;
Modifiers = modifiers;
}
}
/// <summary>
/// Hotkey modifier keys.
/// </summary>
[Flags]
public enum HotkeyModifiers
{
None = 0,
Shift = 1 << 0,
Control = 1 << 1,
Alt = 1 << 2,
Super = 1 << 3
}
/// <summary>
/// Hotkey keys (X11 keysyms).
/// </summary>
public enum HotkeyKey : uint
{
// Letters
A = 0x61, B = 0x62, C = 0x63, D = 0x64, E = 0x65,
F = 0x66, G = 0x67, H = 0x68, I = 0x69, J = 0x6A,
K = 0x6B, L = 0x6C, M = 0x6D, N = 0x6E, O = 0x6F,
P = 0x70, Q = 0x71, R = 0x72, S = 0x73, T = 0x74,
U = 0x75, V = 0x76, W = 0x77, X = 0x78, Y = 0x79,
Z = 0x7A,
// Numbers
D0 = 0x30, D1 = 0x31, D2 = 0x32, D3 = 0x33, D4 = 0x34,
D5 = 0x35, D6 = 0x36, D7 = 0x37, D8 = 0x38, D9 = 0x39,
// Function keys
F1 = 0xFFBE, F2 = 0xFFBF, F3 = 0xFFC0, F4 = 0xFFC1,
F5 = 0xFFC2, F6 = 0xFFC3, F7 = 0xFFC4, F8 = 0xFFC5,
F9 = 0xFFC6, F10 = 0xFFC7, F11 = 0xFFC8, F12 = 0xFFC9,
// Special keys
Escape = 0xFF1B,
Tab = 0xFF09,
Return = 0xFF0D,
Space = 0x20,
BackSpace = 0xFF08,
Delete = 0xFFFF,
Insert = 0xFF63,
Home = 0xFF50,
End = 0xFF57,
PageUp = 0xFF55,
PageDown = 0xFF56,
// Arrow keys
Left = 0xFF51,
Up = 0xFF52,
Right = 0xFF53,
Down = 0xFF54,
// Media keys
AudioPlay = 0x1008FF14,
AudioStop = 0x1008FF15,
AudioPrev = 0x1008FF16,
AudioNext = 0x1008FF17,
AudioMute = 0x1008FF12,
AudioRaiseVolume = 0x1008FF13,
AudioLowerVolume = 0x1008FF11,
// Print screen
Print = 0xFF61
public void Unregister(int id)
{
if (_registrations.TryRemove(id, out HotkeyRegistration value))
{
uint[] modifierCombinations = GetModifierCombinations(value.Modifiers);
foreach (uint modifiers in modifierCombinations)
{
XUngrabKey(_display, value.KeyCode, modifiers, _rootWindow);
}
XFlush(_display);
}
}
public void UnregisterAll()
{
foreach (int item in _registrations.Keys.ToList())
{
Unregister(item);
}
}
private void ListenForHotkeys()
{
while (_isListening && _display != IntPtr.Zero)
{
try
{
if (XPending(_display) > 0)
{
XEvent xevent = default(XEvent);
XNextEvent(_display, ref xevent);
if (xevent.type == 2)
{
XKeyEvent keyEvent = xevent.KeyEvent;
ProcessKeyEvent(keyEvent.keycode, keyEvent.state);
}
}
else
{
Thread.Sleep(10);
}
}
catch (Exception ex)
{
Console.WriteLine("GlobalHotkeyService error: " + ex.Message);
}
}
}
private void ProcessKeyEvent(int keyCode, uint state)
{
uint num = state & 0xFFFFFFEDu;
foreach (HotkeyRegistration value in _registrations.Values)
{
if (value.KeyCode == keyCode && (value.Modifiers == num || value.Modifiers == (num & 0xFFFFFFEFu)))
{
OnHotkeyPressed(value);
break;
}
}
}
private void OnHotkeyPressed(HotkeyRegistration registration)
{
this.HotkeyPressed?.Invoke(this, new HotkeyEventArgs(registration.Id, registration.Key, registration.ModifierKeys));
}
private uint GetModifierMask(HotkeyModifiers modifiers)
{
uint num = 0u;
if (modifiers.HasFlag(HotkeyModifiers.Shift))
{
num |= 1;
}
if (modifiers.HasFlag(HotkeyModifiers.Control))
{
num |= 4;
}
if (modifiers.HasFlag(HotkeyModifiers.Alt))
{
num |= 8;
}
if (modifiers.HasFlag(HotkeyModifiers.Super))
{
num |= 0x40;
}
return num;
}
private uint[] GetModifierCombinations(uint baseMask)
{
return new uint[4]
{
baseMask,
baseMask | 0x10,
baseMask | 2,
baseMask | 0x10 | 2
};
}
public void Dispose()
{
if (!_disposed)
{
_disposed = true;
_isListening = false;
UnregisterAll();
if (_display != IntPtr.Zero)
{
XCloseDisplay(_display);
_display = IntPtr.Zero;
}
}
}
[DllImport("libX11.so.6")]
private static extern IntPtr XOpenDisplay(IntPtr display);
[DllImport("libX11.so.6")]
private static extern void XCloseDisplay(IntPtr display);
[DllImport("libX11.so.6")]
private static extern IntPtr XDefaultRootWindow(IntPtr display);
[DllImport("libX11.so.6")]
private static extern int XKeysymToKeycode(IntPtr display, IntPtr keysym);
[DllImport("libX11.so.6")]
private static extern int XGrabKey(IntPtr display, int keycode, uint modifiers, IntPtr grabWindow, bool ownerEvents, int pointerMode, int keyboardMode);
[DllImport("libX11.so.6")]
private static extern int XUngrabKey(IntPtr display, int keycode, uint modifiers, IntPtr grabWindow);
[DllImport("libX11.so.6")]
private static extern int XPending(IntPtr display);
[DllImport("libX11.so.6")]
private static extern int XNextEvent(IntPtr display, ref XEvent xevent);
[DllImport("libX11.so.6")]
private static extern void XFlush(IntPtr display);
}

View File

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,11 @@
namespace Microsoft.Maui.Platform.Linux.Services;
public enum GtkButtonsType
{
None,
Ok,
Close,
Cancel,
YesNo,
OkCancel
}

View File

@@ -0,0 +1,68 @@
using System;
using System.Collections.Generic;
using System.Runtime.InteropServices;
using Microsoft.Maui.Platform.Linux.Native;
namespace Microsoft.Maui.Platform.Linux.Services;
public static class GtkContextMenuService
{
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
private delegate void ActivateCallback(IntPtr menuItem, IntPtr userData);
private static readonly List<ActivateCallback> _callbacks = new List<ActivateCallback>();
private static readonly List<Action> _actions = new List<Action>();
public static void ShowContextMenu(List<GtkMenuItem> items)
{
if (items == null || items.Count == 0)
{
return;
}
_callbacks.Clear();
_actions.Clear();
IntPtr intPtr = GtkNative.gtk_menu_new();
if (intPtr == IntPtr.Zero)
{
Console.WriteLine("[GtkContextMenuService] Failed to create GTK menu");
return;
}
foreach (GtkMenuItem item in items)
{
IntPtr intPtr2;
if (item.IsSeparator)
{
intPtr2 = GtkNative.gtk_separator_menu_item_new();
}
else
{
intPtr2 = GtkNative.gtk_menu_item_new_with_label(item.Text);
GtkNative.gtk_widget_set_sensitive(intPtr2, item.IsEnabled);
if (item.IsEnabled && item.Action != null)
{
Action action = item.Action;
_actions.Add(action);
int actionIndex = _actions.Count - 1;
ActivateCallback activateCallback = delegate
{
Console.WriteLine("[GtkContextMenuService] Menu item activated: " + item.Text);
_actions[actionIndex]?.Invoke();
};
_callbacks.Add(activateCallback);
GtkNative.g_signal_connect_data(intPtr2, "activate", Marshal.GetFunctionPointerForDelegate(activateCallback), IntPtr.Zero, IntPtr.Zero, 0);
}
}
GtkNative.gtk_menu_shell_append(intPtr, intPtr2);
GtkNative.gtk_widget_show(intPtr2);
}
GtkNative.gtk_widget_show(intPtr);
IntPtr intPtr3 = GtkNative.gtk_get_current_event();
GtkNative.gtk_menu_popup_at_pointer(intPtr, intPtr3);
if (intPtr3 != IntPtr.Zero)
{
GtkNative.gdk_event_free(intPtr3);
}
Console.WriteLine($"[GtkContextMenuService] Showed GTK menu with {items.Count} items");
}
}

View File

@@ -0,0 +1,9 @@
namespace Microsoft.Maui.Platform.Linux.Services;
public enum GtkFileChooserAction
{
Open,
Save,
SelectFolder,
CreateFolder
}

View File

@@ -0,0 +1,56 @@
using System;
using Microsoft.Maui.Platform.Linux.Handlers;
using Microsoft.Maui.Platform.Linux.Window;
namespace Microsoft.Maui.Platform.Linux.Services;
public class GtkHostService
{
private static GtkHostService? _instance;
private GtkHostWindow? _hostWindow;
private GtkWebViewManager? _webViewManager;
public static GtkHostService Instance => _instance ?? (_instance = new GtkHostService());
public GtkHostWindow? HostWindow => _hostWindow;
public GtkWebViewManager? WebViewManager => _webViewManager;
public bool IsInitialized => _hostWindow != null;
public event EventHandler<GtkHostWindow>? HostWindowCreated;
public void Initialize(string title, int width, int height)
{
if (_hostWindow == null)
{
_hostWindow = new GtkHostWindow(title, width, height);
_webViewManager = new GtkWebViewManager(_hostWindow);
this.HostWindowCreated?.Invoke(this, _hostWindow);
}
}
public GtkHostWindow GetOrCreateHostWindow(string title = "MAUI Application", int width = 800, int height = 600)
{
if (_hostWindow == null)
{
Initialize(title, width, height);
}
return _hostWindow;
}
public void SetWindowIcon(string iconPath)
{
_hostWindow?.SetIcon(iconPath);
}
public void Shutdown()
{
_webViewManager?.Clear();
_webViewManager = null;
_hostWindow?.Dispose();
_hostWindow = null;
}
}

32
Services/GtkMenuItem.cs Normal file
View File

@@ -0,0 +1,32 @@
using System;
namespace Microsoft.Maui.Platform.Linux.Services;
public class GtkMenuItem
{
public string Text { get; }
public Action? Action { get; }
public bool IsEnabled { get; }
public bool IsSeparator { get; }
public static GtkMenuItem Separator => new GtkMenuItem();
public GtkMenuItem(string text, Action? action, bool isEnabled = true)
{
Text = text;
Action = action;
IsEnabled = isEnabled;
IsSeparator = false;
}
private GtkMenuItem()
{
Text = "";
Action = null;
IsEnabled = false;
IsSeparator = true;
}
}

View File

@@ -0,0 +1,10 @@
namespace Microsoft.Maui.Platform.Linux.Services;
public enum GtkMessageType
{
Info,
Warning,
Question,
Error,
Other
}

View File

@@ -0,0 +1,16 @@
namespace Microsoft.Maui.Platform.Linux.Services;
public enum GtkResponseType
{
None = -1,
Reject = -2,
Accept = -3,
DeleteEvent = -4,
Ok = -5,
Cancel = -6,
Close = -7,
Yes = -8,
No = -9,
Apply = -10,
Help = -11
}

View File

File diff suppressed because it is too large Load Diff

View File

@@ -1,524 +1,387 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System;
using System.Diagnostics;
using System.IO;
using System.Runtime.InteropServices;
using System.Text.RegularExpressions;
namespace Microsoft.Maui.Platform.Linux.Services;
/// <summary>
/// Provides HiDPI and display scaling detection for Linux.
/// </summary>
public class HiDpiService
{
private const float DefaultDpi = 96f;
private float _scaleFactor = 1.0f;
private float _dpi = DefaultDpi;
private bool _initialized;
private const float DefaultDpi = 96f;
/// <summary>
/// Gets the current scale factor.
/// </summary>
public float ScaleFactor => _scaleFactor;
private float _scaleFactor = 1f;
/// <summary>
/// Gets the current DPI.
/// </summary>
public float Dpi => _dpi;
private float _dpi = 96f;
/// <summary>
/// Event raised when scale factor changes.
/// </summary>
public event EventHandler<ScaleChangedEventArgs>? ScaleChanged;
private bool _initialized;
/// <summary>
/// Initializes the HiDPI detection service.
/// </summary>
public void Initialize()
{
if (_initialized) return;
_initialized = true;
public float ScaleFactor => _scaleFactor;
DetectScaleFactor();
}
public float Dpi => _dpi;
/// <summary>
/// Detects the current scale factor using multiple methods.
/// </summary>
public void DetectScaleFactor()
{
float scale = 1.0f;
float dpi = DefaultDpi;
public event EventHandler<ScaleChangedEventArgs>? ScaleChanged;
// Try multiple detection methods in order of preference
if (TryGetEnvironmentScale(out float envScale))
{
scale = envScale;
}
else if (TryGetGnomeScale(out float gnomeScale, out float gnomeDpi))
{
scale = gnomeScale;
dpi = gnomeDpi;
}
else if (TryGetKdeScale(out float kdeScale))
{
scale = kdeScale;
}
else if (TryGetX11Scale(out float x11Scale, out float x11Dpi))
{
scale = x11Scale;
dpi = x11Dpi;
}
else if (TryGetXrandrScale(out float xrandrScale))
{
scale = xrandrScale;
}
public void Initialize()
{
if (!_initialized)
{
_initialized = true;
DetectScaleFactor();
}
}
UpdateScale(scale, dpi);
}
public void DetectScaleFactor()
{
float scale = 1f;
float dpi = 96f;
float scale3;
float dpi2;
float scale4;
float scale5;
float dpi3;
float scale6;
if (TryGetEnvironmentScale(out var scale2))
{
scale = scale2;
}
else if (TryGetGnomeScale(out scale3, out dpi2))
{
scale = scale3;
dpi = dpi2;
}
else if (TryGetKdeScale(out scale4))
{
scale = scale4;
}
else if (TryGetX11Scale(out scale5, out dpi3))
{
scale = scale5;
dpi = dpi3;
}
else if (TryGetXrandrScale(out scale6))
{
scale = scale6;
}
UpdateScale(scale, dpi);
}
private void UpdateScale(float scale, float dpi)
{
if (Math.Abs(_scaleFactor - scale) > 0.01f || Math.Abs(_dpi - dpi) > 0.01f)
{
var oldScale = _scaleFactor;
_scaleFactor = scale;
_dpi = dpi;
ScaleChanged?.Invoke(this, new ScaleChangedEventArgs(oldScale, scale, dpi));
}
}
private void UpdateScale(float scale, float dpi)
{
if (Math.Abs(_scaleFactor - scale) > 0.01f || Math.Abs(_dpi - dpi) > 0.01f)
{
float scaleFactor = _scaleFactor;
_scaleFactor = scale;
_dpi = dpi;
this.ScaleChanged?.Invoke(this, new ScaleChangedEventArgs(scaleFactor, scale, dpi));
}
}
/// <summary>
/// Gets scale from environment variables.
/// </summary>
private static bool TryGetEnvironmentScale(out float scale)
{
scale = 1.0f;
private static bool TryGetEnvironmentScale(out float scale)
{
scale = 1f;
string environmentVariable = Environment.GetEnvironmentVariable("GDK_SCALE");
if (!string.IsNullOrEmpty(environmentVariable) && float.TryParse(environmentVariable, out var result))
{
scale = result;
return true;
}
string environmentVariable2 = Environment.GetEnvironmentVariable("GDK_DPI_SCALE");
if (!string.IsNullOrEmpty(environmentVariable2) && float.TryParse(environmentVariable2, out var result2))
{
scale = result2;
return true;
}
string environmentVariable3 = Environment.GetEnvironmentVariable("QT_SCALE_FACTOR");
if (!string.IsNullOrEmpty(environmentVariable3) && float.TryParse(environmentVariable3, out var result3))
{
scale = result3;
return true;
}
string environmentVariable4 = Environment.GetEnvironmentVariable("QT_SCREEN_SCALE_FACTORS");
if (!string.IsNullOrEmpty(environmentVariable4))
{
string text = environmentVariable4.Split(';')[0];
if (text.Contains('='))
{
text = text.Split('=')[1];
}
if (float.TryParse(text, out var result4))
{
scale = result4;
return true;
}
}
return false;
}
// GDK_SCALE (GTK3/4)
var gdkScale = Environment.GetEnvironmentVariable("GDK_SCALE");
if (!string.IsNullOrEmpty(gdkScale) && float.TryParse(gdkScale, out float gdk))
{
scale = gdk;
return true;
}
private static bool TryGetGnomeScale(out float scale, out float dpi)
{
scale = 1f;
dpi = 96f;
try
{
string text = RunCommand("gsettings", "get org.gnome.desktop.interface scaling-factor");
if (!string.IsNullOrEmpty(text))
{
Match match = Regex.Match(text, "uint32\\s+(\\d+)");
if (match.Success && int.TryParse(match.Groups[1].Value, out var result) && result > 0)
{
scale = result;
}
}
text = RunCommand("gsettings", "get org.gnome.desktop.interface text-scaling-factor");
if (!string.IsNullOrEmpty(text) && float.TryParse(text.Trim(), out var result2) && result2 > 0.5f)
{
scale = Math.Max(scale, result2);
}
text = RunCommand("gsettings", "get org.gnome.mutter experimental-features");
if (text != null && text.Contains("scale-monitor-framebuffer"))
{
text = RunCommand("gdbus", "call --session --dest org.gnome.Mutter.DisplayConfig --object-path /org/gnome/Mutter/DisplayConfig --method org.gnome.Mutter.DisplayConfig.GetCurrentState");
if (text != null)
{
Match match2 = Regex.Match(text, "'scale':\\s*<(\\d+\\.?\\d*)>");
if (match2.Success && float.TryParse(match2.Groups[1].Value, out var result3))
{
scale = result3;
}
}
}
return scale > 1f || Math.Abs(scale - 1f) < 0.01f;
}
catch
{
return false;
}
}
// GDK_DPI_SCALE (GTK3/4)
var gdkDpiScale = Environment.GetEnvironmentVariable("GDK_DPI_SCALE");
if (!string.IsNullOrEmpty(gdkDpiScale) && float.TryParse(gdkDpiScale, out float gdkDpi))
{
scale = gdkDpi;
return true;
}
private static bool TryGetKdeScale(out float scale)
{
scale = 1f;
try
{
string text = RunCommand("kreadconfig5", "--file kdeglobals --group KScreen --key ScaleFactor");
if (!string.IsNullOrEmpty(text) && float.TryParse(text.Trim(), out var result) && result > 0f)
{
scale = result;
return true;
}
text = RunCommand("kreadconfig6", "--file kdeglobals --group KScreen --key ScaleFactor");
if (!string.IsNullOrEmpty(text) && float.TryParse(text.Trim(), out var result2) && result2 > 0f)
{
scale = result2;
return true;
}
string path = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".config", "kdeglobals");
if (File.Exists(path))
{
string[] array = File.ReadAllLines(path);
bool flag = false;
string[] array2 = array;
foreach (string text2 in array2)
{
if (text2.Trim() == "[KScreen]")
{
flag = true;
continue;
}
if (flag && text2.StartsWith("["))
{
break;
}
if (flag && text2.StartsWith("ScaleFactor=") && float.TryParse(text2.Substring("ScaleFactor=".Length), out var result3))
{
scale = result3;
return true;
}
}
}
return false;
}
catch
{
return false;
}
}
// QT_SCALE_FACTOR
var qtScale = Environment.GetEnvironmentVariable("QT_SCALE_FACTOR");
if (!string.IsNullOrEmpty(qtScale) && float.TryParse(qtScale, out float qt))
{
scale = qt;
return true;
}
private bool TryGetX11Scale(out float scale, out float dpi)
{
scale = 1f;
dpi = 96f;
try
{
string text = RunCommand("xrdb", "-query");
if (!string.IsNullOrEmpty(text))
{
Match match = Regex.Match(text, "Xft\\.dpi:\\s*(\\d+)");
if (match.Success && float.TryParse(match.Groups[1].Value, out var result))
{
dpi = result;
scale = result / 96f;
return true;
}
}
string path = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".Xresources");
if (File.Exists(path))
{
Match match2 = Regex.Match(File.ReadAllText(path), "Xft\\.dpi:\\s*(\\d+)");
if (match2.Success && float.TryParse(match2.Groups[1].Value, out var result2))
{
dpi = result2;
scale = result2 / 96f;
return true;
}
}
return TryGetX11DpiDirect(out scale, out dpi);
}
catch
{
return false;
}
}
// QT_SCREEN_SCALE_FACTORS (can be per-screen)
var qtScreenScales = Environment.GetEnvironmentVariable("QT_SCREEN_SCALE_FACTORS");
if (!string.IsNullOrEmpty(qtScreenScales))
{
// Format: "screen1=1.5;screen2=2.0" or just "1.5"
var first = qtScreenScales.Split(';')[0];
if (first.Contains('='))
{
first = first.Split('=')[1];
}
if (float.TryParse(first, out float qtScreen))
{
scale = qtScreen;
return true;
}
}
private bool TryGetX11DpiDirect(out float scale, out float dpi)
{
scale = 1f;
dpi = 96f;
try
{
IntPtr intPtr = XOpenDisplay(IntPtr.Zero);
if (intPtr == IntPtr.Zero)
{
return false;
}
try
{
int screen = XDefaultScreen(intPtr);
int num = XDisplayWidthMM(intPtr, screen);
int num2 = XDisplayHeightMM(intPtr, screen);
int num3 = XDisplayWidth(intPtr, screen);
int num4 = XDisplayHeight(intPtr, screen);
if (num > 0 && num2 > 0)
{
float num5 = (float)num3 * 25.4f / (float)num;
float num6 = (float)num4 * 25.4f / (float)num2;
dpi = (num5 + num6) / 2f;
scale = dpi / 96f;
return true;
}
return false;
}
finally
{
XCloseDisplay(intPtr);
}
}
catch
{
return false;
}
}
return false;
}
private static bool TryGetXrandrScale(out float scale)
{
scale = 1f;
try
{
string text = RunCommand("xrandr", "--query");
if (string.IsNullOrEmpty(text))
{
return false;
}
string[] array = text.Split('\n');
foreach (string text2 in array)
{
if (text2.Contains("connected") && !text2.Contains("disconnected"))
{
Match match = Regex.Match(text2, "(\\d+)x(\\d+)\\+\\d+\\+\\d+");
Match match2 = Regex.Match(text2, "(\\d+)mm x (\\d+)mm");
if (match.Success && match2.Success && int.TryParse(match.Groups[1].Value, out var result) && int.TryParse(match2.Groups[1].Value, out var result2) && result2 > 0)
{
float num = (float)result * 25.4f / (float)result2;
scale = num / 96f;
return true;
}
}
}
return false;
}
catch
{
return false;
}
}
/// <summary>
/// Gets scale from GNOME settings.
/// </summary>
private static bool TryGetGnomeScale(out float scale, out float dpi)
{
scale = 1.0f;
dpi = DefaultDpi;
private static string? RunCommand(string command, string arguments)
{
try
{
using Process process = new Process();
process.StartInfo = new ProcessStartInfo
{
FileName = command,
Arguments = arguments,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};
process.Start();
string result = process.StandardOutput.ReadToEnd();
process.WaitForExit(1000);
return result;
}
catch
{
return null;
}
}
try
{
// Try gsettings for GNOME
var result = RunCommand("gsettings", "get org.gnome.desktop.interface scaling-factor");
if (!string.IsNullOrEmpty(result))
{
var match = Regex.Match(result, @"uint32\s+(\d+)");
if (match.Success && int.TryParse(match.Groups[1].Value, out int gnomeScale))
{
if (gnomeScale > 0)
{
scale = gnomeScale;
}
}
}
public float ToPhysicalPixels(float logicalPixels)
{
return logicalPixels * _scaleFactor;
}
// Also check text-scaling-factor for fractional scaling
result = RunCommand("gsettings", "get org.gnome.desktop.interface text-scaling-factor");
if (!string.IsNullOrEmpty(result) && float.TryParse(result.Trim(), out float textScale))
{
if (textScale > 0.5f)
{
scale = Math.Max(scale, textScale);
}
}
public float ToLogicalPixels(float physicalPixels)
{
return physicalPixels / _scaleFactor;
}
// Check for GNOME 40+ experimental fractional scaling
result = RunCommand("gsettings", "get org.gnome.mutter experimental-features");
if (result != null && result.Contains("scale-monitor-framebuffer"))
{
// Fractional scaling is enabled, try to get actual scale
result = RunCommand("gdbus", "call --session --dest org.gnome.Mutter.DisplayConfig --object-path /org/gnome/Mutter/DisplayConfig --method org.gnome.Mutter.DisplayConfig.GetCurrentState");
if (result != null)
{
// Parse for scale value
var scaleMatch = Regex.Match(result, @"'scale':\s*<(\d+\.?\d*)>");
if (scaleMatch.Success && float.TryParse(scaleMatch.Groups[1].Value, out float mutterScale))
{
scale = mutterScale;
}
}
}
public float GetFontScaleFactor()
{
try
{
string text = RunCommand("gsettings", "get org.gnome.desktop.interface text-scaling-factor");
if (!string.IsNullOrEmpty(text) && float.TryParse(text.Trim(), out var result))
{
return result;
}
}
catch
{
}
return _scaleFactor;
}
return scale > 1.0f || Math.Abs(scale - 1.0f) < 0.01f;
}
catch
{
return false;
}
}
[DllImport("libX11.so.6")]
private static extern IntPtr XOpenDisplay(IntPtr display);
/// <summary>
/// Gets scale from KDE settings.
/// </summary>
private static bool TryGetKdeScale(out float scale)
{
scale = 1.0f;
[DllImport("libX11.so.6")]
private static extern void XCloseDisplay(IntPtr display);
try
{
// Try kreadconfig5 for KDE Plasma 5
var result = RunCommand("kreadconfig5", "--file kdeglobals --group KScreen --key ScaleFactor");
if (!string.IsNullOrEmpty(result) && float.TryParse(result.Trim(), out float kdeScale))
{
if (kdeScale > 0)
{
scale = kdeScale;
return true;
}
}
[DllImport("libX11.so.6")]
private static extern int XDefaultScreen(IntPtr display);
// Try KDE Plasma 6
result = RunCommand("kreadconfig6", "--file kdeglobals --group KScreen --key ScaleFactor");
if (!string.IsNullOrEmpty(result) && float.TryParse(result.Trim(), out float kde6Scale))
{
if (kde6Scale > 0)
{
scale = kde6Scale;
return true;
}
}
[DllImport("libX11.so.6")]
private static extern int XDisplayWidth(IntPtr display, int screen);
// Check kdeglobals config file directly
var configPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
".config", "kdeglobals");
[DllImport("libX11.so.6")]
private static extern int XDisplayHeight(IntPtr display, int screen);
if (File.Exists(configPath))
{
var lines = File.ReadAllLines(configPath);
bool inKScreenSection = false;
foreach (var line in lines)
{
if (line.Trim() == "[KScreen]")
{
inKScreenSection = true;
continue;
}
if (inKScreenSection && line.StartsWith("["))
{
break;
}
if (inKScreenSection && line.StartsWith("ScaleFactor="))
{
var value = line.Substring("ScaleFactor=".Length);
if (float.TryParse(value, out float fileScale))
{
scale = fileScale;
return true;
}
}
}
}
[DllImport("libX11.so.6")]
private static extern int XDisplayWidthMM(IntPtr display, int screen);
return false;
}
catch
{
return false;
}
}
/// <summary>
/// Gets scale from X11 Xresources.
/// </summary>
private bool TryGetX11Scale(out float scale, out float dpi)
{
scale = 1.0f;
dpi = DefaultDpi;
try
{
// Try xrdb query
var result = RunCommand("xrdb", "-query");
if (!string.IsNullOrEmpty(result))
{
// Look for Xft.dpi
var match = Regex.Match(result, @"Xft\.dpi:\s*(\d+)");
if (match.Success && float.TryParse(match.Groups[1].Value, out float xftDpi))
{
dpi = xftDpi;
scale = xftDpi / DefaultDpi;
return true;
}
}
// Try reading .Xresources directly
var xresourcesPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
".Xresources");
if (File.Exists(xresourcesPath))
{
var content = File.ReadAllText(xresourcesPath);
var match = Regex.Match(content, @"Xft\.dpi:\s*(\d+)");
if (match.Success && float.TryParse(match.Groups[1].Value, out float fileDpi))
{
dpi = fileDpi;
scale = fileDpi / DefaultDpi;
return true;
}
}
// Try X11 directly
return TryGetX11DpiDirect(out scale, out dpi);
}
catch
{
return false;
}
}
/// <summary>
/// Gets DPI directly from X11 server.
/// </summary>
private bool TryGetX11DpiDirect(out float scale, out float dpi)
{
scale = 1.0f;
dpi = DefaultDpi;
try
{
var display = XOpenDisplay(IntPtr.Zero);
if (display == IntPtr.Zero) return false;
try
{
int screen = XDefaultScreen(display);
// Get physical dimensions
int widthMm = XDisplayWidthMM(display, screen);
int heightMm = XDisplayHeightMM(display, screen);
int widthPx = XDisplayWidth(display, screen);
int heightPx = XDisplayHeight(display, screen);
if (widthMm > 0 && heightMm > 0)
{
float dpiX = widthPx * 25.4f / widthMm;
float dpiY = heightPx * 25.4f / heightMm;
dpi = (dpiX + dpiY) / 2;
scale = dpi / DefaultDpi;
return true;
}
return false;
}
finally
{
XCloseDisplay(display);
}
}
catch
{
return false;
}
}
/// <summary>
/// Gets scale from xrandr output.
/// </summary>
private static bool TryGetXrandrScale(out float scale)
{
scale = 1.0f;
try
{
var result = RunCommand("xrandr", "--query");
if (string.IsNullOrEmpty(result)) return false;
// Look for connected displays with scaling
// Format: "eDP-1 connected primary 2560x1440+0+0 (normal left inverted right x axis y axis) 309mm x 174mm"
var lines = result.Split('\n');
foreach (var line in lines)
{
if (!line.Contains("connected") || line.Contains("disconnected")) continue;
// Try to find resolution and physical size
var resMatch = Regex.Match(line, @"(\d+)x(\d+)\+\d+\+\d+");
var mmMatch = Regex.Match(line, @"(\d+)mm x (\d+)mm");
if (resMatch.Success && mmMatch.Success)
{
if (int.TryParse(resMatch.Groups[1].Value, out int widthPx) &&
int.TryParse(mmMatch.Groups[1].Value, out int widthMm) &&
widthMm > 0)
{
float dpi = widthPx * 25.4f / widthMm;
scale = dpi / DefaultDpi;
return true;
}
}
}
return false;
}
catch
{
return false;
}
}
private static string? RunCommand(string command, string arguments)
{
try
{
using var process = new System.Diagnostics.Process();
process.StartInfo = new System.Diagnostics.ProcessStartInfo
{
FileName = command,
Arguments = arguments,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};
process.Start();
var output = process.StandardOutput.ReadToEnd();
process.WaitForExit(1000);
return output;
}
catch
{
return null;
}
}
/// <summary>
/// Converts logical pixels to physical pixels.
/// </summary>
public float ToPhysicalPixels(float logicalPixels)
{
return logicalPixels * _scaleFactor;
}
/// <summary>
/// Converts physical pixels to logical pixels.
/// </summary>
public float ToLogicalPixels(float physicalPixels)
{
return physicalPixels / _scaleFactor;
}
/// <summary>
/// Gets the recommended font scale factor.
/// </summary>
public float GetFontScaleFactor()
{
// Some desktop environments use a separate text scaling factor
try
{
var result = RunCommand("gsettings", "get org.gnome.desktop.interface text-scaling-factor");
if (!string.IsNullOrEmpty(result) && float.TryParse(result.Trim(), out float textScale))
{
return textScale;
}
}
catch { }
return _scaleFactor;
}
#region X11 Interop
[DllImport("libX11.so.6")]
private static extern nint XOpenDisplay(nint display);
[DllImport("libX11.so.6")]
private static extern void XCloseDisplay(nint display);
[DllImport("libX11.so.6")]
private static extern int XDefaultScreen(nint display);
[DllImport("libX11.so.6")]
private static extern int XDisplayWidth(nint display, int screen);
[DllImport("libX11.so.6")]
private static extern int XDisplayHeight(nint display, int screen);
[DllImport("libX11.so.6")]
private static extern int XDisplayWidthMM(nint display, int screen);
[DllImport("libX11.so.6")]
private static extern int XDisplayHeightMM(nint display, int screen);
#endregion
}
/// <summary>
/// Event args for scale change events.
/// </summary>
public class ScaleChangedEventArgs : EventArgs
{
/// <summary>
/// Gets the old scale factor.
/// </summary>
public float OldScale { get; }
/// <summary>
/// Gets the new scale factor.
/// </summary>
public float NewScale { get; }
/// <summary>
/// Gets the new DPI.
/// </summary>
public float NewDpi { get; }
public ScaleChangedEventArgs(float oldScale, float newScale, float newDpi)
{
OldScale = oldScale;
NewScale = newScale;
NewDpi = newDpi;
}
[DllImport("libX11.so.6")]
private static extern int XDisplayHeightMM(IntPtr display, int screen);
}

View File

@@ -0,0 +1,16 @@
using System;
namespace Microsoft.Maui.Platform.Linux.Services;
public class HighContrastChangedEventArgs : EventArgs
{
public bool IsEnabled { get; }
public HighContrastTheme Theme { get; }
public HighContrastChangedEventArgs(bool isEnabled, HighContrastTheme theme)
{
IsEnabled = isEnabled;
Theme = theme;
}
}

View File

@@ -0,0 +1,32 @@
using SkiaSharp;
namespace Microsoft.Maui.Platform.Linux.Services;
public class HighContrastColors
{
public SKColor Background { get; set; }
public SKColor Foreground { get; set; }
public SKColor Accent { get; set; }
public SKColor Border { get; set; }
public SKColor Error { get; set; }
public SKColor Success { get; set; }
public SKColor Warning { get; set; }
public SKColor Link { get; set; }
public SKColor LinkVisited { get; set; }
public SKColor Selection { get; set; }
public SKColor SelectionText { get; set; }
public SKColor DisabledText { get; set; }
public SKColor DisabledBackground { get; set; }
}

View File

@@ -1,402 +1,342 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System;
using System.Diagnostics;
using System.IO;
using SkiaSharp;
namespace Microsoft.Maui.Platform.Linux.Services;
/// <summary>
/// Provides high contrast mode detection and theme support for accessibility.
/// </summary>
public class HighContrastService
{
private bool _isHighContrastEnabled;
private HighContrastTheme _currentTheme = HighContrastTheme.None;
private bool _initialized;
private bool _isHighContrastEnabled;
/// <summary>
/// Gets whether high contrast mode is enabled.
/// </summary>
public bool IsHighContrastEnabled => _isHighContrastEnabled;
private HighContrastTheme _currentTheme;
/// <summary>
/// Gets the current high contrast theme.
/// </summary>
public HighContrastTheme CurrentTheme => _currentTheme;
private bool _initialized;
/// <summary>
/// Event raised when high contrast mode changes.
/// </summary>
public event EventHandler<HighContrastChangedEventArgs>? HighContrastChanged;
public bool IsHighContrastEnabled => _isHighContrastEnabled;
/// <summary>
/// Initializes the high contrast service.
/// </summary>
public void Initialize()
{
if (_initialized) return;
_initialized = true;
public HighContrastTheme CurrentTheme => _currentTheme;
DetectHighContrast();
}
public event EventHandler<HighContrastChangedEventArgs>? HighContrastChanged;
/// <summary>
/// Detects current high contrast mode settings.
/// </summary>
public void DetectHighContrast()
{
bool isEnabled = false;
var theme = HighContrastTheme.None;
public void Initialize()
{
if (!_initialized)
{
_initialized = true;
DetectHighContrast();
}
}
// Try GNOME settings
if (TryGetGnomeHighContrast(out bool gnomeEnabled, out string? gnomeTheme))
{
isEnabled = gnomeEnabled;
if (gnomeEnabled)
{
theme = ParseThemeName(gnomeTheme);
}
}
// Try KDE settings
else if (TryGetKdeHighContrast(out bool kdeEnabled, out string? kdeTheme))
{
isEnabled = kdeEnabled;
if (kdeEnabled)
{
theme = ParseThemeName(kdeTheme);
}
}
// Try GTK settings
else if (TryGetGtkHighContrast(out bool gtkEnabled, out string? gtkTheme))
{
isEnabled = gtkEnabled;
if (gtkEnabled)
{
theme = ParseThemeName(gtkTheme);
}
}
// Check environment variables
else if (TryGetEnvironmentHighContrast(out bool envEnabled))
{
isEnabled = envEnabled;
theme = HighContrastTheme.WhiteOnBlack; // Default
}
public void DetectHighContrast()
{
bool isEnabled = false;
HighContrastTheme theme = HighContrastTheme.None;
bool isEnabled3;
string themeName2;
bool isEnabled4;
string themeName3;
bool isEnabled5;
if (TryGetGnomeHighContrast(out bool isEnabled2, out string themeName))
{
isEnabled = isEnabled2;
if (isEnabled2)
{
theme = ParseThemeName(themeName);
}
}
else if (TryGetKdeHighContrast(out isEnabled3, out themeName2))
{
isEnabled = isEnabled3;
if (isEnabled3)
{
theme = ParseThemeName(themeName2);
}
}
else if (TryGetGtkHighContrast(out isEnabled4, out themeName3))
{
isEnabled = isEnabled4;
if (isEnabled4)
{
theme = ParseThemeName(themeName3);
}
}
else if (TryGetEnvironmentHighContrast(out isEnabled5))
{
isEnabled = isEnabled5;
theme = HighContrastTheme.WhiteOnBlack;
}
UpdateHighContrast(isEnabled, theme);
}
UpdateHighContrast(isEnabled, theme);
}
private void UpdateHighContrast(bool isEnabled, HighContrastTheme theme)
{
if (_isHighContrastEnabled != isEnabled || _currentTheme != theme)
{
_isHighContrastEnabled = isEnabled;
_currentTheme = theme;
this.HighContrastChanged?.Invoke(this, new HighContrastChangedEventArgs(isEnabled, theme));
}
}
private void UpdateHighContrast(bool isEnabled, HighContrastTheme theme)
{
if (_isHighContrastEnabled != isEnabled || _currentTheme != theme)
{
_isHighContrastEnabled = isEnabled;
_currentTheme = theme;
HighContrastChanged?.Invoke(this, new HighContrastChangedEventArgs(isEnabled, theme));
}
}
private static bool TryGetGnomeHighContrast(out bool isEnabled, out string? themeName)
{
isEnabled = false;
themeName = null;
try
{
string text = RunCommand("gsettings", "get org.gnome.desktop.a11y.interface high-contrast");
if (!string.IsNullOrEmpty(text))
{
isEnabled = text.Trim().ToLower() == "true";
}
text = RunCommand("gsettings", "get org.gnome.desktop.interface gtk-theme");
if (!string.IsNullOrEmpty(text))
{
themeName = text.Trim().Trim('\'');
if (!isEnabled && themeName != null)
{
string text2 = themeName.ToLower();
isEnabled = text2.Contains("highcontrast") || text2.Contains("high-contrast") || text2.Contains("hc");
}
}
return true;
}
catch
{
return false;
}
}
private static bool TryGetGnomeHighContrast(out bool isEnabled, out string? themeName)
{
isEnabled = false;
themeName = null;
private static bool TryGetKdeHighContrast(out bool isEnabled, out string? themeName)
{
isEnabled = false;
themeName = null;
try
{
string path = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".config", "kdeglobals");
if (!File.Exists(path))
{
return false;
}
string[] array = File.ReadAllLines(path);
foreach (string text in array)
{
if (text.StartsWith("ColorScheme="))
{
themeName = text.Substring("ColorScheme=".Length);
string text2 = themeName.ToLower();
isEnabled = text2.Contains("highcontrast") || text2.Contains("high-contrast") || text2.Contains("breeze-high-contrast");
return true;
}
}
return false;
}
catch
{
return false;
}
}
try
{
// Check if high contrast is enabled via gsettings
var result = RunCommand("gsettings", "get org.gnome.desktop.a11y.interface high-contrast");
if (!string.IsNullOrEmpty(result))
{
isEnabled = result.Trim().ToLower() == "true";
}
private static bool TryGetGtkHighContrast(out bool isEnabled, out string? themeName)
{
isEnabled = false;
themeName = null;
try
{
string path = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".config", "gtk-3.0", "settings.ini");
if (!File.Exists(path))
{
path = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".config", "gtk-4.0", "settings.ini");
}
if (!File.Exists(path))
{
return false;
}
string[] array = File.ReadAllLines(path);
foreach (string text in array)
{
if (text.StartsWith("gtk-theme-name="))
{
themeName = text.Substring("gtk-theme-name=".Length);
string text2 = themeName.ToLower();
isEnabled = text2.Contains("highcontrast") || text2.Contains("high-contrast");
return true;
}
}
return false;
}
catch
{
return false;
}
}
// Get the current GTK theme
result = RunCommand("gsettings", "get org.gnome.desktop.interface gtk-theme");
if (!string.IsNullOrEmpty(result))
{
themeName = result.Trim().Trim('\'');
private static bool TryGetEnvironmentHighContrast(out bool isEnabled)
{
isEnabled = false;
string environmentVariable = Environment.GetEnvironmentVariable("GTK_THEME");
if (!string.IsNullOrEmpty(environmentVariable))
{
string text = environmentVariable.ToLower();
isEnabled = text.Contains("highcontrast") || text.Contains("high-contrast");
if (isEnabled)
{
return true;
}
}
string environmentVariable2 = Environment.GetEnvironmentVariable("GTK_A11Y");
if (!(environmentVariable2?.ToLower() == "atspi"))
{
_ = environmentVariable2 == "1";
}
return isEnabled;
}
// Check if theme name indicates high contrast
if (!isEnabled && themeName != null)
{
var lowerTheme = themeName.ToLower();
isEnabled = lowerTheme.Contains("highcontrast") ||
lowerTheme.Contains("high-contrast") ||
lowerTheme.Contains("hc");
}
}
private static HighContrastTheme ParseThemeName(string? themeName)
{
if (string.IsNullOrEmpty(themeName))
{
return HighContrastTheme.WhiteOnBlack;
}
string text = themeName.ToLower();
if (text.Contains("inverse") || text.Contains("dark") || text.Contains("white-on-black"))
{
return HighContrastTheme.WhiteOnBlack;
}
if (text.Contains("light") || text.Contains("black-on-white"))
{
return HighContrastTheme.BlackOnWhite;
}
return HighContrastTheme.WhiteOnBlack;
}
return true;
}
catch
{
return false;
}
}
public HighContrastColors GetColors()
{
//IL_001d: Unknown result type (might be due to invalid IL or missing references)
//IL_0028: Unknown result type (might be due to invalid IL or missing references)
//IL_003e: Unknown result type (might be due to invalid IL or missing references)
//IL_0049: Unknown result type (might be due to invalid IL or missing references)
//IL_005d: Unknown result type (might be due to invalid IL or missing references)
//IL_0071: Unknown result type (might be due to invalid IL or missing references)
//IL_007c: Unknown result type (might be due to invalid IL or missing references)
//IL_0093: Unknown result type (might be due to invalid IL or missing references)
//IL_00ad: Unknown result type (might be due to invalid IL or missing references)
//IL_00c0: Unknown result type (might be due to invalid IL or missing references)
//IL_00cb: Unknown result type (might be due to invalid IL or missing references)
//IL_00e5: Unknown result type (might be due to invalid IL or missing references)
//IL_00f6: Unknown result type (might be due to invalid IL or missing references)
//IL_010c: Unknown result type (might be due to invalid IL or missing references)
//IL_0117: Unknown result type (might be due to invalid IL or missing references)
//IL_0129: Unknown result type (might be due to invalid IL or missing references)
//IL_0134: Unknown result type (might be due to invalid IL or missing references)
//IL_0146: Unknown result type (might be due to invalid IL or missing references)
//IL_0158: Unknown result type (might be due to invalid IL or missing references)
//IL_016b: Unknown result type (might be due to invalid IL or missing references)
//IL_017d: Unknown result type (might be due to invalid IL or missing references)
//IL_018d: Unknown result type (might be due to invalid IL or missing references)
//IL_01a0: Unknown result type (might be due to invalid IL or missing references)
//IL_01ab: Unknown result type (might be due to invalid IL or missing references)
//IL_01bc: Unknown result type (might be due to invalid IL or missing references)
//IL_01d6: Unknown result type (might be due to invalid IL or missing references)
return _currentTheme switch
{
HighContrastTheme.WhiteOnBlack => new HighContrastColors
{
Background = SKColors.Black,
Foreground = SKColors.White,
Accent = new SKColor((byte)0, byte.MaxValue, byte.MaxValue),
Border = SKColors.White,
Error = new SKColor(byte.MaxValue, (byte)100, (byte)100),
Success = new SKColor((byte)100, byte.MaxValue, (byte)100),
Warning = SKColors.Yellow,
Link = new SKColor((byte)100, (byte)200, byte.MaxValue),
LinkVisited = new SKColor((byte)200, (byte)150, byte.MaxValue),
Selection = new SKColor((byte)0, (byte)120, (byte)215),
SelectionText = SKColors.White,
DisabledText = new SKColor((byte)160, (byte)160, (byte)160),
DisabledBackground = new SKColor((byte)40, (byte)40, (byte)40)
},
HighContrastTheme.BlackOnWhite => new HighContrastColors
{
Background = SKColors.White,
Foreground = SKColors.Black,
Accent = new SKColor((byte)0, (byte)0, (byte)200),
Border = SKColors.Black,
Error = new SKColor((byte)180, (byte)0, (byte)0),
Success = new SKColor((byte)0, (byte)130, (byte)0),
Warning = new SKColor((byte)180, (byte)120, (byte)0),
Link = new SKColor((byte)0, (byte)0, (byte)180),
LinkVisited = new SKColor((byte)80, (byte)0, (byte)120),
Selection = new SKColor((byte)0, (byte)120, (byte)215),
SelectionText = SKColors.White,
DisabledText = new SKColor((byte)100, (byte)100, (byte)100),
DisabledBackground = new SKColor((byte)220, (byte)220, (byte)220)
},
_ => GetDefaultColors(),
};
}
private static bool TryGetKdeHighContrast(out bool isEnabled, out string? themeName)
{
isEnabled = false;
themeName = null;
private static HighContrastColors GetDefaultColors()
{
//IL_0006: Unknown result type (might be due to invalid IL or missing references)
//IL_0017: Unknown result type (might be due to invalid IL or missing references)
//IL_002e: Unknown result type (might be due to invalid IL or missing references)
//IL_0048: Unknown result type (might be due to invalid IL or missing references)
//IL_005c: Unknown result type (might be due to invalid IL or missing references)
//IL_0070: Unknown result type (might be due to invalid IL or missing references)
//IL_0086: Unknown result type (might be due to invalid IL or missing references)
//IL_009d: Unknown result type (might be due to invalid IL or missing references)
//IL_00b4: Unknown result type (might be due to invalid IL or missing references)
//IL_00cb: Unknown result type (might be due to invalid IL or missing references)
//IL_00d6: Unknown result type (might be due to invalid IL or missing references)
//IL_00f0: Unknown result type (might be due to invalid IL or missing references)
//IL_010a: Unknown result type (might be due to invalid IL or missing references)
return new HighContrastColors
{
Background = SKColors.White,
Foreground = new SKColor((byte)33, (byte)33, (byte)33),
Accent = new SKColor((byte)33, (byte)150, (byte)243),
Border = new SKColor((byte)200, (byte)200, (byte)200),
Error = new SKColor((byte)244, (byte)67, (byte)54),
Success = new SKColor((byte)76, (byte)175, (byte)80),
Warning = new SKColor(byte.MaxValue, (byte)152, (byte)0),
Link = new SKColor((byte)33, (byte)150, (byte)243),
LinkVisited = new SKColor((byte)156, (byte)39, (byte)176),
Selection = new SKColor((byte)33, (byte)150, (byte)243),
SelectionText = SKColors.White,
DisabledText = new SKColor((byte)158, (byte)158, (byte)158),
DisabledBackground = new SKColor((byte)238, (byte)238, (byte)238)
};
}
try
{
// Check kdeglobals for color scheme
var configPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
".config", "kdeglobals");
public void ForceHighContrast(bool enabled, HighContrastTheme theme = HighContrastTheme.WhiteOnBlack)
{
UpdateHighContrast(enabled, theme);
}
if (!File.Exists(configPath)) return false;
var lines = File.ReadAllLines(configPath);
foreach (var line in lines)
{
if (line.StartsWith("ColorScheme="))
{
themeName = line.Substring("ColorScheme=".Length);
var lowerTheme = themeName.ToLower();
isEnabled = lowerTheme.Contains("highcontrast") ||
lowerTheme.Contains("high-contrast") ||
lowerTheme.Contains("breeze-high-contrast");
return true;
}
}
return false;
}
catch
{
return false;
}
}
private static bool TryGetGtkHighContrast(out bool isEnabled, out string? themeName)
{
isEnabled = false;
themeName = null;
try
{
// Check GTK settings.ini
var gtkConfigPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
".config", "gtk-3.0", "settings.ini");
if (!File.Exists(gtkConfigPath))
{
gtkConfigPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
".config", "gtk-4.0", "settings.ini");
}
if (!File.Exists(gtkConfigPath)) return false;
var lines = File.ReadAllLines(gtkConfigPath);
foreach (var line in lines)
{
if (line.StartsWith("gtk-theme-name="))
{
themeName = line.Substring("gtk-theme-name=".Length);
var lowerTheme = themeName.ToLower();
isEnabled = lowerTheme.Contains("highcontrast") ||
lowerTheme.Contains("high-contrast");
return true;
}
}
return false;
}
catch
{
return false;
}
}
private static bool TryGetEnvironmentHighContrast(out bool isEnabled)
{
isEnabled = false;
// Check GTK_THEME environment variable
var gtkTheme = Environment.GetEnvironmentVariable("GTK_THEME");
if (!string.IsNullOrEmpty(gtkTheme))
{
var lower = gtkTheme.ToLower();
isEnabled = lower.Contains("highcontrast") || lower.Contains("high-contrast");
if (isEnabled) return true;
}
// Check accessibility-related env vars
var forceA11y = Environment.GetEnvironmentVariable("GTK_A11Y");
if (forceA11y?.ToLower() == "atspi" || forceA11y == "1")
{
// A11y is forced, but doesn't necessarily mean high contrast
}
return isEnabled;
}
private static HighContrastTheme ParseThemeName(string? themeName)
{
if (string.IsNullOrEmpty(themeName))
return HighContrastTheme.WhiteOnBlack;
var lower = themeName.ToLower();
if (lower.Contains("inverse") || lower.Contains("dark") || lower.Contains("white-on-black"))
return HighContrastTheme.WhiteOnBlack;
if (lower.Contains("light") || lower.Contains("black-on-white"))
return HighContrastTheme.BlackOnWhite;
// Default to white on black (more common high contrast choice)
return HighContrastTheme.WhiteOnBlack;
}
/// <summary>
/// Gets the appropriate colors for the current high contrast theme.
/// </summary>
public HighContrastColors GetColors()
{
return _currentTheme switch
{
HighContrastTheme.WhiteOnBlack => new HighContrastColors
{
Background = SKColors.Black,
Foreground = SKColors.White,
Accent = new SKColor(0, 255, 255), // Cyan
Border = SKColors.White,
Error = new SKColor(255, 100, 100),
Success = new SKColor(100, 255, 100),
Warning = SKColors.Yellow,
Link = new SKColor(100, 200, 255),
LinkVisited = new SKColor(200, 150, 255),
Selection = new SKColor(0, 120, 215),
SelectionText = SKColors.White,
DisabledText = new SKColor(160, 160, 160),
DisabledBackground = new SKColor(40, 40, 40)
},
HighContrastTheme.BlackOnWhite => new HighContrastColors
{
Background = SKColors.White,
Foreground = SKColors.Black,
Accent = new SKColor(0, 0, 200), // Dark blue
Border = SKColors.Black,
Error = new SKColor(180, 0, 0),
Success = new SKColor(0, 130, 0),
Warning = new SKColor(180, 120, 0),
Link = new SKColor(0, 0, 180),
LinkVisited = new SKColor(80, 0, 120),
Selection = new SKColor(0, 120, 215),
SelectionText = SKColors.White,
DisabledText = new SKColor(100, 100, 100),
DisabledBackground = new SKColor(220, 220, 220)
},
_ => GetDefaultColors()
};
}
private static HighContrastColors GetDefaultColors()
{
return new HighContrastColors
{
Background = SKColors.White,
Foreground = new SKColor(33, 33, 33),
Accent = new SKColor(33, 150, 243),
Border = new SKColor(200, 200, 200),
Error = new SKColor(244, 67, 54),
Success = new SKColor(76, 175, 80),
Warning = new SKColor(255, 152, 0),
Link = new SKColor(33, 150, 243),
LinkVisited = new SKColor(156, 39, 176),
Selection = new SKColor(33, 150, 243),
SelectionText = SKColors.White,
DisabledText = new SKColor(158, 158, 158),
DisabledBackground = new SKColor(238, 238, 238)
};
}
/// <summary>
/// Forces a specific high contrast mode (for testing or user preference override).
/// </summary>
public void ForceHighContrast(bool enabled, HighContrastTheme theme = HighContrastTheme.WhiteOnBlack)
{
UpdateHighContrast(enabled, theme);
}
private static string? RunCommand(string command, string arguments)
{
try
{
using var process = new System.Diagnostics.Process();
process.StartInfo = new System.Diagnostics.ProcessStartInfo
{
FileName = command,
Arguments = arguments,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};
process.Start();
var output = process.StandardOutput.ReadToEnd();
process.WaitForExit(1000);
return output;
}
catch
{
return null;
}
}
}
/// <summary>
/// High contrast theme types.
/// </summary>
public enum HighContrastTheme
{
None,
WhiteOnBlack,
BlackOnWhite
}
/// <summary>
/// Color palette for high contrast mode.
/// </summary>
public class HighContrastColors
{
public SKColor Background { get; set; }
public SKColor Foreground { get; set; }
public SKColor Accent { get; set; }
public SKColor Border { get; set; }
public SKColor Error { get; set; }
public SKColor Success { get; set; }
public SKColor Warning { get; set; }
public SKColor Link { get; set; }
public SKColor LinkVisited { get; set; }
public SKColor Selection { get; set; }
public SKColor SelectionText { get; set; }
public SKColor DisabledText { get; set; }
public SKColor DisabledBackground { get; set; }
}
/// <summary>
/// Event args for high contrast mode changes.
/// </summary>
public class HighContrastChangedEventArgs : EventArgs
{
/// <summary>
/// Gets whether high contrast mode is enabled.
/// </summary>
public bool IsEnabled { get; }
/// <summary>
/// Gets the current theme.
/// </summary>
public HighContrastTheme Theme { get; }
public HighContrastChangedEventArgs(bool isEnabled, HighContrastTheme theme)
{
IsEnabled = isEnabled;
Theme = theme;
}
private static string? RunCommand(string command, string arguments)
{
try
{
using Process process = new Process();
process.StartInfo = new ProcessStartInfo
{
FileName = command,
Arguments = arguments,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};
process.Start();
string result = process.StandardOutput.ReadToEnd();
process.WaitForExit(1000);
return result;
}
catch
{
return null;
}
}
}

View File

@@ -0,0 +1,8 @@
namespace Microsoft.Maui.Platform.Linux.Services;
public enum HighContrastTheme
{
None,
WhiteOnBlack,
BlackOnWhite
}

View File

@@ -0,0 +1,19 @@
using System;
namespace Microsoft.Maui.Platform.Linux.Services;
public class HotkeyEventArgs : EventArgs
{
public int Id { get; }
public HotkeyKey Key { get; }
public HotkeyModifiers Modifiers { get; }
public HotkeyEventArgs(int id, HotkeyKey key, HotkeyModifiers modifiers)
{
Id = id;
Key = key;
Modifiers = modifiers;
}
}

76
Services/HotkeyKey.cs Normal file
View File

@@ -0,0 +1,76 @@
namespace Microsoft.Maui.Platform.Linux.Services;
public enum HotkeyKey : uint
{
A = 97u,
B = 98u,
C = 99u,
D = 100u,
E = 101u,
F = 102u,
G = 103u,
H = 104u,
I = 105u,
J = 106u,
K = 107u,
L = 108u,
M = 109u,
N = 110u,
O = 111u,
P = 112u,
Q = 113u,
R = 114u,
S = 115u,
T = 116u,
U = 117u,
V = 118u,
W = 119u,
X = 120u,
Y = 121u,
Z = 122u,
D0 = 48u,
D1 = 49u,
D2 = 50u,
D3 = 51u,
D4 = 52u,
D5 = 53u,
D6 = 54u,
D7 = 55u,
D8 = 56u,
D9 = 57u,
F1 = 65470u,
F2 = 65471u,
F3 = 65472u,
F4 = 65473u,
F5 = 65474u,
F6 = 65475u,
F7 = 65476u,
F8 = 65477u,
F9 = 65478u,
F10 = 65479u,
F11 = 65480u,
F12 = 65481u,
Escape = 65307u,
Tab = 65289u,
Return = 65293u,
Space = 32u,
BackSpace = 65288u,
Delete = 65535u,
Insert = 65379u,
Home = 65360u,
End = 65367u,
PageUp = 65365u,
PageDown = 65366u,
Left = 65361u,
Up = 65362u,
Right = 65363u,
Down = 65364u,
AudioPlay = 269025044u,
AudioStop = 269025045u,
AudioPrev = 269025046u,
AudioNext = 269025047u,
AudioMute = 269025042u,
AudioRaiseVolume = 269025043u,
AudioLowerVolume = 269025041u,
Print = 65377u
}

View File

@@ -0,0 +1,13 @@
using System;
namespace Microsoft.Maui.Platform.Linux.Services;
[Flags]
public enum HotkeyModifiers
{
None = 0,
Shift = 1,
Control = 2,
Alt = 4,
Super = 8
}

View File

@@ -1,436 +1,22 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
namespace Microsoft.Maui.Platform.Linux.Services;
/// <summary>
/// Interface for accessibility services using AT-SPI2.
/// Provides screen reader support on Linux.
/// </summary>
public interface IAccessibilityService
{
/// <summary>
/// Gets whether accessibility is enabled.
/// </summary>
bool IsEnabled { get; }
bool IsEnabled { get; }
/// <summary>
/// Initializes the accessibility service.
/// </summary>
void Initialize();
void Initialize();
/// <summary>
/// Registers an accessible object.
/// </summary>
/// <param name="accessible">The accessible object to register.</param>
void Register(IAccessible accessible);
void Register(IAccessible accessible);
/// <summary>
/// Unregisters an accessible object.
/// </summary>
/// <param name="accessible">The accessible object to unregister.</param>
void Unregister(IAccessible accessible);
void Unregister(IAccessible accessible);
/// <summary>
/// Notifies that focus has changed.
/// </summary>
/// <param name="accessible">The newly focused accessible object.</param>
void NotifyFocusChanged(IAccessible? accessible);
void NotifyFocusChanged(IAccessible? accessible);
/// <summary>
/// Notifies that a property has changed.
/// </summary>
/// <param name="accessible">The accessible object.</param>
/// <param name="property">The property that changed.</param>
void NotifyPropertyChanged(IAccessible accessible, AccessibleProperty property);
void NotifyPropertyChanged(IAccessible accessible, AccessibleProperty property);
/// <summary>
/// Notifies that an accessible's state has changed.
/// </summary>
/// <param name="accessible">The accessible object.</param>
/// <param name="state">The state that changed.</param>
/// <param name="value">The new value of the state.</param>
void NotifyStateChanged(IAccessible accessible, AccessibleState state, bool value);
void NotifyStateChanged(IAccessible accessible, AccessibleState state, bool value);
/// <summary>
/// Announces text to the screen reader.
/// </summary>
/// <param name="text">The text to announce.</param>
/// <param name="priority">The announcement priority.</param>
void Announce(string text, AnnouncementPriority priority = AnnouncementPriority.Polite);
void Announce(string text, AnnouncementPriority priority = AnnouncementPriority.Polite);
/// <summary>
/// Shuts down the accessibility service.
/// </summary>
void Shutdown();
}
/// <summary>
/// Interface for accessible objects.
/// </summary>
public interface IAccessible
{
/// <summary>
/// Gets the unique identifier for this accessible.
/// </summary>
string AccessibleId { get; }
/// <summary>
/// Gets the accessible name (label for screen readers).
/// </summary>
string AccessibleName { get; }
/// <summary>
/// Gets the accessible description (additional context).
/// </summary>
string AccessibleDescription { get; }
/// <summary>
/// Gets the accessible role.
/// </summary>
AccessibleRole Role { get; }
/// <summary>
/// Gets the accessible states.
/// </summary>
AccessibleStates States { get; }
/// <summary>
/// Gets the parent accessible.
/// </summary>
IAccessible? Parent { get; }
/// <summary>
/// Gets the child accessibles.
/// </summary>
IReadOnlyList<IAccessible> Children { get; }
/// <summary>
/// Gets the bounding rectangle in screen coordinates.
/// </summary>
AccessibleRect Bounds { get; }
/// <summary>
/// Gets the available actions.
/// </summary>
IReadOnlyList<AccessibleAction> Actions { get; }
/// <summary>
/// Performs an action.
/// </summary>
/// <param name="actionName">The name of the action to perform.</param>
/// <returns>True if the action was performed.</returns>
bool DoAction(string actionName);
/// <summary>
/// Gets the accessible value (for sliders, progress bars, etc.).
/// </summary>
double? Value { get; }
/// <summary>
/// Gets the minimum value.
/// </summary>
double? MinValue { get; }
/// <summary>
/// Gets the maximum value.
/// </summary>
double? MaxValue { get; }
/// <summary>
/// Sets the accessible value.
/// </summary>
bool SetValue(double value);
}
/// <summary>
/// Interface for accessible text components.
/// </summary>
public interface IAccessibleText : IAccessible
{
/// <summary>
/// Gets the text content.
/// </summary>
string Text { get; }
/// <summary>
/// Gets the caret offset.
/// </summary>
int CaretOffset { get; }
/// <summary>
/// Gets the number of selections.
/// </summary>
int SelectionCount { get; }
/// <summary>
/// Gets the selection at the specified index.
/// </summary>
(int Start, int End) GetSelection(int index);
/// <summary>
/// Sets the selection.
/// </summary>
bool SetSelection(int index, int start, int end);
/// <summary>
/// Gets the character at the specified offset.
/// </summary>
char GetCharacterAtOffset(int offset);
/// <summary>
/// Gets the text in the specified range.
/// </summary>
string GetTextInRange(int start, int end);
/// <summary>
/// Gets the bounds of the character at the specified offset.
/// </summary>
AccessibleRect GetCharacterBounds(int offset);
}
/// <summary>
/// Interface for editable text components.
/// </summary>
public interface IAccessibleEditableText : IAccessibleText
{
/// <summary>
/// Sets the text content.
/// </summary>
bool SetText(string text);
/// <summary>
/// Inserts text at the specified position.
/// </summary>
bool InsertText(int position, string text);
/// <summary>
/// Deletes text in the specified range.
/// </summary>
bool DeleteText(int start, int end);
/// <summary>
/// Copies text to clipboard.
/// </summary>
bool CopyText(int start, int end);
/// <summary>
/// Cuts text to clipboard.
/// </summary>
bool CutText(int start, int end);
/// <summary>
/// Pastes text from clipboard.
/// </summary>
bool PasteText(int position);
}
/// <summary>
/// Accessible roles (based on AT-SPI2 roles).
/// </summary>
public enum AccessibleRole
{
Unknown,
Window,
Application,
Panel,
Frame,
Button,
CheckBox,
RadioButton,
ComboBox,
Entry,
Label,
List,
ListItem,
Menu,
MenuBar,
MenuItem,
ScrollBar,
Slider,
SpinButton,
StatusBar,
Tab,
TabPanel,
Text,
ToggleButton,
ToolBar,
ToolTip,
Tree,
TreeItem,
Image,
ProgressBar,
Separator,
Link,
Table,
TableCell,
TableRow,
TableColumnHeader,
TableRowHeader,
PageTab,
PageTabList,
Dialog,
Alert,
Filler,
Icon,
Canvas
}
/// <summary>
/// Accessible states.
/// </summary>
[Flags]
public enum AccessibleStates : long
{
None = 0,
Active = 1L << 0,
Armed = 1L << 1,
Busy = 1L << 2,
Checked = 1L << 3,
Collapsed = 1L << 4,
Defunct = 1L << 5,
Editable = 1L << 6,
Enabled = 1L << 7,
Expandable = 1L << 8,
Expanded = 1L << 9,
Focusable = 1L << 10,
Focused = 1L << 11,
HasToolTip = 1L << 12,
Horizontal = 1L << 13,
Iconified = 1L << 14,
Modal = 1L << 15,
MultiLine = 1L << 16,
MultiSelectable = 1L << 17,
Opaque = 1L << 18,
Pressed = 1L << 19,
Resizable = 1L << 20,
Selectable = 1L << 21,
Selected = 1L << 22,
Sensitive = 1L << 23,
Showing = 1L << 24,
SingleLine = 1L << 25,
Stale = 1L << 26,
Transient = 1L << 27,
Vertical = 1L << 28,
Visible = 1L << 29,
ManagesDescendants = 1L << 30,
Indeterminate = 1L << 31,
Required = 1L << 32,
Truncated = 1L << 33,
Animated = 1L << 34,
InvalidEntry = 1L << 35,
SupportsAutocompletion = 1L << 36,
SelectableText = 1L << 37,
IsDefault = 1L << 38,
Visited = 1L << 39,
ReadOnly = 1L << 40
}
/// <summary>
/// Accessible state enumeration for notifications.
/// </summary>
public enum AccessibleState
{
Active,
Armed,
Busy,
Checked,
Collapsed,
Defunct,
Editable,
Enabled,
Expandable,
Expanded,
Focusable,
Focused,
Horizontal,
Iconified,
Modal,
MultiLine,
Opaque,
Pressed,
Resizable,
Selectable,
Selected,
Sensitive,
Showing,
SingleLine,
Stale,
Transient,
Vertical,
Visible,
ManagesDescendants,
Indeterminate,
Required,
InvalidEntry,
ReadOnly
}
/// <summary>
/// Accessible property for notifications.
/// </summary>
public enum AccessibleProperty
{
Name,
Description,
Role,
Value,
Parent,
Children
}
/// <summary>
/// Announcement priority.
/// </summary>
public enum AnnouncementPriority
{
/// <summary>
/// Low priority - can be interrupted.
/// </summary>
Polite,
/// <summary>
/// High priority - interrupts current speech.
/// </summary>
Assertive
}
/// <summary>
/// Represents an accessible action.
/// </summary>
public class AccessibleAction
{
/// <summary>
/// The action name.
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// The action description.
/// </summary>
public string Description { get; set; } = string.Empty;
/// <summary>
/// The keyboard shortcut for this action.
/// </summary>
public string? KeyBinding { get; set; }
}
/// <summary>
/// Represents a rectangle in accessible coordinates.
/// </summary>
public struct AccessibleRect
{
public int X { get; set; }
public int Y { get; set; }
public int Width { get; set; }
public int Height { get; set; }
public AccessibleRect(int x, int y, int width, int height)
{
X = x;
Y = y;
Width = width;
Height = height;
}
void Shutdown();
}

34
Services/IAccessible.cs Normal file
View File

@@ -0,0 +1,34 @@
using System.Collections.Generic;
namespace Microsoft.Maui.Platform.Linux.Services;
public interface IAccessible
{
string AccessibleId { get; }
string AccessibleName { get; }
string AccessibleDescription { get; }
AccessibleRole Role { get; }
AccessibleStates States { get; }
IAccessible? Parent { get; }
IReadOnlyList<IAccessible> Children { get; }
AccessibleRect Bounds { get; }
IReadOnlyList<AccessibleAction> Actions { get; }
double? Value { get; }
double? MinValue { get; }
double? MaxValue { get; }
bool DoAction(string actionName);
bool SetValue(double value);
}

View File

@@ -0,0 +1,16 @@
namespace Microsoft.Maui.Platform.Linux.Services;
public interface IAccessibleEditableText : IAccessibleText, IAccessible
{
bool SetText(string text);
bool InsertText(int position, string text);
bool DeleteText(int start, int end);
bool CopyText(int start, int end);
bool CutText(int start, int end);
bool PasteText(int position);
}

View File

@@ -0,0 +1,20 @@
namespace Microsoft.Maui.Platform.Linux.Services;
public interface IAccessibleText : IAccessible
{
string Text { get; }
int CaretOffset { get; }
int SelectionCount { get; }
(int Start, int End) GetSelection(int index);
bool SetSelection(int index, int start, int end);
char GetCharacterAtOffset(int offset);
string GetTextInRange(int start, int end);
AccessibleRect GetCharacterBounds(int offset);
}

View File

@@ -1,379 +1,382 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System;
using System.Collections.Generic;
using System.Runtime.InteropServices;
using System.Text;
namespace Microsoft.Maui.Platform.Linux.Services;
/// <summary>
/// IBus Input Method service using D-Bus interface.
/// Provides modern IME support on Linux desktops.
/// </summary>
public class IBusInputMethodService : IInputMethodService, IDisposable
{
private nint _bus;
private nint _context;
private IInputContext? _currentContext;
private string _preEditText = string.Empty;
private int _preEditCursorPosition;
private bool _isActive;
private bool _disposed;
// Callback delegates (prevent GC)
private IBusCommitTextCallback? _commitCallback;
private IBusUpdatePreeditTextCallback? _preeditCallback;
private IBusShowPreeditTextCallback? _showPreeditCallback;
private IBusHidePreeditTextCallback? _hidePreeditCallback;
public bool IsActive => _isActive;
public string PreEditText => _preEditText;
public int PreEditCursorPosition => _preEditCursorPosition;
public event EventHandler<TextCommittedEventArgs>? TextCommitted;
public event EventHandler<PreEditChangedEventArgs>? PreEditChanged;
public event EventHandler? PreEditEnded;
public void Initialize(nint windowHandle)
{
try
{
// Initialize IBus
ibus_init();
// Get the IBus bus connection
_bus = ibus_bus_new();
if (_bus == IntPtr.Zero)
{
Console.WriteLine("IBusInputMethodService: Failed to connect to IBus");
return;
}
// Check if IBus is connected
if (!ibus_bus_is_connected(_bus))
{
Console.WriteLine("IBusInputMethodService: IBus not connected");
return;
}
// Create input context
_context = ibus_bus_create_input_context(_bus, "maui-linux");
if (_context == IntPtr.Zero)
{
Console.WriteLine("IBusInputMethodService: Failed to create input context");
return;
}
// Set capabilities
uint capabilities = IBUS_CAP_PREEDIT_TEXT | IBUS_CAP_FOCUS | IBUS_CAP_SURROUNDING_TEXT;
ibus_input_context_set_capabilities(_context, capabilities);
// Connect signals
ConnectSignals();
Console.WriteLine("IBusInputMethodService: Initialized successfully");
}
catch (Exception ex)
{
Console.WriteLine($"IBusInputMethodService: Initialization failed - {ex.Message}");
}
}
private void ConnectSignals()
{
if (_context == IntPtr.Zero) return;
// Set up callbacks for IBus signals
_commitCallback = OnCommitText;
_preeditCallback = OnUpdatePreeditText;
_showPreeditCallback = OnShowPreeditText;
_hidePreeditCallback = OnHidePreeditText;
// Connect to commit-text signal
g_signal_connect(_context, "commit-text",
Marshal.GetFunctionPointerForDelegate(_commitCallback), IntPtr.Zero);
// Connect to update-preedit-text signal
g_signal_connect(_context, "update-preedit-text",
Marshal.GetFunctionPointerForDelegate(_preeditCallback), IntPtr.Zero);
// Connect to show-preedit-text signal
g_signal_connect(_context, "show-preedit-text",
Marshal.GetFunctionPointerForDelegate(_showPreeditCallback), IntPtr.Zero);
// Connect to hide-preedit-text signal
g_signal_connect(_context, "hide-preedit-text",
Marshal.GetFunctionPointerForDelegate(_hidePreeditCallback), IntPtr.Zero);
}
private void OnCommitText(nint context, nint text, nint userData)
{
if (text == IntPtr.Zero) return;
string committedText = GetIBusTextString(text);
if (!string.IsNullOrEmpty(committedText))
{
_preEditText = string.Empty;
_preEditCursorPosition = 0;
_isActive = false;
TextCommitted?.Invoke(this, new TextCommittedEventArgs(committedText));
_currentContext?.OnTextCommitted(committedText);
}
}
private void OnUpdatePreeditText(nint context, nint text, uint cursorPos, bool visible, nint userData)
{
if (!visible)
{
OnHidePreeditText(context, userData);
return;
}
_isActive = true;
_preEditText = text != IntPtr.Zero ? GetIBusTextString(text) : string.Empty;
_preEditCursorPosition = (int)cursorPos;
var attributes = GetPreeditAttributes(text);
PreEditChanged?.Invoke(this, new PreEditChangedEventArgs(_preEditText, _preEditCursorPosition, attributes));
_currentContext?.OnPreEditChanged(_preEditText, _preEditCursorPosition);
}
private void OnShowPreeditText(nint context, nint userData)
{
_isActive = true;
}
private void OnHidePreeditText(nint context, nint userData)
{
_isActive = false;
_preEditText = string.Empty;
_preEditCursorPosition = 0;
PreEditEnded?.Invoke(this, EventArgs.Empty);
_currentContext?.OnPreEditEnded();
}
private string GetIBusTextString(nint ibusText)
{
if (ibusText == IntPtr.Zero) return string.Empty;
nint textPtr = ibus_text_get_text(ibusText);
if (textPtr == IntPtr.Zero) return string.Empty;
return Marshal.PtrToStringUTF8(textPtr) ?? string.Empty;
}
private List<PreEditAttribute> GetPreeditAttributes(nint ibusText)
{
var attributes = new List<PreEditAttribute>();
if (ibusText == IntPtr.Zero) return attributes;
nint attrList = ibus_text_get_attributes(ibusText);
if (attrList == IntPtr.Zero) return attributes;
uint count = ibus_attr_list_size(attrList);
for (uint i = 0; i < count; i++)
{
nint attr = ibus_attr_list_get(attrList, i);
if (attr == IntPtr.Zero) continue;
var type = ibus_attribute_get_attr_type(attr);
var start = ibus_attribute_get_start_index(attr);
var end = ibus_attribute_get_end_index(attr);
attributes.Add(new PreEditAttribute
{
Start = (int)start,
Length = (int)(end - start),
Type = ConvertAttributeType(type)
});
}
return attributes;
}
private PreEditAttributeType ConvertAttributeType(uint ibusType)
{
return ibusType switch
{
IBUS_ATTR_TYPE_UNDERLINE => PreEditAttributeType.Underline,
IBUS_ATTR_TYPE_FOREGROUND => PreEditAttributeType.Highlighted,
IBUS_ATTR_TYPE_BACKGROUND => PreEditAttributeType.Reverse,
_ => PreEditAttributeType.None
};
}
public void SetFocus(IInputContext? context)
{
_currentContext = context;
if (_context != IntPtr.Zero)
{
if (context != null)
{
ibus_input_context_focus_in(_context);
}
else
{
ibus_input_context_focus_out(_context);
}
}
}
public void SetCursorLocation(int x, int y, int width, int height)
{
if (_context == IntPtr.Zero) return;
ibus_input_context_set_cursor_location(_context, x, y, width, height);
}
public bool ProcessKeyEvent(uint keyCode, KeyModifiers modifiers, bool isKeyDown)
{
if (_context == IntPtr.Zero) return false;
uint state = ConvertModifiers(modifiers);
if (!isKeyDown)
{
state |= IBUS_RELEASE_MASK;
}
return ibus_input_context_process_key_event(_context, keyCode, keyCode, state);
}
private uint ConvertModifiers(KeyModifiers modifiers)
{
uint state = 0;
if (modifiers.HasFlag(KeyModifiers.Shift)) state |= IBUS_SHIFT_MASK;
if (modifiers.HasFlag(KeyModifiers.Control)) state |= IBUS_CONTROL_MASK;
if (modifiers.HasFlag(KeyModifiers.Alt)) state |= IBUS_MOD1_MASK;
if (modifiers.HasFlag(KeyModifiers.Super)) state |= IBUS_SUPER_MASK;
if (modifiers.HasFlag(KeyModifiers.CapsLock)) state |= IBUS_LOCK_MASK;
return state;
}
public void Reset()
{
if (_context != IntPtr.Zero)
{
ibus_input_context_reset(_context);
}
_preEditText = string.Empty;
_preEditCursorPosition = 0;
_isActive = false;
PreEditEnded?.Invoke(this, EventArgs.Empty);
_currentContext?.OnPreEditEnded();
}
public void Shutdown()
{
Dispose();
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
if (_context != IntPtr.Zero)
{
ibus_input_context_focus_out(_context);
g_object_unref(_context);
_context = IntPtr.Zero;
}
if (_bus != IntPtr.Zero)
{
g_object_unref(_bus);
_bus = IntPtr.Zero;
}
}
#region IBus Constants
private const uint IBUS_CAP_PREEDIT_TEXT = 1 << 0;
private const uint IBUS_CAP_FOCUS = 1 << 3;
private const uint IBUS_CAP_SURROUNDING_TEXT = 1 << 5;
private const uint IBUS_SHIFT_MASK = 1 << 0;
private const uint IBUS_LOCK_MASK = 1 << 1;
private const uint IBUS_CONTROL_MASK = 1 << 2;
private const uint IBUS_MOD1_MASK = 1 << 3;
private const uint IBUS_SUPER_MASK = 1 << 26;
private const uint IBUS_RELEASE_MASK = 1 << 30;
private const uint IBUS_ATTR_TYPE_UNDERLINE = 1;
private const uint IBUS_ATTR_TYPE_FOREGROUND = 2;
private const uint IBUS_ATTR_TYPE_BACKGROUND = 3;
#endregion
#region IBus Interop
private delegate void IBusCommitTextCallback(nint context, nint text, nint userData);
private delegate void IBusUpdatePreeditTextCallback(nint context, nint text, uint cursorPos, bool visible, nint userData);
private delegate void IBusShowPreeditTextCallback(nint context, nint userData);
private delegate void IBusHidePreeditTextCallback(nint context, nint userData);
private delegate void IBusCommitTextCallback(IntPtr context, IntPtr text, IntPtr userData);
[DllImport("libibus-1.0.so.5")]
private static extern void ibus_init();
private delegate void IBusUpdatePreeditTextCallback(IntPtr context, IntPtr text, uint cursorPos, bool visible, IntPtr userData);
[DllImport("libibus-1.0.so.5")]
private static extern nint ibus_bus_new();
private delegate void IBusShowPreeditTextCallback(IntPtr context, IntPtr userData);
[DllImport("libibus-1.0.so.5")]
private static extern bool ibus_bus_is_connected(nint bus);
private delegate void IBusHidePreeditTextCallback(IntPtr context, IntPtr userData);
[DllImport("libibus-1.0.so.5")]
private static extern nint ibus_bus_create_input_context(nint bus, string clientName);
private IntPtr _bus;
[DllImport("libibus-1.0.so.5")]
private static extern void ibus_input_context_set_capabilities(nint context, uint capabilities);
private IntPtr _context;
[DllImport("libibus-1.0.so.5")]
private static extern void ibus_input_context_focus_in(nint context);
private IInputContext? _currentContext;
[DllImport("libibus-1.0.so.5")]
private static extern void ibus_input_context_focus_out(nint context);
private string _preEditText = string.Empty;
[DllImport("libibus-1.0.so.5")]
private static extern void ibus_input_context_reset(nint context);
private int _preEditCursorPosition;
[DllImport("libibus-1.0.so.5")]
private static extern void ibus_input_context_set_cursor_location(nint context, int x, int y, int w, int h);
private bool _isActive;
[DllImport("libibus-1.0.so.5")]
private static extern bool ibus_input_context_process_key_event(nint context, uint keyval, uint keycode, uint state);
private bool _disposed;
[DllImport("libibus-1.0.so.5")]
private static extern nint ibus_text_get_text(nint text);
private IBusCommitTextCallback? _commitCallback;
[DllImport("libibus-1.0.so.5")]
private static extern nint ibus_text_get_attributes(nint text);
private IBusUpdatePreeditTextCallback? _preeditCallback;
[DllImport("libibus-1.0.so.5")]
private static extern uint ibus_attr_list_size(nint attrList);
private IBusShowPreeditTextCallback? _showPreeditCallback;
[DllImport("libibus-1.0.so.5")]
private static extern nint ibus_attr_list_get(nint attrList, uint index);
private IBusHidePreeditTextCallback? _hidePreeditCallback;
[DllImport("libibus-1.0.so.5")]
private static extern uint ibus_attribute_get_attr_type(nint attr);
private const uint IBUS_CAP_PREEDIT_TEXT = 1u;
[DllImport("libibus-1.0.so.5")]
private static extern uint ibus_attribute_get_start_index(nint attr);
private const uint IBUS_CAP_FOCUS = 8u;
[DllImport("libibus-1.0.so.5")]
private static extern uint ibus_attribute_get_end_index(nint attr);
private const uint IBUS_CAP_SURROUNDING_TEXT = 32u;
[DllImport("libgobject-2.0.so.0")]
private static extern void g_object_unref(nint obj);
private const uint IBUS_SHIFT_MASK = 1u;
[DllImport("libgobject-2.0.so.0")]
private static extern ulong g_signal_connect(nint instance, string signal, nint handler, nint data);
private const uint IBUS_LOCK_MASK = 2u;
#endregion
private const uint IBUS_CONTROL_MASK = 4u;
private const uint IBUS_MOD1_MASK = 8u;
private const uint IBUS_SUPER_MASK = 67108864u;
private const uint IBUS_RELEASE_MASK = 1073741824u;
private const uint IBUS_ATTR_TYPE_UNDERLINE = 1u;
private const uint IBUS_ATTR_TYPE_FOREGROUND = 2u;
private const uint IBUS_ATTR_TYPE_BACKGROUND = 3u;
public bool IsActive => _isActive;
public string PreEditText => _preEditText;
public int PreEditCursorPosition => _preEditCursorPosition;
public event EventHandler<TextCommittedEventArgs>? TextCommitted;
public event EventHandler<PreEditChangedEventArgs>? PreEditChanged;
public event EventHandler? PreEditEnded;
public void Initialize(IntPtr windowHandle)
{
try
{
ibus_init();
_bus = ibus_bus_new();
if (_bus == IntPtr.Zero)
{
Console.WriteLine("IBusInputMethodService: Failed to connect to IBus");
return;
}
if (!ibus_bus_is_connected(_bus))
{
Console.WriteLine("IBusInputMethodService: IBus not connected");
return;
}
_context = ibus_bus_create_input_context(_bus, "maui-linux");
if (_context == IntPtr.Zero)
{
Console.WriteLine("IBusInputMethodService: Failed to create input context");
return;
}
uint capabilities = 41u;
ibus_input_context_set_capabilities(_context, capabilities);
ConnectSignals();
Console.WriteLine("IBusInputMethodService: Initialized successfully");
}
catch (Exception ex)
{
Console.WriteLine("IBusInputMethodService: Initialization failed - " + ex.Message);
}
}
private void ConnectSignals()
{
if (_context != IntPtr.Zero)
{
_commitCallback = OnCommitText;
_preeditCallback = OnUpdatePreeditText;
_showPreeditCallback = OnShowPreeditText;
_hidePreeditCallback = OnHidePreeditText;
g_signal_connect(_context, "commit-text", Marshal.GetFunctionPointerForDelegate(_commitCallback), IntPtr.Zero);
g_signal_connect(_context, "update-preedit-text", Marshal.GetFunctionPointerForDelegate(_preeditCallback), IntPtr.Zero);
g_signal_connect(_context, "show-preedit-text", Marshal.GetFunctionPointerForDelegate(_showPreeditCallback), IntPtr.Zero);
g_signal_connect(_context, "hide-preedit-text", Marshal.GetFunctionPointerForDelegate(_hidePreeditCallback), IntPtr.Zero);
}
}
private void OnCommitText(IntPtr context, IntPtr text, IntPtr userData)
{
if (text != IntPtr.Zero)
{
string iBusTextString = GetIBusTextString(text);
if (!string.IsNullOrEmpty(iBusTextString))
{
_preEditText = string.Empty;
_preEditCursorPosition = 0;
_isActive = false;
this.TextCommitted?.Invoke(this, new TextCommittedEventArgs(iBusTextString));
_currentContext?.OnTextCommitted(iBusTextString);
}
}
}
private void OnUpdatePreeditText(IntPtr context, IntPtr text, uint cursorPos, bool visible, IntPtr userData)
{
if (!visible)
{
OnHidePreeditText(context, userData);
return;
}
_isActive = true;
_preEditText = ((text != IntPtr.Zero) ? GetIBusTextString(text) : string.Empty);
_preEditCursorPosition = (int)cursorPos;
List<PreEditAttribute> preeditAttributes = GetPreeditAttributes(text);
this.PreEditChanged?.Invoke(this, new PreEditChangedEventArgs(_preEditText, _preEditCursorPosition, preeditAttributes));
_currentContext?.OnPreEditChanged(_preEditText, _preEditCursorPosition);
}
private void OnShowPreeditText(IntPtr context, IntPtr userData)
{
_isActive = true;
}
private void OnHidePreeditText(IntPtr context, IntPtr userData)
{
_isActive = false;
_preEditText = string.Empty;
_preEditCursorPosition = 0;
this.PreEditEnded?.Invoke(this, EventArgs.Empty);
_currentContext?.OnPreEditEnded();
}
private string GetIBusTextString(IntPtr ibusText)
{
if (ibusText == IntPtr.Zero)
{
return string.Empty;
}
IntPtr intPtr = ibus_text_get_text(ibusText);
if (intPtr == IntPtr.Zero)
{
return string.Empty;
}
return Marshal.PtrToStringUTF8(intPtr) ?? string.Empty;
}
private List<PreEditAttribute> GetPreeditAttributes(IntPtr ibusText)
{
List<PreEditAttribute> list = new List<PreEditAttribute>();
if (ibusText == IntPtr.Zero)
{
return list;
}
IntPtr intPtr = ibus_text_get_attributes(ibusText);
if (intPtr == IntPtr.Zero)
{
return list;
}
uint num = ibus_attr_list_size(intPtr);
for (uint num2 = 0u; num2 < num; num2++)
{
IntPtr intPtr2 = ibus_attr_list_get(intPtr, num2);
if (intPtr2 != IntPtr.Zero)
{
uint ibusType = ibus_attribute_get_attr_type(intPtr2);
uint num3 = ibus_attribute_get_start_index(intPtr2);
uint num4 = ibus_attribute_get_end_index(intPtr2);
list.Add(new PreEditAttribute
{
Start = (int)num3,
Length = (int)(num4 - num3),
Type = ConvertAttributeType(ibusType)
});
}
}
return list;
}
private PreEditAttributeType ConvertAttributeType(uint ibusType)
{
return ibusType switch
{
1u => PreEditAttributeType.Underline,
2u => PreEditAttributeType.Highlighted,
3u => PreEditAttributeType.Reverse,
_ => PreEditAttributeType.None,
};
}
public void SetFocus(IInputContext? context)
{
_currentContext = context;
if (_context != IntPtr.Zero)
{
if (context != null)
{
ibus_input_context_focus_in(_context);
}
else
{
ibus_input_context_focus_out(_context);
}
}
}
public void SetCursorLocation(int x, int y, int width, int height)
{
if (_context != IntPtr.Zero)
{
ibus_input_context_set_cursor_location(_context, x, y, width, height);
}
}
public bool ProcessKeyEvent(uint keyCode, KeyModifiers modifiers, bool isKeyDown)
{
if (_context == IntPtr.Zero)
{
return false;
}
uint num = ConvertModifiers(modifiers);
if (!isKeyDown)
{
num |= 0x40000000;
}
return ibus_input_context_process_key_event(_context, keyCode, keyCode, num);
}
private uint ConvertModifiers(KeyModifiers modifiers)
{
uint num = 0u;
if (modifiers.HasFlag(KeyModifiers.Shift))
{
num |= 1;
}
if (modifiers.HasFlag(KeyModifiers.Control))
{
num |= 4;
}
if (modifiers.HasFlag(KeyModifiers.Alt))
{
num |= 8;
}
if (modifiers.HasFlag(KeyModifiers.Super))
{
num |= 0x4000000;
}
if (modifiers.HasFlag(KeyModifiers.CapsLock))
{
num |= 2;
}
return num;
}
public void Reset()
{
if (_context != IntPtr.Zero)
{
ibus_input_context_reset(_context);
}
_preEditText = string.Empty;
_preEditCursorPosition = 0;
_isActive = false;
this.PreEditEnded?.Invoke(this, EventArgs.Empty);
_currentContext?.OnPreEditEnded();
}
public void Shutdown()
{
Dispose();
}
public void Dispose()
{
if (!_disposed)
{
_disposed = true;
if (_context != IntPtr.Zero)
{
ibus_input_context_focus_out(_context);
g_object_unref(_context);
_context = IntPtr.Zero;
}
if (_bus != IntPtr.Zero)
{
g_object_unref(_bus);
_bus = IntPtr.Zero;
}
}
}
[DllImport("libibus-1.0.so.5")]
private static extern void ibus_init();
[DllImport("libibus-1.0.so.5")]
private static extern IntPtr ibus_bus_new();
[DllImport("libibus-1.0.so.5")]
private static extern bool ibus_bus_is_connected(IntPtr bus);
[DllImport("libibus-1.0.so.5")]
private static extern IntPtr ibus_bus_create_input_context(IntPtr bus, string clientName);
[DllImport("libibus-1.0.so.5")]
private static extern void ibus_input_context_set_capabilities(IntPtr context, uint capabilities);
[DllImport("libibus-1.0.so.5")]
private static extern void ibus_input_context_focus_in(IntPtr context);
[DllImport("libibus-1.0.so.5")]
private static extern void ibus_input_context_focus_out(IntPtr context);
[DllImport("libibus-1.0.so.5")]
private static extern void ibus_input_context_reset(IntPtr context);
[DllImport("libibus-1.0.so.5")]
private static extern void ibus_input_context_set_cursor_location(IntPtr context, int x, int y, int w, int h);
[DllImport("libibus-1.0.so.5")]
private static extern bool ibus_input_context_process_key_event(IntPtr context, uint keyval, uint keycode, uint state);
[DllImport("libibus-1.0.so.5")]
private static extern IntPtr ibus_text_get_text(IntPtr text);
[DllImport("libibus-1.0.so.5")]
private static extern IntPtr ibus_text_get_attributes(IntPtr text);
[DllImport("libibus-1.0.so.5")]
private static extern uint ibus_attr_list_size(IntPtr attrList);
[DllImport("libibus-1.0.so.5")]
private static extern IntPtr ibus_attr_list_get(IntPtr attrList, uint index);
[DllImport("libibus-1.0.so.5")]
private static extern uint ibus_attribute_get_attr_type(IntPtr attr);
[DllImport("libibus-1.0.so.5")]
private static extern uint ibus_attribute_get_start_index(IntPtr attr);
[DllImport("libibus-1.0.so.5")]
private static extern uint ibus_attribute_get_end_index(IntPtr attr);
[DllImport("libgobject-2.0.so.0")]
private static extern void g_object_unref(IntPtr obj);
[DllImport("libgobject-2.0.so.0")]
private static extern ulong g_signal_connect(IntPtr instance, string signal, IntPtr handler, IntPtr data);
}

View File

@@ -0,0 +1,44 @@
using System;
namespace Microsoft.Maui.Platform.Linux.Services;
public interface IDisplayWindow : IDisposable
{
int Width { get; }
int Height { get; }
bool IsRunning { get; }
event EventHandler<KeyEventArgs>? KeyDown;
event EventHandler<KeyEventArgs>? KeyUp;
event EventHandler<TextInputEventArgs>? TextInput;
event EventHandler<PointerEventArgs>? PointerMoved;
event EventHandler<PointerEventArgs>? PointerPressed;
event EventHandler<PointerEventArgs>? PointerReleased;
event EventHandler<ScrollEventArgs>? Scroll;
event EventHandler? Exposed;
event EventHandler<(int Width, int Height)>? Resized;
event EventHandler? CloseRequested;
void Show();
void Hide();
void SetTitle(string title);
void Resize(int width, int height);
void ProcessEvents();
void Stop();
}

18
Services/IInputContext.cs Normal file
View File

@@ -0,0 +1,18 @@
namespace Microsoft.Maui.Platform.Linux.Services;
public interface IInputContext
{
string Text { get; set; }
int CursorPosition { get; set; }
int SelectionStart { get; }
int SelectionLength { get; }
void OnTextCommitted(string text);
void OnPreEditChanged(string preEditText, int cursorPosition);
void OnPreEditEnded();
}

View File

@@ -1,231 +1,30 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System;
namespace Microsoft.Maui.Platform.Linux.Services;
/// <summary>
/// Interface for Input Method Editor (IME) services.
/// Provides support for complex text input methods like CJK languages.
/// </summary>
public interface IInputMethodService
{
/// <summary>
/// Gets whether IME is currently active.
/// </summary>
bool IsActive { get; }
bool IsActive { get; }
/// <summary>
/// Gets the current pre-edit (composition) text.
/// </summary>
string PreEditText { get; }
string PreEditText { get; }
/// <summary>
/// Gets the cursor position within the pre-edit text.
/// </summary>
int PreEditCursorPosition { get; }
int PreEditCursorPosition { get; }
/// <summary>
/// Initializes the IME service for the given window.
/// </summary>
/// <param name="windowHandle">The native window handle.</param>
void Initialize(nint windowHandle);
event EventHandler<TextCommittedEventArgs>? TextCommitted;
/// <summary>
/// Sets focus to the specified input context.
/// </summary>
/// <param name="context">The input context to focus.</param>
void SetFocus(IInputContext? context);
event EventHandler<PreEditChangedEventArgs>? PreEditChanged;
/// <summary>
/// Sets the cursor location for candidate window positioning.
/// </summary>
/// <param name="x">X coordinate in screen space.</param>
/// <param name="y">Y coordinate in screen space.</param>
/// <param name="width">Width of the cursor area.</param>
/// <param name="height">Height of the cursor area.</param>
void SetCursorLocation(int x, int y, int width, int height);
event EventHandler? PreEditEnded;
/// <summary>
/// Processes a key event through the IME.
/// </summary>
/// <param name="keyCode">The key code.</param>
/// <param name="modifiers">Key modifiers.</param>
/// <param name="isKeyDown">True for key press, false for key release.</param>
/// <returns>True if the IME handled the event.</returns>
bool ProcessKeyEvent(uint keyCode, KeyModifiers modifiers, bool isKeyDown);
void Initialize(IntPtr windowHandle);
/// <summary>
/// Resets the IME state, canceling any composition.
/// </summary>
void Reset();
void SetFocus(IInputContext? context);
/// <summary>
/// Shuts down the IME service.
/// </summary>
void Shutdown();
void SetCursorLocation(int x, int y, int width, int height);
/// <summary>
/// Event raised when text is committed from IME.
/// </summary>
event EventHandler<TextCommittedEventArgs>? TextCommitted;
bool ProcessKeyEvent(uint keyCode, KeyModifiers modifiers, bool isKeyDown);
/// <summary>
/// Event raised when pre-edit (composition) text changes.
/// </summary>
event EventHandler<PreEditChangedEventArgs>? PreEditChanged;
void Reset();
/// <summary>
/// Event raised when pre-edit is completed or cancelled.
/// </summary>
event EventHandler? PreEditEnded;
}
/// <summary>
/// Represents an input context that can receive IME input.
/// </summary>
public interface IInputContext
{
/// <summary>
/// Gets or sets the current text content.
/// </summary>
string Text { get; set; }
/// <summary>
/// Gets or sets the cursor position.
/// </summary>
int CursorPosition { get; set; }
/// <summary>
/// Gets the selection start position.
/// </summary>
int SelectionStart { get; }
/// <summary>
/// Gets the selection length.
/// </summary>
int SelectionLength { get; }
/// <summary>
/// Called when text is committed from the IME.
/// </summary>
/// <param name="text">The committed text.</param>
void OnTextCommitted(string text);
/// <summary>
/// Called when pre-edit text changes.
/// </summary>
/// <param name="preEditText">The current pre-edit text.</param>
/// <param name="cursorPosition">Cursor position within pre-edit text.</param>
void OnPreEditChanged(string preEditText, int cursorPosition);
/// <summary>
/// Called when pre-edit mode ends.
/// </summary>
void OnPreEditEnded();
}
/// <summary>
/// Event args for text committed events.
/// </summary>
public class TextCommittedEventArgs : EventArgs
{
/// <summary>
/// The committed text.
/// </summary>
public string Text { get; }
public TextCommittedEventArgs(string text)
{
Text = text;
}
}
/// <summary>
/// Event args for pre-edit changed events.
/// </summary>
public class PreEditChangedEventArgs : EventArgs
{
/// <summary>
/// The current pre-edit text.
/// </summary>
public string PreEditText { get; }
/// <summary>
/// Cursor position within the pre-edit text.
/// </summary>
public int CursorPosition { get; }
/// <summary>
/// Formatting attributes for the pre-edit text.
/// </summary>
public IReadOnlyList<PreEditAttribute> Attributes { get; }
public PreEditChangedEventArgs(string preEditText, int cursorPosition, IReadOnlyList<PreEditAttribute>? attributes = null)
{
PreEditText = preEditText;
CursorPosition = cursorPosition;
Attributes = attributes ?? Array.Empty<PreEditAttribute>();
}
}
/// <summary>
/// Represents formatting for a portion of pre-edit text.
/// </summary>
public class PreEditAttribute
{
/// <summary>
/// Start position in the pre-edit text.
/// </summary>
public int Start { get; set; }
/// <summary>
/// Length of the attributed range.
/// </summary>
public int Length { get; set; }
/// <summary>
/// The attribute type.
/// </summary>
public PreEditAttributeType Type { get; set; }
}
/// <summary>
/// Types of pre-edit text attributes.
/// </summary>
public enum PreEditAttributeType
{
/// <summary>
/// Normal text (no special formatting).
/// </summary>
None,
/// <summary>
/// Underlined text (typical for composition).
/// </summary>
Underline,
/// <summary>
/// Highlighted/selected text.
/// </summary>
Highlighted,
/// <summary>
/// Reverse video (selected clause in some IMEs).
/// </summary>
Reverse
}
/// <summary>
/// Key modifiers for IME processing.
/// </summary>
[Flags]
public enum KeyModifiers
{
None = 0,
Shift = 1 << 0,
Control = 1 << 1,
Alt = 1 << 2,
Super = 1 << 3,
CapsLock = 1 << 4,
NumLock = 1 << 5
void Shutdown();
}

View File

@@ -1,203 +1,154 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System;
using System.Runtime.InteropServices;
namespace Microsoft.Maui.Platform.Linux.Services;
/// <summary>
/// Factory for creating the appropriate Input Method service.
/// Automatically selects IBus or XIM based on availability.
/// </summary>
public static class InputMethodServiceFactory
{
private static IInputMethodService? _instance;
private static readonly object _lock = new();
private static IInputMethodService? _instance;
/// <summary>
/// Gets the singleton input method service instance.
/// </summary>
public static IInputMethodService Instance
{
get
{
if (_instance == null)
{
lock (_lock)
{
_instance ??= CreateService();
}
}
return _instance;
}
}
private static readonly object _lock = new object();
/// <summary>
/// Creates the most appropriate input method service for the current environment.
/// </summary>
public static IInputMethodService CreateService()
{
// Check environment variable for user preference
var imePreference = Environment.GetEnvironmentVariable("MAUI_INPUT_METHOD");
public static IInputMethodService Instance
{
get
{
if (_instance == null)
{
lock (_lock)
{
if (_instance == null)
{
_instance = CreateService();
}
}
}
return _instance;
}
}
if (!string.IsNullOrEmpty(imePreference))
{
return imePreference.ToLowerInvariant() switch
{
"ibus" => CreateIBusService(),
"fcitx" or "fcitx5" => CreateFcitx5Service(),
"xim" => CreateXIMService(),
"none" => new NullInputMethodService(),
_ => CreateAutoService()
};
}
public static IInputMethodService CreateService()
{
string environmentVariable = Environment.GetEnvironmentVariable("MAUI_INPUT_METHOD");
if (!string.IsNullOrEmpty(environmentVariable))
{
switch (environmentVariable.ToLowerInvariant())
{
case "ibus":
return CreateIBusService();
case "fcitx":
case "fcitx5":
return CreateFcitx5Service();
case "xim":
return CreateXIMService();
case "none":
return new NullInputMethodService();
default:
return CreateAutoService();
}
}
return CreateAutoService();
}
return CreateAutoService();
}
private static IInputMethodService CreateAutoService()
{
string obj = Environment.GetEnvironmentVariable("GTK_IM_MODULE")?.ToLowerInvariant();
if (obj != null && obj.Contains("fcitx") && Fcitx5InputMethodService.IsAvailable())
{
Console.WriteLine("InputMethodServiceFactory: Using Fcitx5");
return CreateFcitx5Service();
}
if (IsIBusAvailable())
{
Console.WriteLine("InputMethodServiceFactory: Using IBus");
return CreateIBusService();
}
if (Fcitx5InputMethodService.IsAvailable())
{
Console.WriteLine("InputMethodServiceFactory: Using Fcitx5");
return CreateFcitx5Service();
}
if (IsXIMAvailable())
{
Console.WriteLine("InputMethodServiceFactory: Using XIM");
return CreateXIMService();
}
Console.WriteLine("InputMethodServiceFactory: No IME available, using null service");
return new NullInputMethodService();
}
private static IInputMethodService CreateAutoService()
{
// Check GTK_IM_MODULE for hint
var imModule = Environment.GetEnvironmentVariable("GTK_IM_MODULE")?.ToLowerInvariant();
private static IInputMethodService CreateIBusService()
{
try
{
return new IBusInputMethodService();
}
catch (Exception ex)
{
Console.WriteLine("InputMethodServiceFactory: Failed to create IBus service - " + ex.Message);
return new NullInputMethodService();
}
}
// Try Fcitx5 first if it's the configured IM
if (imModule?.Contains("fcitx") == true && Fcitx5InputMethodService.IsAvailable())
{
Console.WriteLine("InputMethodServiceFactory: Using Fcitx5");
return CreateFcitx5Service();
}
private static IInputMethodService CreateFcitx5Service()
{
try
{
return new Fcitx5InputMethodService();
}
catch (Exception ex)
{
Console.WriteLine("InputMethodServiceFactory: Failed to create Fcitx5 service - " + ex.Message);
return new NullInputMethodService();
}
}
// Try IBus (most common on modern Linux)
if (IsIBusAvailable())
{
Console.WriteLine("InputMethodServiceFactory: Using IBus");
return CreateIBusService();
}
private static IInputMethodService CreateXIMService()
{
try
{
return new X11InputMethodService();
}
catch (Exception ex)
{
Console.WriteLine("InputMethodServiceFactory: Failed to create XIM service - " + ex.Message);
return new NullInputMethodService();
}
}
// Try Fcitx5 as fallback
if (Fcitx5InputMethodService.IsAvailable())
{
Console.WriteLine("InputMethodServiceFactory: Using Fcitx5");
return CreateFcitx5Service();
}
private static bool IsIBusAvailable()
{
if (!string.IsNullOrEmpty(Environment.GetEnvironmentVariable("IBUS_ADDRESS")))
{
return true;
}
try
{
NativeLibrary.Free(NativeLibrary.Load("libibus-1.0.so.5"));
return true;
}
catch
{
return false;
}
}
// Fall back to XIM
if (IsXIMAvailable())
{
Console.WriteLine("InputMethodServiceFactory: Using XIM");
return CreateXIMService();
}
private static bool IsXIMAvailable()
{
string environmentVariable = Environment.GetEnvironmentVariable("XMODIFIERS");
if (!string.IsNullOrEmpty(environmentVariable) && environmentVariable.Contains("@im="))
{
return true;
}
return !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("DISPLAY"));
}
// No IME available
Console.WriteLine("InputMethodServiceFactory: No IME available, using null service");
return new NullInputMethodService();
}
private static IInputMethodService CreateIBusService()
{
try
{
return new IBusInputMethodService();
}
catch (Exception ex)
{
Console.WriteLine($"InputMethodServiceFactory: Failed to create IBus service - {ex.Message}");
return new NullInputMethodService();
}
}
private static IInputMethodService CreateFcitx5Service()
{
try
{
return new Fcitx5InputMethodService();
}
catch (Exception ex)
{
Console.WriteLine($"InputMethodServiceFactory: Failed to create Fcitx5 service - {ex.Message}");
return new NullInputMethodService();
}
}
private static IInputMethodService CreateXIMService()
{
try
{
return new X11InputMethodService();
}
catch (Exception ex)
{
Console.WriteLine($"InputMethodServiceFactory: Failed to create XIM service - {ex.Message}");
return new NullInputMethodService();
}
}
private static bool IsIBusAvailable()
{
// Check if IBus daemon is running
var ibusAddress = Environment.GetEnvironmentVariable("IBUS_ADDRESS");
if (!string.IsNullOrEmpty(ibusAddress))
{
return true;
}
// Try to load IBus library
try
{
var handle = NativeLibrary.Load("libibus-1.0.so.5");
NativeLibrary.Free(handle);
return true;
}
catch
{
return false;
}
}
private static bool IsXIMAvailable()
{
// Check XMODIFIERS environment variable
var xmodifiers = Environment.GetEnvironmentVariable("XMODIFIERS");
if (!string.IsNullOrEmpty(xmodifiers) && xmodifiers.Contains("@im="))
{
return true;
}
// Check if running under X11
var display = Environment.GetEnvironmentVariable("DISPLAY");
return !string.IsNullOrEmpty(display);
}
/// <summary>
/// Resets the singleton instance (useful for testing).
/// </summary>
public static void Reset()
{
lock (_lock)
{
_instance?.Shutdown();
_instance = null;
}
}
}
/// <summary>
/// Null implementation of IInputMethodService for when no IME is available.
/// </summary>
public class NullInputMethodService : IInputMethodService
{
public bool IsActive => false;
public string PreEditText => string.Empty;
public int PreEditCursorPosition => 0;
public event EventHandler<TextCommittedEventArgs>? TextCommitted;
public event EventHandler<PreEditChangedEventArgs>? PreEditChanged;
public event EventHandler? PreEditEnded;
public void Initialize(nint windowHandle) { }
public void SetFocus(IInputContext? context) { }
public void SetCursorLocation(int x, int y, int width, int height) { }
public bool ProcessKeyEvent(uint keyCode, KeyModifiers modifiers, bool isKeyDown) => false;
public void Reset() { }
public void Shutdown() { }
public static void Reset()
{
lock (_lock)
{
_instance?.Shutdown();
_instance = null;
}
}
}

15
Services/KeyModifiers.cs Normal file
View File

@@ -0,0 +1,15 @@
using System;
namespace Microsoft.Maui.Platform.Linux.Services;
[Flags]
public enum KeyModifiers
{
None = 0,
Shift = 1,
Control = 2,
Alt = 4,
Super = 8,
CapsLock = 0x10,
NumLock = 0x20
}

View File

@@ -1,85 +1,77 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System;
using System.Diagnostics;
using System.Threading.Tasks;
using Microsoft.Maui.ApplicationModel;
using Microsoft.Maui.Storage;
namespace Microsoft.Maui.Platform.Linux.Services;
/// <summary>
/// Linux launcher service for opening URLs and files.
/// </summary>
public class LauncherService : ILauncher
{
public Task<bool> CanOpenAsync(Uri uri)
{
// On Linux, we can generally open any URI using xdg-open
return Task.FromResult(true);
}
public Task<bool> CanOpenAsync(Uri uri)
{
return Task.FromResult(result: true);
}
public Task<bool> OpenAsync(Uri uri)
{
return Task.Run(() =>
{
try
{
var psi = new ProcessStartInfo
{
FileName = "xdg-open",
Arguments = uri.ToString(),
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true
};
public Task<bool> OpenAsync(Uri uri)
{
return Task.Run(delegate
{
try
{
using Process process = Process.Start(new ProcessStartInfo
{
FileName = "xdg-open",
Arguments = uri.ToString(),
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true
});
if (process == null)
{
return false;
}
return true;
}
catch
{
return false;
}
});
}
using var process = Process.Start(psi);
if (process == null)
return false;
public Task<bool> OpenAsync(OpenFileRequest request)
{
if (request.File == null)
{
return Task.FromResult(result: false);
}
return Task.Run(delegate
{
try
{
string fullPath = ((FileBase)request.File).FullPath;
using Process process = Process.Start(new ProcessStartInfo
{
FileName = "xdg-open",
Arguments = "\"" + fullPath + "\"",
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true
});
return process != null;
}
catch
{
return false;
}
});
}
// Don't wait for the process to exit - xdg-open may spawn another process
return true;
}
catch
{
return false;
}
});
}
public Task<bool> OpenAsync(OpenFileRequest request)
{
if (request.File == null)
return Task.FromResult(false);
return Task.Run(() =>
{
try
{
var filePath = request.File.FullPath;
var psi = new ProcessStartInfo
{
FileName = "xdg-open",
Arguments = $"\"{filePath}\"",
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true
};
using var process = Process.Start(psi);
return process != null;
}
catch
{
return false;
}
});
}
public Task<bool> TryOpenAsync(Uri uri)
{
return OpenAsync(uri);
}
public Task<bool> TryOpenAsync(Uri uri)
{
return OpenAsync(uri);
}
}

View File

@@ -0,0 +1,11 @@
using Microsoft.Maui.Storage;
namespace Microsoft.Maui.Platform.Linux.Services;
internal class LinuxFileResult : FileResult
{
public LinuxFileResult(string fullPath)
: base(fullPath)
{
}
}

View File

@@ -1,53 +1,109 @@
// 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.Controls;
using Microsoft.Maui.Controls.Internals;
[assembly: Dependency(typeof(Microsoft.Maui.Platform.Linux.Services.LinuxResourcesProvider))]
namespace Microsoft.Maui.Platform.Linux.Services;
/// <summary>
/// Provides system resources for the Linux platform.
/// </summary>
internal sealed class LinuxResourcesProvider : ISystemResourcesProvider
{
private ResourceDictionary? _dictionary;
private ResourceDictionary? _dictionary;
public IResourceDictionary GetSystemResources()
{
_dictionary ??= CreateResourceDictionary();
return _dictionary;
}
public IResourceDictionary GetSystemResources()
{
if (_dictionary == null)
{
_dictionary = CreateResourceDictionary();
}
return (IResourceDictionary)(object)_dictionary;
}
private ResourceDictionary CreateResourceDictionary()
{
var dictionary = new ResourceDictionary();
private ResourceDictionary CreateResourceDictionary()
{
//IL_0000: Unknown result type (might be due to invalid IL or missing references)
//IL_0005: Unknown result type (might be due to invalid IL or missing references)
//IL_0015: Unknown result type (might be due to invalid IL or missing references)
//IL_001f: Expected O, but got Unknown
//IL_001f: Unknown result type (might be due to invalid IL or missing references)
//IL_002f: Unknown result type (might be due to invalid IL or missing references)
//IL_003f: Unknown result type (might be due to invalid IL or missing references)
//IL_004f: Unknown result type (might be due to invalid IL or missing references)
//IL_005f: Unknown result type (might be due to invalid IL or missing references)
//IL_0069: Expected O, but got Unknown
//IL_0069: Unknown result type (might be due to invalid IL or missing references)
//IL_007a: Expected O, but got Unknown
return new ResourceDictionary
{
[Styles.BodyStyleKey] = (object)new Style(typeof(Label)),
[Styles.TitleStyleKey] = CreateTitleStyle(),
[Styles.SubtitleStyleKey] = CreateSubtitleStyle(),
[Styles.CaptionStyleKey] = CreateCaptionStyle(),
[Styles.ListItemTextStyleKey] = (object)new Style(typeof(Label)),
[Styles.ListItemDetailTextStyleKey] = CreateCaptionStyle()
};
}
// Add default styles
dictionary[Device.Styles.BodyStyleKey] = new Style(typeof(Label));
dictionary[Device.Styles.TitleStyleKey] = CreateTitleStyle();
dictionary[Device.Styles.SubtitleStyleKey] = CreateSubtitleStyle();
dictionary[Device.Styles.CaptionStyleKey] = CreateCaptionStyle();
dictionary[Device.Styles.ListItemTextStyleKey] = new Style(typeof(Label));
dictionary[Device.Styles.ListItemDetailTextStyleKey] = CreateCaptionStyle();
private static Style CreateTitleStyle()
{
//IL_000a: Unknown result type (might be due to invalid IL or missing references)
//IL_000f: Unknown result type (might be due to invalid IL or missing references)
//IL_0015: Unknown result type (might be due to invalid IL or missing references)
//IL_001a: Unknown result type (might be due to invalid IL or missing references)
//IL_0025: Unknown result type (might be due to invalid IL or missing references)
//IL_003e: Expected O, but got Unknown
//IL_003f: Expected O, but got Unknown
return new Style(typeof(Label))
{
Setters =
{
new Setter
{
Property = Label.FontSizeProperty,
Value = 24.0
}
}
};
}
return dictionary;
}
private static Style CreateSubtitleStyle()
{
//IL_000a: Unknown result type (might be due to invalid IL or missing references)
//IL_000f: Unknown result type (might be due to invalid IL or missing references)
//IL_0015: Unknown result type (might be due to invalid IL or missing references)
//IL_001a: Unknown result type (might be due to invalid IL or missing references)
//IL_0025: Unknown result type (might be due to invalid IL or missing references)
//IL_003e: Expected O, but got Unknown
//IL_003f: Expected O, but got Unknown
return new Style(typeof(Label))
{
Setters =
{
new Setter
{
Property = Label.FontSizeProperty,
Value = 18.0
}
}
};
}
private static Style CreateTitleStyle() => new(typeof(Label))
{
Setters = { new Setter { Property = Label.FontSizeProperty, Value = 24.0 } }
};
private static Style CreateSubtitleStyle() => new(typeof(Label))
{
Setters = { new Setter { Property = Label.FontSizeProperty, Value = 18.0 } }
};
private static Style CreateCaptionStyle() => new(typeof(Label))
{
Setters = { new Setter { Property = Label.FontSizeProperty, Value = 12.0 } }
};
private static Style CreateCaptionStyle()
{
//IL_000a: Unknown result type (might be due to invalid IL or missing references)
//IL_000f: Unknown result type (might be due to invalid IL or missing references)
//IL_0015: Unknown result type (might be due to invalid IL or missing references)
//IL_001a: Unknown result type (might be due to invalid IL or missing references)
//IL_0025: Unknown result type (might be due to invalid IL or missing references)
//IL_003e: Expected O, but got Unknown
//IL_003f: Expected O, but got Unknown
return new Style(typeof(Label))
{
Setters =
{
new Setter
{
Property = Label.FontSizeProperty,
Value = 12.0
}
}
};
}
}

View File

@@ -0,0 +1,184 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using SkiaSharp;
using Svg.Skia;
namespace Microsoft.Maui.Platform.Linux.Services;
public static class MauiIconGenerator
{
private const int DefaultIconSize = 256;
public static string? GenerateIcon(string metaFilePath)
{
//IL_0095: Unknown result type (might be due to invalid IL or missing references)
//IL_008c: Unknown result type (might be due to invalid IL or missing references)
//IL_009a: Unknown result type (might be due to invalid IL or missing references)
//IL_0123: Unknown result type (might be due to invalid IL or missing references)
//IL_0165: Unknown result type (might be due to invalid IL or missing references)
//IL_017c: Unknown result type (might be due to invalid IL or missing references)
//IL_018e: Unknown result type (might be due to invalid IL or missing references)
//IL_0195: Expected O, but got Unknown
//IL_01b5: Unknown result type (might be due to invalid IL or missing references)
//IL_01ba: Unknown result type (might be due to invalid IL or missing references)
if (!File.Exists(metaFilePath))
{
Console.WriteLine("[MauiIconGenerator] Metadata file not found: " + metaFilePath);
return null;
}
try
{
string? path = Path.GetDirectoryName(metaFilePath) ?? "";
Dictionary<string, string> dictionary = ParseMetadata(File.ReadAllText(metaFilePath));
Path.Combine(path, "appicon_bg.svg");
string text = Path.Combine(path, "appicon_fg.svg");
string text2 = Path.Combine(path, "appicon.png");
string value;
int result;
int num = ((dictionary.TryGetValue("Size", out value) && int.TryParse(value, out result)) ? result : 256);
string value2;
SKColor val = (dictionary.TryGetValue("Color", out value2) ? ParseColor(value2) : SKColors.Purple);
string value3;
float result2;
float num2 = ((dictionary.TryGetValue("Scale", out value3) && float.TryParse(value3, out result2)) ? result2 : 0.65f);
Console.WriteLine($"[MauiIconGenerator] Generating {num}x{num} icon");
Console.WriteLine($"[MauiIconGenerator] Color: {val}");
Console.WriteLine($"[MauiIconGenerator] Scale: {num2}");
SKSurface val2 = SKSurface.Create(new SKImageInfo(num, num, (SKColorType)4, (SKAlphaType)2));
try
{
SKCanvas canvas = val2.Canvas;
canvas.Clear(val);
if (File.Exists(text))
{
SKSvg val3 = new SKSvg();
try
{
if (val3.Load(text) != null && val3.Picture != null)
{
SKRect cullRect = val3.Picture.CullRect;
float num3 = (float)num * num2 / Math.Max(((SKRect)(ref cullRect)).Width, ((SKRect)(ref cullRect)).Height);
float num4 = ((float)num - ((SKRect)(ref cullRect)).Width * num3) / 2f;
float num5 = ((float)num - ((SKRect)(ref cullRect)).Height * num3) / 2f;
canvas.Save();
canvas.Translate(num4, num5);
canvas.Scale(num3);
canvas.DrawPicture(val3.Picture, (SKPaint)null);
canvas.Restore();
}
}
finally
{
((IDisposable)val3)?.Dispose();
}
}
SKImage val4 = val2.Snapshot();
try
{
SKData val5 = val4.Encode((SKEncodedImageFormat)4, 100);
try
{
using FileStream fileStream = File.OpenWrite(text2);
val5.SaveTo((Stream)fileStream);
Console.WriteLine("[MauiIconGenerator] Generated: " + text2);
return text2;
}
finally
{
((IDisposable)val5)?.Dispose();
}
}
finally
{
((IDisposable)val4)?.Dispose();
}
}
finally
{
((IDisposable)val2)?.Dispose();
}
}
catch (Exception ex)
{
Console.WriteLine("[MauiIconGenerator] Error: " + ex.Message);
return null;
}
}
private static Dictionary<string, string> ParseMetadata(string content)
{
Dictionary<string, string> dictionary = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
string[] array = content.Split(new char[2] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries);
for (int i = 0; i < array.Length; i++)
{
string[] array2 = array[i].Split('=', 2);
if (array2.Length == 2)
{
dictionary[array2[0].Trim()] = array2[1].Trim();
}
}
return dictionary;
}
private static SKColor ParseColor(string colorStr)
{
//IL_0008: Unknown result type (might be due to invalid IL or missing references)
//IL_0246: Unknown result type (might be due to invalid IL or missing references)
//IL_024b: Unknown result type (might be due to invalid IL or missing references)
//IL_024d: Unknown result type (might be due to invalid IL or missing references)
//IL_0207: Unknown result type (might be due to invalid IL or missing references)
//IL_020c: Unknown result type (might be due to invalid IL or missing references)
//IL_0219: Unknown result type (might be due to invalid IL or missing references)
//IL_021e: Unknown result type (might be due to invalid IL or missing references)
//IL_00d2: Unknown result type (might be due to invalid IL or missing references)
//IL_023d: Unknown result type (might be due to invalid IL or missing references)
//IL_0242: Unknown result type (might be due to invalid IL or missing references)
//IL_022b: Unknown result type (might be due to invalid IL or missing references)
//IL_0230: Unknown result type (might be due to invalid IL or missing references)
//IL_0119: Unknown result type (might be due to invalid IL or missing references)
//IL_0210: Unknown result type (might be due to invalid IL or missing references)
//IL_0215: Unknown result type (might be due to invalid IL or missing references)
//IL_0222: Unknown result type (might be due to invalid IL or missing references)
//IL_0227: Unknown result type (might be due to invalid IL or missing references)
//IL_0234: Unknown result type (might be due to invalid IL or missing references)
//IL_0239: Unknown result type (might be due to invalid IL or missing references)
if (string.IsNullOrEmpty(colorStr))
{
return SKColors.Purple;
}
colorStr = colorStr.Trim();
if (colorStr.StartsWith("#"))
{
string text = colorStr.Substring(1);
if (text.Length == 3)
{
text = $"{text[0]}{text[0]}{text[1]}{text[1]}{text[2]}{text[2]}";
}
uint result2;
if (text.Length == 6)
{
if (uint.TryParse(text, NumberStyles.HexNumber, null, out var result))
{
return new SKColor((byte)((result >> 16) & 0xFF), (byte)((result >> 8) & 0xFF), (byte)(result & 0xFF));
}
}
else if (text.Length == 8 && uint.TryParse(text, NumberStyles.HexNumber, null, out result2))
{
return new SKColor((byte)((result2 >> 16) & 0xFF), (byte)((result2 >> 8) & 0xFF), (byte)(result2 & 0xFF), (byte)((result2 >> 24) & 0xFF));
}
}
return (SKColor)(colorStr.ToLowerInvariant() switch
{
"red" => SKColors.Red,
"green" => SKColors.Green,
"blue" => SKColors.Blue,
"purple" => SKColors.Purple,
"orange" => SKColors.Orange,
"white" => SKColors.White,
"black" => SKColors.Black,
_ => SKColors.Purple,
});
}
}

View File

@@ -0,0 +1,23 @@
using System;
namespace Microsoft.Maui.Platform.Linux.Services;
public class NotificationAction
{
public string Key { get; set; } = "";
public string Label { get; set; } = "";
public Action? Callback { get; set; }
public NotificationAction()
{
}
public NotificationAction(string key, string label, Action? callback = null)
{
Key = key;
Label = label;
Callback = callback;
}
}

View File

@@ -0,0 +1,19 @@
using System;
namespace Microsoft.Maui.Platform.Linux.Services;
public class NotificationActionEventArgs : EventArgs
{
public uint NotificationId { get; }
public string ActionKey { get; }
public string? Tag { get; }
public NotificationActionEventArgs(uint notificationId, string actionKey, string? tag)
{
NotificationId = notificationId;
ActionKey = actionKey;
Tag = tag;
}
}

View File

@@ -0,0 +1,9 @@
namespace Microsoft.Maui.Platform.Linux.Services;
public enum NotificationCloseReason
{
Expired = 1,
Dismissed,
Closed,
Undefined
}

View File

@@ -0,0 +1,19 @@
using System;
namespace Microsoft.Maui.Platform.Linux.Services;
public class NotificationClosedEventArgs : EventArgs
{
public uint NotificationId { get; }
public NotificationCloseReason Reason { get; }
public string? Tag { get; }
public NotificationClosedEventArgs(uint notificationId, NotificationCloseReason reason, string? tag)
{
NotificationId = notificationId;
Reason = reason;
Tag = tag;
}
}

View File

@@ -0,0 +1,11 @@
using System;
using System.Collections.Generic;
namespace Microsoft.Maui.Platform.Linux.Services;
internal class NotificationContext
{
public string? Tag { get; set; }
public Dictionary<string, Action?>? ActionCallbacks { get; set; }
}

View File

@@ -0,0 +1,24 @@
using System.Collections.Generic;
namespace Microsoft.Maui.Platform.Linux.Services;
public class NotificationOptions
{
public string Title { get; set; } = "";
public string Message { get; set; } = "";
public string? IconPath { get; set; }
public string? IconName { get; set; }
public NotificationUrgency Urgency { get; set; } = NotificationUrgency.Normal;
public int ExpireTimeMs { get; set; } = 5000;
public string? Category { get; set; }
public bool IsTransient { get; set; }
public Dictionary<string, string>? Actions { get; set; }
}

View File

@@ -1,537 +1,370 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System.Diagnostics;
using System.Text;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
namespace Microsoft.Maui.Platform.Linux.Services;
/// <summary>
/// Linux notification service using notify-send (libnotify) or D-Bus directly.
/// Supports interactive notifications with action callbacks.
/// </summary>
public class NotificationService
{
private readonly string _appName;
private readonly string? _defaultIconPath;
private readonly ConcurrentDictionary<uint, NotificationContext> _activeNotifications = new();
private static uint _notificationIdCounter = 1;
private Process? _dBusMonitor;
private bool _monitoringActions;
private readonly string _appName;
/// <summary>
/// Event raised when a notification action is invoked.
/// </summary>
public event EventHandler<NotificationActionEventArgs>? ActionInvoked;
private readonly string? _defaultIconPath;
/// <summary>
/// Event raised when a notification is closed.
/// </summary>
public event EventHandler<NotificationClosedEventArgs>? NotificationClosed;
private readonly ConcurrentDictionary<uint, NotificationContext> _activeNotifications = new ConcurrentDictionary<uint, NotificationContext>();
public NotificationService(string appName = "MAUI Application", string? defaultIconPath = null)
{
_appName = appName;
_defaultIconPath = defaultIconPath;
}
private static uint _notificationIdCounter = 1u;
/// <summary>
/// Starts monitoring for notification action callbacks via D-Bus.
/// Call this once at application startup if you want to receive action callbacks.
/// </summary>
public void StartActionMonitoring()
{
if (_monitoringActions) return;
_monitoringActions = true;
private Process? _dBusMonitor;
// Start D-Bus monitor for notification signals
Task.Run(MonitorNotificationSignals);
}
private bool _monitoringActions;
/// <summary>
/// Stops monitoring for notification action callbacks.
/// </summary>
public void StopActionMonitoring()
{
_monitoringActions = false;
try
{
_dBusMonitor?.Kill();
_dBusMonitor?.Dispose();
_dBusMonitor = null;
}
catch { }
}
public event EventHandler<NotificationActionEventArgs>? ActionInvoked;
private async Task MonitorNotificationSignals()
{
try
{
var startInfo = new ProcessStartInfo
{
FileName = "dbus-monitor",
Arguments = "--session \"interface='org.freedesktop.Notifications'\"",
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true
};
public event EventHandler<NotificationClosedEventArgs>? NotificationClosed;
_dBusMonitor = Process.Start(startInfo);
if (_dBusMonitor == null) return;
public NotificationService(string appName = "MAUI Application", string? defaultIconPath = null)
{
_appName = appName;
_defaultIconPath = defaultIconPath;
}
var reader = _dBusMonitor.StandardOutput;
var buffer = new StringBuilder();
public void StartActionMonitoring()
{
if (!_monitoringActions)
{
_monitoringActions = true;
Task.Run((Func<Task?>)MonitorNotificationSignals);
}
}
while (_monitoringActions && !_dBusMonitor.HasExited)
{
var line = await reader.ReadLineAsync();
if (line == null) break;
public void StopActionMonitoring()
{
_monitoringActions = false;
try
{
_dBusMonitor?.Kill();
_dBusMonitor?.Dispose();
_dBusMonitor = null;
}
catch
{
}
}
buffer.AppendLine(line);
private async Task MonitorNotificationSignals()
{
_ = 2;
try
{
ProcessStartInfo startInfo = new ProcessStartInfo
{
FileName = "dbus-monitor",
Arguments = "--session \"interface='org.freedesktop.Notifications'\"",
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true
};
_dBusMonitor = Process.Start(startInfo);
if (_dBusMonitor == null)
{
return;
}
StreamReader reader = _dBusMonitor.StandardOutput;
StringBuilder buffer = new StringBuilder();
while (_monitoringActions && !_dBusMonitor.HasExited)
{
string text = await reader.ReadLineAsync();
if (text != null)
{
buffer.AppendLine(text);
if (text.Contains("ActionInvoked"))
{
await ProcessActionInvoked(reader);
}
else if (text.Contains("NotificationClosed"))
{
await ProcessNotificationClosed(reader);
}
continue;
}
break;
}
}
catch (Exception ex)
{
Console.WriteLine("[NotificationService] D-Bus monitor error: " + ex.Message);
}
}
// Look for ActionInvoked or NotificationClosed signals
if (line.Contains("ActionInvoked"))
{
await ProcessActionInvoked(reader);
}
else if (line.Contains("NotificationClosed"))
{
await ProcessNotificationClosed(reader);
}
}
}
catch (Exception ex)
{
Console.WriteLine($"[NotificationService] D-Bus monitor error: {ex.Message}");
}
}
private async Task ProcessActionInvoked(StreamReader reader)
{
try
{
uint notificationId = 0u;
string actionKey = null;
for (int i = 0; i < 10; i++)
{
string text = await reader.ReadLineAsync();
if (text == null)
{
break;
}
if (text.Contains("uint32"))
{
Match match = Regex.Match(text, "uint32\\s+(\\d+)");
if (match.Success)
{
notificationId = uint.Parse(match.Groups[1].Value);
}
}
else if (text.Contains("string"))
{
Match match2 = Regex.Match(text, "string\\s+\"([^\"]*)\"");
if (match2.Success && actionKey == null)
{
actionKey = match2.Groups[1].Value;
}
}
if (notificationId != 0 && actionKey != null)
{
break;
}
}
if (notificationId != 0 && actionKey != null && _activeNotifications.TryGetValue(notificationId, out NotificationContext value))
{
Action value2 = default(Action);
if (value.ActionCallbacks?.TryGetValue(actionKey, out value2) ?? false)
{
value2?.Invoke();
}
this.ActionInvoked?.Invoke(this, new NotificationActionEventArgs(notificationId, actionKey, value.Tag));
}
}
catch
{
}
}
private async Task ProcessActionInvoked(StreamReader reader)
{
try
{
// Read the signal data (notification id and action key)
uint notificationId = 0;
string? actionKey = null;
private async Task ProcessNotificationClosed(StreamReader reader)
{
try
{
uint notificationId = 0u;
uint reason = 0u;
for (int i = 0; i < 5; i++)
{
string text = await reader.ReadLineAsync();
if (text == null)
{
break;
}
if (!text.Contains("uint32"))
{
continue;
}
Match match = Regex.Match(text, "uint32\\s+(\\d+)");
if (match.Success)
{
if (notificationId == 0)
{
notificationId = uint.Parse(match.Groups[1].Value);
}
else
{
reason = uint.Parse(match.Groups[1].Value);
}
}
}
if (notificationId != 0)
{
_activeNotifications.TryRemove(notificationId, out NotificationContext value);
this.NotificationClosed?.Invoke(this, new NotificationClosedEventArgs(notificationId, (NotificationCloseReason)reason, value?.Tag));
}
}
catch
{
}
}
for (int i = 0; i < 10; i++) // Read a few lines to get the data
{
var line = await reader.ReadLineAsync();
if (line == null) break;
public async Task ShowAsync(string title, string message)
{
await ShowAsync(new NotificationOptions
{
Title = title,
Message = message
});
}
if (line.Contains("uint32"))
{
var idMatch = System.Text.RegularExpressions.Regex.Match(line, @"uint32\s+(\d+)");
if (idMatch.Success)
{
notificationId = uint.Parse(idMatch.Groups[1].Value);
}
}
else if (line.Contains("string"))
{
var strMatch = System.Text.RegularExpressions.Regex.Match(line, @"string\s+""([^""]*)""");
if (strMatch.Success && actionKey == null)
{
actionKey = strMatch.Groups[1].Value;
}
}
public async Task<uint> ShowWithActionsAsync(string title, string message, IEnumerable<NotificationAction> actions, string? tag = null)
{
uint notificationId = _notificationIdCounter++;
NotificationContext value = new NotificationContext
{
Tag = tag,
ActionCallbacks = actions.ToDictionary((NotificationAction a) => a.Key, (NotificationAction a) => a.Callback)
};
_activeNotifications[notificationId] = value;
Dictionary<string, string> actions2 = actions.ToDictionary((NotificationAction a) => a.Key, (NotificationAction a) => a.Label);
await ShowAsync(new NotificationOptions
{
Title = title,
Message = message,
Actions = actions2
});
return notificationId;
}
if (notificationId > 0 && actionKey != null) break;
}
public async Task CancelAsync(uint notificationId)
{
try
{
ProcessStartInfo startInfo = new ProcessStartInfo
{
FileName = "gdbus",
Arguments = $"call --session --dest org.freedesktop.Notifications --object-path /org/freedesktop/Notifications --method org.freedesktop.Notifications.CloseNotification {notificationId}",
UseShellExecute = false,
CreateNoWindow = true
};
using Process process = Process.Start(startInfo);
if (process != null)
{
await process.WaitForExitAsync();
}
_activeNotifications.TryRemove(notificationId, out NotificationContext _);
}
catch
{
}
}
if (notificationId > 0 && actionKey != null)
{
if (_activeNotifications.TryGetValue(notificationId, out var context))
{
// Invoke callback if registered
if (context.ActionCallbacks?.TryGetValue(actionKey, out var callback) == true)
{
callback?.Invoke();
}
public async Task ShowAsync(NotificationOptions options)
{
try
{
string arguments = BuildNotifyArgs(options);
ProcessStartInfo startInfo = new ProcessStartInfo
{
FileName = "notify-send",
Arguments = arguments,
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true
};
using Process process = Process.Start(startInfo);
if (process != null)
{
await process.WaitForExitAsync();
}
}
catch (Exception ex)
{
_ = ex;
await TryZenityNotification(options);
}
}
ActionInvoked?.Invoke(this, new NotificationActionEventArgs(notificationId, actionKey, context.Tag));
}
}
}
catch { }
}
private string BuildNotifyArgs(NotificationOptions options)
{
List<string> list = new List<string>();
list.Add("--app-name=\"" + EscapeArg(_appName) + "\"");
list.Add("--urgency=" + options.Urgency.ToString().ToLower());
if (options.ExpireTimeMs > 0)
{
list.Add($"--expire-time={options.ExpireTimeMs}");
}
string text = options.IconPath ?? _defaultIconPath;
if (!string.IsNullOrEmpty(text))
{
list.Add("--icon=\"" + EscapeArg(text) + "\"");
}
else if (!string.IsNullOrEmpty(options.IconName))
{
list.Add("--icon=" + options.IconName);
}
if (!string.IsNullOrEmpty(options.Category))
{
list.Add("--category=" + options.Category);
}
if (options.IsTransient)
{
list.Add("--hint=int:transient:1");
}
Dictionary<string, string>? actions = options.Actions;
if (actions != null && actions.Count > 0)
{
foreach (KeyValuePair<string, string> action in options.Actions)
{
list.Add($"--action=\"{action.Key}={EscapeArg(action.Value)}\"");
}
}
list.Add("\"" + EscapeArg(options.Title) + "\"");
list.Add("\"" + EscapeArg(options.Message) + "\"");
return string.Join(" ", list);
}
private async Task ProcessNotificationClosed(StreamReader reader)
{
try
{
uint notificationId = 0;
uint reason = 0;
private async Task TryZenityNotification(NotificationOptions options)
{
try
{
string value = "";
if (!string.IsNullOrEmpty(options.IconPath))
{
value = "--window-icon=\"" + options.IconPath + "\"";
}
string value2 = ((options.Urgency == NotificationUrgency.Critical) ? "--error" : "--info");
ProcessStartInfo startInfo = new ProcessStartInfo
{
FileName = "zenity",
Arguments = $"{value2} {value} --title=\"{EscapeArg(options.Title)}\" --text=\"{EscapeArg(options.Message)}\" --timeout=5",
UseShellExecute = false,
CreateNoWindow = true
};
using Process process = Process.Start(startInfo);
if (process != null)
{
await process.WaitForExitAsync();
}
}
catch
{
}
}
for (int i = 0; i < 5; i++)
{
var line = await reader.ReadLineAsync();
if (line == null) break;
public static bool IsAvailable()
{
try
{
using Process process = Process.Start(new ProcessStartInfo
{
FileName = "which",
Arguments = "notify-send",
UseShellExecute = false,
RedirectStandardOutput = true,
CreateNoWindow = true
});
if (process == null)
{
return false;
}
process.WaitForExit();
return process.ExitCode == 0;
}
catch
{
return false;
}
}
if (line.Contains("uint32"))
{
var match = System.Text.RegularExpressions.Regex.Match(line, @"uint32\s+(\d+)");
if (match.Success)
{
if (notificationId == 0)
notificationId = uint.Parse(match.Groups[1].Value);
else
reason = uint.Parse(match.Groups[1].Value);
}
}
}
if (notificationId > 0)
{
_activeNotifications.TryRemove(notificationId, out var context);
NotificationClosed?.Invoke(this, new NotificationClosedEventArgs(
notificationId,
(NotificationCloseReason)reason,
context?.Tag));
}
}
catch { }
}
/// <summary>
/// Shows a simple notification.
/// </summary>
public async Task ShowAsync(string title, string message)
{
await ShowAsync(new NotificationOptions
{
Title = title,
Message = message
});
}
/// <summary>
/// Shows a notification with action buttons and callbacks.
/// </summary>
/// <param name="title">Notification title.</param>
/// <param name="message">Notification message.</param>
/// <param name="actions">List of action buttons with callbacks.</param>
/// <param name="tag">Optional tag to identify the notification in events.</param>
/// <returns>The notification ID.</returns>
public async Task<uint> ShowWithActionsAsync(
string title,
string message,
IEnumerable<NotificationAction> actions,
string? tag = null)
{
var notificationId = _notificationIdCounter++;
// Store context for callbacks
var context = new NotificationContext
{
Tag = tag,
ActionCallbacks = actions.ToDictionary(a => a.Key, a => a.Callback)
};
_activeNotifications[notificationId] = context;
// Build actions dictionary for options
var actionDict = actions.ToDictionary(a => a.Key, a => a.Label);
await ShowAsync(new NotificationOptions
{
Title = title,
Message = message,
Actions = actionDict
});
return notificationId;
}
/// <summary>
/// Cancels/closes an active notification.
/// </summary>
public async Task CancelAsync(uint notificationId)
{
try
{
// Use gdbus to close the notification
var startInfo = new ProcessStartInfo
{
FileName = "gdbus",
Arguments = $"call --session --dest org.freedesktop.Notifications " +
$"--object-path /org/freedesktop/Notifications " +
$"--method org.freedesktop.Notifications.CloseNotification {notificationId}",
UseShellExecute = false,
CreateNoWindow = true
};
using var process = Process.Start(startInfo);
if (process != null)
{
await process.WaitForExitAsync();
}
_activeNotifications.TryRemove(notificationId, out _);
}
catch { }
}
/// <summary>
/// Shows a notification with options.
/// </summary>
public async Task ShowAsync(NotificationOptions options)
{
try
{
var args = BuildNotifyArgs(options);
var startInfo = new ProcessStartInfo
{
FileName = "notify-send",
Arguments = args,
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true
};
using var process = Process.Start(startInfo);
if (process != null)
{
await process.WaitForExitAsync();
}
}
catch (Exception ex)
{
// Fall back to zenity notification
await TryZenityNotification(options);
}
}
private string BuildNotifyArgs(NotificationOptions options)
{
var args = new List<string>();
// App name
args.Add($"--app-name=\"{EscapeArg(_appName)}\"");
// Urgency
args.Add($"--urgency={options.Urgency.ToString().ToLower()}");
// Expire time (milliseconds, 0 = never expire)
if (options.ExpireTimeMs > 0)
{
args.Add($"--expire-time={options.ExpireTimeMs}");
}
// Icon
var icon = options.IconPath ?? _defaultIconPath;
if (!string.IsNullOrEmpty(icon))
{
args.Add($"--icon=\"{EscapeArg(icon)}\"");
}
else if (!string.IsNullOrEmpty(options.IconName))
{
args.Add($"--icon={options.IconName}");
}
// Category
if (!string.IsNullOrEmpty(options.Category))
{
args.Add($"--category={options.Category}");
}
// Hint for transient notifications
if (options.IsTransient)
{
args.Add("--hint=int:transient:1");
}
// Actions (if supported)
if (options.Actions?.Count > 0)
{
foreach (var action in options.Actions)
{
args.Add($"--action=\"{action.Key}={EscapeArg(action.Value)}\"");
}
}
// Title and message
args.Add($"\"{EscapeArg(options.Title)}\"");
args.Add($"\"{EscapeArg(options.Message)}\"");
return string.Join(" ", args);
}
private async Task TryZenityNotification(NotificationOptions options)
{
try
{
var iconArg = "";
if (!string.IsNullOrEmpty(options.IconPath))
{
iconArg = $"--window-icon=\"{options.IconPath}\"";
}
var typeArg = options.Urgency == NotificationUrgency.Critical ? "--error" : "--info";
var startInfo = new ProcessStartInfo
{
FileName = "zenity",
Arguments = $"{typeArg} {iconArg} --title=\"{EscapeArg(options.Title)}\" --text=\"{EscapeArg(options.Message)}\" --timeout=5",
UseShellExecute = false,
CreateNoWindow = true
};
using var process = Process.Start(startInfo);
if (process != null)
{
await process.WaitForExitAsync();
}
}
catch
{
// Silently fail if no notification method available
}
}
/// <summary>
/// Checks if notifications are available on this system.
/// </summary>
public static bool IsAvailable()
{
try
{
var startInfo = new ProcessStartInfo
{
FileName = "which",
Arguments = "notify-send",
UseShellExecute = false,
RedirectStandardOutput = true,
CreateNoWindow = true
};
using var process = Process.Start(startInfo);
if (process == null) return false;
process.WaitForExit();
return process.ExitCode == 0;
}
catch
{
return false;
}
}
private static string EscapeArg(string arg)
{
return arg?.Replace("\\", "\\\\").Replace("\"", "\\\"") ?? "";
}
}
/// <summary>
/// Options for displaying a notification.
/// </summary>
public class NotificationOptions
{
public string Title { get; set; } = "";
public string Message { get; set; } = "";
public string? IconPath { get; set; }
public string? IconName { get; set; } // Standard icon name like "dialog-information"
public NotificationUrgency Urgency { get; set; } = NotificationUrgency.Normal;
public int ExpireTimeMs { get; set; } = 5000; // 5 seconds default
public string? Category { get; set; } // e.g., "email", "im", "transfer"
public bool IsTransient { get; set; }
public Dictionary<string, string>? Actions { get; set; }
}
/// <summary>
/// Notification urgency level.
/// </summary>
public enum NotificationUrgency
{
Low,
Normal,
Critical
}
/// <summary>
/// Reason a notification was closed.
/// </summary>
public enum NotificationCloseReason
{
Expired = 1,
Dismissed = 2,
Closed = 3,
Undefined = 4
}
/// <summary>
/// Internal context for tracking active notifications.
/// </summary>
internal class NotificationContext
{
public string? Tag { get; set; }
public Dictionary<string, Action?>? ActionCallbacks { get; set; }
}
/// <summary>
/// Event args for notification action events.
/// </summary>
public class NotificationActionEventArgs : EventArgs
{
public uint NotificationId { get; }
public string ActionKey { get; }
public string? Tag { get; }
public NotificationActionEventArgs(uint notificationId, string actionKey, string? tag)
{
NotificationId = notificationId;
ActionKey = actionKey;
Tag = tag;
}
}
/// <summary>
/// Event args for notification closed events.
/// </summary>
public class NotificationClosedEventArgs : EventArgs
{
public uint NotificationId { get; }
public NotificationCloseReason Reason { get; }
public string? Tag { get; }
public NotificationClosedEventArgs(uint notificationId, NotificationCloseReason reason, string? tag)
{
NotificationId = notificationId;
Reason = reason;
Tag = tag;
}
}
/// <summary>
/// Defines an action button for a notification.
/// </summary>
public class NotificationAction
{
/// <summary>
/// Internal action key (not displayed).
/// </summary>
public string Key { get; set; } = "";
/// <summary>
/// Display label for the action button.
/// </summary>
public string Label { get; set; } = "";
/// <summary>
/// Callback to invoke when the action is clicked.
/// </summary>
public Action? Callback { get; set; }
public NotificationAction() { }
public NotificationAction(string key, string label, Action? callback = null)
{
Key = key;
Label = label;
Callback = callback;
}
private static string EscapeArg(string arg)
{
return arg?.Replace("\\", "\\\\").Replace("\"", "\\\"") ?? "";
}
}

View File

@@ -0,0 +1,8 @@
namespace Microsoft.Maui.Platform.Linux.Services;
public enum NotificationUrgency
{
Low,
Normal,
Critical
}

View File

@@ -0,0 +1,38 @@
namespace Microsoft.Maui.Platform.Linux.Services;
public class NullAccessibilityService : IAccessibilityService
{
public bool IsEnabled => false;
public void Initialize()
{
}
public void Register(IAccessible accessible)
{
}
public void Unregister(IAccessible accessible)
{
}
public void NotifyFocusChanged(IAccessible? accessible)
{
}
public void NotifyPropertyChanged(IAccessible accessible, AccessibleProperty property)
{
}
public void NotifyStateChanged(IAccessible accessible, AccessibleState state, bool value)
{
}
public void Announce(string text, AnnouncementPriority priority = AnnouncementPriority.Polite)
{
}
public void Shutdown()
{
}
}

View File

@@ -0,0 +1,43 @@
using System;
namespace Microsoft.Maui.Platform.Linux.Services;
public class NullInputMethodService : IInputMethodService
{
public bool IsActive => false;
public string PreEditText => string.Empty;
public int PreEditCursorPosition => 0;
public event EventHandler<TextCommittedEventArgs>? TextCommitted;
public event EventHandler<PreEditChangedEventArgs>? PreEditChanged;
public event EventHandler? PreEditEnded;
public void Initialize(IntPtr windowHandle)
{
}
public void SetFocus(IInputContext? context)
{
}
public void SetCursorLocation(int x, int y, int width, int height)
{
}
public bool ProcessKeyEvent(uint keyCode, KeyModifiers modifiers, bool isKeyDown)
{
return false;
}
public void Reset()
{
}
public void Shutdown()
{
}
}

View File

@@ -1,479 +1,384 @@
// 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;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Microsoft.Maui.Storage;
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;
private bool _portalAvailable = true;
public PortalFilePickerService()
{
DetectAvailableTools();
}
private string? _fallbackTool;
private void DetectAvailableTools()
{
// Check if portal is available
_portalAvailable = CheckPortalAvailable();
public PortalFilePickerService()
{
DetectAvailableTools();
}
if (!_portalAvailable)
{
// Check for fallback tools
if (IsCommandAvailable("zenity"))
_fallbackTool = "zenity";
else if (IsCommandAvailable("kdialog"))
_fallbackTool = "kdialog";
else if (IsCommandAvailable("yad"))
_fallbackTool = "yad";
}
}
private void DetectAvailableTools()
{
_portalAvailable = CheckPortalAvailable();
if (!_portalAvailable)
{
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 CheckPortalAvailable()
{
try
{
return RunCommand("busctl", "--user list | grep -q org.freedesktop.portal.Desktop && echo yes").Trim() == "yes";
}
catch
{
return false;
}
}
private bool IsCommandAvailable(string command)
{
try
{
var output = RunCommand("which", command);
return !string.IsNullOrWhiteSpace(output);
}
catch
{
return false;
}
}
private bool IsCommandAvailable(string command)
{
try
{
return !string.IsNullOrWhiteSpace(RunCommand("which", command));
}
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<FileResult?> PickAsync(PickOptions? options = null)
{
if (options == null)
{
options = new PickOptions();
}
return (await PickFilesAsync(options, allowMultiple: false)).FirstOrDefault();
}
public async Task<IEnumerable<FileResult>> PickMultipleAsync(PickOptions? options = null)
{
options ??= new PickOptions();
return await PickFilesAsync(options, allowMultiple: true);
}
public async Task<IEnumerable<FileResult>> PickMultipleAsync(PickOptions? options = null)
{
if (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>> PickFilesAsync(PickOptions options, bool allowMultiple)
{
if (_portalAvailable)
{
return await PickWithPortalAsync(options, allowMultiple);
}
if (_fallbackTool != null)
{
return await PickWithFallbackAsync(options, allowMultiple);
}
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";
private async Task<IEnumerable<FileResult>> PickWithPortalAsync(PickOptions options, bool allowMultiple)
{
IEnumerable<FileResult> result = default(IEnumerable<FileResult>);
object obj;
int num;
try
{
string text = BuildPortalFilterArgs(options.FileTypes);
string value = (allowMultiple ? "true" : "false");
string input = options.PickerTitle ?? "Open File";
StringBuilder 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("\"\" ");
StringBuilder stringBuilder = args;
StringBuilder stringBuilder2 = stringBuilder;
StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(3, 1, stringBuilder);
handler.AppendLiteral("\"");
handler.AppendFormatted(EscapeForShell(input));
handler.AppendLiteral("\" ");
stringBuilder2.Append(ref handler);
args.Append("@a{sv} {");
stringBuilder = args;
StringBuilder stringBuilder3 = stringBuilder;
handler = new StringBuilder.AppendInterpolatedStringHandler(14, 1, stringBuilder);
handler.AppendLiteral("'multiple': <");
handler.AppendFormatted(value);
handler.AppendLiteral(">");
stringBuilder3.Append(ref handler);
if (text != null)
{
stringBuilder = args;
StringBuilder stringBuilder4 = stringBuilder;
handler = new StringBuilder.AppendInterpolatedStringHandler(15, 1, stringBuilder);
handler.AppendLiteral(", 'filters': <");
handler.AppendFormatted(text);
handler.AppendLiteral(">");
stringBuilder4.Append(ref handler);
}
args.Append("}");
if (string.IsNullOrEmpty(ParseRequestPath(await Task.Run(() => RunCommand("gdbus", args.ToString())))))
{
result = Enumerable.Empty<FileResult>();
return result;
}
await Task.Delay(100);
if (_fallbackTool != null)
{
result = await PickWithFallbackAsync(options, allowMultiple);
return result;
}
result = Enumerable.Empty<FileResult>();
return result;
}
catch (Exception ex)
{
obj = ex;
num = 1;
}
if (num != 1)
{
return result;
}
Exception ex2 = (Exception)obj;
Console.WriteLine("[FilePickerService] Portal error: " + ex2.Message);
if (_fallbackTool != null)
{
return await PickWithFallbackAsync(options, allowMultiple);
}
return Enumerable.Empty<FileResult>();
}
// 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
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>(),
};
}
// Options dictionary
args.Append("@a{sv} {");
args.Append($"'multiple': <{multipleArg}>");
if (filterArgs != null)
{
args.Append($", 'filters': <{filterArgs}>");
}
args.Append("}");
private async Task<IEnumerable<FileResult>> PickWithZenityAsync(PickOptions options, bool allowMultiple)
{
StringBuilder args = new StringBuilder();
args.Append("--file-selection ");
if (!string.IsNullOrEmpty(options.PickerTitle))
{
StringBuilder stringBuilder = args;
StringBuilder stringBuilder2 = stringBuilder;
StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(11, 1, stringBuilder);
handler.AppendLiteral("--title=\"");
handler.AppendFormatted(EscapeForShell(options.PickerTitle));
handler.AppendLiteral("\" ");
stringBuilder2.Append(ref handler);
}
if (allowMultiple)
{
args.Append("--multiple --separator=\"|\" ");
}
List<string> extensionsFromFileType = GetExtensionsFromFileType(options.FileTypes);
if (extensionsFromFileType.Count > 0)
{
string value = string.Join(" ", extensionsFromFileType.Select((string e) => "*" + e));
StringBuilder stringBuilder = args;
StringBuilder stringBuilder3 = stringBuilder;
StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(25, 1, stringBuilder);
handler.AppendLiteral("--file-filter=\"Files | ");
handler.AppendFormatted(value);
handler.AppendLiteral("\" ");
stringBuilder3.Append(ref handler);
}
string text = await Task.Run(() => RunCommand("zenity", args.ToString()));
if (string.IsNullOrWhiteSpace(text))
{
return Enumerable.Empty<FileResult>();
}
return ((IEnumerable<string>)text.Trim().Split('|', StringSplitOptions.RemoveEmptyEntries)).Select((Func<string, FileResult>)((string f) => new FileResult(f.Trim()))).ToList();
}
var output = await Task.Run(() => RunCommand("gdbus", args.ToString()));
private async Task<IEnumerable<FileResult>> PickWithKdialogAsync(PickOptions options, bool allowMultiple)
{
StringBuilder args = new StringBuilder();
args.Append("--getopenfilename ");
args.Append(". ");
List<string> extensionsFromFileType = GetExtensionsFromFileType(options.FileTypes);
if (extensionsFromFileType.Count > 0)
{
string value = string.Join(" ", extensionsFromFileType.Select((string e) => "*" + e));
StringBuilder stringBuilder = args;
StringBuilder stringBuilder2 = stringBuilder;
StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(11, 1, stringBuilder);
handler.AppendLiteral("\"Files (");
handler.AppendFormatted(value);
handler.AppendLiteral(")\" ");
stringBuilder2.Append(ref handler);
}
if (!string.IsNullOrEmpty(options.PickerTitle))
{
StringBuilder stringBuilder = args;
StringBuilder stringBuilder3 = stringBuilder;
StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(11, 1, stringBuilder);
handler.AppendLiteral("--title \"");
handler.AppendFormatted(EscapeForShell(options.PickerTitle));
handler.AppendLiteral("\" ");
stringBuilder3.Append(ref handler);
}
if (allowMultiple)
{
args.Append("--multiple --separate-output ");
}
string text = await Task.Run(() => RunCommand("kdialog", args.ToString()));
if (string.IsNullOrWhiteSpace(text))
{
return Enumerable.Empty<FileResult>();
}
return ((IEnumerable<string>)text.Trim().Split('\n', StringSplitOptions.RemoveEmptyEntries)).Select((Func<string, FileResult>)((string f) => new FileResult(f.Trim()))).ToList();
}
// 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>();
}
private async Task<IEnumerable<FileResult>> PickWithYadAsync(PickOptions options, bool allowMultiple)
{
StringBuilder args = new StringBuilder();
args.Append("--file ");
if (!string.IsNullOrEmpty(options.PickerTitle))
{
StringBuilder stringBuilder = args;
StringBuilder stringBuilder2 = stringBuilder;
StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(11, 1, stringBuilder);
handler.AppendLiteral("--title=\"");
handler.AppendFormatted(EscapeForShell(options.PickerTitle));
handler.AppendLiteral("\" ");
stringBuilder2.Append(ref handler);
}
if (allowMultiple)
{
args.Append("--multiple --separator=\"|\" ");
}
List<string> extensionsFromFileType = GetExtensionsFromFileType(options.FileTypes);
if (extensionsFromFileType.Count > 0)
{
string value = string.Join(" ", extensionsFromFileType.Select((string e) => "*" + e));
StringBuilder stringBuilder = args;
StringBuilder stringBuilder3 = stringBuilder;
StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(25, 1, stringBuilder);
handler.AppendLiteral("--file-filter=\"Files | ");
handler.AppendFormatted(value);
handler.AppendLiteral("\" ");
stringBuilder3.Append(ref handler);
}
string text = await Task.Run(() => RunCommand("yad", args.ToString()));
if (string.IsNullOrWhiteSpace(text))
{
return Enumerable.Empty<FileResult>();
}
return ((IEnumerable<string>)text.Trim().Split('|', StringSplitOptions.RemoveEmptyEntries)).Select((Func<string, FileResult>)((string f) => new FileResult(f.Trim()))).ToList();
}
// Wait for the response signal (simplified - in production use D-Bus signal subscription)
await Task.Delay(100);
private List<string> GetExtensionsFromFileType(FilePickerFileType? fileType)
{
List<string> list = new List<string>();
if (fileType == null)
{
return list;
}
try
{
IEnumerable<string> value = fileType.Value;
if (value == null)
{
return list;
}
foreach (string item2 in value)
{
if (item2.StartsWith(".") || (!item2.Contains('/') && !item2.Contains('*')))
{
string item = (item2.StartsWith(".") ? item2 : ("." + item2));
if (!list.Contains(item))
{
list.Add(item);
}
}
}
}
catch
{
}
return list;
}
// For now, fall back to synchronous zenity if portal response parsing is complex
if (_fallbackTool != null)
{
return await PickWithFallbackAsync(options, allowMultiple);
}
private string? BuildPortalFilterArgs(FilePickerFileType? fileType)
{
List<string> extensionsFromFileType = GetExtensionsFromFileType(fileType);
if (extensionsFromFileType.Count == 0)
{
return null;
}
string text = string.Join(", ", extensionsFromFileType.Select((string e) => "(uint32 0, '*" + e + "')"));
return "[('Files', [" + text + "])]";
}
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 string? ParseRequestPath(string output)
{
int num = output.IndexOf("'/");
int num2 = output.IndexOf("',", num);
if (num >= 0 && num2 > num)
{
return output.Substring(num + 1, num2 - num - 1);
}
return null;
}
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 string EscapeForShell(string input)
{
return input.Replace("\"", "\\\"").Replace("'", "\\'");
}
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; }
private string RunCommand(string command, string arguments)
{
try
{
using Process process = new Process
{
StartInfo = new ProcessStartInfo
{
FileName = command,
Arguments = arguments,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
}
};
process.Start();
string result = process.StandardOutput.ReadToEnd();
process.WaitForExit(30000);
return result;
}
catch (Exception ex)
{
Console.WriteLine("[FilePickerService] Command error: " + ex.Message);
return "";
}
}
}

View File

@@ -0,0 +1,76 @@
using System.Diagnostics;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
namespace Microsoft.Maui.Platform.Linux.Services;
public class PortalFolderPickerService
{
public async Task<FolderPickerResult> PickAsync(FolderPickerOptions? options = null, CancellationToken cancellationToken = default(CancellationToken))
{
if (options == null)
{
options = new FolderPickerOptions();
}
string text = null;
if (IsCommandAvailable("zenity"))
{
string args = "--file-selection --directory --title=\"" + (options.Title ?? "Select Folder") + "\"";
text = await Task.Run(() => RunCommand("zenity", args)?.Trim());
}
else if (IsCommandAvailable("kdialog"))
{
string args2 = "--getexistingdirectory . --title \"" + (options.Title ?? "Select Folder") + "\"";
text = await Task.Run(() => RunCommand("kdialog", args2)?.Trim());
}
if (!string.IsNullOrEmpty(text) && Directory.Exists(text))
{
return new FolderPickerResult(new FolderResult(text));
}
return new FolderPickerResult(null);
}
public async Task<FolderPickerResult> PickAsync(CancellationToken cancellationToken = default(CancellationToken))
{
return await PickAsync(null, cancellationToken);
}
private bool IsCommandAvailable(string command)
{
try
{
return !string.IsNullOrWhiteSpace(RunCommand("which", command));
}
catch
{
return false;
}
}
private string? RunCommand(string command, string arguments)
{
try
{
using Process process = new Process
{
StartInfo = new ProcessStartInfo
{
FileName = command,
Arguments = arguments,
RedirectStandardOutput = true,
UseShellExecute = false,
CreateNoWindow = true
}
};
process.Start();
string result = process.StandardOutput.ReadToEnd();
process.WaitForExit(30000);
return result;
}
catch
{
return null;
}
}
}

View File

@@ -0,0 +1,10 @@
namespace Microsoft.Maui.Platform.Linux.Services;
public class PreEditAttribute
{
public int Start { get; set; }
public int Length { get; set; }
public PreEditAttributeType Type { get; set; }
}

View File

@@ -0,0 +1,9 @@
namespace Microsoft.Maui.Platform.Linux.Services;
public enum PreEditAttributeType
{
None,
Underline,
Highlighted,
Reverse
}

View File

@@ -0,0 +1,20 @@
using System;
using System.Collections.Generic;
namespace Microsoft.Maui.Platform.Linux.Services;
public class PreEditChangedEventArgs : EventArgs
{
public string PreEditText { get; }
public int CursorPosition { get; }
public IReadOnlyList<PreEditAttribute> Attributes { get; }
public PreEditChangedEventArgs(string preEditText, int cursorPosition, IReadOnlyList<PreEditAttribute>? attributes = null)
{
PreEditText = preEditText;
CursorPosition = cursorPosition;
Attributes = attributes ?? Array.Empty<PreEditAttribute>();
}
}

View File

@@ -1,201 +1,195 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System;
using System.Collections.Generic;
using System.IO;
using System.Text.Json;
using Microsoft.Maui.ApplicationModel;
using Microsoft.Maui.Storage;
using MauiAppInfo = Microsoft.Maui.ApplicationModel.AppInfo;
namespace Microsoft.Maui.Platform.Linux.Services;
/// <summary>
/// Linux preferences implementation using JSON file storage.
/// Follows XDG Base Directory Specification.
/// </summary>
public class PreferencesService : IPreferences
{
private readonly string _preferencesPath;
private readonly object _lock = new();
private Dictionary<string, Dictionary<string, object?>> _preferences = new();
private bool _loaded;
private readonly string _preferencesPath;
public PreferencesService()
{
// Use XDG config directory
var configHome = Environment.GetEnvironmentVariable("XDG_CONFIG_HOME");
if (string.IsNullOrEmpty(configHome))
{
configHome = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".config");
}
private readonly object _lock = new object();
var appName = MauiAppInfo.Current?.Name ?? "MauiApp";
var appDir = Path.Combine(configHome, appName);
Directory.CreateDirectory(appDir);
private Dictionary<string, Dictionary<string, object?>> _preferences = new Dictionary<string, Dictionary<string, object>>();
_preferencesPath = Path.Combine(appDir, "preferences.json");
}
private bool _loaded;
private void EnsureLoaded()
{
if (_loaded) return;
public PreferencesService()
{
string text = Environment.GetEnvironmentVariable("XDG_CONFIG_HOME");
if (string.IsNullOrEmpty(text))
{
text = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".config");
}
IAppInfo current = AppInfo.Current;
string path = ((current != null) ? current.Name : null) ?? "MauiApp";
string text2 = Path.Combine(text, path);
Directory.CreateDirectory(text2);
_preferencesPath = Path.Combine(text2, "preferences.json");
}
lock (_lock)
{
if (_loaded) return;
private void EnsureLoaded()
{
if (_loaded)
{
return;
}
lock (_lock)
{
if (_loaded)
{
return;
}
try
{
if (File.Exists(_preferencesPath))
{
string json = File.ReadAllText(_preferencesPath);
_preferences = JsonSerializer.Deserialize<Dictionary<string, Dictionary<string, object>>>(json) ?? new Dictionary<string, Dictionary<string, object>>();
}
}
catch
{
_preferences = new Dictionary<string, Dictionary<string, object>>();
}
_loaded = true;
}
}
try
{
if (File.Exists(_preferencesPath))
{
var json = File.ReadAllText(_preferencesPath);
_preferences = JsonSerializer.Deserialize<Dictionary<string, Dictionary<string, object?>>>(json)
?? new();
}
}
catch
{
_preferences = new();
}
private void Save()
{
lock (_lock)
{
try
{
string contents = JsonSerializer.Serialize(_preferences, new JsonSerializerOptions
{
WriteIndented = true
});
File.WriteAllText(_preferencesPath, contents);
}
catch
{
}
}
}
_loaded = true;
}
}
private Dictionary<string, object?> GetContainer(string? sharedName)
{
string key = sharedName ?? "__default__";
EnsureLoaded();
if (!_preferences.TryGetValue(key, out Dictionary<string, object> value))
{
value = new Dictionary<string, object>();
_preferences[key] = value;
}
return value;
}
private void Save()
{
lock (_lock)
{
try
{
var json = JsonSerializer.Serialize(_preferences, new JsonSerializerOptions
{
WriteIndented = true
});
File.WriteAllText(_preferencesPath, json);
}
catch
{
// Silently fail save operations
}
}
}
public bool ContainsKey(string key, string? sharedName = null)
{
return GetContainer(sharedName).ContainsKey(key);
}
private Dictionary<string, object?> GetContainer(string? sharedName)
{
var key = sharedName ?? "__default__";
public void Remove(string key, string? sharedName = null)
{
lock (_lock)
{
if (GetContainer(sharedName).Remove(key))
{
Save();
}
}
}
EnsureLoaded();
public void Clear(string? sharedName = null)
{
lock (_lock)
{
GetContainer(sharedName).Clear();
Save();
}
}
if (!_preferences.TryGetValue(key, out var container))
{
container = new Dictionary<string, object?>();
_preferences[key] = container;
}
public void Set<T>(string key, T value, string? sharedName = null)
{
lock (_lock)
{
GetContainer(sharedName)[key] = value;
Save();
}
}
return container;
}
public T Get<T>(string key, T defaultValue, string? sharedName = null)
{
if (!GetContainer(sharedName).TryGetValue(key, out object value))
{
return defaultValue;
}
if (value == null)
{
return defaultValue;
}
try
{
if (value is JsonElement element)
{
return ConvertJsonElement(element, defaultValue);
}
if (value is T result)
{
return result;
}
return (T)Convert.ChangeType(value, typeof(T));
}
catch
{
return defaultValue;
}
}
public bool ContainsKey(string key, string? sharedName = null)
{
var container = GetContainer(sharedName);
return container.ContainsKey(key);
}
public void Remove(string key, string? sharedName = null)
{
lock (_lock)
{
var container = GetContainer(sharedName);
if (container.Remove(key))
{
Save();
}
}
}
public void Clear(string? sharedName = null)
{
lock (_lock)
{
var container = GetContainer(sharedName);
container.Clear();
Save();
}
}
public void Set<T>(string key, T value, string? sharedName = null)
{
lock (_lock)
{
var container = GetContainer(sharedName);
container[key] = value;
Save();
}
}
public T Get<T>(string key, T defaultValue, string? sharedName = null)
{
var container = GetContainer(sharedName);
if (!container.TryGetValue(key, out var value))
return defaultValue;
if (value == null)
return defaultValue;
try
{
// Handle JsonElement conversion (from deserialization)
if (value is JsonElement element)
{
return ConvertJsonElement<T>(element, defaultValue);
}
// Direct conversion
if (value is T typedValue)
return typedValue;
// Try Convert.ChangeType for primitive types
return (T)Convert.ChangeType(value, typeof(T));
}
catch
{
return defaultValue;
}
}
private T ConvertJsonElement<T>(JsonElement element, T defaultValue)
{
var targetType = typeof(T);
try
{
if (targetType == typeof(string))
return (T)(object)element.GetString()!;
if (targetType == typeof(int))
return (T)(object)element.GetInt32();
if (targetType == typeof(long))
return (T)(object)element.GetInt64();
if (targetType == typeof(float))
return (T)(object)element.GetSingle();
if (targetType == typeof(double))
return (T)(object)element.GetDouble();
if (targetType == typeof(bool))
return (T)(object)element.GetBoolean();
if (targetType == typeof(DateTime))
return (T)(object)element.GetDateTime();
// For complex types, deserialize
return element.Deserialize<T>() ?? defaultValue;
}
catch
{
return defaultValue;
}
}
private T ConvertJsonElement<T>(JsonElement element, T defaultValue)
{
Type typeFromHandle = typeof(T);
try
{
if (typeFromHandle == typeof(string))
{
return (T)(object)element.GetString();
}
if (typeFromHandle == typeof(int))
{
return (T)(object)element.GetInt32();
}
if (typeFromHandle == typeof(long))
{
return (T)(object)element.GetInt64();
}
if (typeFromHandle == typeof(float))
{
return (T)(object)element.GetSingle();
}
if (typeFromHandle == typeof(double))
{
return (T)(object)element.GetDouble();
}
if (typeFromHandle == typeof(bool))
{
return (T)(object)element.GetBoolean();
}
if (typeFromHandle == typeof(DateTime))
{
return (T)(object)element.GetDateTime();
}
T val = element.Deserialize<T>();
return (T)((val != null) ? ((object)val) : ((object)defaultValue));
}
catch
{
return defaultValue;
}
}
}

View File

@@ -0,0 +1,19 @@
using System;
namespace Microsoft.Maui.Platform.Linux.Services;
public class ScaleChangedEventArgs : EventArgs
{
public float OldScale { get; }
public float NewScale { get; }
public float NewDpi { get; }
public ScaleChangedEventArgs(float oldScale, float newScale, float newDpi)
{
OldScale = oldScale;
NewScale = newScale;
NewDpi = newDpi;
}
}

View File

@@ -1,359 +1,304 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System;
using System.Diagnostics;
using System.IO;
using System.Security.Cryptography;
using System.Text;
using System.Threading.Tasks;
using Microsoft.Maui.Storage;
namespace Microsoft.Maui.Platform.Linux.Services;
/// <summary>
/// Linux secure storage implementation using secret-tool (libsecret) or encrypted file fallback.
/// </summary>
public class SecureStorageService : ISecureStorage
{
private const string ServiceName = "maui-secure-storage";
private const string FallbackDirectory = ".maui-secure";
private readonly string _fallbackPath;
private readonly bool _useSecretService;
private const string ServiceName = "maui-secure-storage";
public SecureStorageService()
{
_fallbackPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
FallbackDirectory);
_useSecretService = CheckSecretServiceAvailable();
}
private const string FallbackDirectory = ".maui-secure";
private bool CheckSecretServiceAvailable()
{
try
{
var startInfo = new ProcessStartInfo
{
FileName = "which",
Arguments = "secret-tool",
UseShellExecute = false,
RedirectStandardOutput = true,
CreateNoWindow = true
};
private readonly string _fallbackPath;
using var process = Process.Start(startInfo);
if (process == null) return false;
private readonly bool _useSecretService;
process.WaitForExit();
return process.ExitCode == 0;
}
catch
{
return false;
}
}
public SecureStorageService()
{
_fallbackPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".maui-secure");
_useSecretService = CheckSecretServiceAvailable();
}
public Task<string?> GetAsync(string key)
{
if (string.IsNullOrEmpty(key))
throw new ArgumentNullException(nameof(key));
private bool CheckSecretServiceAvailable()
{
try
{
using Process process = Process.Start(new ProcessStartInfo
{
FileName = "which",
Arguments = "secret-tool",
UseShellExecute = false,
RedirectStandardOutput = true,
CreateNoWindow = true
});
if (process == null)
{
return false;
}
process.WaitForExit();
return process.ExitCode == 0;
}
catch
{
return false;
}
}
if (_useSecretService)
{
return GetFromSecretServiceAsync(key);
}
else
{
return GetFromFallbackAsync(key);
}
}
public Task<string?> GetAsync(string key)
{
if (string.IsNullOrEmpty(key))
{
throw new ArgumentNullException("key");
}
if (_useSecretService)
{
return GetFromSecretServiceAsync(key);
}
return GetFromFallbackAsync(key);
}
public Task SetAsync(string key, string value)
{
if (string.IsNullOrEmpty(key))
throw new ArgumentNullException(nameof(key));
public Task SetAsync(string key, string value)
{
if (string.IsNullOrEmpty(key))
{
throw new ArgumentNullException("key");
}
if (_useSecretService)
{
return SetInSecretServiceAsync(key, value);
}
return SetInFallbackAsync(key, value);
}
if (_useSecretService)
{
return SetInSecretServiceAsync(key, value);
}
else
{
return SetInFallbackAsync(key, value);
}
}
public bool Remove(string key)
{
if (string.IsNullOrEmpty(key))
{
throw new ArgumentNullException("key");
}
if (_useSecretService)
{
return RemoveFromSecretService(key);
}
return RemoveFromFallback(key);
}
public bool Remove(string key)
{
if (string.IsNullOrEmpty(key))
throw new ArgumentNullException(nameof(key));
public void RemoveAll()
{
if (!_useSecretService && Directory.Exists(_fallbackPath))
{
Directory.Delete(_fallbackPath, recursive: true);
}
}
if (_useSecretService)
{
return RemoveFromSecretService(key);
}
else
{
return RemoveFromFallback(key);
}
}
private async Task<string?> GetFromSecretServiceAsync(string key)
{
_ = 1;
try
{
ProcessStartInfo startInfo = new ProcessStartInfo
{
FileName = "secret-tool",
Arguments = "lookup service maui-secure-storage key " + EscapeArg(key),
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true
};
using Process process = Process.Start(startInfo);
if (process == null)
{
return null;
}
string output = await process.StandardOutput.ReadToEndAsync();
await process.WaitForExitAsync();
if (process.ExitCode == 0 && !string.IsNullOrEmpty(output))
{
return output.TrimEnd('\n');
}
return null;
}
catch
{
return null;
}
}
public void RemoveAll()
{
if (_useSecretService)
{
// Cannot easily remove all from secret service without knowing all keys
// This would require additional tracking
}
else
{
if (Directory.Exists(_fallbackPath))
{
Directory.Delete(_fallbackPath, true);
}
}
}
private async Task SetInSecretServiceAsync(string key, string value)
{
try
{
ProcessStartInfo startInfo = new ProcessStartInfo
{
FileName = "secret-tool",
Arguments = $"store --label=\"{EscapeArg(key)}\" service {"maui-secure-storage"} key {EscapeArg(key)}",
UseShellExecute = false,
RedirectStandardInput = true,
RedirectStandardError = true,
CreateNoWindow = true
};
using Process process = Process.Start(startInfo);
if (process == null)
{
throw new InvalidOperationException("Failed to start secret-tool");
}
await process.StandardInput.WriteAsync(value);
process.StandardInput.Close();
await process.WaitForExitAsync();
if (process.ExitCode != 0)
{
throw new InvalidOperationException("Failed to store secret: " + await process.StandardError.ReadToEndAsync());
}
}
catch (Exception ex) when (!(ex is InvalidOperationException))
{
await SetInFallbackAsync(key, value);
}
}
#region Secret Service (libsecret)
private bool RemoveFromSecretService(string key)
{
try
{
using Process process = Process.Start(new ProcessStartInfo
{
FileName = "secret-tool",
Arguments = "clear service maui-secure-storage key " + EscapeArg(key),
UseShellExecute = false,
CreateNoWindow = true
});
if (process == null)
{
return false;
}
process.WaitForExit();
return process.ExitCode == 0;
}
catch
{
return false;
}
}
private async Task<string?> GetFromSecretServiceAsync(string key)
{
try
{
var startInfo = new ProcessStartInfo
{
FileName = "secret-tool",
Arguments = $"lookup service {ServiceName} key {EscapeArg(key)}",
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true
};
private async Task<string?> GetFromFallbackAsync(string key)
{
string fallbackFilePath = GetFallbackFilePath(key);
if (!File.Exists(fallbackFilePath))
{
return null;
}
try
{
return DecryptData(await File.ReadAllBytesAsync(fallbackFilePath));
}
catch
{
return null;
}
}
using var process = Process.Start(startInfo);
if (process == null) return null;
private async Task SetInFallbackAsync(string key, string value)
{
EnsureFallbackDirectory();
string filePath = GetFallbackFilePath(key);
byte[] bytes = EncryptData(value);
await File.WriteAllBytesAsync(filePath, bytes);
File.SetUnixFileMode(filePath, UnixFileMode.UserWrite | UnixFileMode.UserRead);
}
var output = await process.StandardOutput.ReadToEndAsync();
await process.WaitForExitAsync();
private bool RemoveFromFallback(string key)
{
string fallbackFilePath = GetFallbackFilePath(key);
if (File.Exists(fallbackFilePath))
{
File.Delete(fallbackFilePath);
return true;
}
return false;
}
if (process.ExitCode == 0 && !string.IsNullOrEmpty(output))
{
return output.TrimEnd('\n');
}
private string GetFallbackFilePath(string key)
{
using SHA256 sHA = SHA256.Create();
string path = Convert.ToHexString(sHA.ComputeHash(Encoding.UTF8.GetBytes(key))).ToLowerInvariant();
return Path.Combine(_fallbackPath, path);
}
return null;
}
catch
{
return null;
}
}
private void EnsureFallbackDirectory()
{
if (!Directory.Exists(_fallbackPath))
{
Directory.CreateDirectory(_fallbackPath);
File.SetUnixFileMode(_fallbackPath, UnixFileMode.UserExecute | UnixFileMode.UserWrite | UnixFileMode.UserRead);
}
}
private async Task SetInSecretServiceAsync(string key, string value)
{
try
{
var startInfo = new ProcessStartInfo
{
FileName = "secret-tool",
Arguments = $"store --label=\"{EscapeArg(key)}\" service {ServiceName} key {EscapeArg(key)}",
UseShellExecute = false,
RedirectStandardInput = true,
RedirectStandardError = true,
CreateNoWindow = true
};
private byte[] EncryptData(string data)
{
byte[] machineKey = GetMachineKey();
using Aes aes = Aes.Create();
aes.Key = machineKey;
aes.GenerateIV();
using ICryptoTransform cryptoTransform = aes.CreateEncryptor();
byte[] bytes = Encoding.UTF8.GetBytes(data);
byte[] array = cryptoTransform.TransformFinalBlock(bytes, 0, bytes.Length);
byte[] array2 = new byte[aes.IV.Length + array.Length];
Buffer.BlockCopy(aes.IV, 0, array2, 0, aes.IV.Length);
Buffer.BlockCopy(array, 0, array2, aes.IV.Length, array.Length);
return array2;
}
using var process = Process.Start(startInfo);
if (process == null)
throw new InvalidOperationException("Failed to start secret-tool");
private string DecryptData(byte[] encryptedData)
{
byte[] machineKey = GetMachineKey();
using Aes aes = Aes.Create();
aes.Key = machineKey;
byte[] array = new byte[aes.BlockSize / 8];
Buffer.BlockCopy(encryptedData, 0, array, 0, array.Length);
aes.IV = array;
byte[] array2 = new byte[encryptedData.Length - array.Length];
Buffer.BlockCopy(encryptedData, array.Length, array2, 0, array2.Length);
using ICryptoTransform cryptoTransform = aes.CreateDecryptor();
byte[] bytes = cryptoTransform.TransformFinalBlock(array2, 0, array2.Length);
return Encoding.UTF8.GetString(bytes);
}
await process.StandardInput.WriteAsync(value);
process.StandardInput.Close();
private byte[] GetMachineKey()
{
string machineId = GetMachineId();
string userName = Environment.UserName;
string s = $"{machineId}:{userName}:{"maui-secure-storage"}";
using SHA256 sHA = SHA256.Create();
return sHA.ComputeHash(Encoding.UTF8.GetBytes(s));
}
await process.WaitForExitAsync();
private string GetMachineId()
{
try
{
if (File.Exists("/etc/machine-id"))
{
return File.ReadAllText("/etc/machine-id").Trim();
}
if (File.Exists("/var/lib/dbus/machine-id"))
{
return File.ReadAllText("/var/lib/dbus/machine-id").Trim();
}
return Environment.MachineName;
}
catch
{
return Environment.MachineName;
}
}
if (process.ExitCode != 0)
{
var error = await process.StandardError.ReadToEndAsync();
throw new InvalidOperationException($"Failed to store secret: {error}");
}
}
catch (Exception ex) when (ex is not InvalidOperationException)
{
// Fall back to file storage
await SetInFallbackAsync(key, value);
}
}
private bool RemoveFromSecretService(string key)
{
try
{
var startInfo = new ProcessStartInfo
{
FileName = "secret-tool",
Arguments = $"clear service {ServiceName} key {EscapeArg(key)}",
UseShellExecute = false,
CreateNoWindow = true
};
using var process = Process.Start(startInfo);
if (process == null) return false;
process.WaitForExit();
return process.ExitCode == 0;
}
catch
{
return false;
}
}
#endregion
#region Fallback Encrypted Storage
private async Task<string?> GetFromFallbackAsync(string key)
{
var filePath = GetFallbackFilePath(key);
if (!File.Exists(filePath))
return null;
try
{
var encryptedData = await File.ReadAllBytesAsync(filePath);
return DecryptData(encryptedData);
}
catch
{
return null;
}
}
private async Task SetInFallbackAsync(string key, string value)
{
EnsureFallbackDirectory();
var filePath = GetFallbackFilePath(key);
var encryptedData = EncryptData(value);
await File.WriteAllBytesAsync(filePath, encryptedData);
// Set restrictive permissions
File.SetUnixFileMode(filePath, UnixFileMode.UserRead | UnixFileMode.UserWrite);
}
private bool RemoveFromFallback(string key)
{
var filePath = GetFallbackFilePath(key);
if (File.Exists(filePath))
{
File.Delete(filePath);
return true;
}
return false;
}
private string GetFallbackFilePath(string key)
{
// Hash the key to create a safe filename
using var sha256 = SHA256.Create();
var hash = sha256.ComputeHash(Encoding.UTF8.GetBytes(key));
var fileName = Convert.ToHexString(hash).ToLowerInvariant();
return Path.Combine(_fallbackPath, fileName);
}
private void EnsureFallbackDirectory()
{
if (!Directory.Exists(_fallbackPath))
{
Directory.CreateDirectory(_fallbackPath);
// Set restrictive permissions on the directory
File.SetUnixFileMode(_fallbackPath,
UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
}
}
private byte[] EncryptData(string data)
{
// Use a machine-specific key derived from machine ID
var key = GetMachineKey();
using var aes = Aes.Create();
aes.Key = key;
aes.GenerateIV();
using var encryptor = aes.CreateEncryptor();
var plainBytes = Encoding.UTF8.GetBytes(data);
var encryptedBytes = encryptor.TransformFinalBlock(plainBytes, 0, plainBytes.Length);
// Prepend IV to encrypted data
var result = new byte[aes.IV.Length + encryptedBytes.Length];
Buffer.BlockCopy(aes.IV, 0, result, 0, aes.IV.Length);
Buffer.BlockCopy(encryptedBytes, 0, result, aes.IV.Length, encryptedBytes.Length);
return result;
}
private string DecryptData(byte[] encryptedData)
{
var key = GetMachineKey();
using var aes = Aes.Create();
aes.Key = key;
// Extract IV from beginning of data
var iv = new byte[aes.BlockSize / 8];
Buffer.BlockCopy(encryptedData, 0, iv, 0, iv.Length);
aes.IV = iv;
var cipherText = new byte[encryptedData.Length - iv.Length];
Buffer.BlockCopy(encryptedData, iv.Length, cipherText, 0, cipherText.Length);
using var decryptor = aes.CreateDecryptor();
var plainBytes = decryptor.TransformFinalBlock(cipherText, 0, cipherText.Length);
return Encoding.UTF8.GetString(plainBytes);
}
private byte[] GetMachineKey()
{
// Derive a key from machine-id and user
var machineId = GetMachineId();
var user = Environment.UserName;
var combined = $"{machineId}:{user}:{ServiceName}";
using var sha256 = SHA256.Create();
return sha256.ComputeHash(Encoding.UTF8.GetBytes(combined));
}
private string GetMachineId()
{
try
{
// Try /etc/machine-id first (systemd)
if (File.Exists("/etc/machine-id"))
{
return File.ReadAllText("/etc/machine-id").Trim();
}
// Try /var/lib/dbus/machine-id (older systems)
if (File.Exists("/var/lib/dbus/machine-id"))
{
return File.ReadAllText("/var/lib/dbus/machine-id").Trim();
}
// Fallback to hostname
return Environment.MachineName;
}
catch
{
return Environment.MachineName;
}
}
#endregion
private static string EscapeArg(string arg)
{
return arg.Replace("\"", "\\\"").Replace("'", "\\'");
}
private static string EscapeArg(string arg)
{
return arg.Replace("\"", "\\\"").Replace("'", "\\'");
}
}

View File

@@ -1,147 +1,139 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.Maui.ApplicationModel.DataTransfer;
using Microsoft.Maui.Storage;
namespace Microsoft.Maui.Platform.Linux.Services;
/// <summary>
/// Linux share implementation using xdg-open and portal APIs.
/// </summary>
public class ShareService : IShare
{
public async Task RequestAsync(ShareTextRequest request)
{
if (request == null)
throw new ArgumentNullException(nameof(request));
public async Task RequestAsync(ShareTextRequest request)
{
if (request == null)
{
throw new ArgumentNullException("request");
}
if (!string.IsNullOrEmpty(request.Uri))
{
await OpenUrlAsync(request.Uri);
}
else if (!string.IsNullOrEmpty(request.Text))
{
string text = Uri.EscapeDataString(request.Subject ?? "");
string text2 = Uri.EscapeDataString(request.Text ?? "");
string url = "mailto:?subject=" + text + "&body=" + text2;
await OpenUrlAsync(url);
}
}
// On Linux, we can use mailto: for text sharing or write to a temp file
if (!string.IsNullOrEmpty(request.Uri))
{
// Share as URL
await OpenUrlAsync(request.Uri);
}
else if (!string.IsNullOrEmpty(request.Text))
{
// Try to use email for text sharing
var subject = Uri.EscapeDataString(request.Subject ?? "");
var body = Uri.EscapeDataString(request.Text ?? "");
var mailto = $"mailto:?subject={subject}&body={body}";
await OpenUrlAsync(mailto);
}
}
public async Task RequestAsync(ShareFileRequest request)
{
if (request == null)
{
throw new ArgumentNullException("request");
}
if (request.File == null)
{
throw new ArgumentException("File is required", "request");
}
await ShareFileAsync(((FileBase)request.File).FullPath);
}
public async Task RequestAsync(ShareFileRequest request)
{
if (request == null)
throw new ArgumentNullException(nameof(request));
public async Task RequestAsync(ShareMultipleFilesRequest request)
{
if (request == null)
{
throw new ArgumentNullException("request");
}
if (request.Files == null || !request.Files.Any())
{
throw new ArgumentException("Files are required", "request");
}
foreach (ShareFile file in request.Files)
{
await ShareFileAsync(((FileBase)file).FullPath);
}
}
if (request.File == null)
throw new ArgumentException("File is required", nameof(request));
private async Task OpenUrlAsync(string url)
{
try
{
ProcessStartInfo startInfo = new ProcessStartInfo
{
FileName = "xdg-open",
Arguments = "\"" + url + "\"",
UseShellExecute = false,
CreateNoWindow = true
};
using Process process = Process.Start(startInfo);
if (process != null)
{
await process.WaitForExitAsync();
}
}
catch (Exception innerException)
{
throw new InvalidOperationException("Failed to open URL for sharing", innerException);
}
}
await ShareFileAsync(request.File.FullPath);
}
private async Task ShareFileAsync(string filePath)
{
if (!File.Exists(filePath))
{
throw new FileNotFoundException("File not found for sharing", filePath);
}
try
{
if (await TryPortalShareAsync(filePath))
{
return;
}
ProcessStartInfo startInfo = new ProcessStartInfo
{
FileName = "xdg-open",
Arguments = "\"" + Path.GetDirectoryName(filePath) + "\"",
UseShellExecute = false,
CreateNoWindow = true
};
using Process process = Process.Start(startInfo);
if (process != null)
{
await process.WaitForExitAsync();
}
}
catch (Exception innerException)
{
throw new InvalidOperationException("Failed to share file", innerException);
}
}
public async Task RequestAsync(ShareMultipleFilesRequest request)
{
if (request == null)
throw new ArgumentNullException(nameof(request));
if (request.Files == null || !request.Files.Any())
throw new ArgumentException("Files are required", nameof(request));
// Share files one by one or use file manager
foreach (var file in request.Files)
{
await ShareFileAsync(file.FullPath);
}
}
private async Task OpenUrlAsync(string url)
{
try
{
var startInfo = new ProcessStartInfo
{
FileName = "xdg-open",
Arguments = $"\"{url}\"",
UseShellExecute = false,
CreateNoWindow = true
};
using var process = Process.Start(startInfo);
if (process != null)
{
await process.WaitForExitAsync();
}
}
catch (Exception ex)
{
throw new InvalidOperationException("Failed to open URL for sharing", ex);
}
}
private async Task ShareFileAsync(string filePath)
{
if (!File.Exists(filePath))
throw new FileNotFoundException("File not found for sharing", filePath);
try
{
// Try to use the portal API via gdbus for proper share dialog
var portalResult = await TryPortalShareAsync(filePath);
if (portalResult)
return;
// Fall back to opening with default file manager
var startInfo = new ProcessStartInfo
{
FileName = "xdg-open",
Arguments = $"\"{Path.GetDirectoryName(filePath)}\"",
UseShellExecute = false,
CreateNoWindow = true
};
using var process = Process.Start(startInfo);
if (process != null)
{
await process.WaitForExitAsync();
}
}
catch (Exception ex)
{
throw new InvalidOperationException("Failed to share file", ex);
}
}
private async Task<bool> TryPortalShareAsync(string filePath)
{
try
{
// Try freedesktop portal for proper share dialog
// This would use org.freedesktop.portal.FileChooser or similar
// For now, we'll use zenity --info as a fallback notification
var startInfo = new ProcessStartInfo
{
FileName = "zenity",
Arguments = $"--info --text=\"File ready to share:\\n{Path.GetFileName(filePath)}\\n\\nPath: {filePath}\" --title=\"Share File\"",
UseShellExecute = false,
CreateNoWindow = true
};
using var process = Process.Start(startInfo);
if (process != null)
{
await process.WaitForExitAsync();
return true;
}
return false;
}
catch
{
return false;
}
}
private async Task<bool> TryPortalShareAsync(string filePath)
{
try
{
ProcessStartInfo startInfo = new ProcessStartInfo
{
FileName = "zenity",
Arguments = $"--info --text=\"File ready to share:\\n{Path.GetFileName(filePath)}\\n\\nPath: {filePath}\" --title=\"Share File\"",
UseShellExecute = false,
CreateNoWindow = true
};
using Process process = Process.Start(startInfo);
if (process != null)
{
await process.WaitForExitAsync();
return true;
}
return false;
}
catch
{
return false;
}
}
}

26
Services/SystemColors.cs Normal file
View File

@@ -0,0 +1,26 @@
using SkiaSharp;
namespace Microsoft.Maui.Platform.Linux.Services;
public class SystemColors
{
public SKColor Background { get; init; }
public SKColor Surface { get; init; }
public SKColor Primary { get; init; }
public SKColor OnPrimary { get; init; }
public SKColor Text { get; init; }
public SKColor TextSecondary { get; init; }
public SKColor Border { get; init; }
public SKColor Divider { get; init; }
public SKColor Error { get; init; }
public SKColor Success { get; init; }
}

7
Services/SystemTheme.cs Normal file
View File

@@ -0,0 +1,7 @@
namespace Microsoft.Maui.Platform.Linux.Services;
public enum SystemTheme
{
Light,
Dark
}

View File

@@ -1,481 +1,448 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using SkiaSharp;
using System;
using System.Diagnostics;
using System.IO;
using System.Threading.Tasks;
using SkiaSharp;
namespace Microsoft.Maui.Platform.Linux.Services;
/// <summary>
/// Detects and monitors system theme settings (dark/light mode, accent colors).
/// Supports GNOME, KDE, and GTK-based environments.
/// </summary>
public class SystemThemeService
{
private static SystemThemeService? _instance;
private static readonly object _lock = new();
private static SystemThemeService? _instance;
/// <summary>
/// Gets the singleton instance of the system theme service.
/// </summary>
public static SystemThemeService Instance
{
get
{
if (_instance == null)
{
lock (_lock)
{
_instance ??= new SystemThemeService();
}
}
return _instance;
}
}
private static readonly object _lock = new object();
/// <summary>
/// The current system theme.
/// </summary>
public SystemTheme CurrentTheme { get; private set; } = SystemTheme.Light;
private FileSystemWatcher? _settingsWatcher;
/// <summary>
/// The system accent color (if available).
/// </summary>
public SKColor AccentColor { get; private set; } = new SKColor(0x21, 0x96, 0xF3); // Default blue
public static SystemThemeService Instance
{
get
{
if (_instance == null)
{
lock (_lock)
{
if (_instance == null)
{
_instance = new SystemThemeService();
}
}
}
return _instance;
}
}
/// <summary>
/// The detected desktop environment.
/// </summary>
public DesktopEnvironment Desktop { get; private set; } = DesktopEnvironment.Unknown;
public SystemTheme CurrentTheme { get; private set; }
/// <summary>
/// Event raised when the theme changes.
/// </summary>
public event EventHandler<ThemeChangedEventArgs>? ThemeChanged;
public SKColor AccentColor { get; private set; } = new SKColor((byte)33, (byte)150, (byte)243);
/// <summary>
/// System colors based on the current theme.
/// </summary>
public SystemColors Colors { get; private set; }
public DesktopEnvironment Desktop { get; private set; }
private FileSystemWatcher? _settingsWatcher;
public SystemColors Colors { get; private set; }
private SystemThemeService()
{
DetectDesktopEnvironment();
DetectTheme();
UpdateColors();
SetupWatcher();
}
public event EventHandler<ThemeChangedEventArgs>? ThemeChanged;
private void DetectDesktopEnvironment()
{
var xdgDesktop = Environment.GetEnvironmentVariable("XDG_CURRENT_DESKTOP")?.ToLowerInvariant() ?? "";
var desktopSession = Environment.GetEnvironmentVariable("DESKTOP_SESSION")?.ToLowerInvariant() ?? "";
private SystemThemeService()
{
//IL_000d: Unknown result type (might be due to invalid IL or missing references)
//IL_0012: Unknown result type (might be due to invalid IL or missing references)
DetectDesktopEnvironment();
DetectTheme();
UpdateColors();
SetupWatcher();
}
if (xdgDesktop.Contains("gnome") || desktopSession.Contains("gnome"))
{
Desktop = DesktopEnvironment.GNOME;
}
else if (xdgDesktop.Contains("kde") || xdgDesktop.Contains("plasma") || desktopSession.Contains("plasma"))
{
Desktop = DesktopEnvironment.KDE;
}
else if (xdgDesktop.Contains("xfce") || desktopSession.Contains("xfce"))
{
Desktop = DesktopEnvironment.XFCE;
}
else if (xdgDesktop.Contains("mate") || desktopSession.Contains("mate"))
{
Desktop = DesktopEnvironment.MATE;
}
else if (xdgDesktop.Contains("cinnamon") || desktopSession.Contains("cinnamon"))
{
Desktop = DesktopEnvironment.Cinnamon;
}
else if (xdgDesktop.Contains("lxqt"))
{
Desktop = DesktopEnvironment.LXQt;
}
else if (xdgDesktop.Contains("lxde"))
{
Desktop = DesktopEnvironment.LXDE;
}
else
{
Desktop = DesktopEnvironment.Unknown;
}
}
private void DetectDesktopEnvironment()
{
string text = Environment.GetEnvironmentVariable("XDG_CURRENT_DESKTOP")?.ToLowerInvariant() ?? "";
string text2 = Environment.GetEnvironmentVariable("DESKTOP_SESSION")?.ToLowerInvariant() ?? "";
if (text.Contains("gnome") || text2.Contains("gnome"))
{
Desktop = DesktopEnvironment.GNOME;
}
else if (text.Contains("kde") || text.Contains("plasma") || text2.Contains("plasma"))
{
Desktop = DesktopEnvironment.KDE;
}
else if (text.Contains("xfce") || text2.Contains("xfce"))
{
Desktop = DesktopEnvironment.XFCE;
}
else if (text.Contains("mate") || text2.Contains("mate"))
{
Desktop = DesktopEnvironment.MATE;
}
else if (text.Contains("cinnamon") || text2.Contains("cinnamon"))
{
Desktop = DesktopEnvironment.Cinnamon;
}
else if (text.Contains("lxqt"))
{
Desktop = DesktopEnvironment.LXQt;
}
else if (text.Contains("lxde"))
{
Desktop = DesktopEnvironment.LXDE;
}
else
{
Desktop = DesktopEnvironment.Unknown;
}
}
private void DetectTheme()
{
var theme = Desktop switch
{
DesktopEnvironment.GNOME => DetectGnomeTheme(),
DesktopEnvironment.KDE => DetectKdeTheme(),
DesktopEnvironment.XFCE => DetectXfceTheme(),
DesktopEnvironment.Cinnamon => DetectCinnamonTheme(),
_ => DetectGtkTheme()
};
private void DetectTheme()
{
//IL_0071: Unknown result type (might be due to invalid IL or missing references)
//IL_0076: Unknown result type (might be due to invalid IL or missing references)
//IL_0095: Unknown result type (might be due to invalid IL or missing references)
//IL_007a: Unknown result type (might be due to invalid IL or missing references)
//IL_007f: Unknown result type (might be due to invalid IL or missing references)
//IL_008e: Unknown result type (might be due to invalid IL or missing references)
//IL_0093: Unknown result type (might be due to invalid IL or missing references)
CurrentTheme = (Desktop switch
{
DesktopEnvironment.GNOME => DetectGnomeTheme(),
DesktopEnvironment.KDE => DetectKdeTheme(),
DesktopEnvironment.XFCE => DetectXfceTheme(),
DesktopEnvironment.Cinnamon => DetectCinnamonTheme(),
_ => DetectGtkTheme(),
}).GetValueOrDefault();
AccentColor = (SKColor)(Desktop switch
{
DesktopEnvironment.GNOME => GetGnomeAccentColor(),
DesktopEnvironment.KDE => GetKdeAccentColor(),
_ => new SKColor((byte)33, (byte)150, (byte)243),
});
}
CurrentTheme = theme ?? SystemTheme.Light;
private SystemTheme? DetectGnomeTheme()
{
try
{
string text = RunCommand("gsettings", "get org.gnome.desktop.interface color-scheme");
if (text.Contains("prefer-dark"))
{
return SystemTheme.Dark;
}
if (text.Contains("prefer-light") || text.Contains("default"))
{
return SystemTheme.Light;
}
text = RunCommand("gsettings", "get org.gnome.desktop.interface gtk-theme");
if (text.ToLowerInvariant().Contains("dark"))
{
return SystemTheme.Dark;
}
}
catch
{
}
return null;
}
// Try to get accent color
AccentColor = Desktop switch
{
DesktopEnvironment.GNOME => GetGnomeAccentColor(),
DesktopEnvironment.KDE => GetKdeAccentColor(),
_ => new SKColor(0x21, 0x96, 0xF3)
};
}
private SystemTheme? DetectKdeTheme()
{
try
{
string path = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".config", "kdeglobals");
if (File.Exists(path))
{
string text = File.ReadAllText(path);
if (text.Contains("BreezeDark", StringComparison.OrdinalIgnoreCase) || text.Contains("Dark", StringComparison.OrdinalIgnoreCase))
{
return SystemTheme.Dark;
}
}
}
catch
{
}
return null;
}
private SystemTheme? DetectGnomeTheme()
{
try
{
// gsettings get org.gnome.desktop.interface color-scheme
var output = RunCommand("gsettings", "get org.gnome.desktop.interface color-scheme");
if (output.Contains("prefer-dark"))
return SystemTheme.Dark;
if (output.Contains("prefer-light") || output.Contains("default"))
return SystemTheme.Light;
private SystemTheme? DetectXfceTheme()
{
try
{
if (RunCommand("xfconf-query", "-c xsettings -p /Net/ThemeName").ToLowerInvariant().Contains("dark"))
{
return SystemTheme.Dark;
}
}
catch
{
}
return DetectGtkTheme();
}
// Fallback: check GTK theme name
output = RunCommand("gsettings", "get org.gnome.desktop.interface gtk-theme");
if (output.ToLowerInvariant().Contains("dark"))
return SystemTheme.Dark;
}
catch { }
private SystemTheme? DetectCinnamonTheme()
{
try
{
if (RunCommand("gsettings", "get org.cinnamon.desktop.interface gtk-theme").ToLowerInvariant().Contains("dark"))
{
return SystemTheme.Dark;
}
}
catch
{
}
return null;
}
return null;
}
private SystemTheme? DetectGtkTheme()
{
try
{
string path = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".config", "gtk-3.0", "settings.ini");
if (File.Exists(path))
{
string[] array = File.ReadAllText(path).Split('\n');
foreach (string text in array)
{
if (text.StartsWith("gtk-theme-name=", StringComparison.OrdinalIgnoreCase) && text.Substring("gtk-theme-name=".Length).Trim().Contains("dark", StringComparison.OrdinalIgnoreCase))
{
return SystemTheme.Dark;
}
if (text.StartsWith("gtk-application-prefer-dark-theme=", StringComparison.OrdinalIgnoreCase))
{
string text2 = text.Substring("gtk-application-prefer-dark-theme=".Length).Trim();
if (text2 == "1" || text2.Equals("true", StringComparison.OrdinalIgnoreCase))
{
return SystemTheme.Dark;
}
}
}
}
}
catch
{
}
return null;
}
private SystemTheme? DetectKdeTheme()
{
try
{
// Read ~/.config/kdeglobals
var configPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
".config", "kdeglobals");
private SKColor GetGnomeAccentColor()
{
//IL_021e: Unknown result type (might be due to invalid IL or missing references)
//IL_0223: Unknown result type (might be due to invalid IL or missing references)
//IL_0206: Unknown result type (might be due to invalid IL or missing references)
//IL_020b: Unknown result type (might be due to invalid IL or missing references)
//IL_0227: Unknown result type (might be due to invalid IL or missing references)
//IL_020c: Unknown result type (might be due to invalid IL or missing references)
//IL_020d: Unknown result type (might be due to invalid IL or missing references)
//IL_01bc: Unknown result type (might be due to invalid IL or missing references)
//IL_01c1: Unknown result type (might be due to invalid IL or missing references)
//IL_015c: Unknown result type (might be due to invalid IL or missing references)
//IL_0161: Unknown result type (might be due to invalid IL or missing references)
//IL_0187: Unknown result type (might be due to invalid IL or missing references)
//IL_018c: Unknown result type (might be due to invalid IL or missing references)
//IL_01ab: Unknown result type (might be due to invalid IL or missing references)
//IL_01b0: Unknown result type (might be due to invalid IL or missing references)
//IL_01d0: Unknown result type (might be due to invalid IL or missing references)
//IL_01d5: Unknown result type (might be due to invalid IL or missing references)
//IL_01f2: Unknown result type (might be due to invalid IL or missing references)
//IL_01f7: Unknown result type (might be due to invalid IL or missing references)
//IL_01e4: Unknown result type (might be due to invalid IL or missing references)
//IL_01e9: Unknown result type (might be due to invalid IL or missing references)
//IL_0173: Unknown result type (might be due to invalid IL or missing references)
//IL_0178: Unknown result type (might be due to invalid IL or missing references)
//IL_019b: Unknown result type (might be due to invalid IL or missing references)
//IL_01a0: Unknown result type (might be due to invalid IL or missing references)
try
{
return (SKColor)(RunCommand("gsettings", "get org.gnome.desktop.interface accent-color").Trim().Trim('\'') switch
{
"blue" => new SKColor((byte)53, (byte)132, (byte)228),
"teal" => new SKColor((byte)42, (byte)195, (byte)222),
"green" => new SKColor((byte)58, (byte)148, (byte)74),
"yellow" => new SKColor((byte)246, (byte)211, (byte)45),
"orange" => new SKColor(byte.MaxValue, (byte)120, (byte)0),
"red" => new SKColor((byte)224, (byte)27, (byte)36),
"pink" => new SKColor((byte)214, (byte)86, (byte)140),
"purple" => new SKColor((byte)145, (byte)65, (byte)172),
"slate" => new SKColor((byte)94, (byte)92, (byte)100),
_ => new SKColor((byte)33, (byte)150, (byte)243),
});
}
catch
{
return new SKColor((byte)33, (byte)150, (byte)243);
}
}
if (File.Exists(configPath))
{
var content = File.ReadAllText(configPath);
private SKColor GetKdeAccentColor()
{
//IL_00e8: Unknown result type (might be due to invalid IL or missing references)
//IL_00c1: Unknown result type (might be due to invalid IL or missing references)
//IL_00c6: Unknown result type (might be due to invalid IL or missing references)
//IL_00ee: Unknown result type (might be due to invalid IL or missing references)
try
{
string path = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".config", "kdeglobals");
if (File.Exists(path))
{
string[] array = File.ReadAllText(path).Split('\n');
bool flag = false;
string[] array2 = array;
foreach (string text in array2)
{
if (text.StartsWith("[Colors:Header]"))
{
flag = true;
continue;
}
if (text.StartsWith("[") && flag)
{
break;
}
if (flag && text.StartsWith("BackgroundNormal="))
{
string[] array3 = text.Substring("BackgroundNormal=".Length).Split(',');
if (array3.Length >= 3 && byte.TryParse(array3[0], out var result) && byte.TryParse(array3[1], out var result2) && byte.TryParse(array3[2], out var result3))
{
return new SKColor(result, result2, result3);
}
}
}
}
}
catch
{
}
return new SKColor((byte)33, (byte)150, (byte)243);
}
// Look for ColorScheme or LookAndFeelPackage
if (content.Contains("BreezeDark", StringComparison.OrdinalIgnoreCase) ||
content.Contains("Dark", StringComparison.OrdinalIgnoreCase))
{
return SystemTheme.Dark;
}
}
}
catch { }
private void UpdateColors()
{
//IL_00d9: Unknown result type (might be due to invalid IL or missing references)
//IL_00ea: Unknown result type (might be due to invalid IL or missing references)
//IL_00f6: Unknown result type (might be due to invalid IL or missing references)
//IL_0101: Unknown result type (might be due to invalid IL or missing references)
//IL_011b: Unknown result type (might be due to invalid IL or missing references)
//IL_0135: Unknown result type (might be due to invalid IL or missing references)
//IL_0146: Unknown result type (might be due to invalid IL or missing references)
//IL_0157: Unknown result type (might be due to invalid IL or missing references)
//IL_016b: Unknown result type (might be due to invalid IL or missing references)
//IL_0185: Unknown result type (might be due to invalid IL or missing references)
//IL_0022: Unknown result type (might be due to invalid IL or missing references)
//IL_002d: Unknown result type (might be due to invalid IL or missing references)
//IL_0039: Unknown result type (might be due to invalid IL or missing references)
//IL_0044: Unknown result type (might be due to invalid IL or missing references)
//IL_0055: Unknown result type (might be due to invalid IL or missing references)
//IL_0066: Unknown result type (might be due to invalid IL or missing references)
//IL_0080: Unknown result type (might be due to invalid IL or missing references)
//IL_009a: Unknown result type (might be due to invalid IL or missing references)
//IL_00ad: Unknown result type (might be due to invalid IL or missing references)
//IL_00be: Unknown result type (might be due to invalid IL or missing references)
Colors = ((CurrentTheme == SystemTheme.Dark) ? new SystemColors
{
Background = new SKColor((byte)30, (byte)30, (byte)30),
Surface = new SKColor((byte)45, (byte)45, (byte)45),
Primary = AccentColor,
OnPrimary = SKColors.White,
Text = new SKColor((byte)240, (byte)240, (byte)240),
TextSecondary = new SKColor((byte)160, (byte)160, (byte)160),
Border = new SKColor((byte)64, (byte)64, (byte)64),
Divider = new SKColor((byte)58, (byte)58, (byte)58),
Error = new SKColor((byte)207, (byte)102, (byte)121),
Success = new SKColor((byte)129, (byte)201, (byte)149)
} : new SystemColors
{
Background = new SKColor((byte)250, (byte)250, (byte)250),
Surface = SKColors.White,
Primary = AccentColor,
OnPrimary = SKColors.White,
Text = new SKColor((byte)33, (byte)33, (byte)33),
TextSecondary = new SKColor((byte)117, (byte)117, (byte)117),
Border = new SKColor((byte)224, (byte)224, (byte)224),
Divider = new SKColor((byte)238, (byte)238, (byte)238),
Error = new SKColor((byte)176, (byte)0, (byte)32),
Success = new SKColor((byte)46, (byte)125, (byte)50)
});
}
return null;
}
private void SetupWatcher()
{
try
{
string path = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".config");
if (Directory.Exists(path))
{
_settingsWatcher = new FileSystemWatcher(path)
{
NotifyFilter = NotifyFilters.LastWrite,
IncludeSubdirectories = true,
EnableRaisingEvents = true
};
_settingsWatcher.Changed += OnSettingsChanged;
}
}
catch
{
}
}
private SystemTheme? DetectXfceTheme()
{
try
{
var output = RunCommand("xfconf-query", "-c xsettings -p /Net/ThemeName");
if (output.ToLowerInvariant().Contains("dark"))
return SystemTheme.Dark;
}
catch { }
private void OnSettingsChanged(object sender, FileSystemEventArgs e)
{
string? name = e.Name;
if (name == null || !name.Contains("kdeglobals"))
{
string? name2 = e.Name;
if (name2 == null || !name2.Contains("gtk"))
{
string? name3 = e.Name;
if (name3 == null || !name3.Contains("settings"))
{
return;
}
}
}
Task.Delay(500).ContinueWith(delegate
{
SystemTheme currentTheme = CurrentTheme;
DetectTheme();
UpdateColors();
if (currentTheme != CurrentTheme)
{
this.ThemeChanged?.Invoke(this, new ThemeChangedEventArgs(CurrentTheme));
}
});
}
return DetectGtkTheme();
}
private string RunCommand(string command, string arguments)
{
try
{
using Process process = new Process
{
StartInfo = new ProcessStartInfo
{
FileName = command,
Arguments = arguments,
RedirectStandardOutput = true,
UseShellExecute = false,
CreateNoWindow = true
}
};
process.Start();
string result = process.StandardOutput.ReadToEnd();
process.WaitForExit(1000);
return result;
}
catch
{
return "";
}
}
private SystemTheme? DetectCinnamonTheme()
{
try
{
var output = RunCommand("gsettings", "get org.cinnamon.desktop.interface gtk-theme");
if (output.ToLowerInvariant().Contains("dark"))
return SystemTheme.Dark;
}
catch { }
return null;
}
private SystemTheme? DetectGtkTheme()
{
try
{
// Try GTK3 settings
var configPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
".config", "gtk-3.0", "settings.ini");
if (File.Exists(configPath))
{
var content = File.ReadAllText(configPath);
var lines = content.Split('\n');
foreach (var line in lines)
{
if (line.StartsWith("gtk-theme-name=", StringComparison.OrdinalIgnoreCase))
{
var themeName = line.Substring("gtk-theme-name=".Length).Trim();
if (themeName.Contains("dark", StringComparison.OrdinalIgnoreCase))
return SystemTheme.Dark;
}
if (line.StartsWith("gtk-application-prefer-dark-theme=", StringComparison.OrdinalIgnoreCase))
{
var value = line.Substring("gtk-application-prefer-dark-theme=".Length).Trim();
if (value == "1" || value.Equals("true", StringComparison.OrdinalIgnoreCase))
return SystemTheme.Dark;
}
}
}
}
catch { }
return null;
}
private SKColor GetGnomeAccentColor()
{
try
{
var output = RunCommand("gsettings", "get org.gnome.desktop.interface accent-color");
// Returns something like 'blue', 'teal', 'green', etc.
return output.Trim().Trim('\'') switch
{
"blue" => new SKColor(0x35, 0x84, 0xe4),
"teal" => new SKColor(0x2a, 0xc3, 0xde),
"green" => new SKColor(0x3a, 0x94, 0x4a),
"yellow" => new SKColor(0xf6, 0xd3, 0x2d),
"orange" => new SKColor(0xff, 0x78, 0x00),
"red" => new SKColor(0xe0, 0x1b, 0x24),
"pink" => new SKColor(0xd6, 0x56, 0x8c),
"purple" => new SKColor(0x91, 0x41, 0xac),
"slate" => new SKColor(0x5e, 0x5c, 0x64),
_ => new SKColor(0x21, 0x96, 0xF3)
};
}
catch
{
return new SKColor(0x21, 0x96, 0xF3);
}
}
private SKColor GetKdeAccentColor()
{
try
{
var configPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
".config", "kdeglobals");
if (File.Exists(configPath))
{
var content = File.ReadAllText(configPath);
var lines = content.Split('\n');
bool inColorsHeader = false;
foreach (var line in lines)
{
if (line.StartsWith("[Colors:Header]"))
{
inColorsHeader = true;
continue;
}
if (line.StartsWith("[") && inColorsHeader)
{
break;
}
if (inColorsHeader && line.StartsWith("BackgroundNormal="))
{
var rgb = line.Substring("BackgroundNormal=".Length).Split(',');
if (rgb.Length >= 3 &&
byte.TryParse(rgb[0], out var r) &&
byte.TryParse(rgb[1], out var g) &&
byte.TryParse(rgb[2], out var b))
{
return new SKColor(r, g, b);
}
}
}
}
}
catch { }
return new SKColor(0x21, 0x96, 0xF3);
}
private void UpdateColors()
{
Colors = CurrentTheme == SystemTheme.Dark
? new SystemColors
{
Background = new SKColor(0x1e, 0x1e, 0x1e),
Surface = new SKColor(0x2d, 0x2d, 0x2d),
Primary = AccentColor,
OnPrimary = SKColors.White,
Text = new SKColor(0xf0, 0xf0, 0xf0),
TextSecondary = new SKColor(0xa0, 0xa0, 0xa0),
Border = new SKColor(0x40, 0x40, 0x40),
Divider = new SKColor(0x3a, 0x3a, 0x3a),
Error = new SKColor(0xcf, 0x66, 0x79),
Success = new SKColor(0x81, 0xc9, 0x95)
}
: new SystemColors
{
Background = new SKColor(0xfa, 0xfa, 0xfa),
Surface = SKColors.White,
Primary = AccentColor,
OnPrimary = SKColors.White,
Text = new SKColor(0x21, 0x21, 0x21),
TextSecondary = new SKColor(0x75, 0x75, 0x75),
Border = new SKColor(0xe0, 0xe0, 0xe0),
Divider = new SKColor(0xee, 0xee, 0xee),
Error = new SKColor(0xb0, 0x00, 0x20),
Success = new SKColor(0x2e, 0x7d, 0x32)
};
}
private void SetupWatcher()
{
try
{
var configDir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
".config");
if (Directory.Exists(configDir))
{
_settingsWatcher = new FileSystemWatcher(configDir)
{
NotifyFilter = NotifyFilters.LastWrite,
IncludeSubdirectories = true,
EnableRaisingEvents = true
};
_settingsWatcher.Changed += OnSettingsChanged;
}
}
catch { }
}
private void OnSettingsChanged(object sender, FileSystemEventArgs e)
{
// Debounce and check relevant files
if (e.Name?.Contains("kdeglobals") == true ||
e.Name?.Contains("gtk") == true ||
e.Name?.Contains("settings") == true)
{
// Re-detect theme after a short delay
Task.Delay(500).ContinueWith(_ =>
{
var oldTheme = CurrentTheme;
DetectTheme();
UpdateColors();
if (oldTheme != CurrentTheme)
{
ThemeChanged?.Invoke(this, new ThemeChangedEventArgs(CurrentTheme));
}
});
}
}
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(1000);
return output;
}
catch
{
return "";
}
}
/// <summary>
/// Forces a theme refresh.
/// </summary>
public void RefreshTheme()
{
var oldTheme = CurrentTheme;
DetectTheme();
UpdateColors();
if (oldTheme != CurrentTheme)
{
ThemeChanged?.Invoke(this, new ThemeChangedEventArgs(CurrentTheme));
}
}
}
/// <summary>
/// System theme (light or dark mode).
/// </summary>
public enum SystemTheme
{
Light,
Dark
}
/// <summary>
/// Detected desktop environment.
/// </summary>
public enum DesktopEnvironment
{
Unknown,
GNOME,
KDE,
XFCE,
MATE,
Cinnamon,
LXQt,
LXDE
}
/// <summary>
/// Event args for theme changes.
/// </summary>
public class ThemeChangedEventArgs : EventArgs
{
public SystemTheme NewTheme { get; }
public ThemeChangedEventArgs(SystemTheme newTheme)
{
NewTheme = newTheme;
}
}
/// <summary>
/// System colors based on the current theme.
/// </summary>
public class SystemColors
{
public SKColor Background { get; init; }
public SKColor Surface { get; init; }
public SKColor Primary { get; init; }
public SKColor OnPrimary { get; init; }
public SKColor Text { get; init; }
public SKColor TextSecondary { get; init; }
public SKColor Border { get; init; }
public SKColor Divider { get; init; }
public SKColor Error { get; init; }
public SKColor Success { get; init; }
public void RefreshTheme()
{
SystemTheme currentTheme = CurrentTheme;
DetectTheme();
UpdateColors();
if (currentTheme != CurrentTheme)
{
this.ThemeChanged?.Invoke(this, new ThemeChangedEventArgs(CurrentTheme));
}
}
}

View File

@@ -1,282 +1,263 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
namespace Microsoft.Maui.Platform.Linux.Services;
/// <summary>
/// Linux system tray service using various backends.
/// Supports yad, zenity, or native D-Bus StatusNotifierItem.
/// </summary>
public class SystemTrayService : IDisposable
{
private Process? _trayProcess;
private readonly string _appName;
private string? _iconPath;
private string? _tooltip;
private readonly List<TrayMenuItem> _menuItems = new();
private bool _isVisible;
private bool _disposed;
private Process? _trayProcess;
public event EventHandler? Clicked;
public event EventHandler<string>? MenuItemClicked;
private readonly string _appName;
public SystemTrayService(string appName)
{
_appName = appName;
}
private string? _iconPath;
/// <summary>
/// Gets or sets the tray icon path.
/// </summary>
public string? IconPath
{
get => _iconPath;
set
{
_iconPath = value;
if (_isVisible) UpdateTray();
}
}
private string? _tooltip;
/// <summary>
/// Gets or sets the tooltip text.
/// </summary>
public string? Tooltip
{
get => _tooltip;
set
{
_tooltip = value;
if (_isVisible) UpdateTray();
}
}
private readonly List<TrayMenuItem> _menuItems = new List<TrayMenuItem>();
/// <summary>
/// Gets the menu items.
/// </summary>
public IList<TrayMenuItem> MenuItems => _menuItems;
private bool _isVisible;
/// <summary>
/// Shows the system tray icon.
/// </summary>
public async Task ShowAsync()
{
if (_isVisible) return;
private bool _disposed;
// Try yad first (most feature-complete)
if (await TryYadTray())
{
_isVisible = true;
return;
}
public string? IconPath
{
get
{
return _iconPath;
}
set
{
_iconPath = value;
if (_isVisible)
{
UpdateTray();
}
}
}
// Fall back to a simple approach
_isVisible = true;
}
public string? Tooltip
{
get
{
return _tooltip;
}
set
{
_tooltip = value;
if (_isVisible)
{
UpdateTray();
}
}
}
/// <summary>
/// Hides the system tray icon.
/// </summary>
public void Hide()
{
if (!_isVisible) return;
public IList<TrayMenuItem> MenuItems => _menuItems;
_trayProcess?.Kill();
_trayProcess?.Dispose();
_trayProcess = null;
_isVisible = false;
}
public event EventHandler? Clicked;
/// <summary>
/// Updates the tray icon and menu.
/// </summary>
public void UpdateTray()
{
if (!_isVisible) return;
public event EventHandler<string>? MenuItemClicked;
// Restart tray with new settings
Hide();
_ = ShowAsync();
}
public SystemTrayService(string appName)
{
_appName = appName;
}
private async Task<bool> TryYadTray()
{
try
{
var args = BuildYadArgs();
public async Task ShowAsync()
{
if (!_isVisible)
{
await TryYadTray();
_isVisible = true;
}
}
var startInfo = new ProcessStartInfo
{
FileName = "yad",
Arguments = args,
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true
};
public void Hide()
{
if (_isVisible)
{
_trayProcess?.Kill();
_trayProcess?.Dispose();
_trayProcess = null;
_isVisible = false;
}
}
_trayProcess = Process.Start(startInfo);
if (_trayProcess == null) return false;
public void UpdateTray()
{
if (_isVisible)
{
Hide();
ShowAsync();
}
}
// Start reading output for menu clicks
_ = Task.Run(async () =>
{
try
{
while (!_trayProcess.HasExited)
{
var line = await _trayProcess.StandardOutput.ReadLineAsync();
if (!string.IsNullOrEmpty(line))
{
HandleTrayOutput(line);
}
}
}
catch { }
});
private async Task<bool> TryYadTray()
{
try
{
string arguments = BuildYadArgs();
ProcessStartInfo startInfo = new ProcessStartInfo
{
FileName = "yad",
Arguments = arguments,
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true
};
_trayProcess = Process.Start(startInfo);
if (_trayProcess == null)
{
return false;
}
Task.Run(async delegate
{
try
{
while (!_trayProcess.HasExited)
{
string text = await _trayProcess.StandardOutput.ReadLineAsync();
if (!string.IsNullOrEmpty(text))
{
HandleTrayOutput(text);
}
}
}
catch
{
}
});
return true;
}
catch
{
return false;
}
}
return true;
}
catch
{
return false;
}
}
private string BuildYadArgs()
{
List<string> list = new List<string> { "--notification", "--listen" };
if (!string.IsNullOrEmpty(_iconPath) && File.Exists(_iconPath))
{
list.Add("--image=\"" + _iconPath + "\"");
}
else
{
list.Add("--image=application-x-executable");
}
if (!string.IsNullOrEmpty(_tooltip))
{
list.Add("--text=\"" + EscapeArg(_tooltip) + "\"");
}
if (_menuItems.Count > 0)
{
string text = string.Join("!", _menuItems.Select(delegate(TrayMenuItem m)
{
object obj;
if (!m.IsSeparator)
{
obj = EscapeArg(m.Text);
if (obj == null)
{
return "";
}
}
else
{
obj = "---";
}
return (string)obj;
}));
list.Add("--menu=\"" + text + "\"");
}
list.Add("--command=\"echo clicked\"");
return string.Join(" ", list);
}
private string BuildYadArgs()
{
var args = new List<string>
{
"--notification",
"--listen"
};
private void HandleTrayOutput(string output)
{
if (output == "clicked")
{
this.Clicked?.Invoke(this, EventArgs.Empty);
return;
}
TrayMenuItem trayMenuItem = _menuItems.FirstOrDefault((TrayMenuItem m) => m.Text == output);
if (trayMenuItem != null)
{
trayMenuItem.Action?.Invoke();
this.MenuItemClicked?.Invoke(this, output);
}
}
if (!string.IsNullOrEmpty(_iconPath) && File.Exists(_iconPath))
{
args.Add($"--image=\"{_iconPath}\"");
}
else
{
args.Add("--image=application-x-executable");
}
public void AddMenuItem(string text, Action? action = null)
{
_menuItems.Add(new TrayMenuItem
{
Text = text,
Action = action
});
}
if (!string.IsNullOrEmpty(_tooltip))
{
args.Add($"--text=\"{EscapeArg(_tooltip)}\"");
}
public void AddSeparator()
{
_menuItems.Add(new TrayMenuItem
{
IsSeparator = true
});
}
// Build menu
if (_menuItems.Count > 0)
{
var menuStr = string.Join("!", _menuItems.Select(m =>
m.IsSeparator ? "---" : $"{EscapeArg(m.Text)}"));
args.Add($"--menu=\"{menuStr}\"");
}
public void ClearMenuItems()
{
_menuItems.Clear();
}
args.Add("--command=\"echo clicked\"");
public static bool IsAvailable()
{
try
{
using Process process = Process.Start(new ProcessStartInfo
{
FileName = "which",
Arguments = "yad",
UseShellExecute = false,
RedirectStandardOutput = true,
CreateNoWindow = true
});
if (process == null)
{
return false;
}
process.WaitForExit();
return process.ExitCode == 0;
}
catch
{
return false;
}
}
return string.Join(" ", args);
}
private static string EscapeArg(string arg)
{
return arg?.Replace("\\", "\\\\").Replace("\"", "\\\"").Replace("!", "\\!") ?? "";
}
private void HandleTrayOutput(string output)
{
if (output == "clicked")
{
Clicked?.Invoke(this, EventArgs.Empty);
}
else
{
// Menu item clicked
var menuItem = _menuItems.FirstOrDefault(m => m.Text == output);
if (menuItem != null)
{
menuItem.Action?.Invoke();
MenuItemClicked?.Invoke(this, output);
}
}
}
public void Dispose()
{
if (!_disposed)
{
_disposed = true;
Hide();
GC.SuppressFinalize(this);
}
}
/// <summary>
/// Adds a menu item to the tray context menu.
/// </summary>
public void AddMenuItem(string text, Action? action = null)
{
_menuItems.Add(new TrayMenuItem { Text = text, Action = action });
}
/// <summary>
/// Adds a separator to the tray context menu.
/// </summary>
public void AddSeparator()
{
_menuItems.Add(new TrayMenuItem { IsSeparator = true });
}
/// <summary>
/// Clears all menu items.
/// </summary>
public void ClearMenuItems()
{
_menuItems.Clear();
}
/// <summary>
/// Checks if system tray is available on this system.
/// </summary>
public static bool IsAvailable()
{
try
{
var startInfo = new ProcessStartInfo
{
FileName = "which",
Arguments = "yad",
UseShellExecute = false,
RedirectStandardOutput = true,
CreateNoWindow = true
};
using var process = Process.Start(startInfo);
if (process == null) return false;
process.WaitForExit();
return process.ExitCode == 0;
}
catch
{
return false;
}
}
private static string EscapeArg(string arg)
{
return arg?.Replace("\\", "\\\\").Replace("\"", "\\\"").Replace("!", "\\!") ?? "";
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
Hide();
GC.SuppressFinalize(this);
}
~SystemTrayService()
{
Dispose();
}
}
/// <summary>
/// Represents a tray menu item.
/// </summary>
public class TrayMenuItem
{
public string Text { get; set; } = "";
public Action? Action { get; set; }
public bool IsSeparator { get; set; }
public bool IsEnabled { get; set; } = true;
public string? IconPath { get; set; }
~SystemTrayService()
{
Dispose();
}
}

View File

@@ -0,0 +1,13 @@
using System;
namespace Microsoft.Maui.Platform.Linux.Services;
public class TextCommittedEventArgs : EventArgs
{
public string Text { get; }
public TextCommittedEventArgs(string text)
{
Text = text;
}
}

19
Services/TextRun.cs Normal file
View File

@@ -0,0 +1,19 @@
using SkiaSharp;
namespace Microsoft.Maui.Platform.Linux.Services;
public class TextRun
{
public string Text { get; }
public SKTypeface Typeface { get; }
public int StartIndex { get; }
public TextRun(string text, SKTypeface typeface, int startIndex)
{
Text = text;
Typeface = typeface;
StartIndex = startIndex;
}
}

View File

@@ -0,0 +1,13 @@
using System;
namespace Microsoft.Maui.Platform.Linux.Services;
public class ThemeChangedEventArgs : EventArgs
{
public SystemTheme NewTheme { get; }
public ThemeChangedEventArgs(SystemTheme newTheme)
{
NewTheme = newTheme;
}
}

16
Services/TrayMenuItem.cs Normal file
View File

@@ -0,0 +1,16 @@
using System;
namespace Microsoft.Maui.Platform.Linux.Services;
public class TrayMenuItem
{
public string Text { get; set; } = "";
public Action? Action { get; set; }
public bool IsSeparator { get; set; }
public bool IsEnabled { get; set; } = true;
public string? IconPath { get; set; }
}

View File

@@ -1,251 +1,250 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System;
using System.Collections.Generic;
using System.IO;
using System.Reflection;
using System.Text.Json;
using Microsoft.Maui.ApplicationModel;
namespace Microsoft.Maui.Platform.Linux.Services;
/// <summary>
/// Linux version tracking implementation.
/// </summary>
public class VersionTrackingService : IVersionTracking
{
private const string VersionTrackingFile = ".maui-version-tracking.json";
private readonly string _trackingFilePath;
private VersionTrackingData _data;
private bool _isInitialized;
private class VersionTrackingData
{
public string? CurrentVersion { get; set; }
public VersionTrackingService()
{
_trackingFilePath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
VersionTrackingFile);
_data = new VersionTrackingData();
}
public string? CurrentBuild { get; set; }
private void EnsureInitialized()
{
if (_isInitialized) return;
_isInitialized = true;
public string? PreviousVersion { get; set; }
LoadTrackingData();
UpdateTrackingData();
}
public string? PreviousBuild { get; set; }
private void LoadTrackingData()
{
try
{
if (File.Exists(_trackingFilePath))
{
var json = File.ReadAllText(_trackingFilePath);
_data = JsonSerializer.Deserialize<VersionTrackingData>(json) ?? new VersionTrackingData();
}
}
catch
{
_data = new VersionTrackingData();
}
}
public string? FirstInstalledVersion { get; set; }
private void UpdateTrackingData()
{
var currentVersion = CurrentVersion;
var currentBuild = CurrentBuild;
public string? FirstInstalledBuild { get; set; }
// Check if this is a new version
if (_data.PreviousVersion != currentVersion || _data.PreviousBuild != currentBuild)
{
// Store previous version info
if (!string.IsNullOrEmpty(_data.CurrentVersion))
{
_data.PreviousVersion = _data.CurrentVersion;
_data.PreviousBuild = _data.CurrentBuild;
}
public List<string> VersionHistory { get; set; } = new List<string>();
_data.CurrentVersion = currentVersion;
_data.CurrentBuild = currentBuild;
public List<string> BuildHistory { get; set; } = new List<string>();
// Add to version history
if (!_data.VersionHistory.Contains(currentVersion))
{
_data.VersionHistory.Add(currentVersion);
}
public bool IsFirstLaunchEver { get; set; }
// Add to build history
if (!_data.BuildHistory.Contains(currentBuild))
{
_data.BuildHistory.Add(currentBuild);
}
}
public bool IsFirstLaunchForCurrentVersion { get; set; }
// Track first launch
if (_data.FirstInstalledVersion == null)
{
_data.FirstInstalledVersion = currentVersion;
_data.FirstInstalledBuild = currentBuild;
_data.IsFirstLaunchEver = true;
}
else
{
_data.IsFirstLaunchEver = false;
}
public bool IsFirstLaunchForCurrentBuild { get; set; }
}
// Check if first launch for current version
_data.IsFirstLaunchForCurrentVersion = _data.PreviousVersion != currentVersion;
_data.IsFirstLaunchForCurrentBuild = _data.PreviousBuild != currentBuild;
private const string VersionTrackingFile = ".maui-version-tracking.json";
SaveTrackingData();
}
private readonly string _trackingFilePath;
private void SaveTrackingData()
{
try
{
var directory = Path.GetDirectoryName(_trackingFilePath);
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
{
Directory.CreateDirectory(directory);
}
private VersionTrackingData _data;
var json = JsonSerializer.Serialize(_data, new JsonSerializerOptions { WriteIndented = true });
File.WriteAllText(_trackingFilePath, json);
}
catch
{
// Silently fail if we can't save
}
}
private bool _isInitialized;
public bool IsFirstLaunchEver
{
get
{
EnsureInitialized();
return _data.IsFirstLaunchEver;
}
}
public bool IsFirstLaunchEver
{
get
{
EnsureInitialized();
return _data.IsFirstLaunchEver;
}
}
public bool IsFirstLaunchForCurrentVersion
{
get
{
EnsureInitialized();
return _data.IsFirstLaunchForCurrentVersion;
}
}
public bool IsFirstLaunchForCurrentVersion
{
get
{
EnsureInitialized();
return _data.IsFirstLaunchForCurrentVersion;
}
}
public bool IsFirstLaunchForCurrentBuild
{
get
{
EnsureInitialized();
return _data.IsFirstLaunchForCurrentBuild;
}
}
public bool IsFirstLaunchForCurrentBuild
{
get
{
EnsureInitialized();
return _data.IsFirstLaunchForCurrentBuild;
}
}
public string CurrentVersion => GetAssemblyVersion();
public string CurrentBuild => GetAssemblyBuild();
public string CurrentVersion => GetAssemblyVersion();
public string? PreviousVersion
{
get
{
EnsureInitialized();
return _data.PreviousVersion;
}
}
public string CurrentBuild => GetAssemblyBuild();
public string? PreviousBuild
{
get
{
EnsureInitialized();
return _data.PreviousBuild;
}
}
public string? PreviousVersion
{
get
{
EnsureInitialized();
return _data.PreviousVersion;
}
}
public string? FirstInstalledVersion
{
get
{
EnsureInitialized();
return _data.FirstInstalledVersion;
}
}
public string? PreviousBuild
{
get
{
EnsureInitialized();
return _data.PreviousBuild;
}
}
public string? FirstInstalledBuild
{
get
{
EnsureInitialized();
return _data.FirstInstalledBuild;
}
}
public string? FirstInstalledVersion
{
get
{
EnsureInitialized();
return _data.FirstInstalledVersion;
}
}
public IReadOnlyList<string> VersionHistory
{
get
{
EnsureInitialized();
return _data.VersionHistory.AsReadOnly();
}
}
public string? FirstInstalledBuild
{
get
{
EnsureInitialized();
return _data.FirstInstalledBuild;
}
}
public IReadOnlyList<string> BuildHistory
{
get
{
EnsureInitialized();
return _data.BuildHistory.AsReadOnly();
}
}
public IReadOnlyList<string> VersionHistory
{
get
{
EnsureInitialized();
return _data.VersionHistory.AsReadOnly();
}
}
public bool IsFirstLaunchForVersion(string version)
{
EnsureInitialized();
return !_data.VersionHistory.Contains(version);
}
public IReadOnlyList<string> BuildHistory
{
get
{
EnsureInitialized();
return _data.BuildHistory.AsReadOnly();
}
}
public bool IsFirstLaunchForBuild(string build)
{
EnsureInitialized();
return !_data.BuildHistory.Contains(build);
}
public VersionTrackingService()
{
_trackingFilePath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), ".maui-version-tracking.json");
_data = new VersionTrackingData();
}
public void Track()
{
EnsureInitialized();
}
private void EnsureInitialized()
{
if (!_isInitialized)
{
_isInitialized = true;
LoadTrackingData();
UpdateTrackingData();
}
}
private static string GetAssemblyVersion()
{
var assembly = Assembly.GetEntryAssembly() ?? Assembly.GetExecutingAssembly();
var version = assembly.GetName().Version;
return version != null ? $"{version.Major}.{version.Minor}.{version.Build}" : "1.0.0";
}
private void LoadTrackingData()
{
try
{
if (File.Exists(_trackingFilePath))
{
string json = File.ReadAllText(_trackingFilePath);
_data = JsonSerializer.Deserialize<VersionTrackingData>(json) ?? new VersionTrackingData();
}
}
catch
{
_data = new VersionTrackingData();
}
}
private static string GetAssemblyBuild()
{
var assembly = Assembly.GetEntryAssembly() ?? Assembly.GetExecutingAssembly();
var version = assembly.GetName().Version;
return version?.Revision.ToString() ?? "0";
}
private void UpdateTrackingData()
{
string currentVersion = CurrentVersion;
string currentBuild = CurrentBuild;
if (_data.PreviousVersion != currentVersion || _data.PreviousBuild != currentBuild)
{
if (!string.IsNullOrEmpty(_data.CurrentVersion))
{
_data.PreviousVersion = _data.CurrentVersion;
_data.PreviousBuild = _data.CurrentBuild;
}
_data.CurrentVersion = currentVersion;
_data.CurrentBuild = currentBuild;
if (!_data.VersionHistory.Contains(currentVersion))
{
_data.VersionHistory.Add(currentVersion);
}
if (!_data.BuildHistory.Contains(currentBuild))
{
_data.BuildHistory.Add(currentBuild);
}
}
if (_data.FirstInstalledVersion == null)
{
_data.FirstInstalledVersion = currentVersion;
_data.FirstInstalledBuild = currentBuild;
_data.IsFirstLaunchEver = true;
}
else
{
_data.IsFirstLaunchEver = false;
}
_data.IsFirstLaunchForCurrentVersion = _data.PreviousVersion != currentVersion;
_data.IsFirstLaunchForCurrentBuild = _data.PreviousBuild != currentBuild;
SaveTrackingData();
}
private class VersionTrackingData
{
public string? CurrentVersion { get; set; }
public string? CurrentBuild { get; set; }
public string? PreviousVersion { get; set; }
public string? PreviousBuild { get; set; }
public string? FirstInstalledVersion { get; set; }
public string? FirstInstalledBuild { get; set; }
public List<string> VersionHistory { get; set; } = new();
public List<string> BuildHistory { get; set; } = new();
public bool IsFirstLaunchEver { get; set; }
public bool IsFirstLaunchForCurrentVersion { get; set; }
public bool IsFirstLaunchForCurrentBuild { get; set; }
}
private void SaveTrackingData()
{
try
{
string directoryName = Path.GetDirectoryName(_trackingFilePath);
if (!string.IsNullOrEmpty(directoryName) && !Directory.Exists(directoryName))
{
Directory.CreateDirectory(directoryName);
}
string contents = JsonSerializer.Serialize(_data, new JsonSerializerOptions
{
WriteIndented = true
});
File.WriteAllText(_trackingFilePath, contents);
}
catch
{
}
}
public bool IsFirstLaunchForVersion(string version)
{
EnsureInitialized();
return !_data.VersionHistory.Contains(version);
}
public bool IsFirstLaunchForBuild(string build)
{
EnsureInitialized();
return !_data.BuildHistory.Contains(build);
}
public void Track()
{
EnsureInitialized();
}
private static string GetAssemblyVersion()
{
Version version = (Assembly.GetEntryAssembly() ?? Assembly.GetExecutingAssembly()).GetName().Version;
if (!(version != null))
{
return "1.0.0";
}
return $"{version.Major}.{version.Minor}.{version.Build}";
}
private static string GetAssemblyBuild()
{
return (Assembly.GetEntryAssembly() ?? Assembly.GetExecutingAssembly()).GetName().Version?.Revision.ToString() ?? "0";
}
}

View File

@@ -0,0 +1,9 @@
namespace Microsoft.Maui.Platform.Linux.Services;
public enum VideoAccelerationApi
{
Auto,
VaApi,
Vdpau,
Software
}

44
Services/VideoFrame.cs Normal file
View File

@@ -0,0 +1,44 @@
using System;
namespace Microsoft.Maui.Platform.Linux.Services;
public class VideoFrame : IDisposable
{
private bool _disposed;
private Action? _releaseCallback;
public int Width { get; init; }
public int Height { get; init; }
public IntPtr DataY { get; init; }
public IntPtr DataU { get; init; }
public IntPtr DataV { get; init; }
public int StrideY { get; init; }
public int StrideU { get; init; }
public int StrideV { get; init; }
public long Timestamp { get; init; }
public bool IsKeyFrame { get; init; }
internal void SetReleaseCallback(Action callback)
{
_releaseCallback = callback;
}
public void Dispose()
{
if (!_disposed)
{
_releaseCallback?.Invoke();
_disposed = true;
}
}
}

14
Services/VideoProfile.cs Normal file
View File

@@ -0,0 +1,14 @@
namespace Microsoft.Maui.Platform.Linux.Services;
public enum VideoProfile
{
H264Baseline,
H264Main,
H264High,
H265Main,
H265Main10,
Vp8,
Vp9Profile0,
Vp9Profile2,
Av1Main
}

View File

@@ -0,0 +1,63 @@
using System;
namespace Microsoft.Maui.Platform.Linux.Services;
public static class VirtualizationExtensions
{
public static (int first, int last) CalculateVisibleRange(float scrollOffset, float viewportHeight, float itemHeight, float itemSpacing, int totalItems)
{
if (totalItems == 0)
{
return (first: -1, last: -1);
}
float num = itemHeight + itemSpacing;
int item = Math.Max(0, (int)(scrollOffset / num));
int item2 = Math.Min(totalItems - 1, (int)((scrollOffset + viewportHeight) / num) + 1);
return (first: item, last: item2);
}
public static (int first, int last) CalculateVisibleRangeVariable(float scrollOffset, float viewportHeight, Func<int, float> getItemHeight, float itemSpacing, int totalItems)
{
if (totalItems == 0)
{
return (first: -1, last: -1);
}
int num = 0;
float num2 = 0f;
for (int i = 0; i < totalItems; i++)
{
float num3 = getItemHeight(i);
if (num2 + num3 > scrollOffset)
{
num = i;
break;
}
num2 += num3 + itemSpacing;
}
int item = num;
float num4 = scrollOffset + viewportHeight;
for (int j = num; j < totalItems; j++)
{
float num5 = getItemHeight(j);
if (num2 > num4)
{
break;
}
item = j;
num2 += num5 + itemSpacing;
}
return (first: num, last: item);
}
public static (int firstRow, int lastRow) CalculateVisibleGridRange(float scrollOffset, float viewportHeight, float rowHeight, float rowSpacing, int totalRows)
{
if (totalRows == 0)
{
return (firstRow: -1, lastRow: -1);
}
float num = rowHeight + rowSpacing;
int item = Math.Max(0, (int)(scrollOffset / num));
int item2 = Math.Min(totalRows - 1, (int)((scrollOffset + viewportHeight) / num) + 1);
return (firstRow: item, lastRow: item2);
}
}

View File

@@ -1,307 +1,135 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using SkiaSharp;
using System;
using System.Collections.Generic;
using System.Linq;
namespace Microsoft.Maui.Platform.Linux.Services;
/// <summary>
/// Manages view recycling for virtualized lists and collections.
/// Implements a pool-based recycling strategy to minimize allocations.
/// </summary>
public class VirtualizationManager<T> where T : SkiaView
{
private readonly Dictionary<int, T> _activeViews = new();
private readonly Queue<T> _recyclePool = new();
private readonly Func<T> _viewFactory;
private readonly Action<T>? _viewRecycler;
private readonly int _maxPoolSize;
private readonly Dictionary<int, T> _activeViews = new Dictionary<int, T>();
private int _firstVisibleIndex = -1;
private int _lastVisibleIndex = -1;
private readonly Queue<T> _recyclePool = new Queue<T>();
/// <summary>
/// Number of views currently active (bound to data).
/// </summary>
public int ActiveViewCount => _activeViews.Count;
private readonly Func<T> _viewFactory;
/// <summary>
/// Number of views in the recycle pool.
/// </summary>
public int PooledViewCount => _recyclePool.Count;
private readonly Action<T>? _viewRecycler;
/// <summary>
/// Current visible range.
/// </summary>
public (int First, int Last) VisibleRange => (_firstVisibleIndex, _lastVisibleIndex);
private readonly int _maxPoolSize;
/// <summary>
/// Creates a new virtualization manager.
/// </summary>
/// <param name="viewFactory">Factory function to create new views.</param>
/// <param name="viewRecycler">Optional function to reset views before recycling.</param>
/// <param name="maxPoolSize">Maximum number of views to keep in the recycle pool.</param>
public VirtualizationManager(
Func<T> viewFactory,
Action<T>? viewRecycler = null,
int maxPoolSize = 20)
{
_viewFactory = viewFactory ?? throw new ArgumentNullException(nameof(viewFactory));
_viewRecycler = viewRecycler;
_maxPoolSize = maxPoolSize;
}
private int _firstVisibleIndex = -1;
/// <summary>
/// Updates the visible range and recycles views that scrolled out of view.
/// </summary>
/// <param name="firstVisible">Index of first visible item.</param>
/// <param name="lastVisible">Index of last visible item.</param>
public void UpdateVisibleRange(int firstVisible, int lastVisible)
{
if (firstVisible == _firstVisibleIndex && lastVisible == _lastVisibleIndex)
return;
private int _lastVisibleIndex = -1;
// Recycle views that scrolled out of view
var toRecycle = new List<int>();
foreach (var kvp in _activeViews)
{
if (kvp.Key < firstVisible || kvp.Key > lastVisible)
{
toRecycle.Add(kvp.Key);
}
}
public int ActiveViewCount => _activeViews.Count;
foreach (var index in toRecycle)
{
RecycleView(index);
}
public int PooledViewCount => _recyclePool.Count;
_firstVisibleIndex = firstVisible;
_lastVisibleIndex = lastVisible;
}
public (int First, int Last) VisibleRange => (First: _firstVisibleIndex, Last: _lastVisibleIndex);
/// <summary>
/// Gets or creates a view for the specified index.
/// </summary>
/// <param name="index">Item index.</param>
/// <param name="bindData">Action to bind data to the view.</param>
/// <returns>A view bound to the data.</returns>
public T GetOrCreateView(int index, Action<T> bindData)
{
if (_activeViews.TryGetValue(index, out var existing))
{
return existing;
}
public VirtualizationManager(Func<T> viewFactory, Action<T>? viewRecycler = null, int maxPoolSize = 20)
{
_viewFactory = viewFactory ?? throw new ArgumentNullException("viewFactory");
_viewRecycler = viewRecycler;
_maxPoolSize = maxPoolSize;
}
// Get from pool or create new
T view;
if (_recyclePool.Count > 0)
{
view = _recyclePool.Dequeue();
}
else
{
view = _viewFactory();
}
public void UpdateVisibleRange(int firstVisible, int lastVisible)
{
if (firstVisible == _firstVisibleIndex && lastVisible == _lastVisibleIndex)
{
return;
}
List<int> list = new List<int>();
foreach (KeyValuePair<int, T> activeView in _activeViews)
{
if (activeView.Key < firstVisible || activeView.Key > lastVisible)
{
list.Add(activeView.Key);
}
}
foreach (int item in list)
{
RecycleView(item);
}
_firstVisibleIndex = firstVisible;
_lastVisibleIndex = lastVisible;
}
// Bind data
bindData(view);
_activeViews[index] = view;
public T GetOrCreateView(int index, Action<T> bindData)
{
if (_activeViews.TryGetValue(index, out var value))
{
return value;
}
T val = ((_recyclePool.Count <= 0) ? _viewFactory() : _recyclePool.Dequeue());
bindData(val);
_activeViews[index] = val;
return val;
}
return view;
}
public T? GetActiveView(int index)
{
if (!_activeViews.TryGetValue(index, out var value))
{
return null;
}
return value;
}
/// <summary>
/// Gets an existing view for the index, or null if not active.
/// </summary>
public T? GetActiveView(int index)
{
return _activeViews.TryGetValue(index, out var view) ? view : default;
}
private void RecycleView(int index)
{
if (_activeViews.TryGetValue(index, out var value))
{
_activeViews.Remove(index);
_viewRecycler?.Invoke(value);
if (_recyclePool.Count < _maxPoolSize)
{
_recyclePool.Enqueue(value);
}
else
{
value.Dispose();
}
}
}
/// <summary>
/// Recycles a view at the specified index.
/// </summary>
private void RecycleView(int index)
{
if (!_activeViews.TryGetValue(index, out var view))
return;
public void Clear()
{
foreach (T value in _activeViews.Values)
{
value.Dispose();
}
_activeViews.Clear();
while (_recyclePool.Count > 0)
{
_recyclePool.Dequeue().Dispose();
}
_firstVisibleIndex = -1;
_lastVisibleIndex = -1;
}
_activeViews.Remove(index);
public void RemoveItem(int index)
{
RecycleView(index);
foreach (KeyValuePair<int, T> item in (from kvp in _activeViews
where kvp.Key > index
orderby kvp.Key
select kvp).ToList())
{
_activeViews.Remove(item.Key);
_activeViews[item.Key - 1] = item.Value;
}
}
// Reset the view
_viewRecycler?.Invoke(view);
// Add to pool if not full
if (_recyclePool.Count < _maxPoolSize)
{
_recyclePool.Enqueue(view);
}
else
{
// Pool is full, dispose the view
view.Dispose();
}
}
/// <summary>
/// Clears all active views and the recycle pool.
/// </summary>
public void Clear()
{
foreach (var view in _activeViews.Values)
{
view.Dispose();
}
_activeViews.Clear();
while (_recyclePool.Count > 0)
{
_recyclePool.Dequeue().Dispose();
}
_firstVisibleIndex = -1;
_lastVisibleIndex = -1;
}
/// <summary>
/// Removes a specific item and recycles its view.
/// </summary>
public void RemoveItem(int index)
{
RecycleView(index);
// Shift indices for items after the removed one
var toShift = _activeViews
.Where(kvp => kvp.Key > index)
.OrderBy(kvp => kvp.Key)
.ToList();
foreach (var kvp in toShift)
{
_activeViews.Remove(kvp.Key);
_activeViews[kvp.Key - 1] = kvp.Value;
}
}
/// <summary>
/// Inserts an item and shifts existing indices.
/// </summary>
public void InsertItem(int index)
{
// Shift indices for items at or after the insert position
var toShift = _activeViews
.Where(kvp => kvp.Key >= index)
.OrderByDescending(kvp => kvp.Key)
.ToList();
foreach (var kvp in toShift)
{
_activeViews.Remove(kvp.Key);
_activeViews[kvp.Key + 1] = kvp.Value;
}
}
}
/// <summary>
/// Extension methods for virtualization.
/// </summary>
public static class VirtualizationExtensions
{
/// <summary>
/// Calculates visible item range for a vertical list.
/// </summary>
/// <param name="scrollOffset">Current scroll offset.</param>
/// <param name="viewportHeight">Height of visible area.</param>
/// <param name="itemHeight">Height of each item (fixed).</param>
/// <param name="itemSpacing">Spacing between items.</param>
/// <param name="totalItems">Total number of items.</param>
/// <returns>Tuple of (firstVisible, lastVisible) indices.</returns>
public static (int first, int last) CalculateVisibleRange(
float scrollOffset,
float viewportHeight,
float itemHeight,
float itemSpacing,
int totalItems)
{
if (totalItems == 0)
return (-1, -1);
var rowHeight = itemHeight + itemSpacing;
var first = Math.Max(0, (int)(scrollOffset / rowHeight));
var last = Math.Min(totalItems - 1, (int)((scrollOffset + viewportHeight) / rowHeight) + 1);
return (first, last);
}
/// <summary>
/// Calculates visible item range for variable height items.
/// </summary>
/// <param name="scrollOffset">Current scroll offset.</param>
/// <param name="viewportHeight">Height of visible area.</param>
/// <param name="getItemHeight">Function to get height of item at index.</param>
/// <param name="itemSpacing">Spacing between items.</param>
/// <param name="totalItems">Total number of items.</param>
/// <returns>Tuple of (firstVisible, lastVisible) indices.</returns>
public static (int first, int last) CalculateVisibleRangeVariable(
float scrollOffset,
float viewportHeight,
Func<int, float> getItemHeight,
float itemSpacing,
int totalItems)
{
if (totalItems == 0)
return (-1, -1);
int first = 0;
float cumulativeHeight = 0;
// Find first visible
for (int i = 0; i < totalItems; i++)
{
var itemHeight = getItemHeight(i);
if (cumulativeHeight + itemHeight > scrollOffset)
{
first = i;
break;
}
cumulativeHeight += itemHeight + itemSpacing;
}
// Find last visible
int last = first;
var endOffset = scrollOffset + viewportHeight;
for (int i = first; i < totalItems; i++)
{
var itemHeight = getItemHeight(i);
if (cumulativeHeight > endOffset)
{
break;
}
last = i;
cumulativeHeight += itemHeight + itemSpacing;
}
return (first, last);
}
/// <summary>
/// Calculates visible item range for a grid layout.
/// </summary>
public static (int firstRow, int lastRow) CalculateVisibleGridRange(
float scrollOffset,
float viewportHeight,
float rowHeight,
float rowSpacing,
int totalRows)
{
if (totalRows == 0)
return (-1, -1);
var effectiveRowHeight = rowHeight + rowSpacing;
var first = Math.Max(0, (int)(scrollOffset / effectiveRowHeight));
var last = Math.Min(totalRows - 1, (int)((scrollOffset + viewportHeight) / effectiveRowHeight) + 1);
return (first, last);
}
public void InsertItem(int index)
{
foreach (KeyValuePair<int, T> item in (from kvp in _activeViews
where kvp.Key >= index
orderby kvp.Key descending
select kvp).ToList())
{
_activeViews.Remove(item.Key);
_activeViews[item.Key + 1] = item.Value;
}
}
}

Some files were not shown because too many files have changed in this diff Show More