diff --git a/src/App.zig b/src/App.zig
index 484b67984..fe5a0effa 100644
--- a/src/App.zig
+++ b/src/App.zig
@@ -44,7 +44,11 @@ arena_pool: ArenaPool,
app_dir_path: ?[]const u8,
pub fn init(allocator: Allocator, config: *const Config) !*App {
- const platform = try Platform.init(config.v8Flags());
+ const platform = try Platform.init(.{
+ .v8_flags = config.v8Flags(),
+ .locale = config.locale(),
+ .timezone = config.timezone(),
+ });
errdefer platform.deinit();
const snapshot = try Snapshot.load();
diff --git a/src/Config.zig b/src/Config.zig
index 031becf6b..9db44ba42 100644
--- a/src/Config.zig
+++ b/src/Config.zig
@@ -276,6 +276,8 @@ const CommonOptions = .{
.{ .name = "web_bot_auth_keyid", .type = ?[]const u8 },
.{ .name = "web_bot_auth_domain", .type = ?[]const u8 },
.{ .name = "user_agent", .type = ?[]const u8, .validator = userAgentValidator },
+ .{ .name = "locale", .type = []const u8, .default = HttpHeaders.default_locale, .validator = localeValidator },
+ .{ .name = "timezone", .type = ?[]const u8, .validator = timezoneValidator },
.{ .name = "block_private_networks", .type = bool },
.{ .name = "block_cidrs", .type = ?[]const u8, .validator = accumulateValidator },
.{ .name = "block_urls", .type = ?[]const u8, .validator = accumulateValidator },
@@ -727,6 +729,20 @@ pub fn userAgent(self: *const Config) ?[]const u8 {
};
}
+pub fn locale(self: *const Config) []const u8 {
+ return switch (self.mode) {
+ inline .serve, .fetch, .mcp, .agent => |opts| opts.locale,
+ else => HttpHeaders.default_locale,
+ };
+}
+
+pub fn timezone(self: *const Config) ?[]const u8 {
+ return switch (self.mode) {
+ inline .serve, .fetch, .mcp, .agent => |opts| opts.timezone,
+ else => null,
+ };
+}
+
pub fn httpCacheDir(self: *const Config) ?[]const u8 {
return switch (self.mode) {
inline .serve, .fetch, .mcp, .agent => |opts| opts.http_cache_dir,
@@ -971,7 +987,8 @@ pub const HttpHeaders = struct {
// stream when a client sends Accept-Encoding without Accept-Language,
// treating it as a bot signal. Ship a neutral default so we look like a
// normal client.
- pub const accept_language: [:0]const u8 = "en-US,en;q=0.9";
+ pub const default_locale: []const u8 = "en-US";
+ pub const accept_language_default: [:0]const u8 = "en-US,en;q=0.9";
// Document-navigation Accept value Chrome sends.
pub const navigation_accept: [:0]const u8 = "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8";
@@ -980,6 +997,12 @@ pub const HttpHeaders = struct {
proxy_bearer_header: ?[:0]const u8,
+ // Derived from --locale: the header and the navigator.languages list are
+ // the same information in two shapes, so they are built together.
+ accept_language: [:0]const u8,
+ languages: [2][]const u8,
+ languages_len: u8,
+
pub fn init(allocator: Allocator, config: *const Config) !HttpHeaders {
const user_agent: [:0]const u8 = if (config.userAgent()) |ua|
try allocator.dupeZ(u8, ua)
@@ -993,10 +1016,18 @@ pub const HttpHeaders = struct {
try std.fmt.allocPrintSentinel(allocator, "Proxy-Authorization: Bearer {s}", .{token}, 0)
else
null;
+ errdefer if (proxy_bearer_header) |hdr| allocator.free(hdr);
+
+ const tag = config.locale();
+ const primary = primarySubtag(tag);
+ const accept_language = try acceptLanguageFor(allocator, tag);
return .{
.user_agent = user_agent,
.proxy_bearer_header = proxy_bearer_header,
+ .accept_language = accept_language,
+ .languages = .{ tag, primary },
+ .languages_len = if (primary.len == tag.len) 1 else 2,
};
}
@@ -1007,6 +1038,36 @@ pub const HttpHeaders = struct {
if (self.user_agent.ptr != user_agent_base.ptr) {
allocator.free(self.user_agent);
}
+ if (self.accept_language.ptr != accept_language_default.ptr) {
+ allocator.free(self.accept_language);
+ }
+ }
+
+ pub fn primarySubtag(tag: []const u8) []const u8 {
+ const end = std.mem.indexOfScalar(u8, tag, '-') orelse tag.len;
+ return tag[0..end];
+ }
+
+ /// Chrome's shape: the tag first, then its language alone, then English
+ /// as a last resort, with descending q values. The default returns the
+ /// comptime constant so callers can tell it apart by pointer.
+ pub fn acceptLanguageFor(allocator: Allocator, tag: []const u8) ![:0]const u8 {
+ if (std.mem.eql(u8, tag, default_locale)) {
+ return accept_language_default;
+ }
+ const primary = primarySubtag(tag);
+ const has_region = primary.len != tag.len;
+ const is_english = std.ascii.eqlIgnoreCase(primary, "en");
+ if (has_region and !is_english) {
+ return std.fmt.allocPrintSentinel(allocator, "{s},{s};q=0.9,en;q=0.8", .{ tag, primary }, 0);
+ }
+ if (has_region) {
+ return std.fmt.allocPrintSentinel(allocator, "{s},{s};q=0.9", .{ tag, primary }, 0);
+ }
+ if (!is_english) {
+ return std.fmt.allocPrintSentinel(allocator, "{s},en;q=0.9", .{tag}, 0);
+ }
+ return allocator.dupeZ(u8, tag);
}
};
@@ -1245,6 +1306,84 @@ test "Config: validateUserAgent" {
try std.testing.expectError(error.NonPrintable, validateUserAgent("bad\x01ua"));
}
+test "Config: validateLocale" {
+ try validateLocale("en");
+ try validateLocale("en-US");
+ try validateLocale("zh-Hant-TW");
+ try validateLocale("es-419");
+ try std.testing.expectError(error.InvalidLanguage, validateLocale(""));
+ try std.testing.expectError(error.InvalidLanguage, validateLocale("e"));
+ try std.testing.expectError(error.InvalidLanguage, validateLocale("en_US"));
+ try std.testing.expectError(error.InvalidSubtag, validateLocale("en-"));
+ try std.testing.expectError(error.InvalidSubtag, validateLocale("en-U"));
+ try std.testing.expectError(error.InvalidSubtag, validateLocale("en-US-x-toolongsub"));
+ try std.testing.expectError(error.InvalidSubtag, validateLocale("en-U$"));
+ try std.testing.expectError(error.TooLong, validateLocale("en-" ++ "a" ** 40));
+}
+
+test "Config: validateTimezone" {
+ try validateTimezone("UTC");
+ try validateTimezone("Europe/Paris");
+ try validateTimezone("America/Argentina/Buenos_Aires");
+ try std.testing.expectError(error.Empty, validateTimezone(""));
+ try std.testing.expectError(error.InvalidCharacter, validateTimezone("Europe/ Paris"));
+ try std.testing.expectError(error.InvalidCharacter, validateTimezone("UTC\n"));
+ try std.testing.expectError(error.TooLong, validateTimezone("a" ** 65));
+}
+
+test "Config: HttpHeaders.acceptLanguageFor" {
+ const allocator = std.testing.allocator;
+ const cases = [_]struct { tag: []const u8, expected: []const u8 }{
+ .{ .tag = "en-US", .expected = "en-US,en;q=0.9" },
+ .{ .tag = "en-GB", .expected = "en-GB,en;q=0.9" },
+ .{ .tag = "de-DE", .expected = "de-DE,de;q=0.9,en;q=0.8" },
+ .{ .tag = "de", .expected = "de,en;q=0.9" },
+ .{ .tag = "en", .expected = "en" },
+ .{ .tag = "zh-Hant-TW", .expected = "zh-Hant-TW,zh;q=0.9,en;q=0.8" },
+ };
+ for (cases) |case| {
+ const value = try HttpHeaders.acceptLanguageFor(allocator, case.tag);
+ defer if (value.ptr != HttpHeaders.accept_language_default.ptr) allocator.free(value);
+ try std.testing.expectEqualStrings(case.expected, value);
+ }
+ const default = try HttpHeaders.acceptLanguageFor(allocator, "en-US");
+ try std.testing.expectEqual(HttpHeaders.accept_language_default.ptr, default.ptr);
+}
+
+test "Config: locale drives http_headers" {
+ const allocator = std.testing.allocator;
+ {
+ var config = try Config.init(allocator, "test", .{ .serve = .{ .host = "127.0.0.1" } });
+ defer config.deinit(allocator);
+ try std.testing.expectEqualStrings("en-US,en;q=0.9", config.http_headers.accept_language);
+ try std.testing.expectEqual(2, config.http_headers.languages_len);
+ try std.testing.expectEqualStrings("en-US", config.http_headers.languages[0]);
+ try std.testing.expectEqualStrings("en", config.http_headers.languages[1]);
+ }
+ {
+ var config = try Config.init(allocator, "test", .{ .serve = .{ .host = "127.0.0.1", .locale = "fr" } });
+ defer config.deinit(allocator);
+ try std.testing.expectEqualStrings("fr,en;q=0.9", config.http_headers.accept_language);
+ try std.testing.expectEqual(1, config.http_headers.languages_len);
+ try std.testing.expectEqualStrings("fr", config.http_headers.languages[0]);
+ }
+}
+
+test "Config: parseArgs refuses an invalid --locale and --timezone" {
+ {
+ log.expectLog(&.{.app});
+ const argv = [_][*:0]const u8{ "lightpanda", "fetch", "--locale", "en_US" };
+ const proc_args: std.process.Args = .{ .vector = &argv };
+ try std.testing.expectError(error.InvalidArgument, parseArgs(std.testing.allocator, proc_args));
+ }
+ {
+ log.expectLog(&.{.app});
+ const argv = [_][*:0]const u8{ "lightpanda", "fetch", "--timezone", "Europe/ Paris" };
+ const proc_args: std.process.Args = .{ .vector = &argv };
+ try std.testing.expectError(error.InvalidArgument, parseArgs(std.testing.allocator, proc_args));
+ }
+}
+
test "Config: parseArgs refuses an invalid --http-header" {
const invalid = [_][*:0]const u8{
// no colon to split on
@@ -1375,6 +1514,68 @@ pub fn validateUserAgent(ua: []const u8) !void {
}
}
+fn localeValidator(allocator: Allocator, args: *std.process.Args.Iterator, field: *[]const u8) !void {
+ const str = args.next() orelse return error.MissingArgument;
+ validateLocale(str) catch |err| {
+ log.fatal(.app, "invalid option value", .{ .arg = "--locale", .value = str, .err = err, .hint = "must be a BCP 47 tag such as en-US, de or zh-Hant-TW" });
+ return error.InvalidArgument;
+ };
+ field.* = try allocator.dupe(u8, str);
+}
+
+fn timezoneValidator(allocator: Allocator, args: *std.process.Args.Iterator, field: *?[]const u8) !void {
+ const str = args.next() orelse return error.MissingArgument;
+ validateTimezone(str) catch |err| {
+ log.fatal(.app, "invalid option value", .{ .arg = "--timezone", .value = str, .err = err, .hint = "must be an IANA time zone such as Europe/Paris or UTC" });
+ return error.InvalidArgument;
+ };
+ field.* = try allocator.dupe(u8, str);
+}
+
+/// A BCP 47 tag restricted to what ICU and the Accept-Language derivation
+/// need: a 2-3 letter language followed by 2-8 character alphanumeric subtags.
+pub fn validateLocale(tag: []const u8) !void {
+ if (tag.len > 35) {
+ return error.TooLong;
+ }
+ var it = std.mem.splitScalar(u8, tag, '-');
+ const language = it.next().?;
+ if (language.len < 2 or language.len > 3) {
+ return error.InvalidLanguage;
+ }
+ for (language) |c| {
+ if (!std.ascii.isAlphabetic(c)) {
+ return error.InvalidLanguage;
+ }
+ }
+ while (it.next()) |subtag| {
+ if (subtag.len < 2 or subtag.len > 8) {
+ return error.InvalidSubtag;
+ }
+ for (subtag) |c| {
+ if (!std.ascii.isAlphanumeric(c)) {
+ return error.InvalidSubtag;
+ }
+ }
+ }
+}
+
+/// Only the shape is checked; ICU resolves the id itself and falls back to
+/// GMT for names it does not know.
+pub fn validateTimezone(id: []const u8) !void {
+ if (id.len == 0) {
+ return error.Empty;
+ }
+ if (id.len > 64) {
+ return error.TooLong;
+ }
+ for (id) |c| {
+ if (c <= ' ' or c == 0x7f) {
+ return error.InvalidCharacter;
+ }
+ }
+}
+
/// Tag names of a Zig enum, so a command's allowed values can't drift from the
/// enum it sets.
pub const tagNames = cli.tagNames;
diff --git a/src/browser/js/Platform.zig b/src/browser/js/Platform.zig
index ab9a60c08..942b84150 100644
--- a/src/browser/js/Platform.zig
+++ b/src/browser/js/Platform.zig
@@ -16,17 +16,39 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
+const std = @import("std");
const js = @import("js.zig");
const v8 = js.v8;
const Platform = @This();
handle: *v8.Platform,
-pub fn init(v8_flags: ?[]const u8) !Platform {
- if (v8_flags) |flags| {
+pub const Options = struct {
+ v8_flags: ?[]const u8 = null,
+ // BCP 47 tag; becomes ICU's default locale (Intl, toLocaleString).
+ locale: ?[]const u8 = null,
+ // IANA id; becomes ICU's default time zone. Null keeps the host zone.
+ timezone: ?[]const u8 = null,
+};
+
+/// ICU reads LC_ALL and TZ lazily on first use, so the environment must be
+/// set here, before InitializeICU and before the platform starts its thread
+/// pool (setenv is not safe once other threads may call getenv).
+pub fn init(opts: Options) !Platform {
+ if (opts.v8_flags) |flags| {
v8.v8__V8__SetFlagsFromString(flags.ptr, flags.len);
}
+ if (opts.locale) |tag| {
+ var buf: [32]u8 = undefined;
+ _ = setenv("LC_ALL", posixLocaleId(&buf, tag), 1);
+ }
+ if (opts.timezone) |id| {
+ var buf: [128]u8 = undefined;
+ const value = std.fmt.bufPrintZ(&buf, "{s}", .{id}) catch return error.TimezoneTooLong;
+ _ = setenv("TZ", value, 1);
+ }
+
if (v8.v8__V8__InitializeICU() == false) {
return error.FailedToInitializeICU;
}
@@ -43,3 +65,41 @@ pub fn deinit(self: Platform) void {
v8.v8__V8__DisposePlatform();
v8.v8__Platform__DELETE(self.handle);
}
+
+/// `language[_REGION].UTF-8`, the POSIX id ICU parses from LC_ALL. The region
+/// is the first 2-letter or 3-digit subtag, so a script subtag is skipped.
+/// The tag is assumed valid per Config.validateLocale (language <= 3 bytes).
+pub fn posixLocaleId(buf: *[32]u8, tag: []const u8) [:0]const u8 {
+ var it = std.mem.splitScalar(u8, tag, '-');
+ var language_buf: [3]u8 = undefined;
+ const language = std.ascii.lowerString(&language_buf, it.next().?);
+
+ while (it.next()) |subtag| {
+ const is_alpha2 = subtag.len == 2 and std.ascii.isAlphabetic(subtag[0]) and std.ascii.isAlphabetic(subtag[1]);
+ const is_digit3 = subtag.len == 3 and std.ascii.isDigit(subtag[0]) and std.ascii.isDigit(subtag[1]) and std.ascii.isDigit(subtag[2]);
+ if (is_alpha2 or is_digit3) {
+ var region_buf: [3]u8 = undefined;
+ const region = std.ascii.upperString(®ion_buf, subtag);
+ return std.fmt.bufPrintZ(buf, "{s}_{s}.UTF-8", .{ language, region }) catch unreachable;
+ }
+ }
+ return std.fmt.bufPrintZ(buf, "{s}.UTF-8", .{language}) catch unreachable;
+}
+
+extern fn setenv(name: [*:0]const u8, value: [*:0]const u8, override: c_int) c_int;
+
+test "Platform: posixLocaleId" {
+ const cases = [_]struct { tag: []const u8, expected: []const u8 }{
+ .{ .tag = "en-US", .expected = "en_US.UTF-8" },
+ .{ .tag = "de-DE", .expected = "de_DE.UTF-8" },
+ .{ .tag = "en", .expected = "en.UTF-8" },
+ .{ .tag = "zh-Hant-TW", .expected = "zh_TW.UTF-8" },
+ .{ .tag = "es-419", .expected = "es_419.UTF-8" },
+ .{ .tag = "PT-br", .expected = "pt_BR.UTF-8" },
+ .{ .tag = "de-DE-1996", .expected = "de_DE.UTF-8" },
+ };
+ for (cases) |case| {
+ var buf: [32]u8 = undefined;
+ try std.testing.expectEqualStrings(case.expected, posixLocaleId(&buf, case.tag));
+ }
+}
diff --git a/src/browser/webapi/Navigator.zig b/src/browser/webapi/Navigator.zig
index 4beeae7dc..65fd19d86 100644
--- a/src/browser/webapi/Navigator.zig
+++ b/src/browser/webapi/Navigator.zig
@@ -53,8 +53,8 @@ pub fn getUserAgent(_: *const Navigator, exec: *const Execution) []const u8 {
return exec.session.browser.http_client.getUserAgent();
}
-pub fn getLanguages(_: *const Navigator) [2][]const u8 {
- return .{ "en-US", "en" };
+pub fn getLanguages(_: *const Navigator, exec: *const Execution) []const []const u8 {
+ return exec.session.browser.http_client.getLanguages();
}
pub fn getDoNotTrack(_: *const Navigator) ?[]const u8 {
@@ -73,8 +73,9 @@ pub fn getAppVersion(_: *const Navigator) []const u8 {
return "1.0";
}
-pub fn getLanguage(_: *const Navigator) []const u8 {
- return "en-US";
+pub fn getLanguage(self: *const Navigator, exec: *const Execution) []const u8 {
+ const languages = self.getLanguages(exec);
+ return if (languages.len == 0) "" else languages[0];
}
pub fn getOnLine(_: *const Navigator) bool {
diff --git a/src/browser/webapi/WorkerNavigator.zig b/src/browser/webapi/WorkerNavigator.zig
index cced8ae70..7a03cedac 100644
--- a/src/browser/webapi/WorkerNavigator.zig
+++ b/src/browser/webapi/WorkerNavigator.zig
@@ -46,8 +46,8 @@ pub fn getUserAgent(_: *const WorkerNavigator, exec: *const Execution) []const u
return Navigator.getUserAgent(&Navigator.init, exec);
}
-pub fn getLanguages(_: *const WorkerNavigator) [2][]const u8 {
- return Navigator.getLanguages(&Navigator.init);
+pub fn getLanguages(_: *const WorkerNavigator, exec: *const Execution) []const []const u8 {
+ return Navigator.getLanguages(&Navigator.init, exec);
}
pub fn getAppName(_: *const WorkerNavigator) []const u8 {
@@ -62,8 +62,8 @@ pub fn getAppVersion(_: *const WorkerNavigator) []const u8 {
return Navigator.getAppVersion(&Navigator.init);
}
-pub fn getLanguage(_: *const WorkerNavigator) []const u8 {
- return Navigator.getLanguage(&Navigator.init);
+pub fn getLanguage(_: *const WorkerNavigator, exec: *const Execution) []const u8 {
+ return Navigator.getLanguage(&Navigator.init, exec);
}
pub fn getOnLine(_: *const WorkerNavigator) bool {
diff --git a/src/help.zon b/src/help.zon
index dbb92e69f..809efa3b0 100644
--- a/src/help.zon
+++ b/src/help.zon
@@ -459,6 +459,10 @@
\\ The log level.
\\ Defaults to {1s}.
\\ Allowed values: "debug", "info", "warn", "error", "fatal".
+ \\ --locale
+ \\ BCP 47 language tag driving navigator.language, the
+ \\ Accept-Language header and the default locale of Intl and
+ \\ toLocaleString. Defaults to en-US.
\\ --obey-robots
\\ Fetches and obeys robots.txt of the target page.
\\ Defaults to false.
@@ -472,6 +476,9 @@
\\ --storage-sqlite-path
\\ Path to the SQLite database file for persistent storage.
\\ Use ":memory:" for in-memory storage.
+ \\ --timezone
+ \\ Time zone used by Date and Intl, e.g. Europe/Paris or UTC.
+ \\ Defaults to the host time zone.
\\ --user-agent
\\ Override the User-Agent header entirely. Must not impersonate other
\\ browsers; any value containing "Mozilla" is forbidden. The browser
diff --git a/src/main_snapshot_creator.zig b/src/main_snapshot_creator.zig
index a5b3736d8..03378b6ad 100644
--- a/src/main_snapshot_creator.zig
+++ b/src/main_snapshot_creator.zig
@@ -39,7 +39,7 @@ pub fn main(init: std.process.Init) !void {
}
}
- var platform = try lp.js.Platform.init(v8_flags);
+ var platform = try lp.js.Platform.init(.{ .v8_flags = v8_flags });
defer platform.deinit();
const snapshot = try lp.js.Snapshot.create();
diff --git a/src/network/HttpClient.zig b/src/network/HttpClient.zig
index 2ff673156..12ff36afe 100644
--- a/src/network/HttpClient.zig
+++ b/src/network/HttpClient.zig
@@ -153,6 +153,10 @@ test_fail_submit: if (lp.IS_TEST) ?anyerror else void = if (lp.IS_TEST) null els
// Allocated from self.allocator when set, null otherwise.
user_agent_override: ?[:0]const u8 = null,
+// Accept-Language override set via CDP Emulation.setUserAgentOverride.
+// Drives both the request header and navigator.languages.
+accept_language_override: ?AcceptLanguageOverride = null,
+
// The driver (CDP / BiDi) attached to us. If there's a driver, then there's
// an inbox for us to process (and there's someone to wake us up from a poll)
driver: ?Driver = null,
@@ -263,6 +267,7 @@ pub fn deinit(self: *Client) void {
self.handles.deinit();
self.clearUserAgentOverride();
+ self.clearAcceptLanguageOverride();
if (self.http_proxy_owned) |owned| {
self.allocator.free(owned);
}
@@ -303,6 +308,52 @@ pub fn clearUserAgentOverride(self: *Client) void {
}
}
+const AcceptLanguageOverride = struct {
+ // The header as given; `languages` are sub-slices of it.
+ header: [:0]const u8,
+ languages: []const []const u8,
+
+ const max_languages = 8;
+};
+
+/// Set an Accept-Language override, allocated from self.allocator. The
+/// navigator.languages list is the header's tags with weights dropped.
+pub fn setAcceptLanguageOverride(self: *Client, value: []const u8) !void {
+ self.clearAcceptLanguageOverride();
+
+ const header = try self.allocator.dupeZ(u8, value);
+ errdefer self.allocator.free(header);
+
+ var languages: std.ArrayList([]const u8) = .empty;
+ errdefer languages.deinit(self.allocator);
+
+ var it = std.mem.splitScalar(u8, header, ',');
+ while (it.next()) |item| {
+ if (languages.items.len == AcceptLanguageOverride.max_languages) {
+ break;
+ }
+ const end = std.mem.indexOfScalar(u8, item, ';') orelse item.len;
+ const tag = std.mem.trim(u8, item[0..end], " \t");
+ if (tag.len == 0) {
+ continue;
+ }
+ try languages.append(self.allocator, tag);
+ }
+
+ self.accept_language_override = .{
+ .header = header,
+ .languages = try languages.toOwnedSlice(self.allocator),
+ };
+}
+
+pub fn clearAcceptLanguageOverride(self: *Client) void {
+ if (self.accept_language_override) |override| {
+ self.allocator.free(override.languages);
+ self.allocator.free(override.header);
+ self.accept_language_override = null;
+ }
+}
+
// Enable TLS verification on all connections.
pub fn setTlsVerify(self: *Client, verify: bool) !void {
// Remove inflight connections check on enable TLS b/c chromiumoxide calls
@@ -428,6 +479,23 @@ pub fn getUserAgent(self: *const Client) [:0]const u8 {
return self.user_agent_override orelse self.network.config.http_headers.user_agent;
}
+pub fn getAcceptLanguage(self: *const Client) [:0]const u8 {
+ if (self.accept_language_override) |override| {
+ return override.header;
+ }
+ return self.network.config.http_headers.accept_language;
+}
+
+/// What navigator.languages reports: the override's tags when set, else the
+/// list derived from --locale.
+pub fn getLanguages(self: *const Client) []const []const u8 {
+ if (self.accept_language_override) |override| {
+ return override.languages;
+ }
+ const headers = &self.network.config.http_headers;
+ return headers.languages[0..headers.languages_len];
+}
+
// Headers _all_ requests include.
pub fn baselineHeaders(self: *const Client) [4]Transfer.RequestHeader {
return .{
@@ -436,7 +504,7 @@ pub fn baselineHeaders(self: *const Client) [4]Transfer.RequestHeader {
.{ .name = "Sec-Ch-Ua-Full-Version-List", .value = lp.Config.HttpHeaders.sec_ch_ua_full_version_list, .source = .fixed },
// Omitting Accept-Language triggers bot-protection on some CDNs
// (Akamai) when Accept-Encoding is present.
- .{ .name = "Accept-Language", .value = lp.Config.HttpHeaders.accept_language },
+ .{ .name = "Accept-Language", .value = self.getAcceptLanguage() },
};
}
@@ -4203,6 +4271,7 @@ fn initTestClient(client: *Client, pool: *ArenaPool) void {
.single_flight = .init(testing.allocator),
};
client.url_blocklist = null;
+ client.accept_language_override = null;
client.test_fail_submit = null;
// isUrlBlocked reaches through here for the adblocker; tests that want
// one assign it to `client.network` after this returns.
@@ -4231,6 +4300,36 @@ test "HttpClient: setBlockedUrls owns, replaces, and clears patterns" {
try testing.expectEqual(null, client.url_blocklist);
}
+test "HttpClient: setAcceptLanguageOverride owns, parses, replaces, and clears" {
+ var pool = ArenaPool.init(testing.allocator, .{});
+ defer pool.deinit();
+
+ var client: Client = undefined;
+ initTestClient(&client, &pool);
+ defer client.clearAcceptLanguageOverride();
+
+ var first = "de-DE,de;q=0.9, en;q=0.8".*;
+ try client.setAcceptLanguageOverride(&first);
+ @memset(&first, 'x');
+
+ try std.testing.expectEqualStrings("de-DE,de;q=0.9, en;q=0.8", client.getAcceptLanguage());
+ try testing.expectEqual(3, client.getLanguages().len);
+ try std.testing.expectEqualStrings("de-DE", client.getLanguages()[0]);
+ try std.testing.expectEqualStrings("de", client.getLanguages()[1]);
+ try std.testing.expectEqualStrings("en", client.getLanguages()[2]);
+
+ try client.setAcceptLanguageOverride("fr-FR");
+ try testing.expectEqual(1, client.getLanguages().len);
+ try std.testing.expectEqualStrings("fr-FR", client.getLanguages()[0]);
+
+ try client.setAcceptLanguageOverride("");
+ try std.testing.expectEqualStrings("", client.getAcceptLanguage());
+ try testing.expectEqual(0, client.getLanguages().len);
+
+ client.clearAcceptLanguageOverride();
+ try testing.expectEqual(null, client.accept_language_override);
+}
+
const TestRequest = struct {
url: [:0]const u8,
document: [:0]const u8 = "",
diff --git a/src/server/cdp/CDP.zig b/src/server/cdp/CDP.zig
index 4785f787a..e2be5d3f1 100644
--- a/src/server/cdp/CDP.zig
+++ b/src/server/cdp/CDP.zig
@@ -478,6 +478,7 @@ pub const BrowserContext = struct {
http_proxy_changed: bool = false,
user_agent_changed: bool = false,
+ accept_language_changed: bool = false,
// Extra headers to add to all requests.
extra_headers: std.ArrayList(http.Header) = .empty,
@@ -627,6 +628,9 @@ pub const BrowserContext = struct {
if (self.user_agent_changed) {
browser.http_client.clearUserAgentOverride();
}
+ if (self.accept_language_changed) {
+ browser.http_client.clearAcceptLanguageOverride();
+ }
self.intercept_state.deinit();
}
diff --git a/src/server/cdp/domains/emulation.zig b/src/server/cdp/domains/emulation.zig
index 37f3aae93..a841023a6 100644
--- a/src/server/cdp/domains/emulation.zig
+++ b/src/server/cdp/domains/emulation.zig
@@ -21,6 +21,7 @@ const lp = @import("lightpanda");
const CDP = @import("../CDP.zig");
const Config = @import("../../../Config.zig");
+const Mime = @import("../../../browser/Mime.zig");
const js = @import("../../../browser/js/js.zig");
const log = lp.log;
@@ -157,9 +158,6 @@ pub fn setUserAgentOverride(cmd: *CDP.Command) !void {
platform: ?[]const u8 = null,
})) orelse return error.InvalidParams;
- if (params.acceptLanguage) |v| {
- log.warn(.not_implemented, "Emulation.setUserAgentOverride", .{ .param = "acceptLanguage", .value = v });
- }
if (params.platform) |v| {
log.warn(.not_implemented, "Emulation.setUserAgentOverride", .{ .param = "platform", .value = v });
}
@@ -167,14 +165,27 @@ pub fn setUserAgentOverride(cmd: *CDP.Command) !void {
const ua = params.userAgent;
Config.validateUserAgent(ua) catch |err| switch (err) {
error.NonPrintable => return cmd.sendError(-32602, "User agent contains non-printable characters", .{}),
- error.Reserved => {
- log.warn(.not_implemented, "Emulation.setUserAgentOverride", .{ .param = "userAgent", .value = ua, .info = "User agent must not contain Mozilla" });
- return cmd.sendResult(null, .{});
- },
+ error.Reserved => {},
};
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
const http_client = &cmd.cdp.browser.http_client;
+
+ // Applied even when the user agent is refused below: Playwright always
+ // sends a Mozilla user agent alongside the locale it was asked for.
+ if (params.acceptLanguage) |accept_language| {
+ if (!Mime.isHttpHeaderValue(accept_language)) {
+ return cmd.sendError(-32602, "Accept-Language contains CR, LF or NUL", .{});
+ }
+ try http_client.setAcceptLanguageOverride(accept_language);
+ bc.accept_language_changed = true;
+ }
+
+ if (std.ascii.indexOfIgnoreCase(ua, "mozilla") != null) {
+ log.warn(.not_implemented, "Emulation.setUserAgentOverride", .{ .param = "userAgent", .value = ua, .info = "User agent must not contain Mozilla" });
+ return cmd.sendResult(null, .{});
+ }
+
try http_client.setUserAgentOverride(ua);
bc.user_agent_changed = true;
@@ -211,8 +222,10 @@ fn clearGeolocationOverride(cmd: *CDP.Command) !void {
return cmd.sendResult(null, .{});
}
-// Accepted so drivers can finish context setup; Intl/Date keep the host's
-// locale and timezone.
+// Accepted so drivers can finish context setup; Intl/Date keep the process
+// locale and timezone (--locale, --timezone). Changing them at runtime needs
+// zig-v8-fork bindings for Isolate::DateTimeConfigurationChangeNotification
+// and the ICU default locale.
fn setLocaleOverride(cmd: *CDP.Command) !void {
const Params = struct { locale: ?[]const u8 = null };
const params = (try cmd.params(Params)) orelse Params{};
@@ -381,6 +394,62 @@ test "cdp.Emulation: setUserAgentOverride with optional params" {
try ctx.expectSentResult(null, .{ .id = 5 });
}
+test "cdp.Emulation: setUserAgentOverride acceptLanguage drives navigator.languages" {
+ testing.silenceLog(&.{.not_implemented});
+
+ var ctx = try testing.context();
+ defer ctx.deinit();
+ var bc = try ctx.loadBrowserContext(.{ .id = "BID-AL", .url = "hi.html", .target_id = "FID-00000000AL".* });
+ const frame = bc.mainFrame() orelse unreachable;
+
+ // The default locale reaches both navigator and ICU.
+ try expectJs(frame, "navigator.language === 'en-US' && navigator.languages.join() === 'en-US,en'");
+ try expectJs(frame, "Intl.DateTimeFormat().resolvedOptions().locale === navigator.language");
+
+ try ctx.processMessage(.{
+ .id = 1,
+ .method = "Emulation.setUserAgentOverride",
+ .params = .{ .userAgent = "CustomBot/2.0", .acceptLanguage = "de-DE,de;q=0.9, en;q=0.8" },
+ });
+ try ctx.expectSentResult(null, .{ .id = 1 });
+ try expectJs(frame, "navigator.language === 'de-DE' && navigator.languages.join() === 'de-DE,de,en'");
+ try std.testing.expectEqualStrings("de-DE,de;q=0.9, en;q=0.8", ctx.cdp().browser.http_client.getAcceptLanguage());
+ try testing.expect(bc.accept_language_changed);
+
+ // A Mozilla user agent is refused, the language still applies.
+ try ctx.processMessage(.{
+ .id = 2,
+ .method = "Emulation.setUserAgentOverride",
+ .params = .{ .userAgent = "Mozilla/5.0", .acceptLanguage = "fr-FR" },
+ });
+ try ctx.expectSentResult(null, .{ .id = 2 });
+ try expectJs(frame, "navigator.language === 'fr-FR' && navigator.languages.length === 1");
+ try std.testing.expectEqualStrings("CustomBot/2.0", ctx.cdp().browser.http_client.getUserAgent());
+
+ try ctx.processMessage(.{
+ .id = 3,
+ .method = "Emulation.setUserAgentOverride",
+ .params = .{ .userAgent = "CustomBot/2.0", .acceptLanguage = "" },
+ });
+ try ctx.expectSentResult(null, .{ .id = 3 });
+ try expectJs(frame, "navigator.language === '' && navigator.languages.length === 0");
+
+ try ctx.processMessage(.{
+ .id = 4,
+ .method = "Emulation.setUserAgentOverride",
+ .params = .{ .userAgent = "CustomBot/2.0", .acceptLanguage = "en\r\nX-Injected: 1" },
+ });
+ try ctx.expectSentError(-32602, "Accept-Language contains CR, LF or NUL", .{ .id = 4 });
+}
+
+fn expectJs(frame: *lp.Frame, expression: [:0]const u8) !void {
+ var ls: js.Local.Scope = undefined;
+ frame.js.localScope(&ls);
+ defer ls.deinit();
+ const value = try ls.local.exec(expression, null);
+ try testing.expect(value.toBool());
+}
+
test "cdp.Emulation: setUserAgentOverride can be called multiple times" {
var ctx = try testing.context();
defer ctx.deinit();