Skip to content

Commit f2165eb

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 f2165eb

5 files changed

Lines changed: 111 additions & 6 deletions

File tree

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
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+
`isApplePlatform()` in `useHotkeys` and `detectMac()` in `Kbd` both prefer
8+
`navigator.userAgentData.platform` and fall back to `navigator.platform`. The
9+
guard on that preference was `'platform' in uaData`, which is true whenever the
10+
key exists at all, blank or not. A build that reports `platform: ''` therefore
11+
committed to the client-hints branch, evaluated `/mac/i.test('')`, and got
12+
`false` without ever reaching the fallback that would have answered correctly.
13+
An empty string was being read as "not Apple" rather than as "no answer".
14+
15+
Electron and other embedders that rewrite the app's user-agent or client-hints
16+
identity ship exactly that. On macOS the result was that every `mod` combo
17+
registered through `useHotkeys` listened for Ctrl instead of Cmd, and every
18+
`<Kbd>` drew Ctrl. Both surfaces agreed with each other, so nothing looked
19+
broken: the shortcut simply never fired, and the hint named the key that also
20+
did not work.
21+
22+
A blank platform is now treated as unknown and falls through to
23+
`navigator.platform`. Both call sites change together, since the `useHotkeys`
24+
docstring states it mirrors the detection used by `Kbd` so displayed and
25+
handled shortcuts agree.
26+
27+
@Astro-Han

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

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

29+
function spoofPlatform(platform: string) {
30+
Object.defineProperty(navigator, 'platform', {
31+
value: platform,
32+
configurable: true,
33+
});
34+
}
35+
36+
function spoofBlankClientHints() {
37+
// Builds that rewrite their client-hints identity expose the key with an
38+
// empty value; navigator.platform is the only surface left that answers.
39+
Object.defineProperty(navigator, 'userAgentData', {
40+
value: {platform: ''},
41+
configurable: true,
42+
});
43+
}
44+
2845
it('renders a single key', () => {
2946
render(<Kbd keys="k" />);
3047
const kbd = screen.getByText('K');
@@ -54,6 +71,22 @@ describe('Kbd', () => {
5471
expect(screen.getByText('\u2318')).toBeInTheDocument(); // \u2318
5572
});
5673

74+
it('reads a blank userAgentData.platform as unknown, not as non-Mac', () => {
75+
spoofBlankClientHints();
76+
spoofPlatform('MacIntel');
77+
78+
render(<Kbd keys="mod" />);
79+
expect(screen.getByText('\u2318')).toBeInTheDocument();
80+
});
81+
82+
it('still renders Ctrl when both platform surfaces are non-Mac', () => {
83+
spoofBlankClientHints();
84+
spoofPlatform('Win32');
85+
86+
render(<Kbd keys="mod" />);
87+
expect(screen.getByText('Ctrl')).toBeInTheDocument();
88+
});
89+
5790
it('maps modifier keys to symbols', () => {
5891
render(<Kbd keys="ctrl+alt+shift+k" />);
5992
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: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,12 @@ function stubOtherPlatform() {
2222
vi.stubGlobal('navigator', {platform: 'Win32'});
2323
}
2424

25+
function stubBlankClientHints(platform: string) {
26+
// Builds that rewrite their client-hints identity expose the key with an
27+
// empty value; navigator.platform is the only surface left that answers.
28+
vi.stubGlobal('navigator', {userAgentData: {platform: ''}, platform});
29+
}
30+
2531
function press(
2632
key: string,
2733
init: KeyboardEventInit & {target?: HTMLElement} = {},
@@ -67,6 +73,31 @@ describe('useHotkeys', () => {
6773
expect(onPress).toHaveBeenCalledTimes(1);
6874
});
6975

76+
it('reads a blank userAgentData.platform as unknown, not as non-Apple', () => {
77+
stubBlankClientHints('MacIntel');
78+
const onPress = vi.fn();
79+
renderHook(() => useHotkeys([{keys: 'mod+k', onPress}]));
80+
81+
press('k', {ctrlKey: true});
82+
expect(onPress).not.toHaveBeenCalled();
83+
84+
const event = press('k', {metaKey: true});
85+
expect(onPress).toHaveBeenCalledTimes(1);
86+
expect(onPress).toHaveBeenCalledWith(event);
87+
});
88+
89+
it('still maps mod to ctrlKey when both platform surfaces are non-Apple', () => {
90+
stubBlankClientHints('Win32');
91+
const onPress = vi.fn();
92+
renderHook(() => useHotkeys([{keys: 'mod+k', onPress}]));
93+
94+
press('k', {metaKey: true});
95+
expect(onPress).not.toHaveBeenCalled();
96+
97+
press('k', {ctrlKey: true});
98+
expect(onPress).toHaveBeenCalledTimes(1);
99+
});
100+
70101
it('does not fire a bare key when modifiers are held', () => {
71102
stubApplePlatform();
72103
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)