diff --git a/.changeset/clear-flowers-itch.md b/.changeset/clear-flowers-itch.md new file mode 100644 index 0000000000..9365c7b515 --- /dev/null +++ b/.changeset/clear-flowers-itch.md @@ -0,0 +1,5 @@ +--- +'posthog-react-native': patch +--- + +fix: React Native on web should report hardware type as Desktop or Mobile, not Web diff --git a/.changeset/polite-towns-march.md b/.changeset/polite-towns-march.md new file mode 100644 index 0000000000..be865bc4b8 --- /dev/null +++ b/.changeset/polite-towns-march.md @@ -0,0 +1,5 @@ +--- +'@posthog/core': patch +--- + +chore: move the user-agent-utils from the browser to the core package diff --git a/examples/example-expo-53/README.md b/examples/example-expo-53/README.md index 2947522bc3..707d3618c9 100644 --- a/examples/example-expo-53/README.md +++ b/examples/example-expo-53/README.md @@ -32,6 +32,7 @@ Or... ```bash npx expo run:ios npx expo run:android +npx expo start --web ``` If your RN SDK changes are not picked up: diff --git a/examples/example-expo-53/pnpm-lock.yaml b/examples/example-expo-53/pnpm-lock.yaml index b830d9594b..55c7829505 100644 --- a/examples/example-expo-53/pnpm-lock.yaml +++ b/examples/example-expo-53/pnpm-lock.yaml @@ -7,7 +7,7 @@ settings: overrides: node-forge: 1.3.2 -pnpmfileChecksum: sha256-OcVGlYgAXhwIsFX1WLS9u6NOlScsFrdTUbTxlr/ypOs= +pnpmfileChecksum: sha256-N3riA/tbAnNXqNYCauSgZTrb8uBs1MMWnJDTJCSRm8c= importers: @@ -879,8 +879,8 @@ packages: engines: {node: '>=14'} '@posthog/core@file:../../target/posthog-core.tgz': - resolution: {integrity: sha512-9Qowpawtz7QcjMy8DX4WPrz073+f7qoNJ5piKst/hXYc64+5PVt6ObXe/uVMFip7lKsDN+ilxFAPDtoHQSMfZg==, tarball: file:../../target/posthog-core.tgz} - version: 1.7.0 + resolution: {integrity: sha512-NNk01nHfWGc6D8yJLRM85AxZ0/CgYJmJKKMLqC/zHpDGMKQN1BNKYHgcswey2//Cj1vK9pCspRtuywL6UPE3nA==, tarball: file:../../target/posthog-core.tgz} + version: 1.7.1 '@radix-ui/react-compose-refs@1.1.2': resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==} @@ -3139,8 +3139,8 @@ packages: react-native: '*' posthog-react-native@file:../../target/posthog-react-native.tgz: - resolution: {integrity: sha512-njMKGPiElA/eq3vOJ/zV/6SVWU69qb+tXBJJRr4X4l0RN5bRF4fRGyNZzg73htoPKVA6RCDUgMYba8WefMQRnA==, tarball: file:../../target/posthog-react-native.tgz} - version: 4.14.1 + resolution: {integrity: sha512-kkeSVgJQSEpUbjBj/a3MdGBOPSxfh8/sc9evlY8tDboDNxCydPTv/3dnZw0x9mdsYUIEoRyKZruPxtl0qOAKrQ==, tarball: file:../../target/posthog-react-native.tgz} + version: 4.14.4 peerDependencies: '@react-native-async-storage/async-storage': '>=1.0.0' '@react-navigation/native': '>= 5.0.0' diff --git a/packages/browser/src/__tests__/utils.test.ts b/packages/browser/src/__tests__/utils.test.ts index 1ba2d4cb91..5c9b924f51 100644 --- a/packages/browser/src/__tests__/utils.test.ts +++ b/packages/browser/src/__tests__/utils.test.ts @@ -13,7 +13,7 @@ import { expect } from '@jest/globals' import { _base64Encode } from '../utils/encode-utils' import { getPersonPropertiesHash, propertyComparisons } from '../utils/property-utils' -import { detectDeviceType } from '../utils/user-agent-utils' +import { detectDeviceType } from '@posthog/core' import { getEventProperties } from '../utils/event-utils' function userAgentFor(botString: string) { diff --git a/packages/browser/src/__tests__/utils/user-agent-utils.test.ts b/packages/browser/src/__tests__/utils/user-agent-utils.test.ts index cc2862acdf..e39b92bbc3 100644 --- a/packages/browser/src/__tests__/utils/user-agent-utils.test.ts +++ b/packages/browser/src/__tests__/utils/user-agent-utils.test.ts @@ -1,7 +1,7 @@ import uaParserDeviceTestCases from './device.test.json' import { isUndefined } from '@posthog/core' import uaParserOSTestCases from './os-test.json' -import { detectBrowser, detectBrowserVersion, detectDeviceType, detectOS } from '../../utils/user-agent-utils' +import { detectBrowser, detectBrowserVersion, detectDeviceType, detectOS } from '@posthog/core' describe('user-agent-utils', () => { describe('user agent', () => { diff --git a/packages/browser/src/extensions/surveys/surveys-extension-utils.tsx b/packages/browser/src/extensions/surveys/surveys-extension-utils.tsx index 41d6da8daf..6cb34fc745 100644 --- a/packages/browser/src/extensions/surveys/surveys-extension-utils.tsx +++ b/packages/browser/src/extensions/surveys/surveys-extension-utils.tsx @@ -22,7 +22,7 @@ import { } from '../../utils/survey-utils' import { isArray, isNullish } from '@posthog/core' -import { detectDeviceType } from '../../utils/user-agent-utils' +import { detectDeviceType } from '@posthog/core' import { propertyComparisons } from '../../utils/property-utils' import { PropertyMatchType } from '../../types' import { prepareStylesheet } from '../utils/stylesheet-loader' diff --git a/packages/browser/src/utils/event-utils.ts b/packages/browser/src/utils/event-utils.ts index b7174ac999..dff79f2fd3 100644 --- a/packages/browser/src/utils/event-utils.ts +++ b/packages/browser/src/utils/event-utils.ts @@ -4,7 +4,7 @@ import { Properties } from '../types' import Config from '../config' import { each, extend, extendArray, stripEmptyProperties } from './index' import { document, location, userAgent, window } from './globals' -import { detectBrowser, detectBrowserVersion, detectDevice, detectDeviceType, detectOS } from './user-agent-utils' +import { detectBrowser, detectBrowserVersion, detectDevice, detectDeviceType, detectOS } from '@posthog/core' import { cookieStore } from '../storage' const URL_REGEX_PREFIX = 'https?://(.*)' diff --git a/packages/browser/src/utils/user-agent-utils.ts b/packages/browser/src/utils/user-agent-utils.ts deleted file mode 100644 index 642b17ae15..0000000000 --- a/packages/browser/src/utils/user-agent-utils.ts +++ /dev/null @@ -1,357 +0,0 @@ -import { isFunction, isUndefined, includes } from '@posthog/core' - -/** - * this device detection code is (at time of writing) about 3% of the size of the entire library - * this is mostly because the identifiers user in regexes and results can't be minified away since - * they have meaning - * - * so, there are some pre-uglifying choices in the code to help reduce the size - * e.g. many repeated strings are stored as variables and then old-fashioned concatenated together - * - * TL;DR here be dragons - */ -const FACEBOOK = 'Facebook' -const MOBILE = 'Mobile' -const IOS = 'iOS' -const ANDROID = 'Android' -const TABLET = 'Tablet' -const ANDROID_TABLET = ANDROID + ' ' + TABLET -const IPAD = 'iPad' -const APPLE = 'Apple' -const APPLE_WATCH = APPLE + ' Watch' -const SAFARI = 'Safari' -const BLACKBERRY = 'BlackBerry' -const SAMSUNG = 'Samsung' -const SAMSUNG_BROWSER = SAMSUNG + 'Browser' -const SAMSUNG_INTERNET = SAMSUNG + ' Internet' -const CHROME = 'Chrome' -const CHROME_OS = CHROME + ' OS' -const CHROME_IOS = CHROME + ' ' + IOS -const INTERNET_EXPLORER = 'Internet Explorer' -const INTERNET_EXPLORER_MOBILE = INTERNET_EXPLORER + ' ' + MOBILE -const OPERA = 'Opera' -const OPERA_MINI = OPERA + ' Mini' -const EDGE = 'Edge' -const MICROSOFT_EDGE = 'Microsoft ' + EDGE -const FIREFOX = 'Firefox' -const FIREFOX_IOS = FIREFOX + ' ' + IOS -const NINTENDO = 'Nintendo' -const PLAYSTATION = 'PlayStation' -const XBOX = 'Xbox' -const ANDROID_MOBILE = ANDROID + ' ' + MOBILE -const MOBILE_SAFARI = MOBILE + ' ' + SAFARI -const WINDOWS = 'Windows' -const WINDOWS_PHONE = WINDOWS + ' Phone' -const NOKIA = 'Nokia' -const OUYA = 'Ouya' -const GENERIC = 'Generic' -const GENERIC_MOBILE = GENERIC + ' ' + MOBILE.toLowerCase() -const GENERIC_TABLET = GENERIC + ' ' + TABLET.toLowerCase() -const KONQUEROR = 'Konqueror' - -const BROWSER_VERSION_REGEX_SUFFIX = '(\\d+(\\.\\d+)?)' -const DEFAULT_BROWSER_VERSION_REGEX = new RegExp('Version/' + BROWSER_VERSION_REGEX_SUFFIX) - -const XBOX_REGEX = new RegExp(XBOX, 'i') -const PLAYSTATION_REGEX = new RegExp(PLAYSTATION + ' \\w+', 'i') -const NINTENDO_REGEX = new RegExp(NINTENDO + ' \\w+', 'i') -const BLACKBERRY_REGEX = new RegExp(BLACKBERRY + '|PlayBook|BB10', 'i') - -const windowsVersionMap: Record = { - 'NT3.51': 'NT 3.11', - 'NT4.0': 'NT 4.0', - '5.0': '2000', - '5.1': 'XP', - '5.2': 'XP', - '6.0': 'Vista', - '6.1': '7', - '6.2': '8', - '6.3': '8.1', - '6.4': '10', - '10.0': '10', -} - -/** - * Safari detection turns out to be complicated. For e.g. https://stackoverflow.com/a/29696509 - * We can be slightly loose because some options have been ruled out (e.g. firefox on iOS) - * before this check is made - */ -function isSafari(userAgent: string): boolean { - return includes(userAgent, SAFARI) && !includes(userAgent, CHROME) && !includes(userAgent, ANDROID) -} - -const safariCheck = (ua: string, vendor?: string) => (vendor && includes(vendor, APPLE)) || isSafari(ua) - -/** - * This function detects which browser is running this script. - * The order of the checks are important since many user agents - * include keywords used in later checks. - */ -export const detectBrowser = function (user_agent: string, vendor: string | undefined): string { - vendor = vendor || '' // vendor is undefined for at least IE9 - - if (includes(user_agent, ' OPR/') && includes(user_agent, 'Mini')) { - return OPERA_MINI - } else if (includes(user_agent, ' OPR/')) { - return OPERA - } else if (BLACKBERRY_REGEX.test(user_agent)) { - return BLACKBERRY - } else if (includes(user_agent, 'IE' + MOBILE) || includes(user_agent, 'WPDesktop')) { - return INTERNET_EXPLORER_MOBILE - } - // https://developer.samsung.com/internet/user-agent-string-format - else if (includes(user_agent, SAMSUNG_BROWSER)) { - return SAMSUNG_INTERNET - } else if (includes(user_agent, EDGE) || includes(user_agent, 'Edg/')) { - return MICROSOFT_EDGE - } else if (includes(user_agent, 'FBIOS')) { - return FACEBOOK + ' ' + MOBILE - } else if (includes(user_agent, 'UCWEB') || includes(user_agent, 'UCBrowser')) { - return 'UC Browser' - } else if (includes(user_agent, 'CriOS')) { - return CHROME_IOS // why not just Chrome? - } else if (includes(user_agent, 'CrMo')) { - return CHROME - } else if (includes(user_agent, CHROME)) { - return CHROME - } else if (includes(user_agent, ANDROID) && includes(user_agent, SAFARI)) { - return ANDROID_MOBILE - } else if (includes(user_agent, 'FxiOS')) { - return FIREFOX_IOS - } else if (includes(user_agent.toLowerCase(), KONQUEROR.toLowerCase())) { - return KONQUEROR - } else if (safariCheck(user_agent, vendor)) { - return includes(user_agent, MOBILE) ? MOBILE_SAFARI : SAFARI - } else if (includes(user_agent, FIREFOX)) { - return FIREFOX - } else if (includes(user_agent, 'MSIE') || includes(user_agent, 'Trident/')) { - return INTERNET_EXPLORER - } else if (includes(user_agent, 'Gecko')) { - return FIREFOX - } - - return '' -} - -const versionRegexes: Record = { - [INTERNET_EXPLORER_MOBILE]: [new RegExp('rv:' + BROWSER_VERSION_REGEX_SUFFIX)], - [MICROSOFT_EDGE]: [new RegExp(EDGE + '?\\/' + BROWSER_VERSION_REGEX_SUFFIX)], - [CHROME]: [new RegExp('(' + CHROME + '|CrMo)\\/' + BROWSER_VERSION_REGEX_SUFFIX)], - [CHROME_IOS]: [new RegExp('CriOS\\/' + BROWSER_VERSION_REGEX_SUFFIX)], - 'UC Browser': [new RegExp('(UCBrowser|UCWEB)\\/' + BROWSER_VERSION_REGEX_SUFFIX)], - [SAFARI]: [DEFAULT_BROWSER_VERSION_REGEX], - [MOBILE_SAFARI]: [DEFAULT_BROWSER_VERSION_REGEX], - [OPERA]: [new RegExp('(' + OPERA + '|OPR)\\/' + BROWSER_VERSION_REGEX_SUFFIX)], - [FIREFOX]: [new RegExp(FIREFOX + '\\/' + BROWSER_VERSION_REGEX_SUFFIX)], - [FIREFOX_IOS]: [new RegExp('FxiOS\\/' + BROWSER_VERSION_REGEX_SUFFIX)], - [KONQUEROR]: [new RegExp('Konqueror[:/]?' + BROWSER_VERSION_REGEX_SUFFIX, 'i')], - // not every blackberry user agent has the version after the name - [BLACKBERRY]: [new RegExp(BLACKBERRY + ' ' + BROWSER_VERSION_REGEX_SUFFIX), DEFAULT_BROWSER_VERSION_REGEX], - [ANDROID_MOBILE]: [new RegExp('android\\s' + BROWSER_VERSION_REGEX_SUFFIX, 'i')], - [SAMSUNG_INTERNET]: [new RegExp(SAMSUNG_BROWSER + '\\/' + BROWSER_VERSION_REGEX_SUFFIX)], - [INTERNET_EXPLORER]: [new RegExp('(rv:|MSIE )' + BROWSER_VERSION_REGEX_SUFFIX)], - Mozilla: [new RegExp('rv:' + BROWSER_VERSION_REGEX_SUFFIX)], -} - -/** - * This function detects which browser version is running this script, - * parsing major and minor version (e.g., 42.1). User agent strings from: - * http://www.useragentstring.com/pages/useragentstring.php - * - * `navigator.vendor` is passed in and used to help with detecting certain browsers - * NB `navigator.vendor` is deprecated and not present in every browser - */ -export const detectBrowserVersion = function (userAgent: string, vendor: string | undefined): number | null { - const browser = detectBrowser(userAgent, vendor) - const regexes: RegExp[] | undefined = versionRegexes[browser as keyof typeof versionRegexes] - if (isUndefined(regexes)) { - return null - } - - for (let i = 0; i < regexes.length; i++) { - const regex = regexes[i] - const matches = userAgent.match(regex) - if (matches) { - return parseFloat(matches[matches.length - 2]) - } - } - return null -} - -// to avoid repeating regexes or calling them twice, we have an array of matches -// the first regex that matches uses its matcher function to return the result -const osMatchers: [ - RegExp, - [string, string] | ((match: RegExpMatchArray | null, user_agent: string) => [string, string]), -][] = [ - [ - new RegExp(XBOX + '; ' + XBOX + ' (.*?)[);]', 'i'), - (match) => { - return [XBOX, (match && match[1]) || ''] - }, - ], - [new RegExp(NINTENDO, 'i'), [NINTENDO, '']], - [new RegExp(PLAYSTATION, 'i'), [PLAYSTATION, '']], - [BLACKBERRY_REGEX, [BLACKBERRY, '']], - [ - new RegExp(WINDOWS, 'i'), - (_, user_agent) => { - if (/Phone/.test(user_agent) || /WPDesktop/.test(user_agent)) { - return [WINDOWS_PHONE, ''] - } - // not all JS versions support negative lookbehind, so we need two checks here - if (new RegExp(MOBILE).test(user_agent) && !/IEMobile\b/.test(user_agent)) { - return [WINDOWS + ' ' + MOBILE, ''] - } - const match = /Windows NT ([0-9.]+)/i.exec(user_agent) - if (match && match[1]) { - const version = match[1] - let osVersion = windowsVersionMap[version] || '' - if (/arm/i.test(user_agent)) { - osVersion = 'RT' - } - return [WINDOWS, osVersion] - } - return [WINDOWS, ''] - }, - ], - [ - /((iPhone|iPad|iPod).*?OS (\d+)_(\d+)_?(\d+)?|iPhone)/, - (match) => { - if (match && match[3]) { - const versionParts = [match[3], match[4], match[5] || '0'] - return [IOS, versionParts.join('.')] - } - return [IOS, ''] - }, - ], - [ - /(watch.*\/(\d+\.\d+\.\d+)|watch os,(\d+\.\d+),)/i, - (match) => { - // e.g. Watch4,3/5.3.8 (16U680) - let version = '' - if (match && match.length >= 3) { - version = isUndefined(match[2]) ? match[3] : match[2] - } - return ['watchOS', version] - }, - ], - [ - new RegExp('(' + ANDROID + ' (\\d+)\\.(\\d+)\\.?(\\d+)?|' + ANDROID + ')', 'i'), - (match) => { - if (match && match[2]) { - const versionParts = [match[2], match[3], match[4] || '0'] - return [ANDROID, versionParts.join('.')] - } - return [ANDROID, ''] - }, - ], - [ - /Mac OS X (\d+)[_.](\d+)[_.]?(\d+)?/i, - (match) => { - const result: [string, string] = ['Mac OS X', ''] - if (match && match[1]) { - const versionParts = [match[1], match[2], match[3] || '0'] - result[1] = versionParts.join('.') - } - return result - }, - ], - [ - /Mac/i, - // mop up a few non-standard UAs that should match mac - ['Mac OS X', ''], - ], - [/CrOS/, [CHROME_OS, '']], - [/Linux|debian/i, ['Linux', '']], -] - -export const detectOS = function (user_agent: string): [string, string] { - for (let i = 0; i < osMatchers.length; i++) { - const [rgex, resultOrFn] = osMatchers[i] - const match = rgex.exec(user_agent) - const result = match && (isFunction(resultOrFn) ? resultOrFn(match, user_agent) : resultOrFn) - if (result) { - return result - } - } - return ['', ''] -} - -export const detectDevice = function (user_agent: string): string { - if (NINTENDO_REGEX.test(user_agent)) { - return NINTENDO - } else if (PLAYSTATION_REGEX.test(user_agent)) { - return PLAYSTATION - } else if (XBOX_REGEX.test(user_agent)) { - return XBOX - } else if (new RegExp(OUYA, 'i').test(user_agent)) { - return OUYA - } else if (new RegExp('(' + WINDOWS_PHONE + '|WPDesktop)', 'i').test(user_agent)) { - return WINDOWS_PHONE - } else if (/iPad/.test(user_agent)) { - return IPAD - } else if (/iPod/.test(user_agent)) { - return 'iPod Touch' - } else if (/iPhone/.test(user_agent)) { - return 'iPhone' - } else if (/(watch)(?: ?os[,/]|\d,\d\/)[\d.]+/i.test(user_agent)) { - return APPLE_WATCH - } else if (BLACKBERRY_REGEX.test(user_agent)) { - return BLACKBERRY - } else if (/(kobo)\s(ereader|touch)/i.test(user_agent)) { - return 'Kobo' - } else if (new RegExp(NOKIA, 'i').test(user_agent)) { - return NOKIA - } else if ( - // Kindle Fire without Silk / Echo Show - /(kf[a-z]{2}wi|aeo[c-r]{2})( bui|\))/i.test(user_agent) || - // Kindle Fire HD - /(kf[a-z]+)( bui|\)).+silk\//i.test(user_agent) - ) { - return 'Kindle Fire' - } else if (/(Android|ZTE)/i.test(user_agent)) { - if ( - !new RegExp(MOBILE).test(user_agent) || - /(9138B|TB782B|Nexus [97]|pixel c|HUAWEISHT|BTV|noble nook|smart ultra 6)/i.test(user_agent) - ) { - if ( - (/pixel[\daxl ]{1,6}/i.test(user_agent) && !/pixel c/i.test(user_agent)) || - /(huaweimed-al00|tah-|APA|SM-G92|i980|zte|U304AA)/i.test(user_agent) || - (/lmy47v/i.test(user_agent) && !/QTAQZ3/i.test(user_agent)) - ) { - return ANDROID - } - return ANDROID_TABLET - } else { - return ANDROID - } - } else if (new RegExp('(pda|' + MOBILE + ')', 'i').test(user_agent)) { - return GENERIC_MOBILE - } else if (new RegExp(TABLET, 'i').test(user_agent) && !new RegExp(TABLET + ' pc', 'i').test(user_agent)) { - return GENERIC_TABLET - } else { - return '' - } -} - -export const detectDeviceType = function (user_agent: string): string { - const device = detectDevice(user_agent) - if ( - device === IPAD || - device === ANDROID_TABLET || - device === 'Kobo' || - device === 'Kindle Fire' || - device === GENERIC_TABLET - ) { - return TABLET - } else if (device === NINTENDO || device === XBOX || device === PLAYSTATION || device === OUYA) { - return 'Console' - } else if (device === APPLE_WATCH) { - return 'Wearable' - } else if (device) { - return MOBILE - } else { - return 'Desktop' - } -} diff --git a/packages/core/src/utils/index.ts b/packages/core/src/utils/index.ts index 1598196bcb..31bdd0eb1b 100644 --- a/packages/core/src/utils/index.ts +++ b/packages/core/src/utils/index.ts @@ -7,6 +7,7 @@ export * from './string-utils' export * from './type-utils' export * from './promise-queue' export * from './logger' +export * from './user-agent-utils' export const STRING_FORMAT = 'utf8' diff --git a/packages/core/src/utils/user-agent-utils.ts b/packages/core/src/utils/user-agent-utils.ts new file mode 100644 index 0000000000..21577f4b46 --- /dev/null +++ b/packages/core/src/utils/user-agent-utils.ts @@ -0,0 +1,358 @@ +import { includes } from './string-utils' +import { isFunction, isUndefined } from './type-utils' + +/** + * this device detection code is (at time of writing) about 3% of the size of the entire library + * this is mostly because the identifiers user in regexes and results can't be minified away since + * they have meaning + * + * so, there are some pre-uglifying choices in the code to help reduce the size + * e.g. many repeated strings are stored as variables and then old-fashioned concatenated together + * + * TL;DR here be dragons + */ +const FACEBOOK = 'Facebook' +const MOBILE = 'Mobile' +const IOS = 'iOS' +const ANDROID = 'Android' +const TABLET = 'Tablet' +const ANDROID_TABLET = ANDROID + ' ' + TABLET +const IPAD = 'iPad' +const APPLE = 'Apple' +const APPLE_WATCH = APPLE + ' Watch' +const SAFARI = 'Safari' +const BLACKBERRY = 'BlackBerry' +const SAMSUNG = 'Samsung' +const SAMSUNG_BROWSER = SAMSUNG + 'Browser' +const SAMSUNG_INTERNET = SAMSUNG + ' Internet' +const CHROME = 'Chrome' +const CHROME_OS = CHROME + ' OS' +const CHROME_IOS = CHROME + ' ' + IOS +const INTERNET_EXPLORER = 'Internet Explorer' +const INTERNET_EXPLORER_MOBILE = INTERNET_EXPLORER + ' ' + MOBILE +const OPERA = 'Opera' +const OPERA_MINI = OPERA + ' Mini' +const EDGE = 'Edge' +const MICROSOFT_EDGE = 'Microsoft ' + EDGE +const FIREFOX = 'Firefox' +const FIREFOX_IOS = FIREFOX + ' ' + IOS +const NINTENDO = 'Nintendo' +const PLAYSTATION = 'PlayStation' +const XBOX = 'Xbox' +const ANDROID_MOBILE = ANDROID + ' ' + MOBILE +const MOBILE_SAFARI = MOBILE + ' ' + SAFARI +const WINDOWS = 'Windows' +const WINDOWS_PHONE = WINDOWS + ' Phone' +const NOKIA = 'Nokia' +const OUYA = 'Ouya' +const GENERIC = 'Generic' +const GENERIC_MOBILE = GENERIC + ' ' + MOBILE.toLowerCase() +const GENERIC_TABLET = GENERIC + ' ' + TABLET.toLowerCase() +const KONQUEROR = 'Konqueror' + +const BROWSER_VERSION_REGEX_SUFFIX = '(\\d+(\\.\\d+)?)' +const DEFAULT_BROWSER_VERSION_REGEX = new RegExp('Version/' + BROWSER_VERSION_REGEX_SUFFIX) + +const XBOX_REGEX = new RegExp(XBOX, 'i') +const PLAYSTATION_REGEX = new RegExp(PLAYSTATION + ' \\w+', 'i') +const NINTENDO_REGEX = new RegExp(NINTENDO + ' \\w+', 'i') +const BLACKBERRY_REGEX = new RegExp(BLACKBERRY + '|PlayBook|BB10', 'i') + +const windowsVersionMap: Record = { + 'NT3.51': 'NT 3.11', + 'NT4.0': 'NT 4.0', + '5.0': '2000', + '5.1': 'XP', + '5.2': 'XP', + '6.0': 'Vista', + '6.1': '7', + '6.2': '8', + '6.3': '8.1', + '6.4': '10', + '10.0': '10', +} + +/** + * Safari detection turns out to be complicated. For e.g. https://stackoverflow.com/a/29696509 + * We can be slightly loose because some options have been ruled out (e.g. firefox on iOS) + * before this check is made + */ +function isSafari(userAgent: string): boolean { + return includes(userAgent, SAFARI) && !includes(userAgent, CHROME) && !includes(userAgent, ANDROID) +} + +const safariCheck = (ua: string, vendor?: string) => (vendor && includes(vendor, APPLE)) || isSafari(ua) + +/** + * This function detects which browser is running this script. + * The order of the checks are important since many user agents + * include keywords used in later checks. + */ +export const detectBrowser = function (user_agent: string, vendor: string | undefined): string { + vendor = vendor || '' // vendor is undefined for at least IE9 + + if (includes(user_agent, ' OPR/') && includes(user_agent, 'Mini')) { + return OPERA_MINI + } else if (includes(user_agent, ' OPR/')) { + return OPERA + } else if (BLACKBERRY_REGEX.test(user_agent)) { + return BLACKBERRY + } else if (includes(user_agent, 'IE' + MOBILE) || includes(user_agent, 'WPDesktop')) { + return INTERNET_EXPLORER_MOBILE + } + // https://developer.samsung.com/internet/user-agent-string-format + else if (includes(user_agent, SAMSUNG_BROWSER)) { + return SAMSUNG_INTERNET + } else if (includes(user_agent, EDGE) || includes(user_agent, 'Edg/')) { + return MICROSOFT_EDGE + } else if (includes(user_agent, 'FBIOS')) { + return FACEBOOK + ' ' + MOBILE + } else if (includes(user_agent, 'UCWEB') || includes(user_agent, 'UCBrowser')) { + return 'UC Browser' + } else if (includes(user_agent, 'CriOS')) { + return CHROME_IOS // why not just Chrome? + } else if (includes(user_agent, 'CrMo')) { + return CHROME + } else if (includes(user_agent, CHROME)) { + return CHROME + } else if (includes(user_agent, ANDROID) && includes(user_agent, SAFARI)) { + return ANDROID_MOBILE + } else if (includes(user_agent, 'FxiOS')) { + return FIREFOX_IOS + } else if (includes(user_agent.toLowerCase(), KONQUEROR.toLowerCase())) { + return KONQUEROR + } else if (safariCheck(user_agent, vendor)) { + return includes(user_agent, MOBILE) ? MOBILE_SAFARI : SAFARI + } else if (includes(user_agent, FIREFOX)) { + return FIREFOX + } else if (includes(user_agent, 'MSIE') || includes(user_agent, 'Trident/')) { + return INTERNET_EXPLORER + } else if (includes(user_agent, 'Gecko')) { + return FIREFOX + } + + return '' +} + +const versionRegexes: Record = { + [INTERNET_EXPLORER_MOBILE]: [new RegExp('rv:' + BROWSER_VERSION_REGEX_SUFFIX)], + [MICROSOFT_EDGE]: [new RegExp(EDGE + '?\\/' + BROWSER_VERSION_REGEX_SUFFIX)], + [CHROME]: [new RegExp('(' + CHROME + '|CrMo)\\/' + BROWSER_VERSION_REGEX_SUFFIX)], + [CHROME_IOS]: [new RegExp('CriOS\\/' + BROWSER_VERSION_REGEX_SUFFIX)], + 'UC Browser': [new RegExp('(UCBrowser|UCWEB)\\/' + BROWSER_VERSION_REGEX_SUFFIX)], + [SAFARI]: [DEFAULT_BROWSER_VERSION_REGEX], + [MOBILE_SAFARI]: [DEFAULT_BROWSER_VERSION_REGEX], + [OPERA]: [new RegExp('(' + OPERA + '|OPR)\\/' + BROWSER_VERSION_REGEX_SUFFIX)], + [FIREFOX]: [new RegExp(FIREFOX + '\\/' + BROWSER_VERSION_REGEX_SUFFIX)], + [FIREFOX_IOS]: [new RegExp('FxiOS\\/' + BROWSER_VERSION_REGEX_SUFFIX)], + [KONQUEROR]: [new RegExp('Konqueror[:/]?' + BROWSER_VERSION_REGEX_SUFFIX, 'i')], + // not every blackberry user agent has the version after the name + [BLACKBERRY]: [new RegExp(BLACKBERRY + ' ' + BROWSER_VERSION_REGEX_SUFFIX), DEFAULT_BROWSER_VERSION_REGEX], + [ANDROID_MOBILE]: [new RegExp('android\\s' + BROWSER_VERSION_REGEX_SUFFIX, 'i')], + [SAMSUNG_INTERNET]: [new RegExp(SAMSUNG_BROWSER + '\\/' + BROWSER_VERSION_REGEX_SUFFIX)], + [INTERNET_EXPLORER]: [new RegExp('(rv:|MSIE )' + BROWSER_VERSION_REGEX_SUFFIX)], + Mozilla: [new RegExp('rv:' + BROWSER_VERSION_REGEX_SUFFIX)], +} + +/** + * This function detects which browser version is running this script, + * parsing major and minor version (e.g., 42.1). User agent strings from: + * http://www.useragentstring.com/pages/useragentstring.php + * + * `navigator.vendor` is passed in and used to help with detecting certain browsers + * NB `navigator.vendor` is deprecated and not present in every browser + */ +export const detectBrowserVersion = function (userAgent: string, vendor: string | undefined): number | null { + const browser = detectBrowser(userAgent, vendor) + const regexes: RegExp[] | undefined = versionRegexes[browser as keyof typeof versionRegexes] + if (isUndefined(regexes)) { + return null + } + + for (let i = 0; i < regexes.length; i++) { + const regex = regexes[i] + const matches = userAgent.match(regex) + if (matches) { + return parseFloat(matches[matches.length - 2]) + } + } + return null +} + +// to avoid repeating regexes or calling them twice, we have an array of matches +// the first regex that matches uses its matcher function to return the result +const osMatchers: [ + RegExp, + [string, string] | ((match: RegExpMatchArray | null, user_agent: string) => [string, string]), +][] = [ + [ + new RegExp(XBOX + '; ' + XBOX + ' (.*?)[);]', 'i'), + (match) => { + return [XBOX, (match && match[1]) || ''] + }, + ], + [new RegExp(NINTENDO, 'i'), [NINTENDO, '']], + [new RegExp(PLAYSTATION, 'i'), [PLAYSTATION, '']], + [BLACKBERRY_REGEX, [BLACKBERRY, '']], + [ + new RegExp(WINDOWS, 'i'), + (_, user_agent) => { + if (/Phone/.test(user_agent) || /WPDesktop/.test(user_agent)) { + return [WINDOWS_PHONE, ''] + } + // not all JS versions support negative lookbehind, so we need two checks here + if (new RegExp(MOBILE).test(user_agent) && !/IEMobile\b/.test(user_agent)) { + return [WINDOWS + ' ' + MOBILE, ''] + } + const match = /Windows NT ([0-9.]+)/i.exec(user_agent) + if (match && match[1]) { + const version = match[1] + let osVersion = windowsVersionMap[version] || '' + if (/arm/i.test(user_agent)) { + osVersion = 'RT' + } + return [WINDOWS, osVersion] + } + return [WINDOWS, ''] + }, + ], + [ + /((iPhone|iPad|iPod).*?OS (\d+)_(\d+)_?(\d+)?|iPhone)/, + (match) => { + if (match && match[3]) { + const versionParts = [match[3], match[4], match[5] || '0'] + return [IOS, versionParts.join('.')] + } + return [IOS, ''] + }, + ], + [ + /(watch.*\/(\d+\.\d+\.\d+)|watch os,(\d+\.\d+),)/i, + (match) => { + // e.g. Watch4,3/5.3.8 (16U680) + let version = '' + if (match && match.length >= 3) { + version = isUndefined(match[2]) ? match[3] : match[2] + } + return ['watchOS', version] + }, + ], + [ + new RegExp('(' + ANDROID + ' (\\d+)\\.(\\d+)\\.?(\\d+)?|' + ANDROID + ')', 'i'), + (match) => { + if (match && match[2]) { + const versionParts = [match[2], match[3], match[4] || '0'] + return [ANDROID, versionParts.join('.')] + } + return [ANDROID, ''] + }, + ], + [ + /Mac OS X (\d+)[_.](\d+)[_.]?(\d+)?/i, + (match) => { + const result: [string, string] = ['Mac OS X', ''] + if (match && match[1]) { + const versionParts = [match[1], match[2], match[3] || '0'] + result[1] = versionParts.join('.') + } + return result + }, + ], + [ + /Mac/i, + // mop up a few non-standard UAs that should match mac + ['Mac OS X', ''], + ], + [/CrOS/, [CHROME_OS, '']], + [/Linux|debian/i, ['Linux', '']], +] + +export const detectOS = function (user_agent: string): [string, string] { + for (let i = 0; i < osMatchers.length; i++) { + const [rgex, resultOrFn] = osMatchers[i] + const match = rgex.exec(user_agent) + const result = match && (isFunction(resultOrFn) ? resultOrFn(match, user_agent) : resultOrFn) + if (result) { + return result + } + } + return ['', ''] +} + +export const detectDevice = function (user_agent: string): string { + if (NINTENDO_REGEX.test(user_agent)) { + return NINTENDO + } else if (PLAYSTATION_REGEX.test(user_agent)) { + return PLAYSTATION + } else if (XBOX_REGEX.test(user_agent)) { + return XBOX + } else if (new RegExp(OUYA, 'i').test(user_agent)) { + return OUYA + } else if (new RegExp('(' + WINDOWS_PHONE + '|WPDesktop)', 'i').test(user_agent)) { + return WINDOWS_PHONE + } else if (/iPad/.test(user_agent)) { + return IPAD + } else if (/iPod/.test(user_agent)) { + return 'iPod Touch' + } else if (/iPhone/.test(user_agent)) { + return 'iPhone' + } else if (/(watch)(?: ?os[,/]|\d,\d\/)[\d.]+/i.test(user_agent)) { + return APPLE_WATCH + } else if (BLACKBERRY_REGEX.test(user_agent)) { + return BLACKBERRY + } else if (/(kobo)\s(ereader|touch)/i.test(user_agent)) { + return 'Kobo' + } else if (new RegExp(NOKIA, 'i').test(user_agent)) { + return NOKIA + } else if ( + // Kindle Fire without Silk / Echo Show + /(kf[a-z]{2}wi|aeo[c-r]{2})( bui|\))/i.test(user_agent) || + // Kindle Fire HD + /(kf[a-z]+)( bui|\)).+silk\//i.test(user_agent) + ) { + return 'Kindle Fire' + } else if (/(Android|ZTE)/i.test(user_agent)) { + if ( + !new RegExp(MOBILE).test(user_agent) || + /(9138B|TB782B|Nexus [97]|pixel c|HUAWEISHT|BTV|noble nook|smart ultra 6)/i.test(user_agent) + ) { + if ( + (/pixel[\daxl ]{1,6}/i.test(user_agent) && !/pixel c/i.test(user_agent)) || + /(huaweimed-al00|tah-|APA|SM-G92|i980|zte|U304AA)/i.test(user_agent) || + (/lmy47v/i.test(user_agent) && !/QTAQZ3/i.test(user_agent)) + ) { + return ANDROID + } + return ANDROID_TABLET + } else { + return ANDROID + } + } else if (new RegExp('(pda|' + MOBILE + ')', 'i').test(user_agent)) { + return GENERIC_MOBILE + } else if (new RegExp(TABLET, 'i').test(user_agent) && !new RegExp(TABLET + ' pc', 'i').test(user_agent)) { + return GENERIC_TABLET + } else { + return '' + } +} + +export const detectDeviceType = function (user_agent: string): string { + const device = detectDevice(user_agent) + if ( + device === IPAD || + device === ANDROID_TABLET || + device === 'Kobo' || + device === 'Kindle Fire' || + device === GENERIC_TABLET + ) { + return TABLET + } else if (device === NINTENDO || device === XBOX || device === PLAYSTATION || device === OUYA) { + return 'Console' + } else if (device === APPLE_WATCH) { + return 'Wearable' + } else if (device) { + return MOBILE + } else { + return 'Desktop' + } +} diff --git a/packages/react-native/package.json b/packages/react-native/package.json index 974acdddf9..d0f0ab57ed 100644 --- a/packages/react-native/package.json +++ b/packages/react-native/package.json @@ -43,6 +43,8 @@ }, "devDependencies": { "@babel/cli": "^7.19.3", + "@babel/plugin-transform-class-properties": "^7.27.1", + "@babel/plugin-transform-private-methods": "^7.27.1", "@babel/plugin-transform-private-property-in-object": "^7.27.1", "@posthog-tooling/rollup-utils": "workspace:*", "@posthog-tooling/tsconfig-base": "workspace:*", diff --git a/packages/react-native/src/native-deps.tsx b/packages/react-native/src/native-deps.tsx index 4df62bec47..f9f4b52cc0 100644 --- a/packages/react-native/src/native-deps.tsx +++ b/packages/react-native/src/native-deps.tsx @@ -8,6 +8,7 @@ import { OptionalReactNativeDeviceInfo } from './optional/OptionalReactNativeDev import { PostHogCustomAppProperties, PostHogCustomStorage } from './types' import { OptionalReactNativeLocalize } from './optional/OptionalReactNativeLocalize' import { OptionalExpoFileSystemLegacy } from './optional/OptionalExpoFileSystemLegacy' +import { detectDeviceType } from '@posthog/core' const getDeviceType = (): string => { let deviceType = 'Mobile' @@ -15,7 +16,10 @@ const getDeviceType = (): string => { if (Platform.OS === 'macos' || Platform.OS === 'windows') { deviceType = 'Desktop' } else if (Platform.OS === 'web') { - deviceType = 'Web' + // Check user agent to determine if it's desktop or mobile + const ua = typeof navigator !== 'undefined' && navigator.userAgent ? navigator.userAgent : '' + + deviceType = detectDeviceType(ua) } return deviceType } diff --git a/packages/react-native/src/posthog-rn.ts b/packages/react-native/src/posthog-rn.ts index 83988ac631..e9a825899e 100644 --- a/packages/react-native/src/posthog-rn.ts +++ b/packages/react-native/src/posthog-rn.ts @@ -85,7 +85,7 @@ export interface PostHogOptions extends PostHogCoreOptions { * - $app_namespace: App bundle identifier / namespace * - $os_name: Operating system name * - $os_version: Operating system version - * - $device_type: Device type (Mobile, Desktop, Web) + * - $device_type: Device type (Mobile, Desktop) * - $lib: Name of the SDK library * - $lib_version: Version of the SDK library * diff --git a/packages/react-native/src/types.ts b/packages/react-native/src/types.ts index 87d75959a5..2a9227e61b 100644 --- a/packages/react-native/src/types.ts +++ b/packages/react-native/src/types.ts @@ -78,7 +78,7 @@ export interface PostHogCustomAppProperties { $device_name?: string | null /** Model identifier like "iPhone13,2" or "SM-S921B" */ $device_model?: string | null - /** Device type ("Mobile" | "Desktop" | "Web") */ + /** Device type ("Mobile" | "Desktop") */ $device_type?: string | null /** Operating system name like iOS or Android */ $os_name?: string | null diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8ad73f73d5..d77e927828 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -647,6 +647,12 @@ importers: '@babel/cli': specifier: ^7.19.3 version: 7.28.0(@babel/core@7.28.5) + '@babel/plugin-transform-class-properties': + specifier: ^7.27.1 + version: 7.27.1(@babel/core@7.28.5) + '@babel/plugin-transform-private-methods': + specifier: ^7.27.1 + version: 7.27.1(@babel/core@7.28.5) '@babel/plugin-transform-private-property-in-object': specifier: ^7.27.1 version: 7.27.1(@babel/core@7.28.5) @@ -1034,10 +1040,6 @@ packages: resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} engines: {node: '>=6.9.0'} - '@babel/helper-member-expression-to-functions@7.27.1': - resolution: {integrity: sha512-E5chM8eWjTp/aNoVpcbfM7mLxu9XGLWYise2eBKGQomAk/Mb4XoxyqXTZbuTohbsl8EKqdlMhnDI2CCLfcs9wA==} - engines: {node: '>=6.9.0'} - '@babel/helper-member-expression-to-functions@7.28.5': resolution: {integrity: sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==} engines: {node: '>=6.9.0'} @@ -13139,7 +13141,7 @@ snapshots: dependencies: '@babel/core': 7.28.5 '@babel/helper-annotate-as-pure': 7.27.3 - '@babel/helper-member-expression-to-functions': 7.27.1 + '@babel/helper-member-expression-to-functions': 7.28.5 '@babel/helper-optimise-call-expression': 7.27.1 '@babel/helper-replace-supers': 7.27.1(@babel/core@7.28.5) '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 @@ -13183,13 +13185,6 @@ snapshots: '@babel/helper-globals@7.28.0': {} - '@babel/helper-member-expression-to-functions@7.27.1': - dependencies: - '@babel/traverse': 7.28.5 - '@babel/types': 7.28.5 - transitivePeerDependencies: - - supports-color - '@babel/helper-member-expression-to-functions@7.28.5': dependencies: '@babel/traverse': 7.28.5