diff --git a/Directory.Packages.props b/Directory.Packages.props
index fd61602a8..3fe05425d 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -8,6 +8,7 @@
+
@@ -58,5 +59,7 @@
+
+
\ No newline at end of file
diff --git a/Ryujinx.sln b/Ryujinx.sln
index deddb97a0..07b996f4a 100644
--- a/Ryujinx.sln
+++ b/Ryujinx.sln
@@ -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
diff --git a/src/Ryujinx.HLE/HOS/Applets/AppletManager.cs b/src/Ryujinx.HLE/HOS/Applets/AppletManager.cs
index 5bda957bc..159b7e205 100644
--- a/src/Ryujinx.HLE/HOS/Applets/AppletManager.cs
+++ b/src/Ryujinx.HLE/HOS/Applets/AppletManager.cs
@@ -24,7 +24,7 @@ namespace Ryujinx.HLE.HOS.Applets
case AppletId.LibAppletWeb:
case AppletId.LibAppletShop:
case AppletId.LibAppletOff:
- return new BrowserApplet();
+ return new BrowserApplet(system);
case AppletId.MiiEdit:
Logger.Warning?.Print(LogClass.Application, $"Please use the MiiEdit inside File/Open Applet");
return new DummyApplet(system);
diff --git a/src/Ryujinx.HLE/HOS/Applets/Browser/BrowserApplet.cs b/src/Ryujinx.HLE/HOS/Applets/Browser/BrowserApplet.cs
index c8bbfc4b4..e682656a7 100644
--- a/src/Ryujinx.HLE/HOS/Applets/Browser/BrowserApplet.cs
+++ b/src/Ryujinx.HLE/HOS/Applets/Browser/BrowserApplet.cs
@@ -1,11 +1,27 @@
+using LibHac.Common;
+using LibHac.Fs;
+using LibHac.Fs.Fsa;
+using LibHac.FsSystem;
+using LibHac.Ncm;
+using LibHac.Tools.Fs;
+using LibHac.Tools.FsSystem;
+using LibHac.Tools.FsSystem.NcaUtils;
using Microsoft.IO;
using Ryujinx.Common;
+using Ryujinx.Common.Configuration;
using Ryujinx.Common.Logging;
using Ryujinx.Common.Memory;
+using Ryujinx.HLE.FileSystem;
using Ryujinx.HLE.HOS.Services.Am.AppletAE;
using System;
using System.Collections.Generic;
+using System.Diagnostics;
using System.IO;
+using System.Linq;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using Path = System.IO.Path;
namespace Ryujinx.HLE.HOS.Applets.Browser
{
@@ -13,12 +29,18 @@ namespace Ryujinx.HLE.HOS.Applets.Browser
{
public event EventHandler AppletStateChanged;
+ private readonly Horizon _system;
private AppletSession _normalSession;
private CommonArguments _commonArguments;
private List _arguments;
private ShimKind _shimKind;
+ public BrowserApplet(Horizon system)
+ {
+ _system = system;
+ }
+
public ResultCode Start(AppletSession normalSession, AppletSession interactiveSession)
{
_normalSession = normalSession;
@@ -38,30 +60,720 @@ namespace Ryujinx.HLE.HOS.Applets.Browser
Logger.Stub?.PrintStub(LogClass.ServiceAm, $"{argument.Type}: {argument.GetValue()}");
}
- if ((_commonArguments.AppletVersion >= 0x80000 && _shimKind == ShimKind.Web) || (_commonArguments.AppletVersion >= 0x30000 && _shimKind == ShimKind.Share))
+ if (_shimKind == ShimKind.Offline)
{
- List result =
- [
- new(BrowserOutputType.ExitReason, (uint)WebExitReason.ExitButton)
- ];
+ return HandleOfflineApplet();
+ }
- _normalSession.Push(BuildResponseNew(result));
+ return HandleStubBrowser();
+ }
+
+ ///
+ /// Handles the offline web applet by extracting HTML content from the game's RomFS,
+ /// serving it via a local HTTP server, and capturing callback URLs.
+ /// Falls back to stub behavior if the content cannot be accessed.
+ ///
+ private ResultCode HandleOfflineApplet()
+ {
+ string documentPath = GetDocumentPath();
+ DocumentKind documentKind = GetDocumentKind();
+
+ Logger.Info?.Print(LogClass.ServiceAm, $"Offline applet: DocumentPath={documentPath}, DocumentKind={documentKind}");
+
+ // Get just the file path part (without query string)
+ string documentFilePath = documentPath;
+ int qIdx = documentPath.IndexOf('?');
+ if (qIdx >= 0)
+ {
+ documentFilePath = documentPath[..qIdx];
+ }
+
+ // Try to extract and serve the offline content
+ string extractedDir = null;
+ try
+ {
+ extractedDir = ExtractOfflineContent(documentKind, documentFilePath);
+ }
+ catch (Exception ex)
+ {
+ Logger.Warning?.Print(LogClass.ServiceAm, $"Failed to extract offline content: {ex.Message}");
+ }
+
+ if (extractedDir != null && Directory.Exists(extractedDir))
+ {
+ return ServeOfflineContent(extractedDir, documentPath);
+ }
+
+ // Fall back to stub behavior if extraction failed
+ Logger.Warning?.Print(LogClass.ServiceAm, "Falling back to stub browser behavior for offline applet");
+ return HandleStubBrowser();
+ }
+
+ ///
+ /// Serves extracted offline content via a local HTTP server and captures callback URLs.
+ ///
+ private ResultCode ServeOfflineContent(string documentRoot, string documentPath)
+ {
+ // Split documentPath into file path and query string
+ string filePath = documentPath;
+ int queryIndex = documentPath.IndexOf('?');
+ if (queryIndex >= 0)
+ {
+ filePath = documentPath[..queryIndex];
+ }
+
+ // Find the actual document root - the document path might be directly in the
+ // cache directory, or under a subdirectory like "html-document/"
+ string actualDocumentRoot = ResolveDocumentRoot(documentRoot, filePath);
+ Logger.Info?.Print(LogClass.ServiceAm, $"Resolved document root: {actualDocumentRoot} (original: {documentRoot})");
+
+ using OfflineWebServer server = new(actualDocumentRoot);
+
+ string url = server.Start(documentPath);
+ if (url == null)
+ {
+ Logger.Error?.Print(LogClass.ServiceAm, "Failed to start offline web server");
+ return HandleStubBrowser();
+ }
+
+ Logger.Info?.Print(LogClass.ServiceAm, $"Offline web applet serving at: {url}");
+
+ // Verify the document file actually exists on disk before opening browser
+ string localFilePath = Path.Combine(actualDocumentRoot, filePath.Replace('/', Path.DirectorySeparatorChar));
+ if (!File.Exists(localFilePath))
+ {
+ Logger.Warning?.Print(LogClass.ServiceAm,
+ $"Document file not found on disk: {localFilePath}. Skipping browser launch.");
+ return HandleStubBrowser();
+ }
+
+ // Start waiting for callback in background
+ using CancellationTokenSource serverCts = new();
+ Task<(WebExitReason Reason, string LastUrl)> callbackTask = server.WaitForCallbackAsync(serverCts.Token);
+
+ // Show the web content in an embedded WebView and wait for callback
+ bool userWaited = true;
+ try
+ {
+ // Use the UI handler to display embedded web content
+ if (_system.Device.UIHandler != null)
+ {
+ using CancellationTokenSource uiCts = new();
+
+ // When the callback is received, cancel the UI dialog
+ callbackTask.ContinueWith(_ => uiCts.Cancel(), TaskContinuationOptions.NotOnCanceled);
+
+ userWaited = _system.Device.UIHandler.DisplayWebPage(url, "Web Applet", uiCts.Token);
+ }
+ else
+ {
+ // No UI handler - just wait for callback with a timeout
+ try
+ {
+ callbackTask.Wait(TimeSpan.FromMinutes(5));
+ }
+ catch (AggregateException)
+ {
+ // Timeout or cancellation
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ Logger.Warning?.Print(LogClass.ServiceAm, $"Error during web applet display: {ex.Message}");
+ }
+
+ // Get the result
+ WebExitReason exitReason;
+ string lastUrl;
+
+ if (callbackTask.IsCompletedSuccessfully)
+ {
+ (exitReason, lastUrl) = callbackTask.Result;
+ Logger.Info?.Print(LogClass.ServiceAm, $"Web applet callback received: ExitReason={exitReason}, LastUrl={lastUrl}");
+ }
+ else if (!userWaited)
+ {
+ // User cancelled
+ exitReason = WebExitReason.WindowClosed;
+ lastUrl = "http://localhost/";
+ Logger.Info?.Print(LogClass.ServiceAm, "Web applet cancelled by user");
}
else
{
- WebCommonReturnValue result = new()
- {
- ExitReason = WebExitReason.ExitButton,
- };
-
- _normalSession.Push(BuildResponseOld(result));
+ // Timeout or error - use default
+ exitReason = WebExitReason.WindowClosed;
+ lastUrl = "http://localhost/";
+ Logger.Warning?.Print(LogClass.ServiceAm, "Web applet timed out or encountered an error");
}
+ return BuildAndPushResponse(exitReason, lastUrl);
+ }
+
+ ///
+ /// Stub browser behavior for when offline content can't be served.
+ ///
+ private ResultCode HandleStubBrowser()
+ {
+ WebExitReason exitReason;
+ string lastUrl = string.Empty;
+
+ if (_shimKind == ShimKind.Offline)
+ {
+ exitReason = WebExitReason.WindowClosed;
+ lastUrl = "http://localhost/";
+ }
+ else
+ {
+ exitReason = WebExitReason.ExitButton;
+ }
+
+ return BuildAndPushResponse(exitReason, lastUrl);
+ }
+
+ ///
+ /// Builds the applet response and pushes it to the normal session.
+ ///
+ private ResultCode BuildAndPushResponse(WebExitReason exitReason, string lastUrl)
+ {
+ WebCommonReturnValue result = new()
+ {
+ ExitReason = exitReason,
+ };
+
+ if (!string.IsNullOrEmpty(lastUrl))
+ {
+ byte[] urlBytes = Encoding.UTF8.GetBytes(lastUrl);
+ int copyLength = Math.Min(urlBytes.Length, 4096);
+ urlBytes.AsSpan(0, copyLength).CopyTo(result.LastUrl.AsSpan());
+ result.LastUrlSize = (ulong)copyLength;
+ }
+
+ _normalSession.Push(BuildResponseOld(result));
+
AppletStateChanged?.Invoke(this, null);
+ _system.ReturnFocus();
+
return ResultCode.Success;
}
+ ///
+ /// Resolves the actual document root directory based on where the document file exists.
+ /// The RomFS might place files directly or under subdirectories like "html-document/".
+ ///
+ private static string ResolveDocumentRoot(string extractedDir, string documentFilePath)
+ {
+ // Try the extracted dir directly
+ if (File.Exists(Path.Combine(extractedDir, documentFilePath.Replace('/', Path.DirectorySeparatorChar))))
+ {
+ return extractedDir;
+ }
+
+ // Try common subdirectories (html-document, legal-information)
+ string[] prefixes = ["html-document", "legal-information"];
+ foreach (string prefix in prefixes)
+ {
+ string prefixedRoot = Path.Combine(extractedDir, prefix);
+ if (Directory.Exists(prefixedRoot) &&
+ File.Exists(Path.Combine(prefixedRoot, documentFilePath.Replace('/', Path.DirectorySeparatorChar))))
+ {
+ return prefixedRoot;
+ }
+ }
+
+ // Try all immediate subdirectories
+ try
+ {
+ foreach (string subDir in Directory.GetDirectories(extractedDir))
+ {
+ if (File.Exists(Path.Combine(subDir, documentFilePath.Replace('/', Path.DirectorySeparatorChar))))
+ {
+ return subDir;
+ }
+ }
+ }
+ catch
+ {
+ // Ignore directory enumeration errors
+ }
+
+ // Fall back to the extracted directory itself
+ Logger.Warning?.Print(LogClass.ServiceAm,
+ $"Could not find '{documentFilePath}' in extracted content. " +
+ $"Available files: {string.Join(", ", Directory.GetFiles(extractedDir, "*", SearchOption.AllDirectories).Select(f => f[(extractedDir.Length + 1)..]))}");
+
+ return extractedDir;
+ }
+
+ ///
+ /// Extracts the offline HTML content from the game's Manual NCA RomFS to a temp directory.
+ ///
+ private string ExtractOfflineContent(DocumentKind documentKind, string documentFilePath)
+ {
+ ulong titleId = GetTargetTitleId(documentKind);
+ if (titleId == 0)
+ {
+ Logger.Warning?.Print(LogClass.ServiceAm, "Could not determine title ID for offline content");
+ return null;
+ }
+
+ // Create a cache directory for all extracted RomFS content
+ string cacheDir = Path.Combine(
+ AppDataManager.BaseDirPath,
+ "games",
+ titleId.ToString("x16"),
+ "cache",
+ "offline_web");
+
+ // Check if content is already extracted AND contains the requested document
+ if (Directory.Exists(cacheDir) && Directory.GetFiles(cacheDir, "*", SearchOption.AllDirectories).Length > 0)
+ {
+ // Verify the cached content contains the requested document
+ string resolvedRoot = ResolveDocumentRoot(cacheDir, documentFilePath);
+ string testPath = Path.Combine(resolvedRoot, documentFilePath.Replace('/', Path.DirectorySeparatorChar));
+
+ if (File.Exists(testPath))
+ {
+ Logger.Info?.Print(LogClass.ServiceAm, $"Using cached offline content from: {cacheDir}");
+ return cacheDir;
+ }
+
+ // Cached content doesn't have the file - delete and re-extract
+ Logger.Info?.Print(LogClass.ServiceAm, $"Cached content missing '{documentFilePath}', re-extracting...");
+ try
+ {
+ Directory.Delete(cacheDir, true);
+ }
+ catch (Exception ex)
+ {
+ Logger.Warning?.Print(LogClass.ServiceAm, $"Failed to clear cache: {ex.Message}");
+ }
+ }
+
+ // Find and open the Manual NCA that contains the requested document
+ Nca manualNca = FindManualNca(titleId, documentFilePath);
+ if (manualNca == null)
+ {
+ Logger.Warning?.Print(LogClass.ServiceAm, $"Could not find Manual NCA for title {titleId:x16}");
+ return null;
+ }
+
+ // Extract RomFS content
+ try
+ {
+ LibHac.Fs.IStorage romfsStorage = manualNca.OpenStorage(NcaSectionType.Data, _system.FsIntegrityCheckLevel);
+ LibHac.Tools.FsSystem.RomFs.RomFsFileSystem romfs = new(romfsStorage);
+
+ Logger.Info?.Print(LogClass.ServiceAm, $"Extracting offline content to: {cacheDir}");
+
+ Directory.CreateDirectory(cacheDir);
+
+ // Log the top-level directory structure for debugging
+ foreach (DirectoryEntryEx entry in romfs.EnumerateEntries("/", "*", SearchOptions.Default))
+ {
+ Logger.Debug?.Print(LogClass.ServiceAm, $"RomFS entry: {entry.FullPath} (Type={entry.Type})");
+ }
+
+ // Extract ALL files from the RomFS
+ foreach (DirectoryEntryEx entry in romfs.EnumerateEntries()
+ .Where(f => f.Type == DirectoryEntryType.File))
+ {
+ // Remove leading '/' to get relative path
+ string relativePath = entry.FullPath.TrimStart('/');
+ string outputPath = Path.Combine(cacheDir, relativePath.Replace('/', Path.DirectorySeparatorChar));
+ string outputDir = Path.GetDirectoryName(outputPath);
+
+ if (!string.IsNullOrEmpty(outputDir))
+ {
+ Directory.CreateDirectory(outputDir);
+ }
+
+ using UniqueRef file = new();
+ LibHac.Result result = romfs.OpenFile(ref file.Ref, entry.FullPath.ToU8Span(), OpenMode.Read);
+
+ if (result.IsFailure())
+ {
+ Logger.Warning?.Print(LogClass.ServiceAm, $"Failed to open RomFS file: {entry.FullPath}");
+ continue;
+ }
+
+ file.Get.GetSize(out long fileSize).ThrowIfFailure();
+ byte[] data = new byte[fileSize];
+ file.Get.Read(out _, 0, data, ReadOption.None).ThrowIfFailure();
+
+ File.WriteAllBytes(outputPath, data);
+ }
+
+ int fileCount = Directory.GetFiles(cacheDir, "*", SearchOption.AllDirectories).Length;
+ Logger.Info?.Print(LogClass.ServiceAm, $"Extracted {fileCount} files from offline content");
+
+ return fileCount > 0 ? cacheDir : null;
+ }
+ catch (Exception ex)
+ {
+ Logger.Error?.Print(LogClass.ServiceAm, $"Failed to extract RomFS: {ex}");
+ return null;
+ }
+ }
+
+ ///
+ /// Gets the target title ID based on the document kind.
+ ///
+ private ulong GetTargetTitleId(DocumentKind documentKind)
+ {
+ ulong applicationId = GetApplicationId();
+
+ switch (documentKind)
+ {
+ case DocumentKind.OfflineHtmlPage:
+ // For offline HTML pages, use the current application's title ID
+ if (applicationId != 0)
+ {
+ return applicationId;
+ }
+
+ // Fall back to active application
+ try
+ {
+ return _system.Device.Processes.ActiveApplication.ProgramId;
+ }
+ catch
+ {
+ return 0;
+ }
+
+ case DocumentKind.ApplicationLegalInformation:
+ return applicationId != 0 ? applicationId : 0;
+
+ case DocumentKind.SystemDataPage:
+ return GetSystemDataId();
+
+ default:
+ return 0;
+ }
+ }
+
+ ///
+ /// Finds the Manual NCA for the given title ID by searching all known storage locations.
+ /// Falls back to directly scanning the game file (XCI/NSP) if not found in ContentManager.
+ /// The documentFilePath is used to verify the NCA contains the requested content.
+ ///
+ private Nca FindManualNca(ulong titleId, string documentFilePath)
+ {
+ // First, try the ContentManager (works for installed games)
+ Nca nca = FindManualNcaFromContentManager(titleId);
+ if (nca != null && NcaContainsDocument(nca, documentFilePath))
+ {
+ return nca;
+ }
+
+ // Fall back to scanning the game file directly (for XCI/NSP loaded games)
+ nca = FindManualNcaFromGameFile(titleId, documentFilePath);
+ if (nca != null)
+ {
+ return nca;
+ }
+
+ Logger.Warning?.Print(LogClass.ServiceAm, $"Manual NCA containing '{documentFilePath}' not found for title {titleId:x16} in any location");
+ return null;
+ }
+
+ ///
+ /// Checks if an NCA's RomFS contains a file matching the given document path.
+ /// Searches both at the root level and under common prefixes (html-document/, etc.).
+ ///
+ private bool NcaContainsDocument(Nca nca, string documentFilePath)
+ {
+ if (string.IsNullOrEmpty(documentFilePath))
+ {
+ return true; // Can't verify, assume it's there
+ }
+
+ try
+ {
+ LibHac.Fs.IStorage romfsStorage = nca.OpenStorage(NcaSectionType.Data, _system.FsIntegrityCheckLevel);
+ LibHac.Tools.FsSystem.RomFs.RomFsFileSystem romfs = new(romfsStorage);
+
+ // Check directly: /{documentFilePath}
+ string directPath = $"/{documentFilePath}";
+ if (FileExistsInRomFs(romfs, directPath))
+ {
+ Logger.Debug?.Print(LogClass.ServiceAm, $"Found document at: {directPath}");
+ return true;
+ }
+
+ // Check under common prefixes
+ string[] prefixes = ["html-document", "legal-information"];
+ foreach (string prefix in prefixes)
+ {
+ string prefixedPath = $"/{prefix}/{documentFilePath}";
+ if (FileExistsInRomFs(romfs, prefixedPath))
+ {
+ Logger.Debug?.Print(LogClass.ServiceAm, $"Found document at: {prefixedPath}");
+ return true;
+ }
+ }
+
+ // Log what's actually in this NCA for debugging
+ var entries = romfs.EnumerateEntries("/", "*", SearchOptions.Default).Take(5).ToList();
+ Logger.Debug?.Print(LogClass.ServiceAm,
+ $"NCA does not contain '{documentFilePath}'. Sample entries: {string.Join(", ", entries.Select(e => e.FullPath))}");
+
+ return false;
+ }
+ catch (Exception ex)
+ {
+ Logger.Debug?.Print(LogClass.ServiceAm, $"Error checking NCA contents: {ex.Message}");
+ return true; // Can't verify, assume it might be there
+ }
+ }
+
+ ///
+ /// Checks if a file exists in a RomFS at the given path.
+ ///
+ private static bool FileExistsInRomFs(LibHac.Tools.FsSystem.RomFs.RomFsFileSystem romfs, string path)
+ {
+ try
+ {
+ using UniqueRef file = new();
+ LibHac.Result result = romfs.OpenFile(ref file.Ref, path.ToU8Span(), OpenMode.Read);
+ return result.IsSuccess();
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ ///
+ /// Tries to find the Manual NCA through the ContentManager (for installed games).
+ ///
+ private Nca FindManualNcaFromContentManager(ulong titleId)
+ {
+ ContentManager contentManager = _system.Device.Configuration.ContentManager;
+
+ StorageId[] storageIds =
+ [
+ StorageId.BuiltInUser,
+ StorageId.GameCard,
+ StorageId.SdCard,
+ StorageId.BuiltInSystem,
+ ];
+
+ foreach (StorageId storageId in storageIds)
+ {
+ try
+ {
+ string contentPath = contentManager.GetInstalledContentPath(titleId, storageId, NcaContentType.Manual);
+
+ if (string.IsNullOrEmpty(contentPath))
+ {
+ continue;
+ }
+
+ string systemPath = VirtualFileSystem.SwitchPathToSystemPath(contentPath);
+
+ if (string.IsNullOrWhiteSpace(systemPath) || !File.Exists(systemPath))
+ {
+ continue;
+ }
+
+ Logger.Info?.Print(LogClass.ServiceAm, $"Found Manual NCA via ContentManager at: {systemPath}");
+
+ FileStream ncaFile = new(systemPath, FileMode.Open, FileAccess.Read);
+ Nca nca = new(_system.KeySet, ncaFile.AsStorage());
+
+ if (nca.Header.ContentType == NcaContentType.Manual)
+ {
+ return nca;
+ }
+
+ ncaFile.Dispose();
+ }
+ catch (Exception ex)
+ {
+ Logger.Debug?.Print(LogClass.ServiceAm, $"Error checking StorageId {storageId}: {ex.Message}");
+ }
+ }
+
+ return null;
+ }
+
+ ///
+ /// Tries to find the Manual NCA by directly scanning the game file (XCI/NSP).
+ /// This is needed because XCI-loaded games don't register Manual NCAs in the ContentManager.
+ ///
+ private Nca FindManualNcaFromGameFile(ulong titleId, string documentFilePath)
+ {
+ string applicationPath = _system.Device.ApplicationPath;
+
+ if (string.IsNullOrEmpty(applicationPath) || !File.Exists(applicationPath))
+ {
+ Logger.Debug?.Print(LogClass.ServiceAm, "No application path available for game file scanning");
+ return null;
+ }
+
+ string extension = Path.GetExtension(applicationPath).ToLowerInvariant();
+
+ try
+ {
+ if (extension == ".xci")
+ {
+ return FindManualNcaInXci(applicationPath, documentFilePath);
+ }
+ else if (extension == ".nsp")
+ {
+ return FindManualNcaInNsp(applicationPath, documentFilePath);
+ }
+ }
+ catch (Exception ex)
+ {
+ Logger.Warning?.Print(LogClass.ServiceAm, $"Error scanning game file for Manual NCA: {ex.Message}");
+ }
+
+ return null;
+ }
+
+ ///
+ /// Scans an XCI file for a Manual NCA.
+ ///
+ private Nca FindManualNcaInXci(string xciPath, string documentFilePath)
+ {
+ FileStream stream = new(xciPath, FileMode.Open, FileAccess.Read);
+ Xci xci = new(_system.KeySet, stream.AsStorage());
+
+ if (!xci.HasPartition(XciPartitionType.Secure))
+ {
+ stream.Dispose();
+ return null;
+ }
+
+ return FindManualNcaInPartition(xci.OpenPartition(XciPartitionType.Secure), documentFilePath);
+ }
+
+ ///
+ /// Scans an NSP file for a Manual NCA.
+ ///
+ private Nca FindManualNcaInNsp(string nspPath, string documentFilePath)
+ {
+ FileStream stream = new(nspPath, FileMode.Open, FileAccess.Read);
+ PartitionFileSystem partitionFileSystem = new();
+ partitionFileSystem.Initialize(stream.AsStorage()).ThrowIfFailure();
+
+ return FindManualNcaInPartition(partitionFileSystem, documentFilePath);
+ }
+
+ ///
+ /// Searches a partition file system for a Manual NCA by scanning all .nca files.
+ ///
+ private Nca FindManualNcaInPartition(IFileSystem partitionFileSystem, string documentFilePath)
+ {
+ List manualNcas = [];
+
+ foreach (DirectoryEntryEx fileEntry in partitionFileSystem.EnumerateEntries("/", "*.nca"))
+ {
+ if (fileEntry.Type != DirectoryEntryType.File)
+ {
+ continue;
+ }
+
+ try
+ {
+ using UniqueRef ncaFileRef = new();
+ partitionFileSystem.OpenFile(ref ncaFileRef.Ref, fileEntry.FullPath.ToU8Span(), OpenMode.Read).ThrowIfFailure();
+
+ Nca nca = new(_system.KeySet, ncaFileRef.Release().AsStorage());
+
+ Logger.Debug?.Print(LogClass.ServiceAm,
+ $"NCA in partition: {fileEntry.FullPath} ContentType={nca.Header.ContentType} TitleId={nca.Header.TitleId:x16}");
+
+ if (nca.Header.ContentType == NcaContentType.Manual)
+ {
+ Logger.Info?.Print(LogClass.ServiceAm, $"Found Manual NCA in game file: {fileEntry.FullPath}");
+ manualNcas.Add(nca);
+ }
+ }
+ catch (Exception ex)
+ {
+ Logger.Debug?.Print(LogClass.ServiceAm, $"Error reading NCA {fileEntry.FullPath}: {ex.Message}");
+ }
+ }
+
+ if (manualNcas.Count == 0)
+ {
+ Logger.Warning?.Print(LogClass.ServiceAm, "No Manual NCA found in partition");
+ return null;
+ }
+
+ Logger.Info?.Print(LogClass.ServiceAm, $"Found {manualNcas.Count} Manual NCA(s), checking for document: {documentFilePath}");
+
+ // If there are multiple Manual NCAs, check each one for the requested document
+ foreach (Nca nca in manualNcas)
+ {
+ if (NcaContainsDocument(nca, documentFilePath))
+ {
+ Logger.Info?.Print(LogClass.ServiceAm, $"Selected Manual NCA containing '{documentFilePath}'");
+ return nca;
+ }
+ }
+
+ // If no NCA contains the document, return the first one as fallback
+ Logger.Warning?.Print(LogClass.ServiceAm, $"No Manual NCA contains '{documentFilePath}', using first available");
+ return manualNcas[0];
+ }
+
+ ///
+ /// Opens the given URL in the system's default browser.
+ ///
+ private static void OpenSystemBrowser(string url)
+ {
+ try
+ {
+ Process.Start(new ProcessStartInfo
+ {
+ FileName = url,
+ UseShellExecute = true,
+ });
+
+ Logger.Info?.Print(LogClass.ServiceAm, $"Opened system browser: {url}");
+ }
+ catch (Exception ex)
+ {
+ Logger.Warning?.Print(LogClass.ServiceAm, $"Failed to open system browser: {ex.Message}");
+ }
+ }
+
+ #region Argument Helpers
+
+ private string GetDocumentPath()
+ {
+ BrowserArgument arg = _arguments?.FirstOrDefault(a => a.Type == WebArgTLVType.DocumentPath);
+ return arg != null ? Encoding.UTF8.GetString(arg.Value).TrimEnd('\0') : string.Empty;
+ }
+
+ private DocumentKind GetDocumentKind()
+ {
+ BrowserArgument arg = _arguments?.FirstOrDefault(a => a.Type == WebArgTLVType.DocumentKind);
+ return arg != null ? (DocumentKind)BitConverter.ToInt32(arg.Value) : DocumentKind.OfflineHtmlPage;
+ }
+
+ private ulong GetApplicationId()
+ {
+ BrowserArgument arg = _arguments?.FirstOrDefault(a => a.Type == WebArgTLVType.ApplicationId);
+ return arg != null ? BitConverter.ToUInt64(arg.Value) : 0;
+ }
+
+ private ulong GetSystemDataId()
+ {
+ BrowserArgument arg = _arguments?.FirstOrDefault(a => a.Type == WebArgTLVType.SystemDataId);
+ return arg != null ? BitConverter.ToUInt64(arg.Value) : 0;
+ }
+
+ #endregion
+
+ #region Response Building
+
private static byte[] BuildResponseOld(WebCommonReturnValue result)
{
using RecyclableMemoryStream stream = MemoryStreamManager.Shared.GetStream();
@@ -70,6 +782,7 @@ namespace Ryujinx.HLE.HOS.Applets.Browser
return stream.ToArray();
}
+
private byte[] BuildResponseNew(List outputArguments)
{
using RecyclableMemoryStream stream = MemoryStreamManager.Shared.GetStream();
@@ -89,5 +802,7 @@ namespace Ryujinx.HLE.HOS.Applets.Browser
return stream.ToArray();
}
+
+ #endregion
}
}
diff --git a/src/Ryujinx.HLE/HOS/Applets/Browser/OfflineWebServer.cs b/src/Ryujinx.HLE/HOS/Applets/Browser/OfflineWebServer.cs
new file mode 100644
index 000000000..bdc466b70
--- /dev/null
+++ b/src/Ryujinx.HLE/HOS/Applets/Browser/OfflineWebServer.cs
@@ -0,0 +1,406 @@
+using Ryujinx.Common.Logging;
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Net;
+using System.Text;
+using System.Text.RegularExpressions;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Ryujinx.HLE.HOS.Applets.Browser
+{
+ ///
+ /// A lightweight HTTP server that serves offline web applet content from an extracted RomFS directory.
+ /// It injects NX JavaScript polyfills, rewrites localhost callback URLs to route through this server,
+ /// and captures callback requests to determine the applet's exit reason and last URL.
+ ///
+ internal class OfflineWebServer : IDisposable
+ {
+ private const string CallbackPrefix = "/__nxcb/";
+ private const string EndAppletPath = "/__nx_end";
+ private const string SendMessagePath = "/__nx_msg";
+
+ private HttpListener _listener;
+ private readonly string _documentRoot;
+ private readonly int _port;
+ private readonly TaskCompletionSource<(WebExitReason Reason, string LastUrl)> _callbackTcs;
+ private CancellationTokenSource _cts;
+ private bool _disposed;
+
+ ///
+ /// The port this server is listening on.
+ ///
+ public int Port => _port;
+
+ ///
+ /// Creates a new OfflineWebServer.
+ ///
+ /// The local filesystem path to serve files from (e.g., the extracted html-document directory).
+ public OfflineWebServer(string documentRoot)
+ {
+ _documentRoot = documentRoot;
+ _port = FindAvailablePort();
+ _callbackTcs = new TaskCompletionSource<(WebExitReason, string)>(TaskCreationOptions.RunContinuationsAsynchronously);
+ }
+
+ ///
+ /// Starts the HTTP server and begins accepting requests.
+ ///
+ /// The full URL to open in the browser.
+ public string Start(string documentPath)
+ {
+ _cts = new CancellationTokenSource();
+
+ string prefix = $"http://localhost:{_port}/";
+ _listener = new HttpListener();
+ _listener.Prefixes.Add(prefix);
+
+ try
+ {
+ _listener.Start();
+ }
+ catch (Exception ex)
+ {
+ Logger.Error?.Print(LogClass.ServiceAm, $"Failed to start HTTP listener on port {_port}: {ex.Message}");
+ return null;
+ }
+
+ Logger.Info?.Print(LogClass.ServiceAm, $"OfflineWebServer started on {prefix}");
+
+ // Start accepting requests in the background
+ _ = Task.Run(AcceptRequestsAsync);
+
+ // Split documentPath into path and query
+ string path = documentPath;
+ string query = string.Empty;
+ int queryIndex = documentPath.IndexOf('?');
+ if (queryIndex >= 0)
+ {
+ path = documentPath[..queryIndex];
+ query = documentPath[queryIndex..];
+ }
+
+ return $"http://localhost:{_port}/{path}{query}";
+ }
+
+ ///
+ /// Waits for a callback (localhost navigation or nx.endApplet) from the web page.
+ ///
+ public Task<(WebExitReason Reason, string LastUrl)> WaitForCallbackAsync(CancellationToken cancellationToken)
+ {
+ cancellationToken.Register(() => _callbackTcs.TrySetCanceled());
+ return _callbackTcs.Task;
+ }
+
+ private async Task AcceptRequestsAsync()
+ {
+ while (_listener?.IsListening == true && !_cts.IsCancellationRequested)
+ {
+ try
+ {
+ HttpListenerContext context = await _listener.GetContextAsync();
+ _ = Task.Run(() => HandleRequest(context));
+ }
+ catch (ObjectDisposedException)
+ {
+ break;
+ }
+ catch (HttpListenerException)
+ {
+ break;
+ }
+ catch (Exception ex)
+ {
+ Logger.Warning?.Print(LogClass.ServiceAm, $"OfflineWebServer request error: {ex.Message}");
+ }
+ }
+ }
+
+ private void HandleRequest(HttpListenerContext context)
+ {
+ string requestPath = context.Request.Url?.AbsolutePath ?? "/";
+ string fullUrl = context.Request.Url?.ToString() ?? "";
+
+ Logger.Debug?.Print(LogClass.ServiceAm, $"OfflineWebServer request: {requestPath}");
+
+ try
+ {
+ // Check for callback URL (rewritten localhost navigation)
+ if (requestPath.StartsWith(CallbackPrefix))
+ {
+ string callbackPath = requestPath[CallbackPrefix.Length..];
+ string callbackQuery = context.Request.Url?.Query ?? "";
+ string originalUrl = $"http://localhost/{callbackPath}{callbackQuery}";
+
+ Logger.Info?.Print(LogClass.ServiceAm, $"OfflineWebServer captured callback URL: {originalUrl}");
+
+ // Send a simple response to the browser
+ SendResponse(context, 200, "text/html", "Selection received. You can close this tab.
");
+
+ _callbackTcs.TrySetResult((WebExitReason.LastUrl, originalUrl));
+ return;
+ }
+
+ // Check for NX endApplet call
+ if (requestPath == EndAppletPath)
+ {
+ Logger.Info?.Print(LogClass.ServiceAm, "OfflineWebServer: nx.endApplet() called");
+ SendResponse(context, 200, "application/json", "{\"ok\":true}");
+ _callbackTcs.TrySetResult((WebExitReason.Requested, "http://localhost/"));
+ return;
+ }
+
+ // Check for NX sendMessage call
+ if (requestPath == SendMessagePath)
+ {
+ Logger.Info?.Print(LogClass.ServiceAm, $"OfflineWebServer: nx.sendMessage() called: {context.Request.Url?.Query}");
+ SendResponse(context, 200, "application/json", "{\"ok\":true}");
+ return;
+ }
+
+ // Serve a file from the document root
+ ServeFile(context, requestPath);
+ }
+ catch (Exception ex)
+ {
+ Logger.Warning?.Print(LogClass.ServiceAm, $"OfflineWebServer error handling {requestPath}: {ex.Message}");
+ SendResponse(context, 500, "text/plain", "Internal Server Error");
+ }
+ }
+
+ private void ServeFile(HttpListenerContext context, string requestPath)
+ {
+ // Clean up path
+ string relativePath = Uri.UnescapeDataString(requestPath).TrimStart('/');
+ if (string.IsNullOrEmpty(relativePath))
+ {
+ relativePath = "index.html";
+ }
+
+ string filePath = Path.Combine(_documentRoot, relativePath.Replace('/', Path.DirectorySeparatorChar));
+ filePath = Path.GetFullPath(filePath);
+
+ // Security check: ensure path is within document root
+ if (!filePath.StartsWith(Path.GetFullPath(_documentRoot), StringComparison.OrdinalIgnoreCase))
+ {
+ SendResponse(context, 403, "text/plain", "Forbidden");
+ return;
+ }
+
+ if (!File.Exists(filePath))
+ {
+ Logger.Debug?.Print(LogClass.ServiceAm, $"OfflineWebServer: File not found: {filePath}");
+ SendResponse(context, 404, "text/plain", "Not Found");
+ return;
+ }
+
+ byte[] fileData = File.ReadAllBytes(filePath);
+ string mimeType = GetMimeType(filePath);
+
+ // For HTML files, inject the NX polyfill and rewrite localhost URLs
+ if (mimeType == "text/html")
+ {
+ string html = Encoding.UTF8.GetString(fileData);
+ html = InjectNxPolyfill(html);
+ html = RewriteLocalhostUrls(html);
+ fileData = Encoding.UTF8.GetBytes(html);
+ }
+ else if (mimeType == "application/javascript" || mimeType == "text/javascript")
+ {
+ // Also rewrite localhost URLs in JavaScript files
+ string js = Encoding.UTF8.GetString(fileData);
+ js = RewriteLocalhostUrls(js);
+ fileData = Encoding.UTF8.GetBytes(js);
+ }
+
+ SendResponse(context, 200, mimeType, fileData);
+ }
+
+ private string InjectNxPolyfill(string html)
+ {
+ string polyfill = GetNxPolyfillScript();
+
+ // Try to inject after tag
+ int headIndex = html.IndexOf("", StringComparison.OrdinalIgnoreCase);
+ if (headIndex >= 0)
+ {
+ int insertPos = headIndex + "".Length;
+ return html.Insert(insertPos, $"\n{polyfill}\n");
+ }
+
+ // Try to inject after tag
+ int htmlIndex = html.IndexOf("= 0)
+ {
+ int closeTag = html.IndexOf('>', htmlIndex);
+ if (closeTag >= 0)
+ {
+ return html.Insert(closeTag + 1, $"\n{polyfill}\n");
+ }
+ }
+
+ // Prepend if no tags found
+ return polyfill + "\n" + html;
+ }
+
+ private string GetNxPolyfillScript()
+ {
+ return $@"";
+ }
+
+ private string RewriteLocalhostUrls(string content)
+ {
+ // Rewrite various patterns of http://localhost/ to route through our server's callback endpoint
+ // This catches JavaScript like: window.location = "http://localhost/callback?param=value"
+
+ // Pattern 1: http://localhost/ (with trailing slash, no port)
+ content = Regex.Replace(
+ content,
+ @"http://localhost/",
+ $"http://localhost:{_port}{CallbackPrefix}",
+ RegexOptions.IgnoreCase);
+
+ // Pattern 2: http://localhost" or http://localhost' (without trailing slash)
+ content = Regex.Replace(
+ content,
+ @"http://localhost([""'])",
+ $"http://localhost:{_port}{CallbackPrefix}$1",
+ RegexOptions.IgnoreCase);
+
+ return content;
+ }
+
+ private static void SendResponse(HttpListenerContext context, int statusCode, string contentType, string body)
+ {
+ SendResponse(context, statusCode, contentType, Encoding.UTF8.GetBytes(body));
+ }
+
+ private static void SendResponse(HttpListenerContext context, int statusCode, string contentType, byte[] body)
+ {
+ try
+ {
+ context.Response.StatusCode = statusCode;
+ context.Response.ContentType = contentType;
+ context.Response.ContentLength64 = body.Length;
+
+ // Add CORS headers for local development
+ context.Response.Headers.Add("Access-Control-Allow-Origin", "*");
+ context.Response.Headers.Add("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
+ context.Response.Headers.Add("Cache-Control", "no-cache");
+
+ context.Response.OutputStream.Write(body, 0, body.Length);
+ context.Response.OutputStream.Close();
+ }
+ catch (Exception ex)
+ {
+ Logger.Debug?.Print(LogClass.ServiceAm, $"OfflineWebServer: Error sending response: {ex.Message}");
+ }
+ }
+
+ private static string GetMimeType(string filePath)
+ {
+ string ext = Path.GetExtension(filePath).ToLowerInvariant();
+ return ext switch
+ {
+ ".html" or ".htm" => "text/html",
+ ".css" => "text/css",
+ ".js" => "application/javascript",
+ ".json" => "application/json",
+ ".png" => "image/png",
+ ".jpg" or ".jpeg" => "image/jpeg",
+ ".gif" => "image/gif",
+ ".svg" => "image/svg+xml",
+ ".webp" => "image/webp",
+ ".ico" => "image/x-icon",
+ ".woff" => "font/woff",
+ ".woff2" => "font/woff2",
+ ".ttf" => "font/ttf",
+ ".otf" => "font/otf",
+ ".mp3" => "audio/mpeg",
+ ".ogg" => "audio/ogg",
+ ".wav" => "audio/wav",
+ ".mp4" => "video/mp4",
+ ".webm" => "video/webm",
+ ".xml" => "application/xml",
+ ".txt" => "text/plain",
+ _ => "application/octet-stream",
+ };
+ }
+
+ private static int FindAvailablePort()
+ {
+ // Find an available port by binding to port 0
+ using var listener = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0);
+ listener.Start();
+ int port = ((IPEndPoint)listener.LocalEndpoint).Port;
+ listener.Stop();
+ return port;
+ }
+
+ public void Dispose()
+ {
+ if (_disposed)
+ {
+ return;
+ }
+
+ _disposed = true;
+
+ _cts?.Cancel();
+
+ try
+ {
+ _listener?.Stop();
+ _listener?.Close();
+ }
+ catch
+ {
+ // Ignore errors during cleanup
+ }
+
+ _callbackTcs.TrySetCanceled();
+ _cts?.Dispose();
+ }
+ }
+}
diff --git a/src/Ryujinx.HLE/HOS/Applets/Browser/WebExitReason.cs b/src/Ryujinx.HLE/HOS/Applets/Browser/WebExitReason.cs
index ebb705acb..717036b1b 100644
--- a/src/Ryujinx.HLE/HOS/Applets/Browser/WebExitReason.cs
+++ b/src/Ryujinx.HLE/HOS/Applets/Browser/WebExitReason.cs
@@ -6,6 +6,7 @@ namespace Ryujinx.HLE.HOS.Applets.Browser
BackButton,
Requested,
LastUrl,
+ WindowClosed,
ErrorDialog = 7,
}
}
diff --git a/src/Ryujinx.HLE/Switch.cs b/src/Ryujinx.HLE/Switch.cs
index 850c8b5fa..49007d912 100644
--- a/src/Ryujinx.HLE/Switch.cs
+++ b/src/Ryujinx.HLE/Switch.cs
@@ -66,6 +66,12 @@ namespace Ryujinx.HLE
public DirtyHacks DirtyHacks { get; }
+ ///
+ /// The file path of the currently loaded application (XCI, NSP, NCA, etc.).
+ /// Used by applets like the Browser Applet to access additional game content (e.g., Manual/HtmlDocument NCA).
+ ///
+ public string ApplicationPath { get; set; }
+
public Switch(HleConfiguration configuration)
{
ArgumentNullException.ThrowIfNull(configuration.GpuRenderer);
@@ -157,9 +163,25 @@ namespace Ryujinx.HLE
}
public bool LoadCart(string exeFsDir, string romFsFile = null) => Processes.LoadUnpackedNca(exeFsDir, romFsFile);
- public bool LoadXci(string xciFile, ulong applicationId = 0) => Processes.LoadXci(xciFile, applicationId);
- public bool LoadNca(string ncaFile, BlitStruct? customNacpData = null) => Processes.LoadNca(ncaFile, customNacpData);
- public bool LoadNsp(string nspFile, ulong applicationId = 0) => Processes.LoadNsp(nspFile, applicationId);
+
+ public bool LoadXci(string xciFile, ulong applicationId = 0)
+ {
+ ApplicationPath = xciFile;
+ return Processes.LoadXci(xciFile, applicationId);
+ }
+
+ public bool LoadNca(string ncaFile, BlitStruct? customNacpData = null)
+ {
+ ApplicationPath = ncaFile;
+ return Processes.LoadNca(ncaFile, customNacpData);
+ }
+
+ public bool LoadNsp(string nspFile, ulong applicationId = 0)
+ {
+ ApplicationPath = nspFile;
+ return Processes.LoadNsp(nspFile, applicationId);
+ }
+
public bool LoadProgram(string fileName) => Processes.LoadNxo(fileName);
public void SetVolume(float volume) => AudioDeviceDriver.Volume = Math.Clamp(volume, 0f, 1f);
diff --git a/src/Ryujinx.HLE/UI/IHostUIHandler.cs b/src/Ryujinx.HLE/UI/IHostUIHandler.cs
index 79b479d8a..caab4e8c7 100644
--- a/src/Ryujinx.HLE/UI/IHostUIHandler.cs
+++ b/src/Ryujinx.HLE/UI/IHostUIHandler.cs
@@ -1,6 +1,7 @@
using Ryujinx.HLE.HOS.Applets;
using Ryujinx.HLE.HOS.Services.Account.Acc;
using Ryujinx.HLE.HOS.Services.Am.AppletOE.ApplicationProxyService.ApplicationProxy.Types;
+using System.Threading;
namespace Ryujinx.HLE.UI
{
@@ -73,5 +74,15 @@ namespace Ryujinx.HLE.UI
/// Takes a screenshot from the current renderer and saves it in the screenshots folder.
///
void TakeScreenshot();
+
+ ///
+ /// Opens a web page in the system browser and blocks until the user closes the dialog or the cancellation token is triggered.
+ /// Used by the offline web applet to display game HTML pages (launchers, menus, etc.).
+ ///
+ /// The URL to open in the browser.
+ /// The title for the waiting dialog.
+ /// A token that is cancelled when the web applet has received a callback and the browser is no longer needed.
+ /// True if the user waited for the callback; False if the user manually cancelled.
+ bool DisplayWebPage(string url, string title, CancellationToken cancellationToken) => true;
}
}
diff --git a/src/Ryujinx/Program.cs b/src/Ryujinx/Program.cs
index 8d03f81da..0ca8488dc 100644
--- a/src/Ryujinx/Program.cs
+++ b/src/Ryujinx/Program.cs
@@ -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)
{
diff --git a/src/Ryujinx/Ryujinx.csproj b/src/Ryujinx/Ryujinx.csproj
index 5da152501..bb6003a53 100644
--- a/src/Ryujinx/Ryujinx.csproj
+++ b/src/Ryujinx/Ryujinx.csproj
@@ -51,6 +51,7 @@
+
@@ -74,6 +75,8 @@
+
+
diff --git a/src/Ryujinx/UI/Applet/AvaHostUIHandler.cs b/src/Ryujinx/UI/Applet/AvaHostUIHandler.cs
index 45235ee3f..1eb1ac794 100644
--- a/src/Ryujinx/UI/Applet/AvaHostUIHandler.cs
+++ b/src/Ryujinx/UI/Applet/AvaHostUIHandler.cs
@@ -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
{
@@ -332,5 +335,115 @@ namespace Ryujinx.Ava.UI.Applet
{
_parent.ViewModel.AppHost.ScreenshotRequested = true;
}
+
+ public bool DisplayWebPage(string url, string title, CancellationToken cancellationToken)
+ {
+ ManualResetEvent dialogCloseEvent = new(false);
+ bool closedByCallback = false;
+
+ Dispatcher.UIThread.InvokeAsync(async () =>
+ {
+ try
+ {
+ if (OperatingSystem.IsLinux())
+ {
+ // Linux: NativeWebView is not supported, use a fallback dialog
+ closedByCallback = await ShowFallbackDialog(title, cancellationToken);
+ }
+ else
+ {
+ // Windows / macOS: use embedded NativeWebView in an overlay window
+ closedByCallback = await ShowWebViewOverlay(url, title, cancellationToken);
+ }
+ }
+ catch (Exception ex)
+ {
+ 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
+ {
+ dialogCloseEvent.Set();
+ }
+ });
+
+ dialogCloseEvent.WaitOne();
+
+ return closedByCallback;
+ }
+
+ ///
+ /// Shows an embedded NativeWebView in a full-screen overlay window (Windows/macOS).
+ ///
+ private async Task 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;
+ }
+
+ ///
+ /// Fallback dialog shown when WebView is unavailable (Linux) or initialization fails.
+ ///
+ private async Task 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;
+ }
}
}
diff --git a/src/Ryujinx/UI/Applet/WebAppletWindow.axaml b/src/Ryujinx/UI/Applet/WebAppletWindow.axaml
new file mode 100644
index 000000000..7076d37f9
--- /dev/null
+++ b/src/Ryujinx/UI/Applet/WebAppletWindow.axaml
@@ -0,0 +1,45 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Ryujinx/UI/Applet/WebAppletWindow.axaml.cs b/src/Ryujinx/UI/Applet/WebAppletWindow.axaml.cs
new file mode 100644
index 000000000..7379c5151
--- /dev/null
+++ b/src/Ryujinx/UI/Applet/WebAppletWindow.axaml.cs
@@ -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;
+
+ ///
+ /// True if the window was closed by the callback (not by user).
+ ///
+ public bool ClosedByCallback { get; private set; }
+
+ public WebAppletWindow()
+ {
+ InitializeComponent();
+
+ // Subscribe to WebView2Host events
+ WebViewHost.WebView2Ready += OnWebView2Ready;
+ WebViewHost.NavigationCompleted += OnNavigationCompleted;
+ }
+
+ ///
+ /// Registers the URL and cancellation token. Navigation happens once WebView2 is ready.
+ ///
+ 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("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("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);
+ }
+ }
+}
diff --git a/src/Ryujinx/UI/Applet/WebView2Host.cs b/src/Ryujinx/UI/Applet/WebView2Host.cs
new file mode 100644
index 000000000..3c9217780
--- /dev/null
+++ b/src/Ryujinx/UI/Applet/WebView2Host.cs
@@ -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
+{
+ ///
+ /// 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.
+ ///
+ 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;
+
+ /// Fires when the WebView2 engine is ready (or failed). Args: (success, errorMessage).
+ public event Action WebView2Ready;
+
+ /// Fires when navigation starts. Arg: URL.
+ public event Action NavigationStarted;
+
+ /// Fires when navigation completes. Arg: success.
+ public event Action NavigationCompleted;
+
+ /// True once the WebView2 engine is fully initialized.
+ public bool IsWebView2Ready => _isInitialized;
+
+ ///
+ /// Queues a URL for navigation. If WebView2 is already initialized, navigates immediately.
+ ///
+ 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(),
+ 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
+ }
+}
diff --git a/src/Ryujinx/UI/RyujinxApp.axaml.cs b/src/Ryujinx/UI/RyujinxApp.axaml.cs
index c778f27fb..20d660efe 100644
--- a/src/Ryujinx/UI/RyujinxApp.axaml.cs
+++ b/src/Ryujinx/UI/RyujinxApp.axaml.cs
@@ -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();