Merge branch 'hle/implement-offline-web-applet' into 'master'
HLE: Implement offline web applet with local HTTP server See merge request [ryubing/ryujinx!262](https://git.ryujinx.app/ryubing/ryujinx/-/merge_requests/262)
This commit is contained in:
commit
44e27389af
@ -8,6 +8,7 @@
|
||||
<PackageVersion Include="Avalonia.Desktop" Version="11.3.6" />
|
||||
<PackageVersion Include="Avalonia.Diagnostics" Version="11.3.6" />
|
||||
<PackageVersion Include="Avalonia.Markup.Xaml.Loader" Version="11.3.6" />
|
||||
<PackageVersion Include="Microsoft.Web.WebView2" Version="1.0.3719.77" />
|
||||
<PackageVersion Include="Svg.Controls.Avalonia" Version="11.3.6.2" />
|
||||
<PackageVersion Include="Svg.Controls.Skia.Avalonia" Version="11.3.6.2" />
|
||||
<PackageVersion Include="Microsoft.Build.Framework" Version="17.11.4" />
|
||||
@ -58,5 +59,7 @@
|
||||
<PackageVersion Include="System.IO.Hashing" Version="9.0.2" />
|
||||
<PackageVersion Include="System.Management" Version="9.0.2" />
|
||||
<PackageVersion Include="UnicornEngine.Unicorn" Version="2.0.2-rc1-fb78016" />
|
||||
<PackageVersion Include="WebView.Avalonia.AGPL" Version="11.0.0.2026011404" />
|
||||
<PackageVersion Include="WebView.Avalonia.Desktop" Version="11.0.0.1" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
96
Ryujinx.sln
96
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
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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,720 @@ namespace Ryujinx.HLE.HOS.Applets.Browser
|
||||
Logger.Stub?.PrintStub(LogClass.ServiceAm, $"{argument.Type}: {argument.GetValue()}");
|
||||
}
|
||||
|
||||
if ((_commonArguments.AppletVersion >= 0x80000 && _shimKind == ShimKind.Web) || (_commonArguments.AppletVersion >= 0x30000 && _shimKind == ShimKind.Share))
|
||||
if (_shimKind == ShimKind.Offline)
|
||||
{
|
||||
List<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);
|
||||
|
||||
// Show the web content in an embedded WebView and wait for callback
|
||||
bool userWaited = true;
|
||||
try
|
||||
{
|
||||
// Use the UI handler to display embedded web content
|
||||
if (_system.Device.UIHandler != null)
|
||||
{
|
||||
using CancellationTokenSource uiCts = new();
|
||||
|
||||
// When the callback is received, cancel the UI dialog
|
||||
callbackTask.ContinueWith(_ => uiCts.Cancel(), TaskContinuationOptions.NotOnCanceled);
|
||||
|
||||
userWaited = _system.Device.UIHandler.DisplayWebPage(url, "Web Applet", uiCts.Token);
|
||||
}
|
||||
else
|
||||
{
|
||||
// No UI handler - just wait for callback with a timeout
|
||||
try
|
||||
{
|
||||
callbackTask.Wait(TimeSpan.FromMinutes(5));
|
||||
}
|
||||
catch (AggregateException)
|
||||
{
|
||||
// Timeout or cancellation
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warning?.Print(LogClass.ServiceAm, $"Error during web applet display: {ex.Message}");
|
||||
}
|
||||
|
||||
// Get the result
|
||||
WebExitReason exitReason;
|
||||
string lastUrl;
|
||||
|
||||
if (callbackTask.IsCompletedSuccessfully)
|
||||
{
|
||||
(exitReason, lastUrl) = callbackTask.Result;
|
||||
Logger.Info?.Print(LogClass.ServiceAm, $"Web applet callback received: ExitReason={exitReason}, LastUrl={lastUrl}");
|
||||
}
|
||||
else if (!userWaited)
|
||||
{
|
||||
// User cancelled
|
||||
exitReason = WebExitReason.WindowClosed;
|
||||
lastUrl = "http://localhost/";
|
||||
Logger.Info?.Print(LogClass.ServiceAm, "Web applet cancelled by user");
|
||||
}
|
||||
else
|
||||
{
|
||||
WebCommonReturnValue result = new()
|
||||
{
|
||||
ExitReason = WebExitReason.ExitButton,
|
||||
};
|
||||
|
||||
_normalSession.Push(BuildResponseOld(result));
|
||||
// Timeout or error - use default
|
||||
exitReason = WebExitReason.WindowClosed;
|
||||
lastUrl = "http://localhost/";
|
||||
Logger.Warning?.Print(LogClass.ServiceAm, "Web applet timed out or encountered an error");
|
||||
}
|
||||
|
||||
return BuildAndPushResponse(exitReason, lastUrl);
|
||||
}
|
||||
|
||||
/// <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 +782,7 @@ namespace Ryujinx.HLE.HOS.Applets.Browser
|
||||
|
||||
return stream.ToArray();
|
||||
}
|
||||
|
||||
private byte[] BuildResponseNew(List<BrowserOutput> outputArguments)
|
||||
{
|
||||
using RecyclableMemoryStream stream = MemoryStreamManager.Shared.GetStream();
|
||||
@ -89,5 +802,7 @@ namespace Ryujinx.HLE.HOS.Applets.Browser
|
||||
|
||||
return stream.ToArray();
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
|
||||
406
src/Ryujinx.HLE/HOS/Applets/Browser/OfflineWebServer.cs
Normal file
406
src/Ryujinx.HLE/HOS/Applets/Browser/OfflineWebServer.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -6,6 +6,7 @@ namespace Ryujinx.HLE.HOS.Applets.Browser
|
||||
BackButton,
|
||||
Requested,
|
||||
LastUrl,
|
||||
WindowClosed,
|
||||
ErrorDialog = 7,
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
{
|
||||
|
||||
@ -51,6 +51,7 @@
|
||||
<PackageReference Include="Avalonia.Diagnostics" Condition="'$(Configuration)'=='Debug'" />
|
||||
<PackageReference Include="Avalonia.Controls.DataGrid" />
|
||||
<PackageReference Include="Avalonia.Markup.Xaml.Loader" />
|
||||
<PackageReference Include="Microsoft.Web.WebView2" />
|
||||
<PackageReference Include="Svg.Controls.Avalonia" />
|
||||
<PackageReference Include="Svg.Controls.Skia.Avalonia" />
|
||||
<PackageReference Include="DynamicData" />
|
||||
@ -74,6 +75,8 @@
|
||||
<PackageReference Include="Silk.NET.Vulkan.Extensions.KHR" />
|
||||
<PackageReference Include="SPB" />
|
||||
<PackageReference Include="SharpZipLib" />
|
||||
<PackageReference Include="WebView.Avalonia.AGPL" />
|
||||
<PackageReference Include="WebView.Avalonia.Desktop" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Threading;
|
||||
using FluentAvalonia.UI.Controls;
|
||||
@ -9,6 +10,7 @@ using Ryujinx.Ava.UI.Helpers;
|
||||
using Ryujinx.Ava.UI.ViewModels;
|
||||
using Ryujinx.Ava.UI.Windows;
|
||||
using Ryujinx.Common;
|
||||
using Ryujinx.Common.Logging;
|
||||
using Ryujinx.HLE;
|
||||
using Ryujinx.HLE.HOS.Applets;
|
||||
using Ryujinx.HLE.HOS.Applets.SoftwareKeyboard;
|
||||
@ -19,6 +21,7 @@ using System;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Ryujinx.Ava.UI.Applet
|
||||
{
|
||||
@ -332,5 +335,115 @@ namespace Ryujinx.Ava.UI.Applet
|
||||
{
|
||||
_parent.ViewModel.AppHost.ScreenshotRequested = true;
|
||||
}
|
||||
|
||||
public bool DisplayWebPage(string url, string title, CancellationToken cancellationToken)
|
||||
{
|
||||
ManualResetEvent dialogCloseEvent = new(false);
|
||||
bool closedByCallback = false;
|
||||
|
||||
Dispatcher.UIThread.InvokeAsync(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
if (OperatingSystem.IsLinux())
|
||||
{
|
||||
// Linux: NativeWebView is not supported, use a fallback dialog
|
||||
closedByCallback = await ShowFallbackDialog(title, cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Windows / macOS: use embedded NativeWebView in an overlay window
|
||||
closedByCallback = await ShowWebViewOverlay(url, title, cancellationToken);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warning?.Print(LogClass.ServiceAm,
|
||||
$"Error displaying embedded web applet: {ex.Message}");
|
||||
|
||||
// Fall back to a simple waiting dialog if WebView fails
|
||||
closedByCallback = await ShowFallbackDialog(title, cancellationToken);
|
||||
}
|
||||
finally
|
||||
{
|
||||
dialogCloseEvent.Set();
|
||||
}
|
||||
});
|
||||
|
||||
dialogCloseEvent.WaitOne();
|
||||
|
||||
return closedByCallback;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shows an embedded NativeWebView in a full-screen overlay window (Windows/macOS).
|
||||
/// </summary>
|
||||
private async Task<bool> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fallback dialog shown when WebView is unavailable (Linux) or initialization fails.
|
||||
/// </summary>
|
||||
private async Task<bool> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
45
src/Ryujinx/UI/Applet/WebAppletWindow.axaml
Normal file
45
src/Ryujinx/UI/Applet/WebAppletWindow.axaml
Normal file
@ -0,0 +1,45 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:local="using:Ryujinx.Ava.UI.Applet"
|
||||
x:Class="Ryujinx.Ava.UI.Applet.WebAppletWindow"
|
||||
Title="Web Applet"
|
||||
Background="Black"
|
||||
Width="1280"
|
||||
Height="720"
|
||||
CanResize="False"
|
||||
ShowInTaskbar="False"
|
||||
WindowStartupLocation="Manual"
|
||||
SystemDecorations="Full"
|
||||
ExtendClientAreaToDecorationsHint="True"
|
||||
ExtendClientAreaChromeHints="NoChrome"
|
||||
ExtendClientAreaTitleBarHeightHint="-1">
|
||||
|
||||
<Grid Name="RootGrid">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="*" />
|
||||
<RowDefinition Height="Auto" />
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<!-- WebView2 native host fills the main area -->
|
||||
<local:WebView2Host x:Name="WebViewHost" Grid.Row="0" />
|
||||
|
||||
<!-- Loading overlay (shown until WebView2 is ready) -->
|
||||
<TextBlock Name="LoadingText"
|
||||
Grid.Row="0"
|
||||
Text="Initializing web applet..."
|
||||
Foreground="White"
|
||||
FontSize="18"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center" />
|
||||
|
||||
<!-- Bottom bar with Skip button -->
|
||||
<Border Grid.Row="1" Background="#1A1A1A" Padding="8">
|
||||
<Button Name="CloseButton"
|
||||
Content="Skip"
|
||||
HorizontalAlignment="Right"
|
||||
Padding="24,6"
|
||||
FontSize="13"
|
||||
Click="CloseButton_Click" />
|
||||
</Border>
|
||||
</Grid>
|
||||
</Window>
|
||||
122
src/Ryujinx/UI/Applet/WebAppletWindow.axaml.cs
Normal file
122
src/Ryujinx/UI/Applet/WebAppletWindow.axaml.cs
Normal file
@ -0,0 +1,122 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Threading;
|
||||
using Ryujinx.Common.Logging;
|
||||
using System;
|
||||
using System.Threading;
|
||||
|
||||
namespace Ryujinx.Ava.UI.Applet
|
||||
{
|
||||
public partial class WebAppletWindow : Window
|
||||
{
|
||||
private CancellationTokenRegistration _cancellationRegistration;
|
||||
private string _pendingUrl;
|
||||
private bool _navigated;
|
||||
|
||||
/// <summary>
|
||||
/// True if the window was closed by the callback (not by user).
|
||||
/// </summary>
|
||||
public bool ClosedByCallback { get; private set; }
|
||||
|
||||
public WebAppletWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
// Subscribe to WebView2Host events
|
||||
WebViewHost.WebView2Ready += OnWebView2Ready;
|
||||
WebViewHost.NavigationCompleted += OnNavigationCompleted;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers the URL and cancellation token. Navigation happens once WebView2 is ready.
|
||||
/// </summary>
|
||||
public void Navigate(string url, CancellationToken cancellationToken)
|
||||
{
|
||||
Logger.Info?.Print(LogClass.ServiceAm, $"WebAppletWindow: URL queued: {url}");
|
||||
|
||||
_pendingUrl = url;
|
||||
|
||||
_cancellationRegistration = cancellationToken.Register(() =>
|
||||
{
|
||||
ClosedByCallback = true;
|
||||
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
Close();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Window may already be closed
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
protected override void OnOpened(EventArgs e)
|
||||
{
|
||||
base.OnOpened(e);
|
||||
Logger.Info?.Print(LogClass.ServiceAm, "WebAppletWindow: Window opened");
|
||||
}
|
||||
|
||||
private void OnWebView2Ready(bool success, string errorMessage)
|
||||
{
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
if (success)
|
||||
{
|
||||
Logger.Info?.Print(LogClass.ServiceAm, "WebAppletWindow: WebView2 ready, navigating...");
|
||||
|
||||
if (!_navigated && !string.IsNullOrEmpty(_pendingUrl))
|
||||
{
|
||||
_navigated = true;
|
||||
WebViewHost.NavigateToUrl(_pendingUrl);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Logger.Error?.Print(LogClass.ServiceAm,
|
||||
$"WebAppletWindow: WebView2 failed: {errorMessage}");
|
||||
|
||||
TextBlock loadingText = this.FindControl<TextBlock>("LoadingText");
|
||||
if (loadingText != null)
|
||||
{
|
||||
loadingText.Text = $"WebView2 initialization failed.\n{errorMessage}\n\nClick 'Skip' to continue.";
|
||||
loadingText.TextAlignment = Avalonia.Media.TextAlignment.Center;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void OnNavigationCompleted(bool success)
|
||||
{
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
if (success)
|
||||
{
|
||||
// Hide loading text once page loads
|
||||
TextBlock loadingText = this.FindControl<TextBlock>("LoadingText");
|
||||
if (loadingText != null)
|
||||
{
|
||||
loadingText.IsVisible = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void CloseButton_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
ClosedByCallback = false;
|
||||
Close();
|
||||
}
|
||||
|
||||
protected override void OnClosed(EventArgs e)
|
||||
{
|
||||
WebViewHost.WebView2Ready -= OnWebView2Ready;
|
||||
WebViewHost.NavigationCompleted -= OnNavigationCompleted;
|
||||
_cancellationRegistration.Dispose();
|
||||
base.OnClosed(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
266
src/Ryujinx/UI/Applet/WebView2Host.cs
Normal file
266
src/Ryujinx/UI/Applet/WebView2Host.cs
Normal file
@ -0,0 +1,266 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Platform;
|
||||
using Microsoft.Web.WebView2.Core;
|
||||
using Ryujinx.Common.Logging;
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Runtime.Versioning;
|
||||
using System.Threading.Tasks;
|
||||
using static Ryujinx.Ava.UI.Helpers.Win32NativeInterop;
|
||||
|
||||
namespace Ryujinx.Ava.UI.Applet
|
||||
{
|
||||
/// <summary>
|
||||
/// An Avalonia NativeControlHost that embeds a WebView2 browser instance directly
|
||||
/// using the CoreWebView2 COM API. Handles WM_SIZE to keep the WebView2 controller
|
||||
/// properly sized when Avalonia resizes the host window.
|
||||
/// </summary>
|
||||
public class WebView2Host : NativeControlHost
|
||||
{
|
||||
private const uint WM_SIZE = 0x0005;
|
||||
|
||||
private nint _hwnd;
|
||||
private CoreWebView2Controller _controller;
|
||||
private CoreWebView2Environment _environment;
|
||||
private string _pendingUrl;
|
||||
private bool _isInitialized;
|
||||
private WindowProc _wndProcDelegate;
|
||||
private string _className;
|
||||
|
||||
/// <summary>Fires when the WebView2 engine is ready (or failed). Args: (success, errorMessage).</summary>
|
||||
public event Action<bool, string> WebView2Ready;
|
||||
|
||||
/// <summary>Fires when navigation starts. Arg: URL.</summary>
|
||||
public event Action<string> NavigationStarted;
|
||||
|
||||
/// <summary>Fires when navigation completes. Arg: success.</summary>
|
||||
public event Action<bool> NavigationCompleted;
|
||||
|
||||
/// <summary>True once the WebView2 engine is fully initialized.</summary>
|
||||
public bool IsWebView2Ready => _isInitialized;
|
||||
|
||||
/// <summary>
|
||||
/// Queues a URL for navigation. If WebView2 is already initialized, navigates immediately.
|
||||
/// </summary>
|
||||
public void NavigateToUrl(string url)
|
||||
{
|
||||
_pendingUrl = url;
|
||||
|
||||
if (_controller?.CoreWebView2 != null)
|
||||
{
|
||||
_controller.CoreWebView2.Navigate(url);
|
||||
}
|
||||
}
|
||||
|
||||
protected override IPlatformHandle CreateNativeControlCore(IPlatformHandle parent)
|
||||
{
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
return CreateWin32(parent);
|
||||
}
|
||||
|
||||
Logger.Warning?.Print(LogClass.ServiceAm, "WebView2Host: Only supported on Windows");
|
||||
return base.CreateNativeControlCore(parent);
|
||||
}
|
||||
|
||||
protected override void DestroyNativeControlCore(IPlatformHandle control)
|
||||
{
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
DestroyWin32();
|
||||
}
|
||||
else
|
||||
{
|
||||
base.DestroyNativeControlCore(control);
|
||||
}
|
||||
}
|
||||
|
||||
[SupportedOSPlatform("windows")]
|
||||
private PlatformHandle CreateWin32(IPlatformHandle parent)
|
||||
{
|
||||
_className = "RyujinxWebView2-" + Guid.NewGuid();
|
||||
|
||||
// Window proc that handles WM_SIZE to keep WebView2 controller bounds in sync
|
||||
_wndProcDelegate = (hWnd, msg, wParam, lParam) =>
|
||||
{
|
||||
if ((uint)msg == WM_SIZE && _controller != null)
|
||||
{
|
||||
int width = (int)(lParam & 0xFFFF);
|
||||
int height = (int)((lParam >> 16) & 0xFFFF);
|
||||
|
||||
if (width > 0 && height > 0)
|
||||
{
|
||||
_controller.Bounds = new System.Drawing.Rectangle(0, 0, width, height);
|
||||
}
|
||||
}
|
||||
|
||||
return DefWindowProc(hWnd, msg, wParam, lParam);
|
||||
};
|
||||
|
||||
WndClassEx wndClassEx = new()
|
||||
{
|
||||
cbSize = Marshal.SizeOf<WndClassEx>(),
|
||||
hInstance = GetModuleHandle(null),
|
||||
lpfnWndProc = Marshal.GetFunctionPointerForDelegate(_wndProcDelegate),
|
||||
style = ClassStyles.CsOwndc,
|
||||
lpszClassName = Marshal.StringToHGlobalUni(_className),
|
||||
hCursor = CreateArrowCursor(),
|
||||
};
|
||||
|
||||
RegisterClassEx(ref wndClassEx);
|
||||
|
||||
_hwnd = CreateWindowEx(
|
||||
0, _className, "WebView2Host",
|
||||
WindowStyles.WsChild,
|
||||
0, 0, 1, 1, // Initial size doesn't matter - NativeControlHost will resize via WM_SIZE
|
||||
parent.Handle, nint.Zero, nint.Zero, nint.Zero);
|
||||
|
||||
Marshal.FreeHGlobal(wndClassEx.lpszClassName);
|
||||
|
||||
if (_hwnd == nint.Zero)
|
||||
{
|
||||
Logger.Error?.Print(LogClass.ServiceAm,
|
||||
$"WebView2Host: Failed to create child window. Error: {Marshal.GetLastWin32Error()}");
|
||||
return new PlatformHandle(nint.Zero, "HWND");
|
||||
}
|
||||
|
||||
Logger.Info?.Print(LogClass.ServiceAm,
|
||||
$"WebView2Host: Child window created (HWND=0x{_hwnd:X})");
|
||||
|
||||
// Start async WebView2 initialization
|
||||
_ = InitializeWebView2Async();
|
||||
|
||||
return new PlatformHandle(_hwnd, "HWND");
|
||||
}
|
||||
|
||||
[SupportedOSPlatform("windows")]
|
||||
private async Task InitializeWebView2Async()
|
||||
{
|
||||
try
|
||||
{
|
||||
Logger.Info?.Print(LogClass.ServiceAm, "WebView2Host: Creating CoreWebView2Environment...");
|
||||
|
||||
string userDataFolder = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"Ryujinx", "WebView2");
|
||||
|
||||
_environment = await CoreWebView2Environment.CreateAsync(
|
||||
browserExecutableFolder: null,
|
||||
userDataFolder: userDataFolder);
|
||||
|
||||
Logger.Info?.Print(LogClass.ServiceAm,
|
||||
$"WebView2Host: Environment created (version={_environment.BrowserVersionString}). Creating controller...");
|
||||
|
||||
_controller = await _environment.CreateCoreWebView2ControllerAsync(_hwnd);
|
||||
|
||||
Logger.Info?.Print(LogClass.ServiceAm, "WebView2Host: Controller created. Configuring...");
|
||||
|
||||
// Query the actual HWND size (in physical pixels) and set controller bounds
|
||||
if (GetClientRect(_hwnd, out RECT rect))
|
||||
{
|
||||
int width = rect.Right - rect.Left;
|
||||
int height = rect.Bottom - rect.Top;
|
||||
_controller.Bounds = new System.Drawing.Rectangle(0, 0,
|
||||
Math.Max(1, width), Math.Max(1, height));
|
||||
|
||||
Logger.Info?.Print(LogClass.ServiceAm,
|
||||
$"WebView2Host: Initial bounds set to {width}x{height} (physical pixels)");
|
||||
}
|
||||
|
||||
_controller.IsVisible = true;
|
||||
|
||||
// Configure settings
|
||||
CoreWebView2Settings settings = _controller.CoreWebView2.Settings;
|
||||
settings.AreDevToolsEnabled = false;
|
||||
settings.AreDefaultContextMenusEnabled = false;
|
||||
settings.IsStatusBarEnabled = false;
|
||||
settings.IsZoomControlEnabled = false;
|
||||
|
||||
// Subscribe to events
|
||||
_controller.CoreWebView2.NavigationStarting += (s, e) =>
|
||||
{
|
||||
Logger.Info?.Print(LogClass.ServiceAm, $"WebView2: Navigation starting: {e.Uri}");
|
||||
NavigationStarted?.Invoke(e.Uri);
|
||||
};
|
||||
|
||||
_controller.CoreWebView2.NavigationCompleted += (s, e) =>
|
||||
{
|
||||
Logger.Info?.Print(LogClass.ServiceAm,
|
||||
$"WebView2: Navigation completed (Success={e.IsSuccess}, Status={e.WebErrorStatus})");
|
||||
NavigationCompleted?.Invoke(e.IsSuccess);
|
||||
};
|
||||
|
||||
_controller.CoreWebView2.NewWindowRequested += (s, e) =>
|
||||
{
|
||||
Logger.Info?.Print(LogClass.ServiceAm, $"WebView2: New window requested: {e.Uri}");
|
||||
e.Handled = true;
|
||||
_controller.CoreWebView2.Navigate(e.Uri);
|
||||
};
|
||||
|
||||
_isInitialized = true;
|
||||
|
||||
Logger.Info?.Print(LogClass.ServiceAm,
|
||||
$"WebView2Host: Initialization complete! Browser version: {_environment.BrowserVersionString}");
|
||||
|
||||
// Navigate to pending URL
|
||||
if (!string.IsNullOrEmpty(_pendingUrl))
|
||||
{
|
||||
Logger.Info?.Print(LogClass.ServiceAm, $"WebView2Host: Navigating to pending URL: {_pendingUrl}");
|
||||
_controller.CoreWebView2.Navigate(_pendingUrl);
|
||||
}
|
||||
|
||||
WebView2Ready?.Invoke(true, null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error?.Print(LogClass.ServiceAm,
|
||||
$"WebView2Host: Initialization FAILED: {ex}");
|
||||
WebView2Ready?.Invoke(false, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
[SupportedOSPlatform("windows")]
|
||||
private void DestroyWin32()
|
||||
{
|
||||
try
|
||||
{
|
||||
_controller?.Close();
|
||||
_controller = null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warning?.Print(LogClass.ServiceAm,
|
||||
$"WebView2Host: Error closing controller: {ex.Message}");
|
||||
}
|
||||
|
||||
if (_hwnd != nint.Zero)
|
||||
{
|
||||
DestroyWindow(_hwnd);
|
||||
_hwnd = nint.Zero;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(_className))
|
||||
{
|
||||
UnregisterClass(_className, GetModuleHandle(null));
|
||||
}
|
||||
}
|
||||
|
||||
#region Win32 Interop
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct RECT
|
||||
{
|
||||
public int Left;
|
||||
public int Top;
|
||||
public int Right;
|
||||
public int Bottom;
|
||||
}
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool GetClientRect(nint hWnd, out RECT lpRect);
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
@ -5,6 +5,7 @@ using Avalonia.Markup.Xaml;
|
||||
using Avalonia.Platform;
|
||||
using Avalonia.Styling;
|
||||
using Avalonia.Threading;
|
||||
using AvaloniaWebView;
|
||||
using FluentAvalonia.UI.Windowing;
|
||||
using Gommon;
|
||||
using Ryujinx.Ava.Common.Locale;
|
||||
@ -49,6 +50,14 @@ namespace Ryujinx.Ava
|
||||
public static void SetTaskbarProgressValue(ulong current, ulong total) => MainWindow.PlatformFeatures.SetTaskBarProgressBarValue(current, total);
|
||||
public static void SetTaskbarProgressValue(long current, long total) => SetTaskbarProgressValue(Convert.ToUInt64(current), Convert.ToUInt64(total));
|
||||
|
||||
public override void RegisterServices()
|
||||
{
|
||||
base.RegisterServices();
|
||||
|
||||
// Initialize the WebView builder so that embedded WebView controls work.
|
||||
AvaloniaWebViewBuilder.Initialize(default);
|
||||
}
|
||||
|
||||
public override void Initialize()
|
||||
{
|
||||
Name = FormatTitle();
|
||||
|
||||
Loading…
Reference in New Issue
Block a user