Merge pull request #2218 from lightpanda-io/user_agent_data

Adds navigator.userAgentData
This commit is contained in:
Karl Seguin
2026-04-23 17:05:31 +08:00
committed by GitHub
8 changed files with 226 additions and 15 deletions

View File

@@ -389,7 +389,27 @@ pub const WaitUntil = enum {
/// Must be initialized with an allocator that outlives all HTTP connections.
pub const HttpHeaders = struct {
const user_agent_base: [:0]const u8 = "Lightpanda/1.0";
pub const sec_ch_ua: [:0]const u8 = "Sec-Ch-Ua: \"Lightpanda\";v=\"1\"";
const Brand = struct {
brand: [:0]const u8,
version: [:0]const u8,
};
/// Source of truth for client-hints brand data. Both the Sec-Ch-Ua
/// HTTP header and navigator.userAgentData.brands derive from this
/// list, so the two sides cannot drift.
pub const brands = [_]Brand{
.{ .brand = "Lightpanda", .version = "1" },
};
pub const sec_ch_ua: [:0]const u8 = blk: {
var out: [:0]const u8 = "Sec-Ch-Ua:";
for (brands, 0..) |b, i| {
const sep = if (i == 0) " " else ", ";
out = out ++ sep ++ "\"" ++ b.brand ++ "\";v=\"" ++ b.version ++ "\"";
}
break :blk out;
};
user_agent: [:0]const u8, // User agent value (e.g. "Lightpanda/1.0")
user_agent_header: [:0]const u8,

View File

@@ -809,8 +809,9 @@ pub fn documentIsLoaded(self: *Frame) void {
self._load_state = .load;
self.document._ready_state = .interactive;
self._documentIsLoaded() catch |err| {
log.err(.frame, "document is loaded", .{ .err = err, .type = self._type, .url = self.url });
self._documentIsLoaded() catch |err| switch (err) {
error.JsException => {}, // already logged
else => log.err(.frame, "document is loaded2", .{ .err = err, .type = self._type, .url = self.url }),
};
}
@@ -882,8 +883,9 @@ pub fn documentIsComplete(self: *Frame) void {
}
self._load_state = .complete;
self._documentIsComplete() catch |err| {
log.err(.frame, "document is complete", .{ .err = err, .type = self._type, .url = self.url });
self._documentIsComplete() catch |err| switch (err) {
error.JsException => {}, // already logged
else => log.err(.frame, "document is complete", .{ .err = err, .type = self._type, .url = self.url }),
};
}

View File

@@ -858,6 +858,7 @@ pub const PageJsApis = flattenTypes(&.{
@import("../webapi/EventTarget.zig"),
@import("../webapi/Location.zig"),
@import("../webapi/Navigator.zig"),
@import("../webapi/NavigatorUAData.zig"),
@import("../webapi/net/FormData.zig"),
@import("../webapi/net/Headers.zig"),
@import("../webapi/net/Request.zig"),
@@ -936,6 +937,7 @@ pub const WorkerJsApis = flattenTypes(&.{
@import("../webapi/AbortSignal.zig"),
@import("../webapi/AbortController.zig"),
@import("../webapi/URL.zig"),
@import("../webapi/canvas/OffscreenCanvas.zig"),
// @import("../webapi/Performance.zig"),
});

View File

@@ -65,6 +65,49 @@
testing.expectEqual(8, navigator.deviceMemory);
</script>
<script id=userAgentData>
testing.expectEqual(true, navigator.userAgentData !== undefined);
testing.expectEqual(false, navigator.userAgentData.mobile);
testing.expectEqual(true, navigator.userAgentData.platform.length > 0);
const validUAPlatforms = ['macOS', 'Windows', 'Linux', 'FreeBSD', 'Unknown'];
testing.expectEqual(true, validUAPlatforms.includes(navigator.userAgentData.platform));
testing.expectEqual(1, navigator.userAgentData.brands.length);
testing.expectEqual({brand: 'Lightpanda', version: "1"}, navigator.userAgentData.brands[0]);
// Same instance returned on repeated access
testing.expectEqual(true, navigator.userAgentData === navigator.userAgentData);
const json = navigator.userAgentData.toJSON();
testing.expectEqual(false, json.mobile);
testing.expectEqual(navigator.userAgentData.platform, json.platform);
testing.expectEqual(true, Array.isArray(json.brands));
testing.expectEqual('Lightpanda', json.brands[0].brand);
</script>
<script id=userAgentData_highEntropy type=module>
{
const state = await testing.async();
const p = navigator.userAgentData.getHighEntropyValues(['architecture', 'platformVersion', 'fullVersionList']);
testing.expectTrue(p instanceof Promise);
const v = await p;
state.resolve();
await state.done(() => {
testing.expectEqual(false, v.mobile);
testing.expectEqual(navigator.userAgentData.platform, v.platform);
testing.expectEqual(true, Array.isArray(v.brands));
testing.expectEqual(true, Array.isArray(v.fullVersionList));
testing.expectEqual('Lightpanda', v.fullVersionList[0].brand);
testing.expectEqual('1.0.0.0', v.uaFullVersion);
testing.expectEqual(false, v.wow64);
testing.expectEqual(true, Array.isArray(v.formFactor));
testing.expectEqual('Desktop', v.formFactor[0]);
});
}
</script>
<script id=getBattery type=module>
{
const state = await testing.async();

View File

@@ -26,6 +26,7 @@ const Frame = @import("../Frame.zig");
const PluginArray = @import("PluginArray.zig");
const Permissions = @import("Permissions.zig");
const StorageManager = @import("StorageManager.zig");
const NavigatorUAData = @import("NavigatorUAData.zig");
const log = lp.log;
@@ -34,6 +35,7 @@ _pad: bool = false,
_plugins: PluginArray = .{},
_permissions: Permissions = .{},
_storage: StorageManager = .{},
_ua_data: NavigatorUAData = .{},
pub const init: Navigator = .{};
@@ -72,6 +74,10 @@ pub fn getStorage(self: *Navigator) *StorageManager {
return &self._storage;
}
pub fn getUserAgentData(self: *Navigator) *NavigatorUAData {
return &self._ua_data;
}
pub fn getBattery(_: *const Navigator, frame: *Frame) !js.Promise {
log.info(.not_implemented, "navigator.getBattery", .{});
return frame.js.local.?.rejectErrorPromise(.{ .dom_exception = .{ .err = error.NotSupported } });
@@ -182,6 +188,7 @@ pub const JsApi = struct {
pub const getBattery = bridge.function(Navigator.getBattery, .{});
pub const permissions = bridge.accessor(Navigator.getPermissions, null, .{});
pub const storage = bridge.accessor(Navigator.getStorage, null, .{});
pub const userAgentData = bridge.accessor(Navigator.getUserAgentData, null, .{});
};
const testing = @import("../../testing.zig");

View File

@@ -0,0 +1,134 @@
// 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 builtin = @import("builtin");
const Config = @import("../../Config.zig");
const js = @import("../js/js.zig");
const Frame = @import("../Frame.zig");
const NavigatorUAData = @This();
_pad: bool = false,
const Brand = struct {
brand: []const u8,
version: []const u8,
};
pub fn getBrands(_: *const NavigatorUAData) []const Brand {
return brandList();
}
pub fn getMobile(_: *const NavigatorUAData) bool {
return false;
}
pub fn getPlatform(_: *const NavigatorUAData) []const u8 {
return uaPlatform();
}
pub fn toJSON(_: *const NavigatorUAData) struct {
brands: []const Brand,
mobile: bool,
platform: []const u8,
} {
return .{
.mobile = false,
.brands = brandList(),
.platform = uaPlatform(),
};
}
pub fn getHighEntropyValues(_: *const NavigatorUAData, hints: []const []const u8, frame: *Frame) !js.Promise {
// This should always return `brands` + `mobile` + `platform` and then whatever
// "hints" field is requested (assuming the browser has permission), but it's
// also valid to just return everything.
_ = hints;
return frame.js.local.?.resolvePromise(.{
.brands = brandList(),
.mobile = false,
.platform = uaPlatform(),
.architecture = uaArchitecture(),
.bitness = uaBitness(),
.model = "",
.platformVersion = "",
.uaFullVersion = "1.0.0.0",
.fullVersionList = brandList(),
.wow64 = false,
.formFactor = [_][]const u8{"Desktop"},
});
}
fn brandList() []const Brand {
const out = comptime blk: {
const src = &Config.HttpHeaders.brands;
var arr: [src.len]Brand = undefined;
for (src, 0..) |b, i| {
arr[i] = .{ .brand = b.brand, .version = b.version };
}
const final = arr;
break :blk final;
};
return &out;
}
fn uaPlatform() []const u8 {
return switch (builtin.os.tag) {
.macos => "macOS",
.windows => "Windows",
.linux => "Linux",
.freebsd => "FreeBSD",
else => "Unknown",
};
}
fn uaArchitecture() []const u8 {
return switch (builtin.cpu.arch) {
.x86, .x86_64 => "x86",
.aarch64, .aarch64_be, .arm, .armeb => "arm",
else => "",
};
}
fn uaBitness() []const u8 {
return switch (builtin.cpu.arch) {
.x86_64, .aarch64, .aarch64_be, .powerpc64, .powerpc64le, .riscv64 => "64",
else => "32",
};
}
pub const JsApi = struct {
pub const bridge = js.Bridge(NavigatorUAData);
pub const Meta = struct {
pub const name = "NavigatorUAData";
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
pub const empty_with_no_proto = true;
};
pub const brands = bridge.accessor(NavigatorUAData.getBrands, null, .{});
pub const mobile = bridge.accessor(NavigatorUAData.getMobile, null, .{});
pub const platform = bridge.accessor(NavigatorUAData.getPlatform, null, .{});
pub const toJSON = bridge.function(NavigatorUAData.toJSON, .{});
pub const getHighEntropyValues = bridge.function(NavigatorUAData.getHighEntropyValues, .{});
};

View File

@@ -18,11 +18,12 @@
const std = @import("std");
const js = @import("../../js/js.zig");
const Frame = @import("../../Frame.zig");
const Blob = @import("../Blob.zig");
const OffscreenCanvasRenderingContext2D = @import("OffscreenCanvasRenderingContext2D.zig");
const Execution = js.Execution;
/// https://developer.mozilla.org/en-US/docs/Web/API/OffscreenCanvas
const OffscreenCanvas = @This();
@@ -37,8 +38,8 @@ const DrawingContext = union(enum) {
@"2d": *OffscreenCanvasRenderingContext2D,
};
pub fn constructor(width: u32, height: u32, frame: *Frame) !*OffscreenCanvas {
return frame._factory.create(OffscreenCanvas{
pub fn constructor(width: u32, height: u32, exec: *Execution) !*OffscreenCanvas {
return exec._factory.create(OffscreenCanvas{
._width = width,
._height = height,
});
@@ -60,9 +61,9 @@ pub fn setHeight(self: *OffscreenCanvas, value: u32) void {
self._height = value;
}
pub fn getContext(_: *OffscreenCanvas, context_type: []const u8, frame: *Frame) !?DrawingContext {
pub fn getContext(_: *OffscreenCanvas, context_type: []const u8, exec: *Execution) !?DrawingContext {
if (std.mem.eql(u8, context_type, "2d")) {
const ctx = try frame._factory.create(OffscreenCanvasRenderingContext2D{});
const ctx = try exec._factory.create(OffscreenCanvasRenderingContext2D{});
return .{ .@"2d" = ctx };
}
@@ -71,9 +72,9 @@ pub fn getContext(_: *OffscreenCanvas, context_type: []const u8, frame: *Frame)
/// Returns a Promise that resolves to a Blob containing the image.
/// Since we have no actual rendering, this returns an empty blob.
pub fn convertToBlob(_: *OffscreenCanvas, frame: *Frame) !js.Promise {
const blob = try Blob.init(null, null, frame._page);
return frame.js.local.?.resolvePromise(blob);
pub fn convertToBlob(_: *OffscreenCanvas, exec: *Execution) !js.Promise {
const blob = try Blob.init(null, null, exec.context.page);
return exec.context.local.?.resolvePromise(blob);
}
/// Returns an ImageBitmap with the rendered content (stub).

View File

@@ -27,6 +27,8 @@ const CanvasRenderingContext2D = @import("../../canvas/CanvasRenderingContext2D.
const WebGLRenderingContext = @import("../../canvas/WebGLRenderingContext.zig");
const OffscreenCanvas = @import("../../canvas/OffscreenCanvas.zig");
const Execution = js.Execution;
const Canvas = @This();
_proto: *HtmlElement,
_cached: ?DrawingContext = null,
@@ -97,10 +99,10 @@ pub fn getContext(self: *Canvas, context_type: []const u8, frame: *Frame) !?Draw
/// Transfers control of the canvas to an OffscreenCanvas.
/// Returns an OffscreenCanvas with the same dimensions.
pub fn transferControlToOffscreen(self: *Canvas, frame: *Frame) !*OffscreenCanvas {
pub fn transferControlToOffscreen(self: *Canvas, exec: *Execution) !*OffscreenCanvas {
const width = self.getWidth();
const height = self.getHeight();
return OffscreenCanvas.constructor(width, height, frame);
return OffscreenCanvas.constructor(width, height, exec);
}
pub const JsApi = struct {