cli: add --locale and --timezone, derive language signals from one value

navigator.language was hard-coded to en-US and Accept-Language was a
constant, while Intl, toLocaleString and Date followed the host process
environment. On a de_DE host a page saw navigator.language === "en-US"
next to German number formatting, a mismatch fingerprinting scripts look
for, and the same page rendered differently across machines.

Follow Chrome's --lang rule: one configured tag drives navigator.language(s),
the Accept-Language header and ICU's default locale. --locale defaults to
en-US, so Intl is now en-US on every host instead of whatever LANG says.
--timezone sets the IANA zone Date and Intl use; absent, the host zone stays.

Both are applied by writing LC_ALL and TZ before V8 initializes ICU, which
reads them lazily. Platform.init is the first call in App.init, before any
thread exists, so setenv is safe there.

CDP Emulation.setUserAgentOverride.acceptLanguage, which Playwright sends
for its locale option, now overrides the header and navigator.languages
for the browser context's lifetime, mirroring the user agent override, and
applies even when the Mozilla user agent is refused.

Emulation.setLocaleOverride and setTimezoneOverride stay no-ops: changing
ICU's defaults at runtime needs new zig-v8-fork bindings.
This commit is contained in:
Adrià Arrufat committed 2026-09-09 17:49:02 +02:00
1 parent a6bb66ac3c
commit c55afc1df9
10 files changed
+468 -23

No files matched your search

+100 -1
View File
@@ -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 = "",