/** * Pure browser / device detection helpers used by the system check. * * Side-effect free: every function reads only from `navigator`, `window`, * and `screen`. Safe to import from anywhere — including environments * that don't paint a UI. * * Detection is best-effort. We only care about gating exam delivery, * so we'd rather be slightly conservative (block iOS + low-memory * Android) than chase every browser flavour. The full matrix of UA * weirdness is a problem for the customer's support team, not ours. */ export interface BrowserInfo { name: string; version: string; versionNumber: number; supported: boolean; failReason?: string; } export type DeviceCategory = "Desktop" | "Mobile" | "Tablet"; export interface DeviceInfo { isIOS: boolean; isSamsungBrowser: boolean; isAndroidOrSamsung: boolean; isLowMemory: boolean; isMobileOrTablet: boolean; deviceType: DeviceCategory; os: string; } /** * Minimum Chromium version (Chrome/Edge/Brave) accepted as supported. * 111 is May 2023 — old enough that ~99% of real candidates have it. */ export const CHROME_MIN_VERSION = 111; /** * Minimum Safari version accepted as supported. 16.4 is the first * version with reliable `getDisplayMedia` + `MediaRecorder` end-to-end * on desktop macOS. Below this Safari either lacks the APIs entirely * or stutters chunk pacing badly enough that the chunk uploader * drops frames. * * Note: a Safari-recorded session passes preflight + records, but the * post-session Python face analyzer assumes WebM and will skip MP4 * recordings cleanly. Live face-detect during preflight works on * Safari unchanged (it hits the Python sidecar's /detect-face * endpoint with a JPEG, not a video container). */ export const SAFARI_MIN_VERSION = 16.4; /** iPhone/iPad/iPod or a "MacIntel" laptop reporting touch (iPad pretending to be desktop). */ export const isIOS = (): boolean => /iPad|iPhone|iPod/.test(navigator.userAgent) || (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1); /** Samsung Internet — its own renderer, behaves differently from stock Chrome. */ export const isSamsungBrowser = (): boolean => /SamsungBrowser/i.test(navigator.userAgent); /** Any Android UA, plus Samsung Internet (which doesn't always include "android"). */ export const isAndroidOrSamsung = (): boolean => /android/i.test(navigator.userAgent) || isSamsungBrowser(); /** Brave exposes this marker even though its Chromium user agent omits the browser name. */ const isBraveBrowser = (): boolean => typeof ( navigator as Navigator & { brave?: { isBrave?: unknown } } ).brave?.isBrave === "function"; /** * Reported-memory floor (GB). A device at or below this fails the * low-memory check. Exported so candidate-facing copy can state the * requirement without hard-coding the number in two places. */ export const MIN_DEVICE_MEMORY_GB = 4; /** * Android device with <=4GB of reported memory. The exam UI plus * MediaRecorder plus our IndexedDB queue chews through RAM — under 4GB * we routinely see OOM kills mid-exam. `navigator.deviceMemory` is a * coarse bucketed signal (0.25/0.5/1/2/4/8) — coarse is what we want * here. Browsers may suppress or deliberately alter the signal for * privacy. An unavailable value, or Brave's farbled value, is unknown * and must not be treated as a low-memory report. * * Returns false for non-Android devices; desktop "low memory" is real * but Chrome on a 4GB laptop still copes, where Chrome on a 4GB phone * struggles to keep one camera stream alive. */ export const isLowMemoryDevice = (): boolean => { if (!isAndroidOrSamsung()) return false; if (isBraveBrowser()) return false; const deviceMemory = (navigator as Navigator & { deviceMemory?: number }) .deviceMemory; if (deviceMemory === undefined) return false; return deviceMemory <= MIN_DEVICE_MEMORY_GB; }; /** Best-effort mobile/tablet detection. Used to fail the screen-layout check, not the device check. */ export const isMobileOrTablet = (): boolean => isIOS() || isAndroidOrSamsung() || /Mobile|Tablet|Android|webOS|BlackBerry|Opera Mini|IEMobile/i.test( navigator.userAgent, ) || ("ontouchstart" in window && navigator.maxTouchPoints > 1); /** "Tablet" / "Mobile" / "Desktop" classification for display purposes only — not used to gate. */ export const getDeviceType = (): DeviceCategory => { const ua = navigator.userAgent; if (/ipad/i.test(ua) || (/android/i.test(ua) && !/mobile/i.test(ua))) { return "Tablet"; } if (/iphone|ipod|android.*mobile|mobile/i.test(ua)) { return "Mobile"; } return "Desktop"; }; /** Operating system name for display ("Windows"/"macOS"/"Android"/"iOS"/"Linux"/"Unknown"). */ export const getOS = (): string => { const ua = navigator.userAgent; if (/windows/i.test(ua)) return "Windows"; if (/macintosh|mac os/i.test(ua)) return "macOS"; if (/android/i.test(ua)) return "Android"; if (/iphone|ipad|ipod/i.test(ua)) return "iOS"; if (/linux/i.test(ua)) return "Linux"; return "Unknown"; }; /** UA-based Safari detection that excludes Chromium-on-iOS. */ export const isSafari = (): boolean => { const ua = navigator.userAgent; return /^((?!chrome|android|crios|fxios).)*safari/i.test(ua); }; /** * Detects the browser and decides whether it's supported. Pure: takes * the safari predicate as a callback so tests can pin behaviour without * stubbing navigator. */ export const detectBrowser = ( isSafariFn: () => boolean = isSafari, ): BrowserInfo => { const ua = navigator.userAgent; if (isIOS()) { const match = ua.match(/Version\/([\d.]+)/); return { name: "iOS Safari", version: match?.[1] ?? "", versionNumber: parseFloat(match?.[1] ?? "0"), supported: false, failReason: "iOS devices are not supported", }; } if (isSamsungBrowser()) { const match = ua.match(/SamsungBrowser\/([\d.]+)/); return { name: "Samsung Browser", version: match?.[1] ?? "", versionNumber: parseFloat(match?.[1] ?? "0"), supported: true, }; } if (/firefox/i.test(ua)) { const match = ua.match(/Firefox\/([\d.]+)/); return { name: "Firefox", version: match?.[1] ?? "", versionNumber: parseFloat(match?.[1] ?? "0"), supported: false, failReason: "Firefox is not supported", }; } if (isSafariFn()) { const match = ua.match(/Version\/([\d.]+)/); const ver = parseFloat(match?.[1] ?? "0"); return { name: "Safari", version: match?.[1] ?? "", versionNumber: ver, supported: ver >= SAFARI_MIN_VERSION, failReason: ver < SAFARI_MIN_VERSION ? `Safari ${ver || "(version unknown)"} is outdated (min: ${SAFARI_MIN_VERSION})` : undefined, }; } if (/edg\//i.test(ua)) { const match = ua.match(/Edg\/([\d.]+)/); const ver = parseFloat(match?.[1] ?? "0"); return { name: "Edge", version: match?.[1] ?? "", versionNumber: ver, supported: ver >= CHROME_MIN_VERSION, failReason: ver < CHROME_MIN_VERSION ? `Edge ${Math.floor(ver)} is outdated (min: ${CHROME_MIN_VERSION})` : undefined, }; } if (/opr\/|opera/i.test(ua)) { const match = ua.match(/OPR\/([\d.]+)/); const ver = parseFloat(match?.[1] ?? "0"); return { name: "Opera", version: match?.[1] ?? "", versionNumber: ver, supported: true, }; } if (/chrome/i.test(ua)) { const match = ua.match(/Chrome\/([\d.]+)/); const ver = parseFloat(match?.[1] ?? "0"); return { name: "Chrome", version: match?.[1] ?? "", versionNumber: ver, supported: ver >= CHROME_MIN_VERSION, failReason: ver < CHROME_MIN_VERSION ? `Chrome ${Math.floor(ver)} is outdated (min: ${CHROME_MIN_VERSION})` : undefined, }; } return { name: "Unknown", version: "", versionNumber: 0, supported: false, failReason: "Unknown browser is not supported", }; }; /** One-shot bundle of device facts. Convenient for telemetry. */ export const getDeviceInfo = (): DeviceInfo => ({ isIOS: isIOS(), isSamsungBrowser: isSamsungBrowser(), isAndroidOrSamsung: isAndroidOrSamsung(), isLowMemory: isLowMemoryDevice(), isMobileOrTablet: isMobileOrTablet(), deviceType: getDeviceType(), os: getOS(), });