FPSMS-frontend
選択できるのは25トピックまでです。 トピックは、先頭が英数字で、英数字とダッシュ('-')を使用した35文字以内のものにしてください。
 
 

189 行
5.7 KiB

  1. const DEVICE_ID_KEY = "fpsms_device_id";
  2. const DISPLAY_NAME_KEY = "fpsms_device_display_name";
  3. /** Chromium User-Agent Client Hints (not in lib.dom Navigator yet). */
  4. interface NavigatorUAData {
  5. mobile?: boolean;
  6. platform?: string;
  7. }
  8. interface NavigatorWithUAData extends Navigator {
  9. readonly userAgentData?: NavigatorUAData;
  10. }
  11. export function getOrCreateDeviceId(): string {
  12. if (typeof window === "undefined") return "";
  13. let id = localStorage.getItem(DEVICE_ID_KEY);
  14. if (!id) {
  15. id =
  16. typeof crypto !== "undefined" && crypto.randomUUID
  17. ? crypto.randomUUID()
  18. : `dev-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
  19. localStorage.setItem(DEVICE_ID_KEY, id);
  20. }
  21. return id;
  22. }
  23. export function getDeviceDisplayName(): string {
  24. if (typeof window === "undefined") return "";
  25. return localStorage.getItem(DISPLAY_NAME_KEY)?.trim() ?? "";
  26. }
  27. export function setDeviceDisplayName(name: string): void {
  28. if (typeof window === "undefined") return;
  29. const trimmed = name.trim();
  30. if (trimmed) {
  31. localStorage.setItem(DISPLAY_NAME_KEY, trimmed.slice(0, 128));
  32. } else {
  33. localStorage.removeItem(DISPLAY_NAME_KEY);
  34. }
  35. }
  36. function screenShortSidePx(): number {
  37. if (typeof window === "undefined") return 0;
  38. return Math.min(window.screen.width, window.screen.height);
  39. }
  40. /**
  41. * Classify device for presence monitoring.
  42. * iPadOS 13+ often sends a desktop Macintosh UA; use touch + platform heuristics.
  43. */
  44. export const CLIENT_TYPE_LABEL_ZH: Record<string, string> = {
  45. tablet: "平板",
  46. desktop: "電腦",
  47. mobile: "手機",
  48. unknown: "未知",
  49. };
  50. /** Unique suffix from auto-generated device IDs (e.g. dev-1779250265738-4a07c079 → 4a07c079). */
  51. export function extractDeviceUniqueSuffix(deviceId: string): string {
  52. const trimmed = deviceId.trim();
  53. if (!trimmed) return "—";
  54. const lastDash = trimmed.lastIndexOf("-");
  55. if (lastDash >= 0 && lastDash < trimmed.length - 1) {
  56. return trimmed.slice(lastDash + 1);
  57. }
  58. return trimmed.length > 8 ? trimmed.slice(-8) : trimmed;
  59. }
  60. /** Last IPv4 octet or full display IP for monitor labels. */
  61. export function formatDeviceIpShort(ip?: string | null): string | null {
  62. if (!ip?.trim()) return null;
  63. const display = formatClientIpDisplay(ip);
  64. if (display === "—") return null;
  65. if (display === "Server") return "Server";
  66. const match = display.match(/(\d{1,3})$/);
  67. return match ? match[1] : display;
  68. }
  69. export type DeviceLabelInput = {
  70. deviceId: string;
  71. displayName?: string | null;
  72. username?: string | null;
  73. clientIp?: string | null;
  74. clientType?: string | null;
  75. };
  76. /** Human-readable device label for monitor tables when no custom display name is set. */
  77. export function formatDeviceDisplayLabel(input: DeviceLabelInput): string {
  78. const custom = input.displayName?.trim();
  79. if (custom) return custom;
  80. const typeKey = input.clientType?.trim().toLowerCase() ?? "unknown";
  81. const typeLabel = CLIENT_TYPE_LABEL_ZH[typeKey] ?? typeKey;
  82. const username = input.username?.trim();
  83. const ipShort = formatDeviceIpShort(input.clientIp);
  84. const parts: string[] = [typeLabel];
  85. if (username) parts.push(username);
  86. if (ipShort) parts.push(ipShort);
  87. if (parts.length > 1) {
  88. return parts.join(" · ");
  89. }
  90. return `${typeLabel} · ${extractDeviceUniqueSuffix(input.deviceId)}`;
  91. }
  92. /** Display IP; loopback / same-machine traffic shows as "Server". */
  93. export function formatClientIpDisplay(ip?: string | null): string {
  94. if (!ip?.trim()) return "—";
  95. const n = ip.trim().toLowerCase();
  96. if (n === "server") return "Server";
  97. if (n === "127.0.0.1" || n === "localhost" || n === "::1" || n === "0:0:0:0:0:0:0:1") {
  98. return "Server";
  99. }
  100. if (n.startsWith("127.")) return "Server";
  101. if (n.includes(":") && n.replace(/:/g, "").replace(/0/g, "") === "") return "Server";
  102. return ip.trim();
  103. }
  104. /** CSS px; tablets in portrait are often 600–800; phones are usually smaller. */
  105. const TABLET_MIN_SHORT_SIDE_PX = 480;
  106. function hasTouchCapability(): boolean {
  107. if (typeof navigator === "undefined") return false;
  108. const touchPoints = navigator.maxTouchPoints ?? 0;
  109. return touchPoints > 0 || "ontouchstart" in window;
  110. }
  111. export function detectClientTypeFromUa(): string {
  112. if (typeof navigator === "undefined") return "unknown";
  113. const ua = navigator.userAgent.toLowerCase();
  114. const touchPoints = navigator.maxTouchPoints ?? 0;
  115. const hasTouch = hasTouchCapability();
  116. const shortSide = screenShortSidePx();
  117. if (ua.includes("iphone") || ua.includes("ipod")) {
  118. return "mobile";
  119. }
  120. if (ua.includes("ipad") || ua.includes("tablet")) {
  121. return "tablet";
  122. }
  123. // iPadOS 13+ "desktop" Safari: MacIntel + multi-touch, no "iPad" in UA string
  124. if (
  125. (navigator.platform === "MacIntel" || ua.includes("macintosh")) &&
  126. touchPoints > 1
  127. ) {
  128. return "tablet";
  129. }
  130. // User-Agent Client Hints (Chromium)
  131. const uad = (navigator as NavigatorWithUAData).userAgentData;
  132. if (uad) {
  133. const platform = uad.platform?.toLowerCase() ?? "";
  134. if (platform.includes("android")) {
  135. if (!uad.mobile) return "tablet";
  136. if (hasTouch && shortSide >= TABLET_MIN_SHORT_SIDE_PX) return "tablet";
  137. return "mobile";
  138. }
  139. if (platform === "ios" && hasTouch) {
  140. return "tablet";
  141. }
  142. }
  143. if (ua.includes("android")) {
  144. // Warehouse slates almost always include "Mobile" in UA even when not phones
  145. if (!ua.includes("mobile")) return "tablet";
  146. if (hasTouch && shortSide >= TABLET_MIN_SHORT_SIDE_PX) return "tablet";
  147. // Small Android phone
  148. if (shortSide > 0 && shortSide < 400) return "mobile";
  149. return "tablet";
  150. }
  151. if (ua.includes("mobile")) {
  152. return "mobile";
  153. }
  154. // Touch slate / Surface / kiosk with desktop-like UA
  155. if (hasTouch && shortSide >= 400) {
  156. return "tablet";
  157. }
  158. return "desktop";
  159. }