diff --git a/README.md b/README.md index 9a880cba3..1f7c3a2f2 100644 --- a/README.md +++ b/README.md @@ -164,7 +164,7 @@ The output of an agent session is a [PandaScript](https://lightpanda.io/docs/usage/pandascript): vanilla JavaScript with a small set of native browser primitives built directly into Lightpanda. Run `/save` to export one from your current session, then replay it with -`lightpanda agent diff --git a/src/browser/tests/custom_elements/create_element_post_conditions.html b/src/browser/tests/custom_elements/create_element_post_conditions.html new file mode 100644 index 000000000..c4f1477ad --- /dev/null +++ b/src/browser/tests/custom_elements/create_element_post_conditions.html @@ -0,0 +1,136 @@ + + + + + + + + + + + + + + + + diff --git a/src/browser/tests/custom_elements/error_reporting.html b/src/browser/tests/custom_elements/error_reporting.html new file mode 100644 index 000000000..0b608c671 --- /dev/null +++ b/src/browser/tests/custom_elements/error_reporting.html @@ -0,0 +1,286 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/browser/tests/element/matches.html b/src/browser/tests/element/matches.html index f28d7a716..a1a3743e5 100644 --- a/src/browser/tests/element/matches.html +++ b/src/browser/tests/element/matches.html @@ -62,6 +62,26 @@ } + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/browser/tests/frames/target.html b/src/browser/tests/frames/target.html index 924e0e590..a44a47cb3 100644 --- a/src/browser/tests/frames/target.html +++ b/src/browser/tests/frames/target.html @@ -126,3 +126,44 @@ }); } + + + + + svg link + + + + + + + svg 1.1 link + + + diff --git a/src/browser/tests/net/fetch.html b/src/browser/tests/net/fetch.html index fdd13dbb2..ef676af35 100644 --- a/src/browser/tests/net/fetch.html +++ b/src/browser/tests/net/fetch.html @@ -451,3 +451,20 @@ }); } + + + diff --git a/src/browser/tests/shadowroot/events.html b/src/browser/tests/shadowroot/events.html index 7dbf1fea8..25c305b04 100644 --- a/src/browser/tests/shadowroot/events.html +++ b/src/browser/tests/shadowroot/events.html @@ -326,3 +326,141 @@ host.remove(); } + + + + + + + + diff --git a/src/browser/tools.zig b/src/browser/tools.zig index 73f2ee8bb..a3b989796 100644 --- a/src/browser/tools.zig +++ b/src/browser/tools.zig @@ -276,6 +276,16 @@ pub const Tool = enum { }; } + /// Waits for page readiness on its own, letting the recorder downgrade a + /// preceding `goto` to `domcontentloaded`. Exhaustive like the sibling + /// predicates so a new wait tool makes an explicit choice here. + pub fn waitsForReadiness(self: Tool) bool { + return switch (self) { + .waitForSelector, .waitForScript, .waitForState => true, + .goto, .evaluate, .extract, .click, .fill, .scroll, .hover, .press, .selectOption, .setChecked, .search, .markdown, .html, .links, .tree, .nodeDetails, .interactiveElements, .structuredData, .detectForms, .findElement, .consoleLogs, .getUrl, .getCookies, .getEnv => false, + }; + } + /// A read tool that navigates when handed a `url`. The read isn't recorded, /// but the navigation is, so the recorder captures it as a `goto`. Excludes /// `evaluate` (carries its own `url`), `search` (derived engine URL), and @@ -329,7 +339,10 @@ pub const Tool = enum { \\ "type": "object", \\ "properties": { \\ "url": { "type": "string", "description": "The URL to navigate to, must be a valid URL." }, - \\ "timeout": { "type": "integer", "description": "Optional timeout in milliseconds. Defaults to 10000." } + \\ "timeout": { "type": "integer", "description": "Optional timeout in milliseconds. Defaults to 10000." }, + \\ "waitUntil": { "type": "string", "enum": + ++ lp.Config.tagJsonArray(lp.Config.WaitUntil) ++ + \\, "description": "Event that completes the navigation. Defaults to 'load'. Prefer 'domcontentloaded' followed by waitForSelector on pages whose late scripts (ads) hold 'load' back. Avoid 'done' (full quiescence): on pages with constant background activity it is the slowest choice and can run to the timeout." } \\ }, \\ "required": ["url"] \\} @@ -357,7 +370,7 @@ pub const Tool = enum { \\ "type": "object", \\ "properties": { \\ "selector": { "type": "string", "description": "Optional CSS selector. Render markdown for just that element's subtree." }, - \\ "backendNodeId": { "type": "integer", "description": "Optional backend node ID. Render markdown for just that node's subtree." }, + \\ "backendNodeId": { "type": "integer", "description": "Optional backend node ID. Render markdown for just that node's subtree. 0 is treated as omitted." }, \\ "maxBytes": { "type": "integer", "description": "Optional soft cap on output size in bytes. Content is truncated at a UTF-8 boundary and a short '[truncated]' marker is appended past the cap." }, \\ "url": { "type": "string", "description": "Optional URL to navigate to before rendering." }, \\ "timeout": { "type": "integer", "description": "Optional timeout in milliseconds. Defaults to 10000." } @@ -373,7 +386,7 @@ pub const Tool = enum { \\ "type": "object", \\ "properties": { \\ "selector": { "type": "string", "description": "Optional CSS selector. When set, dump only that element's outerHTML." }, - \\ "backendNodeId": { "type": "integer", "description": "Optional backend node ID. When set, dump only that node's outerHTML." }, + \\ "backendNodeId": { "type": "integer", "description": "Optional backend node ID. When set, dump only that node's outerHTML. 0 is treated as omitted." }, \\ "url": { "type": "string", "description": "Optional URL to navigate to before dumping." }, \\ "timeout": { "type": "integer", "description": "Optional timeout in milliseconds. Defaults to 10000." } \\ } @@ -441,7 +454,7 @@ pub const Tool = enum { \\ "properties": { \\ "url": { "type": "string", "description": "Optional URL to navigate to before fetching the semantic tree." }, \\ "timeout": { "type": "integer", "description": "Optional timeout in milliseconds. Defaults to 10000." }, - \\ "backendNodeId": { "type": "integer", "description": "Optional backend node ID to get the tree for a specific element instead of the document root." }, + \\ "backendNodeId": { "type": "integer", "description": "Optional backend node ID to get the tree for a specific element instead of the document root. 0 is treated as omitted." }, \\ "maxDepth": { "type": "integer", "description": "Optional maximum depth of the tree to return. Useful for exploring high-level structure first." } \\ } \\} @@ -510,7 +523,7 @@ pub const Tool = enum { \\{ \\ "type": "object", \\ "properties": { - \\ "backendNodeId": { "type": "integer", "description": "Optional: The backend node ID of the element to scroll. If omitted, scrolls the window." }, + \\ "backendNodeId": { "type": "integer", "description": "Optional: The backend node ID of the element to scroll. If omitted (or 0), scrolls the window." }, \\ "x": { "type": "integer", "description": "Optional: The horizontal scroll offset." }, \\ "y": { "type": "integer", "description": "Optional: The vertical scroll offset." } \\ } @@ -525,7 +538,7 @@ pub const Tool = enum { \\ "type": "object", \\ "properties": { \\ "selector": { "type": "string", "description": "The CSS selector to wait for." }, - \\ "timeout": { "type": "integer", "description": "Optional timeout in milliseconds. Defaults to 5000." } + \\ "timeout": { "type": "integer", "description": "Optional timeout in milliseconds. Defaults to 5000, or 15000 when the page has not reached 'load' yet." } \\ }, \\ "required": ["selector"] \\} @@ -539,7 +552,7 @@ pub const Tool = enum { \\ "type": "object", \\ "properties": { \\ "script": { "type": "string", "description": "JS expression evaluated each tick until truthy. Must be an expression (not a statement)." }, - \\ "timeout": { "type": "integer", "description": "Optional timeout in milliseconds. Defaults to 5000." } + \\ "timeout": { "type": "integer", "description": "Optional timeout in milliseconds. Defaults to 5000, or 15000 when the page has not reached 'load' yet." } \\ }, \\ "required": ["script"] \\} @@ -583,7 +596,7 @@ pub const Tool = enum { \\ "properties": { \\ "key": { "type": "string", "description": "The key to press (e.g. 'Enter', 'Tab', 'a')." }, \\ "selector": { "type": "string", "description": "Optional CSS selector of the element to target. Preferred over backendNodeId." }, - \\ "backendNodeId": { "type": "integer", "description": "Optional backend node ID of the element to target. Defaults to the document when neither selector nor backendNodeId is provided." } + \\ "backendNodeId": { "type": "integer", "description": "Optional backend node ID of the element to target. Defaults to the document when neither selector nor backendNodeId is provided; 0 is treated as omitted." } \\ }, \\ "required": ["key"] \\} @@ -744,6 +757,16 @@ pub const ToolError = error{ OutOfMemory, }; +/// LLM-facing message for a tool failure. Bare error names leave the model +/// retrying blind; spell out the recovery for errors it can act on. +pub fn errorMessage(err: ToolError) []const u8 { + return switch (err) { + error.NodeNotFound => "NodeNotFound: the selector or backendNodeId matched nothing on the current page. Re-inspect the page (tree/interactiveElements) for fresh node ids, or omit backendNodeId to target the document root.", + error.FrameNotLoaded => "FrameNotLoaded: no page is loaded — call goto (or pass a url) first.", + else => @errorName(err), + }; +} + /// Outcome of running a tool against the page. Operational failures (OOM, /// missing page, invalid params) come out as Zig errors on the enclosing /// `!ToolResult`; `is_error = true` is the in-band signal for a JS-level @@ -758,6 +781,7 @@ pub const ToolResult = struct { pub const GotoParams = struct { url: [:0]const u8, timeout: ?u32 = null, + waitUntil: lp.Config.WaitUntil = default_nav_wait, }; pub const UrlParams = struct { @@ -786,7 +810,13 @@ pub fn call( tool_name: []const u8, arguments: ?std.json.Value, ) ToolError!ToolResult { - const tool = std.meta.stringToEnum(Tool, tool_name) orelse return ToolError.InvalidParams; + // In-band so an LLM that invented a tool name (e.g. OpenAI's internal + // `multi_tool_use.parallel` wrapper) learns the name is wrong instead of + // retrying it with different arguments. + const tool = std.meta.stringToEnum(Tool, tool_name) orelse return .{ + .text = try std.fmt.allocPrint(arena, "Unknown tool: {s}", .{tool_name}), + .is_error = true, + }; if (diagnoseArgs(arena, arguments)) |msg| return .{ .text = msg, .is_error = true }; // Must run before substituteStringArgs so the `key=="value"` secret- @@ -936,7 +966,7 @@ const schema_walker_suffix = ")"; fn execGoto(arena: std.mem.Allocator, session: *lp.Session, registry: *CDPNode.Registry, arguments: ?std.json.Value) ToolError![]const u8 { const args = try parseArgs(GotoParams, arena, arguments); - return switch (try performGoto(session, registry, args.url, args.timeout)) { + return switch (try performGoto(session, registry, args.url, .{ .timeout = args.timeout, .wait_until = args.waitUntil })) { .completed => "Navigated successfully.", .timeout => "Navigation started but the page did not finish loading before the timeout.", }; @@ -988,7 +1018,7 @@ fn execSearch(arena: std.mem.Allocator, session: *lp.Session, registry: *CDPNode .{encoded}, 0, ) catch return ToolError.OutOfMemory; - _ = try performGoto(session, registry, ddg_url, args.timeout); + _ = try performGoto(session, registry, ddg_url, .{ .timeout = args.timeout }); const ddg_frame = try requireFrame(session); return .{ .text = try renderFrameMarkdown(arena, ddg_frame) }; } @@ -1619,6 +1649,14 @@ fn execScroll(arena: std.mem.Allocator, session: *lp.Session, registry: *CDPNode /// already-loaded page rather than a full navigation (which uses 10000). const default_wait_timeout_ms: u32 = 5000; +/// A wait entered before `load` also inherits the nav budget: a post-load +/// selector keeps the wall time it had when `goto` waited for `load` itself. +/// Shared with CDP's `LP.waitForSelector`, which fronts the same action. +pub fn defaultWaitTimeout(frame: *const lp.Frame) u32 { + if (frame._load_state == .complete) return default_wait_timeout_ms; + return default_nav_timeout_ms + default_wait_timeout_ms; +} + fn execWaitForSelector(arena: std.mem.Allocator, session: *lp.Session, registry: *CDPNode.Registry, arguments: ?std.json.Value) ToolError![]const u8 { const Params = struct { selector: [:0]const u8, @@ -1628,7 +1666,7 @@ fn execWaitForSelector(arena: std.mem.Allocator, session: *lp.Session, registry: const frame = try requireFrame(session); - const timeout_ms = args.timeout orelse default_wait_timeout_ms; + const timeout_ms = args.timeout orelse defaultWaitTimeout(frame); const node = lp.actions.waitForSelector(args.selector, timeout_ms, frame._frame_id, session) catch |err| switch (err) { error.InvalidSelector => return ToolError.InvalidParams, @@ -1655,7 +1693,7 @@ fn execWaitForScript(arena: std.mem.Allocator, session: *lp.Session, arguments: const frame = try requireFrame(session); - const timeout_ms = args.timeout orelse default_wait_timeout_ms; + const timeout_ms = args.timeout orelse defaultWaitTimeout(frame); lp.actions.waitForScript(args.script, timeout_ms, frame._frame_id, session) catch |err| switch (err) { error.Cancelled => return ToolError.Cancelled, @@ -1921,7 +1959,7 @@ fn ensurePage(session: *lp.Session, registry: *CDPNode.Registry, url: ?[:0]const if (session.currentFrame()) |frame| { if (std.mem.eql(u8, frame.url, u)) return frame; } - _ = try performGoto(session, registry, u, timeout); + _ = try performGoto(session, registry, u, .{ .timeout = timeout }); } return session.currentFrame() orelse ToolError.FrameNotLoaded; } @@ -1937,6 +1975,7 @@ const default_nav_timeout_ms: u32 = 10000; pub const StartedGoto = struct { frame_id: u32, timeout_ms: u32, + until: lp.Config.WaitUntil, }; /// Open a fresh top-level page and start its navigation. The frame is non-null @@ -1971,10 +2010,19 @@ pub fn startGoto( } } const page = try openPage(session, args.url); - return .{ .frame_id = page.frame_id, .timeout_ms = args.timeout orelse default_nav_timeout_ms }; + return .{ + .frame_id = page.frame_id, + .timeout_ms = args.timeout orelse default_nav_timeout_ms, + .until = args.waitUntil, + }; } -fn performGoto(session: *lp.Session, registry: *CDPNode.Registry, url: [:0]const u8, timeout: ?u32) ToolError!lp.Session.Runner.WaitResult { +const PerformGotoOpts = struct { + timeout: ?u32 = null, + wait_until: lp.Config.WaitUntil = default_nav_wait, +}; + +fn performGoto(session: *lp.Session, registry: *CDPNode.Registry, url: [:0]const u8, opts: PerformGotoOpts) ToolError!lp.Session.Runner.WaitResult { if (session.primaryPage()) |old_page| { registry.reset(); old_page.close(); @@ -1982,9 +2030,9 @@ fn performGoto(session: *lp.Session, registry: *CDPNode.Registry, url: [:0]const const page = try openPage(session, url); var runner = session.runner(.{}); - const condition = lp.Session.Runner.WaitCondition{ .frame_id = page.frame_id, .until = default_nav_wait }; + const condition = lp.Session.Runner.WaitCondition{ .frame_id = page.frame_id, .until = opts.wait_until }; var conditions = [_]lp.Session.Runner.WaitCondition{condition}; - const result = runner.waitResult(timeout orelse default_nav_timeout_ms, &conditions) catch |err| { + const result = runner.waitResult(opts.timeout orelse default_nav_timeout_ms, &conditions) catch |err| { return if (err == error.Cancelled) ToolError.Cancelled else ToolError.NavigationFailed; }; @@ -2038,13 +2086,23 @@ fn formatEnumError(arena: std.mem.Allocator, field: []const u8, got: []const u8, } pub fn parseValue(comptime T: type, arena: std.mem.Allocator, value: std.json.Value) ParseArgsError!T { - return std.json.parseFromValueLeaky(T, arena, value, .{ .ignore_unknown_fields = true }) catch |err| switch (err) { - error.OutOfMemory => error.OutOfMemory, + var parsed = std.json.parseFromValueLeaky(T, arena, value, .{ .ignore_unknown_fields = true }) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, else => { log.debug(.browser, "parseValue rejected", .{ .err = @errorName(err), .type = @typeName(T) }); return error.InvalidParams; }, }; + // Schema contract: backendNodeId 0 means omitted — registry ids start at 1, + // and zero-filling models (gpt-5.x) send 0 for "unset". + if (comptime @typeInfo(T) == .@"struct" and @hasField(T, "backendNodeId") and + @typeInfo(@FieldType(T, "backendNodeId")) == .optional) + { + if (parsed.backendNodeId) |nid| { + if (nid == 0) parsed.backendNodeId = null; + } + } + return parsed; } /// For tools where every field is optional. Missing args → default `T`; @@ -2170,7 +2228,7 @@ pub fn reverseSubstituteEnvVars(arena: std.mem.Allocator, input: []const u8) err // before its full match is found, leaking a suffix into the recording. const Pair = struct { name: []const u8, value: []const u8 }; var pairs: std.ArrayList(Pair) = .empty; - try pairs.ensureTotalCapacity(arena, env_names.len); + try pairs.ensureTotalCapacityPrecise(arena, env_names.len); for (env_names) |name| { const value = lookupLpEnv(name) orelse continue; if (value.len < 4) continue; @@ -2193,6 +2251,46 @@ pub fn reverseSubstituteEnvVars(arena: std.mem.Allocator, input: []const u8) err return if (changed) current else input; } +test "call: unknown tool name surfaces in-band" { + var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); + defer arena.deinit(); + + // Session/registry are never touched on this branch; the name check is + // the first thing `call` does. + const r = try call(arena.allocator(), undefined, undefined, "multi_tool_use.parallel", null); + try std.testing.expect(r.is_error); + try std.testing.expectEqualStrings("Unknown tool: multi_tool_use.parallel", r.text); +} + +test "parseValue: zero-filled optional backendNodeId treated as omitted" { + var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); + defer arena.deinit(); + const aa = arena.allocator(); + + const Params = struct { + backendNodeId: ?CDPNode.Id = null, + maxDepth: ?u32 = null, + }; + const zeroed = try std.json.parseFromSliceLeaky(std.json.Value, aa, + \\{"backendNodeId":0,"maxDepth":2} + , .{}); + const args = try parseValue(Params, aa, zeroed); + try std.testing.expectEqual(@as(?CDPNode.Id, null), args.backendNodeId); + try std.testing.expectEqual(@as(?u32, 2), args.maxDepth); + + const real = try std.json.parseFromSliceLeaky(std.json.Value, aa, + \\{"backendNodeId":7} + , .{}); + try std.testing.expectEqual(@as(?CDPNode.Id, 7), (try parseValue(Params, aa, real)).backendNodeId); + + // Non-optional ids (nodeDetails) pass through untouched. + const Required = struct { backendNodeId: CDPNode.Id }; + const zero_required = try std.json.parseFromSliceLeaky(std.json.Value, aa, + \\{"backendNodeId":0} + , .{}); + try std.testing.expectEqual(@as(CDPNode.Id, 0), (try parseValue(Required, aa, zero_required)).backendNodeId); +} + test "substituteEnvVars resolves LP_* vars" { var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); defer arena.deinit(); diff --git a/src/browser/webapi/CData.zig b/src/browser/webapi/CData.zig index 674472142..4eeab1337 100644 --- a/src/browser/webapi/CData.zig +++ b/src/browser/webapi/CData.zig @@ -77,9 +77,9 @@ pub fn subtype(self: *const CData, comptime T: type) *T { // the arithmetic rides on factory-chain contiguity; the stored // back-pointer doubles as its canary if (comptime T == CDATASection) { - std.debug.assert(sub._proto._proto == self); + std.debug.assert(sub._proto_canary._proto_canary == self); } else { - std.debug.assert(sub._proto == self); + std.debug.assert(sub._proto_canary == self); } } return sub; diff --git a/src/browser/webapi/CustomElementRegistry.zig b/src/browser/webapi/CustomElementRegistry.zig index a6f19830f..36ab863e0 100644 --- a/src/browser/webapi/CustomElementRegistry.zig +++ b/src/browser/webapi/CustomElementRegistry.zig @@ -181,7 +181,9 @@ fn upgradeElement(self: *CustomElementRegistry, element: *Element, frame: *Frame return Custom.checkAndAttachBuiltIn(element, frame); }; - if (custom._definition != null) return; + if (custom._definition != null or custom._upgrade_failed) { + return; + } const name = custom._tag_name.str(); const definition = self._definitions.get(name) orelse return; @@ -198,19 +200,49 @@ pub fn upgradeCustomElement(custom: *Custom, definition: *CustomElementDefinitio const node = custom.asNode(); const prev_upgrading = frame._upgrading_element; + const prev_consumed = frame._upgrading_consumed; frame._upgrading_element = node; - defer frame._upgrading_element = prev_upgrading; + frame._upgrading_consumed = false; + defer { + frame._upgrading_element = prev_upgrading; + frame._upgrading_consumed = prev_consumed; + } var ls: js.Local.Scope = undefined; frame.js.localScope(&ls); defer ls.deinit(); - var caught: js.TryCatch.Caught = undefined; - _ = ls.toLocal(definition.constructor).newInstance(&caught) catch |err| { - log.warn(.js, "custom element upgrade", .{ .name = definition.name, .err = err, .caught = caught }); + const local = &ls.local; + var try_catch: js.TryCatch = undefined; + try_catch.init(local); + defer try_catch.deinit(); + + const object = ls.toLocal(definition.constructor).newInstanceThrow() catch |err| { + if (err == error.ExecutionTerminated) { + custom._definition = null; + return err; + } + log.warn(.js, "custom element upgrade", .{ .name = definition.name, .err = err }); + upgradeFailed(custom); + if (try_catch.exceptionValue()) |exc| { + frame.window.reportError(exc, frame) catch {}; + } return error.CustomElementUpgradeFailed; }; + const same = if (object.toZig(*Node)) |result| result == node else |_| false; + if (!same) { + // the construction result must be the element being upgraded. + log.warn(.js, "custom element upgrade", .{ .name = definition.name, .reason = "constructor returned another value" }); + upgradeFailed(custom); + const exc: js.Value = .{ + .local = local, + .handle = local.isolate.createTypeError("custom element constructor must return the upgraded element"), + }; + frame.window.reportError(exc, frame) catch {}; + return error.CustomElementUpgradeFailed; + } + // Enqueue attributeChangedCallback for existing observed attributes const element = custom.asElement(); for (element.attributeEntries()) |*attr| { @@ -227,6 +259,11 @@ pub fn upgradeCustomElement(custom: *Custom, definition: *CustomElementDefinitio } } +fn upgradeFailed(custom: *Custom) void { + custom._definition = null; + custom._upgrade_failed = true; +} + fn validateName(name: []const u8) !void { if (name.len == 0) { return error.SyntaxError; @@ -288,5 +325,6 @@ pub const JsApi = struct { const testing = @import("../../testing.zig"); test "WebApi: CustomElementRegistry" { + testing.expectLog(&.{ .js, .js, .js, .js, .js, .js, .js, .js, .js, .js, .js, .js, .js, .js, .js, .js, .js, .js, .js, .js, .js, .js, .js }); try testing.htmlRunner("custom_elements", .{}); } diff --git a/src/browser/webapi/DataTransferItem.zig b/src/browser/webapi/DataTransferItem.zig index d62ef7b16..796e3c139 100644 --- a/src/browser/webapi/DataTransferItem.zig +++ b/src/browser/webapi/DataTransferItem.zig @@ -81,7 +81,7 @@ pub fn getAsString(self: *const DataTransferItem, cb_: ?js.Function) !void { .string => |str| str, .file => return, }; - var caught: js.TryCatch.Caught = undefined; + var caught: js.TryCatch.Caught = .{}; cb.tryCall(void, .{s}, &caught) catch { log.debug(.js, "getAsString callback", .{ .caught = caught, .source = "DataTransferItem" }); }; diff --git a/src/browser/webapi/Document.zig b/src/browser/webapi/Document.zig index 78e2a3f83..f3b2e75f3 100644 --- a/src/browser/webapi/Document.zig +++ b/src/browser/webapi/Document.zig @@ -212,6 +212,11 @@ pub fn getLastModified(self: *const Document, frame: *Frame) ![]const u8 { }); } +pub fn getReferrer(self: *const Document) []const u8 { + const frame = self._frame orelse return ""; + return frame._referrer orelse ""; +} + pub fn getCharset(self: *const Document) []const u8 { if (self._charset) |charset| { return charset; @@ -723,7 +728,15 @@ pub fn getReadyState(self: *const Document) []const u8 { pub fn getActiveElement(self: *Document) ?*Element { if (self._active_element) |el| { - return el; + // A focused element inside a shadow tree is exposed as its outermost + // host; one in a detached tree isn't exposed at all. + var candidate = el; + while (candidate.asNode().containingShadowRoot()) |shadow| { + candidate = shadow._host; + } + if (candidate.asNode().getRootNode(.{}) == self.asNode()) { + return candidate; + } } // Default to body if it exists @@ -760,6 +773,9 @@ pub fn adoptNode(self: *Document, node: *Node, frame: *Frame) !*Node { if (node._type == .document) { return error.NotSupported; } + if (node.is(Node.ShadowRoot) != null) { + return error.HierarchyError; + } const old_owner = node.ownerDocument(frame) orelse frame.document; @@ -1588,15 +1604,12 @@ pub const JsApi = struct { pub const hasFocus = bridge.function(Document.hasFocus, .{}); pub const prerendering = bridge.property(false, .{ .template = false }); - pub const characterSet = bridge.accessor(getCharacterSet, null, .{}); - pub const charset = bridge.accessor(getCharacterSet, null, .{}); - pub const inputEncoding = bridge.accessor(getCharacterSet, null, .{}); + pub const characterSet = bridge.accessor(Document.getCharset, null, .{}); + pub const charset = bridge.accessor(Document.getCharset, null, .{}); + pub const inputEncoding = bridge.accessor(Document.getCharset, null, .{}); pub const compatMode = bridge.accessor(Document.getCompatMode, null, .{}); pub const lastModified = bridge.accessor(Document.getLastModified, null, .{}); - fn getCharacterSet(self: *const Document) []const u8 { - return self.getCharset(); - } - pub const referrer = bridge.property("", .{ .template = false }); + pub const referrer = bridge.accessor(Document.getReferrer, null, .{}); // Generates a getter/setter pair backed by the frame's attribute-listener // map, like onclick above, for other document event handler properties. diff --git a/src/browser/webapi/Element.zig b/src/browser/webapi/Element.zig index 180addb36..497ea81c4 100644 --- a/src/browser/webapi/Element.zig +++ b/src/browser/webapi/Element.zig @@ -51,6 +51,30 @@ pub const Proto = Node; pub const DatasetLookup = std.AutoHashMapUnmanaged(*Element, *DOMStringMap); pub const StyleLookup = std.AutoHashMapUnmanaged(*Element, *CSSStyleProperties); +pub const ComputedStyleLookup = std.AutoHashMapUnmanaged(ComputedStyleKey, *CSSStyleProperties); + +pub const ComputedStyleKey = struct { + element: *Element, + pseudo: PseudoElement, +}; + +pub const PseudoElement = enum { + none, + before, + after, + other, + + pub fn parse(pseudo: []const u8) PseudoElement { + if (pseudo.len == 0 or pseudo[0] != ':') { + return .none; + } + const name = if (std.mem.startsWith(u8, pseudo, "::")) pseudo[2..] else pseudo[1..]; + if (std.ascii.eqlIgnoreCase(name, "before")) return .before; + if (std.ascii.eqlIgnoreCase(name, "after")) return .after; + return .other; + } +}; + pub const ClassListLookup = std.AutoHashMapUnmanaged(*Element, *collections.DOMTokenList); pub const RelListLookup = std.AutoHashMapUnmanaged(*Element, *collections.DOMTokenList); pub const ShadowRootLookup = std.AutoHashMapUnmanaged(*Element, *ShadowRoot); @@ -115,15 +139,35 @@ _attributes: Attribute.List = .{}, // work to resolve the proto). _proto_canary: if (lp.IS_DEBUG) *Node else void = undefined, -pub const Type = union(enum) { - html: *Html, - svg: *Svg, +pub const Type = enum(u8) { + html, + svg, }; +pub fn Subtype(comptime tag: Type) type { + return switch (tag) { + .html => Html, + .svg => Svg, + }; +} + +pub fn subtype(self: *const Element, comptime T: type) *T { + const offset = comptime Factory.chainOffsetOf(T, T) - Factory.chainOffsetOf(T, Element); + const sub: *T = @ptrFromInt(@intFromPtr(self) + offset); + if (comptime lp.IS_DEBUG) { + // This pointer dance only works because the factory allocates the chain + // in a contiguous block of memory. In debug, we assert this holds via + // the _proto_canary back pointer. + std.debug.assert(Factory.protoOf(sub) == self); + } + return sub; +} + pub fn is(self: *Element, comptime T: type) ?*T { const type_name = @typeName(T); switch (self._type) { - .html => |el| { + .html => { + const el = self.subtype(Html); if (T == Html) { return el; } @@ -131,7 +175,8 @@ pub fn is(self: *Element, comptime T: type) ?*T { return el.is(T); } }, - .svg => |svg| { + .svg => { + const svg = self.subtype(Svg); if (T == Svg) { return svg; } @@ -178,177 +223,183 @@ pub fn isEqualNode(self: *Element, other: *Element) bool { pub fn getTagNameLower(self: *const Element) []const u8 { switch (self._type) { - .html => |he| switch (he._type) { - .custom => |ce| { - @branchHint(.unlikely); - return ce._tag_name.str(); - }, - else => return switch (he._type) { - .anchor => "a", - .area => "area", - .base => "base", - .body => "body", - .br => "br", - .button => "button", - .canvas => "canvas", - .custom => |e| e._tag_name.str(), - .data => "data", - .datalist => "datalist", - .details => "details", - .dialog => "dialog", - .directory => "dir", - .div => "div", - .dl => "dl", - .embed => "embed", - .fieldset => "fieldset", - .font => "font", - .frameset => "frameset", - .form => "form", - .generic => |e| e._tag_name.str(), - .heading => |e| e._tag_name.str(), - .head => "head", - .html => "html", - .hr => "hr", - .iframe => "iframe", - .img => "img", - .input => "input", - .label => "label", - .legend => "legend", - .li => "li", - .link => "link", - .map => "map", - .marquee => "marquee", - .media => |m| switch (m._type) { - .audio => "audio", - .video => "video", - .generic => "media", + .html => { + const he = self.subtype(Html); + switch (he._type) { + .custom => { + @branchHint(.unlikely); + return he.subtype(Html.Custom)._tag_name.str(); }, - .meta => "meta", - .meter => "meter", - .mod => |e| e._tag_name.str(), - .object => "object", - .ol => "ol", - .optgroup => "optgroup", - .option => "option", - .output => "output", - .p => "p", - .picture => "picture", - .param => "param", - .pre => "pre", - .progress => "progress", - .quote => |e| e._tag_name.str(), - .script => "script", - .select => "select", - .slot => "slot", - .source => "source", - .span => "span", - .style => "style", - .table => "table", - .table_caption => "caption", - .table_cell => |e| e._tag_name.str(), - .table_col => |e| e._tag_name.str(), - .table_row => "tr", - .table_section => |e| e._tag_name.str(), - .template => "template", - .textarea => "textarea", - .time => "time", - .title => "title", - .track => "track", - .ul => "ul", - .unknown => |e| e._tag_name.str(), - }, + else => return switch (he._type) { + .anchor => "a", + .area => "area", + .base => "base", + .body => "body", + .br => "br", + .button => "button", + .canvas => "canvas", + .custom => he.subtype(Html.Custom)._tag_name.str(), + .data => "data", + .datalist => "datalist", + .details => "details", + .dialog => "dialog", + .directory => "dir", + .div => "div", + .dl => "dl", + .embed => "embed", + .fieldset => "fieldset", + .font => "font", + .frameset => "frameset", + .form => "form", + .generic => he.subtype(Html.Generic)._tag_name.str(), + .heading => he.subtype(Html.Heading)._tag_name.str(), + .head => "head", + .html => "html", + .hr => "hr", + .iframe => "iframe", + .img => "img", + .input => "input", + .label => "label", + .legend => "legend", + .li => "li", + .link => "link", + .map => "map", + .marquee => "marquee", + .media => switch (he.subtype(Html.Media)._type) { + .audio => "audio", + .video => "video", + .generic => "media", + }, + .meta => "meta", + .meter => "meter", + .mod => he.subtype(Html.Mod)._tag_name.str(), + .object => "object", + .ol => "ol", + .optgroup => "optgroup", + .option => "option", + .output => "output", + .p => "p", + .picture => "picture", + .param => "param", + .pre => "pre", + .progress => "progress", + .quote => he.subtype(Html.Quote)._tag_name.str(), + .script => "script", + .select => "select", + .slot => "slot", + .source => "source", + .span => "span", + .style => "style", + .table => "table", + .table_caption => "caption", + .table_cell => he.subtype(Html.TableCell)._tag_name.str(), + .table_col => he.subtype(Html.TableCol)._tag_name.str(), + .table_row => "tr", + .table_section => he.subtype(Html.TableSection)._tag_name.str(), + .template => "template", + .textarea => "textarea", + .time => "time", + .title => "title", + .track => "track", + .ul => "ul", + .unknown => he.subtype(Html.Unknown)._tag_name.str(), + }, + } }, - .svg => |svg| return svg._tag_name.str(), + .svg => return self.subtype(Svg)._tag_name.str(), } } pub fn getTagNameSpec(self: *const Element, buf: []u8) []const u8 { return switch (self._type) { - .html => |he| switch (he._type) { - .anchor => "A", - .area => "AREA", - .base => "BASE", - .body => "BODY", - .br => "BR", - .button => "BUTTON", - .canvas => "CANVAS", - .custom => |e| upperTagName(&e._tag_name, buf), - .data => "DATA", - .datalist => "DATALIST", - .details => "DETAILS", - .dialog => "DIALOG", - .directory => "DIR", - .div => "DIV", - .dl => "DL", - .embed => "EMBED", - .fieldset => "FIELDSET", - .font => "FONT", - .frameset => "FRAMESET", - .form => "FORM", - .generic => |e| upperTagName(&e._tag_name, buf), - .heading => |e| upperTagName(&e._tag_name, buf), - .head => "HEAD", - .html => "HTML", - .hr => "HR", - .iframe => "IFRAME", - .img => "IMG", - .input => "INPUT", - .label => "LABEL", - .legend => "LEGEND", - .li => "LI", - .link => "LINK", - .map => "MAP", - .marquee => "MARQUEE", - .meta => "META", - .media => |m| switch (m._type) { - .audio => "AUDIO", - .video => "VIDEO", - .generic => "MEDIA", - }, - .meter => "METER", - .mod => |e| upperTagName(&e._tag_name, buf), - .object => "OBJECT", - .ol => "OL", - .optgroup => "OPTGROUP", - .option => "OPTION", - .output => "OUTPUT", - .p => "P", - .picture => "PICTURE", - .param => "PARAM", - .pre => "PRE", - .progress => "PROGRESS", - .quote => |e| upperTagName(&e._tag_name, buf), - .script => "SCRIPT", - .select => "SELECT", - .slot => "SLOT", - .source => "SOURCE", - .span => "SPAN", - .style => "STYLE", - .table => "TABLE", - .table_caption => "CAPTION", - .table_cell => |e| upperTagName(&e._tag_name, buf), - .table_col => |e| upperTagName(&e._tag_name, buf), - .table_row => "TR", - .table_section => |e| upperTagName(&e._tag_name, buf), - .template => "TEMPLATE", - .textarea => "TEXTAREA", - .time => "TIME", - .title => "TITLE", - .track => "TRACK", - .ul => "UL", - .unknown => |e| switch (self._namespace) { - .html => upperTagName(&e._tag_name, buf), - .svg, .xml, .mathml, .unknown, .null => e._tag_name.str(), - }, + .html => blk: { + const he = self.subtype(Html); + break :blk switch (he._type) { + .anchor => "A", + .area => "AREA", + .base => "BASE", + .body => "BODY", + .br => "BR", + .button => "BUTTON", + .canvas => "CANVAS", + .custom => upperTagName(&he.subtype(Html.Custom)._tag_name, buf), + .data => "DATA", + .datalist => "DATALIST", + .details => "DETAILS", + .dialog => "DIALOG", + .directory => "DIR", + .div => "DIV", + .dl => "DL", + .embed => "EMBED", + .fieldset => "FIELDSET", + .font => "FONT", + .frameset => "FRAMESET", + .form => "FORM", + .generic => upperTagName(&he.subtype(Html.Generic)._tag_name, buf), + .heading => upperTagName(&he.subtype(Html.Heading)._tag_name, buf), + .head => "HEAD", + .html => "HTML", + .hr => "HR", + .iframe => "IFRAME", + .img => "IMG", + .input => "INPUT", + .label => "LABEL", + .legend => "LEGEND", + .li => "LI", + .link => "LINK", + .map => "MAP", + .marquee => "MARQUEE", + .meta => "META", + .media => switch (he.subtype(Html.Media)._type) { + .audio => "AUDIO", + .video => "VIDEO", + .generic => "MEDIA", + }, + .meter => "METER", + .mod => upperTagName(&he.subtype(Html.Mod)._tag_name, buf), + .object => "OBJECT", + .ol => "OL", + .optgroup => "OPTGROUP", + .option => "OPTION", + .output => "OUTPUT", + .p => "P", + .picture => "PICTURE", + .param => "PARAM", + .pre => "PRE", + .progress => "PROGRESS", + .quote => upperTagName(&he.subtype(Html.Quote)._tag_name, buf), + .script => "SCRIPT", + .select => "SELECT", + .slot => "SLOT", + .source => "SOURCE", + .span => "SPAN", + .style => "STYLE", + .table => "TABLE", + .table_caption => "CAPTION", + .table_cell => upperTagName(&he.subtype(Html.TableCell)._tag_name, buf), + .table_col => upperTagName(&he.subtype(Html.TableCol)._tag_name, buf), + .table_row => "TR", + .table_section => upperTagName(&he.subtype(Html.TableSection)._tag_name, buf), + .template => "TEMPLATE", + .textarea => "TEXTAREA", + .time => "TIME", + .title => "TITLE", + .track => "TRACK", + .ul => "UL", + .unknown => switch (self._namespace) { + .html => upperTagName(&he.subtype(Html.Unknown)._tag_name, buf), + .svg, .xml, .mathml, .unknown, .null => he.subtype(Html.Unknown)._tag_name.str(), + }, + }; }, - .svg => |svg| svg._tag_name.str(), + .svg => self.subtype(Svg)._tag_name.str(), }; } pub fn getTagNameDump(self: *const Element) []const u8 { switch (self._type) { .html => return self.getTagNameLower(), - .svg => |svg| return svg._tag_name.str(), + .svg => return self.subtype(Svg)._tag_name.str(), } } @@ -1255,17 +1306,30 @@ pub fn checkVisibility(self: *Element, opts_: ?CheckVisibilityOpts, frame: *Fram }); } -pub fn getElementDimensions(self: *Element, frame: *Frame) struct { width: f64, height: f64 } { - var width: f64 = 5.0; - var height: f64 = 5.0; +pub const Dimensions = struct { + width: f64, + height: f64, + // if the value is explicit (e.g. inline style, width attribute, ...) or defaulted + explicit_width: bool = false, + explicit_height: bool = false, +}; + +pub fn getElementDimensions(self: *Element, frame: *Frame) Dimensions { + var dims: Dimensions = .{ .width = 5.0, .height = 5.0 }; if (self.getStyle(frame)) |style| { const decl = style.asCSSStyleDeclaration(); - width = CSS.parseDimensionViewport(decl.getPropertyValue("width", frame), frame) orelse 5.0; - height = CSS.parseDimensionViewport(decl.getPropertyValue("height", frame), frame) orelse 5.0; + if (CSS.parseDimensionViewport(decl.getPropertyValue("width", frame), frame)) |w| { + dims.width = w; + dims.explicit_width = true; + } + if (CSS.parseDimensionViewport(decl.getPropertyValue("height", frame), frame)) |h| { + dims.height = h; + dims.explicit_height = true; + } } - if (width == 5.0 or height == 5.0) { + if (dims.width == 5.0 or dims.height == 5.0) { const tag = self.getTag(); // Root containers get large default size to contain descendant positions. @@ -1273,35 +1337,69 @@ pub fn getElementDimensions(self: *Element, frame: *Frame) struct { width: f64, // even very deep trees (100 levels) stay within 10,000px. // 100M pixels is plausible for very long documents. if (tag == .html or tag == .body) { - if (width == 5.0) width = 1920.0; - if (height == 5.0) height = 100_000_000.0; + if (dims.width == 5.0) dims.width = 1920.0; + if (dims.height == 5.0) dims.height = 100_000_000.0; } else if (tag == .img or tag == .iframe) { if (self.getAttributeSafe(comptime .wrap("width"))) |w| { - width = std.fmt.parseFloat(f64, w) catch width; + if (std.fmt.parseFloat(f64, w)) |parsed| { + dims.width = parsed; + dims.explicit_width = true; + } else |_| {} } if (self.getAttributeSafe(comptime .wrap("height"))) |h| { - height = std.fmt.parseFloat(f64, h) catch height; + if (std.fmt.parseFloat(f64, h)) |parsed| { + dims.height = parsed; + dims.explicit_height = true; + } else |_| {} } } } - return .{ .width = width, .height = height }; + return dims; } +// We can't do this correctly without full styles and more rendering. We also +// can't just ignore the children since some sites append nodes until a certain +// width / height treshold is reached. If the size isn't explicit, we fallback +// to contentWidth/contentHeight pub fn getClientWidth(self: *Element, frame: *Frame) f64 { - if (!self.checkVisibilityCached(null, frame)) { + var visibility_cache: VisibilityCache = .{}; + return self.getClientWidthWithCache(frame, &visibility_cache); +} + +pub fn getClientWidthWithCache(self: *Element, frame: *Frame, visibility_cache: *VisibilityCache) f64 { + if (!self.checkVisibilityCached(visibility_cache, frame)) { return 0.0; } + const dims = self.getElementDimensions(frame); - return dims.width; + + const tag = self.getTag(); + if (tag == .html or tag == .body or dims.explicit_width) { + return dims.width; + } + + return @max(dims.width, self.contentWidth(frame, visibility_cache)); } pub fn getClientHeight(self: *Element, frame: *Frame) f64 { - if (!self.checkVisibilityCached(null, frame)) { + var visibility_cache: VisibilityCache = .{}; + return self.getClientHeightWithCache(frame, &visibility_cache); +} + +pub fn getClientHeightWithCache(self: *Element, frame: *Frame, visibility_cache: *VisibilityCache) f64 { + if (!self.checkVisibilityCached(visibility_cache, frame)) { return 0.0; } + const dims = self.getElementDimensions(frame); - return dims.height; + + const tag = self.getTag(); + if (tag == .html or tag == .body or dims.explicit_height) { + return dims.height; + } + + return @max(dims.height, self.contentHeight(frame, visibility_cache)); } pub fn getBoundingClientRect(self: *Element, frame: *Frame) !*DOMRect { @@ -1916,81 +2014,84 @@ fn upperTagName(tag_name: *String, buf: []u8) []const u8 { pub fn getTag(self: *const Element) Tag { return switch (self._type) { - .html => |he| switch (he._type) { - .anchor => .anchor, - .area => .area, - .base => .base, - .div => .div, - .dl => .dl, - .embed => .embed, - .form => .form, - .p => .p, - .custom => .custom, - .data => .data, - .datalist => .datalist, - .details => .details, - .dialog => .dialog, - .directory => .directory, - .iframe => .iframe, - .img => .img, - .br => .br, - .button => .button, - .canvas => .canvas, - .fieldset => .fieldset, - .font => .font, - .frameset => .frameset, - .heading => |h| h._tag, - .label => .label, - .legend => .legend, - .li => .li, - .map => .map, - .marquee => .marquee, - .ul => .ul, - .ol => .ol, - .object => .object, - .optgroup => .optgroup, - .output => .output, - .picture => .picture, - .param => .param, - .pre => .pre, - .generic => |g| g._tag, - .media => |m| switch (m._type) { - .audio => .audio, - .video => .video, - .generic => .media, - }, - .meter => .meter, - .mod => |m| m._tag, - .progress => .progress, - .quote => |q| q._tag, - .script => .script, - .select => .select, - .slot => .slot, - .source => .source, - .span => .span, - .option => .option, - .table => .table, - .table_caption => .caption, - .table_cell => |tc| tc._tag, - .table_col => |tc| tc._tag, - .table_row => .tr, - .table_section => |ts| ts._tag, - .template => .template, - .textarea => .textarea, - .time => .time, - .track => .track, - .input => .input, - .link => .link, - .meta => .meta, - .hr => .hr, - .style => .style, - .title => .title, - .body => .body, - .html => .html, - .head => .head, - .unknown => .unknown, + .html => blk: { + const he = self.subtype(Html); + break :blk switch (he._type) { + .anchor => .anchor, + .area => .area, + .base => .base, + .div => .div, + .dl => .dl, + .embed => .embed, + .form => .form, + .p => .p, + .custom => .custom, + .data => .data, + .datalist => .datalist, + .details => .details, + .dialog => .dialog, + .directory => .directory, + .iframe => .iframe, + .img => .img, + .br => .br, + .button => .button, + .canvas => .canvas, + .fieldset => .fieldset, + .font => .font, + .frameset => .frameset, + .heading => he.subtype(Html.Heading)._tag, + .label => .label, + .legend => .legend, + .li => .li, + .map => .map, + .marquee => .marquee, + .ul => .ul, + .ol => .ol, + .object => .object, + .optgroup => .optgroup, + .output => .output, + .picture => .picture, + .param => .param, + .pre => .pre, + .generic => he.subtype(Html.Generic)._tag, + .media => switch (he.subtype(Html.Media)._type) { + .audio => .audio, + .video => .video, + .generic => .media, + }, + .meter => .meter, + .mod => he.subtype(Html.Mod)._tag, + .progress => .progress, + .quote => he.subtype(Html.Quote)._tag, + .script => .script, + .select => .select, + .slot => .slot, + .source => .source, + .span => .span, + .option => .option, + .table => .table, + .table_caption => .caption, + .table_cell => he.subtype(Html.TableCell)._tag, + .table_col => he.subtype(Html.TableCol)._tag, + .table_row => .tr, + .table_section => he.subtype(Html.TableSection)._tag, + .template => .template, + .textarea => .textarea, + .time => .time, + .track => .track, + .input => .input, + .link => .link, + .meta => .meta, + .hr => .hr, + .style => .style, + .title => .title, + .body => .body, + .html => .html, + .head => .head, + .unknown => .unknown, + }; }, - .svg => |se| se.getTag(), + .svg => self.subtype(Svg).getTag(), }; } @@ -2315,6 +2416,7 @@ pub const JsApi = struct { pub const previousElementSibling = bridge.accessor(Element.previousElementSibling, null, .{}); pub const childElementCount = bridge.accessor(Element.getChildElementCount, null, .{}); pub const matches = bridge.function(Element.matches, .{}); + pub const webkitMatchesSelector = bridge.function(Element.matches, .{}); pub const querySelector = bridge.function(Element.querySelector, .{}); pub const querySelectorAll = bridge.function(Element.querySelectorAll, .{}); pub const closest = bridge.function(Element.closest, .{}); @@ -2353,22 +2455,21 @@ pub const Build = struct { // Calls `func_name` with `args` on the most specific type where it is // implement. This could be on the Element itself. pub fn call(self: *const Element, comptime func_name: []const u8, args: anytype) !bool { - inline for (@typeInfo(Element.Type).@"union".fields) |f| { - if (@field(Element.Type, f.name) == self._type) { - // The inner type implements this function. Call it and we're done. - const S = reflect.Struct(f.type); + switch (self._type) { + inline else => |tag| { + const S = Subtype(tag); if (@hasDecl(S, "Build")) { + // The inner type has its own "call" method. Defer to it. if (@hasDecl(S.Build, "call")) { - const sub = @field(self._type, f.name); - return S.Build.call(sub, func_name, args); + return S.Build.call(self.subtype(S), func_name, args); } // The inner type implements this function. Call it and we're done. - if (@hasDecl(f.type, func_name)) { - return @call(.auto, @field(f.type, func_name), args); + if (@hasDecl(S, func_name)) { + return @call(.auto, @field(S, func_name), args); } } - } + }, } if (@hasDecl(Element.Build, func_name)) { diff --git a/src/browser/webapi/Event.zig b/src/browser/webapi/Event.zig index 6076e91c8..95c427598 100644 --- a/src/browser/webapi/Event.zig +++ b/src/browser/webapi/Event.zig @@ -21,6 +21,7 @@ const lp = @import("lightpanda"); const js = @import("../js/js.zig"); const Page = @import("../Page.zig"); +const EventManager = @import("../EventManager.zig"); const Node = @import("Node.zig"); const EventTarget = @import("EventTarget.zig"); @@ -40,6 +41,7 @@ _type_string: String, _target: ?*EventTarget = null, _current_target: ?*EventTarget = null, _dispatch_target: ?*EventTarget = null, // Original target for composedPath() +_dispatch_related_target: ?*EventTarget = null, _prevent_default: bool = false, _stop_propagation: bool = false, _stop_immediate_propagation: bool = false, @@ -325,113 +327,61 @@ pub fn composedPath(self: *Event, exec: *Execution) ![]const *EventTarget { else => return &.{}, }; - // Build the path by walking up from target - var path_len: usize = 0; - var path_buffer: [128]*EventTarget = undefined; - var stopped_at_shadow_boundary = false; - - // Track closed shadow boundaries (position in path and host position) - var closed_shadow_boundary: ?struct { shadow_end: usize, host_start: usize } = null; - const frame_ = switch (exec.js.global) { .frame => |frame| frame, else => null, }; - const target_root = target_node.getRootNode(.{}); - var node: ?*Node = target_node; - while (node) |n| { - if (path_len >= path_buffer.len) { - break; - } - path_buffer[path_len] = n.asEventTarget(); - path_len += 1; - - // Check if this node is a shadow root - if (n._type == .document_fragment) { - const df = n.subtype(Node.DocumentFragment); - if (df._type == .shadow_root) { - const shadow = df._type.shadow_root; - - if (!self._composed and n == target_root) { - stopped_at_shadow_boundary = true; - break; - } - - // Track the first closed shadow boundary we encounter - if (shadow._mode == .closed and closed_shadow_boundary == null) { - // Mark where the shadow root is in the path - // The next element will be the host - closed_shadow_boundary = .{ - .shadow_end = path_len - 1, // index of shadow root - .host_start = path_len, // index where host will be - }; - } - - // Jump to the shadow host and continue - node = shadow._host.asNode(); - continue; - } - } - - // an assigned slottable's event-path parent is its assigned slot, - // routing the event into the slot's shadow tree - if (frame_) |frame| { - if (frame._assigned_slots.get(n)) |slot| { - node = slot.asNode(); - continue; - } - } - - node = n._parent; + var path_buffer: [128]*EventTarget = undefined; + var path_len = EventManager.buildEventPath(target_node, self, frame_, &path_buffer).len; + if (path_len == 0) { + return &.{}; } - // Add window at the end. It only participates when propagation did not stop - // at a shadow boundary... - if (stopped_at_shadow_boundary == false) { - // ... AND when the tree's root is a document - const root_is_document = path_len > 0 and switch (path_buffer[path_len - 1]._type) { - .node => |n| n._type == .document, - else => false, + // Window follows the document at the end of the path. A path that stopped + // early — at a shadow boundary, or at the relatedTarget — doesn't end on + // the document and so doesn't reach it. + const root_is_document = switch (path_buffer[path_len - 1]._type) { + .node => |n| n._type == .document, + else => false, + }; + if (root_is_document and path_len < path_buffer.len) { + if (frame_) |frame| { + path_buffer[path_len] = frame.window.asEventTarget(); + path_len += 1; + } + } + + // The host of the first closed shadow root on the path. Everything before + // it is inside that root and hidden from a currentTarget outside it. + var closed_host_index: ?usize = null; + for (path_buffer[0..path_len], 0..) |entry, i| { + const node = switch (entry._type) { + .node => |n| n, + else => continue, }; - if (root_is_document) { - if (path_len < path_buffer.len) { - switch (exec.js.global) { - .worker => {}, - .frame => |frame| { - path_buffer[path_len] = frame.window.asEventTarget(); - path_len += 1; - }, - } - } + const shadow = node.is(Node.ShadowRoot) orelse continue; + if (shadow._mode == .closed) { + closed_host_index = i + 1; + break; } } // Determine visible path based on current_target and closed shadow boundaries var visible_start_index: usize = 0; - if (closed_shadow_boundary) |boundary| { - // Check if current_target is outside the closed shadow - // If current_target is null or is at/after the host position, hide shadow internals - const current_target = self._current_target; - - if (current_target) |ct| { - // Find current_target in the path - var ct_index: ?usize = null; + if (closed_host_index) |host_index| { + // Find current_target in the path; if it's at or after the host, it's + // outside the closed shadow and must not see the nodes inside it. + if (self._current_target) |ct| { for (path_buffer[0..path_len], 0..) |elem, i| { if (elem == ct) { - ct_index = i; + if (i >= host_index) { + visible_start_index = host_index; + } break; } } - - // If current_target is at or after the host (outside the closed shadow), - // hide everything from target up to the host - if (ct_index) |idx| { - if (idx >= boundary.host_start) { - visible_start_index = boundary.host_start; - } - } } } diff --git a/src/browser/webapi/EventCounts.zig b/src/browser/webapi/EventCounts.zig index f53f01d59..1e74c60c6 100644 --- a/src/browser/webapi/EventCounts.zig +++ b/src/browser/webapi/EventCounts.zig @@ -116,7 +116,7 @@ pub fn forEach(self: *EventCounts, cb_: js.Function, js_this_: ?js.Object) !void const cb = if (js_this_) |js_this| try cb_.withThis(js_this) else cb_; for (tracked_event_types, self._counts) |event_type, count| { - var caught: js.TryCatch.Caught = undefined; + var caught: js.TryCatch.Caught = .{}; cb.tryCall(void, .{ count, event_type, self }, &caught) catch { log.debug(.js, "forEach callback", .{ .caught = caught, .source = "EventCounts" }); }; diff --git a/src/browser/webapi/HTMLDocument.zig b/src/browser/webapi/HTMLDocument.zig index 1ad5814c8..304982f94 100644 --- a/src/browser/webapi/HTMLDocument.zig +++ b/src/browser/webapi/HTMLDocument.zig @@ -119,7 +119,7 @@ pub fn getTitle(self: *HTMLDocument, frame: *Frame) ![]const u8 { var started = false; var in_whitespace = false; var result: std.ArrayList(u8) = .empty; - try result.ensureTotalCapacity(frame.local_arena, text.len); + try result.ensureTotalCapacityPrecise(frame.local_arena, text.len); for (text) |c| { const is_ascii_ws = c == ' ' or c == '\t' or c == '\n' or c == '\r' or c == '\x0C'; diff --git a/src/browser/webapi/IntersectionObserver.zig b/src/browser/webapi/IntersectionObserver.zig index 37fe27b22..5a9a7f848 100644 --- a/src/browser/webapi/IntersectionObserver.zig +++ b/src/browser/webapi/IntersectionObserver.zig @@ -302,7 +302,7 @@ pub fn deliverEntries(self: *IntersectionObserver, frame: *Frame) !void { } const entries = try self.takeRecords(frame); - var caught: js.TryCatch.Caught = undefined; + var caught: js.TryCatch.Caught = .{}; var ls: js.Local.Scope = undefined; frame.js.localScope(&ls); diff --git a/src/browser/webapi/KeyValueList.zig b/src/browser/webapi/KeyValueList.zig index 363e7330e..8956ff0e1 100644 --- a/src/browser/webapi/KeyValueList.zig +++ b/src/browser/webapi/KeyValueList.zig @@ -100,7 +100,7 @@ pub fn init() KeyValueList { } pub fn ensureTotalCapacity(self: *KeyValueList, allocator: Allocator, n: usize) !void { - return self._entries.ensureTotalCapacity(allocator, n); + return self._entries.ensureTotalCapacityPrecise(allocator, n); } pub fn get(self: *const KeyValueList, name: []const u8) ?[]const u8 { diff --git a/src/browser/webapi/ModelContext.zig b/src/browser/webapi/ModelContext.zig index c8797c183..edae42ab6 100644 --- a/src/browser/webapi/ModelContext.zig +++ b/src/browser/webapi/ModelContext.zig @@ -195,7 +195,7 @@ pub const ModelContextClient = struct { defer ls.deinit(); const resolver = ls.local.createPromiseResolver(); - var caught: js.TryCatch.Caught = undefined; + var caught: js.TryCatch.Caught = .{}; if (callback.tryCall(js.Value, .{}, &caught)) |result| { // The callback may itself return a thenable; resolving with its // value lets V8's promise resolution machinery unwrap it. diff --git a/src/browser/webapi/MutationObserver.zig b/src/browser/webapi/MutationObserver.zig index a11782c19..6ed87ba8f 100644 --- a/src/browser/webapi/MutationObserver.zig +++ b/src/browser/webapi/MutationObserver.zig @@ -348,7 +348,7 @@ pub fn deliverRecords(self: *MutationObserver, frame: *Frame) !void { frame.js.localScope(&ls); defer ls.deinit(); - var caught: js.TryCatch.Caught = undefined; + var caught: js.TryCatch.Caught = .{}; ls.toLocal(self._callback).tryCallWithThis(void, self, .{ records, self }, &caught) catch |err| { log.err(.frame, "MutObserver.deliverRecords", .{ .err = err, .caught = caught }); return err; diff --git a/src/browser/webapi/Node.zig b/src/browser/webapi/Node.zig index cb6f038d7..03b84fa8d 100644 --- a/src/browser/webapi/Node.zig +++ b/src/browser/webapi/Node.zig @@ -464,7 +464,7 @@ pub fn getChildTextContent(self: *Node, writer: *std.Io.Writer) error{WriteFaile var it = self.childrenIterator(); while (it.next()) |child| { if (child.is(CData.Text)) |text| { - try writer.writeAll(text._proto._data.str()); + try writer.writeAll(text.asCData()._data.str()); } } } @@ -476,7 +476,7 @@ pub fn childTextContentLen(self: *Node) usize { var it = self.childrenIterator(); while (it.next()) |child| { if (child.is(CData.Text)) |text| { - len += text._proto._data.str().len; + len += text.asCData()._data.str().len; } } return len; @@ -636,15 +636,15 @@ pub fn isEqualChildren(a: *Node, b: *Node) bool { return a_count == b_count; } +// The shadow root whose tree this node belongs to, or null when it belongs to +// a document tree or a detached one. Inclusive: a shadow root is in its own +// tree. +pub fn containingShadowRoot(self: *Node) ?*ShadowRoot { + return self.getRootNode(.{}).is(ShadowRoot); +} + pub fn isInShadowTree(self: *Node) bool { - var node = self._parent; - while (node) |n| { - if (n.is(ShadowRoot) != null) { - return true; - } - node = n._parent; - } - return false; + return self.containingShadowRoot() != null; } pub fn isConnected(self: *const Node) bool { @@ -1209,6 +1209,10 @@ const CloneError = error{ ExecutionTerminated, }; pub fn cloneNode(self: *Node, deep_: ?bool, frame: *Frame) CloneError!*Node { + if (self.is(ShadowRoot) != null) { + return error.NotSupported; + } + const deep = deep_ orelse false; switch (self._type) { .cdata => { @@ -1389,7 +1393,7 @@ fn _normalize(self: *Node, allocator: Allocator, buffer: *std.ArrayList(u8), fra continue; }; - if (text_node._proto.getData().len == 0) { + if (text_node.asCData().getData().len == 0) { frame.removeNode(self, current_node, .{ .will_be_reconnected = false }); child = next_node; continue; @@ -1407,7 +1411,7 @@ fn _normalize(self: *Node, allocator: Allocator, buffer: *std.ArrayList(u8), fra next_node = node_to_merge.nextSibling(); frame.removeNode(self, to_remove, .{ .will_be_reconnected = false }); } - text_node._proto._data = try frame.dupeSSO(buffer.items); + text_node.asCData()._data = try frame.dupeSSO(buffer.items); buffer.clearRetainingCapacity(); } } diff --git a/src/browser/webapi/PerformanceObserver.zig b/src/browser/webapi/PerformanceObserver.zig index 4cc37a2d7..627cad849 100644 --- a/src/browser/webapi/PerformanceObserver.zig +++ b/src/browser/webapi/PerformanceObserver.zig @@ -181,7 +181,7 @@ pub fn dispatch(self: *PerformanceObserver) !void { self._js.localScope(&ls); defer ls.deinit(); - var caught: js.TryCatch.Caught = undefined; + var caught: js.TryCatch.Caught = .{}; ls.toLocal(self._callback).tryCall(void, .{ EntryList{ ._entries = records }, self }, &caught) catch |err| { log.err(.frame, "PerfObserver.dispatch", .{ .err = err, .caught = caught }); return err; diff --git a/src/browser/webapi/Range.zig b/src/browser/webapi/Range.zig index 30da788d6..6b6978ded 100644 --- a/src/browser/webapi/Range.zig +++ b/src/browser/webapi/Range.zig @@ -364,7 +364,7 @@ pub fn insertNode(self: *Range, node: *Node, frame: *Frame) !void { // records browsers do (one for the split-off node, one for // the inserted node). const second = try t.splitText(offset, frame); - _ = try parent.insertBefore(node, second._proto.asNode(), frame); + _ = try parent.insertBefore(node, second.asCData().asNode(), frame); } else { _ = try parent.insertBefore(node, container.nextSibling(), frame); } diff --git a/src/browser/webapi/ResizeObserver.zig b/src/browser/webapi/ResizeObserver.zig index f74ee3201..026564696 100644 --- a/src/browser/webapi/ResizeObserver.zig +++ b/src/browser/webapi/ResizeObserver.zig @@ -183,7 +183,7 @@ pub fn deliverEntries(self: *ResizeObserver, frame: *Frame) !void { return; } - var caught: js.TryCatch.Caught = undefined; + var caught: js.TryCatch.Caught = .{}; var ls: js.Local.Scope = undefined; frame.js.localScope(&ls); diff --git a/src/browser/webapi/ShadowRoot.zig b/src/browser/webapi/ShadowRoot.zig index d2ea005b3..878b4d2f2 100644 --- a/src/browser/webapi/ShadowRoot.zig +++ b/src/browser/webapi/ShadowRoot.zig @@ -124,6 +124,23 @@ pub fn setOnSlotChange(self: *ShadowRoot, callback: ?js.Function.Global, frame: } } +pub fn getActiveElement(self: *ShadowRoot, frame: *Frame) ?*Element { + const root = self.asNode(); + const document = root.ownerDocument(frame) orelse frame.document; + + // This is answering two questions: + // 1 - is the active element contained by me (if not, return null) + // 2 - if it is, is there 1+ other shadowroot between us + // a - if there is, return the nearest (to self) shadowroot's host + // b - if there isn't, return the active element + var candidate = document._active_element orelse return null; + while (candidate.asNode().getRootNode(.{}) != root) { + const shadow = candidate.asNode().containingShadowRoot() orelse return null; + candidate = shadow._host; + } + return candidate; +} + pub fn getElementById(self: *ShadowRoot, id: []const u8, frame: *Frame) ?*Element { if (id.len == 0) { return null; @@ -177,6 +194,7 @@ pub const JsApi = struct { pub var class_id: bridge.ClassId = undefined; }; + pub const activeElement = bridge.accessor(ShadowRoot.getActiveElement, null, .{}); pub const mode = bridge.accessor(ShadowRoot.getMode, null, .{}); pub const host = bridge.accessor(ShadowRoot.getHost, null, .{}); pub const delegatesFocus = bridge.accessor(ShadowRoot.getDelegatesFocus, null, .{}); diff --git a/src/browser/webapi/SharedWorkerGlobalScope.zig b/src/browser/webapi/SharedWorkerGlobalScope.zig index afa2eec6c..65ef9f1d2 100644 --- a/src/browser/webapi/SharedWorkerGlobalScope.zig +++ b/src/browser/webapi/SharedWorkerGlobalScope.zig @@ -212,7 +212,7 @@ fn httpHeaderCallback(transfer: *Transfer) !Transfer.HeaderResult { } if (transfer.getContentLength()) |cl| { - try self._script_buffer.ensureTotalCapacity(self._script_arena.?.allocator(), cl); + try self._script_buffer.ensureTotalCapacityPrecise(self._script_arena.?.allocator(), cl); } return .proceed; diff --git a/src/browser/webapi/WebDriver.zig b/src/browser/webapi/WebDriver.zig index 57ae22def..87ccfb6ab 100644 --- a/src/browser/webapi/WebDriver.zig +++ b/src/browser/webapi/WebDriver.zig @@ -60,8 +60,8 @@ pub fn getComputedLabel(_: *const WebDriver, element: *Element, frame: *Frame) ! pub fn click(_: *const WebDriver, element: *Element, frame: *Frame) !void { if (element.is(Element.Html)) |html| { switch (html._type) { - inline .button, .input, .textarea, .select => |i| { - if (i.getDisabled()) { + inline .button, .input, .textarea, .select => |tag| { + if (html.subtype(Element.Html.Subtype(tag)).getDisabled()) { return; } }, diff --git a/src/browser/webapi/Window.zig b/src/browser/webapi/Window.zig index 4181ffbbd..14ccb86f2 100644 --- a/src/browser/webapi/Window.zig +++ b/src/browser/webapi/Window.zig @@ -639,6 +639,7 @@ pub fn reportError(self: *Window, err: js.Value, frame: *Frame) !void { // We still dispatch so that addEventListener('error', ...) listeners fire. try frame._event_manager.dispatchDirect(target, event, null, .{ .context = "window.reportError", + .run_microtasks = false, }); if (comptime lp.IS_TEST == false) { @@ -661,16 +662,15 @@ pub fn matchMedia(_: *const Window, query: []const u8, frame: *Frame) !*MediaQue } pub fn getComputedStyle(_: *const Window, element: *Element, pseudo_element: ?[]const u8, frame: *Frame) !*CSSStyleProperties { - if (pseudo_element) |pe| { - if (pe.len != 0) { - log.warn(.not_implemented, "window.GetComputedStyle", .{ .pseudo_element = pe }); - // Chrome hands out a distinct object per pseudo-element, so these - // can't share the per-element cache entry. - return CSSStyleProperties.init(element, true, frame); - } - } - const gop = try frame._element_computed_styles.getOrPut(frame.arena, element); + // :before/:after get their own cache entry and no warning: our answer + // (the element's own computed style) is a reasonable default for the + // common probes + const pseudo = Element.PseudoElement.parse(pseudo_element orelse ""); + const gop = try frame._element_computed_styles.getOrPut(frame.arena, .{ .element = element, .pseudo = pseudo }); if (!gop.found_existing) { + if (pseudo == .other) { + log.warn(.not_implemented, "window.GetComputedStyle", .{ .pseudo_element = pseudo_element.? }); + } gop.value_ptr.* = try CSSStyleProperties.init(element, true, frame); } return gop.value_ptr.*; diff --git a/src/browser/webapi/Worker.zig b/src/browser/webapi/Worker.zig index 2a572d986..049bb8064 100644 --- a/src/browser/webapi/Worker.zig +++ b/src/browser/webapi/Worker.zig @@ -170,7 +170,7 @@ fn httpHeaderCallback(transfer: *Transfer) !Transfer.HeaderResult { } if (transfer.getContentLength()) |cl| { - try self._script_buffer.ensureTotalCapacity(self._script_arena.?.allocator(), cl); + try self._script_buffer.ensureTotalCapacityPrecise(self._script_arena.?.allocator(), cl); } return .proceed; diff --git a/src/browser/webapi/cdata/CDATASection.zig b/src/browser/webapi/cdata/CDATASection.zig index ed638c172..f775b1dae 100644 --- a/src/browser/webapi/cdata/CDATASection.zig +++ b/src/browser/webapi/cdata/CDATASection.zig @@ -16,6 +16,8 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +const lp = @import("lightpanda"); + const js = @import("../../js/js.zig"); const Text = @import("Text.zig"); @@ -24,7 +26,8 @@ const CDATASection = @This(); pub const Proto = Text; -_proto: *Text, +_pad: bool = false, +_proto_canary: if (lp.IS_DEBUG) *Text else void = undefined, pub const JsApi = struct { pub const bridge = js.Bridge(CDATASection); diff --git a/src/browser/webapi/cdata/Comment.zig b/src/browser/webapi/cdata/Comment.zig index 0ceb91c04..37ea7d2e2 100644 --- a/src/browser/webapi/cdata/Comment.zig +++ b/src/browser/webapi/cdata/Comment.zig @@ -16,6 +16,8 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +const lp = @import("lightpanda"); + const js = @import("../../js/js.zig"); const Frame = @import("../../Frame.zig"); @@ -25,7 +27,8 @@ const Comment = @This(); pub const Proto = CData; -_proto: *CData, +_pad: bool = false, +_proto_canary: if (lp.IS_DEBUG) *CData else void = undefined, pub fn init(str: ?js.NullableString, frame: *Frame) !*Comment { const node = try Frame.node_factory.createComment(frame, if (str) |s| s.value else ""); diff --git a/src/browser/webapi/cdata/ProcessingInstruction.zig b/src/browser/webapi/cdata/ProcessingInstruction.zig index 3c57238f6..5c5ea3ff9 100644 --- a/src/browser/webapi/cdata/ProcessingInstruction.zig +++ b/src/browser/webapi/cdata/ProcessingInstruction.zig @@ -16,6 +16,8 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +const lp = @import("lightpanda"); + const js = @import("../../js/js.zig"); const CData = @import("../CData.zig"); @@ -24,7 +26,7 @@ const ProcessingInstruction = @This(); pub const Proto = CData; -_proto: *CData, +_proto_canary: if (lp.IS_DEBUG) *CData else void = undefined, _target: []const u8, pub fn getTarget(self: *const ProcessingInstruction) []const u8 { diff --git a/src/browser/webapi/cdata/Text.zig b/src/browser/webapi/cdata/Text.zig index 91300f850..e27a61590 100644 --- a/src/browser/webapi/cdata/Text.zig +++ b/src/browser/webapi/cdata/Text.zig @@ -17,8 +17,10 @@ // along with this program. If not, see . const std = @import("std"); +const lp = @import("lightpanda"); const js = @import("../../js/js.zig"); +const Factory = @import("../../Factory.zig"); const Frame = @import("../../Frame.zig"); const Node = @import("../Node.zig"); const CData = @import("../CData.zig"); @@ -29,7 +31,13 @@ const Text = @This(); pub const Proto = CData; -_proto: *CData, +_pad: bool = false, +_proto_canary: if (lp.IS_DEBUG) *CData else void = undefined, + +// Takes a const text but returns a mutable proto; see Node.subtype. +pub fn asCData(self: *const Text) *CData { + return Factory.protoOf(self); +} pub fn init(str: ?js.NullableString, frame: *Frame) !*Text { const node = try Frame.node_factory.createTextNode(frame, if (str) |s| s.value else ""); @@ -38,13 +46,13 @@ pub fn init(str: ?js.NullableString, frame: *Frame) !*Text { // This Text node's own data (getWholeText below spans adjacent Text nodes). pub fn ownData(self: *const Text) []const u8 { - return self._proto._data.str(); + return Factory.protoOf(self)._data.str(); } // The concatenated data of the contiguous exclusive Text nodes (adjacent // Text siblings on both sides of this one), in tree order. pub fn getWholeText(self: *Text, frame: *Frame) ![]const u8 { - const node = self._proto.asNode(); + const node = Factory.protoOf(self).asNode(); var first = node; while (first.previousSibling()) |prev| { @@ -57,7 +65,7 @@ pub fn getWholeText(self: *Text, frame: *Frame) ![]const u8 { // Common case: no adjacent text nodes, return our data directly. const has_next_text = if (node.nextSibling()) |next| isExclusiveTextNode(next) else false; if (first == node and !has_next_text) { - return self._proto._data.str(); + return Factory.protoOf(self)._data.str(); } var buf: std.ArrayList(u8) = .empty; @@ -74,11 +82,11 @@ fn isExclusiveTextNode(node: *Node) bool { } pub fn getAssignedSlot(self: *Text, frame: *Frame) ?*Slot { - return slotting.findSlot(self._proto.asNode(), true, frame); + return slotting.findSlot(Factory.protoOf(self).asNode(), true, frame); } pub fn splitText(self: *Text, offset: usize, frame: *Frame) !*Text { - const data = self._proto._data.str(); + const data = Factory.protoOf(self)._data.str(); const byte_offset = CData.utf16OffsetToUtf8(data, offset) catch return error.IndexSizeError; @@ -86,7 +94,7 @@ pub fn splitText(self: *Text, offset: usize, frame: *Frame) !*Text { const new_node = try Frame.node_factory.createTextNode(frame, new_data); const new_text = new_node.as(Text); - const node = self._proto.asNode(); + const node = Factory.protoOf(self).asNode(); // Per DOM spec splitText: insert first (step 7a), then update ranges (7b-7e), // then truncate original node (step 8). @@ -103,8 +111,8 @@ pub fn splitText(self: *Text, offset: usize, frame: *Frame) !*Text { // Step 8: truncate original node via replaceData(offset, count, ""). // Use replaceData instead of setData so live range updates fire // (matters for detached text nodes where steps 7b-7e were skipped). - const length = self._proto.getLength(); - try self._proto.replaceData(offset, length - offset, "", frame); + const length = Factory.protoOf(self).getLength(); + try Factory.protoOf(self).replaceData(offset, length - offset, "", frame); return new_text; } diff --git a/src/browser/webapi/collections/DOMTokenList.zig b/src/browser/webapi/collections/DOMTokenList.zig index e71ea6b73..68365df37 100644 --- a/src/browser/webapi/collections/DOMTokenList.zig +++ b/src/browser/webapi/collections/DOMTokenList.zig @@ -246,7 +246,7 @@ pub fn forEach(self: *DOMTokenList, cb_: js.Function, js_this_: ?js.Object, fram if (gop.found_existing) { continue; } - var caught: js.TryCatch.Caught = undefined; + var caught: js.TryCatch.Caught = .{}; cb.tryCall(void, .{ token, i, self }, &caught) catch |err| { frame._page.recordJsError(err); log.debug(.js, "forEach callback", .{ .caught = caught, .source = "DOMTokenList" }); diff --git a/src/browser/webapi/collections/NodeList.zig b/src/browser/webapi/collections/NodeList.zig index f1451cd79..676b92e3c 100644 --- a/src/browser/webapi/collections/NodeList.zig +++ b/src/browser/webapi/collections/NodeList.zig @@ -97,7 +97,7 @@ pub fn forEach(self: *NodeList, cb: js.Function, frame: *Frame) !void { while (true) : (i += 1) { const node = try self.getAtIndex(@intCast(i), frame) orelse return; - var caught: js.TryCatch.Caught = undefined; + var caught: js.TryCatch.Caught = .{}; cb.tryCall(void, .{ node, i, self }, &caught) catch |err| { frame._page.recordJsError(err); log.debug(.js, "forEach callback", .{ .caught = caught, .source = "nodelist" }); diff --git a/src/browser/webapi/collections/node_live.zig b/src/browser/webapi/collections/node_live.zig index c96f9b179..884aefaf6 100644 --- a/src/browser/webapi/collections/node_live.zig +++ b/src/browser/webapi/collections/node_live.zig @@ -407,7 +407,7 @@ pub fn NodeLive(comptime mode: Mode) type { fn isFormControl(el: *Element) bool { if (el._type != .html) return false; - const html = el._type.html; + const html = el.subtype(Element.Html); return switch (html._type) { .input, .button, .select, .textarea => true, else => false, diff --git a/src/browser/webapi/css/CSSStyleDeclaration.zig b/src/browser/webapi/css/CSSStyleDeclaration.zig index 80121f58d..8a9ca1258 100644 --- a/src/browser/webapi/css/CSSStyleDeclaration.zig +++ b/src/browser/webapi/css/CSSStyleDeclaration.zig @@ -97,9 +97,11 @@ pub fn getPropertyValue(self: *const CSSStyleDeclaration, property_name: []const } // Computed width/height must agree with the synthetic layout - // metrics (offsetWidth/getBoundingClientRect). Returning "" - // makes measurement code see contradictory sizes — jQuery's - // "shrink text until it fits" loops then never terminate. + // metrics. Returning "" makes measurement code see + // contradictory sizes — jQuery's "shrink text until it fits" + // loops then never terminate. jQuery's .width() reads this + // value, so it must also carry clientWidth's content fallback + // or append-until-wide marquee loops never terminate. if (wrapped.eql(comptime .wrap("width"))) { return resolvedDimension(element, .width, frame); } @@ -115,13 +117,13 @@ pub fn getPropertyValue(self: *const CSSStyleDeclaration, property_name: []const } fn resolvedDimension(element: *Element, dimension: enum { width, height }, frame: *Frame) []const u8 { - if (!element.checkVisibilityCached(null, frame)) { + var visibility_cache: Element.VisibilityCache = .{}; + if (!element.checkVisibilityCached(&visibility_cache, frame)) { return "auto"; } - const dims = element.getElementDimensions(frame); const value = switch (dimension) { - .width => dims.width, - .height => dims.height, + .width => element.getClientWidthWithCache(frame, &visibility_cache), + .height => element.getClientHeightWithCache(frame, &visibility_cache), }; return std.fmt.allocPrint(frame.local_arena, "{d}px", .{value}) catch "auto"; } @@ -802,8 +804,8 @@ fn getDefaultPropertyValue(self: *const CSSStyleDeclaration, name: String) []con fn getDefaultDisplay(element: *const Element) []const u8 { switch (element._type) { - .html => |html| { - return switch (html._type) { + .html => { + return switch (element.subtype(Element.Html)._type) { .anchor, .br, .span, .label, .time, .font, .mod, .quote => "inline", .body, .div, .dl, .p, .heading, .form, .button, .canvas, .details, .dialog, .embed, .head, .html, .hr, .iframe, .img, .input, .li, .link, .meta, .ol, .option, .script, .select, .slot, .style, .template, .textarea, .title, .ul, .media, .area, .base, .datalist, .directory, .fieldset, .frameset, .legend, .map, .marquee, .meter, .object, .optgroup, .output, .param, .picture, .pre, .progress, .source, .table, .table_caption, .table_cell, .table_col, .table_row, .table_section, .track => "block", .generic, .custom, .unknown, .data => blk: { @@ -835,8 +837,8 @@ fn isInlineTag(tag_name: []const u8) bool { fn getDefaultColor(element: *const Element) []const u8 { switch (element._type) { - .html => |html| { - return switch (html._type) { + .html => { + return switch (element.subtype(Element.Html)._type) { .anchor => "rgb(0, 0, 238)", // blue else => "rgb(0, 0, 0)", }; diff --git a/src/browser/webapi/element/Attribute.zig b/src/browser/webapi/element/Attribute.zig index 12b30ce75..c5486490b 100644 --- a/src/browser/webapi/element/Attribute.zig +++ b/src/browser/webapi/element/Attribute.zig @@ -353,7 +353,7 @@ pub const List = struct { pub fn getNames(self: *const List, allocator: Allocator) ![][]const u8 { var arr: std.ArrayList([]const u8) = .empty; - try arr.ensureTotalCapacity(allocator, self._len); + try arr.ensureTotalCapacityPrecise(allocator, self._len); for (self.entries()) |*e| { arr.appendAssumeCapacity(e.name()); } diff --git a/src/browser/webapi/element/DOMStringMap.zig b/src/browser/webapi/element/DOMStringMap.zig index 09066ef29..9bb125449 100644 --- a/src/browser/webapi/element/DOMStringMap.zig +++ b/src/browser/webapi/element/DOMStringMap.zig @@ -81,7 +81,7 @@ fn camelToKebab(arena: Allocator, camel: String) !String { // Fallback: allocate for longer strings var result: std.ArrayList(u8) = .empty; - try result.ensureTotalCapacity(arena, output_len); + try result.ensureTotalCapacityPrecise(arena, output_len); result.appendSliceAssumeCapacity("data-"); for (camel_str, 0..) |c, i| { @@ -110,7 +110,7 @@ fn kebabToCamel(arena: Allocator, kebab: []const u8) !?[]const u8 { const data_part = kebab[5..]; // Skip "data-" var result: std.ArrayList(u8) = .empty; - try result.ensureTotalCapacity(arena, data_part.len); + try result.ensureTotalCapacityPrecise(arena, data_part.len); var i: usize = 0; while (i < data_part.len) : (i += 1) { diff --git a/src/browser/webapi/element/Html.zig b/src/browser/webapi/element/Html.zig index 64e96684d..1dc08f973 100644 --- a/src/browser/webapi/element/Html.zig +++ b/src/browser/webapi/element/Html.zig @@ -116,6 +116,10 @@ _proto_canary: if (lp.IS_DEBUG) *Element else void = undefined, // which custom element class was invoked; look it up in the registry. pub fn construct(new_target: js.Function, frame: *Frame) !*Element { if (frame._upgrading_element) |node| { + if (frame._upgrading_consumed) { + return error.TypeError; + } + frame._upgrading_consumed = true; return node.is(Element) orelse return error.IllegalConstructor; } return Frame.node_factory.constructCustomElement(frame, new_target); @@ -127,90 +131,176 @@ pub fn construct(new_target: js.Function, frame: *Frame) !*Element { // constructors routed here. pub fn upgradeConstruct(frame: *Frame) !*Element { const node = frame._upgrading_element orelse return error.TypeError; + if (frame._upgrading_consumed) { + return error.TypeError; + } + frame._upgrading_consumed = true; return node.is(Element) orelse return error.TypeError; } -pub const Type = union(enum) { - anchor: *Anchor, - area: *Area, - base: *Base, - body: *Body, - br: *BR, - button: *Button, - canvas: *Canvas, - custom: *Custom, - data: *Data, - datalist: *DataList, - details: *Details, - dialog: *Dialog, - directory: *Directory, - div: *Div, - dl: *DList, - embed: *Embed, - fieldset: *FieldSet, - font: *Font, - form: *Form, - frameset: *FrameSet, - generic: *Generic, - heading: *Heading, - head: *Head, - html: *Html, - hr: *HR, - img: *Image, - iframe: *IFrame, - input: *Input, - label: *Label, - legend: *Legend, - li: *LI, - link: *Link, - map: *Map, - marquee: *Marquee, - media: *Media, - meta: *Meta, - meter: *Meter, - mod: *Mod, - object: *Object, - ol: *OL, - optgroup: *OptGroup, - option: *Option, - output: *Output, - p: *Paragraph, - picture: *Picture, - param: *Param, - pre: *Pre, - progress: *Progress, - quote: *Quote, - script: *Script, - select: *Select, - slot: *Slot, - source: *Source, - span: *Span, - style: *Style, - table: *Table, - table_caption: *TableCaption, - table_cell: *TableCell, - table_col: *TableCol, - table_row: *TableRow, - table_section: *TableSection, - template: *Template, - textarea: *TextArea, - time: *Time, - title: *Title, - track: *Track, - ul: *UL, - unknown: *Unknown, +pub const Type = enum(u8) { + anchor, + area, + base, + body, + br, + button, + canvas, + custom, + data, + datalist, + details, + dialog, + directory, + div, + dl, + embed, + fieldset, + font, + form, + frameset, + generic, + heading, + head, + html, + hr, + img, + iframe, + input, + label, + legend, + li, + link, + map, + marquee, + media, + meta, + meter, + mod, + object, + ol, + optgroup, + option, + output, + p, + picture, + param, + pre, + progress, + quote, + script, + select, + slot, + source, + span, + style, + table, + table_caption, + table_cell, + table_col, + table_row, + table_section, + template, + textarea, + time, + title, + track, + ul, + unknown, }; +pub fn Subtype(comptime tag: Type) type { + return switch (tag) { + .anchor => Anchor, + .area => Area, + .base => Base, + .body => Body, + .br => BR, + .button => Button, + .canvas => Canvas, + .custom => Custom, + .data => Data, + .datalist => DataList, + .details => Details, + .dialog => Dialog, + .directory => Directory, + .div => Div, + .dl => DList, + .embed => Embed, + .fieldset => FieldSet, + .font => Font, + .form => Form, + .frameset => FrameSet, + .generic => Generic, + .heading => Heading, + .head => Head, + .html => Html, + .hr => HR, + .img => Image, + .iframe => IFrame, + .input => Input, + .label => Label, + .legend => Legend, + .li => LI, + .link => Link, + .map => Map, + .marquee => Marquee, + .media => Media, + .meta => Meta, + .meter => Meter, + .mod => Mod, + .object => Object, + .ol => OL, + .optgroup => OptGroup, + .option => Option, + .output => Output, + .p => Paragraph, + .picture => Picture, + .param => Param, + .pre => Pre, + .progress => Progress, + .quote => Quote, + .script => Script, + .select => Select, + .slot => Slot, + .source => Source, + .span => Span, + .style => Style, + .table => Table, + .table_caption => TableCaption, + .table_cell => TableCell, + .table_col => TableCol, + .table_row => TableRow, + .table_section => TableSection, + .template => Template, + .textarea => TextArea, + .time => Time, + .title => Title, + .track => Track, + .ul => UL, + .unknown => Unknown, + }; +} + +pub fn subtype(self: *const HtmlElement, comptime T: type) *T { + const offset = comptime Factory.chainOffsetOf(T, T) - Factory.chainOffsetOf(T, HtmlElement); + const sub: *T = @ptrFromInt(@intFromPtr(self) + offset); + if (comptime lp.IS_DEBUG) { + // This pointer dance only works because the factory allocates the chain + // in a contiguous block of memory. In debug, we assert this holds via + // the _proto_canary back pointer. + std.debug.assert(Factory.protoOf(sub) == self); + } + return sub; +} + pub fn is(self: *HtmlElement, comptime T: type) ?*T { - inline for (@typeInfo(Type).@"union".fields) |f| { - if (@field(Type, f.name) == self._type) { - if (f.type == T) { - return &@field(self._type, f.name); + switch (self._type) { + inline else => |tag| { + if (Subtype(tag) == T) { + return self.subtype(T); } - if (f.type == *T) { - return @field(self._type, f.name); - } - } + }, } return null; } @@ -304,8 +394,8 @@ pub fn insertAdjacentHTML( pub fn click(self: *HtmlElement, frame: *Frame) !void { switch (self._type) { - inline .button, .input, .textarea, .select => |i| { - if (i.getDisabled()) { + inline .button, .input, .textarea, .select => |tag| { + if (self.subtype(Subtype(tag)).getDisabled()) { return; } }, @@ -330,8 +420,8 @@ pub fn click(self: *HtmlElement, frame: *Frame) !void { if (event._prevent_default == false) { // toggle the popover_target const explicit: ?*Element = switch (self._type) { - .button => |b| b._popover_target, - .input => |i| i._popover_target, + .button => self.subtype(Button)._popover_target, + .input => self.subtype(Input)._popover_target, else => null, }; try popover.runInvokerActivation(self, explicit, frame); @@ -543,7 +633,7 @@ fn setAttributeListener( ) !void { if (comptime lp.IS_DEBUG) { log.debug(.event, "Html.setAttributeListener", .{ - .type = std.meta.activeTag(self._type), + .type = self._type, .listener_type = listener_type, }); } @@ -1492,14 +1582,14 @@ fn collectInnerText(self: *HtmlElement, state: *InnerTextState) std.Io.Writer.Er const e = child.subtype(Node.Element); switch (e._type) { .svg => {}, - .html => |he| { + .html => { const tag = e.getTag(); switch (child_filter) { .none => {}, .select => if (tag != .option and tag != .optgroup) continue, .optgroup => if (tag != .option) continue, } - try handleChildElement(he, tag, state, &saw_cell, &saw_row); + try handleChildElement(e.subtype(HtmlElement), tag, state, &saw_cell, &saw_row); }, } }, @@ -1870,17 +1960,17 @@ pub const Build = struct { // Calls `func_name` with `args` on the most specific type where it is // implement. This could be on the HtmlElement itself. pub fn call(self: *const HtmlElement, comptime func_name: []const u8, args: anytype) !bool { - inline for (@typeInfo(HtmlElement.Type).@"union".fields) |f| { - if (@field(HtmlElement.Type, f.name) == self._type) { + switch (self._type) { + inline else => |tag| { + const S = Subtype(tag); // The inner type implements this function. Call it and we're done. - const S = reflect.Struct(f.type); if (@hasDecl(S, "Build")) { if (@hasDecl(S.Build, func_name)) { try @call(.auto, @field(S.Build, func_name), args); return true; } } - } + }, } if (@hasDecl(HtmlElement.Build, func_name)) { diff --git a/src/browser/webapi/element/Svg.zig b/src/browser/webapi/element/Svg.zig index a7a39cfe3..39960ec0b 100644 --- a/src/browser/webapi/element/Svg.zig +++ b/src/browser/webapi/element/Svg.zig @@ -16,6 +16,7 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +const std = @import("std"); const lp = @import("lightpanda"); const js = @import("../../js/js.zig"); @@ -47,64 +48,99 @@ _type: Type, _tag_name: String, // Svg elements are case-preserving _proto_canary: if (lp.IS_DEBUG) *Element else void = undefined, -pub const Type = union(enum) { - graphics: *Graphics, - view: *View, - title: *Title, - desc: *Desc, - metadata: *Metadata, - gradient: *GradientElement, - clip_path: *ClipPath, - marker: *Marker, - mask: *Mask, - pattern: *Pattern, - stop: *Stop, - generic: *Generic, +pub const Type = enum(u8) { + graphics, + view, + title, + desc, + metadata, + gradient, + clip_path, + marker, + mask, + pattern, + stop, + generic, }; +pub fn Subtype(comptime tag: Type) type { + return switch (tag) { + .graphics => Graphics, + .view => View, + .title => Title, + .desc => Desc, + .metadata => Metadata, + .gradient => GradientElement, + .clip_path => ClipPath, + .marker => Marker, + .mask => Mask, + .pattern => Pattern, + .stop => Stop, + .generic => Generic, + }; +} + +pub fn subtype(self: *const Svg, comptime T: type) *T { + const offset = comptime Factory.chainOffsetOf(T, T) - Factory.chainOffsetOf(T, Svg); + const sub: *T = @ptrFromInt(@intFromPtr(self) + offset); + if (comptime lp.IS_DEBUG) { + // This pointer dance only works because the factory allocates the chain + // in a contiguous block of memory. In debug, we assert this holds via + // the _proto_canary back pointer. + std.debug.assert(Factory.protoOf(sub) == self); + } + return sub; +} + pub fn is(self: *Svg, comptime T: type) ?*T { - inline for (@typeInfo(Type).@"union".fields) |field| { - if (@field(Type, field.name) == self._type) { - if (field.type == *T) { - return @field(self._type, field.name); + switch (self._type) { + inline else => |tag| { + if (Subtype(tag) == T) { + return self.subtype(T); } - } + }, } if (self._type == .graphics) { - return self._type.graphics.is(T); + return self.subtype(Graphics).is(T); } if (self._type == .gradient) { - return self._type.gradient.is(T); + return self.subtype(GradientElement).is(T); } return null; } pub fn getTag(self: *const Svg) Element.Tag { return switch (self._type) { - .graphics => |g| switch (g._type) { - .svg => .svg, - .g => .g, - // No dedicated Element.Tag values; tag-name matching falls back - // to _tag_name, like it does for generic SVG elements. - .a, .use, .image, .defs, .symbol, .switch_element, .foreign_object => .unknown, - .text_content => |content| switch (content._type) { - .positioning => |positioning| switch (positioning._type) { - .text => .text, - .tspan => .unknown, + .graphics => blk: { + const g = self.subtype(Graphics); + break :blk switch (g._type) { + .svg => .svg, + .g => .g, + // No dedicated Element.Tag values; tag-name matching falls back + // to _tag_name, like it does for generic SVG elements. + .a, .use, .image, .defs, .symbol, .switch_element, .foreign_object => .unknown, + .text_content => tc: { + const content = g.subtype(Graphics.TextContent); + break :tc switch (content._type) { + .positioning => switch (content.subtype(Graphics.TextContent.TextPositioning)._type) { + .text => .text, + .tspan => .unknown, + }, + .text_path => .unknown, + }; }, - .text_path => .unknown, - }, - .geometry => |geo| switch (geo._type) { - .rect => .rect, - .circle => .circle, - .ellipse => .ellipse, - .line => .line, - .path => .path, - .polygon => .polygon, - .polyline => .polyline, - }, + .geometry => switch (g.subtype(Graphics.Geometry)._type) { + .rect => .rect, + .circle => .circle, + .ellipse => .ellipse, + .line => .line, + .path => .path, + .polygon => .polygon, + .polyline => .polyline, + }, + }; }, - .generic => |g| g._tag, + .generic => self.subtype(Generic)._tag, .title => .title, .view, .desc, .metadata, .gradient, .clip_path, .marker, .mask, .pattern, .stop => .unknown, }; diff --git a/src/browser/webapi/element/html/Anchor.zig b/src/browser/webapi/element/html/Anchor.zig index 7ee163470..54563d1fe 100644 --- a/src/browser/webapi/element/html/Anchor.zig +++ b/src/browser/webapi/element/html/Anchor.zig @@ -16,8 +16,10 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +const lp = @import("lightpanda"); const std = @import("std"); const js = @import("../../../js/js.zig"); +const Factory = @import("../../../Factory.zig"); const Frame = @import("../../../Frame.zig"); const URL = @import("../../../URL.zig"); @@ -28,13 +30,14 @@ const HtmlElement = @import("../Html.zig"); const Anchor = @This(); pub const Proto = HtmlElement; -_proto: *HtmlElement, +_pad: bool = false, +_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined, pub fn asElement(self: *Anchor) *Element { - return self._proto.asElement(); + return Factory.protoOf(self).asElement(); } pub fn asConstElement(self: *const Anchor) *const Element { - return self._proto.asElement(); + return Factory.protoOf(self).asElement(); } pub fn asNode(self: *Anchor) *Node { return self.asElement().asNode(); diff --git a/src/browser/webapi/element/html/Area.zig b/src/browser/webapi/element/html/Area.zig index 81f73ea0b..ba79696af 100644 --- a/src/browser/webapi/element/html/Area.zig +++ b/src/browser/webapi/element/html/Area.zig @@ -13,8 +13,10 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +const lp = @import("lightpanda"); const std = @import("std"); const js = @import("../../../js/js.zig"); +const Factory = @import("../../../Factory.zig"); const Frame = @import("../../../Frame.zig"); const URL = @import("../../../URL.zig"); @@ -28,13 +30,14 @@ const Area = @This(); pub const Proto = HtmlElement; -_proto: *HtmlElement, +_pad: bool = false, +_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined, pub fn asElement(self: *Area) *Element { - return self._proto.asElement(); + return Factory.protoOf(self).asElement(); } pub fn asConstElement(self: *const Area) *const Element { - return self._proto.asElement(); + return Factory.protoOf(self).asElement(); } pub fn asNode(self: *Area) *Node { return self.asElement().asNode(); diff --git a/src/browser/webapi/element/html/Audio.zig b/src/browser/webapi/element/html/Audio.zig index 2c9f807c3..2cf152a08 100644 --- a/src/browser/webapi/element/html/Audio.zig +++ b/src/browser/webapi/element/html/Audio.zig @@ -19,6 +19,7 @@ const lp = @import("lightpanda"); const js = @import("../../../js/js.zig"); +const Factory = @import("../../../Factory.zig"); const Frame = @import("../../../Frame.zig"); const Node = @import("../../Node.zig"); @@ -31,7 +32,8 @@ const Audio = @This(); pub const Proto = Media; -_proto: *Media, +_pad: bool = false, +_proto_canary: if (lp.IS_DEBUG) *Media else void = undefined, pub fn constructor(maybe_url: ?String, frame: *Frame) !*Media { const node = try Frame.node_factory.createElementNS(frame, .html, "audio", null); @@ -48,11 +50,11 @@ pub fn constructor(maybe_url: ?String, frame: *Frame) !*Media { } pub fn asMedia(self: *Audio) *Media { - return self._proto; + return Factory.protoOf(self); } pub fn asElement(self: *Audio) *Element { - return self._proto.asElement(); + return Factory.protoOf(self).asElement(); } pub fn asNode(self: *Audio) *Node { diff --git a/src/browser/webapi/element/html/BR.zig b/src/browser/webapi/element/html/BR.zig index f101d0077..2f9c67824 100644 --- a/src/browser/webapi/element/html/BR.zig +++ b/src/browser/webapi/element/html/BR.zig @@ -1,3 +1,5 @@ +const lp = @import("lightpanda"); +const Factory = @import("../../../Factory.zig"); const js = @import("../../../js/js.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); @@ -7,10 +9,11 @@ const BR = @This(); pub const Proto = HtmlElement; -_proto: *HtmlElement, +_pad: bool = false, +_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined, pub fn asElement(self: *BR) *Element { - return self._proto.asElement(); + return Factory.protoOf(self).asElement(); } pub fn asNode(self: *BR) *Node { return self.asElement().asNode(); diff --git a/src/browser/webapi/element/html/Base.zig b/src/browser/webapi/element/html/Base.zig index e22bfd0fe..0e157c3e6 100644 --- a/src/browser/webapi/element/html/Base.zig +++ b/src/browser/webapi/element/html/Base.zig @@ -1,3 +1,5 @@ +const lp = @import("lightpanda"); +const Factory = @import("../../../Factory.zig"); const js = @import("../../../js/js.zig"); const URL = @import("../../../URL.zig"); const Frame = @import("../../../Frame.zig"); @@ -10,10 +12,11 @@ const Base = @This(); pub const Proto = HtmlElement; -_proto: *HtmlElement, +_pad: bool = false, +_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined, pub fn asElement(self: *Base) *Element { - return self._proto.asElement(); + return Factory.protoOf(self).asElement(); } pub fn asNode(self: *Base) *Node { return self.asElement().asNode(); diff --git a/src/browser/webapi/element/html/Body.zig b/src/browser/webapi/element/html/Body.zig index d4914fc76..5602f3cc0 100644 --- a/src/browser/webapi/element/html/Body.zig +++ b/src/browser/webapi/element/html/Body.zig @@ -18,6 +18,7 @@ const lp = @import("lightpanda"); const js = @import("../../../js/js.zig"); +const Factory = @import("../../../Factory.zig"); const Frame = @import("../../../Frame.zig"); const Node = @import("../../Node.zig"); @@ -31,10 +32,11 @@ const Body = @This(); pub const Proto = HtmlElement; -_proto: *HtmlElement, +_pad: bool = false, +_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined, pub fn asElement(self: *Body) *Element { - return self._proto.asElement(); + return Factory.protoOf(self).asElement(); } pub fn asNode(self: *Body) *Node { return self.asElement().asNode(); diff --git a/src/browser/webapi/element/html/Button.zig b/src/browser/webapi/element/html/Button.zig index 823cdf13b..0ee5acf83 100644 --- a/src/browser/webapi/element/html/Button.zig +++ b/src/browser/webapi/element/html/Button.zig @@ -16,9 +16,11 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +const lp = @import("lightpanda"); const std = @import("std"); const js = @import("../../../js/js.zig"); +const Factory = @import("../../../Factory.zig"); const Frame = @import("../../../Frame.zig"); const Node = @import("../../Node.zig"); @@ -34,16 +36,16 @@ const Button = @This(); pub const Proto = HtmlElement; -_proto: *HtmlElement, +_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined, _custom_validity: ?[]const u8 = null, _validity: ?*ValidityState = null, _popover_target: ?*Element = null, pub fn asElement(self: *Button) *Element { - return self._proto.asElement(); + return Factory.protoOf(self).asElement(); } pub fn asConstElement(self: *const Button) *const Element { - return self._proto.asElement(); + return Factory.protoOf(self).asElement(); } pub fn asNode(self: *Button) *Node { return self.asElement().asNode(); diff --git a/src/browser/webapi/element/html/Canvas.zig b/src/browser/webapi/element/html/Canvas.zig index 9c3ef96ed..e9c301a15 100644 --- a/src/browser/webapi/element/html/Canvas.zig +++ b/src/browser/webapi/element/html/Canvas.zig @@ -16,8 +16,10 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +const lp = @import("lightpanda"); const std = @import("std"); const js = @import("../../../js/js.zig"); +const Factory = @import("../../../Factory.zig"); const Frame = @import("../../../Frame.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); @@ -32,14 +34,14 @@ const Execution = js.Execution; const Canvas = @This(); pub const Proto = HtmlElement; -_proto: *HtmlElement, +_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined, _cached: ?DrawingContext = null, pub fn asElement(self: *Canvas) *Element { - return self._proto.asElement(); + return Factory.protoOf(self).asElement(); } pub fn asConstElement(self: *const Canvas) *const Element { - return self._proto.asElement(); + return Factory.protoOf(self).asElement(); } pub fn asNode(self: *Canvas) *Node { return self.asElement().asNode(); diff --git a/src/browser/webapi/element/html/Custom.zig b/src/browser/webapi/element/html/Custom.zig index 3cccc27c6..153657bef 100644 --- a/src/browser/webapi/element/html/Custom.zig +++ b/src/browser/webapi/element/html/Custom.zig @@ -20,6 +20,7 @@ const std = @import("std"); const lp = @import("lightpanda"); const js = @import("../../../js/js.zig"); +const Factory = @import("../../../Factory.zig"); const Frame = @import("../../../Frame.zig"); const Node = @import("../../Node.zig"); @@ -35,14 +36,15 @@ const String = lp.String; const Custom = @This(); pub const Proto = HtmlElement; -_proto: *HtmlElement, +_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined, _tag_name: String, _definition: ?*CustomElementDefinition, _connected_callback_invoked: bool = false, _disconnected_callback_invoked: bool = false, +_upgrade_failed: bool = false, // a failed upgrade is never retried pub fn asElement(self: *Custom) *Element { - return self._proto.asElement(); + return Factory.protoOf(self).asElement(); } pub fn asNode(self: *Custom) *Node { return self.asElement().asNode(); @@ -61,6 +63,9 @@ pub fn enqueueConnectedCallbackOnElement(comptime from_parser: bool, element: *E if (element.is(Custom)) |custom| { // Upgrade if a definition exists but isn't yet attached if (custom._definition == null) { + if (custom._upgrade_failed) { + return; + } const name = custom._tag_name.str(); if (frame.window._custom_elements._definitions.get(name)) |definition| { const CustomElementRegistry = @import("../../CustomElementRegistry.zig"); @@ -256,29 +261,35 @@ pub fn checkAndAttachBuiltIn(element: *Element, frame: *Frame) !void { // Invoke constructor const prev_upgrading = frame._upgrading_element; + const prev_consumed = frame._upgrading_consumed; const node = element.asNode(); frame._upgrading_element = node; - defer frame._upgrading_element = prev_upgrading; + frame._upgrading_consumed = false; + defer { + frame._upgrading_element = prev_upgrading; + frame._upgrading_consumed = prev_consumed; + } // PERFORMANCE OPTIMIZATION: This pattern is discouraged in general code. // Used here because: (1) multiple early returns before needing Local, // (2) called from both V8 callbacks (Local exists) and parser (no Local). // Prefer either: requiring *const js.Local parameter, OR always creating // Local.Scope upfront. - var ls: ?js.Local.Scope = null; - var local = blk: { + var ls: js.Local.Scope = undefined; + var ls_open = false; + const local = blk: { if (frame.js.local) |l| { break :blk l; } - ls = undefined; - frame.js.localScope(&ls.?); - break :blk &ls.?.local; + frame.js.localScope(&ls); + ls_open = true; + break :blk &ls.local; }; - defer if (ls) |*_ls| { - _ls.deinit(); + defer if (ls_open) { + ls.deinit(); }; - var caught: js.TryCatch.Caught = undefined; + var caught: js.TryCatch.Caught = .{}; _ = local.toLocal(definition.constructor).newInstance(&caught) catch |err| { log.warn(.js, "custom builtin ctor", .{ .name = is_value, .err = err, .caught = caught }); return; diff --git a/src/browser/webapi/element/html/DList.zig b/src/browser/webapi/element/html/DList.zig index 353c0120e..0a22e0396 100644 --- a/src/browser/webapi/element/html/DList.zig +++ b/src/browser/webapi/element/html/DList.zig @@ -16,6 +16,8 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +const lp = @import("lightpanda"); +const Factory = @import("../../../Factory.zig"); const js = @import("../../../js/js.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); @@ -24,10 +26,11 @@ const HtmlElement = @import("../Html.zig"); const DList = @This(); pub const Proto = HtmlElement; -_proto: *HtmlElement, +_pad: bool = false, +_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined, pub fn asElement(self: *DList) *Element { - return self._proto.asElement(); + return Factory.protoOf(self).asElement(); } pub fn asNode(self: *DList) *Node { return self.asElement().asNode(); diff --git a/src/browser/webapi/element/html/Data.zig b/src/browser/webapi/element/html/Data.zig index d4bc50d54..baeb5b41c 100644 --- a/src/browser/webapi/element/html/Data.zig +++ b/src/browser/webapi/element/html/Data.zig @@ -16,6 +16,8 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +const lp = @import("lightpanda"); +const Factory = @import("../../../Factory.zig"); const js = @import("../../../js/js.zig"); const Frame = @import("../../../Frame.zig"); @@ -27,10 +29,11 @@ const Data = @This(); pub const Proto = HtmlElement; -_proto: *HtmlElement, +_pad: bool = false, +_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined, pub fn asElement(self: *Data) *Element { - return self._proto.asElement(); + return Factory.protoOf(self).asElement(); } pub fn asNode(self: *Data) *Node { diff --git a/src/browser/webapi/element/html/DataList.zig b/src/browser/webapi/element/html/DataList.zig index 93ec1a406..ef85c9fe4 100644 --- a/src/browser/webapi/element/html/DataList.zig +++ b/src/browser/webapi/element/html/DataList.zig @@ -1,3 +1,5 @@ +const lp = @import("lightpanda"); +const Factory = @import("../../../Factory.zig"); const js = @import("../../../js/js.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); @@ -7,10 +9,11 @@ const DataList = @This(); pub const Proto = HtmlElement; -_proto: *HtmlElement, +_pad: bool = false, +_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined, pub fn asElement(self: *DataList) *Element { - return self._proto.asElement(); + return Factory.protoOf(self).asElement(); } pub fn asNode(self: *DataList) *Node { return self.asElement().asNode(); diff --git a/src/browser/webapi/element/html/Details.zig b/src/browser/webapi/element/html/Details.zig index 30c4134be..ed077be58 100644 --- a/src/browser/webapi/element/html/Details.zig +++ b/src/browser/webapi/element/html/Details.zig @@ -1,3 +1,5 @@ +const lp = @import("lightpanda"); +const Factory = @import("../../../Factory.zig"); const js = @import("../../../js/js.zig"); const Frame = @import("../../../Frame.zig"); @@ -9,13 +11,14 @@ const Details = @This(); pub const Proto = HtmlElement; -_proto: *HtmlElement, +_pad: bool = false, +_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined, pub fn asElement(self: *Details) *Element { - return self._proto.asElement(); + return Factory.protoOf(self).asElement(); } pub fn asConstElement(self: *const Details) *const Element { - return self._proto.asElement(); + return Factory.protoOf(self).asElement(); } pub fn asNode(self: *Details) *Node { return self.asElement().asNode(); diff --git a/src/browser/webapi/element/html/Dialog.zig b/src/browser/webapi/element/html/Dialog.zig index 5765f51a4..805c37b2d 100644 --- a/src/browser/webapi/element/html/Dialog.zig +++ b/src/browser/webapi/element/html/Dialog.zig @@ -1,3 +1,5 @@ +const lp = @import("lightpanda"); +const Factory = @import("../../../Factory.zig"); const js = @import("../../../js/js.zig"); const Frame = @import("../../../Frame.zig"); @@ -10,13 +12,14 @@ const Dialog = @This(); pub const Proto = HtmlElement; -_proto: *HtmlElement, +_pad: bool = false, +_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined, pub fn asElement(self: *Dialog) *Element { - return self._proto.asElement(); + return Factory.protoOf(self).asElement(); } pub fn asConstElement(self: *const Dialog) *const Element { - return self._proto.asElement(); + return Factory.protoOf(self).asElement(); } pub fn asNode(self: *Dialog) *Node { return self.asElement().asNode(); diff --git a/src/browser/webapi/element/html/Directory.zig b/src/browser/webapi/element/html/Directory.zig index 1c6d8ff00..bf5bb248e 100644 --- a/src/browser/webapi/element/html/Directory.zig +++ b/src/browser/webapi/element/html/Directory.zig @@ -1,3 +1,5 @@ +const lp = @import("lightpanda"); +const Factory = @import("../../../Factory.zig"); const js = @import("../../../js/js.zig"); const Frame = @import("../../../Frame.zig"); const Node = @import("../../Node.zig"); @@ -8,10 +10,11 @@ const Directory = @This(); pub const Proto = HtmlElement; -_proto: *HtmlElement, +_pad: bool = false, +_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined, pub fn asElement(self: *Directory) *Element { - return self._proto.asElement(); + return Factory.protoOf(self).asElement(); } pub fn asNode(self: *Directory) *Node { return self.asElement().asNode(); diff --git a/src/browser/webapi/element/html/Div.zig b/src/browser/webapi/element/html/Div.zig index 6a7edb125..88fa70476 100644 --- a/src/browser/webapi/element/html/Div.zig +++ b/src/browser/webapi/element/html/Div.zig @@ -16,6 +16,8 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +const lp = @import("lightpanda"); +const Factory = @import("../../../Factory.zig"); const js = @import("../../../js/js.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); @@ -24,10 +26,11 @@ const HtmlElement = @import("../Html.zig"); const Div = @This(); pub const Proto = HtmlElement; -_proto: *HtmlElement, +_pad: bool = false, +_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined, pub fn asElement(self: *Div) *Element { - return self._proto.asElement(); + return Factory.protoOf(self).asElement(); } pub fn asNode(self: *Div) *Node { return self.asElement().asNode(); diff --git a/src/browser/webapi/element/html/Embed.zig b/src/browser/webapi/element/html/Embed.zig index b66e94f74..1ee55547f 100644 --- a/src/browser/webapi/element/html/Embed.zig +++ b/src/browser/webapi/element/html/Embed.zig @@ -16,6 +16,8 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +const lp = @import("lightpanda"); +const Factory = @import("../../../Factory.zig"); const js = @import("../../../js/js.zig"); const Frame = @import("../../../Frame.zig"); const Node = @import("../../Node.zig"); @@ -25,13 +27,14 @@ const HtmlElement = @import("../Html.zig"); const Embed = @This(); pub const Proto = HtmlElement; -_proto: *HtmlElement, +_pad: bool = false, +_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined, pub fn asElement(self: *Embed) *Element { - return self._proto.asElement(); + return Factory.protoOf(self).asElement(); } pub fn asConstElement(self: *const Embed) *const Element { - return self._proto.asElement(); + return Factory.protoOf(self).asElement(); } pub fn asNode(self: *Embed) *Node { return self.asElement().asNode(); diff --git a/src/browser/webapi/element/html/FieldSet.zig b/src/browser/webapi/element/html/FieldSet.zig index 717e115af..00e8e1f92 100644 --- a/src/browser/webapi/element/html/FieldSet.zig +++ b/src/browser/webapi/element/html/FieldSet.zig @@ -1,3 +1,5 @@ +const lp = @import("lightpanda"); +const Factory = @import("../../../Factory.zig"); const js = @import("../../../js/js.zig"); const Frame = @import("../../../Frame.zig"); const Node = @import("../../Node.zig"); @@ -8,10 +10,11 @@ const FieldSet = @This(); pub const Proto = HtmlElement; -_proto: *HtmlElement, +_pad: bool = false, +_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined, pub fn asElement(self: *FieldSet) *Element { - return self._proto.asElement(); + return Factory.protoOf(self).asElement(); } pub fn asNode(self: *FieldSet) *Node { return self.asElement().asNode(); diff --git a/src/browser/webapi/element/html/Font.zig b/src/browser/webapi/element/html/Font.zig index 0b198518c..9517d93a6 100644 --- a/src/browser/webapi/element/html/Font.zig +++ b/src/browser/webapi/element/html/Font.zig @@ -1,3 +1,5 @@ +const lp = @import("lightpanda"); +const Factory = @import("../../../Factory.zig"); const js = @import("../../../js/js.zig"); const Frame = @import("../../../Frame.zig"); const Node = @import("../../Node.zig"); @@ -8,10 +10,11 @@ const Font = @This(); pub const Proto = HtmlElement; -_proto: *HtmlElement, +_pad: bool = false, +_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined, pub fn asElement(self: *Font) *Element { - return self._proto.asElement(); + return Factory.protoOf(self).asElement(); } pub fn asNode(self: *Font) *Node { return self.asElement().asNode(); diff --git a/src/browser/webapi/element/html/Form.zig b/src/browser/webapi/element/html/Form.zig index 65c0872a0..ee970e536 100644 --- a/src/browser/webapi/element/html/Form.zig +++ b/src/browser/webapi/element/html/Form.zig @@ -16,8 +16,10 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +const lp = @import("lightpanda"); const std = @import("std"); const js = @import("../../../js/js.zig"); +const Factory = @import("../../../Factory.zig"); const Frame = @import("../../../Frame.zig"); const Node = @import("../../Node.zig"); @@ -33,7 +35,7 @@ pub const TextArea = @import("TextArea.zig"); const Form = @This(); pub const Proto = HtmlElement; -_proto: *HtmlElement, +_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined, // Prevents submission of the form while we're in the process of submitting // the form. You can imagine an onsubmit = () => form.submit() endless loop. @@ -44,13 +46,13 @@ _firing_submission_events: bool = false, _constructing_entry_list: bool = false, pub fn asHtmlElement(self: *Form) *HtmlElement { - return self._proto; + return Factory.protoOf(self); } fn asConstElement(self: *const Form) *const Element { - return self._proto.asElement(); + return Factory.protoOf(self).asElement(); } pub fn asElement(self: *Form) *Element { - return self._proto.asElement(); + return Factory.protoOf(self).asElement(); } pub fn asNode(self: *Form) *Node { return self.asElement().asNode(); diff --git a/src/browser/webapi/element/html/FrameSet.zig b/src/browser/webapi/element/html/FrameSet.zig index f218da810..404a34e96 100644 --- a/src/browser/webapi/element/html/FrameSet.zig +++ b/src/browser/webapi/element/html/FrameSet.zig @@ -1,6 +1,7 @@ const lp = @import("lightpanda"); const js = @import("../../../js/js.zig"); +const Factory = @import("../../../Factory.zig"); const Frame = @import("../../../Frame.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); @@ -13,10 +14,11 @@ const FrameSet = @This(); pub const Proto = HtmlElement; -_proto: *HtmlElement, +_pad: bool = false, +_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined, pub fn asElement(self: *FrameSet) *Element { - return self._proto.asElement(); + return Factory.protoOf(self).asElement(); } pub fn asNode(self: *FrameSet) *Node { return self.asElement().asNode(); diff --git a/src/browser/webapi/element/html/Generic.zig b/src/browser/webapi/element/html/Generic.zig index a5b2ec26c..a4c9d3e6e 100644 --- a/src/browser/webapi/element/html/Generic.zig +++ b/src/browser/webapi/element/html/Generic.zig @@ -19,6 +19,7 @@ const lp = @import("lightpanda"); const js = @import("../../../js/js.zig"); +const Factory = @import("../../../Factory.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); @@ -31,10 +32,10 @@ const Generic = @This(); pub const Proto = HtmlElement; _tag_name: String, _tag: Element.Tag, -_proto: *HtmlElement, +_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined, pub fn asElement(self: *Generic) *Element { - return self._proto.asElement(); + return Factory.protoOf(self).asElement(); } pub fn asNode(self: *Generic) *Node { return self.asElement().asNode(); diff --git a/src/browser/webapi/element/html/HR.zig b/src/browser/webapi/element/html/HR.zig index ad98db6ed..fa3573c92 100644 --- a/src/browser/webapi/element/html/HR.zig +++ b/src/browser/webapi/element/html/HR.zig @@ -16,6 +16,8 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +const lp = @import("lightpanda"); +const Factory = @import("../../../Factory.zig"); const js = @import("../../../js/js.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); @@ -24,10 +26,11 @@ const HtmlElement = @import("../Html.zig"); const HR = @This(); pub const Proto = HtmlElement; -_proto: *HtmlElement, +_pad: bool = false, +_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined, pub fn asElement(self: *HR) *Element { - return self._proto.asElement(); + return Factory.protoOf(self).asElement(); } pub fn asNode(self: *HR) *Node { return self.asElement().asNode(); diff --git a/src/browser/webapi/element/html/Head.zig b/src/browser/webapi/element/html/Head.zig index c69e843b5..bb17950c3 100644 --- a/src/browser/webapi/element/html/Head.zig +++ b/src/browser/webapi/element/html/Head.zig @@ -16,6 +16,8 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +const lp = @import("lightpanda"); +const Factory = @import("../../../Factory.zig"); const js = @import("../../../js/js.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); @@ -24,10 +26,11 @@ const HtmlElement = @import("../Html.zig"); const Head = @This(); pub const Proto = HtmlElement; -_proto: *HtmlElement, +_pad: bool = false, +_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined, pub fn asElement(self: *Head) *Element { - return self._proto.asElement(); + return Factory.protoOf(self).asElement(); } pub fn asNode(self: *Head) *Node { return self.asElement().asNode(); diff --git a/src/browser/webapi/element/html/Heading.zig b/src/browser/webapi/element/html/Heading.zig index 5711b2ccf..dac7d5378 100644 --- a/src/browser/webapi/element/html/Heading.zig +++ b/src/browser/webapi/element/html/Heading.zig @@ -19,6 +19,7 @@ const lp = @import("lightpanda"); const js = @import("../../../js/js.zig"); +const Factory = @import("../../../Factory.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); @@ -29,12 +30,12 @@ const String = lp.String; const Heading = @This(); pub const Proto = HtmlElement; -_proto: *HtmlElement, +_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined, _tag_name: String, _tag: Element.Tag, pub fn asElement(self: *Heading) *Element { - return self._proto.asElement(); + return Factory.protoOf(self).asElement(); } pub fn asNode(self: *Heading) *Node { return self.asElement().asNode(); diff --git a/src/browser/webapi/element/html/Html.zig b/src/browser/webapi/element/html/Html.zig index 0441baab2..f8de33c0b 100644 --- a/src/browser/webapi/element/html/Html.zig +++ b/src/browser/webapi/element/html/Html.zig @@ -16,6 +16,8 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +const lp = @import("lightpanda"); +const Factory = @import("../../../Factory.zig"); const js = @import("../../../js/js.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); @@ -24,10 +26,11 @@ const HtmlElement = @import("../Html.zig"); const Html = @This(); pub const Proto = HtmlElement; -_proto: *HtmlElement, +_pad: bool = false, +_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined, pub fn asElement(self: *Html) *Element { - return self._proto.asElement(); + return Factory.protoOf(self).asElement(); } pub fn asNode(self: *Html) *Node { return self.asElement().asNode(); diff --git a/src/browser/webapi/element/html/IFrame.zig b/src/browser/webapi/element/html/IFrame.zig index 21d0503b4..4dc882b4d 100644 --- a/src/browser/webapi/element/html/IFrame.zig +++ b/src/browser/webapi/element/html/IFrame.zig @@ -16,9 +16,11 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +const lp = @import("lightpanda"); const std = @import("std"); const js = @import("../../../js/js.zig"); +const Factory = @import("../../../Factory.zig"); const Frame = @import("../../../Frame.zig"); const Node = @import("../../Node.zig"); @@ -29,16 +31,17 @@ const DOMTokenList = @import("../../collections.zig").DOMTokenList; const HtmlElement = @import("../Html.zig"); +const String = lp.String; const IFrame = @This(); pub const Proto = HtmlElement; -_proto: *HtmlElement, +_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined, _src: []const u8 = "", _executed: bool = false, _window: ?*Window = null, pub fn asElement(self: *IFrame) *Element { - return self._proto.asElement(); + return Factory.protoOf(self).asElement(); } pub fn asNode(self: *IFrame) *Node { return self.asElement().asNode(); @@ -77,6 +80,19 @@ pub fn setSrc(self: *IFrame, src: []const u8, frame: *Frame) !void { } } +pub fn hasSrcdoc(self: *IFrame) bool { + return self.asElement().getAttributeSafe(comptime .wrap("srcdoc")) != null; +} + +pub fn getSrcdoc(self: *IFrame) []const u8 { + return self.asElement().getAttributeSafe(comptime .wrap("srcdoc")) orelse ""; +} + +pub fn setSrcdoc(self: *IFrame, value: []const u8, frame: *Frame) !void { + // Build.attributeChange triggers the (re)navigation. + try self.asElement().setAttributeSafe(comptime .wrap("srcdoc"), .wrap(value), frame); +} + pub fn getName(self: *IFrame) []const u8 { return self.asElement().getAttributeSafe(comptime .wrap("name")) orelse ""; } @@ -103,6 +119,7 @@ pub const JsApi = struct { }; pub const src = bridge.accessor(IFrame.getSrc, IFrame.setSrc, .{ .ce_reactions = true }); + pub const srcdoc = bridge.accessor(IFrame.getSrcdoc, IFrame.setSrcdoc, .{ .ce_reactions = true }); pub const name = bridge.accessor(IFrame.getName, IFrame.setName, .{ .ce_reactions = true }); pub const contentWindow = bridge.accessor(IFrame.getContentWindow, null, .{}); pub const contentDocument = bridge.accessor(IFrame.getContentDocument, null, .{}); @@ -115,4 +132,28 @@ pub const Build = struct { const element = self.asElement(); self._src = element.getAttributeSafe(comptime .wrap("src")) orelse ""; } + + pub fn attributeChange(element: *Element, name: String, _: String, frame: *Frame) !void { + if (!name.eql(comptime .wrap("srcdoc"))) { + return; + } + if (element.asNode().isConnected()) { + // like src, setting srcdoc reloads the frame even if the value didn't change + const self = element.as(IFrame); + self._executed = false; + try frame.iframeAddedCallback(self); + } + } + + pub fn attributeRemove(element: *Element, name: String, frame: *Frame) !void { + if (!name.eql(comptime .wrap("srcdoc"))) { + return; + } + if (element.asNode().isConnected()) { + const self = element.as(IFrame); + // removing srcdoc falls back to src (or about:blank) + self._executed = false; + try frame.iframeAddedCallback(self); + } + } }; diff --git a/src/browser/webapi/element/html/Image.zig b/src/browser/webapi/element/html/Image.zig index e75e7f9df..62608385b 100644 --- a/src/browser/webapi/element/html/Image.zig +++ b/src/browser/webapi/element/html/Image.zig @@ -1,5 +1,7 @@ +const lp = @import("lightpanda"); const std = @import("std"); const js = @import("../../../js/js.zig"); +const Factory = @import("../../../Factory.zig"); const Frame = @import("../../../Frame.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); @@ -8,7 +10,8 @@ const HtmlElement = @import("../Html.zig"); const Image = @This(); pub const Proto = HtmlElement; -_proto: *HtmlElement, +_pad: bool = false, +_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined, pub fn constructor(w_: ?u32, h_: ?u32, frame: *Frame) !*Image { const node = try Frame.node_factory.createElementNS(frame, .html, "img", null); @@ -26,10 +29,10 @@ pub fn constructor(w_: ?u32, h_: ?u32, frame: *Frame) !*Image { } pub fn asElement(self: *Image) *Element { - return self._proto.asElement(); + return Factory.protoOf(self).asElement(); } pub fn asConstElement(self: *const Image) *const Element { - return self._proto.asElement(); + return Factory.protoOf(self).asElement(); } pub fn asNode(self: *Image) *Node { return self.asElement().asNode(); @@ -130,7 +133,7 @@ pub fn imageAddedCallback(self: *Image, frame: *Frame) !void { const src = element.getAttributeSafe(comptime .wrap("src")) orelse return; if (src.len == 0) return; - try frame.queueLoad(self._proto); + try frame.queueLoad(Factory.protoOf(self)); } pub const JsApi = struct { diff --git a/src/browser/webapi/element/html/Input.zig b/src/browser/webapi/element/html/Input.zig index 0eb523419..c823b3843 100644 --- a/src/browser/webapi/element/html/Input.zig +++ b/src/browser/webapi/element/html/Input.zig @@ -20,6 +20,7 @@ const std = @import("std"); const lp = @import("lightpanda"); const js = @import("../../../js/js.zig"); +const Factory = @import("../../../Factory.zig"); const Frame = @import("../../../Frame.zig"); const Node = @import("../../Node.zig"); @@ -80,7 +81,7 @@ pub const Type = enum { } }; -_proto: *HtmlElement, +_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined, _default_value: ?[]const u8 = null, _default_checked: bool = false, _value: ?[]const u8 = null, @@ -122,10 +123,10 @@ fn dispatchInputEvent(self: *Input, data: ?[]const u8, input_type: []const u8, f } pub fn asElement(self: *Input) *Element { - return self._proto.asElement(); + return Factory.protoOf(self).asElement(); } pub fn asConstElement(self: *const Input) *const Element { - return self._proto.asElement(); + return Factory.protoOf(self).asElement(); } pub fn asNode(self: *Input) *Node { return self.asElement().asNode(); @@ -891,6 +892,20 @@ pub fn getLabels(self: *Input, frame: *Frame) !js.Array { return @import("Label.zig").getControlLabels(self.asElement(), frame); } +pub fn getList(self: *Input, frame: *Frame) ?*HtmlElement.DataList { + switch (self._input_type) { + .hidden, .password, .checkbox, .radio, .file, .submit, .image, .reset, .button => return null, + else => {}, + } + + const element = self.asElement(); + const list_id = element.getAttributeSafe(comptime .wrap("list")) orelse return null; + + // list= resolves in the input's own tree (shadow root or document). + const target = frame.getElementByIdFromNode(element.asNode(), list_id) orelse return null; + return target.is(HtmlElement.DataList); +} + pub fn getForm(self: *Input, frame: *Frame) ?*Form { const element = self.asElement(); @@ -1426,6 +1441,7 @@ pub const JsApi = struct { pub const size = bridge.accessor(Input.getSize, Input.setSize, .{ .ce_reactions = true }); pub const src = bridge.accessor(Input.getSrc, Input.setSrc, .{ .ce_reactions = true }); pub const form = bridge.accessor(Input.getForm, null, .{}); + pub const list = bridge.accessor(Input.getList, null, .{}); pub const formAction = bridge.accessor(Input.getFormAction, Input.setFormAction, .{}); pub const formEnctype = bridge.accessor(Input.getFormEnctype, Input.setFormEnctype, .{}); pub const formMethod = bridge.accessor(Input.getFormMethod, Input.setFormMethod, .{}); diff --git a/src/browser/webapi/element/html/LI.zig b/src/browser/webapi/element/html/LI.zig index 9caa1907f..a209ff33f 100644 --- a/src/browser/webapi/element/html/LI.zig +++ b/src/browser/webapi/element/html/LI.zig @@ -16,8 +16,10 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +const lp = @import("lightpanda"); const std = @import("std"); const js = @import("../../../js/js.zig"); +const Factory = @import("../../../Factory.zig"); const Frame = @import("../../../Frame.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); @@ -26,10 +28,11 @@ const HtmlElement = @import("../Html.zig"); const LI = @This(); pub const Proto = HtmlElement; -_proto: *HtmlElement, +_pad: bool = false, +_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined, pub fn asElement(self: *LI) *Element { - return self._proto.asElement(); + return Factory.protoOf(self).asElement(); } pub fn asNode(self: *LI) *Node { return self.asElement().asNode(); diff --git a/src/browser/webapi/element/html/Label.zig b/src/browser/webapi/element/html/Label.zig index 7616c0e2c..2285e229c 100644 --- a/src/browser/webapi/element/html/Label.zig +++ b/src/browser/webapi/element/html/Label.zig @@ -1,5 +1,7 @@ +const lp = @import("lightpanda"); const std = @import("std"); const js = @import("../../../js/js.zig"); +const Factory = @import("../../../Factory.zig"); const Frame = @import("../../../Frame.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); @@ -10,10 +12,11 @@ const Label = @This(); pub const Proto = HtmlElement; -_proto: *HtmlElement, +_pad: bool = false, +_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined, pub fn asElement(self: *Label) *Element { - return self._proto.asElement(); + return Factory.protoOf(self).asElement(); } pub fn asNode(self: *Label) *Node { return self.asElement().asNode(); @@ -49,7 +52,7 @@ fn isLabelable(el: *Element) bool { const html = el.is(HtmlElement) orelse return false; return switch (html._type) { .button, .meter, .output, .progress, .select, .textarea => true, - .input => |input| input._input_type != .hidden, + .input => html.subtype(HtmlElement.Input)._input_type != .hidden, else => false, }; } diff --git a/src/browser/webapi/element/html/Legend.zig b/src/browser/webapi/element/html/Legend.zig index 27d8a1fa4..0c1661298 100644 --- a/src/browser/webapi/element/html/Legend.zig +++ b/src/browser/webapi/element/html/Legend.zig @@ -1,3 +1,5 @@ +const lp = @import("lightpanda"); +const Factory = @import("../../../Factory.zig"); const js = @import("../../../js/js.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); @@ -7,10 +9,11 @@ const Legend = @This(); pub const Proto = HtmlElement; -_proto: *HtmlElement, +_pad: bool = false, +_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined, pub fn asElement(self: *Legend) *Element { - return self._proto.asElement(); + return Factory.protoOf(self).asElement(); } pub fn asNode(self: *Legend) *Node { return self.asElement().asNode(); diff --git a/src/browser/webapi/element/html/Link.zig b/src/browser/webapi/element/html/Link.zig index af33fcf11..8c29f36f5 100644 --- a/src/browser/webapi/element/html/Link.zig +++ b/src/browser/webapi/element/html/Link.zig @@ -16,8 +16,10 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +const lp = @import("lightpanda"); const std = @import("std"); const js = @import("../../../js/js.zig"); +const Factory = @import("../../../Factory.zig"); const Frame = @import("../../../Frame.zig"); const Node = @import("../../Node.zig"); @@ -29,7 +31,7 @@ const HtmlElement = @import("../Html.zig"); const Link = @This(); pub const Proto = HtmlElement; -_proto: *HtmlElement, +_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined, // Cached CSSStyleSheet for an external `rel=stylesheet` once // `Frame.loadExternalStylesheet` has registered it. Re-fetches (href // mutated on a connected link) reuse this sheet via `replaceSync` so the @@ -38,10 +40,10 @@ _proto: *HtmlElement, _sheet: ?*@import("../../css/CSSStyleSheet.zig") = null, pub fn asElement(self: *Link) *Element { - return self._proto.asElement(); + return Factory.protoOf(self).asElement(); } pub fn asConstElement(self: *const Link) *const Element { - return self._proto.asElement(); + return Factory.protoOf(self).asElement(); } pub fn asNode(self: *Link) *Node { return self.asElement().asNode(); @@ -232,26 +234,26 @@ pub fn linkAddedCallback(self: *Link, frame: *Frame) !void { if (std.mem.eql(u8, rel, "preload")) { const as = element.getAttributeSafe(comptime .wrap("as")) orelse ""; if (std.ascii.eqlIgnoreCase(as, "script")) { - if (Frame.preload.scriptHint(frame, self._proto, href)) { + if (Frame.preload.scriptHint(frame, Factory.protoOf(self), href)) { // load/error fires when the fetch settles return; } } // synthetic load, fires next tick - return frame.queueLoad(self._proto); + return frame.queueLoad(Factory.protoOf(self)); } if (std.mem.eql(u8, rel, "modulepreload")) { // "as" defaults to script in this case const as = element.getAttributeSafe(comptime .wrap("as")) orelse ""; if (as.len == 0 or std.ascii.eqlIgnoreCase(as, "script")) { - if (Frame.preload.moduleHint(frame, self._proto, href)) { + if (Frame.preload.moduleHint(frame, Factory.protoOf(self), href)) { // load/error fires when the fetch settles return; } } // synthetic load, fires next tick - return frame.queueLoad(self._proto); + return frame.queueLoad(Factory.protoOf(self)); } } diff --git a/src/browser/webapi/element/html/Map.zig b/src/browser/webapi/element/html/Map.zig index ee95dbe99..8864f892c 100644 --- a/src/browser/webapi/element/html/Map.zig +++ b/src/browser/webapi/element/html/Map.zig @@ -1,3 +1,5 @@ +const lp = @import("lightpanda"); +const Factory = @import("../../../Factory.zig"); const js = @import("../../../js/js.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); @@ -7,10 +9,11 @@ const Map = @This(); pub const Proto = HtmlElement; -_proto: *HtmlElement, +_pad: bool = false, +_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined, pub fn asElement(self: *Map) *Element { - return self._proto.asElement(); + return Factory.protoOf(self).asElement(); } pub fn asNode(self: *Map) *Node { return self.asElement().asNode(); diff --git a/src/browser/webapi/element/html/Marquee.zig b/src/browser/webapi/element/html/Marquee.zig index c9601176d..804ee34fa 100644 --- a/src/browser/webapi/element/html/Marquee.zig +++ b/src/browser/webapi/element/html/Marquee.zig @@ -1,6 +1,8 @@ +const lp = @import("lightpanda"); const std = @import("std"); const js = @import("../../../js/js.zig"); +const Factory = @import("../../../Factory.zig"); const Frame = @import("../../../Frame.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); @@ -10,10 +12,11 @@ const Marquee = @This(); pub const Proto = HtmlElement; -_proto: *HtmlElement, +_pad: bool = false, +_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined, pub fn asElement(self: *Marquee) *Element { - return self._proto.asElement(); + return Factory.protoOf(self).asElement(); } pub fn asNode(self: *Marquee) *Node { return self.asElement().asNode(); diff --git a/src/browser/webapi/element/html/Media.zig b/src/browser/webapi/element/html/Media.zig index 8e3af790c..823dcee38 100644 --- a/src/browser/webapi/element/html/Media.zig +++ b/src/browser/webapi/element/html/Media.zig @@ -49,12 +49,34 @@ pub const NetworkState = enum(u16) { NETWORK_NO_SOURCE = 3, }; -pub const Type = union(enum) { +pub const Type = enum(u8) { generic, - audio: *Audio, - video: *Video, + audio, + video, }; +// `.generic` maps to Media itself: a bare chain ends at Media, so the +// tag has no chain member of its own. +pub fn Subtype(comptime tag: Type) type { + return switch (tag) { + .generic => Media, + .audio => Audio, + .video => Video, + }; +} + +pub fn subtype(self: *const Media, comptime T: type) *T { + const offset = comptime Factory.chainOffsetOf(T, T) - Factory.chainOffsetOf(T, Media); + const sub: *T = @ptrFromInt(@intFromPtr(self) + offset); + if (comptime lp.IS_DEBUG) { + // This pointer dance only works because the factory allocates the chain + // in a contiguous block of memory. In debug, we assert this holds via + // the _proto_canary back pointer. + std.debug.assert(Factory.protoOf(sub) == self); + } + return sub; +} + _type: Type, _proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined, _paused: bool = true, @@ -77,18 +99,10 @@ pub fn asNode(self: *Media) *Node { } pub fn is(self: *Media, comptime T: type) ?*T { - const type_name = @typeName(T); switch (self._type) { - .audio => |a| { - if (T == *Audio) return a; - if (comptime std.mem.startsWith(u8, type_name, "browser.webapi.element.html.Audio")) { - return a; - } - }, - .video => |v| { - if (T == *Video) return v; - if (comptime std.mem.startsWith(u8, type_name, "browser.webapi.element.html.Video")) { - return v; + inline .audio, .video => |tag| { + if (Subtype(tag) == T) { + return self.subtype(T); } }, .generic => {}, diff --git a/src/browser/webapi/element/html/Meta.zig b/src/browser/webapi/element/html/Meta.zig index ea5baee1a..5ff50eff4 100644 --- a/src/browser/webapi/element/html/Meta.zig +++ b/src/browser/webapi/element/html/Meta.zig @@ -16,11 +16,16 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +const lp = @import("lightpanda"); +const std = @import("std"); + const js = @import("../../../js/js.zig"); +const Factory = @import("../../../Factory.zig"); const Frame = @import("../../../Frame.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); const HtmlElement = @import("../Html.zig"); +const referrer = @import("../../../referrer.zig"); const Meta = @This(); @@ -29,10 +34,11 @@ pub const Proto = HtmlElement; // Create a different alias we can use when in such ambiguous cases. const MetaElement = Meta; -_proto: *HtmlElement, +_pad: bool = false, +_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined, pub fn asElement(self: *Meta) *Element { - return self._proto.asElement(); + return Factory.protoOf(self).asElement(); } pub fn asNode(self: *Meta) *Node { return self.asElement().asNode(); @@ -78,6 +84,21 @@ pub fn setScheme(self: *Meta, value: []const u8, frame: *Frame) !void { try self.asElement().setAttributeSafe(comptime .wrap("scheme"), .wrap(value), frame); } +pub const Build = struct { + // sets the document's referrer policy. + pub fn created(node: *Node, frame: *Frame) !void { + const el = node.as(Element); + const name = el.getAttributeSafe(comptime .wrap("name")) orelse return; + if (std.ascii.eqlIgnoreCase(name, "referrer") == false) { + return; + } + const content = el.getAttributeSafe(comptime .wrap("content")) orelse return; + if (referrer.parseMeta(content)) |rp| { + frame.referrer_policy = rp; + } + } +}; + pub const JsApi = struct { pub const bridge = js.Bridge(MetaElement); diff --git a/src/browser/webapi/element/html/Meter.zig b/src/browser/webapi/element/html/Meter.zig index 922a60502..cae055854 100644 --- a/src/browser/webapi/element/html/Meter.zig +++ b/src/browser/webapi/element/html/Meter.zig @@ -1,3 +1,5 @@ +const lp = @import("lightpanda"); +const Factory = @import("../../../Factory.zig"); const js = @import("../../../js/js.zig"); const Frame = @import("../../../Frame.zig"); const Node = @import("../../Node.zig"); @@ -8,10 +10,11 @@ const Meter = @This(); pub const Proto = HtmlElement; -_proto: *HtmlElement, +_pad: bool = false, +_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined, pub fn asElement(self: *Meter) *Element { - return self._proto.asElement(); + return Factory.protoOf(self).asElement(); } pub fn asNode(self: *Meter) *Node { return self.asElement().asNode(); diff --git a/src/browser/webapi/element/html/Mod.zig b/src/browser/webapi/element/html/Mod.zig index ac6c02223..ef51d64c6 100644 --- a/src/browser/webapi/element/html/Mod.zig +++ b/src/browser/webapi/element/html/Mod.zig @@ -1,6 +1,7 @@ const lp = @import("lightpanda"); const js = @import("../../../js/js.zig"); +const Factory = @import("../../../Factory.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); const HtmlElement = @import("../Html.zig"); @@ -13,10 +14,10 @@ pub const Proto = HtmlElement; _tag_name: String, _tag: Element.Tag, -_proto: *HtmlElement, +_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined, pub fn asElement(self: *Mod) *Element { - return self._proto.asElement(); + return Factory.protoOf(self).asElement(); } pub fn asNode(self: *Mod) *Node { return self.asElement().asNode(); diff --git a/src/browser/webapi/element/html/OL.zig b/src/browser/webapi/element/html/OL.zig index d2c452157..aa44e3c42 100644 --- a/src/browser/webapi/element/html/OL.zig +++ b/src/browser/webapi/element/html/OL.zig @@ -16,8 +16,10 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +const lp = @import("lightpanda"); const std = @import("std"); const js = @import("../../../js/js.zig"); +const Factory = @import("../../../Factory.zig"); const Frame = @import("../../../Frame.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); @@ -26,10 +28,11 @@ const HtmlElement = @import("../Html.zig"); const OL = @This(); pub const Proto = HtmlElement; -_proto: *HtmlElement, +_pad: bool = false, +_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined, pub fn asElement(self: *OL) *Element { - return self._proto.asElement(); + return Factory.protoOf(self).asElement(); } pub fn asNode(self: *OL) *Node { return self.asElement().asNode(); diff --git a/src/browser/webapi/element/html/Object.zig b/src/browser/webapi/element/html/Object.zig index b4f1a1a7e..8ca2703c6 100644 --- a/src/browser/webapi/element/html/Object.zig +++ b/src/browser/webapi/element/html/Object.zig @@ -1,3 +1,5 @@ +const lp = @import("lightpanda"); +const Factory = @import("../../../Factory.zig"); const js = @import("../../../js/js.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); @@ -7,10 +9,11 @@ const Object = @This(); pub const Proto = HtmlElement; -_proto: *HtmlElement, +_pad: bool = false, +_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined, pub fn asElement(self: *Object) *Element { - return self._proto.asElement(); + return Factory.protoOf(self).asElement(); } pub fn asNode(self: *Object) *Node { return self.asElement().asNode(); diff --git a/src/browser/webapi/element/html/OptGroup.zig b/src/browser/webapi/element/html/OptGroup.zig index 82e7e6684..23d788ac5 100644 --- a/src/browser/webapi/element/html/OptGroup.zig +++ b/src/browser/webapi/element/html/OptGroup.zig @@ -1,3 +1,5 @@ +const lp = @import("lightpanda"); +const Factory = @import("../../../Factory.zig"); const js = @import("../../../js/js.zig"); const Frame = @import("../../../Frame.zig"); const Node = @import("../../Node.zig"); @@ -8,10 +10,11 @@ const OptGroup = @This(); pub const Proto = HtmlElement; -_proto: *HtmlElement, +_pad: bool = false, +_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined, pub fn asElement(self: *OptGroup) *Element { - return self._proto.asElement(); + return Factory.protoOf(self).asElement(); } pub fn asNode(self: *OptGroup) *Node { return self.asElement().asNode(); diff --git a/src/browser/webapi/element/html/Option.zig b/src/browser/webapi/element/html/Option.zig index 46d5c4ecb..7ec0d244a 100644 --- a/src/browser/webapi/element/html/Option.zig +++ b/src/browser/webapi/element/html/Option.zig @@ -20,6 +20,7 @@ const std = @import("std"); const lp = @import("lightpanda"); const js = @import("../../../js/js.zig"); +const Factory = @import("../../../Factory.zig"); const Frame = @import("../../../Frame.zig"); const Node = @import("../../Node.zig"); @@ -32,17 +33,17 @@ const Option = @This(); pub const Proto = HtmlElement; -_proto: *HtmlElement, +_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined, _value: ?[]const u8 = null, _selected: bool = false, _default_selected: bool = false, _disabled: bool = false, pub fn asElement(self: *Option) *Element { - return self._proto.asElement(); + return Factory.protoOf(self).asElement(); } pub fn asConstElement(self: *const Option) *const Element { - return self._proto.asElement(); + return Factory.protoOf(self).asElement(); } pub fn asNode(self: *Option) *Node { return self.asElement().asNode(); diff --git a/src/browser/webapi/element/html/Output.zig b/src/browser/webapi/element/html/Output.zig index 0fb343f41..11b2aa1e0 100644 --- a/src/browser/webapi/element/html/Output.zig +++ b/src/browser/webapi/element/html/Output.zig @@ -16,6 +16,8 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +const lp = @import("lightpanda"); +const Factory = @import("../../../Factory.zig"); const js = @import("../../../js/js.zig"); const Frame = @import("../../../Frame.zig"); @@ -29,10 +31,11 @@ const Output = @This(); pub const Proto = HtmlElement; -_proto: *HtmlElement, +_pad: bool = false, +_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined, pub fn asElement(self: *Output) *Element { - return self._proto.asElement(); + return Factory.protoOf(self).asElement(); } pub fn asNode(self: *Output) *Node { return self.asElement().asNode(); diff --git a/src/browser/webapi/element/html/Paragraph.zig b/src/browser/webapi/element/html/Paragraph.zig index 0714e1897..abf6faf65 100644 --- a/src/browser/webapi/element/html/Paragraph.zig +++ b/src/browser/webapi/element/html/Paragraph.zig @@ -16,6 +16,8 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +const lp = @import("lightpanda"); +const Factory = @import("../../../Factory.zig"); const js = @import("../../../js/js.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); @@ -24,10 +26,11 @@ const HtmlElement = @import("../Html.zig"); const Paragraph = @This(); pub const Proto = HtmlElement; -_proto: *HtmlElement, +_pad: bool = false, +_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined, pub fn asElement(self: *Paragraph) *Element { - return self._proto.asElement(); + return Factory.protoOf(self).asElement(); } pub fn asNode(self: *Paragraph) *Node { return self.asElement().asNode(); diff --git a/src/browser/webapi/element/html/Param.zig b/src/browser/webapi/element/html/Param.zig index 7eaa04155..8c1164091 100644 --- a/src/browser/webapi/element/html/Param.zig +++ b/src/browser/webapi/element/html/Param.zig @@ -1,3 +1,5 @@ +const lp = @import("lightpanda"); +const Factory = @import("../../../Factory.zig"); const js = @import("../../../js/js.zig"); const Frame = @import("../../../Frame.zig"); const Node = @import("../../Node.zig"); @@ -8,10 +10,11 @@ const Param = @This(); pub const Proto = HtmlElement; -_proto: *HtmlElement, +_pad: bool = false, +_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined, pub fn asElement(self: *Param) *Element { - return self._proto.asElement(); + return Factory.protoOf(self).asElement(); } pub fn asNode(self: *Param) *Node { return self.asElement().asNode(); diff --git a/src/browser/webapi/element/html/Picture.zig b/src/browser/webapi/element/html/Picture.zig index 5e50bbb61..dc69571e4 100644 --- a/src/browser/webapi/element/html/Picture.zig +++ b/src/browser/webapi/element/html/Picture.zig @@ -1,3 +1,5 @@ +const lp = @import("lightpanda"); +const Factory = @import("../../../Factory.zig"); const js = @import("../../../js/js.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); @@ -7,10 +9,11 @@ const Picture = @This(); pub const Proto = HtmlElement; -_proto: *HtmlElement, +_pad: bool = false, +_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined, pub fn asElement(self: *Picture) *Element { - return self._proto.asElement(); + return Factory.protoOf(self).asElement(); } pub fn asNode(self: *Picture) *Node { return self.asElement().asNode(); diff --git a/src/browser/webapi/element/html/Pre.zig b/src/browser/webapi/element/html/Pre.zig index 3ef89d15c..eaf797a6d 100644 --- a/src/browser/webapi/element/html/Pre.zig +++ b/src/browser/webapi/element/html/Pre.zig @@ -1,3 +1,5 @@ +const lp = @import("lightpanda"); +const Factory = @import("../../../Factory.zig"); const js = @import("../../../js/js.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); @@ -7,10 +9,11 @@ const Pre = @This(); pub const Proto = HtmlElement; -_proto: *HtmlElement, +_pad: bool = false, +_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined, pub fn asElement(self: *Pre) *Element { - return self._proto.asElement(); + return Factory.protoOf(self).asElement(); } pub fn asNode(self: *Pre) *Node { return self.asElement().asNode(); diff --git a/src/browser/webapi/element/html/Progress.zig b/src/browser/webapi/element/html/Progress.zig index d8a5f9acc..e0869d28f 100644 --- a/src/browser/webapi/element/html/Progress.zig +++ b/src/browser/webapi/element/html/Progress.zig @@ -1,3 +1,5 @@ +const lp = @import("lightpanda"); +const Factory = @import("../../../Factory.zig"); const js = @import("../../../js/js.zig"); const Frame = @import("../../../Frame.zig"); const Node = @import("../../Node.zig"); @@ -8,10 +10,11 @@ const Progress = @This(); pub const Proto = HtmlElement; -_proto: *HtmlElement, +_pad: bool = false, +_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined, pub fn asElement(self: *Progress) *Element { - return self._proto.asElement(); + return Factory.protoOf(self).asElement(); } pub fn asNode(self: *Progress) *Node { return self.asElement().asNode(); diff --git a/src/browser/webapi/element/html/Quote.zig b/src/browser/webapi/element/html/Quote.zig index f132096d1..ee9b4ca68 100644 --- a/src/browser/webapi/element/html/Quote.zig +++ b/src/browser/webapi/element/html/Quote.zig @@ -1,6 +1,7 @@ const lp = @import("lightpanda"); const js = @import("../../../js/js.zig"); +const Factory = @import("../../../Factory.zig"); const Frame = @import("../../../Frame.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); @@ -14,10 +15,10 @@ pub const Proto = HtmlElement; _tag_name: String, _tag: Element.Tag, -_proto: *HtmlElement, +_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined, pub fn asElement(self: *Quote) *Element { - return self._proto.asElement(); + return Factory.protoOf(self).asElement(); } pub fn asNode(self: *Quote) *Node { return self.asElement().asNode(); diff --git a/src/browser/webapi/element/html/Script.zig b/src/browser/webapi/element/html/Script.zig index c30c09399..57db5064a 100644 --- a/src/browser/webapi/element/html/Script.zig +++ b/src/browser/webapi/element/html/Script.zig @@ -19,6 +19,7 @@ const std = @import("std"); const lp = @import("lightpanda"); const js = @import("../../../js/js.zig"); +const Factory = @import("../../../Factory.zig"); const Frame = @import("../../../Frame.zig"); const Node = @import("../../Node.zig"); @@ -30,18 +31,18 @@ const Script = @This(); pub const Proto = HtmlElement; -_proto: *HtmlElement, +_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined, _src: []const u8 = "", _executed: bool = false, // dynamic scripts are forced to be async by default _force_async: bool = true, pub fn asElement(self: *Script) *Element { - return self._proto.asElement(); + return Factory.protoOf(self).asElement(); } pub fn asConstElement(self: *const Script) *const Element { - return self._proto.asElement(); + return Factory.protoOf(self).asElement(); } pub fn asNode(self: *Script) *Node { diff --git a/src/browser/webapi/element/html/Select.zig b/src/browser/webapi/element/html/Select.zig index 7345ae39c..48bbb7298 100644 --- a/src/browser/webapi/element/html/Select.zig +++ b/src/browser/webapi/element/html/Select.zig @@ -16,6 +16,8 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +const lp = @import("lightpanda"); +const Factory = @import("../../../Factory.zig"); const js = @import("../../../js/js.zig"); const Frame = @import("../../../Frame.zig"); @@ -32,16 +34,16 @@ const Select = @This(); pub const Proto = HtmlElement; -_proto: *HtmlElement, +_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined, _selected_index_set: bool = false, _custom_validity: ?[]const u8 = null, _validity: ?*ValidityState = null, pub fn asElement(self: *Select) *Element { - return self._proto.asElement(); + return Factory.protoOf(self).asElement(); } pub fn asConstElement(self: *const Select) *const Element { - return self._proto.asElement(); + return Factory.protoOf(self).asElement(); } pub fn asNode(self: *Select) *Node { return self.asElement().asNode(); diff --git a/src/browser/webapi/element/html/Slot.zig b/src/browser/webapi/element/html/Slot.zig index 169ed5ccd..0bb61a579 100644 --- a/src/browser/webapi/element/html/Slot.zig +++ b/src/browser/webapi/element/html/Slot.zig @@ -1,17 +1,18 @@ +const lp = @import("lightpanda"); const std = @import("std"); const js = @import("../../../js/js.zig"); +const Factory = @import("../../../Factory.zig"); const Frame = @import("../../../Frame.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); const HtmlElement = @import("../Html.zig"); -const ShadowRoot = @import("../../ShadowRoot.zig"); const slotting = @import("../slotting.zig"); const Slot = @This(); pub const Proto = HtmlElement; -_proto: *HtmlElement, +_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined, // DOM spec "assigned nodes". Maintained by slotting.assignSlottables; always // empty while the slot isn't in a shadow tree. _assigned: std.ArrayList(*Node) = .empty, @@ -20,11 +21,11 @@ _assigned: std.ArrayList(*Node) = .empty, _manually_assigned: std.ArrayList(*Node) = .empty, pub fn asElement(self: *Slot) *Element { - return self._proto.asElement(); + return Factory.protoOf(self).asElement(); } pub fn asConstElement(self: *const Slot) *const Element { - return self._proto.asElement(); + return Factory.protoOf(self).asElement(); } pub fn asNode(self: *Slot) *Node { @@ -74,7 +75,7 @@ fn CollectionType(comptime elements: bool) type { // DOM spec "find flattened slottables" fn collectFlattened(self: *Slot, comptime elements: bool, coll: CollectionType(elements), frame: *Frame) error{OutOfMemory}!void { - if (self.asNode().getRootNode(.{}).is(ShadowRoot) == null) { + if (self.asNode().containingShadowRoot() == null) { return; } @@ -99,7 +100,7 @@ fn appendFlattened(comptime elements: bool, coll: CollectionType(elements), node if (node.is(Slot)) |nested| { // a slottable (or fallback child) that is itself a slot in a shadow // tree flattens to its own flattened slottables - if (nested.asNode().getRootNode(.{}).is(ShadowRoot) != null) { + if (nested.asNode().containingShadowRoot() != null) { return nested.collectFlattened(elements, coll, frame); } } @@ -151,9 +152,8 @@ pub fn assign(self: *Slot, values: []const js.Value, frame: *Frame) !void { try self._manually_assigned.append(frame.arena, node); } - const root = self.asNode().getRootNode(.{}); - if (root.is(ShadowRoot) != null) { - slotting.assignSlottablesForTree(root, frame); + if (self.asNode().containingShadowRoot()) |shadow_root| { + slotting.assignSlottablesForTree(shadow_root.asNode(), frame); } } diff --git a/src/browser/webapi/element/html/Source.zig b/src/browser/webapi/element/html/Source.zig index 5e45a100f..1db9d63ad 100644 --- a/src/browser/webapi/element/html/Source.zig +++ b/src/browser/webapi/element/html/Source.zig @@ -1,5 +1,7 @@ +const lp = @import("lightpanda"); const std = @import("std"); const js = @import("../../../js/js.zig"); +const Factory = @import("../../../Factory.zig"); const Frame = @import("../../../Frame.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); @@ -9,13 +11,14 @@ const Source = @This(); pub const Proto = HtmlElement; -_proto: *HtmlElement, +_pad: bool = false, +_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined, pub fn asElement(self: *Source) *Element { - return self._proto.asElement(); + return Factory.protoOf(self).asElement(); } pub fn asConstElement(self: *const Source) *const Element { - return self._proto.asElement(); + return Factory.protoOf(self).asElement(); } pub fn asNode(self: *Source) *Node { return self.asElement().asNode(); diff --git a/src/browser/webapi/element/html/Span.zig b/src/browser/webapi/element/html/Span.zig index 9afee3d26..125252cd0 100644 --- a/src/browser/webapi/element/html/Span.zig +++ b/src/browser/webapi/element/html/Span.zig @@ -1,3 +1,5 @@ +const lp = @import("lightpanda"); +const Factory = @import("../../../Factory.zig"); const js = @import("../../../js/js.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); @@ -7,10 +9,11 @@ const Span = @This(); pub const Proto = HtmlElement; -_proto: *HtmlElement, +_pad: bool = false, +_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined, pub fn asElement(self: *Span) *Element { - return self._proto.asElement(); + return Factory.protoOf(self).asElement(); } pub fn asNode(self: *Span) *Node { return self.asElement().asNode(); diff --git a/src/browser/webapi/element/html/Style.zig b/src/browser/webapi/element/html/Style.zig index 1fc7ef552..23276545d 100644 --- a/src/browser/webapi/element/html/Style.zig +++ b/src/browser/webapi/element/html/Style.zig @@ -16,8 +16,10 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +const lp = @import("lightpanda"); const std = @import("std"); const js = @import("../../../js/js.zig"); +const Factory = @import("../../../Factory.zig"); const Frame = @import("../../../Frame.zig"); const Node = @import("../../Node.zig"); @@ -27,14 +29,14 @@ const HtmlElement = @import("../Html.zig"); const Style = @This(); pub const Proto = HtmlElement; -_proto: *HtmlElement, +_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined, _sheet: ?*CSSStyleSheet = null, pub fn asElement(self: *Style) *Element { - return self._proto.asElement(); + return Factory.protoOf(self).asElement(); } pub fn asConstElement(self: *const Style) *const Element { - return self._proto.asElement(); + return Factory.protoOf(self).asElement(); } pub fn asNode(self: *Style) *Node { return self.asElement().asNode(); @@ -116,7 +118,7 @@ pub fn styleAddedCallback(self: *Style, frame: *Frame) !void { return; } - try frame.queueLoad(self._proto); + try frame.queueLoad(Factory.protoOf(self)); } pub const JsApi = struct { diff --git a/src/browser/webapi/element/html/Table.zig b/src/browser/webapi/element/html/Table.zig index 110910023..c7b56891d 100644 --- a/src/browser/webapi/element/html/Table.zig +++ b/src/browser/webapi/element/html/Table.zig @@ -1,3 +1,5 @@ +const lp = @import("lightpanda"); +const Factory = @import("../../../Factory.zig"); const js = @import("../../../js/js.zig"); const Node = @import("../../Node.zig"); const Frame = @import("../../../Frame.zig"); @@ -9,10 +11,11 @@ const Table = @This(); pub const Proto = HtmlElement; -_proto: *HtmlElement, +_pad: bool = false, +_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined, pub fn asElement(self: *Table) *Element { - return self._proto.asElement(); + return Factory.protoOf(self).asElement(); } pub fn asNode(self: *Table) *Node { return self.asElement().asNode(); diff --git a/src/browser/webapi/element/html/TableCaption.zig b/src/browser/webapi/element/html/TableCaption.zig index 0c8da33d5..fedc32cb0 100644 --- a/src/browser/webapi/element/html/TableCaption.zig +++ b/src/browser/webapi/element/html/TableCaption.zig @@ -1,3 +1,5 @@ +const lp = @import("lightpanda"); +const Factory = @import("../../../Factory.zig"); const js = @import("../../../js/js.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); @@ -7,10 +9,11 @@ const TableCaption = @This(); pub const Proto = HtmlElement; -_proto: *HtmlElement, +_pad: bool = false, +_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined, pub fn asElement(self: *TableCaption) *Element { - return self._proto.asElement(); + return Factory.protoOf(self).asElement(); } pub fn asNode(self: *TableCaption) *Node { return self.asElement().asNode(); diff --git a/src/browser/webapi/element/html/TableCell.zig b/src/browser/webapi/element/html/TableCell.zig index 73635f8dc..7abd67205 100644 --- a/src/browser/webapi/element/html/TableCell.zig +++ b/src/browser/webapi/element/html/TableCell.zig @@ -2,6 +2,7 @@ const std = @import("std"); const lp = @import("lightpanda"); const js = @import("../../../js/js.zig"); +const Factory = @import("../../../Factory.zig"); const Frame = @import("../../../Frame.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); @@ -15,10 +16,10 @@ pub const Proto = HtmlElement; _tag_name: String, _tag: Element.Tag, -_proto: *HtmlElement, +_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined, pub fn asElement(self: *TableCell) *Element { - return self._proto.asElement(); + return Factory.protoOf(self).asElement(); } pub fn asNode(self: *TableCell) *Node { return self.asElement().asNode(); diff --git a/src/browser/webapi/element/html/TableCol.zig b/src/browser/webapi/element/html/TableCol.zig index 196a26ae8..55558a97e 100644 --- a/src/browser/webapi/element/html/TableCol.zig +++ b/src/browser/webapi/element/html/TableCol.zig @@ -1,6 +1,7 @@ const lp = @import("lightpanda"); const js = @import("../../../js/js.zig"); +const Factory = @import("../../../Factory.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); const HtmlElement = @import("../Html.zig"); @@ -13,10 +14,10 @@ pub const Proto = HtmlElement; _tag_name: String, _tag: Element.Tag, -_proto: *HtmlElement, +_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined, pub fn asElement(self: *TableCol) *Element { - return self._proto.asElement(); + return Factory.protoOf(self).asElement(); } pub fn asNode(self: *TableCol) *Node { return self.asElement().asNode(); diff --git a/src/browser/webapi/element/html/TableRow.zig b/src/browser/webapi/element/html/TableRow.zig index ff9eec942..6191ed084 100644 --- a/src/browser/webapi/element/html/TableRow.zig +++ b/src/browser/webapi/element/html/TableRow.zig @@ -1,3 +1,5 @@ +const lp = @import("lightpanda"); +const Factory = @import("../../../Factory.zig"); const js = @import("../../../js/js.zig"); const Node = @import("../../Node.zig"); const Frame = @import("../../../Frame.zig"); @@ -9,10 +11,11 @@ const TableRow = @This(); pub const Proto = HtmlElement; -_proto: *HtmlElement, +_pad: bool = false, +_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined, pub fn asElement(self: *TableRow) *Element { - return self._proto.asElement(); + return Factory.protoOf(self).asElement(); } pub fn asNode(self: *TableRow) *Node { return self.asElement().asNode(); diff --git a/src/browser/webapi/element/html/TableSection.zig b/src/browser/webapi/element/html/TableSection.zig index e396de34d..e996ceec6 100644 --- a/src/browser/webapi/element/html/TableSection.zig +++ b/src/browser/webapi/element/html/TableSection.zig @@ -1,6 +1,7 @@ const lp = @import("lightpanda"); const js = @import("../../../js/js.zig"); +const Factory = @import("../../../Factory.zig"); const Node = @import("../../Node.zig"); const Frame = @import("../../../Frame.zig"); const Element = @import("../../Element.zig"); @@ -15,10 +16,10 @@ pub const Proto = HtmlElement; _tag_name: String, _tag: Element.Tag, -_proto: *HtmlElement, +_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined, pub fn asElement(self: *TableSection) *Element { - return self._proto.asElement(); + return Factory.protoOf(self).asElement(); } pub fn asNode(self: *TableSection) *Node { return self.asElement().asNode(); diff --git a/src/browser/webapi/element/html/Template.zig b/src/browser/webapi/element/html/Template.zig index 68af063b0..d84550fb9 100644 --- a/src/browser/webapi/element/html/Template.zig +++ b/src/browser/webapi/element/html/Template.zig @@ -20,6 +20,7 @@ const std = @import("std"); const lp = @import("lightpanda"); const js = @import("../../../js/js.zig"); +const Factory = @import("../../../Factory.zig"); const Frame = @import("../../../Frame.zig"); const Node = @import("../../Node.zig"); @@ -34,15 +35,15 @@ const Template = @This(); pub const Proto = HtmlElement; -_proto: *HtmlElement, +_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined, _content: *DocumentFragment, pub fn asElement(self: *Template) *Element { - return self._proto.asElement(); + return Factory.protoOf(self).asElement(); } pub fn asConstElement(self: *const Template) *const Element { - return self._proto.asElement(); + return Factory.protoOf(self).asElement(); } pub fn asNode(self: *Template) *Node { return self.asElement().asNode(); diff --git a/src/browser/webapi/element/html/TextArea.zig b/src/browser/webapi/element/html/TextArea.zig index 3f19c0d3a..5f488b9e8 100644 --- a/src/browser/webapi/element/html/TextArea.zig +++ b/src/browser/webapi/element/html/TextArea.zig @@ -16,8 +16,10 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +const lp = @import("lightpanda"); const std = @import("std"); const js = @import("../../../js/js.zig"); +const Factory = @import("../../../Factory.zig"); const Frame = @import("../../../Frame.zig"); const Node = @import("../../Node.zig"); @@ -33,7 +35,7 @@ const TextArea = @This(); pub const Proto = HtmlElement; -_proto: *HtmlElement, +_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined, _value: ?[]const u8 = null, _selection_start: u32 = 0, @@ -67,10 +69,10 @@ fn dispatchInputEvent(self: *TextArea, data: ?[]const u8, input_type: []const u8 } pub fn asElement(self: *TextArea) *Element { - return self._proto.asElement(); + return Factory.protoOf(self).asElement(); } pub fn asConstElement(self: *const TextArea) *const Element { - return self._proto.asElement(); + return Factory.protoOf(self).asElement(); } pub fn asNode(self: *TextArea) *Node { return self.asElement().asNode(); @@ -102,7 +104,7 @@ pub fn setDefaultValue(self: *TextArea, value: []const u8, frame: *Frame) !void const node = self.asNode(); if (node.firstChild()) |child| { if (child.is(Node.CData.Text)) |txt| { - txt._proto._data = try frame.dupeSSO(value); + txt.asCData()._data = try frame.dupeSSO(value); return; } } diff --git a/src/browser/webapi/element/html/Time.zig b/src/browser/webapi/element/html/Time.zig index 9232f7489..3bfb88069 100644 --- a/src/browser/webapi/element/html/Time.zig +++ b/src/browser/webapi/element/html/Time.zig @@ -1,3 +1,5 @@ +const lp = @import("lightpanda"); +const Factory = @import("../../../Factory.zig"); const js = @import("../../../js/js.zig"); const Frame = @import("../../../Frame.zig"); const Node = @import("../../Node.zig"); @@ -8,10 +10,11 @@ const Time = @This(); pub const Proto = HtmlElement; -_proto: *HtmlElement, +_pad: bool = false, +_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined, pub fn asElement(self: *Time) *Element { - return self._proto.asElement(); + return Factory.protoOf(self).asElement(); } pub fn asNode(self: *Time) *Node { return self.asElement().asNode(); diff --git a/src/browser/webapi/element/html/Title.zig b/src/browser/webapi/element/html/Title.zig index c81ae6031..bdc4f8622 100644 --- a/src/browser/webapi/element/html/Title.zig +++ b/src/browser/webapi/element/html/Title.zig @@ -1,3 +1,5 @@ +const lp = @import("lightpanda"); +const Factory = @import("../../../Factory.zig"); const js = @import("../../../js/js.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); @@ -7,10 +9,11 @@ const Title = @This(); pub const Proto = HtmlElement; -_proto: *HtmlElement, +_pad: bool = false, +_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined, pub fn asElement(self: *Title) *Element { - return self._proto.asElement(); + return Factory.protoOf(self).asElement(); } pub fn asNode(self: *Title) *Node { return self.asElement().asNode(); diff --git a/src/browser/webapi/element/html/Track.zig b/src/browser/webapi/element/html/Track.zig index 3e3e10514..4d29d3939 100644 --- a/src/browser/webapi/element/html/Track.zig +++ b/src/browser/webapi/element/html/Track.zig @@ -20,6 +20,7 @@ const std = @import("std"); const lp = @import("lightpanda"); const js = @import("../../../js/js.zig"); +const Factory = @import("../../../Factory.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); @@ -31,14 +32,14 @@ const Track = @This(); pub const Proto = HtmlElement; -_proto: *HtmlElement, +_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined, _kind: String, _ready_state: ReadyState, const ReadyState = enum(u8) { none, loading, loaded, @"error" }; pub fn asElement(self: *Track) *Element { - return self._proto.asElement(); + return Factory.protoOf(self).asElement(); } pub fn asNode(self: *Track) *Node { return self.asElement().asNode(); diff --git a/src/browser/webapi/element/html/UL.zig b/src/browser/webapi/element/html/UL.zig index 5d69779e2..372377d24 100644 --- a/src/browser/webapi/element/html/UL.zig +++ b/src/browser/webapi/element/html/UL.zig @@ -16,6 +16,8 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +const lp = @import("lightpanda"); +const Factory = @import("../../../Factory.zig"); const js = @import("../../../js/js.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); @@ -24,10 +26,11 @@ const HtmlElement = @import("../Html.zig"); const UL = @This(); pub const Proto = HtmlElement; -_proto: *HtmlElement, +_pad: bool = false, +_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined, pub fn asElement(self: *UL) *Element { - return self._proto.asElement(); + return Factory.protoOf(self).asElement(); } pub fn asNode(self: *UL) *Node { return self.asElement().asNode(); diff --git a/src/browser/webapi/element/html/Unknown.zig b/src/browser/webapi/element/html/Unknown.zig index 336adc6da..6561a5256 100644 --- a/src/browser/webapi/element/html/Unknown.zig +++ b/src/browser/webapi/element/html/Unknown.zig @@ -19,6 +19,7 @@ const lp = @import("lightpanda"); const js = @import("../../../js/js.zig"); +const Factory = @import("../../../Factory.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); @@ -29,11 +30,11 @@ const String = lp.String; const Unknown = @This(); pub const Proto = HtmlElement; -_proto: *HtmlElement, +_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined, _tag_name: String, pub fn asElement(self: *Unknown) *Element { - return self._proto.asElement(); + return Factory.protoOf(self).asElement(); } pub fn asNode(self: *Unknown) *Node { return self.asElement().asNode(); diff --git a/src/browser/webapi/element/html/Video.zig b/src/browser/webapi/element/html/Video.zig index 4daff3160..ccd3e1b4f 100644 --- a/src/browser/webapi/element/html/Video.zig +++ b/src/browser/webapi/element/html/Video.zig @@ -16,6 +16,8 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +const lp = @import("lightpanda"); +const Factory = @import("../../../Factory.zig"); const js = @import("../../../js/js.zig"); const Frame = @import("../../../Frame.zig"); @@ -27,18 +29,19 @@ const Video = @This(); pub const Proto = Media; -_proto: *Media, +_pad: bool = false, +_proto_canary: if (lp.IS_DEBUG) *Media else void = undefined, pub fn asMedia(self: *Video) *Media { - return self._proto; + return Factory.protoOf(self); } pub fn asElement(self: *Video) *Element { - return self._proto.asElement(); + return Factory.protoOf(self).asElement(); } pub fn asConstElement(self: *const Video) *const Element { - return self._proto.asConstElement(); + return Factory.protoOf(self).asConstElement(); } pub fn asNode(self: *Video) *Node { diff --git a/src/browser/webapi/element/popover.zig b/src/browser/webapi/element/popover.zig index 68c490bc4..4912473de 100644 --- a/src/browser/webapi/element/popover.zig +++ b/src/browser/webapi/element/popover.zig @@ -207,7 +207,7 @@ pub fn invokerTarget(invoker: *Node, explicit: ?*Element, frame: *Frame) ?*Eleme pub fn runInvokerActivation(invoker: *HtmlElement, explicit: ?*Element, frame: *Frame) !void { switch (invoker._type) { .button => {}, - .input => |input| switch (input._input_type) { + .input => switch (invoker.subtype(HtmlElement.Input)._input_type) { .button, .submit, .reset, .image => {}, else => return, // not an invoker }, diff --git a/src/browser/webapi/element/slotting.zig b/src/browser/webapi/element/slotting.zig index c62ff5505..1e26ca1d6 100644 --- a/src/browser/webapi/element/slotting.zig +++ b/src/browser/webapi/element/slotting.zig @@ -23,7 +23,6 @@ const Frame = @import("../../Frame.zig"); const Node = @import("../Node.zig"); const Element = @import("../Element.zig"); -const ShadowRoot = @import("../ShadowRoot.zig"); const TreeWalker = @import("../TreeWalker.zig"); const Text = @import("../cdata/Text.zig"); @@ -93,7 +92,7 @@ fn assignSlottables(slot: *Slot, frame: *Frame) void { fn _assignSlottables(slot: *Slot, frame: *Frame) !void { var slottables: std.ArrayList(*Node) = .empty; - if (slot.asNode().getRootNode(.{}).is(ShadowRoot)) |shadow_root| { + if (slot.asNode().containingShadowRoot()) |shadow_root| { const host = shadow_root.getHost(); if (shadow_root._slot_assignment == .manual) { // manual assignment preserves the assign(...) order, not tree order @@ -184,7 +183,7 @@ pub fn insertionSteps(parent: *Node, child: *Node, in_fragment_parse: bool, fram // assignment they were parsed with. if (in_fragment_parse == false) { if (parent.is(Slot)) |parent_slot| { - if (parent_slot._assigned.items.len == 0 and parent.getRootNode(.{}).is(ShadowRoot) != null) { + if (parent_slot._assigned.items.len == 0 and parent.containingShadowRoot() != null) { frame.signalSlotChange(parent_slot); } } @@ -192,9 +191,8 @@ pub fn insertionSteps(parent: *Node, child: *Node, in_fragment_parse: bool, fram // A subtree containing slots was inserted into a shadow tree. if (subtreeHasSlot(child)) { - const root = child.getRootNode(.{}); - if (root.is(ShadowRoot) != null) { - assignSlottablesForTree(root, frame); + if (child.containingShadowRoot()) |shadow_root| { + assignSlottablesForTree(shadow_root.asNode(), frame); } } } @@ -213,7 +211,7 @@ pub fn removalSteps(parent: *Node, child: *Node, frame: *Frame) void { // Fallback content was removed from a slot that renders its fallback. if (parent.is(Slot)) |parent_slot| { - if (parent_slot._assigned.items.len == 0 and parent.getRootNode(.{}).is(ShadowRoot) != null) { + if (parent_slot._assigned.items.len == 0 and parent.containingShadowRoot() != null) { frame.signalSlotChange(parent_slot); } } @@ -221,9 +219,8 @@ pub fn removalSteps(parent: *Node, child: *Node, frame: *Frame) void { // A subtree containing slots was removed: update assignments in the old // tree, and clear assignments held by slots in the detached subtree. if (subtreeHasSlot(child)) { - const root = parent.getRootNode(.{}); - if (root.is(ShadowRoot) != null) { - assignSlottablesForTree(root, frame); + if (parent.containingShadowRoot()) |shadow_root| { + assignSlottablesForTree(shadow_root.asNode(), frame); } assignSlottablesForTree(child, frame); } @@ -248,8 +245,7 @@ pub fn nameAttributeChanged(slot: *Slot, old_value: []const u8, value: []const u if (std.mem.eql(u8, old_value, value)) { return; } - const root = slot.asNode().getRootNode(.{}); - if (root.is(ShadowRoot) != null) { - assignSlottablesForTree(root, frame); + if (slot.asNode().containingShadowRoot()) |shadow_root| { + assignSlottablesForTree(shadow_root.asNode(), frame); } } diff --git a/src/browser/webapi/element/svg/A.zig b/src/browser/webapi/element/svg/A.zig index 0ce9f09d6..cb7009bf4 100644 --- a/src/browser/webapi/element/svg/A.zig +++ b/src/browser/webapi/element/svg/A.zig @@ -16,6 +16,8 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +const lp = @import("lightpanda"); +const Factory = @import("../../../Factory.zig"); const js = @import("../../../js/js.zig"); const Frame = @import("../../../Frame.zig"); @@ -29,10 +31,11 @@ const Graphics = @import("Graphics.zig"); const A = @This(); pub const Proto = Graphics; -_proto: *Graphics, +_pad: bool = false, +_proto_canary: if (lp.IS_DEBUG) *Graphics else void = undefined, pub fn asElement(self: *A) *Element { - return self._proto.asElement(); + return Factory.protoOf(self).asElement(); } pub fn asNode(self: *A) *Node { return self.asElement().asNode(); diff --git a/src/browser/webapi/element/svg/Circle.zig b/src/browser/webapi/element/svg/Circle.zig index e14cf41cf..c3f73d1d2 100644 --- a/src/browser/webapi/element/svg/Circle.zig +++ b/src/browser/webapi/element/svg/Circle.zig @@ -16,6 +16,8 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +const lp = @import("lightpanda"); +const Factory = @import("../../../Factory.zig"); const js = @import("../../../js/js.zig"); const Frame = @import("../../../Frame.zig"); @@ -28,10 +30,11 @@ const AnimatedLength = @import("../../svg/AnimatedLength.zig"); const Circle = @This(); pub const Proto = Geometry; -_proto: *Geometry, +_pad: bool = false, +_proto_canary: if (lp.IS_DEBUG) *Geometry else void = undefined, pub fn asElement(self: *Circle) *Element { - return self._proto.asElement(); + return Factory.protoOf(self).asElement(); } pub fn asNode(self: *Circle) *Node { return self.asElement().asNode(); diff --git a/src/browser/webapi/element/svg/ClipPath.zig b/src/browser/webapi/element/svg/ClipPath.zig index 152fb3895..76d302de9 100644 --- a/src/browser/webapi/element/svg/ClipPath.zig +++ b/src/browser/webapi/element/svg/ClipPath.zig @@ -16,6 +16,8 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +const lp = @import("lightpanda"); +const Factory = @import("../../../Factory.zig"); const js = @import("../../../js/js.zig"); const Frame = @import("../../../Frame.zig"); @@ -30,10 +32,11 @@ const Svg = @import("../Svg.zig"); const ClipPath = @This(); pub const Proto = Svg; -_proto: *Svg, +_pad: bool = false, +_proto_canary: if (lp.IS_DEBUG) *Svg else void = undefined, pub fn asElement(self: *ClipPath) *Element { - return self._proto.asElement(); + return Factory.protoOf(self).asElement(); } pub fn asNode(self: *ClipPath) *Node { return self.asElement().asNode(); diff --git a/src/browser/webapi/element/svg/Defs.zig b/src/browser/webapi/element/svg/Defs.zig index ec3a15e30..46081cf45 100644 --- a/src/browser/webapi/element/svg/Defs.zig +++ b/src/browser/webapi/element/svg/Defs.zig @@ -16,6 +16,8 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +const lp = @import("lightpanda"); +const Factory = @import("../../../Factory.zig"); const js = @import("../../../js/js.zig"); const Node = @import("../../Node.zig"); @@ -26,10 +28,11 @@ const Graphics = @import("Graphics.zig"); const Defs = @This(); pub const Proto = Graphics; -_proto: *Graphics, +_pad: bool = false, +_proto_canary: if (lp.IS_DEBUG) *Graphics else void = undefined, pub fn asElement(self: *Defs) *Element { - return self._proto.asElement(); + return Factory.protoOf(self).asElement(); } pub fn asNode(self: *Defs) *Node { return self.asElement().asNode(); diff --git a/src/browser/webapi/element/svg/Desc.zig b/src/browser/webapi/element/svg/Desc.zig index 9c87fc62e..a8edcde9c 100644 --- a/src/browser/webapi/element/svg/Desc.zig +++ b/src/browser/webapi/element/svg/Desc.zig @@ -16,6 +16,8 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +const lp = @import("lightpanda"); +const Factory = @import("../../../Factory.zig"); const js = @import("../../../js/js.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); @@ -24,10 +26,11 @@ const Svg = @import("../Svg.zig"); const Desc = @This(); pub const Proto = Svg; -_proto: *Svg, +_pad: bool = false, +_proto_canary: if (lp.IS_DEBUG) *Svg else void = undefined, pub fn asElement(self: *Desc) *Element { - return self._proto.asElement(); + return Factory.protoOf(self).asElement(); } pub fn asNode(self: *Desc) *Node { return self.asElement().asNode(); diff --git a/src/browser/webapi/element/svg/Ellipse.zig b/src/browser/webapi/element/svg/Ellipse.zig index 8f2266407..56adb4f0c 100644 --- a/src/browser/webapi/element/svg/Ellipse.zig +++ b/src/browser/webapi/element/svg/Ellipse.zig @@ -16,6 +16,8 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +const lp = @import("lightpanda"); +const Factory = @import("../../../Factory.zig"); const js = @import("../../../js/js.zig"); const Frame = @import("../../../Frame.zig"); @@ -28,10 +30,11 @@ const AnimatedLength = @import("../../svg/AnimatedLength.zig"); const Ellipse = @This(); pub const Proto = Geometry; -_proto: *Geometry, +_pad: bool = false, +_proto_canary: if (lp.IS_DEBUG) *Geometry else void = undefined, pub fn asElement(self: *Ellipse) *Element { - return self._proto.asElement(); + return Factory.protoOf(self).asElement(); } pub fn asNode(self: *Ellipse) *Node { return self.asElement().asNode(); diff --git a/src/browser/webapi/element/svg/ForeignObject.zig b/src/browser/webapi/element/svg/ForeignObject.zig index 4004e0212..e0ab7784e 100644 --- a/src/browser/webapi/element/svg/ForeignObject.zig +++ b/src/browser/webapi/element/svg/ForeignObject.zig @@ -16,9 +16,11 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +const lp = @import("lightpanda"); const std = @import("std"); const js = @import("../../../js/js.zig"); +const Factory = @import("../../../Factory.zig"); const Frame = @import("../../../Frame.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); @@ -31,10 +33,11 @@ const Graphics = @import("Graphics.zig"); const ForeignObject = @This(); pub const Proto = Graphics; -_proto: *Graphics, +_pad: bool = false, +_proto_canary: if (lp.IS_DEBUG) *Graphics else void = undefined, pub fn asElement(self: *ForeignObject) *Element { - return self._proto.asElement(); + return Factory.protoOf(self).asElement(); } pub fn asNode(self: *ForeignObject) *Node { return self.asElement().asNode(); diff --git a/src/browser/webapi/element/svg/G.zig b/src/browser/webapi/element/svg/G.zig index 88514c893..01c94b339 100644 --- a/src/browser/webapi/element/svg/G.zig +++ b/src/browser/webapi/element/svg/G.zig @@ -16,6 +16,8 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +const lp = @import("lightpanda"); +const Factory = @import("../../../Factory.zig"); const js = @import("../../../js/js.zig"); const Node = @import("../../Node.zig"); @@ -26,10 +28,11 @@ const Graphics = @import("Graphics.zig"); const G = @This(); pub const Proto = Graphics; -_proto: *Graphics, +_pad: bool = false, +_proto_canary: if (lp.IS_DEBUG) *Graphics else void = undefined, pub fn asElement(self: *G) *Element { - return self._proto.asElement(); + return Factory.protoOf(self).asElement(); } pub fn asNode(self: *G) *Node { return self.asElement().asNode(); diff --git a/src/browser/webapi/element/svg/Generic.zig b/src/browser/webapi/element/svg/Generic.zig index 5a7a91417..7d4660041 100644 --- a/src/browser/webapi/element/svg/Generic.zig +++ b/src/browser/webapi/element/svg/Generic.zig @@ -16,6 +16,8 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +const lp = @import("lightpanda"); +const Factory = @import("../../../Factory.zig"); const js = @import("../../../js/js.zig"); const Node = @import("../../Node.zig"); @@ -26,11 +28,11 @@ const Svg = @import("../Svg.zig"); const Generic = @This(); pub const Proto = Svg; -_proto: *Svg, +_proto_canary: if (lp.IS_DEBUG) *Svg else void = undefined, _tag: Element.Tag, pub fn asElement(self: *Generic) *Element { - return self._proto.asElement(); + return Factory.protoOf(self).asElement(); } pub fn asNode(self: *Generic) *Node { return self.asElement().asNode(); diff --git a/src/browser/webapi/element/svg/Geometry.zig b/src/browser/webapi/element/svg/Geometry.zig index 7df09b2ce..5f54155da 100644 --- a/src/browser/webapi/element/svg/Geometry.zig +++ b/src/browser/webapi/element/svg/Geometry.zig @@ -45,23 +45,47 @@ pub const Proto = Graphics; _type: Type, _proto_canary: if (lp.IS_DEBUG) *Graphics else void = undefined, -pub const Type = union(enum) { - rect: *Rect, - circle: *Circle, - ellipse: *Ellipse, - line: *Line, - path: *Path, - polygon: *Polygon, - polyline: *Polyline, +pub const Type = enum(u8) { + rect, + circle, + ellipse, + line, + path, + polygon, + polyline, }; +pub fn Subtype(comptime tag: Type) type { + return switch (tag) { + .rect => Rect, + .circle => Circle, + .ellipse => Ellipse, + .line => Line, + .path => Path, + .polygon => Polygon, + .polyline => Polyline, + }; +} + +pub fn subtype(self: *const Geometry, comptime T: type) *T { + const offset = comptime Factory.chainOffsetOf(T, T) - Factory.chainOffsetOf(T, Geometry); + const sub: *T = @ptrFromInt(@intFromPtr(self) + offset); + if (comptime lp.IS_DEBUG) { + // This pointer dance only works because the factory allocates the chain + // in a contiguous block of memory. In debug, we assert this holds via + // the _proto_canary back pointer. + std.debug.assert(Factory.protoOf(sub) == self); + } + return sub; +} + pub fn is(self: *Geometry, comptime T: type) ?*T { - inline for (@typeInfo(Type).@"union".fields) |f| { - if (@field(Type, f.name) == self._type) { - if (f.type == *T) { - return @field(self._type, f.name); + switch (self._type) { + inline else => |tag| { + if (Subtype(tag) == T) { + return self.subtype(T); } - } + }, } return null; } @@ -107,16 +131,16 @@ pub fn getPointAtLength(self: *Geometry, distance: f64, frame: *Frame) !*DOMPoin pub fn buildPath(self: *Geometry, frame: *Frame) !PathData.Path { return switch (self._type) { - .rect => |rect| buildRect(rect, frame), - .circle => |circle| buildCircle(circle, frame), - .ellipse => |ellipse| buildEllipse(ellipse, frame), - .line => |line| buildLine(line, frame), - .path => |path| PathData.parse( - path.asElement().getAttributeSafe(comptime .wrap("d")) orelse "", + .rect => buildRect(self.subtype(Rect), frame), + .circle => buildCircle(self.subtype(Circle), frame), + .ellipse => buildEllipse(self.subtype(Ellipse), frame), + .line => buildLine(self.subtype(Line), frame), + .path => PathData.parse( + self.subtype(Path).asElement().getAttributeSafe(comptime .wrap("d")) orelse "", frame.local_arena, ), - .polygon => |polygon| buildPoints(try polygon.getPoints(frame), true, frame), - .polyline => |polyline| buildPoints(try polyline.getPoints(frame), false, frame), + .polygon => buildPoints(try self.subtype(Polygon).getPoints(frame), true, frame), + .polyline => buildPoints(try self.subtype(Polyline).getPoints(frame), false, frame), }; } diff --git a/src/browser/webapi/element/svg/GradientElement.zig b/src/browser/webapi/element/svg/GradientElement.zig index c63d460df..26eabb90f 100644 --- a/src/browser/webapi/element/svg/GradientElement.zig +++ b/src/browser/webapi/element/svg/GradientElement.zig @@ -16,6 +16,7 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +const std = @import("std"); const lp = @import("lightpanda"); const js = @import("../../../js/js.zig"); const Frame = @import("../../../Frame.zig"); @@ -39,18 +40,37 @@ pub const Proto = Svg; _type: Type, _proto_canary: if (lp.IS_DEBUG) *Svg else void = undefined, -pub const Type = union(enum) { - linear: *LinearGradient, - radial: *RadialGradient, +pub const Type = enum(u8) { + linear, + radial, }; +pub fn Subtype(comptime tag: Type) type { + return switch (tag) { + .linear => LinearGradient, + .radial => RadialGradient, + }; +} + +pub fn subtype(self: *const GradientElement, comptime T: type) *T { + const offset = comptime Factory.chainOffsetOf(T, T) - Factory.chainOffsetOf(T, GradientElement); + const sub: *T = @ptrFromInt(@intFromPtr(self) + offset); + if (comptime lp.IS_DEBUG) { + // This pointer dance only works because the factory allocates the chain + // in a contiguous block of memory. In debug, we assert this holds via + // the _proto_canary back pointer. + std.debug.assert(Factory.protoOf(sub) == self); + } + return sub; +} + pub fn is(self: *GradientElement, comptime T: type) ?*T { - inline for (@typeInfo(Type).@"union".fields) |field| { - if (@field(Type, field.name) == self._type) { - if (field.type == *T) { - return @field(self._type, field.name); + switch (self._type) { + inline else => |tag| { + if (Subtype(tag) == T) { + return self.subtype(T); } - } + }, } return null; } diff --git a/src/browser/webapi/element/svg/Graphics.zig b/src/browser/webapi/element/svg/Graphics.zig index 8668ba3b1..e4b4f9d05 100644 --- a/src/browser/webapi/element/svg/Graphics.zig +++ b/src/browser/webapi/element/svg/Graphics.zig @@ -49,33 +49,61 @@ pub const Proto = SvgElement; _type: Type, _proto_canary: if (lp.IS_DEBUG) *SvgElement else void = undefined, -pub const Type = union(enum) { - svg: *Svg, - g: *G, - a: *A, - use: *Use, - image: *Image, - defs: *Defs, - symbol: *Symbol, - switch_element: *Switch, - foreign_object: *ForeignObject, - text_content: *TextContent, - geometry: *Geometry, +pub const Type = enum(u8) { + svg, + g, + a, + use, + image, + defs, + symbol, + switch_element, + foreign_object, + text_content, + geometry, }; +pub fn Subtype(comptime tag: Type) type { + return switch (tag) { + .svg => Svg, + .g => G, + .a => A, + .use => Use, + .image => Image, + .defs => Defs, + .symbol => Symbol, + .switch_element => Switch, + .foreign_object => ForeignObject, + .text_content => TextContent, + .geometry => Geometry, + }; +} + +pub fn subtype(self: *const Graphics, comptime T: type) *T { + const offset = comptime Factory.chainOffsetOf(T, T) - Factory.chainOffsetOf(T, Graphics); + const sub: *T = @ptrFromInt(@intFromPtr(self) + offset); + if (comptime lp.IS_DEBUG) { + // This pointer dance only works because the factory allocates the chain + // in a contiguous block of memory. In debug, we assert this holds via + // the _proto_canary back pointer. + std.debug.assert(Factory.protoOf(sub) == self); + } + return sub; +} + pub fn is(self: *Graphics, comptime T: type) ?*T { - inline for (@typeInfo(Type).@"union".fields) |f| { - if (@field(Type, f.name) == self._type) { - if (f.type == *T) { - return @field(self._type, f.name); + switch (self._type) { + inline else => |tag| { + if (Subtype(tag) == T) { + return self.subtype(T); } - } + }, } if (self._type == .geometry) { - return self._type.geometry.is(T); + return self.subtype(Geometry).is(T); } if (self._type == .text_content) { - return self._type.text_content.is(T); + return self.subtype(TextContent).is(T); } return null; } @@ -108,12 +136,12 @@ pub const JsApi = struct { pub fn getBBox(self: *Graphics, frame: *Frame) !*DOMRect { var bounds: PathData.Bounds = .{}; switch (self._type) { - .geometry => |geometry| { - var path = try geometry.buildPath(frame); + .geometry => { + var path = try self.subtype(Geometry).buildPath(frame); defer path.deinit(frame.local_arena); bounds = path.bounds(.{}); }, - .foreign_object => |foreign_object| bounds = try foreign_object.getBounds(frame), + .foreign_object => bounds = try self.subtype(ForeignObject).getBounds(frame), .g, .a, .svg => try accumulateChildren(self, .{}, &bounds, frame), .defs, .symbol, .switch_element, .use, .image, .text_content => {}, } @@ -156,13 +184,13 @@ fn accumulateChildren(parent: *Graphics, matrix: PathData.Matrix, bounds: *PathD const child_matrix = parent_matrix.multiply(transformMatrix(element)); switch (graphics._type) { - .geometry => |geometry| { - var path = try geometry.buildPath(frame); + .geometry => { + var path = try graphics.subtype(Geometry).buildPath(frame); defer path.deinit(frame.local_arena); bounds.merge(path.bounds(child_matrix)); }, - .foreign_object => |foreign_object| { - const child_bounds = try foreign_object.getBounds(frame); + .foreign_object => { + const child_bounds = try graphics.subtype(ForeignObject).getBounds(frame); if (!child_bounds.isEmpty()) { var foreign_path: PathData.Path = .{}; defer foreign_path.deinit(frame.local_arena); diff --git a/src/browser/webapi/element/svg/Image.zig b/src/browser/webapi/element/svg/Image.zig index a01218444..6e0d058bc 100644 --- a/src/browser/webapi/element/svg/Image.zig +++ b/src/browser/webapi/element/svg/Image.zig @@ -16,6 +16,8 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +const lp = @import("lightpanda"); +const Factory = @import("../../../Factory.zig"); const js = @import("../../../js/js.zig"); const Frame = @import("../../../Frame.zig"); @@ -28,10 +30,11 @@ const Graphics = @import("Graphics.zig"); const Image = @This(); pub const Proto = Graphics; -_proto: *Graphics, +_pad: bool = false, +_proto_canary: if (lp.IS_DEBUG) *Graphics else void = undefined, pub fn asElement(self: *Image) *Element { - return self._proto.asElement(); + return Factory.protoOf(self).asElement(); } pub fn asNode(self: *Image) *Node { return self.asElement().asNode(); diff --git a/src/browser/webapi/element/svg/Line.zig b/src/browser/webapi/element/svg/Line.zig index d47e72b3c..323e1b3e4 100644 --- a/src/browser/webapi/element/svg/Line.zig +++ b/src/browser/webapi/element/svg/Line.zig @@ -16,6 +16,8 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +const lp = @import("lightpanda"); +const Factory = @import("../../../Factory.zig"); const js = @import("../../../js/js.zig"); const Frame = @import("../../../Frame.zig"); @@ -28,10 +30,11 @@ const AnimatedLength = @import("../../svg/AnimatedLength.zig"); const Line = @This(); pub const Proto = Geometry; -_proto: *Geometry, +_pad: bool = false, +_proto_canary: if (lp.IS_DEBUG) *Geometry else void = undefined, pub fn asElement(self: *Line) *Element { - return self._proto.asElement(); + return Factory.protoOf(self).asElement(); } pub fn asNode(self: *Line) *Node { return self.asElement().asNode(); diff --git a/src/browser/webapi/element/svg/LinearGradient.zig b/src/browser/webapi/element/svg/LinearGradient.zig index 7a92e803e..a29c4d510 100644 --- a/src/browser/webapi/element/svg/LinearGradient.zig +++ b/src/browser/webapi/element/svg/LinearGradient.zig @@ -16,6 +16,8 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +const lp = @import("lightpanda"); +const Factory = @import("../../../Factory.zig"); const js = @import("../../../js/js.zig"); const Frame = @import("../../../Frame.zig"); @@ -29,10 +31,11 @@ const GradientElement = @import("GradientElement.zig"); const LinearGradient = @This(); pub const Proto = GradientElement; -_proto: *GradientElement, +_pad: bool = false, +_proto_canary: if (lp.IS_DEBUG) *GradientElement else void = undefined, pub fn asElement(self: *LinearGradient) *Element { - return self._proto.asElement(); + return Factory.protoOf(self).asElement(); } pub fn asNode(self: *LinearGradient) *Node { return self.asElement().asNode(); diff --git a/src/browser/webapi/element/svg/Marker.zig b/src/browser/webapi/element/svg/Marker.zig index 73209cd7d..3d1200ff4 100644 --- a/src/browser/webapi/element/svg/Marker.zig +++ b/src/browser/webapi/element/svg/Marker.zig @@ -16,6 +16,8 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +const lp = @import("lightpanda"); +const Factory = @import("../../../Factory.zig"); const js = @import("../../../js/js.zig"); const Frame = @import("../../../Frame.zig"); @@ -30,10 +32,11 @@ const Svg = @import("../Svg.zig"); const Marker = @This(); pub const Proto = Svg; -_proto: *Svg, +_pad: bool = false, +_proto_canary: if (lp.IS_DEBUG) *Svg else void = undefined, pub fn asElement(self: *Marker) *Element { - return self._proto.asElement(); + return Factory.protoOf(self).asElement(); } pub fn asNode(self: *Marker) *Node { return self.asElement().asNode(); diff --git a/src/browser/webapi/element/svg/Mask.zig b/src/browser/webapi/element/svg/Mask.zig index 92dde0a64..26378cde5 100644 --- a/src/browser/webapi/element/svg/Mask.zig +++ b/src/browser/webapi/element/svg/Mask.zig @@ -16,6 +16,8 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +const lp = @import("lightpanda"); +const Factory = @import("../../../Factory.zig"); const js = @import("../../../js/js.zig"); const Frame = @import("../../../Frame.zig"); @@ -30,10 +32,11 @@ const Svg = @import("../Svg.zig"); const Mask = @This(); pub const Proto = Svg; -_proto: *Svg, +_pad: bool = false, +_proto_canary: if (lp.IS_DEBUG) *Svg else void = undefined, pub fn asElement(self: *Mask) *Element { - return self._proto.asElement(); + return Factory.protoOf(self).asElement(); } pub fn asNode(self: *Mask) *Node { return self.asElement().asNode(); diff --git a/src/browser/webapi/element/svg/Metadata.zig b/src/browser/webapi/element/svg/Metadata.zig index 434e0ec1f..636b8edbc 100644 --- a/src/browser/webapi/element/svg/Metadata.zig +++ b/src/browser/webapi/element/svg/Metadata.zig @@ -16,6 +16,8 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +const lp = @import("lightpanda"); +const Factory = @import("../../../Factory.zig"); const js = @import("../../../js/js.zig"); const Node = @import("../../Node.zig"); @@ -26,10 +28,11 @@ const Svg = @import("../Svg.zig"); const Metadata = @This(); pub const Proto = Svg; -_proto: *Svg, +_pad: bool = false, +_proto_canary: if (lp.IS_DEBUG) *Svg else void = undefined, pub fn asElement(self: *Metadata) *Element { - return self._proto.asElement(); + return Factory.protoOf(self).asElement(); } pub fn asNode(self: *Metadata) *Node { return self.asElement().asNode(); diff --git a/src/browser/webapi/element/svg/Path.zig b/src/browser/webapi/element/svg/Path.zig index 15d4d79c0..ccdb78e42 100644 --- a/src/browser/webapi/element/svg/Path.zig +++ b/src/browser/webapi/element/svg/Path.zig @@ -16,6 +16,8 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +const lp = @import("lightpanda"); +const Factory = @import("../../../Factory.zig"); const js = @import("../../../js/js.zig"); const Node = @import("../../Node.zig"); @@ -26,10 +28,11 @@ const Geometry = @import("Geometry.zig"); const Path = @This(); pub const Proto = Geometry; -_proto: *Geometry, +_pad: bool = false, +_proto_canary: if (lp.IS_DEBUG) *Geometry else void = undefined, pub fn asElement(self: *Path) *Element { - return self._proto.asElement(); + return Factory.protoOf(self).asElement(); } pub fn asNode(self: *Path) *Node { return self.asElement().asNode(); diff --git a/src/browser/webapi/element/svg/Pattern.zig b/src/browser/webapi/element/svg/Pattern.zig index 639812707..d30aa0066 100644 --- a/src/browser/webapi/element/svg/Pattern.zig +++ b/src/browser/webapi/element/svg/Pattern.zig @@ -16,6 +16,8 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +const lp = @import("lightpanda"); +const Factory = @import("../../../Factory.zig"); const js = @import("../../../js/js.zig"); const Frame = @import("../../../Frame.zig"); @@ -32,10 +34,11 @@ const Svg = @import("../Svg.zig"); const Pattern = @This(); pub const Proto = Svg; -_proto: *Svg, +_pad: bool = false, +_proto_canary: if (lp.IS_DEBUG) *Svg else void = undefined, pub fn asElement(self: *Pattern) *Element { - return self._proto.asElement(); + return Factory.protoOf(self).asElement(); } pub fn asNode(self: *Pattern) *Node { return self.asElement().asNode(); diff --git a/src/browser/webapi/element/svg/Polygon.zig b/src/browser/webapi/element/svg/Polygon.zig index dac7e8741..cf7074bb2 100644 --- a/src/browser/webapi/element/svg/Polygon.zig +++ b/src/browser/webapi/element/svg/Polygon.zig @@ -16,6 +16,8 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +const lp = @import("lightpanda"); +const Factory = @import("../../../Factory.zig"); const js = @import("../../../js/js.zig"); const Frame = @import("../../../Frame.zig"); @@ -28,10 +30,11 @@ const PointList = @import("../../svg/PointList.zig"); const Polygon = @This(); pub const Proto = Geometry; -_proto: *Geometry, +_pad: bool = false, +_proto_canary: if (lp.IS_DEBUG) *Geometry else void = undefined, pub fn asElement(self: *Polygon) *Element { - return self._proto.asElement(); + return Factory.protoOf(self).asElement(); } pub fn asNode(self: *Polygon) *Node { return self.asElement().asNode(); diff --git a/src/browser/webapi/element/svg/Polyline.zig b/src/browser/webapi/element/svg/Polyline.zig index a4e267af8..02e0d0346 100644 --- a/src/browser/webapi/element/svg/Polyline.zig +++ b/src/browser/webapi/element/svg/Polyline.zig @@ -16,6 +16,8 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +const lp = @import("lightpanda"); +const Factory = @import("../../../Factory.zig"); const js = @import("../../../js/js.zig"); const Frame = @import("../../../Frame.zig"); @@ -28,10 +30,11 @@ const PointList = @import("../../svg/PointList.zig"); const Polyline = @This(); pub const Proto = Geometry; -_proto: *Geometry, +_pad: bool = false, +_proto_canary: if (lp.IS_DEBUG) *Geometry else void = undefined, pub fn asElement(self: *Polyline) *Element { - return self._proto.asElement(); + return Factory.protoOf(self).asElement(); } pub fn asNode(self: *Polyline) *Node { return self.asElement().asNode(); diff --git a/src/browser/webapi/element/svg/RadialGradient.zig b/src/browser/webapi/element/svg/RadialGradient.zig index 041ad36fc..981dd4c9c 100644 --- a/src/browser/webapi/element/svg/RadialGradient.zig +++ b/src/browser/webapi/element/svg/RadialGradient.zig @@ -16,6 +16,8 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +const lp = @import("lightpanda"); +const Factory = @import("../../../Factory.zig"); const js = @import("../../../js/js.zig"); const Frame = @import("../../../Frame.zig"); @@ -29,10 +31,11 @@ const GradientElement = @import("GradientElement.zig"); const RadialGradient = @This(); pub const Proto = GradientElement; -_proto: *GradientElement, +_pad: bool = false, +_proto_canary: if (lp.IS_DEBUG) *GradientElement else void = undefined, pub fn asElement(self: *RadialGradient) *Element { - return self._proto.asElement(); + return Factory.protoOf(self).asElement(); } pub fn asNode(self: *RadialGradient) *Node { return self.asElement().asNode(); diff --git a/src/browser/webapi/element/svg/Rect.zig b/src/browser/webapi/element/svg/Rect.zig index 99d0a1e8c..2cde221c1 100644 --- a/src/browser/webapi/element/svg/Rect.zig +++ b/src/browser/webapi/element/svg/Rect.zig @@ -16,6 +16,8 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +const lp = @import("lightpanda"); +const Factory = @import("../../../Factory.zig"); const js = @import("../../../js/js.zig"); const Frame = @import("../../../Frame.zig"); const Node = @import("../../Node.zig"); @@ -26,10 +28,11 @@ const AnimatedLength = @import("../../svg/AnimatedLength.zig"); const Rect = @This(); pub const Proto = Geometry; -_proto: *Geometry, +_pad: bool = false, +_proto_canary: if (lp.IS_DEBUG) *Geometry else void = undefined, pub fn asElement(self: *Rect) *Element { - return self._proto.asElement(); + return Factory.protoOf(self).asElement(); } pub fn asNode(self: *Rect) *Node { return self.asElement().asNode(); diff --git a/src/browser/webapi/element/svg/Stop.zig b/src/browser/webapi/element/svg/Stop.zig index 845c9f863..dfc88c7ed 100644 --- a/src/browser/webapi/element/svg/Stop.zig +++ b/src/browser/webapi/element/svg/Stop.zig @@ -16,6 +16,8 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +const lp = @import("lightpanda"); +const Factory = @import("../../../Factory.zig"); const js = @import("../../../js/js.zig"); const Frame = @import("../../../Frame.zig"); @@ -29,10 +31,11 @@ const Svg = @import("../Svg.zig"); const Stop = @This(); pub const Proto = Svg; -_proto: *Svg, +_pad: bool = false, +_proto_canary: if (lp.IS_DEBUG) *Svg else void = undefined, pub fn asElement(self: *Stop) *Element { - return self._proto.asElement(); + return Factory.protoOf(self).asElement(); } pub fn asNode(self: *Stop) *Node { return self.asElement().asNode(); diff --git a/src/browser/webapi/element/svg/Svg.zig b/src/browser/webapi/element/svg/Svg.zig index b0d9d88ed..4e2f4f467 100644 --- a/src/browser/webapi/element/svg/Svg.zig +++ b/src/browser/webapi/element/svg/Svg.zig @@ -16,9 +16,11 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +const lp = @import("lightpanda"); const std = @import("std"); const js = @import("../../../js/js.zig"); +const Factory = @import("../../../Factory.zig"); const Frame = @import("../../../Frame.zig"); const Node = @import("../../Node.zig"); @@ -39,10 +41,11 @@ const Graphics = @import("Graphics.zig"); const Svg = @This(); pub const Proto = Graphics; -_proto: *Graphics, +_pad: bool = false, +_proto_canary: if (lp.IS_DEBUG) *Graphics else void = undefined, pub fn asElement(self: *Svg) *Element { - return self._proto.asElement(); + return Factory.protoOf(self).asElement(); } pub fn asNode(self: *Svg) *Node { return self.asElement().asNode(); diff --git a/src/browser/webapi/element/svg/Switch.zig b/src/browser/webapi/element/svg/Switch.zig index 2c528b32d..8d7b5e77a 100644 --- a/src/browser/webapi/element/svg/Switch.zig +++ b/src/browser/webapi/element/svg/Switch.zig @@ -16,6 +16,8 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +const lp = @import("lightpanda"); +const Factory = @import("../../../Factory.zig"); const js = @import("../../../js/js.zig"); const Node = @import("../../Node.zig"); @@ -26,10 +28,11 @@ const Graphics = @import("Graphics.zig"); const Switch = @This(); pub const Proto = Graphics; -_proto: *Graphics, +_pad: bool = false, +_proto_canary: if (lp.IS_DEBUG) *Graphics else void = undefined, pub fn asElement(self: *Switch) *Element { - return self._proto.asElement(); + return Factory.protoOf(self).asElement(); } pub fn asNode(self: *Switch) *Node { return self.asElement().asNode(); diff --git a/src/browser/webapi/element/svg/Symbol.zig b/src/browser/webapi/element/svg/Symbol.zig index c0793c9c4..4718c74b3 100644 --- a/src/browser/webapi/element/svg/Symbol.zig +++ b/src/browser/webapi/element/svg/Symbol.zig @@ -16,6 +16,8 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +const lp = @import("lightpanda"); +const Factory = @import("../../../Factory.zig"); const js = @import("../../../js/js.zig"); const Node = @import("../../Node.zig"); @@ -26,10 +28,11 @@ const Graphics = @import("Graphics.zig"); const Symbol = @This(); pub const Proto = Graphics; -_proto: *Graphics, +_pad: bool = false, +_proto_canary: if (lp.IS_DEBUG) *Graphics else void = undefined, pub fn asElement(self: *Symbol) *Element { - return self._proto.asElement(); + return Factory.protoOf(self).asElement(); } pub fn asNode(self: *Symbol) *Node { return self.asElement().asNode(); diff --git a/src/browser/webapi/element/svg/TSpan.zig b/src/browser/webapi/element/svg/TSpan.zig index a2021df02..c7175bf6b 100644 --- a/src/browser/webapi/element/svg/TSpan.zig +++ b/src/browser/webapi/element/svg/TSpan.zig @@ -16,6 +16,8 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +const lp = @import("lightpanda"); +const Factory = @import("../../../Factory.zig"); const js = @import("../../../js/js.zig"); const Node = @import("../../Node.zig"); @@ -26,10 +28,11 @@ const TextPositioning = @import("TextPositioning.zig"); const TSpan = @This(); pub const Proto = TextPositioning; -_proto: *TextPositioning, +_pad: bool = false, +_proto_canary: if (lp.IS_DEBUG) *TextPositioning else void = undefined, pub fn asElement(self: *TSpan) *Element { - return self._proto.asElement(); + return Factory.protoOf(self).asElement(); } pub fn asNode(self: *TSpan) *Node { return self.asElement().asNode(); diff --git a/src/browser/webapi/element/svg/Text.zig b/src/browser/webapi/element/svg/Text.zig index bc4ed94b2..a4bb67e90 100644 --- a/src/browser/webapi/element/svg/Text.zig +++ b/src/browser/webapi/element/svg/Text.zig @@ -16,6 +16,8 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +const lp = @import("lightpanda"); +const Factory = @import("../../../Factory.zig"); const js = @import("../../../js/js.zig"); const Node = @import("../../Node.zig"); @@ -26,10 +28,11 @@ const TextPositioning = @import("TextPositioning.zig"); const Text = @This(); pub const Proto = TextPositioning; -_proto: *TextPositioning, +_pad: bool = false, +_proto_canary: if (lp.IS_DEBUG) *TextPositioning else void = undefined, pub fn asElement(self: *Text) *Element { - return self._proto.asElement(); + return Factory.protoOf(self).asElement(); } pub fn asNode(self: *Text) *Node { return self.asElement().asNode(); diff --git a/src/browser/webapi/element/svg/TextContent.zig b/src/browser/webapi/element/svg/TextContent.zig index 665453aca..4f8683fda 100644 --- a/src/browser/webapi/element/svg/TextContent.zig +++ b/src/browser/webapi/element/svg/TextContent.zig @@ -16,6 +16,7 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +const std = @import("std"); const lp = @import("lightpanda"); const js = @import("../../../js/js.zig"); const Frame = @import("../../../Frame.zig"); @@ -39,18 +40,39 @@ pub const Proto = Graphics; _type: Type, _proto_canary: if (lp.IS_DEBUG) *Graphics else void = undefined, -pub const Type = union(enum) { - positioning: *TextPositioning, - text_path: *TextPath, +pub const Type = enum(u8) { + positioning, + text_path, }; -pub fn is(self: *TextContent, comptime T: type) ?*T { - inline for (@typeInfo(Type).@"union".fields) |field| { - if (@field(Type, field.name) == self._type) { - if (field.type == *T) return @field(self._type, field.name); - } +pub fn Subtype(comptime tag: Type) type { + return switch (tag) { + .positioning => TextPositioning, + .text_path => TextPath, + }; +} + +pub fn subtype(self: *const TextContent, comptime T: type) *T { + const offset = comptime Factory.chainOffsetOf(T, T) - Factory.chainOffsetOf(T, TextContent); + const sub: *T = @ptrFromInt(@intFromPtr(self) + offset); + if (comptime lp.IS_DEBUG) { + // This pointer dance only works because the factory allocates the chain + // in a contiguous block of memory. In debug, we assert this holds via + // the _proto_canary back pointer. + std.debug.assert(Factory.protoOf(sub) == self); } - if (self._type == .positioning) return self._type.positioning.is(T); + return sub; +} + +pub fn is(self: *TextContent, comptime T: type) ?*T { + switch (self._type) { + inline else => |tag| { + if (Subtype(tag) == T) { + return self.subtype(T); + } + }, + } + if (self._type == .positioning) return self.subtype(TextPositioning).is(T); return null; } diff --git a/src/browser/webapi/element/svg/TextPath.zig b/src/browser/webapi/element/svg/TextPath.zig index 627bb4605..dd5213292 100644 --- a/src/browser/webapi/element/svg/TextPath.zig +++ b/src/browser/webapi/element/svg/TextPath.zig @@ -16,6 +16,8 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +const lp = @import("lightpanda"); +const Factory = @import("../../../Factory.zig"); const js = @import("../../../js/js.zig"); const Frame = @import("../../../Frame.zig"); @@ -31,10 +33,11 @@ const TextContent = @import("TextContent.zig"); const TextPath = @This(); pub const Proto = TextContent; -_proto: *TextContent, +_pad: bool = false, +_proto_canary: if (lp.IS_DEBUG) *TextContent else void = undefined, pub fn asElement(self: *TextPath) *Element { - return self._proto.asElement(); + return Factory.protoOf(self).asElement(); } pub fn asNode(self: *TextPath) *Node { return self.asElement().asNode(); diff --git a/src/browser/webapi/element/svg/TextPositioning.zig b/src/browser/webapi/element/svg/TextPositioning.zig index 91052aed8..61567a4ea 100644 --- a/src/browser/webapi/element/svg/TextPositioning.zig +++ b/src/browser/webapi/element/svg/TextPositioning.zig @@ -16,6 +16,7 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +const std = @import("std"); const lp = @import("lightpanda"); const js = @import("../../../js/js.zig"); @@ -34,16 +35,37 @@ pub const Proto = TextContent; _type: Type, _proto_canary: if (lp.IS_DEBUG) *TextContent else void = undefined, -pub const Type = union(enum) { - text: *Text, - tspan: *TSpan, +pub const Type = enum(u8) { + text, + tspan, }; +pub fn Subtype(comptime tag: Type) type { + return switch (tag) { + .text => Text, + .tspan => TSpan, + }; +} + +pub fn subtype(self: *const TextPositioning, comptime T: type) *T { + const offset = comptime Factory.chainOffsetOf(T, T) - Factory.chainOffsetOf(T, TextPositioning); + const sub: *T = @ptrFromInt(@intFromPtr(self) + offset); + if (comptime lp.IS_DEBUG) { + // This pointer dance only works because the factory allocates the chain + // in a contiguous block of memory. In debug, we assert this holds via + // the _proto_canary back pointer. + std.debug.assert(Factory.protoOf(sub) == self); + } + return sub; +} + pub fn is(self: *TextPositioning, comptime T: type) ?*T { - inline for (@typeInfo(Type).@"union".fields) |field| { - if (@field(Type, field.name) == self._type) { - if (field.type == *T) return @field(self._type, field.name); - } + switch (self._type) { + inline else => |tag| { + if (Subtype(tag) == T) { + return self.subtype(T); + } + }, } return null; } diff --git a/src/browser/webapi/element/svg/Title.zig b/src/browser/webapi/element/svg/Title.zig index 65660827e..b12707012 100644 --- a/src/browser/webapi/element/svg/Title.zig +++ b/src/browser/webapi/element/svg/Title.zig @@ -16,6 +16,8 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +const lp = @import("lightpanda"); +const Factory = @import("../../../Factory.zig"); const js = @import("../../../js/js.zig"); const Node = @import("../../Node.zig"); @@ -26,10 +28,11 @@ const Svg = @import("../Svg.zig"); const Title = @This(); pub const Proto = Svg; -_proto: *Svg, +_pad: bool = false, +_proto_canary: if (lp.IS_DEBUG) *Svg else void = undefined, pub fn asElement(self: *Title) *Element { - return self._proto.asElement(); + return Factory.protoOf(self).asElement(); } pub fn asNode(self: *Title) *Node { return self.asElement().asNode(); diff --git a/src/browser/webapi/element/svg/Use.zig b/src/browser/webapi/element/svg/Use.zig index 8aa3b253f..300cfd3c3 100644 --- a/src/browser/webapi/element/svg/Use.zig +++ b/src/browser/webapi/element/svg/Use.zig @@ -16,6 +16,8 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +const lp = @import("lightpanda"); +const Factory = @import("../../../Factory.zig"); const js = @import("../../../js/js.zig"); const Frame = @import("../../../Frame.zig"); @@ -28,10 +30,11 @@ const Graphics = @import("Graphics.zig"); const Use = @This(); pub const Proto = Graphics; -_proto: *Graphics, +_pad: bool = false, +_proto_canary: if (lp.IS_DEBUG) *Graphics else void = undefined, pub fn asElement(self: *Use) *Element { - return self._proto.asElement(); + return Factory.protoOf(self).asElement(); } pub fn asNode(self: *Use) *Node { return self.asElement().asNode(); diff --git a/src/browser/webapi/element/svg/View.zig b/src/browser/webapi/element/svg/View.zig index 5554fced4..69a849828 100644 --- a/src/browser/webapi/element/svg/View.zig +++ b/src/browser/webapi/element/svg/View.zig @@ -16,6 +16,8 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +const lp = @import("lightpanda"); +const Factory = @import("../../../Factory.zig"); const js = @import("../../../js/js.zig"); const Node = @import("../../Node.zig"); @@ -26,10 +28,11 @@ const Svg = @import("../Svg.zig"); const View = @This(); pub const Proto = Svg; -_proto: *Svg, +_pad: bool = false, +_proto_canary: if (lp.IS_DEBUG) *Svg else void = undefined, pub fn asElement(self: *View) *Element { - return self._proto.asElement(); + return Factory.protoOf(self).asElement(); } pub fn asNode(self: *View) *Node { return self.asElement().asNode(); diff --git a/src/browser/webapi/net/Fetch.zig b/src/browser/webapi/net/Fetch.zig index 0832c1aa8..0e58aef76 100644 --- a/src/browser/webapi/net/Fetch.zig +++ b/src/browser/webapi/net/Fetch.zig @@ -152,7 +152,7 @@ fn httpHeaderDoneCallback(transfer: *Transfer) !Transfer.HeaderResult { const arena = self._response._arena; if (transfer.getContentLength()) |cl| { - try self._buf.ensureTotalCapacity(arena.allocator(), cl); + try self._buf.ensureTotalCapacityPrecise(arena.allocator(), cl); } const res = self._response; diff --git a/src/browser/webapi/net/Headers.zig b/src/browser/webapi/net/Headers.zig index 7bc7c8600..7d3915d3a 100644 --- a/src/browser/webapi/net/Headers.zig +++ b/src/browser/webapi/net/Headers.zig @@ -91,7 +91,7 @@ pub fn forEach(self: *Headers, cb_: js.Function, js_this_: ?js.Object) !void { const cb = if (js_this_) |js_this| try cb_.withThis(js_this) else cb_; for (self._list._entries.items) |entry| { - var caught: js.TryCatch.Caught = undefined; + var caught: js.TryCatch.Caught = .{}; cb.tryCall(void, .{ entry.value.str(), entry.name.str(), self }, &caught) catch { log.debug(.js, "forEach callback", .{ .caught = caught, .source = "headers" }); }; diff --git a/src/browser/webapi/net/WebSocket.zig b/src/browser/webapi/net/WebSocket.zig index f119bc386..aa594ad6d 100644 --- a/src/browser/webapi/net/WebSocket.zig +++ b/src/browser/webapi/net/WebSocket.zig @@ -901,7 +901,7 @@ fn _receivedDataCallback(conn: *http.Connection, data: []const u8) !void { if (meta.len > self._http_client.max_response_size) { return error.MessageTooLarge; } - try self._recv_buffer.ensureTotalCapacity(self._arena.allocator(), meta.len); + try self._recv_buffer.ensureTotalCapacityPrecise(self._arena.allocator(), meta.len); } try self._recv_buffer.appendSlice(self._arena.allocator(), data); diff --git a/src/browser/webapi/net/XMLHttpRequest.zig b/src/browser/webapi/net/XMLHttpRequest.zig index 75275b429..b55e925d9 100644 --- a/src/browser/webapi/net/XMLHttpRequest.zig +++ b/src/browser/webapi/net/XMLHttpRequest.zig @@ -541,7 +541,7 @@ fn httpHeaderDoneCallback(transfer: *Transfer) !Transfer.HeaderResult { self._response_status = transfer.responseStatus().?; if (transfer.getContentLength()) |cl| { self._response_len = cl; - try self._response_data.ensureTotalCapacity(self._arena.allocator(), cl); + try self._response_data.ensureTotalCapacityPrecise(self._arena.allocator(), cl); } self._response_url = try self._arena.dupeZ(u8, transfer.req.url); diff --git a/src/browser/webapi/svg/reflected_list.zig b/src/browser/webapi/svg/reflected_list.zig index 9138ccdc4..d375d6c74 100644 --- a/src/browser/webapi/svg/reflected_list.zig +++ b/src/browser/webapi/svg/reflected_list.zig @@ -175,7 +175,7 @@ pub fn Mixin(comptime List: type, comptime Item: type, comptime hooks: anytype) self._snapshot.clearRetainingCapacity(); try self._snapshot.appendSlice(frame.arena, raw); try retireAll(self, frame); - try self._items.ensureTotalCapacity(frame.arena, parsed.items.len); + try self._items.ensureTotalCapacityPrecise(frame.arena, parsed.items.len); for (parsed.items) |item| { self._items.appendAssumeCapacity(item); hooks.attach(self, item); diff --git a/src/cdp/CDP.zig b/src/cdp/CDP.zig index 2ed72c51b..9399a7821 100644 --- a/src/cdp/CDP.zig +++ b/src/cdp/CDP.zig @@ -193,7 +193,7 @@ pub fn terminateFromNetwork(self: *CDP) void { // valid for the duration of dispatch. pub fn onMessage(self: *CDP, c: *Inbox.Message.Cdp) anyerror!void { // Once a terminate is pending, don't dispatch - if (self.browser.env.isExecutionTerminating()) { + if (self.browser.env.terminatePending()) { return; } @@ -440,15 +440,28 @@ fn dispatchCommand(command: *Command, method: []const u8) !void { return error.UnknownDomain; } -fn isValidSessionId(self: *const CDP, input_session_id: []const u8) bool { +pub fn resolveSessionId(self: *const CDP, input_session_id: []const u8) ?[]const u8 { if (self.browser_session_id) |browser_session_id| { if (std.mem.eql(u8, browser_session_id, input_session_id)) { - return true; + return browser_session_id; } } - const browser_context = &(self.browser_context orelse return false); - const session_id = browser_context.session_id orelse return false; - return std.mem.eql(u8, session_id, input_session_id); + const browser_context = &(self.browser_context orelse return null); + if (browser_context.session_id) |session_id| { + if (std.mem.eql(u8, session_id, input_session_id)) { + return session_id; + } + } + for (browser_context.attached_sessions.items) |session| { + if (std.mem.eql(u8, session.id, input_session_id)) { + return session.id; + } + } + return null; +} + +fn isValidSessionId(self: *const CDP, input_session_id: []const u8) bool { + return self.resolveSessionId(input_session_id) != null; } pub fn createBrowserContext(self: *CDP) ![]const u8 { @@ -496,18 +509,22 @@ pub const BrowserContext = struct { data: std.ArrayList(u8), }; - // Key for `captured_responses`. Documents are keyed by `loader_id`, - // everything else by `request_id` — the two id-spaces are independent - // counters and overlap numerically (loader 1 / request 1, loader 2 / - // request 2, ...), so the map key has to carry the namespace or - // entries collide. The wire-format prefix (`LID-` / `REQ-`) provides - // the same disambiguation on lookup; see `idFromRequestId` in - // domains/network.zig. - pub const CapturedResponseKey = struct { + // Key for `captured_responses` / `captured_requests`. Documents are + // keyed by `loader_id`, everything else by `request_id` — the two + // id-spaces are independent counters and overlap numerically (loader 1 + // / request 1, loader 2 / request 2, ...), so the map key has to carry + // the namespace or entries collide. The wire-format prefix (`LID-` / + // `REQ-`) provides the same disambiguation on lookup; see + // `idFromRequestId` in domains/network.zig. + pub const CapturedKey = struct { kind: enum { request, loader }, id: u32, }; + pub const AttachedSession = struct { + id: []const u8, + parent_id: ?[]const u8, + }; id: []const u8, cdp: *CDP, @@ -550,6 +567,7 @@ pub const BrowserContext = struct { // if we get a request with a sessionId that doesn't match the current one // we should reject it. session_id: ?[]const u8, + attached_sessions: std.ArrayList(AttachedSession) = .empty, security_origin: []const u8, page_life_cycle_events: bool, @@ -572,6 +590,11 @@ pub const BrowserContext = struct { extra_headers: std.ArrayList(http.Header) = .empty, intercept_state: InterceptState, + fetch_session_id: ?[]const u8 = null, + + // Request bodies retained for Network.getRequestPostData, which can be + // called after the transfer is gone. Capped at max_post_data_size. + captured_requests: std.AutoHashMapUnmanaged(CapturedKey, []const u8), // When network is enabled, we'll capture the transfer.id -> body // This is awfully memory intensive, but our underlying http client and @@ -580,7 +603,7 @@ pub const BrowserContext = struct { // ever streamed. So if CDP is the only thing that needs bodies in // memory for an arbitrary amount of time, then that's where we're going // to store the, - captured_responses: std.AutoHashMapUnmanaged(CapturedResponseKey, CapturedResponse), + captured_responses: std.AutoHashMapUnmanaged(CapturedKey, CapturedResponse), notification: *Notification, @@ -632,6 +655,7 @@ pub const BrowserContext = struct { .arena = cdp.browser_context_arena.allocator(), .notification_arena = cdp.notification_arena.allocator(), .intercept_state = try InterceptState.init(allocator), + .captured_requests = .empty, .captured_responses = .empty, .notification = notification, }; @@ -714,6 +738,18 @@ pub const BrowserContext = struct { } pub fn createIsolatedWorld(self: *BrowserContext, world_name: []const u8, grant_universal_access: bool) !*IsolatedWorld { + // The name is the world's identity (matching Chrome). Clients re-issue + // this call after every navigation; appending a duplicate each time + // would grow the per-page context count without bound. + for (self.isolated_worlds.items) |world| { + if (std.mem.eql(u8, world.name, world_name)) { + if (world.grant_universal_access != grant_universal_access) { + log.warn(.cdp, "isolated world mismatch", .{ .name = world_name, .gua = grant_universal_access }); + } + return world; + } + } + const browser = &self.cdp.browser; const arena = try browser.arena_pool.acquire(.small, "IsolatedWorld"); errdefer arena.release(); @@ -820,17 +856,26 @@ pub const BrowserContext = struct { self.notification.unregister(.http_request_served_from_cache, self); } - pub fn fetchEnable(self: *BrowserContext, authRequests: bool) !void { + pub fn fetchEnable(self: *BrowserContext, authRequests: bool, session_id: []const u8) !void { self.fetchDisable(); //in case of multiple calls + self.fetch_session_id = session_id; try self.notification.register(.http_request_intercept, self, onHttpRequestIntercept); if (authRequests) { try self.notification.register(.http_request_auth_required, self, onHttpRequestAuthRequired); } } + pub fn fetchDisableForSession(self: *BrowserContext, session_id: []const u8) void { + const active_session_id = self.fetch_session_id orelse return; + if (std.mem.eql(u8, active_session_id, session_id)) { + self.fetchDisable(); + } + } + pub fn fetchDisable(self: *BrowserContext) void { self.notification.unregister(.http_request_intercept, self); self.notification.unregister(.http_request_auth_required, self); + self.fetch_session_id = null; } pub fn lifecycleEventsEnable(self: *BrowserContext) !void { @@ -951,12 +996,26 @@ pub const BrowserContext = struct { pub fn onHttpRequestStart(ctx: *anyopaque, msg: *const Notification.RequestStart) !void { const self: *BrowserContext = @ptrCast(@alignCast(ctx)); - try @import("domains/network.zig").httpRequestStart(self, msg); + { + // capture the request + const transfer = msg.transfer; + const key = keyFromTransfer(transfer); + const body = transfer.req.body orelse ""; + if (body.len == 0 or body.len > @import("domains/network.zig").max_post_data_size) { + _ = self.captured_requests.remove(key); + } else { + const owned_body = try self.frame_arena.dupe(u8, body); + try self.captured_requests.put(self.frame_arena, key, owned_body); + } + } + defer self.resetNotificationArena(); + try @import("domains/network.zig").httpRequestStart(self.notification_arena, self, msg); } pub fn onHttpRequestIntercept(ctx: *anyopaque, msg: *const Notification.RequestIntercept) !void { const self: *BrowserContext = @ptrCast(@alignCast(ctx)); - try @import("domains/fetch.zig").requestIntercept(self, msg); + defer self.resetNotificationArena(); + try @import("domains/fetch.zig").requestIntercept(self.notification_arena, self, msg); } pub fn onHttpRequestFail(ctx: *anyopaque, msg: *const Notification.RequestFail) !void { @@ -979,7 +1038,7 @@ pub const BrowserContext = struct { return @import("domains/page.zig").javascriptDialogOpening(self, msg); } - fn keyFromTransfer(transfer: *const Transfer) CDP.BrowserContext.CapturedResponseKey { + fn keyFromTransfer(transfer: *const Transfer) CDP.BrowserContext.CapturedKey { return if (transfer.req.resource_type == .document) .{ .kind = .loader, .id = transfer.req.loader_id } else @@ -1038,7 +1097,7 @@ pub const BrowserContext = struct { pub fn onHttpRequestAuthRequired(ctx: *anyopaque, data: *const Notification.RequestAuthRequired) !void { const self: *BrowserContext = @ptrCast(@alignCast(ctx)); defer self.resetNotificationArena(); - try @import("domains/fetch.zig").requestAuthRequired(self, data); + try @import("domains/fetch.zig").requestAuthRequired(self.notification_arena, self, data); } pub fn onHttpRequestServedFromCache(ctx: *anyopaque, msg: *const Notification.RequestServedFromCache) !void { @@ -1110,7 +1169,7 @@ pub const BrowserContext = struct { const message_len = msg.len + session_id.len + 1 + field.len + 10; var buf: std.ArrayList(u8) = .empty; - buf.ensureTotalCapacity(allocator, message_len) catch |err| { + buf.ensureTotalCapacityPrecise(allocator, message_len) catch |err| { log.err(.cdp, "inspector buffer", .{ .err = err }); return; }; diff --git a/src/cdp/Connection.zig b/src/cdp/Connection.zig index 860716d90..d328964bf 100644 --- a/src/cdp/Connection.zig +++ b/src/cdp/Connection.zig @@ -27,6 +27,7 @@ const ArenaPool = @import("../ArenaPool.zig"); const WS = @import("../network/WS.zig"); const sys_net = @import("../sys/net.zig"); +const header_parser = @import("../network/header_parser.zig"); const log = lp.log; const posix = std.posix; @@ -433,59 +434,58 @@ fn pushCdp(self: *Connection, bytes: []const u8) !bool { } pub fn upgrade(self: *Connection, request: []u8) !void { - // our caller already confirmed that we have a trailing \r\n\r\n - const request_line_end = std.mem.indexOfScalar(u8, request, '\r') orelse unreachable; - const request_line = request[0..request_line_end]; + // We need to extract the `Sec-WebSocket-Key` value. + var sec_websocket_key: []const u8 = ""; + // We need to make sure that we got all the necessary headers + values; + // a bit per required header. + const FOUND_UPGRADE: u8 = 1 << 0; // Upgrade: websocket + const FOUND_VERSION: u8 = 1 << 1; // Sec-WebSocket-Version: 13 + const FOUND_CONNECTION: u8 = 1 << 2; // Connection: upgrade + const FOUND_KEY: u8 = 1 << 3; // Sec-WebSocket-Key + const FOUND_ALL = FOUND_UPGRADE | FOUND_VERSION | FOUND_CONNECTION | FOUND_KEY; - if (!std.ascii.endsWithIgnoreCase(request_line, "http/1.1")) { + // A malformed request line maps to a 400 in processHttpRequest. + const method, _, const version, var header_iterator = header_parser.parseRequest(request) catch { + return error.InvalidProtocol; + }; + if (method != .get or version != .@"1.1") { return error.InvalidProtocol; } - // we need to extract the sec-websocket-key value - var key: []const u8 = ""; + var found_headers: u8 = 0; - // we need to make sure that we got all the necessary headers + values - var required_headers: u8 = 0; + // A malformed header maps to a 400 in processHttpRequest. + while (header_iterator.next() catch return error.InvalidRequest) |header| { + const key = header.key; + const value = header.value; - // can't std.mem.split because it forces the iterated value to be const - // (we could @constCast...) - - var buf = request[request_line_end + 2 ..]; - - while (buf.len > 4) { - const index = std.mem.indexOfScalar(u8, buf, '\r') orelse unreachable; - const separator = std.mem.indexOfScalar(u8, buf[0..index], ':') orelse return error.InvalidRequest; - - const name = std.mem.trim(u8, toLower(buf[0..separator]), &std.ascii.whitespace); - const value = std.mem.trim(u8, buf[(separator + 1)..index], &std.ascii.whitespace); - - if (std.mem.eql(u8, name, "upgrade")) { + // Header names are case-insensitive; `Header.parse` keeps their + // original casing. + if (std.ascii.eqlIgnoreCase(key, "upgrade")) { if (!std.ascii.eqlIgnoreCase("websocket", value)) { return error.InvalidUpgradeHeader; } - required_headers |= 1; - } else if (std.mem.eql(u8, name, "sec-websocket-version")) { + found_headers |= FOUND_UPGRADE; + } else if (std.ascii.eqlIgnoreCase(key, "sec-websocket-version")) { if (value.len != 2 or value[0] != '1' or value[1] != '3') { return error.InvalidVersionHeader; } - required_headers |= 2; - } else if (std.mem.eql(u8, name, "connection")) { + found_headers |= FOUND_VERSION; + } else if (std.ascii.eqlIgnoreCase(key, "connection")) { // find if connection header has upgrade in it, example header: // Connection: keep-alive, Upgrade if (std.ascii.indexOfIgnoreCase(value, "upgrade") == null) { return error.InvalidConnectionHeader; } - required_headers |= 4; - } else if (std.mem.eql(u8, name, "sec-websocket-key")) { - key = value; - required_headers |= 8; + found_headers |= FOUND_CONNECTION; + } else if (std.ascii.eqlIgnoreCase(key, "sec-websocket-key")) { + sec_websocket_key = value; + found_headers |= FOUND_KEY; } - - const next = index + 2; - buf = buf[next..]; } - if (required_headers != 15) { + // Check if we've received all related headers. + if (found_headers != FOUND_ALL) { return error.MissingHeaders; } @@ -513,7 +513,7 @@ pub fn upgrade(self: *Connection, request: []u8) !void { const key_pos = res.len - 32; var h: [20]u8 = undefined; var hasher = std.crypto.hash.Sha1.init(.{}); - hasher.update(key); + hasher.update(sec_websocket_key); // websocket spec always used this value hasher.update("258EAFA5-E914-47DA-95CA-C5AB0DC85B11"); hasher.final(&h); @@ -598,11 +598,3 @@ fn websocketHeader(buf: []u8, op_code: WS.OpCode, payload_len: usize) []const u8 buf[9] = @intCast(len & 0xFF); return buf[0..10]; } - -// In-place string lowercase -fn toLower(str: []u8) []u8 { - for (str, 0..) |ch, i| { - str[i] = std.ascii.toLower(ch); - } - return str; -} diff --git a/src/cdp/domains/dom.zig b/src/cdp/domains/dom.zig index 3b0505142..99bb8260f 100644 --- a/src/cdp/domains/dom.zig +++ b/src/cdp/domains/dom.zig @@ -346,32 +346,33 @@ fn resolveNode(cmd: *CDP.Command) !void { const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; const frame = bc.mainFrame() orelse return error.FrameNotLoaded; - var ls: ?js.Local.Scope = null; - defer if (ls) |*_ls| { - _ls.deinit(); + var ls: js.Local.Scope = undefined; + var ls_open = false; + defer if (ls_open) { + ls.deinit(); }; if (params.executionContextId) |context_id| blk: { - ls = undefined; - frame.js.localScope(&ls.?); - if (ls.?.local.debugContextId() == context_id) { + frame.js.localScope(&ls); + ls_open = true; + if (ls.local.debugContextId() == context_id) { break :blk; } // not the default scope, check the other ones for (bc.isolated_worlds.items) |isolated_world| { - ls.?.deinit(); - ls = null; + ls.deinit(); + ls_open = false; const ctx = (isolated_world.context orelse return error.ContextNotFound); - ls = undefined; - ctx.localScope(&ls.?); - if (ls.?.local.debugContextId() == context_id) { + ctx.localScope(&ls); + ls_open = true; + if (ls.local.debugContextId() == context_id) { break :blk; } } else return error.ContextNotFound; } else { - ls = undefined; - frame.js.localScope(&ls.?); + frame.js.localScope(&ls); + ls_open = true; } const input_node_id = params.nodeId orelse params.backendNodeId orelse return error.InvalidParam; @@ -380,7 +381,7 @@ fn resolveNode(cmd: *CDP.Command) !void { // node._node is a *DOMNode we need this to be able to find its most derived type e.g. Node -> Element -> HTMLElement // So we use the Node.Union when retrieve the value from the environment const remote_object = try bc.inspector_session.getRemoteObject( - &ls.?.local, + &ls.local, params.objectGroup orelse "", node.dom, ); diff --git a/src/cdp/domains/fetch.zig b/src/cdp/domains/fetch.zig index c32bf0d03..733dd8b77 100644 --- a/src/cdp/domains/fetch.zig +++ b/src/cdp/domains/fetch.zig @@ -50,15 +50,15 @@ pub fn processMessage(cmd: *CDP.Command) !void { } } -// Stored in CDP. Holds *transfer ids* (not *Transfer pointers) of paused -// transfers waiting for CDP continueRequest/fulfillRequest/failRequest/ -// continueWithAuth. Anyone resolving an entry must look the transfer up via -// `Client.findTransfer(id)` — if the transfer has been destroyed out-of-band -// (e.g. frame shutdown), the lookup returns null and the CDP command should -// no-op rather than UAF. +// Stored in CDP. Maps intercept ids to *transfer ids* (not *Transfer +// pointers) of paused transfers waiting for CDP continueRequest/ +// fulfillRequest/failRequest/continueWithAuth. Anyone resolving an entry must +// look the transfer up via `Client.findTransfer(id)` — if the transfer has been +// destroyed out-of-band (e.g. frame shutdown), the lookup returns null. pub const InterceptState = struct { allocator: Allocator, - waiting: std.AutoArrayHashMapUnmanaged(u32, void), + next_id: u32 = 1, + waiting: std.AutoArrayHashMapUnmanaged(u32, u32), pub fn init(allocator: Allocator) !InterceptState { return .{ @@ -67,17 +67,17 @@ pub const InterceptState = struct { }; } - pub fn empty(self: *const InterceptState) bool { - return self.waiting.count() == 0; + pub fn put(self: *InterceptState, transfer_id: u32) !u32 { + const intercept_id = self.next_id; + try self.waiting.put(self.allocator, intercept_id, transfer_id); + self.next_id +%= 1; + return intercept_id; } - pub fn put(self: *InterceptState, transfer_id: u32) !void { - return self.waiting.put(self.allocator, transfer_id, {}); - } - - // Returns true if the id was present and removed, false otherwise. - pub fn remove(self: *InterceptState, transfer_id: u32) bool { - return self.waiting.swapRemove(transfer_id); + // Returns the transfer id if the intercept id was present and removed. + pub fn remove(self: *InterceptState, intercept_id: u32) ?u32 { + const entry = self.waiting.fetchSwapRemove(intercept_id) orelse return null; + return entry.value; } pub fn deinit(self: *InterceptState) void { @@ -85,7 +85,7 @@ pub const InterceptState = struct { } pub fn pendingIntercepts(self: *const InterceptState) []u32 { - return self.waiting.keys(); + return self.waiting.values(); } }; @@ -143,9 +143,16 @@ const ErrorReason = enum { BlockedByResponse, }; +fn commandSessionId(cmd: *CDP.Command, bc: *CDP.BrowserContext) ![]const u8 { + if (cmd.input.session_id) |session_id| { + return cmd.cdp.resolveSessionId(session_id) orelse error.UnknownSessionId; + } + return bc.session_id orelse error.UnknownSessionId; +} + fn disable(cmd: *CDP.Command) !void { const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; - bc.fetchDisable(); + bc.fetchDisableForSession(try commandSessionId(cmd, bc)); return cmd.sendResult(null, .{}); } @@ -157,7 +164,7 @@ fn enable(cmd: *CDP.Command) !void { } const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; - try bc.fetchEnable(params.handleAuthRequests); + try bc.fetchEnable(params.handleAuthRequests, try commandSessionId(cmd, bc)); return cmd.sendResult(null, .{}); } @@ -188,22 +195,21 @@ fn arePatternsSupported(patterns: []RequestPattern) bool { return true; } -pub fn requestIntercept(bc: *CDP.BrowserContext, intercept: *const Notification.RequestIntercept) !void { - // detachTarget could be called, in which case, we still have a frame doing - // things, but no session. - const session_id = bc.session_id orelse return; +pub fn requestIntercept(arena: Allocator, bc: *CDP.BrowserContext, intercept: *const Notification.RequestIntercept) !void { + // The session that enabled Fetch owns its interception events. + const session_id = bc.fetch_session_id orelse return; // We keep it around to wait for modifications to the request. // TODO: What to do when receiving replies for a previous frame's requests? const transfer = intercept.transfer; - try bc.intercept_state.put(transfer.id); - errdefer _ = bc.intercept_state.remove(transfer.id); + const intercept_id = try bc.intercept_state.put(transfer.id); + errdefer _ = bc.intercept_state.remove(intercept_id); try bc.cdp.sendEvent("Fetch.requestPaused", .{ - .requestId = &id.toInterceptId(transfer.id), + .requestId = &id.toInterceptId(intercept_id), .frameId = &id.toFrameId(transfer.req.frame_id), - .request = network.RequestWriter.init(transfer), + .request = network.RequestWriter.init(arena, transfer), .resourceType = transfer.req.resource_type.string(), .networkId = &id.toRequestId(transfer), // matches the Network REQ-ID }, .{ .session_id = session_id }); @@ -235,9 +241,9 @@ fn continueRequest(cmd: *CDP.Command) !void { const client = &bc.cdp.browser.http_client; var intercept_state = &bc.intercept_state; - const transfer_id = try idFromRequestId(params.requestId); + const intercept_id = try idFromRequestId(params.requestId); - if (!intercept_state.remove(transfer_id)) return error.RequestNotFound; + const transfer_id = intercept_state.remove(intercept_id) orelse return error.RequestNotFound; // Transfer may have been destroyed out-of-band between pause and now // (e.g. frame shutdown). Treat as a no-op rather than an error — the CDP // client's view of "this request still exists" is just stale. @@ -298,9 +304,9 @@ fn continueWithAuth(cmd: *CDP.Command) !void { const client = &bc.cdp.browser.http_client; var intercept_state = &bc.intercept_state; - const transfer_id = try idFromRequestId(params.requestId); + const intercept_id = try idFromRequestId(params.requestId); - if (!intercept_state.remove(transfer_id)) return error.RequestNotFound; + const transfer_id = intercept_state.remove(intercept_id) orelse return error.RequestNotFound; const transfer = client.findTransfer(transfer_id) orelse { log.debug(.cdp, "intercept lookup miss", .{ .id = transfer_id, .op = "auth" }); return cmd.sendResult(null, .{}); @@ -356,9 +362,9 @@ fn fulfillRequest(cmd: *CDP.Command) !void { const client = &bc.cdp.browser.http_client; var intercept_state = &bc.intercept_state; - const transfer_id = try idFromRequestId(params.requestId); + const intercept_id = try idFromRequestId(params.requestId); - if (!intercept_state.remove(transfer_id)) return error.RequestNotFound; + const transfer_id = intercept_state.remove(intercept_id) orelse return error.RequestNotFound; const transfer = client.findTransfer(transfer_id) orelse { log.debug(.cdp, "intercept lookup miss", .{ .id = transfer_id, .op = "fulfill" }); return cmd.sendResult(null, .{}); @@ -393,9 +399,9 @@ fn failRequest(cmd: *CDP.Command) !void { const client = &bc.cdp.browser.http_client; var intercept_state = &bc.intercept_state; - const transfer_id = try idFromRequestId(params.requestId); + const intercept_id = try idFromRequestId(params.requestId); - if (!intercept_state.remove(transfer_id)) return error.RequestNotFound; + const transfer_id = intercept_state.remove(intercept_id) orelse return error.RequestNotFound; const transfer = client.findTransfer(transfer_id) orelse { log.debug(.cdp, "intercept lookup miss", .{ .id = transfer_id, .op = "fail" }); return cmd.sendResult(null, .{}); @@ -412,26 +418,24 @@ fn failRequest(cmd: *CDP.Command) !void { return cmd.sendResult(null, .{}); } -pub fn requestAuthRequired(bc: *CDP.BrowserContext, intercept: *const Notification.RequestAuthRequired) !void { - // detachTarget could be called, in which case, we still have a frame doing - // things, but no session. - const session_id = bc.session_id orelse return; +pub fn requestAuthRequired(arena: Allocator, bc: *CDP.BrowserContext, intercept: *const Notification.RequestAuthRequired) !void { + const session_id = bc.fetch_session_id orelse return; // We keep it around to wait for modifications to the request. // NOTE: we assume whomever created the request created it with a lifetime of the Page. // TODO: What to do when receiving replies for a previous frame's requests? const transfer = intercept.transfer; - try bc.intercept_state.put(transfer.id); - errdefer _ = bc.intercept_state.remove(transfer.id); + const intercept_id = try bc.intercept_state.put(transfer.id); + errdefer _ = bc.intercept_state.remove(intercept_id); const request = &transfer.req; const challenge = transfer._auth_challenge orelse return error.NullAuthChallenge; try bc.cdp.sendEvent("Fetch.authRequired", .{ - .requestId = &id.toInterceptId(transfer.id), + .requestId = &id.toInterceptId(intercept_id), .frameId = &id.toFrameId(request.frame_id), - .request = network.RequestWriter.init(transfer), + .request = network.RequestWriter.init(arena, transfer), .resourceType = request.resource_type.string(), .authChallenge = .{ .origin = "", // TODO get origin, could be the proxy address for example. @@ -459,3 +463,55 @@ fn idFromRequestId(request_id: []const u8) !u32 { } return std.fmt.parseInt(u32, request_id[4..], 10) catch return error.InvalidParams; } + +const testing = @import("../testing.zig"); +test "cdp.Fetch: interception events belong to the enabling session" { + var ctx = try testing.context(); + defer ctx.deinit(); + const bc = try ctx.loadBrowserContext(.{ + .session_id = "SID-PRIMARY", + .target_id = "TID-000000000B".*, + }); + try bc.attached_sessions.append(bc.arena, .{ + .id = "SID-AUX", + .parent_id = null, + }); + + try ctx.processMessage(.{ + .id = 1, + .method = "Fetch.enable", + .sessionId = "SID-AUX", + }); + try testing.expect(std.mem.eql(u8, "SID-AUX", bc.fetch_session_id.?)); + try ctx.expectSentResult(null, .{ .id = 1, .session_id = "SID-AUX" }); + + try ctx.processMessage(.{ + .id = 2, + .method = "Fetch.disable", + .sessionId = "SID-PRIMARY", + }); + try testing.expect(std.mem.eql(u8, "SID-AUX", bc.fetch_session_id.?)); + + try ctx.processMessage(.{ + .id = 3, + .method = "Fetch.disable", + .sessionId = "SID-AUX", + }); + try testing.expectEqual(null, bc.fetch_session_id); +} + +test "cdp.Fetch: InterceptState issues a fresh id per pause" { + var state = try InterceptState.init(testing.allocator); + defer state.deinit(); + + const first = try state.put(7); + const second = try state.put(7); + try testing.expectEqual(false, first == second); + try testing.expectEqual(2, state.pendingIntercepts().len); + try testing.expectEqual(7, state.pendingIntercepts()[0]); + + try testing.expectEqual(7, state.remove(first).?); + try testing.expectEqual(null, state.remove(first)); + try testing.expectEqual(7, state.remove(second).?); + try testing.expectEqual(null, state.remove(second)); +} diff --git a/src/cdp/domains/lp.zig b/src/cdp/domains/lp.zig index 9313e2e5b..8c4617c72 100644 --- a/src/cdp/domains/lp.zig +++ b/src/cdp/domains/lp.zig @@ -344,7 +344,7 @@ fn waitForSelector(cmd: anytype) !void { const bc = cmd.browser_context orelse return error.NoBrowserContext; const frame = bc.mainFrame() orelse return error.FrameNotLoaded; - const timeout_ms = params.timeout orelse 5000; + const timeout_ms = params.timeout orelse lp.tools.defaultWaitTimeout(frame); const selector_z = try cmd.arena.dupeZ(u8, params.selector); const node = lp.actions.waitForSelector(selector_z, timeout_ms, frame._frame_id, bc.session) catch |err| { diff --git a/src/cdp/domains/network.zig b/src/cdp/domains/network.zig index a08f85518..d8e334ac9 100644 --- a/src/cdp/domains/network.zig +++ b/src/cdp/domains/network.zig @@ -37,6 +37,7 @@ const CdpStorage = @import("storage.zig"); const log = lp.log; const Allocator = std.mem.Allocator; +pub const max_post_data_size = 64 * 1024; pub fn processMessage(cmd: *CDP.Command) !void { const action = std.meta.stringToEnum(enum { @@ -55,6 +56,7 @@ pub fn processMessage(cmd: *CDP.Command) !void { getCookies, getAllCookies, getResponseBody, + getRequestPostData, }, cmd.input.action) orelse return error.UnknownMethod; switch (action) { @@ -73,6 +75,7 @@ pub fn processMessage(cmd: *CDP.Command) !void { .getCookies => return getCookies(cmd), .getAllCookies => return getAllCookies(cmd), .getResponseBody => return getResponseBody(cmd), + .getRequestPostData => return getRequestPostData(cmd), } } @@ -322,6 +325,18 @@ fn getResponseBody(cmd: *CDP.Command) !void { }, .{}); } +fn getRequestPostData(cmd: *CDP.Command) !void { + const params = (try cmd.params(struct { + requestId: []const u8, // "REQ-{d}" or "LID-{d}" + })) orelse return error.InvalidParams; + + const key = try keyFromRequestId(params.requestId); + const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; + const body = bc.captured_requests.get(key) orelse return error.RequestNotFound; + + return cmd.sendResult(.{ .postData = SafeString.wrap(body) }, .{}); +} + pub fn httpRequestFail(bc: *CDP.BrowserContext, msg: *const Notification.RequestFail) !void { // It's possible that the request failed because we aborted when the client // sent Target.closeTarget. In that case, bc.session_id will be cleared @@ -344,7 +359,7 @@ pub fn httpRequestFail(bc: *CDP.BrowserContext, msg: *const Notification.Request }, .{ .session_id = session_id }); } -pub fn httpRequestStart(bc: *CDP.BrowserContext, msg: *const Notification.RequestStart) !void { +pub fn httpRequestStart(arena: Allocator, bc: *CDP.BrowserContext, msg: *const Notification.RequestStart) !void { // detachTarget could be called, in which case, we still have a frame doing // things, but no session. const session_id = bc.session_id orelse return; @@ -368,7 +383,7 @@ pub fn httpRequestStart(bc: *CDP.BrowserContext, msg: *const Notification.Reques .loaderId = &id.toLoaderId(req.loader_id), .type = req.resource_type.string(), .documentURL = frame.url, - .request = RequestWriter.init(transfer), + .request = RequestWriter.init(arena, transfer), .initiator = .{ .type = "other" }, .redirectHasExtraInfo = false, // TODO change after adding Network.requestWillBeSentExtraInfo .hasUserGesture = false, @@ -418,10 +433,12 @@ pub fn httpServedFromCache(bc: *CDP.BrowserContext, msg: *const Notification.Req } pub const RequestWriter = struct { + arena: Allocator, transfer: *Transfer, - pub fn init(transfer: *Transfer) RequestWriter { + pub fn init(arena: Allocator, transfer: *Transfer) RequestWriter { return .{ + .arena = arena, .transfer = transfer, }; } @@ -458,6 +475,22 @@ pub const RequestWriter = struct { try jws.write(request.body != null); } + if (request.body) |body| { + if (body.len <= max_post_data_size) { + try jws.objectField("postData"); + try jws.write(SafeString.wrap(body)); + + // postDataEntries is the binary-safe representation + // (postData is lossy for non-UTF-8 bodies). + const encoder = std.base64.standard.Encoder; + const encoded = try self.arena.alloc(u8, encoder.calcSize(body.len)); + try jws.objectField("postDataEntries"); + try jws.write(&[_]struct { bytes: []const u8 }{ + .{ .bytes = encoder.encode(encoded, body) }, + }); + } + } + { try jws.objectField("initialPriority"); try jws.write(initialPriority(request.resource_type)); @@ -625,7 +658,11 @@ fn securityState(url: [:0]const u8) []const u8 { return "unknown"; } -fn keyFromRequestId(request_id: []const u8) !CDP.BrowserContext.CapturedResponseKey { +fn keyFromRequestId(request_id: []const u8) !CDP.BrowserContext.CapturedKey { + if (request_id.len < 4) { + return error.InvalidParams; + } + const key = std.fmt.parseInt(u32, request_id[4..], 10) catch return error.InvalidParams; return if (std.mem.startsWith(u8, request_id, "LID-")) @@ -1078,6 +1115,64 @@ test "cdp.Network: setBlockedURLs blocks requests with inspector reason" { try testing.expectEqual(error.UrlBlocked, error_context.err.?); } +test "cdp.Network: POST body exposed as postData" { + var ctx = try testing.context(); + defer ctx.deinit(); + + const bc = try ctx.loadBrowserContext(.{ .id = "BID-PD", .session_id = "SID-PD" }); + const page = try bc.session.createPage(); + const client = &bc.cdp.browser.http_client; + + try ctx.processMessage(.{ .id = 1, .method = "Network.enable" }); + try ctx.expectSentResult(null, .{ .id = 1 }); + + var request_id: [14]u8 = undefined; + _ = std.fmt.bufPrint(&request_id, "REQ-{d:0>10}", .{client.next_request_id +% 1}) catch unreachable; + + // \xE9 exercises the Latin-1 -> UTF-8 transcode in postData; + // postDataEntries carry the raw bytes in base64. + const body = "name=Zig¬e=caf\xE9"; + + try client.request(.{ + .frame_id = page.frame_id, + .loader_id = 1, + .method = .POST, + .url = "http://127.0.0.1:9582/echo_body", + .body = body, + .cookie_jar = null, + .cookie_origin = "http://127.0.0.1:9582/", + .resource_type = .fetch, + .notification = bc.session.notification, + .shutdown_callback = HttpClient.noopShutdown, + }, null); + + try ctx.expectSentEvent("Network.requestWillBeSent", .{ + .requestId = &request_id, + .request = .{ + .method = "POST", + .hasPostData = true, + .postData = "name=Zig¬e=café", + .postDataEntries = &[_]struct { bytes: []const u8 }{ + .{ .bytes = "bmFtZT1aaWcmbm90ZT1jYWbp" }, + }, + }, + }, .{ .session_id = "SID-PD" }); + + try ctx.processMessage(.{ + .id = 2, + .method = "Network.getRequestPostData", + .params = .{ .requestId = &request_id }, + }); + try ctx.expectSentResult(.{ .postData = "name=Zig¬e=café" }, .{ .id = 2 }); + + try ctx.processMessage(.{ + .id = 3, + .method = "Network.getRequestPostData", + .params = .{ .requestId = "REQ-4294967295" }, + }); + try ctx.expectSentError(-31998, "RequestNotFound", .{ .id = 3 }); +} + test "cdp.Network: worker requests emit network events" { var ctx = try testing.context(); defer ctx.deinit(); diff --git a/src/cdp/domains/page.zig b/src/cdp/domains/page.zig index 8215990ee..0b3e89338 100644 --- a/src/cdp/domains/page.zig +++ b/src/cdp/domains/page.zig @@ -244,6 +244,17 @@ fn createIsolatedWorld(cmd: *CDP.Command) !void { const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; const world = try bc.createIsolatedWorld(params.worldName, params.grantUniveralAccess); + + // An existing world already has a live, inspector-registered context for + // the current document: return its id without re-registering. + if (world.context) |js_context| { + var ls: js.Local.Scope = undefined; + js_context.localScope(&ls); + defer ls.deinit(); + const context_id = bc.inspector_session.inspector.getContextId(&ls.local); + return cmd.sendResult(.{ .executionContextId = context_id }, .{}); + } + const frame = bc.mainFrame() orelse return error.FrameNotLoaded; const js_context = try world.createContext(frame); @@ -531,11 +542,12 @@ pub fn frameCreated(bc: *CDP.BrowserContext, frame: *Frame) !void { _ = try isolated_world.createContext(frame); } - if (!in_commit) { + if (in_commit == false) { // Only retain captured responses until a navigation event. In CDP // terms, this is called a "renderer" and the cache-duration can be // controlled via Network.configureDurableMessages (which we don't // support). + bc.captured_requests = .empty; bc.captured_responses = .empty; } } @@ -1127,6 +1139,36 @@ test "cdp.frame: getFrameTree" { } } +test "cdp.frame: createIsolatedWorld is idempotent per name" { + var ctx = try testing.context(); + defer ctx.deinit(); + + const bc = try ctx.loadBrowserContext(.{ .id = "BID-9", .url = "hi.html", .target_id = "FID-000000000X".* }); + + try ctx.processMessage(.{ .id = 20, .method = "Page.createIsolatedWorld", .params = .{ + .frameId = "FID-000000000X", + .worldName = "utility", + .grantUniveralAccess = true, + } }); + try testing.expectEqual(1, bc.isolated_worlds.items.len); + const world_context = bc.isolated_worlds.items[0].context.?; + + try ctx.processMessage(.{ .id = 21, .method = "Page.createIsolatedWorld", .params = .{ + .frameId = "FID-000000000X", + .worldName = "utility", + .grantUniveralAccess = true, + } }); + try testing.expectEqual(1, bc.isolated_worlds.items.len); + try testing.expectEqual(world_context, bc.isolated_worlds.items[0].context.?); + + try ctx.processMessage(.{ .id = 22, .method = "Page.createIsolatedWorld", .params = .{ + .frameId = "FID-000000000X", + .worldName = "other", + .grantUniveralAccess = true, + } }); + try testing.expectEqual(2, bc.isolated_worlds.items.len); +} + test "cdp.frame: child frame metadata" { var ctx = try testing.context(); defer ctx.deinit(); diff --git a/src/cdp/domains/target.zig b/src/cdp/domains/target.zig index 703cd3d5f..e764e9c7f 100644 --- a/src/cdp/domains/target.zig +++ b/src/cdp/domains/target.zig @@ -246,9 +246,37 @@ fn attachToTarget(cmd: *CDP.Command) !void { return error.UnknownTargetId; } - try doAttachtoTarget(cmd, target_id); + // The first attach becomes the page's primary session, which all + // Page/Network/... events are emitted to. Later attaches — e.g. + // Playwright's browserContext.newCDPSession(page), sent through its + // browser session while the page session is live — get a distinct + // auxiliary session so they don't collide with the primary one. + if (bc.session_id == null) { + try doAttachtoTarget(cmd, target_id); + return cmd.sendResult(.{ .sessionId = bc.session_id }, .{}); + } - return cmd.sendResult(.{ .sessionId = bc.session_id }, .{}); + const parent_id = if (cmd.input.session_id) |session_id| + cmd.cdp.resolveSessionId(session_id) orelse return error.UnknownSessionId + else + null; + const session_id = try bc.arena.dupe(u8, cmd.cdp.session_id_gen.next()); + try bc.attached_sessions.append(bc.arena, .{ + .id = session_id, + .parent_id = parent_id, + }); + + try cmd.sendEvent("Target.attachedToTarget", AttachToTarget{ + .sessionId = session_id, + .targetInfo = TargetInfo{ + .targetId = target_id, + .title = bc.getTitle() orelse "", + .url = bc.getURL() orelse "about:blank", + .browserContextId = bc.id, + }, + }, .{ .session_id = parent_id }); + + return cmd.sendResult(.{ .sessionId = session_id }, .{}); } fn attachToBrowserTarget(cmd: *CDP.Command) !void { @@ -290,8 +318,22 @@ fn closeTarget(cmd: *CDP.Command) !void { try cmd.sendResult(.{ .success = true }, .{ .include_session_id = false }); + for (bc.attached_sessions.items) |session| { + bc.fetchDisableForSession(session.id); + try cmd.sendEvent("Inspector.detached", .{ + .reason = "Render process gone.", + }, .{ .session_id = session.id }); + try cmd.sendEvent("Target.detachedFromTarget", .{ + .targetId = target_id, + .sessionId = session.id, + .reason = "Render process gone.", + }, .{ .session_id = session.parent_id }); + } + bc.attached_sessions.clearRetainingCapacity(); + // could be null, created but never attached if (bc.session_id) |session_id| { + bc.fetchDisableForSession(session_id); // Inspector.detached event try cmd.sendEvent("Inspector.detached", .{ .reason = "Render process gone.", @@ -386,8 +428,33 @@ fn sendMessageToTarget(cmd: *CDP.Command) !void { } fn detachFromTarget(cmd: *CDP.Command) !void { + const Params = struct { + sessionId: ?[]const u8 = null, + targetId: ?[]const u8 = null, + }; + const params = (try cmd.params(Params)) orelse Params{}; + if (cmd.browser_context) |bc| { + if (params.sessionId) |requested_session_id| { + for (bc.attached_sessions.items, 0..) |session, index| { + if (!std.mem.eql(u8, session.id, requested_session_id)) continue; + + _ = bc.attached_sessions.orderedRemove(index); + bc.fetchDisableForSession(session.id); + try cmd.sendEvent("Target.detachedFromTarget", .{ + .sessionId = session.id, + }, .{ .session_id = session.parent_id }); + return cmd.sendResult(null, .{}); + } + + const session_id = bc.session_id orelse return error.UnknownSessionId; + if (!std.mem.eql(u8, session_id, requested_session_id)) { + return error.UnknownSessionId; + } + } + if (bc.session_id) |session_id| { + bc.fetchDisableForSession(session_id); try cmd.sendEvent("Target.detachedFromTarget", .{ .sessionId = session_id, }, .{}); @@ -418,6 +485,7 @@ fn setAutoAttach(cmd: *CDP.Command) !void { // detach from all currently attached targets. if (cmd.browser_context) |bc| { if (bc.session_id) |session_id| { + bc.fetchDisableForSession(session_id); try cmd.sendEvent("Target.detachedFromTarget", .{ .sessionId = session_id, }, .{}); @@ -465,7 +533,9 @@ fn setAutoAttach(cmd: *CDP.Command) !void { fn doAttachtoTarget(cmd: *CDP.Command, target_id: []const u8) !void { const bc = cmd.browser_context.?; - const session_id = bc.session_id orelse cmd.cdp.session_id_gen.next(); + const session_id = bc.session_id orelse blk: { + break :blk try bc.arena.dupe(u8, cmd.cdp.session_id_gen.next()); + }; if (bc.session_id == null) { // extra_headers should not be kept on a new frame or tab, @@ -746,13 +816,52 @@ test "cdp.target: attachToTarget" { } { + // the first attach becomes the primary session try ctx.processMessage(.{ .id = 11, .method = "Target.attachToTarget", .params = .{ .targetId = "TID-000000000B" } }); const session_id = bc.session_id.?; try ctx.expectSentResult(.{ .sessionId = session_id }, .{ .id = 11 }); try ctx.expectSentEvent("Target.attachedToTarget", .{ .sessionId = session_id, .targetInfo = .{ .url = "about:blank", .title = "", .attached = true, .type = "page", .canAccessOpener = false, .browserContextId = "BID-9", .targetId = bc.target_id.? } }, .{}); + try testing.expectEqual(0, bc.attached_sessions.items.len); } } +test "cdp.target: auxiliary session is unique and routed through its parent" { + var ctx = try testing.context(); + defer ctx.deinit(); + const bc = try ctx.loadBrowserContext(.{ + .id = "BID-9", + .session_id = "SID-PRIMARY", + .target_id = "TID-000000000B".*, + }); + + try ctx.processMessage(.{ .id = 1, .method = "Target.attachToBrowserTarget" }); + try ctx.processMessage(.{ + .id = 2, + .method = "Target.attachToTarget", + .sessionId = "BSID-1", + .params = .{ .targetId = "TID-000000000B" }, + }); + + const session_id = bc.attached_sessions.items[0].id; + try testing.expect(!std.mem.eql(u8, session_id, bc.session_id.?)); + try ctx.expectSentEvent("Target.attachedToTarget", .{ + .sessionId = session_id, + .targetInfo = .{ + .url = "about:blank", + .title = "", + .attached = true, + .type = "page", + .canAccessOpener = false, + .browserContextId = "BID-9", + .targetId = "TID-000000000B", + }, + }, .{ .index = 2, .session_id = "BSID-1" }); + try ctx.expectSentResult(.{ .sessionId = session_id }, .{ + .id = 2, + .session_id = "BSID-1", + }); +} + test "cdp.target: getTargetInfo" { var ctx = try testing.context(); defer ctx.deinit(); @@ -823,23 +932,39 @@ test "cdp.target: detachFromTarget" { var ctx = try testing.context(); defer ctx.deinit(); const bc = try ctx.loadBrowserContext(.{ .id = "BID-9" }); - { - try ctx.processMessage(.{ .id = 10, .method = "Target.createTarget", .params = .{ .browserContextId = "BID-9" } }); - try testing.expectEqual(true, bc.target_id != null); - try ctx.expectSentResult(.{ .targetId = bc.target_id.? }, .{ .id = 10 }); + try ctx.processMessage(.{ .id = 10, .method = "Target.createTarget", .params = .{ .browserContextId = "BID-9" } }); + try ctx.processMessage(.{ .id = 11, .method = "Target.attachToTarget", .params = .{ .targetId = bc.target_id.? } }); + const session_id = bc.session_id.?; - try ctx.processMessage(.{ .id = 11, .method = "Target.attachToTarget", .params = .{ .targetId = bc.target_id.? } }); - const session_id = bc.session_id.?; - try ctx.expectSentResult(.{ .sessionId = session_id }, .{ .id = 11 }); + try ctx.processMessage(.{ .id = 12, .method = "Target.detachFromTarget", .params = .{ .sessionId = session_id } }); - try ctx.processMessage(.{ .id = 12, .method = "Target.detachFromTarget", .params = .{ .targetId = bc.target_id.? } }); - try ctx.expectSentEvent("Target.detachedFromTarget", .{ .sessionId = session_id }, .{}); - try testing.expectEqual(null, bc.session_id); - try ctx.expectSentResult(null, .{ .id = 12 }); + try ctx.expectSentEvent("Target.detachedFromTarget", .{ .sessionId = session_id }, .{}); + try testing.expectEqual(null, bc.session_id); + try ctx.expectSentResult(null, .{ .id = 12 }); - try ctx.processMessage(.{ .id = 13, .method = "Target.attachToTarget", .params = .{ .targetId = bc.target_id.? } }); - try ctx.expectSentResult(.{ .sessionId = bc.session_id.? }, .{ .id = 13 }); - } + try ctx.processMessage(.{ .id = 13, .method = "Target.attachToTarget", .params = .{ .targetId = bc.target_id.? } }); + try testing.expect(!std.mem.eql(u8, session_id, bc.session_id.?)); + try ctx.expectSentResult(.{ .sessionId = bc.session_id.? }, .{ .id = 13 }); +} + +test "cdp.target: detachFromTarget auxiliary session" { + var ctx = try testing.context(); + defer ctx.deinit(); + const bc = try ctx.loadBrowserContext(.{ + .id = "BID-9", + .session_id = "SID-PRIMARY", + .target_id = "TID-000000000B".*, + }); + + try ctx.processMessage(.{ .id = 10, .method = "Target.attachToTarget", .params = .{ .targetId = "TID-000000000B" } }); + const session_id = bc.attached_sessions.items[0].id; + try testing.expect(!std.mem.eql(u8, session_id, bc.session_id.?)); + + try ctx.processMessage(.{ .id = 11, .method = "Target.detachFromTarget", .params = .{ .sessionId = session_id } }); + try ctx.expectSentEvent("Target.detachedFromTarget", .{ .sessionId = session_id }, .{}); + try testing.expectEqual(0, bc.attached_sessions.items.len); + try testing.expectEqual(true, bc.session_id != null); + try ctx.expectSentResult(null, .{ .id = 11 }); } test "cdp.target: detachFromTarget without session" { @@ -858,19 +983,13 @@ test "cdp.target: setAutoAttach false sends detachedFromTarget" { var ctx = try testing.context(); defer ctx.deinit(); const bc = try ctx.loadBrowserContext(.{ .id = "BID-9" }); - { - try ctx.processMessage(.{ .id = 10, .method = "Target.createTarget", .params = .{ .browserContextId = "BID-9" } }); - try testing.expectEqual(true, bc.target_id != null); - try ctx.expectSentResult(.{ .targetId = bc.target_id.? }, .{ .id = 10 }); + try ctx.processMessage(.{ .id = 10, .method = "Target.setAutoAttach", .params = .{ .autoAttach = true, .waitForDebuggerOnStart = false } }); + try ctx.processMessage(.{ .id = 11, .method = "Target.createTarget", .params = .{ .browserContextId = "BID-9" } }); + const session_id = bc.session_id.?; - try ctx.processMessage(.{ .id = 11, .method = "Target.attachToTarget", .params = .{ .targetId = bc.target_id.? } }); - const session_id = bc.session_id.?; - try ctx.expectSentResult(.{ .sessionId = session_id }, .{ .id = 11 }); + try ctx.processMessage(.{ .id = 12, .method = "Target.setAutoAttach", .params = .{ .autoAttach = false, .waitForDebuggerOnStart = false } }); - // setAutoAttach false should fire detachedFromTarget event - try ctx.processMessage(.{ .id = 12, .method = "Target.setAutoAttach", .params = .{ .autoAttach = false, .waitForDebuggerOnStart = false } }); - try ctx.expectSentEvent("Target.detachedFromTarget", .{ .sessionId = session_id }, .{}); - try testing.expectEqual(null, bc.session_id); - try ctx.expectSentResult(null, .{ .id = 12 }); - } + try ctx.expectSentEvent("Target.detachedFromTarget", .{ .sessionId = session_id }, .{}); + try testing.expectEqual(null, bc.session_id); + try ctx.expectSentResult(null, .{ .id = 12 }); } diff --git a/src/cdp/domains/webmcp.zig b/src/cdp/domains/webmcp.zig index 9d53b776b..5995648c0 100644 --- a/src/cdp/domains/webmcp.zig +++ b/src/cdp/domains/webmcp.zig @@ -141,7 +141,7 @@ fn invokeTool(cmd: *CDP.Command) !void { const callback = local.toLocal(tool.execute); - var caught: js.TryCatch.Caught = undefined; + var caught: js.TryCatch.Caught = .{}; const result = callback.tryCall(js.Value, .{ input_value, ModelContextClient{} }, &caught) catch { const msg = caught.exception orelse "tool threw"; try respondError(cmd.cdp, bc, invocation, msg); diff --git a/src/crash_handler.zig b/src/crash_handler.zig index 41200409b..b3b86c41a 100644 --- a/src/crash_handler.zig +++ b/src/crash_handler.zig @@ -101,6 +101,11 @@ fn report(reason: []const u8, begin_addr: usize, args: anytype) !void { const body = blk: { var writer: std.Io.Writer = .fixed(body_buffer[0..8191]); // reserve 1 space inline for (@typeInfo(@TypeOf(args)).@"struct".fields) |f| { + // remove url value from the crash report. + if (comptime std.mem.eql(u8, f.name, "url")) { + writer.writeAll("url: REDACTED\n") catch break; + continue; + } writer.writeAll(f.name ++ ": ") catch break; lp.log.writeValue(.pretty, @field(args, f.name), &writer) catch {}; writer.writeByte('\n') catch {}; diff --git a/src/help.zon b/src/help.zon index e88ede3b0..83104a8c3 100644 --- a/src/help.zon +++ b/src/help.zon @@ -114,10 +114,13 @@ \\ --wait-until condition is met. \\ --wait-until \\ Wait until the specified event. Checked before other --wait-* options. - \\ Defaults to 'done'. If --wait-selector, --wait-script or + \\ Defaults to 'load'. If --wait-selector, --wait-script or \\ --wait-script-file specified, defaults to none. \\ Allowed values: "load", "domcontentloaded", "networkalmostidle", \\ "networkidle", "done". + \\ 'done' waits for full quiescence (no pending scripts or network); + \\ pages with constant background activity never reach it and run + \\ to --wait-ms. \\ --with-base \\ Add a tag in dump. \\ Defaults to false. @@ -342,6 +345,9 @@ \\ Directory used as a filesystem cache for network resources. Omitting \\ this disables caching. \\ Defaults to no caching. + \\ --http-cache-entry-limit + \\ Maximum number of entries that can be stored in the HTTP cache. 0 means no limit. + \\ Defaults to 1000. \\ --http-connect-timeout \\ Time in ms to establish an HTTP connection before timing out. 0 means \\ never. diff --git a/src/lightpanda.zig b/src/lightpanda.zig index 01769456f..12f882a3d 100644 --- a/src/lightpanda.zig +++ b/src/lightpanda.zig @@ -235,10 +235,22 @@ pub const FetchOpts = struct { writer: ?*std.Io.Writer = null, json: bool = false, }; + +/// `.load`, not `.done`: pages with constant background activity never go +/// quiescent, so `.done` just rides the `wait_ms` cap. A lone +/// `wait_selector`/`wait_script` is itself the wait, so no level applies +/// unless given explicitly. +fn resolveWaitUntil(opts: FetchOpts) ?Config.WaitUntil { + if (opts.wait_until) |wu| return wu; + if (opts.wait_selector == null and opts.wait_script == null) return .load; + return null; +} /// Loads each url in `urls` in a fresh session and waits per `opts`. /// /// Errors: -/// - `error.Timeout` if the wait deadline (`opts.wait_ms`) expires. +/// - `error.Timeout` if the deadline expires while a `wait_selector` or +/// `wait_script` is still unmet. The `wait_until` phase never raises it: +/// when the budget runs out the page is dumped as-is. /// - `error.Cancelled` if the embedder installed a `Session.cancel_hook` /// that returned true during the wait. The hook is opt-in via /// `session.cancel_hook = .{...}`; without it, this error never fires. @@ -285,13 +297,8 @@ pub fn fetch(app: *App, browser: *Browser, urls: []const [:0]const u8, opts: Fet var timer: std.Io.Timestamp = .now(io, .boot); - if (opts.wait_until) |wu| { + if (resolveWaitUntil(opts)) |wu| { try runner.waitForAll(opts.wait_ms, .{ .until = wu }); - } else if (opts.wait_selector == null and opts.wait_script == null) { - // We default to .done if both wait_selector and wait_script are null - // This allows the caller to ONLY --wait-selector or ONLY --wait-script - // or combine --wait-until WITH --wait-selector/script - try runner.waitForAll(opts.wait_ms, .{ .until = .done }); } if (opts.wait_selector) |selector| { @@ -554,6 +561,17 @@ test "writeJsonEnvelope: null frame with dump mode and content" { }, aw.written()); } +test "fetch: resolveWaitUntil" { + try testing.expectEqual(.load, resolveWaitUntil(.{ .dump = .{} })); + try testing.expectEqual(.done, resolveWaitUntil(.{ .dump = .{}, .wait_until = .done })); + try testing.expectEqual(null, resolveWaitUntil(.{ .dump = .{}, .wait_selector = "#main" })); + try testing.expectEqual(null, resolveWaitUntil(.{ .dump = .{}, .wait_script = "true" })); + try testing.expectEqual( + .networkidle, + resolveWaitUntil(.{ .dump = .{}, .wait_until = .networkidle, .wait_selector = "#main" }), + ); +} + test { std.testing.refAllDecls(@This()); } diff --git a/src/main.zig b/src/main.zig index 26ae7f24d..12f9e9c99 100644 --- a/src/main.zig +++ b/src/main.zig @@ -196,8 +196,10 @@ fn run(allocator: Allocator, main_arena: Allocator, proc_args: std.process.Args) try sighandler.deadline(ms); } - var worker_thread = try std.Thread.spawn(.{}, fetchThread, .{ app, &ft, urls, fetch_opts }); + var fetch_err: ?anyerror = null; + var worker_thread = try std.Thread.spawn(.{}, fetchThread, .{ app, &ft, urls, fetch_opts, &fetch_err }); worker_thread.join(); + if (fetch_err) |err| return err; }, .mcp => |opts| { log.info(.mcp, "starting server", .{}); @@ -209,11 +211,11 @@ fn run(allocator: Allocator, main_arena: Allocator, proc_args: std.process.Args) if (opts.port) |port| { if (opts.cdp_port != null) { log.fatal(.mcp, "port conflicts with cdp-port", .{ .hint = "both need the single network listener" }); - return; + return error.InvalidArgument; } const address = std.Io.net.IpAddress.parse(opts.host, port) catch |err| { log.fatal(.mcp, "invalid address", .{ .err = err, .host = opts.host, .port = port }); - return; + return err; }; const http_server = try lp.mcp.HttpServer.init(allocator, app); defer http_server.deinit(); @@ -221,6 +223,7 @@ fn run(allocator: Allocator, main_arena: Allocator, proc_args: std.process.Args) // a signal stops the accept loop, run() returns, deinit joins. http_server.run(address) catch |err| { log.fatal(.mcp, "mcp http error", .{ .err = err }); + return err; }; return; } @@ -236,15 +239,19 @@ fn run(allocator: Allocator, main_arena: Allocator, proc_args: std.process.Args) } defer if (cdp_server) |s| s.deinit(); - var worker_thread = try std.Thread.spawn(.{}, mcpThread, .{ allocator, app }); - defer worker_thread.join(); + var mcp_err: ?anyerror = null; + { + var worker_thread = try std.Thread.spawn(.{}, mcpThread, .{ allocator, app, &mcp_err }); + defer worker_thread.join(); - // mcp talks over stdio on mcpThread. Only run the CDP accept/read - // loop when an optional CDP server was started; otherwise the main - // thread just waits for the worker. - if (cdp_server != null) { - app.network.run(); + // mcp talks over stdio on mcpThread. Only run the CDP accept/read + // loop when an optional CDP server was started; otherwise the main + // thread just waits for the worker. + if (cdp_server != null) { + app.network.run(); + } } + if (mcp_err) |err| return err; }, .agent => |opts| { log.info(.app, "starting agent", .{}); @@ -343,11 +350,12 @@ const FetchTerminator = struct { } }; -fn fetchThread(app: *App, ft: *FetchTerminator, urls: []const [:0]const u8, fetch_opts: lp.FetchOpts) void { +fn fetchThread(app: *App, ft: *FetchTerminator, urls: []const [:0]const u8, fetch_opts: lp.FetchOpts, err_out: *?anyerror) void { defer app.network.stop(); var browser: lp.Browser = undefined; browser.init(app, .{}, null) catch |err| { + err_out.* = err; log.fatal(.app, "browser init error", .{ .err = err }); return; }; @@ -360,15 +368,17 @@ fn fetchThread(app: *App, ft: *FetchTerminator, urls: []const [:0]const u8, fetc defer ft.releaseBrowser(); lp.fetch(app, &browser, urls, fetch_opts) catch |err| { + err_out.* = err; log.fatal(.app, "fetch error", .{ .err = err, .url_count = urls.len }); }; } -fn mcpThread(allocator: std.mem.Allocator, app: *App) void { +fn mcpThread(allocator: std.mem.Allocator, app: *App, err_out: *?anyerror) void { defer app.network.stop(); var stdout = std.Io.File.stdout().writerStreaming(lp.io, &.{}); var mcp_server: *lp.mcp.Server = lp.mcp.Server.init(allocator, app, &stdout.interface) catch |err| { + err_out.* = err; log.fatal(.mcp, "mcp init error", .{ .err = err }); return; }; @@ -377,6 +387,7 @@ fn mcpThread(allocator: std.mem.Allocator, app: *App) void { var stdin_buf: [64 * 1024]u8 = undefined; var stdin = std.Io.File.stdin().readerStreaming(lp.io, &stdin_buf); lp.mcp.router.processRequests(mcp_server, &stdin.interface, std.Io.File.stdin()) catch |err| { + err_out.* = err; log.fatal(.mcp, "mcp error", .{ .err = err }); }; } diff --git a/src/mcp/tools.zig b/src/mcp/tools.zig index 5b815077c..47ff1c746 100644 --- a/src/mcp/tools.zig +++ b/src/mcp/tools.zig @@ -139,7 +139,7 @@ fn dispatchBrowserTool( error.Timeout => .Timeout, error.NavigationFailed, error.InternalError, error.OutOfMemory => .InternalError, }; - return server.sendError(id, code, @errorName(err)); + return server.sendError(id, code, browser_tools.errorMessage(err)); }; try sendToolResultText(server, id, result.text, result.is_error); @@ -930,6 +930,34 @@ test "MCP - tree rejects stale backendNodeId instead of dumping whole document" try testing.expect(std.mem.indexOf(u8, written, "NodeNotFound") != null); } +test "MCP - tree treats zero-filled backendNodeId as omitted" { + var out: std.Io.Writer.Allocating = .init(testing.arena_allocator); + const server = try testLoadPage("http://localhost:9582/src/browser/tests/mcp_actions.html", &out.writer); + defer server.deinit(); + + const msg = + \\{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"tree","arguments":{"backendNodeId":0,"maxDepth":3}}} + ; + try router.handleMessage(server, testing.arena_allocator, msg); + const written = out.written(); + try testing.expect(std.mem.indexOf(u8, written, "NodeNotFound") == null); + try testing.expect(std.mem.indexOf(u8, written, "\"isError\":true") == null); +} + +test "MCP - stale backendNodeId surfaces recovery guidance" { + var out: std.Io.Writer.Allocating = .init(testing.arena_allocator); + const server = try testLoadPage("http://localhost:9582/src/browser/tests/mcp_actions.html", &out.writer); + defer server.deinit(); + + const msg = + \\{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"tree","arguments":{"backendNodeId":999999}}} + ; + try router.handleMessage(server, testing.arena_allocator, msg); + const written = out.written(); + try testing.expect(std.mem.indexOf(u8, written, "NodeNotFound") != null); + try testing.expect(std.mem.indexOf(u8, written, "omit backendNodeId") != null); +} + test "MCP - PascalCase argument keys from LLMs are normalized to canonical" { var out: std.Io.Writer.Allocating = .init(testing.arena_allocator); const server = try testLoadPage("http://localhost:9582/src/browser/tests/mcp_actions.html", &out.writer); @@ -1268,7 +1296,7 @@ test "MCP - waitForSelector: timeout" { try router.handleMessage(server, testing.arena_allocator, msg); try testing.expectJson(.{ .id = 1, - .@"error" = .{ .message = "NodeNotFound" }, + .@"error" = .{ .message = browser_tools.errorMessage(error.NodeNotFound) }, }, out.written()); } diff --git a/src/network/HttpClient.zig b/src/network/HttpClient.zig index 32155136b..90a2c4eab 100644 --- a/src/network/HttpClient.zig +++ b/src/network/HttpClient.zig @@ -26,6 +26,7 @@ const Notification = @import("../Notification.zig"); const CDP = @import("../cdp/CDP.zig"); const Watchdog = @import("../Watchdog.zig"); const URL = @import("../browser/URL.zig"); +const referrer = @import("../browser/referrer.zig"); const WebSocket = @import("../browser/webapi/net/WebSocket.zig"); const CookieJar = @import("../browser/webapi/storage/Cookie.zig").Jar; @@ -1071,7 +1072,7 @@ const SyncContext = struct { lp.assert(transfer.responseStatus() != null, "HttpClient.SyncRequest.headerCallback", .{ .value = transfer.responseStatus() }); self.status = transfer.responseStatus().?; if (transfer.getContentLength()) |cl| { - try self.body.ensureTotalCapacity(try self.bodyAllocator(cl), cl); + try self.body.ensureTotalCapacityPrecise(try self.bodyAllocator(cl), cl); } return .proceed; } @@ -1447,11 +1448,24 @@ fn processOneMessage(self: *Client, msg: http.Handles.MultiMessage, transfer: *T // Handle redirects: reuse the same connection to preserve TCP state. // A redirect status without a Location header is not a redirect, it's a // final response and falls through so its body is delivered. - if (effective_err == null) { + // When the server closes the TLS connection without a close_notify alert, + // BoringSSL reports RecvError. If we already received valid HTTP headers, + // this is a normal end-of-body (the connection closure signals the end + // of the response per HTTP/1.1 when there is no Content-Length). + // We must check this before endTransfer, which may reset the easy handle. + const is_conn_close_recv = blk: { + const err = effective_err orelse break :blk false; + if (err != error.RecvError) break :blk false; + const hdr = msg.conn.getResponseHeader("connection", 0) orelse break :blk true; + break :blk std.ascii.eqlIgnoreCase(hdr.value, "close"); + }; + + if (effective_err == null or is_conn_close_recv) { const status = try msg.conn.getResponseCode(); if (isRedirectStatus(status)) { if (msg.conn.getResponseHeader("location", 0)) |location| switch (transfer.req.redirect) { .follow => { + transfer.restoreInterceptHeaders(); try transfer.handleRedirect(location.value); if (self.isUrlBlocked(transfer.req.url, transfer.req.internal)) { @@ -1464,6 +1478,28 @@ fn processOneMessage(self: *Client, msg: http.Handles.MultiMessage, transfer: *T if (!transfer.req.internal) lp.metrics.http_redirects.incr(); + if (self.serve_mode) { // e.g. cdp + var wait_for_interception = false; + transfer.req.notification.dispatch(.http_request_intercept, &.{ + .transfer = transfer, + .wait_for_interception = &wait_for_interception, + }); + + if (wait_for_interception) { + transfer.req.notification.dispatch(.http_request_start, &.{ .transfer = transfer }); + + // Same shape as the auth-interception park above: + // give up the connection, wait for the CDP client. + self.removeConn(msg.conn); + transfer._conn = null; + transfer.reset(); + transfer.state = .created; + self.intercepted += 1; + transfer.park(.intercept_request); + return false; + } + } + const conn = transfer._conn.?; try self.handles.remove(conn); @@ -1502,18 +1538,6 @@ fn processOneMessage(self: *Client, msg: http.Handles.MultiMessage, transfer: *T // transfer's arena, release the conn, and buffer the events — user // callbacks run later, from dispatch(), never from here. - // When the server closes the TLS onnection without a close_notify alert, - // BoringSSL reports RecvError. If we already received valid HTTP headers, - // this is a normal end-of-body (the connection closure signals the end - // of the response per HTTP/1.1 when there is no Content-Length). - // We must check this before endTransfer, which may reset the easy handle. - const is_conn_close_recv = blk: { - const err = effective_err orelse break :blk false; - if (err != error.RecvError) break :blk false; - const hdr = msg.conn.getResponseHeader("connection", 0) orelse break :blk true; - break :blk std.ascii.eqlIgnoreCase(hdr.value, "close"); - }; - if (effective_err != null and !is_conn_close_recv) { self.removeConn(msg.conn); transfer._conn = null; @@ -1634,6 +1658,7 @@ pub const Request = struct { cookie_origin: [:0]const u8, resource_type: ResourceType, redirect: RedirectMode = .follow, + referrer_policy: ?referrer.Policy = null, credentials: ?[:0]const u8 = null, notification: *Notification, timeout_ms: u32 = 0, @@ -1913,6 +1938,10 @@ pub const Transfer = struct { _tries: u8 = 0, _redirect_count: u8 = 0, + // Fetch.continueRequest header overrides apply to a single network hop. We + // need to restore (and hence capture) the original headers. + _intercept_original_headers: ?[]const RequestHeader = null, + // Linked into client.pending_queue while .queued; reused to link the // retired transfer into client.graveyard (deinit unlinks it from the // pending queue first, so the node is always free by then). @@ -2577,6 +2606,21 @@ pub const Transfer = struct { } } + // A Referrer-Policy header on a redirect response applies to the + // remaining hops. + if (req.referrer_policy != null) { + // referrer-policy is re-applied on every hop. + var i: usize = 0; + while (conn.getResponseHeader("referrer-policy", i)) |hdr| : (i += 1) { + if (referrer.parseHeader(hdr.value)) |policy| { + req.referrer_policy = policy; + } + if (i >= hdr.amount) { + break; + } + } + } + // base_url and location are owned by curl; applyRedirectTarget resolves a // fresh arena-owned copy that gets stored in transfer.req.url. const base_url = try conn.getEffectiveUrl(); @@ -2624,6 +2668,21 @@ pub const Transfer = struct { req.method = .GET; req.body = null; } + + if (req.referrer_policy) |policy| { + // Referer header was applied based on the original target. It + // needs to be updated based on the redirect target. A redirect can + // only strip it (full -> origin -> none), so we can use whatever + // value we have now as the base + if (transfer.findRequestHeader("referer")) |current| { + const alloc = arena.allocator(); + if (try referrer.compute(alloc, policy, try alloc.dupeZ(u8, current), req.url)) |value| { + try transfer.setHeader("Referer", value, .{}); + } else { + transfer.removeHeader("Referer"); + } + } + } } fn detectAuthChallenge(transfer: *Transfer, conn: *const http.Connection) void { @@ -2675,6 +2734,26 @@ pub const Transfer = struct { }); } + pub fn findRequestHeader(self: *const Transfer, name: []const u8) ?[]const u8 { + for (self.req_headers.items) |hdr| { + if (std.ascii.eqlIgnoreCase(hdr.name, name)) { + return hdr.value; + } + } + return null; + } + + fn removeHeader(self: *Transfer, name: []const u8) void { + var i: usize = 0; + while (i < self.req_headers.items.len) { + if (std.ascii.eqlIgnoreCase(self.req_headers.items[i].name, name)) { + _ = self.req_headers.orderedRemove(i); + continue; + } + i += 1; + } + } + // Adds, replacing every existing header with the same case-insensitive name pub fn setHeader(self: *Transfer, name: []const u8, value: []const u8, opts: HeaderOpts) !void { var found = false; @@ -2709,6 +2788,8 @@ pub const Transfer = struct { // CDP Fetch.continueRequest: the intercepting client supplies the // complete header set, replacing whatever the request carried. pub fn replaceRequestHeaders(self: *Transfer, headers: []const http.Header) !void { + lp.assert(self._intercept_original_headers == null, "Transfer.replaceRequestHeaders", .{ .id = self.id }); + self._intercept_original_headers = try self.arena.allocator().dupe(RequestHeader, self.req_headers.items); self.req_headers.clearRetainingCapacity(); try self.seedHeaders(); for (headers) |hdr| { @@ -2716,6 +2797,15 @@ pub const Transfer = struct { } } + fn restoreInterceptHeaders(self: *Transfer) void { + const headers = self._intercept_original_headers orelse return; + self.req_headers.clearRetainingCapacity(); + // _intercept_original_headers.items.len MIGHT be larger than self.req_headers.items.len + // but the capacity never shrank from when _intercept_original_headers WAS req_headers. + self.req_headers.appendSliceAssumeCapacity(headers); + self._intercept_original_headers = null; + } + // abortAuthChallenge is called when an auth challenge interception is // abort. We don't call self.releaseConn here b/c it has been done // before interception process. @@ -2761,7 +2851,7 @@ pub const Transfer = struct { res.callback_error = error.ResponseTooLarge; return http.writefunc_error; } - res.buffer.ensureTotalCapacity(transfer.arena.allocator(), cl) catch {}; + res.buffer.ensureTotalCapacityPrecise(transfer.arena.allocator(), cl) catch {}; } } @@ -3373,6 +3463,33 @@ test "HttpClient: Transfer.setHeader replaces by case-insensitive name" { try testing.expectEqual("yes", headers[2].value); } +test "HttpClient: Fetch header overrides restore after one hop" { + const original = [_]Transfer.RequestHeader{ + .{ .name = "User-Agent", .value = "original" }, + .{ .name = "X-Original", .value = "yes" }, + }; + var overridden = [_]Transfer.RequestHeader{ + .{ .name = "User-Agent", .value = "override" }, + .{ .name = "X-Override", .value = "yes" }, + }; + + var transfer: Transfer = undefined; + transfer.req_headers = .{ .items = &overridden, .capacity = overridden.len }; + transfer._intercept_original_headers = &original; + transfer.restoreInterceptHeaders(); + + try testing.expectEqual(2, transfer.req_headers.items.len); + try testing.expectEqual("User-Agent", transfer.req_headers.items[0].name); + try testing.expectEqual("original", transfer.req_headers.items[0].value); + try testing.expectEqual("X-Original", transfer.req_headers.items[1].name); + try testing.expectEqual("yes", transfer.req_headers.items[1].value); + try testing.expectEqual(null, transfer._intercept_original_headers); + + // idempotent once restored + transfer.restoreInterceptHeaders(); + try testing.expectEqual(2, transfer.req_headers.items.len); +} + test "HttpClient: fulfillIntercepted survives a done_callback that tears down the owner" { // Regression: the fulfilled response's done_callback runs JS which // navigates / closes the page, re-entrantly killing the transfer diff --git a/src/network/Network.zig b/src/network/Network.zig index ceaf93f0d..a48205d0b 100644 --- a/src/network/Network.zig +++ b/src/network/Network.zig @@ -233,7 +233,11 @@ pub fn init(allocator: Allocator, app: *App, config: *const Config) !Network { const cache = if (config.httpCacheDir()) |cache_dir_path| Cache{ .kind = .{ - .sqlite = SqliteCache.init(allocator, .{ .path = cache_dir_path }) catch |e| { + .sqlite = SqliteCache.init( + allocator, + .{ .path = cache_dir_path }, + config.httpCacheEntryLimit(), + ) catch |e| { log.err(.cache, "failed to init", .{ .kind = "SqliteCache", .path = cache_dir_path, diff --git a/src/network/RobotsGate.zig b/src/network/RobotsGate.zig index a4d025c54..552132a89 100644 --- a/src/network/RobotsGate.zig +++ b/src/network/RobotsGate.zig @@ -214,7 +214,7 @@ const RobotsContext = struct { } lp.metrics.robots_status.incr(http.statusCategory(self.status)); if (transfer.getContentLength()) |cl| { - try self.buffer.ensureTotalCapacity(self.arena.allocator(), cl); + try self.buffer.ensureTotalCapacityPrecise(self.arena.allocator(), cl); } return .proceed; } diff --git a/src/network/cache/SqliteCache.zig b/src/network/cache/SqliteCache.zig index 88541f127..45322e45e 100644 --- a/src/network/cache/SqliteCache.zig +++ b/src/network/cache/SqliteCache.zig @@ -41,6 +41,7 @@ pub const SqliteCache = @This(); allocator: std.mem.Allocator, pool: Pool, +entry_limit: u32, const cache_migrations: []const Migration = &.{ .{ .sql = @@ -67,6 +68,7 @@ const cache_migrations: []const Migration = &.{ \\ ) strict }, .{ .sql = "create index header_url on header(url)" }, + .{ .sql = "create index cache_stored_at on cache(stored_at)" }, }; pub const SqliteCachePath = union(enum) { @@ -85,7 +87,7 @@ pub const SqliteCachePath = union(enum) { } }; -pub fn init(allocator: std.mem.Allocator, path: SqliteCachePath) !SqliteCache { +pub fn init(allocator: std.mem.Allocator, path: SqliteCachePath, entry_limit: u32) !SqliteCache { var pool = switch (path) { .memory => try Pool.init(allocator, ":memory:"), .path => |cache_dir| blk: { @@ -124,14 +126,28 @@ pub fn init(allocator: std.mem.Allocator, path: SqliteCachePath) !SqliteCache { try conn.exec("pragma foreign_keys=on", .{}); } - log.info(.cache, "sqlite cache initialized", .{ .path = path, .version = version }); - return .{ .allocator = allocator, .pool = pool }; + log.info(.cache, "sqlite cache initialized", .{ .path = path, .entry_limit = entry_limit, .version = version }); + return .{ .allocator = allocator, .pool = pool, .entry_limit = entry_limit }; } pub fn deinit(self: *SqliteCache) void { self.pool.deinit(self.allocator); } +fn evictOverflow(self: *SqliteCache, conn: Conn) !void { + const limit = self.entry_limit; + if (limit == 0) return; + + try conn.exec( + \\ delete from cache + \\ where rowid in ( + \\ select rowid from cache + \\ order by stored_at desc + \\ limit -1 offset $1 + \\ ) + , .{@as(i64, @intCast(limit))}); +} + pub fn get(self: *SqliteCache, arena: std.mem.Allocator, req: CacheGetRequest) !CacheGetResult { const conn = try self.pool.acquire(); defer self.pool.release(conn); @@ -270,6 +286,7 @@ pub fn put(self: *SqliteCache, req: CachePutRequest, body: []const u8) !void { ); } + try self.evictOverflow(conn); try conn.commit(); log.debug(.cache, "put", .{ .url = req.url, .body_len = body.len }); @@ -376,7 +393,7 @@ pub fn renew(self: *SqliteCache, _: std.mem.Allocator, req: RenewResponse) !void const testing = std.testing; fn setupCache(allocator: std.mem.Allocator) !Cache { - return Cache{ .kind = .{ .sqlite = try .init(allocator, .memory) } }; + return Cache{ .kind = .{ .sqlite = try .init(allocator, .memory, 0) } }; } test "SqliteCache: Migrations" { @@ -900,3 +917,50 @@ test "SqliteCache: renew preserves body" { try testing.expect(result == .hit); try testing.expectEqualStrings("original body", result.hit.data.buffer); } + +test "SqliteCache: evicts oldest entries over the limit" { + var cache = Cache{ .kind = .{ .sqlite = try .init(testing.allocator, .memory, 3) } }; + defer cache.deinit(); + + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + + const now: u64 = @intCast(std.Io.Timestamp.now(testing.io, .boot).toSeconds()); + + const urls = [_][:0]const u8{ + "https://example.com/a", + "https://example.com/b", + "https://example.com/c", + "https://example.com/d", + }; + + for (urls, 0..) |url, i| { + try cache.put(.{ + .url = url, + .content_type = "text/html", + .status = 200, + .stored_at = now + i, + .age_at_store = 0, + .cache_control = .{ .max_age = 600 }, + .headers = &.{}, + .vary_headers = &.{}, + }, url); + } + + const evicted = try cache.get(arena.allocator(), .{ + .url = "https://example.com/a", + .timestamp = now, + .request_headers = &.{}, + }); + try testing.expect(evicted == .miss); + + for (urls[1..]) |url| { + const hit = try cache.get(arena.allocator(), .{ + .url = url, + .timestamp = now + urls.len, + .request_headers = &.{}, + }); + try testing.expect(hit == .hit); + try testing.expectEqualStrings(url, hit.hit.data.buffer); + } +} diff --git a/src/network/header_parser.zig b/src/network/header_parser.zig index c1842ee8c..23545a233 100644 --- a/src/network/header_parser.zig +++ b/src/network/header_parser.zig @@ -197,7 +197,11 @@ pub const Header = struct { // Found where header value starts. const val_start = cursor.current(); matchHeaderValue(cursor); - const val_end = cursor.current(); + var val_end = cursor.current(); + // Trailing OWS is legal on the wire but not part of the field value; + // parsers must exclude it (RFC 9112 §5.1). Spaces and HTABs are valid + // mid-value, so the scan above can't do this on its own. + while (val_end != val_start and ((val_end - 1)[0] == ' ' or (val_end - 1)[0] == '\t')) : (val_end -= 1) {} // Buffer has been consumed fully without a line ending; the caller // can read more data and try to parse again. @@ -228,8 +232,218 @@ pub const Header = struct { .value = val_start[0 .. val_end - val_start], }; } + + pub const Iterator = struct { + cursor: Cursor, + /// Set once the blank line terminating the header block is consumed. + done: bool = false, + + pub fn next(self: *Iterator) ParseError!?Header { + if (self.done) { + return null; + } + // Running out of buffer before the terminating blank line is a + // truncation, not a clean end; only the blank line yields null. + if (self.cursor.reachedEnd()) { + return error.Incomplete; + } + // Check if headers part has finished. + switch (self.cursor.char()) { + '\n' => { + // End of headers. + self.cursor.advance(1); + self.done = true; + return null; + }, + '\r' => { + // We need an LF too. + if (!self.cursor.hasLength(2)) { + return error.Incomplete; + } + if (!self.cursor.peek2('\r', '\n')) { + return error.Invalid; + } + // End of headers. + self.cursor.advance(2); + self.done = true; + return null; + }, + else => {}, + } + + var header: Header = undefined; + try header.parse(&self.cursor); + return header; + } + }; }; +pub const Method = union(enum) { + get: void, + post: void, + head: void, + put: void, + delete: void, + connect: void, + options: void, + trace: void, + patch: void, + /// Non-standard HTTP method. + custom: []const u8, +}; + +/// Parses an HTTP method, checks for shortest possible request length too. +fn parseMethod(cursor: *Cursor) !Method { + // Though the shortest standardized HTTP method is GET, we can receive + // a single character for method on non-standard ones. + // + // A / HTTP/1.1\n + const min_request_len = 13; + if (cursor.hasLength(min_request_len) == false) { + return error.Incomplete; + } + + const m_u32: u32 = cursor.asInteger(u32); + // Advance as much as integer value. + cursor.advance(4); + switch (m_u32) { + // "GET " + @as(u32, @bitCast([_]u8{ 'G', 'E', 'T', ' ' })) => { + return .{ .get = {} }; + }, + // "POST" + @as(u32, @bitCast([_]u8{ 'P', 'O', 'S', 'T' })) => { + if (cursor.peek(' ')) { + cursor.advance(1); + return .{ .post = {} }; + } + }, + // "HEAD" + @as(u32, @bitCast([_]u8{ 'H', 'E', 'A', 'D' })) => { + if (cursor.peek(' ')) { + cursor.advance(1); + return .{ .head = {} }; + } + }, + // "PUT " + @as(u32, @bitCast([_]u8{ 'P', 'U', 'T', ' ' })) => { + return .{ .put = {} }; + }, + // "DELE" + @as(u32, @bitCast([_]u8{ 'D', 'E', 'L', 'E' })) => { + // "TE " + if (cursor.peek3('T', 'E', ' ')) { + cursor.advance(3); + return .{ .delete = {} }; + } + }, + // "CONN" + @as(u32, @bitCast([_]u8{ 'C', 'O', 'N', 'N' })) => { + // "ECT " + if (cursor.peek4('E', 'C', 'T', ' ')) { + cursor.advance(4); + return .{ .connect = {} }; + } + }, + // "OPTI" + @as(u32, @bitCast([_]u8{ 'O', 'P', 'T', 'I' })) => { + // "ONS " + if (cursor.peek4('O', 'N', 'S', ' ')) { + cursor.advance(4); + return .{ .options = {} }; + } + }, + // "TRAC" + @as(u32, @bitCast([_]u8{ 'T', 'R', 'A', 'C' })) => { + // "E " + if (cursor.peek2('E', ' ')) { + cursor.advance(2); + return .{ .trace = {} }; + } + }, + // "PATC" + @as(u32, @bitCast([_]u8{ 'P', 'A', 'T', 'C' })) => { + // "H " + if (cursor.peek2('H', ' ')) { + cursor.advance(2); + return .{ .patch = {} }; + } + }, + // Custom method. + else => {}, + } + + // That 4 bytes we've consumed earlier might've a delimiter in them. + cursor.rewind(4); + // Continue until first space appearance. + const method_start = cursor.current(); + while (cursor.end - cursor.current() > 0 and cursor.char() != ' ') : (cursor.advance(1)) {} + if (cursor.reachedEnd()) { + return error.Incomplete; + } + const method_end = cursor.current(); + // 0 length. + if (method_end == method_start) { + return error.Invalid; + } + // Consume the delimiting space. + cursor.advance(1); + return .{ .custom = method_start[0 .. method_end - method_start] }; +} + +pub const HttpVersion = enum(u1) { @"1.1", @"1.0" }; + +pub fn parseRequest(bytes: []const u8) !struct { Method, []const u8, HttpVersion, Header.Iterator } { + var cursor = Cursor{ .idx = bytes.ptr, .start = bytes.ptr, .end = bytes.ptr + bytes.len }; + const method = try parseMethod(&cursor); + // Parse path. + const path_start = cursor.current(); + // Find the first space. + while (cursor.end - cursor.current() > 0 and cursor.char() != ' ') : (cursor.advance(1)) {} + const path_end = cursor.current(); + // 0 length path. + if (path_start == path_end) { + return error.Invalid; + } + const path = path_start[0 .. path_end - path_start]; + + // Skip past the delimiting space(s); the scan above guarantees we're on + // a space or at the end, and recipients may parse on whitespace + // boundaries (RFC 9112 §3). + cursor.skipSpaces(); + + // HTTP/1.1(\r)\n + if (cursor.hasLength(9) == false) { + return error.Incomplete; + } + const version: HttpVersion = switch (cursor.asInteger(u64)) { + @as(u64, @bitCast(@as([]const u8, "HTTP/1.1")[0..8].*)) => .@"1.1", + @as(u64, @bitCast(@as([]const u8, "HTTP/1.0")[0..8].*)) => .@"1.0", + else => return error.Invalid, + }; + cursor.advance(8); + + // Trailing (CR)LF. + switch (cursor.char()) { + '\n' => cursor.advance(1), + '\r' => { + // We need an LF too. + if (!cursor.hasLength(2)) { + return error.Incomplete; + } + if (!cursor.peek2('\r', '\n')) { + @branchHint(.unlikely); + return error.Invalid; + } + cursor.advance(2); + }, + // Any other character is invalid. + else => return error.Invalid, + } + + return .{ method, path, version, .{ .cursor = cursor } }; +} + pub const Disposition = struct { name: ?[]const u8 = null, filename: ?[]const u8 = null, @@ -243,7 +457,8 @@ pub const Disposition = struct { // `filename*=UTF-8''...`, which real servers emit — are skipped rather than // failing the whole parse, matching browser leniency. pub fn parseDisposition(value: []const u8) !Disposition { - var rest = std.mem.trim(u8, value, " \t"); + // `Header.parse` already excludes the surrounding OWS from the value. + var rest = value; if (!std.ascii.startsWithIgnoreCase(rest, "form-data")) { return error.InvalidFormData; } @@ -317,6 +532,12 @@ pub const Cursor = struct { cursor.idx += by; } + /// Rewinds the position of the cursor by given value. + /// SAFETY: This function doesn't check if out of bounds reachable. + pub fn rewind(cursor: *Cursor, by: usize) void { + cursor.idx -= by; + } + /// Checks if buffer has `len` length of characters. /// `(cursor.end - cursor.idx >= len)` pub fn hasLength(cursor: *const Cursor, len: usize) bool { @@ -336,12 +557,29 @@ pub const Cursor = struct { return @bitCast(cursor.idx[0 .. @bitSizeOf(T) / @bitSizeOf(u8)].*); } + /// Peek the current character but don't advance. + pub fn peek(cursor: *const Cursor, c: u8) bool { + return cursor.idx[0] == c; + } + /// Peek the current and the next but don't advance. /// SAFETY: This function doesn't check if out of bounds reachable. pub fn peek2(cursor: *const Cursor, c0: u8, c1: u8) bool { return cursor.asInteger(u16) == @as(u16, @bitCast([2]u8{ c0, c1 })); } + /// Peek the current and next 2 characters but don't advance. + /// SAFETY: This function doesn't check if out of bounds reachable. + pub fn peek3(cursor: *const Cursor, c0: u8, c1: u8, c2: u8) bool { + return cursor.idx[0] == c0 and cursor.idx[1] == c1 and cursor.idx[2] == c2; + } + + /// Peek the current and next 3 characters but don't advance. + /// SAFETY: This function doesn't check if out of bounds reachable. + pub fn peek4(cursor: *const Cursor, c0: u8, c1: u8, c2: u8, c3: u8) bool { + return cursor.asInteger(u32) == @as(u32, @bitCast([4]u8{ c0, c1, c2, c3 })); + } + /// Moves the cursor until no leading spaces there are. pub fn skipSpaces(cursor: *Cursor) void { while (cursor.end - cursor.current() > 0 and (cursor.char() == ' ' or cursor.char() == '\t')) : (cursor.advance(1)) {} @@ -368,6 +606,10 @@ fn consumed(cursor: *const Cursor) usize { return cursor.idx - cursor.start; } +fn drain(it: *Header.Iterator) Header.ParseError!void { + while (try it.next()) |_| {} +} + test "header_parser: parse HTTP header" { const bytes = "Content-Disposition: attachment; filename*=UTF-8''file%20name.jpg\r\nrest"; var cursor = initCursor(bytes); @@ -389,17 +631,20 @@ test "header_parser: parse HTTP header" { try testing.expectString("def", header.value); try testing.expectEqual(true, cursor.reachedEnd()); - // Leading whitespaces of the value — spaces and HTABs — are skipped, - // trailing ones are kept. + // Leading and trailing whitespace of the value — spaces and HTABs — is + // excluded from the field value (RFC 9112 §5.1). cursor = initCursor("Key: \t value \t\r\n"); try header.parse(&cursor); - try testing.expectString("value \t", header.value); + try testing.expectString("value", header.value); - // 0 length values are fine. + // 0 length values are fine, including whitespace-only ones. cursor = initCursor("Key:\r\n"); try header.parse(&cursor); try testing.expectString("Key", header.key); try testing.expectString("", header.value); + cursor = initCursor("Key: \t \r\n"); + try header.parse(&cursor); + try testing.expectString("", header.value); // HTAB is legal field content, including next to spaces. cursor = initCursor("Key: a\tb\t c\r\n"); @@ -439,14 +684,55 @@ test "header_parser: parse HTTP header invalid" { } } +test "header_parser: header iterator" { + // Headers iterate until the terminating blank line; afterwards the + // iterator stays exhausted and the cursor is left at the body. + var it = Header.Iterator{ .cursor = initCursor("Host: a\r\nUpgrade: websocket\n\r\nbody") }; + var header = (try it.next()).?; + try testing.expectString("Host", header.key); + try testing.expectString("a", header.value); + header = (try it.next()).?; + try testing.expectString("Upgrade", header.key); + try testing.expectString("websocket", header.value); + try testing.expectEqual(null, try it.next()); + try testing.expectEqual(null, try it.next()); + try testing.expectString("body", it.cursor.remaining()); + + // A blank line right away means no headers at all; both endings work. + it = .{ .cursor = initCursor("\r\n") }; + try testing.expectEqual(null, try it.next()); + it = .{ .cursor = initCursor("\n") }; + try testing.expectEqual(null, try it.next()); + + // Running out of buffer before the terminating blank line is a + // truncation, not a clean end. + const truncated_cases = [_][]const u8{ + "", // nothing at all + "Host: a\r\n", // header block never terminated + "Host: a\r\nUpg", // buffer ends mid-header + "\r", // `\r` still missing its `\n` + }; + for (truncated_cases) |case| { + it = .{ .cursor = initCursor(case) }; + try testing.expectError(error.Incomplete, drain(&it)); + } + + // Malformed headers surface as `Invalid`. + it = .{ .cursor = initCursor("Key\x01: v\r\n\r\n") }; + try testing.expectError(error.Invalid, drain(&it)); + it = .{ .cursor = initCursor("\rX\r\n") }; + try testing.expectError(error.Invalid, drain(&it)); +} + test "header_parser: parse Content-Disposition" { var d = try parseDisposition("form-data; name=\"a\"; filename=\"b.txt\""); try testing.expectString("a", d.name.?); try testing.expectString("b.txt", d.filename.?); - // Type and parameter names are case-insensitive; surrounding whitespace - // is tolerated. - d = try parseDisposition(" Form-Data; NAME=\"x\" "); + // Type and parameter names are case-insensitive; whitespace between + // parameters is tolerated (the value's surrounding OWS is already + // excluded by `Header.parse`). + d = try parseDisposition("Form-Data; NAME=\"x\" "); try testing.expectString("x", d.name.?); try testing.expectEqual(null, d.filename); @@ -501,6 +787,88 @@ test "header_parser: cursor" { try testing.expectEqual(true, cursor.reachedEnd()); } +test "header_parser: parse request" { + // Request line plus headers; the returned iterator picks up right after + // the line ending. + const bytes = "GET /json/version HTTP/1.1\r\nHost: a\r\n\r\n"; + const method, const path, const version, var it = try parseRequest(bytes); + try testing.expectEqual(true, method == .get); + try testing.expectString("/json/version", path); + try testing.expectEqual(.@"1.1", version); + const header = (try it.next()).?; + try testing.expectString("Host", header.key); + try testing.expectString("a", header.value); + try testing.expectEqual(null, try it.next()); + + // HTTP/1.0 and lone `\n` line endings are accepted too. + const method10, _, const version10, _ = try parseRequest("GET / HTTP/1.0\n\n"); + try testing.expectEqual(true, method10 == .get); + try testing.expectEqual(.@"1.0", version10); + + // Multiple spaces before the version are tolerated (RFC 9112 §3). + const req = try parseRequest("GET / HTTP/1.1\r\n\r\n"); + try testing.expectString("/", req[1]); + + // Longer paths are accepted (but not validated). + const req2 = try parseRequest("GET /chat/room?id=1 HTTP/1.1\r\n\r\n"); + try testing.expectString("/chat/room?id=1", req2[1]); +} + +test "header_parser: parse request methods" { + const cases = [_]struct { []const u8, std.meta.Tag(Method) }{ + .{ "GET / HTTP/1.1\r\n\r\n", .get }, + .{ "POST / HTTP/1.1\r\n\r\n", .post }, + .{ "HEAD / HTTP/1.1\r\n\r\n", .head }, + .{ "PUT / HTTP/1.1\r\n\r\n", .put }, + .{ "DELETE / HTTP/1.1\r\n\r\n", .delete }, + .{ "CONNECT / HTTP/1.1\r\n\r\n", .connect }, + .{ "OPTIONS / HTTP/1.1\r\n\r\n", .options }, + .{ "TRACE / HTTP/1.1\r\n\r\n", .trace }, + .{ "PATCH / HTTP/1.1\r\n\r\n", .patch }, + }; + for (cases) |case| { + const method, _, _, _ = try parseRequest(case[0]); + try testing.expectEqual(case[1], std.meta.activeTag(method)); + } + + // Unknown methods fall back to `custom`; a shared 4-byte prefix with a + // standard method or a single character both take that path. + const custom_cases = [_]struct { []const u8, []const u8 }{ + .{ "BREW / HTTP/1.1\r\n\r\n", "BREW" }, + .{ "POSTER / HTTP/1.1\r\n\r\n", "POSTER" }, + .{ "GETX / HTTP/1.1\r\n\r\n", "GETX" }, + .{ "A / HTTP/1.1\r\n\r\n", "A" }, + }; + for (custom_cases) |case| { + const method, _, _, _ = try parseRequest(case[0]); + try testing.expectString(case[1], method.custom); + } +} + +test "header_parser: parse request invalid" { + const invalid_cases = [_][]const u8{ + " / HTTP/1.1\r\n\r\n", // 0 length method + "GET HTTP/1.1\r\n\r\n", // 0 length path + "GET / HTTP/1.1\r\n\r\n", // double space, so a 0 length path + "GET / HTTP/2.0\r\n\r\n", // unknown version + "GET / http/1.1\r\n\r\n", // the version is case-sensitive + "GET / HTTP/1.1X\r\n\r\n", // junk after the version + "GET / HTTP/1.1\rX\n\r\n", // `\r` must be followed by `\n` + }; + for (invalid_cases) |case| { + try testing.expectError(error.Invalid, parseRequest(case)); + } + + // Buffer may end anywhere before the request line ending is complete; + // `DELETE` exercises the multi-byte peek continuation and the path is + // longer than the minimum request length, proving neither scan can run + // past the end of a truncated buffer. + const bytes = "DELETE /websocket/endpoint HTTP/1.1\r\n"; + for (0..bytes.len) |len| { + try testing.expectError(error.Incomplete, parseRequest(bytes[0..len])); + } +} + fn expectMatchesReference(bytes: []const u8) !void { var key_expected: usize = 0; while (key_expected < bytes.len and isHeaderKeyByte(bytes[key_expected])) key_expected += 1; diff --git a/src/script/Recorder.zig b/src/script/Recorder.zig index 5eb8dd8a3..74fe8e5a0 100644 --- a/src/script/Recorder.zig +++ b/src/script/Recorder.zig @@ -38,6 +38,17 @@ content: std.Io.Writer.Allocating, buf: std.Io.Writer.Allocating, /// Reset per write — backs short-lived scrub allocations. arena: std.heap.ArenaAllocator, +/// The just-emitted `goto` line stays rewritable for exactly one step: a +/// readiness wait recorded next (`Tool.waitsForReadiness`) swaps it for its +/// `waitUntil: "domcontentloaded"` variant; anything else closes the window. +pending_goto: ?PendingGoto, + +const PendingGoto = struct { + /// `content` length just before the emitted goto line. + start: usize, + /// Scrubbed replacement line; null when the call carried an explicit waitUntil. + downgraded: ?[]u8, +}; pub fn init(allocator: std.mem.Allocator) Recorder { return .{ @@ -47,20 +58,25 @@ pub fn init(allocator: std.mem.Allocator) Recorder { .content = .init(allocator), .buf = .init(allocator), .arena = .init(allocator), + .pending_goto = null, }; } pub fn deinit(self: *Recorder) void { + self.freePendingGoto(); self.content.deinit(); self.buf.deinit(); self.arena.deinit(); } pub fn bytes(self: *Recorder) []const u8 { + // A snapshot may be persisted verbatim — its goto line must not change afterwards. + self.freePendingGoto(); return self.content.written(); } pub fn reset(self: *Recorder) void { + self.freePendingGoto(); self.lines = 0; self.page_declared = false; self.content.clearRetainingCapacity(); @@ -70,34 +86,86 @@ pub fn reset(self: *Recorder) void { pub fn record(self: *Recorder, cmd: Command) !void { if (!cmd.isRecorded()) return; - self.buf.clearRetainingCapacity(); - _ = self.arena.reset(.retain_capacity); - // `isRecorded` guarantees `.tool_call`. The page is born once, up front; every - // recorded call is then a method on it — `goto` async, the rest sync. + // `isRecorded` guarantees `.tool_call`. + const tool = cmd.tool_call.tool; + if (self.pending_goto) |pending| { + if (tool.waitsForReadiness()) try self.downgradePendingGoto(pending); + self.freePendingGoto(); + } + + // The page is born once, up front; every recorded call is then a method + // on it — `goto` async, the rest sync. if (!self.page_declared) { - try self.buf.writer.writeAll("const page = new Page();\n"); + try self.content.writer.writeAll("const page = new Page();\n"); + self.lines += 1; self.page_declared = true; } - if (cmd.tool_call.tool.isAsync()) try self.buf.writer.writeAll("await "); - try self.buf.writer.writeAll("page."); - try cmd.formatJs(self.arena.allocator(), &self.buf.writer); - try self.buf.writer.writeByte('\n'); + + self.buf.clearRetainingCapacity(); + _ = self.arena.reset(.retain_capacity); + try self.renderCall(cmd, &self.buf.writer); + + const start = self.content.written().len; try self.appendScrubbed(); + + if (tool == .goto) { + self.pending_goto = .{ .start = start, .downgraded = try self.renderDowngradedGoto(cmd) }; + } } pub fn recordComment(self: *Recorder, comment: []const u8) !void { + self.freePendingGoto(); self.buf.clearRetainingCapacity(); try writeCommentLines(&self.buf.writer, comment); try self.appendScrubbed(); } pub fn recordRaw(self: *Recorder, line: []const u8) !void { + self.freePendingGoto(); self.buf.clearRetainingCapacity(); try self.buf.writer.writeAll(line); try self.buf.writer.writeByte('\n'); try self.appendScrubbed(); } +fn renderCall(self: *Recorder, cmd: Command, w: *std.Io.Writer) !void { + if (cmd.tool_call.tool.isAsync()) try w.writeAll("await "); + try w.writeAll("page."); + try cmd.formatJs(self.arena.allocator(), w); + try w.writeByte('\n'); +} + +/// Rendered and scrubbed up front — the command's args die with the caller. +/// Null when there's nothing to downgrade (explicit waitUntil, non-object args). +fn renderDowngradedGoto(self: *Recorder, cmd: Command) !?[]u8 { + // `isRecorded` guaranteed the required `url` arg. + const args = cmd.tool_call.args.?; + if (args != .object) return null; + if (args.object.get("waitUntil") != null) return null; + + const aa = self.arena.allocator(); + var cloned = try args.object.clone(aa); + try cloned.put(aa, "waitUntil", .{ .string = @tagName(lp.Config.WaitUntil.domcontentloaded) }); + + self.buf.clearRetainingCapacity(); + try self.renderCall(.fromToolCall(.goto, .{ .object = cloned }), &self.buf.writer); + const scrubbed = try lp.tools.reverseSubstituteEnvVars(aa, self.buf.written()); + return try self.allocator.dupe(u8, scrubbed); +} + +fn downgradePendingGoto(self: *Recorder, pending: PendingGoto) !void { + const line = pending.downgraded orelse return; + // Both variants are exactly one line, so `lines` needs no adjustment. + self.content.shrinkRetainingCapacity(pending.start); + try self.content.writer.writeAll(line); +} + +fn freePendingGoto(self: *Recorder) void { + const pending = self.pending_goto orelse return; + if (pending.downgraded) |d| self.allocator.free(d); + self.pending_goto = null; +} + fn appendScrubbed(self: *Recorder) !void { // Reverse-substitute any LP_* env-var values that snuck in as literals // (e.g. an agent that retyped a username it saw via getUrl) so the saved @@ -215,6 +283,72 @@ test "recordComment scrubs literal LP_* values back to placeholders" { ); } +test "record downgrades goto before waitForSelector to domcontentloaded" { + var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); + defer arena.deinit(); + const aa = arena.allocator(); + + var recorder: Recorder = .init(std.testing.allocator); + defer recorder.deinit(); + + try recorder.record(parseLine(aa, "/goto https://example.com")); + try recorder.record(parseLine(aa, "/waitForSelector .story")); + + try std.testing.expectEqualStrings( + "const page = new Page();\nawait page.goto({ url: \"https://example.com\", waitUntil: \"domcontentloaded\" });\npage.waitForSelector(\".story\");\n", + recorder.bytes(), + ); +} + +test "record keeps the load wait when goto is followed by extract" { + var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); + defer arena.deinit(); + const aa = arena.allocator(); + + var recorder: Recorder = .init(std.testing.allocator); + defer recorder.deinit(); + + try recorder.record(parseLine(aa, "/goto https://example.com")); + try recorder.record(parseLine(aa, "/extract '{\"title\": \"h1\"}'")); + + try std.testing.expectEqualStrings( + "const page = new Page();\nawait page.goto(\"https://example.com\");\npage.extract({ title: \"h1\" });\n", + recorder.bytes(), + ); +} + +test "record preserves an explicit waitUntil on goto" { + var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); + defer arena.deinit(); + const aa = arena.allocator(); + + var recorder: Recorder = .init(std.testing.allocator); + defer recorder.deinit(); + + try recorder.record(parseLine(aa, "/goto https://example.com waitUntil=networkidle")); + try recorder.record(parseLine(aa, "/waitForSelector .story")); + + try std.testing.expectEqualStrings( + "const page = new Page();\nawait page.goto({ url: \"https://example.com\", waitUntil: \"networkidle\" });\npage.waitForSelector(\".story\");\n", + recorder.bytes(), + ); +} + +test "trailing goto is flushed unmodified by bytes" { + var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); + defer arena.deinit(); + const aa = arena.allocator(); + + var recorder: Recorder = .init(std.testing.allocator); + defer recorder.deinit(); + + try recorder.record(parseLine(aa, "/goto https://example.com")); + try std.testing.expectEqualStrings( + "const page = new Page();\nawait page.goto(\"https://example.com\");\n", + recorder.bytes(), + ); +} + test "record scrubs literal LP_* values in JavaScript calls" { var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); defer arena.deinit(); diff --git a/src/script/Runtime.zig b/src/script/Runtime.zig index 1f76d98b0..ee4ceee41 100644 --- a/src/script/Runtime.zig +++ b/src/script/Runtime.zig @@ -74,6 +74,7 @@ const PendingGoto = struct { receiver: v8.Global, /// `run_timer` reading (ms) past which the navigation is abandoned. deadline_ms: u64, + until: lp.Config.WaitUntil, fn reset(self: *PendingGoto) void { v8.v8__Global__Reset(&self.resolver); @@ -513,6 +514,7 @@ fn invokeGoto( .resolver = undefined, .receiver = undefined, .deadline_ms = @as(u64, @intCast(self.run_timer.untilNow(lp.io, .boot).toMilliseconds())) + started.timeout_ms, + .until = started.until, }; v8.v8__Global__New(self.env.isolate.handle, resolver, &pending.resolver); v8.v8__Global__New(self.env.isolate.handle, this, &pending.receiver); @@ -551,7 +553,7 @@ fn driveAsync(self: *Runtime, context: *const v8.Context, try_catch: *const v8.T break; }; for (self.pending_gotos.items, conditions) |pending, *condition| { - condition.* = .{ .frame_id = pending.frame_id, .until = .load }; + condition.* = .{ .frame_id = pending.frame_id, .until = pending.until }; } // Browser-side tick: run under the browser's isolate, exit before the diff --git a/src/script/Schema.zig b/src/script/Schema.zig index c81c9dc01..8439ba52d 100644 --- a/src/script/Schema.zig +++ b/src/script/Schema.zig @@ -482,7 +482,7 @@ fn enumValuesOf(arena: std.mem.Allocator, value: std.json.Value) ![]const []cons fn jsonStringArray(arena: std.mem.Allocator, value: std.json.Value) ![]const []const u8 { if (value != .array) return &.{}; var out: std.ArrayList([]const u8) = .empty; - try out.ensureTotalCapacity(arena, value.array.items.len); + try out.ensureTotalCapacityPrecise(arena, value.array.items.len); for (value.array.items) |item| { if (item != .string) continue; out.appendAssumeCapacity(item.string); diff --git a/src/script/command.zig b/src/script/command.zig index 9fc402e90..5d7381cb1 100644 --- a/src/script/command.zig +++ b/src/script/command.zig @@ -169,7 +169,7 @@ pub const Command = union(enum) { return .{ .tool_call = .{ .tool = s.tool, .args = args } }; } - /// JavaScript recorder format for `lightpanda agent