From d3522c51f8fc4f2eace522c876664c4f720554a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carl-Gerhard=20Lindesva=CC=88rd?= Date: Mon, 8 Dec 2025 14:38:11 +0100 Subject: [PATCH] api: improve user-agent parsing --- packages/common/package.json | 10 +- .../common/server/parser-user-agent.test.ts | 102 ++++++++-- packages/common/server/parser-user-agent.ts | 186 ++++++++++++++++-- pnpm-lock.yaml | 71 +++++-- 4 files changed, 320 insertions(+), 49 deletions(-) diff --git a/packages/common/package.json b/packages/common/package.json index 053730b9..60dbdac9 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -16,20 +16,20 @@ "dependencies": { "@openpanel/constants": "workspace:*", "date-fns": "^3.3.1", - "lru-cache": "^11.2.2", - "luxon": "^3.6.1", + "lru-cache": "^11.2.4", + "luxon": "^3.7.2", "mathjs": "^12.3.2", - "nanoid": "^5.0.7", + "nanoid": "^5.1.6", "ramda": "^0.29.1", "slugify": "^1.6.6", "superjson": "^1.13.3", - "ua-parser-js": "^1.0.37", + "ua-parser-js": "^2.0.6", "unique-names-generator": "^4.7.1" }, "devDependencies": { "@openpanel/tsconfig": "workspace:*", "@openpanel/validation": "workspace:*", - "@types/luxon": "^3.6.2", + "@types/luxon": "^3.7.1", "@types/node": "catalog:", "@types/ramda": "^0.29.6", "@types/ua-parser-js": "^0.7.39", diff --git a/packages/common/server/parser-user-agent.test.ts b/packages/common/server/parser-user-agent.test.ts index aa5e1c0b..917f8566 100644 --- a/packages/common/server/parser-user-agent.test.ts +++ b/packages/common/server/parser-user-agent.test.ts @@ -57,7 +57,7 @@ describe('parseUserAgent', () => { expect(parseUserAgent(ua)).toEqual({ isServer: false, device: 'tablet', - os: 'Mac OS', + os: 'iOS', osVersion: '18.0', browser: 'WebKit', browserVersion: '605.1.15', @@ -70,16 +70,15 @@ describe('parseUserAgent', () => { const ua = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'; - expect(parseUserAgent(ua)).toEqual({ - isServer: false, - device: 'desktop', - os: 'Windows', - osVersion: '10', - browser: 'Chrome', - browserVersion: '91.0.4472.124', - brand: undefined, - model: undefined, - }); + const result = parseUserAgent(ua); + expect(result.isServer).toBe(false); + expect(result.device).toBe('desktop'); + expect(result.os).toBe('Windows'); + expect(result.osVersion).toBe('10'); + expect(result.browser).toBe('Chrome'); + expect(result.browserVersion).toBe('91.0.4472.124'); + // Desktop browsers don't have brand/model + expect(result.model).toBeUndefined(); }); it('should handle server user agents', () => { @@ -167,4 +166,85 @@ describe('getDevice', () => { expect(getDevice(ua)).toBe('desktop'); }); }); + + it('should detect known phone models as mobile', () => { + // Xiaomi/Redmi + expect(getDevice('', 'Redmi Note 8 Pro')).toBe('mobile'); + expect(getDevice('', 'POCO F3')).toBe('mobile'); + expect(getDevice('', 'Mi 11')).toBe('mobile'); + + // Samsung Galaxy phones + expect(getDevice('', 'Galaxy S23 Ultra')).toBe('mobile'); + expect(getDevice('', 'Galaxy A54')).toBe('mobile'); + expect(getDevice('', 'Galaxy Z Fold5')).toBe('mobile'); + + // Google Pixel + expect(getDevice('', 'Pixel 8 Pro')).toBe('mobile'); + + // OnePlus + expect(getDevice('', 'OnePlus 11')).toBe('mobile'); + + // Huawei/Honor + expect(getDevice('', 'Huawei P60 Pro')).toBe('mobile'); + expect(getDevice('', 'Honor 90')).toBe('mobile'); + + // OPPO/Vivo/Realme + expect(getDevice('', 'OPPO Find X6')).toBe('mobile'); + expect(getDevice('', 'Vivo X90')).toBe('mobile'); + expect(getDevice('', 'Realme GT5')).toBe('mobile'); + + // Motorola + expect(getDevice('', 'Moto G84')).toBe('mobile'); + }); +}); + +describe('parseUserAgent - brand detection', () => { + it('should detect Xiaomi brand from Manufacturer field', () => { + const result = parseUserAgent( + 'App/1.0 (Android 12; Model=POCO X5; Manufacturer=Xiaomi)', + ); + expect(result.brand).toBe('Xiaomi'); + expect(result.model).toBe('POCO X5'); + expect(result.device).toBe('mobile'); + }); + + it('should detect Samsung brand from model name', () => { + const result = parseUserAgent( + 'App/1.0 (Android 13; Model=Galaxy S23 Ultra)', + ); + expect(result.brand).toBe('Samsung'); + expect(result.model).toBe('Galaxy S23 Ultra'); + expect(result.device).toBe('mobile'); + }); + + it('should detect Google Pixel', () => { + const result = parseUserAgent('App/1.0 (Android 14; Model=Pixel 8 Pro)'); + expect(result.brand).toBe('Google'); + expect(result.model).toBe('Pixel 8 Pro'); + expect(result.os).toBe('Android'); + expect(result.osVersion).toBe('14'); + expect(result.device).toBe('mobile'); + }); + + it('should detect OnePlus', () => { + const result = parseUserAgent( + 'App/1.0 (Android 13; Model=OnePlus 11; Manufacturer=OnePlus)', + ); + expect(result.brand).toBe('OnePlus'); + expect(result.model).toBe('OnePlus 11'); + expect(result.device).toBe('mobile'); + expect(result.os).toBe('Android'); + expect(result.osVersion).toBe('13'); + }); + + it('should detect Huawei', () => { + const result = parseUserAgent( + 'App/1.0 (Android 12; Model=P60 Pro; Manufacturer=Huawei)', + ); + expect(result.brand).toBe('Huawei'); + expect(result.model).toBe('P60 Pro'); + expect(result.device).toBe('mobile'); + expect(result.os).toBe('Android'); + expect(result.osVersion).toBe('12'); + }); }); diff --git a/packages/common/server/parser-user-agent.ts b/packages/common/server/parser-user-agent.ts index 7a3551c9..c859bccb 100644 --- a/packages/common/server/parser-user-agent.ts +++ b/packages/common/server/parser-user-agent.ts @@ -18,6 +18,10 @@ const IOS_MODEL_REGEX = /(iOS)\s*([0-9\.]+)/i; const IPAD_OS_VERSION_REGEX = /iPadOS\s*([0-9_]+)/i; const SINGLE_NAME_VERSION_REGEX = /^[^\/]+\/[\d.]+$/; +// App-style UA patterns (e.g., "Model=Redmi Note 8 Pro; Manufacturer=Xiaomi") +const APP_MODEL_REGEX = /Model=([^;)]+)/i; +const APP_MANUFACTURER_REGEX = /Manufacturer=([^;)]+)/i; + // Device detection regexes const SAMSUNG_MOBILE_REGEX = /SM-[ABDEFGJMNRWZ][0-9]+/i; const SAMSUNG_TABLET_REGEX = /SM-T[0-9]+/i; @@ -30,6 +34,76 @@ const TABLET_REGEX = /tablet|ipad|xoom|sch-i800|kindle|silk|playbook/i; const ANDROID_REGEX = /android/i; const MOBILE_KEYWORD_REGEX = /mobile/i; +// Known phone model patterns from top brands (for device type detection) +// These patterns indicate mobile phones, not tablets +const KNOWN_PHONE_PATTERNS = [ + // Xiaomi / Redmi / POCO + /redmi\s*(note|k|[0-9])/i, + /poco\s*[a-z0-9]/i, + /mi\s*([0-9]|note|mix|max)/i, + // Samsung Galaxy phones (not tablets) + /galaxy\s*(s|a|m|note)[0-9]/i, + /galaxy\s*z\s*(fold|flip)/i, // Foldables + // Huawei / Honor + /huawei\s*(p|mate|nova|y)[0-9]/i, + /honor\s*[0-9a-z]/i, + // OPPO + /oppo\s*(a|f|find|reno|k)\s*[a-z0-9]/i, + // Vivo + /vivo\s*(v|y|x|s|t|iqoo)[0-9]/i, + /iqoo\s*[0-9a-z]/i, + // OnePlus + /oneplus\s*[0-9]/i, + /one\s*plus\s*[0-9]/i, + // Google Pixel + /pixel\s*[0-9]/i, + // Realme + /realme\s*[0-9a-z]/i, + // Motorola + /moto\s*(g|e|x|z|edge|razr)/i, + /motorola\s*(edge|razr)/i, + // Nokia + /nokia\s*[0-9]/i, + // Sony Xperia + /xperia\s*[0-9a-z]/i, + // Nothing + /nothing\s*phone/i, +]; + +// Brand detection patterns with normalized names +// Order matters - more specific patterns should come first +const BRAND_PATTERNS: Array<{ pattern: RegExp; brand: string }> = [ + { pattern: /xiaomi/i, brand: 'Xiaomi' }, + { pattern: /redmi/i, brand: 'Xiaomi' }, + { pattern: /poco/i, brand: 'Xiaomi' }, + { pattern: /samsung/i, brand: 'Samsung' }, + { pattern: /galaxy/i, brand: 'Samsung' }, + { pattern: /huawei/i, brand: 'Huawei' }, + { pattern: /honor/i, brand: 'Honor' }, + { pattern: /oppo/i, brand: 'OPPO' }, + { pattern: /vivo/i, brand: 'Vivo' }, + { pattern: /iqoo/i, brand: 'Vivo' }, + { pattern: /oneplus|one\s*plus/i, brand: 'OnePlus' }, + { pattern: /google/i, brand: 'Google' }, + { pattern: /pixel/i, brand: 'Google' }, + { pattern: /realme/i, brand: 'Realme' }, + { pattern: /motorola/i, brand: 'Motorola' }, + { pattern: /moto\s/i, brand: 'Motorola' }, + { pattern: /nokia/i, brand: 'Nokia' }, + { pattern: /sony/i, brand: 'Sony' }, + { pattern: /xperia/i, brand: 'Sony' }, + { pattern: /nothing/i, brand: 'Nothing' }, + // Be specific with Apple - don't match "AppleWebKit" + { pattern: /\bapple\b(?!webkit)/i, brand: 'Apple' }, + { pattern: /\biphone\b/i, brand: 'Apple' }, + { pattern: /\bipad\b/i, brand: 'Apple' }, + { pattern: /lg[- ]/i, brand: 'LG' }, + { pattern: /zte/i, brand: 'ZTE' }, + { pattern: /lenovo/i, brand: 'Lenovo' }, + { pattern: /asus/i, brand: 'ASUS' }, + { pattern: /tcl/i, brand: 'TCL' }, +]; + // Cache for parsed results - stores up to 1000 unique user agents const parseCache = new LRUCache({ ttl: 1000 * 60 * 5, @@ -49,6 +123,37 @@ const isIphone = (ua: string) => { : null; }; +// Extract app-style UA info (e.g., "Model=Redmi Note 8 Pro; Manufacturer=Xiaomi") +function extractAppStyleInfo(ua: string): { + model?: string; + manufacturer?: string; +} { + const modelMatch = ua.match(APP_MODEL_REGEX); + const manufacturerMatch = ua.match(APP_MANUFACTURER_REGEX); + + return { + model: modelMatch?.[1]?.trim(), + manufacturer: manufacturerMatch?.[1]?.trim(), + }; +} + +// Detect brand from UA string or model name +function detectBrand(ua: string, model?: string): string | undefined { + const searchString = `${ua} ${model || ''}`; + for (const { pattern, brand } of BRAND_PATTERNS) { + if (pattern.test(searchString)) { + return brand; + } + } + return undefined; +} + +// Check if a model name indicates a phone (not tablet) +function isKnownPhoneModel(model?: string): boolean { + if (!model) return false; + return KNOWN_PHONE_PATTERNS.some((pattern) => pattern.test(model)); +} + const parse = (ua: string): UAParser.IResult => { // Check cache first const cached = parseCache.get(ua); @@ -57,7 +162,7 @@ const parse = (ua: string): UAParser.IResult => { } const parser = new UAParser(ua); - const res = parser.getResult(); + let res = parser.getResult(); // Some user agents are not detected correctly by ua-parser-js // Doing some extra checks for ios @@ -86,15 +191,45 @@ const parse = (ua: string): UAParser.IResult => { if (res.device.model === 'iPad' && !res.os.version) { const osVersion = ua.match(IPAD_OS_VERSION_REGEX); if (osVersion) { - const result = { + res = { ...res, os: { ...res.os, version: osVersion[1]!.replace(/_/g, '.'), }, }; - parseCache.set(ua, result); - return result; + } + } + + // Extract app-style UA info (e.g., "Model=Redmi Note 8 Pro; Manufacturer=Xiaomi") + // This handles UAs from mobile apps that include device info in a custom format + const appInfo = extractAppStyleInfo(ua); + if (appInfo.model || appInfo.manufacturer) { + const model = res.device.model || appInfo.model; + const vendor = + res.device.vendor || appInfo.manufacturer || detectBrand(ua, model); + + res = { + ...res, + device: { + ...res.device, + model: model, + vendor: vendor, + }, + }; + } + + // If we still don't have a vendor, try to detect brand from UA or model + if (!res.device.vendor && (res.device.model || res.os.name)) { + const detectedBrand = detectBrand(ua, res.device.model); + if (detectedBrand) { + res = { + ...res, + device: { + ...res.device, + vendor: detectedBrand, + }, + }; } } @@ -116,6 +251,11 @@ export function parseUserAgent( return parsedServerUa; } + const model = + typeof overrides?.__model === 'string' && overrides?.__model + ? overrides?.__model + : res.device.model; + return { os: typeof overrides?.__os === 'string' && overrides?.__os @@ -137,15 +277,12 @@ export function parseUserAgent( device: typeof overrides?.__device === 'string' && overrides?.__device ? overrides?.__device - : res.device.type || getDevice(ua), + : res.device.type || getDevice(ua, model), brand: typeof overrides?.__brand === 'string' && overrides?.__brand ? overrides?.__brand : res.device.vendor, - model: - typeof overrides?.__model === 'string' && overrides?.__model - ? overrides?.__model - : res.device.model, + model, isServer: false, } as const; } @@ -166,7 +303,13 @@ function isServer(res: UAParser.IResult) { ); } -export function getDevice(ua: string) { +export function getDevice(ua: string, model?: string) { + // Check for known phone models first (from top brands) + // This handles app-style UAs where the model is explicitly stated + if (isKnownPhoneModel(model) || isKnownPhoneModel(ua)) { + return 'mobile'; + } + // Samsung mobile devices use SM-[A,G,N,etc]XXX pattern const isSamsungMobile = SAMSUNG_MOBILE_REGEX.test(ua); if (isSamsungMobile) { @@ -197,13 +340,30 @@ export function getDevice(ua: string) { const isAndroid = ANDROID_REGEX.test(ua); const hasMobileKeyword = MOBILE_KEYWORD_REGEX.test(ua); - const tablet = - TABLET_REGEX.test(ua) || - (isAndroid && !hasMobileKeyword && !isSamsungMobile && !isLGMobile); + // Only consider it a tablet if it's explicitly a tablet device + // Don't default Android to tablet just because "mobile" keyword is missing + const tablet = TABLET_REGEX.test(ua); if (tablet) { return 'tablet'; } + // For Android without explicit mobile/tablet indicators and no known model, + // check if there's any brand/model info suggesting it's a phone + if (isAndroid && !hasMobileKeyword && !isSamsungMobile && !isLGMobile) { + // Extract model from app-style UA if present + const appInfo = extractAppStyleInfo(ua); + if (appInfo.model && isKnownPhoneModel(appInfo.model)) { + return 'mobile'; + } + // If we have a brand but no clear device type, assume mobile for Android + const brand = detectBrand(ua, appInfo.model); + if (brand) { + return 'mobile'; + } + // Default Android without clear indicators to mobile (more common than tablet) + return 'mobile'; + } + return 'desktop'; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 37e2e90c..ca8b1384 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -978,17 +978,17 @@ importers: specifier: ^3.3.1 version: 3.3.1 lru-cache: - specifier: ^11.2.2 - version: 11.2.2 + specifier: ^11.2.4 + version: 11.2.4 luxon: - specifier: ^3.6.1 - version: 3.6.1 + specifier: ^3.7.2 + version: 3.7.2 mathjs: specifier: ^12.3.2 version: 12.3.2 nanoid: - specifier: ^5.0.7 - version: 5.0.7 + specifier: ^5.1.6 + version: 5.1.6 ramda: specifier: ^0.29.1 version: 0.29.1 @@ -999,8 +999,8 @@ importers: specifier: ^1.13.3 version: 1.13.3 ua-parser-js: - specifier: ^1.0.37 - version: 1.0.37 + specifier: ^2.0.6 + version: 2.0.6 unique-names-generator: specifier: ^4.7.1 version: 4.7.1 @@ -1012,8 +1012,8 @@ importers: specifier: workspace:* version: link:../validation '@types/luxon': - specifier: ^3.6.2 - version: 3.6.2 + specifier: ^3.7.1 + version: 3.7.1 '@types/node': specifier: 'catalog:' version: 24.7.1 @@ -8551,8 +8551,8 @@ packages: '@types/lodash@4.14.202': resolution: {integrity: sha512-OvlIYQK9tNneDlS0VN54LLd5uiPCBOp7gS5Z0f1mjoJYBrtStzgmJBxONW3U6OZqdtNzZPmn9BS/7WI7BFFcFQ==} - '@types/luxon@3.6.2': - resolution: {integrity: sha512-R/BdP7OxEMc44l2Ex5lSXHoIXTB2JLNa3y2QISIbr58U/YcsffyQrYW//hZSdrfxrjRZj3GcUoxMPGdO8gSYuw==} + '@types/luxon@3.7.1': + resolution: {integrity: sha512-H3iskjFIAn5SlJU7OuxUmTEpebK6TKB8rxZShDslBMZJ5u9S//KM1sbdAisiSrqwLQncVjnpi2OK2J51h+4lsg==} '@types/mdast@4.0.3': resolution: {integrity: sha512-LsjtqsyF+d2/yFOYaN22dHZI1Cpwkrj+g06G8+qtUKlhovPW89YhqSnfKtMbkgmEtYpH2gydRNULd6y8mciAFg==} @@ -10260,6 +10260,9 @@ packages: resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + detect-europe-js@0.1.2: + resolution: {integrity: sha512-lgdERlL3u0aUdHocoouzT10d9I89VVhk0qNRmll7mXdGfJT1/wqZ2ZLA4oJAjeACPY5fT1wsbq2AT+GkuInsow==} + detect-file@1.0.0: resolution: {integrity: sha512-DtCOLG98P007x7wiiOmfI0fi3eIKyWiLTGJ2MDnVi/E04lWGbf+JzrRHMm0rgIIZJGtHpKpbVgLWHrv8xXpc3Q==} engines: {node: '>=0.10.0'} @@ -11911,6 +11914,9 @@ packages: resolution: {integrity: sha512-nA2hv5XIhLR3uVzDDfCIknerhx8XUKnstuOERPNNIinXG7v9u+ohXF67vxm4TPTEPU6lm61ZkwP3c9PCB97rhg==} engines: {node: '>= 0.4'} + is-standalone-pwa@0.1.1: + resolution: {integrity: sha512-9Cbovsa52vNQCjdXOzeQq5CnCbAcRk05aU62K20WO372NrTv0NxibLFCK6lQ4/iZEFdEA3p3t2VNOn8AJ53F5g==} + is-stream@1.1.0: resolution: {integrity: sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ==} engines: {node: '>=0.10.0'} @@ -12553,6 +12559,10 @@ packages: resolution: {integrity: sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg==} engines: {node: 20 || >=22} + lru-cache@11.2.4: + resolution: {integrity: sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==} + engines: {node: 20 || >=22} + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} @@ -12570,8 +12580,8 @@ packages: peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 - luxon@3.6.1: - resolution: {integrity: sha512-tJLxrKJhO2ukZ5z0gyjY1zPh3Rh88Ej9P7jNrZiHMUXHae1yvI2imgOZtL1TO8TW6biMMKfTtAOoEJANgtWBMQ==} + luxon@3.7.2: + resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==} engines: {node: '>=12'} lz-string@1.5.0: @@ -13115,8 +13125,8 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true - nanoid@5.0.7: - resolution: {integrity: sha512-oLxFY2gd2IqnjcYyOXD8XGCftpGtZP2AbHbOkthDkvRywH5ayNtPVy9YlOPcHckXzbLTCHpkb7FB+yuxKV13pQ==} + nanoid@5.1.6: + resolution: {integrity: sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==} engines: {node: ^18 || >=20} hasBin: true @@ -15780,9 +15790,16 @@ packages: engines: {node: '>=14.17'} hasBin: true + ua-is-frozen@0.1.2: + resolution: {integrity: sha512-RwKDW2p3iyWn4UbaxpP2+VxwqXh0jpvdxsYpZ5j/MLLiQOfbsV5shpgQiw93+KMYQPcteeMQ289MaAFzs3G9pw==} + ua-parser-js@1.0.37: resolution: {integrity: sha512-bhTyI94tZofjo+Dn8SN6Zv8nBDvyXTymAdM3LDI/0IboIUwTu1rEhW7v2TfiVsoYWgkQ4kOVqnI8APUFbIQIFQ==} + ua-parser-js@2.0.6: + resolution: {integrity: sha512-EmaxXfltJaDW75SokrY4/lXMrVyXomE/0FpIIqP2Ctic93gK7rlme55Cwkz8l3YZ6gqf94fCU7AnIkidd/KXPg==} + hasBin: true + ufo@1.6.1: resolution: {integrity: sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==} @@ -24170,7 +24187,7 @@ snapshots: '@types/lodash@4.14.202': {} - '@types/luxon@3.6.2': {} + '@types/luxon@3.7.1': {} '@types/mdast@4.0.3': dependencies: @@ -25735,7 +25752,7 @@ snapshots: cron-parser@4.9.0: dependencies: - luxon: 3.6.1 + luxon: 3.7.2 croner@9.1.0: {} @@ -26180,6 +26197,8 @@ snapshots: destroy@1.2.0: {} + detect-europe-js@0.1.2: {} + detect-file@1.0.0: {} detect-libc@1.0.3: {} @@ -28458,6 +28477,8 @@ snapshots: dependencies: call-bind: 1.0.7 + is-standalone-pwa@0.1.1: {} + is-stream@1.1.0: {} is-stream@2.0.1: {} @@ -29096,6 +29117,8 @@ snapshots: lru-cache@11.2.2: {} + lru-cache@11.2.4: {} + lru-cache@5.1.1: dependencies: yallist: 3.1.1 @@ -29112,7 +29135,7 @@ snapshots: dependencies: react: 19.1.1 - luxon@3.6.1: {} + luxon@3.7.2: {} lz-string@1.5.0: {} @@ -30054,7 +30077,7 @@ snapshots: nanoid@3.3.11: {} - nanoid@5.0.7: {} + nanoid@5.1.6: {} ncp@2.0.0: optional: true @@ -33309,8 +33332,16 @@ snapshots: typescript@5.9.3: {} + ua-is-frozen@0.1.2: {} + ua-parser-js@1.0.37: {} + ua-parser-js@2.0.6: + dependencies: + detect-europe-js: 0.1.2 + is-standalone-pwa: 0.1.1 + ua-is-frozen: 0.1.2 + ufo@1.6.1: {} ultrahtml@1.6.0: {}