mirror of
https://github.com/lightpanda-io/browser.git
synced 2026-03-24 17:33:06 -04:00
This is done for a couple reasons. The first is just to have things a little more self-contained for eventually supporting more advanced "wait" logic, e.g. waiting for a selector. The other is to provide callers with more fine-grained controlled. Specifically the ability to manually "tick", so that they can [presumably] do something after every tick. This is needed by the test runner to support more advanced cases (cases that need to test beyond 'load') and it also improves (and fixes potential use-after-free, the lp.waitForSelector)
476 lines
15 KiB
Zig
476 lines
15 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");
|
|
const lp = @import("lightpanda");
|
|
const builtin = @import("builtin");
|
|
|
|
const log = @import("../log.zig");
|
|
const App = @import("../App.zig");
|
|
|
|
const js = @import("js/js.zig");
|
|
const v8 = js.v8;
|
|
const storage = @import("webapi/storage/storage.zig");
|
|
const Navigation = @import("webapi/navigation/Navigation.zig");
|
|
const History = @import("webapi/History.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 = Page.QueuedNavigation;
|
|
|
|
const Allocator = std.mem.Allocator;
|
|
const ArenaPool = App.ArenaPool;
|
|
const IS_DEBUG = builtin.mode == .Debug;
|
|
|
|
// You can create successively multiple pages for a session, but you must
|
|
// deinit a page before running another one. It manages two distinct lifetimes.
|
|
//
|
|
// The first is the lifetime of the Session itself, where pages 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 page hierarchy, i.e. \
|
|
// the root page and all of its frames (and all of their frames.)
|
|
const Session = @This();
|
|
|
|
// These are the fields that remain intact for the duration of the Session
|
|
browser: *Browser,
|
|
arena: Allocator,
|
|
history: History,
|
|
navigation: Navigation,
|
|
storage_shed: storage.Shed,
|
|
notification: *Notification,
|
|
cookie_jar: storage.Cookie.Jar,
|
|
|
|
// These are the fields that get reset whenever the Session's page (the root) is reset.
|
|
factory: Factory,
|
|
|
|
page_arena: Allocator,
|
|
|
|
// Origin map for same-origin context sharing. Scoped to the root page 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 resources for all pages in this session.
|
|
// These live for the duration of the page tree (root + frames).
|
|
arena_pool: *ArenaPool,
|
|
|
|
page: ?Page,
|
|
|
|
queued_navigation: std.ArrayList(*Page),
|
|
// 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(*Page),
|
|
|
|
page_id_gen: u32 = 0,
|
|
frame_id_gen: u32 = 0,
|
|
|
|
pub fn init(self: *Session, browser: *Browser, notification: *Notification) !void {
|
|
const allocator = browser.app.allocator;
|
|
const arena_pool = browser.arena_pool;
|
|
|
|
const arena = try arena_pool.acquire(.{ .debug = "Session" });
|
|
errdefer arena_pool.release(arena);
|
|
|
|
const page_arena = try arena_pool.acquire(.{ .debug = "Session.page_arena" });
|
|
errdefer arena_pool.release(page_arena);
|
|
|
|
self.* = .{
|
|
.page = null,
|
|
.arena = arena,
|
|
.arena_pool = arena_pool,
|
|
.page_arena = page_arena,
|
|
.factory = Factory.init(page_arena),
|
|
.history = .{},
|
|
// The prototype (EventTarget) for Navigation is created when a Page is created.
|
|
.navigation = .{ ._proto = undefined },
|
|
.storage_shed = .{},
|
|
.browser = browser,
|
|
.queued_navigation = .{},
|
|
.queued_queued_navigation = .{},
|
|
.notification = notification,
|
|
.cookie_jar = storage.Cookie.Jar.init(allocator),
|
|
};
|
|
}
|
|
|
|
pub fn deinit(self: *Session) void {
|
|
if (self.page != null) {
|
|
self.removePage();
|
|
}
|
|
self.cookie_jar.deinit();
|
|
|
|
self.storage_shed.deinit(self.browser.app.allocator);
|
|
self.arena_pool.release(self.page_arena);
|
|
self.arena_pool.release(self.arena);
|
|
}
|
|
|
|
// NOTE: the caller is not the owner of the returned value,
|
|
// the pointer on Page is just returned as a convenience
|
|
pub fn createPage(self: *Session) !*Page {
|
|
lp.assert(self.page == null, "Session.createPage - page not null", .{});
|
|
|
|
self.page = @as(Page, undefined);
|
|
const page = &self.page.?;
|
|
try Page.init(page, self.nextFrameId(), self, null);
|
|
|
|
// Creates a new NavigationEventTarget for this page.
|
|
try self.navigation.onNewPage(page);
|
|
|
|
if (comptime IS_DEBUG) {
|
|
log.debug(.browser, "create page", .{});
|
|
}
|
|
// start JS env
|
|
// Inform CDP the main page has been created such that additional context for other Worlds can be created as well
|
|
self.notification.dispatch(.page_created, page);
|
|
|
|
return page;
|
|
}
|
|
|
|
pub fn removePage(self: *Session) void {
|
|
// Inform CDP the page is going to be removed, allowing other worlds to remove themselves before the main one
|
|
self.notification.dispatch(.page_remove, .{});
|
|
lp.assert(self.page != null, "Session.removePage - page is null", .{});
|
|
|
|
self.page.?.deinit(false);
|
|
self.page = null;
|
|
|
|
self.navigation.onRemovePage();
|
|
self.resetPageResources();
|
|
|
|
if (comptime IS_DEBUG) {
|
|
log.debug(.browser, "remove page", .{});
|
|
}
|
|
}
|
|
|
|
pub const GetArenaOpts = struct {
|
|
debug: []const u8,
|
|
};
|
|
|
|
pub fn getArena(self: *Session, opts: GetArenaOpts) !Allocator {
|
|
return self.arena_pool.acquire(.{ .debug = opts.debug });
|
|
}
|
|
|
|
pub fn releaseArena(self: *Session, allocator: Allocator) void {
|
|
self.arena_pool.release(allocator);
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
/// Reset page_arena and factory for a clean slate.
|
|
/// Called when root page is removed.
|
|
fn resetPageResources(self: *Session) void {
|
|
self.identity.deinit();
|
|
self.identity = .{};
|
|
|
|
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;
|
|
}
|
|
|
|
self.frame_id_gen = 0;
|
|
self.arena_pool.reset(self.page_arena, 64 * 1024);
|
|
self.factory = Factory.init(self.page_arena);
|
|
}
|
|
|
|
pub fn replacePage(self: *Session) !*Page {
|
|
if (comptime IS_DEBUG) {
|
|
log.debug(.browser, "replace page", .{});
|
|
}
|
|
|
|
lp.assert(self.page != null, "Session.replacePage null page", .{});
|
|
lp.assert(self.page.?.parent == null, "Session.replacePage with parent", .{});
|
|
|
|
var current = self.page.?;
|
|
const frame_id = current._frame_id;
|
|
current.deinit(true);
|
|
|
|
self.resetPageResources();
|
|
self.browser.env.memoryPressureNotification(.moderate);
|
|
|
|
self.page = @as(Page, undefined);
|
|
const page = &self.page.?;
|
|
try Page.init(page, frame_id, self, null);
|
|
return page;
|
|
}
|
|
|
|
pub fn currentPage(self: *Session) ?*Page {
|
|
return &(self.page orelse return null);
|
|
}
|
|
|
|
pub fn findPageByFrameId(self: *Session, frame_id: u32) ?*Page {
|
|
const page = self.currentPage() orelse return null;
|
|
return findPageBy(page, "_frame_id", frame_id);
|
|
}
|
|
|
|
pub fn findPageById(self: *Session, id: u32) ?*Page {
|
|
const page = self.currentPage() orelse return null;
|
|
return findPageBy(page, "id", id);
|
|
}
|
|
|
|
fn findPageBy(page: *Page, comptime field: []const u8, id: u32) ?*Page {
|
|
if (@field(page, field) == id) return page;
|
|
for (page.frames.items) |f| {
|
|
if (findPageBy(f, field, id)) |found| {
|
|
return found;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
pub fn runner(self: *Session, opts: Runner.Opts) !Runner {
|
|
return Runner.init(self, opts);
|
|
}
|
|
|
|
pub fn scheduleNavigation(self: *Session, page: *Page) !void {
|
|
const list = &self.queued_navigation;
|
|
|
|
// Check if page is already queued
|
|
for (list.items) |existing| {
|
|
if (existing == page) {
|
|
// Already queued
|
|
return;
|
|
}
|
|
}
|
|
|
|
return list.append(self.arena, page);
|
|
}
|
|
|
|
pub fn processQueuedNavigation(self: *Session) !void {
|
|
const navigations = &self.queued_navigation;
|
|
|
|
if (self.page.?._queued_navigation != null) {
|
|
// This is both an optimization and a simplification of sorts. If the
|
|
// root page is navigating, then we don't need to process any other
|
|
// navigation. Also, the navigation for the root page and for a frame
|
|
// is different enough that have two distinct code blocks is, imo,
|
|
// better. Yes, there will be duplication.
|
|
navigations.clearRetainingCapacity();
|
|
return self.processRootQueuedNavigation();
|
|
}
|
|
|
|
const about_blank_queue = &self.queued_queued_navigation;
|
|
defer about_blank_queue.clearRetainingCapacity();
|
|
|
|
// First pass: process async navigations (non-about:blank)
|
|
// These cannot cause re-entrant navigation scheduling
|
|
for (navigations.items) |page| {
|
|
const qn = page._queued_navigation.?;
|
|
|
|
if (qn.is_about_blank) {
|
|
// Defer about:blank to second pass
|
|
try about_blank_queue.append(self.arena, page);
|
|
continue;
|
|
}
|
|
|
|
self.processFrameNavigation(page, qn) catch |err| {
|
|
log.warn(.page, "frame navigation", .{ .url = qn.url, .err = err });
|
|
};
|
|
}
|
|
|
|
// Clear the queue after first pass
|
|
navigations.clearRetainingCapacity();
|
|
|
|
// Second pass: process synchronous navigations (about:blank)
|
|
// These may trigger new navigations which go into queued_navigation
|
|
for (about_blank_queue.items) |page| {
|
|
const qn = page._queued_navigation.?;
|
|
try self.processFrameNavigation(page, qn);
|
|
}
|
|
|
|
// Safety: Remove any about:blank navigations that were queued during the
|
|
// second pass to prevent infinite loops
|
|
var i: usize = 0;
|
|
while (i < navigations.items.len) {
|
|
const page = navigations.items[i];
|
|
if (page._queued_navigation) |qn| {
|
|
if (qn.is_about_blank) {
|
|
log.warn(.page, "recursive about blank", .{});
|
|
_ = navigations.swapRemove(i);
|
|
continue;
|
|
}
|
|
}
|
|
i += 1;
|
|
}
|
|
}
|
|
|
|
fn processFrameNavigation(self: *Session, page: *Page, qn: *QueuedNavigation) !void {
|
|
lp.assert(page.parent != null, "root queued navigation", .{});
|
|
|
|
const iframe = page.iframe.?;
|
|
const parent = page.parent.?;
|
|
|
|
page._queued_navigation = null;
|
|
defer self.releaseArena(qn.arena);
|
|
|
|
errdefer iframe._window = null;
|
|
|
|
const parent_notified = page._parent_notified;
|
|
if (parent_notified) {
|
|
// we already notified the parent that we had loaded
|
|
parent._pending_loads += 1;
|
|
}
|
|
|
|
const frame_id = page._frame_id;
|
|
page.deinit(true);
|
|
page.* = undefined;
|
|
|
|
try Page.init(page, frame_id, self, parent);
|
|
errdefer {
|
|
for (parent.frames.items, 0..) |frame, i| {
|
|
if (frame == page) {
|
|
parent.frames_sorted = false;
|
|
_ = parent.frames.swapRemove(i);
|
|
break;
|
|
}
|
|
}
|
|
if (parent_notified) {
|
|
parent._pending_loads -= 1;
|
|
}
|
|
page.deinit(true);
|
|
}
|
|
|
|
page.iframe = iframe;
|
|
iframe._window = page.window;
|
|
|
|
page.navigate(qn.url, qn.opts) catch |err| {
|
|
log.err(.browser, "queued frame navigation error", .{ .err = err });
|
|
return err;
|
|
};
|
|
}
|
|
|
|
fn processRootQueuedNavigation(self: *Session) !void {
|
|
const current_page = &self.page.?;
|
|
const frame_id = current_page._frame_id;
|
|
|
|
// create a copy before the page is cleared
|
|
const qn = current_page._queued_navigation.?;
|
|
current_page._queued_navigation = null;
|
|
|
|
defer self.arena_pool.release(qn.arena);
|
|
|
|
self.removePage();
|
|
|
|
self.page = @as(Page, undefined);
|
|
const new_page = &self.page.?;
|
|
try Page.init(new_page, frame_id, self, null);
|
|
|
|
// Creates a new NavigationEventTarget for this page.
|
|
try self.navigation.onNewPage(new_page);
|
|
|
|
// start JS env
|
|
// Inform CDP the main page has been created such that additional context for other Worlds can be created as well
|
|
self.notification.dispatch(.page_created, new_page);
|
|
|
|
new_page.navigate(qn.url, qn.opts) catch |err| {
|
|
log.err(.browser, "queued navigation error", .{ .err = err });
|
|
return err;
|
|
};
|
|
}
|
|
|
|
pub fn nextFrameId(self: *Session) u32 {
|
|
const id = self.frame_id_gen +% 1;
|
|
self.frame_id_gen = id;
|
|
return id;
|
|
}
|
|
|
|
pub fn nextPageId(self: *Session) u32 {
|
|
const id = self.page_id_gen +% 1;
|
|
self.page_id_gen = id;
|
|
return id;
|
|
}
|
|
|
|
// A type that has a finalizer can have its finalizer called one of two ways.
|
|
// The first is from V8 via the WeakCallback we give to weakRef. But that isn't
|
|
// guaranteed to fire, so we track this in finalizer_callbacks and call them on
|
|
// page reset.
|
|
pub const FinalizerCallback = struct {
|
|
arena: Allocator,
|
|
session: *Session,
|
|
ptr: *anyopaque,
|
|
global: v8.Global,
|
|
identity: *js.Identity,
|
|
zig_finalizer: *const fn (ptr: *anyopaque, session: *Session) void,
|
|
|
|
pub fn deinit(self: *FinalizerCallback) void {
|
|
self.zig_finalizer(self.ptr, self.session);
|
|
self.session.releaseArena(self.arena);
|
|
}
|
|
|
|
/// Release this item from the identity tracking maps (called after finalizer runs from V8)
|
|
pub fn releaseIdentity(self: *FinalizerCallback) void {
|
|
const session = self.session;
|
|
const id = @intFromPtr(self.ptr);
|
|
|
|
if (self.identity.identity_map.fetchRemove(id)) |kv| {
|
|
var global = kv.value;
|
|
v8.v8__Global__Reset(&global);
|
|
}
|
|
|
|
_ = self.identity.finalizer_callbacks.remove(id);
|
|
|
|
session.releaseArena(self.arena);
|
|
}
|
|
};
|