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 @@ + + + + + + + + + + + + + + + + +