diff --git a/src/browser/HttpClient.zig b/src/browser/HttpClient.zig index a45b6b6c..81c4dc14 100644 --- a/src/browser/HttpClient.zig +++ b/src/browser/HttpClient.zig @@ -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| { diff --git a/src/browser/js/bridge.zig b/src/browser/js/bridge.zig index 2f9a073c..73c2a821 100644 --- a/src/browser/js/bridge.zig +++ b/src/browser/js/bridge.zig @@ -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"), diff --git a/src/browser/tests/canvas/canvas_rendering_context_2d.html b/src/browser/tests/canvas/canvas_rendering_context_2d.html index 66a673ad..060ca6c8 100644 --- a/src/browser/tests/canvas/canvas_rendering_context_2d.html +++ b/src/browser/tests/canvas/canvas_rendering_context_2d.html @@ -158,3 +158,16 @@ testing.expectEqual(null, element.getContext('webgl')); } + + diff --git a/src/browser/tests/notification.html b/src/browser/tests/notification.html new file mode 100644 index 00000000..667ef3d0 --- /dev/null +++ b/src/browser/tests/notification.html @@ -0,0 +1,47 @@ + + + + + + + + + + diff --git a/src/browser/webapi/EventTarget.zig b/src/browser/webapi/EventTarget.zig index 845537cd..d5d07310 100644 --- a/src/browser/webapi/EventTarget.zig +++ b/src/browser/webapi/EventTarget.zig @@ -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(""), .websocket => writer.writeAll(""), .cookie_store => writer.writeAll(""), + .notification => writer.writeAll(""), }; } @@ -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]", }; } diff --git a/src/browser/webapi/HTMLDocument.zig b/src/browser/webapi/HTMLDocument.zig index e4b343fe..44e0ccfa 100644 --- a/src/browser/webapi/HTMLDocument.zig +++ b/src/browser/webapi/HTMLDocument.zig @@ -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 diff --git a/src/browser/webapi/Notification.zig b/src/browser/webapi/Notification.zig new file mode 100644 index 00000000..c9ce3226 --- /dev/null +++ b/src/browser/webapi/Notification.zig @@ -0,0 +1,175 @@ +// 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 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", .{}); +} diff --git a/src/browser/webapi/canvas/CanvasRenderingContext2D.zig b/src/browser/webapi/canvas/CanvasRenderingContext2D.zig index e1018c19..68d5ec6c 100644 --- a/src/browser/webapi/canvas/CanvasRenderingContext2D.zig +++ b/src/browser/webapi/canvas/CanvasRenderingContext2D.zig @@ -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 }); diff --git a/src/browser/webapi/storage/Cookie.zig b/src/browser/webapi/storage/Cookie.zig index 1de5f4e6..259dd6e9 100644 --- a/src/browser/webapi/storage/Cookie.zig +++ b/src/browser/webapi/storage/Cookie.zig @@ -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]); } } diff --git a/src/browser/webapi/storage/CookieStore.zig b/src/browser/webapi/storage/CookieStore.zig index 1e475286..4d679e54 100644 --- a/src/browser/webapi/storage/CookieStore.zig +++ b/src/browser/webapi/storage/CookieStore.zig @@ -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. diff --git a/src/cdp/domains/storage.zig b/src/cdp/domains/storage.zig index 65a7509e..dac20e10 100644 --- a/src/cdp/domains/storage.zig +++ b/src/cdp/domains/storage.zig @@ -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); } diff --git a/src/cookies.zig b/src/cookies.zig index a654b279..3726f349 100644 --- a/src/cookies.zig +++ b/src/cookies.zig @@ -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; };