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>
407 lines
15 KiB
C#
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();
|
|
}
|
|
}
|
|
}
|