using System.Net; using AngleSharp.Dom; using AngleSharp.Html.Parser; using XFESystem.Desktop; using XFESystem.Documents; namespace XFESystem.Browser; /// A limited HTML/DOM/JavaScript browser backend for the XFESystem desktop. 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 pendingResources = []; private readonly HashSet 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? Trace { get; init; } public IDocument? Document => document; public IReadOnlyList 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" => "
" + WebUtility.HtmlEncode(response.Body) + "
", "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; }); } /// Only sanitized, generated local documents receive the bundled-resource profile. 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."); Navigate(PackagedBrowserResources.MonacoPageAddress); CheckOperationCancellation(); string language = path.EndsWith(".cs", StringComparison.OrdinalIgnoreCase) ? "csharp" : path.EndsWith(".js", StringComparison.OrdinalIgnoreCase) ? "javascript" : "plaintext"; // JSON encoding is a data boundary, never concatenate an unescaped file into JavaScript. var initialized = ExecuteJavaScript("globalThis.xfeMonacoFileMode=true;xfeMonacoSetLanguage(" + JavaScriptString(language) + "," + JavaScriptString(source) + ");xfeMonacoEditor.focus();'ready'"); CheckOperationCancellation(); if (initialized != "ready") throw new InvalidOperationException("The Monaco editor could not initialize: " + initialized); return RefreshDocument(); } 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(); } /// Preview a local script in a fresh runtime with no page DOM or shared variables. 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(Func 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 BrowserPage LoadHtml(Uri target, string html, string? contentSecurityPolicy = null, bool packagedMonaco = false, bool deferDocumentScripts = false) { 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; 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()); 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(); 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 = """ XFESystem Browser

XFESystem | XFEstudio

HTML + JavaScript

JavaScript pending

Self-developed CSS layout, external scripts and browser events.

Open official Monaco Editor

C# editor with website highlighting

Markdown with website CSS and scripts

HTTP/HTTPS 需要当前平台提供网络适配器。

"""; }