HLE: Implement offline web applet with local HTTP server

Add a lightweight offline web rendering applet (LibAppletOff) that
extracts HTML content from the game's Manual NCA, serves it via a
local HTTP server, and injects NX JavaScript polyfills to capture
applet callbacks.

Key changes:
- Add OfflineWebServer to serve extracted RomFS content and handle
  callback URLs (nx.endApplet, nx.sendMessage, localhost redirects)
- Rewrite BrowserApplet to extract and serve offline HTML content
  from Manual NCAs, with content-aware NCA selection for games that
  ship multiple Manual NCAs (e.g. AC3 Remastered)
- Add XCI/NSP fallback for Manual NCA discovery when ContentManager
  does not register them (common for XCI-loaded games)
- Store ApplicationPath on Switch for game file re-scanning
- Add DisplayWebPage to IHostUIHandler with Avalonia implementation
- Add WindowClosed to WebExitReason enum

Signed-off-by: Zephyron <zephyron@citron-emu.org>
This commit is contained in:
Zephyron 2026-02-14 18:45:51 +10:00
parent 1260f93aaf
commit b3ec6628ef
No known key found for this signature in database
GPG Key ID: A1F93756F8FAE023
7 changed files with 1241 additions and 16 deletions

View File

@ -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);

View File

@ -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<BrowserArgument> _arguments;
private ShimKind _shimKind;
public BrowserApplet(Horizon system)
{
_system = system;
}
public ResultCode Start(AppletSession normalSession, AppletSession interactiveSession)
{
_normalSession = normalSession;
@ -38,30 +60,723 @@ 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<BrowserOutput> result =
[
new(BrowserOutputType.ExitReason, (uint)WebExitReason.ExitButton)
];
return HandleOfflineApplet();
}
_normalSession.Push(BuildResponseNew(result));
return HandleStubBrowser();
}
/// <summary>
/// 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.
/// </summary>
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();
}
/// <summary>
/// Serves extracted offline content via a local HTTP server and captures callback URLs.
/// </summary>
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);
// Open the URL in the system browser and show a waiting dialog
bool userWaited = true;
try
{
// Open system browser
OpenSystemBrowser(url);
// Use the UI handler to show a blocking dialog
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);
}
/// <summary>
/// Stub browser behavior for when offline content can't be served.
/// </summary>
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);
}
/// <summary>
/// Builds the applet response and pushes it to the normal session.
/// </summary>
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;
}
/// <summary>
/// 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/".
/// </summary>
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;
}
/// <summary>
/// Extracts the offline HTML content from the game's Manual NCA RomFS to a temp directory.
/// </summary>
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<IFile> 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;
}
}
/// <summary>
/// Gets the target title ID based on the document kind.
/// </summary>
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;
}
}
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>
/// 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.).
/// </summary>
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
}
}
/// <summary>
/// Checks if a file exists in a RomFS at the given path.
/// </summary>
private static bool FileExistsInRomFs(LibHac.Tools.FsSystem.RomFs.RomFsFileSystem romfs, string path)
{
try
{
using UniqueRef<IFile> file = new();
LibHac.Result result = romfs.OpenFile(ref file.Ref, path.ToU8Span(), OpenMode.Read);
return result.IsSuccess();
}
catch
{
return false;
}
}
/// <summary>
/// Tries to find the Manual NCA through the ContentManager (for installed games).
/// </summary>
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;
}
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>
/// Scans an XCI file for a Manual NCA.
/// </summary>
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);
}
/// <summary>
/// Scans an NSP file for a Manual NCA.
/// </summary>
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);
}
/// <summary>
/// Searches a partition file system for a Manual NCA by scanning all .nca files.
/// </summary>
private Nca FindManualNcaInPartition(IFileSystem partitionFileSystem, string documentFilePath)
{
List<Nca> manualNcas = [];
foreach (DirectoryEntryEx fileEntry in partitionFileSystem.EnumerateEntries("/", "*.nca"))
{
if (fileEntry.Type != DirectoryEntryType.File)
{
continue;
}
try
{
using UniqueRef<IFile> 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];
}
/// <summary>
/// Opens the given URL in the system's default browser.
/// </summary>
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 +785,7 @@ namespace Ryujinx.HLE.HOS.Applets.Browser
return stream.ToArray();
}
private byte[] BuildResponseNew(List<BrowserOutput> outputArguments)
{
using RecyclableMemoryStream stream = MemoryStreamManager.Shared.GetStream();
@ -89,5 +805,7 @@ namespace Ryujinx.HLE.HOS.Applets.Browser
return stream.ToArray();
}
#endregion
}
}

View File

@ -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
{
/// <summary>
/// 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.
/// </summary>
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;
/// <summary>
/// The port this server is listening on.
/// </summary>
public int Port => _port;
/// <summary>
/// Creates a new OfflineWebServer.
/// </summary>
/// <param name="documentRoot">The local filesystem path to serve files from (e.g., the extracted html-document directory).</param>
public OfflineWebServer(string documentRoot)
{
_documentRoot = documentRoot;
_port = FindAvailablePort();
_callbackTcs = new TaskCompletionSource<(WebExitReason, string)>(TaskCreationOptions.RunContinuationsAsynchronously);
}
/// <summary>
/// Starts the HTTP server and begins accepting requests.
/// </summary>
/// <returns>The full URL to open in the browser.</returns>
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}";
}
/// <summary>
/// Waits for a callback (localhost navigation or nx.endApplet) from the web page.
/// </summary>
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", "<html><body><h2>Selection received. You can close this tab.</h2></body></html>");
_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 <head> tag
int headIndex = html.IndexOf("<head>", StringComparison.OrdinalIgnoreCase);
if (headIndex >= 0)
{
int insertPos = headIndex + "<head>".Length;
return html.Insert(insertPos, $"\n{polyfill}\n");
}
// Try to inject after <html> tag
int htmlIndex = html.IndexOf("<html", StringComparison.OrdinalIgnoreCase);
if (htmlIndex >= 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 $@"<script>
/* NX JavaScript Extensions Polyfill - Ryujinx Offline Web Applet */
(function() {{
var serverBase = 'http://localhost:{_port}';
if (!window.nx) {{
window.nx = {{}};
}}
// nx.endApplet() - signals the applet should close
window.nx.endApplet = function() {{
var xhr = new XMLHttpRequest();
xhr.open('GET', serverBase + '{EndAppletPath}', true);
xhr.send();
}};
// nx.sendMessage(msg) - sends a message to the host application
window.nx.sendMessage = function(msg) {{
var xhr = new XMLHttpRequest();
xhr.open('GET', serverBase + '{SendMessagePath}?msg=' + encodeURIComponent(msg), true);
xhr.send();
}};
// nx.playSystemSe(name) - plays a system sound effect (no-op in emulator)
window.nx.playSystemSe = function(name) {{}};
// nx.footer - footer button management (no-op in emulator)
window.nx.footer = {{
setAssign: function() {{}},
setFixed: function() {{}},
unsetAssign: function() {{}}
}};
// nx.exit() - alternative exit method
window.nx.exit = function() {{
window.nx.endApplet();
}};
console.log('[Ryujinx] NX JavaScript polyfill loaded. Server port: {_port}');
}})();
</script>";
}
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();
}
}
}

View File

@ -6,6 +6,7 @@ namespace Ryujinx.HLE.HOS.Applets.Browser
BackButton,
Requested,
LastUrl,
WindowClosed,
ErrorDialog = 7,
}
}

View File

@ -66,6 +66,12 @@ namespace Ryujinx.HLE
public DirtyHacks DirtyHacks { get; }
/// <summary>
/// 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).
/// </summary>
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<ApplicationControlProperty>? 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<ApplicationControlProperty>? 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);

View File

@ -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.
/// </summary>
void TakeScreenshot();
/// <summary>
/// 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.).
/// </summary>
/// <param name="url">The URL to open in the browser.</param>
/// <param name="title">The title for the waiting dialog.</param>
/// <param name="cancellationToken">A token that is cancelled when the web applet has received a callback and the browser is no longer needed.</param>
/// <returns>True if the user waited for the callback; False if the user manually cancelled.</returns>
bool DisplayWebPage(string url, string title, CancellationToken cancellationToken) => true;
}
}

View File

@ -332,5 +332,72 @@ namespace Ryujinx.Ava.UI.Applet
{
_parent.ViewModel.AppHost.ScreenshotRequested = true;
}
public bool DisplayWebPage(string url, string title, CancellationToken cancellationToken)
{
ManualResetEvent dialogCloseEvent = new(false);
bool userCancelled = false;
Dispatcher.UIThread.InvokeAsync(async () =>
{
try
{
ContentDialog dialog = new()
{
Title = title,
CloseButtonText = LocaleManager.Instance[LocaleKeys.SettingsButtonClose],
Content = new TextBlock
{
Text = "A web page has been opened in your system browser.\n\n" +
"Please interact with it to continue.\n\n" +
"This dialog will close automatically when the page sends a response.\n\n" +
"Click Close to skip the web applet.",
TextWrapping = Avalonia.Media.TextWrapping.Wrap,
MaxWidth = 400,
},
};
dialog.CloseButtonCommand = Commands.Create(() =>
{
if (!cancellationToken.IsCancellationRequested)
{
userCancelled = true;
}
});
// Register cancellation to close the dialog when the callback arrives
using CancellationTokenRegistration registration = cancellationToken.Register(() =>
{
Dispatcher.UIThread.Post(() =>
{
try
{
dialog.Hide();
}
catch
{
// Dialog may already be closed
}
});
});
await ContentDialogHelper.ShowAsync(dialog);
}
catch (Exception ex)
{
Ryujinx.Common.Logging.Logger.Warning?.Print(
Ryujinx.Common.Logging.LogClass.ServiceAm,
$"Error displaying web applet dialog: {ex.Message}");
}
finally
{
dialogCloseEvent.Set();
}
});
dialogCloseEvent.WaitOne();
return !userCancelled;
}
}
}