auralynq-rag / web /lib /clienthw.ts
MHamdan's picture
HardwareGate: detect the visitor's real device client-side
4de5f82 verified
Raw
History Blame Contribute Delete
6.93 kB
/**
* 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, <renderer>, <api>)".
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<ClientHardware> {
const nav = navigator as Navigator & {
deviceMemory?: number;
userAgentData?: {
mobile?: boolean;
platform?: string;
getHighEntropyValues?: (hints: string[]) => Promise<Record<string, string>>;
};
};
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";
}