/** * Client-side hardware detection — reads the *visitor's own device* from browser * APIs, so the hardware readout is real per-device instead of reporting the * server that happens to host the API. * * Browser detection is necessarily coarser than the server-side probe * (auralynq/modelfit/hardware.py): there is no way to read an exact CPU model or * true total RAM from a web page. We surface what is reliably available — * logical cores, GPU (via WebGL), OS/arch — and mark the rest as approximate or * unavailable rather than guessing. Every field carries a `sources` flag so the * UI can be honest about what was actually measured. */ export interface ClientHardware { os: string; // "macOS" | "Windows" | "Linux" | "iOS" | "Android" | "unknown" arch: string; // "arm64" | "x86_64" | "" cpuCores: number | null; // navigator.hardwareConcurrency (logical) ramGb: number | null; // navigator.deviceMemory — coarse, Chromium-only ramIsLowerBound: boolean; // Chrome caps deviceMemory at 8 → "8 GB or more" gpu: string | null; // parsed WebGL UNMASKED_RENDERER gpuVendor: string | null; // "Apple" | "NVIDIA" | "AMD" | "Intel" | null mobile: boolean; browser: string; sources: { cores: boolean; ram: boolean; gpu: boolean; os: boolean }; } function detectBrowser(ua: string): string { if (/Edg\//.test(ua)) return "Edge"; if (/OPR\//.test(ua)) return "Opera"; if (/Firefox\//.test(ua)) return "Firefox"; if (/Chrome\//.test(ua)) return "Chrome"; if (/Safari\//.test(ua)) return "Safari"; return "browser"; } function osFromUA(ua: string, platform: string): string { const s = `${ua} ${platform}`; if (/iPhone|iPad|iPod/i.test(s)) return "iOS"; if (/Android/i.test(s)) return "Android"; if (/Mac|Macintosh|Mac OS X/i.test(s)) return "macOS"; if (/Win/i.test(s)) return "Windows"; if (/CrOS/i.test(s)) return "ChromeOS"; if (/Linux/i.test(s)) return "Linux"; return "unknown"; } function normalizeUadPlatform(p?: string): string { switch ((p || "").toLowerCase()) { case "macos": return "macOS"; case "windows": return "Windows"; case "linux": return "Linux"; case "android": return "Android"; case "chrome os": case "chromeos": return "ChromeOS"; default: return p || ""; } } function archFrom(architecture?: string, bitness?: string, ua?: string): string { const a = (architecture || "").toLowerCase(); if (a.includes("arm")) return "arm64"; if (a === "x86") return bitness === "64" ? "x86_64" : "x86"; // Fallback: Apple Silicon Macs report Intel in UA, so we can't be sure here. if (ua && /aarch64|arm64/i.test(ua)) return "arm64"; if (ua && /x86_64|Win64|WOW64|x64/i.test(ua)) return "x86_64"; return ""; } /** Parse a WebGL UNMASKED_RENDERER string down to a human GPU name + vendor. */ function parseGpu(raw: string): { name: string; vendor: string | null } { let s = raw.trim(); // Chrome wraps everything in "ANGLE (vendor, , )". const angle = s.match(/^ANGLE \((.*)\)$/i); if (angle) { const parts = angle[1].split(",").map((p) => p.trim()); // The middle segment holds the renderer; prefer the longest descriptive one. s = parts.sort((a, b) => b.length - a.length)[0] || s; } s = s .replace(/ANGLE Metal Renderer:\s*/i, "") .replace(/\s*\(0x[0-9a-f]+\)/i, "") .replace(/\s*Direct3D.*$/i, "") .replace(/\s*OpenGL.*$/i, "") .replace(/\s*Metal\s*-?\s*.*$/i, (m) => (/Apple/i.test(m) ? m : "")) .replace(/\s*vs_\d.*$/i, "") .replace(/\bMesa\b\s*/i, "") .replace(/\s+/g, " ") .trim(); let vendor: string | null = null; if (/apple/i.test(s)) vendor = "Apple"; else if (/nvidia|geforce|rtx|gtx/i.test(s)) vendor = "NVIDIA"; else if (/amd|radeon/i.test(s)) vendor = "AMD"; else if (/intel/i.test(s)) vendor = "Intel"; return { name: s || raw, vendor }; } function detectGpu(): { name: string; vendor: string | null } | null { try { const canvas = document.createElement("canvas"); const gl = (canvas.getContext("webgl") || canvas.getContext("experimental-webgl")) as WebGLRenderingContext | null; if (!gl) return null; const ext = gl.getExtension("WEBGL_debug_renderer_info"); const raw = ext ? (gl.getParameter(ext.UNMASKED_RENDERER_WEBGL) as string) : (gl.getParameter(gl.RENDERER) as string); if (!raw) return null; return parseGpu(raw); } catch { return null; } } export async function detectClientHardware(): Promise { const nav = navigator as Navigator & { deviceMemory?: number; userAgentData?: { mobile?: boolean; platform?: string; getHighEntropyValues?: (hints: string[]) => Promise>; }; }; const ua = nav.userAgent || ""; const platform = (nav as unknown as { platform?: string }).platform || ""; let os = osFromUA(ua, platform); let arch = archFrom(undefined, undefined, ua); let osFromApi = false; const uad = nav.userAgentData; if (uad?.getHighEntropyValues) { try { const hev = await uad.getHighEntropyValues([ "architecture", "bitness", "platform", "platformVersion", "model", ]); const p = normalizeUadPlatform(hev.platform || uad.platform); if (p) { os = p; osFromApi = true; } const a = archFrom(hev.architecture, hev.bitness, ua); if (a) arch = a; } catch { /* fall back to UA parsing */ } } const cpuCores = typeof nav.hardwareConcurrency === "number" ? nav.hardwareConcurrency : null; const ramRaw = typeof nav.deviceMemory === "number" ? nav.deviceMemory : null; const gpu = detectGpu(); return { os, arch, cpuCores, ramGb: ramRaw, ramIsLowerBound: ramRaw != null && ramRaw >= 8, gpu: gpu?.name ?? null, gpuVendor: gpu?.vendor ?? null, mobile: Boolean(uad?.mobile) || /Mobi|Android/i.test(ua), browser: detectBrowser(ua), sources: { cores: cpuCores != null, ram: ramRaw != null, gpu: gpu != null, os: osFromApi || os !== "unknown", }, }; } /** * Best-effort per-device fit for a model whose memory footprint is `needGb` * (a property of the model, not the host). Uses detected RAM when available; * returns `unknown` when the browser won't reveal enough to judge. */ export function clientFit( hw: ClientHardware, needGb: number | null | undefined, ): "runs" | "tight" | "too_big" | "unknown" { if (needGb == null || hw.ramGb == null) return "unknown"; // Chrome caps deviceMemory at 8; if we only know "≥8" and the model fits in 8, // it's a safe "runs"; larger models are genuinely unknown at that ceiling. const cap = hw.ramGb; if (needGb <= cap * 0.7) return "runs"; if (needGb <= cap) return hw.ramIsLowerBound ? "unknown" : "tight"; return hw.ramIsLowerBound ? "unknown" : "too_big"; }