diff --git a/src/Config.zig b/src/Config.zig index e3c010ce..248363d2 100644 --- a/src/Config.zig +++ b/src/Config.zig @@ -18,26 +18,18 @@ const std = @import("std"); const lp = @import("lightpanda"); +const log = lp.log; const builtin = @import("builtin"); +const cli = @import("cli.zig"); const dump = @import("browser/dump.zig"); const mcp = @import("mcp.zig"); const Storage = @import("storage/Storage.zig"); const WebBotAuthConfig = @import("network/WebBotAuth.zig").Config; -const log = lp.log; const Allocator = std.mem.Allocator; -pub const RunMode = enum { - help, - fetch, - serve, - version, - mcp, - agent, -}; - pub const CDP_MAX_HTTP_REQUEST_SIZE = 4096; // max message size @@ -45,12 +37,140 @@ pub const CDP_MAX_HTTP_REQUEST_SIZE = 4096; // +140 for the max control packet that might be interleaved in a message pub const CDP_MAX_MESSAGE_SIZE = 512 * 1024 + 14 + 140; +const Config = @This(); + +fn logFilterScopesValidator(allocator: Allocator, args: *std.process.ArgIterator, list: *std.ArrayList(log.Scope)) !void { + const str = args.next() orelse return error.InvalidOption; + + var it = std.mem.splitScalar(u8, str, ','); + while (it.next()) |part| { + const v = std.meta.stringToEnum(log.Scope, part) orelse { + log.fatal(.app, "invalid option choice", .{ .arg = "log-filter-scopes", .value = part }); + return error.InvalidOption; + }; + + try list.append(allocator, v); + } +} + +/// Common CLI args. +const CommonOptions = .{ + .{ .name = "obey_robots", .type = bool }, + .{ .name = "proxy_bearer_token", .type = ?[:0]const u8 }, + .{ .name = "http_proxy", .type = ?[:0]const u8 }, + .{ .name = "http_max_concurrent", .type = ?u8 }, + .{ .name = "http_max_host_open", .type = ?u8 }, + .{ .name = "http_timeout", .type = ?u31 }, + .{ .name = "http_connect_timeout", .type = ?u31 }, + .{ .name = "http_max_response_size", .type = ?usize }, + .{ .name = "ws_max_concurrent", .type = ?u8 }, + .{ .name = "insecure_disable_tls_host_verification", .type = bool }, + .{ .name = "log_level", .type = ?log.Level }, + .{ .name = "log_format", .type = ?log.Format }, + .{ .name = "log_filter_scopes", .type = log.Scope, .multiple = true, .validator = logFilterScopesValidator }, + .{ .name = "user_agent_suffix", .type = ?[]const u8 }, + .{ .name = "http_cache_dir", .type = ?[]const u8 }, + .{ .name = "web_bot_auth_key_file", .type = ?[]const u8 }, + .{ .name = "web_bot_auth_keyid", .type = ?[]const u8 }, + .{ .name = "web_bot_auth_domain", .type = ?[]const u8 }, + .{ .name = "user_agent", .type = ?[]const u8 }, + .{ .name = "block_private_networks", .type = bool }, + .{ .name = "block_cidrs", .type = ?[]const u8 }, + .{ .name = "cookie", .type = ?[]const u8 }, + .{ .name = "cookie_jar", .type = ?[]const u8 }, + .{ .name = "storage_engine", .type = ?Storage.EngineType }, + .{ .name = "storage_sqlite_path", .type = ?[:0]const u8 }, +}; + +fn dumpValidator(_: Allocator, args: *std.process.ArgIterator) !?DumpFormat { + // Peek next argument. + var peek_args = args.*; + if (peek_args.next()) |next_arg| { + const mode = std.meta.stringToEnum(DumpFormat, next_arg) orelse { + return .html; + }; + + // Skip the argument we peek if successful. + _ = args.next(); + return mode; + } + + // Means we couldn't get something like `--dump html` but we do have + // `--dump`; which should fall to `html` by default. + return .html; +} + +pub const AiProvider = enum { + anthropic, + openai, + gemini, + ollama, +}; + +/// Definition for all the commands and its arguments. See @cli.zig for further. +const Commands = cli.Builder(.{ + .{ + .name = "serve", + .options = .{ + .{ .name = "host", .type = []const u8, .default = "127.0.0.1" }, + .{ .name = "port", .type = u16, .default = 9222 }, + .{ .name = "advertise_host", .type = ?[]const u8 }, + .{ .name = "timeout", .type = u31, .default = 10 }, + .{ .name = "cdp_max_connections", .type = u16, .default = 16 }, + .{ .name = "cdp_max_pending_connections", .type = u16, .default = 128 }, + }, + .shared_options = CommonOptions, + }, + .{ + .name = "fetch", + // This argument can be given out of order. + .positional = .{ .name = "url", .type = ?[:0]const u8 }, + .options = .{ + .{ .name = "dump", .type = ?DumpFormat, .validator = dumpValidator }, + .{ .name = "with_base", .type = bool }, + .{ .name = "with_frames", .type = bool }, + .{ .name = "strip_mode", .type = dump.Opts.Strip, .default = dump.Opts.Strip{} }, + .{ .name = "wait_ms", .type = u32, .default = 5_000 }, + .{ .name = "wait_until", .type = ?WaitUntil }, + .{ .name = "wait_script", .type = ?[:0]const u8 }, + .{ .name = "wait_selector", .type = ?[:0]const u8 }, + }, + .shared_options = CommonOptions, + }, + .{ + .name = "mcp", + .options = .{ + .{ .name = "cdp_port", .type = ?u16 }, + }, + .shared_options = CommonOptions, + }, + .{ + .name = "agent", + .positional = .{ .name = "script_file", .type = ?[:0]const u8 }, + .options = .{ + .{ .name = "provider", .type = ?AiProvider }, + .{ .name = "model", .type = ?[:0]const u8 }, + .{ .name = "base_url", .type = ?[:0]const u8 }, + .{ .name = "system_prompt", .type = ?[:0]const u8 }, + .{ .name = "self_heal", .type = bool }, + .{ .name = "interactive", .type = bool }, + .{ .name = "task", .type = ?[]const u8 }, + .{ .name = "task_attachments", .type = []const u8, .multiple = true }, + }, + .shared_options = CommonOptions, + }, + .{ .name = "version", .options = .{} }, + .{ .name = "help", .options = .{} }, +}); + +pub const RunMode = Commands.Enum; +pub const Mode = Commands.Union; +pub const Agent = @FieldType(Mode, "agent"); + mode: Mode, exec_name: []const u8, http_headers: HttpHeaders, -const Config = @This(); - pub fn init(allocator: Allocator, exec_name: []const u8, mode: Mode) !Config { var config = Config{ .mode = mode, @@ -69,43 +189,60 @@ pub fn deinit(self: *const Config, allocator: Allocator) void { } } -fn commonOpts(self: *const Config) Common { +pub fn tlsVerifyHost(self: *const Config) bool { return switch (self.mode) { - inline .serve, .fetch, .mcp, .agent => |opts| opts.common, + inline .serve, .fetch, .mcp, .agent => |opts| !opts.insecure_disable_tls_host_verification, else => unreachable, }; } -pub fn tlsVerifyHost(self: *const Config) bool { - return self.commonOpts().tls_verify_host; -} - pub fn obeyRobots(self: *const Config) bool { - return self.commonOpts().obey_robots; + return switch (self.mode) { + inline .serve, .fetch, .mcp, .agent => |opts| opts.obey_robots, + else => unreachable, + }; } pub fn httpProxy(self: *const Config) ?[:0]const u8 { - return self.commonOpts().http_proxy; + return switch (self.mode) { + inline .serve, .fetch, .mcp, .agent => |opts| opts.http_proxy, + else => unreachable, + }; } pub fn proxyBearerToken(self: *const Config) ?[:0]const u8 { - return self.commonOpts().proxy_bearer_token; + return switch (self.mode) { + inline .serve, .fetch, .mcp, .agent => |opts| opts.proxy_bearer_token, + .help, .version => null, + }; } pub fn httpMaxConcurrent(self: *const Config) u8 { - return self.commonOpts().http_max_concurrent orelse 10; + return switch (self.mode) { + inline .serve, .fetch, .mcp, .agent => |opts| opts.http_max_concurrent orelse 10, + else => unreachable, + }; } pub fn httpMaxHostOpen(self: *const Config) u8 { - return self.commonOpts().http_max_host_open orelse 4; + return switch (self.mode) { + inline .serve, .fetch, .mcp, .agent => |opts| opts.http_max_host_open orelse 4, + else => unreachable, + }; } pub fn httpConnectTimeout(self: *const Config) u31 { - return self.commonOpts().http_connect_timeout orelse 0; + return switch (self.mode) { + inline .serve, .fetch, .mcp, .agent => |opts| opts.http_connect_timeout orelse 0, + else => unreachable, + }; } pub fn httpTimeout(self: *const Config) u31 { - return self.commonOpts().http_timeout orelse 5000; + return switch (self.mode) { + inline .serve, .fetch, .mcp, .agent => |opts| opts.http_timeout orelse 5000, + else => unreachable, + }; } pub fn httpMaxRedirects(_: *const Config) u8 { @@ -113,61 +250,79 @@ pub fn httpMaxRedirects(_: *const Config) u8 { } pub fn httpMaxResponseSize(self: *const Config) ?usize { - return self.commonOpts().http_max_response_size; + return switch (self.mode) { + inline .serve, .fetch, .mcp, .agent => |opts| opts.http_max_response_size, + else => unreachable, + }; } pub fn wsMaxConcurrent(self: *const Config) u8 { - return self.commonOpts().ws_max_concurrent orelse 64; + return switch (self.mode) { + inline .serve, .fetch, .mcp, .agent => |opts| opts.ws_max_concurrent orelse 8, + else => unreachable, + }; } pub fn logLevel(self: *const Config) ?log.Level { - return self.commonOpts().log_level; + return switch (self.mode) { + inline .serve, .fetch, .mcp, .agent => |opts| opts.log_level, + else => unreachable, + }; } pub fn logFormat(self: *const Config) ?log.Format { - return self.commonOpts().log_format; + return switch (self.mode) { + inline .serve, .fetch, .mcp, .agent => |opts| opts.log_format, + else => unreachable, + }; } -pub fn logFilterScopes(self: *const Config) ?[]const log.Scope { - return self.commonOpts().log_filter_scopes; +pub fn logFilterScopes(self: *const Config) std.ArrayList(log.Scope) { + return switch (self.mode) { + inline .serve, .fetch, .mcp, .agent => |opts| opts.log_filter_scopes, + else => unreachable, + }; } pub fn userAgentSuffix(self: *const Config) ?[]const u8 { - return self.commonOpts().user_agent_suffix; + return switch (self.mode) { + inline .serve, .fetch, .mcp, .agent => |opts| opts.user_agent_suffix, + .help, .version => null, + }; } pub fn userAgent(self: *const Config) ?[]const u8 { return switch (self.mode) { - inline .serve, .fetch, .mcp, .agent => |opts| opts.common.user_agent, + inline .serve, .fetch, .mcp, .agent => |opts| opts.user_agent, .help, .version => null, }; } pub fn httpCacheDir(self: *const Config) ?[]const u8 { return switch (self.mode) { - .help, .version => null, - else => self.commonOpts().http_cache_dir, + inline .serve, .fetch, .mcp, .agent => |opts| opts.http_cache_dir, + else => null, }; } pub fn cookieFile(self: *const Config) ?[]const u8 { return switch (self.mode) { - inline .serve, .fetch, .mcp => |opts| opts.common.cookie, + inline .serve, .fetch, .mcp, .agent => |opts| opts.cookie, else => null, }; } pub fn cookieJarFile(self: *const Config) ?[]const u8 { return switch (self.mode) { - inline .fetch, .mcp => |opts| opts.common.cookie_jar, + inline .fetch, .mcp, .agent => |opts| opts.cookie_jar, else => null, }; } pub fn cdpTimeout(self: *const Config) usize { return switch (self.mode) { - .serve => |opts| if (opts.timeout > 604_800) 604_800_000 else @as(usize, opts.timeout) * 1000, - .mcp => 10000, // Default timeout for MCP-CDP + .serve => |opts| if (opts.timeout > 604_800) 604_800_000 else @as(usize, opts.timeout) * 1_000, + .mcp => 10_000, // Default timeout for MCP-CDP else => unreachable, }; } @@ -189,20 +344,28 @@ pub fn advertiseHost(self: *const Config) []const u8 { } pub fn webBotAuth(self: *const Config) ?WebBotAuthConfig { - const common = self.commonOpts(); - return .{ - .key_file = common.web_bot_auth_key_file orelse return null, - .keyid = common.web_bot_auth_keyid orelse return null, - .domain = common.web_bot_auth_domain orelse return null, + return switch (self.mode) { + inline .serve, .fetch, .mcp, .agent => |opts| WebBotAuthConfig{ + .key_file = opts.web_bot_auth_key_file orelse return null, + .keyid = opts.web_bot_auth_keyid orelse return null, + .domain = opts.web_bot_auth_domain orelse return null, + }, + .help, .version => null, }; } pub fn blockPrivateNetworks(self: *const Config) bool { - return self.commonOpts().block_private_networks; + return switch (self.mode) { + inline .serve, .fetch, .mcp, .agent => |opts| opts.block_private_networks, + else => unreachable, + }; } pub fn blockCidrs(self: *const Config) ?[]const u8 { - return self.commonOpts().block_cidrs; + return switch (self.mode) { + inline .serve, .fetch, .mcp, .agent => |opts| opts.block_cidrs, + else => unreachable, + }; } pub fn maxConnections(self: *const Config) u16 { @@ -223,65 +386,18 @@ pub fn maxPendingConnections(self: *const Config) u31 { pub fn storageEngine(self: *const Config) ?Storage.EngineType { return switch (self.mode) { - inline .serve, .fetch, .mcp => |opts| opts.common.storage_engine, + inline .serve, .fetch, .mcp, .agent => |opts| opts.storage_engine, else => unreachable, }; } pub fn storageSqlitePath(self: *const Config) ?[:0]const u8 { return switch (self.mode) { - inline .serve, .fetch, .mcp => |opts| opts.common.storage_sqlite_path, + inline .serve, .fetch, .mcp, .agent => |opts| opts.storage_sqlite_path, else => unreachable, }; } -pub const Mode = union(RunMode) { - help: bool, // false when being printed because of an error - fetch: Fetch, - serve: Serve, - version: void, - mcp: Mcp, - agent: Agent, -}; - -pub const Serve = struct { - host: []const u8 = "127.0.0.1", - port: u16 = 9222, - advertise_host: ?[]const u8 = null, - timeout: u31 = 10, - cdp_max_connections: u16 = 16, - cdp_max_pending_connections: u16 = 128, - common: Common = .{}, -}; - -pub const Mcp = struct { - common: Common = .{}, - cdp_port: ?u16 = null, -}; - -pub const AiProvider = enum { - anthropic, - openai, - gemini, - ollama, -}; - -pub const Agent = struct { - common: Common = .{}, - provider: ?AiProvider = null, - model: ?[:0]const u8 = null, - base_url: ?[:0]const u8 = null, - system_prompt: ?[:0]const u8 = null, - script_file: ?[]const u8 = null, - self_heal: bool = false, - interactive: bool = false, - task: ?[]const u8 = null, - /// Local file paths attached to `--task`. Used only in one-shot mode. - /// Small text files are inlined as text; binary files (images, audio, - /// PDF) are sent as provider `inlineData` parts when supported. - task_attachments: ?[]const []const u8 = null, -}; - pub const DumpFormat = enum { html, markdown, @@ -297,54 +413,31 @@ pub const WaitUntil = enum { done, }; -pub const Fetch = struct { - url: [:0]const u8, - dump_mode: ?DumpFormat = null, - common: Common = .{}, - with_base: bool = false, - with_frames: bool = false, - strip: dump.Opts.Strip = .{}, - wait_ms: u32 = 5000, - wait_until: ?WaitUntil = null, - wait_script: ?[:0]const u8 = null, - wait_selector: ?[:0]const u8 = null, -}; - -pub const Common = struct { - obey_robots: bool = false, - proxy_bearer_token: ?[:0]const u8 = null, - http_proxy: ?[:0]const u8 = null, - http_max_concurrent: ?u8 = null, - http_max_host_open: ?u8 = null, - http_timeout: ?u31 = null, - http_connect_timeout: ?u31 = null, - http_max_response_size: ?usize = null, - ws_max_concurrent: ?u8 = null, - tls_verify_host: bool = true, - log_level: ?log.Level = null, - log_format: ?log.Format = null, - log_filter_scopes: ?[]log.Scope = null, - user_agent_suffix: ?[]const u8 = null, - user_agent: ?[]const u8 = null, - http_cache_dir: ?[]const u8 = null, - cookie: ?[]const u8 = null, - cookie_jar: ?[]const u8 = null, - storage_engine: ?Storage.EngineType = null, - storage_sqlite_path: ?[:0]const u8 = null, - - web_bot_auth_key_file: ?[]const u8 = null, - web_bot_auth_keyid: ?[]const u8 = null, - web_bot_auth_domain: ?[]const u8 = null, - - block_private_networks: bool = false, - block_cidrs: ?[]const u8 = null, -}; - /// Pre-formatted HTTP headers for reuse across Http and Client. /// Must be initialized with an allocator that outlives all HTTP connections. pub const HttpHeaders = struct { const user_agent_base: [:0]const u8 = "Lightpanda/1.0"; - pub const sec_ch_ua: [:0]const u8 = "Sec-Ch-Ua: \"Lightpanda\";v=\"1\""; + + const Brand = struct { + brand: [:0]const u8, + version: [:0]const u8, + }; + + /// Source of truth for client-hints brand data. Both the Sec-Ch-Ua + /// HTTP header and navigator.userAgentData.brands derive from this + /// list, so the two sides cannot drift. + pub const brands = [_]Brand{ + .{ .brand = "Lightpanda", .version = "1" }, + }; + + pub const sec_ch_ua: [:0]const u8 = blk: { + var out: [:0]const u8 = "Sec-Ch-Ua:"; + for (brands, 0..) |b, i| { + const sep = if (i == 0) " " else ", "; + out = out ++ sep ++ "\"" ++ b.brand ++ "\";v=\"" ++ b.version ++ "\""; + } + break :blk out; + }; user_agent: [:0]const u8, // User agent value (e.g. "Lightpanda/1.0") user_agent_header: [:0]const u8, @@ -657,773 +750,8 @@ pub fn printUsageAndExit(self: *const Config, success: bool) void { } pub fn parseArgs(allocator: Allocator) !Config { - var args = try std.process.argsWithAllocator(allocator); - defer args.deinit(); - - const exec_name = try allocator.dupe(u8, std.fs.path.basename(args.next().?)); - - const mode_string = args.next() orelse ""; - const run_mode = std.meta.stringToEnum(RunMode, mode_string) orelse blk: { - const inferred_mode = inferMode(mode_string) orelse - return init(allocator, exec_name, .{ .help = false }); - // "command" wasn't a command but an option. We can't reset args, but - // we can create a new one. Not great, but this fallback is temporary - // as we transition to this command mode approach. - args.deinit(); - - args = try std.process.argsWithAllocator(allocator); - // skip the exec_name - _ = args.skip(); - - break :blk inferred_mode; - }; - - const mode: Mode = switch (run_mode) { - .help => .{ .help = true }, - .serve => .{ .serve = parseServeArgs(allocator, &args) catch - return init(allocator, exec_name, .{ .help = false }) }, - .fetch => .{ .fetch = parseFetchArgs(allocator, &args) catch - return init(allocator, exec_name, .{ .help = false }) }, - .mcp => .{ .mcp = parseMcpArgs(allocator, &args) catch - return init(allocator, exec_name, .{ .help = false }) }, - .agent => .{ .agent = parseAgentArgs(allocator, &args) catch - return init(allocator, exec_name, .{ .help = false }) }, - .version => .{ .version = {} }, - }; - return init(allocator, exec_name, mode); -} - -fn inferMode(opt: []const u8) ?RunMode { - if (opt.len == 0) { - return .serve; - } - - if (std.mem.startsWith(u8, opt, "--") == false) { - return .fetch; - } - - if (std.mem.eql(u8, opt, "--dump")) { - return .fetch; - } - - if (std.mem.eql(u8, opt, "--noscript")) { - return .fetch; - } - - if (std.mem.eql(u8, opt, "--strip-mode") or std.mem.eql(u8, opt, "--strip_mode")) { - return .fetch; - } - - if (std.mem.eql(u8, opt, "--with-base") or std.mem.eql(u8, opt, "--with_base")) { - return .fetch; - } - - if (std.mem.eql(u8, opt, "--with-frames") or std.mem.eql(u8, opt, "--with_frames")) { - return .fetch; - } - - if (std.mem.eql(u8, opt, "--host")) { - return .serve; - } - - if (std.mem.eql(u8, opt, "--port")) { - return .serve; - } - - if (std.mem.eql(u8, opt, "--timeout")) { - return .serve; - } - - return null; -} - -fn parseServeArgs( - allocator: Allocator, - args: *std.process.ArgIterator, -) !Serve { - var serve: Serve = .{}; - - while (args.next()) |opt| { - if (std.mem.eql(u8, "--host", opt)) { - const str = args.next() orelse { - log.fatal(.app, "missing argument value", .{ .arg = "--host" }); - return error.InvalidArgument; - }; - serve.host = try allocator.dupe(u8, str); - continue; - } - - if (std.mem.eql(u8, "--port", opt)) { - const str = args.next() orelse { - log.fatal(.app, "missing argument value", .{ .arg = "--port" }); - return error.InvalidArgument; - }; - - serve.port = std.fmt.parseInt(u16, str, 10) catch |err| { - log.fatal(.app, "invalid argument value", .{ .arg = "--port", .err = err }); - return error.InvalidArgument; - }; - continue; - } - - if (std.mem.eql(u8, "--advertise-host", opt) or std.mem.eql(u8, "--advertise_host", opt)) { - const str = args.next() orelse { - log.fatal(.app, "missing argument value", .{ .arg = opt }); - return error.InvalidArgument; - }; - serve.advertise_host = try allocator.dupe(u8, str); - continue; - } - - if (std.mem.eql(u8, "--timeout", opt)) { - const str = args.next() orelse { - log.fatal(.app, "missing argument value", .{ .arg = "--timeout" }); - return error.InvalidArgument; - }; - - serve.timeout = std.fmt.parseInt(u31, str, 10) catch |err| { - log.fatal(.app, "invalid argument value", .{ .arg = "--timeout", .err = err }); - return error.InvalidArgument; - }; - continue; - } - - if (std.mem.eql(u8, "--cdp-max-connections", opt) or std.mem.eql(u8, "--cdp_max_connections", opt)) { - const str = args.next() orelse { - log.fatal(.app, "missing argument value", .{ .arg = opt }); - return error.InvalidArgument; - }; - - serve.cdp_max_connections = std.fmt.parseInt(u16, str, 10) catch |err| { - log.fatal(.app, "invalid argument value", .{ .arg = opt, .err = err }); - return error.InvalidArgument; - }; - continue; - } - - if (std.mem.eql(u8, "--cdp-max-pending-connections", opt) or std.mem.eql(u8, "--cdp_max_pending_connections", opt)) { - const str = args.next() orelse { - log.fatal(.app, "missing argument value", .{ .arg = opt }); - return error.InvalidArgument; - }; - - serve.cdp_max_pending_connections = std.fmt.parseInt(u16, str, 10) catch |err| { - log.fatal(.app, "invalid argument value", .{ .arg = opt, .err = err }); - return error.InvalidArgument; - }; - continue; - } - - if (std.mem.eql(u8, "--cookie-jar", opt)) { - log.fatal(.app, "invalid argument value", .{ - .arg = opt, - .detail = "--cookie-jar is only available for fetch and mcp", - }); - return error.InvalidArgument; - } - - if (try parseCommonArg(allocator, opt, args, &serve.common)) { - continue; - } - - log.fatal(.app, "unknown argument", .{ .mode = "serve", .arg = opt }); - return error.UnknownOption; - } - - return serve; -} - -fn parseMcpArgs( - allocator: Allocator, - args: *std.process.ArgIterator, -) !Mcp { - var result: Mcp = .{}; - - while (args.next()) |opt| { - if (std.mem.eql(u8, "--cdp-port", opt) or std.mem.eql(u8, "--cdp_port", opt)) { - const str = args.next() orelse { - log.fatal(.mcp, "missing argument value", .{ .arg = opt }); - return error.InvalidArgument; - }; - - result.cdp_port = std.fmt.parseInt(u16, str, 10) catch |err| { - log.fatal(.mcp, "invalid argument value", .{ .arg = opt, .err = err }); - return error.InvalidArgument; - }; - continue; - } - - if (try parseCommonArg(allocator, opt, args, &result.common)) { - continue; - } - - log.fatal(.mcp, "unknown argument", .{ .mode = "mcp", .arg = opt }); - return error.UnknownOption; - } - - return result; -} - -fn parseFetchArgs( - allocator: Allocator, - args: *std.process.ArgIterator, -) !Fetch { - var dump_mode: ?DumpFormat = null; - var with_base: bool = false; - var with_frames: bool = false; - var url: ?[:0]const u8 = null; - var common: Common = .{}; - var strip: dump.Opts.Strip = .{}; - var wait_ms: u32 = 5000; - var wait_until: ?WaitUntil = null; - var wait_script: ?[:0]const u8 = null; - var wait_selector: ?[:0]const u8 = null; - - while (args.next()) |opt| { - if (std.mem.eql(u8, "--wait-ms", opt) or std.mem.eql(u8, "--wait_ms", opt)) { - const str = args.next() orelse { - log.fatal(.app, "missing argument value", .{ .arg = opt }); - return error.InvalidArgument; - }; - wait_ms = std.fmt.parseInt(u32, str, 10) catch |err| { - log.fatal(.app, "invalid argument value", .{ .arg = opt, .err = err }); - return error.InvalidArgument; - }; - continue; - } - - if (std.mem.eql(u8, "--wait-until", opt) or std.mem.eql(u8, "--wait_until", opt)) { - const str = args.next() orelse { - log.fatal(.app, "missing argument value", .{ .arg = opt }); - return error.InvalidArgument; - }; - wait_until = std.meta.stringToEnum(WaitUntil, str) orelse { - log.fatal(.app, "invalid argument value", .{ .arg = opt, .val = str }); - return error.InvalidArgument; - }; - continue; - } - - if (std.mem.eql(u8, "--wait-selector", opt) or std.mem.eql(u8, "--wait_selector", opt)) { - const str = args.next() orelse { - log.fatal(.app, "missing argument value", .{ .arg = opt }); - return error.InvalidArgument; - }; - wait_selector = try allocator.dupeZ(u8, str); - continue; - } - - if (std.mem.eql(u8, "--wait-script", opt) or std.mem.eql(u8, "--wait_script", opt)) { - const str = args.next() orelse { - log.fatal(.app, "missing argument value", .{ .arg = opt }); - return error.InvalidArgument; - }; - wait_script = try allocator.dupeZ(u8, str); - continue; - } - - if (std.mem.eql(u8, "--wait-script-file", opt) or std.mem.eql(u8, "--wait_script_file", opt)) { - const path = args.next() orelse { - log.fatal(.app, "missing argument value", .{ .arg = opt }); - return error.InvalidArgument; - }; - wait_script = std.fs.cwd().readFileAllocOptions(allocator, path, 1024 * 1024, null, .of(u8), 0) catch |err| { - log.fatal(.app, "failed to read file", .{ .arg = opt, .path = path, .err = err }); - return error.InvalidArgument; - }; - continue; - } - - if (std.mem.eql(u8, "--dump", opt)) { - var peek_args = args.*; - if (peek_args.next()) |next_arg| { - if (std.meta.stringToEnum(DumpFormat, next_arg)) |mode| { - dump_mode = mode; - _ = args.next(); - } else { - dump_mode = .html; - } - } else { - dump_mode = .html; - } - continue; - } - - if (std.mem.eql(u8, "--noscript", opt)) { - log.warn(.app, "deprecation warning", .{ - .feature = "--noscript argument", - .hint = "use '--strip-mode js' instead", - }); - strip.js = true; - continue; - } - - if (std.mem.eql(u8, "--with-base", opt) or std.mem.eql(u8, "--with_base", opt)) { - with_base = true; - continue; - } - - if (std.mem.eql(u8, "--with-frames", opt) or std.mem.eql(u8, "--with_frames", opt)) { - with_frames = true; - continue; - } - - if (std.mem.eql(u8, "--strip-mode", opt) or std.mem.eql(u8, "--strip_mode", opt)) { - const str = args.next() orelse { - log.fatal(.app, "missing argument value", .{ .arg = opt }); - return error.InvalidArgument; - }; - - var it = std.mem.splitScalar(u8, str, ','); - while (it.next()) |part| { - const trimmed = std.mem.trim(u8, part, &std.ascii.whitespace); - if (std.mem.eql(u8, trimmed, "js")) { - strip.js = true; - } else if (std.mem.eql(u8, trimmed, "ui")) { - strip.ui = true; - } else if (std.mem.eql(u8, trimmed, "css")) { - strip.css = true; - } else if (std.mem.eql(u8, trimmed, "full")) { - strip.js = true; - strip.ui = true; - strip.css = true; - } else { - log.fatal(.app, "invalid option choice", .{ .arg = opt, .value = trimmed }); - } - } - continue; - } - - if (try parseCommonArg(allocator, opt, args, &common)) { - continue; - } - - if (std.mem.startsWith(u8, opt, "--")) { - log.fatal(.app, "unknown argument", .{ .mode = "fetch", .arg = opt }); - return error.UnknownOption; - } - - if (url != null) { - log.fatal(.app, "duplicate fetch url", .{ .help = "only 1 URL can be specified" }); - return error.TooManyURLs; - } - url = try allocator.dupeZ(u8, opt); - } - - if (url == null) { - log.fatal(.app, "missing fetch url", .{ .help = "URL to fetch must be provided" }); - return error.MissingURL; - } - - return .{ - .url = url.?, - .dump_mode = dump_mode, - .strip = strip, - .common = common, - .with_base = with_base, - .with_frames = with_frames, - .wait_ms = wait_ms, - .wait_until = wait_until, - .wait_selector = wait_selector, - .wait_script = wait_script, - }; -} - -fn parseAgentArgs( - allocator: Allocator, - args: *std.process.ArgIterator, -) !Agent { - var result: Agent = .{}; - - while (args.next()) |opt| { - if (std.mem.eql(u8, "--provider", opt)) { - const str = args.next() orelse { - log.fatal(.app, "missing argument value", .{ .arg = opt }); - return error.InvalidArgument; - }; - result.provider = std.meta.stringToEnum(AiProvider, str) orelse { - log.fatal(.app, "invalid provider", .{ .arg = opt, .val = str }); - return error.InvalidArgument; - }; - continue; - } - - if (std.mem.eql(u8, "--model", opt)) { - const str = args.next() orelse { - log.fatal(.app, "missing argument value", .{ .arg = opt }); - return error.InvalidArgument; - }; - result.model = try allocator.dupeZ(u8, str); - continue; - } - - if (std.mem.eql(u8, "--base-url", opt) or std.mem.eql(u8, "--base_url", opt)) { - const str = args.next() orelse { - log.fatal(.app, "missing argument value", .{ .arg = opt }); - return error.InvalidArgument; - }; - result.base_url = try allocator.dupeZ(u8, str); - continue; - } - - if (std.mem.eql(u8, "--self-heal", opt) or std.mem.eql(u8, "--self_heal", opt)) { - result.self_heal = true; - continue; - } - - if (std.mem.eql(u8, "-i", opt) or std.mem.eql(u8, "--interactive", opt)) { - result.interactive = true; - continue; - } - - if (std.mem.eql(u8, "--system-prompt", opt) or std.mem.eql(u8, "--system_prompt", opt)) { - const str = args.next() orelse { - log.fatal(.app, "missing argument value", .{ .arg = opt }); - return error.InvalidArgument; - }; - result.system_prompt = try allocator.dupeZ(u8, str); - continue; - } - - if (std.mem.eql(u8, "--task", opt)) { - const str = args.next() orelse { - log.fatal(.app, "missing argument value", .{ .arg = opt }); - return error.InvalidArgument; - }; - result.task = try allocator.dupe(u8, str); - continue; - } - - if (std.mem.eql(u8, "--task-attachment", opt) or std.mem.eql(u8, "--task_attachment", opt)) { - const str = args.next() orelse { - log.fatal(.app, "missing argument value", .{ .arg = opt }); - return error.InvalidArgument; - }; - const path = try allocator.dupe(u8, str); - const existing = result.task_attachments orelse &[_][]const u8{}; - var list = try allocator.alloc([]const u8, existing.len + 1); - @memcpy(list[0..existing.len], existing); - list[existing.len] = path; - if (existing.len > 0) allocator.free(@as([]const []const u8, existing)); - result.task_attachments = list; - continue; - } - - if (try parseCommonArg(allocator, opt, args, &result.common)) { - continue; - } - - if (!std.mem.startsWith(u8, opt, "-")) { - result.script_file = opt; - continue; - } - - log.fatal(.app, "unknown argument", .{ .mode = "agent", .arg = opt }); - return error.UnknownOption; - } - - return result; -} - -fn parseCommonArg( - allocator: Allocator, - opt: []const u8, - args: *std.process.ArgIterator, - common: *Common, -) !bool { - if (std.mem.eql(u8, "--insecure-disable-tls-host-verification", opt) or std.mem.eql(u8, "--insecure_disable_tls_host_verification", opt)) { - common.tls_verify_host = false; - return true; - } - - if (std.mem.eql(u8, "--obey-robots", opt) or std.mem.eql(u8, "--obey_robots", opt)) { - common.obey_robots = true; - return true; - } - - if (std.mem.eql(u8, "--http-proxy", opt) or std.mem.eql(u8, "--http_proxy", opt)) { - const str = args.next() orelse { - log.fatal(.app, "missing argument value", .{ .arg = opt }); - return error.InvalidArgument; - }; - common.http_proxy = try allocator.dupeZ(u8, str); - return true; - } - - if (std.mem.eql(u8, "--proxy-bearer-token", opt) or std.mem.eql(u8, "--proxy_bearer_token", opt)) { - const str = args.next() orelse { - log.fatal(.app, "missing argument value", .{ .arg = opt }); - return error.InvalidArgument; - }; - common.proxy_bearer_token = try allocator.dupeZ(u8, str); - return true; - } - - if (std.mem.eql(u8, "--http-max-concurrent", opt) or std.mem.eql(u8, "--http_max_concurrent", opt)) { - const str = args.next() orelse { - log.fatal(.app, "missing argument value", .{ .arg = opt }); - return error.InvalidArgument; - }; - - common.http_max_concurrent = std.fmt.parseInt(u8, str, 10) catch |err| { - log.fatal(.app, "invalid argument value", .{ .arg = opt, .err = err }); - return error.InvalidArgument; - }; - return true; - } - - if (std.mem.eql(u8, "--http-max-host-open", opt) or std.mem.eql(u8, "--http_max_host_open", opt)) { - const str = args.next() orelse { - log.fatal(.app, "missing argument value", .{ .arg = opt }); - return error.InvalidArgument; - }; - - common.http_max_host_open = std.fmt.parseInt(u8, str, 10) catch |err| { - log.fatal(.app, "invalid argument value", .{ .arg = opt, .err = err }); - return error.InvalidArgument; - }; - return true; - } - - if (std.mem.eql(u8, "--http-connect-timeout", opt) or std.mem.eql(u8, "--http_connect_timeout", opt)) { - const str = args.next() orelse { - log.fatal(.app, "missing argument value", .{ .arg = opt }); - return error.InvalidArgument; - }; - - common.http_connect_timeout = std.fmt.parseInt(u31, str, 10) catch |err| { - log.fatal(.app, "invalid argument value", .{ .arg = opt, .err = err }); - return error.InvalidArgument; - }; - return true; - } - - if (std.mem.eql(u8, "--http-timeout", opt) or std.mem.eql(u8, "--http_timeout", opt)) { - const str = args.next() orelse { - log.fatal(.app, "missing argument value", .{ .arg = opt }); - return error.InvalidArgument; - }; - - common.http_timeout = std.fmt.parseInt(u31, str, 10) catch |err| { - log.fatal(.app, "invalid argument value", .{ .arg = opt, .err = err }); - return error.InvalidArgument; - }; - return true; - } - - if (std.mem.eql(u8, "--http-max-response-size", opt) or std.mem.eql(u8, "--http_max_response_size", opt)) { - const str = args.next() orelse { - log.fatal(.app, "missing argument value", .{ .arg = opt }); - return error.InvalidArgument; - }; - - common.http_max_response_size = std.fmt.parseInt(usize, str, 10) catch |err| { - log.fatal(.app, "invalid argument value", .{ .arg = opt, .err = err }); - return error.InvalidArgument; - }; - return true; - } - - if (std.mem.eql(u8, "--ws-max-concurrent", opt) or std.mem.eql(u8, "--ws_max_concurrent", opt)) { - const str = args.next() orelse { - log.fatal(.app, "missing argument value", .{ .arg = opt }); - return error.InvalidArgument; - }; - - common.ws_max_concurrent = std.fmt.parseInt(u8, str, 10) catch |err| { - log.fatal(.app, "invalid argument value", .{ .arg = opt, .err = err }); - return error.InvalidArgument; - }; - return true; - } - - if (std.mem.eql(u8, "--log-level", opt) or std.mem.eql(u8, "--log_level", opt)) { - const str = args.next() orelse { - log.fatal(.app, "missing argument value", .{ .arg = opt }); - return error.InvalidArgument; - }; - - common.log_level = std.meta.stringToEnum(log.Level, str) orelse blk: { - if (std.mem.eql(u8, str, "error")) { - break :blk .err; - } - log.fatal(.app, "invalid option choice", .{ .arg = opt, .value = str }); - return error.InvalidArgument; - }; - return true; - } - - if (std.mem.eql(u8, "--log-format", opt) or std.mem.eql(u8, "--log_format", opt)) { - const str = args.next() orelse { - log.fatal(.app, "missing argument value", .{ .arg = opt }); - return error.InvalidArgument; - }; - - common.log_format = std.meta.stringToEnum(log.Format, str) orelse { - log.fatal(.app, "invalid option choice", .{ .arg = opt, .value = str }); - return error.InvalidArgument; - }; - return true; - } - - if (std.mem.eql(u8, "--log-filter-scopes", opt) or std.mem.eql(u8, "--log_filter_scopes", opt)) { - if (builtin.mode != .Debug) { - log.fatal(.app, "experimental", .{ .help = "log scope filtering is only available in debug builds" }); - return false; - } - - const str = args.next() orelse { - // disables the default filters - common.log_filter_scopes = &.{}; - return true; - }; - - var arr: std.ArrayList(log.Scope) = .empty; - - var it = std.mem.splitScalar(u8, str, ','); - while (it.next()) |part| { - try arr.append(allocator, std.meta.stringToEnum(log.Scope, part) orelse { - log.fatal(.app, "invalid option choice", .{ .arg = opt, .value = part }); - return false; - }); - } - common.log_filter_scopes = arr.items; - return true; - } - - if (std.mem.eql(u8, "--user-agent", opt)) { - const str = args.next() orelse { - log.fatal(.app, "missing argument value", .{ .arg = opt }); - return error.InvalidArgument; - }; - - validateUserAgent(str) catch |err| { - log.fatal(.app, "invalid value", .{ - .detail = "invalid user agent", - .arg = opt, - .err = err, - }); - return error.InvalidArgument; - }; - - common.user_agent = try allocator.dupe(u8, str); - return true; - } - - if (std.mem.eql(u8, "--user-agent-suffix", opt) or std.mem.eql(u8, "--user_agent_suffix", opt)) { - const str = args.next() orelse { - log.fatal(.app, "missing argument value", .{ .arg = opt }); - return error.InvalidArgument; - }; - - if (common.user_agent != null) { - log.fatal(.app, "exclusive options", .{ - .arg = opt, - .detail = "--user-agent and --user-agent-suffix are exclusive", - }); - return error.InvalidArgument; - } - - for (str) |c| { - if (!std.ascii.isPrint(c)) { - log.fatal(.app, "not printable character", .{ .arg = opt }); - return error.InvalidArgument; - } - } - common.user_agent_suffix = try allocator.dupe(u8, str); - return true; - } - - if (std.mem.eql(u8, "--web-bot-auth-key-file", opt) or std.mem.eql(u8, "--web_bot_auth_key_file", opt)) { - const str = args.next() orelse { - log.fatal(.app, "missing argument value", .{ .arg = opt }); - return error.InvalidArgument; - }; - common.web_bot_auth_key_file = try allocator.dupe(u8, str); - return true; - } - - if (std.mem.eql(u8, "--web-bot-auth-keyid", opt) or std.mem.eql(u8, "--web_bot_auth_keyid", opt)) { - const str = args.next() orelse { - log.fatal(.app, "missing argument value", .{ .arg = opt }); - return error.InvalidArgument; - }; - common.web_bot_auth_keyid = try allocator.dupe(u8, str); - return true; - } - - if (std.mem.eql(u8, "--web-bot-auth-domain", opt) or std.mem.eql(u8, "--web_bot_auth_domain", opt)) { - const str = args.next() orelse { - log.fatal(.app, "missing argument value", .{ .arg = opt }); - return error.InvalidArgument; - }; - common.web_bot_auth_domain = try allocator.dupe(u8, str); - return true; - } - - if (std.mem.eql(u8, "--http-cache-dir", opt)) { - const str = args.next() orelse { - log.fatal(.app, "missing argument value", .{ .arg = "--http-cache-dir" }); - return error.InvalidArgument; - }; - common.http_cache_dir = try allocator.dupe(u8, str); - return true; - } - - if (std.mem.eql(u8, "--storage-engine", opt)) { - const str = args.next() orelse { - log.fatal(.app, "missing argument value", .{ .arg = "--storage-engine" }); - return error.InvalidArgument; - }; - common.storage_engine = std.meta.stringToEnum(Storage.EngineType, str) orelse { - log.fatal(.app, "invalid argument value", .{ .arg = opt, .val = str }); - return error.InvalidArgument; - }; - return true; - } - - if (std.mem.eql(u8, "--storage-sqlite-path", opt)) { - const str = args.next() orelse { - log.fatal(.app, "missing argument value", .{ .arg = "--storage-sqlite-path" }); - return error.InvalidArgument; - }; - common.storage_sqlite_path = try allocator.dupeZ(u8, str); - return true; - } - - if (std.mem.eql(u8, "--block-private-networks", opt)) { - common.block_private_networks = true; - return true; - } - - if (std.mem.eql(u8, "--block-cidrs", opt)) { - const str = args.next() orelse { - log.fatal(.app, "missing argument value", .{ .arg = "--block-cidrs" }); - return error.InvalidArgument; - }; - common.block_cidrs = try allocator.dupe(u8, str); - return true; - } - - if (std.mem.eql(u8, "--cookie", opt)) { - const str = args.next() orelse { - log.fatal(.app, "missing argument value", .{ .arg = "--cookie" }); - return error.InvalidArgument; - }; - common.cookie = try allocator.dupe(u8, str); - return true; - } - - if (std.mem.eql(u8, "--cookie-jar", opt)) { - const str = args.next() orelse { - log.fatal(.app, "missing argument value", .{ .arg = "--cookie-jar" }); - return error.InvalidArgument; - }; - common.cookie_jar = try allocator.dupe(u8, str); - return true; - } - - return false; + const exec_name, const command = try Commands.parse(allocator); + return .init(allocator, exec_name, command); } pub fn validateUserAgent(ua: []const u8) !void { @@ -1437,49 +765,3 @@ pub fn validateUserAgent(ua: []const u8) !void { return error.Reserved; } } - -const testing = @import("testing.zig"); -test "Config: HttpHeaders - default user agent" { - var config = try Config.init(testing.allocator, "", .{ .serve = .{} }); - defer config.deinit(testing.allocator); - - try testing.expectEqual("Lightpanda/1.0", config.http_headers.user_agent); - try testing.expectEqual("User-Agent: Lightpanda/1.0", config.http_headers.user_agent_header); - try testing.expect(config.http_headers.proxy_bearer_header == null); -} - -test "Config: HttpHeaders - custom user agent override" { - var config = try Config.init(testing.allocator, "", .{ .serve = .{ .common = .{ .user_agent = "MyBot/2.0" } } }); - defer config.deinit(testing.allocator); - - try testing.expectEqual("MyBot/2.0", config.http_headers.user_agent); - try testing.expectEqual("User-Agent: MyBot/2.0", config.http_headers.user_agent_header); -} - -test "Config: HttpHeaders - user agent suffix" { - var config = try Config.init(testing.allocator, "", .{ .serve = .{ .common = .{ .user_agent_suffix = "CustomSuffix/3.0" } } }); - defer config.deinit(testing.allocator); - - try testing.expectEqual("Lightpanda/1.0 CustomSuffix/3.0", config.http_headers.user_agent); - try testing.expectEqual("User-Agent: Lightpanda/1.0 CustomSuffix/3.0", config.http_headers.user_agent_header); -} - -test "Config: HttpHeaders - fetch mode default user agent" { - var config = try Config.init(testing.allocator, "", .{ .fetch = .{ .url = "https://example.com" } }); - defer config.deinit(testing.allocator); - try testing.expectEqual("Lightpanda/1.0", config.http_headers.user_agent); -} - -test "Config: HttpHeaders - fetch mode custom user agent" { - var config = try Config.init(testing.allocator, "", .{ .fetch = .{ .url = "https://example.com", .common = .{ .user_agent = "FetchBot/1.0" } } }); - defer config.deinit(testing.allocator); - try testing.expectEqual("FetchBot/1.0", config.http_headers.user_agent); - try testing.expectEqual("User-Agent: FetchBot/1.0", config.http_headers.user_agent_header); -} - -test "Config: HttpHeaders - proxy bearer header" { - var config = try Config.init(testing.allocator, "", .{ .serve = .{ .common = .{ .proxy_bearer_token = "secret-token" } } }); - defer config.deinit(testing.allocator); - try testing.expectEqual("Lightpanda/1.0", config.http_headers.user_agent); - try testing.expectEqual("Proxy-Authorization: Bearer secret-token", config.http_headers.proxy_bearer_header.?); -} diff --git a/src/SemanticTree.zig b/src/SemanticTree.zig index 6ed57d25..47220745 100644 --- a/src/SemanticTree.zig +++ b/src/SemanticTree.zig @@ -726,7 +726,7 @@ test "SemanticTree backendDOMNodeId" { var frame = try testing.pageTest("cdp/registry1.html", .{}); defer testing.reset(); - defer frame._session.removeFrame(); + defer frame._session.removePage(); const st: Self = .{ .dom_node = frame.window._document.asNode(), @@ -750,7 +750,7 @@ test "SemanticTree max_depth" { var frame = try testing.pageTest("cdp/registry1.html", .{}); defer testing.reset(); - defer frame._session.removeFrame(); + defer frame._session.removePage(); const st: Self = .{ .dom_node = frame.window._document.asNode(), diff --git a/src/agent/Agent.zig b/src/agent/Agent.zig index 21cda48f..87f7a225 100644 --- a/src/agent/Agent.zig +++ b/src/agent/Agent.zig @@ -172,7 +172,7 @@ pub fn init(allocator: std.mem.Allocator, app: *App, opts: Config.Agent) !*Self .self_heal = opts.self_heal, .interactive = opts.interactive, .one_shot_task = opts.task, - .one_shot_attachments = opts.task_attachments, + .one_shot_attachments = if (opts.task_attachments.items.len == 0) null else opts.task_attachments.items, }; self.cmd_executor = CommandExecutor.init(allocator, tool_executor, &self.terminal); diff --git a/src/browser/EventManager.zig b/src/browser/EventManager.zig index 1b4231fb..cabc56a3 100644 --- a/src/browser/EventManager.zig +++ b/src/browser/EventManager.zig @@ -104,7 +104,7 @@ pub fn dispatch(self: *EventManager, target: *EventTarget, event: *Event) Dispat pub fn dispatchOpts(self: *EventManager, target: *EventTarget, event: *Event, comptime opts: DispatchOpts) DispatchError!void { event.acquireRef(); - defer _ = event.releaseRef(self.frame._session); + defer _ = event.releaseRef(self.frame._page); // Increment event count for Event Timing API self.frame.window._performance._event_counts.increment(event._type_string.str()); @@ -124,7 +124,7 @@ pub fn dispatchOpts(self: *EventManager, target: *EventTarget, event: *Event, co // property is just a shortcut for calling addEventListener, but they are distinct. // An event set via property cannot be removed by removeEventListener. If you // set both the property and add a listener, they both execute. -const DispatchDirectOptions = EventManagerBase.DispatchDirectOptions; +pub const DispatchDirectOptions = EventManagerBase.DispatchDirectOptions; // Direct dispatch for non-DOM targets (Window, XHR, AbortSignal) or DOM nodes with // property handlers. No propagation - just calls the handler and registered listeners. @@ -138,7 +138,7 @@ pub fn dispatchDirect(self: *EventManager, target: *EventTarget, event: *Event, window._current_event = event; defer window._current_event = prev_event; - try self.base.dispatchDirect(frame.call_arena, frame.js, target, event, handler, frame._session, opts); + try self.base.dispatchDirect(frame.call_arena, frame.js, target, event, handler, frame._page, opts); } /// Check if there are any listeners for a direct dispatch (non-DOM target). @@ -617,7 +617,7 @@ const ActivationState = struct { const event = try Event.initTrusted(comptime .wrap(typ), .{ .bubbles = true, .cancelable = false, - }, frame); + }, frame._page); const target = input.asElement().asEventTarget(); try frame._event_manager.dispatch(target, event); diff --git a/src/browser/EventManagerBase.zig b/src/browser/EventManagerBase.zig index 055ea213..8e13ecd5 100644 --- a/src/browser/EventManagerBase.zig +++ b/src/browser/EventManagerBase.zig @@ -21,7 +21,7 @@ const lp = @import("lightpanda"); const builtin = @import("builtin"); const js = @import("js/js.zig"); -const Session = @import("Session.zig"); +const Page = @import("Page.zig"); const Event = @import("webapi/Event.zig"); const EventTarget = @import("webapi/EventTarget.zig"); @@ -216,7 +216,7 @@ pub fn dispatchDirect( target: *EventTarget, event: *Event, handler: anytype, - session: *Session, + page: *Page, comptime opts: DispatchDirectOptions, ) DispatchError!void { if (comptime IS_DEBUG) { @@ -224,7 +224,7 @@ pub fn dispatchDirect( } event.acquireRef(); - defer _ = event.releaseRef(session); + defer _ = event.releaseRef(page); if (comptime opts.inject_target) { event._target = target; diff --git a/src/browser/Frame.zig b/src/browser/Frame.zig index c40af4b5..95f7e129 100644 --- a/src/browser/Frame.zig +++ b/src/browser/Frame.zig @@ -22,6 +22,7 @@ const builtin = @import("builtin"); const JS = @import("js/js.zig"); const Mime = @import("Mime.zig"); +const Page = @import("Page.zig"); const Factory = @import("Factory.zig"); const Session = @import("Session.zig"); const EventManager = @import("EventManager.zig"); @@ -35,6 +36,7 @@ const URL = @import("URL.zig"); const Blob = @import("webapi/Blob.zig"); const Node = @import("webapi/Node.zig"); const Event = @import("webapi/Event.zig"); +const EventTarget = @import("webapi/EventTarget.zig"); const CData = @import("webapi/CData.zig"); const Element = @import("webapi/Element.zig"); const HtmlElement = @import("webapi/element/Html.zig"); @@ -86,6 +88,8 @@ _frame_id: u32, // navigate. _loader_id: u32, +_page: *Page, + _session: *Session, _event_manager: EventManager, @@ -255,35 +259,39 @@ _req_id: u32 = 0, _console_messages: std.ArrayListUnmanaged(ConsoleMessage) = .{}, _navigated_options: ?NavigatedOpts = null, -pub fn init(self: *Frame, frame_id: u32, session: *Session, parent: ?*Frame) !void { +pub fn init(self: *Frame, frame_id: u32, page: *Page, parent: ?*Frame) !void { if (comptime IS_DEBUG) { log.debug(.frame, "frame.init", .{}); } + const session = page.session; const call_arena = try session.getArena(.medium, "call_arena"); errdefer session.releaseArena(call_arena); - const factory = &session.factory; + const factory = &page.factory; const document = (try factory.document(Node.Document.HTMLDocument{ ._proto = undefined, })).asDocument(); + const arena = page.frame_arena; + self.* = .{ .js = undefined, + .arena = arena, .parent = parent, - .arena = session.frame_arena, .document = document, .window = undefined, .call_arena = call_arena, ._frame_id = frame_id, - ._loader_id = session.nextLoaderId(), + ._page = page, ._session = session, + ._loader_id = session.nextLoaderId(), ._factory = factory, ._pending_loads = 1, // always 1 for the ScriptManager ._type = if (parent == null) .root else .frame, ._style_manager = undefined, ._script_manager = undefined, - ._event_manager = EventManager.init(session.frame_arena, self), + ._event_manager = EventManager.init(arena, self), }; self._to_load = &self._to_load_1; @@ -322,8 +330,8 @@ pub fn init(self: *Frame, frame_id: u32, session: *Session, parent: ?*Frame) !vo errdefer self._script_manager.deinit(); self.js = try browser.env.createContext(self, .{ - .identity = &session.identity, - .identity_arena = session.frame_arena, + .identity = &page.identity, + .identity_arena = arena, .call_arena = self.call_arena, }); errdefer browser.env.destroyContext(self.js); @@ -363,10 +371,10 @@ pub fn deinit(self: *Frame, abort_http: bool) void { // stats.print(&stream) catch unreachable; } - const session = self._session; + const page = self._page; if (self._queued_navigation) |qn| { - session.releaseArena(qn.arena); + page.releaseArena(qn.arena); } { @@ -374,7 +382,7 @@ pub fn deinit(self: *Frame, abort_http: bool) void { { var it = self._blob_urls.valueIterator(); while (it.next()) |blob| { - blob.*.releaseRef(session); + blob.*.releaseRef(page); } } @@ -383,39 +391,40 @@ pub fn deinit(self: *Frame, abort_http: bool) void { while (node) |n| { node = n.next; // capture before we potentially delete observer const observer: *MutationObserver = @fieldParentPtr("node", n); - observer.releaseRef(session); + observer.releaseRef(page); } } for (self._intersection_observers.items) |observer| { - observer.releaseRef(session); + observer.releaseRef(page); } var document = self.window._document; - document._selection.releaseRef(session); + document._selection.releaseRef(page); if (document._fonts) |f| { - f.releaseRef(session); + f.releaseRef(page); } } - session.browser.env.destroyContext(self.js); + const browser = page.session.browser; + browser.env.destroyContext(self.js); self._script_manager.shutdown = true; if (self.parent == null) { - session.browser.http_client.abort(); + browser.http_client.abort(); } else if (abort_http) { // a small optimization, it's faster to abort _everything_ on the root // frame, so we prefer that. But if it's just the frame that's going // away (a frame navigation) then we'll abort the frame-related requests - session.browser.http_client.abortFrame(self._frame_id); + browser.http_client.abortFrame(self._frame_id); } self._script_manager.deinit(); self._style_manager.deinit(); - session.releaseArena(self.call_arena); + page.releaseArena(self.call_arena); } pub fn trackWorker(self: *Frame, worker: *Worker) !void { @@ -801,13 +810,14 @@ pub fn documentIsLoaded(self: *Frame) void { self._load_state = .load; self.document._ready_state = .interactive; - self._documentIsLoaded() catch |err| { - log.err(.frame, "document is loaded", .{ .err = err, .type = self._type, .url = self.url }); + self._documentIsLoaded() catch |err| switch (err) { + error.JsException => {}, // already logged + else => log.err(.frame, "document is loaded2", .{ .err = err, .type = self._type, .url = self.url }), }; } pub fn _documentIsLoaded(self: *Frame) !void { - const event = try Event.initTrusted(.wrap("DOMContentLoaded"), .{ .bubbles = true }, self); + const event = try Event.initTrusted(.wrap("DOMContentLoaded"), .{ .bubbles = true }, self._page); try self._event_manager.dispatch( self.document.asEventTarget(), event, @@ -834,7 +844,7 @@ pub fn iframeCompletedLoading(self: *Frame, iframe: *IFrame) void { defer entered.exit(); blk: { - const event = Event.initTrusted(comptime .wrap("load"), .{}, self) catch |err| { + const event = Event.initTrusted(comptime .wrap("load"), .{}, self._page) catch |err| { log.err(.frame, "iframe event init", .{ .err = err, .url = iframe._src }); break :blk; }; @@ -874,8 +884,9 @@ pub fn documentIsComplete(self: *Frame) void { } self._load_state = .complete; - self._documentIsComplete() catch |err| { - log.err(.frame, "document is complete", .{ .err = err, .type = self._type, .url = self.url }); + self._documentIsComplete() catch |err| switch (err) { + error.JsException => {}, // already logged + else => log.err(.frame, "document is complete", .{ .err = err, .type = self._type, .url = self.url }), }; } @@ -888,7 +899,7 @@ fn _documentIsComplete(self: *Frame) !void { // Dispatch window.load event. const window_target = self.window.asEventTarget(); if (self._event_manager.hasDirectListeners(window_target, "load", self.window._on_load)) { - const event = try Event.initTrusted(comptime .wrap("load"), .{}, self); + const event = try Event.initTrusted(comptime .wrap("load"), .{}, self._page); // This event is weird, it's dispatched directly on the window, but // with the document as the target. event._target = self.document.asEventTarget(); @@ -1213,7 +1224,7 @@ pub fn iframeAddedCallback(self: *Frame, iframe: *IFrame) !void { const new_frame = try self.arena.create(Frame); const frame_id = session.nextFrameId(); - try Frame.init(new_frame, frame_id, session, self); + try Frame.init(new_frame, frame_id, self._page, self); errdefer new_frame.deinit(true); self._pending_loads += 1; @@ -1431,7 +1442,7 @@ pub fn registerMutationObserver(self: *Frame, observer: *MutationObserver) !void } pub fn unregisterMutationObserver(self: *Frame, observer: *MutationObserver) void { - observer.releaseRef(self._session); + observer.releaseRef(self._page); self._mutation_observers.remove(&observer.node); } @@ -1443,7 +1454,7 @@ pub fn registerIntersectionObserver(self: *Frame, observer: *IntersectionObserve pub fn unregisterIntersectionObserver(self: *Frame, observer: *IntersectionObserver) void { for (self._intersection_observers.items, 0..) |obs, i| { if (obs == observer) { - observer.releaseRef(self._session); + observer.releaseRef(self._page); _ = self._intersection_observers.swapRemove(i); return; } @@ -1468,7 +1479,7 @@ pub fn dispatchLoad(self: *Frame) !void { for (to_process.items) |html_element| { if (has_dom_load_listener or html_element.hasAttributeFunction(.onload, self)) { - const event = try Event.initTrusted(comptime .wrap("load"), .{}, self); + const event = try Event.initTrusted(comptime .wrap("load"), .{}, self._page); try self._event_manager.dispatch(html_element.asEventTarget(), event); } } @@ -1579,7 +1590,7 @@ pub fn deliverSlotchangeEvents(self: *Frame) void { self._slots_pending_slotchange.clearRetainingCapacity(); for (slots) |slot| { - const event = Event.initTrusted(comptime .wrap("slotchange"), .{ .bubbles = true }, self) catch |err| { + const event = Event.initTrusted(comptime .wrap("slotchange"), .{ .bubbles = true }, self._page) catch |err| { log.err(.frame, "deliverSlotchange.init", .{ .err = err, .type = self._type, .url = self.url }); continue; }; @@ -2625,6 +2636,19 @@ pub fn dupeString(self: *Frame, value: []const u8) ![]const u8 { return self.arena.dupe(u8, value); } +// Direct (non-propagating) dispatch of an event. Mirrors WorkerGlobalScope.dispatch +// so worker-compatible APIs can uniformly call `global.dispatch(...)` across both +// Frame and Worker contexts. +pub fn dispatch( + self: *Frame, + target: *EventTarget, + event: *Event, + handler: anytype, + comptime opts: EventManager.DispatchDirectOptions, +) !void { + return self._event_manager.dispatchDirect(target, event, handler, opts); +} + pub fn dupeSSO(self: *Frame, value: []const u8) !String { return String.init(self.arena, value, .{ .dupe = true }); } @@ -3534,7 +3558,7 @@ pub fn handleClick(self: *Frame, target: *Node) !void { pub fn triggerKeyboard(self: *Frame, keyboard_event: *KeyboardEvent) !void { const event = keyboard_event.asEvent(); const element = self.window._document._active_element orelse { - event.deinit(self._session); + event.deinit(self._page); return; }; @@ -3630,7 +3654,7 @@ pub fn submitForm(self: *Frame, submitter_: ?*Element, form_: ?*Element.Html.For // so submit_event is still valid when we check _prevent_default submit_event.acquireRef(); - defer _ = submit_event.releaseRef(self._session); + defer _ = submit_event.releaseRef(self._page); try self._event_manager.dispatch(form_element.asEventTarget(), submit_event); // If the submit event was prevented, don't submit the form diff --git a/src/browser/Page.zig b/src/browser/Page.zig new file mode 100644 index 00000000..af58f34a --- /dev/null +++ b/src/browser/Page.zig @@ -0,0 +1,232 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// 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 . + +const std = @import("std"); +const lp = @import("lightpanda"); +const builtin = @import("builtin"); + +const App = @import("../App.zig"); + +const js = @import("js/js.zig"); +const v8 = js.v8; + +const Frame = @import("Frame.zig"); +const Session = @import("Session.zig"); +const Factory = @import("Factory.zig"); + +const log = lp.log; +const Allocator = std.mem.Allocator; +const IS_DEBUG = builtin.mode == .Debug; + +// A Page is the container for a root Frame and all of its descendants +// (nested iframes). It owns the resources that share the lifetime of the root +// document: the DOM factory, the per-page arena, the JS identity map, shared +// origins, v8 global handles, and queued navigation buffers. +// +// In the future, a Session may hold multiple Pages at once (e.g. during a +// navigation, while the old Page is retiring and the new one is provisional). +// For now, Session still holds a single Page. +const Page = @This(); + +session: *Session, + +// DOM object factory scoped to this Page's documents. +factory: Factory, + +// The arena for this Page's lifetime. Document / Frame / Factory / DOM +// objects allocate out of this. +frame_arena: Allocator, + +// Origin map for same-origin context sharing. Entries live for the Page's +// lifetime. +origins: std.StringHashMapUnmanaged(*js.Origin) = .empty, + +// Identity tracking for the main world. All main-world contexts in this Page +// share this, ensuring object identity works across same-origin frames. +identity: js.Identity = .{}, + +// Finalizer callbacks for Zig instances exposed to v8 in this Page. Keyed by +// Zig instance ptr. The backing FinalizerCallback.Identity structs come from +// Session.fc_identity_pool so they outlive the Page for v8 weak-callback +// safety. +finalizer_callbacks: std.AutoHashMapUnmanaged(usize, *Session.FinalizerCallback) = .empty, + +// Tracked global v8 objects that need to be released when the Page tears down. +globals: std.ArrayList(v8.Global) = .empty, + +// Temporary v8 globals that can be released early. Key is global.data_ptr. +temps: std.AutoHashMapUnmanaged(usize, v8.Global) = .empty, + +// Double buffered so that, as we process one list of queued navigations, new +// entries are added to the separate buffer. Prevents endless navigation loops +// and invalidation of the list during iteration. +queued_navigation_1: std.ArrayList(*Frame) = .empty, +queued_navigation_2: std.ArrayList(*Frame) = .empty, +// pointer to either queued_navigation_1 or queued_navigation_2 +queued_navigation: *std.ArrayList(*Frame) = undefined, + +// Temporary buffer for about:blank navigations during processing. +// We process async navigations first (safe from re-entrance), then sync +// about:blank navigations (which may add to queued_navigation). +queued_queued_navigation: std.ArrayList(*Frame) = .empty, + +// The root Frame of this Page. Non-optional — a Page always has a root frame. +frame: Frame, + +// Initialize a Page and its root Frame. +pub fn init(self: *Page, session: *Session, frame_id: u32) !void { + const frame_arena = try session.arena_pool.acquire(.large, "Page.frame_arena"); + errdefer session.arena_pool.release(frame_arena); + + self.* = .{ + .session = session, + .frame = undefined, + .frame_arena = frame_arena, + .factory = Factory.init(frame_arena), + }; + self.queued_navigation = &self.queued_navigation_1; + + try Frame.init(&self.frame, frame_id, self, null); +} + +// Tear down the Page and its root Frame. Equivalent to the old +// Session.removePage + Session.resetFrameResources. +pub fn deinit(self: *Page, abort_http: bool) void { + self.frame.deinit(abort_http); + + const session = self.session; + defer session.browser.env.memoryPressureNotification(.moderate); + + self.identity.deinit(); + self.identity = .{}; + + // Force cleanup all remaining finalized objects. + { + var it = self.finalizer_callbacks.valueIterator(); + while (it.next()) |fc| { + fc.*.deinit(self); + } + self.finalizer_callbacks = .empty; + } + + { + for (self.globals.items) |*global| { + v8.v8__Global__Reset(global); + } + self.globals = .empty; + } + + { + var it = self.temps.valueIterator(); + while (it.next()) |global| { + v8.v8__Global__Reset(global); + } + self.temps = .empty; + } + + if (comptime IS_DEBUG) { + std.debug.assert(self.origins.count() == 0); + } + // Defensive cleanup in case origins leaked. + { + const app = session.browser.app; + var it = self.origins.valueIterator(); + while (it.next()) |value| { + value.*.deinit(app); + } + self.origins = .empty; + } + + session.arena_pool.release(self.frame_arena); +} + +pub fn getArena(self: *Page, size_or_bucket: anytype, debug: []const u8) !Allocator { + return self.session.getArena(size_or_bucket, debug); +} + +pub fn releaseArena(self: *Page, allocator: Allocator) void { + return self.session.releaseArena(allocator); +} + +pub fn getOrCreateOrigin(self: *Page, key_: ?[]const u8) !*js.Origin { + const session = self.session; + const key = key_ orelse { + var opaque_origin: [36]u8 = undefined; + @import("../id.zig").uuidv4(&opaque_origin); + // Origin.init will dupe opaque_origin. It's fine that this doesn't + // get added to self.origins. In fact, it further isolates it. When the + // context is freed, it'll call Page.releaseOrigin which will free it. + return js.Origin.init(session.browser.app, session.browser.env.isolate, &opaque_origin); + }; + + const gop = try self.origins.getOrPut(session.arena, key); + if (gop.found_existing) { + const origin = gop.value_ptr.*; + origin.rc += 1; + return origin; + } + + errdefer _ = self.origins.remove(key); + + const origin = try js.Origin.init(session.browser.app, session.browser.env.isolate, key); + gop.key_ptr.* = origin.key; + gop.value_ptr.* = origin; + return origin; +} + +pub fn releaseOrigin(self: *Page, origin: *js.Origin) void { + const rc = origin.rc; + if (rc == 1) { + _ = self.origins.remove(origin.key); + origin.deinit(self.session.browser.app); + } else { + origin.rc = rc - 1; + } +} + +pub fn scheduleNavigation(self: *Page, frame: *Frame) !void { + const list = self.queued_navigation; + + // Check if frame is already queued + for (list.items) |existing| { + if (existing == frame) { + // Already queued + return; + } + } + + return list.append(self.session.arena, frame); +} + +pub fn findFrameByFrameId(self: *Page, frame_id: u32) ?*Frame { + return findFrameBy(&self.frame, "_frame_id", frame_id); +} + +pub fn findFrameByLoaderId(self: *Page, loader_id: u32) ?*Frame { + return findFrameBy(&self.frame, "_loader_id", loader_id); +} + +fn findFrameBy(frame: *Frame, comptime field: []const u8, id: u32) ?*Frame { + if (@field(frame, field) == id) return frame; + for (frame.child_frames.items) |f| { + if (findFrameBy(f, field, id)) |found| { + return found; + } + } + return null; +} diff --git a/src/browser/Runner.zig b/src/browser/Runner.zig index 69b66a9f..b6274eb0 100644 --- a/src/browser/Runner.zig +++ b/src/browser/Runner.zig @@ -40,7 +40,7 @@ http_client: *HttpClient, pub const Opts = struct {}; pub fn init(session: *Session, _: Opts) !Runner { - const frame = &(session.frame orelse return error.NoPage); + const frame = session.currentFrame() orelse return error.NoPage; return .{ .frame = frame, @@ -150,10 +150,12 @@ fn _tick(self: *Runner, comptime is_cdp: bool, opts: TickOpts) !CDPTickResult { }, .html, .complete => { const session = self.session; - if (session.queued_navigation.items.len != 0) { - try session.processQueuedNavigation(); - self.frame = &session.frame.?; // might have changed - return .{ .ok = 0 }; + if (session.currentPage()) |page| { + if (page.queued_navigation.items.len != 0) { + try session.processQueuedNavigation(); + self.frame = session.currentFrame().?; // might have changed + return .{ .ok = 0 }; + } } const browser = session.browser; @@ -323,7 +325,7 @@ test "Runner: no page" { test "Runner: waitForSelector timeout" { const frame = try testing.pageTest("runner/runner1.html", .{}); - defer frame._session.removeFrame(); + defer frame._session.removePage(); var runner = try frame._session.runner(.{}); try testing.expectError(error.Timeout, runner.waitForSelector("#nope", 10)); @@ -332,7 +334,7 @@ test "Runner: waitForSelector timeout" { test "Runner: waitForSelector" { defer testing.reset(); const frame = try testing.pageTest("runner/runner1.html", .{}); - defer frame._session.removeFrame(); + defer frame._session.removePage(); var runner = try frame._session.runner(.{}); const el = try runner.waitForSelector("#sel1", 10); @@ -341,7 +343,7 @@ test "Runner: waitForSelector" { test "Runner: waitForScript timeout" { const frame = try testing.pageTest("runner/runner1.html", .{}); - defer frame._session.removeFrame(); + defer frame._session.removePage(); var runner = try frame._session.runner(.{}); try testing.expectError(error.Timeout, runner.waitForScript("document.querySelector('#nope')", 10)); @@ -349,7 +351,7 @@ test "Runner: waitForScript timeout" { test "Runner: waitForScript" { const frame = try testing.pageTest("runner/runner1.html", .{}); - defer frame._session.removeFrame(); + defer frame._session.removePage(); var runner = try frame._session.runner(.{}); try runner.waitForScript("document.querySelector('#sel1')", 10); diff --git a/src/browser/ScriptManager.zig b/src/browser/ScriptManager.zig index 24fd7642..5f82b88e 100644 --- a/src/browser/ScriptManager.zig +++ b/src/browser/ScriptManager.zig @@ -949,7 +949,7 @@ pub const Script = struct { fn executeCallback(self: *const Script, typ: String, frame: *Frame) void { const Event = @import("webapi/Event.zig"); - const event = Event.initTrusted(typ, .{}, frame) catch |err| { + const event = Event.initTrusted(typ, .{}, frame._page) catch |err| { log.warn(.js, "script internal callback", .{ .url = self.url, .type = typ, diff --git a/src/browser/Session.zig b/src/browser/Session.zig index 12799521..1d2a04e6 100644 --- a/src/browser/Session.zig +++ b/src/browser/Session.zig @@ -29,9 +29,9 @@ const Navigation = @import("webapi/navigation/Navigation.zig"); const History = @import("webapi/History.zig"); const Frame = @import("Frame.zig"); +const Page = @import("Page.zig"); pub const Runner = @import("Runner.zig"); const Browser = @import("Browser.zig"); -const Factory = @import("Factory.zig"); const Notification = @import("../Notification.zig"); const QueuedNavigation = Frame.QueuedNavigation; @@ -40,17 +40,16 @@ const ArenaPool = App.ArenaPool; const Allocator = std.mem.Allocator; const IS_DEBUG = builtin.mode == .Debug; -// You can create successively multiple frames for a session, but you must -// deinit a frame before running another one. It manages two distinct lifetimes. +// A Session represents a browsing context group (cookie jar, session storage, +// navigation history) within a Browser. It hosts one Page at a time — the +// root Frame and all of its descendants — and is responsible for Page +// lifecycle (create, remove, replace on root navigation). // -// The first is the lifetime of the Session itself, where frames are created and -// removed, but share the same cookie jar and navigation history (etc...) -// -// The second is as a container the data needed by the full frame hierarchy, i.e. \ -// the root frame and all of its frames (and all of their frames.) +// Multiple concurrent Pages (e.g. an old Page retiring while a new provisional +// Page is loading) are not yet supported; see Page.zig for the intended +// direction. const Session = @This(); -// These are the fields that remain intact for the duration of the Session browser: *Browser, arena: Allocator, history: History, @@ -59,54 +58,18 @@ storage_shed: storage.Shed, notification: *Notification, cookie_jar: storage.Cookie.Jar, -// These are the fields that get reset whenever the Session's frame (the root) is reset. -factory: Factory, - -frame_arena: Allocator, - -// Origin map for same-origin context sharing. Scoped to the root frame lifetime. -origins: std.StringHashMapUnmanaged(*js.Origin) = .empty, - -// Identity tracking for the main world. All main world contexts share this, -// ensuring object identity works across same-origin frames. -identity: js.Identity = .{}, - -// Shared finalizer callbacks across all Identities. Keyed by Zig instance ptr. -// This ensures objects are only freed when ALL v8 wrappers are gone. -finalizer_callbacks: std.AutoHashMapUnmanaged(usize, *FinalizerCallback) = .empty, - -// Pool for FinalizerCallback.Identity structs. These must survive frame resets -// so V8 weak callbacks can validate the FC before dereferencing it. -fc_identity_pool: std.heap.MemoryPool(FinalizerCallback.Identity), - -// Tracked global v8 objects that need to be released on cleanup. -// Lives at Session level so objects can outlive individual Identities. -globals: std.ArrayList(v8.Global) = .empty, - -// Temporary v8 globals that can be released early. Key is global.data_ptr. -// Lives at Session level so objects holding Temps can outlive individual Identities. -temps: std.AutoHashMapUnmanaged(usize, v8.Global) = .empty, - -// Shared resources for all frames in this session. -// These live for the duration of the frame tree (root + frames). +// Shared allocator. Used by Session itself and borrowed by Pages. arena_pool: *ArenaPool, -frame: ?Frame, +// Pool for FinalizerCallback.Identity structs. These must survive Page +// teardowns so V8 weak callbacks can validate the FC before dereferencing it. +fc_identity_pool: std.heap.MemoryPool(FinalizerCallback.Identity), -// Double buffer so that, as we process one list of queued navigations, new entries -// are added to the separate buffer. This ensures that we don't end up with -// endless navigation loops AND that we don't invalidate the list while iterating -// if a new entry gets appended -queued_navigation_1: std.ArrayList(*Frame), -queued_navigation_2: std.ArrayList(*Frame), -// pointer to either queued_navigation_1 or queued_navigation_2 -queued_navigation: *std.ArrayList(*Frame), - -// Temporary buffer for about:blank navigations during processing. -// We process async navigations first (safe from re-entrance), then sync -// about:blank navigations (which may add to queued_navigation). -queued_queued_navigation: std.ArrayList(*Frame), +// The currently-active Page. Null when no Page exists (between removePage +// and createPage, or at startup). +page: ?Page, +// IDs. Kept at Session level so IDs can remain unique across Page replacements. frame_id_gen: u32 = 0, loader_id_gen: u32 = 0, @@ -117,57 +80,47 @@ pub fn init(self: *Session, browser: *Browser, notification: *Notification) !voi const arena = try arena_pool.acquire(.small, "Session"); errdefer arena_pool.release(arena); - const frame_arena = try arena_pool.acquire(.large, "Session.frame_arena"); - errdefer arena_pool.release(frame_arena); - self.* = .{ - .frame = null, + .page = null, .arena = arena, .arena_pool = arena_pool, - .frame_arena = frame_arena, - .factory = Factory.init(frame_arena), .history = .{}, // The prototype (EventTarget) for Navigation is created when a Frame is created. .navigation = .{ ._proto = undefined }, .storage_shed = .{}, .browser = browser, - .queued_navigation = undefined, - .queued_navigation_1 = .{}, - .queued_navigation_2 = .{}, - .queued_queued_navigation = .{}, .notification = notification, - .cookie_jar = storage.Cookie.Jar.init(allocator), .fc_identity_pool = .init(allocator), + .cookie_jar = storage.Cookie.Jar.init(allocator), }; - self.queued_navigation = &self.queued_navigation_1; } pub fn deinit(self: *Session) void { - if (self.frame != null) { - self.removeFrame(); + if (self.page != null) { + self.removePage(); } self.cookie_jar.deinit(); self.fc_identity_pool.deinit(); self.storage_shed.deinit(self.browser.app.allocator); - self.arena_pool.release(self.frame_arena); self.arena_pool.release(self.arena); } // NOTE: the caller is not the owner of the returned value, // the pointer on Frame is just returned as a convenience -pub fn createFrame(self: *Session) !*Frame { - lp.assert(self.frame == null, "Session.createFrame - frame not null", .{}); +pub fn createPage(self: *Session) !*Frame { + lp.assert(self.page == null, "Session.createPage - page not null", .{}); - self.frame = @as(Frame, undefined); - const frame = &self.frame.?; - try Frame.init(frame, self.nextFrameId(), self, null); + self.page = @as(Page, undefined); + const page = &self.page.?; + try Page.init(page, self, self.nextFrameId()); + const frame = &page.frame; // Creates a new NavigationEventTarget for this frame. try self.navigation.onNewFrame(frame); if (comptime IS_DEBUG) { - log.debug(.browser, "create frame", .{}); + log.debug(.browser, "create page", .{}); } // start JS env // Inform CDP the main frame has been created such that additional context for other Worlds can be created as well @@ -176,19 +129,22 @@ pub fn createFrame(self: *Session) !*Frame { return frame; } -pub fn removeFrame(self: *Session) void { +pub fn removePage(self: *Session) void { // Inform CDP the frame is going to be removed, allowing other worlds to remove themselves before the main one self.notification.dispatch(.frame_remove, .{}); - lp.assert(self.frame != null, "Session.removeFrame - frame is null", .{}); + lp.assert(self.page != null, "Session.removePage - page is null", .{}); - self.frame.?.deinit(false); - self.frame = null; + self.page.?.deinit(false); + self.page = null; self.navigation.onRemoveFrame(); - self.resetFrameResources(); + + // resetting frame_id_gen preserves previous behavior where removing the + // root page returned us to a clean-slate state. + self.frame_id_gen = 0; if (comptime IS_DEBUG) { - log.debug(.browser, "remove frame", .{}); + log.debug(.browser, "remove page", .{}); } } @@ -201,132 +157,49 @@ pub fn releaseArena(self: *Session, allocator: Allocator) void { } pub fn getOrCreateOrigin(self: *Session, key_: ?[]const u8) !*js.Origin { - const key = key_ orelse { - var opaque_origin: [36]u8 = undefined; - @import("../id.zig").uuidv4(&opaque_origin); - // Origin.init will dupe opaque_origin. It's fine that this doesn't - // get added to self.origins. In fact, it further isolates it. When the - // context is freed, it'll call session.releaseOrigin which will free it. - return js.Origin.init(self.browser.app, self.browser.env.isolate, &opaque_origin); - }; - - const gop = try self.origins.getOrPut(self.arena, key); - if (gop.found_existing) { - const origin = gop.value_ptr.*; - origin.rc += 1; - return origin; - } - - errdefer _ = self.origins.remove(key); - - const origin = try js.Origin.init(self.browser.app, self.browser.env.isolate, key); - gop.key_ptr.* = origin.key; - gop.value_ptr.* = origin; - return origin; + return self.page.?.getOrCreateOrigin(key_); } pub fn releaseOrigin(self: *Session, origin: *js.Origin) void { - const rc = origin.rc; - if (rc == 1) { - _ = self.origins.remove(origin.key); - origin.deinit(self.browser.app); - } else { - origin.rc = rc - 1; - } + return self.page.?.releaseOrigin(origin); } -/// Reset frame_arena and factory for a clean slate. -/// Called when root frame is removed. -fn resetFrameResources(self: *Session) void { - defer self.browser.env.memoryPressureNotification(.moderate); - - self.identity.deinit(); - self.identity = .{}; - - // Force cleanup all remaining finalized objects - { - var it = self.finalizer_callbacks.valueIterator(); - while (it.next()) |fc| { - fc.*.deinit(self); - } - self.finalizer_callbacks = .empty; - } - - { - for (self.globals.items) |*global| { - v8.v8__Global__Reset(global); - } - self.globals = .empty; - } - - { - var it = self.temps.valueIterator(); - while (it.next()) |global| { - v8.v8__Global__Reset(global); - } - self.temps = .empty; - } - +pub fn replacePage(self: *Session) !*Frame { if (comptime IS_DEBUG) { - std.debug.assert(self.origins.count() == 0); - } - // Defensive cleanup in case origins leaked - { - const app = self.browser.app; - var it = self.origins.valueIterator(); - while (it.next()) |value| { - value.*.deinit(app); - } - self.origins = .empty; + log.debug(.browser, "replace page", .{}); } - self.frame_id_gen = 0; - self.arena_pool.reset(self.frame_arena, 64 * 1024); - self.factory = Factory.init(self.frame_arena); -} + lp.assert(self.page != null, "Session.replacePage null page", .{}); + const current = &self.page.?; + lp.assert(current.frame.parent == null, "Session.replacePage with parent", .{}); -pub fn replaceFrame(self: *Session) !*Frame { - if (comptime IS_DEBUG) { - log.debug(.browser, "replace frame", .{}); - } - - lp.assert(self.frame != null, "Session.replaceFrame null frame", .{}); - lp.assert(self.frame.?.parent == null, "Session.replaceFrame with parent", .{}); - - var current = self.frame.?; - const frame_id = current._frame_id; + const frame_id = current.frame._frame_id; current.deinit(true); + self.page = null; - self.resetFrameResources(); + // Preserve prior behavior: frame_id_gen reset on root replacement so a + // subsequent createPage starts from id 1. The captured frame_id is + // passed into Page.init explicitly, so it isn't affected. + self.frame_id_gen = 0; - self.frame = @as(Frame, undefined); - const frame = &self.frame.?; - try Frame.init(frame, frame_id, self, null); - return frame; + self.page = @as(Page, undefined); + const page = &self.page.?; + try Page.init(page, self, frame_id); + return &page.frame; +} + +pub fn currentPage(self: *Session) ?*Page { + return &(self.page orelse return null); } pub fn currentFrame(self: *Session) ?*Frame { - return &(self.frame orelse return null); + const page = self.currentPage() orelse return null; + return &page.frame; } pub fn findFrameByFrameId(self: *Session, frame_id: u32) ?*Frame { - const frame = self.currentFrame() orelse return null; - return findFrameBy(frame, "_frame_id", frame_id); -} - -pub fn findFrameByLoaderId(self: *Session, loader_id: u32) ?*Frame { - const frame = self.currentFrame() orelse return null; - return findFrameBy(frame, "_loader_id", loader_id); -} - -fn findFrameBy(frame: *Frame, comptime field: []const u8, id: u32) ?*Frame { - if (@field(frame, field) == id) return frame; - for (frame.child_frames.items) |f| { - if (findFrameBy(f, field, id)) |found| { - return found; - } - } - return null; + const page = self.currentPage() orelse return null; + return page.findFrameByFrameId(frame_id); } pub fn runner(self: *Session, opts: Runner.Opts) !Runner { @@ -334,28 +207,19 @@ pub fn runner(self: *Session, opts: Runner.Opts) !Runner { } pub fn scheduleNavigation(self: *Session, frame: *Frame) !void { - const list = self.queued_navigation; - - // Check if frame is already queued - for (list.items) |existing| { - if (existing == frame) { - // Already queued - return; - } - } - - return list.append(self.arena, frame); + return self.page.?.scheduleNavigation(frame); } pub fn processQueuedNavigation(self: *Session) !void { - const navigations = self.queued_navigation; - if (self.queued_navigation == &self.queued_navigation_1) { - self.queued_navigation = &self.queued_navigation_2; + const page = self.currentPage() orelse return; + const navigations = page.queued_navigation; + if (page.queued_navigation == &page.queued_navigation_1) { + page.queued_navigation = &page.queued_navigation_2; } else { - self.queued_navigation = &self.queued_navigation_1; + page.queued_navigation = &page.queued_navigation_1; } - if (self.frame.?._queued_navigation != null) { + if (page.frame._queued_navigation != null) { // This is both an optimization and a simplification of sorts. If the // root frame is navigating, then we don't need to process any other // navigation. Also, the navigation for the root frame and for a frame @@ -365,7 +229,7 @@ pub fn processQueuedNavigation(self: *Session) !void { return self.processRootQueuedNavigation(); } - const about_blank_queue = &self.queued_queued_navigation; + const about_blank_queue = &page.queued_queued_navigation; defer about_blank_queue.clearRetainingCapacity(); // First pass: process async navigations (non-about:blank) @@ -401,14 +265,14 @@ pub fn processQueuedNavigation(self: *Session) !void { // Safety: Remove any about:blank navigations that were queued during // processing to prevent infinite loops. New navigations have been queued // in the other buffer. - const new_navigations = self.queued_navigation; + const new_navigations = page.queued_navigation; var i: usize = 0; while (i < new_navigations.items.len) { const frame = new_navigations.items[i]; if (frame._queued_navigation) |qn| { if (qn.is_about_blank) { log.warn(.frame, "recursive about blank", .{}); - _ = self.queued_navigation.swapRemove(i); + _ = page.queued_navigation.swapRemove(i); continue; } } @@ -434,10 +298,11 @@ fn processFrameNavigation(self: *Session, frame: *Frame, qn: *QueuedNavigation) } const frame_id = frame._frame_id; + const page = self.currentPage().?; frame.deinit(true); frame.* = undefined; - try Frame.init(frame, frame_id, self, parent); + try Frame.init(frame, frame_id, page, parent); errdefer { for (parent.child_frames.items, 0..) |f, i| { if (f == frame) { @@ -462,7 +327,7 @@ fn processFrameNavigation(self: *Session, frame: *Frame, qn: *QueuedNavigation) } fn processRootQueuedNavigation(self: *Session) !void { - const current_frame = &self.frame.?; + const current_frame = &self.page.?.frame; const frame_id = current_frame._frame_id; // create a copy before the frame is cleared @@ -471,11 +336,21 @@ fn processRootQueuedNavigation(self: *Session) !void { defer self.arena_pool.release(qn.arena); - self.removeFrame(); + // Dispatch frame_remove (same as removePage) then replace the Page + // in-place, keeping the frame_id stable. + self.notification.dispatch(.frame_remove, .{}); + self.page.?.deinit(true); + self.page = null; - self.frame = @as(Frame, undefined); - const new_frame = &self.frame.?; - try Frame.init(new_frame, frame_id, self, null); + self.navigation.onRemoveFrame(); + + // Preserve prior behavior: the old resetFrameResources reset frame_id_gen. + self.frame_id_gen = 0; + + self.page = @as(Page, undefined); + const page = &self.page.?; + try Page.init(page, self, frame_id); + const new_frame = &page.frame; // Creates a new NavigationEventTarget for this frame. try self.navigation.onNewFrame(new_frame); @@ -503,14 +378,14 @@ pub fn nextLoaderId(self: *Session) u32 { } // Every finalizable instance of Zig gets 1 FinalizerCallback registered in the -// session. This is to ensure that, if v8 doesn't finalize the value, we can -// release on frame reset. +// Page. This is to ensure that, if v8 doesn't finalize the value, we can +// release on Page teardown. pub const FinalizerCallback = struct { + page: *Page, arena: Allocator, - session: *Session, resolved_ptr_id: usize, finalizer_ptr_id: usize, - release_ref: *const fn (ptr_id: usize, session: *Session) void, + release_ref: *const fn (ptr_id: usize, page: *Page) void, // Linked list of Identities referencing this FC. identities: ?*Identity = null, @@ -519,10 +394,14 @@ pub const FinalizerCallback = struct { // For every FinalizerCallback we'll have 1+ FinalizerCallback.Identity: one // for every identity that gets the instance. In most cases, that'll be 1. - // Allocated from Session.fc_identity_pool so it survives frame resets and + // Allocated from Session.fc_identity_pool so it survives Page teardowns and // allows the weak callback to safely check the done flag. pub const Identity = struct { session: *Session, + // The Page that owns the FinalizerCallback this Identity references. + // Only safe to dereference when `done == false`. When done is true, + // the Page may have been torn down and this pointer is stale. + page: *Page, identity: *js.Identity, finalizer_ptr_id: usize, resolved_ptr_id: usize, @@ -530,8 +409,8 @@ pub const FinalizerCallback = struct { done: bool = false, }; - // Called during frame reset to force cleanup regardless of identities. - fn deinit(self: *FinalizerCallback, session: *Session) void { + // Called during Page teardown to force cleanup regardless of identities. + pub fn deinit(self: *FinalizerCallback, page: *Page) void { // Mark all identities as done so stale V8 weak callbacks // won't find the wrong FC if resolved_ptr_id is reused. var id = self.identities; @@ -539,7 +418,7 @@ pub const FinalizerCallback = struct { identity.done = true; id = identity.next; } - self.release_ref(self.finalizer_ptr_id, session); - session.releaseArena(self.arena); + self.release_ref(self.finalizer_ptr_id, page); + page.releaseArena(self.arena); } }; diff --git a/src/browser/actions.zig b/src/browser/actions.zig index cca8b9a0..9380de04 100644 --- a/src/browser/actions.zig +++ b/src/browser/actions.zig @@ -28,12 +28,12 @@ const Session = @import("Session.zig"); const Selector = @import("webapi/selector/Selector.zig"); fn dispatchInputAndChangeEvents(el: *Element, frame: *Frame) !void { - const input_evt: *Event = try .initTrusted(comptime .wrap("input"), .{ .bubbles = true }, frame); + const input_evt: *Event = try .initTrusted(comptime .wrap("input"), .{ .bubbles = true }, frame._page); frame._event_manager.dispatch(el.asEventTarget(), input_evt) catch |err| { lp.log.err(.app, "dispatch input event failed", .{ .err = err }); }; - const change_evt: *Event = try .initTrusted(comptime .wrap("change"), .{ .bubbles = true }, frame); + const change_evt: *Event = try .initTrusted(comptime .wrap("change"), .{ .bubbles = true }, frame._page); frame._event_manager.dispatch(el.asEventTarget(), change_evt) catch |err| { lp.log.err(.app, "dispatch change event failed", .{ .err = err }); }; @@ -196,7 +196,7 @@ pub fn scroll(node: ?*DOMNode, x: ?i32, y: ?i32, frame: *Frame) !void { }; } - const scroll_evt: *Event = try .initTrusted(comptime .wrap("scroll"), .{ .bubbles = true }, frame); + const scroll_evt: *Event = try .initTrusted(comptime .wrap("scroll"), .{ .bubbles = true }, frame._page); frame._event_manager.dispatch(el.asEventTarget(), scroll_evt) catch |err| { lp.log.err(.app, "dispatch scroll event failed", .{ .err = err }); }; diff --git a/src/browser/dump.zig b/src/browser/dump.zig index 84bcc8a6..3a8e1bf7 100644 --- a/src/browser/dump.zig +++ b/src/browser/dump.zig @@ -30,7 +30,7 @@ pub const Opts = struct { strip: Opts.Strip = .{}, shadow: Opts.Shadow = .rendered, - pub const Strip = struct { + pub const Strip = packed struct(u3) { js: bool = false, ui: bool = false, css: bool = false, diff --git a/src/browser/forms.zig b/src/browser/forms.zig index 34a931d3..51366627 100644 --- a/src/browser/forms.zig +++ b/src/browser/forms.zig @@ -278,7 +278,7 @@ fn collectSelectOptions( const testing = @import("../testing.zig"); fn testForms(html: []const u8) ![]FormInfo { - const frame = try testing.test_session.createFrame(); + const frame = try testing.test_session.createPage(); const doc = frame.window._document; const div = try doc.createElement("div", null, frame); @@ -289,7 +289,7 @@ fn testForms(html: []const u8) ![]FormInfo { test "browser.forms: login form" { defer testing.reset(); - defer testing.test_session.removeFrame(); + defer testing.test_session.removePage(); const forms = try testForms( \\
\\ @@ -310,7 +310,7 @@ test "browser.forms: login form" { test "browser.forms: form with select" { defer testing.reset(); - defer testing.test_session.removeFrame(); + defer testing.test_session.removePage(); const forms = try testForms( \\ \\ @@ -343,7 +343,7 @@ test "browser.forms: form with textarea" { test "browser.forms: empty form skipped" { defer testing.reset(); - defer testing.test_session.removeFrame(); + defer testing.test_session.removePage(); const forms = try testForms( \\ \\

No fields here

@@ -354,7 +354,7 @@ test "browser.forms: empty form skipped" { test "browser.forms: hidden inputs excluded" { defer testing.reset(); - defer testing.test_session.removeFrame(); + defer testing.test_session.removePage(); const forms = try testForms( \\ \\ @@ -368,7 +368,7 @@ test "browser.forms: hidden inputs excluded" { test "browser.forms: multiple forms" { defer testing.reset(); - defer testing.test_session.removeFrame(); + defer testing.test_session.removePage(); const forms = try testForms( \\ \\ @@ -385,7 +385,7 @@ test "browser.forms: multiple forms" { test "browser.forms: disabled fields flagged" { defer testing.reset(); - defer testing.test_session.removeFrame(); + defer testing.test_session.removePage(); const forms = try testForms( \\ \\ @@ -400,7 +400,7 @@ test "browser.forms: disabled fields flagged" { test "browser.forms: disabled fieldset" { defer testing.reset(); - defer testing.test_session.removeFrame(); + defer testing.test_session.removePage(); const forms = try testForms( \\ \\
@@ -417,7 +417,7 @@ test "browser.forms: disabled fieldset" { test "browser.forms: external field via form attribute" { defer testing.reset(); - defer testing.test_session.removeFrame(); + defer testing.test_session.removePage(); const forms = try testForms( \\ \\ @@ -430,7 +430,7 @@ test "browser.forms: external field via form attribute" { test "browser.forms: checkbox and radio return value attribute" { defer testing.reset(); - defer testing.test_session.removeFrame(); + defer testing.test_session.removePage(); const forms = try testForms( \\ \\ @@ -447,7 +447,7 @@ test "browser.forms: checkbox and radio return value attribute" { test "browser.forms: form without action or method" { defer testing.reset(); - defer testing.test_session.removeFrame(); + defer testing.test_session.removePage(); const forms = try testForms( \\ \\ diff --git a/src/browser/interactive.zig b/src/browser/interactive.zig index b97b7bc3..fc5887ce 100644 --- a/src/browser/interactive.zig +++ b/src/browser/interactive.zig @@ -452,8 +452,8 @@ fn getListenerTypes(target: *EventTarget, listener_targets: ListenerTargetMap) [ const testing = @import("../testing.zig"); fn testInteractive(html: []const u8) ![]InteractiveElement { - const frame = try testing.test_session.createFrame(); - defer testing.test_session.removeFrame(); + const frame = try testing.test_session.createPage(); + defer testing.test_session.removePage(); const doc = frame.window._document; const div = try doc.createElement("div", null, frame); diff --git a/src/browser/js/Caller.zig b/src/browser/js/Caller.zig index 3e20c134..4df8813a 100644 --- a/src/browser/js/Caller.zig +++ b/src/browser/js/Caller.zig @@ -21,6 +21,7 @@ const lp = @import("lightpanda"); const string = @import("../../string.zig"); const Frame = @import("../Frame.zig"); +const Page = @import("../Page.zig"); const Session = @import("../Session.zig"); const WorkerGlobalScope = @import("../webapi/WorkerGlobalScope.zig"); @@ -447,10 +448,14 @@ fn tupleFieldName(comptime i: usize) [:0]const u8 { }; } -fn isPage(comptime T: type) bool { +fn isFrame(comptime T: type) bool { return T == *Frame or T == *const Frame; } +fn isPage(comptime T: type) bool { + return T == *Page or T == *const Page; +} + fn isSession(comptime T: type) bool { return T == *Session or T == *const Session; } @@ -460,19 +465,19 @@ fn isExecution(comptime T: type) bool { } fn getGlobalArg(comptime T: type, ctx: *Context) T { - if (comptime isPage(T)) { + if (comptime isFrame(T)) { return switch (ctx.global) { .frame => |frame| frame, .worker => unreachable, }; } - if (comptime isExecution(T)) { - return &ctx.execution; + if (comptime isPage(T)) { + return ctx.page; } - if (comptime isSession(T)) { - return ctx.session; + if (comptime isExecution(T)) { + return &ctx.execution; } @compileError("Unsupported global arg type: " ++ @typeName(T)); @@ -761,17 +766,17 @@ fn getArgs(comptime F: type, comptime offset: usize, local: *const Local, info: return args; } - // If the last parameter is the Page or Worker, set it, and exclude it - // from our params slice, because we don't want to bind it to - // a JS argument + // If the last parameter is Frame/Page/Session/Execution, set it from + // context and exclude it from our params slice, because we don't want + // to bind it to a JS argument. const LastParamType = params[params.len - 1].type.?; - if (comptime isPage(LastParamType) or isExecution(LastParamType) or isSession(LastParamType)) { + if (comptime isFrame(LastParamType) or isPage(LastParamType) or isExecution(LastParamType) or isSession(LastParamType)) { @field(args, tupleFieldName(params.len - 1 + offset)) = getGlobalArg(LastParamType, local.ctx); break :blk params[0 .. params.len - 1]; } - // we have neither a Page, Execution, nor a JsObject. All params must be - // bound to a JavaScript value. + // we have neither a Frame/Page/Session/Execution nor a JsObject. + // All params must be bound to a JavaScript value. break :blk params; }; @@ -818,7 +823,9 @@ fn getArgs(comptime F: type, comptime offset: usize, local: *const Local, info: } } - if (comptime isPage(param.type.?)) { + if (comptime isFrame(param.type.?)) { + @compileError("Frame must be the last parameter: " ++ @typeName(F)); + } else if (comptime isPage(param.type.?)) { @compileError("Page must be the last parameter: " ++ @typeName(F)); } else if (comptime isExecution(param.type.?)) { @compileError("Execution must be the last parameter: " ++ @typeName(F)); diff --git a/src/browser/js/Context.zig b/src/browser/js/Context.zig index 7087130c..b880ce6e 100644 --- a/src/browser/js/Context.zig +++ b/src/browser/js/Context.zig @@ -27,6 +27,7 @@ const Scheduler = @import("Scheduler.zig"); const Execution = @import("Execution.zig"); const Frame = @import("../Frame.zig"); +const Page = @import("../Page.zig"); const Session = @import("../Session.zig"); const ScriptManager = @import("../ScriptManager.zig"); const WorkerGlobalScope = @import("../webapi/WorkerGlobalScope.zig"); @@ -69,7 +70,14 @@ pub const GlobalScope = union(enum) { id: usize, env: *Env, global: GlobalScope, -session: *Session, + +// The Page this Context belongs to. For main-world frame contexts, this is +// the Page of the frame. For worker contexts, this is the Page of the +// worker's parent frame — a worker's v8 globals and identity tracking live +// on the same Page as its owning frame (worker dies with its page). The +// Session is always reachable via `page.session`. +page: *Page, + isolate: js.Isolate, // Per-context microtask queue for isolation between contexts @@ -114,7 +122,7 @@ origin: *Origin, identity: *js.Identity, // Allocator to use for identity map operations. For main world contexts this is -// session.frame_arena, for isolated worlds it's the isolated world's arena. +// page.frame_arena, for isolated worlds it's the isolated world's arena. identity_arena: Allocator, // Unlike other v8 types, like functions or objects, modules are not shared @@ -207,7 +215,7 @@ pub fn deinit(self: *Context) void { v8.v8__Global__Reset(global); } - self.session.releaseOrigin(self.origin); + self.page.releaseOrigin(self.origin); // Clear the embedder data so that if V8 keeps this context alive // (because objects created in it are still referenced), we don't @@ -234,9 +242,9 @@ pub fn setOrigin(self: *Context, key: ?[]const u8) !void { lp.assert(self.origin.rc == 1, "Ref opaque origin", .{ .rc = self.origin.rc }); } - const origin = try self.session.getOrCreateOrigin(key); + const origin = try self.page.getOrCreateOrigin(key); - self.session.releaseOrigin(self.origin); + self.page.releaseOrigin(self.origin); self.origin = origin; { @@ -252,11 +260,11 @@ pub fn setOrigin(self: *Context, key: ?[]const u8) !void { } pub fn trackGlobal(self: *Context, global: v8.Global) !void { - return self.session.globals.append(self.session.frame_arena, global); + return self.page.globals.append(self.page.frame_arena, global); } pub fn trackTemp(self: *Context, global: v8.Global) !void { - return self.session.temps.put(self.session.frame_arena, global.data_ptr, global); + return self.page.temps.put(self.page.frame_arena, global.data_ptr, global); } pub const IdentityResult = struct { diff --git a/src/browser/js/Env.zig b/src/browser/js/Env.zig index 6572a3ca..2d4a7b15 100644 --- a/src/browser/js/Env.zig +++ b/src/browser/js/Env.zig @@ -275,9 +275,9 @@ fn _createContext(self: *Env, global: anytype, params: ContextParams) !*Context const context_id = self.context_id; self.context_id = context_id + 1; - const session = global._session; - const origin = try session.getOrCreateOrigin(null); - errdefer session.releaseOrigin(origin); + const page = global._page; + const origin = try page.getOrCreateOrigin(null); + errdefer page.releaseOrigin(origin); const context = try context_arena.create(Context); context.* = .{ @@ -285,7 +285,7 @@ fn _createContext(self: *Env, global: anytype, params: ContextParams) !*Context .global = if (comptime is_frame) .{ .frame = global } else .{ .worker = global }, .origin = origin, .id = context_id, - .session = session, + .page = page, .isolate = isolate, .arena = context_arena, .handle = context_global, @@ -558,8 +558,8 @@ const PrivateSymbols = struct { const testing = @import("../../testing.zig"); test "Env: Worker context " { const session = testing.test_session; - const frame = try session.createFrame(); - defer session.removeFrame(); + const frame = try session.createPage(); + defer session.removePage(); const worker = try @import("../webapi/Worker.zig").init("http://localhost:9582/src/browser/tests/testing.js", &frame.js.execution); @@ -573,8 +573,8 @@ test "Env: Worker context " { test "Env: Frame context" { const session = testing.test_session; - const frame = try session.createFrame(); - defer session.removeFrame(); + const frame = try session.createPage(); + defer session.removePage(); // Frame already has a context created, use it directly const ctx = frame.js; diff --git a/src/browser/js/Execution.zig b/src/browser/js/Execution.zig index 6acfe455..e752e904 100644 --- a/src/browser/js/Execution.zig +++ b/src/browser/js/Execution.zig @@ -26,10 +26,13 @@ //! whether it's a Page context or a Worker context. const std = @import("std"); +const lp = @import("lightpanda"); + const Context = @import("Context.zig"); const Scheduler = @import("Scheduler.zig"); const Factory = @import("../Factory.zig"); +const String = lp.String; const Allocator = std.mem.Allocator; const Execution = @This(); @@ -53,3 +56,10 @@ charset: *[]const u8, pub fn base(self: *const Execution) [:0]const u8 { return self.context.global.base(); } + +pub fn dupeString(self: *const Execution, value: []const u8) ![]const u8 { + if (String.intern(value)) |v| { + return v; + } + return self.arena.dupe(u8, value); +} diff --git a/src/browser/js/Function.zig b/src/browser/js/Function.zig index e8713a8d..218c7bcd 100644 --- a/src/browser/js/Function.zig +++ b/src/browser/js/Function.zig @@ -215,7 +215,7 @@ fn _persist(self: *const Function, comptime is_global: bool) !(if (is_global) Gl return .{ .handle = global, .temps = {} }; } try ctx.trackTemp(global); - return .{ .handle = global, .temps = &ctx.session.temps }; + return .{ .handle = global, .temps = &ctx.page.temps }; } pub fn tempWithThis(self: *const Function, value: anytype) !Temp { diff --git a/src/browser/js/Local.zig b/src/browser/js/Local.zig index d386a9a5..3e810f31 100644 --- a/src/browser/js/Local.zig +++ b/src/browser/js/Local.zig @@ -20,7 +20,8 @@ const std = @import("std"); const lp = @import("lightpanda"); const string = @import("../../string.zig"); -const Session = @import("../Session.zig"); +const Page = @import("../Page.zig"); +const FinalizerCallback = @import("../Session.zig").FinalizerCallback; const js = @import("js.zig"); const bridge = @import("bridge.zig"); @@ -270,19 +271,21 @@ pub fn mapZigInstanceToJs(self: *const Local, js_obj_handle: ?*const v8.Object, if (resolved.finalizer) |finalizer| { const finalizer_ptr_id = finalizer.ptr_id; - const session = ctx.session; - const finalizer_gop = try session.finalizer_callbacks.getOrPut(session.frame_arena, finalizer_ptr_id); + const page = ctx.page; + const session = page.session; + const finalizer_gop = try page.finalizer_callbacks.getOrPut(page.frame_arena, finalizer_ptr_id); if (finalizer_gop.found_existing == false) { // This is the first context (and very likely only one) to // see this Zig instance. We need to create the FinalizerCallback - // so that we can cleanup on frame reset if v8 doesn't finalize. - errdefer _ = session.finalizer_callbacks.remove(finalizer_ptr_id); + // so that we can cleanup on Page teardown if v8 doesn't finalize. + errdefer _ = page.finalizer_callbacks.remove(finalizer_ptr_id); finalizer.acquire_ref(finalizer_ptr_id); finalizer_gop.value_ptr.* = try self.createFinalizerCallback(resolved_ptr_id, finalizer_ptr_id, finalizer.release_ref_from_zig); } const fc = finalizer_gop.value_ptr.*; const identity_finalizer = try session.fc_identity_pool.create(); identity_finalizer.* = .{ + .page = page, .session = session, .identity = ctx.identity, .finalizer_ptr_id = finalizer_ptr_id, @@ -1178,7 +1181,7 @@ const Resolved = struct { ptr_id: usize, acquire_ref: *const fn (ptr_id: usize) void, release_ref: *const fn (handle: ?*const v8.WeakCallbackInfo) callconv(.c) void, - release_ref_from_zig: *const fn (ptr_id: usize, session: *Session) void, + release_ref_from_zig: *const fn (ptr_id: usize, page: *Page) void, }; }; pub fn resolveValue(value: anytype) Resolved { @@ -1224,12 +1227,12 @@ fn resolveT(comptime T: type, value: *T) Resolved { fn releaseRef(handle: ?*const v8.WeakCallbackInfo) callconv(.c) void { const ptr = v8.v8__WeakCallbackInfo__GetParameter(handle.?).?; - const identity_finalizer: *Session.FinalizerCallback.Identity = @ptrCast(@alignCast(ptr)); + const identity_finalizer: *FinalizerCallback.Identity = @ptrCast(@alignCast(ptr)); // Identity is allocated from pool, so it's valid even after frame reset. - const session = identity_finalizer.session; + const page = identity_finalizer.page; const resolved_ptr_id = identity_finalizer.resolved_ptr_id; - defer session.fc_identity_pool.destroy(identity_finalizer); + defer page.session.fc_identity_pool.destroy(identity_finalizer); // Always clean up the identity map entry if (identity_finalizer.identity.identity_map.fetchRemove(resolved_ptr_id)) |kv| { @@ -1237,28 +1240,29 @@ fn resolveT(comptime T: type, value: *T) Resolved { v8.v8__Global__Reset(&global); } - // If done, FC was already cleaned up during frame reset. The - // finalizer_ptr_id may have been reused for a new object, so - // we must not look it up in the map. + // If done, FC was already cleaned up during Page teardown. + // The finalizer_ptr_id may have been reused for a new object, + // so we must not look it up in the map. It's also unsafe to + // dereference identity_finalizer.page after done is true. if (identity_finalizer.done) return; const finalizer_ptr_id = identity_finalizer.finalizer_ptr_id; - const fc = session.finalizer_callbacks.get(finalizer_ptr_id) orelse return; + const fc = page.finalizer_callbacks.get(finalizer_ptr_id) orelse return; const identity_count = fc.identity_count; if (identity_count == 1) { // Last identity - clean up the FC. // Remove from map before releaseRef to prevent address reuse issues. - _ = session.finalizer_callbacks.remove(finalizer_ptr_id); - FT.releaseRef(@ptrFromInt(finalizer_ptr_id), session); - session.releaseArena(fc.arena); + _ = page.finalizer_callbacks.remove(finalizer_ptr_id); + FT.releaseRef(@ptrFromInt(finalizer_ptr_id), page); + page.releaseArena(fc.arena); } else { fc.identity_count = identity_count - 1; } } - fn releaseRefFromZig(ptr_id: usize, session: *Session) void { - FT.releaseRef(@ptrFromInt(ptr_id), session); + fn releaseRefFromZig(ptr_id: usize, page: *Page) void { + FT.releaseRef(@ptrFromInt(ptr_id), page); } }; break :blk .{ @@ -1524,17 +1528,17 @@ fn createFinalizerCallback( // The most specific value where finalizers are defined // What actually gets acquired / released / deinit finalizer_ptr_id: usize, - release_ref: *const fn (ptr_id: usize, session: *Session) void, -) !*Session.FinalizerCallback { - const session = self.ctx.session; + release_ref: *const fn (ptr_id: usize, page: *Page) void, +) !*FinalizerCallback { + const page = self.ctx.page; - const arena = try session.getArena(.tiny, "FinalizerCallback"); - errdefer session.releaseArena(arena); + const arena = try page.getArena(.tiny, "FinalizerCallback"); + errdefer page.releaseArena(arena); - const fc = try arena.create(Session.FinalizerCallback); + const fc = try arena.create(FinalizerCallback); fc.* = .{ + .page = page, .arena = arena, - .session = session, .release_ref = release_ref, .resolved_ptr_id = resolved_ptr_id, .finalizer_ptr_id = finalizer_ptr_id, diff --git a/src/browser/js/Promise.zig b/src/browser/js/Promise.zig index 8418c408..0118532c 100644 --- a/src/browser/js/Promise.zig +++ b/src/browser/js/Promise.zig @@ -67,7 +67,7 @@ fn _persist(self: *const Promise, comptime is_global: bool) !(if (is_global) Glo return .{ .handle = global, .temps = {} }; } try ctx.trackTemp(global); - return .{ .handle = global, .temps = &ctx.session.temps }; + return .{ .handle = global, .temps = &ctx.page.temps }; } pub const Temp = G(.temp); diff --git a/src/browser/js/String.zig b/src/browser/js/String.zig index 3558c178..5c51863f 100644 --- a/src/browser/js/String.zig +++ b/src/browser/js/String.zig @@ -57,7 +57,7 @@ fn _toSlice(self: String, comptime null_terminate: bool, allocator: Allocator) ! pub fn toSSO(self: String, comptime global: bool) !(if (global) lp.String.Global else lp.String) { if (comptime global) { - return .{ .str = try self.toSSOWithAlloc(self.local.ctx.session.frame_arena) }; + return .{ .str = try self.toSSOWithAlloc(self.local.ctx.page.frame_arena) }; } return self.toSSOWithAlloc(self.local.call_arena); } diff --git a/src/browser/js/Value.zig b/src/browser/js/Value.zig index a59701d7..b73a9fb2 100644 --- a/src/browser/js/Value.zig +++ b/src/browser/js/Value.zig @@ -398,7 +398,7 @@ fn _persist(self: *const Value, comptime is_global: bool) !(if (is_global) Globa return .{ .handle = global, .temps = {} }; } try ctx.trackTemp(global); - return .{ .handle = global, .temps = &ctx.session.temps }; + return .{ .handle = global, .temps = &ctx.page.temps }; } pub fn toZig(self: Value, comptime T: type) !T { diff --git a/src/browser/js/bridge.zig b/src/browser/js/bridge.zig index 32ac0de2..ed903ea0 100644 --- a/src/browser/js/bridge.zig +++ b/src/browser/js/bridge.zig @@ -858,6 +858,7 @@ pub const PageJsApis = flattenTypes(&.{ @import("../webapi/EventTarget.zig"), @import("../webapi/Location.zig"), @import("../webapi/Navigator.zig"), + @import("../webapi/NavigatorUAData.zig"), @import("../webapi/net/FormData.zig"), @import("../webapi/net/Headers.zig"), @import("../webapi/net/Request.zig"), @@ -933,10 +934,11 @@ pub const WorkerJsApis = flattenTypes(&.{ @import("../webapi/streams/WritableStreamDefaultController.zig"), @import("../webapi/encoding/TextEncoderStream.zig"), @import("../webapi/encoding/TextDecoderStream.zig"), - // @import("../webapi/URL.zig"), + @import("../webapi/AbortSignal.zig"), + @import("../webapi/AbortController.zig"), + @import("../webapi/URL.zig"), + @import("../webapi/canvas/OffscreenCanvas.zig"), // @import("../webapi/Performance.zig"), - // @import("../webapi/AbortSignal.zig"), - // @import("../webapi/AbortController.zig"), }); // Master list of ALL JS APIs across all contexts. diff --git a/src/browser/links.zig b/src/browser/links.zig index b3f5f192..158bca61 100644 --- a/src/browser/links.zig +++ b/src/browser/links.zig @@ -31,7 +31,7 @@ pub fn collectLinks(arena: Allocator, root: *Node, frame: *Frame) ![]const []con var links: std.ArrayList([]const u8) = .empty; if (Selector.querySelectorAll(root, "a[href]", frame)) |list| { - defer list.deinit(frame._session); + defer list.deinit(frame._page); for (list._nodes) |node| { if (node.is(Element.Html.Anchor)) |anchor| { diff --git a/src/browser/markdown.zig b/src/browser/markdown.zig index 69ae5e8d..52f792dc 100644 --- a/src/browser/markdown.zig +++ b/src/browser/markdown.zig @@ -474,8 +474,8 @@ pub fn dump(node: *Node, opts: Opts, writer: *std.Io.Writer, frame: *Frame) !voi fn testMarkdownHTML(html: []const u8, expected: []const u8) !void { const testing = @import("../testing.zig"); - const frame = try testing.test_session.createFrame(); - defer testing.test_session.removeFrame(); + const frame = try testing.test_session.createPage(); + defer testing.test_session.removePage(); frame.url = "http://localhost/"; const doc = frame.window._document; @@ -677,8 +677,8 @@ test "browser.markdown: skip empty links" { test "browser.markdown: resolve links" { const testing = @import("../testing.zig"); - const frame = try testing.test_session.createFrame(); - defer testing.test_session.removeFrame(); + const frame = try testing.test_session.createPage(); + defer testing.test_session.removePage(); frame.url = "https://example.com/a/index.html"; const doc = frame.window._document; diff --git a/src/browser/structured_data.zig b/src/browser/structured_data.zig index 9fe1f7b3..8b03add6 100644 --- a/src/browser/structured_data.zig +++ b/src/browser/structured_data.zig @@ -318,8 +318,8 @@ fn collectLink( const testing = @import("../testing.zig"); fn testStructuredData(html: []const u8) !StructuredData { - const frame = try testing.test_session.createFrame(); - defer testing.test_session.removeFrame(); + const frame = try testing.test_session.createPage(); + defer testing.test_session.removePage(); const doc = frame.window._document; const div = try doc.createElement("div", null, frame); diff --git a/src/browser/tests/css/stylesheet.html b/src/browser/tests/css/stylesheet.html index e242f99d..0dba8165 100644 --- a/src/browser/tests/css/stylesheet.html +++ b/src/browser/tests/css/stylesheet.html @@ -498,6 +498,28 @@ } + + + + + + + + + + + + + + + + + + + + + diff --git a/src/browser/tests/document/set_body/complex.html b/src/browser/tests/document/set_body/complex.html new file mode 100644 index 00000000..0f3823ec --- /dev/null +++ b/src/browser/tests/document/set_body/complex.html @@ -0,0 +1,5 @@ + +
original
+ diff --git a/src/browser/tests/document/set_body/empty.html b/src/browser/tests/document/set_body/empty.html new file mode 100644 index 00000000..f946a756 --- /dev/null +++ b/src/browser/tests/document/set_body/empty.html @@ -0,0 +1,5 @@ + +
to be wiped
+ diff --git a/src/browser/tests/document/set_body/frameset.html b/src/browser/tests/document/set_body/frameset.html new file mode 100644 index 00000000..4e694801 --- /dev/null +++ b/src/browser/tests/document/set_body/frameset.html @@ -0,0 +1,7 @@ + +
original
+ diff --git a/src/browser/tests/document/set_body/no_body.html b/src/browser/tests/document/set_body/no_body.html new file mode 100644 index 00000000..a50bddcd --- /dev/null +++ b/src/browser/tests/document/set_body/no_body.html @@ -0,0 +1,9 @@ + + +no_body + + + diff --git a/src/browser/tests/document/set_body/simple.html b/src/browser/tests/document/set_body/simple.html new file mode 100644 index 00000000..7bdc2db4 --- /dev/null +++ b/src/browser/tests/document/set_body/simple.html @@ -0,0 +1,5 @@ + +
original
+ diff --git a/src/browser/tests/navigator/navigator.html b/src/browser/tests/navigator/navigator.html index f6dafde8..92f33bab 100644 --- a/src/browser/tests/navigator/navigator.html +++ b/src/browser/tests/navigator/navigator.html @@ -65,6 +65,49 @@ testing.expectEqual(8, navigator.deviceMemory); + + + + diff --git a/src/browser/tools.zig b/src/browser/tools.zig index 5776cf4b..74acec97 100644 --- a/src/browser/tools.zig +++ b/src/browser/tools.zig @@ -553,9 +553,11 @@ fn execClick(session: *lp.Session, registry: *CDPNode.Registry, arena: std.mem.A // If the click triggered a navigation (e.g. form submission, link click), // wait for it to complete. - if (session.queued_navigation.items.len != 0) { - var runner = session.runner(.{}) catch return ToolError.InternalError; - runner.wait(.{ .ms = 10000, .until = .done }) catch return ToolError.NavigationFailed; + if (session.currentPage()) |page| { + if (page.queued_navigation.items.len != 0) { + var runner = session.runner(.{}) catch return ToolError.InternalError; + runner.wait(.{ .ms = 10000, .until = .done }) catch return ToolError.NavigationFailed; + } } const page = session.currentFrame() orelse return ToolError.FrameNotLoaded; @@ -711,9 +713,11 @@ fn execPress(session: *lp.Session, registry: *CDPNode.Registry, arena: std.mem.A }; // Pressing Enter on a form input triggers implicit form submission. - if (session.queued_navigation.items.len != 0) { - var runner = session.runner(.{}) catch return ToolError.InternalError; - runner.wait(.{ .ms = 10000, .until = .done }) catch return ToolError.NavigationFailed; + if (session.currentPage()) |p| { + if (p.queued_navigation.items.len != 0) { + var runner = session.runner(.{}) catch return ToolError.InternalError; + runner.wait(.{ .ms = 10000, .until = .done }) catch return ToolError.NavigationFailed; + } } const current_page = session.currentFrame() orelse return ToolError.FrameNotLoaded; @@ -882,11 +886,11 @@ fn ensurePage(session: *lp.Session, registry: *CDPNode.Registry, url: ?[:0]const } fn performGoto(session: *lp.Session, registry: *CDPNode.Registry, url: [:0]const u8, timeout: ?u32, waitUntil: ?lp.Config.WaitUntil) ToolError!void { - if (session.frame != null) { + if (session.page != null) { registry.reset(); - session.removeFrame(); + session.removePage(); } - const page = session.createFrame() catch return ToolError.NavigationFailed; + const page = session.createPage() catch return ToolError.NavigationFailed; _ = page.navigate(url, .{ .reason = .address_bar, .kind = .{ .push = null }, diff --git a/src/browser/webapi/AbortController.zig b/src/browser/webapi/AbortController.zig index d4d0d07f..0c751497 100644 --- a/src/browser/webapi/AbortController.zig +++ b/src/browser/webapi/AbortController.zig @@ -19,16 +19,17 @@ const std = @import("std"); const js = @import("../js/js.zig"); -const Frame = @import("../Frame.zig"); const AbortSignal = @import("AbortSignal.zig"); +const Execution = js.Execution; + const AbortController = @This(); _signal: *AbortSignal, -pub fn init(frame: *Frame) !*AbortController { - const signal = try AbortSignal.init(frame); - return frame._factory.create(AbortController{ +pub fn init(exec: *const Execution) !*AbortController { + const signal = try AbortSignal.init(exec); + return exec._factory.create(AbortController{ ._signal = signal, }); } @@ -37,8 +38,8 @@ pub fn getSignal(self: *const AbortController) *AbortSignal { return self._signal; } -pub fn abort(self: *AbortController, reason_: ?js.Value.Global, frame: *Frame) !void { - try self._signal.abort(if (reason_) |r| .{ .js_val = r } else null, frame); +pub fn abort(self: *AbortController, reason_: ?js.Value.Global, exec: *const Execution) !void { + try self._signal.abort(if (reason_) |r| .{ .js_val = r } else null, exec); } pub const JsApi = struct { diff --git a/src/browser/webapi/AbortSignal.zig b/src/browser/webapi/AbortSignal.zig index f4e554fc..a1b5cd00 100644 --- a/src/browser/webapi/AbortSignal.zig +++ b/src/browser/webapi/AbortSignal.zig @@ -20,12 +20,12 @@ const std = @import("std"); const lp = @import("lightpanda"); const js = @import("../js/js.zig"); -const Frame = @import("../Frame.zig"); const Event = @import("Event.zig"); const EventTarget = @import("EventTarget.zig"); const log = lp.log; +const Execution = js.Execution; const AbortSignal = @This(); @@ -34,8 +34,8 @@ _aborted: bool = false, _reason: Reason = .undefined, _on_abort: ?js.Function.Global = null, -pub fn init(frame: *Frame) !*AbortSignal { - return frame._factory.eventTarget(AbortSignal{ +pub fn init(exec: *const Execution) !*AbortSignal { + return exec._factory.eventTarget(AbortSignal{ ._proto = undefined, }); } @@ -60,7 +60,7 @@ pub fn asEventTarget(self: *AbortSignal) *EventTarget { return self._proto; } -pub fn abort(self: *AbortSignal, reason_: ?Reason, frame: *Frame) !void { +pub fn abort(self: *AbortSignal, reason_: ?Reason, exec: *const Execution) !void { if (self._aborted) { return; } @@ -71,36 +71,40 @@ pub fn abort(self: *AbortSignal, reason_: ?Reason, frame: *Frame) !void { if (reason_) |reason| { switch (reason) { .js_val => |js_val| self._reason = .{ .js_val = js_val }, - .string => |str| self._reason = .{ .string = try frame.dupeString(str) }, + .string => |str| self._reason = .{ .string = try exec.dupeString(str) }, .undefined => self._reason = reason, } } else { self._reason = .{ .string = "AbortError" }; } - // Dispatch abort event const target = self.asEventTarget(); - if (frame._event_manager.hasDirectListeners(target, "abort", self._on_abort)) { - const event = try Event.initTrusted(comptime .wrap("abort"), .{}, frame); - try frame._event_manager.dispatchDirect(target, event, self._on_abort, .{ .context = "abort signal" }); + const on_abort = self._on_abort; + switch (exec.context.global) { + inline else => |g| { + if (g._event_manager.hasDirectListeners(target, "abort", on_abort)) { + const event = try Event.initTrusted(comptime .wrap("abort"), .{}, g._page); + try g.dispatch(target, event, on_abort, .{ .context = "abort signal" }); + } + }, } } // Static method to create an already-aborted signal -pub fn createAborted(reason_: ?js.Value.Global, frame: *Frame) !*AbortSignal { - const signal = try init(frame); - try signal.abort(if (reason_) |r| .{ .js_val = r } else null, frame); +pub fn createAborted(reason_: ?js.Value.Global, exec: *const Execution) !*AbortSignal { + const signal = try init(exec); + try signal.abort(if (reason_) |r| .{ .js_val = r } else null, exec); return signal; } -pub fn createTimeout(delay: u32, frame: *Frame) !*AbortSignal { - const callback = try frame.arena.create(TimeoutCallback); +pub fn createTimeout(delay: u32, exec: *const Execution) !*AbortSignal { + const callback = try exec.arena.create(TimeoutCallback); callback.* = .{ - .frame = frame, - .signal = try init(frame), + .exec = exec, + .signal = try init(exec), }; - try frame.js.scheduler.add(callback, TimeoutCallback.run, delay, .{ + try exec._scheduler.add(callback, TimeoutCallback.run, delay, .{ .name = "AbortSignal.timeout", }); @@ -111,8 +115,8 @@ const ThrowIfAborted = union(enum) { exception: js.Exception, undefined: void, }; -pub fn throwIfAborted(self: *const AbortSignal, frame: *Frame) !ThrowIfAborted { - const local = frame.js.local.?; +pub fn throwIfAborted(self: *const AbortSignal, exec: *const Execution) !ThrowIfAborted { + const local = exec.context.local.?; if (self._aborted) { const exception = switch (self._reason) { @@ -132,12 +136,12 @@ const Reason = union(enum) { }; const TimeoutCallback = struct { - frame: *Frame, + exec: *const Execution, signal: *AbortSignal, fn run(ctx: *anyopaque) !?u32 { const self: *TimeoutCallback = @ptrCast(@alignCast(ctx)); - self.signal.abort(.{ .string = "TimeoutError" }, self.frame) catch |err| { + self.signal.abort(.{ .string = "TimeoutError" }, self.exec) catch |err| { log.warn(.app, "abort signal timeout", .{ .err = err }); }; return null; diff --git a/src/browser/webapi/AbstractRange.zig b/src/browser/webapi/AbstractRange.zig index 8e934849..46af8b3f 100644 --- a/src/browser/webapi/AbstractRange.zig +++ b/src/browser/webapi/AbstractRange.zig @@ -20,8 +20,7 @@ const std = @import("std"); const lp = @import("lightpanda"); const js = @import("../js/js.zig"); - -const Session = @import("../Session.zig"); +const Page = @import("../Page.zig"); const Node = @import("Node.zig"); const Range = @import("Range.zig"); @@ -49,15 +48,15 @@ pub fn acquireRef(self: *AbstractRange) void { self._rc.acquire(); } -pub fn deinit(self: *AbstractRange, session: *Session) void { - if (session.findFrameByLoaderId(self._frame_loader_id)) |frame| { +pub fn deinit(self: *AbstractRange, page: *Page) void { + if (page.findFrameByLoaderId(self._frame_loader_id)) |frame| { frame._live_ranges.remove(&self._range_link); } - session.releaseArena(self._arena); + page.releaseArena(self._arena); } -pub fn releaseRef(self: *AbstractRange, session: *Session) void { - self._rc.release(self, session); +pub fn releaseRef(self: *AbstractRange, page: *Page) void { + self._rc.release(self, page); } pub const Type = union(enum) { diff --git a/src/browser/webapi/Blob.zig b/src/browser/webapi/Blob.zig index 5de433ff..2bead21d 100644 --- a/src/browser/webapi/Blob.zig +++ b/src/browser/webapi/Blob.zig @@ -20,7 +20,7 @@ const std = @import("std"); const lp = @import("lightpanda"); const js = @import("../js/js.zig"); -const Session = @import("../Session.zig"); +const Page = @import("../Page.zig"); const Mime = @import("../Mime.zig"); @@ -61,7 +61,8 @@ const InitOptions = struct { /// Creates a new Blob from JS values with optional MIME validation. /// This is the JS Constructor -pub fn init(parts_: ?[]const js.Value, opts_: ?InitOptions, session: *Session) !*Blob { +pub fn init(parts_: ?[]const js.Value, opts_: ?InitOptions, page: *Page) !*Blob { + const session = page.session; const arena = try session.getArena(.large, "Blob"); errdefer session.releaseArena(arena); @@ -94,9 +95,9 @@ pub fn init(parts_: ?[]const js.Value, opts_: ?InitOptions, session: *Session) ! } /// Creates a new Blob from raw byte slices (for internal Zig use). -pub fn initFromBytes(data: []const u8, content_type: []const u8, validate_mime: bool, session: *Session) !*Blob { - const arena = try session.getArena(.large, "Blob"); - errdefer session.releaseArena(arena); +pub fn initFromBytes(data: []const u8, content_type: []const u8, validate_mime: bool, page: *Page) !*Blob { + const arena = try page.getArena(.large, "Blob"); + errdefer page.releaseArena(arena); const mime = try validateMimeType(arena, content_type, validate_mime); @@ -137,12 +138,12 @@ fn validateMimeType(arena: Allocator, mime_type: []const u8, full_validation: bo return buf; } -pub fn deinit(self: *Blob, session: *Session) void { - session.releaseArena(self._arena); +pub fn deinit(self: *Blob, page: *Page) void { + page.releaseArena(self._arena); } -pub fn releaseRef(self: *Blob, session: *Session) void { - self._rc.release(self, session); +pub fn releaseRef(self: *Blob, page: *Page) void { + self._rc.release(self, page); } pub fn acquireRef(self: *Blob) void { @@ -291,7 +292,7 @@ pub fn slice( start_: ?i32, end_: ?i32, content_type_: ?[]const u8, - session: *Session, + page: *Page, ) !*Blob { const data = self._slice; @@ -312,7 +313,7 @@ pub fn slice( break :blk @min(data.len, @max(start, @as(u31, @intCast(requested_end)))); }; - return Blob.initFromBytes(data[start..end], content_type_ orelse "", false, session); + return Blob.initFromBytes(data[start..end], content_type_ orelse "", false, page); } /// Returns the size of the Blob in bytes. diff --git a/src/browser/webapi/Document.zig b/src/browser/webapi/Document.zig index 81db1a80..2fb80e0c 100644 --- a/src/browser/webapi/Document.zig +++ b/src/browser/webapi/Document.zig @@ -353,12 +353,12 @@ pub fn createEvent(_: *const Document, event_type: []const u8, frame: *Frame) !* const normalized = std.ascii.lowerString(&frame.buf, event_type); if (std.mem.eql(u8, normalized, "event") or std.mem.eql(u8, normalized, "events") or std.mem.eql(u8, normalized, "htmlevents")) { - return Event.init("", null, frame); + return Event.init("", null, frame._page); } if (std.mem.eql(u8, normalized, "customevent") or std.mem.eql(u8, normalized, "customevents")) { const CustomEvent = @import("event/CustomEvent.zig"); - return (try CustomEvent.init("", null, frame)).asEvent(); + return (try CustomEvent.init("", null, frame._page)).asEvent(); } if (std.mem.eql(u8, normalized, "keyboardevent")) { @@ -378,7 +378,7 @@ pub fn createEvent(_: *const Document, event_type: []const u8, frame: *Frame) !* if (std.mem.eql(u8, normalized, "messageevent")) { const MessageEvent = @import("event/MessageEvent.zig"); - return (try MessageEvent.init("", null, frame._session)).asEvent(); + return (try MessageEvent.init("", null, frame._page)).asEvent(); } if (std.mem.eql(u8, normalized, "uievent") or std.mem.eql(u8, normalized, "uievents")) { diff --git a/src/browser/webapi/Event.zig b/src/browser/webapi/Event.zig index f8117e39..5692efe6 100644 --- a/src/browser/webapi/Event.zig +++ b/src/browser/webapi/Event.zig @@ -20,8 +20,8 @@ const std = @import("std"); const lp = @import("lightpanda"); const js = @import("../js/js.zig"); +const Page = @import("../Page.zig"); const Frame = @import("../Frame.zig"); -const Session = @import("../Session.zig"); const Node = @import("Node.zig"); const EventTarget = @import("EventTarget.zig"); @@ -89,16 +89,16 @@ pub const Options = struct { composed: bool = false, }; -pub fn init(typ: []const u8, opts_: ?Options, frame: *Frame) !*Event { - const arena = try frame.getArena(.tiny, "Event"); - errdefer frame.releaseArena(arena); +pub fn init(typ: []const u8, opts_: ?Options, page: *Page) !*Event { + const arena = try page.getArena(.tiny, "Event"); + errdefer page.releaseArena(arena); const str = try String.init(arena, typ, .{}); return initWithTrusted(arena, str, opts_, false); } -pub fn initTrusted(typ: String, opts_: ?Options, frame: *Frame) !*Event { - const arena = try frame.getArena(.tiny, "Event.trusted"); - errdefer frame.releaseArena(arena); +pub fn initTrusted(typ: String, opts_: ?Options, page: *Page) !*Event { + const arena = try page.getArena(.tiny, "Event.trusted"); + errdefer page.releaseArena(arena); return initWithTrusted(arena, typ, opts_, true); } @@ -145,13 +145,12 @@ pub fn acquireRef(self: *Event) void { self._rc.acquire(); } -/// Force cleanup on Session shutdown. -pub fn deinit(self: *Event, session: *Session) void { - session.releaseArena(self._arena); +pub fn deinit(self: *Event, page: *Page) void { + page.releaseArena(self._arena); } -pub fn releaseRef(self: *Event, session: *Session) void { - self._rc.release(self, session); +pub fn releaseRef(self: *Event, page: *Page) void { + self._rc.release(self, page); } pub fn as(self: *Event, comptime T: type) *T { diff --git a/src/browser/webapi/EventTarget.zig b/src/browser/webapi/EventTarget.zig index f7effdd6..1e390900 100644 --- a/src/browser/webapi/EventTarget.zig +++ b/src/browser/webapi/EventTarget.zig @@ -19,7 +19,7 @@ const std = @import("std"); const js = @import("../js/js.zig"); -const Session = @import("../Session.zig"); +const Page = @import("../Page.zig"); const EventManager = @import("../EventManager.zig"); const Event = @import("Event.zig"); @@ -52,8 +52,8 @@ pub const Type = union(enum) { websocket: *@import("net/WebSocket.zig"), }; -pub fn init(session: *Session) !*EventTarget { - return session.factory.create(EventTarget{ +pub fn init(page: *Page) !*EventTarget { + return page.factory.create(EventTarget{ ._type = .generic, }); } @@ -67,10 +67,10 @@ pub fn dispatchEvent(self: *EventTarget, event: *Event, exec: *js.Execution) !bo switch (exec.context.global) { .frame => |frame| { event.acquireRef(); - defer _ = event.releaseRef(frame._session); + defer _ = event.releaseRef(frame._page); try frame._event_manager.dispatch(self, event); }, - .worker => |wgs| try wgs.dispatch(self, event, null), + .worker => |wgs| try wgs.dispatch(self, event, null, .{}), } return !event._cancelable or !event._prevent_default; } @@ -101,8 +101,7 @@ pub fn addEventListener(self: *EventTarget, typ: []const u8, callback_: ?EventLi }; switch (exec.context.global) { - .frame => |frame| _ = try frame._event_manager.register(self, typ, em_callback, options), - .worker => |wgs| _ = try wgs._event_manager.register(self, typ, em_callback, options), + inline else => |g| _ = try g._event_manager.register(self, typ, em_callback, options), } } @@ -138,8 +137,7 @@ pub fn removeEventListener(self: *EventTarget, typ: []const u8, callback_: ?Even }; switch (exec.context.global) { - .frame => |frame| frame._event_manager.remove(self, typ, em_callback, use_capture), - .worker => |wgs| wgs._event_manager.remove(self, typ, em_callback, use_capture), + inline else => |g| g._event_manager.remove(self, typ, em_callback, use_capture), } } diff --git a/src/browser/webapi/File.zig b/src/browser/webapi/File.zig index 9f4cfb46..a0dce126 100644 --- a/src/browser/webapi/File.zig +++ b/src/browser/webapi/File.zig @@ -20,7 +20,7 @@ const std = @import("std"); const lp = @import("lightpanda"); const js = @import("../js/js.zig"); -const Session = @import("../Session.zig"); +const Page = @import("../Page.zig"); const Blob = @import("Blob.zig"); @@ -29,10 +29,11 @@ const File = @This(); _proto: *Blob, // TODO: Implement File API. -pub fn init(session: *Session) !*File { +pub fn init(page: *Page) !*File { + const session = page.session; const arena = try session.getArena(.tiny, "File"); errdefer session.releaseArena(arena); - return session.factory.blob(arena, File{ ._proto = undefined }); + return page.factory.blob(arena, File{ ._proto = undefined }); } pub const JsApi = struct { diff --git a/src/browser/webapi/FileReader.zig b/src/browser/webapi/FileReader.zig index fcec74c2..ead58366 100644 --- a/src/browser/webapi/FileReader.zig +++ b/src/browser/webapi/FileReader.zig @@ -21,8 +21,8 @@ const lp = @import("lightpanda"); const js = @import("../js/js.zig"); +const Page = @import("../Page.zig"); const Frame = @import("../Frame.zig"); -const Session = @import("../Session.zig"); const EventTarget = @import("EventTarget.zig"); const ProgressEvent = @import("event/ProgressEvent.zig"); const Blob = @import("Blob.zig"); @@ -73,7 +73,7 @@ pub fn init(frame: *Frame) !*FileReader { }); } -pub fn deinit(self: *FileReader, session: *Session) void { +pub fn deinit(self: *FileReader, page: *Page) void { if (self._on_abort) |func| func.release(); if (self._on_error) |func| func.release(); if (self._on_load) |func| func.release(); @@ -81,11 +81,11 @@ pub fn deinit(self: *FileReader, session: *Session) void { if (self._on_load_start) |func| func.release(); if (self._on_progress) |func| func.release(); - session.releaseArena(self._arena); + page.releaseArena(self._arena); } -pub fn releaseRef(self: *FileReader, session: *Session) void { - self._rc.release(self, session); +pub fn releaseRef(self: *FileReader, page: *Page) void { + self._rc.release(self, page); } pub fn acquireRef(self: *FileReader) void { diff --git a/src/browser/webapi/HTMLDocument.zig b/src/browser/webapi/HTMLDocument.zig index 6487fc2b..e6904006 100644 --- a/src/browser/webapi/HTMLDocument.zig +++ b/src/browser/webapi/HTMLDocument.zig @@ -57,8 +57,30 @@ pub fn getHead(self: *HTMLDocument) ?*Element.Html.Head { } pub fn getBody(self: *HTMLDocument) ?*Element.Html.Body { - const doc_el = self._proto.getDocumentElement() orelse return null; - var child = doc_el.asNode().firstChild(); + const document_element = self._proto.getDocumentElement() orelse return null; + return findBodyForDoc(document_element); +} + +pub fn setBody(self: *HTMLDocument, html: []const u8, frame: *Frame) !void { + const document_element = self._proto.getDocumentElement() orelse return error.HierarchyError; + + // Build a fresh holding the parsed HTML as its children. Fragment + // parsing strips any // wrappers the author included. + const new_body_node = try frame.createElementNS(.html, "body", null); + if (html.len > 0) { + try frame.parseHtmlAsChildren(new_body_node, html); + } + + const document_node = document_element.asNode(); + if (findBodyForDoc(document_element)) |current| { + _ = try document_node.replaceChild(new_body_node, current.asNode(), frame); + } else { + _ = try document_node.appendChild(new_body_node, frame); + } +} + +fn findBodyForDoc(document_element: *Element) ?*Element.Html.Body { + var child = document_element.asNode().firstChild(); while (child) |node| { if (node.is(Element.Html.Body)) |body| { return body; @@ -276,7 +298,7 @@ pub const JsApi = struct { pub const dir = bridge.accessor(HTMLDocument.getDir, HTMLDocument.setDir, .{}); pub const head = bridge.accessor(HTMLDocument.getHead, null, .{}); - pub const body = bridge.accessor(HTMLDocument.getBody, null, .{}); + pub const body = bridge.accessor(HTMLDocument.getBody, HTMLDocument.setBody, .{ .dom_exception = true }); pub const lang = bridge.accessor(HTMLDocument.getLang, HTMLDocument.setLang, .{}); pub const title = bridge.accessor(HTMLDocument.getTitle, HTMLDocument.setTitle, .{}); pub const images = bridge.accessor(HTMLDocument.getImages, null, .{}); diff --git a/src/browser/webapi/IntersectionObserver.zig b/src/browser/webapi/IntersectionObserver.zig index 2a3f3d56..64e4e594 100644 --- a/src/browser/webapi/IntersectionObserver.zig +++ b/src/browser/webapi/IntersectionObserver.zig @@ -20,8 +20,8 @@ const lp = @import("lightpanda"); const js = @import("../js/js.zig"); +const Page = @import("../Page.zig"); const Frame = @import("../Frame.zig"); -const Session = @import("../Session.zig"); const Node = @import("Node.zig"); const Element = @import("Element.zig"); @@ -110,18 +110,18 @@ pub fn init(callback: js.Function.Temp, options: ?ObserverInit, frame: *Frame) ! return self; } -pub fn deinit(self: *IntersectionObserver, session: *Session) void { +pub fn deinit(self: *IntersectionObserver, page: *Page) void { self._callback.release(); for (self._pending_entries.items) |entry| { // These were never handed to v8, they do not have a corresponding // FinalizerCallback. We 100% own them. - entry.deinit(session); + entry.deinit(page); } - session.releaseArena(self._arena); + page.releaseArena(self._arena); } -pub fn releaseRef(self: *IntersectionObserver, session: *Session) void { - self._rc.release(self, session); +pub fn releaseRef(self: *IntersectionObserver, page: *Page) void { + self._rc.release(self, page); } pub fn acquireRef(self: *IntersectionObserver) void { @@ -164,7 +164,7 @@ pub fn unobserve(self: *IntersectionObserver, target: *Element, frame: *Frame) v while (j < self._pending_entries.items.len) { if (self._pending_entries.items[j]._target == target) { const entry = self._pending_entries.swapRemove(j); - entry.deinit(frame._session); + entry.deinit(frame._page); } else { j += 1; } @@ -180,7 +180,7 @@ pub fn unobserve(self: *IntersectionObserver, target: *Element, frame: *Frame) v pub fn disconnect(self: *IntersectionObserver, frame: *Frame) void { for (self._pending_entries.items) |entry| { - entry.deinit(frame._session); + entry.deinit(frame._page); } self._pending_entries.clearRetainingCapacity(); self._previous_states.clearRetainingCapacity(); @@ -330,12 +330,12 @@ pub const IntersectionObserverEntry = struct { _intersection_ratio: f64, _is_intersecting: bool, - pub fn deinit(self: *IntersectionObserverEntry, session: *Session) void { - session.releaseArena(self._arena); + pub fn deinit(self: *IntersectionObserverEntry, page: *Page) void { + page.releaseArena(self._arena); } - pub fn releaseRef(self: *IntersectionObserverEntry, session: *Session) void { - self._rc.release(self, session); + pub fn releaseRef(self: *IntersectionObserverEntry, page: *Page) void { + self._rc.release(self, page); } pub fn acquireRef(self: *IntersectionObserverEntry) void { diff --git a/src/browser/webapi/MessagePort.zig b/src/browser/webapi/MessagePort.zig index 58b48674..42831378 100644 --- a/src/browser/webapi/MessagePort.zig +++ b/src/browser/webapi/MessagePort.zig @@ -131,7 +131,7 @@ const PostMessageCallback = struct { .data = .{ .value = self.message }, .origin = "", .source = null, - }, frame._session) catch |err| { + }, frame._page) catch |err| { log.err(.dom, "MessagePort.postMessage", .{ .err = err }); return null; }).asEvent(); diff --git a/src/browser/webapi/MutationObserver.zig b/src/browser/webapi/MutationObserver.zig index a60e25a1..2e538207 100644 --- a/src/browser/webapi/MutationObserver.zig +++ b/src/browser/webapi/MutationObserver.zig @@ -20,8 +20,8 @@ const std = @import("std"); const lp = @import("lightpanda"); const js = @import("../js/js.zig"); +const Page = @import("../Page.zig"); const Frame = @import("../Frame.zig"); -const Session = @import("../Session.zig"); const Node = @import("Node.zig"); const Element = @import("Element.zig"); @@ -86,18 +86,18 @@ pub fn init(callback: js.Function.Temp, frame: *Frame) !*MutationObserver { return self; } -pub fn deinit(self: *MutationObserver, session: *Session) void { +pub fn deinit(self: *MutationObserver, page: *Page) void { for (self._pending_records.items) |record| { // These were never handed to v8, they do not have a corresponding // FinalizerCallback. We 100% own them. - record.deinit(session); + record.deinit(page); } self._callback.release(); - session.releaseArena(self._arena); + page.releaseArena(self._arena); } -pub fn releaseRef(self: *MutationObserver, session: *Session) void { - self._rc.release(self, session); +pub fn releaseRef(self: *MutationObserver, page: *Page) void { + self._rc.release(self, page); } pub fn acquireRef(self: *MutationObserver) void { @@ -178,7 +178,7 @@ pub fn observe(self: *MutationObserver, target: *Node, options: ObserveOptions, pub fn disconnect(self: *MutationObserver, frame: *Frame) void { for (self._pending_records.items) |record| { - record.deinit(frame._session); + record.deinit(frame._page); } self._pending_records.clearRetainingCapacity(); @@ -375,11 +375,11 @@ pub const MutationRecord = struct { characterData, }; - pub fn deinit(self: *MutationRecord, session: *Session) void { + pub fn deinit(self: *MutationRecord, session: *Page) void { session.releaseArena(self._arena); } - pub fn releaseRef(self: *MutationRecord, session: *Session) void { + pub fn releaseRef(self: *MutationRecord, session: *Page) void { self._rc.release(self, session); } diff --git a/src/browser/webapi/Navigator.zig b/src/browser/webapi/Navigator.zig index bd481f51..89a919d1 100644 --- a/src/browser/webapi/Navigator.zig +++ b/src/browser/webapi/Navigator.zig @@ -26,6 +26,7 @@ const Frame = @import("../Frame.zig"); const PluginArray = @import("PluginArray.zig"); const Permissions = @import("Permissions.zig"); const StorageManager = @import("StorageManager.zig"); +const NavigatorUAData = @import("NavigatorUAData.zig"); const log = lp.log; @@ -34,6 +35,7 @@ _pad: bool = false, _plugins: PluginArray = .{}, _permissions: Permissions = .{}, _storage: StorageManager = .{}, +_ua_data: NavigatorUAData = .{}, pub const init: Navigator = .{}; @@ -72,6 +74,10 @@ pub fn getStorage(self: *Navigator) *StorageManager { return &self._storage; } +pub fn getUserAgentData(self: *Navigator) *NavigatorUAData { + return &self._ua_data; +} + pub fn getBattery(_: *const Navigator, frame: *Frame) !js.Promise { log.info(.not_implemented, "navigator.getBattery", .{}); return frame.js.local.?.rejectErrorPromise(.{ .dom_exception = .{ .err = error.NotSupported } }); @@ -182,6 +188,7 @@ pub const JsApi = struct { pub const getBattery = bridge.function(Navigator.getBattery, .{}); pub const permissions = bridge.accessor(Navigator.getPermissions, null, .{}); pub const storage = bridge.accessor(Navigator.getStorage, null, .{}); + pub const userAgentData = bridge.accessor(Navigator.getUserAgentData, null, .{}); }; const testing = @import("../../testing.zig"); diff --git a/src/browser/webapi/NavigatorUAData.zig b/src/browser/webapi/NavigatorUAData.zig new file mode 100644 index 00000000..765564ff --- /dev/null +++ b/src/browser/webapi/NavigatorUAData.zig @@ -0,0 +1,134 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// 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 . + +const std = @import("std"); +const builtin = @import("builtin"); + +const Config = @import("../../Config.zig"); +const js = @import("../js/js.zig"); +const Frame = @import("../Frame.zig"); + +const NavigatorUAData = @This(); + +_pad: bool = false, + +const Brand = struct { + brand: []const u8, + version: []const u8, +}; + +pub fn getBrands(_: *const NavigatorUAData) []const Brand { + return brandList(); +} + +pub fn getMobile(_: *const NavigatorUAData) bool { + return false; +} + +pub fn getPlatform(_: *const NavigatorUAData) []const u8 { + return uaPlatform(); +} + +pub fn toJSON(_: *const NavigatorUAData) struct { + brands: []const Brand, + mobile: bool, + platform: []const u8, +} { + return .{ + .mobile = false, + .brands = brandList(), + .platform = uaPlatform(), + }; +} + +pub fn getHighEntropyValues(_: *const NavigatorUAData, hints: []const []const u8, frame: *Frame) !js.Promise { + // This should always return `brands` + `mobile` + `platform` and then whatever + // "hints" field is requested (assuming the browser has permission), but it's + // also valid to just return everything. + + _ = hints; + + return frame.js.local.?.resolvePromise(.{ + .brands = brandList(), + .mobile = false, + .platform = uaPlatform(), + .architecture = uaArchitecture(), + .bitness = uaBitness(), + .model = "", + .platformVersion = "", + .uaFullVersion = "1.0.0.0", + .fullVersionList = brandList(), + .wow64 = false, + .formFactor = [_][]const u8{"Desktop"}, + }); +} + +fn brandList() []const Brand { + const out = comptime blk: { + const src = &Config.HttpHeaders.brands; + var arr: [src.len]Brand = undefined; + for (src, 0..) |b, i| { + arr[i] = .{ .brand = b.brand, .version = b.version }; + } + const final = arr; + break :blk final; + }; + return &out; +} + +fn uaPlatform() []const u8 { + return switch (builtin.os.tag) { + .macos => "macOS", + .windows => "Windows", + .linux => "Linux", + .freebsd => "FreeBSD", + else => "Unknown", + }; +} + +fn uaArchitecture() []const u8 { + return switch (builtin.cpu.arch) { + .x86, .x86_64 => "x86", + .aarch64, .aarch64_be, .arm, .armeb => "arm", + else => "", + }; +} + +fn uaBitness() []const u8 { + return switch (builtin.cpu.arch) { + .x86_64, .aarch64, .aarch64_be, .powerpc64, .powerpc64le, .riscv64 => "64", + else => "32", + }; +} + +pub const JsApi = struct { + pub const bridge = js.Bridge(NavigatorUAData); + + pub const Meta = struct { + pub const name = "NavigatorUAData"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + pub const empty_with_no_proto = true; + }; + + pub const brands = bridge.accessor(NavigatorUAData.getBrands, null, .{}); + pub const mobile = bridge.accessor(NavigatorUAData.getMobile, null, .{}); + pub const platform = bridge.accessor(NavigatorUAData.getPlatform, null, .{}); + pub const toJSON = bridge.function(NavigatorUAData.toJSON, .{}); + pub const getHighEntropyValues = bridge.function(NavigatorUAData.getHighEntropyValues, .{}); +}; diff --git a/src/browser/webapi/Permissions.zig b/src/browser/webapi/Permissions.zig index e530a8a6..3485e11a 100644 --- a/src/browser/webapi/Permissions.zig +++ b/src/browser/webapi/Permissions.zig @@ -18,9 +18,10 @@ const std = @import("std"); const lp = @import("lightpanda"); + const js = @import("../js/js.zig"); +const Page = @import("../Page.zig"); const Frame = @import("../Frame.zig"); -const Session = @import("../Session.zig"); const Allocator = std.mem.Allocator; @@ -56,12 +57,12 @@ const PermissionStatus = struct { _name: []const u8, _state: []const u8, - pub fn deinit(self: *PermissionStatus, session: *Session) void { - session.releaseArena(self._arena); + pub fn deinit(self: *PermissionStatus, page: *Page) void { + page.releaseArena(self._arena); } - pub fn releaseRef(self: *PermissionStatus, session: *Session) void { - self._rc.release(self, session); + pub fn releaseRef(self: *PermissionStatus, page: *Page) void { + self._rc.release(self, page); } pub fn acquireRef(self: *PermissionStatus) void { diff --git a/src/browser/webapi/Selection.zig b/src/browser/webapi/Selection.zig index f6fa694b..a9724152 100644 --- a/src/browser/webapi/Selection.zig +++ b/src/browser/webapi/Selection.zig @@ -20,8 +20,8 @@ const std = @import("std"); const lp = @import("lightpanda"); const js = @import("../js/js.zig"); +const Page = @import("../Page.zig"); const Frame = @import("../Frame.zig"); -const Session = @import("../Session.zig"); const Range = @import("Range.zig"); const AbstractRange = @import("AbstractRange.zig"); @@ -39,15 +39,15 @@ _direction: SelectionDirection = .none, pub const init: Selection = .{}; -pub fn deinit(self: *Selection, session: *Session) void { +pub fn deinit(self: *Selection, page: *Page) void { if (self._range) |r| { - r.asAbstractRange().releaseRef(session); + r.asAbstractRange().releaseRef(page); self._range = null; } } -pub fn releaseRef(self: *Selection, session: *Session) void { - self._rc.release(self, session); +pub fn releaseRef(self: *Selection, page: *Page) void { + self._rc.release(self, page); } pub fn acquireRef(self: *Selection) void { @@ -55,7 +55,7 @@ pub fn acquireRef(self: *Selection) void { } fn dispatchSelectionChangeEvent(frame: *Frame) !void { - const event = try Event.init("selectionchange", .{}, frame); + const event = try Event.init("selectionchange", .{}, frame._page); try frame._event_manager.dispatch(frame.document.asEventTarget(), event); } @@ -703,7 +703,7 @@ pub fn toString(self: *const Selection, frame: *Frame) ![]const u8 { fn setRange(self: *Selection, new_range: ?*Range, frame: *Frame) void { if (self._range) |existing| { - _ = existing.asAbstractRange().releaseRef(frame._session); + _ = existing.asAbstractRange().releaseRef(frame._page); } if (new_range) |nr| { nr.asAbstractRange().acquireRef(); diff --git a/src/browser/webapi/TreeWalker.zig b/src/browser/webapi/TreeWalker.zig index 77946c9e..f21c304b 100644 --- a/src/browser/webapi/TreeWalker.zig +++ b/src/browser/webapi/TreeWalker.zig @@ -160,8 +160,8 @@ pub fn TreeWalker(comptime mode: Mode) type { test "TreeWalker: skipChildren" { const testing = @import("../../testing.zig"); - const frame = try testing.test_session.createFrame(); - defer testing.test_session.removeFrame(); + const frame = try testing.test_session.createPage(); + defer testing.test_session.removePage(); const doc = frame.window._document; //
diff --git a/src/browser/webapi/URL.zig b/src/browser/webapi/URL.zig index bb1f712e..ba8a6083 100644 --- a/src/browser/webapi/URL.zig +++ b/src/browser/webapi/URL.zig @@ -20,7 +20,6 @@ const std = @import("std"); const js = @import("../js/js.zig"); const U = @import("../URL.zig"); -const Frame = @import("../Frame.zig"); const URLSearchParams = @import("net/URLSearchParams.zig"); const Blob = @import("Blob.zig"); const Execution = js.Execution; @@ -248,28 +247,36 @@ pub fn canParse(url: []const u8, base_: ?[]const u8) bool { return U.isCompleteHTTPUrl(url); } -pub fn createObjectURL(blob: *Blob, frame: *Frame) ![]const u8 { +pub fn createObjectURL(blob: *Blob, exec: *const Execution) ![]const u8 { var uuid_buf: [36]u8 = undefined; @import("../../id.zig").uuidv4(&uuid_buf); - const blob_url = try std.fmt.allocPrint( - frame.arena, - "blob:{s}/{s}", - .{ frame.origin orelse "null", uuid_buf }, - ); - try frame._blob_urls.put(frame.arena, blob_url, blob); - blob.acquireRef(); - return blob_url; + switch (exec.context.global) { + inline else => |g| { + const blob_url = try std.fmt.allocPrint( + g.arena, + "blob:{s}/{s}", + .{ g.origin orelse "null", uuid_buf }, + ); + try g._blob_urls.put(g.arena, blob_url, blob); + blob.acquireRef(); + return blob_url; + }, + } } -pub fn revokeObjectURL(url: []const u8, frame: *Frame) void { +pub fn revokeObjectURL(url: []const u8, exec: *const Execution) void { // Per spec: silently ignore non-blob URLs if (!std.mem.startsWith(u8, url, "blob:")) { return; } - if (frame._blob_urls.fetchRemove(url)) |entry| { - entry.value.releaseRef(frame._session); + switch (exec.context.global) { + inline else => |g| { + if (g._blob_urls.fetchRemove(url)) |entry| { + entry.value.releaseRef(g._page); + } + }, } } diff --git a/src/browser/webapi/Window.zig b/src/browser/webapi/Window.zig index ea53a242..83a90df9 100644 --- a/src/browser/webapi/Window.zig +++ b/src/browser/webapi/Window.zig @@ -362,7 +362,7 @@ pub fn reportError(self: *Window, err: js.Value, frame: *Frame) !void { .message = err.toStringSlice() catch "Unknown error", .bubbles = false, .cancelable = true, - }, frame._session); + }, frame._page); // Invoke window.onerror callback if set (per WHATWG spec, this is called // with 5 arguments: message, source, lineno, colno, error) @@ -530,17 +530,16 @@ pub fn scrollTo(self: *Window, opts: ScrollToOpts, y: ?i32, frame: *Frame) !void frame, struct { fn dispatch(_frame: *anyopaque) anyerror!?u32 { - const p: *Frame = @ptrCast(@alignCast(_frame)); - const pos = &p.window._scroll_pos; + const f: *Frame = @ptrCast(@alignCast(_frame)); + const pos = &f.window._scroll_pos; // If the state isn't scroll, we can ignore safely to throttle // the events. if (pos.state != .scroll) { return null; } - const event = try Event.initTrusted(comptime .wrap("scroll"), .{ .bubbles = true }, p); - try p._event_manager.dispatch(p.document.asEventTarget(), event); - + const event = try Event.initTrusted(comptime .wrap("scroll"), .{ .bubbles = true }, f._page); + try f._event_manager.dispatch(f.document.asEventTarget(), event); pos.state = .end; return null; @@ -554,8 +553,8 @@ pub fn scrollTo(self: *Window, opts: ScrollToOpts, y: ?i32, frame: *Frame) !void frame, struct { fn dispatch(_frame: *anyopaque) anyerror!?u32 { - const p: *Frame = @ptrCast(@alignCast(_frame)); - const pos = &p.window._scroll_pos; + const f: *Frame = @ptrCast(@alignCast(_frame)); + const pos = &f.window._scroll_pos; // Dispatch only if the state is .end. // If a scroll is pending, retry in 10ms. // If the state is .end, the event has been dispatched, so @@ -565,9 +564,8 @@ pub fn scrollTo(self: *Window, opts: ScrollToOpts, y: ?i32, frame: *Frame) !void .end => {}, .done => return null, } - const event = try Event.initTrusted(comptime .wrap("scrollend"), .{ .bubbles = true }, p); - try p._event_manager.dispatch(p.document.asEventTarget(), event); - + const event = try Event.initTrusted(comptime .wrap("scrollend"), .{ .bubbles = true }, f._page); + try f._event_manager.dispatch(f.document.asEventTarget(), event); pos.state = .done; return null; @@ -622,7 +620,7 @@ pub fn unhandledPromiseRejection(self: *Window, no_handler: bool, rejection: js. const event = (try @import("event/PromiseRejectionEvent.zig").init(event_name, .{ .reason = if (rejection.reason()) |r| try r.temp() else null, .promise = try rejection.promise().temp(), - }, frame._session)).asEvent(); + }, frame._page)).asEvent(); try frame._event_manager.dispatchDirect(target, event, attribute_callback, .{ .context = "window.unhandledrejection" }); } } @@ -811,7 +809,7 @@ const PostMessageCallback = struct { .source = self.source, .bubbles = false, .cancelable = false, - }, frame._session)).asEvent(); + }, frame._page)).asEvent(); try frame._event_manager.dispatchDirect(event_target, event, window._on_message, .{ .context = "window.postMessage" }); } diff --git a/src/browser/webapi/Worker.zig b/src/browser/webapi/Worker.zig index 3d0bacaf..86eb985f 100644 --- a/src/browser/webapi/Worker.zig +++ b/src/browser/webapi/Worker.zig @@ -24,7 +24,6 @@ const http = @import("../../network/http.zig"); const URL = @import("../URL.zig"); const Frame = @import("../Frame.zig"); -const Session = @import("../Session.zig"); const HttpClient = @import("../HttpClient.zig"); const Blob = @import("Blob.zig"); @@ -71,7 +70,7 @@ pub fn init(url: []const u8, exec: *Execution) !*Worker { errdefer session.releaseArena(arena); const resolved_url = try URL.resolve(arena, exec.url.*, url, .{}); - const self = try session.factory.eventTargetWithAllocator(arena, Worker{ + const self = try frame._page.factory.eventTargetWithAllocator(arena, Worker{ ._arena = arena, ._proto = undefined, ._frame = frame, @@ -216,7 +215,6 @@ fn fireErrorEvent(self: *Worker, message: []const u8, error_value: ?js.Value.Tem fn _fireErrorEvent(self: *Worker, message: []const u8, error_value: ?js.Value.Temp) !void { const frame = self._frame; - const session = frame._session; const target = self.asEventTarget(); const on_error = self._on_error; @@ -232,7 +230,7 @@ fn _fireErrorEvent(self: *Worker, message: []const u8, error_value: ?js.Value.Te .filename = self._url, .bubbles = false, .cancelable = true, - }, session); + }, frame._page); try frame._event_manager.dispatchDirect(target, error_event.asEvent(), on_error, .{ .context = "Worker.onerror", @@ -354,7 +352,7 @@ const ReceiveMessageCallback = struct { .data = .{ .string = @errorName(err) }, .bubbles = false, .cancelable = false, - }, frame._session)).asEvent(); + }, frame._page)).asEvent(); try frame._event_manager.dispatchDirect(target, event, on_messageerror, .{ .context = "Worker.messageerror" }); return null; }; @@ -371,7 +369,7 @@ const ReceiveMessageCallback = struct { .data = .{ .value = data }, .bubbles = false, .cancelable = false, - }, frame._session)).asEvent(); + }, frame._page)).asEvent(); try frame._event_manager.dispatchDirect(target, event, on_message, .{ .context = "Worker.receiveMessage" }); diff --git a/src/browser/webapi/WorkerGlobalScope.zig b/src/browser/webapi/WorkerGlobalScope.zig index 9aa0f3a3..42280fe7 100644 --- a/src/browser/webapi/WorkerGlobalScope.zig +++ b/src/browser/webapi/WorkerGlobalScope.zig @@ -24,10 +24,12 @@ const std = @import("std"); const lp = @import("lightpanda"); const JS = @import("../js/js.zig"); +const Page = @import("../Page.zig"); const Factory = @import("../Factory.zig"); const Session = @import("../Session.zig"); const EventManagerBase = @import("../EventManagerBase.zig"); +const Blob = @import("Blob.zig"); const Worker = @import("Worker.zig"); const Crypto = @import("Crypto.zig"); const Console = @import("Console.zig"); @@ -47,16 +49,22 @@ const WorkerGlobalScope = @This(); // can access these the same for a Page of a WGS. // These fields represent the "Page"-like component of the WGS _session: *Session, +_page: *Page, _factory: *Factory, _identity: JS.Identity = .{}, arena: Allocator, call_arena: Allocator, url: [:0]const u8, +// Same-origin constraint: a worker's origin is inherited from its parent frame. +origin: ?[]const u8 = null, buf: [1024]u8 = undefined, // same size as frame.buf // Document charset (matches Page.charset). Workers default to UTF-8. charset: []const u8 = "UTF-8", js: *JS.Context, +// Blob URL registry for URL.createObjectURL/revokeObjectURL. +_blob_urls: std.StringHashMapUnmanaged(*Blob) = .{}, + // Reference back to the Worker object (for postMessage to frame) _worker: *Worker, @@ -76,18 +84,21 @@ _on_messageerror: ?JS.Function.Global = null, pub fn init(worker: *Worker, url: [:0]const u8) !*WorkerGlobalScope { const arena = worker._arena; + const parent = worker._frame; const session = worker._frame._session; - const factory = &session.factory; const call_arena = try session.getArena(.small, "WorkerGlobalScope.call_arena"); errdefer session.releaseArena(call_arena); + const factory = parent._factory; const self = try factory.eventTargetWithAllocator(arena, WorkerGlobalScope{ .url = url, .arena = arena, + .origin = parent.origin, .js = undefined, .call_arena = call_arena, ._session = session, + ._page = parent._page, ._identity = .{}, ._proto = undefined, ._factory = factory, @@ -107,9 +118,13 @@ pub fn init(worker: *Worker, url: [:0]const u8) !*WorkerGlobalScope { pub fn deinit(self: *WorkerGlobalScope) void { self._identity.deinit(); - const session = self._session; - session.browser.env.destroyContext(self.js); - session.releaseArena(self.call_arena); + const page = self._page; + var it = self._blob_urls.valueIterator(); + while (it.next()) |blob| { + blob.*.releaseRef(page); + } + page.session.browser.env.destroyContext(self.js); + page.releaseArena(self.call_arena); } pub fn base(self: *const WorkerGlobalScope) [:0]const u8 { @@ -123,15 +138,21 @@ pub fn asEventTarget(self: *WorkerGlobalScope) *EventTarget { const Event = @import("Event.zig"); // Dispatch an event to listeners on the given target within this worker context. -pub fn dispatch(self: *WorkerGlobalScope, target: *EventTarget, event: *Event, handler: anytype) !void { +pub fn dispatch( + self: *WorkerGlobalScope, + target: *EventTarget, + event: *Event, + handler: anytype, + comptime opts: EventManagerBase.DispatchDirectOptions, +) !void { try self._event_manager.dispatchDirect( self.call_arena, self.js, target, event, handler, - self._session, - .{}, + self._page, + opts, ); } @@ -264,8 +285,8 @@ pub fn unhandledPromiseRejection(self: *WorkerGlobalScope, no_handler: bool, rej const event = (try @import("event/PromiseRejectionEvent.zig").init(event_name, .{ .reason = if (rejection.reason()) |r| try r.temp() else null, .promise = try rejection.promise().temp(), - }, self._session)).asEvent(); - try self.dispatch(target, event, attribute_callback); + }, self._page)).asEvent(); + try self.dispatch(target, event, attribute_callback, .{}); } } @@ -281,7 +302,7 @@ pub fn reportError(self: *WorkerGlobalScope, err: JS.Value) !void { .message = err.toStringSlice() catch "Unknown error", .bubbles = false, .cancelable = true, - }, self._session); + }, self._page); // Invoke onerror callback if set (per WHATWG spec, this is called // with 5 arguments: message, source, lineno, colno, error) @@ -311,7 +332,7 @@ pub fn reportError(self: *WorkerGlobalScope, err: JS.Value) !void { event._prevent_default = prevent_default; // Pass null as handler: onerror was already called above with 5 args. // We still dispatch so that addEventListener('error', ...) listeners fire. - try self.dispatch(self.asEventTarget(), event, null); + try self.dispatch(self.asEventTarget(), event, null, .{}); if (comptime builtin.is_test == false) { if (!event._prevent_default) { @@ -374,8 +395,8 @@ const ReceiveMessageCallback = struct { const event = (try MessageEvent.initTrusted(comptime .wrap("messageerror"), .{ .bubbles = false, .cancelable = false, - }, worker_scope._session)).asEvent(); - try worker_scope.dispatch(target, event, on_messageerror); + }, worker_scope._page)).asEvent(); + try worker_scope.dispatch(target, event, on_messageerror, .{}); return null; } @@ -391,8 +412,8 @@ const ReceiveMessageCallback = struct { .data = .{ .value = self.data.? }, .bubbles = false, .cancelable = false, - }, worker_scope._session)).asEvent(); - try worker_scope.dispatch(target, event, on_message); + }, worker_scope._page)).asEvent(); + try worker_scope.dispatch(target, event, on_message, .{}); return null; } }; diff --git a/src/browser/webapi/animation/Animation.zig b/src/browser/webapi/animation/Animation.zig index 02cf8236..91ddff41 100644 --- a/src/browser/webapi/animation/Animation.zig +++ b/src/browser/webapi/animation/Animation.zig @@ -19,8 +19,8 @@ const std = @import("std"); const lp = @import("lightpanda"); const js = @import("../../js/js.zig"); +const Page = @import("../../Page.zig"); const Frame = @import("../../Frame.zig"); -const Session = @import("../../Session.zig"); const log = lp.log; const Allocator = std.mem.Allocator; @@ -64,12 +64,12 @@ pub fn init(frame: *Frame) !*Animation { return self; } -pub fn deinit(self: *Animation, session: *Session) void { - session.releaseArena(self._arena); +pub fn deinit(self: *Animation, page: *Page) void { + page.releaseArena(self._arena); } -pub fn releaseRef(self: *Animation, session: *Session) void { - self._rc.release(self, session); +pub fn releaseRef(self: *Animation, page: *Page) void { + self._rc.release(self, page); } pub fn acquireRef(self: *Animation) void { @@ -211,7 +211,7 @@ fn update(ctx: *anyopaque) !?u32 { } // No future change scheduled, set the object weak for garbage collection. - self.releaseRef(self._frame._session); + self.releaseRef(self._frame._page); return null; } diff --git a/src/browser/webapi/canvas/OffscreenCanvas.zig b/src/browser/webapi/canvas/OffscreenCanvas.zig index e43de01c..96363756 100644 --- a/src/browser/webapi/canvas/OffscreenCanvas.zig +++ b/src/browser/webapi/canvas/OffscreenCanvas.zig @@ -18,11 +18,12 @@ const std = @import("std"); const js = @import("../../js/js.zig"); -const Frame = @import("../../Frame.zig"); const Blob = @import("../Blob.zig"); const OffscreenCanvasRenderingContext2D = @import("OffscreenCanvasRenderingContext2D.zig"); +const Execution = js.Execution; + /// https://developer.mozilla.org/en-US/docs/Web/API/OffscreenCanvas const OffscreenCanvas = @This(); @@ -37,8 +38,8 @@ const DrawingContext = union(enum) { @"2d": *OffscreenCanvasRenderingContext2D, }; -pub fn constructor(width: u32, height: u32, frame: *Frame) !*OffscreenCanvas { - return frame._factory.create(OffscreenCanvas{ +pub fn constructor(width: u32, height: u32, exec: *Execution) !*OffscreenCanvas { + return exec._factory.create(OffscreenCanvas{ ._width = width, ._height = height, }); @@ -60,9 +61,9 @@ pub fn setHeight(self: *OffscreenCanvas, value: u32) void { self._height = value; } -pub fn getContext(_: *OffscreenCanvas, context_type: []const u8, frame: *Frame) !?DrawingContext { +pub fn getContext(_: *OffscreenCanvas, context_type: []const u8, exec: *Execution) !?DrawingContext { if (std.mem.eql(u8, context_type, "2d")) { - const ctx = try frame._factory.create(OffscreenCanvasRenderingContext2D{}); + const ctx = try exec._factory.create(OffscreenCanvasRenderingContext2D{}); return .{ .@"2d" = ctx }; } @@ -71,9 +72,9 @@ pub fn getContext(_: *OffscreenCanvas, context_type: []const u8, frame: *Frame) /// Returns a Promise that resolves to a Blob containing the image. /// Since we have no actual rendering, this returns an empty blob. -pub fn convertToBlob(_: *OffscreenCanvas, frame: *Frame) !js.Promise { - const blob = try Blob.init(null, null, frame._session); - return frame.js.local.?.resolvePromise(blob); +pub fn convertToBlob(_: *OffscreenCanvas, exec: *Execution) !js.Promise { + const blob = try Blob.init(null, null, exec.context.page); + return exec.context.local.?.resolvePromise(blob); } /// Returns an ImageBitmap with the rendered content (stub). diff --git a/src/browser/webapi/collections/ChildNodes.zig b/src/browser/webapi/collections/ChildNodes.zig index ecce1baf..0bda6560 100644 --- a/src/browser/webapi/collections/ChildNodes.zig +++ b/src/browser/webapi/collections/ChildNodes.zig @@ -19,9 +19,10 @@ const std = @import("std"); const js = @import("../../js/js.zig"); -const Node = @import("../Node.zig"); +const Page = @import("../../Page.zig"); const Frame = @import("../../Frame.zig"); -const Session = @import("../../Session.zig"); + +const Node = @import("../Node.zig"); const GenericIterator = @import("iterator.zig").Entry; // Optimized for node.childNodes, which has to be a live list. @@ -55,8 +56,8 @@ pub fn init(node: *Node, frame: *Frame) !*ChildNodes { return self; } -pub fn deinit(self: *const ChildNodes, session: *Session) void { - session.releaseArena(self._arena); +pub fn deinit(self: *const ChildNodes, page: *Page) void { + page.releaseArena(self._arena); } pub fn length(self: *ChildNodes, frame: *Frame) !u32 { diff --git a/src/browser/webapi/collections/NodeList.zig b/src/browser/webapi/collections/NodeList.zig index 440b3c79..38d0525f 100644 --- a/src/browser/webapi/collections/NodeList.zig +++ b/src/browser/webapi/collections/NodeList.zig @@ -20,8 +20,9 @@ const std = @import("std"); const lp = @import("lightpanda"); const js = @import("../../js/js.zig"); +const Page = @import("../../Page.zig"); const Frame = @import("../../Frame.zig"); -const Session = @import("../../Session.zig"); + const Node = @import("../Node.zig"); const ChildNodes = @import("ChildNodes.zig"); @@ -41,16 +42,16 @@ _data: union(enum) { }, _rc: lp.RC(u32) = .{}, -pub fn deinit(self: *NodeList, session: *Session) void { +pub fn deinit(self: *NodeList, page: *Page) void { switch (self._data) { - .child_nodes => |cn| cn.deinit(session), - .selector_list => |list| list.deinit(session), + .child_nodes => |cn| cn.deinit(page), + .selector_list => |list| list.deinit(page), else => {}, } } -pub fn releaseRef(self: *NodeList, session: *Session) void { - self._rc.release(self, session); +pub fn releaseRef(self: *NodeList, page: *Page) void { + self._rc.release(self, page); } pub fn acquireRef(self: *NodeList) void { @@ -115,12 +116,12 @@ const Iterator = struct { const Entry = struct { u32, *Node }; - pub fn deinit(self: *Iterator, session: *Session) void { - self.list.deinit(session); + pub fn deinit(self: *Iterator, page: *Page) void { + self.list.deinit(page); } - pub fn releaseRef(self: *Iterator, session: *Session) void { - self.list.releaseRef(session); + pub fn releaseRef(self: *Iterator, page: *Page) void { + self.list.releaseRef(page); } pub fn acquireRef(self: *Iterator) void { diff --git a/src/browser/webapi/collections/iterator.zig b/src/browser/webapi/collections/iterator.zig index 24d1d52e..ab45d708 100644 --- a/src/browser/webapi/collections/iterator.zig +++ b/src/browser/webapi/collections/iterator.zig @@ -18,9 +18,11 @@ const std = @import("std"); const lp = @import("lightpanda"); + const js = @import("../../js/js.zig"); +const Page = @import("../../Page.zig"); const Frame = @import("../../Frame.zig"); -const Session = @import("../../Session.zig"); + const Execution = js.Execution; pub fn Entry(comptime Inner: type, comptime field: ?[]const u8) type { @@ -48,15 +50,15 @@ pub fn Entry(comptime Inner: type, comptime field: ?[]const u8) type { return self; } - pub fn deinit(self: *Self, session: *Session) void { + pub fn deinit(self: *Self, page: *Page) void { if (@hasDecl(Inner, "releaseRef")) { - self._inner.releaseRef(session); + self._inner.releaseRef(page); } - session.factory.destroy(self); + page.factory.destroy(self); } - pub fn releaseRef(self: *Self, session: *Session) void { - self._rc.release(self, session); + pub fn releaseRef(self: *Self, page: *Page) void { + self._rc.release(self, page); } pub fn acquireRef(self: *Self) void { diff --git a/src/browser/webapi/css/CSSStyleSheet.zig b/src/browser/webapi/css/CSSStyleSheet.zig index 54f0bbea..83dbced9 100644 --- a/src/browser/webapi/css/CSSStyleSheet.zig +++ b/src/browser/webapi/css/CSSStyleSheet.zig @@ -79,7 +79,7 @@ pub fn getOwnerRule(self: *const CSSStyleSheet) ?*CSSRule { } pub fn insertRule(self: *CSSStyleSheet, rule: []const u8, maybe_index: ?u32, frame: *Frame) !u32 { - const index = maybe_index orelse 0; + const requested_index = maybe_index orelse 0; var it = Parser.parseStylesheet(rule); const parsed_rule = it.next() orelse { if (it.has_skipped_at_rule) { @@ -88,7 +88,7 @@ pub fn insertRule(self: *CSSStyleSheet, rule: []const u8, maybe_index: ?u32, fra // CSS parser. To prevent JS apps (like Expo/Reanimated) from crashing // during initialization, we simulate a successful insertion by returning // the requested index. - return index; + return requested_index; } return error.SyntaxError; }; @@ -103,6 +103,16 @@ pub fn insertRule(self: *CSSStyleSheet, rule: []const u8, maybe_index: ?u32, fra try style.setCssText(parsed_rule.block, frame); const rules = try self.getCssRules(frame); + + // Per spec, an index > rules.length should throw IndexSizeError. But because + // we don't process @import and @font-face, indexes that code hard-codes can + // be off. As a workaround, we clamp to the tail. + // See #2214 (and the sibling #1970 / #1972 tolerance for at-rules). + const length = rules.length(); + const index = if (requested_index > length) length else requested_index; + if (index != requested_index) { + log.debug(.not_implemented, "insertRule clamped index", .{}); + } try rules.insert(index, style_rule._proto, frame); // Notify StyleManager that rules have changed diff --git a/src/browser/webapi/css/FontFace.zig b/src/browser/webapi/css/FontFace.zig index 41bb6743..9b6cd636 100644 --- a/src/browser/webapi/css/FontFace.zig +++ b/src/browser/webapi/css/FontFace.zig @@ -18,9 +18,10 @@ const std = @import("std"); const lp = @import("lightpanda"); + const js = @import("../../js/js.zig"); +const Page = @import("../../Page.zig"); const Frame = @import("../../Frame.zig"); -const Session = @import("../../Session.zig"); const Allocator = std.mem.Allocator; @@ -44,12 +45,12 @@ pub fn init(family: []const u8, source: []const u8, frame: *Frame) !*FontFace { return self; } -pub fn deinit(self: *FontFace, session: *Session) void { - session.releaseArena(self._arena); +pub fn deinit(self: *FontFace, page: *Page) void { + page.releaseArena(self._arena); } -pub fn releaseRef(self: *FontFace, session: *Session) void { - self._rc.release(self, session); +pub fn releaseRef(self: *FontFace, page: *Page) void { + self._rc.release(self, page); } pub fn acquireRef(self: *FontFace) void { diff --git a/src/browser/webapi/css/FontFaceSet.zig b/src/browser/webapi/css/FontFaceSet.zig index e5009c85..f0bd994f 100644 --- a/src/browser/webapi/css/FontFaceSet.zig +++ b/src/browser/webapi/css/FontFaceSet.zig @@ -18,12 +18,15 @@ const std = @import("std"); const lp = @import("lightpanda"); + const js = @import("../../js/js.zig"); +const Page = @import("../../Page.zig"); const Frame = @import("../../Frame.zig"); -const Session = @import("../../Session.zig"); -const FontFace = @import("FontFace.zig"); -const EventTarget = @import("../EventTarget.zig"); + const Event = @import("../Event.zig"); +const EventTarget = @import("../EventTarget.zig"); + +const FontFace = @import("FontFace.zig"); const Allocator = std.mem.Allocator; @@ -43,12 +46,12 @@ pub fn init(frame: *Frame) !*FontFaceSet { }); } -pub fn deinit(self: *FontFaceSet, session: *Session) void { - session.releaseArena(self._arena); +pub fn deinit(self: *FontFaceSet, page: *Page) void { + page.releaseArena(self._arena); } -pub fn releaseRef(self: *FontFaceSet, session: *Session) void { - self._rc.release(self, session); +pub fn releaseRef(self: *FontFaceSet, page: *Page) void { + self._rc.release(self, page); } pub fn acquireRef(self: *FontFaceSet) void { @@ -80,13 +83,13 @@ pub fn load(self: *FontFaceSet, font: []const u8, frame: *Frame) !js.Promise { // Dispatch loading event const target = self.asEventTarget(); if (frame._event_manager.hasDirectListeners(target, "loading", null)) { - const event = try Event.initTrusted(comptime .wrap("loading"), .{}, frame); + const event = try Event.initTrusted(comptime .wrap("loading"), .{}, frame._page); try frame._event_manager.dispatchDirect(target, event, null, .{ .context = "load font face set" }); } // Dispatch loadingdone event if (frame._event_manager.hasDirectListeners(target, "loadingdone", null)) { - const event = try Event.initTrusted(comptime .wrap("loadingdone"), .{}, frame); + const event = try Event.initTrusted(comptime .wrap("loadingdone"), .{}, frame._page); try frame._event_manager.dispatchDirect(target, event, null, .{ .context = "load font face set" }); } diff --git a/src/browser/webapi/element/html/Canvas.zig b/src/browser/webapi/element/html/Canvas.zig index afb43da2..32c60510 100644 --- a/src/browser/webapi/element/html/Canvas.zig +++ b/src/browser/webapi/element/html/Canvas.zig @@ -27,6 +27,8 @@ const CanvasRenderingContext2D = @import("../../canvas/CanvasRenderingContext2D. const WebGLRenderingContext = @import("../../canvas/WebGLRenderingContext.zig"); const OffscreenCanvas = @import("../../canvas/OffscreenCanvas.zig"); +const Execution = js.Execution; + const Canvas = @This(); _proto: *HtmlElement, _cached: ?DrawingContext = null, @@ -97,10 +99,10 @@ pub fn getContext(self: *Canvas, context_type: []const u8, frame: *Frame) !?Draw /// Transfers control of the canvas to an OffscreenCanvas. /// Returns an OffscreenCanvas with the same dimensions. -pub fn transferControlToOffscreen(self: *Canvas, frame: *Frame) !*OffscreenCanvas { +pub fn transferControlToOffscreen(self: *Canvas, exec: *Execution) !*OffscreenCanvas { const width = self.getWidth(); const height = self.getHeight(); - return OffscreenCanvas.constructor(width, height, frame); + return OffscreenCanvas.constructor(width, height, exec); } pub const JsApi = struct { diff --git a/src/browser/webapi/element/html/Input.zig b/src/browser/webapi/element/html/Input.zig index 8d503723..be82f254 100644 --- a/src/browser/webapi/element/html/Input.zig +++ b/src/browser/webapi/element/html/Input.zig @@ -102,7 +102,7 @@ pub fn setOnSelectionChange(self: *Input, listener: ?js.Function) !void { } fn dispatchSelectionChangeEvent(self: *Input, frame: *Frame) !void { - const event = try Event.init("selectionchange", .{ .bubbles = true }, frame); + const event = try Event.init("selectionchange", .{ .bubbles = true }, frame._page); try frame._event_manager.dispatch(self.asElement().asEventTarget(), event); } @@ -372,7 +372,7 @@ pub fn setAutocomplete(self: *Input, autocomplete: []const u8, frame: *Frame) !v pub fn select(self: *Input, frame: *Frame) !void { const len = if (self._value) |v| @as(u32, @intCast(v.len)) else 0; try self.setSelectionRange(0, len, null, frame); - const event = try Event.init("select", .{ .bubbles = true }, frame); + const event = try Event.init("select", .{ .bubbles = true }, frame._page); try frame._event_manager.dispatch(self.asElement().asEventTarget(), event); } diff --git a/src/browser/webapi/element/html/Media.zig b/src/browser/webapi/element/html/Media.zig index 9fc5d65a..27be59d9 100644 --- a/src/browser/webapi/element/html/Media.zig +++ b/src/browser/webapi/element/html/Media.zig @@ -166,7 +166,7 @@ pub fn load(self: *Media, frame: *Frame) !void { } fn dispatchEvent(self: *Media, name: []const u8, frame: *Frame) !void { - const event = try Event.init(name, .{ .bubbles = false, .cancelable = false }, frame); + const event = try Event.init(name, .{ .bubbles = false, .cancelable = false }, frame._page); try frame._event_manager.dispatch(self.asElement().asEventTarget(), event); } diff --git a/src/browser/webapi/element/html/TextArea.zig b/src/browser/webapi/element/html/TextArea.zig index c81f165e..89910e92 100644 --- a/src/browser/webapi/element/html/TextArea.zig +++ b/src/browser/webapi/element/html/TextArea.zig @@ -52,7 +52,7 @@ pub fn setOnSelectionChange(self: *TextArea, listener: ?js.Function) !void { } fn dispatchSelectionChangeEvent(self: *TextArea, frame: *Frame) !void { - const event = try Event.init("selectionchange", .{ .bubbles = true }, frame); + const event = try Event.init("selectionchange", .{ .bubbles = true }, frame._page); try frame._event_manager.dispatch(self.asElement().asEventTarget(), event); } @@ -142,7 +142,7 @@ pub fn setRequired(self: *TextArea, required: bool, frame: *Frame) !void { pub fn select(self: *TextArea, frame: *Frame) !void { const len = if (self._value) |v| @as(u32, @intCast(v.len)) else 0; try self.setSelectionRange(0, len, null, frame); - const event = try Event.init("select", .{ .bubbles = true }, frame); + const event = try Event.init("select", .{ .bubbles = true }, frame._page); try frame._event_manager.dispatch(self.asElement().asEventTarget(), event); } diff --git a/src/browser/webapi/encoding/TextDecoder.zig b/src/browser/webapi/encoding/TextDecoder.zig index 6c7cf6c5..e32ac3a5 100644 --- a/src/browser/webapi/encoding/TextDecoder.zig +++ b/src/browser/webapi/encoding/TextDecoder.zig @@ -18,10 +18,12 @@ const std = @import("std"); const lp = @import("lightpanda"); + const js = @import("../../js/js.zig"); +const Page = @import("../../Page.zig"); + const html5ever = @import("../../parser/html5ever.zig"); -const Session = @import("../../Session.zig"); const Allocator = std.mem.Allocator; const TextDecoder = @This(); @@ -41,7 +43,7 @@ const InitOpts = struct { ignoreBOM: bool = false, }; -pub fn init(label_: ?[]const u8, opts_: ?InitOpts, session: *Session) !*TextDecoder { +pub fn init(label_: ?[]const u8, opts_: ?InitOpts, page: *Page) !*TextDecoder { const label = label_ orelse "utf-8"; const info = html5ever.encoding_for_label(label.ptr, label.len); @@ -55,8 +57,8 @@ pub fn init(label_: ?[]const u8, opts_: ?InitOpts, session: *Session) !*TextDeco return error.RangeError; } - const arena = try session.getArena(.large, "TextDecoder"); - errdefer session.releaseArena(arena); + const arena = try page.getArena(.large, "TextDecoder"); + errdefer page.releaseArena(arena); const opts = opts_ orelse InitOpts{}; const self = try arena.create(TextDecoder); @@ -73,15 +75,15 @@ pub fn init(label_: ?[]const u8, opts_: ?InitOpts, session: *Session) !*TextDeco return self; } -pub fn deinit(self: *TextDecoder, session: *Session) void { +pub fn deinit(self: *TextDecoder, page: *Page) void { if (self._decoder) |decoder| { html5ever.encoding_decoder_free(decoder); } - session.releaseArena(self._arena); + page.releaseArena(self._arena); } -pub fn releaseRef(self: *TextDecoder, session: *Session) void { - self._rc.release(self, session); +pub fn releaseRef(self: *TextDecoder, page: *Page) void { + self._rc.release(self, page); } pub fn acquireRef(self: *TextDecoder) void { diff --git a/src/browser/webapi/event/CustomEvent.zig b/src/browser/webapi/event/CustomEvent.zig index 55455d33..859349be 100644 --- a/src/browser/webapi/event/CustomEvent.zig +++ b/src/browser/webapi/event/CustomEvent.zig @@ -20,8 +20,9 @@ const std = @import("std"); const lp = @import("lightpanda"); const js = @import("../../js/js.zig"); +const Page = @import("../../Page.zig"); const Frame = @import("../../Frame.zig"); -const Session = @import("../../Session.zig"); + const Event = @import("../Event.zig"); const String = lp.String; @@ -39,13 +40,13 @@ const CustomEventOptions = struct { const Options = Event.inheritOptions(CustomEvent, CustomEventOptions); -pub fn init(typ: []const u8, opts_: ?Options, frame: *Frame) !*CustomEvent { - const arena = try frame.getArena(.tiny, "CustomEvent"); - errdefer frame.releaseArena(arena); +pub fn init(typ: []const u8, opts_: ?Options, page: *Page) !*CustomEvent { + const arena = try page.getArena(.tiny, "CustomEvent"); + errdefer page.releaseArena(arena); const type_string = try String.init(arena, typ, .{}); const opts = opts_ orelse Options{}; - const event = try frame._factory.event( + const event = try page.factory.event( arena, type_string, CustomEvent{ @@ -75,21 +76,21 @@ pub fn initCustomEvent( self._detail = detail_; } -pub fn deinit(self: *CustomEvent, session: *Session) void { +pub fn deinit(self: *CustomEvent, page: *Page) void { if (self._detail) |d| { d.release(); } - self._proto.deinit(session); + self._proto.deinit(page); +} + +pub fn releaseRef(self: *CustomEvent, page: *Page) void { + self._proto._rc.release(self, page); } pub fn acquireRef(self: *CustomEvent) void { self._proto.acquireRef(); } -pub fn releaseRef(self: *CustomEvent, session: *Session) void { - self._proto._rc.release(self, session); -} - pub fn asEvent(self: *CustomEvent) *Event { return self._proto; } diff --git a/src/browser/webapi/event/ErrorEvent.zig b/src/browser/webapi/event/ErrorEvent.zig index d3d60bf7..de7c1d92 100644 --- a/src/browser/webapi/event/ErrorEvent.zig +++ b/src/browser/webapi/event/ErrorEvent.zig @@ -20,7 +20,7 @@ const std = @import("std"); const lp = @import("lightpanda"); const js = @import("../../js/js.zig"); -const Session = @import("../../Session.zig"); +const Page = @import("../../Page.zig"); const Event = @import("../Event.zig"); @@ -47,23 +47,23 @@ pub const ErrorEventOptions = struct { const Options = Event.inheritOptions(ErrorEvent, ErrorEventOptions); -pub fn init(typ: []const u8, opts_: ?Options, session: *Session) !*ErrorEvent { - const arena = try session.getArena(.small, "ErrorEvent"); - errdefer session.releaseArena(arena); +pub fn init(typ: []const u8, opts_: ?Options, page: *Page) !*ErrorEvent { + const arena = try page.getArena(.small, "ErrorEvent"); + errdefer page.releaseArena(arena); const type_string = try String.init(arena, typ, .{}); - return initWithTrusted(arena, type_string, opts_, false, session); + return initWithTrusted(arena, type_string, opts_, false, page); } -pub fn initTrusted(typ: String, opts_: ?Options, session: *Session) !*ErrorEvent { - const arena = try session.getArena(.small, "ErrorEvent.trusted"); - errdefer session.releaseArena(arena); - return initWithTrusted(arena, typ, opts_, true, session); +pub fn initTrusted(typ: String, opts_: ?Options, page: *Page) !*ErrorEvent { + const arena = try page.getArena(.small, "ErrorEvent.trusted"); + errdefer page.releaseArena(arena); + return initWithTrusted(arena, typ, opts_, true, page); } -fn initWithTrusted(arena: Allocator, typ: String, opts_: ?Options, trusted: bool, session: *Session) !*ErrorEvent { +fn initWithTrusted(arena: Allocator, typ: String, opts_: ?Options, trusted: bool, page: *Page) !*ErrorEvent { const opts = opts_ orelse Options{}; - const event = try session.factory.event( + const event = try page.factory.event( arena, typ, ErrorEvent{ @@ -81,21 +81,21 @@ fn initWithTrusted(arena: Allocator, typ: String, opts_: ?Options, trusted: bool return event; } -pub fn deinit(self: *ErrorEvent, session: *Session) void { +pub fn deinit(self: *ErrorEvent, page: *Page) void { if (self._error) |e| { e.release(); } - self._proto.deinit(session); + self._proto.deinit(page); +} + +pub fn releaseRef(self: *ErrorEvent, page: *Page) void { + self._proto._rc.release(self, page); } pub fn acquireRef(self: *ErrorEvent) void { self._proto.acquireRef(); } -pub fn releaseRef(self: *ErrorEvent, session: *Session) void { - self._proto._rc.release(self, session); -} - pub fn asEvent(self: *ErrorEvent) *Event { return self._proto; } diff --git a/src/browser/webapi/event/MessageEvent.zig b/src/browser/webapi/event/MessageEvent.zig index 40f0f699..6f2c761e 100644 --- a/src/browser/webapi/event/MessageEvent.zig +++ b/src/browser/webapi/event/MessageEvent.zig @@ -20,7 +20,7 @@ const std = @import("std"); const lp = @import("lightpanda"); const js = @import("../../js/js.zig"); -const Session = @import("../../Session.zig"); +const Page = @import("../../Page.zig"); const Event = @import("../Event.zig"); const Window = @import("../Window.zig"); @@ -50,23 +50,23 @@ pub const Data = union(enum) { const Options = Event.inheritOptions(MessageEvent, MessageEventOptions); -pub fn init(typ: []const u8, opts_: ?Options, session: *Session) !*MessageEvent { - const arena = try session.getArena(.small, "MessageEvent"); - errdefer session.releaseArena(arena); +pub fn init(typ: []const u8, opts_: ?Options, page: *Page) !*MessageEvent { + const arena = try page.getArena(.small, "MessageEvent"); + errdefer page.releaseArena(arena); const type_string = try String.init(arena, typ, .{}); - return initWithTrusted(arena, type_string, opts_, false, session); + return initWithTrusted(arena, type_string, opts_, false, page); } -pub fn initTrusted(typ: String, opts_: ?Options, session: *Session) !*MessageEvent { - const arena = try session.getArena(.small, "MessageEvent.trusted"); - errdefer session.releaseArena(arena); - return initWithTrusted(arena, typ, opts_, true, session); +pub fn initTrusted(typ: String, opts_: ?Options, page: *Page) !*MessageEvent { + const arena = try page.getArena(.small, "MessageEvent.trusted"); + errdefer page.releaseArena(arena); + return initWithTrusted(arena, typ, opts_, true, page); } -fn initWithTrusted(arena: Allocator, typ: String, opts_: ?Options, trusted: bool, session: *Session) !*MessageEvent { +fn initWithTrusted(arena: Allocator, typ: String, opts_: ?Options, trusted: bool, page: *Page) !*MessageEvent { const opts = opts_ orelse Options{}; - const event = try session.factory.event( + const event = try page.factory.event( arena, typ, MessageEvent{ @@ -81,23 +81,23 @@ fn initWithTrusted(arena: Allocator, typ: String, opts_: ?Options, trusted: bool return event; } -pub fn deinit(self: *MessageEvent, session: *Session) void { +pub fn deinit(self: *MessageEvent, page: *Page) void { if (self._data) |d| { switch (d) { .value => |js_val| js_val.release(), - .blob => |blob| blob.releaseRef(session), + .blob => |blob| blob.releaseRef(page), .string, .arraybuffer => {}, } } - self._proto.deinit(session); + self._proto.deinit(page); } pub fn acquireRef(self: *MessageEvent) void { self._proto.acquireRef(); } -pub fn releaseRef(self: *MessageEvent, session: *Session) void { - self._proto._rc.release(self, session); +pub fn releaseRef(self: *MessageEvent, page: *Page) void { + self._proto._rc.release(self, page); } pub fn asEvent(self: *MessageEvent) *Event { diff --git a/src/browser/webapi/event/PromiseRejectionEvent.zig b/src/browser/webapi/event/PromiseRejectionEvent.zig index 7de0feeb..0ef2aeb4 100644 --- a/src/browser/webapi/event/PromiseRejectionEvent.zig +++ b/src/browser/webapi/event/PromiseRejectionEvent.zig @@ -19,7 +19,7 @@ const std = @import("std"); const lp = @import("lightpanda"); const js = @import("../../js/js.zig"); -const Session = @import("../../Session.zig"); +const Page = @import("../../Page.zig"); const Event = @import("../Event.zig"); @@ -38,13 +38,13 @@ const PromiseRejectionEventOptions = struct { const Options = Event.inheritOptions(PromiseRejectionEvent, PromiseRejectionEventOptions); -pub fn init(typ: []const u8, opts_: ?Options, session: *Session) !*PromiseRejectionEvent { - const arena = try session.getArena(.tiny, "PromiseRejectionEvent"); - errdefer session.releaseArena(arena); +pub fn init(typ: []const u8, opts_: ?Options, page: *Page) !*PromiseRejectionEvent { + const arena = try page.getArena(.tiny, "PromiseRejectionEvent"); + errdefer page.releaseArena(arena); const type_string = try String.init(arena, typ, .{}); const opts = opts_ orelse Options{}; - const event = try session.factory.event( + const event = try page.factory.event( arena, type_string, PromiseRejectionEvent{ @@ -58,24 +58,24 @@ pub fn init(typ: []const u8, opts_: ?Options, session: *Session) !*PromiseReject return event; } -pub fn deinit(self: *PromiseRejectionEvent, session: *Session) void { +pub fn deinit(self: *PromiseRejectionEvent, page: *Page) void { if (self._reason) |r| { r.release(); } if (self._promise) |p| { p.release(); } - self._proto.deinit(session); + self._proto.deinit(page); +} + +pub fn releaseRef(self: *PromiseRejectionEvent, page: *Page) void { + self._proto._rc.release(self, page); } pub fn acquireRef(self: *PromiseRejectionEvent) void { self._proto.acquireRef(); } -pub fn releaseRef(self: *PromiseRejectionEvent, session: *Session) void { - self._proto._rc.release(self, session); -} - pub fn asEvent(self: *PromiseRejectionEvent) *Event { return self._proto; } diff --git a/src/browser/webapi/net/Fetch.zig b/src/browser/webapi/net/Fetch.zig index f37cf19b..623037d6 100644 --- a/src/browser/webapi/net/Fetch.zig +++ b/src/browser/webapi/net/Fetch.zig @@ -62,7 +62,7 @@ pub fn init(input: Input, options: ?InitOpts, frame: *Frame) !js.Promise { } const response = try Response.init(null, .{ .status = 0 }, &frame.js.execution); - errdefer response.deinit(frame._session); + errdefer response.deinit(frame._page); const fetch = try response._arena.create(Fetch); fetch.* = .{ @@ -248,7 +248,7 @@ fn httpErrorCallback(ctx: *anyopaque, err: anyerror) void { // clear this. (defer since `self is in the response's arena). defer if (self._owns_response) { - response.deinit(self._frame._session); + response.deinit(self._frame._page); }; var ls: js.Local.Scope = undefined; @@ -269,7 +269,7 @@ fn httpShutdownCallback(ctx: *anyopaque) void { if (self._owns_response) { var response = self._response; response._http_response = null; - response.deinit(self._frame._session); + response.deinit(self._frame._page); // Do not access `self` after this point: the Fetch struct was // allocated from response._arena which has been released. } diff --git a/src/browser/webapi/net/Request.zig b/src/browser/webapi/net/Request.zig index 9b20d973..5d749e6d 100644 --- a/src/browser/webapi/net/Request.zig +++ b/src/browser/webapi/net/Request.zig @@ -175,7 +175,7 @@ pub fn blob(self: *Request, exec: *const Execution) !js.Promise { const headers = try self.getHeaders(exec); const content_type = try headers.get("content-type", exec) orelse ""; - const b = try Blob.initFromBytes(body, content_type, true, exec.context.session); + const b = try Blob.initFromBytes(body, content_type, true, exec.context.page); return exec.context.local.?.resolvePromise(b); } diff --git a/src/browser/webapi/net/Response.zig b/src/browser/webapi/net/Response.zig index 881513e6..a7ff5aa4 100644 --- a/src/browser/webapi/net/Response.zig +++ b/src/browser/webapi/net/Response.zig @@ -20,7 +20,7 @@ const std = @import("std"); const lp = @import("lightpanda"); const js = @import("../../js/js.zig"); -const Session = @import("../../Session.zig"); +const Page = @import("../../Page.zig"); const HttpClient = @import("../../HttpClient.zig"); const Blob = @import("../Blob.zig"); @@ -72,7 +72,7 @@ pub const BodyInit = union(enum) { }; pub fn init(body_: ?BodyInit, opts_: ?InitOpts, exec: *const Execution) !*Response { - const session = exec.context.session; + const session = exec.context.page.session; const arena = try session.getArena(.large, "Response"); errdefer session.releaseArena(arena); @@ -109,16 +109,16 @@ pub fn init(body_: ?BodyInit, opts_: ?InitOpts, exec: *const Execution) !*Respon return self; } -pub fn deinit(self: *Response, session: *Session) void { +pub fn deinit(self: *Response, page: *Page) void { if (self._http_response) |resp| { resp.abort(error.Abort); self._http_response = null; } - session.releaseArena(self._arena); + page.releaseArena(self._arena); } -pub fn releaseRef(self: *Response, session: *Session) void { - self._rc.release(self, session); +pub fn releaseRef(self: *Response, page: *Page) void { + self._rc.release(self, page); } pub fn acquireRef(self: *Response) void { @@ -321,7 +321,7 @@ pub fn blob(self: *const Response, exec: *const Execution) !js.Promise { .stream => return local.rejectPromise(.{ .type_error = "Cannot read blob from stream body" }), }; const content_type = try self._headers.get("content-type", exec) orelse ""; - const b = try Blob.initFromBytes(body, content_type, true, exec.context.session); + const b = try Blob.initFromBytes(body, content_type, true, exec.context.page); return local.resolvePromise(b); } @@ -336,7 +336,7 @@ pub fn bytes(self: *const Response, exec: *const Execution) !js.Promise { } pub fn clone(self: *const Response, exec: *const Execution) !*Response { - const session = exec.context.session; + const session = exec.context.page.session; const body_len = switch (self._body) { .bytes => |b| b.len, .empty => 0, diff --git a/src/browser/webapi/net/WebSocket.zig b/src/browser/webapi/net/WebSocket.zig index 74bd668b..0b49b693 100644 --- a/src/browser/webapi/net/WebSocket.zig +++ b/src/browser/webapi/net/WebSocket.zig @@ -24,14 +24,15 @@ const http = @import("../../../network/http.zig"); const js = @import("../../js/js.zig"); const Blob = @import("../Blob.zig"); const URL = @import("../../URL.zig"); + +const Page = @import("../../Page.zig"); const Frame = @import("../../Frame.zig"); -const Session = @import("../../Session.zig"); const HttpClient = @import("../../HttpClient.zig"); const Event = @import("../Event.zig"); const EventTarget = @import("../EventTarget.zig"); -const MessageEvent = @import("../event/MessageEvent.zig"); const CloseEvent = @import("../event/CloseEvent.zig"); +const MessageEvent = @import("../event/MessageEvent.zig"); const log = lp.log; const Allocator = std.mem.Allocator; @@ -160,7 +161,7 @@ pub fn init(url: []const u8, protocols: [][]const u8, frame: *Frame) !*WebSocket return self; } -pub fn deinit(self: *WebSocket, session: *Session) void { +pub fn deinit(self: *WebSocket, page: *Page) void { self.cleanup(); if (self._on_open) |func| { @@ -177,14 +178,14 @@ pub fn deinit(self: *WebSocket, session: *Session) void { } for (self._send_queue.items) |msg| { - msg.deinit(session); + msg.deinit(page); } - session.releaseArena(self._arena); + page.releaseArena(self._arena); } -pub fn releaseRef(self: *WebSocket, session: *Session) void { - self._rc.release(self, session); +pub fn releaseRef(self: *WebSocket, page: *Page) void { + self._rc.release(self, page); } pub fn acquireRef(self: *WebSocket) void { @@ -235,7 +236,7 @@ fn cleanup(self: *WebSocket) void { self._http_client.removeConn(conn); self._req_headers.deinit(); self._conn = null; - self.releaseRef(self._frame._session); + self.releaseRef(self._frame._page); self._send_queue.clearRetainingCapacity(); } } @@ -303,8 +304,8 @@ pub fn send(self: *WebSocket, data: SendData) !void { switch (data) { .blob => |blob| { - const arena = try self._frame._session.getArena(blob._slice.len, "WebSocket.message"); - errdefer self._frame._session.releaseArena(arena); + const arena = try self._frame.getArena(blob._slice.len, "WebSocket.message"); + errdefer self._frame.releaseArena(arena); try self.queueMessage(.{ .binary = .{ .arena = arena, .data = try arena.dupe(u8, blob._slice), @@ -312,8 +313,8 @@ pub fn send(self: *WebSocket, data: SendData) !void { }, .js_val => |js_val| { if (js_val.isString()) |str| { - const arena = try self._frame._session.getArena(str.len(), "WebSocket.message"); - errdefer self._frame._session.releaseArena(arena); + const arena = try self._frame.getArena(str.len(), "WebSocket.message"); + errdefer self._frame.releaseArena(arena); try self.queueMessage(.{ .text = .{ .arena = arena, .data = try str.toSliceWithAlloc(arena), @@ -322,8 +323,8 @@ pub fn send(self: *WebSocket, data: SendData) !void { const binary = try js_val.toZig(BinaryData); const buffer = binary.asBuffer(); - const arena = try self._frame._session.getArena(buffer.len, "WebSocket.message"); - errdefer self._frame._session.releaseArena(arena); + const arena = try self._frame.getArena(buffer.len, "WebSocket.message"); + errdefer self._frame.releaseArena(arena); try self.queueMessage(.{ .binary = .{ .arena = arena, .data = try arena.dupe(u8, buffer), @@ -452,7 +453,7 @@ fn dispatchOpenEvent(self: *WebSocket) !void { const target = self.asEventTarget(); if (frame._event_manager.hasDirectListeners(target, "open", self._on_open)) { - const event = try Event.initTrusted(comptime .wrap("open"), .{}, frame); + const event = try Event.initTrusted(comptime .wrap("open"), .{}, frame._page); try frame._event_manager.dispatchDirect(target, event, self._on_open, .{ .context = "WebSocket open" }); } } @@ -466,7 +467,7 @@ fn dispatchMessageEvent(self: *WebSocket, data: []const u8, frame_type: http.WsF switch (self._binary_type) { .arraybuffer => .{ .arraybuffer = .{ .values = data } }, .blob => blk: { - const blob = try Blob.initFromBytes(data, "", false, frame._session); + const blob = try Blob.initFromBytes(data, "", false, frame._page); blob.acquireRef(); break :blk .{ .blob = blob }; }, @@ -477,7 +478,7 @@ fn dispatchMessageEvent(self: *WebSocket, data: []const u8, frame_type: http.WsF const event = try MessageEvent.initTrusted(comptime .wrap("message"), .{ .data = msg_data, .origin = "", - }, frame._session); + }, frame._page); try frame._event_manager.dispatchDirect(target, event.asEvent(), self._on_message, .{ .context = "WebSocket message" }); } } @@ -487,7 +488,7 @@ fn dispatchErrorEvent(self: *WebSocket) !void { const target = self.asEventTarget(); if (frame._event_manager.hasDirectListeners(target, "error", self._on_error)) { - const event = try Event.initTrusted(comptime .wrap("error"), .{}, frame); + const event = try Event.initTrusted(comptime .wrap("error"), .{}, frame._page); try frame._event_manager.dispatchDirect(target, event, self._on_error, .{ .context = "WebSocket error" }); } } @@ -575,7 +576,7 @@ fn writeContent(self: *WebSocket, conn: *http.Connection, buf: []u8, byte_msg: M if (self._send_offset >= byte_msg.data.len) { const removed = self._send_queue.orderedRemove(0); - removed.deinit(self._frame._session); + removed.deinit(self._frame._page); if (comptime IS_DEBUG) { log.debug(.websocket, "send complete", .{ .url = self._url, .len = byte_msg.data.len, .queue = self._send_queue.items.len }); } @@ -718,9 +719,9 @@ const Message = union(enum) { arena: Allocator, data: []const u8, }; - fn deinit(self: Message, session: *Session) void { + fn deinit(self: Message, page: *Page) void { switch (self) { - .text, .binary => |msg| session.releaseArena(msg.arena), + .text, .binary => |msg| page.releaseArena(msg.arena), .close => {}, } } diff --git a/src/browser/webapi/net/XMLHttpRequest.zig b/src/browser/webapi/net/XMLHttpRequest.zig index 660be153..35f7f7fa 100644 --- a/src/browser/webapi/net/XMLHttpRequest.zig +++ b/src/browser/webapi/net/XMLHttpRequest.zig @@ -25,8 +25,8 @@ const http = @import("../../../network/http.zig"); const URL = @import("../../URL.zig"); const Mime = @import("../../Mime.zig"); +const Page = @import("../../Page.zig"); const Frame = @import("../../Frame.zig"); -const Session = @import("../../Session.zig"); const Node = @import("../Node.zig"); const Event = @import("../Event.zig"); @@ -100,7 +100,7 @@ pub fn init(frame: *Frame) !*XMLHttpRequest { return self; } -pub fn deinit(self: *XMLHttpRequest, session: *Session) void { +pub fn deinit(self: *XMLHttpRequest, page: *Page) void { if (self._http_response) |resp| { resp.abort(error.Abort); self._http_response = null; @@ -135,19 +135,19 @@ pub fn deinit(self: *XMLHttpRequest, session: *Session) void { } } - session.releaseArena(self._arena); + page.releaseArena(self._arena); } fn releaseSelfRef(self: *XMLHttpRequest) void { if (self._active_request == false) { return; } - self.releaseRef(self._frame._session); + self.releaseRef(self._frame._page); self._active_request = false; } -pub fn releaseRef(self: *XMLHttpRequest, session: *Session) void { - self._rc.release(self, session); +pub fn releaseRef(self: *XMLHttpRequest, page: *Page) void { + self._rc.release(self, page); } pub fn acquireRef(self: *XMLHttpRequest) void { @@ -588,7 +588,7 @@ fn stateChanged(self: *XMLHttpRequest, state: ReadyState, frame: *Frame) !void { const target = self.asEventTarget(); if (frame._event_manager.hasDirectListeners(target, "readystatechange", self._on_ready_state_change)) { - const event = try Event.initTrusted(.wrap("readystatechange"), .{}, frame); + const event = try Event.initTrusted(.wrap("readystatechange"), .{}, frame._page); try frame._event_manager.dispatchDirect(target, event, self._on_ready_state_change, .{ .context = "XHR state change" }); } } diff --git a/src/browser/webapi/selector/List.zig b/src/browser/webapi/selector/List.zig index 416adc8e..fd3a4204 100644 --- a/src/browser/webapi/selector/List.zig +++ b/src/browser/webapi/selector/List.zig @@ -18,8 +18,8 @@ const std = @import("std"); +const Page = @import("../../Page.zig"); const Frame = @import("../../Frame.zig"); -const Session = @import("../../Session.zig"); const Node = @import("../Node.zig"); const Part = @import("Selector.zig").Part; @@ -41,8 +41,8 @@ pub const EntryIterator = GenericIterator(Iterator, null); pub const KeyIterator = GenericIterator(Iterator, "0"); pub const ValueIterator = GenericIterator(Iterator, "1"); -pub fn deinit(self: *const List, session: *Session) void { - session.releaseArena(self._arena); +pub fn deinit(self: *const List, page: *Page) void { + page.releaseArena(self._arena); } pub fn collect( diff --git a/src/cdp/AXNode.zig b/src/cdp/AXNode.zig index 4a9008eb..8f145e5a 100644 --- a/src/cdp/AXNode.zig +++ b/src/cdp/AXNode.zig @@ -1366,7 +1366,7 @@ test "AXNode: writer" { defer registry.deinit(); var frame = try testing.pageTest("cdp/dom3.html", .{}); - defer frame._session.removeFrame(); + defer frame._session.removePage(); var doc = frame.window._document; const node = try registry.register(doc.asNode()); @@ -1440,7 +1440,7 @@ test "AXNode: writer prunes hidden and resolves labels" { defer registry.deinit(); var frame = try testing.pageTest("cdp/ax_tree.html", .{}); - defer frame._session.removeFrame(); + defer frame._session.removePage(); var doc = frame.window._document; const node = try registry.register(doc.asNode()); diff --git a/src/cdp/CDP.zig b/src/cdp/CDP.zig index ec23e658..16a08cc9 100644 --- a/src/cdp/CDP.zig +++ b/src/cdp/CDP.zig @@ -223,6 +223,7 @@ fn dispatchCommand(command: *Command, method: []const u8) !void { 5 => switch (@as(u40, @bitCast(domain[0..5].*))) { asUint(u40, "Fetch") => return @import("domains/fetch.zig").processMessage(command), asUint(u40, "Input") => return @import("domains/input.zig").processMessage(command), + asUint(u40, "Audit") => return @import("domains/audit.zig").processMessage(command), else => {}, }, 6 => switch (@as(u48, @bitCast(domain[0..6].*))) { @@ -836,7 +837,7 @@ const IsolatedWorld = struct { // The isolate world must share at least some of the state with the related frame, specifically the DocumentHTML // (assuming grantUniversalAccess will be set to True!). // We just created the world and the frame. The frame's state lives in the session, but is update on navigation. - // This also means this pointer becomes invalid after removeFrame until a new frame is created. + // This also means this pointer becomes invalid after removePage until a new frame is created. // Currently we have only 1 frame and thus also only 1 state in the isolate world. pub fn createContext(self: *IsolatedWorld, frame: *Frame) !*js.Context { if (self.context == null) { diff --git a/src/cdp/Node.zig b/src/cdp/Node.zig index f854d2ce..2fbbde95 100644 --- a/src/cdp/Node.zig +++ b/src/cdp/Node.zig @@ -346,7 +346,7 @@ test "cdp Node: Registry register" { try testing.expectEqual(0, registry.lookup_by_node.count()); var frame = try testing.pageTest("cdp/registry1.html", .{}); - defer frame._session.removeFrame(); + defer frame._session.removePage(); var doc = frame.window._document; { @@ -403,12 +403,12 @@ test "cdp Node: search list" { { var frame = try testing.pageTest("cdp/registry2.html", .{}); - defer frame._session.removeFrame(); + defer frame._session.removePage(); var doc = frame.window._document; { const l1 = try doc.querySelectorAll(.wrap("a"), frame); - defer l1.deinit(frame._session); + defer l1.deinit(frame._page); const s1 = try search_list.create(l1._nodes); try testing.expectEqual("1", s1.name); try testing.expectEqualSlices(u32, &.{ 1, 2 }, s1.node_ids); @@ -419,7 +419,7 @@ test "cdp Node: search list" { { const l2 = try doc.querySelectorAll(.wrap("#a1"), frame); - defer l2.deinit(frame._session); + defer l2.deinit(frame._page); const s2 = try search_list.create(l2._nodes); try testing.expectEqual("2", s2.name); try testing.expectEqualSlices(u32, &.{1}, s2.node_ids); @@ -427,7 +427,7 @@ test "cdp Node: search list" { { const l3 = try doc.querySelectorAll(.wrap("#a2"), frame); - defer l3.deinit(frame._session); + defer l3.deinit(frame._page); const s3 = try search_list.create(l3._nodes); try testing.expectEqual("3", s3.name); try testing.expectEqualSlices(u32, &.{2}, s3.node_ids); @@ -443,7 +443,7 @@ test "cdp Node: Writer" { defer registry.deinit(); var frame = try testing.pageTest("cdp/registry3.html", .{}); - defer frame._session.removeFrame(); + defer frame._session.removePage(); var doc = frame.window._document; { diff --git a/src/cdp/domains/audit.zig b/src/cdp/domains/audit.zig new file mode 100644 index 00000000..c51bd609 --- /dev/null +++ b/src/cdp/domains/audit.zig @@ -0,0 +1,39 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// 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 . + +const std = @import("std"); +const CDP = @import("../CDP.zig"); + +pub fn processMessage(cmd: *CDP.Command) !void { + const action = std.meta.stringToEnum(enum { + enable, + disable, + }, cmd.input.action) orelse return error.UnknownMethod; + + switch (action) { + .enable => return enable(cmd), + .disable => return disable(cmd), + } +} +fn enable(cmd: *CDP.Command) !void { + return cmd.sendResult(null, .{}); +} + +fn disable(cmd: *CDP.Command) !void { + return cmd.sendResult(null, .{}); +} diff --git a/src/cdp/domains/dom.zig b/src/cdp/domains/dom.zig index daf321eb..a322f5cd 100644 --- a/src/cdp/domains/dom.zig +++ b/src/cdp/domains/dom.zig @@ -101,7 +101,7 @@ fn performSearch(cmd: *CDP.Command) !void { const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; const frame = bc.session.currentFrame() orelse return error.FrameNotLoaded; const list = try Selector.querySelectorAll(frame.window._document.asNode(), params.query, frame); - defer list.deinit(frame._session); + defer list.deinit(frame._page); const search = try bc.node_search_list.create(list._nodes); @@ -252,7 +252,7 @@ fn querySelectorAll(cmd: *CDP.Command) !void { }; const selected_nodes = try Selector.querySelectorAll(node.dom, params.selector, frame); - defer selected_nodes.deinit(frame._session); + defer selected_nodes.deinit(frame._page); const nodes = selected_nodes._nodes; diff --git a/src/cdp/domains/lp.zig b/src/cdp/domains/lp.zig index cc0b74a6..7f5defbd 100644 --- a/src/cdp/domains/lp.zig +++ b/src/cdp/domains/lp.zig @@ -299,7 +299,7 @@ test "cdp.lp: getMarkdown" { defer ctx.deinit(); const bc = try ctx.loadBrowserContext(.{}); - _ = try bc.session.createFrame(); + _ = try bc.session.createPage(); try ctx.processMessage(.{ .id = 1, @@ -315,7 +315,7 @@ test "cdp.lp: getInteractiveElements" { defer ctx.deinit(); const bc = try ctx.loadBrowserContext(.{}); - _ = try bc.session.createFrame(); + _ = try bc.session.createPage(); try ctx.processMessage(.{ .id = 1, @@ -331,7 +331,7 @@ test "cdp.lp: getStructuredData" { defer ctx.deinit(); const bc = try ctx.loadBrowserContext(.{}); - _ = try bc.session.createFrame(); + _ = try bc.session.createPage(); try ctx.processMessage(.{ .id = 1, @@ -347,7 +347,7 @@ test "cdp.lp: action tools" { defer ctx.deinit(); const bc = try ctx.loadBrowserContext(.{}); - const frame = try bc.session.createFrame(); + const frame = try bc.session.createPage(); const url = "http://localhost:9582/src/browser/tests/mcp_actions.html"; try frame.navigate(url, .{ .reason = .address_bar, .kind = .{ .push = null } }); var runner = try bc.session.runner(.{}); @@ -408,7 +408,7 @@ test "cdp.lp: waitForSelector" { defer ctx.deinit(); const bc = try ctx.loadBrowserContext(.{}); - const frame = try bc.session.createFrame(); + const frame = try bc.session.createPage(); const url = "http://localhost:9582/src/browser/tests/mcp_wait_for_selector.html"; try frame.navigate(url, .{ .reason = .address_bar, .kind = .{ .push = null } }); var runner = try bc.session.runner(.{}); diff --git a/src/cdp/domains/network.zig b/src/cdp/domains/network.zig index 1e11e88c..c60aa7a7 100644 --- a/src/cdp/domains/network.zig +++ b/src/cdp/domains/network.zig @@ -187,7 +187,7 @@ fn getCookies(cmd: *CDP.Command) !void { const params = (try cmd.params(GetCookiesParam)) orelse GetCookiesParam{}; // If not specified, use the URLs of the page and all of its subframes. TODO subframes - const frame_url = if (bc.session.frame) |frame| frame.url else null; + const frame_url = if (bc.session.currentFrame()) |frame| frame.url else null; const param_urls = params.urls orelse &[_][:0]const u8{frame_url orelse return error.InvalidParams}; var urls = try std.ArrayList(CdpStorage.PreparedUri).initCapacity(cmd.arena, param_urls.len); @@ -239,7 +239,7 @@ pub fn httpRequestFail(bc: *CDP.BrowserContext, msg: *const Notification.Request // Isn't possible to do a network request within a Browser (which our // notification is tied to), without a frame. - lp.assert(bc.session.frame != null, "CDP.network.httpRequestFail null frame", .{}); + lp.assert(bc.session.page != null, "CDP.network.httpRequestFail null frame", .{}); // We're missing a bunch of fields, but, for now, this seems like enough try bc.cdp.sendEvent("Network.loadingFailed", .{ diff --git a/src/cdp/domains/page.zig b/src/cdp/domains/page.zig index 02209eea..b0aae79e 100644 --- a/src/cdp/domains/page.zig +++ b/src/cdp/domains/page.zig @@ -212,7 +212,7 @@ fn close(cmd: *CDP.Command) !void { const target_id = bc.target_id orelse return error.TargetNotLoaded; // can't be null if we have a target_id - lp.assert(bc.session.frame != null, "CDP.frame.close null frame", .{}); + lp.assert(bc.session.page != null, "CDP.frame.close null frame", .{}); try cmd.sendResult(.{}, .{}); @@ -235,7 +235,7 @@ fn close(cmd: *CDP.Command) !void { bc.session_id = null; } - bc.session.removeFrame(); + bc.session.removePage(); for (bc.isolated_worlds.items) |world| { world.deinit(); } @@ -307,7 +307,7 @@ fn navigate(cmd: *CDP.Command) !void { isolated_world.identity.deinit(); isolated_world.identity = .{}; } - frame = try session.replaceFrame(); + frame = try session.replacePage(); } const encoded_url = try URL.ensureEncoded(frame.call_arena, params.url, "UTF-8"); @@ -333,7 +333,7 @@ fn doReload(cmd: *CDP.Command) !void { const session = bc.session; var frame = session.currentFrame() orelse return error.FrameNotLoaded; - // Dupe URL before replaceFrame() frees the old frame's arena. + // Dupe URL before replacePage() frees the old frame's arena. const reload_url = try cmd.arena.dupeZ(u8, frame.url); if (frame._load_state != .waiting) { @@ -343,7 +343,7 @@ fn doReload(cmd: *CDP.Command) !void { isolated_world.identity.deinit(); isolated_world.identity = .{}; } - frame = try session.replaceFrame(); + frame = try session.replacePage(); } try frame.navigate(reload_url, .{ diff --git a/src/cdp/domains/target.zig b/src/cdp/domains/target.zig index fccf0dd3..e6cfe1ff 100644 --- a/src/cdp/domains/target.zig +++ b/src/cdp/domains/target.zig @@ -171,12 +171,12 @@ fn createTarget(cmd: *CDP.Command) !void { } // if target_id is null, we should never have a blank frame - lp.assert(bc.session.frame == null, "CDP.target.createTarget not null page", .{}); + lp.assert(bc.session.page == null, "CDP.target.createTarget not null page", .{}); // if target_id is null, we should never have a session_id lp.assert(bc.session_id == null, "CDP.target.createTarget not null session_id", .{}); - const frame = try bc.session.createFrame(); + const frame = try bc.session.createPage(); // the target_id == the frame_id of the "root" frame const frame_id = id.toFrameId(frame._frame_id); @@ -284,7 +284,7 @@ fn closeTarget(cmd: *CDP.Command) !void { } // can't be null if we have a target_id - lp.assert(bc.session.frame != null, "CDP.target.closeTarget null frame", .{}); + lp.assert(bc.session.page != null, "CDP.target.closeTarget null frame", .{}); try cmd.sendResult(.{ .success = true }, .{ .include_session_id = false }); @@ -305,7 +305,7 @@ fn closeTarget(cmd: *CDP.Command) !void { bc.session_id = null; } - bc.session.removeFrame(); + bc.session.removePage(); for (bc.isolated_worlds.items) |world| { world.deinit(); } @@ -626,7 +626,7 @@ test "cdp.target: closeTarget" { } // pretend we createdTarget first - _ = try bc.session.createFrame(); + _ = try bc.session.createPage(); bc.target_id = "TID-000000000A".*; { try ctx.processMessage(.{ .id = 10, .method = "Target.closeTarget", .params = .{ .targetId = "TID-8" } }); @@ -636,7 +636,7 @@ test "cdp.target: closeTarget" { { try ctx.processMessage(.{ .id = 11, .method = "Target.closeTarget", .params = .{ .targetId = "TID-000000000A" } }); try ctx.expectSentResult(.{ .success = true }, .{ .id = 11 }); - try testing.expectEqual(null, bc.session.frame); + try testing.expectEqual(null, bc.session.page); try testing.expectEqual(null, bc.target_id); } } @@ -657,7 +657,7 @@ test "cdp.target: attachToTarget" { } // pretend we createdTarget first - _ = try bc.session.createFrame(); + _ = try bc.session.createPage(); bc.target_id = "TID-000000000B".*; { try ctx.processMessage(.{ .id = 10, .method = "Target.attachToTarget", .params = .{ .targetId = "TID-8" } }); @@ -701,7 +701,7 @@ test "cdp.target: getTargetInfo" { } // pretend we createdTarget first - _ = try bc.session.createFrame(); + _ = try bc.session.createPage(); bc.target_id = "TID-000000000C".*; { try ctx.processMessage(.{ .id = 10, .method = "Target.getTargetInfo", .params = .{ .targetId = "TID-8" } }); diff --git a/src/cdp/testing.zig b/src/cdp/testing.zig index 488b5bd1..8be66b43 100644 --- a/src/cdp/testing.zig +++ b/src/cdp/testing.zig @@ -93,7 +93,7 @@ const TestContext = struct { if (bc.target_id == null) { bc.target_id = "TID-000000000Z".*; } - const frame = try bc.session.createFrame(); + const frame = try bc.session.createPage(); const full_url = try std.fmt.allocPrintSentinel( base.arena_allocator, "http://127.0.0.1:9582/src/browser/tests/{s}", @@ -204,7 +204,7 @@ const TestContext = struct { if (self.cdp_) |*cdp__| { if (cdp__.browser_context) |*bc| { - if (bc.session.frame != null) { + if (bc.session.page != null) { var runner = try bc.session.runner(.{}); _ = try runner.tick(.{ .ms = 1000 }); } diff --git a/src/cli.zig b/src/cli.zig new file mode 100644 index 00000000..8506839a --- /dev/null +++ b/src/cli.zig @@ -0,0 +1,592 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// 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 . + +const std = @import("std"); +const Allocator = std.mem.Allocator; +const lp = @import("lightpanda"); +const log = lp.log; + +/// Comptime CLI builder that generates a tagged union parser from a +/// declarative command recipe. Each command becomes a union variant whose +/// payload is a struct with one field per option. +/// +/// ## Command descriptor fields +/// +/// - `name: []const u8` — canonical command name on the command line. +/// - `options: tuple` — tuple of option descriptors (see below). Use `.{}` +/// for none. +/// - `shared_options: tuple` (optional) — extra options merged into this +/// command. Useful for common flags shared across commands. +/// - `positional: struct` (optional) — a single positional argument with +/// `.name` and `.type`. Type must be an optional pointer-to-u8 slice +/// (e.g. `?[:0]const u8`). Positionals can appear anywhere in argv and +/// must be provided; a missing positional returns `error.MissingArgument`. +/// +/// ## Option descriptor fields +/// +/// - `name: []const u8` — snake_case field name. Both `--snake_case` and +/// `--kebab-case` are accepted on the command line. +/// - `type` — the Zig type of the parsed value (see supported types below). +/// - `default` (optional) — compile-time default when the flag is absent. +/// Rules vary by type; see the defaults section below. +/// - `multiple: bool` (optional) — when `true`, the field becomes a +/// `std.ArrayList(type)` and each occurrence appends. Not supported for +/// `bool` or packed-struct options. +/// - `validator: fn` (optional) — custom parse function that replaces the +/// built-in type switch. See the validator section below. +/// +/// ## Supported types and their defaults +/// +/// - `bool` — presence flips the field to the opposite of its `default` +/// (so a flag with `default = true` acts as a disable switch). Defaults +/// to `false` when no `default` is given. `?bool` is not allowed. +/// - Integers (`u8`, `u16`, `u31`, `usize`, etc.) — parsed with +/// `std.fmt.parseInt`. Requires `default` unless wrapped in `?`. +/// - `[]const u8`, `[:0]const u8` (and mutable variants) — string slices +/// duped from argv. Sentinel is preserved. Requires `default` unless `?`. +/// - Enums — parsed via `std.meta.stringToEnum`. Returns +/// `error.UnknownArgument` on a bad value. Requires `default` unless `?`. +/// - Packed structs of `bool` fields — parsed from a comma-separated list +/// (e.g. `--strip-mode js,css`). The literal `"full"` sets every field. +/// Unknown names return `error.UnknownArgument`. Requires `default`. +/// `multiple` is not supported. +/// - Optional types default to `null` when `default` is omitted. +/// +/// ## Validators +/// +/// A `validator` is a custom parse function that takes over argument +/// consumption for an option. Its signature depends on whether `multiple` +/// is set: +/// +/// - Single: `fn (Allocator, *ArgIterator) !T` — returns the parsed value. +/// - Multiple: `fn (Allocator, *ArgIterator, *std.ArrayList(T)) !void` — +/// appends directly into the list. +/// +/// When a validator is present, the built-in type switch is skipped entirely. +/// The validator owns advancing the iterator and is free to peek ahead. +/// +/// ## Example +/// +/// ```zig +/// const StripMode = packed struct(u2) { +/// js: bool = false, +/// css: bool = false, +/// }; +/// +/// const WaitUntil = enum { load, domcontentloaded, networkidle }; +/// +/// const CommonOptions = .{ +/// .{ .name = "verbose", .type = bool }, +/// .{ .name = "log_level", .type = ?log.Level }, +/// .{ .name = "timeout", .type = u31, .default = 30 }, +/// }; +/// +/// const Cli = cli.Builder(.{ +/// .{ +/// .name = "serve", +/// .options = .{ +/// .{ .name = "host", .type = []const u8, .default = "127.0.0.1" }, +/// .{ .name = "port", .type = u16, .default = 9222 }, +/// }, +/// .shared_options = CommonOptions, +/// }, +/// .{ +/// .name = "fetch", +/// .positional = .{ .name = "url", .type = ?[:0]const u8 }, +/// .options = .{ +/// .{ .name = "dump", .type = ?DumpFormat, .validator = dumpValidator }, +/// .{ .name = "strip_mode", .type = StripMode, .default = .{} }, +/// .{ .name = "wait_until", .type = ?WaitUntil }, +/// .{ .name = "extra_header", .type = []const u8, .multiple = true }, +/// }, +/// .shared_options = CommonOptions, +/// }, +/// .{ .name = "version", .options = .{} }, +/// .{ .name = "help", .options = .{} }, +/// }); +/// +/// const _, const cmd = try Cli.parse(arena); +/// switch (cmd) { +/// .serve => |opts| listen(opts.host, opts.port), +/// .fetch => |opts| fetch(opts.url.?, opts.dump), +/// .version => printVersion(), +/// .help => printHelp(), +/// } +/// ``` +pub fn Builder(comptime commands: anytype) type { + return struct { + const Self = @This(); + + /// Enum type for provided commands. + pub const Enum = blk: { + var enum_fields: [commands.len]std.builtin.Type.EnumField = undefined; + for (commands, 0..) |command, i| { + enum_fields[i] = .{ .name = command.name, .value = i }; + } + + break :blk @Type(.{ + .@"enum" = .{ + .decls = &.{}, + .fields = &enum_fields, + .is_exhaustive = true, + .tag_type = std.math.IntFittingRange(0, commands.len), + }, + }); + }; + + /// Creates an array of `StructField` out of given options. + fn optionsToStructFields(comptime options: anytype) [options.len]std.builtin.Type.StructField { + var fields: [options.len]std.builtin.Type.StructField = undefined; + + inline for (options, 0..) |option, j| { + // Whether prefer `ArrayList` for the option. + const is_multiple = @hasField(@TypeOf(option), "multiple") and option.multiple; + // Whether option has a default value. + const has_default = @hasField(@TypeOf(option), "default"); + + const T = if (is_multiple) std.ArrayList(option.type) else option.type; + + const default = blk: { + if (is_multiple) { + // We currently don't allow default values for lists. + if (has_default) { + @compileError("`default` is not allowed for lists"); + } + // Multiples are always initialized the same. + break :blk @as(*const anyopaque, @ptrCast(&@as(T, .{}))); + } + + switch (@typeInfo(option.type)) { + .optional => |optional| { + if (optional.child == bool) { + @compileError("?bool is not supported, prefer enum"); + } + + // If type is an optional type without default value, prefer null. + if (!has_default) { + break :blk @as(*const anyopaque, @ptrCast(&@as(T, null))); + } + // We have default value for an optional. + break :blk @as(*const anyopaque, @ptrCast(&@as(T, option.default))); + }, + .bool => { + // Prefer `false` if no default. + const default = if (has_default) option.default else false; + break :blk @as(*const anyopaque, @ptrCast(&@as(T, default))); + }, + inline else => { + if (!has_default) { + @compileError("option `" ++ option.name ++ "` is not optional type and has no default value"); + } + break :blk @as(*const anyopaque, @ptrCast(&@as(T, option.default))); + }, + } + }; + + fields[j] = .{ + .name = option.name, + .type = T, + .default_value_ptr = default, + .is_comptime = false, + .alignment = @alignOf(T), + }; + } + + return fields; + } + + /// Union type for provided commands. + pub const Union = blk: { + var union_fields: [commands.len]std.builtin.Type.UnionField = undefined; + for (commands, 0..) |command, i| { + const Command = @TypeOf(command); + const options = command.options; + + const fields = optionsToStructFields(options) ++ + (if (@hasField(Command, "shared_options")) + optionsToStructFields(command.shared_options) + else + .{}) ++ + (if (@hasField(Command, "positional")) + [1]std.builtin.Type.StructField{ + .{ + .name = command.positional.name, + .type = command.positional.type, + .default_value_ptr = @ptrCast(&@as(command.positional.type, null)), + .is_comptime = false, + .alignment = @alignOf(command.positional.type), + }, + } + else + .{}); + + const T = @Type(.{ + .@"struct" = .{ + .decls = &.{}, + .fields = &fields, + .is_tuple = false, + .layout = .auto, + }, + }); + + union_fields[i] = .{ .name = command.name, .type = T, .alignment = @alignOf(T) }; + } + + break :blk @Type(.{ + .@"union" = .{ + .decls = &.{}, + .fields = &union_fields, + .layout = .auto, + .tag_type = Enum, + }, + }); + }; + + /// Parses executable name, command and options via single call. + pub fn parse(allocator: Allocator) !struct { []const u8, Union } { + var args = try std.process.argsWithAllocator(allocator); + defer args.deinit(); + + const exec_name = std.fs.path.basename(args.next().?); + + const cmd_str: []const u8 = args.next() orelse return error.MissingCommand; + inline for (commands) |command| { + // Match a command. + if (std.mem.eql(u8, cmd_str, command.name)) { + return .{ exec_name, try parseCommand(allocator, command, &args) }; + } + } + + // Last resort, try sniffing. + const command_enum = try sniffCommand(cmd_str); + + // `help` takes no arguments; short-circuit so the sniffed flag + // isn't re-parsed as an unknown option. + if (command_enum == .help) { + return .{ exec_name, .{ .help = .{} } }; + } + + // "cmd_str" wasn't a command but an option. We can't reset args, but + // we can create a new one. Not great, but this fallback is temporary + // as we transition to this command mode approach. + args.deinit(); + args = try std.process.argsWithAllocator(allocator); + // Skip the `exec_name`. + _ = args.skip(); + + inline for (commands) |command| { + if (std.mem.eql(u8, @tagName(command_enum), command.name)) { + return .{ exec_name, try parseCommand(allocator, command, &args) }; + } + } + + unreachable; + } + + /// Try to sniff the command out of given option. + /// Only exists for legacy reasons; hence hardcoded. + fn sniffCommand(cmd_str: []const u8) error{UnknownCommand}!Enum { + if (std.mem.startsWith(u8, cmd_str, "--") == false) { + return .fetch; + } + + // Fetch heuristics. + inline for (.{ + "--dump", + "--strip-mode", + "--strip_mode", + "--with-base", + "--with_base", + "--with-frames", + "--with_frames", + }) |heuristic| { + if (std.mem.eql(u8, cmd_str, heuristic)) { + return .fetch; + } + } + + // Serve heuristics. + inline for (.{ + "--host", + "--port", + "--timeout", + }) |heuristic| { + if (std.mem.eql(u8, cmd_str, heuristic)) { + return .serve; + } + } + + // Legacy `--help` flag maps to the `help` command. + if (std.mem.eql(u8, cmd_str, "--help")) { + return .help; + } + + return error.UnknownCommand; + } + + /// Parses the command with its options. + fn parseCommand( + allocator: Allocator, + command: anytype, + args: *std.process.ArgIterator, + ) !Union { + const Command = @FieldType(Union, command.name); + var c = Command{}; + + const options = blk: { + if (@hasField(@TypeOf(command), "shared_options")) { + break :blk command.options ++ command.shared_options; + } + + break :blk command.options; + }; + iter_args: while (args.next()) |option_name| { + inline for (options) |option| { + // We allow both `--my-option` and `--my_option` variants; + // assuming given `option` struct prefer snake_case for `name`. + const kebab_cased = comptime casing: { + var output: [option.name.len]u8 = undefined; + @memcpy(&output, option.name); + std.mem.replaceScalar(u8, &output, '_', '-'); + break :casing "--" ++ output; + }; + + // Match an option. + const match = + std.mem.eql(u8, option_name, "--" ++ option.name) or + std.mem.eql(u8, option_name, kebab_cased); + + if (match) { + const T = option.type; + const option_info = blk: { + const info = @typeInfo(T); + // If wrapped in optional, prefer the child type. + if (info == .optional) break :blk @typeInfo(info.optional.child); + break :blk info; + }; + + const is_multiple = @hasField(@TypeOf(option), "multiple") and option.multiple; + const has_validator = @hasField(@TypeOf(option), "validator"); + + // Prefer custom validator logic instead. + if (has_validator) { + const validator = option.validator; + if (is_multiple) { + // Pass the list. + try @call(.auto, validator, .{ allocator, args, &@field(c, option.name) }); + } else { + // Receive the value from return. + const v = try @call(.auto, validator, .{ allocator, args }); + @field(c, option.name) = v; + } + } else { + switch (option_info) { + .int => |int| { + const Int = std.meta.Int(int.signedness, int.bits); + + const str = args.next() orelse return error.MissingArgument; + const v = std.fmt.parseInt(Int, str, 10) catch |err| { + switch (err) { + error.Overflow => log.fatal(.app, "range overflow", .{ .arg = kebab_cased, .value = str }), + error.InvalidCharacter => log.fatal(.app, "invalid character", .{ .arg = kebab_cased, .value = str }), + } + continue :iter_args; + }; + + if (is_multiple) { + // Push to ArrayList. + try @field(c, option.name).append(allocator, v); + } else { + @field(c, option.name) = v; + } + }, + .pointer => |pointer| { + const not_u8_slice = pointer.child != u8 or pointer.size != .slice; + if (not_u8_slice) { + @compileError("Only []u8, []const u8, [:sentinel]u8 and [:sentinel]const u8 pointers are supported"); + } + + const v = blk: { + const str = args.next() orelse return error.MissingArgument; + + // DupeZ branch. + if (comptime pointer.sentinel()) |sentinel| { + const buf = try allocator.alignedAlloc(u8, .fromByteUnits(pointer.alignment), str.len + 1); + @memcpy(buf[0..str.len], str); + buf[str.len] = sentinel; + break :blk buf[0..str.len :sentinel]; + } + + // Dupe branch. + const buf = try allocator.alignedAlloc(u8, .fromByteUnits(pointer.alignment), str.len); + @memcpy(buf, str); + break :blk buf; + }; + + if (is_multiple) { + try @field(c, option.name).append(allocator, v); + } else { + @field(c, option.name) = v; + } + }, + .@"struct" => |_struct| { + // Don't support multiple for structs for now. + if (is_multiple) { + @compileError("multiple option is not supported for structs"); + } + + const not_packed = _struct.layout != .@"packed"; + if (not_packed) { + @compileError("only packed structs are allowed"); + } + + const str = args.next() orelse return error.MissingArgument; + + if (std.mem.eql(u8, str, "full")) { + // "full" sets all the fields of packed struct. + const Int = _struct.backing_integer.?; + @field(c, option.name) = @bitCast(@as(Int, std.math.maxInt(Int))); + } else { + // Parse given args. + var it = std.mem.tokenizeScalar(u8, str, ','); + outer: while (it.next()) |part| { + const trimmed = std.mem.trim(u8, part, &std.ascii.whitespace); + + inline for (_struct.fields) |f| { + lp.assert(f.type == bool, "all fields of packed struct must be boolean", .{ + .option = option.name, + .field = f.name, + }); + + if (std.mem.eql(u8, trimmed, @as([]const u8, f.name))) { + @field(@field(c, option.name), f.name) = true; + continue :outer; + } + } + + // Invalid option choice. + log.fatal(.app, "invalid option choice", .{ .arg = kebab_cased, .value = trimmed }); + } + } + }, + .@"enum" => { + const E = switch (@typeInfo(T)) { + .optional => |optional| optional.child, + inline else => T, + }; + + const str = args.next() orelse return error.MissingArgument; + const v = std.meta.stringToEnum(E, str) orelse { + log.fatal(.app, "invalid option choice", .{ .arg = kebab_cased, .value = str }); + continue :iter_args; + }; + + if (is_multiple) { + try @field(c, option.name).append(allocator, v); + } else { + @field(c, option.name) = v; + } + }, + .bool => { + if (is_multiple) { + @compileError("multiple option is not supported for booleans"); + } + + const default = blk: { + if (@hasField(@TypeOf(option), "default")) { + break :blk option.default; + } + break :blk false; + }; + + // Set opposite of the default. + @field(c, option.name) = !default; + }, + + else => {}, + } + } + + continue :iter_args; + } + } + + // Encountered an option we don't know of. + if (std.mem.startsWith(u8, option_name, "--")) { + log.fatal(.app, "unknown argument", .{ .mode = command.name, .arg = option_name }); + return error.UnknownOption; + } + + // Parse positional arg if provided; can be given out of order: + // + // lightpanda fetch --wait-ms 2_000 "https://lightpanda.io" --dump "html" + // ---------------------------------^ + if (comptime @hasField(@TypeOf(command), "positional")) { + const positional = command.positional; + + // Already given one. + if (@field(c, positional.name) != null) { + return error.TooManyPositionalArguments; + } + + // The positional must be an optional type. + const info = @typeInfo(@typeInfo(positional.type).optional.child); + + const str = @as([]const u8, option_name); + switch (info) { + .pointer => |pointer| { + const not_u8_slice = pointer.child != u8 or pointer.size != .slice; + if (not_u8_slice) { + @compileError("Only []u8, []const u8, [:sentinel]u8 and [:sentinel]const u8 pointers are supported"); + } + + const v = blk: { + // DupeZ branch. + if (comptime pointer.sentinel()) |sentinel| { + const buf = try allocator.alignedAlloc(u8, .fromByteUnits(pointer.alignment), str.len + 1); + @memcpy(buf[0..str.len], str); + buf[str.len] = sentinel; + break :blk buf[0..str.len :sentinel]; + } + + // Dupe branch. + const buf = try allocator.alignedAlloc(u8, .fromByteUnits(pointer.alignment), str.len); + @memcpy(buf, str); + break :blk buf; + }; + + @field(c, positional.name) = v; + }, + inline else => @compileError("not supported"), + } + } else { + log.fatal(.app, "unknown argument", .{ .mode = command.name, .arg = option_name }); + return error.UnknownOption; + } + } + + // Parsing is complete and positional is null. + const positional_is_null = @hasField(@TypeOf(command), "positional") and @field(c, command.positional.name) == null; + if (positional_is_null) { + return error.MissingArgument; + } + + return @unionInit(Union, command.name, c); + } + }; +} diff --git a/src/lightpanda.zig b/src/lightpanda.zig index b9d6b9df..292ba44c 100644 --- a/src/lightpanda.zig +++ b/src/lightpanda.zig @@ -23,12 +23,14 @@ pub const App = @import("App.zig"); pub const Network = @import("network/Network.zig"); pub const Server = @import("Server.zig"); pub const Config = @import("Config.zig"); -pub const URL = @import("browser/URL.zig"); pub const String = @import("string.zig").String; +pub const Notification = @import("Notification.zig"); + +pub const URL = @import("browser/URL.zig"); +pub const Page = @import("browser/Page.zig"); pub const Frame = @import("browser/Frame.zig"); pub const Browser = @import("browser/Browser.zig"); pub const Session = @import("browser/Session.zig"); -pub const Notification = @import("Notification.zig"); pub const js = @import("browser/js/js.zig"); pub const dump = @import("browser/dump.zig"); @@ -41,14 +43,14 @@ pub const forms = @import("browser/forms.zig"); pub const actions = @import("browser/actions.zig"); pub const structured_data = @import("browser/structured_data.zig"); pub const tools = @import("browser/tools.zig"); +pub const HttpClient = @import("browser/HttpClient.zig"); + pub const mcp = @import("mcp.zig"); pub const agent = @import("agent.zig"); pub const cookies = @import("cookies.zig"); pub const build_config = @import("build_config"); pub const crash_handler = @import("crash_handler.zig"); -pub const HttpClient = @import("browser/HttpClient.zig"); - const IS_DEBUG = @import("builtin").mode == .Debug; pub const FetchOpts = struct { @@ -82,7 +84,7 @@ pub fn fetch(app: *App, url: [:0]const u8, opts: FetchOpts) !void { } } - const frame = try session.createFrame(); + const frame = try session.createPage(); // // Comment this out to get a profile of the JS code in v8/profile.json. // // You can open this in Chrome's profiler. @@ -264,7 +266,7 @@ pub fn RC(comptime T: type) type { self._refs += 1; } - pub fn release(self: *@This(), value: anytype, session: *Session) void { + pub fn release(self: *@This(), value: anytype, page: *Page) void { assert(self._refs > 0, "release overflow", .{ .type = @typeName(@TypeOf(value)) }); const refs = self._refs - 1; @@ -272,7 +274,7 @@ pub fn RC(comptime T: type) type { if (refs > 0) { return; } - value.deinit(session); + value.deinit(page); } pub fn format(self: @This(), writer: *std.Io.Writer) !void { diff --git a/src/main.zig b/src/main.zig index ba3f9e46..00940c5d 100644 --- a/src/main.zig +++ b/src/main.zig @@ -55,7 +55,7 @@ fn run(allocator: Allocator, main_arena: Allocator) !void { switch (args.mode) { .help => { - args.printUsageAndExit(args.mode.help); + args.printUsageAndExit(true); return std.process.cleanExit(); }, .version => { @@ -72,9 +72,9 @@ fn run(allocator: Allocator, main_arena: Allocator) !void { if (args.logFormat()) |lf| { log.opts.format = lf; } - if (args.logFilterScopes()) |lfs| { - log.opts.filter_scopes = lfs; - } + + // Set log filter scopes. + log.opts.filter_scopes = args.logFilterScopes().items; // must be installed before any other threads const sighandler = try main_arena.create(SigHandler); @@ -118,16 +118,16 @@ fn run(allocator: Allocator, main_arena: Allocator) !void { }, .fetch => |opts| { const url = opts.url; - log.debug(.app, "startup", .{ .mode = "fetch", .dump_mode = opts.dump_mode, .url = url, .snapshot = app.snapshot.fromEmbedded() }); + log.debug(.app, "startup", .{ .mode = "fetch", .dump_mode = opts.dump, .url = url, .snapshot = app.snapshot.fromEmbedded() }); var fetch_opts = lp.FetchOpts{ .wait_ms = opts.wait_ms, .wait_until = opts.wait_until, .wait_script = opts.wait_script, .wait_selector = opts.wait_selector, - .dump_mode = opts.dump_mode, + .dump_mode = opts.dump, .dump = .{ - .strip = opts.strip, + .strip = opts.strip_mode, .with_base = opts.with_base, .with_frames = opts.with_frames, }, @@ -135,11 +135,11 @@ fn run(allocator: Allocator, main_arena: Allocator) !void { var stdout = std.fs.File.stdout(); var writer = stdout.writer(&.{}); - if (opts.dump_mode != null) { + if (opts.dump != null) { fetch_opts.writer = &writer.interface; } - var worker_thread = try std.Thread.spawn(.{}, fetchThread, .{ app, url, fetch_opts }); + var worker_thread = try std.Thread.spawn(.{}, fetchThread, .{ app, url.?, fetch_opts }); defer worker_thread.join(); app.network.run(); diff --git a/src/main_legacy_test.zig b/src/main_legacy_test.zig index baa0275b..8e02f4ff 100644 --- a/src/main_legacy_test.zig +++ b/src/main_legacy_test.zig @@ -33,10 +33,8 @@ pub fn main() !void { } lp.log.opts.level = .warn; const config = try lp.Config.init(allocator, "legacy-test", .{ .serve = .{ - .common = .{ - .tls_verify_host = false, - .user_agent_suffix = "internal-tester", - }, + .insecure_disable_tls_host_verification = true, + .user_agent_suffix = "internal-tester", } }); defer config.deinit(allocator); @@ -94,8 +92,8 @@ pub fn main() !void { pub fn run(allocator: Allocator, file: []const u8, session: *lp.Session) !void { const url = try std.fmt.allocPrintSentinel(allocator, "http://localhost:9589/{s}", .{file}, 0); - const frame = try session.createFrame(); - defer session.removeFrame(); + const frame = try session.createPage(); + defer session.removePage(); var ls: lp.js.Local.Scope = undefined; frame.js.localScope(&ls); diff --git a/src/mcp/tools.zig b/src/mcp/tools.zig index 5bbd7607..23f566da 100644 --- a/src/mcp/tools.zig +++ b/src/mcp/tools.zig @@ -106,7 +106,7 @@ test "MCP - Actions: click, fill, scroll, hover, press, selectOption, setChecked const server = try testLoadPage("http://localhost:9582/src/browser/tests/mcp_actions.html", &out.writer); defer server.deinit(); - const frame = &server.session.frame.?; + const frame = server.session.currentFrame().?; { // Test Click @@ -424,7 +424,7 @@ fn testLoadPage(url: [:0]const u8, writer: *std.Io.Writer) !*Server { var server = try Server.init(testing.allocator, testing.test_app, writer); errdefer server.deinit(); - const frame = try server.session.createFrame(); + const frame = try server.session.createPage(); try frame.navigate(url, .{}); var runner = try server.session.runner(.{}); diff --git a/src/testing.zig b/src/testing.zig index bba6bcdd..e2500f81 100644 --- a/src/testing.zig +++ b/src/testing.zig @@ -394,8 +394,8 @@ pub fn htmlRunner(comptime path: []const u8, opts: HtmlRunnerOpts) !void { } fn runWebApiTest(test_file: [:0]const u8) !void { - const frame = try test_session.createFrame(); - defer test_session.removeFrame(); + const frame = try test_session.createPage(); + defer test_session.removePage(); const url = try std.fmt.allocPrintSentinel( arena_allocator, @@ -453,8 +453,8 @@ const PageTestOpts = struct { wait_until_done: bool = true, }; pub fn pageTest(comptime test_file: []const u8, opts: PageTestOpts) !*Frame { - const frame = try test_session.createFrame(); - errdefer test_session.removeFrame(); + const frame = try test_session.createPage(); + errdefer test_session.removePage(); const url = try std.fmt.allocPrintSentinel( arena_allocator, @@ -491,11 +491,9 @@ test "tests:beforeAll" { const test_allocator = @import("root").tracking_allocator; test_config = try Config.init(test_allocator, "test", .{ .serve = .{ - .common = .{ - .tls_verify_host = false, - .user_agent_suffix = "internal-tester", - .ws_max_concurrent = 50, - }, + .insecure_disable_tls_host_verification = true, + .user_agent_suffix = "internal-tester", + .ws_max_concurrent = 50, } }); test_app = try App.init(test_allocator, &test_config);