Merge branch 'main' into agent

This commit is contained in:
Adrià Arrufat
2026-05-30 22:05:01 +02:00
12 changed files with 307 additions and 52 deletions

View File

@@ -602,6 +602,9 @@ const Synthetic = struct {
}
fn run(transfer: *Transfer, _: *anyopaque) void {
// prevents a callback that triggers a navigation queue from killing
// this transfer from under us.
transfer.state = .completing;
defer transfer.deinit();
const fulfilled = build(transfer) catch |err| {

View File

@@ -941,6 +941,7 @@ pub const PageJsApis = flattenTypes(&.{
@import("../webapi/ModelContext.zig"),
@import("../webapi/Navigator.zig"),
@import("../webapi/NavigatorUAData.zig"),
@import("../webapi/Notification.zig"),
@import("../webapi/net/FormData.zig"),
@import("../webapi/net/Headers.zig"),
@import("../webapi/net/Request.zig"),

View File

@@ -158,3 +158,16 @@
testing.expectEqual(null, element.getContext('webgl'));
}
</script>
<script id="CanvasRenderingContext2D#drawImage">
{
const ctx = document.createElement('canvas').getContext('2d');
const img = new Image();
const src = document.createElement('canvas');
// No-op, but must accept the (image, dx, dy), (image, dx, dy, dw, dh) and
// 9-arg overloads without throwing.
ctx.drawImage(img, 0, 0);
ctx.drawImage(src, 0, 0, 10, 10);
testing.expectEqual(undefined, ctx.drawImage(src, 0, 0, 10, 10, 0, 0, 10, 10));
}
</script>

View File

@@ -0,0 +1,47 @@
<!DOCTYPE html>
<script src="./testing.js"></script>
<script id=Notification>
{
testing.expectEqual("function", typeof Notification);
testing.expectEqual("default", Notification.permission);
testing.expectEqual(2, Notification.maxActions);
}
</script>
<script id=Notification#requestPermission>
{
testing.async(async () => {
testing.expectEqual("default", await Notification.requestPermission());
});
}
</script>
<script id=Notification#constructor>
{
const n = new Notification("hello", { body: "world", icon: "i.png", tag: "t", lang: "en", dir: "rtl", silent: true });
testing.expectEqual(true, n instanceof Notification);
testing.expectEqual(true, n instanceof EventTarget);
testing.expectEqual("hello", n.title);
testing.expectEqual("world", n.body);
testing.expectEqual("i.png", n.icon);
testing.expectEqual("t", n.tag);
testing.expectEqual("en", n.lang);
testing.expectEqual("rtl", n.dir);
testing.expectEqual(true, n.silent);
testing.expectEqual("[object Notification]", Object.prototype.toString.call(n));
}
</script>
<script id=Notification#defaults>
{
const n = new Notification("only-title");
testing.expectEqual("only-title", n.title);
testing.expectEqual("", n.body);
testing.expectEqual("auto", n.dir);
testing.expectEqual(false, n.silent);
testing.expectEqual(false, n.requireInteraction);
// close() is a no-op but must exist and not throw.
n.close();
}
</script>

View File

@@ -50,6 +50,7 @@ pub const Type = union(enum) {
font_face_set: *@import("css/FontFaceSet.zig"),
websocket: *@import("net/WebSocket.zig"),
cookie_store: *@import("storage/CookieStore.zig"),
notification: *@import("Notification.zig"),
};
pub fn init(page: *Page) !*EventTarget {
@@ -161,6 +162,7 @@ pub fn format(self: *EventTarget, writer: *std.Io.Writer) !void {
.font_face_set => writer.writeAll("<FontFaceSet>"),
.websocket => writer.writeAll("<WebSocket>"),
.cookie_store => writer.writeAll("<CookieStore>"),
.notification => writer.writeAll("<Notification>"),
};
}
@@ -184,6 +186,7 @@ pub fn toString(self: *EventTarget) []const u8 {
.font_face_set => return "[object FontFaceSet]",
.websocket => return "[object WebSocket]",
.cookie_store => return "[object CookieStore]",
.notification => return "[object Notification]",
};
}

View File

@@ -241,7 +241,6 @@ pub fn setCookie(_: *HTMLDocument, cookie_str: []const u8, frame: *Frame) ![]con
// Invalid cookies should be silently ignored, not throw errors
return "";
};
errdefer c.deinit();
if (c.http_only) {
c.deinit();
return ""; // HttpOnly cookies cannot be set from JS

View File

@@ -0,0 +1,175 @@
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std");
const lp = @import("lightpanda");
const js = @import("../js/js.zig");
const Page = @import("../Page.zig");
const EventTarget = @import("EventTarget.zig");
const Execution = js.Execution;
const Allocator = std.mem.Allocator;
const Notification = @This();
_rc: lp.RC(u8) = .{},
_arena: Allocator,
_proto: *EventTarget,
_title: []const u8,
_body: []const u8 = "",
_icon: []const u8 = "",
_image: []const u8 = "",
_badge: []const u8 = "",
_tag: []const u8 = "",
_lang: []const u8 = "",
_dir: []const u8 = "auto",
_silent: bool = false,
_require_interaction: bool = false,
_renotify: bool = false,
const Options = struct {
body: ?[]const u8 = null,
icon: ?[]const u8 = null,
image: ?[]const u8 = null,
badge: ?[]const u8 = null,
tag: ?[]const u8 = null,
lang: ?[]const u8 = null,
dir: ?[]const u8 = null,
silent: ?bool = null,
requireInteraction: ?bool = null,
renotify: ?bool = null,
};
pub fn init(title: []const u8, options_: ?Options, exec: *const Execution) !*Notification {
const arena = try exec.getArena(.small, "Notification");
errdefer exec.releaseArena(arena);
const options = options_ orelse Options{};
return exec._factory.eventTargetWithAllocator(arena, Notification{
._arena = arena,
._proto = undefined,
._title = try arena.dupe(u8, title),
._body = if (options.body) |v| try arena.dupe(u8, v) else "",
._icon = if (options.icon) |v| try arena.dupe(u8, v) else "",
._image = if (options.image) |v| try arena.dupe(u8, v) else "",
._badge = if (options.badge) |v| try arena.dupe(u8, v) else "",
._tag = if (options.tag) |v| try arena.dupe(u8, v) else "",
._lang = if (options.lang) |v| try arena.dupe(u8, v) else "",
._dir = if (options.dir) |d| try arena.dupe(u8, d) else "auto",
._silent = options.silent orelse false,
._require_interaction = options.requireInteraction orelse false,
._renotify = options.renotify orelse false,
});
}
pub fn deinit(self: *Notification, page: *Page) void {
page.releaseArena(self._arena);
}
pub fn releaseRef(self: *Notification, page: *Page) void {
self._rc.release(self, page);
}
pub fn acquireRef(self: *Notification) void {
self._rc.acquire();
}
pub fn close(_: *Notification) void {}
fn getPermission() []const u8 {
return "default";
}
fn getMaxActions() u32 {
return 2;
}
fn requestPermission(_: ?js.Function, exec: *const Execution) !js.Promise {
return exec.js.local.?.resolvePromise("default");
}
fn getTitle(self: *const Notification) []const u8 {
return self._title;
}
fn getBody(self: *const Notification) []const u8 {
return self._body;
}
fn getIcon(self: *const Notification) []const u8 {
return self._icon;
}
fn getImage(self: *const Notification) []const u8 {
return self._image;
}
fn getBadge(self: *const Notification) []const u8 {
return self._badge;
}
fn getTag(self: *const Notification) []const u8 {
return self._tag;
}
fn getLang(self: *const Notification) []const u8 {
return self._lang;
}
fn getDir(self: *const Notification) []const u8 {
return self._dir;
}
fn getSilent(self: *const Notification) bool {
return self._silent;
}
fn getRequireInteraction(self: *const Notification) bool {
return self._require_interaction;
}
fn getRenotify(self: *const Notification) bool {
return self._renotify;
}
pub const JsApi = struct {
pub const bridge = js.Bridge(Notification);
pub const Meta = struct {
pub const name = "Notification";
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
};
pub const constructor = bridge.constructor(Notification.init, .{});
pub const permission = bridge.accessor(getPermission, null, .{ .static = true });
pub const maxActions = bridge.accessor(getMaxActions, null, .{ .static = true });
pub const requestPermission = bridge.function(Notification.requestPermission, .{ .static = true });
pub const close = bridge.function(Notification.close, .{ .noop = true });
pub const title = bridge.accessor(getTitle, null, .{});
pub const body = bridge.accessor(getBody, null, .{});
pub const icon = bridge.accessor(getIcon, null, .{});
pub const image = bridge.accessor(getImage, null, .{});
pub const badge = bridge.accessor(getBadge, null, .{});
pub const tag = bridge.accessor(getTag, null, .{});
pub const lang = bridge.accessor(getLang, null, .{});
pub const dir = bridge.accessor(getDir, null, .{});
pub const silent = bridge.accessor(getSilent, null, .{});
pub const requireInteraction = bridge.accessor(getRequireInteraction, null, .{});
pub const renotify = bridge.accessor(getRenotify, null, .{});
};
const testing = @import("../../testing.zig");
test "WebApi: Notification" {
try testing.htmlRunner("notification.html", .{});
}

View File

@@ -83,6 +83,10 @@ pub fn createImageData(
pub fn putImageData(_: *const CanvasRenderingContext2D, _: *ImageData, _: f64, _: f64, _: ?f64, _: ?f64, _: ?f64, _: ?f64) void {}
// CanvasImageSource (HTMLImageElement, HTMLCanvasElement, ImageBitmap, ...) is
// just taken as a js.Value for now since we don't use it, and that's much easier.
pub fn drawImage(_: *const CanvasRenderingContext2D, _: js.Value, _: f64, _: f64, _: ?f64, _: ?f64, _: ?f64, _: ?f64, _: ?f64, _: ?f64) void {}
pub fn getImageData(
_: *const CanvasRenderingContext2D,
_: i32, // sx
@@ -150,6 +154,7 @@ pub const JsApi = struct {
pub const createImageData = bridge.function(CanvasRenderingContext2D.createImageData, .{ .dom_exception = true });
pub const putImageData = bridge.function(CanvasRenderingContext2D.putImageData, .{ .noop = true });
pub const drawImage = bridge.function(CanvasRenderingContext2D.drawImage, .{ .noop = true });
pub const getImageData = bridge.function(CanvasRenderingContext2D.getImageData, .{ .dom_exception = true });
pub const save = bridge.function(CanvasRenderingContext2D.save, .{ .noop = true });
pub const restore = bridge.function(CanvasRenderingContext2D.restore, .{ .noop = true });

View File

@@ -481,10 +481,9 @@ pub const Jar = struct {
/// Checks if addition comes from HTTP request or JS context.
comptime is_http: bool,
) !void {
const is_expired = isCookieExpired(&cookie, request_time);
defer if (is_expired) {
cookie.deinit();
};
// `add` takes ownership of `cookie` unconditionally on entry.
var stored = false;
defer if (!stored) cookie.deinit();
if (self.cookies.items.len >= max_jar_size) {
return error.CookieJarQuotaExceeded;
@@ -493,6 +492,8 @@ pub const Jar = struct {
return error.CookieSizeExceeded;
}
const is_expired = isCookieExpired(&cookie, request_time);
for (self.cookies.items, 0..) |*c, i| {
// We're only looking for the equal one.
if (areCookiesEqual(&cookie, c) == false) {
@@ -502,7 +503,6 @@ pub const Jar = struct {
// RFC 6265bis 5.7.2: a non-HTTP API (e.g. document.cookie) must
// not replace an HttpOnly cookie.
if (c.http_only and is_http == false) {
if (is_expired == false) cookie.deinit();
return;
}
@@ -519,6 +519,7 @@ pub const Jar = struct {
// after the assignment, c points at the new cookie.
c.deinit();
self.cookies.items[i] = cookie;
stored = true;
self.dispatchChange(.changed, &self.cookies.items[i]);
}
return;
@@ -526,6 +527,7 @@ pub const Jar = struct {
if (!is_expired) {
try self.cookies.append(self.allocator, cookie);
stored = true;
self.dispatchChange(.changed, &self.cookies.items[self.cookies.items.len - 1]);
}
}

View File

@@ -427,34 +427,38 @@ fn storeCookie(exec: *const Execution, init: CookieInit) !void {
if (!is_https) return error.InvalidPrefixedCookie;
}
var arena = std.heap.ArenaAllocator.init(session.cookie_jar.allocator);
errdefer arena.deinit();
const aa = arena.allocator();
// The errdefer only protects construction failures. Once we `break :blk`
// with the Cookie value, `Jar.add` owns its lifetime.
const cookie: Cookie = blk: {
var arena = std.heap.ArenaAllocator.init(session.cookie_jar.allocator);
errdefer arena.deinit();
const aa = arena.allocator();
const owned_name = try aa.dupe(u8, init.name);
const owned_value = try aa.dupe(u8, init.value);
const owned_path = try Cookie.parsePath(aa, url, init.path);
const owned_domain = try Cookie.parseDomain(aa, url, init.domain);
const owned_name = try aa.dupe(u8, init.name);
const owned_value = try aa.dupe(u8, init.value);
const owned_path = try Cookie.parsePath(aa, url, init.path);
const owned_domain = try Cookie.parseDomain(aa, url, init.domain);
const cookie: Cookie = .{
.arena = arena,
.name = owned_name,
.value = owned_value,
.path = owned_path,
.domain = owned_domain,
break :blk .{
.arena = arena,
.name = owned_name,
.value = owned_value,
.path = owned_path,
.domain = owned_domain,
// CookieStore.expires is a unix timestamp in milliseconds; Cookie tracks
// expiry in seconds. A timestamp at or before "now" deletes the cookie via
// the Jar's expiry path.
.expires = if (init.expires) |ms| ms / 1000.0 else null,
// CookieStore.expires is a unix timestamp in milliseconds; Cookie tracks
// expiry in seconds. A timestamp at or before "now" deletes the cookie via
// the Jar's expiry path.
.expires = if (init.expires) |ms| ms / 1000.0 else null,
.secure = secure,
.http_only = false,
.same_site = switch (init.sameSite) {
.strict => .strict,
.lax => .lax,
.none => .none,
},
.secure = secure,
.http_only = false,
.same_site = switch (init.sameSite) {
.strict => .strict,
.lax => .lax,
.none => .none,
},
};
};
// CookieStore is a script API, so is_http = false.

View File

@@ -142,30 +142,34 @@ pub fn setCdpCookie(cookie_jar: *CookieJar, param: CdpCookie) !void {
return error.NotImplemented;
}
var arena = std.heap.ArenaAllocator.init(cookie_jar.allocator);
errdefer arena.deinit();
const a = arena.allocator();
// The errdefer only protects construction failures. Once we `break :blk`
// with the Cookie value, `Jar.add` owns its lifetime.
const cookie = blk: {
var arena = std.heap.ArenaAllocator.init(cookie_jar.allocator);
errdefer arena.deinit();
const a = arena.allocator();
// NOTE: The param.url can affect the default domain, (NOT path), secure, source port, and source scheme.
const domain = try Cookie.parseDomain(a, param.url, param.domain);
const path = if (param.path == null) "/" else try Cookie.parsePath(a, null, param.path);
// NOTE: The param.url can affect the default domain, (NOT path), secure, source port, and source scheme.
const domain = try Cookie.parseDomain(a, param.url, param.domain);
const path = if (param.path == null) "/" else try Cookie.parsePath(a, null, param.path);
const secure = if (param.secure) |s| s else if (param.url) |url| URL.isHTTPS(url) else false;
const secure = if (param.secure) |s| s else if (param.url) |url| URL.isHTTPS(url) else false;
const cookie = Cookie{
.arena = arena,
.name = try a.dupe(u8, param.name),
.value = try a.dupe(u8, param.value),
.path = path,
.domain = domain,
.expires = param.expires,
.secure = secure,
.http_only = param.httpOnly,
.same_site = switch (param.sameSite) {
.Strict => .strict,
.Lax => .lax,
.None => .none,
},
break :blk Cookie{
.arena = arena,
.name = try a.dupe(u8, param.name),
.value = try a.dupe(u8, param.value),
.path = path,
.domain = domain,
.expires = param.expires,
.secure = secure,
.http_only = param.httpOnly,
.same_site = switch (param.sameSite) {
.Strict => .strict,
.Lax => .lax,
.None => .none,
},
};
};
try cookie_jar.add(cookie, std.time.timestamp(), true);
}

View File

@@ -77,7 +77,6 @@ fn _loadFromFile(session: *Session, path: []const u8) !void {
};
jar.add(cookie, now, true) catch |err| {
cookie.deinit();
log.warn(.app, "invalid cookie", .{ .name = jc.name, .err = err });
continue;
};