mirror of
https://github.com/lightpanda-io/browser.git
synced 2026-09-14 23:15:14 -04:00
Merge pull request #3233 from rohitsux/feat/geolocation-emulation
feat: navigator.geolocation + Emulation.setGeolocationOverride
This commit is contained in:
10 files changed
+942
-13
No files matched your search
@@ -21,15 +21,16 @@ const lp = @import("lightpanda");
|
||||
|
||||
const App = @import("../App.zig");
|
||||
const CDP = @import("../cdp/CDP.zig");
|
||||
const Watchdog = @import("../Watchdog.zig");
|
||||
const Notification = @import("../Notification.zig");
|
||||
const HttpClient = @import("../network/HttpClient.zig");
|
||||
|
||||
const js = @import("js/js.zig");
|
||||
const Page = @import("Page.zig");
|
||||
const Watchdog = @import("../Watchdog.zig");
|
||||
const Session = @import("Session.zig");
|
||||
const Selector = @import("webapi/selector/Selector.zig");
|
||||
const Viewport = @import("Viewport.zig");
|
||||
const HttpClient = @import("../network/HttpClient.zig");
|
||||
const Selector = @import("webapi/selector/Selector.zig");
|
||||
const Geolocation = @import("webapi/geolocation/Geolocation.zig");
|
||||
const PermissionState = @import("webapi/Permissions.zig").State;
|
||||
|
||||
const ArenaPool = App.ArenaPool;
|
||||
@@ -63,14 +64,12 @@ last_reported_js_bytes: usize = 0,
|
||||
// browser context. Keys are owned by `allocator`; values are enum tags.
|
||||
permissions: std.StringHashMapUnmanaged(PermissionState) = .empty,
|
||||
|
||||
// Runtime viewport override set via Emulation.setDeviceMetricsOverride and
|
||||
// cleared via clearDeviceMetricsOverride. Null means use the compile-time
|
||||
// Viewport.default. Scoped to the Browser so it persists across page
|
||||
// navigations (matching how Chrome scopes the override to the connection).
|
||||
// Every viewport consumer reads it through Page.getViewport so they all
|
||||
// observe the same (possibly overridden) value.
|
||||
// Runtime viewport override
|
||||
viewport_override: ?Viewport = null,
|
||||
|
||||
// Runtime geolocation override
|
||||
geolocation_override: ?Geolocation.Override = null,
|
||||
|
||||
// used by sessions to allocate pages.
|
||||
page_pool: std.heap.MemoryPool(Page),
|
||||
|
||||
|
||||
@@ -1200,6 +1200,10 @@ pub const PageJsApis = flattenTypes(&.{
|
||||
@import("../webapi/PluginArray.zig"),
|
||||
@import("../webapi/MutationObserver.zig"),
|
||||
@import("../webapi/IntersectionObserver.zig"),
|
||||
@import("../webapi/geolocation/Geolocation.zig"),
|
||||
@import("../webapi/geolocation/GeolocationPosition.zig"),
|
||||
@import("../webapi/geolocation/GeolocationCoordinates.zig"),
|
||||
@import("../webapi/geolocation/GeolocationPositionError.zig"),
|
||||
@import("../webapi/CustomElementRegistry.zig"),
|
||||
@import("../webapi/ResizeObserver.zig"),
|
||||
@import("../webapi/IdleDeadline.zig"),
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
<!DOCTYPE html>
|
||||
<script src="./testing.js"></script>
|
||||
|
||||
<script id=geo_shape>
|
||||
testing.expectTrue('geolocation' in navigator);
|
||||
testing.expectTrue(navigator.geolocation === navigator.geolocation);
|
||||
testing.expectEqual('function', typeof navigator.geolocation.getCurrentPosition);
|
||||
testing.expectEqual('function', typeof navigator.geolocation.watchPosition);
|
||||
testing.expectEqual('function', typeof navigator.geolocation.clearWatch);
|
||||
testing.expectEqual(1, GeolocationPositionError.PERMISSION_DENIED);
|
||||
testing.expectEqual(2, GeolocationPositionError.POSITION_UNAVAILABLE);
|
||||
testing.expectEqual(3, GeolocationPositionError.TIMEOUT);
|
||||
</script>
|
||||
|
||||
<script id=geo_no_permission type=module>
|
||||
{
|
||||
// nothing granted the permission (only CDP can), so this is the denied path
|
||||
const state = await testing.async();
|
||||
let called = false;
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
() => { called = true; testing.fail('should not succeed without a grant'); state.resolve(); },
|
||||
(err) => { called = true; state.resolve(err); }
|
||||
);
|
||||
// never delivered on the stack that registered it
|
||||
testing.expectFalse(called);
|
||||
state.done((err) => {
|
||||
testing.expectEqual(1, err.code);
|
||||
testing.expectEqual('User denied Geolocation', err.message);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=geo_watch type=module>
|
||||
{
|
||||
const state = await testing.async();
|
||||
const id = navigator.geolocation.watchPosition(
|
||||
() => { testing.fail('should not succeed without a grant'); state.resolve(); },
|
||||
(err) => { state.resolve(err); }
|
||||
);
|
||||
testing.expectTrue(id > 0);
|
||||
state.done((err) => {
|
||||
testing.expectEqual(1, err.code);
|
||||
// clearing a watch that already delivered is a no-op, not an error
|
||||
navigator.geolocation.clearWatch(id);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=geo_clear_watch type=module>
|
||||
{
|
||||
const state = await testing.async();
|
||||
let watched = false;
|
||||
const id = navigator.geolocation.watchPosition(
|
||||
() => { watched = true; },
|
||||
() => { watched = true; }
|
||||
);
|
||||
navigator.geolocation.clearWatch(id);
|
||||
|
||||
// registered after the watch, so it can only run once the watch would have
|
||||
testing.expectFalse(watched);
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
() => { state.resolve(); },
|
||||
() => { state.resolve(); }
|
||||
);
|
||||
state.done(() => { testing.expectFalse(watched); });
|
||||
}
|
||||
</script>
|
||||
@@ -25,14 +25,25 @@ const Execution = js.Execution;
|
||||
|
||||
const PluginArray = @import("PluginArray.zig");
|
||||
const Permissions = @import("Permissions.zig");
|
||||
const ModelContext = @import("ModelContext.zig");
|
||||
const StorageManager = @import("StorageManager.zig");
|
||||
const NavigatorUAData = @import("NavigatorUAData.zig");
|
||||
const ModelContext = @import("ModelContext.zig");
|
||||
const Geolocation = @import("geolocation/Geolocation.zig");
|
||||
|
||||
const Navigator = @This();
|
||||
_pad: bool = false,
|
||||
|
||||
comptime {
|
||||
// Ensure we don't cause an identity map conflict. Because _geolocation is
|
||||
// lazy and, for now, Zig orders the highest-aligned field first, none of
|
||||
// the other fields land at offset 0.
|
||||
for ([_][]const u8{ "_plugins", "_permissions", "_storage", "_ua_data" }) |name| {
|
||||
if (@offsetOf(Navigator, name) == 0) @compileError(name ++ " aliases the Navigator");
|
||||
}
|
||||
}
|
||||
|
||||
_plugins: PluginArray = .{},
|
||||
_permissions: Permissions = .{},
|
||||
_geolocation: ?*Geolocation = null,
|
||||
_storage: StorageManager = .{},
|
||||
_ua_data: NavigatorUAData = .{},
|
||||
|
||||
@@ -137,6 +148,15 @@ pub fn getPermissions(self: *Navigator) *Permissions {
|
||||
return &self._permissions;
|
||||
}
|
||||
|
||||
pub fn getGeolocation(self: *Navigator, exec: *Execution) !*Geolocation {
|
||||
if (self._geolocation) |g| {
|
||||
return g;
|
||||
}
|
||||
const g = try exec._factory.create(Geolocation{});
|
||||
self._geolocation = g;
|
||||
return g;
|
||||
}
|
||||
|
||||
pub fn getStorage(self: *Navigator) *StorageManager {
|
||||
return &self._storage;
|
||||
}
|
||||
@@ -224,7 +244,6 @@ pub const JsApi = struct {
|
||||
pub const name = "Navigator";
|
||||
pub const prototype_chain = bridge.prototypeChain();
|
||||
pub var class_id: bridge.ClassId = undefined;
|
||||
pub const empty_with_no_proto = true;
|
||||
};
|
||||
|
||||
pub const userAgent = bridge.accessor(Navigator.getUserAgent, null, .{});
|
||||
@@ -253,6 +272,7 @@ pub const JsApi = struct {
|
||||
|
||||
// window only
|
||||
pub const plugins = bridge.accessor(Navigator.getPlugins, null, .{ .exposed = .window });
|
||||
pub const geolocation = bridge.accessor(Navigator.getGeolocation, null, .{ .exposed = .window });
|
||||
pub const modelContext = bridge.accessor(Navigator.getModelContext, null, .{ .exposed = .window });
|
||||
pub const registerProtocolHandler = bridge.function(Navigator.registerProtocolHandler, .{ .exposed = .window });
|
||||
pub const unregisterProtocolHandler = bridge.function(Navigator.unregisterProtocolHandler, .{ .exposed = .window });
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
// 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 GeolocationPosition = @import("GeolocationPosition.zig");
|
||||
const GeolocationPositionError = @import("GeolocationPositionError.zig");
|
||||
|
||||
const log = lp.log;
|
||||
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/Geolocation
|
||||
const Geolocation = @This();
|
||||
|
||||
pub const Override = struct {
|
||||
latitude: f64,
|
||||
longitude: f64,
|
||||
accuracy: f64,
|
||||
};
|
||||
|
||||
const PositionOptions = struct {
|
||||
timeout: ?u32 = null,
|
||||
maximumAge: u32 = 0,
|
||||
enableHighAccuracy: bool = false,
|
||||
};
|
||||
|
||||
// Last id handed out by watchPosition. 0 is a sentinel indicating that the
|
||||
// request is in getCurrentPosition
|
||||
_watch_id: i32 = 0,
|
||||
|
||||
// Watches that haven't delivered yet, so clearWatch can cancel them.
|
||||
_watches: std.ArrayListUnmanaged(*Request) = .empty,
|
||||
|
||||
pub fn getCurrentPosition(
|
||||
self: *Geolocation,
|
||||
success: js.Function.Global,
|
||||
error_cb: ?js.Function.Global,
|
||||
options: ?PositionOptions,
|
||||
exec: *js.Execution,
|
||||
) !void {
|
||||
_ = options; // enableHighAccuracy/timeout/maximumAge don't apply to a fixed position
|
||||
return self.request(0, success, error_cb, exec);
|
||||
}
|
||||
|
||||
pub fn watchPosition(
|
||||
self: *Geolocation,
|
||||
success: js.Function.Global,
|
||||
error_cb: ?js.Function.Global,
|
||||
options: ?PositionOptions,
|
||||
exec: *js.Execution,
|
||||
) !i32 {
|
||||
_ = options;
|
||||
|
||||
// wrap to 1, 0 is a sentinel
|
||||
const watch_id = if (self._watch_id == std.math.maxInt(i32)) 1 else self._watch_id + 1;
|
||||
try self.request(watch_id, success, error_cb, exec);
|
||||
self._watch_id = watch_id;
|
||||
return watch_id;
|
||||
}
|
||||
|
||||
pub fn clearWatch(self: *Geolocation, watch_id: i32) void {
|
||||
for (self._watches.items) |req| {
|
||||
if (req.watch_id == watch_id) {
|
||||
req.cleared = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn request(
|
||||
self: *Geolocation,
|
||||
watch_id: i32,
|
||||
success: js.Function.Global,
|
||||
error_cb: ?js.Function.Global,
|
||||
exec: *js.Execution,
|
||||
) !void {
|
||||
errdefer success.release();
|
||||
errdefer if (error_cb) |cb| cb.release();
|
||||
|
||||
const req = try exec._factory.create(Request{
|
||||
.exec = exec,
|
||||
.success = success,
|
||||
.error_cb = error_cb,
|
||||
.watch_id = watch_id,
|
||||
.geolocation = self,
|
||||
});
|
||||
errdefer exec._factory.destroy(req);
|
||||
|
||||
if (watch_id != 0) {
|
||||
try self._watches.append(exec.arena, req);
|
||||
}
|
||||
errdefer if (watch_id != 0) {
|
||||
_ = self._watches.pop();
|
||||
};
|
||||
|
||||
try exec.js.scheduler.add(req, Request.run, 0, .{
|
||||
.name = if (watch_id == 0) "geolocation.getCurrentPosition" else "geolocation.watchPosition",
|
||||
.finalizer = Request.cancelled,
|
||||
});
|
||||
}
|
||||
|
||||
fn unwatch(self: *Geolocation, req: *Request) void {
|
||||
for (self._watches.items, 0..) |candidate, i| {
|
||||
if (candidate == req) {
|
||||
_ = self._watches.swapRemove(i);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const Request = struct {
|
||||
exec: *js.Execution,
|
||||
cleared: bool = false,
|
||||
geolocation: *Geolocation,
|
||||
success: js.Function.Global,
|
||||
error_cb: ?js.Function.Global,
|
||||
|
||||
// 0 for getCurrentPosition, else the watchPosition id
|
||||
watch_id: i32,
|
||||
|
||||
fn deinit(self: *Request) void {
|
||||
self.success.release();
|
||||
if (self.error_cb) |cb| {
|
||||
cb.release();
|
||||
}
|
||||
self.exec._factory.destroy(self);
|
||||
}
|
||||
|
||||
fn cancelled(ctx: *anyopaque) void {
|
||||
const self: *Request = @ptrCast(@alignCast(ctx));
|
||||
if (self.watch_id != 0) {
|
||||
self.geolocation.unwatch(self);
|
||||
}
|
||||
self.deinit();
|
||||
}
|
||||
|
||||
fn run(ctx: *anyopaque) anyerror!?u32 {
|
||||
const self: *Request = @ptrCast(@alignCast(ctx));
|
||||
defer self.deinit();
|
||||
|
||||
if (self.watch_id != 0) {
|
||||
self.geolocation.unwatch(self);
|
||||
}
|
||||
if (self.cleared) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const browser = self.exec.session.browser;
|
||||
|
||||
// Headless Chrome can't answer a permission prompt, so anything short of
|
||||
// an explicit grant is a denial - which is why Puppeteer and Playwright
|
||||
// both send Browser.grantPermissions alongside setGeolocationOverride.
|
||||
if (browser.permissions.get("geolocation") orelse .prompt != .granted) {
|
||||
self.deliverError(.permission_denied);
|
||||
return null;
|
||||
}
|
||||
|
||||
const override = browser.geolocation_override orelse {
|
||||
self.deliverError(.position_unavailable);
|
||||
return null;
|
||||
};
|
||||
self.deliverPosition(override);
|
||||
return null;
|
||||
}
|
||||
|
||||
fn deliverPosition(self: *Request, override: Override) void {
|
||||
const exec = self.exec;
|
||||
const position = GeolocationPosition.init(exec, override) catch |err| {
|
||||
log.err(.js, "geolocation.position", .{ .err = err });
|
||||
return;
|
||||
};
|
||||
|
||||
// The page can hold on to the position (or its coords), in which case
|
||||
// the JS wrapper takes its own ref; ours only has to cover the call.
|
||||
position.acquireRef();
|
||||
defer position.releaseRef(exec.page);
|
||||
|
||||
self.invoke(self.success, position);
|
||||
}
|
||||
|
||||
fn deliverError(self: *Request, code: GeolocationPositionError.Code) void {
|
||||
const error_cb = self.error_cb orelse return;
|
||||
|
||||
const exec = self.exec;
|
||||
const position_error = GeolocationPositionError.init(exec, code) catch |err| {
|
||||
log.err(.js, "geolocation.error", .{ .err = err });
|
||||
return;
|
||||
};
|
||||
position_error.acquireRef();
|
||||
defer position_error.releaseRef(exec.page);
|
||||
|
||||
self.invoke(error_cb, position_error);
|
||||
}
|
||||
|
||||
fn invoke(self: *Request, callback: js.Function.Global, arg: anytype) void {
|
||||
const exec = self.exec;
|
||||
|
||||
var ls: js.Local.Scope = undefined;
|
||||
exec.js.localScope(&ls);
|
||||
defer ls.deinit();
|
||||
|
||||
ls.toLocal(callback).call(void, .{arg}) catch |err| {
|
||||
exec.page.recordJsError(err);
|
||||
log.warn(.js, "geolocation", .{ .err = err });
|
||||
};
|
||||
ls.local.runMicrotasks();
|
||||
}
|
||||
};
|
||||
|
||||
pub const JsApi = struct {
|
||||
pub const bridge = js.Bridge(Geolocation);
|
||||
|
||||
pub const Meta = struct {
|
||||
pub const name = "Geolocation";
|
||||
pub const prototype_chain = bridge.prototypeChain();
|
||||
pub var class_id: bridge.ClassId = undefined;
|
||||
};
|
||||
|
||||
pub const getCurrentPosition = bridge.function(Geolocation.getCurrentPosition, .{});
|
||||
pub const watchPosition = bridge.function(Geolocation.watchPosition, .{});
|
||||
pub const clearWatch = bridge.function(Geolocation.clearWatch, .{});
|
||||
};
|
||||
|
||||
const testing = @import("../../../testing.zig");
|
||||
test "WebApi: Geolocation" {
|
||||
try testing.htmlRunner("geolocation.html", .{});
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
// 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 js = @import("../../js/js.zig");
|
||||
const Page = @import("../../Page.zig");
|
||||
const GeolocationPosition = @import("GeolocationPosition.zig");
|
||||
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/GeolocationCoordinates
|
||||
const GeolocationCoordinates = @This();
|
||||
|
||||
_latitude: f64,
|
||||
_longitude: f64,
|
||||
_accuracy: f64,
|
||||
_position: *GeolocationPosition,
|
||||
|
||||
// Nothing emulates these yet
|
||||
_altitude: ?f64 = null,
|
||||
_altitude_accuracy: ?f64 = null,
|
||||
_heading: ?f64 = null,
|
||||
_speed: ?f64 = null,
|
||||
|
||||
pub fn acquireRef(self: *GeolocationCoordinates) void {
|
||||
// self exists in self._position._arena
|
||||
self._position.acquireRef();
|
||||
}
|
||||
|
||||
pub fn releaseRef(self: *GeolocationCoordinates, page: *Page) void {
|
||||
self._position.releaseRef(page);
|
||||
}
|
||||
|
||||
fn getLatitude(self: *const GeolocationCoordinates) f64 {
|
||||
return self._latitude;
|
||||
}
|
||||
|
||||
fn getLongitude(self: *const GeolocationCoordinates) f64 {
|
||||
return self._longitude;
|
||||
}
|
||||
|
||||
fn getAccuracy(self: *const GeolocationCoordinates) f64 {
|
||||
return self._accuracy;
|
||||
}
|
||||
|
||||
fn getAltitude(self: *const GeolocationCoordinates) ?f64 {
|
||||
return self._altitude;
|
||||
}
|
||||
|
||||
fn getAltitudeAccuracy(self: *const GeolocationCoordinates) ?f64 {
|
||||
return self._altitude_accuracy;
|
||||
}
|
||||
|
||||
fn getHeading(self: *const GeolocationCoordinates) ?f64 {
|
||||
return self._heading;
|
||||
}
|
||||
|
||||
fn getSpeed(self: *const GeolocationCoordinates) ?f64 {
|
||||
return self._speed;
|
||||
}
|
||||
|
||||
pub const Json = struct {
|
||||
accuracy: f64,
|
||||
latitude: f64,
|
||||
longitude: f64,
|
||||
altitude: ?f64,
|
||||
altitudeAccuracy: ?f64,
|
||||
heading: ?f64,
|
||||
speed: ?f64,
|
||||
};
|
||||
|
||||
pub fn toJSON(self: *const GeolocationCoordinates) Json {
|
||||
return .{
|
||||
.accuracy = self._accuracy,
|
||||
.latitude = self._latitude,
|
||||
.longitude = self._longitude,
|
||||
.altitude = self._altitude,
|
||||
.altitudeAccuracy = self._altitude_accuracy,
|
||||
.heading = self._heading,
|
||||
.speed = self._speed,
|
||||
};
|
||||
}
|
||||
|
||||
pub const JsApi = struct {
|
||||
pub const bridge = js.Bridge(GeolocationCoordinates);
|
||||
|
||||
pub const Meta = struct {
|
||||
pub const name = "GeolocationCoordinates";
|
||||
pub const prototype_chain = bridge.prototypeChain();
|
||||
pub var class_id: bridge.ClassId = undefined;
|
||||
};
|
||||
|
||||
pub const latitude = bridge.accessor(GeolocationCoordinates.getLatitude, null, .{});
|
||||
pub const longitude = bridge.accessor(GeolocationCoordinates.getLongitude, null, .{});
|
||||
pub const accuracy = bridge.accessor(GeolocationCoordinates.getAccuracy, null, .{});
|
||||
pub const altitude = bridge.accessor(GeolocationCoordinates.getAltitude, null, .{});
|
||||
pub const altitudeAccuracy = bridge.accessor(GeolocationCoordinates.getAltitudeAccuracy, null, .{});
|
||||
pub const heading = bridge.accessor(GeolocationCoordinates.getHeading, null, .{});
|
||||
pub const speed = bridge.accessor(GeolocationCoordinates.getSpeed, null, .{});
|
||||
pub const toJSON = bridge.function(GeolocationCoordinates.toJSON, .{});
|
||||
};
|
||||
@@ -0,0 +1,99 @@
|
||||
// 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 lp = @import("lightpanda");
|
||||
|
||||
const js = @import("../../js/js.zig");
|
||||
const Page = @import("../../Page.zig");
|
||||
|
||||
const Geolocation = @import("Geolocation.zig");
|
||||
const GeolocationCoordinates = @import("GeolocationCoordinates.zig");
|
||||
|
||||
const GeolocationPosition = @This();
|
||||
|
||||
_rc: lp.RC = .{},
|
||||
_timestamp: f64,
|
||||
_arena: *lp.Arena,
|
||||
_coords: *GeolocationCoordinates,
|
||||
|
||||
pub fn init(exec: *js.Execution, override: Geolocation.Override) !*GeolocationPosition {
|
||||
const arena = try exec.getArena(.tiny, "GeolocationPosition");
|
||||
errdefer arena.release();
|
||||
|
||||
// coords needs it own distinct address from position, else it will collide
|
||||
// in the identity map
|
||||
const coords = try arena.create(GeolocationCoordinates);
|
||||
const position = try arena.create(GeolocationPosition);
|
||||
|
||||
position.* = .{
|
||||
._arena = arena,
|
||||
._coords = coords,
|
||||
._timestamp = @floatFromInt(lp.datetime.milliTimestamp(.real)),
|
||||
};
|
||||
coords.* = .{
|
||||
._position = position,
|
||||
._latitude = override.latitude,
|
||||
._longitude = override.longitude,
|
||||
._accuracy = override.accuracy,
|
||||
};
|
||||
return position;
|
||||
}
|
||||
|
||||
pub fn deinit(self: *GeolocationPosition, _: *Page) void {
|
||||
self._arena.release();
|
||||
}
|
||||
|
||||
pub fn acquireRef(self: *GeolocationPosition) void {
|
||||
self._rc.acquire();
|
||||
}
|
||||
|
||||
pub fn releaseRef(self: *GeolocationPosition, page: *Page) void {
|
||||
self._rc.release(self, page);
|
||||
}
|
||||
|
||||
fn getCoords(self: *const GeolocationPosition) *GeolocationCoordinates {
|
||||
return self._coords;
|
||||
}
|
||||
|
||||
fn getTimestamp(self: *const GeolocationPosition) f64 {
|
||||
return self._timestamp;
|
||||
}
|
||||
|
||||
pub fn toJSON(self: *const GeolocationPosition) struct {
|
||||
coords: GeolocationCoordinates.Json,
|
||||
timestamp: f64,
|
||||
} {
|
||||
return .{
|
||||
.coords = self._coords.toJSON(),
|
||||
.timestamp = self._timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
pub const JsApi = struct {
|
||||
pub const bridge = js.Bridge(GeolocationPosition);
|
||||
|
||||
pub const Meta = struct {
|
||||
pub const name = "GeolocationPosition";
|
||||
pub const prototype_chain = bridge.prototypeChain();
|
||||
pub var class_id: bridge.ClassId = undefined;
|
||||
};
|
||||
|
||||
pub const coords = bridge.accessor(GeolocationPosition.getCoords, null, .{});
|
||||
pub const timestamp = bridge.accessor(GeolocationPosition.getTimestamp, null, .{});
|
||||
pub const toJSON = bridge.function(GeolocationPosition.toJSON, .{});
|
||||
};
|
||||
@@ -0,0 +1,89 @@
|
||||
// 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 lp = @import("lightpanda");
|
||||
|
||||
const js = @import("../../js/js.zig");
|
||||
const Page = @import("../../Page.zig");
|
||||
|
||||
const GeolocationPositionError = @This();
|
||||
|
||||
pub const Code = enum(u16) {
|
||||
permission_denied = 1,
|
||||
position_unavailable = 2,
|
||||
timeout = 3,
|
||||
|
||||
// Chrome's wording; some pages show it verbatim.
|
||||
fn message(self: Code) []const u8 {
|
||||
return switch (self) {
|
||||
.permission_denied => "User denied Geolocation",
|
||||
.position_unavailable => "Position unavailable",
|
||||
.timeout => "Timeout expired",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
_rc: lp.RC = .{},
|
||||
_code: Code,
|
||||
_arena: *lp.Arena,
|
||||
|
||||
pub fn init(exec: *js.Execution, code: Code) !*GeolocationPositionError {
|
||||
const arena = try exec.getArena(.tiny, "GeolocationPositionError");
|
||||
errdefer arena.release();
|
||||
|
||||
const self = try arena.create(GeolocationPositionError);
|
||||
self.* = .{ ._arena = arena, ._code = code };
|
||||
return self;
|
||||
}
|
||||
|
||||
pub fn deinit(self: *GeolocationPositionError, _: *Page) void {
|
||||
self._arena.release();
|
||||
}
|
||||
|
||||
pub fn acquireRef(self: *GeolocationPositionError) void {
|
||||
self._rc.acquire();
|
||||
}
|
||||
|
||||
pub fn releaseRef(self: *GeolocationPositionError, page: *Page) void {
|
||||
self._rc.release(self, page);
|
||||
}
|
||||
|
||||
fn getCode(self: *const GeolocationPositionError) u16 {
|
||||
return @intFromEnum(self._code);
|
||||
}
|
||||
|
||||
fn getMessage(self: *const GeolocationPositionError) []const u8 {
|
||||
return self._code.message();
|
||||
}
|
||||
|
||||
pub const JsApi = struct {
|
||||
pub const bridge = js.Bridge(GeolocationPositionError);
|
||||
|
||||
pub const Meta = struct {
|
||||
pub const name = "GeolocationPositionError";
|
||||
pub const prototype_chain = bridge.prototypeChain();
|
||||
pub var class_id: bridge.ClassId = undefined;
|
||||
};
|
||||
|
||||
pub const code = bridge.accessor(GeolocationPositionError.getCode, null, .{});
|
||||
pub const message = bridge.accessor(GeolocationPositionError.getMessage, null, .{});
|
||||
|
||||
pub const PERMISSION_DENIED = bridge.property(@intFromEnum(Code.permission_denied), .{ .template = true });
|
||||
pub const POSITION_UNAVAILABLE = bridge.property(@intFromEnum(Code.position_unavailable), .{ .template = true });
|
||||
pub const TIMEOUT = bridge.property(@intFromEnum(Code.timeout), .{ .template = true });
|
||||
};
|
||||
@@ -21,6 +21,7 @@ const lp = @import("lightpanda");
|
||||
|
||||
const CDP = @import("../CDP.zig");
|
||||
const Config = @import("../../Config.zig");
|
||||
const js = @import("../../browser/js/js.zig");
|
||||
|
||||
const log = lp.log;
|
||||
|
||||
@@ -32,6 +33,8 @@ pub fn processMessage(cmd: *CDP.Command) !void {
|
||||
clearDeviceMetricsOverride,
|
||||
setTouchEmulationEnabled,
|
||||
setUserAgentOverride,
|
||||
setGeolocationOverride,
|
||||
clearGeolocationOverride,
|
||||
}, cmd.input.action) orelse return error.UnknownMethod;
|
||||
|
||||
switch (action) {
|
||||
@@ -41,6 +44,8 @@ pub fn processMessage(cmd: *CDP.Command) !void {
|
||||
.clearDeviceMetricsOverride => return clearDeviceMetricsOverride(cmd),
|
||||
.setTouchEmulationEnabled => return setTouchEmulationEnabled(cmd),
|
||||
.setUserAgentOverride => return setUserAgentOverride(cmd),
|
||||
.setGeolocationOverride => return setGeolocationOverride(cmd),
|
||||
.clearGeolocationOverride => return clearGeolocationOverride(cmd),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,6 +176,36 @@ pub fn setUserAgentOverride(cmd: *CDP.Command) !void {
|
||||
return cmd.sendResult(null, .{});
|
||||
}
|
||||
|
||||
fn setGeolocationOverride(cmd: *CDP.Command) !void {
|
||||
const Params = struct {
|
||||
latitude: ?f64 = null,
|
||||
longitude: ?f64 = null,
|
||||
accuracy: ?f64 = null,
|
||||
};
|
||||
// Absent params emulates "position unavailable" (Chrome semantics), so fall
|
||||
// back to all-null defaults rather than erroring.
|
||||
const params = (try cmd.params(Params)) orelse Params{};
|
||||
|
||||
const browser = &cmd.cdp.browser;
|
||||
if (params.latitude) |lat| {
|
||||
if (params.longitude) |lon| {
|
||||
browser.geolocation_override = .{
|
||||
.latitude = lat,
|
||||
.longitude = lon,
|
||||
.accuracy = params.accuracy orelse 0,
|
||||
};
|
||||
return cmd.sendResult(null, .{});
|
||||
}
|
||||
}
|
||||
browser.geolocation_override = null;
|
||||
return cmd.sendResult(null, .{});
|
||||
}
|
||||
|
||||
fn clearGeolocationOverride(cmd: *CDP.Command) !void {
|
||||
cmd.cdp.browser.geolocation_override = null;
|
||||
return cmd.sendResult(null, .{});
|
||||
}
|
||||
|
||||
const testing = @import("../testing.zig");
|
||||
|
||||
test "cdp.Emulation: setUserAgentOverride with valid user agent" {
|
||||
@@ -315,3 +350,263 @@ test "cdp.Emulation: setDeviceMetricsOverride and clear" {
|
||||
try testing.expectEqual(1920, page.getViewport().width);
|
||||
try testing.expectEqual(1080, page.getViewport().height);
|
||||
}
|
||||
|
||||
test "cdp.Emulation: setGeolocationOverride and clear" {
|
||||
var ctx = try testing.context();
|
||||
defer ctx.deinit();
|
||||
|
||||
const bc = try ctx.loadBrowserContext(.{ .id = "BID-GEO", .url = "cdp/dom1.html" });
|
||||
const browser = bc.session.browser;
|
||||
|
||||
try ctx.processMessage(.{
|
||||
.id = 1,
|
||||
.method = "Emulation.setGeolocationOverride",
|
||||
.params = .{ .latitude = 48.8584, .longitude = 2.2945, .accuracy = 10 },
|
||||
});
|
||||
try ctx.expectSentResult(null, .{ .id = 1 });
|
||||
try testing.expectEqual(48.8584, browser.geolocation_override.?.latitude);
|
||||
try testing.expectEqual(2.2945, browser.geolocation_override.?.longitude);
|
||||
|
||||
// no coordinates => emulate "position unavailable" (stored as null)
|
||||
try ctx.processMessage(.{ .id = 2, .method = "Emulation.setGeolocationOverride" });
|
||||
try ctx.expectSentResult(null, .{ .id = 2 });
|
||||
try testing.expect(browser.geolocation_override == null);
|
||||
|
||||
// re-set then clear
|
||||
try ctx.processMessage(.{
|
||||
.id = 3,
|
||||
.method = "Emulation.setGeolocationOverride",
|
||||
.params = .{ .latitude = 1.0, .longitude = 2.0, .accuracy = 5 },
|
||||
});
|
||||
try ctx.expectSentResult(null, .{ .id = 3 });
|
||||
try ctx.processMessage(.{ .id = 4, .method = "Emulation.clearGeolocationOverride" });
|
||||
try ctx.expectSentResult(null, .{ .id = 4 });
|
||||
try testing.expect(browser.geolocation_override == null);
|
||||
}
|
||||
|
||||
test "cdp.Emulation: navigator.geolocation reads the override" {
|
||||
var ctx = try testing.context();
|
||||
defer ctx.deinit();
|
||||
const bc = try ctx.loadBrowserContext(.{ .id = "BID-GEO2", .url = "cdp/dom1.html" });
|
||||
|
||||
try ctx.processMessage(.{
|
||||
.id = 1,
|
||||
.method = "Browser.grantPermissions",
|
||||
.params = .{ .permissions = &[_][]const u8{"geolocation"} },
|
||||
});
|
||||
try ctx.expectSentResult(null, .{ .id = 1, .session_id = null });
|
||||
|
||||
try ctx.processMessage(.{
|
||||
.id = 2,
|
||||
.method = "Emulation.setGeolocationOverride",
|
||||
.params = .{ .latitude = 48.0, .longitude = 2.0, .accuracy = 10 },
|
||||
});
|
||||
try ctx.expectSentResult(null, .{ .id = 2 });
|
||||
|
||||
const frame = bc.mainFrame() orelse unreachable;
|
||||
|
||||
{
|
||||
// Registers the callback synchronously; getCurrentPosition schedules
|
||||
// delivery on the calling context's scheduler and returns before it runs.
|
||||
var ls: js.Local.Scope = undefined;
|
||||
frame.js.localScope(&ls);
|
||||
defer ls.deinit();
|
||||
|
||||
_ = try ls.local.exec(
|
||||
\\ window.__geo_ok = false;
|
||||
\\ navigator.geolocation.getCurrentPosition(p => {
|
||||
\\ window.__geo_ok = (Math.round(p.coords.latitude) === 48 && Math.round(p.coords.longitude) === 2);
|
||||
\\ // outlives both the callback and the position wrapper
|
||||
\\ window.__geo_coords = p.coords;
|
||||
\\ window.__geo_json = JSON.stringify(p);
|
||||
\\ });
|
||||
, null);
|
||||
}
|
||||
|
||||
// Drive the session loop so the scheduled Task fires: Runner._tick runs
|
||||
// browser.runMacrotasks() (which drains frame.js.scheduler) on every tick
|
||||
// for a loaded page, same primitive Runner.waitForSelector/waitForScript
|
||||
// use to pump pending scheduler work under a CDP-loaded page.
|
||||
var runner = bc.session.runner(.{});
|
||||
_ = try runner.tickForFrame(bc.page_handle.?.frame_id, 1000, .{ .until = .done });
|
||||
|
||||
var ls: js.Local.Scope = undefined;
|
||||
frame.js.localScope(&ls);
|
||||
defer ls.deinit();
|
||||
|
||||
const v = try ls.local.exec("window.__geo_ok && Math.round(window.__geo_coords.latitude) === 48", null);
|
||||
try testing.expect(v.isTrue());
|
||||
|
||||
// toJSON nests a plain object, so the coords survive JSON.stringify
|
||||
const j = try ls.local.exec(
|
||||
\\ (() => {
|
||||
\\ const p = JSON.parse(window.__geo_json);
|
||||
\\ return Math.round(p.coords.latitude) === 48 && p.coords.altitude === null && p.timestamp > 0;
|
||||
\\ })()
|
||||
, null);
|
||||
try testing.expect(j.isTrue());
|
||||
}
|
||||
|
||||
test "cdp.Emulation: navigator.geolocation watchPosition delivers the override" {
|
||||
var ctx = try testing.context();
|
||||
defer ctx.deinit();
|
||||
const bc = try ctx.loadBrowserContext(.{ .id = "BID-GEO4", .url = "cdp/dom1.html" });
|
||||
|
||||
try ctx.processMessage(.{
|
||||
.id = 1,
|
||||
.method = "Browser.grantPermissions",
|
||||
.params = .{ .permissions = &[_][]const u8{"geolocation"} },
|
||||
});
|
||||
try ctx.expectSentResult(null, .{ .id = 1, .session_id = null });
|
||||
|
||||
try ctx.processMessage(.{
|
||||
.id = 2,
|
||||
.method = "Emulation.setGeolocationOverride",
|
||||
.params = .{ .latitude = 48.0, .longitude = 2.0, .accuracy = 10 },
|
||||
});
|
||||
try ctx.expectSentResult(null, .{ .id = 2 });
|
||||
|
||||
const frame = bc.mainFrame() orelse unreachable;
|
||||
|
||||
{
|
||||
var ls: js.Local.Scope = undefined;
|
||||
frame.js.localScope(&ls);
|
||||
defer ls.deinit();
|
||||
|
||||
// The second watch is cleared before the scheduler gets to run either,
|
||||
// so only the first one may report.
|
||||
_ = try ls.local.exec(
|
||||
\\ window.__geo_watched = 0;
|
||||
\\ window.__geo_cleared = 0;
|
||||
\\ navigator.geolocation.watchPosition(p => { window.__geo_watched = p.coords.latitude; });
|
||||
\\ const id = navigator.geolocation.watchPosition(() => { window.__geo_cleared += 1; });
|
||||
\\ navigator.geolocation.clearWatch(id);
|
||||
, null);
|
||||
}
|
||||
|
||||
var runner = bc.session.runner(.{});
|
||||
_ = try runner.tickForFrame(bc.page_handle.?.frame_id, 1000, .{ .until = .done });
|
||||
|
||||
var ls: js.Local.Scope = undefined;
|
||||
frame.js.localScope(&ls);
|
||||
defer ls.deinit();
|
||||
|
||||
const v = try ls.local.exec("window.__geo_watched === 48 && window.__geo_cleared === 0", null);
|
||||
try testing.expect(v.isTrue());
|
||||
}
|
||||
|
||||
test "cdp.Emulation: navigator.geolocation needs an explicit permission grant" {
|
||||
var ctx = try testing.context();
|
||||
defer ctx.deinit();
|
||||
const bc = try ctx.loadBrowserContext(.{ .id = "BID-GEO5", .url = "cdp/dom1.html" });
|
||||
|
||||
// An override with no grant leaves the permission at "prompt", which headless
|
||||
// Chrome resolves as denied.
|
||||
try ctx.processMessage(.{
|
||||
.id = 1,
|
||||
.method = "Emulation.setGeolocationOverride",
|
||||
.params = .{ .latitude = 48.0, .longitude = 2.0, .accuracy = 10 },
|
||||
});
|
||||
try ctx.expectSentResult(null, .{ .id = 1 });
|
||||
|
||||
try testing.expectEqual(1, try errorCodeFor(bc));
|
||||
}
|
||||
|
||||
test "cdp.Emulation: navigator.geolocation granted without an override is POSITION_UNAVAILABLE" {
|
||||
var ctx = try testing.context();
|
||||
defer ctx.deinit();
|
||||
const bc = try ctx.loadBrowserContext(.{ .id = "BID-GEO6", .url = "cdp/dom1.html" });
|
||||
|
||||
try ctx.processMessage(.{
|
||||
.id = 1,
|
||||
.method = "Browser.grantPermissions",
|
||||
.params = .{ .permissions = &[_][]const u8{"geolocation"} },
|
||||
});
|
||||
try ctx.expectSentResult(null, .{ .id = 1, .session_id = null });
|
||||
|
||||
try testing.expectEqual(2, try errorCodeFor(bc));
|
||||
}
|
||||
|
||||
// Runs getCurrentPosition, pumps the scheduler, and returns the code the error
|
||||
// callback saw (0 if the success callback ran instead).
|
||||
fn errorCodeFor(bc: *CDP.BrowserContext) !i32 {
|
||||
const frame = bc.mainFrame() orelse unreachable;
|
||||
|
||||
{
|
||||
var ls: js.Local.Scope = undefined;
|
||||
frame.js.localScope(&ls);
|
||||
defer ls.deinit();
|
||||
|
||||
_ = try ls.local.exec(
|
||||
\\ window.__geo_code = 0;
|
||||
\\ navigator.geolocation.getCurrentPosition(
|
||||
\\ () => { window.__geo_code = 0; },
|
||||
\\ (err) => { window.__geo_code = err.code; },
|
||||
\\ );
|
||||
, null);
|
||||
}
|
||||
|
||||
var runner = bc.session.runner(.{});
|
||||
_ = try runner.tickForFrame(bc.page_handle.?.frame_id, 1000, .{ .until = .done });
|
||||
|
||||
var ls: js.Local.Scope = undefined;
|
||||
frame.js.localScope(&ls);
|
||||
defer ls.deinit();
|
||||
|
||||
return (try ls.local.exec("window.__geo_code", null)).toZig(i32);
|
||||
}
|
||||
|
||||
test "cdp.Emulation: navigator.geolocation errors PERMISSION_DENIED when permission is denied" {
|
||||
var ctx = try testing.context();
|
||||
defer ctx.deinit();
|
||||
const bc = try ctx.loadBrowserContext(.{ .id = "BID-GEO3", .url = "cdp/dom1.html" });
|
||||
|
||||
try ctx.processMessage(.{
|
||||
.id = 1,
|
||||
.method = "Browser.setPermission",
|
||||
.params = .{ .permission = .{ .name = "geolocation" }, .setting = "denied" },
|
||||
});
|
||||
try ctx.expectSentResult(null, .{ .id = 1, .session_id = null });
|
||||
|
||||
// Denied is authoritative over the override: even though an override is
|
||||
// set, the denied permission must still win and produce PERMISSION_DENIED.
|
||||
try ctx.processMessage(.{
|
||||
.id = 2,
|
||||
.method = "Emulation.setGeolocationOverride",
|
||||
.params = .{ .latitude = 48.0, .longitude = 2.0, .accuracy = 10 },
|
||||
});
|
||||
try ctx.expectSentResult(null, .{ .id = 2 });
|
||||
|
||||
const frame = bc.mainFrame() orelse unreachable;
|
||||
|
||||
{
|
||||
// Registers the callback synchronously; getCurrentPosition schedules
|
||||
// delivery on the calling context's scheduler and returns before it runs.
|
||||
var ls: js.Local.Scope = undefined;
|
||||
frame.js.localScope(&ls);
|
||||
defer ls.deinit();
|
||||
|
||||
_ = try ls.local.exec(
|
||||
\\ window.__geo_code = 0;
|
||||
\\ navigator.geolocation.getCurrentPosition(p => {
|
||||
\\ p;
|
||||
\\ }, error => {
|
||||
\\ window.__geo_code = error.code;
|
||||
\\ });
|
||||
, null);
|
||||
}
|
||||
|
||||
// Drive the session loop so the scheduled Task fires: Runner._tick runs
|
||||
// browser.runMacrotasks() (which drains frame.js.scheduler) on every tick
|
||||
// for a loaded page, same primitive Runner.waitForSelector/waitForScript
|
||||
// use to pump pending scheduler work under a CDP-loaded page.
|
||||
var runner = bc.session.runner(.{});
|
||||
_ = try runner.tickForFrame(bc.page_handle.?.frame_id, 1000, .{ .until = .done });
|
||||
|
||||
var ls: js.Local.Scope = undefined;
|
||||
frame.js.localScope(&ls);
|
||||
defer ls.deinit();
|
||||
|
||||
const v = try ls.local.exec("window.__geo_code === 1", null);
|
||||
try testing.expect(v.isTrue());
|
||||
}
|
||||
File diff suppressed because one or more lines are too long.
Reference in new issue
Block a user