fix(client): better architecture detection for exam downloads (#65040)

This commit is contained in:
Oliver Eyton-Williams
2026-01-20 10:49:09 +01:00
committed by GitHub
parent b0608bae23
commit 5802eba7b9
4 changed files with 293 additions and 96 deletions
@@ -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>({