Skip to content

Commit 571499c

Browse files
committed
fix(core): read a blank client-hints platform as unknown, not as non-Apple
`isApplePlatform()` in `useHotkeys` and `detectMac()` in `Kbd` both prefer `navigator.userAgentData.platform` and fall back to `navigator.platform`. The guard on that preference was `'platform' in uaData`, true whenever the key exists at all, blank or not. A build reporting `platform: ''` committed to the client-hints branch, evaluated `/mac/i.test('')`, and got `false` without ever reaching the fallback that would have answered correctly. An empty string was read as "not Apple" rather than as "no answer". Electron and other embedders that rewrite the app's user-agent or client-hints identity ship exactly that. On macOS every `mod` combo registered through `useHotkeys` listened for Ctrl instead of Cmd, and every `<Kbd>` drew Ctrl. Both surfaces agreed with each other, so nothing looked broken: the shortcut never fired, and the hint named the key that also did not work. A blank platform now falls through to `navigator.platform`. Both call sites change in this one commit, since the `useHotkeys` docstring states it mirrors the detection used by `Kbd` so displayed and handled shortcuts agree. Fixes #5253
1 parent e891c0d commit 571499c

5 files changed

Lines changed: 71 additions & 6 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@astryxdesign/core': patch
3+
---
4+
5+
[fix] `mod` hotkeys and `Kbd` resolve to Cmd on macOS again when client hints report a blank platform
6+
7+
`useHotkeys` and `Kbd` both prefer `navigator.userAgentData.platform` and fall
8+
back to `navigator.platform`, but guarded the preference with
9+
`'platform' in uaData`, which is true whenever the key exists at all. A build
10+
reporting `platform: ''` therefore committed to the client-hints branch and got
11+
`false` without ever reaching the fallback, so on macOS every `mod` combo
12+
listened for Ctrl and every `<Kbd>` drew Ctrl. Electron and other embedders
13+
that rewrite the app's user-agent identity ship exactly that. A blank platform
14+
is now treated as unknown and falls through.
15+
16+
@Astro-Han

packages/core/src/Kbd/Kbd.test.tsx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ describe('Kbd', () => {
2323
value: originalPlatform,
2424
configurable: true,
2525
});
26+
delete (navigator as {userAgentData?: unknown}).userAgentData;
2627
});
2728

2829
it('renders a single key', () => {
@@ -54,6 +55,22 @@ describe('Kbd', () => {
5455
expect(screen.getByText('\u2318')).toBeInTheDocument(); // \u2318
5556
});
5657

58+
it('reads a blank userAgentData.platform as unknown, not as non-Mac', () => {
59+
// Builds that rewrite their client-hints identity expose the key with an
60+
// empty value; navigator.platform is the only surface left that answers.
61+
Object.defineProperty(navigator, 'userAgentData', {
62+
value: {platform: ''},
63+
configurable: true,
64+
});
65+
Object.defineProperty(navigator, 'platform', {
66+
value: 'MacIntel',
67+
configurable: true,
68+
});
69+
70+
render(<Kbd keys="mod" />);
71+
expect(screen.getByText('\u2318')).toBeInTheDocument();
72+
});
73+
5774
it('maps modifier keys to symbols', () => {
5875
render(<Kbd keys="ctrl+alt+shift+k" />);
5976
expect(screen.getByText('\u2303')).toBeInTheDocument(); // \u2303

packages/core/src/Kbd/Kbd.tsx

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -124,8 +124,9 @@ function getServerPlatformSnapshot(): boolean {
124124

125125
/**
126126
* Detects whether the current platform is macOS/iOS.
127-
* Prefers the User-Agent Client Hints API when available (modern Chrome/Edge),
128-
* falls back to navigator.platform (deprecated but universally supported).
127+
* Prefers the User-Agent Client Hints API when it names a platform (modern
128+
* Chrome/Edge), falls back to navigator.platform (deprecated but universally
129+
* supported) when it is absent or blank.
129130
*/
130131
function detectMac(): boolean {
131132
if (typeof navigator === 'undefined') {
@@ -134,7 +135,13 @@ function detectMac(): boolean {
134135
// Prefer User-Agent Client Hints API (not deprecated)
135136
const uaData = 'userAgentData' in navigator ? navigator.userAgentData : null;
136137
if (uaData && typeof uaData === 'object' && 'platform' in uaData) {
137-
return /mac/i.test((uaData as {platform: string}).platform ?? '');
138+
const uaPlatform = (uaData as {platform?: unknown}).platform;
139+
// A blank platform is no answer, not a negative one. Builds that rewrite
140+
// their client-hints identity ship '', so fall through rather than
141+
// reading it as "not Apple".
142+
if (typeof uaPlatform === 'string' && uaPlatform.trim() !== '') {
143+
return /mac/i.test(uaPlatform);
144+
}
138145
}
139146
// Fallback: navigator.platform (deprecated but still shipped everywhere)
140147
return /Mac|iPhone|iPad|iPod/.test(navigator.platform ?? '');

packages/core/src/hooks/useHotkeys.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,24 @@ describe('useHotkeys', () => {
6767
expect(onPress).toHaveBeenCalledTimes(1);
6868
});
6969

70+
it('reads a blank userAgentData.platform as unknown, not as non-Apple', () => {
71+
// Builds that rewrite their client-hints identity expose the key with an
72+
// empty value; navigator.platform is the only surface left that answers.
73+
vi.stubGlobal('navigator', {
74+
userAgentData: {platform: ''},
75+
platform: 'MacIntel',
76+
});
77+
const onPress = vi.fn();
78+
renderHook(() => useHotkeys([{keys: 'mod+k', onPress}]));
79+
80+
press('k', {ctrlKey: true});
81+
expect(onPress).not.toHaveBeenCalled();
82+
83+
const event = press('k', {metaKey: true});
84+
expect(onPress).toHaveBeenCalledTimes(1);
85+
expect(onPress).toHaveBeenCalledWith(event);
86+
});
87+
7088
it('does not fire a bare key when modifiers are held', () => {
7189
stubApplePlatform();
7290
const onPress = vi.fn();

packages/core/src/hooks/useHotkeys.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -73,8 +73,9 @@ const KEY_ALIASES: Record<string, string> = {
7373

7474
/**
7575
* Detects whether the current platform is macOS/iOS.
76-
* Prefers the User-Agent Client Hints API when available (modern Chrome/Edge),
77-
* falls back to navigator.platform (deprecated but universally supported).
76+
* Prefers the User-Agent Client Hints API when it names a platform (modern
77+
* Chrome/Edge), falls back to navigator.platform (deprecated but universally
78+
* supported) when it is absent or blank.
7879
* Mirrors the detection used by Kbd so displayed and handled shortcuts agree.
7980
*/
8081
function isApplePlatform(): boolean {
@@ -83,7 +84,13 @@ function isApplePlatform(): boolean {
8384
}
8485
const uaData = 'userAgentData' in navigator ? navigator.userAgentData : null;
8586
if (uaData && typeof uaData === 'object' && 'platform' in uaData) {
86-
return /mac/i.test((uaData as {platform: string}).platform ?? '');
87+
const uaPlatform = (uaData as {platform?: unknown}).platform;
88+
// A blank platform is no answer, not a negative one. Builds that rewrite
89+
// their client-hints identity ship '', so fall through rather than
90+
// reading it as "not Apple".
91+
if (typeof uaPlatform === 'string' && uaPlatform.trim() !== '') {
92+
return /mac/i.test(uaPlatform);
93+
}
8794
}
8895
return /Mac|iPhone|iPad|iPod/.test(navigator.platform ?? '');
8996
}

0 commit comments

Comments
 (0)