// What a web page can learn about the phone it runs on. Browsers deliberately hide security settings, // so this is limited to the OS and version, device model (Android), browser, whether a device unlock // method is set up, and WebGPU support. Everything else is asked. function iosVersion(ua) { // Safari 26+ freezes the OS version in the user agent (it reports 18_6), but its own Version/ follows iOS. const safari = /Version\/(\d+(?:\.\d+){0,2}).*Safari/.exec(ua); const os = /OS (\d+)_(\d+)(?:_(\d+))?/.exec(ua); const osVersion = os ? [os[1], os[2], os[3]].filter(Boolean).join('.') : null; if (safari && !/CriOS|FxiOS|EdgiOS/.test(ua) && parseInt(safari[1], 10) >= 26) return { version: safari[1], precise: false }; if (osVersion && osVersion !== '18.6') return { version: osVersion, precise: Boolean(os[3]) }; if (safari) return { version: safari[1], precise: false }; return { version: osVersion, precise: false }; } function browserName(ua, brands) { let m; if ((m = /SamsungBrowser\/(\d+(?:\.\d+)?)/.exec(ua))) return `Samsung Internet ${m[1]}`; if ((m = /EdgA\/(\d+)|EdgiOS\/(\d+)/.exec(ua))) return `Edge ${m[1] || m[2]}`; if ((m = /Firefox\/(\d+)|FxiOS\/(\d+)/.exec(ua))) return `Firefox ${m[1] || m[2]}`; if ((m = /CriOS\/(\d+)/.exec(ua))) return `Chrome ${m[1]} (iOS)`; const chrome = (brands || []).find(b => b.brand === 'Google Chrome'); if (chrome) return `Chrome ${chrome.version}`; if ((m = /Chrome\/(\d+)/.exec(ua))) return `Chrome ${m[1]}`; if ((m = /Version\/(\d+(?:\.\d+)?).*Safari/.exec(ua))) return `Safari ${m[1]}`; return 'Unknown browser'; } async function withTimeout(promise, ms, fallback) { return Promise.race([promise, new Promise(resolve => setTimeout(() => resolve(fallback), ms))]); } export async function detectDevice() { const ua = navigator.userAgent; const uaData = navigator.userAgentData; const out = { os_family: null, os_name: null, os_version: null, version_precise: false, device_model: '', browser: browserName(ua, uaData && uaData.brands), platform_authenticator: null, webgpu: 'gpu' in navigator, cellular: Boolean(navigator.connection && navigator.connection.type === 'cellular'), desktop: false }; const ipadDesktopMode = /Macintosh/.test(ua) && navigator.maxTouchPoints > 1; if (/iPhone|iPod/.test(ua) || /iPad/.test(ua) || ipadDesktopMode) { const ipad = /iPad/.test(ua) || ipadDesktopMode; const v = iosVersion(ua); Object.assign(out, { os_family: 'ios', os_name: ipad ? 'iPadOS' : 'iOS', os_version: v.version, version_precise: v.precise, device_model: ipad ? 'iPad' : 'iPhone' }); } else if (/Android/.test(ua)) { out.os_family = 'android'; out.os_name = 'Android'; if (uaData && uaData.getHighEntropyValues) { try { const hi = await withTimeout(uaData.getHighEntropyValues(['platform', 'platformVersion', 'model']), 1500, {}); // Only trust the hints when they describe Android (desktop browsers emulating a phone report the host OS). if (hi.platform === 'Android') { if (hi.platformVersion) out.os_version = String(parseInt(hi.platformVersion, 10)); if (hi.model) out.device_model = hi.model; } } catch { /* not allowed */ } } if (!out.os_version) { const m = /Android (\d+)/.exec(ua); // Chrome's reduced user agent always says "Android 10; K": not a real version. if (m && !/Android 10; K\)/.test(ua)) out.os_version = m[1]; } if (!out.device_model) { const m = /Android [\d.]+; ([^;)]+)[;)]/.exec(ua); if (m && m[1] !== 'K') out.device_model = m[1].trim(); } } else { out.desktop = true; } if (window.PublicKeyCredential && PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable) { try { out.platform_authenticator = await withTimeout(PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable(), 2000, null); } catch { out.platform_authenticator = null; } } return out; }