Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions hooks/__tests__/useDetectOS.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { renderHook, waitFor } from '@testing-library/react';
import { useDetectOS } from '../useDetectOS';

const mockNavigator = {
Comment thread
FHachez marked this conversation as resolved.
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' }),
},
};

describe('useDetectOS', () => {
it('should detect the user OS and bitness', async () => {
Object.defineProperty(global, 'navigator', {
value: mockNavigator,
writable: true,
Comment thread
FHachez marked this conversation as resolved.
});

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'
Comment thread
ovflowd marked this conversation as resolved.
);
});

expect(
mockNavigator.userAgentData.getHighEntropyValues
).toHaveBeenCalledWith(['bitness']);
});
});
32 changes: 32 additions & 0 deletions hooks/useDetectOs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
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<UserOS>('UNKNOWN');
const [bitness, setBitness] = useState('');

useEffect(() => {
setUserOS(detectOS);
Comment thread
FHachez marked this conversation as resolved.
Outdated

// 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') {
Comment thread
FHachez marked this conversation as resolved.
Outdated
// @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(navigator.userAgent, userOS, version, bitness),
};
};
1 change: 1 addition & 0 deletions types/userOS.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export type UserOS = 'MAC' | 'WIN' | 'LINUX' | 'UNKNOWN';
30 changes: 30 additions & 0 deletions util/__tests__/detectOS.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
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',
'UNKNOWN',
],
[
'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',
],
['', 'UNKNOWN'],
['UnknownAgent/1.0', 'UNKNOWN'],
])('detectOsInUserAgent(%s) returns %s', (os, expected) => {
expect(detectOsInUserAgent(os)).toBe(expected);
});
});
72 changes: 72 additions & 0 deletions util/__tests__/downloadUrlByOS.test.ts
Original file line number Diff line number Diff line change
@@ -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 = 'UNKNOWN';
const expectedUrl = 'https://nodejs.org/dist/v18.16.0/node-v18.16.0.tar.gz';

expect(downloadUrlByOS(userAgent, userOS, version)).toBe(expectedUrl);
});
});
24 changes: 24 additions & 0 deletions util/detectOS.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import type { UserOS } from '../types/userOS';

export const detectOS = (): UserOS => {
Comment thread
FHachez marked this conversation as resolved.
Outdated
return (
Comment thread
FHachez marked this conversation as resolved.
Outdated
// Since `navigator.appVersion` is deprecated, we use the `userAgent``
// https://developer.mozilla.org/en-US/docs/Web/API/Navigator/appVersion
detectOsInUserAgent(navigator.userAgent) || 'UNKNOWN'
);
};

export const detectOsInUserAgent = (userAgent: string): UserOS => {
const osMatch = userAgent.match(/(Win|Mac|Linux)/);
const os = (osMatch && osMatch[1]) || '';
switch (os) {
Comment thread
FHachez marked this conversation as resolved.
Outdated
case 'Win':
return 'WIN';
case 'Mac':
return 'MAC';
case 'Linux':
return 'LINUX';
default:
return 'UNKNOWN';
Comment thread
FHachez marked this conversation as resolved.
Outdated
}
};
48 changes: 48 additions & 0 deletions util/downloadUrlByOS.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import type { UserOS } from '../types/userOS';

const X64_BITNESS = '64';
Comment thread
FHachez marked this conversation as resolved.
Outdated

export const downloadUrlByOS = (
Comment thread
FHachez marked this conversation as resolved.
Outdated
userAgent: string,
userOS: UserOS,
version: string,
bitness?: string
): string => {
const baseURL = getBaseURL(version);
switch (userOS) {
case 'MAC':
return getMacUrl(baseURL, version);
Comment thread
FHachez marked this conversation as resolved.
Outdated
case 'WIN':
return getWinUrl(baseURL, version, userAgent, bitness);
Comment thread
FHachez marked this conversation as resolved.
Outdated
default:
return getDefaultUrl(baseURL, version);
Comment thread
FHachez marked this conversation as resolved.
Outdated
}
};

const getBaseURL = (version: string): string =>
Comment thread
FHachez marked this conversation as resolved.
Outdated
`https://nodejs.org/dist/${version}`;

const getMacUrl = (baseURL: string, version: string): string => {
return `${baseURL}/node-${version}.pkg`;
};

const getWinUrl = (
baseURL: string,
version: string,
userAgent: string,
bitness?: string
): string => {
if (
bitness === X64_BITNESS ||
userAgent.includes('WOW64') ||
userAgent.includes('Win64')
) {
return `${baseURL}/node-${version}-x64.msi`;
}

return `${baseURL}/node-${version}-x86.msi`;
};

const getDefaultUrl = (baseURL: string, version: string): string => {
return `${baseURL}/node-${version}.tar.gz`;
};