mirror of
https://github.com/freeCodeCamp/freeCodeCamp.git
synced 2026-05-28 18:26:54 +00:00
fix(client): better architecture detection for exam downloads (#65040)
This commit is contained in:
committed by
GitHub
parent
b0608bae23
commit
5802eba7b9
@@ -0,0 +1,146 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { handleDownloadLink } from './show';
|
||||
import type { UserOSState } from '../utils/use-detect-os';
|
||||
|
||||
describe('handleDownloadLink', () => {
|
||||
// handleDownloadLink only cares about the architecture and extension, so we
|
||||
// can use simplified sample links for testing.
|
||||
const sampleDownloadLinks = [
|
||||
'url_x64.exe',
|
||||
'url_x86.exe',
|
||||
'url_aarch64.dmg',
|
||||
'url_x64.dmg',
|
||||
'url_x86_64.AppImage',
|
||||
'url_aarch64.AppImage',
|
||||
'url_x64.app.tar.gz',
|
||||
'url_aarch64.tar.gz',
|
||||
'url.sig',
|
||||
'url.json'
|
||||
];
|
||||
|
||||
test('ignores irrelevant extensions', () => {
|
||||
const links = ['url.deb', 'url.rpm', 'url.zip', 'url.sig', 'url.json'];
|
||||
const osState: UserOSState = { os: 'WIN', architecture: '' };
|
||||
const result = handleDownloadLink(osState, links);
|
||||
expect(result).toBe('');
|
||||
});
|
||||
|
||||
describe('Windows OS', () => {
|
||||
test('returns x64 exe for Windows with x86_64 architecture', () => {
|
||||
const osState: UserOSState = { os: 'WIN', architecture: 'x86_64' };
|
||||
const result = handleDownloadLink(osState, sampleDownloadLinks);
|
||||
expect(result).toBe('url_x64.exe');
|
||||
});
|
||||
|
||||
test('returns x86 exe for Windows with x86 architecture', () => {
|
||||
const osState: UserOSState = { os: 'WIN', architecture: 'x86' };
|
||||
const result = handleDownloadLink(osState, sampleDownloadLinks);
|
||||
expect(result).toBe('url_x86.exe');
|
||||
});
|
||||
|
||||
test('returns any exe when architecture is not specified', () => {
|
||||
const osState: UserOSState = { os: 'WIN', architecture: '' };
|
||||
const result = handleDownloadLink(osState, sampleDownloadLinks);
|
||||
expect(result).toMatch(/\.exe$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Mac OS', () => {
|
||||
test('returns x64 dmg for Mac with x86_64 architecture', () => {
|
||||
const osState: UserOSState = { os: 'MAC', architecture: 'x86_64' };
|
||||
const result = handleDownloadLink(osState, sampleDownloadLinks);
|
||||
expect(result).toBe('url_x64.dmg');
|
||||
});
|
||||
|
||||
test('returns aarch64 dmg for Mac with arm64 architecture', () => {
|
||||
const osState: UserOSState = { os: 'MAC', architecture: 'arm64' };
|
||||
const result = handleDownloadLink(osState, sampleDownloadLinks);
|
||||
expect(result).toBe('url_aarch64.dmg');
|
||||
});
|
||||
|
||||
test('returns any dmg when architecture is not specified', () => {
|
||||
const osState: UserOSState = { os: 'MAC', architecture: '' };
|
||||
const result = handleDownloadLink(osState, sampleDownloadLinks);
|
||||
expect(result).toMatch(/\.dmg$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Linux OS', () => {
|
||||
test('returns x86_64 AppImage for Linux with x86_64 architecture', () => {
|
||||
const osState: UserOSState = { os: 'LINUX', architecture: 'x86_64' };
|
||||
const result = handleDownloadLink(osState, sampleDownloadLinks);
|
||||
expect(result).toBe('url_x86_64.AppImage');
|
||||
});
|
||||
|
||||
test('returns aarch64 AppImage for Linux with arm64 architecture', () => {
|
||||
const osState: UserOSState = { os: 'LINUX', architecture: 'arm64' };
|
||||
const result = handleDownloadLink(osState, sampleDownloadLinks);
|
||||
expect(result).toBe('url_aarch64.AppImage');
|
||||
});
|
||||
|
||||
test('prefers AppImage over tar.gz', () => {
|
||||
const links = ['url_x64.tar.gz', 'url_x64.AppImage'];
|
||||
const osState: UserOSState = { os: 'LINUX', architecture: 'x86_64' };
|
||||
const result = handleDownloadLink(osState, links);
|
||||
expect(result).toBe('url_x64.AppImage');
|
||||
});
|
||||
|
||||
test('prefers app.tar.gz over tar.gz', () => {
|
||||
const links = ['url_x64.tar.gz', 'url_x64.app.tar.gz'];
|
||||
const osState: UserOSState = { os: 'LINUX', architecture: 'x86_64' };
|
||||
const result = handleDownloadLink(osState, links);
|
||||
expect(result).toBe('url_x64.app.tar.gz');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Unknown OSes', () => {
|
||||
test('returns empty string for unknown OS', () => {
|
||||
const osState: UserOSState = { os: 'OTHER', architecture: 'x86_64' };
|
||||
const result = handleDownloadLink(osState, sampleDownloadLinks);
|
||||
expect(result).toBe('');
|
||||
});
|
||||
|
||||
test('returns empty string for LOADING OS state', () => {
|
||||
const osState: UserOSState = { os: 'LOADING', architecture: '' };
|
||||
const result = handleDownloadLink(osState, sampleDownloadLinks);
|
||||
expect(result).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Architecture normalization', () => {
|
||||
test('picks the first link if two normalize to the same arch', () => {
|
||||
const links = ['url_x64.exe', 'url_amd64.exe'];
|
||||
const osState: UserOSState = { os: 'WIN', architecture: 'amd64' };
|
||||
const result = handleDownloadLink(osState, links);
|
||||
expect(result).toBe('url_x64.exe');
|
||||
});
|
||||
|
||||
test('normalizes amd64 to x64', () => {
|
||||
const links = ['url_fake.exe', 'url_x64.exe'];
|
||||
const osState: UserOSState = { os: 'WIN', architecture: 'amd64' };
|
||||
const result = handleDownloadLink(osState, links);
|
||||
expect(result).toBe('url_x64.exe');
|
||||
});
|
||||
|
||||
test('normalizes arm64 to arm', () => {
|
||||
const links = ['url_fake.dmg', 'url_arm.dmg'];
|
||||
const osState: UserOSState = { os: 'MAC', architecture: 'arm64' };
|
||||
const result = handleDownloadLink(osState, links);
|
||||
expect(result).toBe('url_arm.dmg');
|
||||
});
|
||||
|
||||
test('normalizes i386 to x86', () => {
|
||||
const links = ['url_fake.exe', 'url_x86.exe'];
|
||||
const osState: UserOSState = { os: 'WIN', architecture: 'i386' };
|
||||
const result = handleDownloadLink(osState, links);
|
||||
expect(result).toBe('url_x86.exe');
|
||||
});
|
||||
|
||||
test('normalizes i686 to x86', () => {
|
||||
const links = ['url_fake.exe', 'url_x86.exe'];
|
||||
const osState: UserOSState = { os: 'WIN', architecture: 'i686' };
|
||||
const result = handleDownloadLink(osState, links);
|
||||
expect(result).toBe('url_x86.exe');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -17,7 +17,7 @@ import { connect } from 'react-redux';
|
||||
|
||||
import LearnLayout from '../../../components/layouts/learn';
|
||||
import ChallengeTitle from '../components/challenge-title';
|
||||
import useDetectOS from '../utils/use-detect-os';
|
||||
import useDetectOS, { type UserOSState } from '../utils/use-detect-os';
|
||||
import {
|
||||
ChallengeNode,
|
||||
CompletedChallenge,
|
||||
@@ -78,6 +78,79 @@ interface ShowExamDownloadProps {
|
||||
user: User | null;
|
||||
}
|
||||
|
||||
function normalizeArch(name: string): string {
|
||||
const archMatch = name.match(
|
||||
/(aarch64|arm|arm64|amd64|x86_64|x64|x86|i386|i686)/i
|
||||
);
|
||||
|
||||
const token = archMatch?.[0];
|
||||
|
||||
if (!token) return '';
|
||||
const t = token.toLowerCase();
|
||||
if (/aarch64|arm64|arm/i.test(t)) return 'arm';
|
||||
if (/x86_64|x64|amd64/i.test(t)) return 'x64';
|
||||
if (/x86|i386|i686/i.test(t)) return 'x86';
|
||||
return t;
|
||||
}
|
||||
|
||||
export function handleDownloadLink(
|
||||
{ os, architecture }: UserOSState,
|
||||
downloadLinks: string[]
|
||||
) {
|
||||
const items = downloadLinks.map(link => {
|
||||
const urlEnd = link.split('/').pop() ?? '';
|
||||
const name = urlEnd;
|
||||
let ext = '';
|
||||
if (name.endsWith('.app.tar.gz')) ext = '.app.tar.gz';
|
||||
else if (name.endsWith('.tar.gz')) ext = '.tar.gz';
|
||||
else if (name.endsWith('.AppImage')) ext = '.AppImage';
|
||||
else if (name.endsWith('.dmg')) ext = '.dmg';
|
||||
else if (name.endsWith('.exe')) ext = '.exe';
|
||||
else {
|
||||
const m = name.match(/(\.[^./]+)$/);
|
||||
ext = m ? m[0] : '';
|
||||
}
|
||||
|
||||
return { link, name, ext, arch: normalizeArch(name) };
|
||||
});
|
||||
|
||||
const detectedArch = normalizeArch(architecture || '');
|
||||
|
||||
function pickByExts(exts: string[], preferArch?: string) {
|
||||
// prefer both ext + arch
|
||||
for (const ext of exts) {
|
||||
const found = items.find(it => it.ext === ext && it.arch === preferArch);
|
||||
if (found) return found.link;
|
||||
}
|
||||
// then any with ext and unspecified arch
|
||||
const withExt = items.find(
|
||||
it => exts.includes(it.ext) && (!preferArch || it.arch === '')
|
||||
);
|
||||
if (withExt) return withExt.link;
|
||||
// then any with ext
|
||||
const anyExt = items.find(it => exts.includes(it.ext));
|
||||
return anyExt ? anyExt.link : '';
|
||||
}
|
||||
|
||||
if (os === 'WIN') {
|
||||
return pickByExts(['.exe'], detectedArch) || '';
|
||||
}
|
||||
|
||||
if (os === 'MAC') {
|
||||
// prefer .dmg files
|
||||
return pickByExts(['.dmg'], detectedArch) || '';
|
||||
}
|
||||
|
||||
if (os === 'LINUX') {
|
||||
// prefer AppImage, then .app.tar.gz, then .tar.gz
|
||||
return (
|
||||
pickByExts(['.AppImage', '.app.tar.gz', '.tar.gz'], detectedArch) || ''
|
||||
);
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function ShowExamDownload({
|
||||
data: {
|
||||
challengeNode: {
|
||||
@@ -102,82 +175,10 @@ function ShowExamDownload({
|
||||
skip: !isSignedIn
|
||||
});
|
||||
|
||||
const os = useDetectOS();
|
||||
const userOSState = useDetectOS();
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
function handleDownloadLink(downloadLinks: string[]) {
|
||||
// Filter out signature and metadata files first
|
||||
const filtered = downloadLinks.filter(link => !/\.(sig|json)$/i.test(link));
|
||||
|
||||
function normalizeArch(token: string): string {
|
||||
if (!token) return '';
|
||||
const t = token.toLowerCase();
|
||||
if (/aarch64|arm64|arm/i.test(t)) return 'arm';
|
||||
if (/x86_64|x64|amd64/i.test(t)) return 'x64';
|
||||
if (/x86|i386|i686/i.test(t)) return 'x86';
|
||||
return t;
|
||||
}
|
||||
|
||||
const items = filtered.map(link => {
|
||||
const urlEnd = link.split('/').pop() ?? '';
|
||||
const name = urlEnd;
|
||||
let ext = '';
|
||||
if (name.endsWith('.app.tar.gz')) ext = '.app.tar.gz';
|
||||
else if (name.endsWith('.tar.gz')) ext = '.tar.gz';
|
||||
else if (name.endsWith('.AppImage')) ext = '.AppImage';
|
||||
else if (name.endsWith('.dmg')) ext = '.dmg';
|
||||
else if (name.endsWith('.exe')) ext = '.exe';
|
||||
else {
|
||||
const m = name.match(/(\.[^./]+)$/);
|
||||
ext = m ? m[0] : '';
|
||||
}
|
||||
|
||||
const archMatch = name.match(
|
||||
/(aarch64|arm64|amd64|x86_64|x64|x86|i386)/i
|
||||
);
|
||||
const archToken = archMatch ? normalizeArch(archMatch[0]) : '';
|
||||
|
||||
return { link, name, ext, arch: archToken };
|
||||
});
|
||||
|
||||
const detectedArch = normalizeArch(os.architecture || '');
|
||||
|
||||
function pickByExts(exts: string[], preferArch?: string) {
|
||||
// prefer both ext + arch
|
||||
let found = items.find(
|
||||
it => exts.includes(it.ext) && it.arch === preferArch
|
||||
);
|
||||
if (found) return found.link;
|
||||
// then any with ext and unspecified arch
|
||||
found = items.find(
|
||||
it => exts.includes(it.ext) && (!preferArch || it.arch === '')
|
||||
);
|
||||
if (found) return found.link;
|
||||
// then any with ext
|
||||
found = items.find(it => exts.includes(it.ext));
|
||||
return found ? found.link : '';
|
||||
}
|
||||
|
||||
if (os.os === 'WIN') {
|
||||
return pickByExts(['.exe'], detectedArch) || '';
|
||||
}
|
||||
|
||||
if (os.os === 'MAC') {
|
||||
// prefer .dmg files
|
||||
return pickByExts(['.dmg'], detectedArch) || '';
|
||||
}
|
||||
|
||||
if (os.os === 'LINUX') {
|
||||
// prefer AppImage, then .app.tar.gz, then .tar.gz
|
||||
return (
|
||||
pickByExts(['.AppImage', '.app.tar.gz', '.tar.gz'], detectedArch) || ''
|
||||
);
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
async function checkLatestVersion() {
|
||||
try {
|
||||
@@ -218,18 +219,17 @@ function ShowExamDownload({
|
||||
const { tag_name, assets } = latest;
|
||||
setLatestVersion(tag_name);
|
||||
const urls = assets.map(link => link.browser_download_url);
|
||||
setDownloadLink(handleDownloadLink(urls));
|
||||
setDownloadLink(handleDownloadLink(userOSState, urls));
|
||||
setDownloadLinks(urls);
|
||||
} catch {
|
||||
setLatestVersion(null);
|
||||
}
|
||||
}
|
||||
|
||||
if (os.os) {
|
||||
if (userOSState.os) {
|
||||
void checkLatestVersion();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [os]);
|
||||
}, [userOSState]);
|
||||
|
||||
const examId = examIdsQuery.data?.at(0)?.examId;
|
||||
const exam = getExamsQuery.data?.find(examItem => examItem.id === examId);
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
|
||||
import { userAgentDataToArchitecture } from './use-detect-os';
|
||||
|
||||
describe('userAgentDataToArchitecture', () => {
|
||||
test('defaults to x86 when architecture is empty and bitness is 32', () => {
|
||||
const data = { architecture: '', bitness: '32' } as const;
|
||||
const result = userAgentDataToArchitecture(data);
|
||||
expect(result).toBe('x86');
|
||||
});
|
||||
|
||||
test('defaults to x86_64 when architecture is empty and bitness is 64', () => {
|
||||
const data = { architecture: '', bitness: '64' } as const;
|
||||
const result = userAgentDataToArchitecture(data);
|
||||
expect(result).toBe('x86_64');
|
||||
});
|
||||
|
||||
test('defaults to 32bit when bitness is empty', () => {
|
||||
const empty = { architecture: '', bitness: '' } as const;
|
||||
const emptyResult = userAgentDataToArchitecture(empty);
|
||||
expect(emptyResult).toBe('x86');
|
||||
|
||||
const x86 = { architecture: 'x86', bitness: '' } as const;
|
||||
const x86Result = userAgentDataToArchitecture(x86);
|
||||
expect(x86Result).toBe('x86');
|
||||
|
||||
const arm = { architecture: 'arm', bitness: '' } as const;
|
||||
const armResult = userAgentDataToArchitecture(arm);
|
||||
expect(armResult).toBe('arm');
|
||||
});
|
||||
});
|
||||
@@ -1,30 +1,50 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
type UserOS = 'MAC' | 'WIN' | 'LINUX' | 'AIX' | 'OTHER' | 'LOADING';
|
||||
type UserOS = 'MAC' | 'WIN' | 'LINUX' | 'OTHER' | 'LOADING';
|
||||
type Architecture = 'x86' | 'x86_64' | 'arm' | 'arm64' | 'Unknown' | 'LOADING';
|
||||
|
||||
type UserOSState = {
|
||||
export type UserOSState = {
|
||||
os: UserOS;
|
||||
architecture: string;
|
||||
};
|
||||
|
||||
// There are other hints, such as "model", that we're not using, so it's not
|
||||
// exhaustive. This covers our use cases.
|
||||
type HighEntropyValues = {
|
||||
architecture: 'x86' | 'arm' | '';
|
||||
bitness: '32' | '64' | '';
|
||||
};
|
||||
|
||||
interface NavigatorUAData {
|
||||
getHighEntropyValues(hints: string[]): Promise<Record<string, string>>;
|
||||
getHighEntropyValues(hints: string[]): Promise<HighEntropyValues>;
|
||||
}
|
||||
|
||||
interface Navigator {
|
||||
userAgentData?: NavigatorUAData;
|
||||
}
|
||||
export const userAgentDataToArchitecture = ({
|
||||
architecture,
|
||||
bitness
|
||||
}: HighEntropyValues) => {
|
||||
if (architecture === 'arm') {
|
||||
return bitness === '64' ? 'arm64' : 'arm';
|
||||
} else {
|
||||
return bitness === '64' ? 'x86_64' : 'x86';
|
||||
}
|
||||
};
|
||||
|
||||
const getArchitecture = async (): Promise<string> => {
|
||||
if ((navigator as Navigator).userAgentData?.getHighEntropyValues) {
|
||||
const hasUserAgentData = (
|
||||
nav: globalThis.Navigator
|
||||
): nav is globalThis.Navigator & { userAgentData: NavigatorUAData } => {
|
||||
return 'userAgentData' in nav && nav.userAgentData !== undefined;
|
||||
};
|
||||
|
||||
const getArchitecture = async (): Promise<Architecture> => {
|
||||
if (hasUserAgentData(navigator)) {
|
||||
try {
|
||||
const { architecture } =
|
||||
(await (navigator as Navigator).userAgentData?.getHighEntropyValues([
|
||||
'architecture'
|
||||
])) ?? {};
|
||||
if (architecture) {
|
||||
return architecture;
|
||||
}
|
||||
const uAData = await navigator.userAgentData.getHighEntropyValues([
|
||||
'architecture',
|
||||
'bitness'
|
||||
]);
|
||||
|
||||
return userAgentDataToArchitecture(uAData);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'Error fetching architecture via User-Agent Client Hints:',
|
||||
@@ -40,18 +60,18 @@ const getArchitecture = async (): Promise<string> => {
|
||||
} else if (/i686|x86|Win32/.test(userAgent)) {
|
||||
return 'x86';
|
||||
} else if (/arm|aarch64/.test(userAgent)) {
|
||||
return /aarch64/.test(userAgent) ? 'ARM64' : 'ARM';
|
||||
return /aarch64/.test(userAgent) ? 'arm64' : 'arm';
|
||||
}
|
||||
|
||||
return 'Unknown';
|
||||
};
|
||||
|
||||
export const detectOsInUserAgent = (userAgent: string | undefined): UserOS => {
|
||||
const detectOsInUserAgent = (userAgent: string | undefined): UserOS => {
|
||||
const osMatch = userAgent?.match(/(Win|Mac|Linux)/);
|
||||
return osMatch ? (osMatch[1].toUpperCase() as UserOS) : 'OTHER';
|
||||
};
|
||||
|
||||
export const detectOS = (): UserOS => detectOsInUserAgent(navigator?.userAgent);
|
||||
const detectOS = (): UserOS => detectOsInUserAgent(navigator?.userAgent);
|
||||
|
||||
const useDetectOS = () => {
|
||||
const [userOS, setUserOS] = useState<UserOSState>({
|
||||
|
||||
Reference in New Issue
Block a user