maui-linux/Views/LinuxWebView.cs

491 lines
14 KiB
C#

// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using Microsoft.Maui.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;
}
}