import { UAParser } from "ua-parser-js" import type { IResult } from "ua-parser-js" import { isAIAssistant, isAICrawler, isBot } from "ua-parser-js/bot-detection" import { isChromeFamily, isElectron, isFromEU, isStandalonePWA, } from "ua-parser-js/browser-detection" import { isAppleSilicon } from "ua-parser-js/device-detection" import type { BrowserName, BrowserType, CPUArch, DeviceType, DeviceVendor, EngineName, Extension, OSName, } from "ua-parser-js/enums" import { Bots, CLIs, Crawlers, Emails, ExtraDevices, Fetchers, InApps, Libraries, MediaPlayers, Vehicles, } from "ua-parser-js/extensions" import { isFrozenUA } from "ua-parser-js/helpers" interface NetworkInformation { downlink: number downlinkMax: number effectiveType: "slow-2g" | "2g" | "3g" | "4g" rtt: number saveData: boolean type: "bluetooth" | "cellular" | "ethernet" | "none" | "wifi" | "wimax" | "other" | "unknown" } const getNetworkInformation = (): NetworkInformation | undefined => { let connection: NetworkInformation | undefined if ("connection" in navigator) { connection = navigator.connection as NetworkInformation } else if ("mozConnection" in navigator) { connection = navigator.mozConnection as NetworkInformation } else if ("webkitConnection" in navigator) { connection = navigator.webkitConnection as NetworkInformation } return connection } /** * @description Describe navigator-level device and network metadata. */ export interface DeviceNavigatorInfo { oscpu: string | undefined deviceMemory: number | undefined hardwareConcurrency: number | undefined maxTouchPoints: number | undefined platform: Navigator["platform"] devicePosture: "continuous" | "folded" | undefined connectionDownlink: NetworkInformation["downlink"] | undefined connectionDownlinkMax: NetworkInformation["downlinkMax"] | undefined connectionEffectiveType: NetworkInformation["effectiveType"] | undefined connectionRtt: NetworkInformation["rtt"] | undefined connectionSaveData: NetworkInformation["saveData"] | undefined connectionType: NetworkInformation["type"] | undefined online: boolean cookiesEnabled: boolean language: string languages: string[] pdfViewerEnabled: boolean } /** * @description Collect device information from the Navigator API. */ export const getDeviceNavigatorInfo = (): DeviceNavigatorInfo => { const connection = getNetworkInformation() return { oscpu: "oscpu" in navigator ? String(navigator.oscpu) : undefined, hardwareConcurrency: "hardwareConcurrency" in navigator ? Number.parseInt(String(navigator.hardwareConcurrency), 10) : undefined, deviceMemory: "deviceMemory" in navigator ? Number.parseFloat(String(navigator.deviceMemory)) : undefined, maxTouchPoints: navigator.maxTouchPoints, platform: navigator.platform, devicePosture: "devicePosture" in navigator ? (navigator.devicePosture as { type: "continuous" | "folded" }).type : undefined, connectionDownlink: connection?.downlink ?? undefined, connectionDownlinkMax: connection?.downlinkMax ?? undefined, connectionEffectiveType: connection?.effectiveType ?? undefined, connectionRtt: connection?.rtt ?? undefined, connectionSaveData: connection?.saveData ?? undefined, connectionType: connection?.type ?? undefined, online: navigator.onLine, cookiesEnabled: navigator.cookieEnabled, language: navigator.language, languages: [...navigator.languages], pdfViewerEnabled: navigator.pdfViewerEnabled, } } /** * @description Describe device metadata parsed from a user-agent string. */ export interface DeviceUserAgentInfo { ua: string uaIsFrozen: boolean cpuArchitecture: (typeof CPUArch)[keyof typeof CPUArch] | undefined osName: (typeof OSName)[keyof typeof OSName] | undefined osVersion: IResult["os"]["version"] | undefined engineName: (typeof EngineName)[keyof typeof EngineName] | undefined engineVersion: IResult["engine"]["version"] | undefined browserName: | (typeof BrowserName)[keyof typeof BrowserName] | (typeof Extension)["BrowserName"]["CLI"][keyof (typeof Extension)["BrowserName"]["CLI"]] | (typeof Extension)["BrowserName"]["Crawler"][keyof (typeof Extension)["BrowserName"]["Crawler"]] | (typeof Extension)["BrowserName"]["Email"][keyof (typeof Extension)["BrowserName"]["Email"]] | (typeof Extension)["BrowserName"]["Fetcher"][keyof (typeof Extension)["BrowserName"]["Fetcher"]] | (typeof Extension)["BrowserName"]["InApp"][keyof (typeof Extension)["BrowserName"]["InApp"]] | (typeof Extension)["BrowserName"]["Library"][keyof (typeof Extension)["BrowserName"]["Library"]] | undefined browserVersion: IResult["browser"]["version"] | undefined browserMajor: IResult["browser"]["major"] | undefined browserType: (typeof BrowserType)[keyof typeof BrowserType] | undefined browserIsAiAssistant: boolean browserIsAiCrawler: boolean browserIsBot: boolean browserIsChromeFamily: boolean browserIsElectron: boolean browserIsFromEU: boolean browserIsStandalonePWA: boolean deviceType: (typeof DeviceType)[keyof typeof DeviceType] | undefined deviceVendor: | (typeof DeviceVendor)[keyof typeof DeviceVendor] | (typeof Extension)["DeviceVendor"]["Vehicle"][keyof (typeof Extension)["DeviceVendor"]["Vehicle"]] | undefined deviceModel: IResult["device"]["model"] | undefined deviceIsAppleSilicon: boolean } /** * @description Parse the user-agent string into normalized device metadata. * * @see {@link https://docs.uaparser.dev/ | UAParser.js} */ export const parseUserAgent = (userAgent: string): DeviceUserAgentInfo => { const parser = new UAParser([ // @ts-expect-error - UAParser.js type definitions has unknown issue with extensions Bots, // @ts-expect-error - UAParser.js type definitions has unknown issue with extensions CLIs, // @ts-expect-error - UAParser.js type definitions has unknown issue with extensions Crawlers, // @ts-expect-error - UAParser.js type definitions has unknown issue with extensions Emails, // @ts-expect-error - UAParser.js type definitions has unknown issue with extensions ExtraDevices, // @ts-expect-error - UAParser.js type definitions has unknown issue with extensions Fetchers, // @ts-expect-error - UAParser.js type definitions has unknown issue with extensions InApps, // @ts-expect-error - UAParser.js type definitions has unknown issue with extensions Libraries, // @ts-expect-error - UAParser.js type definitions has unknown issue with extensions MediaPlayers, // @ts-expect-error - UAParser.js type definitions has unknown issue with extensions Vehicles, ]) parser.setUA(userAgent) const result = parser.getResult() let browserIsElectron: boolean try { browserIsElectron = isElectron() } catch { browserIsElectron = false } let browserIsStandalonePWA: boolean try { browserIsStandalonePWA = isStandalonePWA() } catch { browserIsStandalonePWA = false } return { ua: userAgent, uaIsFrozen: isFrozenUA(userAgent), cpuArchitecture: result.cpu.architecture, osName: result.os.name as (typeof OSName)[keyof typeof OSName], osVersion: result.os.version, engineName: result.engine.name, engineVersion: result.engine.version, browserName: result.browser.name as (typeof BrowserName)[keyof typeof BrowserName], browserVersion: result.browser.version, browserMajor: result.browser.major, browserType: result.browser.type, browserIsAiAssistant: isAIAssistant(userAgent), browserIsAiCrawler: isAICrawler(userAgent), browserIsBot: isBot(userAgent), browserIsChromeFamily: isChromeFamily(userAgent), browserIsElectron, browserIsFromEU: isFromEU(), browserIsStandalonePWA, deviceType: result.device.type, deviceVendor: result.device.vendor as (typeof DeviceVendor)[keyof typeof DeviceVendor], deviceModel: result.device.model, deviceIsAppleSilicon: isAppleSilicon(userAgent), } } const getSafeAreaInset = (edge: "top" | "bottom" | "left" | "right"): number => { const style = getComputedStyle(document.documentElement) // 尝试 env(),现代 Safari let value = style.getPropertyValue(`env(safe-area-inset-${edge})`) if (value === "") { // 尝试 constant(),旧版 iOS value = style.getPropertyValue(`constant(safe-area-inset-${edge})`) } return value !== "" ? Number.parseFloat(value) : 0 } /** * @description Describe screen, viewport, and safe-area metadata. */ export interface DeviceScreenInfo { pixelRatio: number orientationOriginal: OrientationType | "unknown" orientationCalculated: "portrait" | "landscape" screenWidth: number screenHeight: number screenAvailableWidth: number screenAvailableHeight: number browserWidth: number browserHeight: number /** * @description With scrollbar. */ viewportWidth: number /** * @description With scrollbar. */ viewportHeight: number /** * @description Without scrollbar. */ viewportAvailableWidth: number /** * @description Without scrollbar. */ viewportAvailableHeight: number /** * @description Without scrollbar. */ visualViewportWidth: number | undefined /** * @description Without scrollbar. */ visualViewportHeight: number | undefined visualViewportOffsetLeft: number | undefined visualViewportOffsetTop: number | undefined visualViewportPageLeft: number | undefined visualViewportPageTop: number | undefined visualViewportScale: number | undefined safeAreaTop: number safeAreaBottom: number safeAreaLeft: number safeAreaRight: number contentWidth: number contentHeight: number } /** * @description Collect screen and viewport information from the current window. * * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/Window/visualViewport} */ export const getDeviceScreenInfo = (): DeviceScreenInfo => { return { pixelRatio: window.devicePixelRatio, orientationOriginal: window.screen.orientation?.type || "unknown", orientationCalculated: window.innerWidth > window.innerHeight ? "landscape" : "portrait", screenWidth: window.screen.width, screenHeight: window.screen.height, screenAvailableWidth: window.screen.availWidth, screenAvailableHeight: window.screen.availHeight, browserWidth: window.outerWidth, browserHeight: window.outerHeight, viewportWidth: window.innerWidth, viewportHeight: window.innerHeight, viewportAvailableWidth: document.documentElement.clientWidth, viewportAvailableHeight: document.documentElement.clientHeight, visualViewportWidth: window.visualViewport?.width, visualViewportHeight: window.visualViewport?.height, visualViewportOffsetLeft: window.visualViewport?.offsetLeft, visualViewportOffsetTop: window.visualViewport?.offsetTop, visualViewportPageLeft: window.visualViewport?.pageLeft, visualViewportPageTop: window.visualViewport?.pageTop, visualViewportScale: window.visualViewport?.scale, safeAreaTop: getSafeAreaInset("top"), safeAreaBottom: getSafeAreaInset("bottom"), safeAreaLeft: getSafeAreaInset("left"), safeAreaRight: getSafeAreaInset("right"), contentWidth: document.documentElement.scrollWidth, contentHeight: document.documentElement.scrollHeight, } } /** * @description Describe merged device information from navigator, UA, and screen. */ export interface DeviceInfo { navigator: DeviceNavigatorInfo userAgent: DeviceUserAgentInfo screen: DeviceScreenInfo } /** * @description Get merged device information from navigator, user-agent, and screen. */ export const getDeviceInfo = (userAgent: string): DeviceInfo => { const navigatorInfo = getDeviceNavigatorInfo() const userAgentInfo = parseUserAgent(userAgent) const screenInfo = getDeviceScreenInfo() return { navigator: navigatorInfo, userAgent: userAgentInfo, screen: screenInfo, } }