Major production merge: GTK support, context menus, and dispatcher fixes

Core Infrastructure:
- Add Dispatching folder with LinuxDispatcher, LinuxDispatcherProvider, LinuxDispatcherTimer
- Add Native folder with P/Invoke wrappers (GTK, GLib, GDK, Cairo, WebKit)
- Add GTK host window system with GtkHostWindow and GtkSkiaSurfaceWidget
- Update LinuxApplication with GTK mode, theme handling, and icon support
- Fix duplicate LinuxDispatcher in LinuxMauiContext

Handlers:
- Add GtkWebViewManager and GtkWebViewPlatformView for GTK WebView
- Add FlexLayoutHandler and GestureManager
- Update multiple handlers with ToViewHandler fix and missing mappers
- Add MauiHandlerExtensions with ToViewHandler extension method

Views:
- Add SkiaContextMenu with hover, keyboard, and dark theme support
- Add LinuxDialogService with context menu management
- Add SkiaFlexLayout for flex container support
- Update SkiaShell with RefreshTheme, MauiShell, ContentRenderer
- Update SkiaWebView with SetMainWindow, ProcessGtkEvents
- Update SkiaImage with LoadFromBitmap method

Services:
- Add AppInfoService, ConnectivityService, DeviceDisplayService, DeviceInfoService
- Add GtkHostService, GtkContextMenuService, MauiIconGenerator

Window:
- Add CursorType enum and GtkHostWindow
- Update X11Window with SetIcon, SetCursor methods

Build: SUCCESS (0 errors)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-01 11:19:58 -05:00
parent e02af03be0
commit f7043ab9c7
56 changed files with 6061 additions and 473 deletions

142
Services/AppInfoService.cs Normal file
View File

@@ -0,0 +1,142 @@
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.LeftToRight;
public AppTheme RequestedTheme
{
get
{
try
{
string environmentVariable = Environment.GetEnvironmentVariable("GTK_THEME");
if (!string.IsNullOrEmpty(environmentVariable) && environmentVariable.Contains("dark", StringComparison.OrdinalIgnoreCase))
{
return AppTheme.Dark;
}
if (GetGnomeColorScheme().Contains("dark", StringComparison.OrdinalIgnoreCase))
{
return AppTheme.Dark;
}
return AppTheme.Light;
}
catch
{
return AppTheme.Light;
}
}
}
public AppPackagingModel PackagingModel
{
get
{
if (Environment.GetEnvironmentVariable("FLATPAK_ID") != null)
{
return AppPackagingModel.Packaged;
}
if (Environment.GetEnvironmentVariable("SNAP") != null)
{
return AppPackagingModel.Packaged;
}
if (Environment.GetEnvironmentVariable("APPIMAGE") != null)
{
return AppPackagingModel.Packaged;
}
return AppPackagingModel.Unpackaged;
}
}
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

@@ -0,0 +1,170 @@
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
{
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()
{
try
{
IEnumerable<NetworkInterface> activeInterfaces = from ni in NetworkInterface.GetAllNetworkInterfaces()
where ni.OperationalStatus == OperationalStatus.Up && ni.NetworkInterfaceType != NetworkInterfaceType.Loopback
select ni;
if (!activeInterfaces.Any())
{
_networkAccess = NetworkAccess.None;
_connectionProfiles = Enumerable.Empty<ConnectionProfile>();
return;
}
List<ConnectionProfile> profiles = new List<ConnectionProfile>();
foreach (var networkInterface in activeInterfaces)
{
switch (networkInterface.NetworkInterfaceType)
{
case NetworkInterfaceType.Ethernet:
case NetworkInterfaceType.FastEthernetT:
case NetworkInterfaceType.FastEthernetFx:
case NetworkInterfaceType.GigabitEthernet:
profiles.Add(ConnectionProfile.Ethernet);
break;
case NetworkInterfaceType.Wireless80211:
profiles.Add(ConnectionProfile.WiFi);
break;
case NetworkInterfaceType.Ppp:
case NetworkInterfaceType.Slip:
profiles.Add(ConnectionProfile.Cellular);
break;
default:
profiles.Add(ConnectionProfile.Unknown);
break;
}
}
_connectionProfiles = profiles.Distinct().ToList();
if (CheckInternetAccess())
{
_networkAccess = NetworkAccess.Internet;
}
else if (_connectionProfiles.Any())
{
_networkAccess = NetworkAccess.Local;
}
else
{
_networkAccess = NetworkAccess.None;
}
}
catch
{
_networkAccess = NetworkAccess.Unknown;
_connectionProfiles = new ConnectionProfile[] { ConnectionProfile.Unknown };
}
}
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)
{
NetworkAccess previousAccess = _networkAccess;
List<ConnectionProfile> previousProfiles = _connectionProfiles.ToList();
RefreshConnectivity();
if (previousAccess != _networkAccess || !previousProfiles.SequenceEqual(_connectionProfiles))
{
ConnectivityChanged?.Invoke(this, new ConnectivityChangedEventArgs(_networkAccess, _connectionProfiles));
}
}
private void OnNetworkAddressChanged(object? sender, EventArgs e)
{
NetworkAccess previousAccess = _networkAccess;
List<ConnectionProfile> previousProfiles = _connectionProfiles.ToList();
RefreshConnectivity();
if (previousAccess != _networkAccess || !previousProfiles.SequenceEqual(_connectionProfiles))
{
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,124 @@
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
{
RefreshDisplayInfo();
return _mainDisplayInfo;
}
}
public event EventHandler<DisplayInfoChangedEventArgs>? MainDisplayInfoChanged;
public DeviceDisplayService()
{
RefreshDisplayInfo();
}
private void RefreshDisplayInfo()
{
try
{
IntPtr screen = GdkNative.gdk_screen_get_default();
if (screen != IntPtr.Zero)
{
int width = GdkNative.gdk_screen_get_width(screen);
int height = GdkNative.gdk_screen_get_height(screen);
double scaleFactor = GetScaleFactor();
DisplayOrientation orientation = (width <= height) ? DisplayOrientation.Portrait : DisplayOrientation.Landscape;
_mainDisplayInfo = new DisplayInfo(width, height, scaleFactor, orientation, DisplayRotation.Rotation0, GetRefreshRate());
}
else
{
_mainDisplayInfo = new DisplayInfo(1920.0, 1080.0, 1.0, DisplayOrientation.Landscape, DisplayRotation.Rotation0, 60f);
}
}
catch
{
_mainDisplayInfo = new DisplayInfo(1920.0, 1080.0, 1.0, DisplayOrientation.Landscape, DisplayRotation.Rotation0, 60f);
}
}
private double GetScaleFactor()
{
string gdkScale = Environment.GetEnvironmentVariable("GDK_SCALE");
if (!string.IsNullOrEmpty(gdkScale) && double.TryParse(gdkScale, out var result))
{
return result;
}
string qtScale = Environment.GetEnvironmentVariable("QT_SCALE_FACTOR");
if (!string.IsNullOrEmpty(qtScale) && double.TryParse(qtScale, out result))
{
return result;
}
return 1.0;
}
private float GetRefreshRate()
{
return 60f;
}
private void SetScreenSaverInhibit(bool inhibit)
{
try
{
string action = inhibit ? "suspend" : "resume";
IntPtr windowHandle = LinuxApplication.Current?.MainWindow?.Handle ?? IntPtr.Zero;
if (windowHandle != IntPtr.Zero)
{
long windowId = windowHandle.ToInt64();
Process.Start(new ProcessStartInfo
{
FileName = "xdg-screensaver",
Arguments = $"{action} {windowId}",
UseShellExecute = false,
CreateNoWindow = true
});
}
}
catch
{
}
}
public void OnDisplayInfoChanged()
{
RefreshDisplayInfo();
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.Physical;
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

@@ -0,0 +1,90 @@
using System;
using System.Collections.Generic;
using System.Runtime.InteropServices;
using Microsoft.Maui.Platform.Linux.Native;
namespace Microsoft.Maui.Platform.Linux.Services;
/// <summary>
/// Service for displaying native GTK context menus in MAUI applications.
/// Provides popup menu functionality with action callbacks.
/// </summary>
public static class GtkContextMenuService
{
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
private delegate void ActivateCallback(IntPtr menuItem, IntPtr userData);
// Keep references to prevent garbage collection
private static readonly List<ActivateCallback> _callbacks = new();
private static readonly List<Action> _actions = new();
public static void ShowContextMenu(List<GtkMenuItem> items)
{
if (items == null || items.Count == 0)
{
return;
}
_callbacks.Clear();
_actions.Clear();
IntPtr menu = GtkNative.gtk_menu_new();
if (menu == IntPtr.Zero)
{
Console.WriteLine("[GtkContextMenuService] Failed to create GTK menu");
return;
}
foreach (var item in items)
{
IntPtr menuItem;
if (item.IsSeparator)
{
menuItem = GtkNative.gtk_separator_menu_item_new();
}
else
{
menuItem = GtkNative.gtk_menu_item_new_with_label(item.Text);
GtkNative.gtk_widget_set_sensitive(menuItem, item.IsEnabled);
if (item.IsEnabled && item.Action != null)
{
var action = item.Action;
_actions.Add(action);
int actionIndex = _actions.Count - 1;
ActivateCallback callback = delegate
{
Console.WriteLine("[GtkContextMenuService] Menu item activated: " + item.Text);
_actions[actionIndex]?.Invoke();
};
_callbacks.Add(callback);
GtkNative.g_signal_connect_data(
menuItem,
"activate",
Marshal.GetFunctionPointerForDelegate(callback),
IntPtr.Zero,
IntPtr.Zero,
0);
}
}
GtkNative.gtk_menu_shell_append(menu, menuItem);
GtkNative.gtk_widget_show(menuItem);
}
GtkNative.gtk_widget_show(menu);
IntPtr currentEvent = GtkNative.gtk_get_current_event();
GtkNative.gtk_menu_popup_at_pointer(menu, currentEvent);
if (currentEvent != IntPtr.Zero)
{
GtkNative.gdk_event_free(currentEvent);
}
Console.WriteLine($"[GtkContextMenuService] Showed GTK menu with {items.Count} items");
}
}

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;
/// <summary>
/// Singleton service that manages the GTK host window and WebView manager.
/// Provides centralized access to the GTK infrastructure for MAUI applications.
/// </summary>
public class GtkHostService
{
private static GtkHostService? _instance;
private GtkHostWindow? _hostWindow;
private GtkWebViewManager? _webViewManager;
public static GtkHostService 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);
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;
/// <summary>
/// Represents a menu item for use with GtkContextMenuService.
/// </summary>
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,158 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using SkiaSharp;
namespace Microsoft.Maui.Platform.Linux.Services;
/// <summary>
/// Generates application icons from MAUI icon metadata.
/// Creates PNG icons suitable for use as window icons on Linux.
/// Note: SVG overlay support requires Svg.Skia package (optional).
/// </summary>
public static class MauiIconGenerator
{
private const int DefaultIconSize = 256;
public static string? GenerateIcon(string metaFilePath)
{
if (!File.Exists(metaFilePath))
{
Console.WriteLine("[MauiIconGenerator] Metadata file not found: " + metaFilePath);
return null;
}
try
{
string path = Path.GetDirectoryName(metaFilePath) ?? "";
var metadata = ParseMetadata(File.ReadAllText(metaFilePath));
string outputPath = Path.Combine(path, "appicon.png");
int size = metadata.TryGetValue("Size", out var sizeStr) && int.TryParse(sizeStr, out var sizeVal)
? sizeVal
: DefaultIconSize;
SKColor color = metadata.TryGetValue("Color", out var colorStr)
? ParseColor(colorStr)
: SKColors.Purple;
Console.WriteLine($"[MauiIconGenerator] Generating {size}x{size} icon");
Console.WriteLine($"[MauiIconGenerator] Color: {color}");
using var surface = SKSurface.Create(new SKImageInfo(size, size, SKColorType.Bgra8888, SKAlphaType.Premul));
var canvas = surface.Canvas;
// Draw background with rounded corners
canvas.Clear(SKColors.Transparent);
float cornerRadius = size * 0.2f;
using var paint = new SKPaint { Color = color, IsAntialias = true };
canvas.DrawRoundRect(new SKRoundRect(new SKRect(0, 0, size, size), cornerRadius), paint);
// Try to load PNG foreground as fallback (appicon_fg.png)
string fgPngPath = Path.Combine(path, "appicon_fg.png");
if (File.Exists(fgPngPath))
{
try
{
using var fgBitmap = SKBitmap.Decode(fgPngPath);
if (fgBitmap != null)
{
float scale = size * 0.65f / Math.Max(fgBitmap.Width, fgBitmap.Height);
float fgWidth = fgBitmap.Width * scale;
float fgHeight = fgBitmap.Height * scale;
float offsetX = (size - fgWidth) / 2f;
float offsetY = (size - fgHeight) / 2f;
var dstRect = new SKRect(offsetX, offsetY, offsetX + fgWidth, offsetY + fgHeight);
canvas.DrawBitmap(fgBitmap, dstRect);
}
}
catch (Exception ex)
{
Console.WriteLine("[MauiIconGenerator] Failed to load foreground PNG: " + ex.Message);
}
}
using var image = surface.Snapshot();
using var data = image.Encode(SKEncodedImageFormat.Png, 100);
using var fileStream = File.OpenWrite(outputPath);
data.SaveTo(fileStream);
Console.WriteLine("[MauiIconGenerator] Generated: " + outputPath);
return outputPath;
}
catch (Exception ex)
{
Console.WriteLine("[MauiIconGenerator] Error: " + ex.Message);
return null;
}
}
private static Dictionary<string, string> ParseMetadata(string content)
{
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
var lines = content.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries);
foreach (var line in lines)
{
var parts = line.Split('=', 2);
if (parts.Length == 2)
{
result[parts[0].Trim()] = parts[1].Trim();
}
}
return result;
}
private static SKColor ParseColor(string colorStr)
{
if (string.IsNullOrEmpty(colorStr))
{
return SKColors.Purple;
}
colorStr = colorStr.Trim();
if (colorStr.StartsWith("#"))
{
string hex = colorStr.Substring(1);
// Expand 3-digit hex to 6-digit
if (hex.Length == 3)
{
hex = $"{hex[0]}{hex[0]}{hex[1]}{hex[1]}{hex[2]}{hex[2]}";
}
if (hex.Length == 6 && uint.TryParse(hex, NumberStyles.HexNumber, null, out var rgb))
{
return new SKColor(
(byte)((rgb >> 16) & 0xFF),
(byte)((rgb >> 8) & 0xFF),
(byte)(rgb & 0xFF));
}
if (hex.Length == 8 && uint.TryParse(hex, NumberStyles.HexNumber, null, out var argb))
{
return new SKColor(
(byte)((argb >> 16) & 0xFF),
(byte)((argb >> 8) & 0xFF),
(byte)(argb & 0xFF),
(byte)((argb >> 24) & 0xFF));
}
}
return 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,
};
}
}