Merge pull request #2664 from rohitsux/feat/cdp-set-device-metrics

feat(cdp): honor viewport in Emulation.setDeviceMetricsOverride
This commit is contained in:
Pierre Tachoire
2026-06-22 07:12:23 +00:00
committed by GitHub
10 changed files with 156 additions and 34 deletions

View File

@@ -25,6 +25,7 @@ const v8 = js.v8;
const Frame = @import("Frame.zig");
const Session = @import("Session.zig");
const Factory = @import("Factory.zig");
const Viewport = @import("Viewport.zig");
const Allocator = std.mem.Allocator;
const IS_DEBUG = builtin.mode == .Debug;
@@ -123,6 +124,18 @@ closed_frames: std.ArrayList(*Frame) = .empty,
// notification (so CDP doesn't reset its node registry yet) and
_state: enum { active, pending } = .active,
// Runtime viewport override set via Emulation.setDeviceMetricsOverride and
// cleared via Emulation.clearDeviceMetricsOverride. Null means use the
// compile-time Viewport.default. Read every viewport consumer through
// getViewport so they all observe the same (possibly overridden) value.
viewport_override: ?Viewport = null,
// The viewport every consumer should read: the runtime override if set,
// otherwise the compile-time default.
pub fn getViewport(self: *const Page) Viewport {
return self.viewport_override orelse Viewport.default;
}
// Initialize a Page and its root Frame.
pub fn init(self: *Page, session: *Session, frame_id: u32) !void {
const frame_arena = try session.arena_pool.acquire(.large, "Page.frame_arena");

View File

@@ -20,7 +20,6 @@ const std = @import("std");
const lp = @import("lightpanda");
const Frame = @import("Frame.zig");
const Viewport = @import("Viewport.zig");
const CssParser = @import("css/Parser.zig");
const MediaQuery = @import("css/MediaQuery.zig");
@@ -148,7 +147,7 @@ fn applyMediaAtRule(self: *StyleManager, text: []const u8, depth: u8) !void {
const query = std.mem.trim(u8, rest[0..open], &std.ascii.whitespace);
const inner = rest[open + 1 .. close];
if (!MediaQuery.matches(query, Viewport.default)) return;
if (!MediaQuery.matches(query, self.frame._page.getViewport())) return;
var it = CssParser.parseStylesheet(inner);
while (it.next()) |nested_rule| {

View File

@@ -1543,7 +1543,7 @@ pub fn scrollIntoViewIfNeeded(self: *Element, center_if_needed: ?bool, frame: *F
_ = center_if_needed;
const y = calculateDocumentPosition(self.asNode());
const scroll_y: f64 = @floatFromInt(frame.window.getScrollY());
const viewport_height: f64 = @floatFromInt(frame.window.getInnerHeight());
const viewport_height: f64 = @floatFromInt(frame.window.getInnerHeight(frame));
if (y >= scroll_y and y <= scroll_y + viewport_height) {
return;
}

View File

@@ -22,7 +22,6 @@ const js = @import("../js/js.zig");
const Page = @import("../Page.zig");
const Frame = @import("../Frame.zig");
const Viewport = @import("../Viewport.zig");
const Node = @import("Node.zig");
const Element = @import("Element.zig");
@@ -208,13 +207,15 @@ fn calculateIntersection(
// Use root element's rect or the faux-layout viewport.
const root_rect = if (self._root) |root|
root.getBoundingClientRect(frame)
else
DOMRect{
else blk: {
const viewport = frame._page.getViewport();
break :blk DOMRect{
._x = 0.0,
._y = 0.0,
._width = @floatFromInt(Viewport.default.width),
._height = @floatFromInt(Viewport.default.height),
._width = @floatFromInt(viewport.width),
._height = @floatFromInt(viewport.height),
};
};
// For a headless browser without real layout, we treat all elements as fully visible.
// This avoids fingerprinting issues (massive viewports) and matches the behavior

View File

@@ -18,7 +18,6 @@
const js = @import("../js/js.zig");
const Frame = @import("../Frame.zig");
const Viewport = @import("../Viewport.zig");
const EventTarget = @import("EventTarget.zig");
pub fn registerTypes() []const type {
@@ -46,6 +45,14 @@ pub fn getOrientation(self: *Screen, frame: *Frame) !*Orientation {
return orientation;
}
pub fn getWidth(_: *const Screen, frame: *Frame) u32 {
return frame._page.getViewport().width;
}
pub fn getHeight(_: *const Screen, frame: *Frame) u32 {
return frame._page.getViewport().height;
}
pub const JsApi = struct {
pub const bridge = js.Bridge(Screen);
@@ -55,9 +62,9 @@ pub const JsApi = struct {
pub var class_id: bridge.ClassId = undefined;
};
pub const width = bridge.property(Viewport.default.width, .{ .template = false });
pub const height = bridge.property(Viewport.default.height, .{ .template = false });
pub const availWidth = bridge.property(Viewport.default.width, .{ .template = false });
pub const width = bridge.accessor(Screen.getWidth, null, .{});
pub const height = bridge.accessor(Screen.getHeight, null, .{});
pub const availWidth = bridge.accessor(Screen.getWidth, null, .{});
pub const availHeight = bridge.property(1040, .{ .template = false });
pub const colorDepth = bridge.property(24, .{ .template = false });
pub const pixelDepth = bridge.property(24, .{ .template = false });

View File

@@ -18,7 +18,6 @@
const js = @import("../js/js.zig");
const Frame = @import("../Frame.zig");
const Viewport = @import("../Viewport.zig");
const EventTarget = @import("EventTarget.zig");
const VisualViewport = @This();
@@ -37,6 +36,14 @@ pub fn getPageTop(_: *const VisualViewport, frame: *Frame) u32 {
return frame.window.getScrollY();
}
pub fn getWidth(_: *const VisualViewport, frame: *Frame) u32 {
return frame._page.getViewport().width;
}
pub fn getHeight(_: *const VisualViewport, frame: *Frame) u32 {
return frame._page.getViewport().height;
}
pub const JsApi = struct {
pub const bridge = js.Bridge(VisualViewport);
@@ -52,7 +59,7 @@ pub const JsApi = struct {
pub const offsetTop = bridge.property(0, .{ .template = false });
pub const pageLeft = bridge.accessor(VisualViewport.getPageLeft, null, .{});
pub const pageTop = bridge.accessor(VisualViewport.getPageTop, null, .{});
pub const width = bridge.property(Viewport.default.width, .{ .template = false });
pub const height = bridge.property(Viewport.default.height, .{ .template = false });
pub const width = bridge.accessor(VisualViewport.getWidth, null, .{});
pub const height = bridge.accessor(VisualViewport.getHeight, null, .{});
pub const scale = bridge.property(1.0, .{ .template = false });
};

View File

@@ -23,7 +23,6 @@ const builtin = @import("builtin");
const js = @import("../js/js.zig");
const URL = @import("../URL.zig");
const Frame = @import("../Frame.zig");
const Viewport = @import("../Viewport.zig");
const Console = @import("Console.zig");
const History = @import("History.zig");
const Navigation = @import("navigation/Navigation.zig");
@@ -195,6 +194,14 @@ pub fn setLength(_: *Window, value: js.Value) void {
replaceGlobalProperty(value, "length");
}
pub fn setInnerWidth(_: *Window, value: js.Value) void {
replaceGlobalProperty(value, "innerWidth");
}
pub fn setInnerHeight(_: *Window, value: js.Value) void {
replaceGlobalProperty(value, "innerHeight");
}
pub fn setScrollX(_: *Window, value: js.Value) void {
replaceGlobalProperty(value, "scrollX");
}
@@ -727,10 +734,14 @@ pub fn getScrollY(self: *const Window) u32 {
return self._scroll_pos.y;
}
pub fn getInnerWidth(_: *const Window, frame: *Frame) u32 {
return frame._page.getViewport().width;
}
// Faux-layout viewport height, used to decide whether an element is already
// within view (e.g. scrollIntoViewIfNeeded).
pub fn getInnerHeight(_: *const Window) u32 {
return Viewport.default.height;
pub fn getInnerHeight(_: *const Window, frame: *Frame) u32 {
return frame._page.getViewport().height;
}
const ScrollToOpts = union(enum) {
@@ -1042,9 +1053,11 @@ pub const JsApi = struct {
// sites not to try to access those features
pub const isSecureContext = bridge.property(false, .{ .template = false });
// [Replaceable] (CSSOM-View): writable so assignment overwrites rather than throws.
pub const innerWidth = bridge.property(Viewport.default.width, .{ .template = false, .readonly = false });
pub const innerHeight = bridge.property(Viewport.default.height, .{ .template = false, .readonly = false });
// [Replaceable] (CSSOM-View): the getter reads the page's runtime viewport
// (overridable via Emulation.setDeviceMetricsOverride); the setter overwrites
// the attribute rather than throwing.
pub const innerWidth = bridge.accessor(Window.getInnerWidth, Window.setInnerWidth, .{});
pub const innerHeight = bridge.accessor(Window.getInnerHeight, Window.setInnerHeight, .{});
pub const devicePixelRatio = bridge.property(1, .{ .template = false, .readonly = false });
pub const opener = bridge.accessor(Window.getOpener, null, .{});

View File

@@ -18,9 +18,9 @@
// zlint-disable unused-decls
const js = @import("../../js/js.zig");
const Frame = @import("../../Frame.zig");
const EventTarget = @import("../EventTarget.zig");
const MediaQuery = @import("../../css/MediaQuery.zig");
const Viewport = @import("../../Viewport.zig");
const MediaQueryList = @This();
@@ -40,18 +40,17 @@ pub fn getMedia(self: *const MediaQueryList) []const u8 {
}
/// Re-evaluates the stored query against the current viewport on every call
/// so the result stays in sync if viewport emulation later lands. The
/// viewport currently comes from `Viewport.default` (1920×1080), matching
/// `Window.innerWidth` / `innerHeight`.
pub fn getMatches(self: *const MediaQueryList) bool {
return MediaQuery.matches(self._media, Viewport.default);
/// so the result stays in sync with viewport emulation. The viewport comes
/// from the page (overridable via Emulation.setDeviceMetricsOverride),
/// matching `Window.innerWidth` / `innerHeight`.
pub fn getMatches(self: *const MediaQueryList, frame: *Frame) bool {
return MediaQuery.matches(self._media, frame._page.getViewport());
}
// TODO: once viewport emulation lands, `change` events need to fire on
// every listener registered here (and via `addEventListener("change", ...)`
// through the EventTarget proto) when the viewport crosses the breakpoint
// that flips `matches`. Today the viewport is a constant, so no event can
// ever fire — the registrations are no-ops rather than stored hooks.
// TODO: `change` events need to fire on every listener registered here (and
// via `addEventListener("change", ...)` through the EventTarget proto) when a
// viewport override crosses the breakpoint that flips `matches`. Today these
// registrations are no-ops rather than stored hooks.
pub fn addListener(_: *const MediaQueryList, _: js.Function) void {}
pub fn removeListener(_: *const MediaQueryList, _: js.Function) void {}

View File

@@ -29,6 +29,7 @@ pub fn processMessage(cmd: *CDP.Command) !void {
setEmulatedMedia,
setFocusEmulationEnabled,
setDeviceMetricsOverride,
clearDeviceMetricsOverride,
setTouchEmulationEnabled,
setUserAgentOverride,
}, cmd.input.action) orelse return error.UnknownMethod;
@@ -37,6 +38,7 @@ pub fn processMessage(cmd: *CDP.Command) !void {
.setEmulatedMedia => return setEmulatedMedia(cmd),
.setFocusEmulationEnabled => return setFocusEmulationEnabled(cmd),
.setDeviceMetricsOverride => return setDeviceMetricsOverride(cmd),
.clearDeviceMetricsOverride => return clearDeviceMetricsOverride(cmd),
.setTouchEmulationEnabled => return setTouchEmulationEnabled(cmd),
.setUserAgentOverride => return setUserAgentOverride(cmd),
}
@@ -63,8 +65,53 @@ fn setFocusEmulationEnabled(cmd: *CDP.Command) !void {
return cmd.sendResult(null, .{});
}
// TODO: noop method
fn setDeviceMetricsOverride(cmd: *CDP.Command) !void {
const params = (try cmd.params(struct {
width: u32,
height: u32,
deviceScaleFactor: ?f64 = null,
mobile: ?bool = null,
scale: ?f64 = null,
screenWidth: ?u32 = null,
screenHeight: ?u32 = null,
})) orelse return error.InvalidParams;
// Not-yet-emulated parameters: accept them but warn so the caller knows
// they are ignored.
if (params.deviceScaleFactor) |v| {
if (v != 0) log.warn(.not_implemented, "Emulation.setDeviceMetricsOverride", .{ .param = "deviceScaleFactor", .value = v });
}
if (params.mobile) |v| {
if (v) log.warn(.not_implemented, "Emulation.setDeviceMetricsOverride", .{ .param = "mobile", .value = v });
}
if (params.scale) |v| {
if (v != 0) log.warn(.not_implemented, "Emulation.setDeviceMetricsOverride", .{ .param = "scale", .value = v });
}
if (params.screenWidth) |v| {
if (v != 0) log.warn(.not_implemented, "Emulation.setDeviceMetricsOverride", .{ .param = "screenWidth", .value = v });
}
if (params.screenHeight) |v| {
if (v != 0) log.warn(.not_implemented, "Emulation.setDeviceMetricsOverride", .{ .param = "screenHeight", .value = v });
}
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
const page = bc.session.currentPage() orelse return error.FrameNotLoaded;
// CDP convention: a 0 width/height means "don't override that dimension",
// so keep the current value for any dimension passed as 0.
const current = page.getViewport();
page.viewport_override = .{
.width = if (params.width > 0) params.width else current.width,
.height = if (params.height > 0) params.height else current.height,
};
return cmd.sendResult(null, .{});
}
fn clearDeviceMetricsOverride(cmd: *CDP.Command) !void {
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
const page = bc.session.currentPage() orelse return error.FrameNotLoaded;
page.viewport_override = null;
return cmd.sendResult(null, .{});
}
@@ -216,3 +263,35 @@ test "cdp.Emulation: setUserAgentOverride can be called multiple times" {
try ctx.expectSentResult(null, .{ .id = 7 });
}
test "cdp.Emulation: setDeviceMetricsOverride and clear" {
var ctx = try testing.context();
defer ctx.deinit();
const bc = try ctx.loadBrowserContext(.{ .id = "BID-DM1" });
_ = try bc.session.createPage();
const page = bc.session.currentPage().?;
// Defaults to the compile-time viewport before any override.
try testing.expectEqual(1920, page.getViewport().width);
try testing.expectEqual(1080, page.getViewport().height);
try ctx.processMessage(.{
.id = 8,
.method = "Emulation.setDeviceMetricsOverride",
.params = .{ .width = 375, .height = 812 },
});
try ctx.expectSentResult(null, .{ .id = 8 });
try testing.expectEqual(375, page.getViewport().width);
try testing.expectEqual(812, page.getViewport().height);
try ctx.processMessage(.{
.id = 9,
.method = "Emulation.clearDeviceMetricsOverride",
});
try ctx.expectSentResult(null, .{ .id = 9 });
try testing.expectEqual(1920, page.getViewport().width);
try testing.expectEqual(1080, page.getViewport().height);
}

View File

@@ -926,7 +926,11 @@ fn printToPDF(cmd: *CDP.Command) !void {
}
fn getLayoutMetrics(cmd: *CDP.Command) !void {
const viewport = @import("../../browser/Viewport.zig").default;
const BrowserViewport = @import("../../browser/Viewport.zig");
const viewport = if (cmd.browser_context) |bc|
if (bc.session.currentPage()) |page| page.getViewport() else BrowserViewport.default
else
BrowserViewport.default;
const width = viewport.width;
const height = viewport.height;