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

XFESystem

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

公开
关注 0 Fork 0 Star 0
UTF-8
using AngleSharp.Dom;
using Jint;
using Jint.Native;
using Jint.Native.Object;
using Jint.Runtime.Descriptors;
using Jint.Runtime.Interop;

namespace XFESystem.Browser;

/// <summary>Explicit, identity-preserving DOM projection, without CLR reflection wrappers.</summary>
public sealed partial class DomBridge
{
    private readonly Engine engine;
    private readonly IDocument document;
    private readonly Uri address;
    private readonly BrowserLimits limits;
    private readonly BrowserDomHost host;
    private sealed record NodeIdentityValue(int Id);
    private readonly DependentIdentityMap<INode,ObjectInstance> wrappers = new();
    private readonly DependentIdentityMap<ObjectInstance,INode> nodes = new();
    private readonly DependentIdentityMap<INode,NodeIdentityValue> identities = new();
    private readonly ObjectInstance bootstrap;
    private INode? focused;
    private int listenerCount;
    private int nextNodeId;
    private string readyState = "loading";
    private int callbackDepth;
    private long validatedCharacters;
    private bool characterBudgetValid;
    internal bool IsExecutingCallback => callbackDepth > 0;
    internal long BudgetValidationCount { get; private set; }
    internal long BudgetValidationTicks { get; private set; }
    internal long BudgetNodeVisits { get; private set; }

    public DomBridge(JavaScriptRuntime runtime, IDocument document, Uri address, BrowserLimits limits, BrowserDomHost? host = null)
    {
        engine = runtime.Engine; this.document = document; this.address = address; this.limits = limits; this.host = host ?? new();
        runtime.ScriptEntering = () => characterBudgetValid = false;
        ValidateDocument(document, limits);
        foreach (var name in new[] { "window", "self", "parent", "top" }) engine.SetValue(name, engine.Global);
        engine.SetValue("__xfeListenerAdded", new ClrFunction(engine, "listenerAdded", (_, _) =>
        {
            if (++listenerCount > limits.MaximumPendingTasks * 8) { --listenerCount; throw new BrowserLimitException("The DOM event listener limit was reached."); }
            return JsValue.Undefined;
        }));
        engine.SetValue("__xfeListenerRemoved", new ClrFunction(engine, "listenerRemoved", (_, _) => { listenerCount = Math.Max(0, listenerCount - 1); return JsValue.Undefined; }));
        engine.SetValue("__xfeObserverAdded", new ClrFunction(engine, "observerAdded", (_, args) =>
        {
            if (Value(args, 0).AsNumber() > limits.MaximumPendingTasks) throw new BrowserLimitException("The DOM observer limit was reached.");
            return JsValue.Undefined;
        }));
        engine.SetValue("__xfeEventError", new ClrFunction(engine, "eventError", (_, args) =>
        {
            var console = engine.GetValue("console");
            ((Func<JsValue, JsValue[], JsValue>)console.AsObject().Get("error").ToObject()!)(console, args);
            return JsValue.Undefined;
        }));
        InstallDomParserCallback();
        InstallEncodingCallbacks();
        InstallCssomCallbacks();
        InstallNetworkCallbacks();
        Function(engine.Global, "__xfeMediaMatches", args => BrowserMediaQuery.Matches(Argument(args, 0), this.host.Viewport?.Invoke() ?? new(800, 600)));
        // Fixed trusted Web API setup precedes all author code. Its finite
        // initialization allowance must not consume a small page-script budget.
        var statements = engine.Constraints.Find<Jint.Constraints.MaxStatementsConstraint>();
        int originalStatements = statements?.MaxStatements ?? 0;
        if (statements is not null) statements.MaxStatements = Math.Max(originalStatements, 10_000);
        try
        {
            bootstrap = engine.Evaluate(BrowserDomBootstrap.Prepared).AsObject();
            InstallNodeMutationPrototypes();
            engine.SetValue("document", Wrap(document));
            InstallWindow();
            CallInternal(bootstrap.Get("configureWindow"), []);
        }
        catch
        {
            if (statements is not null) statements.MaxStatements = originalStatements;
            engine.Constraints.Reset(); throw;
        }
        if (statements is not null) statements.MaxStatements = originalStatements;
        engine.Constraints.Reset();
    }

    public int NodeIdentity(INode node)
    {
        if (identities.TryGetValue(node, out var known)) return known.Id;
        if (identities.Count >= limits.MaximumDomNodes) throw new BrowserLimitException("The DOM identity limit was reached.");
        int id = checked(++nextNodeId); identities.Add(node,new(id)); return id;
    }
    public INode? ResolveNode(int identity)
    {foreach(var pair in identities)if(pair.Value.Id==identity)return pair.Key;return null;}
    public INode? FocusedNode => focused;
    public void NotifyLayoutChanged() => engine.Invoke(bootstrap.Get("notifyResize"), bootstrap, []);

    public bool DispatchEvent(INode? target, string type, BrowserDomEventInit? init = null)
    {
        characterBudgetValid = false;
        init ??= new(); var values = new BrowserHostObject(engine);
        Data(values, "bubbles", init.Bubbles); Data(values, "cancelable", init.Cancelable);
        Data(values, "clientX", init.ClientX); Data(values, "clientY", init.ClientY); Data(values, "deltaX", init.DeltaX); Data(values, "deltaY", init.DeltaY);
        Data(values, "button", init.Button); Data(values, "buttons", init.Buttons); Data(values, "key", init.Key); Data(values, "code", init.Code);
        Data(values, "ctrlKey", init.CtrlKey); Data(values, "shiftKey", init.ShiftKey); Data(values, "altKey", init.AltKey); Data(values, "metaKey", init.MetaKey);
        Data(values, "data", init.Data); Data(values, "inputType", init.InputType);
        var evt = engine.Invoke(bootstrap.Get("event"), bootstrap, [(JsValue)type, values]);
        callbackDepth++;
        JsValue result;
        try { result = engine.Invoke(bootstrap.Get("dispatch"), bootstrap, [target is null ? engine.Global : Wrap(target), evt]); engine.Evaluate(BrowserDomBootstrap.FlushJobs); }
        catch { callbackDepth--; throw; }
        callbackDepth--; return result.AsBoolean();
    }

    public void CompleteLoading()
    {
        if (readyState == "complete") return;
        readyState = "interactive"; DispatchEvent(document, "readystatechange", new() { Bubbles = false, Cancelable = false });
        DispatchEvent(document, "DOMContentLoaded", new() { Cancelable = false }); readyState = "complete";
        DispatchEvent(document, "readystatechange", new() { Bubbles = false, Cancelable = false });
        DispatchEvent(null, "load", new() { Bubbles = false, Cancelable = false });
    }

    private void Changed(INode node, string type, string? name = null, string? oldValue = null, INode[]? added = null, INode[]? removed = null)
    {
        if (type != "attributes") characterBudgetValid = false;
        if (Connected(node) && (type != "attributes" || node is not IElement element || element.GetAttribute(name!) != oldValue || element.LocalName == "canvas" && name is "width" or "height"))
        {
            if (type == "attributes" && node is IElement changed && host.AttributeChanged is not null) host.AttributeChanged(changed, name!);
            else host.Mutated?.Invoke();
        }
        CallInternal(bootstrap.Get("mutation"), [Wrap(node), type, Text(name), Text(oldValue), List(added ?? []), List(removed ?? [])]);
    }
    private JsValue DispatchInternal(INode target, string type, bool bubbles, bool cancelable)
    {
        var init = new BrowserHostObject(engine); Data(init, "bubbles", bubbles); Data(init, "cancelable", cancelable);
        var evt = CallInternal(bootstrap.Get("event"), [type, init]); return CallInternal(bootstrap.Get("dispatch"), [Wrap(target), evt]);
    }
    private void Focus(INode? node)
    {
        if (focused == node) return; var old = focused; focused = node;
        if (old is not null) { DispatchInternal(old, "blur", false, false); DispatchInternal(old, "focusout", true, false); }
        if (node is not null) { DispatchInternal(node, "focus", false, false); DispatchInternal(node, "focusin", true, false); }
        host.FocusChanged?.Invoke(node);
    }

    private readonly HashSet<IElement> createdScripts = new(ReferenceEqualityComparer.Instance);
    internal bool IsDynamicScript(IElement element) => createdScripts.Contains(element);
    private JsValue Created(INode node, bool createdByApi = false)
    {
        var result = Wrap(node);
        try { ValidateBudget(); }
        catch
        {
            wrappers.Remove(node); nodes.Remove(result.AsObject());
            identities.Remove(node);
            throw;
        }
        if (createdByApi && node is IElement { LocalName: "script" } script) createdScripts.Add(script);
        return result;
    }
    private JsValue Insert(INode parent, INode child, INode? before)
    {
        if (before is not null && before.Parent != parent) throw new ArgumentException("Reference node is not a child of this parent.");
        if (Ancestor(child, parent)) throw new ArgumentException("A DOM node cannot contain itself.");
        var oldParent = child.Parent; var oldNext = child.NextSibling;
        var added = child.NodeType == NodeType.DocumentFragment ? child.ChildNodes.ToArray() : [child];
        parent.InsertBefore(child, before);
        try { ValidateBudget(); }
        catch
        {
            foreach (var addedNode in added) addedNode.Parent?.RemoveChild(addedNode);
            if (child.NodeType == NodeType.DocumentFragment) foreach (var addedNode in added) child.AppendChild(addedNode);
            if (oldParent is not null) oldParent.InsertBefore(child, oldNext);
            throw;
        }
        if (oldParent is not null) Changed(oldParent, "childList", removed: added);
        Changed(parent, "childList", added: added);
        foreach (var addedNode in added) if (Connected(addedNode)) foreach (var resource in SelfAndDescendants(addedNode).Where(e => e.LocalName is "script" or "link")) host.ResourceRequested?.Invoke(resource);
        return Wrap(child);
    }
    private void MutateText(INode node, JsValue value)
    {
        var text = LimitedText(value); var old = node.ChildNodes.ToArray(); var oldText = node.TextContent; node.TextContent = text;
        try { ValidateBudget(); }
        catch { node.TextContent = oldText; if (old.Length != 0) { foreach (var child in node.ChildNodes.ToArray()) node.RemoveChild(child); foreach (var child in old) node.AppendChild(child); } throw; }
        Changed(node, node.NodeType is NodeType.Text or NodeType.Comment ? "characterData" : "childList", oldValue: oldText, added: node.ChildNodes.ToArray(), removed: old);
    }
    private static string InnerTextValue(INode node)
    {
        if (node is IText) return node.TextContent;
        if (node is IElement { LocalName: "br" }) return "\n";
        return string.Concat(node.ChildNodes.Select(InnerTextValue));
    }
    private void MutateInnerText(IElement element, JsValue value)
    {
        string[] lines = LimitedText(value).Replace("\r\n", "\n").Replace('\r', '\n').Split('\n');
        var added = new List<INode>();
        for (int index = 0; index < lines.Length; index++)
        {
            if (index != 0) added.Add(document.CreateElement("br"));
            if (lines[index].Length != 0) added.Add(document.CreateTextNode(lines[index]));
            if (added.Count > limits.MaximumDomNodes) throw new BrowserLimitException("The innerText replacement exceeds its DOM node limit.");
        }
        var removed = element.ChildNodes.ToArray();
        foreach (var child in removed) element.RemoveChild(child);
        foreach (var child in added) element.AppendChild(child);
        try { ValidateBudget(); }
        catch { foreach (var child in added) element.RemoveChild(child); foreach (var child in removed) element.AppendChild(child); characterBudgetValid = false; throw; }
        Changed(element, "childList", added: added.ToArray(), removed: removed);
    }
    private void MutateHtml(IElement element, JsValue value)
    {
        var previous = element.ChildNodes.ToArray(); element.InnerHtml = LimitedText(value);
        try { ValidateBudget(); }
        catch { foreach (var child in element.ChildNodes.ToArray()) element.RemoveChild(child); foreach (var child in previous) element.AppendChild(child); throw; }
        Changed(element, "childList", added: element.ChildNodes.ToArray(), removed: previous);
        // Fragment-inserted scripts are inert. Link styles still trigger page-owned loading.
        if (Connected(element)) foreach (var link in element.QuerySelectorAll("link")) host.ResourceRequested?.Invoke(link);
    }
    private void SetAttribute(IElement element, string name, JsValue value)
    {
        long before = AttributeAndFormCharacters(element, name);
        var previous = element.GetAttribute(name); element.SetAttribute(name, LimitedText(value));
        try
        {
            if (element.GetAttribute(name) != previous)
            {
                if (characterBudgetValid) AdjustAttributeBudget(AttributeAndFormCharacters(element, name) - before);
                else ValidateBudget();
            }
            CanvasAttributeChanged(element, name);
        }
        catch { if (previous is null) element.RemoveAttribute(name); else element.SetAttribute(name, previous); characterBudgetValid = false; throw; }
        Changed(element, "attributes", name, previous);
        if ((Connected(element) && (element.LocalName is "script" or "link") || element.LocalName == "img") && (name is "src" or "href" or "rel")) host.ResourceRequested?.Invoke(element);
    }
    private void RemoveAttribute(IElement element, string name)
    {
        long before = AttributeAndFormCharacters(element, name);
        var previous = element.GetAttribute(name); element.RemoveAttribute(name);
        try
        {
            if (characterBudgetValid) AdjustAttributeBudget(AttributeAndFormCharacters(element, name) - before);
            else if (element is AngleSharp.Html.Dom.IHtmlInputElement or AngleSharp.Html.Dom.IHtmlTextAreaElement) ValidateBudget();
            CanvasAttributeChanged(element, name);
        }
        catch { if (previous is not null) element.SetAttribute(name, previous); characterBudgetValid = false; throw; }
        if (previous is not null) Changed(element, "attributes", name, previous);
    }
    private void SetTitle(IDocument owner, JsValue value)
    {
        var previous = owner.Title; var previousElement = owner.Head?.QuerySelector("title"); owner.Title = LimitedText(value);
        try { ValidateBudget(); } catch { owner.Title = previous; if (previousElement is null) owner.Head?.QuerySelector("title")?.Remove(); throw; }
        Changed(owner, "childList");
    }

    private JsValue List(IEnumerable<INode> values)
    {
        // Snapshot membership immediately, but create JS wrappers only for
        // entries the page actually reads. Reading a NodeList's length must
        // not project the entire result tree into the interpreter.
        var snapshot = values.ToArray();
        return LiveList(() => snapshot);
    }
    private JsValue LiveList(Func<IEnumerable<INode>> values, string kind = "NodeList")
    {
        var collection = new BrowserHostObject(engine);
        Function(collection, "length", _ => values().Count());
        Function(collection, "item", args => Wrap(values().ElementAtOrDefault(NumberIndex(Value(args, 0)))));
        Function(collection, "all", _ => new JsArray(engine, values().Select(Wrap).ToArray()));
        return CallInternal(bootstrap.Get("liveList"), [collection, kind]);
    }
    private JsValue ListValues(IEnumerable<JsValue> values) => CallInternal(bootstrap.Get("list"), [new JsArray(engine, values.ToArray())]);
    // These are private trusted ScriptFunctions. Their direct call delegate preserves the outer
    // evaluation constraints; re-entering Engine.Invoke here would reset the statement budget.
    private JsValue CallInternal(JsValue callable, JsValue[] arguments) =>
        ((Func<JsValue, JsValue[], JsValue>)callable.ToObject()!)(bootstrap, arguments);
    private INode RequireNode(JsValue value) => OptionalNode(value) ?? throw new ArgumentException("Expected a node created by this document.");
    private INode? OptionalNode(JsValue value) => value.IsNull() || value.IsUndefined() ? null : value.IsObject() && nodes.TryGetValue(value.AsObject(), out var node) ? node : throw new ArgumentException("Expected a node created by this document.");
    private INode NodeOrText(JsValue value) => value.IsObject() ? RequireNode(value) : document.CreateTextNode(value.IsNull() ? "null" : value.IsUndefined() ? "undefined" : LimitedText(value));
    private string ResolveUrl(string? url) => string.IsNullOrEmpty(url) ? string.Empty : Uri.TryCreate(address, url, out var resolved) ? resolved.AbsoluteUri : url;
    private static bool Connected(INode node) { while (node.Parent is not null) node = node.Parent; return node.NodeType == NodeType.Document; }
    private static bool Ancestor(INode parent, INode child) { for (var node = child; node is not null; node = node.Parent) if (node == parent) return true; return false; }
    private static IEnumerable<IElement> SelfAndDescendants(INode node) { if (node is IElement element) yield return element; foreach (var child in Descendants(node)) yield return child; }
    private static IEnumerable<IElement> Descendants(INode root)
    {
        // Live collection length/index reads can traverse this tree many
        // times. Walk existing links instead of allocating an iterator and a
        // ChildNodes enumerator at every node of every traversal.
        var node = root.FirstChild;
        while (node is not null)
        {
            if (node is IElement element) yield return element;
            if (node.FirstChild is not null) { node = node.FirstChild; continue; }
            while (node is not null && node != root && node.NextSibling is null) node = node.Parent;
            node = node == root ? null : node?.NextSibling;
        }
    }
    private void ValidateBudget(IEnumerable<INode>? additionalDetachedRoots = null)
    {
        characterBudgetValid = false;
        long started = System.Diagnostics.Stopwatch.GetTimestamp();
        BudgetValidationCount++;
        // Dirty form values are not HTML attributes/text nodes and need their own combined budget.
        long formCharacters = 0;
        foreach (var element in wrappers.Keys.OfType<IElement>())
            if (element is AngleSharp.Html.Dom.IHtmlInputElement or AngleSharp.Html.Dom.IHtmlTextAreaElement)
                if ((formCharacters += FormValue(element).Length) > limits.MaximumHtmlCharacters)
                    throw new BrowserLimitException("The editable controls exceed their combined character limit.");
        BudgetNodeVisits += ValidateNodes(document, limits, wrappers.Keys.Where(node => node != document && node.Parent is null).Concat(additionalDetachedRoots ?? []), formCharacters, out validatedCharacters);
        characterBudgetValid = true;
        BudgetValidationTicks += System.Diagnostics.Stopwatch.GetTimestamp() - started;
    }
    private static long AttributeAndFormCharacters(IElement element, string name)
    {
        var attribute = element.GetAttributeNode(name);
        return (attribute is null ? 0 : attribute.Name.Length + attribute.Value.Length) +
            (element is AngleSharp.Html.Dom.IHtmlInputElement or AngleSharp.Html.Dom.IHtmlTextAreaElement ? FormValue(element).Length : 0);
    }
    private void AdjustAttributeBudget(long difference)
    {
        long next = validatedCharacters + difference;
        if (next > limits.MaximumHtmlCharacters) throw new BrowserLimitException("The document exceeds its DOM text limit: " + next + " > " + limits.MaximumHtmlCharacters + ".");
        validatedCharacters = next;
    }
    // Web IDL operations can be wrapped/replaced by page JavaScript (for
    // example form.submit instrumentation). This exposes no additional host
    // capability: calls still use only the explicit primitive callback.
    private void Function(ObjectInstance target, string name, Func<JsValue[], JsValue> callback) => target.DefineOwnProperty(name, new PropertyDescriptor(new ClrFunction(engine, name, (_, args) => callback(args)), true, true, true));
    private static void Data(ObjectInstance target, string name, JsValue value, bool writable = false) => target.DefineOwnProperty(name, new PropertyDescriptor(value, writable, true, false));
    private void Property(ObjectInstance target, string name, Func<JsValue> getter, Action<JsValue>? setter = null)
    {
        var get = new ClrFunction(engine, "get " + name, (_, _) => getter());
        var set = setter is null ? null : new ClrFunction(engine, "set " + name, (_, args) => { setter(Value(args, 0)); return JsValue.Undefined; });
        target.DefineOwnProperty(name, new GetSetPropertyDescriptor(get, set, true, false));
    }
    private string LimitedText(JsValue value)
    {
        if (value.IsObject()) throw new ArgumentException("DOM text requires a primitive value."); var text = value.IsNull() ? string.Empty : value.ToString();
        if (text.Length > limits.MaximumHtmlCharacters) throw new BrowserLimitException("The DOM text exceeds its character limit."); return text;
    }
    private string Argument(JsValue[] arguments, int index) => LimitedText(Value(arguments, index));
    private string ShortArgument(JsValue[] arguments, int index) { var text = Argument(arguments, index); if (text.Length > 1_024) throw new BrowserLimitException("The DOM name exceeds its character limit."); return text; }
    private static JsValue Value(JsValue[] arguments, int index) => index < arguments.Length ? arguments[index] : JsValue.Undefined;
    private static JsValue Text(string? value) => value is null ? JsValue.Null : value;
    private static bool Bool(JsValue value) => !value.IsNull() && !value.IsUndefined() && (!value.IsBoolean() || value.AsBoolean()) && (!value.IsNumber() || value.AsNumber() != 0) && (!value.IsString() || value.AsString().Length != 0);
    internal static void ValidateDocument(IDocument document, BrowserLimits limits) => ValidateNodes(document, limits, []);
    private static int ValidateNodes(IDocument document, BrowserLimits limits, IEnumerable<INode> detachedRoots, long additionalCharacters = 0) =>
        ValidateNodes(document, limits, detachedRoots, additionalCharacters, out _);
    private static int ValidateNodes(IDocument document, BrowserLimits limits, IEnumerable<INode> detachedRoots, long additionalCharacters, out long observedCharacters)
    {
        var pending = new Stack<(INode Node, int Depth)>(); pending.Push((document, 0)); foreach (var node in detachedRoots) pending.Push((node, 0));
        var count = 0; var characters = additionalCharacters;
        while (pending.TryPop(out var entry))
        {
            if (++count > limits.MaximumDomNodes || entry.Depth > limits.MaximumDomDepth) throw new BrowserLimitException("The document exceeds its DOM node or depth limit.");
            if (entry.Node.NodeType is NodeType.Text or NodeType.Comment) characters += entry.Node.TextContent.Length;
            if (entry.Node is IElement element) foreach (var attribute in element.Attributes) characters += attribute.Name.Length + attribute.Value.Length;
            if (characters > limits.MaximumHtmlCharacters) throw new BrowserLimitException("The document exceeds its DOM text limit: " + characters + " > " + limits.MaximumHtmlCharacters + ".");
            foreach (var child in entry.Node.ChildNodes) pending.Push((child, entry.Depth + 1));
        }
        observedCharacters = characters; return count;
    }
}