| @@ -61,19 +61,36 @@ export function formatClientIpDisplay(ip?: string | null): string { | |||||
| return ip.trim(); | return ip.trim(); | ||||
| } | } | ||||
| /** CSS px; tablets in portrait are often 600–800; phones are usually smaller. */ | |||||
| const TABLET_MIN_SHORT_SIDE_PX = 480; | |||||
| function hasTouchCapability(): boolean { | |||||
| if (typeof navigator === "undefined") return false; | |||||
| const touchPoints = navigator.maxTouchPoints ?? 0; | |||||
| return touchPoints > 0 || "ontouchstart" in window; | |||||
| } | |||||
| export function detectClientTypeFromUa(): string { | export function detectClientTypeFromUa(): string { | ||||
| if (typeof navigator === "undefined") return "unknown"; | if (typeof navigator === "undefined") return "unknown"; | ||||
| const ua = navigator.userAgent.toLowerCase(); | const ua = navigator.userAgent.toLowerCase(); | ||||
| const touchPoints = navigator.maxTouchPoints ?? 0; | const touchPoints = navigator.maxTouchPoints ?? 0; | ||||
| const hasTouch = hasTouchCapability(); | |||||
| const shortSide = screenShortSidePx(); | const shortSide = screenShortSidePx(); | ||||
| // iPadOS 13+ "desktop" Safari: MacIntel + multi-touch, no "iPad" in UA string | |||||
| const isIpad = | |||||
| ua.includes("ipad") || | |||||
| (navigator.platform === "MacIntel" && touchPoints > 1); | |||||
| if (ua.includes("iphone") || ua.includes("ipod")) { | |||||
| return "mobile"; | |||||
| } | |||||
| if (ua.includes("ipad") || ua.includes("tablet")) { | |||||
| return "tablet"; | |||||
| } | |||||
| if (isIpad || ua.includes("tablet")) { | |||||
| // iPadOS 13+ "desktop" Safari: MacIntel + multi-touch, no "iPad" in UA string | |||||
| if ( | |||||
| (navigator.platform === "MacIntel" || ua.includes("macintosh")) && | |||||
| touchPoints > 1 | |||||
| ) { | |||||
| return "tablet"; | return "tablet"; | ||||
| } | } | ||||
| @@ -83,24 +100,30 @@ export function detectClientTypeFromUa(): string { | |||||
| const platform = uad.platform?.toLowerCase() ?? ""; | const platform = uad.platform?.toLowerCase() ?? ""; | ||||
| if (platform.includes("android")) { | if (platform.includes("android")) { | ||||
| if (!uad.mobile) return "tablet"; | if (!uad.mobile) return "tablet"; | ||||
| if (touchPoints > 0 && shortSide >= 600) return "tablet"; | |||||
| if (hasTouch && shortSide >= TABLET_MIN_SHORT_SIDE_PX) return "tablet"; | |||||
| return "mobile"; | return "mobile"; | ||||
| } | } | ||||
| if (platform === "ios" && hasTouch) { | |||||
| return "tablet"; | |||||
| } | |||||
| } | } | ||||
| if (ua.includes("android")) { | if (ua.includes("android")) { | ||||
| // Many Android tablets still include "Mobile" in the UA string | |||||
| // Warehouse slates almost always include "Mobile" in UA even when not phones | |||||
| if (!ua.includes("mobile")) return "tablet"; | if (!ua.includes("mobile")) return "tablet"; | ||||
| if (touchPoints > 0 && shortSide >= 600) return "tablet"; | |||||
| return "mobile"; | |||||
| if (hasTouch && shortSide >= TABLET_MIN_SHORT_SIDE_PX) return "tablet"; | |||||
| // Small Android phone | |||||
| if (shortSide > 0 && shortSide < 400) return "mobile"; | |||||
| return "tablet"; | |||||
| } | } | ||||
| if (ua.includes("iphone") || ua.includes("ipod")) { | |||||
| if (ua.includes("mobile")) { | |||||
| return "mobile"; | return "mobile"; | ||||
| } | } | ||||
| if (ua.includes("mobile")) { | |||||
| return "mobile"; | |||||
| // Touch slate / Surface / kiosk with desktop-like UA | |||||
| if (hasTouch && shortSide >= 400) { | |||||
| return "tablet"; | |||||
| } | } | ||||
| return "desktop"; | return "desktop"; | ||||