mirror of
https://github.com/lightpanda-io/browser.git
synced 2026-03-29 16:10:04 +00:00
Compare commits
1 Commits
build-chec
...
url-userin
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c04ccf5e87 |
27
build.zig
27
build.zig
@@ -85,15 +85,6 @@ pub fn build(b: *Build) !void {
|
|||||||
break :blk mod;
|
break :blk mod;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Check compilation
|
|
||||||
const check = b.step("check", "Check if lightpanda compiles");
|
|
||||||
|
|
||||||
const check_lib = b.addLibrary(.{
|
|
||||||
.name = "lightpanda_check",
|
|
||||||
.root_module = lightpanda_module,
|
|
||||||
});
|
|
||||||
check.dependOn(&check_lib.step);
|
|
||||||
|
|
||||||
{
|
{
|
||||||
// browser
|
// browser
|
||||||
const exe = b.addExecutable(.{
|
const exe = b.addExecutable(.{
|
||||||
@@ -112,12 +103,6 @@ pub fn build(b: *Build) !void {
|
|||||||
});
|
});
|
||||||
b.installArtifact(exe);
|
b.installArtifact(exe);
|
||||||
|
|
||||||
const exe_check = b.addLibrary(.{
|
|
||||||
.name = "lightpanda_exe_check",
|
|
||||||
.root_module = exe.root_module,
|
|
||||||
});
|
|
||||||
check.dependOn(&exe_check.step);
|
|
||||||
|
|
||||||
const run_cmd = b.addRunArtifact(exe);
|
const run_cmd = b.addRunArtifact(exe);
|
||||||
if (b.args) |args| {
|
if (b.args) |args| {
|
||||||
run_cmd.addArgs(args);
|
run_cmd.addArgs(args);
|
||||||
@@ -147,12 +132,6 @@ pub fn build(b: *Build) !void {
|
|||||||
});
|
});
|
||||||
b.installArtifact(exe);
|
b.installArtifact(exe);
|
||||||
|
|
||||||
const exe_check = b.addLibrary(.{
|
|
||||||
.name = "snapshot_creator_check",
|
|
||||||
.root_module = exe.root_module,
|
|
||||||
});
|
|
||||||
check.dependOn(&exe_check.step);
|
|
||||||
|
|
||||||
const run_cmd = b.addRunArtifact(exe);
|
const run_cmd = b.addRunArtifact(exe);
|
||||||
if (b.args) |args| {
|
if (b.args) |args| {
|
||||||
run_cmd.addArgs(args);
|
run_cmd.addArgs(args);
|
||||||
@@ -191,12 +170,6 @@ pub fn build(b: *Build) !void {
|
|||||||
});
|
});
|
||||||
b.installArtifact(exe);
|
b.installArtifact(exe);
|
||||||
|
|
||||||
const exe_check = b.addLibrary(.{
|
|
||||||
.name = "legacy_test_check",
|
|
||||||
.root_module = exe.root_module,
|
|
||||||
});
|
|
||||||
check.dependOn(&exe_check.step);
|
|
||||||
|
|
||||||
const run_cmd = b.addRunArtifact(exe);
|
const run_cmd = b.addRunArtifact(exe);
|
||||||
if (b.args) |args| {
|
if (b.args) |args| {
|
||||||
run_cmd.addArgs(args);
|
run_cmd.addArgs(args);
|
||||||
|
|||||||
@@ -357,38 +357,25 @@ pub fn isHTTPS(raw: [:0]const u8) bool {
|
|||||||
|
|
||||||
pub fn getHostname(raw: [:0]const u8) []const u8 {
|
pub fn getHostname(raw: [:0]const u8) []const u8 {
|
||||||
const host = getHost(raw);
|
const host = getHost(raw);
|
||||||
const port_sep = findPortSeparator(host) orelse return host;
|
const pos = std.mem.lastIndexOfScalar(u8, host, ':') orelse return host;
|
||||||
return host[0..port_sep];
|
return host[0..pos];
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn getPort(raw: [:0]const u8) []const u8 {
|
pub fn getPort(raw: [:0]const u8) []const u8 {
|
||||||
const host = getHost(raw);
|
const host = getHost(raw);
|
||||||
const port_sep = findPortSeparator(host) orelse return "";
|
const pos = std.mem.lastIndexOfScalar(u8, host, ':') orelse return "";
|
||||||
return host[port_sep + 1 ..];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Finds the colon separating host from port, handling IPv6 bracket notation.
|
if (pos + 1 >= host.len) {
|
||||||
// For IPv6 like "[::1]:8080", returns position of ":" after "]".
|
return "";
|
||||||
// For IPv6 like "[::1]" (no port), returns null.
|
|
||||||
// For regular hosts, returns position of last ":" if followed by digits.
|
|
||||||
fn findPortSeparator(host: []const u8) ?usize {
|
|
||||||
if (host.len > 0 and host[0] == '[') {
|
|
||||||
// IPv6: find closing bracket, port separator must be after it
|
|
||||||
const bracket_end = std.mem.indexOfScalar(u8, host, ']') orelse return null;
|
|
||||||
if (bracket_end + 1 < host.len and host[bracket_end + 1] == ':') {
|
|
||||||
return bracket_end + 1;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Regular host: find last colon and verify it's followed by digits
|
|
||||||
const pos = std.mem.lastIndexOfScalar(u8, host, ':') orelse return null;
|
|
||||||
if (pos + 1 >= host.len) return null;
|
|
||||||
|
|
||||||
for (host[pos + 1 ..]) |c| {
|
for (host[pos + 1 ..]) |c| {
|
||||||
if (c < '0' or c > '9') return null;
|
if (c < '0' or c > '9') {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return pos;
|
|
||||||
|
return host[pos + 1 ..];
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn getSearch(raw: [:0]const u8) []const u8 {
|
pub fn getSearch(raw: [:0]const u8) []const u8 {
|
||||||
@@ -416,12 +403,23 @@ pub fn getOrigin(allocator: Allocator, raw: [:0]const u8) !?[]const u8 {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const auth = parseAuthority(raw) orelse return null;
|
var authority_start = scheme_end + 3;
|
||||||
const has_user_info = auth.has_user_info;
|
|
||||||
const authority_end = auth.host_end;
|
// Find end of authority (start of path/query/fragment or end of string)
|
||||||
|
const authority_end_relative = std.mem.indexOfAny(u8, raw[authority_start..], "/?#");
|
||||||
|
const authority_end = if (authority_end_relative) |end|
|
||||||
|
authority_start + end
|
||||||
|
else
|
||||||
|
raw.len;
|
||||||
|
|
||||||
|
// We mustn't search the `@` after the first path separator.
|
||||||
|
const has_user_info = if (std.mem.indexOf(u8, raw[authority_start..authority_end], "@")) |pos| blk: {
|
||||||
|
authority_start += pos + 1;
|
||||||
|
break :blk true;
|
||||||
|
} else false;
|
||||||
|
|
||||||
// Check for port in the host:port section
|
// Check for port in the host:port section
|
||||||
const host_part = auth.getHost(raw);
|
const host_part = raw[authority_start..authority_end];
|
||||||
if (std.mem.lastIndexOfScalar(u8, host_part, ':')) |colon_pos_in_host| {
|
if (std.mem.lastIndexOfScalar(u8, host_part, ':')) |colon_pos_in_host| {
|
||||||
const port = host_part[colon_pos_in_host + 1 ..];
|
const port = host_part[colon_pos_in_host + 1 ..];
|
||||||
|
|
||||||
@@ -462,18 +460,45 @@ pub fn getOrigin(allocator: Allocator, raw: [:0]const u8) !?[]const u8 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn getUserInfo(raw: [:0]const u8) ?[]const u8 {
|
fn getUserInfo(raw: [:0]const u8) ?[]const u8 {
|
||||||
const auth = parseAuthority(raw) orelse return null;
|
const scheme_end = std.mem.indexOf(u8, raw, "://") orelse return null;
|
||||||
if (!auth.has_user_info) return null;
|
|
||||||
|
|
||||||
// User info is from authority_start to host_start - 1 (excluding the @)
|
|
||||||
const scheme_end = std.mem.indexOf(u8, raw, "://").?;
|
|
||||||
const authority_start = scheme_end + 3;
|
const authority_start = scheme_end + 3;
|
||||||
return raw[authority_start .. auth.host_start - 1];
|
|
||||||
|
// We mustn't search the `@` after the first path separator.
|
||||||
|
const path_start = blk: {
|
||||||
|
if (std.mem.indexOfAny(u8, raw[authority_start..], "/?#")) |idx| {
|
||||||
|
break :blk authority_start + idx;
|
||||||
|
}
|
||||||
|
break :blk raw.len;
|
||||||
|
};
|
||||||
|
|
||||||
|
const pos = std.mem.indexOfScalar(u8, raw[authority_start..path_start], '@') orelse return null;
|
||||||
|
|
||||||
|
const full_pos = authority_start + pos;
|
||||||
|
if (full_pos < path_start) {
|
||||||
|
return raw[authority_start..full_pos];
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn getHost(raw: [:0]const u8) []const u8 {
|
pub fn getHost(raw: [:0]const u8) []const u8 {
|
||||||
const auth = parseAuthority(raw) orelse return "";
|
const scheme_end = std.mem.indexOf(u8, raw, "://") orelse return "";
|
||||||
return auth.getHost(raw);
|
|
||||||
|
var authority_start = scheme_end + 3;
|
||||||
|
|
||||||
|
// We mustn't search the `@` after the first path separator.
|
||||||
|
const path_start = blk: {
|
||||||
|
if (std.mem.indexOfAny(u8, raw[authority_start..], "/?#")) |idx| {
|
||||||
|
break :blk authority_start + idx;
|
||||||
|
}
|
||||||
|
break :blk raw.len;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (std.mem.indexOf(u8, raw[authority_start..path_start], "@")) |pos| {
|
||||||
|
authority_start += pos + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return raw[authority_start..path_start];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Returns true if these two URLs point to the same document.
|
// Returns true if these two URLs point to the same document.
|
||||||
@@ -752,47 +777,6 @@ pub fn unescape(arena: Allocator, input: []const u8) ![]const u8 {
|
|||||||
return result.items;
|
return result.items;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AuthorityInfo = struct {
|
|
||||||
host_start: usize,
|
|
||||||
host_end: usize,
|
|
||||||
has_user_info: bool,
|
|
||||||
|
|
||||||
fn getHost(self: AuthorityInfo, raw: []const u8) []const u8 {
|
|
||||||
return raw[self.host_start..self.host_end];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Parses the authority component of a URL, correctly handling userinfo.
|
|
||||||
// Returns null if the URL doesn't have a valid scheme (no "://").
|
|
||||||
// SECURITY: Only looks for @ within the authority portion (before /?#)
|
|
||||||
// to prevent path-based @ injection attacks.
|
|
||||||
fn parseAuthority(raw: []const u8) ?AuthorityInfo {
|
|
||||||
const scheme_end = std.mem.indexOf(u8, raw, "://") orelse return null;
|
|
||||||
const authority_start = scheme_end + 3;
|
|
||||||
|
|
||||||
// Find end of authority FIRST (start of path/query/fragment or end of string)
|
|
||||||
const authority_end = if (std.mem.indexOfAny(u8, raw[authority_start..], "/?#")) |end|
|
|
||||||
authority_start + end
|
|
||||||
else
|
|
||||||
raw.len;
|
|
||||||
|
|
||||||
// Only look for @ within the authority portion, not in path/query/fragment
|
|
||||||
const authority_portion = raw[authority_start..authority_end];
|
|
||||||
if (std.mem.indexOf(u8, authority_portion, "@")) |pos| {
|
|
||||||
return .{
|
|
||||||
.host_start = authority_start + pos + 1,
|
|
||||||
.host_end = authority_end,
|
|
||||||
.has_user_info = true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return .{
|
|
||||||
.host_start = authority_start,
|
|
||||||
.host_end = authority_end,
|
|
||||||
.has_user_info = false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const testing = @import("../testing.zig");
|
const testing = @import("../testing.zig");
|
||||||
test "URL: isCompleteHTTPUrl" {
|
test "URL: isCompleteHTTPUrl" {
|
||||||
try testing.expectEqual(true, isCompleteHTTPUrl("http://example.com/about"));
|
try testing.expectEqual(true, isCompleteHTTPUrl("http://example.com/about"));
|
||||||
@@ -1461,42 +1445,6 @@ test "URL: getHost" {
|
|||||||
try testing.expectEqualSlices(u8, "example.com", getHost("https://user:pass@example.com/page"));
|
try testing.expectEqualSlices(u8, "example.com", getHost("https://user:pass@example.com/page"));
|
||||||
try testing.expectEqualSlices(u8, "example.com:8080", getHost("https://user:pass@example.com:8080/page"));
|
try testing.expectEqualSlices(u8, "example.com:8080", getHost("https://user:pass@example.com:8080/page"));
|
||||||
try testing.expectEqualSlices(u8, "", getHost("not-a-url"));
|
try testing.expectEqualSlices(u8, "", getHost("not-a-url"));
|
||||||
|
|
||||||
// SECURITY: @ in path must NOT be treated as userinfo separator
|
|
||||||
try testing.expectEqualSlices(u8, "evil.example.com", getHost("http://evil.example.com/@victim.example.com/"));
|
|
||||||
try testing.expectEqualSlices(u8, "evil.example.com", getHost("https://evil.example.com/path/@victim.example.com"));
|
|
||||||
|
|
||||||
// IPv6 addresses
|
|
||||||
try testing.expectEqualSlices(u8, "[::1]:8080", getHost("http://[::1]:8080/path"));
|
|
||||||
try testing.expectEqualSlices(u8, "[::1]", getHost("http://[::1]/path"));
|
|
||||||
try testing.expectEqualSlices(u8, "[2001:db8::1]", getHost("https://[2001:db8::1]/"));
|
|
||||||
}
|
|
||||||
|
|
||||||
test "URL: getHostname" {
|
|
||||||
// Regular hosts
|
|
||||||
try testing.expectEqualSlices(u8, "example.com", getHostname("https://example.com:8080/path"));
|
|
||||||
try testing.expectEqualSlices(u8, "example.com", getHostname("https://example.com/path"));
|
|
||||||
|
|
||||||
// IPv6 with port
|
|
||||||
try testing.expectEqualSlices(u8, "[::1]", getHostname("http://[::1]:8080/path"));
|
|
||||||
|
|
||||||
// IPv6 without port - must return full bracket notation
|
|
||||||
try testing.expectEqualSlices(u8, "[::1]", getHostname("http://[::1]/path"));
|
|
||||||
try testing.expectEqualSlices(u8, "[2001:db8::1]", getHostname("https://[2001:db8::1]/"));
|
|
||||||
}
|
|
||||||
|
|
||||||
test "URL: getPort" {
|
|
||||||
// Regular hosts
|
|
||||||
try testing.expectEqualSlices(u8, "8080", getPort("https://example.com:8080/path"));
|
|
||||||
try testing.expectEqualSlices(u8, "", getPort("https://example.com/path"));
|
|
||||||
|
|
||||||
// IPv6 with port
|
|
||||||
try testing.expectEqualSlices(u8, "8080", getPort("http://[::1]:8080/path"));
|
|
||||||
try testing.expectEqualSlices(u8, "3000", getPort("http://[2001:db8::1]:3000/"));
|
|
||||||
|
|
||||||
// IPv6 without port - colons inside brackets must not be treated as port separator
|
|
||||||
try testing.expectEqualSlices(u8, "", getPort("http://[::1]/path"));
|
|
||||||
try testing.expectEqualSlices(u8, "", getPort("https://[2001:db8::1]/"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
test "URL: setPathname percent-encodes" {
|
test "URL: setPathname percent-encodes" {
|
||||||
@@ -1519,54 +1467,34 @@ test "URL: setPathname percent-encodes" {
|
|||||||
}
|
}
|
||||||
|
|
||||||
test "URL: getOrigin" {
|
test "URL: getOrigin" {
|
||||||
defer testing.reset();
|
var arena = std.heap.ArenaAllocator.init(testing.allocator);
|
||||||
|
defer arena.deinit();
|
||||||
const Case = struct {
|
const allocator = arena.allocator();
|
||||||
url: [:0]const u8,
|
try testing.expectEqualSlices(u8, "http://example.com:8080", try getOrigin(allocator, "http://example.com:8080/path") orelse unreachable);
|
||||||
expected: ?[]const u8,
|
try testing.expectEqualSlices(u8, "https://example.com:8080", try getOrigin(allocator, "https://example.com:8080/path") orelse unreachable);
|
||||||
};
|
try testing.expectEqualSlices(u8, "https://example.com", try getOrigin(allocator, "https://example.com/path") orelse unreachable);
|
||||||
|
try testing.expectEqualSlices(u8, "https://example.com", try getOrigin(allocator, "https://example.com:443/") orelse unreachable);
|
||||||
const cases = [_]Case{
|
try testing.expectEqualSlices(u8, "https://example.com", try getOrigin(allocator, "https://user:pass@example.com/page") orelse unreachable);
|
||||||
// Basic HTTP/HTTPS origins
|
try testing.expectEqualSlices(u8, "https://example.com:8080", try getOrigin(allocator, "https://user:pass@example.com:8080/page") orelse unreachable);
|
||||||
.{ .url = "http://example.com/path", .expected = "http://example.com" },
|
try testing.expectEqual(null, try getOrigin(allocator, "not-a-url"));
|
||||||
.{ .url = "https://example.com/path", .expected = "https://example.com" },
|
}
|
||||||
.{ .url = "https://example.com:8080/path", .expected = "https://example.com:8080" },
|
|
||||||
|
test "URL: SOP bypass" {
|
||||||
// Default ports should be stripped
|
// SOP Bypass
|
||||||
.{ .url = "http://example.com:80/path", .expected = "http://example.com" },
|
try testing.expectEqualSlices(u8, "attacker.com", getHost("http://attacker.com/@bank.com/"));
|
||||||
.{ .url = "https://example.com:443/path", .expected = "https://example.com" },
|
try testing.expectEqualSlices(u8, "attacker.com", getHost("https://attacker.com/@bank.com/"));
|
||||||
|
try testing.expectEqualSlices(u8, "attacker.com", getHost("http://attacker.com?@bank.com/"));
|
||||||
// User info should be stripped from origin
|
try testing.expectEqualSlices(u8, "attacker.com", getHost("http://attacker.com#@bank.com/"));
|
||||||
.{ .url = "http://user:pass@example.com/path", .expected = "http://example.com" },
|
try testing.expectEqualSlices(u8, "attacker.com", getHostname("http://attacker.com/@bank.com/"));
|
||||||
.{ .url = "https://user@example.com:8080/path", .expected = "https://example.com:8080" },
|
try testing.expectEqualSlices(u8, "attacker.com", getHostname("https://attacker.com/@bank.com/"));
|
||||||
|
try testing.expectEqualSlices(u8, "attacker.com", getHostname("http://attacker.com?@bank.com/"));
|
||||||
// Non-HTTP schemes return null
|
try testing.expectEqualSlices(u8, "attacker.com", getHostname("http://attacker.com#@bank.com/"));
|
||||||
.{ .url = "ftp://example.com/path", .expected = null },
|
|
||||||
.{ .url = "file:///path/to/file", .expected = null },
|
var arena = std.heap.ArenaAllocator.init(testing.allocator);
|
||||||
.{ .url = "about:blank", .expected = null },
|
defer arena.deinit();
|
||||||
|
const allocator = arena.allocator();
|
||||||
// Query and fragment should not affect origin
|
try testing.expectEqualSlices(u8, "http://attacker.com", try getOrigin(allocator, "http://attacker.com/@bank.com/") orelse unreachable);
|
||||||
.{ .url = "https://example.com?query=1", .expected = "https://example.com" },
|
try testing.expectEqualSlices(u8, "https://attacker.com", try getOrigin(allocator, "https://attacker.com/@bank.com/") orelse unreachable);
|
||||||
.{ .url = "https://example.com#fragment", .expected = "https://example.com" },
|
try testing.expectEqualSlices(u8, "http://attacker.com", try getOrigin(allocator, "http://attacker.com?bank.com/") orelse unreachable);
|
||||||
.{ .url = "https://example.com/path?q=1#frag", .expected = "https://example.com" },
|
try testing.expectEqualSlices(u8, "http://attacker.com", try getOrigin(allocator, "http://attacker.com#bank.com/") orelse unreachable);
|
||||||
|
|
||||||
// SECURITY: @ in path must NOT be treated as userinfo separator
|
|
||||||
// This would be a Same-Origin Policy bypass if mishandled
|
|
||||||
.{ .url = "http://evil.example.com/@victim.example.com/", .expected = "http://evil.example.com" },
|
|
||||||
.{ .url = "https://evil.example.com/path/@victim.example.com/steal", .expected = "https://evil.example.com" },
|
|
||||||
.{ .url = "http://evil.example.com/@victim.example.com:443/", .expected = "http://evil.example.com" },
|
|
||||||
|
|
||||||
// @ in query/fragment must also not affect origin
|
|
||||||
.{ .url = "https://example.com/path?user=foo@bar.com", .expected = "https://example.com" },
|
|
||||||
.{ .url = "https://example.com/path#user@host", .expected = "https://example.com" },
|
|
||||||
};
|
|
||||||
|
|
||||||
for (cases) |case| {
|
|
||||||
const result = try getOrigin(testing.arena_allocator, case.url);
|
|
||||||
if (case.expected) |expected| {
|
|
||||||
try testing.expectString(expected, result.?);
|
|
||||||
} else {
|
|
||||||
try testing.expectEqual(null, result);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,7 +36,6 @@ pub const InteractivityType = enum {
|
|||||||
};
|
};
|
||||||
|
|
||||||
pub const InteractiveElement = struct {
|
pub const InteractiveElement = struct {
|
||||||
backendNodeId: ?u32 = null,
|
|
||||||
node: *Node,
|
node: *Node,
|
||||||
tag_name: []const u8,
|
tag_name: []const u8,
|
||||||
role: ?[]const u8,
|
role: ?[]const u8,
|
||||||
@@ -56,11 +55,6 @@ pub const InteractiveElement = struct {
|
|||||||
pub fn jsonStringify(self: *const InteractiveElement, jw: anytype) !void {
|
pub fn jsonStringify(self: *const InteractiveElement, jw: anytype) !void {
|
||||||
try jw.beginObject();
|
try jw.beginObject();
|
||||||
|
|
||||||
if (self.backendNodeId) |id| {
|
|
||||||
try jw.objectField("backendNodeId");
|
|
||||||
try jw.write(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
try jw.objectField("tagName");
|
try jw.objectField("tagName");
|
||||||
try jw.write(self.tag_name);
|
try jw.write(self.tag_name);
|
||||||
|
|
||||||
@@ -129,15 +123,6 @@ pub const InteractiveElement = struct {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Populate backendNodeId on each interactive element by registering
|
|
||||||
/// their nodes in the given registry. Works with both CDP and MCP registries.
|
|
||||||
pub fn registerNodes(elements: []InteractiveElement, registry: anytype) !void {
|
|
||||||
for (elements) |*el| {
|
|
||||||
const registered = try registry.register(el.node);
|
|
||||||
el.backendNodeId = registered.id;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Collect all interactive elements under `root`.
|
/// Collect all interactive elements under `root`.
|
||||||
pub fn collectInteractiveElements(
|
pub fn collectInteractiveElements(
|
||||||
root: *Node,
|
root: *Node,
|
||||||
|
|||||||
@@ -853,7 +853,6 @@ pub const JsApis = flattenTypes(&.{
|
|||||||
@import("../webapi/event/InputEvent.zig"),
|
@import("../webapi/event/InputEvent.zig"),
|
||||||
@import("../webapi/event/PromiseRejectionEvent.zig"),
|
@import("../webapi/event/PromiseRejectionEvent.zig"),
|
||||||
@import("../webapi/event/SubmitEvent.zig"),
|
@import("../webapi/event/SubmitEvent.zig"),
|
||||||
@import("../webapi/event/FormDataEvent.zig"),
|
|
||||||
@import("../webapi/MessageChannel.zig"),
|
@import("../webapi/MessageChannel.zig"),
|
||||||
@import("../webapi/MessagePort.zig"),
|
@import("../webapi/MessagePort.zig"),
|
||||||
@import("../webapi/media/MediaError.zig"),
|
@import("../webapi/media/MediaError.zig"),
|
||||||
|
|||||||
@@ -1,54 +0,0 @@
|
|||||||
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
|
|
||||||
//
|
|
||||||
// Francis Bouvier <francis@lightpanda.io>
|
|
||||||
// Pierre Tachoire <pierre@lightpanda.io>
|
|
||||||
//
|
|
||||||
// This program is free software: you can redistribute it and/or modify
|
|
||||||
// it under the terms of the GNU Affero General Public License as
|
|
||||||
// published by the Free Software Foundation, either version 3 of the
|
|
||||||
// License, or (at your option) any later version.
|
|
||||||
//
|
|
||||||
// This program is distributed in the hope that it will be useful,
|
|
||||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
// GNU Affero General Public License for more details.
|
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public License
|
|
||||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
const std = @import("std");
|
|
||||||
|
|
||||||
const Element = @import("webapi/Element.zig");
|
|
||||||
const Node = @import("webapi/Node.zig");
|
|
||||||
const Page = @import("Page.zig");
|
|
||||||
const Selector = @import("webapi/selector/Selector.zig");
|
|
||||||
|
|
||||||
const Allocator = std.mem.Allocator;
|
|
||||||
|
|
||||||
/// Collect all links (href attributes from anchor tags) under `root`.
|
|
||||||
/// Returns a slice of strings allocated with `arena`.
|
|
||||||
pub fn collectLinks(arena: Allocator, root: *Node, page: *Page) ![]const []const u8 {
|
|
||||||
var links: std.ArrayList([]const u8) = .empty;
|
|
||||||
|
|
||||||
if (Selector.querySelectorAll(root, "a[href]", page)) |list| {
|
|
||||||
defer list.deinit(page._session);
|
|
||||||
|
|
||||||
for (list._nodes) |node| {
|
|
||||||
if (node.is(Element.Html.Anchor)) |anchor| {
|
|
||||||
const href = anchor.getHref(page) catch |err| {
|
|
||||||
@import("../lightpanda.zig").log.err(.app, "resolve href failed", .{ .err = err });
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (href.len > 0) {
|
|
||||||
try links.append(arena, href);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else |err| {
|
|
||||||
@import("../lightpanda.zig").log.err(.app, "query links failed", .{ .err = err });
|
|
||||||
return err;
|
|
||||||
}
|
|
||||||
|
|
||||||
return links.items;
|
|
||||||
}
|
|
||||||
@@ -125,19 +125,6 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
||||||
<script id="CanvasRenderingContext2D#canvas">
|
|
||||||
{
|
|
||||||
const element = document.createElement("canvas");
|
|
||||||
const ctx = element.getContext("2d");
|
|
||||||
testing.expectEqual(ctx.canvas, element);
|
|
||||||
// Setting dimensions via ctx.canvas should update the element.
|
|
||||||
ctx.canvas.width = 40;
|
|
||||||
ctx.canvas.height = 25;
|
|
||||||
testing.expectEqual(element.width, 40);
|
|
||||||
testing.expectEqual(element.height, 25);
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<script id="getter">
|
<script id="getter">
|
||||||
{
|
{
|
||||||
const element = document.createElement("canvas");
|
const element = document.createElement("canvas");
|
||||||
|
|||||||
@@ -734,101 +734,3 @@
|
|||||||
testing.expectEqual([['field', 'data'], ['x', '0'], ['y', '0']], entries);
|
testing.expectEqual([['field', 'data'], ['x', '0'], ['y', '0']], entries);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script id=formDataEventFires>
|
|
||||||
{
|
|
||||||
// formdata event fires on the form when FormData is constructed with a form
|
|
||||||
const form = document.createElement('form');
|
|
||||||
|
|
||||||
const input = document.createElement('input');
|
|
||||||
input.name = 'field';
|
|
||||||
input.value = 'hello';
|
|
||||||
form.appendChild(input);
|
|
||||||
|
|
||||||
let eventFired = false;
|
|
||||||
let receivedFormData = null;
|
|
||||||
|
|
||||||
form.addEventListener('formdata', (e) => {
|
|
||||||
eventFired = true;
|
|
||||||
receivedFormData = e.formData;
|
|
||||||
});
|
|
||||||
|
|
||||||
const fd = new FormData(form);
|
|
||||||
testing.expectEqual(true, eventFired);
|
|
||||||
testing.expectEqual(fd, receivedFormData);
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<script id=formDataEventNotFiredWithoutForm>
|
|
||||||
{
|
|
||||||
// formdata event should NOT fire when FormData is constructed without a form
|
|
||||||
const fd = new FormData();
|
|
||||||
fd.append('a', '1');
|
|
||||||
testing.expectEqual('1', fd.get('a'));
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<script id=formDataEventBubbles>
|
|
||||||
{
|
|
||||||
// formdata event should bubble
|
|
||||||
const container = document.createElement('div');
|
|
||||||
const form = document.createElement('form');
|
|
||||||
container.appendChild(form);
|
|
||||||
document.body.appendChild(container);
|
|
||||||
|
|
||||||
const input = document.createElement('input');
|
|
||||||
input.name = 'x';
|
|
||||||
input.value = '1';
|
|
||||||
form.appendChild(input);
|
|
||||||
|
|
||||||
let bubbled = false;
|
|
||||||
container.addEventListener('formdata', () => {
|
|
||||||
bubbled = true;
|
|
||||||
});
|
|
||||||
|
|
||||||
const fd = new FormData(form);
|
|
||||||
testing.expectEqual(true, bubbled);
|
|
||||||
|
|
||||||
document.body.removeChild(container);
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<script id=formDataEventNotCancelable>
|
|
||||||
{
|
|
||||||
// formdata event should not be cancelable
|
|
||||||
const form = document.createElement('form');
|
|
||||||
|
|
||||||
const input = document.createElement('input');
|
|
||||||
input.name = 'key';
|
|
||||||
input.value = 'val';
|
|
||||||
form.appendChild(input);
|
|
||||||
|
|
||||||
let cancelable = null;
|
|
||||||
form.addEventListener('formdata', (e) => {
|
|
||||||
cancelable = e.cancelable;
|
|
||||||
});
|
|
||||||
|
|
||||||
const fd = new FormData(form);
|
|
||||||
testing.expectEqual(false, cancelable);
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<script id=formDataEventModifyFormData>
|
|
||||||
{
|
|
||||||
// Listeners can modify formData during the event
|
|
||||||
const form = document.createElement('form');
|
|
||||||
|
|
||||||
const input = document.createElement('input');
|
|
||||||
input.name = 'original';
|
|
||||||
input.value = 'data';
|
|
||||||
form.appendChild(input);
|
|
||||||
|
|
||||||
form.addEventListener('formdata', (e) => {
|
|
||||||
e.formData.append('added', 'by-listener');
|
|
||||||
});
|
|
||||||
|
|
||||||
const fd = new FormData(form);
|
|
||||||
testing.expectEqual('data', fd.get('original'));
|
|
||||||
testing.expectEqual('by-listener', fd.get('added'));
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|||||||
@@ -871,3 +871,33 @@
|
|||||||
testing.expectEqual('', url.search);
|
testing.expectEqual('', url.search);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<script id="SOP Bypass">
|
||||||
|
{
|
||||||
|
const url = new URL('http://example.com/@bank.com');
|
||||||
|
testing.expectEqual('http:', url.protocol);
|
||||||
|
testing.expectEqual('example.com', url.hostname);
|
||||||
|
testing.expectEqual('', url.port);
|
||||||
|
testing.expectEqual('http://example.com', url.origin);
|
||||||
|
testing.expectEqual('', url.username);
|
||||||
|
testing.expectEqual('', url.password);
|
||||||
|
}
|
||||||
|
{
|
||||||
|
const url = new URL('http://example.com?@bank.com');
|
||||||
|
testing.expectEqual('http:', url.protocol);
|
||||||
|
testing.expectEqual('example.com', url.hostname);
|
||||||
|
testing.expectEqual('', url.port);
|
||||||
|
testing.expectEqual('http://example.com', url.origin);
|
||||||
|
testing.expectEqual('', url.username);
|
||||||
|
testing.expectEqual('', url.password);
|
||||||
|
}
|
||||||
|
{
|
||||||
|
const url = new URL('http://example.com#@bank.com');
|
||||||
|
testing.expectEqual('http:', url.protocol);
|
||||||
|
testing.expectEqual('example.com', url.hostname);
|
||||||
|
testing.expectEqual('', url.port);
|
||||||
|
testing.expectEqual('http://example.com', url.origin);
|
||||||
|
testing.expectEqual('', url.username);
|
||||||
|
testing.expectEqual('', url.password);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|||||||
@@ -77,7 +77,6 @@ pub const Type = union(enum) {
|
|||||||
ui_event: *@import("event/UIEvent.zig"),
|
ui_event: *@import("event/UIEvent.zig"),
|
||||||
promise_rejection_event: *@import("event/PromiseRejectionEvent.zig"),
|
promise_rejection_event: *@import("event/PromiseRejectionEvent.zig"),
|
||||||
submit_event: *@import("event/SubmitEvent.zig"),
|
submit_event: *@import("event/SubmitEvent.zig"),
|
||||||
form_data_event: *@import("event/FormDataEvent.zig"),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
pub const Options = struct {
|
pub const Options = struct {
|
||||||
@@ -177,7 +176,6 @@ pub fn is(self: *Event, comptime T: type) ?*T {
|
|||||||
.pop_state_event => |e| return if (T == @import("event/PopStateEvent.zig")) e else null,
|
.pop_state_event => |e| return if (T == @import("event/PopStateEvent.zig")) e else null,
|
||||||
.promise_rejection_event => |e| return if (T == @import("event/PromiseRejectionEvent.zig")) e else null,
|
.promise_rejection_event => |e| return if (T == @import("event/PromiseRejectionEvent.zig")) e else null,
|
||||||
.submit_event => |e| return if (T == @import("event/SubmitEvent.zig")) e else null,
|
.submit_event => |e| return if (T == @import("event/SubmitEvent.zig")) e else null,
|
||||||
.form_data_event => |e| return if (T == @import("event/FormDataEvent.zig")) e else null,
|
|
||||||
.ui_event => |e| {
|
.ui_event => |e| {
|
||||||
if (T == @import("event/UIEvent.zig")) {
|
if (T == @import("event/UIEvent.zig")) {
|
||||||
return e;
|
return e;
|
||||||
|
|||||||
@@ -62,6 +62,5 @@ pub const JsApi = struct {
|
|||||||
|
|
||||||
pub const constructor = bridge.constructor(ResizeObserver.init, .{});
|
pub const constructor = bridge.constructor(ResizeObserver.init, .{});
|
||||||
pub const observe = bridge.function(ResizeObserver.observe, .{});
|
pub const observe = bridge.function(ResizeObserver.observe, .{});
|
||||||
pub const unobserve = bridge.function(ResizeObserver.unobserve, .{});
|
|
||||||
pub const disconnect = bridge.function(ResizeObserver.disconnect, .{});
|
pub const disconnect = bridge.function(ResizeObserver.disconnect, .{});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -23,24 +23,16 @@ const js = @import("../../js/js.zig");
|
|||||||
const color = @import("../../color.zig");
|
const color = @import("../../color.zig");
|
||||||
const Page = @import("../../Page.zig");
|
const Page = @import("../../Page.zig");
|
||||||
|
|
||||||
const Canvas = @import("../element/html/Canvas.zig");
|
|
||||||
const ImageData = @import("../ImageData.zig");
|
const ImageData = @import("../ImageData.zig");
|
||||||
|
|
||||||
/// This class doesn't implement a `constructor`.
|
/// This class doesn't implement a `constructor`.
|
||||||
/// It can be obtained with a call to `HTMLCanvasElement#getContext`.
|
/// It can be obtained with a call to `HTMLCanvasElement#getContext`.
|
||||||
/// https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D
|
/// https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D
|
||||||
const CanvasRenderingContext2D = @This();
|
const CanvasRenderingContext2D = @This();
|
||||||
/// Reference to the parent canvas element.
|
|
||||||
/// https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/canvas
|
|
||||||
_canvas: *Canvas,
|
|
||||||
/// Fill color.
|
/// Fill color.
|
||||||
/// TODO: Add support for `CanvasGradient` and `CanvasPattern`.
|
/// TODO: Add support for `CanvasGradient` and `CanvasPattern`.
|
||||||
_fill_style: color.RGBA = color.RGBA.Named.black,
|
_fill_style: color.RGBA = color.RGBA.Named.black,
|
||||||
|
|
||||||
pub fn getCanvas(self: *const CanvasRenderingContext2D) *Canvas {
|
|
||||||
return self._canvas;
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn getFillStyle(self: *const CanvasRenderingContext2D, page: *Page) ![]const u8 {
|
pub fn getFillStyle(self: *const CanvasRenderingContext2D, page: *Page) ![]const u8 {
|
||||||
var w = std.Io.Writer.Allocating.init(page.call_arena);
|
var w = std.Io.Writer.Allocating.init(page.call_arena);
|
||||||
try self._fill_style.format(&w.writer);
|
try self._fill_style.format(&w.writer);
|
||||||
@@ -133,7 +125,6 @@ pub const JsApi = struct {
|
|||||||
pub var class_id: bridge.ClassId = undefined;
|
pub var class_id: bridge.ClassId = undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
pub const canvas = bridge.accessor(CanvasRenderingContext2D.getCanvas, null, .{});
|
|
||||||
pub const font = bridge.property("10px sans-serif", .{ .template = false, .readonly = false });
|
pub const font = bridge.property("10px sans-serif", .{ .template = false, .readonly = false });
|
||||||
pub const globalAlpha = bridge.property(1.0, .{ .template = false, .readonly = false });
|
pub const globalAlpha = bridge.property(1.0, .{ .template = false, .readonly = false });
|
||||||
pub const globalCompositeOperation = bridge.property("source-over", .{ .template = false, .readonly = false });
|
pub const globalCompositeOperation = bridge.property("source-over", .{ .template = false, .readonly = false });
|
||||||
|
|||||||
@@ -67,9 +67,9 @@ const DrawingContext = union(enum) {
|
|||||||
webgl: *WebGLRenderingContext,
|
webgl: *WebGLRenderingContext,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub fn getContext(self: *Canvas, context_type: []const u8, page: *Page) !?DrawingContext {
|
pub fn getContext(_: *Canvas, context_type: []const u8, page: *Page) !?DrawingContext {
|
||||||
if (std.mem.eql(u8, context_type, "2d")) {
|
if (std.mem.eql(u8, context_type, "2d")) {
|
||||||
const ctx = try page._factory.create(CanvasRenderingContext2D{ ._canvas = self });
|
const ctx = try page._factory.create(CanvasRenderingContext2D{});
|
||||||
return .{ .@"2d" = ctx };
|
return .{ .@"2d" = ctx };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,95 +0,0 @@
|
|||||||
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
|
|
||||||
//
|
|
||||||
// Francis Bouvier <francis@lightpanda.io>
|
|
||||||
// Pierre Tachoire <pierre@lightpanda.io>
|
|
||||||
//
|
|
||||||
// This program is free software: you can redistribute it and/or modify
|
|
||||||
// it under the terms of the GNU Affero General Public License as
|
|
||||||
// published by the Free Software Foundation, either version 3 of the
|
|
||||||
// License, or (at your option) any later version.
|
|
||||||
//
|
|
||||||
// This program is distributed in the hope that it will be useful,
|
|
||||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
// GNU Affero General Public License for more details.
|
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public License
|
|
||||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
const std = @import("std");
|
|
||||||
const Allocator = std.mem.Allocator;
|
|
||||||
const String = @import("../../../string.zig").String;
|
|
||||||
const Page = @import("../../Page.zig");
|
|
||||||
const Session = @import("../../Session.zig");
|
|
||||||
const js = @import("../../js/js.zig");
|
|
||||||
|
|
||||||
const Event = @import("../Event.zig");
|
|
||||||
const UIEvent = @import("UIEvent.zig");
|
|
||||||
|
|
||||||
const FormData = @import("../net/FormData.zig");
|
|
||||||
|
|
||||||
/// https://developer.mozilla.org/en-US/docs/Web/API/FormDataEvent
|
|
||||||
const FormDataEvent = @This();
|
|
||||||
|
|
||||||
_proto: *Event,
|
|
||||||
_form_data: ?*FormData = null,
|
|
||||||
|
|
||||||
const Options = Event.inheritOptions(FormDataEvent, struct {
|
|
||||||
formData: ?*FormData = null,
|
|
||||||
});
|
|
||||||
|
|
||||||
pub fn init(typ: []const u8, maybe_options: Options, page: *Page) !*FormDataEvent {
|
|
||||||
const arena = try page.getArena(.{ .debug = "FormDataEvent" });
|
|
||||||
errdefer page.releaseArena(arena);
|
|
||||||
const type_string = try String.init(arena, typ, .{});
|
|
||||||
return initWithTrusted(arena, type_string, maybe_options, false, page);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn initTrusted(typ: String, _opts: ?Options, page: *Page) !*FormDataEvent {
|
|
||||||
const arena = try page.getArena(.{ .debug = "FormDataEvent.trusted" });
|
|
||||||
errdefer page.releaseArena(arena);
|
|
||||||
return initWithTrusted(arena, typ, _opts, true, page);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn initWithTrusted(arena: Allocator, typ: String, maybe_options: ?Options, trusted: bool, page: *Page) !*FormDataEvent {
|
|
||||||
const options = maybe_options orelse Options{};
|
|
||||||
|
|
||||||
const event = try page._factory.event(
|
|
||||||
arena,
|
|
||||||
typ,
|
|
||||||
FormDataEvent{
|
|
||||||
._proto = undefined,
|
|
||||||
._form_data = options.formData,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
Event.populatePrototypes(event, options, trusted);
|
|
||||||
return event;
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn deinit(self: *FormDataEvent, shutdown: bool, session: *Session) void {
|
|
||||||
self._proto.deinit(shutdown, session);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn asEvent(self: *FormDataEvent) *Event {
|
|
||||||
return self._proto;
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn getFormData(self: *const FormDataEvent) ?*FormData {
|
|
||||||
return self._form_data;
|
|
||||||
}
|
|
||||||
|
|
||||||
pub const JsApi = struct {
|
|
||||||
pub const bridge = js.Bridge(FormDataEvent);
|
|
||||||
|
|
||||||
pub const Meta = struct {
|
|
||||||
pub const name = "FormDataEvent";
|
|
||||||
pub const prototype_chain = bridge.prototypeChain();
|
|
||||||
pub var class_id: bridge.ClassId = undefined;
|
|
||||||
pub const weak = true;
|
|
||||||
pub const finalizer = bridge.finalizer(FormDataEvent.deinit);
|
|
||||||
};
|
|
||||||
|
|
||||||
pub const constructor = bridge.constructor(FormDataEvent.init, .{});
|
|
||||||
pub const formData = bridge.accessor(FormDataEvent.getFormData, null, .{});
|
|
||||||
};
|
|
||||||
@@ -26,7 +26,7 @@ const Event = @import("../Event.zig");
|
|||||||
const HtmlElement = @import("../element/Html.zig");
|
const HtmlElement = @import("../element/Html.zig");
|
||||||
const Allocator = std.mem.Allocator;
|
const Allocator = std.mem.Allocator;
|
||||||
|
|
||||||
/// https://developer.mozilla.org/en-US/docs/Web/API/SubmitEvent
|
// https://developer.mozilla.org/en-US/docs/Web/API/SubmitEvent
|
||||||
const SubmitEvent = @This();
|
const SubmitEvent = @This();
|
||||||
|
|
||||||
_proto: *Event,
|
_proto: *Event,
|
||||||
|
|||||||
@@ -35,22 +35,10 @@ _arena: Allocator,
|
|||||||
_list: KeyValueList,
|
_list: KeyValueList,
|
||||||
|
|
||||||
pub fn init(form: ?*Form, submitter: ?*Element, page: *Page) !*FormData {
|
pub fn init(form: ?*Form, submitter: ?*Element, page: *Page) !*FormData {
|
||||||
const form_data = try page._factory.create(FormData{
|
return page._factory.create(FormData{
|
||||||
._arena = page.arena,
|
._arena = page.arena,
|
||||||
._list = try collectForm(page.arena, form, submitter, page),
|
._list = try collectForm(page.arena, form, submitter, page),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Dispatch `formdata` event if form provided.
|
|
||||||
if (form) |_form| {
|
|
||||||
const form_data_event = try (@import("../event/FormDataEvent.zig")).initTrusted(
|
|
||||||
comptime .wrap("formdata"),
|
|
||||||
.{ .bubbles = true, .cancelable = false, .formData = form_data },
|
|
||||||
page,
|
|
||||||
);
|
|
||||||
try page._event_manager.dispatch(_form.asNode().asEventTarget(), form_data_event.asEvent());
|
|
||||||
}
|
|
||||||
|
|
||||||
return form_data;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get(self: *const FormData, name: []const u8) ?[]const u8 {
|
pub fn get(self: *const FormData, name: []const u8) ?[]const u8 {
|
||||||
|
|||||||
@@ -135,10 +135,17 @@ fn getInteractiveElements(cmd: anytype) !void {
|
|||||||
page.document.asNode();
|
page.document.asNode();
|
||||||
|
|
||||||
const elements = try interactive.collectInteractiveElements(root, cmd.arena, page);
|
const elements = try interactive.collectInteractiveElements(root, cmd.arena, page);
|
||||||
try interactive.registerNodes(elements, &bc.node_registry);
|
|
||||||
|
// Register nodes so nodeIds are valid for subsequent CDP calls.
|
||||||
|
var node_ids: std.ArrayList(Node.Id) = try .initCapacity(cmd.arena, elements.len);
|
||||||
|
for (elements) |el| {
|
||||||
|
const registered = try bc.node_registry.register(el.node);
|
||||||
|
node_ids.appendAssumeCapacity(registered.id);
|
||||||
|
}
|
||||||
|
|
||||||
return cmd.sendResult(.{
|
return cmd.sendResult(.{
|
||||||
.elements = elements,
|
.elements = elements,
|
||||||
|
.nodeIds = node_ids.items,
|
||||||
}, .{});
|
}, .{});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -301,6 +308,7 @@ test "cdp.lp: getInteractiveElements" {
|
|||||||
|
|
||||||
const result = (try ctx.getSentMessage(0)).?.object.get("result").?.object;
|
const result = (try ctx.getSentMessage(0)).?.object.get("result").?.object;
|
||||||
try testing.expect(result.get("elements") != null);
|
try testing.expect(result.get("elements") != null);
|
||||||
|
try testing.expect(result.get("nodeIds") != null);
|
||||||
}
|
}
|
||||||
|
|
||||||
test "cdp.lp: getStructuredData" {
|
test "cdp.lp: getStructuredData" {
|
||||||
|
|||||||
@@ -158,13 +158,13 @@ pub extern "C" fn html5ever_attribute_iterator_next(
|
|||||||
|
|
||||||
let attr = &iter.vec[pos];
|
let attr = &iter.vec[pos];
|
||||||
iter.pos += 1;
|
iter.pos += 1;
|
||||||
CNullable::<CAttribute>::some(CAttribute {
|
return CNullable::<CAttribute>::some(CAttribute {
|
||||||
name: CQualName::create(&attr.name),
|
name: CQualName::create(&attr.name),
|
||||||
value: StringSlice {
|
value: StringSlice {
|
||||||
ptr: attr.value.as_ptr(),
|
ptr: attr.value.as_ptr(),
|
||||||
len: attr.value.len(),
|
len: attr.value.len(),
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
#[no_mangle]
|
#[no_mangle]
|
||||||
@@ -186,12 +186,12 @@ pub extern "C" fn html5ever_get_memory_usage() -> Memory {
|
|||||||
use tikv_jemalloc_ctl::{epoch, stats};
|
use tikv_jemalloc_ctl::{epoch, stats};
|
||||||
|
|
||||||
// many statistics are cached and only updated when the epoch is advanced.
|
// many statistics are cached and only updated when the epoch is advanced.
|
||||||
drop(epoch::advance());
|
epoch::advance().unwrap();
|
||||||
|
|
||||||
Memory {
|
return Memory {
|
||||||
resident: stats::resident::read().unwrap_or(0),
|
resident: stats::resident::read().unwrap(),
|
||||||
allocated: stats::allocated::read().unwrap_or(0),
|
allocated: stats::allocated::read().unwrap(),
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Streaming parser API
|
// Streaming parser API
|
||||||
@@ -325,7 +325,7 @@ pub extern "C" fn html5ever_streaming_parser_destroy(parser_ptr: *mut c_void) {
|
|||||||
// Drop the parser box without finishing
|
// Drop the parser box without finishing
|
||||||
// This is for cases where you want to cancel parsing
|
// This is for cases where you want to cancel parsing
|
||||||
unsafe {
|
unsafe {
|
||||||
drop(Box::from_raw(parser_ptr as *mut StreamingParser));
|
let _ = Box::from_raw(parser_ptr as *mut StreamingParser);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,10 +36,10 @@ pub struct ElementData {
|
|||||||
}
|
}
|
||||||
impl ElementData {
|
impl ElementData {
|
||||||
fn new(qname: QualName, flags: ElementFlags) -> Self {
|
fn new(qname: QualName, flags: ElementFlags) -> Self {
|
||||||
Self {
|
return Self {
|
||||||
qname: qname,
|
qname: qname,
|
||||||
mathml_annotation_xml_integration_point: flags.mathml_annotation_xml_integration_point,
|
mathml_annotation_xml_integration_point: flags.mathml_annotation_xml_integration_point,
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,12 +130,12 @@ impl<'arena> TreeSink for Sink<'arena> {
|
|||||||
unsafe {
|
unsafe {
|
||||||
let mut attribute_iterator = CAttributeIterator { vec: attrs, pos: 0 };
|
let mut attribute_iterator = CAttributeIterator { vec: attrs, pos: 0 };
|
||||||
|
|
||||||
(self.create_element_callback)(
|
return (self.create_element_callback)(
|
||||||
self.ctx,
|
self.ctx,
|
||||||
data as *mut _ as *mut c_void,
|
data as *mut _ as *mut c_void,
|
||||||
CQualName::create(&name),
|
CQualName::create(&name),
|
||||||
&mut attribute_iterator as *mut _ as *mut c_void,
|
&mut attribute_iterator as *mut _ as *mut c_void,
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -126,21 +126,21 @@ impl CQualName {
|
|||||||
None => CNullable::<StringSlice>::none(),
|
None => CNullable::<StringSlice>::none(),
|
||||||
Some(prefix) => CNullable::<StringSlice>::some(StringSlice { ptr: prefix.as_ptr(), len: prefix.len()}),
|
Some(prefix) => CNullable::<StringSlice>::some(StringSlice { ptr: prefix.as_ptr(), len: prefix.len()}),
|
||||||
};
|
};
|
||||||
CQualName{
|
return CQualName{
|
||||||
// inner: q as *const _ as *const c_void,
|
// inner: q as *const _ as *const c_void,
|
||||||
ns: ns,
|
ns: ns,
|
||||||
local: local,
|
local: local,
|
||||||
prefix: prefix,
|
prefix: prefix,
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
impl Default for CQualName {
|
impl Default for CQualName {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self{
|
return Self{
|
||||||
prefix: CNullable::<StringSlice>::none(),
|
prefix: CNullable::<StringSlice>::none(),
|
||||||
ns: StringSlice::default(),
|
ns: StringSlice::default(),
|
||||||
local: StringSlice::default(),
|
local: StringSlice::default(),
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,6 @@ pub const markdown = @import("browser/markdown.zig");
|
|||||||
pub const SemanticTree = @import("SemanticTree.zig");
|
pub const SemanticTree = @import("SemanticTree.zig");
|
||||||
pub const CDPNode = @import("cdp/Node.zig");
|
pub const CDPNode = @import("cdp/Node.zig");
|
||||||
pub const interactive = @import("browser/interactive.zig");
|
pub const interactive = @import("browser/interactive.zig");
|
||||||
pub const links = @import("browser/links.zig");
|
|
||||||
pub const forms = @import("browser/forms.zig");
|
pub const forms = @import("browser/forms.zig");
|
||||||
pub const actions = @import("browser/actions.zig");
|
pub const actions = @import("browser/actions.zig");
|
||||||
pub const structured_data = @import("browser/structured_data.zig");
|
pub const structured_data = @import("browser/structured_data.zig");
|
||||||
|
|||||||
@@ -22,14 +22,12 @@ pub const resource_list = [_]protocol.Resource{
|
|||||||
};
|
};
|
||||||
|
|
||||||
pub fn handleList(server: *Server, req: protocol.Request) !void {
|
pub fn handleList(server: *Server, req: protocol.Request) !void {
|
||||||
const id = req.id orelse return;
|
try server.sendResult(req.id.?, .{ .resources = &resource_list });
|
||||||
try server.sendResult(id, .{ .resources = &resource_list });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const ReadParams = struct {
|
const ReadParams = struct {
|
||||||
uri: []const u8,
|
uri: []const u8,
|
||||||
};
|
};
|
||||||
const Format = enum { html, markdown };
|
|
||||||
|
|
||||||
const ResourceStreamingResult = struct {
|
const ResourceStreamingResult = struct {
|
||||||
contents: []const struct {
|
contents: []const struct {
|
||||||
@@ -40,7 +38,7 @@ const ResourceStreamingResult = struct {
|
|||||||
|
|
||||||
const StreamingText = struct {
|
const StreamingText = struct {
|
||||||
page: *lp.Page,
|
page: *lp.Page,
|
||||||
format: Format,
|
format: enum { html, markdown },
|
||||||
|
|
||||||
pub fn jsonStringify(self: @This(), jw: *std.json.Stringify) !void {
|
pub fn jsonStringify(self: @This(), jw: *std.json.Stringify) !void {
|
||||||
try jw.beginWriteRaw();
|
try jw.beginWriteRaw();
|
||||||
@@ -49,11 +47,9 @@ const ResourceStreamingResult = struct {
|
|||||||
switch (self.format) {
|
switch (self.format) {
|
||||||
.html => lp.dump.root(self.page.document, .{}, &escaped.writer, self.page) catch |err| {
|
.html => lp.dump.root(self.page.document, .{}, &escaped.writer, self.page) catch |err| {
|
||||||
log.err(.mcp, "html dump failed", .{ .err = err });
|
log.err(.mcp, "html dump failed", .{ .err = err });
|
||||||
return error.WriteFailed;
|
|
||||||
},
|
},
|
||||||
.markdown => lp.markdown.dump(self.page.document.asNode(), .{}, &escaped.writer, self.page) catch |err| {
|
.markdown => lp.markdown.dump(self.page.document.asNode(), .{}, &escaped.writer, self.page) catch |err| {
|
||||||
log.err(.mcp, "markdown dump failed", .{ .err = err });
|
log.err(.mcp, "markdown dump failed", .{ .err = err });
|
||||||
return error.WriteFailed;
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
try jw.writer.writeByte('"');
|
try jw.writer.writeByte('"');
|
||||||
@@ -90,25 +86,28 @@ pub fn handleRead(server: *Server, arena: std.mem.Allocator, req: protocol.Reque
|
|||||||
return server.sendError(req_id, .PageNotLoaded, "Page not loaded");
|
return server.sendError(req_id, .PageNotLoaded, "Page not loaded");
|
||||||
};
|
};
|
||||||
|
|
||||||
const format: Format = switch (uri) {
|
switch (uri) {
|
||||||
.@"mcp://page/html" => .html,
|
.@"mcp://page/html" => {
|
||||||
.@"mcp://page/markdown" => .markdown,
|
const result: ResourceStreamingResult = .{
|
||||||
};
|
.contents = &.{.{
|
||||||
const mime_type: []const u8 = switch (uri) {
|
.uri = params.uri,
|
||||||
.@"mcp://page/html" => "text/html",
|
.mimeType = "text/html",
|
||||||
.@"mcp://page/markdown" => "text/markdown",
|
.text = .{ .page = page, .format = .html },
|
||||||
};
|
}},
|
||||||
|
};
|
||||||
const result: ResourceStreamingResult = .{
|
try server.sendResult(req_id, result);
|
||||||
.contents = &.{.{
|
},
|
||||||
.uri = params.uri,
|
.@"mcp://page/markdown" => {
|
||||||
.mimeType = mime_type,
|
const result: ResourceStreamingResult = .{
|
||||||
.text = .{ .page = page, .format = format },
|
.contents = &.{.{
|
||||||
}},
|
.uri = params.uri,
|
||||||
};
|
.mimeType = "text/markdown",
|
||||||
server.sendResult(req_id, result) catch {
|
.text = .{ .page = page, .format = .markdown },
|
||||||
return server.sendError(req_id, .InternalError, "Failed to serialize resource content");
|
}},
|
||||||
};
|
};
|
||||||
|
try server.sendResult(req_id, result);
|
||||||
|
},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const testing = @import("../testing.zig");
|
const testing = @import("../testing.zig");
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ pub fn processRequests(server: *Server, reader: *std.io.Reader) !void {
|
|||||||
const buffered_line = reader.takeDelimiter('\n') catch |err| switch (err) {
|
const buffered_line = reader.takeDelimiter('\n') catch |err| switch (err) {
|
||||||
error.StreamTooLong => {
|
error.StreamTooLong => {
|
||||||
log.err(.mcp, "Message too long", .{});
|
log.err(.mcp, "Message too long", .{});
|
||||||
try server.sendError(.null, .InvalidRequest, "Message too long");
|
|
||||||
continue;
|
continue;
|
||||||
},
|
},
|
||||||
else => return err,
|
else => return err,
|
||||||
@@ -81,7 +80,6 @@ pub fn handleMessage(server: *Server, arena: std.mem.Allocator, msg: []const u8)
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn handleInitialize(server: *Server, req: protocol.Request) !void {
|
fn handleInitialize(server: *Server, req: protocol.Request) !void {
|
||||||
const id = req.id orelse return;
|
|
||||||
const result = protocol.InitializeResult{
|
const result = protocol.InitializeResult{
|
||||||
.protocolVersion = "2025-11-25",
|
.protocolVersion = "2025-11-25",
|
||||||
.capabilities = .{
|
.capabilities = .{
|
||||||
@@ -94,7 +92,7 @@ fn handleInitialize(server: *Server, req: protocol.Request) !void {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
try server.sendResult(id, result);
|
try server.sendResult(req.id.?, result);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handlePing(server: *Server, req: protocol.Request) !void {
|
fn handlePing(server: *Server, req: protocol.Request) !void {
|
||||||
|
|||||||
@@ -172,18 +172,13 @@ pub const tool_list = [_]protocol.Tool{
|
|||||||
|
|
||||||
pub fn handleList(server: *Server, arena: std.mem.Allocator, req: protocol.Request) !void {
|
pub fn handleList(server: *Server, arena: std.mem.Allocator, req: protocol.Request) !void {
|
||||||
_ = arena;
|
_ = arena;
|
||||||
const id = req.id orelse return;
|
try server.sendResult(req.id.?, .{ .tools = &tool_list });
|
||||||
try server.sendResult(id, .{ .tools = &tool_list });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const GotoParams = struct {
|
const GotoParams = struct {
|
||||||
url: [:0]const u8,
|
url: [:0]const u8,
|
||||||
};
|
};
|
||||||
|
|
||||||
const UrlParams = struct {
|
|
||||||
url: ?[:0]const u8 = null,
|
|
||||||
};
|
|
||||||
|
|
||||||
const EvaluateParams = struct {
|
const EvaluateParams = struct {
|
||||||
script: [:0]const u8,
|
script: [:0]const u8,
|
||||||
url: ?[:0]const u8 = null,
|
url: ?[:0]const u8 = null,
|
||||||
@@ -206,18 +201,28 @@ const ToolStreamingText = struct {
|
|||||||
switch (self.action) {
|
switch (self.action) {
|
||||||
.markdown => lp.markdown.dump(self.page.document.asNode(), .{}, w, self.page) catch |err| {
|
.markdown => lp.markdown.dump(self.page.document.asNode(), .{}, w, self.page) catch |err| {
|
||||||
log.err(.mcp, "markdown dump failed", .{ .err = err });
|
log.err(.mcp, "markdown dump failed", .{ .err = err });
|
||||||
return error.WriteFailed;
|
|
||||||
},
|
},
|
||||||
.links => {
|
.links => {
|
||||||
const links = lp.links.collectLinks(self.page.call_arena, self.page.document.asNode(), self.page) catch |err| {
|
if (Selector.querySelectorAll(self.page.document.asNode(), "a[href]", self.page)) |list| {
|
||||||
|
defer list.deinit(self.page._session);
|
||||||
|
|
||||||
|
var first = true;
|
||||||
|
for (list._nodes) |node| {
|
||||||
|
if (node.is(Element.Html.Anchor)) |anchor| {
|
||||||
|
const href = anchor.getHref(self.page) catch |err| {
|
||||||
|
log.err(.mcp, "resolve href failed", .{ .err = err });
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (href.len > 0) {
|
||||||
|
if (!first) try w.writeByte('\n');
|
||||||
|
try w.writeAll(href);
|
||||||
|
first = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else |err| {
|
||||||
log.err(.mcp, "query links failed", .{ .err = err });
|
log.err(.mcp, "query links failed", .{ .err = err });
|
||||||
return error.WriteFailed;
|
|
||||||
};
|
|
||||||
var first = true;
|
|
||||||
for (links) |href| {
|
|
||||||
if (!first) try w.writeByte('\n');
|
|
||||||
try w.writeAll(href);
|
|
||||||
first = false;
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
.semantic_tree => {
|
.semantic_tree => {
|
||||||
@@ -243,7 +248,6 @@ const ToolStreamingText = struct {
|
|||||||
|
|
||||||
st.textStringify(w) catch |err| {
|
st.textStringify(w) catch |err| {
|
||||||
log.err(.mcp, "semantic tree dump failed", .{ .err = err });
|
log.err(.mcp, "semantic tree dump failed", .{ .err = err });
|
||||||
return error.WriteFailed;
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -320,7 +324,7 @@ pub fn handleCall(server: *Server, arena: std.mem.Allocator, req: protocol.Reque
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn handleGoto(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void {
|
fn handleGoto(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void {
|
||||||
const args = try parseArgs(GotoParams, arena, arguments, server, id, "goto");
|
const args = try parseArguments(GotoParams, arena, arguments, server, id, "goto");
|
||||||
try performGoto(server, args.url, id);
|
try performGoto(server, args.url, id);
|
||||||
|
|
||||||
const content = [_]protocol.TextContent([]const u8){.{ .text = "Navigated successfully." }};
|
const content = [_]protocol.TextContent([]const u8){.{ .text = "Navigated successfully." }};
|
||||||
@@ -328,27 +332,45 @@ fn handleGoto(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arg
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn handleMarkdown(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void {
|
fn handleMarkdown(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void {
|
||||||
const args = try parseArgsOrDefault(UrlParams, arena, arguments, server, id);
|
const MarkdownParams = struct {
|
||||||
const page = try ensurePage(server, id, args.url);
|
url: ?[:0]const u8 = null,
|
||||||
|
};
|
||||||
|
if (arguments) |args_raw| {
|
||||||
|
if (std.json.parseFromValueLeaky(MarkdownParams, arena, args_raw, .{ .ignore_unknown_fields = true })) |args| {
|
||||||
|
if (args.url) |u| {
|
||||||
|
try performGoto(server, u, id);
|
||||||
|
}
|
||||||
|
} else |_| {}
|
||||||
|
}
|
||||||
|
const page = server.session.currentPage() orelse {
|
||||||
|
return server.sendError(id, .PageNotLoaded, "Page not loaded");
|
||||||
|
};
|
||||||
|
|
||||||
const content = [_]protocol.TextContent(ToolStreamingText){.{
|
const content = [_]protocol.TextContent(ToolStreamingText){.{
|
||||||
.text = .{ .page = page, .action = .markdown },
|
.text = .{ .page = page, .action = .markdown },
|
||||||
}};
|
}};
|
||||||
server.sendResult(id, protocol.CallToolResult(ToolStreamingText){ .content = &content }) catch {
|
try server.sendResult(id, protocol.CallToolResult(ToolStreamingText){ .content = &content });
|
||||||
return server.sendError(id, .InternalError, "Failed to serialize markdown content");
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handleLinks(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void {
|
fn handleLinks(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void {
|
||||||
const args = try parseArgsOrDefault(UrlParams, arena, arguments, server, id);
|
const LinksParams = struct {
|
||||||
const page = try ensurePage(server, id, args.url);
|
url: ?[:0]const u8 = null,
|
||||||
|
};
|
||||||
|
if (arguments) |args_raw| {
|
||||||
|
if (std.json.parseFromValueLeaky(LinksParams, arena, args_raw, .{ .ignore_unknown_fields = true })) |args| {
|
||||||
|
if (args.url) |u| {
|
||||||
|
try performGoto(server, u, id);
|
||||||
|
}
|
||||||
|
} else |_| {}
|
||||||
|
}
|
||||||
|
const page = server.session.currentPage() orelse {
|
||||||
|
return server.sendError(id, .PageNotLoaded, "Page not loaded");
|
||||||
|
};
|
||||||
|
|
||||||
const content = [_]protocol.TextContent(ToolStreamingText){.{
|
const content = [_]protocol.TextContent(ToolStreamingText){.{
|
||||||
.text = .{ .page = page, .action = .links },
|
.text = .{ .page = page, .action = .links },
|
||||||
}};
|
}};
|
||||||
server.sendResult(id, protocol.CallToolResult(ToolStreamingText){ .content = &content }) catch {
|
try server.sendResult(id, protocol.CallToolResult(ToolStreamingText){ .content = &content });
|
||||||
return server.sendError(id, .InternalError, "Failed to serialize links content");
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handleSemanticTree(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void {
|
fn handleSemanticTree(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void {
|
||||||
@@ -357,38 +379,44 @@ fn handleSemanticTree(server: *Server, arena: std.mem.Allocator, id: std.json.Va
|
|||||||
backendNodeId: ?u32 = null,
|
backendNodeId: ?u32 = null,
|
||||||
maxDepth: ?u32 = null,
|
maxDepth: ?u32 = null,
|
||||||
};
|
};
|
||||||
const args = try parseArgsOrDefault(TreeParams, arena, arguments, server, id);
|
var tree_args: TreeParams = .{};
|
||||||
const page = try ensurePage(server, id, args.url);
|
if (arguments) |args_raw| {
|
||||||
|
if (std.json.parseFromValueLeaky(TreeParams, arena, args_raw, .{ .ignore_unknown_fields = true })) |args| {
|
||||||
|
tree_args = args;
|
||||||
|
if (args.url) |u| {
|
||||||
|
try performGoto(server, u, id);
|
||||||
|
}
|
||||||
|
} else |_| {}
|
||||||
|
}
|
||||||
|
const page = server.session.currentPage() orelse {
|
||||||
|
return server.sendError(id, .PageNotLoaded, "Page not loaded");
|
||||||
|
};
|
||||||
|
|
||||||
const content = [_]protocol.TextContent(ToolStreamingText){.{
|
const content = [_]protocol.TextContent(ToolStreamingText){.{
|
||||||
.text = .{
|
.text = .{ .page = page, .action = .semantic_tree, .registry = &server.node_registry, .arena = arena, .backendNodeId = tree_args.backendNodeId, .maxDepth = tree_args.maxDepth },
|
||||||
.page = page,
|
|
||||||
.action = .semantic_tree,
|
|
||||||
.registry = &server.node_registry,
|
|
||||||
.arena = arena,
|
|
||||||
.backendNodeId = args.backendNodeId,
|
|
||||||
.maxDepth = args.maxDepth,
|
|
||||||
},
|
|
||||||
}};
|
}};
|
||||||
server.sendResult(id, protocol.CallToolResult(ToolStreamingText){ .content = &content }) catch {
|
try server.sendResult(id, protocol.CallToolResult(ToolStreamingText){ .content = &content });
|
||||||
return server.sendError(id, .InternalError, "Failed to serialize semantic tree content");
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handleInteractiveElements(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void {
|
fn handleInteractiveElements(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void {
|
||||||
const args = try parseArgsOrDefault(UrlParams, arena, arguments, server, id);
|
const Params = struct {
|
||||||
const page = try ensurePage(server, id, args.url);
|
url: ?[:0]const u8 = null,
|
||||||
|
};
|
||||||
|
if (arguments) |args_raw| {
|
||||||
|
if (std.json.parseFromValueLeaky(Params, arena, args_raw, .{ .ignore_unknown_fields = true })) |args| {
|
||||||
|
if (args.url) |u| {
|
||||||
|
try performGoto(server, u, id);
|
||||||
|
}
|
||||||
|
} else |_| {}
|
||||||
|
}
|
||||||
|
const page = server.session.currentPage() orelse {
|
||||||
|
return server.sendError(id, .PageNotLoaded, "Page not loaded");
|
||||||
|
};
|
||||||
|
|
||||||
const elements = lp.interactive.collectInteractiveElements(page.document.asNode(), arena, page) catch |err| {
|
const elements = lp.interactive.collectInteractiveElements(page.document.asNode(), arena, page) catch |err| {
|
||||||
log.err(.mcp, "elements collection failed", .{ .err = err });
|
log.err(.mcp, "elements collection failed", .{ .err = err });
|
||||||
return server.sendError(id, .InternalError, "Failed to collect interactive elements");
|
return server.sendError(id, .InternalError, "Failed to collect interactive elements");
|
||||||
};
|
};
|
||||||
|
|
||||||
lp.interactive.registerNodes(elements, &server.node_registry) catch |err| {
|
|
||||||
log.err(.mcp, "node registration failed", .{ .err = err });
|
|
||||||
return server.sendError(id, .InternalError, "Failed to register element nodes");
|
|
||||||
};
|
|
||||||
|
|
||||||
var aw: std.Io.Writer.Allocating = .init(arena);
|
var aw: std.Io.Writer.Allocating = .init(arena);
|
||||||
try std.json.Stringify.value(elements, .{}, &aw.writer);
|
try std.json.Stringify.value(elements, .{}, &aw.writer);
|
||||||
|
|
||||||
@@ -397,8 +425,19 @@ fn handleInteractiveElements(server: *Server, arena: std.mem.Allocator, id: std.
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn handleStructuredData(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void {
|
fn handleStructuredData(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void {
|
||||||
const args = try parseArgsOrDefault(UrlParams, arena, arguments, server, id);
|
const Params = struct {
|
||||||
const page = try ensurePage(server, id, args.url);
|
url: ?[:0]const u8 = null,
|
||||||
|
};
|
||||||
|
if (arguments) |args_raw| {
|
||||||
|
if (std.json.parseFromValueLeaky(Params, arena, args_raw, .{ .ignore_unknown_fields = true })) |args| {
|
||||||
|
if (args.url) |u| {
|
||||||
|
try performGoto(server, u, id);
|
||||||
|
}
|
||||||
|
} else |_| {}
|
||||||
|
}
|
||||||
|
const page = server.session.currentPage() orelse {
|
||||||
|
return server.sendError(id, .PageNotLoaded, "Page not loaded");
|
||||||
|
};
|
||||||
|
|
||||||
const data = lp.structured_data.collectStructuredData(page.document.asNode(), arena, page) catch |err| {
|
const data = lp.structured_data.collectStructuredData(page.document.asNode(), arena, page) catch |err| {
|
||||||
log.err(.mcp, "struct data collection failed", .{ .err = err });
|
log.err(.mcp, "struct data collection failed", .{ .err = err });
|
||||||
@@ -412,8 +451,20 @@ fn handleStructuredData(server: *Server, arena: std.mem.Allocator, id: std.json.
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn handleDetectForms(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void {
|
fn handleDetectForms(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void {
|
||||||
const args = try parseArgsOrDefault(UrlParams, arena, arguments, server, id);
|
const Params = struct {
|
||||||
const page = try ensurePage(server, id, args.url);
|
url: ?[:0]const u8 = null,
|
||||||
|
};
|
||||||
|
if (arguments) |args_raw| {
|
||||||
|
const args = std.json.parseFromValueLeaky(Params, arena, args_raw, .{ .ignore_unknown_fields = true }) catch {
|
||||||
|
return server.sendError(id, .InvalidParams, "Invalid arguments for detectForms");
|
||||||
|
};
|
||||||
|
if (args.url) |u| {
|
||||||
|
try performGoto(server, u, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const page = server.session.currentPage() orelse {
|
||||||
|
return server.sendError(id, .PageNotLoaded, "Page not loaded");
|
||||||
|
};
|
||||||
|
|
||||||
const forms_data = lp.forms.collectForms(arena, page.document.asNode(), page) catch |err| {
|
const forms_data = lp.forms.collectForms(arena, page.document.asNode(), page) catch |err| {
|
||||||
log.err(.mcp, "form collection failed", .{ .err = err });
|
log.err(.mcp, "form collection failed", .{ .err = err });
|
||||||
@@ -433,8 +484,14 @@ fn handleDetectForms(server: *Server, arena: std.mem.Allocator, id: std.json.Val
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn handleEvaluate(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void {
|
fn handleEvaluate(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void {
|
||||||
const args = try parseArgs(EvaluateParams, arena, arguments, server, id, "evaluate");
|
const args = try parseArguments(EvaluateParams, arena, arguments, server, id, "evaluate");
|
||||||
const page = try ensurePage(server, id, args.url);
|
|
||||||
|
if (args.url) |url| {
|
||||||
|
try performGoto(server, url, id);
|
||||||
|
}
|
||||||
|
const page = server.session.currentPage() orelse {
|
||||||
|
return server.sendError(id, .PageNotLoaded, "Page not loaded");
|
||||||
|
};
|
||||||
|
|
||||||
var ls: js.Local.Scope = undefined;
|
var ls: js.Local.Scope = undefined;
|
||||||
page.js.localScope(&ls);
|
page.js.localScope(&ls);
|
||||||
@@ -463,7 +520,7 @@ fn handleClick(server: *Server, arena: std.mem.Allocator, id: std.json.Value, ar
|
|||||||
const ClickParams = struct {
|
const ClickParams = struct {
|
||||||
backendNodeId: CDPNode.Id,
|
backendNodeId: CDPNode.Id,
|
||||||
};
|
};
|
||||||
const args = try parseArgs(ClickParams, arena, arguments, server, id, "click");
|
const args = try parseArguments(ClickParams, arena, arguments, server, id, "click");
|
||||||
|
|
||||||
const page = server.session.currentPage() orelse {
|
const page = server.session.currentPage() orelse {
|
||||||
return server.sendError(id, .PageNotLoaded, "Page not loaded");
|
return server.sendError(id, .PageNotLoaded, "Page not loaded");
|
||||||
@@ -495,7 +552,7 @@ fn handleFill(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arg
|
|||||||
backendNodeId: CDPNode.Id,
|
backendNodeId: CDPNode.Id,
|
||||||
text: []const u8,
|
text: []const u8,
|
||||||
};
|
};
|
||||||
const args = try parseArgs(FillParams, arena, arguments, server, id, "fill");
|
const args = try parseArguments(FillParams, arena, arguments, server, id, "fill");
|
||||||
|
|
||||||
const page = server.session.currentPage() orelse {
|
const page = server.session.currentPage() orelse {
|
||||||
return server.sendError(id, .PageNotLoaded, "Page not loaded");
|
return server.sendError(id, .PageNotLoaded, "Page not loaded");
|
||||||
@@ -529,7 +586,7 @@ fn handleScroll(server: *Server, arena: std.mem.Allocator, id: std.json.Value, a
|
|||||||
x: ?i32 = null,
|
x: ?i32 = null,
|
||||||
y: ?i32 = null,
|
y: ?i32 = null,
|
||||||
};
|
};
|
||||||
const args = try parseArgs(ScrollParams, arena, arguments, server, id, "scroll");
|
const args = try parseArguments(ScrollParams, arena, arguments, server, id, "scroll");
|
||||||
|
|
||||||
const page = server.session.currentPage() orelse {
|
const page = server.session.currentPage() orelse {
|
||||||
return server.sendError(id, .PageNotLoaded, "Page not loaded");
|
return server.sendError(id, .PageNotLoaded, "Page not loaded");
|
||||||
@@ -566,7 +623,7 @@ fn handleWaitForSelector(server: *Server, arena: std.mem.Allocator, id: std.json
|
|||||||
selector: [:0]const u8,
|
selector: [:0]const u8,
|
||||||
timeout: ?u32 = null,
|
timeout: ?u32 = null,
|
||||||
};
|
};
|
||||||
const args = try parseArgs(WaitParams, arena, arguments, server, id, "waitForSelector");
|
const args = try parseArguments(WaitParams, arena, arguments, server, id, "waitForSelector");
|
||||||
|
|
||||||
_ = server.session.currentPage() orelse {
|
_ = server.session.currentPage() orelse {
|
||||||
return server.sendError(id, .PageNotLoaded, "Page not loaded");
|
return server.sendError(id, .PageNotLoaded, "Page not loaded");
|
||||||
@@ -590,38 +647,12 @@ fn handleWaitForSelector(server: *Server, arena: std.mem.Allocator, id: std.json
|
|||||||
return server.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content });
|
return server.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content });
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ensurePage(server: *Server, id: std.json.Value, url: ?[:0]const u8) !*lp.Page {
|
fn parseArguments(comptime T: type, arena: std.mem.Allocator, arguments: ?std.json.Value, server: *Server, id: std.json.Value, tool_name: []const u8) !T {
|
||||||
if (url) |u| {
|
if (arguments == null) {
|
||||||
try performGoto(server, u, id);
|
|
||||||
}
|
|
||||||
return server.session.currentPage() orelse {
|
|
||||||
try server.sendError(id, .PageNotLoaded, "Page not loaded");
|
|
||||||
return error.PageNotLoaded;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parses JSON arguments into a given struct type `T`.
|
|
||||||
/// If the arguments are missing, it returns a default-initialized `T` (e.g., `.{}`).
|
|
||||||
/// If the arguments are present but invalid, it sends an MCP error response and returns `error.InvalidParams`.
|
|
||||||
/// Use this for tools where all arguments are optional.
|
|
||||||
fn parseArgsOrDefault(comptime T: type, arena: std.mem.Allocator, arguments: ?std.json.Value, server: *Server, id: std.json.Value) !T {
|
|
||||||
const args_raw = arguments orelse return .{};
|
|
||||||
return std.json.parseFromValueLeaky(T, arena, args_raw, .{ .ignore_unknown_fields = true }) catch {
|
|
||||||
try server.sendError(id, .InvalidParams, "Invalid arguments");
|
|
||||||
return error.InvalidParams;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parses JSON arguments into a given struct type `T`.
|
|
||||||
/// If the arguments are missing or invalid, it automatically sends an MCP error response to the client
|
|
||||||
/// and returns an `error.InvalidParams`.
|
|
||||||
/// Use this for tools that require strict validation or mandatory arguments.
|
|
||||||
fn parseArgs(comptime T: type, arena: std.mem.Allocator, arguments: ?std.json.Value, server: *Server, id: std.json.Value, tool_name: []const u8) !T {
|
|
||||||
const args_raw = arguments orelse {
|
|
||||||
try server.sendError(id, .InvalidParams, "Missing arguments");
|
try server.sendError(id, .InvalidParams, "Missing arguments");
|
||||||
return error.InvalidParams;
|
return error.InvalidParams;
|
||||||
};
|
}
|
||||||
return std.json.parseFromValueLeaky(T, arena, args_raw, .{ .ignore_unknown_fields = true }) catch {
|
return std.json.parseFromValueLeaky(T, arena, arguments.?, .{ .ignore_unknown_fields = true }) catch {
|
||||||
const msg = std.fmt.allocPrint(arena, "Invalid arguments for {s}", .{tool_name}) catch "Invalid arguments";
|
const msg = std.fmt.allocPrint(arena, "Invalid arguments for {s}", .{tool_name}) catch "Invalid arguments";
|
||||||
try server.sendError(id, .InvalidParams, msg);
|
try server.sendError(id, .InvalidParams, msg);
|
||||||
return error.InvalidParams;
|
return error.InvalidParams;
|
||||||
@@ -633,10 +664,7 @@ fn performGoto(server: *Server, url: [:0]const u8, id: std.json.Value) !void {
|
|||||||
if (session.page != null) {
|
if (session.page != null) {
|
||||||
session.removePage();
|
session.removePage();
|
||||||
}
|
}
|
||||||
const page = session.createPage() catch {
|
const page = try session.createPage();
|
||||||
try server.sendError(id, .InternalError, "Failed to create page");
|
|
||||||
return error.NavigationFailed;
|
|
||||||
};
|
|
||||||
page.navigate(url, .{
|
page.navigate(url, .{
|
||||||
.reason = .address_bar,
|
.reason = .address_bar,
|
||||||
.kind = .{ .push = null },
|
.kind = .{ .push = null },
|
||||||
@@ -645,14 +673,8 @@ fn performGoto(server: *Server, url: [:0]const u8, id: std.json.Value) !void {
|
|||||||
return error.NavigationFailed;
|
return error.NavigationFailed;
|
||||||
};
|
};
|
||||||
|
|
||||||
var runner = session.runner(.{}) catch {
|
var runner = try session.runner(.{});
|
||||||
try server.sendError(id, .InternalError, "Failed to start page runner");
|
try runner.wait(.{ .ms = 2000 });
|
||||||
return error.NavigationFailed;
|
|
||||||
};
|
|
||||||
runner.wait(.{ .ms = 2000 }) catch {
|
|
||||||
try server.sendError(id, .InternalError, "Timeout waiting for page load");
|
|
||||||
return error.NavigationFailed;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const router = @import("router.zig");
|
const router = @import("router.zig");
|
||||||
|
|||||||
Reference in New Issue
Block a user