diff --git a/hooks/__tests__/useDetectOS.test.ts b/hooks/__tests__/useDetectOS.test.ts new file mode 100644 index 0000000000000..53df39aa7587b --- /dev/null +++ b/hooks/__tests__/useDetectOS.test.ts @@ -0,0 +1,66 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import { useDetectOS } from '../useDetectOS'; + +const mockNavigator = { + userAgent: + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.36', + userAgentData: { + getHighEntropyValues: jest.fn().mockResolvedValue({ bitness: '64' }), + }, +}; + +const originalNavigator = global.navigator; + +describe('useDetectOS', () => { + afterEach(() => { + // Reset the navigator global to the original value + Object.defineProperty(global, 'navigator', { + value: originalNavigator, + writable: true, + }); + }); + + it('should detect the user OS and bitness', async () => { + Object.defineProperty(global, 'navigator', { + value: mockNavigator, + // Allow us to change the value of navigator for the other tests + writable: true, + }); + + const { result } = renderHook(() => useDetectOS()); + + await waitFor(() => { + expect(result.current.userOS).toBe('WIN'); + }); + + await waitFor(() => { + expect(result.current.getDownloadLink('v18.16.0')).toBe( + 'https://nodejs.org/dist/v18.16.0/node-v18.16.0-x64.msi' + ); + }); + + expect( + mockNavigator.userAgentData.getHighEntropyValues + ).toHaveBeenCalledWith(['bitness']); + }); + + it('should return the default url if global.navigator does not exist', async () => { + Object.defineProperty(global, 'navigator', { + value: undefined, + // Allow us to change the value of navigator for the other tests + writable: true, + }); + + const { result } = renderHook(() => useDetectOS()); + + await waitFor(() => { + expect(result.current.userOS).toBe('OTHER'); + }); + + await waitFor(() => { + expect(result.current.getDownloadLink('v18.16.0')).toBe( + 'https://nodejs.org/dist/v18.16.0/node-v18.16.0.tar.gz' + ); + }); + }); +}); diff --git a/hooks/useDetectOS.ts b/hooks/useDetectOS.ts new file mode 100644 index 0000000000000..2dcf1588e5efe --- /dev/null +++ b/hooks/useDetectOS.ts @@ -0,0 +1,37 @@ +import { useEffect, useState } from 'react'; +import { detectOS } from '../util/detectOS'; +import { downloadUrlByOS } from '../util/downloadUrlByOS'; + +import type { UserOS } from '../types/userOS'; + +export const useDetectOS = () => { + const [userOS, setUserOS] = useState('OTHER'); + const [bitness, setBitness] = useState(''); + + useEffect(() => { + setUserOS(detectOS()); + + // This is necessary to detect Windows 11 on Edge. + // [MDN](https://developer.mozilla.org/en-US/docs/Web/API/NavigatorUAData/getHighEntropyValues) + // [MSFT](https://learn.microsoft.com/en-us/microsoft-edge/web-platform/how-to-detect-win11) + // @ts-expect-error no types for "userAgentData" because this API is experimental + if (typeof navigator?.userAgentData?.getHighEntropyValues === 'function') { + // @ts-expect-error no types for "userAgentData" because this API is experimental + navigator.userAgentData + .getHighEntropyValues(['bitness']) + .then((ua: { bitness: string }) => setBitness(ua.bitness)) + .catch(); + } + }, []); + + return { + userOS, + getDownloadLink: (version: string) => + downloadUrlByOS({ + userAgent: navigator?.userAgent, + userOS, + version, + bitness, + }), + }; +}; diff --git a/types/userOS.ts b/types/userOS.ts new file mode 100644 index 0000000000000..fe5c03131bd66 --- /dev/null +++ b/types/userOS.ts @@ -0,0 +1 @@ +export type UserOS = 'MAC' | 'WIN' | 'LINUX' | 'OTHER'; diff --git a/util/__tests__/detectOS.test.ts b/util/__tests__/detectOS.test.ts new file mode 100644 index 0000000000000..c80538bd8046d --- /dev/null +++ b/util/__tests__/detectOS.test.ts @@ -0,0 +1,31 @@ +import { detectOsInUserAgent } from '../detectOS'; + +describe('detectOsInUserAgent', () => { + it.each([ + [ + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/42.0.2311.135 Safari/537.36 Edge/12.246', + 'WIN', + ], + [ + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36', + 'MAC', + ], + [ + 'Mozilla/5.0 (X11; CrOS x86_64 8172.45.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.64 Safari/537.36', + 'OTHER', + ], + [ + 'Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:15.0) Gecko/20100101 Firefox/15.0.1', + 'LINUX', + ], + [ + 'Mozilla/5.0 (iPhone; CPU iPhone OS 13_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.3 Mobile/15E148 Safari/604.1', + 'MAC', + ], + ['', 'OTHER'], + ['OTHERAgent/1.0', 'OTHER'], + [undefined, 'OTHER'], + ])('detectOsInUserAgent(%s) returns %s', (os, expected) => { + expect(detectOsInUserAgent(os)).toBe(expected); + }); +}); diff --git a/util/__tests__/downloadUrlByOS.test.ts b/util/__tests__/downloadUrlByOS.test.ts new file mode 100644 index 0000000000000..f4e892122bc14 --- /dev/null +++ b/util/__tests__/downloadUrlByOS.test.ts @@ -0,0 +1,72 @@ +import { downloadUrlByOS } from '../downloadUrlByOS'; + +const version = 'v18.16.0'; +describe('downloadUrlByOS', () => { + it('returns the correct download URL for Mac', () => { + const userAgent = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_2) AppleWebKit/601.3.9 (KHTML, like Gecko) Version/9.0.2 Safari/601.3.9'; + const userOS = 'MAC'; + const expectedUrl = 'https://nodejs.org/dist/v18.16.0/node-v18.16.0.pkg'; + + expect(downloadUrlByOS({ userAgent, userOS, version })).toBe(expectedUrl); + }); + + it('returns the correct download URL for Windows (32-bit)', () => { + const userAgent = + 'Mozilla/5.0 (Windows NT 10.0; Win32; x86) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/81.0.4044.129 Safari/537.36'; + const userOS = 'WIN'; + const bitness = '32'; + const expectedUrl = + 'https://nodejs.org/dist/v18.16.0/node-v18.16.0-x86.msi'; + + expect(downloadUrlByOS({ userAgent, userOS, version, bitness })).toBe( + expectedUrl + ); + }); + + it('returns the correct download URL for Windows (64-bit) because the userAgent contains Win64', () => { + const userAgent = + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/42.0.2311.135 Safari/537.36 Edge/12.246'; + const userOS = 'WIN'; + const bitness = ''; + const expectedUrl = + 'https://nodejs.org/dist/v18.16.0/node-v18.16.0-x64.msi'; + + expect(downloadUrlByOS({ userAgent, userOS, version, bitness })).toBe( + expectedUrl + ); + }); + + it('returns the correct download URL for Windows (64-bit) because the userAgent contains WOW64', () => { + const userAgent = + 'Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/47.0.2526.111 Safari/537.36'; + const userOS = 'WIN'; + const bitness = ''; + const expectedUrl = + 'https://nodejs.org/dist/v18.16.0/node-v18.16.0-x64.msi'; + + expect(downloadUrlByOS({ userAgent, userOS, version, bitness })).toBe( + expectedUrl + ); + }); + + it('returns the correct download URL for Windows (64-bit) because bitness = 64', () => { + const userAgent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)'; + const userOS = 'WIN'; + const bitness = '64'; + const expectedUrl = + 'https://nodejs.org/dist/v18.16.0/node-v18.16.0-x64.msi'; + + expect(downloadUrlByOS({ userAgent, userOS, version, bitness })).toBe( + expectedUrl + ); + }); + + it('returns the default download URL for other operating systems', () => { + const userAgent = 'Mozilla/5.0 (Linux; Android 11; SM-G975U1)'; + const userOS = 'OTHER'; + const expectedUrl = 'https://nodejs.org/dist/v18.16.0/node-v18.16.0.tar.gz'; + + expect(downloadUrlByOS({ userAgent, userOS, version })).toBe(expectedUrl); + }); +}); diff --git a/util/detectOS.ts b/util/detectOS.ts new file mode 100644 index 0000000000000..4bfe6fccacb56 --- /dev/null +++ b/util/detectOS.ts @@ -0,0 +1,19 @@ +import type { UserOS } from '../types/userOS'; + +export const detectOsInUserAgent = (userAgent: string | undefined): UserOS => { + const osMatch = userAgent?.match(/(Win|Mac|Linux)/); + switch (osMatch && osMatch[1]) { + case 'Win': + return 'WIN'; + case 'Mac': + return 'MAC'; + case 'Linux': + return 'LINUX'; + default: + return 'OTHER'; + } +}; + +// Since `navigator.appVersion` is deprecated, we use the `userAgent`` +// https://developer.mozilla.org/en-US/docs/Web/API/Navigator/appVersion +export const detectOS = (): UserOS => detectOsInUserAgent(navigator?.userAgent); diff --git a/util/downloadUrlByOS.ts b/util/downloadUrlByOS.ts new file mode 100644 index 0000000000000..6491324094931 --- /dev/null +++ b/util/downloadUrlByOS.ts @@ -0,0 +1,30 @@ +import type { UserOS } from '../types/userOS'; + +type DownloadUrlByOS = { + userAgent?: string | undefined; + userOS: UserOS; + version: string; + bitness?: string; +}; + +export const downloadUrlByOS = ({ + userAgent, + userOS, + version, + bitness, +}: DownloadUrlByOS): string => { + const baseURL = `https://nodejs.org/dist/${version}`; + const is64Bit = + bitness === '64' || + userAgent?.includes('WOW64') || + userAgent?.includes('Win64'); + + switch (userOS) { + case 'MAC': + return `${baseURL}/node-${version}.pkg`; + case 'WIN': + return `${baseURL}/node-${version}-x${is64Bit ? 64 : 86}.msi`; + default: + return `${baseURL}/node-${version}.tar.gz`; + } +};