XFE Git
XFE Studio Git
Git 首页 全局搜索
XFE 主站 文档 NuGet

XFESystem

【Rust】XFE系统,基于Rust和汇编

公开
关注 0 Fork 0 Star 0
UTF-8
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.");
        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();
    }

    /// <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 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<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>
        """;
}