diff --git a/.changeset/platform-user-agent-data.md b/.changeset/platform-user-agent-data.md new file mode 100644 index 00000000..01b8b4e1 --- /dev/null +++ b/.changeset/platform-user-agent-data.md @@ -0,0 +1,5 @@ +--- +'react-simplikit': patch +--- + +Prefer `navigator.userAgentData` when detecting iOS and Android platforms diff --git a/packages/plugin/skills/react-simplikit/references/isAndroid.md b/packages/plugin/skills/react-simplikit/references/isAndroid.md index a6504105..c076a0ea 100644 --- a/packages/plugin/skills/react-simplikit/references/isAndroid.md +++ b/packages/plugin/skills/react-simplikit/references/isAndroid.md @@ -2,9 +2,13 @@ `isAndroid` is a utility function that detects whether the current device is running Android. +Detection prefers the User-Agent Client Hints API (`navigator.userAgentData.platform`) +and falls back to matching `"Android"` in Client Hints brands or `navigator.userAgent`. + Notes: - All Android browsers include the token "Android" in the user agent. +- Chromium on Android typically exposes Client Hints `platform` as `"Android"`. ## Interface diff --git a/packages/plugin/skills/react-simplikit/references/isIOS.md b/packages/plugin/skills/react-simplikit/references/isIOS.md index 71bc4d7f..12186ced 100644 --- a/packages/plugin/skills/react-simplikit/references/isIOS.md +++ b/packages/plugin/skills/react-simplikit/references/isIOS.md @@ -2,12 +2,16 @@ `isIOS` is a utility function that detects whether the current device is running iOS or iPadOS. +Detection prefers the User-Agent Client Hints API (`navigator.userAgentData.platform`) +and falls back to the legacy `navigator.platform` / `navigator.userAgent` strings. + Notes on platform inconsistencies: - Prior to iPadOS 13, iPads reported their platform as "iPad" (or matched /iPad/ in UA). - Starting from iPadOS 13, Apple changed the platform string to "MacIntel" to make websites treat iPadOS as desktop-class Safari. However, these devices still expose multi-touch capabilities. +- Chromium-based browsers may report Client Hints `platform` as `"iOS"` instead of `"iPhone"`. ## Interface @@ -20,7 +24,7 @@ function isIOS(userAgent?: string): boolean; ### Return Value diff --git a/packages/react-simplikit/src/_internal/platform/testPlatform.test.ts b/packages/react-simplikit/src/_internal/platform/testPlatform.test.ts new file mode 100644 index 00000000..d94042fc --- /dev/null +++ b/packages/react-simplikit/src/_internal/platform/testPlatform.test.ts @@ -0,0 +1,88 @@ +import { afterEach, describe, expect, it } from 'vitest'; + +import { serverEnvironments } from '../test-utils/serverEnvironments.ts'; + +import { testPlatform } from './testPlatform.ts'; +import type { NavigatorUAData } from './types.ts'; + +describe('testPlatform', () => { + const originalPlatform = navigator.platform; + const originalDescriptor = Object.getOwnPropertyDescriptor(Navigator.prototype, 'userAgentData'); + + afterEach(function restoreNavigator() { + Object.defineProperty(navigator, 'platform', { + value: originalPlatform, + configurable: true, + }); + + if (originalDescriptor == null) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- cleanup test stub + delete (navigator as any).userAgentData; + return; + } + + Object.defineProperty(Navigator.prototype, 'userAgentData', originalDescriptor); + }); + + function stubUserAgentData(userAgentData: NavigatorUAData | undefined) { + Object.defineProperty(navigator, 'userAgentData', { + value: userAgentData, + configurable: true, + }); + } + + it('prefers userAgentData.platform over navigator.platform', () => { + stubUserAgentData({ + brands: [{ brand: 'Chromium', version: '120' }], + mobile: true, + platform: 'Android', + }); + Object.defineProperty(navigator, 'platform', { + value: 'Linux armv8l', + configurable: true, + }); + + expect(testPlatform(/^Android/i)).toBe(true); + expect(testPlatform(/^Linux/i)).toBe(false); + }); + + it('falls back to navigator.platform when userAgentData is missing', () => { + stubUserAgentData(undefined); + Object.defineProperty(navigator, 'platform', { + value: 'iPhone', + configurable: true, + }); + + expect(testPlatform(/^iPhone/i)).toBe(true); + }); + + it('matches iOS Client Hints platform', () => { + stubUserAgentData({ + brands: [{ brand: 'Chromium', version: '120' }], + mobile: true, + platform: 'iOS', + }); + Object.defineProperty(navigator, 'platform', { + value: 'iPhone', + configurable: true, + }); + + expect(testPlatform(/^(iPhone|iPad|iPod|iOS)/i)).toBe(true); + }); + + it('returns false when the platform does not match', () => { + stubUserAgentData({ + brands: [{ brand: 'Chromium', version: '120' }], + mobile: false, + platform: 'Windows', + }); + + expect(testPlatform(/^Android/i)).toBe(false); + }); + + it('returns false on the server', () => { + const result = serverEnvironments(() => testPlatform(/^Android/i)); + + expect(result).toBe(false); + }); +}); diff --git a/packages/react-simplikit/src/_internal/platform/testPlatform.ts b/packages/react-simplikit/src/_internal/platform/testPlatform.ts new file mode 100644 index 00000000..a8419129 --- /dev/null +++ b/packages/react-simplikit/src/_internal/platform/testPlatform.ts @@ -0,0 +1,16 @@ +import type { NavigatorWithUserAgentData } from './types.ts'; + +/** + * Tests a regular expression against `navigator.userAgentData.platform` when + * available, otherwise against the legacy `navigator.platform` string. + */ +export function testPlatform(re: RegExp): boolean { + if (typeof window === 'undefined') { + return false; + } + + const navigatorWithHints = window.navigator as NavigatorWithUserAgentData; + const platform = navigatorWithHints.userAgentData?.platform ?? window.navigator.platform; + + return re.test(platform); +} diff --git a/packages/react-simplikit/src/_internal/platform/testUserAgent.test.ts b/packages/react-simplikit/src/_internal/platform/testUserAgent.test.ts new file mode 100644 index 00000000..afd5b1ee --- /dev/null +++ b/packages/react-simplikit/src/_internal/platform/testUserAgent.test.ts @@ -0,0 +1,94 @@ +import { afterEach, describe, expect, it } from 'vitest'; + +import { serverEnvironments } from '../test-utils/serverEnvironments.ts'; + +import { testUserAgent } from './testUserAgent.ts'; +import type { NavigatorUAData } from './types.ts'; + +describe('testUserAgent', () => { + const originalUserAgent = navigator.userAgent; + const originalDescriptor = Object.getOwnPropertyDescriptor(Navigator.prototype, 'userAgentData'); + + afterEach(function restoreNavigator() { + Object.defineProperty(navigator, 'userAgent', { + value: originalUserAgent, + configurable: true, + }); + + if (originalDescriptor == null) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- cleanup test stub + delete (navigator as any).userAgentData; + return; + } + + Object.defineProperty(Navigator.prototype, 'userAgentData', originalDescriptor); + }); + + function stubUserAgentData(userAgentData: NavigatorUAData | undefined) { + Object.defineProperty(navigator, 'userAgentData', { + value: userAgentData, + configurable: true, + }); + } + + it('returns true when a Client Hints brand matches', () => { + stubUserAgentData({ + brands: [ + { brand: 'Chromium', version: '120' }, + { brand: 'Google Chrome', version: '120' }, + ], + mobile: false, + platform: 'Windows', + }); + Object.defineProperty(navigator, 'userAgent', { + value: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)', + configurable: true, + }); + + expect(testUserAgent(/Chrome/i)).toBe(true); + }); + + it('falls back to navigator.userAgent when brands do not match', () => { + stubUserAgentData({ + brands: [{ brand: 'Chromium', version: '120' }], + mobile: true, + platform: 'Android', + }); + Object.defineProperty(navigator, 'userAgent', { + value: 'Mozilla/5.0 (Linux; Android 14; Pixel 8) Chrome/126', + configurable: true, + }); + + expect(testUserAgent(/Android/i)).toBe(true); + }); + + it('falls back to navigator.userAgent when userAgentData is missing', () => { + stubUserAgentData(undefined); + Object.defineProperty(navigator, 'userAgent', { + value: 'Mozilla/5.0 (Linux; Android 14; Pixel 8) Chrome/126', + configurable: true, + }); + + expect(testUserAgent(/Android/i)).toBe(true); + }); + + it('returns false when neither brands nor userAgent match', () => { + stubUserAgentData({ + brands: [{ brand: 'Chromium', version: '120' }], + mobile: false, + platform: 'Windows', + }); + Object.defineProperty(navigator, 'userAgent', { + value: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)', + configurable: true, + }); + + expect(testUserAgent(/Android/i)).toBe(false); + }); + + it('returns false on the server', () => { + const result = serverEnvironments(() => testUserAgent(/Android/i)); + + expect(result).toBe(false); + }); +}); diff --git a/packages/react-simplikit/src/_internal/platform/testUserAgent.ts b/packages/react-simplikit/src/_internal/platform/testUserAgent.ts new file mode 100644 index 00000000..40e20e5b --- /dev/null +++ b/packages/react-simplikit/src/_internal/platform/testUserAgent.ts @@ -0,0 +1,23 @@ +import type { NavigatorWithUserAgentData } from './types.ts'; + +/** + * Tests a regular expression against Client Hints brand list when available, + * otherwise against `navigator.userAgent`. + * + * Brand matching is useful for browser engines (Chrome, Firefox, …). + * Platform tokens such as "Android" usually appear only in the UA string. + */ +export function testUserAgent(re: RegExp): boolean { + if (typeof window === 'undefined') { + return false; + } + + const navigatorWithHints = window.navigator as NavigatorWithUserAgentData; + const brands = navigatorWithHints.userAgentData?.brands; + + if (Array.isArray(brands) && brands.some(brand => re.test(brand.brand))) { + return true; + } + + return re.test(window.navigator.userAgent); +} diff --git a/packages/react-simplikit/src/_internal/platform/types.ts b/packages/react-simplikit/src/_internal/platform/types.ts new file mode 100644 index 00000000..6500bcfd --- /dev/null +++ b/packages/react-simplikit/src/_internal/platform/types.ts @@ -0,0 +1,19 @@ +/** + * Minimal typings for the User-Agent Client Hints API. + * TypeScript's bundled DOM lib does not include these yet in this project. + * @see https://developer.mozilla.org/en-US/docs/Web/API/NavigatorUAData + */ +export type NavigatorUABrandVersion = { + brand: string; + version: string; +}; + +export type NavigatorUAData = { + brands: NavigatorUABrandVersion[]; + mobile: boolean; + platform: string; +}; + +export type NavigatorWithUserAgentData = Navigator & { + userAgentData?: NavigatorUAData; +}; diff --git a/packages/react-simplikit/src/utils/isAndroid/es/isAndroid.md b/packages/react-simplikit/src/utils/isAndroid/es/isAndroid.md index 250f7cbb..803b1cb0 100644 --- a/packages/react-simplikit/src/utils/isAndroid/es/isAndroid.md +++ b/packages/react-simplikit/src/utils/isAndroid/es/isAndroid.md @@ -2,9 +2,12 @@ `isAndroid` es una función de utilidad que detecta si el dispositivo actual usa Android. +Prioriza la API User-Agent Client Hints (`navigator.userAgentData.platform`) y, si no está disponible, busca `"Android"` en las marcas de Client Hints o en `navigator.userAgent`. + Notas: - Todos los navegadores de Android incluyen el token “Android” en el agente de usuario. +- Chromium en Android suele exponer el `platform` de Client Hints como `"Android"`. ## Interfaz @@ -17,7 +20,7 @@ function isAndroid(userAgent?: string): boolean; ### Valor de retorno @@ -25,7 +28,7 @@ function isAndroid(userAgent?: string): boolean; ## Ejemplo diff --git a/packages/react-simplikit/src/utils/isAndroid/isAndroid.md b/packages/react-simplikit/src/utils/isAndroid/isAndroid.md index a6504105..c076a0ea 100644 --- a/packages/react-simplikit/src/utils/isAndroid/isAndroid.md +++ b/packages/react-simplikit/src/utils/isAndroid/isAndroid.md @@ -2,9 +2,13 @@ `isAndroid` is a utility function that detects whether the current device is running Android. +Detection prefers the User-Agent Client Hints API (`navigator.userAgentData.platform`) +and falls back to matching `"Android"` in Client Hints brands or `navigator.userAgent`. + Notes: - All Android browsers include the token "Android" in the user agent. +- Chromium on Android typically exposes Client Hints `platform` as `"Android"`. ## Interface diff --git a/packages/react-simplikit/src/utils/isAndroid/isAndroid.test.ts b/packages/react-simplikit/src/utils/isAndroid/isAndroid.test.ts index bf1c2364..b73cbd5d 100644 --- a/packages/react-simplikit/src/utils/isAndroid/isAndroid.test.ts +++ b/packages/react-simplikit/src/utils/isAndroid/isAndroid.test.ts @@ -1,8 +1,52 @@ -import { describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import type { NavigatorUAData } from '../../_internal/platform/types.ts'; import { isAndroid } from './isAndroid.ts'; describe('isAndroid', () => { + const originalUserAgent = navigator.userAgent; + const originalPlatform = navigator.platform; + const originalUserAgentDataDescriptor = Object.getOwnPropertyDescriptor(Navigator.prototype, 'userAgentData'); + + function stubUserAgentData(userAgentData: NavigatorUAData | undefined) { + Object.defineProperty(navigator, 'userAgentData', { + value: userAgentData, + configurable: true, + }); + } + + beforeEach(() => { + Object.defineProperty(navigator, 'userAgent', { + value: originalUserAgent, + configurable: true, + }); + Object.defineProperty(navigator, 'platform', { + value: originalPlatform, + configurable: true, + }); + stubUserAgentData(undefined); + }); + + afterEach(() => { + Object.defineProperty(navigator, 'userAgent', { + value: originalUserAgent, + configurable: true, + }); + Object.defineProperty(navigator, 'platform', { + value: originalPlatform, + configurable: true, + }); + + if (originalUserAgentDataDescriptor == null) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- cleanup test stub + delete (navigator as any).userAgentData; + return; + } + + Object.defineProperty(Navigator.prototype, 'userAgentData', originalUserAgentDataDescriptor); + }); + it('should detect Android (Pixel)', () => { expect(isAndroid('Mozilla/5.0 (Linux; Android 12; Pixel 6) Chrome/120')).toBe(true); }); @@ -24,17 +68,43 @@ describe('isAndroid', () => { }); it('should fall back to navigator.userAgent when no argument is given', () => { - const originalUserAgent = navigator.userAgent; Object.defineProperty(navigator, 'userAgent', { value: 'Mozilla/5.0 (Linux; Android 14; Pixel 8) Chrome/126', configurable: true, }); expect(isAndroid()).toBe(true); + }); + it('should detect Android from userAgentData.platform', () => { + stubUserAgentData({ + brands: [{ brand: 'Chromium', version: '120' }], + mobile: true, + platform: 'Android', + }); + Object.defineProperty(navigator, 'platform', { + value: 'Linux armv8l', + configurable: true, + }); Object.defineProperty(navigator, 'userAgent', { - value: originalUserAgent, + value: 'Mozilla/5.0 (X11; Linux x86_64) Chrome/120', configurable: true, }); + + expect(isAndroid()).toBe(true); + }); + + it('should return false when Client Hints and userAgent are not Android', () => { + stubUserAgentData({ + brands: [{ brand: 'Chromium', version: '120' }], + mobile: false, + platform: 'Windows', + }); + Object.defineProperty(navigator, 'userAgent', { + value: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120', + configurable: true, + }); + + expect(isAndroid()).toBe(false); }); }); diff --git a/packages/react-simplikit/src/utils/isAndroid/isAndroid.ts b/packages/react-simplikit/src/utils/isAndroid/isAndroid.ts index 3ac5e64c..51690d5b 100644 --- a/packages/react-simplikit/src/utils/isAndroid/isAndroid.ts +++ b/packages/react-simplikit/src/utils/isAndroid/isAndroid.ts @@ -1,11 +1,17 @@ +import { testPlatform } from '../../_internal/platform/testPlatform.ts'; +import { testUserAgent } from '../../_internal/platform/testUserAgent.ts'; import { isServer } from '../isServer/index.ts'; /** * @description * `isAndroid` is a utility function that detects whether the current device is running Android. * + * Detection prefers the User-Agent Client Hints API (`navigator.userAgentData.platform`) + * and falls back to matching `"Android"` in Client Hints brands or `navigator.userAgent`. + * * Notes: * - All Android browsers include the token "Android" in the user agent. + * - Chromium on Android typically exposes Client Hints `platform` as `"Android"`. * * @param {string} [userAgent] - Optional user agent string to check. Defaults to `navigator.userAgent`. * @@ -27,6 +33,9 @@ export function isAndroid(userAgent?: string): boolean { return false; } - const ua = userAgent ?? navigator.userAgent; - return /Android/i.test(ua); + if (userAgent !== undefined) { + return /Android/i.test(userAgent); + } + + return testPlatform(/^Android/i) || testUserAgent(/Android/i); } diff --git a/packages/react-simplikit/src/utils/isAndroid/ja/isAndroid.md b/packages/react-simplikit/src/utils/isAndroid/ja/isAndroid.md index bbd8c962..7a187551 100644 --- a/packages/react-simplikit/src/utils/isAndroid/ja/isAndroid.md +++ b/packages/react-simplikit/src/utils/isAndroid/ja/isAndroid.md @@ -2,9 +2,12 @@ `isAndroid` は、現在のデバイスで Android が動作しているかどうかを判定するユーティリティ関数です。 +User-Agent Client Hints API(`navigator.userAgentData.platform`)を優先し、利用できない場合は Client Hints の brands または `navigator.userAgent` で `"Android"` を照合します。 + 補足: - すべての Android ブラウザーのユーザーエージェントには、トークン「Android」が含まれます。 +- Android 上の Chromium は、通常 Client Hints の `platform` を `"Android"` として公開します。 ## インターフェース @@ -17,7 +20,7 @@ function isAndroid(userAgent?: string): boolean; ### 戻り値 @@ -25,20 +28,20 @@ function isAndroid(userAgent?: string): boolean; -## 使用例 +## 例 ```tsx if (isAndroid()) { - // Android 固有のコード + // Android 固有の処理 enableAndroidOptimizations(); } ``` ```tsx -// カスタムのユーザーエージェントを指定する場合 +// ユーザーエージェントを直接渡す場合 const isAndroidDevice = isAndroid( 'Mozilla/5.0 (Linux; Android 12; Pixel 6) Chrome/120' ); diff --git a/packages/react-simplikit/src/utils/isAndroid/ko/isAndroid.md b/packages/react-simplikit/src/utils/isAndroid/ko/isAndroid.md index 7a8e0820..2dd33ea3 100644 --- a/packages/react-simplikit/src/utils/isAndroid/ko/isAndroid.md +++ b/packages/react-simplikit/src/utils/isAndroid/ko/isAndroid.md @@ -2,9 +2,12 @@ `isAndroid`는 현재 기기가 안드로이드에서 실행 중인지 감지하는 유틸리티 함수예요. +User-Agent Client Hints API(`navigator.userAgentData.platform`)를 우선 사용하고, 없으면 Client Hints brands 또는 `navigator.userAgent`에서 `"Android"`를 매칭해요. + **참고 사항** - 모든 안드로이드 브라우저는 사용자 에이전트에 'Android' 토큰을 포함해요. +- 안드로이드의 Chromium은 Client Hints `platform`을 보통 `"Android"`로 노출해요. ## 인터페이스 diff --git a/packages/react-simplikit/src/utils/isAndroid/zh-Hans/isAndroid.md b/packages/react-simplikit/src/utils/isAndroid/zh-Hans/isAndroid.md index e4ae8040..54711e4f 100644 --- a/packages/react-simplikit/src/utils/isAndroid/zh-Hans/isAndroid.md +++ b/packages/react-simplikit/src/utils/isAndroid/zh-Hans/isAndroid.md @@ -2,9 +2,12 @@ `isAndroid` 是一个检测当前设备是否运行 Android 的工具函数。 +优先使用 User-Agent Client Hints API(`navigator.userAgentData.platform`),不可用时回退到在 Client Hints brands 或 `navigator.userAgent` 中匹配 `"Android"`。 + 注意: - 所有 Android 浏览器的 user agent 中都包含“Android”字符串。 +- Android 上的 Chromium 通常将 Client Hints 的 `platform` 暴露为 `"Android"`。 ## 接口 @@ -17,7 +20,7 @@ function isAndroid(userAgent?: string): boolean; ### 返回值 @@ -25,20 +28,20 @@ function isAndroid(userAgent?: string): boolean; ## 示例 ```tsx if (isAndroid()) { - // Android-specific code + // Android 专用代码 enableAndroidOptimizations(); } ``` ```tsx -// With custom user agent +// 传入自定义 user agent const isAndroidDevice = isAndroid( 'Mozilla/5.0 (Linux; Android 12; Pixel 6) Chrome/120' ); diff --git a/packages/react-simplikit/src/utils/isIOS/es/isIOS.md b/packages/react-simplikit/src/utils/isIOS/es/isIOS.md index 123b765c..7eb6a387 100644 --- a/packages/react-simplikit/src/utils/isIOS/es/isIOS.md +++ b/packages/react-simplikit/src/utils/isIOS/es/isIOS.md @@ -2,12 +2,15 @@ `isIOS` es una función de utilidad que detecta si el dispositivo actual ejecuta iOS o iPadOS. +Prioriza la API User-Agent Client Hints (`navigator.userAgentData.platform`) y, si no está disponible, recurre a las cadenas heredadas `navigator.platform` / `navigator.userAgent`. + Notas sobre las inconsistencias de la plataforma: - Antes de iPadOS 13, los iPad indicaban “iPad” como su plataforma (o coincidían con /iPad/ en el agente de usuario). - A partir de iPadOS 13, Apple cambió la cadena de plataforma a “MacIntel” para que los sitios web trataran iPadOS como Safari de escritorio. Sin embargo, estos dispositivos siguen exponiendo capacidades multitáctiles. +- Los navegadores basados en Chromium pueden informar el `platform` de Client Hints como `"iOS"` en lugar de `"iPhone"`. ## Interfaz @@ -20,7 +23,7 @@ function isIOS(userAgent?: string): boolean; ### Valor de retorno @@ -28,7 +31,7 @@ function isIOS(userAgent?: string): boolean; ## Ejemplo diff --git a/packages/react-simplikit/src/utils/isIOS/isIOS.md b/packages/react-simplikit/src/utils/isIOS/isIOS.md index 71bc4d7f..12186ced 100644 --- a/packages/react-simplikit/src/utils/isIOS/isIOS.md +++ b/packages/react-simplikit/src/utils/isIOS/isIOS.md @@ -2,12 +2,16 @@ `isIOS` is a utility function that detects whether the current device is running iOS or iPadOS. +Detection prefers the User-Agent Client Hints API (`navigator.userAgentData.platform`) +and falls back to the legacy `navigator.platform` / `navigator.userAgent` strings. + Notes on platform inconsistencies: - Prior to iPadOS 13, iPads reported their platform as "iPad" (or matched /iPad/ in UA). - Starting from iPadOS 13, Apple changed the platform string to "MacIntel" to make websites treat iPadOS as desktop-class Safari. However, these devices still expose multi-touch capabilities. +- Chromium-based browsers may report Client Hints `platform` as `"iOS"` instead of `"iPhone"`. ## Interface @@ -20,7 +24,7 @@ function isIOS(userAgent?: string): boolean; ### Return Value diff --git a/packages/react-simplikit/src/utils/isIOS/isIOS.test.ts b/packages/react-simplikit/src/utils/isIOS/isIOS.test.ts index 934ee1da..d95d92f2 100644 --- a/packages/react-simplikit/src/utils/isIOS/isIOS.test.ts +++ b/packages/react-simplikit/src/utils/isIOS/isIOS.test.ts @@ -1,10 +1,21 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import type { NavigatorUAData } from '../../_internal/platform/types.ts'; + import { isIOS } from './isIOS.ts'; describe('isIOS', () => { const originalPlatform = navigator.platform; const originalMaxTouchPoints = navigator.maxTouchPoints; + const originalUserAgent = navigator.userAgent; + const originalUserAgentDataDescriptor = Object.getOwnPropertyDescriptor(Navigator.prototype, 'userAgentData'); + + function stubUserAgentData(userAgentData: NavigatorUAData | undefined) { + Object.defineProperty(navigator, 'userAgentData', { + value: userAgentData, + configurable: true, + }); + } beforeEach(() => { // Reset to default values before each test @@ -16,6 +27,11 @@ describe('isIOS', () => { value: originalMaxTouchPoints, configurable: true, }); + Object.defineProperty(navigator, 'userAgent', { + value: originalUserAgent, + configurable: true, + }); + stubUserAgentData(undefined); }); afterEach(() => { @@ -28,6 +44,18 @@ describe('isIOS', () => { value: originalMaxTouchPoints, configurable: true, }); + Object.defineProperty(navigator, 'userAgent', { + value: originalUserAgent, + configurable: true, + }); + + if (originalUserAgentDataDescriptor == null) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- cleanup test stub + delete (navigator as any).userAgentData; + return; + } + + Object.defineProperty(Navigator.prototype, 'userAgentData', originalUserAgentDataDescriptor); }); it('should detect iOS (iPhone)', () => { @@ -82,17 +110,55 @@ describe('isIOS', () => { }); it('should fall back to navigator.userAgent when no argument is given', () => { - const originalUserAgent = navigator.userAgent; Object.defineProperty(navigator, 'userAgent', { value: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)', configurable: true, }); expect(isIOS()).toBe(true); + }); + it('should detect iOS from userAgentData.platform', () => { + stubUserAgentData({ + brands: [{ brand: 'Chromium', version: '120' }], + mobile: true, + platform: 'iOS', + }); + Object.defineProperty(navigator, 'platform', { + value: 'Win32', + configurable: true, + }); Object.defineProperty(navigator, 'userAgent', { - value: originalUserAgent, + value: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120', configurable: true, }); + Object.defineProperty(navigator, 'maxTouchPoints', { + value: 0, + configurable: true, + }); + + expect(isIOS()).toBe(true); + }); + + it('should detect iPadOS from userAgentData macOS platform with touch', () => { + stubUserAgentData({ + brands: [{ brand: 'Chromium', version: '120' }], + mobile: true, + platform: 'macOS', + }); + Object.defineProperty(navigator, 'platform', { + value: 'MacIntel', + configurable: true, + }); + Object.defineProperty(navigator, 'maxTouchPoints', { + value: 5, + configurable: true, + }); + Object.defineProperty(navigator, 'userAgent', { + value: 'Mozilla/5.0 (Macintosh; Intel Mac OS X)', + configurable: true, + }); + + expect(isIOS()).toBe(true); }); }); diff --git a/packages/react-simplikit/src/utils/isIOS/isIOS.ts b/packages/react-simplikit/src/utils/isIOS/isIOS.ts index 7866ec50..8cf90230 100644 --- a/packages/react-simplikit/src/utils/isIOS/isIOS.ts +++ b/packages/react-simplikit/src/utils/isIOS/isIOS.ts @@ -1,16 +1,24 @@ +import { testPlatform } from '../../_internal/platform/testPlatform.ts'; +import { testUserAgent } from '../../_internal/platform/testUserAgent.ts'; import { isServer } from '../isServer/index.ts'; /** * @description * `isIOS` is a utility function that detects whether the current device is running iOS or iPadOS. * + * Detection prefers the User-Agent Client Hints API (`navigator.userAgentData.platform`) + * and falls back to the legacy `navigator.platform` / `navigator.userAgent` strings. + * * Notes on platform inconsistencies: * - Prior to iPadOS 13, iPads reported their platform as "iPad" (or matched /iPad/ in UA). * - Starting from iPadOS 13, Apple changed the platform string to "MacIntel" * to make websites treat iPadOS as desktop-class Safari. * However, these devices still expose multi-touch capabilities. + * - Chromium-based browsers may report Client Hints `platform` as `"iOS"` instead of `"iPhone"`. * * @param {string} [userAgent] - Optional user agent string to check. Defaults to `navigator.userAgent`. + * When provided, the string is matched directly; the modern iPadOS heuristic still uses the live + * platform and touch APIs so desktop-class iPad Safari can be detected. * * @returns {boolean} `true` if the device is running iOS or iPadOS, `false` otherwise. * Returns `false` on server-side rendering environments. @@ -30,12 +38,16 @@ export function isIOS(userAgent?: string): boolean { return false; } - const ua = userAgent ?? navigator.userAgent; - const platform = navigator.platform; - const maxTouchPoints = navigator.maxTouchPoints; + const matchesModernIPad = + testPlatform(/^Mac/i) && typeof navigator.maxTouchPoints === 'number' && navigator.maxTouchPoints > 1; + + if (userAgent !== undefined) { + const matchesClassicIOS = /iPhone|iPad|iPod/i.test(userAgent); + return matchesClassicIOS || matchesModernIPad; + } - const matchesClassicIOS = /iPhone|iPad|iPod/i.test(ua); - const matchesModernIPad = platform === 'MacIntel' && typeof maxTouchPoints === 'number' && maxTouchPoints > 1; + const matchesClientHintsIOS = testPlatform(/^(iPhone|iPad|iPod|iOS)/i); + const matchesClassicIOS = testUserAgent(/iPhone|iPad|iPod/i); - return matchesClassicIOS || matchesModernIPad; + return matchesClientHintsIOS || matchesClassicIOS || matchesModernIPad; } diff --git a/packages/react-simplikit/src/utils/isIOS/ja/isIOS.md b/packages/react-simplikit/src/utils/isIOS/ja/isIOS.md index 67056f5e..f7443f5a 100644 --- a/packages/react-simplikit/src/utils/isIOS/ja/isIOS.md +++ b/packages/react-simplikit/src/utils/isIOS/ja/isIOS.md @@ -2,12 +2,15 @@ `isIOS` は、現在のデバイスで iOS または iPadOS が動作しているかどうかを判定するユーティリティ関数です。 +User-Agent Client Hints API(`navigator.userAgentData.platform`)を優先し、利用できない場合はレガシーな `navigator.platform` / `navigator.userAgent` 文字列にフォールバックします。 + プラットフォーム情報の違いに関する補足: - iPadOS 13 より前の iPad は、プラットフォームを「iPad」として報告していました(または UA が /iPad/ に一致していました)。 - iPadOS 13 以降、Apple はプラットフォーム文字列を「MacIntel」に変更し、 Web サイトが iPadOS をデスクトップ向けの Safari として扱うようにしました。 ただし、これらのデバイスでは引き続きマルチタッチ機能を検出できます。 +- Chromium 系ブラウザーは Client Hints の `platform` を `"iPhone"` ではなく `"iOS"` として報告する場合があります。 ## インターフェース @@ -20,7 +23,7 @@ function isIOS(userAgent?: string): boolean; ### 戻り値 @@ -28,20 +31,20 @@ function isIOS(userAgent?: string): boolean; -## 使用例 +## 例 ```tsx if (isIOS()) { - // iOS 固有のコード + // iOS 固有の処理 enableIOSOptimizations(); } ``` ```tsx -// カスタムのユーザーエージェントを指定する場合 +// ユーザーエージェントを直接渡す場合 const isIOSDevice = isIOS( 'Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X)' ); diff --git a/packages/react-simplikit/src/utils/isIOS/ko/isIOS.md b/packages/react-simplikit/src/utils/isIOS/ko/isIOS.md index c2ed6359..4f30b4e3 100644 --- a/packages/react-simplikit/src/utils/isIOS/ko/isIOS.md +++ b/packages/react-simplikit/src/utils/isIOS/ko/isIOS.md @@ -1,9 +1,14 @@ # isIOS -`isIOS`는 현재 기기가 iOS 또는 iPadOS를 실행 중인지 감지하는 유틸리티 함수에요. 플랫폼에 따라 다음과 같은 차이가 있어요. +`isIOS`는 현재 기기가 iOS 또는 iPadOS를 실행 중인지 감지하는 유틸리티 함수예요. -- iPadOS 13 이전에는 iPads가 플랫폼을 'iPad'로 보고했었어요 (또는 UA에서 /iPad/와 일치함). -- iPadOS 13부터 Apple은 웹 사이트가 iPadOS를 데스크톱급 Safari로 취급하게하기 위해 플랫폼 문자열을 'MacIntel'로 변경했어요. 그러나 이러한 기기는 여전히 멀티터치 기능을 노출해요. +User-Agent Client Hints API(`navigator.userAgentData.platform`)를 우선 사용하고, 없으면 레거시 `navigator.platform` / `navigator.userAgent` 문자열로 판별해요. + +플랫폼에 따라 다음과 같은 차이가 있어요. + +- iPadOS 13 이전에는 iPad가 플랫폼을 'iPad'로 보고했어요 (또는 UA에서 /iPad/와 일치함). +- iPadOS 13부터 Apple은 웹사이트가 iPadOS를 데스크톱급 Safari로 취급하도록 플랫폼 문자열을 'MacIntel'로 변경했어요. 그러나 이러한 기기는 여전히 멀티터치 기능을 노출해요. +- Chromium 계열 브라우저는 Client Hints `platform`을 `"iPhone"` 대신 `"iOS"`로 보고할 수 있어요. ## 인터페이스 @@ -16,7 +21,7 @@ function isIOS(userAgent?: string): boolean; ### 반환 값 diff --git a/packages/react-simplikit/src/utils/isIOS/zh-Hans/isIOS.md b/packages/react-simplikit/src/utils/isIOS/zh-Hans/isIOS.md index 7fcc99b8..b0095a3d 100644 --- a/packages/react-simplikit/src/utils/isIOS/zh-Hans/isIOS.md +++ b/packages/react-simplikit/src/utils/isIOS/zh-Hans/isIOS.md @@ -2,10 +2,13 @@ `isIOS` 是一个检测当前设备是否运行 iOS 或 iPadOS 的工具函数。 +优先使用 User-Agent Client Hints API(`navigator.userAgentData.platform`),不可用时回退到旧版 `navigator.platform` / `navigator.userAgent` 字符串。 + 关于平台不一致性的说明: - 在 iPadOS 13 之前,iPad 申报的平台为“iPad”(或在 UA 中匹配 /iPad/)。 - 从 iPadOS 13 开始,Apple 将平台字符串改为“MacIntel”,让网站将 iPadOS 当作桌面级 Safari 对待。但这些设备仍然支持多点触控。 +- 基于 Chromium 的浏览器可能将 Client Hints 的 `platform` 报告为 `"iOS"` 而不是 `"iPhone"`。 ## 接口 @@ -18,7 +21,7 @@ function isIOS(userAgent?: string): boolean; ### 返回值 @@ -26,20 +29,20 @@ function isIOS(userAgent?: string): boolean; ## 示例 ```tsx if (isIOS()) { - // iOS-specific code + // iOS 专用代码 enableIOSOptimizations(); } ``` ```tsx -// With custom user agent +// 传入自定义 user agent const isIOSDevice = isIOS( 'Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X)' );