GUI: Add embedded WebView2 renderer for offline web applets

Replace the external system browser with an embedded WebView2 instance
using Avalonia's NativeControlHost and CoreWebView2 COM API directly.
This renders offline web applet content (e.g., AC3 Remastered menus)
inside the Ryujinx window without leaving the application.

Key changes:
- WebView2Host: custom NativeControlHost with Win32 child HWND
- WebAppletWindow: overlay dialog hosting the WebView2 control
- STAThread on Main for COM STA apartment (required by WebView2)
- WM_SIZE handler for proper WebView2 resize tracking
- Fallback dialog for Linux where WebView2 is unavailable

Signed-off-by: Zephyron <zephyron@citron-emu.org>
This commit is contained in:
Zephyron 2026-02-14 21:15:07 +10:00
parent b3ec6628ef
commit ad1d9dee6f
No known key found for this signature in database
GPG Key ID: A1F93756F8FAE023
10 changed files with 595 additions and 91 deletions

View File

@ -8,6 +8,7 @@
<PackageVersion Include="Avalonia.Desktop" Version="11.3.6" />
<PackageVersion Include="Avalonia.Diagnostics" Version="11.3.6" />
<PackageVersion Include="Avalonia.Markup.Xaml.Loader" Version="11.3.6" />
<PackageVersion Include="Microsoft.Web.WebView2" Version="1.0.3719.77" />
<PackageVersion Include="Svg.Controls.Avalonia" Version="11.3.6.2" />
<PackageVersion Include="Svg.Controls.Skia.Avalonia" Version="11.3.6.2" />
<PackageVersion Include="Microsoft.Build.Framework" Version="17.11.4" />
@ -58,5 +59,7 @@
<PackageVersion Include="System.IO.Hashing" Version="9.0.2" />
<PackageVersion Include="System.Management" Version="9.0.2" />
<PackageVersion Include="UnicornEngine.Unicorn" Version="2.0.2-rc1-fb78016" />
<PackageVersion Include="WebView.Avalonia.AGPL" Version="11.0.0.2026011404" />
<PackageVersion Include="WebView.Avalonia.Desktop" Version="11.0.0.1" />
</ItemGroup>
</Project>

View File

@ -1,7 +1,7 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.1.32228.430
# Visual Studio Version 18
VisualStudioVersion = 18.2.11415.280 d18.0
MinimumVisualStudioVersion = 10.0.40219.1
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Ryujinx.Tests", "src\Ryujinx.Tests\Ryujinx.Tests.csproj", "{EBB55AEA-C7D7-4DEB-BF96-FA1789E225E9}"
EndProject
@ -21,7 +21,7 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Ryujinx.Graphics.GAL", "src
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Ryujinx.Graphics.OpenGL", "src\Ryujinx.Graphics.OpenGL\Ryujinx.Graphics.OpenGL.csproj", "{9558FB96-075D-4219-8FFF-401979DC0B69}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ryujinx.Graphics.RenderDoc", "src\Ryujinx.Graphics.RenderDocApi\Ryujinx.Graphics.RenderDocApi.csproj", "{D58FA894-27D5-4EAA-9042-AD422AD82931}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ryujinx.Graphics.RenderDocApi", "src\Ryujinx.Graphics.RenderDocApi\Ryujinx.Graphics.RenderDocApi.csproj", "{D58FA894-27D5-4EAA-9042-AD422AD82931}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Ryujinx.Graphics.Texture", "src\Ryujinx.Graphics.Texture\Ryujinx.Graphics.Texture.csproj", "{E1B1AD28-289D-47B7-A106-326972240207}"
EndProject
@ -88,10 +88,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution
.editorconfig = .editorconfig
.github\workflows\build.yml = .github\workflows\build.yml
.github\workflows\canary.yml = .github\workflows\canary.yml
Directory.Packages.props = Directory.Packages.props
Directory.Build.props = Directory.Build.props
.github\workflows\release.yml = .github\workflows\release.yml
Directory.Packages.props = Directory.Packages.props
nuget.config = nuget.config
.github\workflows\release.yml = .github\workflows\release.yml
EndProjectSection
EndProject
Global
@ -212,6 +212,18 @@ Global
{9558FB96-075D-4219-8FFF-401979DC0B69}.Release|x64.Build.0 = Release|Any CPU
{9558FB96-075D-4219-8FFF-401979DC0B69}.Release|x86.ActiveCfg = Release|Any CPU
{9558FB96-075D-4219-8FFF-401979DC0B69}.Release|x86.Build.0 = Release|Any CPU
{D58FA894-27D5-4EAA-9042-AD422AD82931}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{D58FA894-27D5-4EAA-9042-AD422AD82931}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D58FA894-27D5-4EAA-9042-AD422AD82931}.Debug|x64.ActiveCfg = Debug|Any CPU
{D58FA894-27D5-4EAA-9042-AD422AD82931}.Debug|x64.Build.0 = Debug|Any CPU
{D58FA894-27D5-4EAA-9042-AD422AD82931}.Debug|x86.ActiveCfg = Debug|Any CPU
{D58FA894-27D5-4EAA-9042-AD422AD82931}.Debug|x86.Build.0 = Debug|Any CPU
{D58FA894-27D5-4EAA-9042-AD422AD82931}.Release|Any CPU.ActiveCfg = Release|Any CPU
{D58FA894-27D5-4EAA-9042-AD422AD82931}.Release|Any CPU.Build.0 = Release|Any CPU
{D58FA894-27D5-4EAA-9042-AD422AD82931}.Release|x64.ActiveCfg = Release|Any CPU
{D58FA894-27D5-4EAA-9042-AD422AD82931}.Release|x64.Build.0 = Release|Any CPU
{D58FA894-27D5-4EAA-9042-AD422AD82931}.Release|x86.ActiveCfg = Release|Any CPU
{D58FA894-27D5-4EAA-9042-AD422AD82931}.Release|x86.Build.0 = Release|Any CPU
{E1B1AD28-289D-47B7-A106-326972240207}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{E1B1AD28-289D-47B7-A106-326972240207}.Debug|Any CPU.Build.0 = Debug|Any CPU
{E1B1AD28-289D-47B7-A106-326972240207}.Debug|x64.ActiveCfg = Debug|Any CPU
@ -356,6 +368,30 @@ Global
{FD4A2C14-8E3D-4957-ABBE-3C38897B3E2D}.Release|x64.Build.0 = Release|Any CPU
{FD4A2C14-8E3D-4957-ABBE-3C38897B3E2D}.Release|x86.ActiveCfg = Release|Any CPU
{FD4A2C14-8E3D-4957-ABBE-3C38897B3E2D}.Release|x86.Build.0 = Release|Any CPU
{AC26EFF0-8593-4184-9A09-98E37EFFB32E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{AC26EFF0-8593-4184-9A09-98E37EFFB32E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{AC26EFF0-8593-4184-9A09-98E37EFFB32E}.Debug|x64.ActiveCfg = Debug|Any CPU
{AC26EFF0-8593-4184-9A09-98E37EFFB32E}.Debug|x64.Build.0 = Debug|Any CPU
{AC26EFF0-8593-4184-9A09-98E37EFFB32E}.Debug|x86.ActiveCfg = Debug|Any CPU
{AC26EFF0-8593-4184-9A09-98E37EFFB32E}.Debug|x86.Build.0 = Debug|Any CPU
{AC26EFF0-8593-4184-9A09-98E37EFFB32E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{AC26EFF0-8593-4184-9A09-98E37EFFB32E}.Release|Any CPU.Build.0 = Release|Any CPU
{AC26EFF0-8593-4184-9A09-98E37EFFB32E}.Release|x64.ActiveCfg = Release|Any CPU
{AC26EFF0-8593-4184-9A09-98E37EFFB32E}.Release|x64.Build.0 = Release|Any CPU
{AC26EFF0-8593-4184-9A09-98E37EFFB32E}.Release|x86.ActiveCfg = Release|Any CPU
{AC26EFF0-8593-4184-9A09-98E37EFFB32E}.Release|x86.Build.0 = Release|Any CPU
{988E6191-82E1-4E13-9DDB-CB9FA2FDAF29}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{988E6191-82E1-4E13-9DDB-CB9FA2FDAF29}.Debug|Any CPU.Build.0 = Debug|Any CPU
{988E6191-82E1-4E13-9DDB-CB9FA2FDAF29}.Debug|x64.ActiveCfg = Debug|Any CPU
{988E6191-82E1-4E13-9DDB-CB9FA2FDAF29}.Debug|x64.Build.0 = Debug|Any CPU
{988E6191-82E1-4E13-9DDB-CB9FA2FDAF29}.Debug|x86.ActiveCfg = Debug|Any CPU
{988E6191-82E1-4E13-9DDB-CB9FA2FDAF29}.Debug|x86.Build.0 = Debug|Any CPU
{988E6191-82E1-4E13-9DDB-CB9FA2FDAF29}.Release|Any CPU.ActiveCfg = Release|Any CPU
{988E6191-82E1-4E13-9DDB-CB9FA2FDAF29}.Release|Any CPU.Build.0 = Release|Any CPU
{988E6191-82E1-4E13-9DDB-CB9FA2FDAF29}.Release|x64.ActiveCfg = Release|Any CPU
{988E6191-82E1-4E13-9DDB-CB9FA2FDAF29}.Release|x64.Build.0 = Release|Any CPU
{988E6191-82E1-4E13-9DDB-CB9FA2FDAF29}.Release|x86.ActiveCfg = Release|Any CPU
{988E6191-82E1-4E13-9DDB-CB9FA2FDAF29}.Release|x86.Build.0 = Release|Any CPU
{0BE11899-DF2D-4BDE-B9EE-2489E8D35E7D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{0BE11899-DF2D-4BDE-B9EE-2489E8D35E7D}.Debug|Any CPU.Build.0 = Debug|Any CPU
{0BE11899-DF2D-4BDE-B9EE-2489E8D35E7D}.Debug|x64.ActiveCfg = Debug|Any CPU
@ -392,6 +428,18 @@ Global
{C16F112F-38C3-40BC-9F5F-4791112063D6}.Release|x64.Build.0 = Release|Any CPU
{C16F112F-38C3-40BC-9F5F-4791112063D6}.Release|x86.ActiveCfg = Release|Any CPU
{C16F112F-38C3-40BC-9F5F-4791112063D6}.Release|x86.Build.0 = Release|Any CPU
{D728444C-3D1F-4A0E-B4C9-5C9375D47EA3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{D728444C-3D1F-4A0E-B4C9-5C9375D47EA3}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D728444C-3D1F-4A0E-B4C9-5C9375D47EA3}.Debug|x64.ActiveCfg = Debug|Any CPU
{D728444C-3D1F-4A0E-B4C9-5C9375D47EA3}.Debug|x64.Build.0 = Debug|Any CPU
{D728444C-3D1F-4A0E-B4C9-5C9375D47EA3}.Debug|x86.ActiveCfg = Debug|Any CPU
{D728444C-3D1F-4A0E-B4C9-5C9375D47EA3}.Debug|x86.Build.0 = Debug|Any CPU
{D728444C-3D1F-4A0E-B4C9-5C9375D47EA3}.Release|Any CPU.ActiveCfg = Release|Any CPU
{D728444C-3D1F-4A0E-B4C9-5C9375D47EA3}.Release|Any CPU.Build.0 = Release|Any CPU
{D728444C-3D1F-4A0E-B4C9-5C9375D47EA3}.Release|x64.ActiveCfg = Release|Any CPU
{D728444C-3D1F-4A0E-B4C9-5C9375D47EA3}.Release|x64.Build.0 = Release|Any CPU
{D728444C-3D1F-4A0E-B4C9-5C9375D47EA3}.Release|x86.ActiveCfg = Release|Any CPU
{D728444C-3D1F-4A0E-B4C9-5C9375D47EA3}.Release|x86.Build.0 = Release|Any CPU
{BEE1C184-C9A4-410B-8DFC-FB74D5C93AEB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{BEE1C184-C9A4-410B-8DFC-FB74D5C93AEB}.Debug|Any CPU.Build.0 = Debug|Any CPU
{BEE1C184-C9A4-410B-8DFC-FB74D5C93AEB}.Debug|x64.ActiveCfg = Debug|Any CPU
@ -535,44 +583,6 @@ Global
{F6F9826A-BC58-4D78-A700-F358A66B2B06}.Release|x64.Build.0 = Release|Any CPU
{F6F9826A-BC58-4D78-A700-F358A66B2B06}.Release|x86.ActiveCfg = Release|Any CPU
{F6F9826A-BC58-4D78-A700-F358A66B2B06}.Release|x86.Build.0 = Release|Any CPU
{D728444C-3D1F-4A0E-B4C9-5C9375D47EA3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{D728444C-3D1F-4A0E-B4C9-5C9375D47EA3}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D728444C-3D1F-4A0E-B4C9-5C9375D47EA3}.Debug|x64.ActiveCfg = Debug|Any CPU
{D728444C-3D1F-4A0E-B4C9-5C9375D47EA3}.Debug|x64.Build.0 = Debug|Any CPU
{D728444C-3D1F-4A0E-B4C9-5C9375D47EA3}.Debug|x86.ActiveCfg = Debug|Any CPU
{D728444C-3D1F-4A0E-B4C9-5C9375D47EA3}.Debug|x86.Build.0 = Debug|Any CPU
{D728444C-3D1F-4A0E-B4C9-5C9375D47EA3}.Release|Any CPU.ActiveCfg = Release|Any CPU
{D728444C-3D1F-4A0E-B4C9-5C9375D47EA3}.Release|Any CPU.Build.0 = Release|Any CPU
{D728444C-3D1F-4A0E-B4C9-5C9375D47EA3}.Release|x64.ActiveCfg = Release|Any CPU
{D728444C-3D1F-4A0E-B4C9-5C9375D47EA3}.Release|x64.Build.0 = Release|Any CPU
{D728444C-3D1F-4A0E-B4C9-5C9375D47EA3}.Release|x86.ActiveCfg = Release|Any CPU
{D728444C-3D1F-4A0E-B4C9-5C9375D47EA3}.Release|x86.Build.0 = Release|Any CPU
{988E6191-82E1-4E13-9DDB-CB9FA2FDAF29}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{988E6191-82E1-4E13-9DDB-CB9FA2FDAF29}.Debug|Any CPU.Build.0 = Debug|Any CPU
{988E6191-82E1-4E13-9DDB-CB9FA2FDAF29}.Debug|x64.ActiveCfg = Debug|Any CPU
{988E6191-82E1-4E13-9DDB-CB9FA2FDAF29}.Debug|x64.Build.0 = Debug|Any CPU
{988E6191-82E1-4E13-9DDB-CB9FA2FDAF29}.Debug|x86.ActiveCfg = Debug|Any CPU
{988E6191-82E1-4E13-9DDB-CB9FA2FDAF29}.Debug|x86.Build.0 = Debug|Any CPU
{988E6191-82E1-4E13-9DDB-CB9FA2FDAF29}.Release|Any CPU.ActiveCfg = Release|Any CPU
{988E6191-82E1-4E13-9DDB-CB9FA2FDAF29}.Release|Any CPU.Build.0 = Release|Any CPU
{988E6191-82E1-4E13-9DDB-CB9FA2FDAF29}.Release|x64.ActiveCfg = Release|Any CPU
{988E6191-82E1-4E13-9DDB-CB9FA2FDAF29}.Release|x64.Build.0 = Release|Any CPU
{988E6191-82E1-4E13-9DDB-CB9FA2FDAF29}.Release|x86.ActiveCfg = Release|Any CPU
{988E6191-82E1-4E13-9DDB-CB9FA2FDAF29}.Release|x86.Build.0 = Release|Any CPU
{D58FA894-27D5-4EAA-9042-AD422AD82931}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{D58FA894-27D5-4EAA-9042-AD422AD82931}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D58FA894-27D5-4EAA-9042-AD422AD82931}.Debug|x64.ActiveCfg = Debug|Any CPU
{D58FA894-27D5-4EAA-9042-AD422AD82931}.Debug|x64.Build.0 = Debug|Any CPU
{D58FA894-27D5-4EAA-9042-AD422AD82931}.Debug|x86.ActiveCfg = Debug|Any CPU
{D58FA894-27D5-4EAA-9042-AD422AD82931}.Debug|x86.Build.0 = Debug|Any CPU
{D58FA894-27D5-4EAA-9042-AD422AD82931}.Release|Any CPU.ActiveCfg = Release|Any CPU
{D58FA894-27D5-4EAA-9042-AD422AD82931}.Release|Any CPU.Build.0 = Release|Any CPU
{D58FA894-27D5-4EAA-9042-AD422AD82931}.Release|x64.ActiveCfg = Release|Any CPU
{D58FA894-27D5-4EAA-9042-AD422AD82931}.Release|x64.Build.0 = Release|Any CPU
{D58FA894-27D5-4EAA-9042-AD422AD82931}.Release|x86.ActiveCfg = Release|Any CPU
{D58FA894-27D5-4EAA-9042-AD422AD82931}.Release|x86.Build.0 = Release|Any CPU
{AC26EFF0-8593-4184-9A09-98E37EFFB32E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{AC26EFF0-8593-4184-9A09-98E37EFFB32E}.Debug|Any CPU.Build.0 = Debug|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE

View File

@ -151,14 +151,11 @@ namespace Ryujinx.HLE.HOS.Applets.Browser
using CancellationTokenSource serverCts = new();
Task<(WebExitReason Reason, string LastUrl)> callbackTask = server.WaitForCallbackAsync(serverCts.Token);
// Open the URL in the system browser and show a waiting dialog
// Show the web content in an embedded WebView and wait for callback
bool userWaited = true;
try
{
// Open system browser
OpenSystemBrowser(url);
// Use the UI handler to show a blocking dialog
// Use the UI handler to display embedded web content
if (_system.Device.UIHandler != null)
{
using CancellationTokenSource uiCts = new();

View File

@ -1,5 +1,6 @@
using Avalonia;
using Avalonia.Threading;
using Avalonia.WebView.Desktop;
using DiscordRPC;
using Gommon;
using Projektanker.Icons.Avalonia;
@ -45,6 +46,7 @@ namespace Ryujinx.Ava
private const uint MbIconwarning = 0x30;
[STAThread]
public static int Main(string[] args)
{
Version = ReleaseInformation.Version;
@ -127,7 +129,8 @@ namespace Ryujinx.Ava
RenderingMode = UseHardwareAcceleration
? [Win32RenderingMode.AngleEgl, Win32RenderingMode.Software]
: [Win32RenderingMode.Software]
});
})
.UseDesktopWebView();
private static bool ConsumeCommandLineArgument(ref string[] args, string targetArgument)
{

View File

@ -51,6 +51,7 @@
<PackageReference Include="Avalonia.Diagnostics" Condition="'$(Configuration)'=='Debug'" />
<PackageReference Include="Avalonia.Controls.DataGrid" />
<PackageReference Include="Avalonia.Markup.Xaml.Loader" />
<PackageReference Include="Microsoft.Web.WebView2" />
<PackageReference Include="Svg.Controls.Avalonia" />
<PackageReference Include="Svg.Controls.Skia.Avalonia" />
<PackageReference Include="DynamicData" />
@ -74,6 +75,8 @@
<PackageReference Include="Silk.NET.Vulkan.Extensions.KHR" />
<PackageReference Include="SPB" />
<PackageReference Include="SharpZipLib" />
<PackageReference Include="WebView.Avalonia.AGPL" />
<PackageReference Include="WebView.Avalonia.Desktop" />
</ItemGroup>
<ItemGroup>

View File

@ -1,3 +1,4 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Threading;
using FluentAvalonia.UI.Controls;
@ -9,6 +10,7 @@ using Ryujinx.Ava.UI.Helpers;
using Ryujinx.Ava.UI.ViewModels;
using Ryujinx.Ava.UI.Windows;
using Ryujinx.Common;
using Ryujinx.Common.Logging;
using Ryujinx.HLE;
using Ryujinx.HLE.HOS.Applets;
using Ryujinx.HLE.HOS.Applets.SoftwareKeyboard;
@ -19,6 +21,7 @@ using System;
using System.Collections.ObjectModel;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
namespace Ryujinx.Ava.UI.Applet
{
@ -336,58 +339,30 @@ namespace Ryujinx.Ava.UI.Applet
public bool DisplayWebPage(string url, string title, CancellationToken cancellationToken)
{
ManualResetEvent dialogCloseEvent = new(false);
bool userCancelled = false;
bool closedByCallback = false;
Dispatcher.UIThread.InvokeAsync(async () =>
{
try
{
ContentDialog dialog = new()
if (OperatingSystem.IsLinux())
{
Title = title,
CloseButtonText = LocaleManager.Instance[LocaleKeys.SettingsButtonClose],
Content = new TextBlock
{
Text = "A web page has been opened in your system browser.\n\n" +
"Please interact with it to continue.\n\n" +
"This dialog will close automatically when the page sends a response.\n\n" +
"Click Close to skip the web applet.",
TextWrapping = Avalonia.Media.TextWrapping.Wrap,
MaxWidth = 400,
},
};
dialog.CloseButtonCommand = Commands.Create(() =>
// Linux: NativeWebView is not supported, use a fallback dialog
closedByCallback = await ShowFallbackDialog(title, cancellationToken);
}
else
{
if (!cancellationToken.IsCancellationRequested)
{
userCancelled = true;
}
});
// Register cancellation to close the dialog when the callback arrives
using CancellationTokenRegistration registration = cancellationToken.Register(() =>
{
Dispatcher.UIThread.Post(() =>
{
try
{
dialog.Hide();
}
catch
{
// Dialog may already be closed
}
});
});
await ContentDialogHelper.ShowAsync(dialog);
// Windows / macOS: use embedded NativeWebView in an overlay window
closedByCallback = await ShowWebViewOverlay(url, title, cancellationToken);
}
}
catch (Exception ex)
{
Ryujinx.Common.Logging.Logger.Warning?.Print(
Ryujinx.Common.Logging.LogClass.ServiceAm,
$"Error displaying web applet dialog: {ex.Message}");
Logger.Warning?.Print(LogClass.ServiceAm,
$"Error displaying embedded web applet: {ex.Message}");
// Fall back to a simple waiting dialog if WebView fails
closedByCallback = await ShowFallbackDialog(title, cancellationToken);
}
finally
{
@ -397,6 +372,77 @@ namespace Ryujinx.Ava.UI.Applet
dialogCloseEvent.WaitOne();
return closedByCallback;
}
/// <summary>
/// Shows an embedded NativeWebView in a full-screen overlay window (Windows/macOS).
/// </summary>
private async Task<bool> ShowWebViewOverlay(string url, string title, CancellationToken cancellationToken)
{
MainWindow mainWindow = RyujinxApp.MainWindow;
WebAppletWindow webWindow = new()
{
Title = title,
Width = mainWindow.Bounds.Width,
Height = mainWindow.Bounds.Height,
Position = mainWindow.PointToScreen(new Point()),
};
webWindow.Navigate(url, cancellationToken);
await webWindow.ShowDialog(mainWindow);
return webWindow.ClosedByCallback;
}
/// <summary>
/// Fallback dialog shown when WebView is unavailable (Linux) or initialization fails.
/// </summary>
private async Task<bool> ShowFallbackDialog(string title, CancellationToken cancellationToken)
{
bool userCancelled = false;
ContentDialog dialog = new()
{
Title = title,
CloseButtonText = LocaleManager.Instance[LocaleKeys.SettingsButtonClose],
Content = new TextBlock
{
Text = "The web applet is running.\n\n" +
"This dialog will close automatically when the applet completes.\n\n" +
"Click Close to skip.",
TextWrapping = Avalonia.Media.TextWrapping.Wrap,
MaxWidth = 400,
},
};
dialog.CloseButtonCommand = Commands.Create(() =>
{
if (!cancellationToken.IsCancellationRequested)
{
userCancelled = true;
}
});
using CancellationTokenRegistration registration = cancellationToken.Register(() =>
{
Dispatcher.UIThread.Post(() =>
{
try
{
dialog.Hide();
}
catch
{
// Dialog may already be closed
}
});
});
await ContentDialogHelper.ShowAsync(dialog);
return !userCancelled;
}
}

View File

@ -0,0 +1,45 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="using:Ryujinx.Ava.UI.Applet"
x:Class="Ryujinx.Ava.UI.Applet.WebAppletWindow"
Title="Web Applet"
Background="Black"
Width="1280"
Height="720"
CanResize="False"
ShowInTaskbar="False"
WindowStartupLocation="Manual"
SystemDecorations="Full"
ExtendClientAreaToDecorationsHint="True"
ExtendClientAreaChromeHints="NoChrome"
ExtendClientAreaTitleBarHeightHint="-1">
<Grid Name="RootGrid">
<Grid.RowDefinitions>
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<!-- WebView2 native host fills the main area -->
<local:WebView2Host x:Name="WebViewHost" Grid.Row="0" />
<!-- Loading overlay (shown until WebView2 is ready) -->
<TextBlock Name="LoadingText"
Grid.Row="0"
Text="Initializing web applet..."
Foreground="White"
FontSize="18"
HorizontalAlignment="Center"
VerticalAlignment="Center" />
<!-- Bottom bar with Skip button -->
<Border Grid.Row="1" Background="#1A1A1A" Padding="8">
<Button Name="CloseButton"
Content="Skip"
HorizontalAlignment="Right"
Padding="24,6"
FontSize="13"
Click="CloseButton_Click" />
</Border>
</Grid>
</Window>

View File

@ -0,0 +1,122 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Threading;
using Ryujinx.Common.Logging;
using System;
using System.Threading;
namespace Ryujinx.Ava.UI.Applet
{
public partial class WebAppletWindow : Window
{
private CancellationTokenRegistration _cancellationRegistration;
private string _pendingUrl;
private bool _navigated;
/// <summary>
/// True if the window was closed by the callback (not by user).
/// </summary>
public bool ClosedByCallback { get; private set; }
public WebAppletWindow()
{
InitializeComponent();
// Subscribe to WebView2Host events
WebViewHost.WebView2Ready += OnWebView2Ready;
WebViewHost.NavigationCompleted += OnNavigationCompleted;
}
/// <summary>
/// Registers the URL and cancellation token. Navigation happens once WebView2 is ready.
/// </summary>
public void Navigate(string url, CancellationToken cancellationToken)
{
Logger.Info?.Print(LogClass.ServiceAm, $"WebAppletWindow: URL queued: {url}");
_pendingUrl = url;
_cancellationRegistration = cancellationToken.Register(() =>
{
ClosedByCallback = true;
Dispatcher.UIThread.Post(() =>
{
try
{
Close();
}
catch
{
// Window may already be closed
}
});
});
}
protected override void OnOpened(EventArgs e)
{
base.OnOpened(e);
Logger.Info?.Print(LogClass.ServiceAm, "WebAppletWindow: Window opened");
}
private void OnWebView2Ready(bool success, string errorMessage)
{
Dispatcher.UIThread.Post(() =>
{
if (success)
{
Logger.Info?.Print(LogClass.ServiceAm, "WebAppletWindow: WebView2 ready, navigating...");
if (!_navigated && !string.IsNullOrEmpty(_pendingUrl))
{
_navigated = true;
WebViewHost.NavigateToUrl(_pendingUrl);
}
}
else
{
Logger.Error?.Print(LogClass.ServiceAm,
$"WebAppletWindow: WebView2 failed: {errorMessage}");
TextBlock loadingText = this.FindControl<TextBlock>("LoadingText");
if (loadingText != null)
{
loadingText.Text = $"WebView2 initialization failed.\n{errorMessage}\n\nClick 'Skip' to continue.";
loadingText.TextAlignment = Avalonia.Media.TextAlignment.Center;
}
}
});
}
private void OnNavigationCompleted(bool success)
{
Dispatcher.UIThread.Post(() =>
{
if (success)
{
// Hide loading text once page loads
TextBlock loadingText = this.FindControl<TextBlock>("LoadingText");
if (loadingText != null)
{
loadingText.IsVisible = false;
}
}
});
}
private void CloseButton_Click(object sender, RoutedEventArgs e)
{
ClosedByCallback = false;
Close();
}
protected override void OnClosed(EventArgs e)
{
WebViewHost.WebView2Ready -= OnWebView2Ready;
WebViewHost.NavigationCompleted -= OnNavigationCompleted;
_cancellationRegistration.Dispose();
base.OnClosed(e);
}
}
}

View File

@ -0,0 +1,266 @@
using Avalonia.Controls;
using Avalonia.Platform;
using Microsoft.Web.WebView2.Core;
using Ryujinx.Common.Logging;
using System;
using System.IO;
using System.Runtime.InteropServices;
using System.Runtime.Versioning;
using System.Threading.Tasks;
using static Ryujinx.Ava.UI.Helpers.Win32NativeInterop;
namespace Ryujinx.Ava.UI.Applet
{
/// <summary>
/// An Avalonia NativeControlHost that embeds a WebView2 browser instance directly
/// using the CoreWebView2 COM API. Handles WM_SIZE to keep the WebView2 controller
/// properly sized when Avalonia resizes the host window.
/// </summary>
public class WebView2Host : NativeControlHost
{
private const uint WM_SIZE = 0x0005;
private nint _hwnd;
private CoreWebView2Controller _controller;
private CoreWebView2Environment _environment;
private string _pendingUrl;
private bool _isInitialized;
private WindowProc _wndProcDelegate;
private string _className;
/// <summary>Fires when the WebView2 engine is ready (or failed). Args: (success, errorMessage).</summary>
public event Action<bool, string> WebView2Ready;
/// <summary>Fires when navigation starts. Arg: URL.</summary>
public event Action<string> NavigationStarted;
/// <summary>Fires when navigation completes. Arg: success.</summary>
public event Action<bool> NavigationCompleted;
/// <summary>True once the WebView2 engine is fully initialized.</summary>
public bool IsWebView2Ready => _isInitialized;
/// <summary>
/// Queues a URL for navigation. If WebView2 is already initialized, navigates immediately.
/// </summary>
public void NavigateToUrl(string url)
{
_pendingUrl = url;
if (_controller?.CoreWebView2 != null)
{
_controller.CoreWebView2.Navigate(url);
}
}
protected override IPlatformHandle CreateNativeControlCore(IPlatformHandle parent)
{
if (OperatingSystem.IsWindows())
{
return CreateWin32(parent);
}
Logger.Warning?.Print(LogClass.ServiceAm, "WebView2Host: Only supported on Windows");
return base.CreateNativeControlCore(parent);
}
protected override void DestroyNativeControlCore(IPlatformHandle control)
{
if (OperatingSystem.IsWindows())
{
DestroyWin32();
}
else
{
base.DestroyNativeControlCore(control);
}
}
[SupportedOSPlatform("windows")]
private PlatformHandle CreateWin32(IPlatformHandle parent)
{
_className = "RyujinxWebView2-" + Guid.NewGuid();
// Window proc that handles WM_SIZE to keep WebView2 controller bounds in sync
_wndProcDelegate = (hWnd, msg, wParam, lParam) =>
{
if ((uint)msg == WM_SIZE && _controller != null)
{
int width = (int)(lParam & 0xFFFF);
int height = (int)((lParam >> 16) & 0xFFFF);
if (width > 0 && height > 0)
{
_controller.Bounds = new System.Drawing.Rectangle(0, 0, width, height);
}
}
return DefWindowProc(hWnd, msg, wParam, lParam);
};
WndClassEx wndClassEx = new()
{
cbSize = Marshal.SizeOf<WndClassEx>(),
hInstance = GetModuleHandle(null),
lpfnWndProc = Marshal.GetFunctionPointerForDelegate(_wndProcDelegate),
style = ClassStyles.CsOwndc,
lpszClassName = Marshal.StringToHGlobalUni(_className),
hCursor = CreateArrowCursor(),
};
RegisterClassEx(ref wndClassEx);
_hwnd = CreateWindowEx(
0, _className, "WebView2Host",
WindowStyles.WsChild,
0, 0, 1, 1, // Initial size doesn't matter - NativeControlHost will resize via WM_SIZE
parent.Handle, nint.Zero, nint.Zero, nint.Zero);
Marshal.FreeHGlobal(wndClassEx.lpszClassName);
if (_hwnd == nint.Zero)
{
Logger.Error?.Print(LogClass.ServiceAm,
$"WebView2Host: Failed to create child window. Error: {Marshal.GetLastWin32Error()}");
return new PlatformHandle(nint.Zero, "HWND");
}
Logger.Info?.Print(LogClass.ServiceAm,
$"WebView2Host: Child window created (HWND=0x{_hwnd:X})");
// Start async WebView2 initialization
_ = InitializeWebView2Async();
return new PlatformHandle(_hwnd, "HWND");
}
[SupportedOSPlatform("windows")]
private async Task InitializeWebView2Async()
{
try
{
Logger.Info?.Print(LogClass.ServiceAm, "WebView2Host: Creating CoreWebView2Environment...");
string userDataFolder = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"Ryujinx", "WebView2");
_environment = await CoreWebView2Environment.CreateAsync(
browserExecutableFolder: null,
userDataFolder: userDataFolder);
Logger.Info?.Print(LogClass.ServiceAm,
$"WebView2Host: Environment created (version={_environment.BrowserVersionString}). Creating controller...");
_controller = await _environment.CreateCoreWebView2ControllerAsync(_hwnd);
Logger.Info?.Print(LogClass.ServiceAm, "WebView2Host: Controller created. Configuring...");
// Query the actual HWND size (in physical pixels) and set controller bounds
if (GetClientRect(_hwnd, out RECT rect))
{
int width = rect.Right - rect.Left;
int height = rect.Bottom - rect.Top;
_controller.Bounds = new System.Drawing.Rectangle(0, 0,
Math.Max(1, width), Math.Max(1, height));
Logger.Info?.Print(LogClass.ServiceAm,
$"WebView2Host: Initial bounds set to {width}x{height} (physical pixels)");
}
_controller.IsVisible = true;
// Configure settings
CoreWebView2Settings settings = _controller.CoreWebView2.Settings;
settings.AreDevToolsEnabled = false;
settings.AreDefaultContextMenusEnabled = false;
settings.IsStatusBarEnabled = false;
settings.IsZoomControlEnabled = false;
// Subscribe to events
_controller.CoreWebView2.NavigationStarting += (s, e) =>
{
Logger.Info?.Print(LogClass.ServiceAm, $"WebView2: Navigation starting: {e.Uri}");
NavigationStarted?.Invoke(e.Uri);
};
_controller.CoreWebView2.NavigationCompleted += (s, e) =>
{
Logger.Info?.Print(LogClass.ServiceAm,
$"WebView2: Navigation completed (Success={e.IsSuccess}, Status={e.WebErrorStatus})");
NavigationCompleted?.Invoke(e.IsSuccess);
};
_controller.CoreWebView2.NewWindowRequested += (s, e) =>
{
Logger.Info?.Print(LogClass.ServiceAm, $"WebView2: New window requested: {e.Uri}");
e.Handled = true;
_controller.CoreWebView2.Navigate(e.Uri);
};
_isInitialized = true;
Logger.Info?.Print(LogClass.ServiceAm,
$"WebView2Host: Initialization complete! Browser version: {_environment.BrowserVersionString}");
// Navigate to pending URL
if (!string.IsNullOrEmpty(_pendingUrl))
{
Logger.Info?.Print(LogClass.ServiceAm, $"WebView2Host: Navigating to pending URL: {_pendingUrl}");
_controller.CoreWebView2.Navigate(_pendingUrl);
}
WebView2Ready?.Invoke(true, null);
}
catch (Exception ex)
{
Logger.Error?.Print(LogClass.ServiceAm,
$"WebView2Host: Initialization FAILED: {ex}");
WebView2Ready?.Invoke(false, ex.Message);
}
}
[SupportedOSPlatform("windows")]
private void DestroyWin32()
{
try
{
_controller?.Close();
_controller = null;
}
catch (Exception ex)
{
Logger.Warning?.Print(LogClass.ServiceAm,
$"WebView2Host: Error closing controller: {ex.Message}");
}
if (_hwnd != nint.Zero)
{
DestroyWindow(_hwnd);
_hwnd = nint.Zero;
}
if (!string.IsNullOrEmpty(_className))
{
UnregisterClass(_className, GetModuleHandle(null));
}
}
#region Win32 Interop
[StructLayout(LayoutKind.Sequential)]
private struct RECT
{
public int Left;
public int Top;
public int Right;
public int Bottom;
}
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool GetClientRect(nint hWnd, out RECT lpRect);
#endregion
}
}

View File

@ -5,6 +5,7 @@ using Avalonia.Markup.Xaml;
using Avalonia.Platform;
using Avalonia.Styling;
using Avalonia.Threading;
using AvaloniaWebView;
using FluentAvalonia.UI.Windowing;
using Gommon;
using Ryujinx.Ava.Common.Locale;
@ -49,6 +50,14 @@ namespace Ryujinx.Ava
public static void SetTaskbarProgressValue(ulong current, ulong total) => MainWindow.PlatformFeatures.SetTaskBarProgressBarValue(current, total);
public static void SetTaskbarProgressValue(long current, long total) => SetTaskbarProgressValue(Convert.ToUInt64(current), Convert.ToUInt64(total));
public override void RegisterServices()
{
base.RegisterServices();
// Initialize the WebView builder so that embedded WebView controls work.
AvaloniaWebViewBuilder.Initialize(default);
}
public override void Initialize()
{
Name = FormatTitle();