Ryujinx/src/Ryujinx.HLE/HOS/Applets/Browser/OfflineWebServer.cs
Zephyron b3ec6628ef
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>
2026-02-14 18:45:51 +10:00

407 lines
15 KiB
C#

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();
}
}
}