diff --git a/lib/__test__/utils.test.ts b/lib/__test__/utils.test.ts index 5f44096..a4d981d 100644 --- a/lib/__test__/utils.test.ts +++ b/lib/__test__/utils.test.ts @@ -1,6 +1,30 @@ -import { describe, it, expect } from '@jest/globals'; +import { afterEach, describe, it, expect, jest } from '@jest/globals'; -import { generateQuery, getFrame, getMountElement } from '../src/utils'; +import { generateQuery, getFrame, getMountElement, setContext } from '../src/utils'; + +const ORIGINAL_USER_AGENT = navigator.userAgent; + +function getRuntimeContexts(userAgent: string) { + Object.defineProperty(navigator, 'userAgent', { + configurable: true, + value: userAgent, + }); + + const setContextMock = jest.fn(); + setContext({ + setTag: jest.fn(), + setContext: setContextMock, + }); + + return Object.fromEntries(setContextMock.mock.calls); +} + +afterEach(() => { + Object.defineProperty(navigator, 'userAgent', { + configurable: true, + value: ORIGINAL_USER_AGENT, + }); +}); describe('generateQuery', () => { @@ -103,3 +127,157 @@ describe('getMountElement', () => { }); }); + +describe('setContext', () => { + + it('should identify an iPhone WebView runtime', () => { + const userAgent = 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148'; + const contexts = getRuntimeContexts(userAgent); + + expect(contexts.os).toEqual({ + name: 'iOS', + version: '18.0', + UA: userAgent, + }); + expect(contexts.browser).toEqual({ + name: 'iOS WebView', + }); + expect(contexts.device).toEqual(expect.objectContaining({ + device: 'Mobile', + family: 'iPhone', + model: 'iPhone', + })); + }); + + it('should identify an iOS in-app WebView from the v1 user-agent corpus', () => { + const userAgent = 'Mozilla/5.0 (iPhone; CPU iPhone OS 13_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 [FBAN/FBIOS;FBDV/iPhone11,8;FBMD/iPhone;FBSN/iOS;FBSV/13.3.1;FBSS/2;FBID/phone;FBLC/en_US;FBOP/5;FBCR/]'; + const contexts = getRuntimeContexts(userAgent); + + expect(contexts.os).toEqual(expect.objectContaining({ + name: 'iOS', + version: '13.3.1', + })); + expect(contexts.browser).toEqual({ + name: 'iOS WebView', + }); + expect(contexts.device).toEqual(expect.objectContaining({ + device: 'Mobile', + family: 'iPhone', + model: 'iPhone', + })); + }); + + it('should not identify a standalone iOS browser as a WebView', () => { + const userAgent = 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X; zh-CN) AppleWebKit/537.51.1 (KHTML, like Gecko) Mobile/15A5304i UCBrowser/11.5.7.986 Mobile AliApp(TUnionSDK/0.1.15)'; + const contexts = getRuntimeContexts(userAgent); + + expect(contexts.os).toEqual(expect.objectContaining({ + name: 'iOS', + version: '11.0', + })); + expect(contexts.browser).toEqual({ + name: 'Unknown', + version: 'Unknown', + }); + expect(contexts.device).toEqual(expect.objectContaining({ + device: 'Mobile', + family: 'iPhone', + model: 'iPhone', + })); + }); + + it('should identify an iPad WebView runtime', () => { + const userAgent = 'Mozilla/5.0 (iPad; CPU OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148'; + const contexts = getRuntimeContexts(userAgent); + + expect(contexts.os).toEqual(expect.objectContaining({ + name: 'iOS', + version: '18.0', + })); + expect(contexts.browser).toEqual({ + name: 'iOS WebView', + }); + expect(contexts.device).toEqual(expect.objectContaining({ + device: 'Tablet', + family: 'iPad', + model: 'iPad', + })); + }); + + it('should identify an Android WebView runtime', () => { + const userAgent = 'Mozilla/5.0 (Linux; Android 14; Pixel 8 Pro Build/AP2A.240605.024; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/125.0.6422.165 Mobile Safari/537.36'; + const contexts = getRuntimeContexts(userAgent); + + expect(contexts.os).toEqual(expect.objectContaining({ + name: 'Android', + version: '14', + })); + expect(contexts.browser).toEqual({ + name: 'Android WebView', + version: '125.0.6422.165', + }); + expect(contexts.device).toEqual(expect.objectContaining({ + device: 'Mobile', + family: 'Android', + model: 'Android', + })); + }); + + it('should identify the v1 Version/ Chrome WebView pattern', () => { + const userAgent = 'Mozilla/5.0 (Linux; Android 4.4.2; de-de; SAMSUNG GT-I9505/I9505XXUGNG8 Build/KOT49H) AppleWebKit/537.36 (KHTML, like Gecko) Version/1.5 Chrome/28.0.1500.94 Mobile Safari/537.36'; + const contexts = getRuntimeContexts(userAgent); + + expect(contexts.os).toEqual(expect.objectContaining({ + name: 'Android', + version: '4.4.2', + })); + expect(contexts.browser).toEqual({ + name: 'Android WebView', + version: '28.0.1500.94', + }); + expect(contexts.device).toEqual(expect.objectContaining({ + device: 'Mobile', + family: 'Android', + model: 'Android', + })); + }); + + it('should continue to identify iOS Safari', () => { + const userAgent = 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1'; + const contexts = getRuntimeContexts(userAgent); + + expect(contexts.os).toEqual(expect.objectContaining({ + name: 'iOS', + version: '18.0', + })); + expect(contexts.browser).toEqual({ + name: 'Safari', + version: '18.0', + }); + expect(contexts.device).toEqual(expect.objectContaining({ + device: 'Mobile', + family: 'iPhone', + model: 'iPhone', + })); + }); + + it('should continue to identify Chrome on Android', () => { + const userAgent = 'Mozilla/5.0 (Linux; Android 14; Pixel 8 Pro Build/AP2A.240605.024) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.6422.165 Mobile Safari/537.36'; + const contexts = getRuntimeContexts(userAgent); + + expect(contexts.os).toEqual(expect.objectContaining({ + name: 'Android', + version: '14', + })); + expect(contexts.browser).toEqual({ + name: 'Chrome', + version: '125.0.6422.165', + }); + expect(contexts.device).toEqual(expect.objectContaining({ + device: 'Mobile', + family: 'Android', + model: 'Android', + })); + }); + +}); diff --git a/lib/src/constants.ts b/lib/src/constants.ts index dabee0b..50fecca 100644 --- a/lib/src/constants.ts +++ b/lib/src/constants.ts @@ -10,7 +10,12 @@ export const RETRY_DELAY = 1000; export enum SentryContext { ANDROID = 'Android', + ANDROID_WEBVIEW = 'Android WebView', IOS = 'iOS', + IOS_WEBVIEW = 'iOS WebView', + IPAD = 'iPad', + IPHONE = 'iPhone', + IPOD = 'iPod', LINUX = 'Linux', MAC = 'Mac', WINDOWS = 'Windows', diff --git a/lib/src/types.ts b/lib/src/types.ts index 19a1195..a191200 100644 --- a/lib/src/types.ts +++ b/lib/src/types.ts @@ -40,7 +40,12 @@ export interface ScopeTag { export interface BrowserContext { name: string; - version: string; + version?: string; +} + +export interface OperatingSystemContext { + name: string; + version?: string; } export interface DeviceContext { diff --git a/lib/src/utils.ts b/lib/src/utils.ts index 47d1da3..7cdb082 100644 --- a/lib/src/utils.ts +++ b/lib/src/utils.ts @@ -1,6 +1,19 @@ -import { BrowserContext, DeviceContext } from './types'; +import { BrowserContext, DeviceContext, OperatingSystemContext } from './types'; import { SENTRY_TAG, SentryContext } from './constants'; +const ANDROID_WEBVIEW_PATTERNS = [ + /Version\/.+Chrome\/(\d+)\.(\d+)\.(\d+)\.(\d+)/, + /;\s*wv\).+Chrome\/(\d+)\.(\d+)\.(\d+)\.(\d+)/, +]; +const ANDROID_OS_PATTERNS = [ + /Android[ \-/](\d+)(?:\.(\d+)|)(?:[.-]([a-z0-9]+)|)/i, +]; +const IOS_OS_PATTERNS = [ + /(?:CPU[ +]OS|iPhone[ +]OS|CPU[ +]iPhone|CPU IPhone OS)[ +]+(\d+)[_.](\d+)(?:[_.](\d+)|)/, + /\b(?:iOS[ /]|iOS; |iPhone(?:\/| v|[ _]OS[/,]|; | OS : |\d,\d\/|\d,\d; )|iPad\/)(\d{1,2})[_.](\d{1,2})(?:[_.](\d+)|)/, +]; +const IOS_BROWSER_PATTERN = /(?:CriOS|FxiOS|OPiOS|EdgiOS|UCBrowser|Puffin)\//; + export function generateQuery(params) { return Object.entries(params) .filter(([, value]) => value || value === false) @@ -21,18 +34,21 @@ export function getMountElement(element?: Element) { } export function setContext(scope): void { + const userAgent = navigator.userAgent; + scope.setTag('source', SENTRY_TAG); scope.setTag('url', document.URL); scope.setContext('os', { - UA: navigator.userAgent, + ...getMobileOperatingSystem(userAgent), + UA: userAgent, }); scope.setContext('browser', { - ...getBrowser(), + ...getBrowser(userAgent), }); scope.setContext('device', { - ...getDevice(), + ...getDevice(userAgent), screen_width_pixels: screen.width, screen_height_pixels: screen.height, language: navigator.language, @@ -42,9 +58,11 @@ export function setContext(scope): void { }); } -function getBrowser(): BrowserContext { - const userAgent = navigator.userAgent; +function getBrowser(userAgent: string): BrowserContext { let name, version; + const androidWebViewVersion = userAgent.indexOf(SentryContext.ANDROID) !== -1 + ? findVersion(userAgent, ANDROID_WEBVIEW_PATTERNS) + : undefined; if (userAgent.indexOf('Firefox') !== -1) { name = SentryContext.FIREFOX; @@ -52,9 +70,14 @@ function getBrowser(): BrowserContext { } else if (userAgent.indexOf('Edg') !== -1) { name = SentryContext.EDGE; version = userAgent.match(/Edg\/([\d.]+)/)?.[1]; + } else if (androidWebViewVersion) { + name = SentryContext.ANDROID_WEBVIEW; + version = androidWebViewVersion; } else if (userAgent.indexOf('Chrome') !== -1 && userAgent.indexOf('Safari') !== -1) { name = SentryContext.CHROME; version = userAgent.match(/Chrome\/([\d.]+)/)?.[1]; + } else if (isIOSWebView(userAgent)) { + name = SentryContext.IOS_WEBVIEW; } else if (userAgent.indexOf('Safari') !== -1 && userAgent.indexOf('Chrome') === -1) { name = SentryContext.SAFARI; version = userAgent.match(/Version\/([\d.]+)/)?.[1]; @@ -69,44 +92,101 @@ function getBrowser(): BrowserContext { version = SentryContext.UNKNOWN; } - return { name, version }; + const context: BrowserContext = { name }; + if (version) { + context.version = version; + } + + return context; +} + +function getMobileOperatingSystem(userAgent: string): Partial { + if (userAgent.indexOf('Windows Phone') !== -1 || userAgent.indexOf('Windows Mobile') !== -1) { + return {}; + } + + if (userAgent.indexOf(SentryContext.ANDROID) !== -1) { + return buildOperatingSystemContext(SentryContext.ANDROID, findVersion(userAgent, ANDROID_OS_PATTERNS)); + } else if (isIOS(userAgent)) { + return buildOperatingSystemContext(SentryContext.IOS, findVersion(userAgent, IOS_OS_PATTERNS)); + } + + return {}; +} + +function buildOperatingSystemContext(name: string, version?: string): OperatingSystemContext { + const context: OperatingSystemContext = { name }; + if (version) { + context.version = version; + } + + return context; +} + +function findVersion(userAgent: string, patterns: RegExp[]): string | undefined { + for (const pattern of patterns) { + const match = pattern.exec(userAgent); + if (match) { + return match.slice(1).filter(Boolean).join('.'); + } + } } export function delay(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)); } -function getDevice(): DeviceContext { - const userAgent = navigator.userAgent; - +function getDevice(userAgent: string): DeviceContext { let model; if (userAgent.indexOf('Win') !== -1) { model = SentryContext.WINDOWS; - + } else if (isIPhone(userAgent)) { + model = SentryContext.IPHONE; + } else if (isIPad(userAgent)) { + model = SentryContext.IPAD; + } else if (isIPod(userAgent)) { + model = SentryContext.IPOD; + } else if (userAgent.indexOf(SentryContext.ANDROID) !== -1) { + model = SentryContext.ANDROID; } else if (userAgent.indexOf(SentryContext.MAC) !== -1) { model = SentryContext.MAC; } else if (userAgent.indexOf(SentryContext.LINUX) !== -1) { model = SentryContext.LINUX; - } else if (userAgent.indexOf(SentryContext.ANDROID) !== -1) { - model = SentryContext.ANDROID; - } else if ( - userAgent.indexOf('like Mac') !== -1 || - userAgent.indexOf('iPhone') !== -1 || - userAgent.indexOf('iPad') !== -1 - ) { - model = SentryContext.IOS; } else { model = SentryContext.UNKNOWN; } let device; - if (/Mobile|iPhone|iPod|Android/i.test(userAgent)) { - device = 'Mobile'; - } else if (/Tablet|iPad/i.test(userAgent)) { + if (isIPad(userAgent) || /Tablet/i.test(userAgent)) { device = 'Tablet'; + } else if (/Mobile|iPhone|iPod|Android/i.test(userAgent)) { + device = 'Mobile'; } else { device = 'Desktop'; } return { model, family: model, device }; } + +function isIOSWebView(userAgent: string): boolean { + return isIOS(userAgent) && + userAgent.indexOf('AppleWebKit') !== -1 && + userAgent.indexOf('Safari') === -1 && + !IOS_BROWSER_PATTERN.test(userAgent); +} + +function isIOS(userAgent: string): boolean { + return isIPhone(userAgent) || isIPad(userAgent) || isIPod(userAgent); +} + +function isIPhone(userAgent: string): boolean { + return /iPhone(?:;|\/)/.test(userAgent); +} + +function isIPad(userAgent: string): boolean { + return /iPad(?:;|\/)/.test(userAgent); +} + +function isIPod(userAgent: string): boolean { + return /iPod(?: touch)?(?:;|\/)/.test(userAgent); +} diff --git a/package.json b/package.json index 61377b6..1e32fc3 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@hcaptcha/loader", "description": "This is a JavaScript library to easily configure the loading of the hCaptcha JS client SDK with built-in error handling.", - "version": "2.4.1", + "version": "2.4.2", "author": "hCaptcha team and contributors", "license": "MIT", "keywords": [