|
- 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<string, string> = {
- 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";
- }
|