const DEVICE_ID_KEY = "fpsms_device_id"; const DISPLAY_NAME_KEY = "fpsms_device_display_name"; /** Chromium User-Agent Client Hints (not in lib.dom Navigator yet). */ interface NavigatorUAData { mobile?: boolean; platform?: string; } interface NavigatorWithUAData extends Navigator { readonly userAgentData?: NavigatorUAData; } export function getOrCreateDeviceId(): string { if (typeof window === "undefined") return ""; let id = localStorage.getItem(DEVICE_ID_KEY); if (!id) { id = typeof crypto !== "undefined" && crypto.randomUUID ? crypto.randomUUID() : `dev-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; localStorage.setItem(DEVICE_ID_KEY, id); } return id; } export function getDeviceDisplayName(): string { if (typeof window === "undefined") return ""; return localStorage.getItem(DISPLAY_NAME_KEY)?.trim() ?? ""; } export function setDeviceDisplayName(name: string): void { if (typeof window === "undefined") return; const trimmed = name.trim(); if (trimmed) { localStorage.setItem(DISPLAY_NAME_KEY, trimmed.slice(0, 128)); } else { localStorage.removeItem(DISPLAY_NAME_KEY); } } function screenShortSidePx(): number { if (typeof window === "undefined") return 0; return Math.min(window.screen.width, window.screen.height); } /** * Classify device for presence monitoring. * iPadOS 13+ often sends a desktop Macintosh UA; use touch + platform heuristics. */ export const CLIENT_TYPE_LABEL_ZH: Record = { tablet: "平板", desktop: "電腦", mobile: "手機", unknown: "未知", }; /** Unique suffix from auto-generated device IDs (e.g. dev-1779250265738-4a07c079 → 4a07c079). */ export function extractDeviceUniqueSuffix(deviceId: string): string { const trimmed = deviceId.trim(); if (!trimmed) return "—"; const lastDash = trimmed.lastIndexOf("-"); if (lastDash >= 0 && lastDash < trimmed.length - 1) { return trimmed.slice(lastDash + 1); } return trimmed.length > 8 ? trimmed.slice(-8) : trimmed; } /** Last IPv4 octet or full display IP for monitor labels. */ export function formatDeviceIpShort(ip?: string | null): string | null { if (!ip?.trim()) return null; const display = formatClientIpDisplay(ip); if (display === "—") return null; if (display === "Server") return "Server"; const match = display.match(/(\d{1,3})$/); return match ? match[1] : display; } export type DeviceLabelInput = { deviceId: string; displayName?: string | null; username?: string | null; clientIp?: string | null; clientType?: string | null; }; /** Human-readable device label for monitor tables when no custom display name is set. */ export function formatDeviceDisplayLabel(input: DeviceLabelInput): string { const custom = input.displayName?.trim(); if (custom) return custom; const typeKey = input.clientType?.trim().toLowerCase() ?? "unknown"; const typeLabel = CLIENT_TYPE_LABEL_ZH[typeKey] ?? typeKey; const username = input.username?.trim(); const ipShort = formatDeviceIpShort(input.clientIp); const parts: string[] = [typeLabel]; if (username) parts.push(username); if (ipShort) parts.push(ipShort); if (parts.length > 1) { return parts.join(" · "); } return `${typeLabel} · ${extractDeviceUniqueSuffix(input.deviceId)}`; } /** Display IP; loopback / same-machine traffic shows as "Server". */ export function formatClientIpDisplay(ip?: string | null): string { if (!ip?.trim()) return "—"; const n = ip.trim().toLowerCase(); if (n === "server") return "Server"; if (n === "127.0.0.1" || n === "localhost" || n === "::1" || n === "0:0:0:0:0:0:0:1") { return "Server"; } if (n.startsWith("127.")) return "Server"; if (n.includes(":") && n.replace(/:/g, "").replace(/0/g, "") === "") return "Server"; 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 { if (typeof navigator === "undefined") return "unknown"; const ua = navigator.userAgent.toLowerCase(); const touchPoints = navigator.maxTouchPoints ?? 0; const hasTouch = hasTouchCapability(); const shortSide = screenShortSidePx(); if (ua.includes("iphone") || ua.includes("ipod")) { return "mobile"; } if (ua.includes("ipad") || ua.includes("tablet")) { return "tablet"; } // iPadOS 13+ "desktop" Safari: MacIntel + multi-touch, no "iPad" in UA string if ( (navigator.platform === "MacIntel" || ua.includes("macintosh")) && touchPoints > 1 ) { return "tablet"; } // User-Agent Client Hints (Chromium) const uad = (navigator as NavigatorWithUAData).userAgentData; if (uad) { const platform = uad.platform?.toLowerCase() ?? ""; if (platform.includes("android")) { if (!uad.mobile) return "tablet"; if (hasTouch && shortSide >= TABLET_MIN_SHORT_SIDE_PX) return "tablet"; return "mobile"; } if (platform === "ios" && hasTouch) { return "tablet"; } } if (ua.includes("android")) { // Warehouse slates almost always include "Mobile" in UA even when not phones if (!ua.includes("mobile")) return "tablet"; 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("mobile")) { return "mobile"; } // Touch slate / Surface / kiosk with desktop-like UA if (hasTouch && shortSide >= 400) { return "tablet"; } return "desktop"; }