← dictionary
Environment

Keyboard Layout Fingerprinting

getLayoutMap() returns the characters Chromium’s selected layout assigns to each physical key position, built by a different native API per OS — so the same US layout answers differently on macOS, Windows and Linux.

Category
Environment
API
navigator.keyboard.getLayoutMap
Reveals
The keyboard layout Chromium selected, and which OS produced it
Spoofing
Spoofable, spoof detectable
Since
2018
Liverunning the probe…

navigator.keyboard.getLayoutMap() answers a narrow, reasonable question: if someone presses the physical key that a US keyboard calls KeyW, what character does it actually produce on this machine? A game that wants to tell a French player to press Z,Q,S,D instead of W,A,S,D genuinely needs this.

To answer it, Chromium calls into the operating system. There is no shared implementation and no web standard for the data — macOS is asked through Carbon, Windows through ToUnicodeEx, Linux and ChromeOS through XKB — and those three sources disagree about what a "US English" keyboard is. Android is not asked at all.

The panel above ran it. The single most useful row is not any character: it is whether the call worked, and in which of four distinguishable ways.

Absence is the loudest signal here

Most fingerprinting surfaces are interesting when they return a value. This one is most interesting when it does not, because the failures are not interchangeable — each one has a different cause, and they are all measurable.

What you observe What it means Measured
Map resolves with 48 entries Chromium on a desktop OS, typical Western layout Yes — Chrome 152 and Edge 153, macOS 26.6
Map resolves with 0 entries No producer answered — Android never implements it Yes — Pixel 8, Galaxy S23, Galaxy Tab S9
navigator.keyboard exists, getLayoutMap is undefined The method was deliberately deleted from a Chromium build Yes — Brave 151, macOS 26.6
navigator.keyboard does not exist Never implemented — or the page is not secure Yes — a WebKit build and a Firefox build

The last row was measured on WebKit and Firefox builds rather than on shipping Safari, so treat the browser names as source-confirmed rather than measured: WebKit's Navigator.idl has no keyboard attribute at all, and the only KeyboardLayoutMap in the WebKit tree is an imported web-platform-tests fixture with no implementation behind it.

That third row is worth dwelling on. Brave does not stub the method out or make it return noise; it keeps the Keyboard interface and Keyboard Lock, and removes getLayoutMap from it. A browser that had simply never implemented the Keyboard API would have no navigator.keyboard at all, which is what Safari and Firefox show. The shape of the removal is a Brave signature, and it is the exact shape that a privacy feature has to have when it is subtracting one method from an interface it otherwise supports.

The fourth row carries a trap that is easy to get wrong. The whole Keyboard interface is [SecureContext]:

webidl
[
    Exposed=Window,
    SecureContext
] interface Keyboard {

So on a plain http:// page, Chrome looks exactly like Safari: 'keyboard' in navigator is false. Measured, on Chrome, on a non-secure origin. Anyone using the absence of this API as a browser tell is also measuring their own deployment, and localhost will not reveal the mistake because localhost is a secure context.

Three operating systems, four producers

Blink does not compute any of this. It sends a Mojo request to the browser process, which hands back a flat map of DOM code string to character string. The platform-specific work happens in ui/events/keycodes/dom/, where there is one producer per platform and no shared logic between them beyond a base class that walks a fixed list of keys.

On macOS, the layout comes from Carbon's Text Input Services:

objc
base::apple::ScopedCFTypeRef<TISInputSourceRef> input_source(
    TISCopyCurrentASCIICapableKeyboardLayoutInputSource());
UniChar char_value = ui::TranslatedUnicodeCharFromKeyCode(
    input_source.get(), key_code, kUCKeyActionDisplay, 0, LMGetKbdType(),
    &dead_key_state);

On Windows, from the Win32 keyboard stack, against a specific layout handle:

cpp
uint32_t virtual_key_code =
    MapVirtualKeyEx(scan_code, MAPVK_VSC_TO_VK_EX, keyboard_layout);
// ...
int key_type =
    ::ToUnicodeEx(virtual_key_code, scan_code, keyboard_state, char_buffer,
                  std::size(char_buffer), /*wFlags=*/0, keyboard_layout);

On Linux, from GTK, which reads the live XKB keymap through GDK:

cpp
base::flat_map<std::string, std::string> GtkUi::GetKeyboardLayoutMap() {
  GdkDisplay* display = gdk_display_get_default();
  GdkKeymap* keymap = nullptr;

Three operating systems, three keyboard databases maintained by three different organizations, none of which is the W3C. They agree on the letters and digits because those are effectively fixed by the hardware market. They do not agree about the edges.

Linux has a fourth producer sitting behind the third, and it matters more than it looks — it is what makes headless Chrome answer differently, below. When Chromium has no desktop UI layer to ask, FallbackLinuxUi calls the Ozone path instead, which on X11 is not XKB at all:

cpp
// TODO(crbug.com/41472924): Support XKB.
keyboard_layout_engine_ = std::make_unique<StubKeyboardLayoutEngine>();

That stub answers from a table compiled into Chromium itself.

The key that separates them

IntlBackslash is the extra key ISO keyboards carry between left Shift and Z. US ANSI keyboards do not physically have it, but every OS still ships a mapping for it, and the three mappings differ:

OS IntlBackslash on a US layout How established
macOS § Measured — Chrome 152.0.7977.83 and Edge 153, macOS 26.6
Windows \ Measured — Chrome 152 and Edge 152 on Windows 11
Linux < Measured — Chromium 152 on Debian 12, X11 with GTK
ChromeOS < expected Not measured, and not the same path as Linux

The Linux value traces all the way down. Dumping the live XKB keymap on the machine that produced that measurement shows where < is decided:

text
key <LSGT> {
    type= "FOUR_LEVEL",
    symbols[Group1]= [ less, greater, bar, brokenbar ]
};

XKB calls the key LSGT, maps it to less, GDK reports that to GTK, GTK hands it to Chromium, and JavaScript reads <.

ChromeOS is expected to land on < as well, but it is worth not calling that "the Linux path", because it is a different one. ChromeOS has no GTK. It runs the Ozone DRM platform, where the layout engine is XkbKeyboardLayoutEngine under USE_XKBCOMMON, so the map comes through the Ozone producer rather than through GtkUi. Same underlying XKB data, different plumbing, and I have not measured it.

Nothing about that divergence is a bug. Each OS made a defensible choice about a key most of its users do not have, decades ago, independently. The consequence is that the character reported for one physical key is a reasonable proxy for which keyboard database answered — which is to say, for the OS — on a machine using a standard US layout.

That caveat matters and is the honest limit of this signal: the layout is a user setting. Someone on Dvorak, AZERTY or a Korean layout produces different values everywhere, and the OS inference degrades or disappears. This is an environment signal, not a compile-time constant like the gzip OS byte. It tells you about a configuration that happens to be OS-flavored, and it should be read as "consistent with macOS" rather than "proves macOS".

The four rows above were all measured on a standard US layout. If you are on ChromeOS, or on any layout that is not US, the panel above is showing a value I have no reading for — I would like to see it, so please tell me what it says.

Fifty keys, forty-eight answers

The API does not walk your whole keyboard. It walks a fixed array of fifty "Writing System Keys" — the ones whose meaning changes with layout, as opposed to Enter or F1, which do not:

cpp
inline constexpr std::array kWritingSystemKeyDomCodes{
    // Keyboard Row E
    DomCode::BACKQUOTE,
    DomCode::DIGIT1,
    // ...
    DomCode::INTL_YEN,
    // ...
    DomCode::INTL_BACKSLASH,
    // ...
    DomCode::INTL_RO,
};

Two of those fifty, IntlYen and IntlRo, go undefined on a typical Western layout. Keys that produce nothing are skipped, so a Western layout returns 48 entries — measured here, on both Chrome and Edge — and a JIS layout returns 50, without reading a single character and independently of the browser's language settings.

Only one of those two keys is Japanese-specific, which is easy to get wrong. Chromium's own code table is explicit about it:

text
// USB#070087 is used for Brazilian /? and Japanese _ 'ro'.
DOM_CODE(0x070087, 0x0059, 0x0061, 0x0073, 0x005e, "IntlRo", INTL_RO),
// USB#070089 is used as Japanese Yen key.
DOM_CODE(0x070089, 0x007c, 0x0084, 0x007d, 0x005d, "IntlYen", INTL_YEN),

IntlYen is the Japanese yen key. IntlRo is shared with Brazilian ABNT layouts. So a map containing IntlRo alone is Brazilian or Japanese and cannot be called Japanese on that evidence, which is why the probe above wants both keys before it says so.

Worth being precise about what that means, because it is easy to overclaim. getLayoutMap() reports what the selected layout makes each standardized key position produce. It does not enumerate the hardware. Someone who selects a Japanese layout on a US keyboard gets 50 entries too, because the layout defines those positions whether or not the keys physically exist. So the size tells you about a layout, not about which keyboard is plugged in.

And "selected" means selected by Chromium, which is not always the layout the user is typing in. Both desktop producers deliberately skip past a layout that cannot produce ASCII: macOS asks for TISCopyCurrentASCIICapableKeyboardLayoutInputSource, and the Windows producer enumerates every installed layout and returns the first ASCII-capable one, as the section below describes. Someone whose active layout is Russian or Greek is therefore reported through whichever Latin layout they also have installed. The map is a fact about what Chromium chose to answer with, and only usually a fact about what the person is typing.

Windows carries hardcoded corrections for exactly this region, because the Win32 API gets it wrong:

cpp
// Handle special cases for Korean keyboard layout.
if (0x04120412 == reinterpret_cast<uintptr_t>(keyboard_layout)) {
  // Fix value for Korean won currency symbol.
  // Windows returns '\' for both Backslash and IntlBackslash, even though
  // IntlBackslash should be the won symbol.
  if (dom_code == ui::DomCode::INTL_BACKSLASH)
    return ui::DomKey::FromCharacter(0x20a9);  // Korean won symbol.
}

There is a matching one for the Japanese yen. Neither exists in the macOS or Ozone producer, so IntlBackslash reading is a Windows answer twice over: once from the layout, and once because only the Windows code path contains the fixup that produces it.

The Windows producer differs structurally in another way. macOS and Ozone both hardcode a layout count of one:

cpp
uint32_t DomKeyboardLayoutMapOzone::GetKeyboardLayoutCount() {
  // There is only one keyboard layout available on Ozone.
  return 1;
}

Windows calls GetKeyboardLayoutList and enumerates every installed layout, moving the active one to the front, then returns the first one that IsAsciiCapable(). So a Windows user whose active layout is Russian can still get a Latin map back, because the browser walked past it to find one that can produce ASCII. On the other platforms there is nothing to walk past.

Why Android returns an empty map

Android does not reject the call. The promise resolves, successfully, with zero entries — because RenderWidgetHostViewAndroid never overrides the method, and the base class is a stub:

cpp
RenderWidgetHostViewBase::GetKeyboardLayoutMap() {
  NOTIMPLEMENTED_LOG_ONCE();
  return base::flat_map<std::string, std::string>();
}

Measured on three real devices — a Pixel 8 on Android 14, a Galaxy S23 on Android 13, and a Galaxy Tab S9 tablet — every one reported navigator.keyboard present, getLayoutMap a function, the promise resolved, and size of zero. The tablet behaves exactly like the phones, so this is a platform property rather than a form-factor one.

An empty map is therefore a positive statement — no producer supplied a layout — and not an error to be retried. Any code treating "resolved" as "supported" gets this one wrong, and any code treating an empty result as a transient failure will retry forever.

It is worth resisting the next step, which is to read "empty" as "Android." Android is the platform that never implements the call, and it is what I measured, but it is not the only way to reach an empty result. A Linux Qt session delegates this call to the GTK instance QtUi was constructed with and returns an empty map when there isn't one, and a platform lookup that simply fails lands in the same place on any OS: the Windows producer returns nothing if GetKeyboardLayoutList reports zero layouts. Emptiness is consistent with Android rather than proof of it, which is why the probe above declines to name a platform on an empty map, and declines to call it a contradiction of the user agent. There is no producer there to disagree with.

Headless Chrome on Linux answers differently

This one appears to be undocumented, and it is the most useful thing on this page. On Linux — and only on Linux — headless Chrome returns a different keyboard layout map than the same binary running headful.

Measured on Chromium 152.0.7977.82, Debian 12, one binary, one machine:

Mode Keys IntlBackslash IntlYen
Headful, X11 with GTK 48 < absent
--headless=new 49 < ¥

The maps are otherwise byte-identical. The entire difference is one extra key.

The cause is the fourth producer. Headless Chrome never initializes GTK, so there is no GtkUi to ask, and the call falls through to FallbackLinuxUi:

cpp
FallbackLinuxUi::GetKeyboardLayoutMap() {
  return ui::GenerateDomKeyboardLayoutMap();
}

which reaches the Ozone stub, which answers from Chromium's own compiled-in US layout table — and that table contains a yen key that no XKB us layout has:

cpp
{DomCode::INTL_BACKSLASH, {'<', '>'}},
{DomCode::INTL_YEN, {0x00A5, '|'}},

0x00A5 is ¥. IntlRo, the other Japanese key, is commented out a hundred lines further down, which is why the fallback produces 49 keys rather than 50.

So on Linux there is a signature for the fallback producer having answered, and it is worth stating in full rather than in shorthand, because the shorthand is what a careless implementation would ship. All three of these have to hold:

  1. the map is the standard US arrangement,
  2. it contains exactly 49 entries, and
  3. IntlYen is present while IntlRo is absent.

Take any one away and the test starts catching real people. A Brazilian ABNT layout defines IntlRo without IntlYen and also lands on 49 entries, so the count alone proves nothing. A custom 49-key layout that happens to define IntlYen would match on the last two conditions while being somebody's genuine layout, which is what the US requirement is there to exclude. With all three, what you have is not a timing heuristic or a missing-plugin guess but a structural consequence of which C++ object served the call: a JIS layout defines both keys and returns 50, a typical Western layout defines neither and returns 48, and only the hardcoded table returns exactly 49 US keys with a lone yen.

Be precise about what that buys you, because it is a fallback-UI tell rather than a mode tell. What the signature proves is that no desktop toolkit answered. Headless is the cause I measured, and the common one in practice, but it is not the only route to that producer: --ui-toolkit=fallback selects FallbackLinuxUi explicitly in a perfectly headful browser, and a GTK or Qt initialization that fails lands there too. Qt is worth noting separately, because QtUi does not implement this call at all and delegates to the GTK instance it was constructed with, returning an empty map if there isn't one.

Treated as "no desktop toolkit answered" the signature is sound. Treated as "this browser is headless" it will eventually be wrong about somebody, and the people it is wrong about are running unusual Linux configurations rather than bots.

Two limits worth stating. The divergence is Linux-specific: measured on macOS, --headless=new and headful return identical 48-key maps with §, because macOS asks Carbon either way and never routes through a desktop UI layer. And the --ozone-platform flag is not what drives it — headless with --ozone-platform=x11 against a live X server still returns 49, because the missing piece is GTK, not the display server.

Most automation tells are absences: a missing codec, an empty plugin array, a property that should be present and is not. This one runs the other way. The headless browser reports one key more than the real machine has, and the extra key is a yen sign on a keyboard that has never seen one.

The iframe gate

The map is available to a top-level page with no prompt, no permission and no user gesture. Inside a cross-origin iframe it is denied unless the embedder grants it, which the browser process checks before it ever reaches the platform code:

cpp
// The keyboard layout map is only accessible from the outermost main frame or
// with the permission policy enabled.
if (frame_host_impl.GetParentOrOuterDocument() &&
    !frame_host_impl.IsFeatureEnabled(
        network::mojom::PermissionsPolicyFeature::kKeyboardMap)) {
  response->status = blink::mojom::GetKeyboardLayoutMapStatus::kDenied;

Measured, in a cross-origin iframe with no allow attribute:

text
SecurityError: getLayoutMap() must be called from a top-level browsing context
or allowed by the permission policy.

Add allow="keyboard-map" to the iframe and the same call resolves with the full 48 entries. This is one of the few fingerprinting surfaces where a site can actually withhold a signal from its embedded third parties by doing nothing, which makes it worth knowing that the default is closed.

How identifying is it, really

Modestly, and less than it first appears. The layout is stable — it survives reboots, browser updates and profile resets, because it is an OS setting rather than anything the browser stores — which makes it a good tracking token in the sense that it holds still. But it is heavily correlated with the language list the browser already sends, so most of what it reveals about a visitor was available anyway.

Its real value is the part that is not implied by language: someone whose layout disagrees with their locale. A programmer on Dvorak, a bilingual user on a layout their Accept-Language does not predict, a US layout on a machine reporting French — those are the cases where the map adds information rather than confirming it.

Chrome and Edge on this machine returned byte-identical maps, hashing to the same value. That is the expected result and a useful control: it establishes that the value comes from the OS layout configuration rather than from the browser brand, so a difference between two Chromium browsers on one machine means one of them is interfering. It says nothing about the hardware, for the reasons above.

Spoofing it, and catching the spoof

getLayoutMap is an ordinary method on an ordinary interface, so anything running script in the realm can replace it. That is the easy part. Returning something convincing is harder than it looks, and there are three separate ways to fail.

The return type. A real call resolves to a KeyboardLayoutMap, not a Map and not an object literal. Object.prototype.toString.call(map) reads [object KeyboardLayoutMap], and the constructor is on the prototype chain. A hand-rolled Map gets both of these wrong, and a Proxy around one has to fake them deliberately.

The patch itself. A replaced getLayoutMap is not native code, so Function.prototype.toString on it does not read [native code], the same tell that catches every other monkey-patched API. It also has to be applied in every realm that has the interface at all, which means every fresh iframe, or the realms disagree with each other. Workers are not on that list: Keyboard is [Exposed=Window], as the excerpt above shows, so there is no worker copy to keep in sync — and equally no worker realm to cross-check a window against, which is a cross-check that does work for most other APIs.

Internal coherence. This is the interesting one, because it does not require catching the patch at all. The map has to agree with itself and with everything else the browser says. A commercial anti-detect browser, running on Linux and configured to present itself as Windows, was measured leaking < from this key while its user agent, its client hints and several of its graphics constants all claimed Windows. Nothing about the lie was visible in any individual value; it was visible because one subsystem had not been taught to lie, and that subsystem is the one wired directly to the host OS. The deeper a value sits in the platform, the more expensive it is to forge consistently, and keyboard layout data sits about as deep as a web API reaches.

Which is the general lesson worth taking from this entry. The characters themselves are low-value and user-configurable. The fact that they came from Carbon, or from ToUnicodeEx, or from XKB, or from a NOTIMPLEMENTED stub, is not.

References

  • Keyboard Map API (WICG) and UI Events KeyboardEvent code Values for the definition of Writing System Keys.
  • MDN: Keyboard.getLayoutMap() and KeyboardLayoutMap.
  • Chromium source, all public: third_party/blink/renderer/modules/keyboard/keyboard.idl (the SecureContext gate), keyboard_layout.cc (the Mojo round trip), content/browser/keyboard_lock/keyboard_lock_service_impl.cc (the permissions-policy check), render_widget_host_view_base.cc (the NOTIMPLEMENTED stub Android lands on), ui/events/keycodes/dom/dom_keyboard_layout.h (the fifty Writing System Keys), and the producers in ui/events/keycodes/dom/: dom_keyboard_layout_map_base.cc, _mac.mm, _win.cc, _ozone.cc.
  • For the Linux split: ui/gtk/gtk_ui.cc (GtkUi::GetKeyboardLayoutMap, the GDK path), ui/linux/fallback_linux_ui.cc (the fallback), ui/ozone/platform/x11/ozone_platform_x11.cc (the StubKeyboardLayoutEngine and its TODO(crbug.com/41472924): Support XKB), and ui/events/keycodes/dom_us_layout_data.h (the compiled-in table, including the yen key and the commented-out INTL_RO).
  • Microsoft documents ToUnicodeEx. Apple no longer publishes reference documentation for TISCopyCurrentASCIICapableKeyboardLayoutInputSource; it is declared in TextInputSources.h inside the Carbon HIToolbox framework, which is how Chromium reaches it.

How the probe works

This is the exact source that ran in the panel above — no summary, no drift.

JavaScript
// Reads the active keyboard layout through navigator.keyboard.getLayoutMap(), which
// maps standardized physical key positions to the characters the selected layout
// produces for them. Chromium builds that map in the browser process from entirely different native
// APIs per platform — Carbon on macOS, ToUnicodeEx on Windows, and on a Linux
// desktop GTK reading the live XKB keymap through GDK — so the same nominal "US"
// layout produces different characters per platform. Android has no implementation
// at all and returns an empty map. Linux with no desktop toolkit answering is
// a fourth path and is NOT XKB-backed; see the fallback-table check below.
//
// The layout itself is user-configurable, so this is an environment signal, not a
// compile-time constant. What IS structural: which native API produced it, and
// whether the platform implements the call at all.

// The 50 Writing System Keys the Keyboard Map API walks, in source order
// (ui/events/keycodes/dom/dom_keyboard_layout.h, kWritingSystemKeyDomCodes).
// Two of them go undefined on a typical Western layout, which is why a full map
// is normally 48 entries rather than 50 — but only one of the two is Japanese-
// specific. Chromium's dom_code_data.inc is explicit about the difference:
//   USB#070087 is used for Brazilian /? and Japanese _ 'ro'   -> IntlRo
//   USB#070089 is used as Japanese Yen key                    -> IntlYen
// So IntlRo alone does not mean Japanese; a Brazilian ABNT layout defines it too,
// which is why the readout below requires both keys before it says "Japanese".

// What the character on IntlBackslash implies about which OS produced the map.
// Only established for a standard US layout — see US_SIGNATURE below. Note there
// is deliberately no entry for "¥": Chromium's Windows yen fixup targets
// DomCode::INTL_YEN, not INTL_BACKSLASH, so a yen on this key has no recorded
// meaning and guessing Windows from it would be unfounded.
const INTL_BACKSLASH_SOURCE = {
  "§": ["apple", "macOS — Carbon TISCopyCurrentASCIICapableKeyboardLayoutInputSource"],
  "\\": ["windows", "Windows — ToUnicodeEx against the active HKL"],
  // XKB data on both, but by different plumbing: a Linux desktop reaches it
  // through GtkUi/GDK, while ChromeOS has no GTK and goes through the Ozone
  // producer's XkbKeyboardLayoutEngine. Only the Linux leg was measured.
  "<": ["unix", "Linux or ChromeOS — XKB data, via GTK/GDK on a Linux desktop and via Ozone's XKB engine on ChromeOS"],
  "₩": ["windows", "Windows — the hardcoded Korean won fixup, which targets INTL_BACKSLASH and only exists in the Windows producer"],
};

// The per-OS IntlBackslash values above were measured on a standard US layout,
// and only there do they identify the OS. Other layouts legitimately land on the
// same characters — a Windows AZERTY or QWERTZ maps this key to "<", which is
// also the Linux value — so applying the lookup unconditionally would report a
// French or German Windows user as Linux and raise a false disagreement against
// their user agent. Require the US arrangement before trusting the key.
// Backslash is load-bearing here, not padding: the UK layout matches US on every
// other key checked below, and differs only in that Backslash is "#" and
// IntlBackslash is "\". Without it a UK Linux map passes as US, the "\" lookup
// then reports Windows, and that user is accused of a spoof they are not running.
const US_SIGNATURE = {
  KeyQ: "q", KeyW: "w", KeyE: "e", KeyR: "r", KeyT: "t", KeyY: "y",
  KeyA: "a", KeyS: "s", KeyD: "d", KeyF: "f",
  KeyZ: "z", KeyX: "x", KeyC: "c", KeyV: "v",
  Semicolon: ";", Quote: "'", BracketLeft: "[", BracketRight: "]",
  Comma: ",", Period: ".", Slash: "/", Minus: "-", Equal: "=", Backquote: "`",
  Backslash: "\\",
};

const isUsLayout = (map) => Object.entries(US_SIGNATURE).every(([code, ch]) => map.get(code) === ch);

// Top-row signatures, read off the physical keys rather than guessed from locale.
const TOP_ROW = ["KeyQ", "KeyW", "KeyE", "KeyR", "KeyT", "KeyY"];
const LAYOUTS = {
  qwerty: "QWERTY",
  azerty: "AZERTY",
  qwertz: "QWERTZ",
  "',.pyf": "Dvorak",
  qwfpgj: "Colemak",
};

function layoutFamily(map) {
  const row = TOP_ROW.map((c) => map.get(c) ?? "").join("").toLowerCase();
  if (!row) return "unknown — top row did not resolve";
  const name = LAYOUTS[row] ?? "non-standard";
  return `${name} (top row reads "${row}")`;
}

// What the UA claims, reduced to the same families the layout source can express.
function claimedFamily() {
  const p = navigator.userAgentData?.platform;
  if (p) {
    if (p === "Windows") return "windows";
    if (p === "macOS") return "apple";
    if (p === "Android") return "android";
    if (p === "Linux" || p === "Chrome OS" || p === "Chromium OS") return "unix";
  }
  const ua = navigator.userAgent;
  if (/Android/.test(ua)) return "android";
  if (/Windows NT/.test(ua)) return "windows";
  if (/Mac OS X/.test(ua)) return "apple";
  if (/CrOS|X11|Linux/.test(ua)) return "unix";
  return "unknown";
}

async function sha256Hex(input) {
  const bytes = new TextEncoder().encode(input);
  const digest = await crypto.subtle.digest("SHA-256", bytes);
  return Array.from(new Uint8Array(digest))
    .map((b) => b.toString(16).padStart(2, "0"))
    .join("");
}

export async function probe() {
  // Three distinguishable shapes of absence, and they identify different things.
  if (!window.isSecureContext) {
    throw new Error(
      "not a secure context — the whole Keyboard interface is [SecureContext], so Chromium withholds " +
        "navigator.keyboard here. Safari reaches the same observable state by a different route, never " +
        "having implemented it, which is why absence is only a browser tell on https.",
    );
  }
  if (!("keyboard" in navigator)) {
    throw new Error(
      "navigator.keyboard does not exist — the Keyboard Map API is Chromium-only. Safari and Firefox " +
        "have never shipped it.",
    );
  }
  if (typeof navigator.keyboard.getLayoutMap !== "function") {
    throw new Error(
      "navigator.keyboard exists but getLayoutMap has been removed from it — a deliberate subtraction " +
        "rather than a missing implementation. Brave does exactly this, keeping Keyboard Lock and " +
        "deleting the layout map.",
    );
  }

  const map = await navigator.keyboard.getLayoutMap();

  const entries = [...map].sort((a, b) => (a[0] < b[0] ? -1 : 1));
  const size = entries.length;

  // An empty map is not a failure: the promise resolved with nothing in it. That
  // says no producer supplied a layout, which is NOT the same as saying Android.
  // Android never implements the call, but a Linux Qt session with no GTK instance
  // delegates to nothing and returns empty too, and a platform lookup that fails
  // (GetKeyboardLayoutList returning 0 on Windows, say) ends up here on any OS.
  // Emptiness is therefore consistent with Android, never proof of it, and it
  // must not contradict a user agent — there is no producer to disagree with.
  const empty = size === 0;

  const intlBackslash = map.get("IntlBackslash");
  const usLayout = isUsLayout(map);
  const hasYen = map.has("IntlYen");
  const hasRo = map.has("IntlRo");

  // Chromium's compiled-in US fallback table (dom_us_layout_data.h) carries
  // IntlYen but has IntlRo commented out, so it produces exactly 49 keys with a
  // lone yen. A JIS layout defines both and returns 50; a Western one defines
  // neither and returns 48. On Linux this signature means no GTK answered the
  // call. Headless is the usual cause; --ui-toolkit=fallback and a failed GTK/Qt
  // init reach the same producer, so this is a fallback-UI tell, not a mode tell.
  //
  // This has to be decided BEFORE the IntlBackslash lookup below. The fallback
  // table maps IntlBackslash to "<" and otherwise matches US, so it would sail
  // through the lookup and be reported as XKB via GTK — naming a producer this
  // same readout elsewhere rules out.
  // usLayout is required, not decorative: the fallback table IS the US
  // arrangement (dom_us_layout_data.h maps Backslash to "\", Semicolon to ";"
  // and so on), so demanding it costs nothing on the real fallback and stops a
  // custom 49-key layout that happens to define IntlYen from being mislabeled —
  // which would both suppress that user's real layout fact and raise a warning.
  const fallbackTable = usLayout && size === 49 && map.has("IntlYen") && !map.has("IntlRo");

  const [source, sourceNote] = empty
    ? [
        "unknown",
        "nothing answered — no producer supplied a layout. Android never implements the call; " +
          "a Linux Qt session without GTK and a failed platform lookup reach the same empty result",
      ]
    : fallbackTable
      ? [
          // "unknown" on purpose. This signature identifies the producer, not the
          // OS: the characters are Chromium's own constants, so they carry no
          // platform information and must not be used to contradict a user agent.
          "unknown",
          "Chromium's compiled-in US table (dom_us_layout_data.h), not a platform keyboard layer — " +
            "no desktop toolkit answered, so the characters are Chromium's own defaults rather than your layout",
        ]
      : (usLayout ? INTL_BACKSLASH_SOURCE[intlBackslash] : null) ?? [
          "unknown",
          usLayout
            ? "no OS mapping recorded for this value on a US layout"
            : "not a standard US layout — the per-OS mapping for this key is only established for US, so no OS is inferred",
        ];

  const claimed = claimedFamily();
  // An unrecognized user agent makes no claim, so there is nothing for the
  // layout source to contradict. Treat it like an unknown source.
  const agrees = source === "unknown" || claimed === "unknown" || source === claimed;

  const value = {
    "Keys returned": `${size} of 50 writing-system keys${empty ? " — empty map" : ""}`,
    "IntlBackslash": empty ? "(map is empty)" : (intlBackslash ?? "(absent)"),
    "Produced by": sourceNote,
    "Layout family": empty
      ? "(map is empty)"
      : fallbackTable
        ? `${layoutFamily(map)} — but this is the fallback table's own arrangement, not necessarily yours`
        : layoutFamily(map),
    "Backquote": empty ? "(map is empty)" : (map.get("Backquote") ?? "(absent)"),
    "IntlYen / IntlRo": empty
      ? "(map is empty)"
      : fallbackTable
        ? "IntlYen present, IntlRo absent — the signature of Chromium's compiled-in US table"
        : hasYen && hasRo
          ? "IntlYen + IntlRo present — a JIS (Japanese) layout is selected"
          : hasRo
            ? "IntlRo present, IntlYen absent — Brazilian or Japanese; Chromium assigns this key to both, so it does not single out Japan"
            : hasYen
              ? "IntlYen present, IntlRo absent — the Japanese yen key without the other JIS key"
              : "both absent — neither key is defined by this layout (the usual case, hence 48 keys not 50)",
    "Answered by": empty
      ? "nothing — no producer supplied a layout"
      : fallbackTable
        ? "Chromium's compiled-in US table, not a desktop toolkit — no GTK or Qt answered (headless is the common cause, but --ui-toolkit=fallback and a failed toolkit init do this too)"
        : "the platform keyboard layer",
    "User-agent claims": claimed,
    "Layout data came from": source,
    "Consistent": agrees
      ? "yes"
      : "NO — the layout source DISAGREES with the user agent",
  };

  if (!empty) {
    value["Full map"] = entries.map(([k, v]) => `${k}=${v}`).join(" ");
  }

  // Abstain from naming a layout unless one was actually observed. On the
  // fallback path the characters are Chromium's own constants, so publishing
  // them would tell a Dvorak user their layout is QWERTY — and because facts
  // feed the combined digest, it would put that false reading in the hash too.
  // The "Keyboard layer" fact below carries the real finding for that case.
  const facts = {};
  if (empty) {
    facts["Keyboard layout"] = "not reported — no producer answered";
  } else if (fallbackTable) {
    facts["Keyboard layout"] = "not reported — the answer was Chromium's defaults, not your layout";
  } else {
    facts["Keyboard layout"] = `${layoutFamily(map).split(" (")[0]}, ${size} keys`;
  }
  if (!agrees) {
    facts["Keyboard layout source"] = "DISAGREES with the user agent";
  }
  if (fallbackTable) {
    facts["Keyboard layer"] = "absent — Chromium's built-in fallback table answered";
  }

  return {
    facts,
    // The hash covers the layout, which is stable across reloads and profiles,
    // but is a configuration token rather than a hardware one.
    hash: await sha256Hex(entries.map(([k, v]) => `${k}=${v}`).join("|")),
    label: empty ? "empty map — no producer answered" : `${size} keys · IntlBackslash = ${intlBackslash ?? "absent"}`,
    value,
  };
}