From 8580f9248882563f05f4e420b390fc00fc2f179a Mon Sep 17 00:00:00 2001 From: Rohit <71192000+rohitsux@users.noreply.github.com> Date: Sat, 20 Jun 2026 07:18:12 +0530 Subject: [PATCH] feat(cdp): honor viewport in Emulation.setDeviceMetricsOverride Now that #2694 centralized the default viewport, store an optional override on the Page and have every viewport consumer read it through Page.getViewport(): window.innerWidth/innerHeight, screen.width/height/ availWidth, visualViewport.width/height, Page.getLayoutMetrics, the IntersectionObserver root rect, and matchMedia / the StyleManager media cascade. This keeps the viewport consistent across all of them instead of the split-brain a single-property override would create. Emulation.setDeviceMetricsOverride writes the override (a 0 width or height keeps that dimension, per the CDP default); clearDeviceMetricsOverride resets to the default. Not-yet-emulated params (deviceScaleFactor, mobile, scale, screenWidth/Height) are accepted but logged as not_implemented. --- src/browser/Page.zig | 13 ++++ src/browser/StyleManager.zig | 3 +- src/browser/webapi/Element.zig | 2 +- src/browser/webapi/IntersectionObserver.zig | 11 +-- src/browser/webapi/Screen.zig | 15 +++- src/browser/webapi/VisualViewport.zig | 13 +++- src/browser/webapi/Window.zig | 25 +++++-- src/browser/webapi/css/MediaQueryList.zig | 21 +++--- src/cdp/domains/emulation.zig | 81 ++++++++++++++++++++- src/cdp/domains/page.zig | 6 +- 10 files changed, 156 insertions(+), 34 deletions(-) diff --git a/src/browser/Page.zig b/src/browser/Page.zig index 76e739b76..98e5b000d 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -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; @@ -118,6 +119,18 @@ popups: 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"); diff --git a/src/browser/StyleManager.zig b/src/browser/StyleManager.zig index e20e7af0d..93a9a5f25 100644 --- a/src/browser/StyleManager.zig +++ b/src/browser/StyleManager.zig @@ -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| { diff --git a/src/browser/webapi/Element.zig b/src/browser/webapi/Element.zig index b8d47fcdc..42f00be7b 100644 --- a/src/browser/webapi/Element.zig +++ b/src/browser/webapi/Element.zig @@ -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; } diff --git a/src/browser/webapi/IntersectionObserver.zig b/src/browser/webapi/IntersectionObserver.zig index 8d9d98e4d..579a4192e 100644 --- a/src/browser/webapi/IntersectionObserver.zig +++ b/src/browser/webapi/IntersectionObserver.zig @@ -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 diff --git a/src/browser/webapi/Screen.zig b/src/browser/webapi/Screen.zig index 72b69f5e1..e8ee3b8bb 100644 --- a/src/browser/webapi/Screen.zig +++ b/src/browser/webapi/Screen.zig @@ -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 }); diff --git a/src/browser/webapi/VisualViewport.zig b/src/browser/webapi/VisualViewport.zig index 23c59bfbc..569726f3f 100644 --- a/src/browser/webapi/VisualViewport.zig +++ b/src/browser/webapi/VisualViewport.zig @@ -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 }); }; diff --git a/src/browser/webapi/Window.zig b/src/browser/webapi/Window.zig index 49510a081..68bcde438 100644 --- a/src/browser/webapi/Window.zig +++ b/src/browser/webapi/Window.zig @@ -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"); @@ -193,6 +192,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"); } @@ -725,10 +732,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) { @@ -1040,9 +1051,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, .{}); diff --git a/src/browser/webapi/css/MediaQueryList.zig b/src/browser/webapi/css/MediaQueryList.zig index 9a2f34bef..1dc02509c 100644 --- a/src/browser/webapi/css/MediaQueryList.zig +++ b/src/browser/webapi/css/MediaQueryList.zig @@ -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 {} diff --git a/src/cdp/domains/emulation.zig b/src/cdp/domains/emulation.zig index 4738e9acb..6a03638dc 100644 --- a/src/cdp/domains/emulation.zig +++ b/src/cdp/domains/emulation.zig @@ -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); +} diff --git a/src/cdp/domains/page.zig b/src/cdp/domains/page.zig index 2a5808e1d..04250cada 100644 --- a/src/cdp/domains/page.zig +++ b/src/cdp/domains/page.zig @@ -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;