|
| 1 | +"use client"; |
| 2 | + |
| 3 | +import { useEffect, useState } from "react"; |
| 4 | +import { useTranslations } from "next-intl"; |
| 5 | + |
| 6 | +interface DeviceInfo { |
| 7 | + userAgent: string; |
| 8 | + browser: string; |
| 9 | + os: string; |
| 10 | + platform: string; |
| 11 | + language: string; |
| 12 | + languages: string[]; |
| 13 | + timezone: string; |
| 14 | + cookiesEnabled: boolean; |
| 15 | + online: boolean; |
| 16 | + doNotTrack: string; |
| 17 | + screenWidth: number; |
| 18 | + screenHeight: number; |
| 19 | + screenAvailWidth: number; |
| 20 | + screenAvailHeight: number; |
| 21 | + colorDepth: number; |
| 22 | + pixelDepth: number; |
| 23 | + devicePixelRatio: number; |
| 24 | + windowWidth: number; |
| 25 | + windowHeight: number; |
| 26 | + hardwareConcurrency: number; |
| 27 | + deviceMemory: number | null; |
| 28 | + maxTouchPoints: number; |
| 29 | + webgl: string; |
| 30 | + storageQuota: number | null; |
| 31 | + storageUsage: number | null; |
| 32 | + connection: { effectiveType: string; downlink: number; rtt: number } | null; |
| 33 | + prefersDarkMode: boolean; |
| 34 | + prefersReducedMotion: boolean; |
| 35 | + pdfViewer: boolean; |
| 36 | + webdriver: boolean; |
| 37 | +} |
| 38 | + |
| 39 | +declare global { |
| 40 | + interface Navigator { |
| 41 | + deviceMemory?: number; |
| 42 | + connection?: { effectiveType: string; downlink: number; rtt: number }; |
| 43 | + pdfViewerEnabled?: boolean; |
| 44 | + } |
| 45 | +} |
| 46 | + |
| 47 | +function detectBrowser(ua: string): string { |
| 48 | + if (/Edg\//.test(ua)) return "Microsoft Edge"; |
| 49 | + if (/OPR\//.test(ua) || /Opera/.test(ua)) return "Opera"; |
| 50 | + if (/Firefox/.test(ua)) return "Firefox"; |
| 51 | + if (/Chrome/.test(ua)) return "Chrome"; |
| 52 | + if (/Safari/.test(ua) && !/Chrome/.test(ua)) return "Safari"; |
| 53 | + if (/MSIE|Trident/.test(ua)) return "Internet Explorer"; |
| 54 | + return "Unknown"; |
| 55 | +} |
| 56 | + |
| 57 | +function detectOS(ua: string): string { |
| 58 | + if (/Windows NT 10\.0/.test(ua)) return "Windows 10/11"; |
| 59 | + if (/Windows NT 6\.3/.test(ua)) return "Windows 8.1"; |
| 60 | + if (/Windows NT 6\.1/.test(ua)) return "Windows 7"; |
| 61 | + if (/Windows/.test(ua)) return "Windows"; |
| 62 | + if (/Mac OS X (\d+[._]\d+)/.test(ua)) { |
| 63 | + const m = /Mac OS X (\d+[._]\d+(?:[._]\d+)?)/.exec(ua); |
| 64 | + return `macOS ${m ? m[1]!.replace(/_/g, ".") : ""}`; |
| 65 | + } |
| 66 | + if (/Mac/.test(ua)) return "macOS"; |
| 67 | + if (/Android (\d+)/.test(ua)) { |
| 68 | + const m = /Android (\d+(?:\.\d+)?)/.exec(ua); |
| 69 | + return `Android ${m ? m[1] : ""}`; |
| 70 | + } |
| 71 | + if (/iPhone|iPad|iPod/.test(ua)) { |
| 72 | + const m = /OS (\d+[._]\d+)/.exec(ua); |
| 73 | + return `iOS/iPadOS ${m ? m[1]!.replace(/_/g, ".") : ""}`; |
| 74 | + } |
| 75 | + if (/Linux/.test(ua)) return "Linux"; |
| 76 | + return "Unknown"; |
| 77 | +} |
| 78 | + |
| 79 | +function detectWebGL(): string { |
| 80 | + try { |
| 81 | + const canvas = document.createElement("canvas"); |
| 82 | + const gl = (canvas.getContext("webgl2") ?? canvas.getContext("webgl")) as WebGLRenderingContext | null; |
| 83 | + if (!gl) return "Not supported"; |
| 84 | + const debugInfo = gl.getExtension("WEBGL_debug_renderer_info"); |
| 85 | + if (debugInfo) { |
| 86 | + const renderer = gl.getParameter(debugInfo.UNMASKED_RENDERER_WEBGL); |
| 87 | + return String(renderer); |
| 88 | + } |
| 89 | + return gl.getParameter(gl.RENDERER) as string; |
| 90 | + } catch { |
| 91 | + return "Not available"; |
| 92 | + } |
| 93 | +} |
| 94 | + |
| 95 | +export default function DeviceInfoChecker() { |
| 96 | + const t = useTranslations("tools.device-info-checker"); |
| 97 | + const [info, setInfo] = useState<DeviceInfo | null>(null); |
| 98 | + const [copied, setCopied] = useState(false); |
| 99 | + |
| 100 | + useEffect(() => { |
| 101 | + async function gather() { |
| 102 | + const ua = navigator.userAgent; |
| 103 | + let storageQuota: number | null = null; |
| 104 | + let storageUsage: number | null = null; |
| 105 | + try { |
| 106 | + if (navigator.storage?.estimate) { |
| 107 | + const est = await navigator.storage.estimate(); |
| 108 | + storageQuota = est.quota ?? null; |
| 109 | + storageUsage = est.usage ?? null; |
| 110 | + } |
| 111 | + } catch { |
| 112 | + // ignore |
| 113 | + } |
| 114 | + setInfo({ |
| 115 | + userAgent: ua, |
| 116 | + browser: detectBrowser(ua), |
| 117 | + os: detectOS(ua), |
| 118 | + platform: navigator.platform, |
| 119 | + language: navigator.language, |
| 120 | + languages: Array.from(navigator.languages), |
| 121 | + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, |
| 122 | + cookiesEnabled: navigator.cookieEnabled, |
| 123 | + online: navigator.onLine, |
| 124 | + doNotTrack: navigator.doNotTrack ?? "(unset)", |
| 125 | + screenWidth: window.screen.width, |
| 126 | + screenHeight: window.screen.height, |
| 127 | + screenAvailWidth: window.screen.availWidth, |
| 128 | + screenAvailHeight: window.screen.availHeight, |
| 129 | + colorDepth: window.screen.colorDepth, |
| 130 | + pixelDepth: window.screen.pixelDepth, |
| 131 | + devicePixelRatio: window.devicePixelRatio, |
| 132 | + windowWidth: window.innerWidth, |
| 133 | + windowHeight: window.innerHeight, |
| 134 | + hardwareConcurrency: navigator.hardwareConcurrency ?? 0, |
| 135 | + deviceMemory: navigator.deviceMemory ?? null, |
| 136 | + maxTouchPoints: navigator.maxTouchPoints ?? 0, |
| 137 | + webgl: detectWebGL(), |
| 138 | + storageQuota, |
| 139 | + storageUsage, |
| 140 | + connection: navigator.connection ? { effectiveType: navigator.connection.effectiveType, downlink: navigator.connection.downlink, rtt: navigator.connection.rtt } : null, |
| 141 | + prefersDarkMode: window.matchMedia("(prefers-color-scheme: dark)").matches, |
| 142 | + prefersReducedMotion: window.matchMedia("(prefers-reduced-motion: reduce)").matches, |
| 143 | + pdfViewer: navigator.pdfViewerEnabled ?? false, |
| 144 | + webdriver: navigator.webdriver ?? false, |
| 145 | + }); |
| 146 | + } |
| 147 | + gather(); |
| 148 | + const onResize = () => gather(); |
| 149 | + window.addEventListener("resize", onResize); |
| 150 | + return () => window.removeEventListener("resize", onResize); |
| 151 | + }, []); |
| 152 | + |
| 153 | + function fmtBytes(n: number | null): string { |
| 154 | + if (n == null) return t("notAvailable"); |
| 155 | + const units = ["B", "KB", "MB", "GB", "TB"]; |
| 156 | + let i = 0; |
| 157 | + let v = n; |
| 158 | + while (v >= 1024 && i < units.length - 1) { |
| 159 | + v /= 1024; |
| 160 | + i++; |
| 161 | + } |
| 162 | + return `${v.toFixed(2)} ${units[i]}`; |
| 163 | + } |
| 164 | + |
| 165 | + async function copyAll() { |
| 166 | + if (!info) return; |
| 167 | + const text = JSON.stringify(info, null, 2); |
| 168 | + await navigator.clipboard.writeText(text); |
| 169 | + setCopied(true); |
| 170 | + setTimeout(() => setCopied(false), 1500); |
| 171 | + } |
| 172 | + |
| 173 | + if (!info) { |
| 174 | + return <div className="text-sm text-slate-500">{t("loading")}</div>; |
| 175 | + } |
| 176 | + |
| 177 | + const Row = ({ label, value }: { label: string; value: React.ReactNode }) => ( |
| 178 | + <div className="flex items-start justify-between gap-3 border-b border-slate-200 py-2 dark:border-slate-800"> |
| 179 | + <dt className="flex-shrink-0 text-sm text-slate-600 dark:text-slate-400">{label}</dt> |
| 180 | + <dd className="text-right font-mono text-sm tabular-nums">{value}</dd> |
| 181 | + </div> |
| 182 | + ); |
| 183 | + |
| 184 | + return ( |
| 185 | + <div> |
| 186 | + <div className="mb-3 flex justify-end"> |
| 187 | + <button onClick={copyAll} className="rounded bg-brand-600 px-3 py-1.5 text-sm font-medium text-white"> |
| 188 | + {copied ? t("copied") : t("copyJson")} |
| 189 | + </button> |
| 190 | + </div> |
| 191 | + |
| 192 | + <h3 className="mt-2 text-sm font-bold uppercase tracking-wide text-slate-500">{t("section.browser")}</h3> |
| 193 | + <dl> |
| 194 | + <Row label={t("field.browser")} value={info.browser} /> |
| 195 | + <Row label={t("field.os")} value={info.os} /> |
| 196 | + <Row label={t("field.platform")} value={info.platform} /> |
| 197 | + <Row label={t("field.language")} value={info.language} /> |
| 198 | + <Row label={t("field.languages")} value={info.languages.join(", ")} /> |
| 199 | + <Row label={t("field.timezone")} value={info.timezone} /> |
| 200 | + <Row label={t("field.cookiesEnabled")} value={info.cookiesEnabled ? "✓" : "✗"} /> |
| 201 | + <Row label={t("field.online")} value={info.online ? "✓" : "✗"} /> |
| 202 | + <Row label={t("field.doNotTrack")} value={info.doNotTrack} /> |
| 203 | + <Row label={t("field.webdriver")} value={info.webdriver ? t("automation") : t("real")} /> |
| 204 | + </dl> |
| 205 | + |
| 206 | + <h3 className="mt-6 text-sm font-bold uppercase tracking-wide text-slate-500">{t("section.display")}</h3> |
| 207 | + <dl> |
| 208 | + <Row label={t("field.screenSize")} value={`${info.screenWidth} × ${info.screenHeight}`} /> |
| 209 | + <Row label={t("field.availSize")} value={`${info.screenAvailWidth} × ${info.screenAvailHeight}`} /> |
| 210 | + <Row label={t("field.windowSize")} value={`${info.windowWidth} × ${info.windowHeight}`} /> |
| 211 | + <Row label={t("field.colorDepth")} value={`${info.colorDepth} bit`} /> |
| 212 | + <Row label={t("field.pixelRatio")} value={info.devicePixelRatio} /> |
| 213 | + <Row label={t("field.darkMode")} value={info.prefersDarkMode ? "✓" : "✗"} /> |
| 214 | + <Row label={t("field.reducedMotion")} value={info.prefersReducedMotion ? "✓" : "✗"} /> |
| 215 | + </dl> |
| 216 | + |
| 217 | + <h3 className="mt-6 text-sm font-bold uppercase tracking-wide text-slate-500">{t("section.hardware")}</h3> |
| 218 | + <dl> |
| 219 | + <Row label={t("field.cpuCores")} value={info.hardwareConcurrency} /> |
| 220 | + <Row label={t("field.deviceMemory")} value={info.deviceMemory != null ? `${info.deviceMemory} GB` : t("notAvailable")} /> |
| 221 | + <Row label={t("field.touchPoints")} value={info.maxTouchPoints} /> |
| 222 | + <Row label={t("field.gpu")} value={info.webgl} /> |
| 223 | + <Row label={t("field.storageQuota")} value={fmtBytes(info.storageQuota)} /> |
| 224 | + <Row label={t("field.storageUsage")} value={fmtBytes(info.storageUsage)} /> |
| 225 | + </dl> |
| 226 | + |
| 227 | + {info.connection && ( |
| 228 | + <> |
| 229 | + <h3 className="mt-6 text-sm font-bold uppercase tracking-wide text-slate-500">{t("section.network")}</h3> |
| 230 | + <dl> |
| 231 | + <Row label={t("field.connectionType")} value={info.connection.effectiveType} /> |
| 232 | + <Row label={t("field.downlink")} value={`${info.connection.downlink} Mbps`} /> |
| 233 | + <Row label={t("field.rtt")} value={`${info.connection.rtt} ms`} /> |
| 234 | + </dl> |
| 235 | + </> |
| 236 | + )} |
| 237 | + |
| 238 | + <h3 className="mt-6 text-sm font-bold uppercase tracking-wide text-slate-500">{t("section.userAgent")}</h3> |
| 239 | + <pre className="overflow-auto rounded bg-slate-100 p-3 font-mono text-xs dark:bg-slate-800">{info.userAgent}</pre> |
| 240 | + </div> |
| 241 | + ); |
| 242 | +} |
0 commit comments