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

    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();
        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;
        }));
        InstallEncodingCallbacks();
        InstallCssomCallbacks();
        bootstrap = engine.Evaluate(BrowserDomBootstrap.Source).AsObject();
        engine.SetValue("document", Wrap(document));
        InstallWindow();
    }

    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)
    {
        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]);
        var result = engine.Invoke(bootstrap.Get("dispatch"), bootstrap, [target is null ? engine.Global : Wrap(target), evt]);
        engine.Evaluate("void 0"); 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)
    {
        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 JsValue Created(INode node)
    {
        var result = Wrap(node);
        try { ValidateBudget(); }
        catch
        {
            wrappers.Remove(node); nodes.Remove(result.AsObject());
            identities.Remove(node);
            throw;
        }
        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 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)
    {
        var previous = element.GetAttribute(name); element.SetAttribute(name, LimitedText(value));
        try { ValidateBudget(); CanvasAttributeChanged(element, name); }
        catch { if (previous is null) element.RemoveAttribute(name); else element.SetAttribute(name, previous); throw; }
        Changed(element, "attributes", name, previous);
        if (Connected(element) && (element.LocalName is "script" or "link") && (name is "src" or "href" or "rel")) host.ResourceRequested?.Invoke(element);
    }
    private void RemoveAttribute(IElement element, string name)
    {
        var previous = element.GetAttribute(name); element.RemoveAttribute(name);
        try { CanvasAttributeChanged(element, name); } catch { if (previous is not null) element.SetAttribute(name, previous); throw; }
        if (previous is not null) Changed(element, "attributes", name, previous);
    }
    private void SetTitle(JsValue value)
    {
        var previous = document.Title; var previousElement = document.Head?.QuerySelector("title"); document.Title = LimitedText(value);
        try { ValidateBudget(); } catch { document.Title = previous; if (previousElement is null) document.Head?.QuerySelector("title")?.Remove(); throw; }
        Changed(document, "childList");
    }

    private JsValue List(IEnumerable<INode> values) => ListValues(values.Select(Wrap));
    private JsValue LiveList(Func<IEnumerable<INode>> values) => CallInternal(bootstrap.Get("liveList"),
        [new ClrFunction(engine, "read collection", (_, _) => new JsArray(engine, values().Select(Wrap).ToArray()))]);
    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 node) { foreach (var child in node.ChildNodes) { if (child is IElement element) yield return element; foreach (var descendant in Descendants(child)) yield return descendant; } }
    private void ValidateBudget(IEnumerable<INode>? additionalDetachedRoots = null)
    {
        // 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.");
        ValidateNodes(document, limits, wrappers.Keys.Where(node => node != document && node.Parent is null).Concat(additionalDetachedRoots ?? []), formCharacters);
    }
    private void Function(ObjectInstance target, string name, Func<JsValue[], JsValue> callback) => target.DefineOwnProperty(name, new PropertyDescriptor(new ClrFunction(engine, name, (_, args) => callback(args)), false, true, false));
    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 void ValidateNodes(IDocument document, BrowserLimits limits, IEnumerable<INode> detachedRoots, long additionalCharacters = 0)
    {
        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.");
            foreach (var child in entry.Node.ChildNodes) pending.Push((child, entry.Depth + 1));
        }
    }
}