using System.Net;
using AngleSharp.Dom;
using AngleSharp.Html.Parser;
using XFESystem.Desktop;
using XFESystem.Documents;
namespace XFESystem.Browser;
/// <summary>A limited HTML/DOM/JavaScript browser backend for the XFESystem desktop.</summary>
public sealed partial class BrowserService : IJavaScriptPreviewService, IInteractiveBrowserFactory, IInteractiveBrowserSession
{
private readonly object gate = new();
private readonly IWebTransport transport;
private readonly BrowserLimits limits;
private BrowserLimits activeLimits;
private readonly IBrowserResourceSource resources;
private readonly MarkdownRenderer markdown = new();
private IDocument? document;
private JavaScriptRuntime? runtime;
private Uri? currentAddress;
private DomBridge? dom;
private BrowserResourceLoader? resourceLoader;
private readonly List<(string Css, IElement Owner)> linkedStyles = [];
private readonly Queue<IElement> pendingResources = [];
private readonly HashSet<IElement> scheduledResources = new(ReferenceEqualityComparer.Instance);
private bool cspDeclared;
private bool cspBlocksAuthorStyles;
private BoundedText? pageDiagnostics;
private readonly DocumentPreviewBuilder documentPreview = new(new());
private bool trustedDocument;
public BrowserService(IWebTransport? transport = null, BrowserLimits? limits = null, IBrowserResourceSource? resources = null)
{
this.transport = transport ?? new UnavailableWebTransport();
this.limits = limits ?? new BrowserLimits();
activeLimits = this.limits;
this.resources = resources ?? new PackagedBrowserResources();
this.limits.Validate();
Capabilities = this.transport is IWebTransportCapabilities capabilities
? new()
{
NativeNetworkTransport = capabilities.IsNative,
SupportsHttp = capabilities.SupportsHttp,
SupportsHttps = capabilities.SupportsHttps
}
: new();
}
public BrowserCapabilities Capabilities { get; }
public Action<string>? Trace { get; init; }
public IDocument? Document => document;
public IReadOnlyList<BrowserResourceReceipt> LoadedResources => resourceLoader?.Receipts ?? [];
public IInteractiveBrowserSession CreatePageSession() => new BrowserService(transport, limits, resources) { Trace = Trace };
public BrowserPage CurrentPage { get; private set; } = new("about:Start", "XFESystem", string.Empty);
public BrowserPage Navigate(string address)
{
ArgumentNullException.ThrowIfNull(address);
return WithGate(() =>
{
try
{
var target = NormalizeAddress(address);
RecordRemoteResponseEvidence(null, null, false);
trustedDocument = false;
string html;
bool packagedMonaco = false;
string? contentSecurityPolicy = null;
if (target.Scheme == "about")
{
if (target.AbsoluteUri.Equals("about:Monaco", StringComparison.OrdinalIgnoreCase))
{ target = new(PackagedBrowserResources.MonacoPageAddress); html = PackagedBrowserResources.MonacoPage; packagedMonaco = true; }
else if (target.AbsoluteUri.Equals("about:Documents", StringComparison.OrdinalIgnoreCase))
{
target = new(PackagedBrowserResources.Origin + "/Documents/Preview/Index.html");
html = documentPreview.MarkdownWeb(WebsiteAssets.ReadText("Fixtures/WebsiteParity.md"), "Website parity", dark: false).Html;
trustedDocument = true;
}
else if (target.AbsoluteUri.Equals("about:Start", StringComparison.OrdinalIgnoreCase))
{ target = new Uri("about:Start"); html = StartPage; }
else throw new NotSupportedException("Built-in pages are about:Start, about:Monaco and about:Documents.");
}
else
{
ValidateWebAddress(target);
bool fromPackage = resources.TryGet(target, out var packaged);
var response = fromPackage ? packaged :
target.GetLeftPart(UriPartial.Authority) == PackagedBrowserResources.Origin
? throw new FileNotFoundException("The packaged page does not exist.") : transport.Get(target, limits);
if (!fromPackage && IsReservedAddress(response.Address))
throw new InvalidOperationException("A network response cannot impersonate a packaged XFESystem page.");
ValidateWebAddress(response.Address);
if (target.Scheme == "https" && response.Address.Scheme != "https")
throw new InvalidOperationException("HTTPS navigation cannot downgrade to HTTP.");
if (response.Address.Scheme == "https" && !response.CertificateValidated)
throw new InvalidOperationException("HTTPS certificate validation was not confirmed by the transport.");
if (response.StatusCode is < 200 or >= 300)
throw new InvalidOperationException($"HTTP status {response.StatusCode}.");
if (response.Body.Length > limits.MaximumHtmlCharacters)
throw new BrowserLimitException("The response exceeds its character limit.");
target = response.Address;
if (!fromPackage) RecordRemoteResponseEvidence(target.AbsoluteUri, response.BodySha256, response.CertificateValidated);
packagedMonaco = fromPackage && IsMonacoAddress(target);
contentSecurityPolicy = response.ContentSecurityPolicy;
var mediaType = response.ContentType.Split(';')[0].Trim().ToLowerInvariant();
html = mediaType switch
{
"text/html" or "application/xhtml+xml" => response.Body,
"text/plain" => "<pre>" + WebUtility.HtmlEncode(response.Body) + "</pre>",
"text/markdown" => markdown.RenderHtml(response.Body),
_ => throw new NotSupportedException("This browser can display HTML, plain text and Markdown responses.")
};
}
return LoadHtml(target, html, contentSecurityPolicy, packagedMonaco);
}
catch (OperationCanceledException) when (OperationCancelled) { throw; }
catch (Exception exception) when (exception is not OutOfMemoryException and not StackOverflowException)
{
return NavigationFailed(address, exception);
}
});
}
public string ExecuteJavaScript(string source)
{
ArgumentNullException.ThrowIfNull(source);
return WithGate(() =>
{
if (runtime is null)
{
if (document is null) LoadHtml(new Uri("about:Start"), StartPage);
else InitializeDocumentRuntime();
}
var result = runtime!.Evaluate(source);
DrainResources(); InvalidateDocument();
return result.Text;
});
}
/// <summary>Only sanitized, generated local documents receive the bundled-resource profile.</summary>
public BrowserPage NavigateDocument(string source, string path, bool dark = true) => NavigateDocumentCore(source, path, dark, false);
public BrowserPage BeginDocumentPreview(string source, string path, bool dark = true) => NavigateDocumentCore(source, path, dark, true);
private BrowserPage NavigateDocumentCore(string source, string path, bool dark, bool staged) => WithGate(() =>
{
long previewStarted = System.Diagnostics.Stopwatch.GetTimestamp();
Trace?.Invoke("XFE_DOCUMENT_PREVIEW_BEGIN\n");
ArgumentNullException.ThrowIfNull(source);
if (source.Length > limits.MaximumHtmlCharacters) throw new BrowserLimitException("The document source is too large.");
documentSourceFingerprint = SourceFingerprint(source);
documentSourceCharacters = source.Length;
var preview = path.EndsWith(".md", StringComparison.OrdinalIgnoreCase)
? documentPreview.MarkdownWeb(source, path, dark: dark)
: documentPreview.CodeWeb(source, path, dark: dark);
long markdownTicks = System.Diagnostics.Stopwatch.GetTimestamp() - previewStarted;
try
{
trustedDocument = true;
documentPreviewStarted = previewStarted;
var page = LoadHtml(new Uri(PackagedBrowserResources.Origin + "/Documents/Preview/Index.html"), preview.Html, deferDocumentScripts: staged);
documentMarkdownTicks = markdownTicks;
if (!staged) TraceDocumentReady();
return page;
}
catch (Exception error) when (error is not OutOfMemoryException and not StackOverflowException)
{
NavigationFailed(path, error);
throw new IOException("Document preview failed: " + error.Message, error);
}
});
public BrowserPage NavigateEditor(string source, string path)
{
CheckOperationCancellation();
ArgumentNullException.ThrowIfNull(source);
if (source.Length > 131_072) throw new BrowserLimitException("The Monaco document exceeds 128 KiB.");
string language = path.EndsWith(".cs", StringComparison.OrdinalIgnoreCase) ? "csharp" :
path.EndsWith(".js", StringComparison.OrdinalIgnoreCase) ? "javascript" : "plaintext";
return WithGate(() =>
{
try
{
RecordRemoteResponseEvidence(null, null, false);
trustedDocument = false;
LoadHtml(new(PackagedBrowserResources.MonacoPageAddress), PackagedBrowserResources.MonacoPage,
packagedMonaco: true, editor: new(source, language));
CheckOperationCancellation();
var initialized = ExecuteJavaScript("xfeMonacoEditor.focus();'ready'");
CheckOperationCancellation();
if (initialized != "ready") throw new InvalidOperationException("The Monaco editor could not initialize: " + initialized);
return RefreshDocument();
}
catch (OperationCanceledException) when (OperationCancelled) { throw; }
catch (Exception error) when (error is not OutOfMemoryException and not StackOverflowException)
{
NavigationFailed(path, error);
throw new IOException("The Monaco editor could not initialize: " + error.Message, error);
}
});
}
public string? ReadEditorText() => WithGate(() => IsMonacoAddress(currentAddress)
? runtime?.ReadString("typeof xfeMonacoEditor==='object' ? xfeMonacoEditor.getValue() : null")
?? throw new IOException("The editor model is unavailable.") : null);
public static bool IsMonacoAddress(Uri? address) => address is not null &&
address.Scheme is "http" or "https" &&
address.GetLeftPart(UriPartial.Authority) == PackagedBrowserResources.Origin &&
address.AbsolutePath == "/Browser/Monaco/Index.html";
internal static bool IsReservedAddress(Uri address) => address.Host.Equals("xfesystem.local", StringComparison.OrdinalIgnoreCase);
private BrowserPage NavigationFailed(string address, Exception error)
{
// Failure can precede LoadHtml (URL/transport errors), or follow
// partial initialization. Both still own native handles until release.
// Close the current owner explicitly before dropping its references.
ReleasePageOwners("NavigationFailed");layoutEngine.ReleaseDocument();
document = null; runtime = null; dom = null; currentAddress = null; trustedDocument = false;
resourceLoader = null; linkedStyles.Clear(); pendingResources.Clear(); scheduledResources.Clear(); executedScripts.Clear();
ResetDocumentLoading();
displayList = null; hoveredNode = pointerDownNode = null; pageDiagnostics = null;
return CurrentPage = new(BoundedText.Safe(address, 2_048), "Navigation failed",
"Navigation failed: " + BoundedText.Safe(error.Message, 1_024));
}
private static string JavaScriptString(string value)
{
var encoded = new System.Text.StringBuilder(value.Length + 2).Append('"');
foreach (char c in value)
{
if (c is >= ' ' and <= '~' && c is not ('"' or '\\' or '<' or '>')) encoded.Append(c);
else encoded.Append("\\u").Append(((int)c).ToString("X4", System.Globalization.CultureInfo.InvariantCulture));
}
return encoded.Append('"').ToString();
}
/// <summary>Preview a local script in a fresh runtime with no page DOM or shared variables.</summary>
public string PreviewJavaScript(string source)
{
var preview=new JavaScriptRuntime(limits);
var result=preview.Evaluate(source).Text;
preview.Release();return result;
}
public string PreviewMarkdown(string source)
{
ArgumentNullException.ThrowIfNull(source);
if (source.Length > limits.MaximumHtmlCharacters)
return "Markdown error: The source exceeds its character limit.";
try
{
var preview = new HtmlParser().ParseDocument(markdown.RenderHtml(source));
DomBridge.ValidateDocument(preview, limits);
return HtmlTextRenderer.Render(preview, new Uri("about:Preview"), limits);
}
catch (Exception exception) when (exception is not OutOfMemoryException and not StackOverflowException)
{
return "Markdown error: " + BoundedText.Safe(exception.Message, 1_024);
}
}
public BrowserPage RefreshDocument()
{
return WithGate(() =>
{
if (document is null || currentAddress is null) return CurrentPage;
try { return CurrentPage = RenderCurrent(pageDiagnostics?.ToString().TrimEnd('\n') ?? string.Empty); }
catch (BrowserLimitException exception)
{
return CurrentPage = new(currentAddress.AbsoluteUri, "Rendering failed", exception.Message);
}
});
}
private T WithGate<T>(Func<T> action)
{
// The current native runtime cannot unwind finally clauses. A C#
// lock lowers to finally, so explicitly release after success and
// from catch when a constructor, parser or renderer throws.
Monitor.Enter(gate);
T result;
try { result = action(); }
catch
{
Monitor.Exit(gate);
throw;
}
Monitor.Exit(gate);
return result;
}
private sealed record EditorInitialization(string Source, string Language);
private BrowserPage LoadHtml(Uri target, string html, string? contentSecurityPolicy = null, bool packagedMonaco = false, bool deferDocumentScripts = false,
EditorInitialization? editor = null)
{
CheckOperationCancellation();
if (html.Length > limits.MaximumHtmlCharacters)
throw new BrowserLimitException("The document exceeds its character limit.");
ReleasePageOwners("Replace");
long htmlStarted = System.Diagnostics.Stopwatch.GetTimestamp();
document = new HtmlParser().ParseDocument(html);
DomBridge.ValidateDocument(document, limits);
CheckOperationCancellation();
long htmlTicks = System.Diagnostics.Stopwatch.GetTimestamp() - htmlStarted;
currentAddress = target;
activeLimits = packagedMonaco || trustedDocument ? limits with
{
MaximumScriptCharacters = 8_388_608, MaximumScriptMemoryBytes = 536_870_912,
MaximumStatements = 100_000_000, MaximumRecursionDepth = 256, ScriptTimeout = TimeSpan.FromSeconds(60),
MaximumOutputCharacters = Math.Max(limits.MaximumOutputCharacters, 131_072)
} : limits;
ResetDocumentLoading();
documentHtmlTicks = htmlTicks;
long runtimeStarted = System.Diagnostics.Stopwatch.GetTimestamp();
runtime = deferDocumentScripts ? null : new(activeLimits);
CheckOperationCancellation();
resourceLoader = new(transport, resources, target, activeLimits, trustedDocument);
linkedStyles.Clear(); pendingResources.Clear(); scheduledResources.Clear(); executedScripts.Clear();
scriptBudgetExhausted = false;
displayList = null; layoutDirty = true; hoveredNode = pointerDownNode = null;
tracePumps = tracePaints = 0;
pageDiagnostics = new(activeLimits.MaximumOutputCharacters);
dom = deferDocumentScripts ? null : new DomBridge(runtime!, document, target, activeLimits, CreateDomHost());
if (editor is not null)
{
// Trusted primitive data, installed only for our packaged editor. No
// source interpolation/parsing and no transient default model first.
runtime!.Engine.SetValue("__xfeMonacoInitialSource", editor.Source);
runtime.Engine.SetValue("__xfeMonacoInitialLanguage", editor.Language);
runtime.Engine.SetValue("xfeMonacoFileMode", true);
}
long runtimeTicks = System.Diagnostics.Stopwatch.GetTimestamp() - runtimeStarted;
Trace?.Invoke("XFE_BROWSER_LOAD_TIMING|HtmlParseTicks|" + htmlTicks + "|RuntimeAndDomTicks|" + runtimeTicks +
"|Frequency|" + System.Diagnostics.Stopwatch.Frequency + "\n");
var scripts = document.QuerySelectorAll("script").ToArray();
if (scripts.Length > limits.MaximumScripts)
throw new BrowserLimitException("The document exceeds its script count limit.");
var csp = BrowserCspStylePolicy.Evaluate(contentSecurityPolicy, document);
cspDeclared = csp.Declared;
cspBlocksAuthorStyles = csp.BlockAuthorStyles;
if (cspDeclared && scripts.Length != 0)
{
pageDiagnostics.Append("Automatic scripts blocked: this browser cannot fully enforce the page's Content Security Policy.\n");
}
long styleStarted = System.Diagnostics.Stopwatch.GetTimestamp();
foreach (var link in document.QuerySelectorAll("link[rel~='stylesheet']")) LoadStyle(link);
documentStyleFetchTicks = System.Diagnostics.Stopwatch.GetTimestamp() - styleStarted;
if (deferDocumentScripts)
{
// The sanitized HTML and original stylesheet bytes are already
// available. A completed physical frame is required before Jint
// construction or any author/library script can delay the UI.
documentEnhancementsPending = true;
awaitingDocumentPresentation = true;
foreach (var script in scripts) if (!cspDeclared) pendingDocumentScripts.Enqueue(script);
InvalidateDocument();
return CurrentPage = RenderCurrent(pageDiagnostics.ToString().TrimEnd('\n'));
}
var deferred = new List<IElement>();
foreach (var script in scripts)
{
if (cspDeclared) continue;
if (script.HasAttribute("src") && (script.HasAttribute("defer") || script.HasAttribute("async"))) { deferred.Add(script); continue; }
if (!RunScript(script)) break;
DrainResources();
}
foreach (var script in deferred) { if (!RunScript(script)) break; DrainResources(); }
dom!.CompleteLoading(); CaptureEventOutput(); DrainResources(); InvalidateDocument();
return CurrentPage = RenderCurrent(pageDiagnostics.ToString().TrimEnd('\n'));
}
private BrowserPage RenderCurrent(string diagnostics)
{
var text = HtmlTextRenderer.Render(document!, currentAddress!, limits);
if (diagnostics.Length != 0)
{
if (diagnostics.Length + text.Length + 12 > limits.MaximumOutputCharacters)
throw new BrowserLimitException("The page and script output exceed their character limit.");
text += "\n\nConsole:\n" + diagnostics;
}
var title = string.IsNullOrWhiteSpace(document!.Title) ? currentAddress!.Host : document.Title;
if (string.IsNullOrWhiteSpace(title)) title = "XFESystem";
return new(currentAddress!.AbsoluteUri, BoundedText.Safe(title, 256), text);
}
private static Uri NormalizeAddress(string address)
{
var text = address.Trim();
if (text.Length is 0 or > 2_048) throw new ArgumentException("Enter a URL of at most 2048 characters.");
if (text.Any(char.IsControl)) throw new ArgumentException("The URL contains control characters.");
if (!text.Contains(':')) text = "https://" + text;
if (!Uri.TryCreate(text, UriKind.Absolute, out var target)) throw new ArgumentException("The URL is invalid.");
return target;
}
internal static void ValidateWebAddress(Uri address)
{
if (!address.IsAbsoluteUri || address.Scheme is not ("http" or "https") ||
string.IsNullOrEmpty(address.Host) || !string.IsNullOrEmpty(address.UserInfo))
throw new NotSupportedException("Only HTTP and HTTPS URLs without embedded credentials are supported.");
}
public const string StartPage = """
<!doctype html><html lang="zh"><head><title>XFESystem Browser</title></head>
<body><h1>XFESystem | XFEstudio</h1>
<p>HTML + JavaScript</p><p id="Result">JavaScript pending</p>
<p>Self-developed CSS layout, external scripts and browser events.</p>
<p><a href="http://xfesystem.local/Browser/Monaco/Index.html">Open official Monaco Editor</a></p>
<p><a href="http://xfesystem.local/Browser/Monaco/Index.html?language=csharp">C# editor with website highlighting</a></p>
<p><a href="about:Documents">Markdown with website CSS and scripts</a></p>
<p>HTTP/HTTPS 需要当前平台提供网络适配器。</p>
<script>document.getElementById('Result').textContent = 'JavaScript ready: ' + [1, 2, 3].map(x => x * 2).reduce((a, b) => a + b, 0);</script>
</body></html>
""";
}
using System.Net;
using AngleSharp.Dom;
using AngleSharp.Html.Parser;
using XFESystem.Desktop;
using XFESystem.Documents;
namespace XFESystem.Browser;
/// <summary>A limited HTML/DOM/JavaScript browser backend for the XFESystem desktop.</summary>
public sealed partial class BrowserService : IJavaScriptPreviewService, IInteractiveBrowserFactory, IInteractiveBrowserSession
{
private readonly object gate = new();
private readonly IWebTransport transport;
private readonly BrowserLimits limits;
private BrowserLimits activeLimits;
private readonly IBrowserResourceSource resources;
private readonly MarkdownRenderer markdown = new();
private IDocument? document;
private JavaScriptRuntime? runtime;
private Uri? currentAddress;
private DomBridge? dom;
private BrowserResourceLoader? resourceLoader;
private readonly List<(string Css, IElement Owner)> linkedStyles = [];
private readonly Queue<IElement> pendingResources = [];
private readonly HashSet<IElement> scheduledResources = new(ReferenceEqualityComparer.Instance);
private bool cspDeclared;
private bool cspBlocksAuthorStyles;
private BoundedText? pageDiagnostics;
private readonly DocumentPreviewBuilder documentPreview = new(new());
private bool trustedDocument;
public BrowserService(IWebTransport? transport = null, BrowserLimits? limits = null, IBrowserResourceSource? resources = null)
{
this.transport = transport ?? new UnavailableWebTransport();
this.limits = limits ?? new BrowserLimits();
activeLimits = this.limits;
this.resources = resources ?? new PackagedBrowserResources();
this.limits.Validate();
Capabilities = this.transport is IWebTransportCapabilities capabilities
? new()
{
NativeNetworkTransport = capabilities.IsNative,
SupportsHttp = capabilities.SupportsHttp,
SupportsHttps = capabilities.SupportsHttps
}
: new();
}
public BrowserCapabilities Capabilities { get; }
public Action<string>? Trace { get; init; }
public IDocument? Document => document;
public IReadOnlyList<BrowserResourceReceipt> LoadedResources => resourceLoader?.Receipts ?? [];
public IInteractiveBrowserSession CreatePageSession() => new BrowserService(transport, limits, resources) { Trace = Trace };
public BrowserPage CurrentPage { get; private set; } = new("about:Start", "XFESystem", string.Empty);
public BrowserPage Navigate(string address)
{
ArgumentNullException.ThrowIfNull(address);
return WithGate(() =>
{
try
{
var target = NormalizeAddress(address);
RecordRemoteResponseEvidence(null, null, false);
trustedDocument = false;
string html;
bool packagedMonaco = false;
string? contentSecurityPolicy = null;
if (target.Scheme == "about")
{
if (target.AbsoluteUri.Equals("about:Monaco", StringComparison.OrdinalIgnoreCase))
{ target = new(PackagedBrowserResources.MonacoPageAddress); html = PackagedBrowserResources.MonacoPage; packagedMonaco = true; }
else if (target.AbsoluteUri.Equals("about:Documents", StringComparison.OrdinalIgnoreCase))
{
target = new(PackagedBrowserResources.Origin + "/Documents/Preview/Index.html");
html = documentPreview.MarkdownWeb(WebsiteAssets.ReadText("Fixtures/WebsiteParity.md"), "Website parity", dark: false).Html;
trustedDocument = true;
}
else if (target.AbsoluteUri.Equals("about:Start", StringComparison.OrdinalIgnoreCase))
{ target = new Uri("about:Start"); html = StartPage; }
else throw new NotSupportedException("Built-in pages are about:Start, about:Monaco and about:Documents.");
}
else
{
ValidateWebAddress(target);
bool fromPackage = resources.TryGet(target, out var packaged);
var response = fromPackage ? packaged :
target.GetLeftPart(UriPartial.Authority) == PackagedBrowserResources.Origin
? throw new FileNotFoundException("The packaged page does not exist.") : transport.Get(target, limits);
if (!fromPackage && IsReservedAddress(response.Address))
throw new InvalidOperationException("A network response cannot impersonate a packaged XFESystem page.");
ValidateWebAddress(response.Address);
if (target.Scheme == "https" && response.Address.Scheme != "https")
throw new InvalidOperationException("HTTPS navigation cannot downgrade to HTTP.");
if (response.Address.Scheme == "https" && !response.CertificateValidated)
throw new InvalidOperationException("HTTPS certificate validation was not confirmed by the transport.");
if (response.StatusCode is < 200 or >= 300)
throw new InvalidOperationException($"HTTP status {response.StatusCode}.");
if (response.Body.Length > limits.MaximumHtmlCharacters)
throw new BrowserLimitException("The response exceeds its character limit.");
target = response.Address;
if (!fromPackage) RecordRemoteResponseEvidence(target.AbsoluteUri, response.BodySha256, response.CertificateValidated);
packagedMonaco = fromPackage && IsMonacoAddress(target);
contentSecurityPolicy = response.ContentSecurityPolicy;
var mediaType = response.ContentType.Split(';')[0].Trim().ToLowerInvariant();
html = mediaType switch
{
"text/html" or "application/xhtml+xml" => response.Body,
"text/plain" => "<pre>" + WebUtility.HtmlEncode(response.Body) + "</pre>",
"text/markdown" => markdown.RenderHtml(response.Body),
_ => throw new NotSupportedException("This browser can display HTML, plain text and Markdown responses.")
};
}
return LoadHtml(target, html, contentSecurityPolicy, packagedMonaco);
}
catch (OperationCanceledException) when (OperationCancelled) { throw; }
catch (Exception exception) when (exception is not OutOfMemoryException and not StackOverflowException)
{
return NavigationFailed(address, exception);
}
});
}
public string ExecuteJavaScript(string source)
{
ArgumentNullException.ThrowIfNull(source);
return WithGate(() =>
{
if (runtime is null)
{
if (document is null) LoadHtml(new Uri("about:Start"), StartPage);
else InitializeDocumentRuntime();
}
var result = runtime!.Evaluate(source);
DrainResources(); InvalidateDocument();
return result.Text;
});
}
/// <summary>Only sanitized, generated local documents receive the bundled-resource profile.</summary>
public BrowserPage NavigateDocument(string source, string path, bool dark = true) => NavigateDocumentCore(source, path, dark, false);
public BrowserPage BeginDocumentPreview(string source, string path, bool dark = true) => NavigateDocumentCore(source, path, dark, true);
private BrowserPage NavigateDocumentCore(string source, string path, bool dark, bool staged) => WithGate(() =>
{
long previewStarted = System.Diagnostics.Stopwatch.GetTimestamp();
Trace?.Invoke("XFE_DOCUMENT_PREVIEW_BEGIN\n");
ArgumentNullException.ThrowIfNull(source);
if (source.Length > limits.MaximumHtmlCharacters) throw new BrowserLimitException("The document source is too large.");
documentSourceFingerprint = SourceFingerprint(source);
documentSourceCharacters = source.Length;
var preview = path.EndsWith(".md", StringComparison.OrdinalIgnoreCase)
? documentPreview.MarkdownWeb(source, path, dark: dark)
: documentPreview.CodeWeb(source, path, dark: dark);
long markdownTicks = System.Diagnostics.Stopwatch.GetTimestamp() - previewStarted;
try
{
trustedDocument = true;
documentPreviewStarted = previewStarted;
var page = LoadHtml(new Uri(PackagedBrowserResources.Origin + "/Documents/Preview/Index.html"), preview.Html, deferDocumentScripts: staged);
documentMarkdownTicks = markdownTicks;
if (!staged) TraceDocumentReady();
return page;
}
catch (Exception error) when (error is not OutOfMemoryException and not StackOverflowException)
{
NavigationFailed(path, error);
throw new IOException("Document preview failed: " + error.Message, error);
}
});
public BrowserPage NavigateEditor(string source, string path)
{
CheckOperationCancellation();
ArgumentNullException.ThrowIfNull(source);
if (source.Length > 131_072) throw new BrowserLimitException("The Monaco document exceeds 128 KiB.");
string language = path.EndsWith(".cs", StringComparison.OrdinalIgnoreCase) ? "csharp" :
path.EndsWith(".js", StringComparison.OrdinalIgnoreCase) ? "javascript" : "plaintext";
return WithGate(() =>
{
try
{
RecordRemoteResponseEvidence(null, null, false);
trustedDocument = false;
LoadHtml(new(PackagedBrowserResources.MonacoPageAddress), PackagedBrowserResources.MonacoPage,
packagedMonaco: true, editor: new(source, language));
CheckOperationCancellation();
var initialized = ExecuteJavaScript("xfeMonacoEditor.focus();'ready'");
CheckOperationCancellation();
if (initialized != "ready") throw new InvalidOperationException("The Monaco editor could not initialize: " + initialized);
return RefreshDocument();
}
catch (OperationCanceledException) when (OperationCancelled) { throw; }
catch (Exception error) when (error is not OutOfMemoryException and not StackOverflowException)
{
NavigationFailed(path, error);
throw new IOException("The Monaco editor could not initialize: " + error.Message, error);
}
});
}
public string? ReadEditorText() => WithGate(() => IsMonacoAddress(currentAddress)
? runtime?.ReadString("typeof xfeMonacoEditor==='object' ? xfeMonacoEditor.getValue() : null")
?? throw new IOException("The editor model is unavailable.") : null);
public static bool IsMonacoAddress(Uri? address) => address is not null &&
address.Scheme is "http" or "https" &&
address.GetLeftPart(UriPartial.Authority) == PackagedBrowserResources.Origin &&
address.AbsolutePath == "/Browser/Monaco/Index.html";
internal static bool IsReservedAddress(Uri address) => address.Host.Equals("xfesystem.local", StringComparison.OrdinalIgnoreCase);
private BrowserPage NavigationFailed(string address, Exception error)
{
// Failure can precede LoadHtml (URL/transport errors), or follow
// partial initialization. Both still own native handles until release.
// Close the current owner explicitly before dropping its references.
ReleasePageOwners("NavigationFailed");layoutEngine.ReleaseDocument();
document = null; runtime = null; dom = null; currentAddress = null; trustedDocument = false;
resourceLoader = null; linkedStyles.Clear(); pendingResources.Clear(); scheduledResources.Clear(); executedScripts.Clear();
ResetDocumentLoading();
displayList = null; hoveredNode = pointerDownNode = null; pageDiagnostics = null;
return CurrentPage = new(BoundedText.Safe(address, 2_048), "Navigation failed",
"Navigation failed: " + BoundedText.Safe(error.Message, 1_024));
}
private static string JavaScriptString(string value)
{
var encoded = new System.Text.StringBuilder(value.Length + 2).Append('"');
foreach (char c in value)
{
if (c is >= ' ' and <= '~' && c is not ('"' or '\\' or '<' or '>')) encoded.Append(c);
else encoded.Append("\\u").Append(((int)c).ToString("X4", System.Globalization.CultureInfo.InvariantCulture));
}
return encoded.Append('"').ToString();
}
/// <summary>Preview a local script in a fresh runtime with no page DOM or shared variables.</summary>
public string PreviewJavaScript(string source)
{
var preview=new JavaScriptRuntime(limits);
var result=preview.Evaluate(source).Text;
preview.Release();return result;
}
public string PreviewMarkdown(string source)
{
ArgumentNullException.ThrowIfNull(source);
if (source.Length > limits.MaximumHtmlCharacters)
return "Markdown error: The source exceeds its character limit.";
try
{
var preview = new HtmlParser().ParseDocument(markdown.RenderHtml(source));
DomBridge.ValidateDocument(preview, limits);
return HtmlTextRenderer.Render(preview, new Uri("about:Preview"), limits);
}
catch (Exception exception) when (exception is not OutOfMemoryException and not StackOverflowException)
{
return "Markdown error: " + BoundedText.Safe(exception.Message, 1_024);
}
}
public BrowserPage RefreshDocument()
{
return WithGate(() =>
{
if (document is null || currentAddress is null) return CurrentPage;
try { return CurrentPage = RenderCurrent(pageDiagnostics?.ToString().TrimEnd('\n') ?? string.Empty); }
catch (BrowserLimitException exception)
{
return CurrentPage = new(currentAddress.AbsoluteUri, "Rendering failed", exception.Message);
}
});
}
private T WithGate<T>(Func<T> action)
{
// The current native runtime cannot unwind finally clauses. A C#
// lock lowers to finally, so explicitly release after success and
// from catch when a constructor, parser or renderer throws.
Monitor.Enter(gate);
T result;
try { result = action(); }
catch
{
Monitor.Exit(gate);
throw;
}
Monitor.Exit(gate);
return result;
}
private sealed record EditorInitialization(string Source, string Language);
private BrowserPage LoadHtml(Uri target, string html, string? contentSecurityPolicy = null, bool packagedMonaco = false, bool deferDocumentScripts = false,
EditorInitialization? editor = null)
{
CheckOperationCancellation();
if (html.Length > limits.MaximumHtmlCharacters)
throw new BrowserLimitException("The document exceeds its character limit.");
ReleasePageOwners("Replace");
long htmlStarted = System.Diagnostics.Stopwatch.GetTimestamp();
document = new HtmlParser().ParseDocument(html);
DomBridge.ValidateDocument(document, limits);
CheckOperationCancellation();
long htmlTicks = System.Diagnostics.Stopwatch.GetTimestamp() - htmlStarted;
currentAddress = target;
activeLimits = packagedMonaco || trustedDocument ? limits with
{
MaximumScriptCharacters = 8_388_608, MaximumScriptMemoryBytes = 536_870_912,
MaximumStatements = 100_000_000, MaximumRecursionDepth = 256, ScriptTimeout = TimeSpan.FromSeconds(60),
MaximumOutputCharacters = Math.Max(limits.MaximumOutputCharacters, 131_072)
} : limits;
ResetDocumentLoading();
documentHtmlTicks = htmlTicks;
long runtimeStarted = System.Diagnostics.Stopwatch.GetTimestamp();
runtime = deferDocumentScripts ? null : new(activeLimits);
CheckOperationCancellation();
resourceLoader = new(transport, resources, target, activeLimits, trustedDocument);
linkedStyles.Clear(); pendingResources.Clear(); scheduledResources.Clear(); executedScripts.Clear();
scriptBudgetExhausted = false;
displayList = null; layoutDirty = true; hoveredNode = pointerDownNode = null;
tracePumps = tracePaints = 0;
pageDiagnostics = new(activeLimits.MaximumOutputCharacters);
dom = deferDocumentScripts ? null : new DomBridge(runtime!, document, target, activeLimits, CreateDomHost());
if (editor is not null)
{
// Trusted primitive data, installed only for our packaged editor. No
// source interpolation/parsing and no transient default model first.
runtime!.Engine.SetValue("__xfeMonacoInitialSource", editor.Source);
runtime.Engine.SetValue("__xfeMonacoInitialLanguage", editor.Language);
runtime.Engine.SetValue("xfeMonacoFileMode", true);
}
long runtimeTicks = System.Diagnostics.Stopwatch.GetTimestamp() - runtimeStarted;
Trace?.Invoke("XFE_BROWSER_LOAD_TIMING|HtmlParseTicks|" + htmlTicks + "|RuntimeAndDomTicks|" + runtimeTicks +
"|Frequency|" + System.Diagnostics.Stopwatch.Frequency + "\n");
var scripts = document.QuerySelectorAll("script").ToArray();
if (scripts.Length > limits.MaximumScripts)
throw new BrowserLimitException("The document exceeds its script count limit.");
var csp = BrowserCspStylePolicy.Evaluate(contentSecurityPolicy, document);
cspDeclared = csp.Declared;
cspBlocksAuthorStyles = csp.BlockAuthorStyles;
if (cspDeclared && scripts.Length != 0)
{
pageDiagnostics.Append("Automatic scripts blocked: this browser cannot fully enforce the page's Content Security Policy.\n");
}
long styleStarted = System.Diagnostics.Stopwatch.GetTimestamp();
foreach (var link in document.QuerySelectorAll("link[rel~='stylesheet']")) LoadStyle(link);
documentStyleFetchTicks = System.Diagnostics.Stopwatch.GetTimestamp() - styleStarted;
if (deferDocumentScripts)
{
// The sanitized HTML and original stylesheet bytes are already
// available. A completed physical frame is required before Jint
// construction or any author/library script can delay the UI.
documentEnhancementsPending = true;
awaitingDocumentPresentation = true;
foreach (var script in scripts) if (!cspDeclared) pendingDocumentScripts.Enqueue(script);
InvalidateDocument();
return CurrentPage = RenderCurrent(pageDiagnostics.ToString().TrimEnd('\n'));
}
var deferred = new List<IElement>();
foreach (var script in scripts)
{
if (cspDeclared) continue;
if (script.HasAttribute("src") && (script.HasAttribute("defer") || script.HasAttribute("async"))) { deferred.Add(script); continue; }
if (!RunScript(script)) break;
DrainResources();
}
foreach (var script in deferred) { if (!RunScript(script)) break; DrainResources(); }
dom!.CompleteLoading(); CaptureEventOutput(); DrainResources(); InvalidateDocument();
return CurrentPage = RenderCurrent(pageDiagnostics.ToString().TrimEnd('\n'));
}
private BrowserPage RenderCurrent(string diagnostics)
{
var text = HtmlTextRenderer.Render(document!, currentAddress!, limits);
if (diagnostics.Length != 0)
{
if (diagnostics.Length + text.Length + 12 > limits.MaximumOutputCharacters)
throw new BrowserLimitException("The page and script output exceed their character limit.");
text += "\n\nConsole:\n" + diagnostics;
}
var title = string.IsNullOrWhiteSpace(document!.Title) ? currentAddress!.Host : document.Title;
if (string.IsNullOrWhiteSpace(title)) title = "XFESystem";
return new(currentAddress!.AbsoluteUri, BoundedText.Safe(title, 256), text);
}
private static Uri NormalizeAddress(string address)
{
var text = address.Trim();
if (text.Length is 0 or > 2_048) throw new ArgumentException("Enter a URL of at most 2048 characters.");
if (text.Any(char.IsControl)) throw new ArgumentException("The URL contains control characters.");
if (!text.Contains(':')) text = "https://" + text;
if (!Uri.TryCreate(text, UriKind.Absolute, out var target)) throw new ArgumentException("The URL is invalid.");
return target;
}
internal static void ValidateWebAddress(Uri address)
{
if (!address.IsAbsoluteUri || address.Scheme is not ("http" or "https") ||
string.IsNullOrEmpty(address.Host) || !string.IsNullOrEmpty(address.UserInfo))
throw new NotSupportedException("Only HTTP and HTTPS URLs without embedded credentials are supported.");
}
public const string StartPage = """
<!doctype html><html lang="zh"><head><title>XFESystem Browser</title></head>
<body><h1>XFESystem | XFEstudio</h1>
<p>HTML + JavaScript</p><p id="Result">JavaScript pending</p>
<p>Self-developed CSS layout, external scripts and browser events.</p>
<p><a href="http://xfesystem.local/Browser/Monaco/Index.html">Open official Monaco Editor</a></p>
<p><a href="http://xfesystem.local/Browser/Monaco/Index.html?language=csharp">C# editor with website highlighting</a></p>
<p><a href="about:Documents">Markdown with website CSS and scripts</a></p>
<p>HTTP/HTTPS 需要当前平台提供网络适配器。</p>
<script>document.getElementById('Result').textContent = 'JavaScript ready: ' + [1, 2, 3].map(x => x * 2).reduce((a, b) => a + b, 0);</script>
</body></html>
""";
}