Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2a4e35cd39 | |||
| 33914bf572 | |||
| 1f096c38dc |
@@ -1,207 +0,0 @@
|
|||||||
// 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.Handlers;
|
|
||||||
|
|
||||||
namespace Microsoft.Maui.Platform;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Linux handler for WebView control using WebKitGTK.
|
|
||||||
/// </summary>
|
|
||||||
public partial class WebViewHandler : ViewHandler<IWebView, LinuxWebView>
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Property mapper for WebView properties.
|
|
||||||
/// </summary>
|
|
||||||
public static IPropertyMapper<IWebView, WebViewHandler> Mapper = new PropertyMapper<IWebView, WebViewHandler>(ViewHandler.ViewMapper)
|
|
||||||
{
|
|
||||||
[nameof(IWebView.Source)] = MapSource,
|
|
||||||
[nameof(IWebView.UserAgent)] = MapUserAgent,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Command mapper for WebView commands.
|
|
||||||
/// </summary>
|
|
||||||
public static CommandMapper<IWebView, WebViewHandler> CommandMapper = new(ViewHandler.ViewCommandMapper)
|
|
||||||
{
|
|
||||||
[nameof(IWebView.GoBack)] = MapGoBack,
|
|
||||||
[nameof(IWebView.GoForward)] = MapGoForward,
|
|
||||||
[nameof(IWebView.Reload)] = MapReload,
|
|
||||||
[nameof(IWebView.Eval)] = MapEval,
|
|
||||||
[nameof(IWebView.EvaluateJavaScriptAsync)] = MapEvaluateJavaScriptAsync,
|
|
||||||
};
|
|
||||||
|
|
||||||
public WebViewHandler() : base(Mapper, CommandMapper)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public WebViewHandler(IPropertyMapper? mapper)
|
|
||||||
: base(mapper ?? Mapper, CommandMapper)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public WebViewHandler(IPropertyMapper? mapper, CommandMapper? commandMapper)
|
|
||||||
: base(mapper ?? Mapper, commandMapper ?? CommandMapper)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override LinuxWebView CreatePlatformView()
|
|
||||||
{
|
|
||||||
Console.WriteLine("[WebViewHandler] Creating LinuxWebView");
|
|
||||||
return new LinuxWebView();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override void ConnectHandler(LinuxWebView platformView)
|
|
||||||
{
|
|
||||||
base.ConnectHandler(platformView);
|
|
||||||
|
|
||||||
platformView.Navigating += OnNavigating;
|
|
||||||
platformView.Navigated += OnNavigated;
|
|
||||||
|
|
||||||
// Map initial properties
|
|
||||||
if (VirtualView != null)
|
|
||||||
{
|
|
||||||
MapSource(this, VirtualView);
|
|
||||||
MapUserAgent(this, VirtualView);
|
|
||||||
}
|
|
||||||
|
|
||||||
Console.WriteLine("[WebViewHandler] Handler connected");
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override void DisconnectHandler(LinuxWebView platformView)
|
|
||||||
{
|
|
||||||
platformView.Navigating -= OnNavigating;
|
|
||||||
platformView.Navigated -= OnNavigated;
|
|
||||||
|
|
||||||
base.DisconnectHandler(platformView);
|
|
||||||
Console.WriteLine("[WebViewHandler] Handler disconnected");
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnNavigating(object? sender, WebViewNavigatingEventArgs e)
|
|
||||||
{
|
|
||||||
if (VirtualView == null)
|
|
||||||
return;
|
|
||||||
|
|
||||||
// Notify the virtual view about navigation starting
|
|
||||||
VirtualView.Navigating(WebNavigationEvent.NewPage, e.Url);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnNavigated(object? sender, WebViewNavigatedEventArgs e)
|
|
||||||
{
|
|
||||||
if (VirtualView == null)
|
|
||||||
return;
|
|
||||||
|
|
||||||
// Notify the virtual view about navigation completed
|
|
||||||
var result = e.Success ? WebNavigationResult.Success : WebNavigationResult.Failure;
|
|
||||||
VirtualView.Navigated(WebNavigationEvent.NewPage, e.Url, result);
|
|
||||||
}
|
|
||||||
|
|
||||||
#region Property Mappers
|
|
||||||
|
|
||||||
public static void MapSource(WebViewHandler handler, IWebView webView)
|
|
||||||
{
|
|
||||||
var source = webView.Source;
|
|
||||||
if (source == null)
|
|
||||||
return;
|
|
||||||
|
|
||||||
Console.WriteLine($"[WebViewHandler] MapSource: {source.GetType().Name}");
|
|
||||||
|
|
||||||
if (source is IUrlWebViewSource urlSource && !string.IsNullOrEmpty(urlSource.Url))
|
|
||||||
{
|
|
||||||
handler.PlatformView?.LoadUrl(urlSource.Url);
|
|
||||||
}
|
|
||||||
else if (source is IHtmlWebViewSource htmlSource && !string.IsNullOrEmpty(htmlSource.Html))
|
|
||||||
{
|
|
||||||
handler.PlatformView?.LoadHtml(htmlSource.Html, htmlSource.BaseUrl);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static void MapUserAgent(WebViewHandler handler, IWebView webView)
|
|
||||||
{
|
|
||||||
if (handler.PlatformView != null && !string.IsNullOrEmpty(webView.UserAgent))
|
|
||||||
{
|
|
||||||
handler.PlatformView.UserAgent = webView.UserAgent;
|
|
||||||
Console.WriteLine($"[WebViewHandler] MapUserAgent: {webView.UserAgent}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
#region Command Mappers
|
|
||||||
|
|
||||||
public static void MapGoBack(WebViewHandler handler, IWebView webView, object? args)
|
|
||||||
{
|
|
||||||
if (handler.PlatformView?.CanGoBack == true)
|
|
||||||
{
|
|
||||||
handler.PlatformView.GoBack();
|
|
||||||
Console.WriteLine("[WebViewHandler] GoBack");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static void MapGoForward(WebViewHandler handler, IWebView webView, object? args)
|
|
||||||
{
|
|
||||||
if (handler.PlatformView?.CanGoForward == true)
|
|
||||||
{
|
|
||||||
handler.PlatformView.GoForward();
|
|
||||||
Console.WriteLine("[WebViewHandler] GoForward");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static void MapReload(WebViewHandler handler, IWebView webView, object? args)
|
|
||||||
{
|
|
||||||
handler.PlatformView?.Reload();
|
|
||||||
Console.WriteLine("[WebViewHandler] Reload");
|
|
||||||
}
|
|
||||||
|
|
||||||
public static void MapEval(WebViewHandler handler, IWebView webView, object? args)
|
|
||||||
{
|
|
||||||
if (args is string script)
|
|
||||||
{
|
|
||||||
handler.PlatformView?.Eval(script);
|
|
||||||
Console.WriteLine($"[WebViewHandler] Eval: {script.Substring(0, Math.Min(50, script.Length))}...");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static void MapEvaluateJavaScriptAsync(WebViewHandler handler, IWebView webView, object? args)
|
|
||||||
{
|
|
||||||
if (args is EvaluateJavaScriptAsyncRequest request)
|
|
||||||
{
|
|
||||||
var result = handler.PlatformView?.EvaluateJavaScriptAsync(request.Script);
|
|
||||||
if (result != null)
|
|
||||||
{
|
|
||||||
result.ContinueWith(t =>
|
|
||||||
{
|
|
||||||
request.SetResult(t.Result);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
request.SetResult(null);
|
|
||||||
}
|
|
||||||
Console.WriteLine($"[WebViewHandler] EvaluateJavaScriptAsync: {request.Script.Substring(0, Math.Min(50, request.Script.Length))}...");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Request object for async JavaScript evaluation.
|
|
||||||
/// </summary>
|
|
||||||
public class EvaluateJavaScriptAsyncRequest
|
|
||||||
{
|
|
||||||
public string Script { get; }
|
|
||||||
private readonly TaskCompletionSource<string?> _tcs = new();
|
|
||||||
|
|
||||||
public EvaluateJavaScriptAsyncRequest(string script)
|
|
||||||
{
|
|
||||||
Script = script;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task<string?> Task => _tcs.Task;
|
|
||||||
|
|
||||||
public void SetResult(string? result)
|
|
||||||
{
|
|
||||||
_tcs.TrySetResult(result);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -98,9 +98,6 @@ public static class LinuxMauiAppBuilderExtensions
|
|||||||
handlers.AddHandler<ImageButton, ImageButtonHandler>();
|
handlers.AddHandler<ImageButton, ImageButtonHandler>();
|
||||||
handlers.AddHandler<GraphicsView, GraphicsViewHandler>();
|
handlers.AddHandler<GraphicsView, GraphicsViewHandler>();
|
||||||
|
|
||||||
// Web
|
|
||||||
handlers.AddHandler<WebView, WebViewHandler>();
|
|
||||||
|
|
||||||
// Collection Views
|
// Collection Views
|
||||||
handlers.AddHandler<CollectionView, CollectionViewHandler>();
|
handlers.AddHandler<CollectionView, CollectionViewHandler>();
|
||||||
handlers.AddHandler<ListView, CollectionViewHandler>();
|
handlers.AddHandler<ListView, CollectionViewHandler>();
|
||||||
|
|||||||
@@ -1,345 +0,0 @@
|
|||||||
// 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;
|
|
||||||
|
|
||||||
namespace Microsoft.Maui.Platform.Linux.Interop;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// P/Invoke bindings for WebKitGTK library.
|
|
||||||
/// WebKitGTK provides a full-featured web browser engine for Linux.
|
|
||||||
/// </summary>
|
|
||||||
public static class WebKitGtk
|
|
||||||
{
|
|
||||||
private const string WebKit2Lib = "libwebkit2gtk-4.1.so.0";
|
|
||||||
private const string GtkLib = "libgtk-3.so.0";
|
|
||||||
private const string GObjectLib = "libgobject-2.0.so.0";
|
|
||||||
private const string GLibLib = "libglib-2.0.so.0";
|
|
||||||
|
|
||||||
#region GTK Initialization
|
|
||||||
|
|
||||||
[DllImport(GtkLib, CallingConvention = CallingConvention.Cdecl)]
|
|
||||||
public static extern bool gtk_init_check(ref int argc, ref IntPtr argv);
|
|
||||||
|
|
||||||
[DllImport(GtkLib, CallingConvention = CallingConvention.Cdecl)]
|
|
||||||
public static extern void gtk_main();
|
|
||||||
|
|
||||||
[DllImport(GtkLib, CallingConvention = CallingConvention.Cdecl)]
|
|
||||||
public static extern void gtk_main_quit();
|
|
||||||
|
|
||||||
[DllImport(GtkLib, CallingConvention = CallingConvention.Cdecl)]
|
|
||||||
public static extern bool gtk_events_pending();
|
|
||||||
|
|
||||||
[DllImport(GtkLib, CallingConvention = CallingConvention.Cdecl)]
|
|
||||||
public static extern void gtk_main_iteration();
|
|
||||||
|
|
||||||
[DllImport(GtkLib, CallingConvention = CallingConvention.Cdecl)]
|
|
||||||
public static extern bool gtk_main_iteration_do(bool blocking);
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
#region GTK Window
|
|
||||||
|
|
||||||
[DllImport(GtkLib, CallingConvention = CallingConvention.Cdecl)]
|
|
||||||
public static extern IntPtr gtk_window_new(int type);
|
|
||||||
|
|
||||||
[DllImport(GtkLib, CallingConvention = CallingConvention.Cdecl)]
|
|
||||||
public static extern void gtk_window_set_default_size(IntPtr window, int width, int height);
|
|
||||||
|
|
||||||
[DllImport(GtkLib, CallingConvention = CallingConvention.Cdecl)]
|
|
||||||
public static extern void gtk_window_set_decorated(IntPtr window, bool decorated);
|
|
||||||
|
|
||||||
[DllImport(GtkLib, CallingConvention = CallingConvention.Cdecl)]
|
|
||||||
public static extern void gtk_window_move(IntPtr window, int x, int y);
|
|
||||||
|
|
||||||
[DllImport(GtkLib, CallingConvention = CallingConvention.Cdecl)]
|
|
||||||
public static extern void gtk_window_resize(IntPtr window, int width, int height);
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
#region GTK Widget
|
|
||||||
|
|
||||||
[DllImport(GtkLib, CallingConvention = CallingConvention.Cdecl)]
|
|
||||||
public static extern void gtk_widget_show_all(IntPtr widget);
|
|
||||||
|
|
||||||
[DllImport(GtkLib, CallingConvention = CallingConvention.Cdecl)]
|
|
||||||
public static extern void gtk_widget_show(IntPtr widget);
|
|
||||||
|
|
||||||
[DllImport(GtkLib, CallingConvention = CallingConvention.Cdecl)]
|
|
||||||
public static extern void gtk_widget_hide(IntPtr widget);
|
|
||||||
|
|
||||||
[DllImport(GtkLib, CallingConvention = CallingConvention.Cdecl)]
|
|
||||||
public static extern void gtk_widget_destroy(IntPtr widget);
|
|
||||||
|
|
||||||
[DllImport(GtkLib, CallingConvention = CallingConvention.Cdecl)]
|
|
||||||
public static extern void gtk_widget_set_size_request(IntPtr widget, int width, int height);
|
|
||||||
|
|
||||||
[DllImport(GtkLib, CallingConvention = CallingConvention.Cdecl)]
|
|
||||||
public static extern void gtk_widget_realize(IntPtr widget);
|
|
||||||
|
|
||||||
[DllImport(GtkLib, CallingConvention = CallingConvention.Cdecl)]
|
|
||||||
public static extern IntPtr gtk_widget_get_window(IntPtr widget);
|
|
||||||
|
|
||||||
[DllImport(GtkLib, CallingConvention = CallingConvention.Cdecl)]
|
|
||||||
public static extern void gtk_widget_set_can_focus(IntPtr widget, bool canFocus);
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
#region GTK Container
|
|
||||||
|
|
||||||
[DllImport(GtkLib, CallingConvention = CallingConvention.Cdecl)]
|
|
||||||
public static extern void gtk_container_add(IntPtr container, IntPtr widget);
|
|
||||||
|
|
||||||
[DllImport(GtkLib, CallingConvention = CallingConvention.Cdecl)]
|
|
||||||
public static extern void gtk_container_remove(IntPtr container, IntPtr widget);
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
#region GTK Plug (for embedding in X11 windows)
|
|
||||||
|
|
||||||
[DllImport(GtkLib, CallingConvention = CallingConvention.Cdecl)]
|
|
||||||
public static extern IntPtr gtk_plug_new(ulong socketId);
|
|
||||||
|
|
||||||
[DllImport(GtkLib, CallingConvention = CallingConvention.Cdecl)]
|
|
||||||
public static extern ulong gtk_plug_get_id(IntPtr plug);
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
#region WebKitWebView
|
|
||||||
|
|
||||||
[DllImport(WebKit2Lib, CallingConvention = CallingConvention.Cdecl)]
|
|
||||||
public static extern IntPtr webkit_web_view_new();
|
|
||||||
|
|
||||||
[DllImport(WebKit2Lib, CallingConvention = CallingConvention.Cdecl)]
|
|
||||||
public static extern IntPtr webkit_web_view_new_with_context(IntPtr context);
|
|
||||||
|
|
||||||
[DllImport(WebKit2Lib, CallingConvention = CallingConvention.Cdecl)]
|
|
||||||
public static extern void webkit_web_view_load_uri(IntPtr webView, [MarshalAs(UnmanagedType.LPUTF8Str)] string uri);
|
|
||||||
|
|
||||||
[DllImport(WebKit2Lib, CallingConvention = CallingConvention.Cdecl)]
|
|
||||||
public static extern void webkit_web_view_load_html(IntPtr webView,
|
|
||||||
[MarshalAs(UnmanagedType.LPUTF8Str)] string content,
|
|
||||||
[MarshalAs(UnmanagedType.LPUTF8Str)] string? baseUri);
|
|
||||||
|
|
||||||
[DllImport(WebKit2Lib, CallingConvention = CallingConvention.Cdecl)]
|
|
||||||
public static extern void webkit_web_view_reload(IntPtr webView);
|
|
||||||
|
|
||||||
[DllImport(WebKit2Lib, CallingConvention = CallingConvention.Cdecl)]
|
|
||||||
public static extern void webkit_web_view_stop_loading(IntPtr webView);
|
|
||||||
|
|
||||||
[DllImport(WebKit2Lib, CallingConvention = CallingConvention.Cdecl)]
|
|
||||||
public static extern void webkit_web_view_go_back(IntPtr webView);
|
|
||||||
|
|
||||||
[DllImport(WebKit2Lib, CallingConvention = CallingConvention.Cdecl)]
|
|
||||||
public static extern void webkit_web_view_go_forward(IntPtr webView);
|
|
||||||
|
|
||||||
[DllImport(WebKit2Lib, CallingConvention = CallingConvention.Cdecl)]
|
|
||||||
public static extern bool webkit_web_view_can_go_back(IntPtr webView);
|
|
||||||
|
|
||||||
[DllImport(WebKit2Lib, CallingConvention = CallingConvention.Cdecl)]
|
|
||||||
public static extern bool webkit_web_view_can_go_forward(IntPtr webView);
|
|
||||||
|
|
||||||
[DllImport(WebKit2Lib, CallingConvention = CallingConvention.Cdecl)]
|
|
||||||
public static extern IntPtr webkit_web_view_get_uri(IntPtr webView);
|
|
||||||
|
|
||||||
[DllImport(WebKit2Lib, CallingConvention = CallingConvention.Cdecl)]
|
|
||||||
public static extern IntPtr webkit_web_view_get_title(IntPtr webView);
|
|
||||||
|
|
||||||
[DllImport(WebKit2Lib, CallingConvention = CallingConvention.Cdecl)]
|
|
||||||
public static extern double webkit_web_view_get_estimated_load_progress(IntPtr webView);
|
|
||||||
|
|
||||||
[DllImport(WebKit2Lib, CallingConvention = CallingConvention.Cdecl)]
|
|
||||||
public static extern bool webkit_web_view_is_loading(IntPtr webView);
|
|
||||||
|
|
||||||
[DllImport(WebKit2Lib, CallingConvention = CallingConvention.Cdecl)]
|
|
||||||
public static extern void webkit_web_view_run_javascript(IntPtr webView,
|
|
||||||
[MarshalAs(UnmanagedType.LPUTF8Str)] string script,
|
|
||||||
IntPtr cancellable,
|
|
||||||
IntPtr callback,
|
|
||||||
IntPtr userData);
|
|
||||||
|
|
||||||
[DllImport(WebKit2Lib, CallingConvention = CallingConvention.Cdecl)]
|
|
||||||
public static extern IntPtr webkit_web_view_run_javascript_finish(IntPtr webView,
|
|
||||||
IntPtr result,
|
|
||||||
out IntPtr error);
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
#region WebKitSettings
|
|
||||||
|
|
||||||
[DllImport(WebKit2Lib, CallingConvention = CallingConvention.Cdecl)]
|
|
||||||
public static extern IntPtr webkit_web_view_get_settings(IntPtr webView);
|
|
||||||
|
|
||||||
[DllImport(WebKit2Lib, CallingConvention = CallingConvention.Cdecl)]
|
|
||||||
public static extern void webkit_settings_set_enable_javascript(IntPtr settings, bool enabled);
|
|
||||||
|
|
||||||
[DllImport(WebKit2Lib, CallingConvention = CallingConvention.Cdecl)]
|
|
||||||
public static extern void webkit_settings_set_user_agent(IntPtr settings,
|
|
||||||
[MarshalAs(UnmanagedType.LPUTF8Str)] string userAgent);
|
|
||||||
|
|
||||||
[DllImport(WebKit2Lib, CallingConvention = CallingConvention.Cdecl)]
|
|
||||||
public static extern IntPtr webkit_settings_get_user_agent(IntPtr settings);
|
|
||||||
|
|
||||||
[DllImport(WebKit2Lib, CallingConvention = CallingConvention.Cdecl)]
|
|
||||||
public static extern void webkit_settings_set_enable_developer_extras(IntPtr settings, bool enabled);
|
|
||||||
|
|
||||||
[DllImport(WebKit2Lib, CallingConvention = CallingConvention.Cdecl)]
|
|
||||||
public static extern void webkit_settings_set_javascript_can_access_clipboard(IntPtr settings, bool enabled);
|
|
||||||
|
|
||||||
[DllImport(WebKit2Lib, CallingConvention = CallingConvention.Cdecl)]
|
|
||||||
public static extern void webkit_settings_set_enable_webgl(IntPtr settings, bool enabled);
|
|
||||||
|
|
||||||
[DllImport(WebKit2Lib, CallingConvention = CallingConvention.Cdecl)]
|
|
||||||
public static extern void webkit_settings_set_allow_file_access_from_file_urls(IntPtr settings, bool enabled);
|
|
||||||
|
|
||||||
[DllImport(WebKit2Lib, CallingConvention = CallingConvention.Cdecl)]
|
|
||||||
public static extern void webkit_settings_set_allow_universal_access_from_file_urls(IntPtr settings, bool enabled);
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
#region WebKitWebContext
|
|
||||||
|
|
||||||
[DllImport(WebKit2Lib, CallingConvention = CallingConvention.Cdecl)]
|
|
||||||
public static extern IntPtr webkit_web_context_get_default();
|
|
||||||
|
|
||||||
[DllImport(WebKit2Lib, CallingConvention = CallingConvention.Cdecl)]
|
|
||||||
public static extern IntPtr webkit_web_context_new();
|
|
||||||
|
|
||||||
[DllImport(WebKit2Lib, CallingConvention = CallingConvention.Cdecl)]
|
|
||||||
public static extern IntPtr webkit_web_context_get_cookie_manager(IntPtr context);
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
#region WebKitCookieManager
|
|
||||||
|
|
||||||
[DllImport(WebKit2Lib, CallingConvention = CallingConvention.Cdecl)]
|
|
||||||
public static extern void webkit_cookie_manager_set_accept_policy(IntPtr cookieManager, int policy);
|
|
||||||
|
|
||||||
[DllImport(WebKit2Lib, CallingConvention = CallingConvention.Cdecl)]
|
|
||||||
public static extern void webkit_cookie_manager_set_persistent_storage(IntPtr cookieManager,
|
|
||||||
[MarshalAs(UnmanagedType.LPUTF8Str)] string filename,
|
|
||||||
int storage);
|
|
||||||
|
|
||||||
// Cookie accept policies
|
|
||||||
public const int WEBKIT_COOKIE_POLICY_ACCEPT_ALWAYS = 0;
|
|
||||||
public const int WEBKIT_COOKIE_POLICY_ACCEPT_NEVER = 1;
|
|
||||||
public const int WEBKIT_COOKIE_POLICY_ACCEPT_NO_THIRD_PARTY = 2;
|
|
||||||
|
|
||||||
// Cookie persistent storage types
|
|
||||||
public const int WEBKIT_COOKIE_PERSISTENT_STORAGE_TEXT = 0;
|
|
||||||
public const int WEBKIT_COOKIE_PERSISTENT_STORAGE_SQLITE = 1;
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
#region WebKitNavigationAction
|
|
||||||
|
|
||||||
[DllImport(WebKit2Lib, CallingConvention = CallingConvention.Cdecl)]
|
|
||||||
public static extern IntPtr webkit_navigation_action_get_request(IntPtr action);
|
|
||||||
|
|
||||||
[DllImport(WebKit2Lib, CallingConvention = CallingConvention.Cdecl)]
|
|
||||||
public static extern int webkit_navigation_action_get_navigation_type(IntPtr action);
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
#region WebKitURIRequest
|
|
||||||
|
|
||||||
[DllImport(WebKit2Lib, CallingConvention = CallingConvention.Cdecl)]
|
|
||||||
public static extern IntPtr webkit_uri_request_get_uri(IntPtr request);
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
#region WebKitPolicyDecision
|
|
||||||
|
|
||||||
[DllImport(WebKit2Lib, CallingConvention = CallingConvention.Cdecl)]
|
|
||||||
public static extern void webkit_policy_decision_use(IntPtr decision);
|
|
||||||
|
|
||||||
[DllImport(WebKit2Lib, CallingConvention = CallingConvention.Cdecl)]
|
|
||||||
public static extern void webkit_policy_decision_ignore(IntPtr decision);
|
|
||||||
|
|
||||||
[DllImport(WebKit2Lib, CallingConvention = CallingConvention.Cdecl)]
|
|
||||||
public static extern void webkit_policy_decision_download(IntPtr decision);
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
#region GObject Signal Connection
|
|
||||||
|
|
||||||
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
|
||||||
public delegate void GCallback();
|
|
||||||
|
|
||||||
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
|
||||||
public delegate void LoadChangedCallback(IntPtr webView, int loadEvent, IntPtr userData);
|
|
||||||
|
|
||||||
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
|
||||||
public delegate bool DecidePolicyCallback(IntPtr webView, IntPtr decision, int decisionType, IntPtr userData);
|
|
||||||
|
|
||||||
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
|
||||||
public delegate void LoadFailedCallback(IntPtr webView, int loadEvent, IntPtr failingUri, IntPtr error, IntPtr userData);
|
|
||||||
|
|
||||||
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
|
||||||
public delegate void NotifyCallback(IntPtr webView, IntPtr paramSpec, IntPtr userData);
|
|
||||||
|
|
||||||
[DllImport(GObjectLib, CallingConvention = CallingConvention.Cdecl)]
|
|
||||||
public static extern ulong g_signal_connect_data(IntPtr instance,
|
|
||||||
[MarshalAs(UnmanagedType.LPUTF8Str)] string detailedSignal,
|
|
||||||
Delegate handler,
|
|
||||||
IntPtr data,
|
|
||||||
IntPtr destroyData,
|
|
||||||
int connectFlags);
|
|
||||||
|
|
||||||
[DllImport(GObjectLib, CallingConvention = CallingConvention.Cdecl)]
|
|
||||||
public static extern void g_signal_handler_disconnect(IntPtr instance, ulong handlerId);
|
|
||||||
|
|
||||||
[DllImport(GObjectLib, CallingConvention = CallingConvention.Cdecl)]
|
|
||||||
public static extern void g_object_unref(IntPtr obj);
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
#region GLib Memory
|
|
||||||
|
|
||||||
[DllImport(GLibLib, CallingConvention = CallingConvention.Cdecl)]
|
|
||||||
public static extern void g_free(IntPtr mem);
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
#region WebKit Load Events
|
|
||||||
|
|
||||||
public const int WEBKIT_LOAD_STARTED = 0;
|
|
||||||
public const int WEBKIT_LOAD_REDIRECTED = 1;
|
|
||||||
public const int WEBKIT_LOAD_COMMITTED = 2;
|
|
||||||
public const int WEBKIT_LOAD_FINISHED = 3;
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
#region WebKit Policy Decision Types
|
|
||||||
|
|
||||||
public const int WEBKIT_POLICY_DECISION_TYPE_NAVIGATION_ACTION = 0;
|
|
||||||
public const int WEBKIT_POLICY_DECISION_TYPE_NEW_WINDOW_ACTION = 1;
|
|
||||||
public const int WEBKIT_POLICY_DECISION_TYPE_RESPONSE = 2;
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
#region Helper Methods
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Converts a native UTF-8 string pointer to a managed string.
|
|
||||||
/// </summary>
|
|
||||||
public static string? PtrToStringUtf8(IntPtr ptr)
|
|
||||||
{
|
|
||||||
if (ptr == IntPtr.Zero)
|
|
||||||
return null;
|
|
||||||
return Marshal.PtrToStringUTF8(ptr);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Processes pending GTK events without blocking.
|
|
||||||
/// </summary>
|
|
||||||
public static void ProcessGtkEvents()
|
|
||||||
{
|
|
||||||
while (gtk_events_pending())
|
|
||||||
{
|
|
||||||
gtk_main_iteration_do(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
}
|
|
||||||
@@ -46,6 +46,9 @@
|
|||||||
<!-- HarfBuzz for advanced text shaping -->
|
<!-- HarfBuzz for advanced text shaping -->
|
||||||
<PackageReference Include="HarfBuzzSharp" Version="7.3.0.3" />
|
<PackageReference Include="HarfBuzzSharp" Version="7.3.0.3" />
|
||||||
<PackageReference Include="HarfBuzzSharp.NativeAssets.Linux" Version="7.3.0.3" />
|
<PackageReference Include="HarfBuzzSharp.NativeAssets.Linux" Version="7.3.0.3" />
|
||||||
|
|
||||||
|
<!-- SVG support for icon loading -->
|
||||||
|
<PackageReference Include="Svg.Skia" Version="2.0.0.4" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<!-- Include README and icon in package -->
|
<!-- Include README and icon in package -->
|
||||||
|
|||||||
@@ -1,349 +0,0 @@
|
|||||||
// 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 Microsoft.Maui.Platform.Linux.Window;
|
|
||||||
using System.Runtime.InteropServices;
|
|
||||||
|
|
||||||
namespace Microsoft.Maui.Platform.Linux.Rendering;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// GPU-accelerated rendering engine using OpenGL.
|
|
||||||
/// Falls back to software rendering if GPU initialization fails.
|
|
||||||
/// </summary>
|
|
||||||
public class GpuRenderingEngine : IDisposable
|
|
||||||
{
|
|
||||||
private readonly X11Window _window;
|
|
||||||
private GRContext? _grContext;
|
|
||||||
private GRBackendRenderTarget? _renderTarget;
|
|
||||||
private SKSurface? _surface;
|
|
||||||
private SKCanvas? _canvas;
|
|
||||||
private bool _disposed;
|
|
||||||
private bool _gpuAvailable;
|
|
||||||
private int _width;
|
|
||||||
private int _height;
|
|
||||||
|
|
||||||
// Fallback to software rendering
|
|
||||||
private SKBitmap? _softwareBitmap;
|
|
||||||
private SKCanvas? _softwareCanvas;
|
|
||||||
|
|
||||||
// Dirty region tracking
|
|
||||||
private readonly List<SKRect> _dirtyRegions = new();
|
|
||||||
private readonly object _dirtyLock = new();
|
|
||||||
private bool _fullRedrawNeeded = true;
|
|
||||||
private const int MaxDirtyRegions = 32;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets whether GPU acceleration is available and active.
|
|
||||||
/// </summary>
|
|
||||||
public bool IsGpuAccelerated => _gpuAvailable && _grContext != null;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the current rendering backend name.
|
|
||||||
/// </summary>
|
|
||||||
public string BackendName => IsGpuAccelerated ? "OpenGL" : "Software";
|
|
||||||
|
|
||||||
public int Width => _width;
|
|
||||||
public int Height => _height;
|
|
||||||
|
|
||||||
public GpuRenderingEngine(X11Window window)
|
|
||||||
{
|
|
||||||
_window = window;
|
|
||||||
_width = window.Width;
|
|
||||||
_height = window.Height;
|
|
||||||
|
|
||||||
// Try to initialize GPU rendering
|
|
||||||
_gpuAvailable = TryInitializeGpu();
|
|
||||||
|
|
||||||
if (!_gpuAvailable)
|
|
||||||
{
|
|
||||||
Console.WriteLine("[GpuRenderingEngine] GPU not available, using software rendering");
|
|
||||||
InitializeSoftwareRendering();
|
|
||||||
}
|
|
||||||
|
|
||||||
_window.Resized += OnWindowResized;
|
|
||||||
_window.Exposed += OnWindowExposed;
|
|
||||||
}
|
|
||||||
|
|
||||||
private bool TryInitializeGpu()
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// Check if we can create an OpenGL context
|
|
||||||
var glInterface = GRGlInterface.Create();
|
|
||||||
if (glInterface == null)
|
|
||||||
{
|
|
||||||
Console.WriteLine("[GpuRenderingEngine] Failed to create GL interface");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
_grContext = GRContext.CreateGl(glInterface);
|
|
||||||
if (_grContext == null)
|
|
||||||
{
|
|
||||||
Console.WriteLine("[GpuRenderingEngine] Failed to create GR context");
|
|
||||||
glInterface.Dispose();
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
CreateGpuSurface();
|
|
||||||
Console.WriteLine("[GpuRenderingEngine] GPU acceleration enabled");
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Console.WriteLine($"[GpuRenderingEngine] GPU initialization failed: {ex.Message}");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void CreateGpuSurface()
|
|
||||||
{
|
|
||||||
if (_grContext == null) return;
|
|
||||||
|
|
||||||
_renderTarget?.Dispose();
|
|
||||||
_surface?.Dispose();
|
|
||||||
|
|
||||||
var width = Math.Max(1, _width);
|
|
||||||
var height = Math.Max(1, _height);
|
|
||||||
|
|
||||||
// Create framebuffer info (assuming default framebuffer 0)
|
|
||||||
var framebufferInfo = new GRGlFramebufferInfo(0, SKColorType.Rgba8888.ToGlSizedFormat());
|
|
||||||
|
|
||||||
_renderTarget = new GRBackendRenderTarget(
|
|
||||||
width, height,
|
|
||||||
0, // sample count
|
|
||||||
8, // stencil bits
|
|
||||||
framebufferInfo);
|
|
||||||
|
|
||||||
_surface = SKSurface.Create(
|
|
||||||
_grContext,
|
|
||||||
_renderTarget,
|
|
||||||
GRSurfaceOrigin.BottomLeft,
|
|
||||||
SKColorType.Rgba8888);
|
|
||||||
|
|
||||||
if (_surface == null)
|
|
||||||
{
|
|
||||||
Console.WriteLine("[GpuRenderingEngine] Failed to create GPU surface, falling back to software");
|
|
||||||
_gpuAvailable = false;
|
|
||||||
InitializeSoftwareRendering();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_canvas = _surface.Canvas;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void InitializeSoftwareRendering()
|
|
||||||
{
|
|
||||||
var width = Math.Max(1, _width);
|
|
||||||
var height = Math.Max(1, _height);
|
|
||||||
|
|
||||||
_softwareBitmap?.Dispose();
|
|
||||||
_softwareCanvas?.Dispose();
|
|
||||||
|
|
||||||
var imageInfo = new SKImageInfo(width, height, SKColorType.Bgra8888, SKAlphaType.Premul);
|
|
||||||
_softwareBitmap = new SKBitmap(imageInfo);
|
|
||||||
_softwareCanvas = new SKCanvas(_softwareBitmap);
|
|
||||||
_canvas = _softwareCanvas;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnWindowResized(object? sender, (int Width, int Height) size)
|
|
||||||
{
|
|
||||||
_width = size.Width;
|
|
||||||
_height = size.Height;
|
|
||||||
|
|
||||||
if (_gpuAvailable && _grContext != null)
|
|
||||||
{
|
|
||||||
CreateGpuSurface();
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
InitializeSoftwareRendering();
|
|
||||||
}
|
|
||||||
|
|
||||||
_fullRedrawNeeded = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnWindowExposed(object? sender, EventArgs e)
|
|
||||||
{
|
|
||||||
_fullRedrawNeeded = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Marks a region as needing redraw.
|
|
||||||
/// </summary>
|
|
||||||
public void InvalidateRegion(SKRect region)
|
|
||||||
{
|
|
||||||
if (region.IsEmpty || region.Width <= 0 || region.Height <= 0)
|
|
||||||
return;
|
|
||||||
|
|
||||||
region = SKRect.Intersect(region, new SKRect(0, 0, Width, Height));
|
|
||||||
if (region.IsEmpty) return;
|
|
||||||
|
|
||||||
lock (_dirtyLock)
|
|
||||||
{
|
|
||||||
if (_dirtyRegions.Count >= MaxDirtyRegions)
|
|
||||||
{
|
|
||||||
_fullRedrawNeeded = true;
|
|
||||||
_dirtyRegions.Clear();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
_dirtyRegions.Add(region);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Marks the entire surface as needing redraw.
|
|
||||||
/// </summary>
|
|
||||||
public void InvalidateAll()
|
|
||||||
{
|
|
||||||
_fullRedrawNeeded = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Renders the view tree with dirty region optimization.
|
|
||||||
/// </summary>
|
|
||||||
public void Render(SkiaView rootView)
|
|
||||||
{
|
|
||||||
if (_canvas == null) return;
|
|
||||||
|
|
||||||
// Measure and arrange
|
|
||||||
var availableSize = new SKSize(Width, Height);
|
|
||||||
rootView.Measure(availableSize);
|
|
||||||
rootView.Arrange(new SKRect(0, 0, Width, Height));
|
|
||||||
|
|
||||||
// Determine regions to redraw
|
|
||||||
List<SKRect> regionsToRedraw;
|
|
||||||
bool isFullRedraw;
|
|
||||||
|
|
||||||
lock (_dirtyLock)
|
|
||||||
{
|
|
||||||
isFullRedraw = _fullRedrawNeeded || _dirtyRegions.Count == 0;
|
|
||||||
if (isFullRedraw)
|
|
||||||
{
|
|
||||||
regionsToRedraw = new List<SKRect> { new SKRect(0, 0, Width, Height) };
|
|
||||||
_dirtyRegions.Clear();
|
|
||||||
_fullRedrawNeeded = false;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
regionsToRedraw = new List<SKRect>(_dirtyRegions);
|
|
||||||
_dirtyRegions.Clear();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Render each dirty region
|
|
||||||
foreach (var region in regionsToRedraw)
|
|
||||||
{
|
|
||||||
_canvas.Save();
|
|
||||||
if (!isFullRedraw)
|
|
||||||
{
|
|
||||||
_canvas.ClipRect(region);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear region
|
|
||||||
_canvas.Clear(SKColors.White);
|
|
||||||
|
|
||||||
// Draw view tree
|
|
||||||
rootView.Draw(_canvas);
|
|
||||||
|
|
||||||
_canvas.Restore();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Draw popup overlays
|
|
||||||
SkiaView.DrawPopupOverlays(_canvas);
|
|
||||||
|
|
||||||
// Draw modal dialogs
|
|
||||||
if (LinuxDialogService.HasActiveDialog)
|
|
||||||
{
|
|
||||||
LinuxDialogService.DrawDialogs(_canvas, new SKRect(0, 0, Width, Height));
|
|
||||||
}
|
|
||||||
|
|
||||||
_canvas.Flush();
|
|
||||||
|
|
||||||
// Present to window
|
|
||||||
if (_gpuAvailable && _grContext != null)
|
|
||||||
{
|
|
||||||
_grContext.Submit();
|
|
||||||
// Swap buffers would happen here via GLX/EGL
|
|
||||||
}
|
|
||||||
else if (_softwareBitmap != null)
|
|
||||||
{
|
|
||||||
var pixels = _softwareBitmap.GetPixels();
|
|
||||||
if (pixels != IntPtr.Zero)
|
|
||||||
{
|
|
||||||
_window.DrawPixels(pixels, Width, Height, _softwareBitmap.RowBytes);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets performance statistics for the GPU context.
|
|
||||||
/// </summary>
|
|
||||||
public GpuStats GetStats()
|
|
||||||
{
|
|
||||||
if (_grContext == null)
|
|
||||||
{
|
|
||||||
return new GpuStats { IsGpuAccelerated = false };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get resource cache limits from GRContext
|
|
||||||
_grContext.GetResourceCacheLimits(out var maxResources, out var maxBytes);
|
|
||||||
|
|
||||||
return new GpuStats
|
|
||||||
{
|
|
||||||
IsGpuAccelerated = true,
|
|
||||||
MaxTextureSize = 4096, // Common default, SkiaSharp doesn't expose this directly
|
|
||||||
ResourceCacheUsedBytes = 0, // Would need to track manually
|
|
||||||
ResourceCacheLimitBytes = maxBytes
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Purges unused GPU resources to free memory.
|
|
||||||
/// </summary>
|
|
||||||
public void PurgeResources()
|
|
||||||
{
|
|
||||||
_grContext?.PurgeResources();
|
|
||||||
}
|
|
||||||
|
|
||||||
public SKCanvas? GetCanvas() => _canvas;
|
|
||||||
|
|
||||||
protected virtual void Dispose(bool disposing)
|
|
||||||
{
|
|
||||||
if (!_disposed)
|
|
||||||
{
|
|
||||||
if (disposing)
|
|
||||||
{
|
|
||||||
_window.Resized -= OnWindowResized;
|
|
||||||
_window.Exposed -= OnWindowExposed;
|
|
||||||
|
|
||||||
_surface?.Dispose();
|
|
||||||
_renderTarget?.Dispose();
|
|
||||||
_grContext?.Dispose();
|
|
||||||
_softwareBitmap?.Dispose();
|
|
||||||
_softwareCanvas?.Dispose();
|
|
||||||
}
|
|
||||||
_disposed = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Dispose()
|
|
||||||
{
|
|
||||||
Dispose(true);
|
|
||||||
GC.SuppressFinalize(this);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// GPU performance statistics.
|
|
||||||
/// </summary>
|
|
||||||
public class GpuStats
|
|
||||||
{
|
|
||||||
public bool IsGpuAccelerated { get; init; }
|
|
||||||
public int MaxTextureSize { get; init; }
|
|
||||||
public long ResourceCacheUsedBytes { get; init; }
|
|
||||||
public long ResourceCacheLimitBytes { get; init; }
|
|
||||||
|
|
||||||
public double ResourceCacheUsedMB => ResourceCacheUsedBytes / (1024.0 * 1024.0);
|
|
||||||
public double ResourceCacheLimitMB => ResourceCacheLimitBytes / (1024.0 * 1024.0);
|
|
||||||
}
|
|
||||||
@@ -9,43 +9,22 @@ using System.Runtime.InteropServices;
|
|||||||
namespace Microsoft.Maui.Platform.Linux.Rendering;
|
namespace Microsoft.Maui.Platform.Linux.Rendering;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Manages Skia rendering to an X11 window with dirty region optimization.
|
/// Manages Skia rendering to an X11 window.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class SkiaRenderingEngine : IDisposable
|
public class SkiaRenderingEngine : IDisposable
|
||||||
{
|
{
|
||||||
private readonly X11Window _window;
|
private readonly X11Window _window;
|
||||||
private SKBitmap? _bitmap;
|
private SKBitmap? _bitmap;
|
||||||
private SKBitmap? _backBuffer;
|
|
||||||
private SKCanvas? _canvas;
|
private SKCanvas? _canvas;
|
||||||
private SKImageInfo _imageInfo;
|
private SKImageInfo _imageInfo;
|
||||||
private bool _disposed;
|
private bool _disposed;
|
||||||
private bool _fullRedrawNeeded = true;
|
private bool _fullRedrawNeeded = true;
|
||||||
|
|
||||||
// Dirty region tracking for optimized rendering
|
|
||||||
private readonly List<SKRect> _dirtyRegions = new();
|
|
||||||
private readonly object _dirtyLock = new();
|
|
||||||
private const int MaxDirtyRegions = 32;
|
|
||||||
private const float RegionMergeThreshold = 0.3f; // Merge if overlap > 30%
|
|
||||||
|
|
||||||
public static SkiaRenderingEngine? Current { get; private set; }
|
public static SkiaRenderingEngine? Current { get; private set; }
|
||||||
public ResourceCache ResourceCache { get; }
|
public ResourceCache ResourceCache { get; }
|
||||||
public int Width => _imageInfo.Width;
|
public int Width => _imageInfo.Width;
|
||||||
public int Height => _imageInfo.Height;
|
public int Height => _imageInfo.Height;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets whether dirty region optimization is enabled.
|
|
||||||
/// When disabled, full redraws occur (useful for debugging).
|
|
||||||
/// </summary>
|
|
||||||
public bool EnableDirtyRegionOptimization { get; set; } = true;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the number of dirty regions in the current frame.
|
|
||||||
/// </summary>
|
|
||||||
public int DirtyRegionCount
|
|
||||||
{
|
|
||||||
get { lock (_dirtyLock) return _dirtyRegions.Count; }
|
|
||||||
}
|
|
||||||
|
|
||||||
public SkiaRenderingEngine(X11Window window)
|
public SkiaRenderingEngine(X11Window window)
|
||||||
{
|
{
|
||||||
_window = window;
|
_window = window;
|
||||||
@@ -61,7 +40,6 @@ public class SkiaRenderingEngine : IDisposable
|
|||||||
private void CreateSurface(int width, int height)
|
private void CreateSurface(int width, int height)
|
||||||
{
|
{
|
||||||
_bitmap?.Dispose();
|
_bitmap?.Dispose();
|
||||||
_backBuffer?.Dispose();
|
|
||||||
_canvas?.Dispose();
|
_canvas?.Dispose();
|
||||||
|
|
||||||
_imageInfo = new SKImageInfo(
|
_imageInfo = new SKImageInfo(
|
||||||
@@ -71,14 +49,9 @@ public class SkiaRenderingEngine : IDisposable
|
|||||||
SKAlphaType.Premul);
|
SKAlphaType.Premul);
|
||||||
|
|
||||||
_bitmap = new SKBitmap(_imageInfo);
|
_bitmap = new SKBitmap(_imageInfo);
|
||||||
_backBuffer = new SKBitmap(_imageInfo);
|
|
||||||
_canvas = new SKCanvas(_bitmap);
|
_canvas = new SKCanvas(_bitmap);
|
||||||
_fullRedrawNeeded = true;
|
_fullRedrawNeeded = true;
|
||||||
|
|
||||||
lock (_dirtyLock)
|
|
||||||
{
|
|
||||||
_dirtyRegions.Clear();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnWindowResized(object? sender, (int Width, int Height) size)
|
private void OnWindowResized(object? sender, (int Width, int Height) size)
|
||||||
@@ -91,117 +64,28 @@ public class SkiaRenderingEngine : IDisposable
|
|||||||
_fullRedrawNeeded = true;
|
_fullRedrawNeeded = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Marks the entire surface as needing redraw.
|
|
||||||
/// </summary>
|
|
||||||
public void InvalidateAll()
|
public void InvalidateAll()
|
||||||
{
|
{
|
||||||
_fullRedrawNeeded = true;
|
_fullRedrawNeeded = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Marks a specific region as needing redraw.
|
|
||||||
/// Multiple regions are tracked and merged for efficiency.
|
|
||||||
/// </summary>
|
|
||||||
public void InvalidateRegion(SKRect region)
|
|
||||||
{
|
|
||||||
if (region.IsEmpty || region.Width <= 0 || region.Height <= 0)
|
|
||||||
return;
|
|
||||||
|
|
||||||
// Clamp to surface bounds
|
|
||||||
region = SKRect.Intersect(region, new SKRect(0, 0, Width, Height));
|
|
||||||
if (region.IsEmpty)
|
|
||||||
return;
|
|
||||||
|
|
||||||
lock (_dirtyLock)
|
|
||||||
{
|
|
||||||
// If we have too many regions, just do a full redraw
|
|
||||||
if (_dirtyRegions.Count >= MaxDirtyRegions)
|
|
||||||
{
|
|
||||||
_fullRedrawNeeded = true;
|
|
||||||
_dirtyRegions.Clear();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to merge with existing regions
|
|
||||||
for (int i = 0; i < _dirtyRegions.Count; i++)
|
|
||||||
{
|
|
||||||
var existing = _dirtyRegions[i];
|
|
||||||
if (ShouldMergeRegions(existing, region))
|
|
||||||
{
|
|
||||||
_dirtyRegions[i] = SKRect.Union(existing, region);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_dirtyRegions.Add(region);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private bool ShouldMergeRegions(SKRect a, SKRect b)
|
|
||||||
{
|
|
||||||
// Check if regions overlap
|
|
||||||
var intersection = SKRect.Intersect(a, b);
|
|
||||||
if (intersection.IsEmpty)
|
|
||||||
{
|
|
||||||
// Check if they're adjacent (within a few pixels)
|
|
||||||
var expanded = new SKRect(a.Left - 4, a.Top - 4, a.Right + 4, a.Bottom + 4);
|
|
||||||
return expanded.IntersectsWith(b);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Merge if intersection is significant relative to either region
|
|
||||||
var intersectionArea = intersection.Width * intersection.Height;
|
|
||||||
var aArea = a.Width * a.Height;
|
|
||||||
var bArea = b.Width * b.Height;
|
|
||||||
var minArea = Math.Min(aArea, bArea);
|
|
||||||
|
|
||||||
return intersectionArea / minArea >= RegionMergeThreshold;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Renders the view tree, optionally using dirty region optimization.
|
|
||||||
/// </summary>
|
|
||||||
public void Render(SkiaView rootView)
|
public void Render(SkiaView rootView)
|
||||||
{
|
{
|
||||||
if (_canvas == null || _bitmap == null)
|
if (_canvas == null || _bitmap == null)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
// Measure and arrange
|
_canvas.Clear(SKColors.White);
|
||||||
|
|
||||||
|
// Measure first, then arrange
|
||||||
var availableSize = new SKSize(Width, Height);
|
var availableSize = new SKSize(Width, Height);
|
||||||
rootView.Measure(availableSize);
|
rootView.Measure(availableSize);
|
||||||
|
|
||||||
rootView.Arrange(new SKRect(0, 0, Width, Height));
|
rootView.Arrange(new SKRect(0, 0, Width, Height));
|
||||||
|
|
||||||
// Determine what to redraw
|
// Draw the view tree
|
||||||
List<SKRect> regionsToRedraw;
|
rootView.Draw(_canvas);
|
||||||
bool isFullRedraw = _fullRedrawNeeded || !EnableDirtyRegionOptimization;
|
|
||||||
|
// Draw popup overlays (dropdowns, calendars, etc.) on top
|
||||||
lock (_dirtyLock)
|
|
||||||
{
|
|
||||||
if (isFullRedraw)
|
|
||||||
{
|
|
||||||
regionsToRedraw = new List<SKRect> { new SKRect(0, 0, Width, Height) };
|
|
||||||
_dirtyRegions.Clear();
|
|
||||||
_fullRedrawNeeded = false;
|
|
||||||
}
|
|
||||||
else if (_dirtyRegions.Count == 0)
|
|
||||||
{
|
|
||||||
// Nothing to redraw
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
regionsToRedraw = MergeOverlappingRegions(_dirtyRegions.ToList());
|
|
||||||
_dirtyRegions.Clear();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Render dirty regions
|
|
||||||
foreach (var region in regionsToRedraw)
|
|
||||||
{
|
|
||||||
RenderRegion(rootView, region, isFullRedraw);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Draw popup overlays (always on top, full redraw)
|
|
||||||
SkiaView.DrawPopupOverlays(_canvas);
|
SkiaView.DrawPopupOverlays(_canvas);
|
||||||
|
|
||||||
// Draw modal dialogs on top of everything
|
// Draw modal dialogs on top of everything
|
||||||
@@ -216,67 +100,6 @@ public class SkiaRenderingEngine : IDisposable
|
|||||||
PresentToWindow();
|
PresentToWindow();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void RenderRegion(SkiaView rootView, SKRect region, bool isFullRedraw)
|
|
||||||
{
|
|
||||||
if (_canvas == null) return;
|
|
||||||
|
|
||||||
_canvas.Save();
|
|
||||||
|
|
||||||
if (!isFullRedraw)
|
|
||||||
{
|
|
||||||
// Clip to dirty region for partial updates
|
|
||||||
_canvas.ClipRect(region);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear the region
|
|
||||||
using var clearPaint = new SKPaint { Color = SKColors.White, Style = SKPaintStyle.Fill };
|
|
||||||
_canvas.DrawRect(region, clearPaint);
|
|
||||||
|
|
||||||
// Draw the view tree (views will naturally clip to their bounds)
|
|
||||||
rootView.Draw(_canvas);
|
|
||||||
|
|
||||||
_canvas.Restore();
|
|
||||||
}
|
|
||||||
|
|
||||||
private List<SKRect> MergeOverlappingRegions(List<SKRect> regions)
|
|
||||||
{
|
|
||||||
if (regions.Count <= 1)
|
|
||||||
return regions;
|
|
||||||
|
|
||||||
var merged = new List<SKRect>();
|
|
||||||
var used = new bool[regions.Count];
|
|
||||||
|
|
||||||
for (int i = 0; i < regions.Count; i++)
|
|
||||||
{
|
|
||||||
if (used[i]) continue;
|
|
||||||
|
|
||||||
var current = regions[i];
|
|
||||||
used[i] = true;
|
|
||||||
|
|
||||||
// Keep merging until no more merges possible
|
|
||||||
bool didMerge;
|
|
||||||
do
|
|
||||||
{
|
|
||||||
didMerge = false;
|
|
||||||
for (int j = i + 1; j < regions.Count; j++)
|
|
||||||
{
|
|
||||||
if (used[j]) continue;
|
|
||||||
|
|
||||||
if (ShouldMergeRegions(current, regions[j]))
|
|
||||||
{
|
|
||||||
current = SKRect.Union(current, regions[j]);
|
|
||||||
used[j] = true;
|
|
||||||
didMerge = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} while (didMerge);
|
|
||||||
|
|
||||||
merged.Add(current);
|
|
||||||
}
|
|
||||||
|
|
||||||
return merged;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void PresentToWindow()
|
private void PresentToWindow()
|
||||||
{
|
{
|
||||||
if (_bitmap == null) return;
|
if (_bitmap == null) return;
|
||||||
@@ -299,7 +122,6 @@ public class SkiaRenderingEngine : IDisposable
|
|||||||
_window.Exposed -= OnWindowExposed;
|
_window.Exposed -= OnWindowExposed;
|
||||||
_canvas?.Dispose();
|
_canvas?.Dispose();
|
||||||
_bitmap?.Dispose();
|
_bitmap?.Dispose();
|
||||||
_backBuffer?.Dispose();
|
|
||||||
ResourceCache.Dispose();
|
ResourceCache.Dispose();
|
||||||
if (Current == this) Current = null;
|
if (Current == this) Current = null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,326 +0,0 @@
|
|||||||
// 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;
|
|
||||||
|
|
||||||
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;
|
|
||||||
|
|
||||||
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
|
|
||||||
{
|
|
||||||
// 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\" \"\"");
|
|
||||||
|
|
||||||
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 void StartMonitoring()
|
|
||||||
{
|
|
||||||
if (string.IsNullOrEmpty(_inputContextPath)) return;
|
|
||||||
|
|
||||||
Task.Run(async () =>
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var startInfo = new ProcessStartInfo
|
|
||||||
{
|
|
||||||
FileName = "dbus-monitor",
|
|
||||||
Arguments = $"--session \"path='{_inputContextPath}'\"",
|
|
||||||
UseShellExecute = false,
|
|
||||||
RedirectStandardOutput = true,
|
|
||||||
CreateNoWindow = true
|
|
||||||
};
|
|
||||||
|
|
||||||
_dBusMonitor = Process.Start(startInfo);
|
|
||||||
if (_dBusMonitor == null) return;
|
|
||||||
|
|
||||||
var reader = _dBusMonitor.StandardOutput;
|
|
||||||
while (!_disposed && !_dBusMonitor.HasExited)
|
|
||||||
{
|
|
||||||
var line = await reader.ReadLineAsync();
|
|
||||||
if (line == null) break;
|
|
||||||
|
|
||||||
// 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}");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task ProcessCommitSignal(StreamReader reader)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
for (int i = 0; i < 5; i++)
|
|
||||||
{
|
|
||||||
var line = await reader.ReadLineAsync();
|
|
||||||
if (line == null) break;
|
|
||||||
|
|
||||||
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;
|
|
||||||
|
|
||||||
TextCommitted?.Invoke(this, new TextCommittedEventArgs(text));
|
|
||||||
_currentContext?.OnTextCommitted(text);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch { }
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task ProcessPreeditSignal(StreamReader reader)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
for (int i = 0; i < 10; i++)
|
|
||||||
{
|
|
||||||
var line = await reader.ReadLineAsync();
|
|
||||||
if (line == null) break;
|
|
||||||
|
|
||||||
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);
|
|
||||||
|
|
||||||
PreEditChanged?.Invoke(this, new PreEditChangedEventArgs(_preEditText, _preEditCursorPosition, new List<PreEditAttribute>()));
|
|
||||||
_currentContext?.OnPreEditChanged(_preEditText, _preEditCursorPosition);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch { }
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
|
|
||||||
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 state = ConvertModifiers(modifiers);
|
|
||||||
if (!isKeyDown) state |= 0x40000000; // Release flag
|
|
||||||
|
|
||||||
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");
|
|
||||||
|
|
||||||
return result?.Contains("true") == true;
|
|
||||||
}
|
|
||||||
|
|
||||||
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 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,310 +0,0 @@
|
|||||||
// Licensed to the .NET Foundation under one or more agreements.
|
|
||||||
// The .NET Foundation licenses this file to you under the MIT license.
|
|
||||||
|
|
||||||
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();
|
|
||||||
|
|
||||||
/// <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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback font chain ordered by priority
|
|
||||||
private readonly string[] _fallbackFonts = new[]
|
|
||||||
{
|
|
||||||
// Primary sans-serif fonts
|
|
||||||
"Noto Sans",
|
|
||||||
"DejaVu Sans",
|
|
||||||
"Liberation Sans",
|
|
||||||
"FreeSans",
|
|
||||||
|
|
||||||
// Emoji fonts
|
|
||||||
"Noto Color Emoji",
|
|
||||||
"Noto Emoji",
|
|
||||||
"Symbola",
|
|
||||||
"Segoe UI Emoji",
|
|
||||||
|
|
||||||
// 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",
|
|
||||||
|
|
||||||
// Arabic and RTL scripts
|
|
||||||
"Noto Sans Arabic",
|
|
||||||
"Noto Naskh Arabic",
|
|
||||||
"DejaVu Sans",
|
|
||||||
|
|
||||||
// Indic scripts
|
|
||||||
"Noto Sans Devanagari",
|
|
||||||
"Noto Sans Tamil",
|
|
||||||
"Noto Sans Bengali",
|
|
||||||
"Noto Sans Telugu",
|
|
||||||
|
|
||||||
// Thai
|
|
||||||
"Noto Sans Thai",
|
|
||||||
"Loma",
|
|
||||||
|
|
||||||
// Hebrew
|
|
||||||
"Noto Sans Hebrew",
|
|
||||||
|
|
||||||
// System fallbacks
|
|
||||||
"Sans",
|
|
||||||
"sans-serif"
|
|
||||||
};
|
|
||||||
|
|
||||||
// Cache for typeface lookups
|
|
||||||
private readonly Dictionary<string, SKTypeface?> _typefaceCache = new();
|
|
||||||
private readonly Dictionary<(int codepoint, string preferredFont), SKTypeface?> _glyphCache = new();
|
|
||||||
|
|
||||||
private FontFallbackManager()
|
|
||||||
{
|
|
||||||
// Pre-cache common fallback fonts
|
|
||||||
foreach (var fontName in _fallbackFonts.Take(10))
|
|
||||||
{
|
|
||||||
GetCachedTypeface(fontName);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -45,7 +45,6 @@ public static class InputMethodServiceFactory
|
|||||||
return imePreference.ToLowerInvariant() switch
|
return imePreference.ToLowerInvariant() switch
|
||||||
{
|
{
|
||||||
"ibus" => CreateIBusService(),
|
"ibus" => CreateIBusService(),
|
||||||
"fcitx" or "fcitx5" => CreateFcitx5Service(),
|
|
||||||
"xim" => CreateXIMService(),
|
"xim" => CreateXIMService(),
|
||||||
"none" => new NullInputMethodService(),
|
"none" => new NullInputMethodService(),
|
||||||
_ => CreateAutoService()
|
_ => CreateAutoService()
|
||||||
@@ -57,30 +56,13 @@ public static class InputMethodServiceFactory
|
|||||||
|
|
||||||
private static IInputMethodService CreateAutoService()
|
private static IInputMethodService CreateAutoService()
|
||||||
{
|
{
|
||||||
// Check GTK_IM_MODULE for hint
|
// Try IBus first (most common on modern Linux)
|
||||||
var imModule = Environment.GetEnvironmentVariable("GTK_IM_MODULE")?.ToLowerInvariant();
|
|
||||||
|
|
||||||
// Try Fcitx5 first if it's the configured IM
|
|
||||||
if (imModule?.Contains("fcitx") == true && Fcitx5InputMethodService.IsAvailable())
|
|
||||||
{
|
|
||||||
Console.WriteLine("InputMethodServiceFactory: Using Fcitx5");
|
|
||||||
return CreateFcitx5Service();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try IBus (most common on modern Linux)
|
|
||||||
if (IsIBusAvailable())
|
if (IsIBusAvailable())
|
||||||
{
|
{
|
||||||
Console.WriteLine("InputMethodServiceFactory: Using IBus");
|
Console.WriteLine("InputMethodServiceFactory: Using IBus");
|
||||||
return CreateIBusService();
|
return CreateIBusService();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try Fcitx5 as fallback
|
|
||||||
if (Fcitx5InputMethodService.IsAvailable())
|
|
||||||
{
|
|
||||||
Console.WriteLine("InputMethodServiceFactory: Using Fcitx5");
|
|
||||||
return CreateFcitx5Service();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fall back to XIM
|
// Fall back to XIM
|
||||||
if (IsXIMAvailable())
|
if (IsXIMAvailable())
|
||||||
{
|
{
|
||||||
@@ -106,19 +88,6 @@ public static class InputMethodServiceFactory
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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()
|
private static IInputMethodService CreateXIMService()
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|||||||
@@ -2,33 +2,16 @@
|
|||||||
// The .NET Foundation licenses this file to you under the MIT license.
|
// The .NET Foundation licenses this file to you under the MIT license.
|
||||||
|
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.Text;
|
|
||||||
using System.Collections.Concurrent;
|
|
||||||
|
|
||||||
namespace Microsoft.Maui.Platform.Linux.Services;
|
namespace Microsoft.Maui.Platform.Linux.Services;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Linux notification service using notify-send (libnotify) or D-Bus directly.
|
/// Linux notification service using notify-send (libnotify).
|
||||||
/// Supports interactive notifications with action callbacks.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class NotificationService
|
public class NotificationService
|
||||||
{
|
{
|
||||||
private readonly string _appName;
|
private readonly string _appName;
|
||||||
private readonly string? _defaultIconPath;
|
private readonly string? _defaultIconPath;
|
||||||
private readonly ConcurrentDictionary<uint, NotificationContext> _activeNotifications = new();
|
|
||||||
private static uint _notificationIdCounter = 1;
|
|
||||||
private Process? _dBusMonitor;
|
|
||||||
private bool _monitoringActions;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Event raised when a notification action is invoked.
|
|
||||||
/// </summary>
|
|
||||||
public event EventHandler<NotificationActionEventArgs>? ActionInvoked;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Event raised when a notification is closed.
|
|
||||||
/// </summary>
|
|
||||||
public event EventHandler<NotificationClosedEventArgs>? NotificationClosed;
|
|
||||||
|
|
||||||
public NotificationService(string appName = "MAUI Application", string? defaultIconPath = null)
|
public NotificationService(string appName = "MAUI Application", string? defaultIconPath = null)
|
||||||
{
|
{
|
||||||
@@ -36,165 +19,6 @@ public class NotificationService
|
|||||||
_defaultIconPath = defaultIconPath;
|
_defaultIconPath = defaultIconPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <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;
|
|
||||||
|
|
||||||
// Start D-Bus monitor for notification signals
|
|
||||||
Task.Run(MonitorNotificationSignals);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Stops monitoring for notification action callbacks.
|
|
||||||
/// </summary>
|
|
||||||
public void StopActionMonitoring()
|
|
||||||
{
|
|
||||||
_monitoringActions = false;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
_dBusMonitor?.Kill();
|
|
||||||
_dBusMonitor?.Dispose();
|
|
||||||
_dBusMonitor = null;
|
|
||||||
}
|
|
||||||
catch { }
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
};
|
|
||||||
|
|
||||||
_dBusMonitor = Process.Start(startInfo);
|
|
||||||
if (_dBusMonitor == null) return;
|
|
||||||
|
|
||||||
var reader = _dBusMonitor.StandardOutput;
|
|
||||||
var buffer = new StringBuilder();
|
|
||||||
|
|
||||||
while (_monitoringActions && !_dBusMonitor.HasExited)
|
|
||||||
{
|
|
||||||
var line = await reader.ReadLineAsync();
|
|
||||||
if (line == null) break;
|
|
||||||
|
|
||||||
buffer.AppendLine(line);
|
|
||||||
|
|
||||||
// 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
|
|
||||||
{
|
|
||||||
// Read the signal data (notification id and action key)
|
|
||||||
uint notificationId = 0;
|
|
||||||
string? actionKey = null;
|
|
||||||
|
|
||||||
for (int i = 0; i < 10; i++) // Read a few lines to get the data
|
|
||||||
{
|
|
||||||
var line = await reader.ReadLineAsync();
|
|
||||||
if (line == null) break;
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (notificationId > 0 && actionKey != null) break;
|
|
||||||
}
|
|
||||||
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
|
|
||||||
ActionInvoked?.Invoke(this, new NotificationActionEventArgs(notificationId, actionKey, context.Tag));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch { }
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task ProcessNotificationClosed(StreamReader reader)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
uint notificationId = 0;
|
|
||||||
uint reason = 0;
|
|
||||||
|
|
||||||
for (int i = 0; i < 5; i++)
|
|
||||||
{
|
|
||||||
var line = await reader.ReadLineAsync();
|
|
||||||
if (line == null) break;
|
|
||||||
|
|
||||||
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>
|
/// <summary>
|
||||||
/// Shows a simple notification.
|
/// Shows a simple notification.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -207,72 +31,6 @@ public class NotificationService
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <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>
|
/// <summary>
|
||||||
/// Shows a notification with options.
|
/// Shows a notification with options.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -451,87 +209,3 @@ public enum NotificationUrgency
|
|||||||
Normal,
|
Normal,
|
||||||
Critical
|
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,479 +0,0 @@
|
|||||||
// Licensed to the .NET Foundation under one or more agreements.
|
|
||||||
// The .NET Foundation licenses this file to you under the MIT license.
|
|
||||||
|
|
||||||
using Microsoft.Maui.Storage;
|
|
||||||
using System.Diagnostics;
|
|
||||||
using System.Text;
|
|
||||||
|
|
||||||
namespace Microsoft.Maui.Platform.Linux.Services;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// File picker service using xdg-desktop-portal for native dialogs.
|
|
||||||
/// Falls back to zenity/kdialog if portal is unavailable.
|
|
||||||
/// </summary>
|
|
||||||
public class PortalFilePickerService : IFilePicker
|
|
||||||
{
|
|
||||||
private bool _portalAvailable = true;
|
|
||||||
private string? _fallbackTool;
|
|
||||||
|
|
||||||
public PortalFilePickerService()
|
|
||||||
{
|
|
||||||
DetectAvailableTools();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void DetectAvailableTools()
|
|
||||||
{
|
|
||||||
// Check if portal is available
|
|
||||||
_portalAvailable = CheckPortalAvailable();
|
|
||||||
|
|
||||||
if (!_portalAvailable)
|
|
||||||
{
|
|
||||||
// Check for fallback tools
|
|
||||||
if (IsCommandAvailable("zenity"))
|
|
||||||
_fallbackTool = "zenity";
|
|
||||||
else if (IsCommandAvailable("kdialog"))
|
|
||||||
_fallbackTool = "kdialog";
|
|
||||||
else if (IsCommandAvailable("yad"))
|
|
||||||
_fallbackTool = "yad";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private bool CheckPortalAvailable()
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// Check if xdg-desktop-portal is running
|
|
||||||
var output = RunCommand("busctl", "--user list | grep -q org.freedesktop.portal.Desktop && echo yes");
|
|
||||||
return output.Trim() == "yes";
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private bool IsCommandAvailable(string command)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var output = RunCommand("which", command);
|
|
||||||
return !string.IsNullOrWhiteSpace(output);
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<FileResult?> PickAsync(PickOptions? options = null)
|
|
||||||
{
|
|
||||||
options ??= new PickOptions();
|
|
||||||
var results = await PickFilesAsync(options, allowMultiple: false);
|
|
||||||
return results.FirstOrDefault();
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<IEnumerable<FileResult>> PickMultipleAsync(PickOptions? options = null)
|
|
||||||
{
|
|
||||||
options ??= new PickOptions();
|
|
||||||
return await PickFilesAsync(options, allowMultiple: true);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<IEnumerable<FileResult>> PickFilesAsync(PickOptions options, bool allowMultiple)
|
|
||||||
{
|
|
||||||
if (_portalAvailable)
|
|
||||||
{
|
|
||||||
return await PickWithPortalAsync(options, allowMultiple);
|
|
||||||
}
|
|
||||||
else if (_fallbackTool != null)
|
|
||||||
{
|
|
||||||
return await PickWithFallbackAsync(options, allowMultiple);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// No file picker available
|
|
||||||
Console.WriteLine("[FilePickerService] No file picker available (install xdg-desktop-portal, zenity, or kdialog)");
|
|
||||||
return Enumerable.Empty<FileResult>();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<IEnumerable<FileResult>> PickWithPortalAsync(PickOptions options, bool allowMultiple)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// Use gdbus to call the portal
|
|
||||||
var filterArgs = BuildPortalFilterArgs(options.FileTypes);
|
|
||||||
var multipleArg = allowMultiple ? "true" : "false";
|
|
||||||
var title = options.PickerTitle ?? "Open File";
|
|
||||||
|
|
||||||
// Build the D-Bus call
|
|
||||||
var args = new StringBuilder();
|
|
||||||
args.Append("call --session ");
|
|
||||||
args.Append("--dest org.freedesktop.portal.Desktop ");
|
|
||||||
args.Append("--object-path /org/freedesktop/portal/desktop ");
|
|
||||||
args.Append("--method org.freedesktop.portal.FileChooser.OpenFile ");
|
|
||||||
args.Append("\"\" "); // Parent window (empty for no parent)
|
|
||||||
args.Append($"\"{EscapeForShell(title)}\" "); // Title
|
|
||||||
|
|
||||||
// Options dictionary
|
|
||||||
args.Append("@a{sv} {");
|
|
||||||
args.Append($"'multiple': <{multipleArg}>");
|
|
||||||
if (filterArgs != null)
|
|
||||||
{
|
|
||||||
args.Append($", 'filters': <{filterArgs}>");
|
|
||||||
}
|
|
||||||
args.Append("}");
|
|
||||||
|
|
||||||
var output = await Task.Run(() => RunCommand("gdbus", args.ToString()));
|
|
||||||
|
|
||||||
// Parse the response to get the request path
|
|
||||||
// Response format: (objectpath '/org/freedesktop/portal/desktop/request/...',)
|
|
||||||
var requestPath = ParseRequestPath(output);
|
|
||||||
if (string.IsNullOrEmpty(requestPath))
|
|
||||||
{
|
|
||||||
return Enumerable.Empty<FileResult>();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wait for the response signal (simplified - in production use D-Bus signal subscription)
|
|
||||||
await Task.Delay(100);
|
|
||||||
|
|
||||||
// For now, fall back to synchronous zenity if portal response parsing is complex
|
|
||||||
if (_fallbackTool != null)
|
|
||||||
{
|
|
||||||
return await PickWithFallbackAsync(options, allowMultiple);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Enumerable.Empty<FileResult>();
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Console.WriteLine($"[FilePickerService] Portal error: {ex.Message}");
|
|
||||||
// Fall back to zenity/kdialog
|
|
||||||
if (_fallbackTool != null)
|
|
||||||
{
|
|
||||||
return await PickWithFallbackAsync(options, allowMultiple);
|
|
||||||
}
|
|
||||||
return Enumerable.Empty<FileResult>();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<IEnumerable<FileResult>> PickWithFallbackAsync(PickOptions options, bool allowMultiple)
|
|
||||||
{
|
|
||||||
return _fallbackTool switch
|
|
||||||
{
|
|
||||||
"zenity" => await PickWithZenityAsync(options, allowMultiple),
|
|
||||||
"kdialog" => await PickWithKdialogAsync(options, allowMultiple),
|
|
||||||
"yad" => await PickWithYadAsync(options, allowMultiple),
|
|
||||||
_ => Enumerable.Empty<FileResult>()
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<IEnumerable<FileResult>> PickWithZenityAsync(PickOptions options, bool allowMultiple)
|
|
||||||
{
|
|
||||||
var args = new StringBuilder();
|
|
||||||
args.Append("--file-selection ");
|
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(options.PickerTitle))
|
|
||||||
{
|
|
||||||
args.Append($"--title=\"{EscapeForShell(options.PickerTitle)}\" ");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (allowMultiple)
|
|
||||||
{
|
|
||||||
args.Append("--multiple --separator=\"|\" ");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add file filters from FilePickerFileType
|
|
||||||
var extensions = GetExtensionsFromFileType(options.FileTypes);
|
|
||||||
if (extensions.Count > 0)
|
|
||||||
{
|
|
||||||
var filterPattern = string.Join(" ", extensions.Select(e => $"*{e}"));
|
|
||||||
args.Append($"--file-filter=\"Files | {filterPattern}\" ");
|
|
||||||
}
|
|
||||||
|
|
||||||
var output = await Task.Run(() => RunCommand("zenity", args.ToString()));
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(output))
|
|
||||||
{
|
|
||||||
return Enumerable.Empty<FileResult>();
|
|
||||||
}
|
|
||||||
|
|
||||||
var files = output.Trim().Split('|', StringSplitOptions.RemoveEmptyEntries);
|
|
||||||
return files.Select(f => new FileResult(f.Trim())).ToList();
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<IEnumerable<FileResult>> PickWithKdialogAsync(PickOptions options, bool allowMultiple)
|
|
||||||
{
|
|
||||||
var args = new StringBuilder();
|
|
||||||
args.Append("--getopenfilename ");
|
|
||||||
|
|
||||||
// Start directory
|
|
||||||
args.Append(". ");
|
|
||||||
|
|
||||||
// Add file filters
|
|
||||||
var extensions = GetExtensionsFromFileType(options.FileTypes);
|
|
||||||
if (extensions.Count > 0)
|
|
||||||
{
|
|
||||||
var filterPattern = string.Join(" ", extensions.Select(e => $"*{e}"));
|
|
||||||
args.Append($"\"Files ({filterPattern})\" ");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(options.PickerTitle))
|
|
||||||
{
|
|
||||||
args.Append($"--title \"{EscapeForShell(options.PickerTitle)}\" ");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (allowMultiple)
|
|
||||||
{
|
|
||||||
args.Append("--multiple --separate-output ");
|
|
||||||
}
|
|
||||||
|
|
||||||
var output = await Task.Run(() => RunCommand("kdialog", args.ToString()));
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(output))
|
|
||||||
{
|
|
||||||
return Enumerable.Empty<FileResult>();
|
|
||||||
}
|
|
||||||
|
|
||||||
var files = output.Trim().Split('\n', StringSplitOptions.RemoveEmptyEntries);
|
|
||||||
return files.Select(f => new FileResult(f.Trim())).ToList();
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<IEnumerable<FileResult>> PickWithYadAsync(PickOptions options, bool allowMultiple)
|
|
||||||
{
|
|
||||||
// YAD is similar to zenity
|
|
||||||
var args = new StringBuilder();
|
|
||||||
args.Append("--file ");
|
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(options.PickerTitle))
|
|
||||||
{
|
|
||||||
args.Append($"--title=\"{EscapeForShell(options.PickerTitle)}\" ");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (allowMultiple)
|
|
||||||
{
|
|
||||||
args.Append("--multiple --separator=\"|\" ");
|
|
||||||
}
|
|
||||||
|
|
||||||
var extensions = GetExtensionsFromFileType(options.FileTypes);
|
|
||||||
if (extensions.Count > 0)
|
|
||||||
{
|
|
||||||
var filterPattern = string.Join(" ", extensions.Select(e => $"*{e}"));
|
|
||||||
args.Append($"--file-filter=\"Files | {filterPattern}\" ");
|
|
||||||
}
|
|
||||||
|
|
||||||
var output = await Task.Run(() => RunCommand("yad", args.ToString()));
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(output))
|
|
||||||
{
|
|
||||||
return Enumerable.Empty<FileResult>();
|
|
||||||
}
|
|
||||||
|
|
||||||
var files = output.Trim().Split('|', StringSplitOptions.RemoveEmptyEntries);
|
|
||||||
return files.Select(f => new FileResult(f.Trim())).ToList();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Extracts file extensions from a MAUI FilePickerFileType.
|
|
||||||
/// </summary>
|
|
||||||
private List<string> GetExtensionsFromFileType(FilePickerFileType? fileType)
|
|
||||||
{
|
|
||||||
var extensions = new List<string>();
|
|
||||||
if (fileType == null) return extensions;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// FilePickerFileType.Value is IEnumerable<string> for the current platform
|
|
||||||
var value = fileType.Value;
|
|
||||||
if (value == null) return extensions;
|
|
||||||
|
|
||||||
foreach (var ext in value)
|
|
||||||
{
|
|
||||||
// Skip MIME types, only take file extensions
|
|
||||||
if (ext.StartsWith(".") || (!ext.Contains('/') && !ext.Contains('*')))
|
|
||||||
{
|
|
||||||
var normalized = ext.StartsWith(".") ? ext : $".{ext}";
|
|
||||||
if (!extensions.Contains(normalized))
|
|
||||||
{
|
|
||||||
extensions.Add(normalized);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
// Silently fail if we can't parse the file type
|
|
||||||
}
|
|
||||||
|
|
||||||
return extensions;
|
|
||||||
}
|
|
||||||
|
|
||||||
private string? BuildPortalFilterArgs(FilePickerFileType? fileType)
|
|
||||||
{
|
|
||||||
var extensions = GetExtensionsFromFileType(fileType);
|
|
||||||
if (extensions.Count == 0)
|
|
||||||
return null;
|
|
||||||
|
|
||||||
var patterns = string.Join(", ", extensions.Select(e => $"(uint32 0, '*{e}')"));
|
|
||||||
return $"[('Files', [{patterns}])]";
|
|
||||||
}
|
|
||||||
|
|
||||||
private string? ParseRequestPath(string output)
|
|
||||||
{
|
|
||||||
// Parse D-Bus response like: (objectpath '/org/freedesktop/portal/desktop/request/...',)
|
|
||||||
var start = output.IndexOf("'/");
|
|
||||||
var end = output.IndexOf("',", start);
|
|
||||||
if (start >= 0 && end > start)
|
|
||||||
{
|
|
||||||
return output.Substring(start + 1, end - start - 1);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private string EscapeForShell(string input)
|
|
||||||
{
|
|
||||||
return input.Replace("\"", "\\\"").Replace("'", "\\'");
|
|
||||||
}
|
|
||||||
|
|
||||||
private string RunCommand(string command, string arguments)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
using var process = new Process
|
|
||||||
{
|
|
||||||
StartInfo = new ProcessStartInfo
|
|
||||||
{
|
|
||||||
FileName = command,
|
|
||||||
Arguments = arguments,
|
|
||||||
RedirectStandardOutput = true,
|
|
||||||
RedirectStandardError = true,
|
|
||||||
UseShellExecute = false,
|
|
||||||
CreateNoWindow = true
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
process.Start();
|
|
||||||
var output = process.StandardOutput.ReadToEnd();
|
|
||||||
process.WaitForExit(30000);
|
|
||||||
return output;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Console.WriteLine($"[FilePickerService] Command error: {ex.Message}");
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Folder picker service using xdg-desktop-portal for native dialogs.
|
|
||||||
/// </summary>
|
|
||||||
public class PortalFolderPickerService
|
|
||||||
{
|
|
||||||
public async Task<FolderPickerResult> PickAsync(FolderPickerOptions? options = null, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
options ??= new FolderPickerOptions();
|
|
||||||
|
|
||||||
// Use zenity/kdialog for folder selection (simpler than portal)
|
|
||||||
string? selectedFolder = null;
|
|
||||||
|
|
||||||
if (IsCommandAvailable("zenity"))
|
|
||||||
{
|
|
||||||
var args = $"--file-selection --directory --title=\"{options.Title ?? "Select Folder"}\"";
|
|
||||||
selectedFolder = await Task.Run(() => RunCommand("zenity", args)?.Trim());
|
|
||||||
}
|
|
||||||
else if (IsCommandAvailable("kdialog"))
|
|
||||||
{
|
|
||||||
var args = $"--getexistingdirectory . --title \"{options.Title ?? "Select Folder"}\"";
|
|
||||||
selectedFolder = await Task.Run(() => RunCommand("kdialog", args)?.Trim());
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(selectedFolder) && Directory.Exists(selectedFolder))
|
|
||||||
{
|
|
||||||
return new FolderPickerResult(new FolderResult(selectedFolder));
|
|
||||||
}
|
|
||||||
|
|
||||||
return new FolderPickerResult(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<FolderPickerResult> PickAsync(CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
return await PickAsync(null, cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
private bool IsCommandAvailable(string command)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var output = RunCommand("which", command);
|
|
||||||
return !string.IsNullOrWhiteSpace(output);
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private string? RunCommand(string command, string arguments)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
using var process = new Process
|
|
||||||
{
|
|
||||||
StartInfo = new ProcessStartInfo
|
|
||||||
{
|
|
||||||
FileName = command,
|
|
||||||
Arguments = arguments,
|
|
||||||
RedirectStandardOutput = true,
|
|
||||||
UseShellExecute = false,
|
|
||||||
CreateNoWindow = true
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
process.Start();
|
|
||||||
var output = process.StandardOutput.ReadToEnd();
|
|
||||||
process.WaitForExit(30000);
|
|
||||||
return output;
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Result of a folder picker operation.
|
|
||||||
/// </summary>
|
|
||||||
public class FolderResult
|
|
||||||
{
|
|
||||||
public string Path { get; }
|
|
||||||
public string Name => System.IO.Path.GetFileName(Path) ?? Path;
|
|
||||||
|
|
||||||
public FolderResult(string path)
|
|
||||||
{
|
|
||||||
Path = path;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Result wrapper for folder picker.
|
|
||||||
/// </summary>
|
|
||||||
public class FolderPickerResult
|
|
||||||
{
|
|
||||||
public FolderResult? Folder { get; }
|
|
||||||
public bool WasSuccessful => Folder != null;
|
|
||||||
|
|
||||||
public FolderPickerResult(FolderResult? folder)
|
|
||||||
{
|
|
||||||
Folder = folder;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Options for folder picker.
|
|
||||||
/// </summary>
|
|
||||||
public class FolderPickerOptions
|
|
||||||
{
|
|
||||||
public string? Title { get; set; }
|
|
||||||
public string? InitialDirectory { get; set; }
|
|
||||||
}
|
|
||||||
@@ -1,481 +0,0 @@
|
|||||||
// 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.Diagnostics;
|
|
||||||
|
|
||||||
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();
|
|
||||||
|
|
||||||
/// <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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The current system theme.
|
|
||||||
/// </summary>
|
|
||||||
public SystemTheme CurrentTheme { get; private set; } = SystemTheme.Light;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The system accent color (if available).
|
|
||||||
/// </summary>
|
|
||||||
public SKColor AccentColor { get; private set; } = new SKColor(0x21, 0x96, 0xF3); // Default blue
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The detected desktop environment.
|
|
||||||
/// </summary>
|
|
||||||
public DesktopEnvironment Desktop { get; private set; } = DesktopEnvironment.Unknown;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Event raised when the theme changes.
|
|
||||||
/// </summary>
|
|
||||||
public event EventHandler<ThemeChangedEventArgs>? ThemeChanged;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// System colors based on the current theme.
|
|
||||||
/// </summary>
|
|
||||||
public SystemColors Colors { get; private set; }
|
|
||||||
|
|
||||||
private FileSystemWatcher? _settingsWatcher;
|
|
||||||
|
|
||||||
private SystemThemeService()
|
|
||||||
{
|
|
||||||
DetectDesktopEnvironment();
|
|
||||||
DetectTheme();
|
|
||||||
UpdateColors();
|
|
||||||
SetupWatcher();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void DetectDesktopEnvironment()
|
|
||||||
{
|
|
||||||
var xdgDesktop = Environment.GetEnvironmentVariable("XDG_CURRENT_DESKTOP")?.ToLowerInvariant() ?? "";
|
|
||||||
var desktopSession = Environment.GetEnvironmentVariable("DESKTOP_SESSION")?.ToLowerInvariant() ?? "";
|
|
||||||
|
|
||||||
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 DetectTheme()
|
|
||||||
{
|
|
||||||
var theme = Desktop switch
|
|
||||||
{
|
|
||||||
DesktopEnvironment.GNOME => DetectGnomeTheme(),
|
|
||||||
DesktopEnvironment.KDE => DetectKdeTheme(),
|
|
||||||
DesktopEnvironment.XFCE => DetectXfceTheme(),
|
|
||||||
DesktopEnvironment.Cinnamon => DetectCinnamonTheme(),
|
|
||||||
_ => DetectGtkTheme()
|
|
||||||
};
|
|
||||||
|
|
||||||
CurrentTheme = theme ?? SystemTheme.Light;
|
|
||||||
|
|
||||||
// Try to get accent color
|
|
||||||
AccentColor = Desktop switch
|
|
||||||
{
|
|
||||||
DesktopEnvironment.GNOME => GetGnomeAccentColor(),
|
|
||||||
DesktopEnvironment.KDE => GetKdeAccentColor(),
|
|
||||||
_ => new SKColor(0x21, 0x96, 0xF3)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
|
|
||||||
// Fallback: check GTK theme name
|
|
||||||
output = RunCommand("gsettings", "get org.gnome.desktop.interface gtk-theme");
|
|
||||||
if (output.ToLowerInvariant().Contains("dark"))
|
|
||||||
return SystemTheme.Dark;
|
|
||||||
}
|
|
||||||
catch { }
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private SystemTheme? DetectKdeTheme()
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// Read ~/.config/kdeglobals
|
|
||||||
var configPath = Path.Combine(
|
|
||||||
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
|
|
||||||
".config", "kdeglobals");
|
|
||||||
|
|
||||||
if (File.Exists(configPath))
|
|
||||||
{
|
|
||||||
var content = File.ReadAllText(configPath);
|
|
||||||
|
|
||||||
// Look for ColorScheme or LookAndFeelPackage
|
|
||||||
if (content.Contains("BreezeDark", StringComparison.OrdinalIgnoreCase) ||
|
|
||||||
content.Contains("Dark", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
return SystemTheme.Dark;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch { }
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private SystemTheme? DetectXfceTheme()
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var output = RunCommand("xfconf-query", "-c xsettings -p /Net/ThemeName");
|
|
||||||
if (output.ToLowerInvariant().Contains("dark"))
|
|
||||||
return SystemTheme.Dark;
|
|
||||||
}
|
|
||||||
catch { }
|
|
||||||
|
|
||||||
return DetectGtkTheme();
|
|
||||||
}
|
|
||||||
|
|
||||||
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; }
|
|
||||||
}
|
|
||||||
@@ -1,307 +0,0 @@
|
|||||||
// Licensed to the .NET Foundation under one or more agreements.
|
|
||||||
// The .NET Foundation licenses this file to you under the MIT license.
|
|
||||||
|
|
||||||
using SkiaSharp;
|
|
||||||
|
|
||||||
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 int _firstVisibleIndex = -1;
|
|
||||||
private int _lastVisibleIndex = -1;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Number of views currently active (bound to data).
|
|
||||||
/// </summary>
|
|
||||||
public int ActiveViewCount => _activeViews.Count;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Number of views in the recycle pool.
|
|
||||||
/// </summary>
|
|
||||||
public int PooledViewCount => _recyclePool.Count;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Current visible range.
|
|
||||||
/// </summary>
|
|
||||||
public (int First, int Last) VisibleRange => (_firstVisibleIndex, _lastVisibleIndex);
|
|
||||||
|
|
||||||
/// <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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <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;
|
|
||||||
|
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var index in toRecycle)
|
|
||||||
{
|
|
||||||
RecycleView(index);
|
|
||||||
}
|
|
||||||
|
|
||||||
_firstVisibleIndex = firstVisible;
|
|
||||||
_lastVisibleIndex = lastVisible;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get from pool or create new
|
|
||||||
T view;
|
|
||||||
if (_recyclePool.Count > 0)
|
|
||||||
{
|
|
||||||
view = _recyclePool.Dequeue();
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
view = _viewFactory();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bind data
|
|
||||||
bindData(view);
|
|
||||||
_activeViews[index] = view;
|
|
||||||
|
|
||||||
return view;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Recycles a view at the specified index.
|
|
||||||
/// </summary>
|
|
||||||
private void RecycleView(int index)
|
|
||||||
{
|
|
||||||
if (!_activeViews.TryGetValue(index, out var view))
|
|
||||||
return;
|
|
||||||
|
|
||||||
_activeViews.Remove(index);
|
|
||||||
|
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,490 +0,0 @@
|
|||||||
// 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.Platform.Linux.Interop;
|
|
||||||
using SkiaSharp;
|
|
||||||
|
|
||||||
namespace Microsoft.Maui.Platform;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Linux platform WebView using WebKitGTK.
|
|
||||||
/// This is a native widget overlay that renders on top of the Skia surface.
|
|
||||||
/// </summary>
|
|
||||||
public class LinuxWebView : SkiaView
|
|
||||||
{
|
|
||||||
private IntPtr _webView;
|
|
||||||
private IntPtr _gtkWindow;
|
|
||||||
private bool _initialized;
|
|
||||||
private bool _isVisible = true;
|
|
||||||
private string? _currentUrl;
|
|
||||||
private string? _userAgent;
|
|
||||||
|
|
||||||
// Signal handler IDs for cleanup
|
|
||||||
private ulong _loadChangedHandlerId;
|
|
||||||
private ulong _decidePolicyHandlerId;
|
|
||||||
private ulong _titleChangedHandlerId;
|
|
||||||
|
|
||||||
// Keep delegates alive to prevent GC
|
|
||||||
private WebKitGtk.LoadChangedCallback? _loadChangedCallback;
|
|
||||||
private WebKitGtk.DecidePolicyCallback? _decidePolicyCallback;
|
|
||||||
private WebKitGtk.NotifyCallback? _titleChangedCallback;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Event raised when navigation starts.
|
|
||||||
/// </summary>
|
|
||||||
public event EventHandler<WebViewNavigatingEventArgs>? Navigating;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Event raised when navigation completes.
|
|
||||||
/// </summary>
|
|
||||||
public event EventHandler<WebViewNavigatedEventArgs>? Navigated;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Event raised when the page title changes.
|
|
||||||
/// </summary>
|
|
||||||
public event EventHandler<string?>? TitleChanged;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets whether the WebView can navigate back.
|
|
||||||
/// </summary>
|
|
||||||
public bool CanGoBack => _webView != IntPtr.Zero && WebKitGtk.webkit_web_view_can_go_back(_webView);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets whether the WebView can navigate forward.
|
|
||||||
/// </summary>
|
|
||||||
public bool CanGoForward => _webView != IntPtr.Zero && WebKitGtk.webkit_web_view_can_go_forward(_webView);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the current URL.
|
|
||||||
/// </summary>
|
|
||||||
public string? CurrentUrl
|
|
||||||
{
|
|
||||||
get
|
|
||||||
{
|
|
||||||
if (_webView == IntPtr.Zero)
|
|
||||||
return _currentUrl;
|
|
||||||
|
|
||||||
var uriPtr = WebKitGtk.webkit_web_view_get_uri(_webView);
|
|
||||||
return WebKitGtk.PtrToStringUtf8(uriPtr) ?? _currentUrl;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the user agent string.
|
|
||||||
/// </summary>
|
|
||||||
public string? UserAgent
|
|
||||||
{
|
|
||||||
get => _userAgent;
|
|
||||||
set
|
|
||||||
{
|
|
||||||
_userAgent = value;
|
|
||||||
if (_webView != IntPtr.Zero && value != null)
|
|
||||||
{
|
|
||||||
var settings = WebKitGtk.webkit_web_view_get_settings(_webView);
|
|
||||||
WebKitGtk.webkit_settings_set_user_agent(settings, value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public LinuxWebView()
|
|
||||||
{
|
|
||||||
// WebView will be initialized when first shown or when source is set
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Initializes the WebKitGTK WebView.
|
|
||||||
/// </summary>
|
|
||||||
private void EnsureInitialized()
|
|
||||||
{
|
|
||||||
if (_initialized)
|
|
||||||
return;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// Initialize GTK if not already done
|
|
||||||
int argc = 0;
|
|
||||||
IntPtr argv = IntPtr.Zero;
|
|
||||||
WebKitGtk.gtk_init_check(ref argc, ref argv);
|
|
||||||
|
|
||||||
// Create a top-level window to host the WebView
|
|
||||||
// GTK_WINDOW_TOPLEVEL = 0
|
|
||||||
_gtkWindow = WebKitGtk.gtk_window_new(0);
|
|
||||||
if (_gtkWindow == IntPtr.Zero)
|
|
||||||
{
|
|
||||||
Console.WriteLine("[LinuxWebView] Failed to create GTK window");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Configure the window
|
|
||||||
WebKitGtk.gtk_window_set_decorated(_gtkWindow, false);
|
|
||||||
WebKitGtk.gtk_widget_set_can_focus(_gtkWindow, true);
|
|
||||||
|
|
||||||
// Create the WebKit WebView
|
|
||||||
_webView = WebKitGtk.webkit_web_view_new();
|
|
||||||
if (_webView == IntPtr.Zero)
|
|
||||||
{
|
|
||||||
Console.WriteLine("[LinuxWebView] Failed to create WebKit WebView");
|
|
||||||
WebKitGtk.gtk_widget_destroy(_gtkWindow);
|
|
||||||
_gtkWindow = IntPtr.Zero;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Configure settings
|
|
||||||
var settings = WebKitGtk.webkit_web_view_get_settings(_webView);
|
|
||||||
WebKitGtk.webkit_settings_set_enable_javascript(settings, true);
|
|
||||||
WebKitGtk.webkit_settings_set_enable_webgl(settings, true);
|
|
||||||
WebKitGtk.webkit_settings_set_enable_developer_extras(settings, true);
|
|
||||||
WebKitGtk.webkit_settings_set_javascript_can_access_clipboard(settings, true);
|
|
||||||
|
|
||||||
if (_userAgent != null)
|
|
||||||
{
|
|
||||||
WebKitGtk.webkit_settings_set_user_agent(settings, _userAgent);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Connect signals
|
|
||||||
ConnectSignals();
|
|
||||||
|
|
||||||
// Add WebView to window
|
|
||||||
WebKitGtk.gtk_container_add(_gtkWindow, _webView);
|
|
||||||
|
|
||||||
_initialized = true;
|
|
||||||
Console.WriteLine("[LinuxWebView] WebKitGTK WebView initialized successfully");
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Console.WriteLine($"[LinuxWebView] Initialization failed: {ex.Message}");
|
|
||||||
Console.WriteLine($"[LinuxWebView] Make sure WebKitGTK is installed: sudo apt install libwebkit2gtk-4.1-0");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void ConnectSignals()
|
|
||||||
{
|
|
||||||
// Keep callbacks alive
|
|
||||||
_loadChangedCallback = OnLoadChanged;
|
|
||||||
_decidePolicyCallback = OnDecidePolicy;
|
|
||||||
_titleChangedCallback = OnTitleChanged;
|
|
||||||
|
|
||||||
// Connect load-changed signal
|
|
||||||
_loadChangedHandlerId = WebKitGtk.g_signal_connect_data(
|
|
||||||
_webView, "load-changed", _loadChangedCallback, IntPtr.Zero, IntPtr.Zero, 0);
|
|
||||||
|
|
||||||
// Connect decide-policy signal for navigation control
|
|
||||||
_decidePolicyHandlerId = WebKitGtk.g_signal_connect_data(
|
|
||||||
_webView, "decide-policy", _decidePolicyCallback, IntPtr.Zero, IntPtr.Zero, 0);
|
|
||||||
|
|
||||||
// Connect notify::title for title changes
|
|
||||||
_titleChangedHandlerId = WebKitGtk.g_signal_connect_data(
|
|
||||||
_webView, "notify::title", _titleChangedCallback, IntPtr.Zero, IntPtr.Zero, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnLoadChanged(IntPtr webView, int loadEvent, IntPtr userData)
|
|
||||||
{
|
|
||||||
var url = CurrentUrl ?? "";
|
|
||||||
|
|
||||||
switch (loadEvent)
|
|
||||||
{
|
|
||||||
case WebKitGtk.WEBKIT_LOAD_STARTED:
|
|
||||||
case WebKitGtk.WEBKIT_LOAD_REDIRECTED:
|
|
||||||
Navigating?.Invoke(this, new WebViewNavigatingEventArgs(url));
|
|
||||||
break;
|
|
||||||
|
|
||||||
case WebKitGtk.WEBKIT_LOAD_FINISHED:
|
|
||||||
Navigated?.Invoke(this, new WebViewNavigatedEventArgs(url, true));
|
|
||||||
break;
|
|
||||||
|
|
||||||
case WebKitGtk.WEBKIT_LOAD_COMMITTED:
|
|
||||||
// Page content has started loading
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private bool OnDecidePolicy(IntPtr webView, IntPtr decision, int decisionType, IntPtr userData)
|
|
||||||
{
|
|
||||||
if (decisionType == WebKitGtk.WEBKIT_POLICY_DECISION_TYPE_NAVIGATION_ACTION)
|
|
||||||
{
|
|
||||||
var action = WebKitGtk.webkit_navigation_action_get_request(decision);
|
|
||||||
var uriPtr = WebKitGtk.webkit_uri_request_get_uri(action);
|
|
||||||
var url = WebKitGtk.PtrToStringUtf8(uriPtr) ?? "";
|
|
||||||
|
|
||||||
var args = new WebViewNavigatingEventArgs(url);
|
|
||||||
Navigating?.Invoke(this, args);
|
|
||||||
|
|
||||||
if (args.Cancel)
|
|
||||||
{
|
|
||||||
WebKitGtk.webkit_policy_decision_ignore(decision);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
WebKitGtk.webkit_policy_decision_use(decision);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnTitleChanged(IntPtr webView, IntPtr paramSpec, IntPtr userData)
|
|
||||||
{
|
|
||||||
var titlePtr = WebKitGtk.webkit_web_view_get_title(_webView);
|
|
||||||
var title = WebKitGtk.PtrToStringUtf8(titlePtr);
|
|
||||||
TitleChanged?.Invoke(this, title);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Navigates to the specified URL.
|
|
||||||
/// </summary>
|
|
||||||
public void LoadUrl(string url)
|
|
||||||
{
|
|
||||||
EnsureInitialized();
|
|
||||||
if (_webView == IntPtr.Zero)
|
|
||||||
return;
|
|
||||||
|
|
||||||
_currentUrl = url;
|
|
||||||
WebKitGtk.webkit_web_view_load_uri(_webView, url);
|
|
||||||
UpdateWindowPosition();
|
|
||||||
ShowWebView();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Loads HTML content.
|
|
||||||
/// </summary>
|
|
||||||
public void LoadHtml(string html, string? baseUrl = null)
|
|
||||||
{
|
|
||||||
EnsureInitialized();
|
|
||||||
if (_webView == IntPtr.Zero)
|
|
||||||
return;
|
|
||||||
|
|
||||||
WebKitGtk.webkit_web_view_load_html(_webView, html, baseUrl);
|
|
||||||
UpdateWindowPosition();
|
|
||||||
ShowWebView();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Navigates back in history.
|
|
||||||
/// </summary>
|
|
||||||
public void GoBack()
|
|
||||||
{
|
|
||||||
if (_webView != IntPtr.Zero && CanGoBack)
|
|
||||||
{
|
|
||||||
WebKitGtk.webkit_web_view_go_back(_webView);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Navigates forward in history.
|
|
||||||
/// </summary>
|
|
||||||
public void GoForward()
|
|
||||||
{
|
|
||||||
if (_webView != IntPtr.Zero && CanGoForward)
|
|
||||||
{
|
|
||||||
WebKitGtk.webkit_web_view_go_forward(_webView);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Reloads the current page.
|
|
||||||
/// </summary>
|
|
||||||
public void Reload()
|
|
||||||
{
|
|
||||||
if (_webView != IntPtr.Zero)
|
|
||||||
{
|
|
||||||
WebKitGtk.webkit_web_view_reload(_webView);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Stops loading the current page.
|
|
||||||
/// </summary>
|
|
||||||
public void Stop()
|
|
||||||
{
|
|
||||||
if (_webView != IntPtr.Zero)
|
|
||||||
{
|
|
||||||
WebKitGtk.webkit_web_view_stop_loading(_webView);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Evaluates JavaScript and returns the result.
|
|
||||||
/// </summary>
|
|
||||||
public Task<string?> EvaluateJavaScriptAsync(string script)
|
|
||||||
{
|
|
||||||
var tcs = new TaskCompletionSource<string?>();
|
|
||||||
|
|
||||||
if (_webView == IntPtr.Zero)
|
|
||||||
{
|
|
||||||
tcs.SetResult(null);
|
|
||||||
return tcs.Task;
|
|
||||||
}
|
|
||||||
|
|
||||||
// For now, use fire-and-forget JavaScript execution
|
|
||||||
// Full async result handling requires GAsyncReadyCallback marshaling
|
|
||||||
WebKitGtk.webkit_web_view_run_javascript(_webView, script, IntPtr.Zero, IntPtr.Zero, IntPtr.Zero);
|
|
||||||
tcs.SetResult(null); // Return null for now, full implementation needs async callback
|
|
||||||
|
|
||||||
return tcs.Task;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Evaluates JavaScript without waiting for result.
|
|
||||||
/// </summary>
|
|
||||||
public void Eval(string script)
|
|
||||||
{
|
|
||||||
if (_webView != IntPtr.Zero)
|
|
||||||
{
|
|
||||||
WebKitGtk.webkit_web_view_run_javascript(_webView, script, IntPtr.Zero, IntPtr.Zero, IntPtr.Zero);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void ShowWebView()
|
|
||||||
{
|
|
||||||
if (_gtkWindow != IntPtr.Zero && _isVisible)
|
|
||||||
{
|
|
||||||
WebKitGtk.gtk_widget_show_all(_gtkWindow);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void HideWebView()
|
|
||||||
{
|
|
||||||
if (_gtkWindow != IntPtr.Zero)
|
|
||||||
{
|
|
||||||
WebKitGtk.gtk_widget_hide(_gtkWindow);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void UpdateWindowPosition()
|
|
||||||
{
|
|
||||||
if (_gtkWindow == IntPtr.Zero)
|
|
||||||
return;
|
|
||||||
|
|
||||||
// Get the screen position of this view's bounds
|
|
||||||
var bounds = Bounds;
|
|
||||||
var screenX = (int)bounds.Left;
|
|
||||||
var screenY = (int)bounds.Top;
|
|
||||||
var width = (int)bounds.Width;
|
|
||||||
var height = (int)bounds.Height;
|
|
||||||
|
|
||||||
if (width > 0 && height > 0)
|
|
||||||
{
|
|
||||||
WebKitGtk.gtk_window_move(_gtkWindow, screenX, screenY);
|
|
||||||
WebKitGtk.gtk_window_resize(_gtkWindow, width, height);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override void OnBoundsChanged()
|
|
||||||
{
|
|
||||||
base.OnBoundsChanged();
|
|
||||||
UpdateWindowPosition();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override void OnVisibilityChanged()
|
|
||||||
{
|
|
||||||
base.OnVisibilityChanged();
|
|
||||||
_isVisible = IsVisible;
|
|
||||||
|
|
||||||
if (_isVisible)
|
|
||||||
{
|
|
||||||
ShowWebView();
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
HideWebView();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override void OnDraw(SKCanvas canvas, SKRect bounds)
|
|
||||||
{
|
|
||||||
// Draw a placeholder rectangle where the WebView will be overlaid
|
|
||||||
using var paint = new SKPaint
|
|
||||||
{
|
|
||||||
Color = new SKColor(240, 240, 240),
|
|
||||||
Style = SKPaintStyle.Fill
|
|
||||||
};
|
|
||||||
canvas.DrawRect(bounds, paint);
|
|
||||||
|
|
||||||
// Draw border
|
|
||||||
using var borderPaint = new SKPaint
|
|
||||||
{
|
|
||||||
Color = new SKColor(200, 200, 200),
|
|
||||||
Style = SKPaintStyle.Stroke,
|
|
||||||
StrokeWidth = 1
|
|
||||||
};
|
|
||||||
canvas.DrawRect(bounds, borderPaint);
|
|
||||||
|
|
||||||
// Draw "WebView" label if not yet initialized
|
|
||||||
if (!_initialized)
|
|
||||||
{
|
|
||||||
using var textPaint = new SKPaint
|
|
||||||
{
|
|
||||||
Color = SKColors.Gray,
|
|
||||||
TextSize = 14,
|
|
||||||
IsAntialias = true
|
|
||||||
};
|
|
||||||
var text = "WebView (WebKitGTK)";
|
|
||||||
var textBounds = new SKRect();
|
|
||||||
textPaint.MeasureText(text, ref textBounds);
|
|
||||||
var x = bounds.MidX - textBounds.MidX;
|
|
||||||
var y = bounds.MidY - textBounds.MidY;
|
|
||||||
canvas.DrawText(text, x, y, textPaint);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process GTK events to keep WebView responsive
|
|
||||||
WebKitGtk.ProcessGtkEvents();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override void Dispose(bool disposing)
|
|
||||||
{
|
|
||||||
if (disposing)
|
|
||||||
{
|
|
||||||
// Disconnect signals
|
|
||||||
if (_webView != IntPtr.Zero)
|
|
||||||
{
|
|
||||||
if (_loadChangedHandlerId != 0)
|
|
||||||
WebKitGtk.g_signal_handler_disconnect(_webView, _loadChangedHandlerId);
|
|
||||||
if (_decidePolicyHandlerId != 0)
|
|
||||||
WebKitGtk.g_signal_handler_disconnect(_webView, _decidePolicyHandlerId);
|
|
||||||
if (_titleChangedHandlerId != 0)
|
|
||||||
WebKitGtk.g_signal_handler_disconnect(_webView, _titleChangedHandlerId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Destroy widgets
|
|
||||||
if (_gtkWindow != IntPtr.Zero)
|
|
||||||
{
|
|
||||||
WebKitGtk.gtk_widget_destroy(_gtkWindow);
|
|
||||||
_gtkWindow = IntPtr.Zero;
|
|
||||||
_webView = IntPtr.Zero; // WebView is destroyed with window
|
|
||||||
}
|
|
||||||
|
|
||||||
_loadChangedCallback = null;
|
|
||||||
_decidePolicyCallback = null;
|
|
||||||
_titleChangedCallback = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
base.Dispose(disposing);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Event args for WebView navigation starting.
|
|
||||||
/// </summary>
|
|
||||||
public class WebViewNavigatingEventArgs : EventArgs
|
|
||||||
{
|
|
||||||
public string Url { get; }
|
|
||||||
public bool Cancel { get; set; }
|
|
||||||
|
|
||||||
public WebViewNavigatingEventArgs(string url)
|
|
||||||
{
|
|
||||||
Url = url;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Event args for WebView navigation completed.
|
|
||||||
/// </summary>
|
|
||||||
public class WebViewNavigatedEventArgs : EventArgs
|
|
||||||
{
|
|
||||||
public string Url { get; }
|
|
||||||
public bool Success { get; }
|
|
||||||
|
|
||||||
public WebViewNavigatedEventArgs(string url, bool success)
|
|
||||||
{
|
|
||||||
Url = url;
|
|
||||||
Success = success;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
87
fix_decompiler.py
Normal file
87
fix_decompiler.py
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Fix decompiler artifacts in C# files."""
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
|
||||||
|
def fix_file(filepath):
|
||||||
|
with open(filepath, 'r') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
original = content
|
||||||
|
|
||||||
|
# Pattern 1: Fix ((Type)(ref var))._002Ector(args) on same line as declaration
|
||||||
|
# Pattern: Type var = default(Type); followed by ((Type)(ref var))._002Ector(args);
|
||||||
|
# Combine: Type var = default(Type); + var._002Ector(args) -> Type var = new Type(args);
|
||||||
|
|
||||||
|
# First, fix the _002Ector pattern to use "new Type(...)"
|
||||||
|
# Pattern: ((TypeName)(ref varName))._002Ector(args);
|
||||||
|
pattern_ctor = r'\(\((SK\w+|SKRect|SKSize|SKPoint|SKColor|Thickness|Font|LayoutOptions|SKFontMetrics|RectF|Rect)\)\(ref\s+(\w+)\)\)\._002Ector\(([^;]+)\);'
|
||||||
|
|
||||||
|
def replace_ctor(match):
|
||||||
|
type_name = match.group(1)
|
||||||
|
var_name = match.group(2)
|
||||||
|
args = match.group(3)
|
||||||
|
return f'{var_name} = new {type_name}({args});'
|
||||||
|
|
||||||
|
content = re.sub(pattern_ctor, replace_ctor, content)
|
||||||
|
|
||||||
|
# Also handle simpler pattern: var._002Ector(args);
|
||||||
|
pattern_simple = r'(\w+)\._002Ector\(([^;]+)\);'
|
||||||
|
def replace_simple(match):
|
||||||
|
var_name = match.group(1)
|
||||||
|
args = match.group(2)
|
||||||
|
# We need to figure out the type from context - look for declaration
|
||||||
|
return f'// FIXME: {var_name} = new TYPE({args});'
|
||||||
|
|
||||||
|
# Don't do the simple pattern - it's harder to fix without knowing the type
|
||||||
|
|
||||||
|
# Pattern 2: Fix _003F (which is just ?)
|
||||||
|
content = content.replace('_003F', '?')
|
||||||
|
|
||||||
|
# Pattern 2.5: Fix broken nullable cast patterns
|
||||||
|
# (((??)something) ?? fallback) -> (something ?? fallback)
|
||||||
|
content = re.sub(r'\(\(\(\?\?\)(\w+\.\w+)\)', r'(\1', content)
|
||||||
|
content = content.replace('((?)', '((') # Fix broken nullable casts
|
||||||
|
content = content.replace('(?))', '))') # Fix broken casts
|
||||||
|
|
||||||
|
# Pattern 3: Clean up remaining ((Type)(ref var)) patterns without _002Ector
|
||||||
|
# These become just var
|
||||||
|
# First handle more types: Font, Thickness, Color, LayoutOptions, GridLength, etc.
|
||||||
|
types_to_fix = r'SK\w+|Font|Thickness|Color|LayoutOptions|SKFontMetrics|Rectangle|Point|Size|Rect|GridLength|GRGlFramebufferInfo|CornerRadius|RectF'
|
||||||
|
pattern_ref = r'\(\((' + types_to_fix + r')\)\(ref\s+(\w+)\)\)'
|
||||||
|
content = re.sub(pattern_ref, r'\2', content)
|
||||||
|
|
||||||
|
# Pattern 3.5: Handle static property refs like ((SKColor)(ref SKColors.White))
|
||||||
|
pattern_static_ref = r'\(\((' + types_to_fix + r')\)\(ref\s+(\w+\.\w+)\)\)'
|
||||||
|
content = re.sub(pattern_static_ref, r'\2', content)
|
||||||
|
|
||||||
|
# Pattern 4: Also handle ViewHandler casts like ((ViewHandler<ISearchBar, SkiaSearchBar>)(object)handler)
|
||||||
|
# This should stay as-is but the inner (ref x) needs fixing first
|
||||||
|
|
||||||
|
# Pattern 5: Fix simple (ref var) that might appear in other contexts
|
||||||
|
# Pattern: (ref varName) when standalone (not in a cast)
|
||||||
|
# Skip for now as this could break valid ref usage
|
||||||
|
|
||||||
|
if content != original:
|
||||||
|
with open(filepath, 'w') as f:
|
||||||
|
f.write(content)
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def main():
|
||||||
|
base_dir = '/Users/nible/Documents/GitHub/maui-linux-main'
|
||||||
|
count = 0
|
||||||
|
for root, dirs, files in os.walk(base_dir):
|
||||||
|
# Skip hidden dirs and .git
|
||||||
|
dirs[:] = [d for d in dirs if not d.startswith('.')]
|
||||||
|
for fname in files:
|
||||||
|
if fname.endswith('.cs'):
|
||||||
|
filepath = os.path.join(root, fname)
|
||||||
|
if fix_file(filepath):
|
||||||
|
print(f'Fixed: {filepath}')
|
||||||
|
count += 1
|
||||||
|
print(f'Fixed {count} files')
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
151
fixfuckup.md
Normal file
151
fixfuckup.md
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
# Fix Fuckup Recovery Plan
|
||||||
|
|
||||||
|
## What Happened
|
||||||
|
Code was stored in /tmp directory which got cleared on restart. Recovered code from decompiled VM binaries.
|
||||||
|
|
||||||
|
## What Was Lost
|
||||||
|
The decompiled code has all the **logic** but:
|
||||||
|
1. **XAML files are gone** - they were compiled to C# code
|
||||||
|
2. **AppThemeBinding additions** - dark/light mode XAML bindings
|
||||||
|
3. **Original formatting/comments** - decompiler output is messy
|
||||||
|
|
||||||
|
## Recovery Order
|
||||||
|
|
||||||
|
### Step 1: Fix maui-linux Library First
|
||||||
|
The library code is recovered and functional. Build and verify:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/Documents/GitHub/maui-linux-main
|
||||||
|
dotnet build
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: Recreate Sample XAML with AppThemeBinding
|
||||||
|
|
||||||
|
#### ShellDemo XAML to Recreate
|
||||||
|
All pages had AppThemeBinding added for dark/light mode:
|
||||||
|
|
||||||
|
- [ ] **AppShell.xaml** - FlyoutHeader with:
|
||||||
|
- VerticalStackLayout (logo above text)
|
||||||
|
- Image with AspectFit
|
||||||
|
- BackgroundColor: `{AppThemeBinding Light=#F0F0F0, Dark=#2A2A2A}`
|
||||||
|
- TextColor bindings for labels
|
||||||
|
|
||||||
|
- [ ] **HomePage.xaml** - AppThemeBinding for:
|
||||||
|
- BackgroundColor
|
||||||
|
- TextColor
|
||||||
|
- Button colors
|
||||||
|
|
||||||
|
- [ ] **ButtonsPage.xaml** - AppThemeBinding colors
|
||||||
|
- [ ] **TextInputPage.xaml** - Entry/Editor theme colors
|
||||||
|
- [ ] **PickersPage.xaml** - Picker theme colors
|
||||||
|
- [ ] **ProgressPage.xaml** - ProgressBar theme colors
|
||||||
|
- [ ] **SelectionPage.xaml** - CheckBox/Switch theme colors
|
||||||
|
- [ ] **ListsPage.xaml** - CollectionView theme colors
|
||||||
|
- [ ] **GridsPage.xaml** - Grid theme colors
|
||||||
|
- [ ] **AboutPage.xaml** - Links with tap gestures, theme colors
|
||||||
|
- [ ] **DetailPage.xaml** - Theme colors
|
||||||
|
|
||||||
|
#### TodoApp XAML to Recreate
|
||||||
|
- [ ] **TodoListPage.xaml** - AppThemeBinding for:
|
||||||
|
- Page background
|
||||||
|
- List item colors
|
||||||
|
- Button colors
|
||||||
|
|
||||||
|
- [ ] **TodoDetailPage.xaml** - Theme colors
|
||||||
|
- [ ] **NewTodoPage.xaml** - Theme colors
|
||||||
|
|
||||||
|
#### XamlBrowser XAML to Recreate
|
||||||
|
- [ ] **MainPage.xaml** - WebView container with theme
|
||||||
|
|
||||||
|
## AppThemeBinding Pattern
|
||||||
|
All XAML used this pattern:
|
||||||
|
```xml
|
||||||
|
<Label TextColor="{AppThemeBinding Light=#333333, Dark=#E0E0E0}" />
|
||||||
|
<Grid BackgroundColor="{AppThemeBinding Light=#FFFFFF, Dark=#1E1E1E}" />
|
||||||
|
<Button BackgroundColor="{AppThemeBinding Light=#2196F3, Dark=#1976D2}" />
|
||||||
|
```
|
||||||
|
|
||||||
|
## FlyoutHeader Specifics
|
||||||
|
The FlyoutHeader had this structure:
|
||||||
|
```xml
|
||||||
|
<Shell.FlyoutHeader>
|
||||||
|
<Grid BackgroundColor="{AppThemeBinding Light=#F0F0F0, Dark=#2A2A2A}"
|
||||||
|
HeightRequest="160"
|
||||||
|
Padding="15">
|
||||||
|
<VerticalStackLayout VerticalOptions="Center"
|
||||||
|
HorizontalOptions="Center"
|
||||||
|
Spacing="8">
|
||||||
|
<Image Source="openmaui_logo.svg"
|
||||||
|
WidthRequest="70"
|
||||||
|
HeightRequest="70"
|
||||||
|
Aspect="AspectFit"/>
|
||||||
|
<Label Text="OpenMaui"
|
||||||
|
FontSize="20"
|
||||||
|
FontAttributes="Bold"
|
||||||
|
HorizontalOptions="Center"
|
||||||
|
TextColor="{AppThemeBinding Light=#333333, Dark=#E0E0E0}"/>
|
||||||
|
<Label Text="Controls Demo"
|
||||||
|
FontSize="12"
|
||||||
|
HorizontalOptions="Center"
|
||||||
|
TextColor="{AppThemeBinding Light=#666666, Dark=#B0B0B0}"/>
|
||||||
|
</VerticalStackLayout>
|
||||||
|
</Grid>
|
||||||
|
</Shell.FlyoutHeader>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Screenshots Needed
|
||||||
|
User can take screenshots of running app to recreate XAML:
|
||||||
|
|
||||||
|
1. **ShellDemo Flyout open** - Light mode
|
||||||
|
2. **ShellDemo Flyout open** - Dark mode
|
||||||
|
3. **Each page** - Light and dark mode
|
||||||
|
4. **TodoApp** - Light and dark mode
|
||||||
|
|
||||||
|
## Key Features Recovered in Library
|
||||||
|
|
||||||
|
### SkiaShell (1325 lines)
|
||||||
|
- [x] FlyoutHeaderView, FlyoutHeaderHeight
|
||||||
|
- [x] FlyoutFooterText, FlyoutFooterHeight
|
||||||
|
- [x] Flyout scrolling
|
||||||
|
- [x] All BindableProperties for theming
|
||||||
|
|
||||||
|
### X11Window
|
||||||
|
- [x] Cursor support (XCreateFontCursor, XDefineCursor)
|
||||||
|
- [x] CursorType enum
|
||||||
|
|
||||||
|
### Theme Support
|
||||||
|
- [x] SystemThemeService
|
||||||
|
- [x] UserAppTheme detection
|
||||||
|
- [x] Theme-aware handlers
|
||||||
|
|
||||||
|
## File Locations
|
||||||
|
|
||||||
|
| Item | Path |
|
||||||
|
|------|------|
|
||||||
|
| Library | `~/Documents/GitHub/maui-linux-main` |
|
||||||
|
| Samples | `~/Documents/GitHub/maui-linux-samples-main` |
|
||||||
|
| Recovered backup | `~/Documents/GitHub/recovered/` |
|
||||||
|
|
||||||
|
## Build & Deploy Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build library
|
||||||
|
cd ~/Documents/GitHub/maui-linux-main
|
||||||
|
dotnet build
|
||||||
|
|
||||||
|
# Build ShellDemo
|
||||||
|
cd ~/Documents/GitHub/maui-linux-samples-main/ShellDemo
|
||||||
|
dotnet publish -c Release -r linux-arm64 --self-contained
|
||||||
|
|
||||||
|
# Deploy
|
||||||
|
sshpass -p Basilisk scp -r bin/Release/net9.0/linux-arm64/publish/* marketally@172.16.1.128:~/shelltest/
|
||||||
|
|
||||||
|
# Run
|
||||||
|
sshpass -p Basilisk ssh marketally@172.16.1.128 "cd ~/shelltest && DISPLAY=:0 XAUTHORITY=/run/user/1000/.mutter-Xwaylandauth.* ./ShellDemo"
|
||||||
|
```
|
||||||
|
|
||||||
|
## CRITICAL RULES
|
||||||
|
|
||||||
|
1. **NEVER use /tmp** - always use ~/Documents/GitHub/
|
||||||
|
2. **Commit and push after EVERY significant change**
|
||||||
|
3. **Only push to dev branch** - main has CI/CD actions
|
||||||
Reference in New Issue
Block a user