From 697ae06852b3d38e2f676f3e77ed653482eecc79 Mon Sep 17 00:00:00 2001 From: Rohit <71192000+rohitsux@users.noreply.github.com> Date: Fri, 12 Jun 2026 21:31:50 +0530 Subject: [PATCH] feat(cdp): honor Browser.grantPermissions / setPermission / resetPermissions These three Browser methods were noops, so a CDP client could not change what navigator.permissions.query() reports - it always returned "prompt". Store permission state on the active Page (keyed by permission name) and have navigator.permissions.query() read it back. grantPermissions sets each listed permission to "granted", setPermission sets a single permission to an explicit state, and resetPermissions clears them (query falls back to "prompt"). State is scoped to the Page and resets on navigation. Adds a CDP round-trip test exercising all three methods. --- src/browser/Page.zig | 20 ++++++++ src/browser/webapi/Permissions.zig | 8 ++- src/cdp/domains/browser.zig | 78 +++++++++++++++++++++++++++--- 3 files changed, 98 insertions(+), 8 deletions(-) diff --git a/src/browser/Page.zig b/src/browser/Page.zig index 76e739b76..1b4b6264a 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -70,6 +70,12 @@ frame_arena: Allocator, // lifetime. origins: std.StringHashMapUnmanaged(*js.Origin) = .empty, +// Permission state set via CDP Browser.grantPermissions / setPermission / +// resetPermissions, keyed by permission name (e.g. "geolocation"). Read back +// by navigator.permissions.query(). Keys and values are duped into +// frame_arena, so they live for (and reset with) the Page. +permissions: std.StringHashMapUnmanaged([]const u8) = .empty, + // Identity tracking for the main world. All main-world contexts in this Page // share this, ensuring object identity works across same-origin frames. identity: js.Identity = .{}, @@ -187,6 +193,10 @@ pub fn deinit(self: *Page) void { self.origins = .empty; } + // Keys and values are duped into frame_arena, released just below; drop + // the map so we don't keep a dangling reference. + self.permissions = .empty; + session.arena_pool.release(self.frame_arena); } @@ -198,6 +208,16 @@ pub fn releaseArena(self: *Page, allocator: Allocator) void { return self.session.releaseArena(allocator); } +// Set (or overwrite) the stored state for a permission. Name and state are +// duped into frame_arena. Used by CDP Browser.grantPermissions / setPermission. +pub fn setPermission(self: *Page, name: []const u8, state: []const u8) !void { + const gop = try self.permissions.getOrPut(self.frame_arena, name); + if (!gop.found_existing) { + gop.key_ptr.* = try self.frame_arena.dupe(u8, name); + } + gop.value_ptr.* = try self.frame_arena.dupe(u8, state); +} + pub fn getOrCreateOrigin(self: *Page, key_: ?[]const u8) !*js.Origin { const session = self.session; const key = key_ orelse { diff --git a/src/browser/webapi/Permissions.zig b/src/browser/webapi/Permissions.zig index 795df7c9b..1cf3249e3 100644 --- a/src/browser/webapi/Permissions.zig +++ b/src/browser/webapi/Permissions.zig @@ -37,15 +37,19 @@ _pad: bool = false, const QueryDescriptor = struct { name: []const u8, }; -// We always report 'prompt' (the default safe value — neither granted nor denied). + +// Report the state set via CDP Browser.grantPermissions / setPermission, or +// 'prompt' (the default safe value — neither granted nor denied) when unset. pub fn query(_: *const Permissions, qd: QueryDescriptor, exec: *const Execution) !js.Promise { const arena = try exec.getArena(.tiny, "PermissionStatus"); errdefer exec.releaseArena(arena); + const state = exec.page.permissions.get(qd.name) orelse "prompt"; + const status = try arena.create(PermissionStatus); status.* = .{ ._arena = arena, - ._state = "prompt", + ._state = try arena.dupe(u8, state), ._name = try arena.dupe(u8, qd.name), }; return exec.js.local.?.resolvePromise(status); diff --git a/src/cdp/domains/browser.zig b/src/cdp/domains/browser.zig index a63be3fae..8766f87ee 100644 --- a/src/cdp/domains/browser.zig +++ b/src/cdp/domains/browser.zig @@ -96,19 +96,50 @@ fn setWindowBounds(cmd: *CDP.Command) !void { return cmd.sendResult(null, .{}); } -// TODO: noop method +// Grant the listed permissions so navigator.permissions.query() reports them +// as "granted". State is stored on the active Page and resets on navigation. fn grantPermissions(cmd: *CDP.Command) !void { - return cmd.sendResult(null, .{}); + const params = (try cmd.params(struct { + permissions: []const []const u8, + origin: ?[]const u8 = null, + browserContextId: ?[]const u8 = null, + })) orelse return error.InvalidParams; + + const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; + const page = bc.session.currentPage() orelse return error.PageNotLoaded; + for (params.permissions) |name| { + try page.setPermission(name, "granted"); + } + + return cmd.sendResult(null, .{ .include_session_id = false }); } -// TODO: noop method +// Set a single permission to an explicit state ("granted", "denied" or +// "prompt"), reflected by navigator.permissions.query(). fn setPermission(cmd: *CDP.Command) !void { - return cmd.sendResult(null, .{}); + const params = (try cmd.params(struct { + permission: struct { name: []const u8 }, + setting: []const u8, + origin: ?[]const u8 = null, + browserContextId: ?[]const u8 = null, + })) orelse return error.InvalidParams; + + const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; + const page = bc.session.currentPage() orelse return error.PageNotLoaded; + try page.setPermission(params.permission.name, params.setting); + + return cmd.sendResult(null, .{ .include_session_id = false }); } -// TODO: noop method +// Clear all granted permissions; navigator.permissions.query() falls back to +// the default "prompt". fn resetPermissions(cmd: *CDP.Command) !void { - return cmd.sendResult(null, .{}); + const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; + if (bc.session.currentPage()) |page| { + page.permissions.clearRetainingCapacity(); + } + + return cmd.sendResult(null, .{ .include_session_id = false }); } const testing = @import("../testing.zig"); @@ -146,3 +177,38 @@ test "cdp.browser: getWindowForTarget" { .bounds = .{ .windowState = "normal" }, }, .{ .id = 33, .index = 0, .session_id = null }); } + +test "cdp.browser: grant/set/reset permissions reach navigator.permissions" { + var ctx = try testing.context(); + defer ctx.deinit(); + + const bc = try ctx.loadBrowserContext(.{ .id = "BID-PERM", .url = "cdp/dom1.html" }); + const page = bc.session.currentPage() orelse unreachable; + + // grantPermissions: each listed permission becomes "granted". + try ctx.processMessage(.{ + .id = 40, + .method = "Browser.grantPermissions", + .params = .{ .permissions = &[_][]const u8{ "geolocation", "notifications" } }, + }); + try ctx.expectSentResult(null, .{ .id = 40, .session_id = null }); + try testing.expectEqualSlices(u8, "granted", page.permissions.get("geolocation").?); + try testing.expectEqualSlices(u8, "granted", page.permissions.get("notifications").?); + + // setPermission: override a single permission to an explicit state. + try ctx.processMessage(.{ + .id = 41, + .method = "Browser.setPermission", + .params = .{ .permission = .{ .name = "geolocation" }, .setting = "denied" }, + }); + try ctx.expectSentResult(null, .{ .id = 41, .session_id = null }); + try testing.expectEqualSlices(u8, "denied", page.permissions.get("geolocation").?); + + // resetPermissions: clears everything; query falls back to "prompt". + try ctx.processMessage(.{ + .id = 42, + .method = "Browser.resetPermissions", + }); + try ctx.expectSentResult(null, .{ .id = 42, .session_id = null }); + try testing.expectEqual(@as(usize, 0), page.permissions.count()); +}