From b3ec6628efeee90f29bbbe69f53f8f5cafc2e441 Mon Sep 17 00:00:00 2001 From: Zephyron Date: Sat, 14 Feb 2026 18:45:51 +1000 Subject: [PATCH 1/2] 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 --- src/Ryujinx.HLE/HOS/Applets/AppletManager.cs | 2 +- .../HOS/Applets/Browser/BrowserApplet.cs | 742 +++++++++++++++++- .../HOS/Applets/Browser/OfflineWebServer.cs | 406 ++++++++++ .../HOS/Applets/Browser/WebExitReason.cs | 1 + src/Ryujinx.HLE/Switch.cs | 28 +- src/Ryujinx.HLE/UI/IHostUIHandler.cs | 11 + src/Ryujinx/UI/Applet/AvaHostUIHandler.cs | 67 ++ 7 files changed, 1241 insertions(+), 16 deletions(-) create mode 100644 src/Ryujinx.HLE/HOS/Applets/Browser/OfflineWebServer.cs 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..ff17b4910 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,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 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); + + // 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); + } + + /// + /// 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 +785,7 @@ namespace Ryujinx.HLE.HOS.Applets.Browser return stream.ToArray(); } + private byte[] BuildResponseNew(List outputArguments) { using RecyclableMemoryStream stream = MemoryStreamManager.Shared.GetStream(); @@ -89,5 +805,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/UI/Applet/AvaHostUIHandler.cs b/src/Ryujinx/UI/Applet/AvaHostUIHandler.cs index 45235ee3f..90a7be4ad 100644 --- a/src/Ryujinx/UI/Applet/AvaHostUIHandler.cs +++ b/src/Ryujinx/UI/Applet/AvaHostUIHandler.cs @@ -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; + } } } From ad1d9dee6fdce632471208ffd75752709e2d0e53 Mon Sep 17 00:00:00 2001 From: Zephyron Date: Sat, 14 Feb 2026 21:15:07 +1000 Subject: [PATCH 2/2] GUI: Add embedded WebView2 renderer for offline web applets Replace the external system browser with an embedded WebView2 instance using Avalonia's NativeControlHost and CoreWebView2 COM API directly. This renders offline web applet content (e.g., AC3 Remastered menus) inside the Ryujinx window without leaving the application. Key changes: - WebView2Host: custom NativeControlHost with Win32 child HWND - WebAppletWindow: overlay dialog hosting the WebView2 control - STAThread on Main for COM STA apartment (required by WebView2) - WM_SIZE handler for proper WebView2 resize tracking - Fallback dialog for Linux where WebView2 is unavailable Signed-off-by: Zephyron --- Directory.Packages.props | 3 + Ryujinx.sln | 96 ++++--- .../HOS/Applets/Browser/BrowserApplet.cs | 7 +- src/Ryujinx/Program.cs | 5 +- src/Ryujinx/Ryujinx.csproj | 3 + src/Ryujinx/UI/Applet/AvaHostUIHandler.cs | 130 ++++++--- src/Ryujinx/UI/Applet/WebAppletWindow.axaml | 45 +++ .../UI/Applet/WebAppletWindow.axaml.cs | 122 ++++++++ src/Ryujinx/UI/Applet/WebView2Host.cs | 266 ++++++++++++++++++ src/Ryujinx/UI/RyujinxApp.axaml.cs | 9 + 10 files changed, 595 insertions(+), 91 deletions(-) create mode 100644 src/Ryujinx/UI/Applet/WebAppletWindow.axaml create mode 100644 src/Ryujinx/UI/Applet/WebAppletWindow.axaml.cs create mode 100644 src/Ryujinx/UI/Applet/WebView2Host.cs 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/Browser/BrowserApplet.cs b/src/Ryujinx.HLE/HOS/Applets/Browser/BrowserApplet.cs index ff17b4910..e682656a7 100644 --- a/src/Ryujinx.HLE/HOS/Applets/Browser/BrowserApplet.cs +++ b/src/Ryujinx.HLE/HOS/Applets/Browser/BrowserApplet.cs @@ -151,14 +151,11 @@ namespace Ryujinx.HLE.HOS.Applets.Browser 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 + // Show the web content in an embedded WebView and wait for callback bool userWaited = true; try { - // Open system browser - OpenSystemBrowser(url); - - // Use the UI handler to show a blocking dialog + // Use the UI handler to display embedded web content if (_system.Device.UIHandler != null) { using CancellationTokenSource uiCts = new(); 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 90a7be4ad..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 { @@ -336,58 +339,30 @@ namespace Ryujinx.Ava.UI.Applet public bool DisplayWebPage(string url, string title, CancellationToken cancellationToken) { ManualResetEvent dialogCloseEvent = new(false); - bool userCancelled = false; + bool closedByCallback = false; Dispatcher.UIThread.InvokeAsync(async () => { try { - ContentDialog dialog = new() + if (OperatingSystem.IsLinux()) { - 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(() => + // Linux: NativeWebView is not supported, use a fallback dialog + closedByCallback = await ShowFallbackDialog(title, cancellationToken); + } + else { - 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); + // Windows / macOS: use embedded NativeWebView in an overlay window + closedByCallback = await ShowWebViewOverlay(url, title, cancellationToken); + } } catch (Exception ex) { - Ryujinx.Common.Logging.Logger.Warning?.Print( - Ryujinx.Common.Logging.LogClass.ServiceAm, - $"Error displaying web applet dialog: {ex.Message}"); + 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 { @@ -397,6 +372,77 @@ namespace Ryujinx.Ava.UI.Applet 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 @@ + + + + + + + + + + + + + + + + +