Files
browser/src/Notification.zig

627 lines
21 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 js = @import("browser/js/js.zig");
const Frame = @import("browser/Frame.zig");
const Transfer = @import("network/HttpClient.zig").Transfer;
const log = lp.log;
const Execution = js.Execution;
const List = std.DoublyLinkedList;
const Allocator = std.mem.Allocator;
// Allows code to register for and emit events.
// Keeps two lists
// 1 - for a given event type, a linked list of all the listeners
// 2 - for a given listener, a list of all it's registration
// The 2nd one is so that a listener can unregister all of it's listeners
// (there's currently no need for a listener to unregister only 1 or more
// specific listener).
//
// Scoping is important. Imagine we created a global singleton registry, and our
// CDP code registers for the "network_bytes_sent" event, because it needs to
// send messages to the client when this happens. Our HTTP client could then
// emit a "network_bytes_sent" message. It would be easy, and it would work.
// That is, it would work until multiple CDP clients connect, and because
// everything's just one big global, events from one CDP session would be sent
// to all CDP clients.
//
// To avoid this, one way or another, we need scoping. We could still have
// a global registry but every "register" and every "emit" has some type of
// "scope". This would have a run-time cost and still require some coordination
// between components to share a common scope.
//
// Instead, the approach that we take is to have a notification instance per
// CDP connection (BrowserContext). Each CDP connection has its own notification
// that is shared across all Sessions (tabs) within that connection. This ensures
// proper isolation between different CDP clients while allowing a single client
// to receive events from all its tabs.
const ModelContextTool = @import("browser/webapi/ModelContext.zig").Tool;
const Cookie = @import("browser/webapi/storage/Cookie.zig");
const Notification = @This();
// Every event type (which are hard-coded), has a list of Listeners.
// When the event happens, we dispatch to those listener.
event_listeners: EventListeners,
// list of listeners for a specified receiver
// @intFromPtr(receiver) -> [listener1, listener2, ...]
// Used when `unregisterAll` is called.
listeners: std.AutoHashMapUnmanaged(usize, std.ArrayList(*Listener)),
allocator: Allocator,
mem_pool: std.heap.MemoryPool(Listener),
const EventListeners = struct {
frame_remove: List = .{},
frame_created: List = .{},
frame_navigate: List = .{},
frame_navigated: List = .{},
frame_navigated_within_document: List = .{},
frame_navigate_failed: List = .{},
frame_network_idle: List = .{},
frame_network_almost_idle: List = .{},
frame_child_frame_created: List = .{},
frame_dom_content_loaded: List = .{},
frame_loaded: List = .{},
http_request_fail: List = .{},
http_request_start: List = .{},
http_request_intercept: List = .{},
http_request_done: List = .{},
http_request_auth_required: List = .{},
http_request_served_from_cache: List = .{},
http_response_data: List = .{},
http_response_header_done: List = .{},
javascript_dialog_opening: List = .{},
console_message: List = .{},
runtime_console_message: List = .{},
model_context_tool_added: List = .{},
model_context_tool_removed: List = .{},
cookie_changed: List = .{},
download_will_begin: List = .{},
download_progress: List = .{},
};
const Events = union(enum) {
frame_remove: FrameRemove,
frame_created: *Frame,
frame_navigate: *const FrameNavigate,
frame_navigated: *const FrameNavigated,
frame_navigated_within_document: *const FrameNavigatedWithinDocument,
frame_navigate_failed: *const FrameNavigateFailed,
frame_network_idle: *const FrameNetworkIdle,
frame_network_almost_idle: *const FrameNetworkAlmostIdle,
frame_child_frame_created: *const FrameChildFrameCreated,
frame_dom_content_loaded: *const FrameDOMContentLoaded,
frame_loaded: *const FrameLoaded,
http_request_fail: *const RequestFail,
http_request_start: *const RequestStart,
http_request_intercept: *const RequestIntercept,
http_request_auth_required: *const RequestAuthRequired,
http_request_served_from_cache: *const RequestServedFromCache,
http_request_done: *const RequestDone,
http_response_data: *const ResponseData,
http_response_header_done: *const ResponseHeaderDone,
javascript_dialog_opening: *const JavascriptDialogOpening,
console_message: *const ConsoleMessage,
runtime_console_message: *const ConsoleMessage,
model_context_tool_added: *const ModelContextToolEvent,
model_context_tool_removed: *const ModelContextToolEvent,
cookie_changed: *const CookieChanged,
download_will_begin: *const DownloadWillBegin,
download_progress: *const DownloadProgress,
};
const EventType = std.meta.FieldEnum(Events);
pub const FrameRemove = struct {};
pub const FrameNavigate = struct {
req_id: u32,
frame_id: u32,
loader_id: u32,
timestamp: u64,
url: [:0]const u8,
opts: Frame.NavigateOpts,
// True when this navigation is being issued against a Page that is in
// .pending state (i.e. an in-flight root navigation whose old Page is
// still alive). CDP uses this to skip BrowserContext.reset() — the old
// page's nodes must remain live and addressable until commit.
is_pending_root: bool = false,
};
pub const FrameNavigated = struct {
req_id: u32,
frame_id: u32,
loader_id: u32,
timestamp: u64,
url: [:0]const u8,
opts: Frame.NavigatedOpts,
};
// A same-document navigation (History pushState/replaceState, fragment
// changes, or history traversal). The document and its execution context
// stay alive — only the URL changes — so CDP must emit
// Page.navigatedWithinDocument WITHOUT clearing the execution context or
// sending DOM.documentUpdated.
pub const FrameNavigatedWithinDocument = struct {
frame_id: u32,
url: [:0]const u8,
navigation_type: NavigationType = .other,
pub const NavigationType = enum {
fragment,
historyApi,
other,
};
};
// A root navigation that failed before commit (DNS failure, connection
// refused, malformed response, ...). Dispatched so CDP can answer the
// pending Page.navigate command with an errorText instead of leaving the
// command id unanswered forever.
pub const FrameNavigateFailed = struct {
frame_id: u32,
loader_id: u32,
timestamp: u64,
url: [:0]const u8,
err: anyerror,
opts: Frame.NavigatedOpts,
};
pub const FrameNetworkIdle = struct {
req_id: u32,
frame_id: u32,
loader_id: u32,
timestamp: u64,
};
pub const FrameNetworkAlmostIdle = struct {
req_id: u32,
frame_id: u32,
loader_id: u32,
timestamp: u64,
};
pub const FrameChildFrameCreated = struct {
frame_id: u32,
loader_id: u32,
parent_id: u32,
timestamp: u64,
};
pub const FrameDOMContentLoaded = struct {
req_id: u32,
frame_id: u32,
loader_id: u32,
timestamp: u64,
};
pub const FrameLoaded = struct {
req_id: u32,
frame_id: u32,
loader_id: u32,
timestamp: u64,
};
pub const RequestStart = struct {
transfer: *Transfer,
};
pub const RequestIntercept = struct {
transfer: *Transfer,
wait_for_interception: *bool,
};
pub const RequestAuthRequired = struct {
transfer: *Transfer,
wait_for_interception: *bool,
};
pub const ResponseData = struct {
data: []const u8,
transfer: *Transfer,
};
pub const ResponseHeaderDone = struct {
transfer: *Transfer,
};
pub const RequestDone = struct {
transfer: *Transfer,
content_length: usize,
};
pub const RequestFail = struct {
pub const BlockedReason = enum {
inspector,
};
transfer: *Transfer,
err: anyerror,
blocked_reason: ?BlockedReason = null,
};
pub const RequestServedFromCache = struct {
transfer: *Transfer,
};
pub const JavascriptDialogOpening = struct {
url: [:0]const u8,
message: []const u8,
dialog_type: []const u8,
// Output param. The CDP listener may set this from a pre-armed response
// queued by Page.handleJavaScriptDialog. The dispatcher (alert/confirm/
// prompt in Window.zig) reads it back to decide what to return to JS.
// Headless mode auto-dismisses if no listener fills it in: confirm→false,
// prompt→null, alert→void (default-zero DialogResponse).
response: *DialogResponse,
};
pub const ModelContextToolEvent = struct {
exec: *const Execution,
tool: *const ModelContextTool,
};
// Fired by Cookie.Jar after a mutation. Pointers and slices borrow from the
// dispatcher's stack — listeners must copy whatever they need to retain.
pub const CookieChanged = struct {
kind: Kind,
name: []const u8,
value: []const u8,
// Cookie domain in jar-canonical form (leading "." for explicit Domain
// attribute; bare host otherwise).
domain: []const u8,
path: []const u8,
secure: bool,
http_only: bool,
same_site: Cookie.SameSite,
pub const Kind = enum { changed, deleted };
};
// Emitted by Frame when a navigation response is treated as a file download
// (Content-Disposition: attachment) under an `allow`/`allowAndName`
// `Browser.setDownloadBehavior`. The CDP browser domain turns this into a
// `Browser.downloadWillBegin` event. All fields point at Frame-owned memory that
// outlives the synchronous dispatch.
pub const DownloadWillBegin = struct {
frame_id: u32,
guid: []const u8,
url: []const u8,
suggested_filename: []const u8,
};
// Emitted by Frame as a download is written to disk: once when it starts
// (`.in_progress`) and once when the body is fully written (`.completed`).
// The CDP browser domain turns this into a `Browser.downloadProgress` event.
pub const DownloadProgress = struct {
guid: []const u8,
total_bytes: u64,
received_bytes: u64,
state: State,
pub const State = enum {
in_progress,
completed,
canceled,
};
};
pub const DialogResponse = struct {
accept: bool = false,
// Set when the CDP client sent a `promptText` with `accept: true`. Memory
// is owned by whoever filled in the response (typically the BrowserContext
// arena) and must outlive a single dispatch call.
prompt_text: ?[]const u8 = null,
};
pub const ConsoleMessageType = enum {
log,
debug,
info,
warning,
@"error",
fatal,
trace,
};
pub const ConsoleMessage = struct {
timestamp: u64,
source: enum {
xml,
javascript,
network,
console_api,
storage,
appcache,
rendering,
security,
other,
deprecation,
worker,
},
type: ConsoleMessageType,
values: []js.Value,
url: ?[]const u8 = null,
line: ?u32 = null,
columns: ?u32 = null,
};
pub fn init(allocator: Allocator) !*Notification {
const notification = try allocator.create(Notification);
errdefer allocator.destroy(notification);
notification.* = .{
.listeners = .{},
.mem_pool = .empty,
.event_listeners = .{},
.allocator = allocator,
};
return notification;
}
pub fn deinit(self: *Notification) void {
const allocator = self.allocator;
var it = self.listeners.valueIterator();
while (it.next()) |listener| {
listener.deinit(allocator);
}
self.listeners.deinit(allocator);
self.mem_pool.deinit(allocator);
allocator.destroy(self);
}
pub fn register(self: *Notification, comptime event: EventType, receiver: anytype, func: EventFunc(event)) !void {
const allocator = self.allocator;
const gop = try self.listeners.getOrPut(allocator, @intFromPtr(receiver));
if (gop.found_existing) {
for (gop.value_ptr.items) |existing| {
if (existing.event == event) {
lp.assert(@as(*const anyopaque, @ptrCast(func)) == existing.func, "different notification callbacks per receiver", .{ .event = event });
return;
}
}
} else {
gop.value_ptr.* = .empty;
}
var list = &@field(self.event_listeners, @tagName(event));
var listener = try self.mem_pool.create(self.allocator);
errdefer self.mem_pool.destroy(listener);
listener.* = .{
.node = .{},
.list = list,
.receiver = receiver,
.event = event,
.func = @ptrCast(func),
.struct_name = @typeName(@typeInfo(@TypeOf(receiver)).pointer.child),
};
try gop.value_ptr.append(allocator, listener);
// we don't add this until we've successfully added the entry to
// self.listeners
list.append(&listener.node);
}
pub fn unregister(self: *Notification, comptime event: EventType, receiver: anytype) void {
var listeners = self.listeners.getPtr(@intFromPtr(receiver)) orelse return;
var i: usize = 0;
while (i < listeners.items.len) {
const listener = listeners.items[i];
if (listener.event != event) {
i += 1;
continue;
}
listener.list.remove(&listener.node);
self.mem_pool.destroy(listener);
_ = listeners.swapRemove(i);
}
if (listeners.items.len == 0) {
listeners.deinit(self.allocator);
const removed = self.listeners.remove(@intFromPtr(receiver));
lp.assert(removed == true, "Notification.unregister", .{ .type = event });
}
}
pub fn unregisterAll(self: *Notification, receiver: *anyopaque) void {
var kv = self.listeners.fetchRemove(@intFromPtr(receiver)) orelse return;
for (kv.value.items) |listener| {
listener.list.remove(&listener.node);
self.mem_pool.destroy(listener);
}
kv.value.deinit(self.allocator);
}
pub fn dispatch(self: *Notification, comptime event: EventType, data: ArgType(event)) void {
if (self.listeners.count() == 0) {
return;
}
const list = &@field(self.event_listeners, @tagName(event));
var node = list.first;
while (node) |n| {
const listener: *Listener = @fieldParentPtr("node", n);
const func: EventFunc(event) = @ptrCast(@alignCast(listener.func));
func(listener.receiver, data) catch |err| {
log.err(.app, "dispatch error", .{
.err = err,
.event = event,
.source = "notification",
.listener = listener.struct_name,
});
};
node = n.next;
}
}
// Given an event type enum, returns the type of arg the event emits
fn ArgType(comptime event: Notification.EventType) type {
inline for (std.meta.fields(Notification.Events)) |f| {
if (std.mem.eql(u8, f.name, @tagName(event))) {
return f.type;
}
}
unreachable;
}
// Given an event type enum, returns the listening function type
fn EventFunc(comptime event: Notification.EventType) type {
return *const fn (*anyopaque, ArgType(event)) anyerror!void;
}
// A listener. This is 1 receiver, with its function, and the linked list
// node that goes in the appropriate EventListeners list.
const Listener = struct {
// the receiver of the event, i.e. the self parameter to `func`
receiver: *anyopaque,
// the function to call
func: *const anyopaque,
// For logging slightly better error
struct_name: []const u8,
event: Notification.EventType,
// intrusive linked list node
node: List.Node,
// The event list this listener belongs to.
// We need this in order to be able to remove the node from the list
list: *List,
};
const testing = std.testing;
test "Notification" {
var notifier = try Notification.init(testing.allocator);
defer notifier.deinit();
// noop
notifier.dispatch(.frame_navigate, &.{
.loader_id = 39,
.frame_id = 0,
.req_id = 1,
.timestamp = 4,
.url = undefined,
.opts = .{},
});
var tc = TestClient{};
try notifier.register(.frame_navigate, &tc, TestClient.frameNavigate);
notifier.dispatch(.frame_navigate, &.{
.loader_id = 39,
.frame_id = 0,
.req_id = 1,
.timestamp = 4,
.url = undefined,
.opts = .{},
});
try testing.expectEqual(4, tc.frame_navigate);
notifier.unregisterAll(&tc);
notifier.dispatch(.frame_navigate, &.{
.loader_id = 39,
.frame_id = 0,
.req_id = 1,
.timestamp = 10,
.url = undefined,
.opts = .{},
});
try testing.expectEqual(4, tc.frame_navigate);
try notifier.register(.frame_navigate, &tc, TestClient.frameNavigate);
try notifier.register(.frame_navigated, &tc, TestClient.frameNavigated);
notifier.dispatch(.frame_navigate, &.{
.loader_id = 39,
.frame_id = 0,
.req_id = 1,
.timestamp = 10,
.url = undefined,
.opts = .{},
});
notifier.dispatch(.frame_navigated, &.{ .loader_id = 39, .frame_id = 0, .req_id = 1, .timestamp = 6, .url = undefined, .opts = .{} });
try testing.expectEqual(14, tc.frame_navigate);
try testing.expectEqual(6, tc.frame_navigated);
notifier.unregisterAll(&tc);
notifier.dispatch(.frame_navigate, &.{
.loader_id = 39,
.frame_id = 0,
.req_id = 1,
.timestamp = 100,
.url = undefined,
.opts = .{},
});
notifier.dispatch(.frame_navigated, &.{ .loader_id = 39, .frame_id = 0, .req_id = 1, .timestamp = 100, .url = undefined, .opts = .{} });
try testing.expectEqual(14, tc.frame_navigate);
try testing.expectEqual(6, tc.frame_navigated);
{
// unregister
try notifier.register(.frame_navigate, &tc, TestClient.frameNavigate);
try notifier.register(.frame_navigated, &tc, TestClient.frameNavigated);
notifier.dispatch(.frame_navigate, &.{ .loader_id = 39, .frame_id = 0, .req_id = 1, .timestamp = 100, .url = undefined, .opts = .{} });
notifier.dispatch(.frame_navigated, &.{ .loader_id = 39, .frame_id = 0, .req_id = 1, .timestamp = 1000, .url = undefined, .opts = .{} });
try testing.expectEqual(114, tc.frame_navigate);
try testing.expectEqual(1006, tc.frame_navigated);
notifier.unregister(.frame_navigate, &tc);
notifier.dispatch(.frame_navigate, &.{ .loader_id = 39, .frame_id = 0, .req_id = 1, .timestamp = 100, .url = undefined, .opts = .{} });
notifier.dispatch(.frame_navigated, &.{ .loader_id = 39, .frame_id = 0, .req_id = 1, .timestamp = 1000, .url = undefined, .opts = .{} });
try testing.expectEqual(114, tc.frame_navigate);
try testing.expectEqual(2006, tc.frame_navigated);
notifier.unregister(.frame_navigated, &tc);
notifier.dispatch(.frame_navigate, &.{ .loader_id = 39, .frame_id = 0, .req_id = 1, .timestamp = 100, .url = undefined, .opts = .{} });
notifier.dispatch(.frame_navigated, &.{ .loader_id = 39, .frame_id = 0, .req_id = 1, .timestamp = 1000, .url = undefined, .opts = .{} });
try testing.expectEqual(114, tc.frame_navigate);
try testing.expectEqual(2006, tc.frame_navigated);
// already unregistered, try anyways
notifier.unregister(.frame_navigated, &tc);
notifier.dispatch(.frame_navigate, &.{ .loader_id = 39, .frame_id = 0, .req_id = 1, .timestamp = 100, .url = undefined, .opts = .{} });
notifier.dispatch(.frame_navigated, &.{ .loader_id = 39, .frame_id = 0, .req_id = 1, .timestamp = 1000, .url = undefined, .opts = .{} });
try testing.expectEqual(114, tc.frame_navigate);
try testing.expectEqual(2006, tc.frame_navigated);
}
}
const TestClient = struct {
frame_navigate: u64 = 0,
frame_navigated: u64 = 0,
fn frameNavigate(ptr: *anyopaque, data: *const Notification.FrameNavigate) !void {
const self: *TestClient = @ptrCast(@alignCast(ptr));
self.frame_navigate += data.timestamp;
}
fn frameNavigated(ptr: *anyopaque, data: *const Notification.FrameNavigated) !void {
const self: *TestClient = @ptrCast(@alignCast(ptr));
self.frame_navigated += data.timestamp;
}
};