mirror of
https://github.com/lightpanda-io/browser.git
synced 2026-07-31 09:46:05 -04:00
509 lines
19 KiB
Zig
509 lines
19 KiB
Zig
// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
|
|
//
|
|
// Francis Bouvier <francis@lightpanda.io>
|
|
// Pierre Tachoire <pierre@lightpanda.io>
|
|
//
|
|
// This program is free software: you can redistribute it and/or modify
|
|
// it under the terms of the GNU Affero General Public License as
|
|
// published by the Free Software Foundation, either version 3 of the
|
|
// License, or (at your option) any later version.
|
|
//
|
|
// This program is distributed in the hope that it will be useful,
|
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
// GNU Affero General Public License for more details.
|
|
//
|
|
// You should have received a copy of the GNU Affero General Public License
|
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
|
|
const std = @import("std");
|
|
|
|
pub const log = @import("log.zig");
|
|
pub const datetime = @import("datetime.zig");
|
|
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 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 js = @import("browser/js/js.zig");
|
|
pub const dump = @import("browser/dump.zig");
|
|
pub const markdown = @import("browser/markdown.zig");
|
|
pub const SemanticTree = @import("SemanticTree.zig");
|
|
pub const CDPNode = @import("cdp/Node.zig");
|
|
pub const interactive = @import("browser/interactive.zig");
|
|
pub const links = @import("browser/links.zig");
|
|
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("network/HttpClient.zig");
|
|
|
|
pub const mcp = @import("mcp.zig");
|
|
pub const Agent = @import("agent/Agent.zig");
|
|
pub const Command = @import("script/command.zig").Command;
|
|
pub const Recorder = @import("script/Recorder.zig");
|
|
pub const Runtime = @import("script/Runtime.zig");
|
|
pub const Schema = @import("script/Schema.zig");
|
|
pub const skill = @import("script/skill.zig");
|
|
pub const cookies = @import("cookies.zig");
|
|
pub const build_config = @import("build_config");
|
|
pub const crash_handler = @import("crash_handler.zig");
|
|
pub const core_dump = @import("core_dump.zig");
|
|
|
|
pub const Updater = @import("Updater.zig");
|
|
|
|
pub var metrics = @import("Metrics.zig"){};
|
|
|
|
/// Process-wide Io instance for blocking syscalls (fs, net, time, futex).
|
|
/// Single-threaded-init only disables Io.async/Io.concurrent task spawning;
|
|
/// blocking operations work from any thread.
|
|
var io_threaded: std.Io.Threaded = .init_single_threaded;
|
|
pub const io: std.Io = io_threaded.io();
|
|
|
|
/// The single-threaded Io instance carries an empty environ; consumers that
|
|
/// need the real process environment (env-var lookups, spawned children) use
|
|
/// this instead.
|
|
pub fn environ() std.process.Environ {
|
|
return .{ .block = .{ .slice = std.mem.span(std.c.environ) } };
|
|
}
|
|
|
|
/// Environ.Map view of `environ` for spawned children that should inherit the
|
|
/// process environment (argv[0] PATH resolution always uses the parent
|
|
/// environment regardless).
|
|
pub fn environMap(allocator: std.mem.Allocator) !std.process.Environ.Map {
|
|
return environ().createMap(allocator);
|
|
}
|
|
|
|
/// Io.Condition has no timed wait (@ZIG16: delete when std grows one).
|
|
/// Mirrors std's Condition.waitInner with a deadline-bounded futex wait.
|
|
/// Not a cancelation point. Returns error.Timeout when no signal arrives.
|
|
pub fn timedWait(cond: *std.Io.Condition, mutex: *std.Io.Mutex, timeout_ns: u64) error{Timeout}!void {
|
|
const deadline: std.Io.Clock.Timestamp = .fromNow(io, .{
|
|
.raw = .fromNanoseconds(@intCast(timeout_ns)),
|
|
.clock = .awake,
|
|
});
|
|
|
|
var epoch = cond.epoch.load(.acquire);
|
|
_ = cond.state.fetchAdd(.{ .waiters = 1, .signals = 0 }, .monotonic);
|
|
|
|
mutex.unlock(io);
|
|
defer mutex.lockUncancelable(io);
|
|
|
|
while (true) {
|
|
io.futexWaitTimeout(u32, &cond.epoch.raw, epoch, .{ .deadline = deadline }) catch {};
|
|
epoch = cond.epoch.load(.acquire);
|
|
|
|
// Consume a pending signal even after a timeout-shaped wake, so a
|
|
// signal never gets stuck in the state with no waiter (same race
|
|
// std's waitInner defends against).
|
|
var prev_state = cond.state.load(.monotonic);
|
|
while (prev_state.signals > 0) {
|
|
prev_state = cond.state.cmpxchgWeak(prev_state, .{
|
|
.waiters = prev_state.waiters - 1,
|
|
.signals = prev_state.signals - 1,
|
|
}, .acquire, .monotonic) orelse return;
|
|
}
|
|
|
|
if (deadline.compare(.lte, .now(io, .awake))) {
|
|
_ = cond.state.fetchSub(.{ .waiters = 1, .signals = 0 }, .monotonic);
|
|
return error.Timeout;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Drop-in for the removed std.Thread.WaitGroup (start/finish/wait subset).
|
|
pub const WaitGroup = struct {
|
|
state: std.atomic.Value(u32) = .init(0),
|
|
|
|
pub fn start(self: *WaitGroup) void {
|
|
_ = self.state.fetchAdd(1, .monotonic);
|
|
}
|
|
|
|
pub fn startMany(self: *WaitGroup, n: u32) void {
|
|
_ = self.state.fetchAdd(n, .monotonic);
|
|
}
|
|
|
|
pub fn finish(self: *WaitGroup) void {
|
|
if (self.state.fetchSub(1, .acq_rel) == 1) {
|
|
io.futexWake(u32, &self.state.raw, std.math.maxInt(u32));
|
|
}
|
|
}
|
|
|
|
pub fn wait(self: *WaitGroup) void {
|
|
while (true) {
|
|
const n = self.state.load(.acquire);
|
|
if (n == 0) {
|
|
return;
|
|
}
|
|
io.futexWaitUncancelable(u32, &self.state.raw, n);
|
|
}
|
|
}
|
|
};
|
|
|
|
/// Drop-in for the removed std.once: `f` runs exactly once; concurrent
|
|
/// callers block until the first call completes.
|
|
pub fn once(comptime f: fn () void) Once(f) {
|
|
return .{};
|
|
}
|
|
|
|
pub fn Once(comptime f: fn () void) type {
|
|
return struct {
|
|
done: bool = false,
|
|
mutex: std.Io.Mutex = .init,
|
|
|
|
pub fn call(self: *@This()) void {
|
|
if (@atomicLoad(bool, &self.done, .acquire)) {
|
|
return;
|
|
}
|
|
self.mutex.lockUncancelable(io);
|
|
defer self.mutex.unlock(io);
|
|
if (!self.done) {
|
|
f();
|
|
@atomicStore(bool, &self.done, true, .release);
|
|
}
|
|
}
|
|
};
|
|
}
|
|
|
|
pub const FetchOpts = struct {
|
|
wait_ms: u32 = 5000,
|
|
wait_until: ?Config.WaitUntil = null,
|
|
wait_script: ?[:0]const u8 = null,
|
|
inject_script: std.ArrayList([]const u8) = .empty,
|
|
wait_selector: ?[:0]const u8 = null,
|
|
dump: dump.Opts,
|
|
dump_mode: ?Config.DumpFormat = null,
|
|
writer: ?*std.Io.Writer = null,
|
|
json: bool = false,
|
|
};
|
|
/// Loads each url in `urls` in a fresh session and waits per `opts`.
|
|
///
|
|
/// Errors:
|
|
/// - `error.Timeout` if the wait deadline (`opts.wait_ms`) expires.
|
|
/// - `error.Cancelled` if the embedder installed a `Session.cancel_hook`
|
|
/// that returned true during the wait. The hook is opt-in via
|
|
/// `session.cancel_hook = .{...}`; without it, this error never fires.
|
|
/// - Other errors from navigation / parsing / I/O surface as their
|
|
/// underlying tag.
|
|
pub fn fetch(app: *App, browser: *Browser, urls: []const [:0]const u8, opts: FetchOpts) !void {
|
|
const notification = try Notification.init(app.allocator);
|
|
defer notification.deinit();
|
|
|
|
var session = try browser.newSession(notification);
|
|
// Session.deinit unregisters from notification; close before notification.deinit runs.
|
|
defer browser.closeSession();
|
|
|
|
if (app.config.cookieFile()) |cookie_path| {
|
|
cookies.loadFromFile(session, cookie_path);
|
|
}
|
|
|
|
defer {
|
|
if (app.config.cookieJarFile()) |cookie_jar_path| {
|
|
cookies.saveToFile(&session.cookie_jar, cookie_jar_path);
|
|
}
|
|
}
|
|
|
|
// Stash scripts user want to inject.
|
|
session.inject_scripts = opts.inject_script.items;
|
|
|
|
// One page per url. `PageHandle.frame()` always re-resolves the live frame,
|
|
// so the handles stay valid across navigate / wait. The Runner's wait paths
|
|
// already operate over every live page in the session.
|
|
var pages: std.ArrayList(Session.PageHandle) = try .initCapacity(session.arena, urls.len);
|
|
for (urls) |url| {
|
|
const page = try session.createPage();
|
|
const frame = page.frame().?;
|
|
// not guaranteed to be valid after navigate
|
|
const encoded_url = try URL.resolveNavigation(frame.call_arena, url, .{});
|
|
_ = try frame.navigate(encoded_url, .{
|
|
.reason = .address_bar,
|
|
.kind = .{ .push = null },
|
|
});
|
|
pages.appendAssumeCapacity(page);
|
|
}
|
|
|
|
var runner = session.runner(.{});
|
|
|
|
var timer: std.Io.Timestamp = .now(io, .boot);
|
|
|
|
if (opts.wait_until) |wu| {
|
|
try runner.waitForAll(opts.wait_ms, .{ .until = wu });
|
|
} else if (opts.wait_selector == null and opts.wait_script == null) {
|
|
// We default to .done if both wait_selector and wait_script are null
|
|
// This allows the caller to ONLY --wait-selector or ONLY --wait-script
|
|
// or combine --wait-until WITH --wait-selector/script
|
|
try runner.waitForAll(opts.wait_ms, .{ .until = .done });
|
|
}
|
|
|
|
if (opts.wait_selector) |selector| {
|
|
const elapsed: u32 = @intCast(timer.untilNow(io, .boot).toMilliseconds());
|
|
const remaining = opts.wait_ms -| elapsed;
|
|
if (remaining == 0) {
|
|
return error.Timeout;
|
|
}
|
|
for (session.pages.items) |p| {
|
|
if (p.replacement == null) {
|
|
_ = try runner.waitForSelector(p.frame._frame_id, selector, remaining);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (opts.wait_script) |wait_script| {
|
|
const elapsed: u32 = @intCast(timer.untilNow(io, .boot).toMilliseconds());
|
|
const remaining = opts.wait_ms -| elapsed;
|
|
if (remaining == 0) {
|
|
return error.Timeout;
|
|
}
|
|
for (session.pages.items) |p| {
|
|
if (p.replacement == null) {
|
|
try runner.waitForScript(p.frame._frame_id, wait_script, remaining);
|
|
}
|
|
}
|
|
}
|
|
|
|
const writer = opts.writer orelse return;
|
|
|
|
if (opts.json) {
|
|
// A single url keeps the original bare-object output. Multiple urls are
|
|
// wrapped in an extensible `{"results": [...]}` envelope: a bare
|
|
// top-level array is hard to evolve (consumers index it directly),
|
|
// whereas an object lets us add sibling fields later without breaking
|
|
// anyone reading `results`.
|
|
const wrap = pages.items.len > 1;
|
|
if (wrap) {
|
|
try writer.writeAll("{\"results\":[");
|
|
}
|
|
for (pages.items, 0..) |page, i| {
|
|
if (i != 0) {
|
|
try writer.writeByte(',');
|
|
}
|
|
|
|
var aw: std.Io.Writer.Allocating = .init(app.allocator);
|
|
defer aw.deinit();
|
|
|
|
if (opts.dump_mode) |mode| blk: {
|
|
const frame = page.frame() orelse break :blk;
|
|
try dumpContent(app, mode, opts.dump, frame, &aw.writer);
|
|
}
|
|
|
|
try writeJsonEnvelope(writer, page.frame(), opts.dump_mode, aw.written());
|
|
}
|
|
if (wrap) {
|
|
try writer.writeAll("]}");
|
|
}
|
|
try writer.writeByte('\n');
|
|
} else {
|
|
// main validates that non-JSON dump is only reached with a single url.
|
|
const page = pages.items[0];
|
|
if (opts.dump_mode) |mode| blk: {
|
|
const frame = page.frame() orelse {
|
|
try writer.writeAll("Frame closed. Please open a bug report including the URL\n");
|
|
break :blk;
|
|
};
|
|
try dumpContent(app, mode, opts.dump, frame, writer);
|
|
}
|
|
}
|
|
try writer.flush();
|
|
}
|
|
|
|
fn dumpContent(app: *App, mode: Config.DumpFormat, dump_opts: dump.Opts, frame: *Frame, writer: *std.Io.Writer) !void {
|
|
switch (mode) {
|
|
.html => try dump.root(frame.window._document, dump_opts, writer, frame),
|
|
.markdown => try markdown.dump(frame.window._document.asNode(), .{}, writer, frame),
|
|
.semantic_tree, .semantic_tree_text => {
|
|
var registry = CDPNode.Registry.init(app.allocator);
|
|
defer registry.deinit();
|
|
|
|
const st: SemanticTree = .{
|
|
.dom_node = frame.window._document.asNode(),
|
|
.registry = ®istry,
|
|
.frame = frame,
|
|
.arena = frame.call_arena,
|
|
.prune = (mode == .semantic_tree_text),
|
|
};
|
|
|
|
if (mode == .semantic_tree) {
|
|
try std.json.Stringify.value(st, .{}, writer);
|
|
} else {
|
|
try st.textStringify(writer);
|
|
}
|
|
},
|
|
.wpt => try dumpWPT(frame, writer),
|
|
}
|
|
}
|
|
|
|
pub fn checkVersion(allocator: std.mem.Allocator, config: *const Config) !void {
|
|
var client = try Updater.init(allocator, config);
|
|
defer client.deinit();
|
|
|
|
const stdout = std.Io.File.stdout();
|
|
var buf: [4096]u8 = undefined;
|
|
var writer = stdout.writer(io, &buf);
|
|
const w = &writer.interface;
|
|
try client.inform(w);
|
|
}
|
|
|
|
// Writes a single page's result object. Framing (the enclosing array and any
|
|
// separators / trailing newline) is the caller's responsibility.
|
|
fn writeJsonEnvelope(writer: *std.Io.Writer, frame: ?*Frame, dump_mode: ?Config.DumpFormat, content: []const u8) !void {
|
|
const meta: ?Frame.HttpMetadata = if (frame) |f| f.httpMetadata() else null;
|
|
try std.json.Stringify.value(.{
|
|
.url = if (meta) |m| m.url else "",
|
|
.http_status = if (meta) |m| m.status orelse 0 else 0,
|
|
.headers = if (meta) |m| m.headers else &.{},
|
|
.dump = if (dump_mode) |mode| @tagName(mode) else "",
|
|
.content = content,
|
|
}, .{}, writer);
|
|
}
|
|
|
|
fn dumpWPT(frame: *Frame, writer: *std.Io.Writer) !void {
|
|
var ls: js.Local.Scope = undefined;
|
|
frame.js.localScope(&ls);
|
|
defer ls.deinit();
|
|
|
|
var try_catch: js.TryCatch = undefined;
|
|
try_catch.init(&ls.local);
|
|
defer try_catch.deinit();
|
|
|
|
// return the detailed result.
|
|
const dump_script =
|
|
\\ JSON.stringify((() => {
|
|
\\ const statuses = ['Pass', 'Fail', 'Timeout', 'Not Run', 'Optional Feature Unsupported'];
|
|
\\ const parse = (raw) => {
|
|
\\ for (const status of statuses) {
|
|
\\ const idx = raw.indexOf('|' + status);
|
|
\\ if (idx !== -1) {
|
|
\\ const name = raw.slice(0, idx);
|
|
\\ const rest = raw.slice(idx + status.length + 1);
|
|
\\ const message = rest.length > 0 && rest[0] === '|' ? rest.slice(1) : null;
|
|
\\ return { name, status, message };
|
|
\\ }
|
|
\\ }
|
|
\\ return { name: raw, status: 'Unknown', message: null };
|
|
\\ };
|
|
\\ const cases = Object.values(report.cases).map(parse);
|
|
\\ return {
|
|
\\ url: window.location.href,
|
|
\\ status: report.status,
|
|
\\ message: report.message,
|
|
\\ summary: {
|
|
\\ total: cases.length,
|
|
\\ passed: cases.filter(c => c.status === 'Pass').length,
|
|
\\ failed: cases.filter(c => c.status === 'Fail').length,
|
|
\\ timeout: cases.filter(c => c.status === 'Timeout').length,
|
|
\\ notrun: cases.filter(c => c.status === 'Not Run').length,
|
|
\\ unsupported: cases.filter(c => c.status === 'Optional Feature Unsupported').length
|
|
\\ },
|
|
\\ not_passed: cases.filter(c => c.status !== 'Pass')
|
|
\\ };
|
|
\\ })(), null, 2)
|
|
;
|
|
const value = ls.local.exec(dump_script, "dump_script") catch |err| {
|
|
const caught = try_catch.caughtOrError(frame.call_arena, err);
|
|
return writer.print("Caught error trying to access WPT's report: {f}\n", .{caught});
|
|
};
|
|
try writer.writeAll("== WPT Results==\n");
|
|
try writer.writeAll(try value.toStringSliceWithAlloc(frame.call_arena));
|
|
}
|
|
|
|
pub inline fn assert(ok: bool, comptime ctx: []const u8, args: anytype) void {
|
|
if (!ok) {
|
|
assertionFailure(ctx, args);
|
|
}
|
|
}
|
|
|
|
noinline fn assertionFailure(comptime ctx: []const u8, args: anytype) noreturn {
|
|
@branchHint(.cold);
|
|
if (@inComptime()) {
|
|
@compileError(std.fmt.comptimePrint("assertion failure: " ++ ctx, args));
|
|
}
|
|
@import("crash_handler.zig").crash(ctx, args, @returnAddress());
|
|
}
|
|
|
|
// Written into every RC at construction (rc_canary) and overwritten with
|
|
// rc_poison on the final release. We only get crash reports (not logs) from
|
|
// prod, so reading _canary in the "release overflow" assert tells us which kind
|
|
// of bug it is:
|
|
// - rc_canary ("RCNT"): the struct still looks live -> a real refcount
|
|
// accounting bug, OR the memory was reused by a freshly-built RC (which
|
|
// re-stamps the canary, so this case can't be fully ruled out).
|
|
// - rc_poison ("DEADC0DE"): a stale finalizer fired again on an object we
|
|
// already released, before its memory was reused -> UAF.
|
|
// - anything else: the memory was freed and reused by non-RC data -> UAF.
|
|
const rc_canary: u32 = 0x52434E54;
|
|
const rc_poison: u32 = 0xDEADC0DE;
|
|
|
|
// Reference counting helper. The count is a u32: a u8 silently wrapped at 256
|
|
// concurrent refs (e.g. hundreds of live iterators on one URLSearchParams),
|
|
// causing a premature deinit and a poisoned "release overflow" crash.
|
|
pub const RC = struct {
|
|
_refs: std.atomic.Value(u32) = .init(0),
|
|
_canary: u32 = rc_canary,
|
|
|
|
pub fn init(refs: u32) RC {
|
|
return .{ ._refs = .init(refs) };
|
|
}
|
|
|
|
pub fn acquire(self: *RC) void {
|
|
_ = self._refs.fetchAdd(1, .monotonic);
|
|
}
|
|
|
|
pub fn release(self: *RC, value: anytype, page: *Page) void {
|
|
const prev = self._refs.fetchSub(1, .acq_rel);
|
|
assert(prev > 0, "release overflow", .{
|
|
.type = @typeName(@TypeOf(value)),
|
|
.canary = self._canary, // rc_canary=live/accounting, rc_poison=double-release, else=reuse
|
|
.refs = prev,
|
|
.ptr = @intFromPtr(value),
|
|
});
|
|
if (prev == 1) {
|
|
// Mark dead before deinit frees this memory, so a stale
|
|
// weak-callback re-fire reads rc_poison instead of a
|
|
// misleadingly-intact canary.
|
|
self._canary = rc_poison;
|
|
value.deinit(page);
|
|
}
|
|
}
|
|
|
|
pub fn format(self: RC, writer: *std.Io.Writer) !void {
|
|
return writer.print("{d}", .{self._refs.load(.monotonic)});
|
|
}
|
|
};
|
|
|
|
const testing = @import("testing.zig");
|
|
test "writeJsonEnvelope: null frame" {
|
|
var aw: std.Io.Writer.Allocating = .init(testing.allocator);
|
|
defer aw.deinit();
|
|
|
|
try writeJsonEnvelope(&aw.writer, null, null, "");
|
|
try testing.expectJson(.{
|
|
.url = "",
|
|
.http_status = 0,
|
|
.dump = "",
|
|
.content = "",
|
|
}, aw.written());
|
|
}
|
|
|
|
test "writeJsonEnvelope: null frame with dump mode and content" {
|
|
var aw: std.Io.Writer.Allocating = .init(testing.allocator);
|
|
defer aw.deinit();
|
|
|
|
try writeJsonEnvelope(&aw.writer, null, .html, "<html><body>hello</body></html>");
|
|
try testing.expectJson(.{
|
|
.dump = "html",
|
|
.content = "<html><body>hello</body></html>",
|
|
}, aw.written());
|
|
}
|
|
|
|
test {
|
|
std.testing.refAllDecls(@This());
|
|
}
|