diff --git a/src/Utils/browser-utils.ts b/src/Utils/browser-utils.ts index ee2e42dc..7659c4c5 100644 --- a/src/Utils/browser-utils.ts +++ b/src/Utils/browser-utils.ts @@ -2,30 +2,175 @@ import { platform, release } from 'os' import { proto } from '../../WAProto/index.js' import type { BrowsersMap } from '../Types' -const PLATFORM_MAP = { +// ============================================================ +// Constants +// ============================================================ + +/** + * Default platform name when OS detection fails or returns unsupported value + */ +const DEFAULT_PLATFORM_NAME = 'Ubuntu' as const + +/** + * Default platform ID (Chrome = 1) for unknown browser mappings + * @see proto.DeviceProps.PlatformType + */ +const DEFAULT_PLATFORM_ID = '1' as const + +/** + * Maps Node.js platform identifiers to WhatsApp-recognized platform names. + * Values of `undefined` will fall back to DEFAULT_PLATFORM_NAME. + */ +const PLATFORM_MAP: Readonly> = { aix: 'AIX', - darwin: 'Mac OS', - win32: 'Windows', android: 'Android', + cygwin: undefined, + darwin: 'Mac OS', freebsd: 'FreeBSD', + haiku: undefined, + linux: undefined, + netbsd: undefined, openbsd: 'OpenBSD', sunos: 'Solaris', - linux: undefined, - haiku: undefined, - cygwin: undefined, - netbsd: undefined + win32: 'Windows', +} as const + +// ============================================================ +// Platform Type Resolution +// ============================================================ + +/** + * Pre-computed map of browser names to platform IDs. + * Built once at module load to avoid repeated proto access. + */ +const BROWSER_TO_PLATFORM_ID: ReadonlyMap = (() => { + const platformType = proto.DeviceProps?.PlatformType + if (!platformType || typeof platformType !== 'object') { + return new Map() + } + + const entries: Array<[string, string]> = [] + for (const [key, value] of Object.entries(platformType)) { + // PlatformType has both numeric values and reverse mappings + // We only want the string keys with numeric values + if (typeof value === 'number' && typeof key === 'string' && !/^\d+$/.test(key)) { + entries.push([key.toUpperCase(), value.toString()]) + } + } + return new Map(entries) +})() + +// ============================================================ +// Helper Functions +// ============================================================ + +/** + * Resolves the current platform name for WhatsApp device identification. + * Uses the OS platform detection with a fallback to Ubuntu. + * + * @returns Platform name string (never undefined) + */ +const getPlatformName = (): string => { + try { + const currentPlatform = platform() + return PLATFORM_MAP[currentPlatform] ?? DEFAULT_PLATFORM_NAME + } catch { + return DEFAULT_PLATFORM_NAME + } } +/** + * Normalizes a browser identifier for platform type lookup. + * + * @param browser - The browser identifier to normalize + * @returns Uppercase trimmed string, or null if input is invalid + */ +const normalizeBrowserKey = (browser: unknown): string | null => { + if (typeof browser !== 'string') { + return null + } + const normalized = browser.trim() + return normalized.length > 0 ? normalized.toUpperCase() : null +} + +/** + * Safely gets the OS release version with fallback + * + * @returns OS release string or empty string on failure + */ +const safeRelease = (): string => { + try { + return release() + } catch { + return '' + } +} + +// ============================================================ +// Exported Constants +// ============================================================ + +/** + * Browser configuration presets for WhatsApp device registration. + * Each factory returns a tuple of [platform, browser, version]. + * + * @example + * // Use Ubuntu preset + * const config = Browsers.ubuntu('Chrome') + * // Returns: ['Ubuntu', 'Chrome', '22.04.4'] + * + * @example + * // Use automatic platform detection + * const config = Browsers.appropriate('MyApp') + * // Returns: ['Mac OS', 'MyApp', '23.1.0'] (on macOS) + */ export const Browsers: BrowsersMap = { - ubuntu: browser => ['Ubuntu', browser, '22.04.4'], - macOS: browser => ['Mac OS', browser, '14.4.1'], - baileys: browser => ['Baileys', browser, '6.5.0'], - windows: browser => ['Windows', browser, '10.0.22631'], - /** The appropriate browser based on your OS & release */ - appropriate: browser => [PLATFORM_MAP[platform()] || 'Ubuntu', browser, release()] + ubuntu: (browser: string): [string, string, string] => ['Ubuntu', browser, '22.04.4'], + macOS: (browser: string): [string, string, string] => ['Mac OS', browser, '14.4.1'], + windows: (browser: string): [string, string, string] => ['Windows', browser, '10.0.22631'], + baileys: (browser: string): [string, string, string] => ['Baileys', browser, '6.5.0'], + appropriate: (browser: string): [string, string, string] => [getPlatformName(), browser, safeRelease()], +} as const + +// ============================================================ +// Exported Functions +// ============================================================ + +/** + * Resolves the platform type ID for a given browser name. + * Uses the WhatsApp protocol buffer definitions for mapping. + * + * @param browser - Browser identifier (e.g., 'chrome', 'firefox', 'safari') + * @returns Platform type ID as string (defaults to '1' for Chrome) + * + * @example + * getPlatformId('chrome') // Returns '1' + * getPlatformId('CHROME') // Returns '1' (case-insensitive) + * getPlatformId('firefox') // Returns platform-specific ID + * getPlatformId('') // Returns '1' (default) + * getPlatformId(undefined) // Returns '1' (default, handles invalid input) + */ +export const getPlatformId = (browser: string): string => { + const key = normalizeBrowserKey(browser) + if (key === null) { + return DEFAULT_PLATFORM_ID + } + + return BROWSER_TO_PLATFORM_ID.get(key) ?? DEFAULT_PLATFORM_ID } -export const getPlatformId = (browser: string) => { - const platformType = proto.DeviceProps.PlatformType[browser.toUpperCase() as any] - return platformType ? platformType.toString() : '1' //chrome +/** + * Type guard to check if a platform string is a valid browser preset key. + * Useful for external validation before using Browsers presets. + * + * @param value - Value to check + * @returns True if value is a valid browser preset key + * + * @example + * if (isValidBrowserPreset(userInput)) { + * const config = Browsers[userInput]('MyApp') + * } + */ +export const isValidBrowserPreset = (value: unknown): value is keyof BrowsersMap => { + return typeof value === 'string' && value in Browsers } diff --git a/src/__tests__/Utils/browser-utils.test.ts b/src/__tests__/Utils/browser-utils.test.ts new file mode 100644 index 00000000..e09a11c6 --- /dev/null +++ b/src/__tests__/Utils/browser-utils.test.ts @@ -0,0 +1,186 @@ +import { Browsers, getPlatformId, isValidBrowserPreset } from '../../Utils/browser-utils' + +describe('browser-utils', () => { + describe('Browsers', () => { + describe('ubuntu', () => { + it('should return correct tuple for Ubuntu', () => { + const result = Browsers.ubuntu('Chrome') + expect(result).toEqual(['Ubuntu', 'Chrome', '22.04.4']) + }) + + it('should handle empty browser string', () => { + const result = Browsers.ubuntu('') + expect(result).toEqual(['Ubuntu', '', '22.04.4']) + }) + }) + + describe('macOS', () => { + it('should return correct tuple for macOS', () => { + const result = Browsers.macOS('Safari') + expect(result).toEqual(['Mac OS', 'Safari', '14.4.1']) + }) + }) + + describe('windows', () => { + it('should return correct tuple for Windows', () => { + const result = Browsers.windows('Edge') + expect(result).toEqual(['Windows', 'Edge', '10.0.22631']) + }) + }) + + describe('baileys', () => { + it('should return correct tuple for Baileys', () => { + const result = Browsers.baileys('Baileys') + expect(result).toEqual(['Baileys', 'Baileys', '6.5.0']) + }) + }) + + describe('appropriate', () => { + it('should return a valid tuple with platform detection', () => { + const result = Browsers.appropriate('MyApp') + expect(result).toHaveLength(3) + expect(typeof result[0]).toBe('string') + expect(result[1]).toBe('MyApp') + expect(typeof result[2]).toBe('string') + }) + + it('should never return undefined for platform', () => { + const result = Browsers.appropriate('Test') + expect(result[0]).toBeDefined() + expect(result[0]).not.toBe('') + // Should be a valid platform name + expect(['Ubuntu', 'Mac OS', 'Windows', 'AIX', 'Android', 'FreeBSD', 'OpenBSD', 'Solaris']).toContain(result[0]) + }) + }) + }) + + describe('getPlatformId', () => { + it('should return platform ID for valid browser string', () => { + const result = getPlatformId('chrome') + expect(result).toBe('1') + }) + + it('should be case-insensitive', () => { + const lowercase = getPlatformId('chrome') + const uppercase = getPlatformId('CHROME') + const mixed = getPlatformId('Chrome') + expect(lowercase).toBe(uppercase) + expect(uppercase).toBe(mixed) + }) + + it('should return default ID for empty string', () => { + const result = getPlatformId('') + expect(result).toBe('1') + }) + + it('should return default ID for whitespace-only string', () => { + const result = getPlatformId(' ') + expect(result).toBe('1') + }) + + it('should return default ID for unknown browser', () => { + const result = getPlatformId('unknown_browser_xyz') + expect(result).toBe('1') + }) + + it('should handle string with leading/trailing whitespace', () => { + const result = getPlatformId(' chrome ') + expect(result).toBe('1') + }) + + it('should return default ID for undefined input', () => { + // @ts-expect-error - Testing runtime behavior with invalid input + const result = getPlatformId(undefined) + expect(result).toBe('1') + }) + + it('should return default ID for null input', () => { + // @ts-expect-error - Testing runtime behavior with invalid input + const result = getPlatformId(null) + expect(result).toBe('1') + }) + + it('should return default ID for number input', () => { + // @ts-expect-error - Testing runtime behavior with invalid input + const result = getPlatformId(123) + expect(result).toBe('1') + }) + + it('should return default ID for object input', () => { + // @ts-expect-error - Testing runtime behavior with invalid input + const result = getPlatformId({ browser: 'chrome' }) + expect(result).toBe('1') + }) + + it('should always return a string', () => { + const validResult = getPlatformId('chrome') + const invalidResult = getPlatformId('nonexistent') + expect(typeof validResult).toBe('string') + expect(typeof invalidResult).toBe('string') + }) + }) + + describe('isValidBrowserPreset', () => { + it('should return true for valid preset keys', () => { + expect(isValidBrowserPreset('ubuntu')).toBe(true) + expect(isValidBrowserPreset('macOS')).toBe(true) + expect(isValidBrowserPreset('windows')).toBe(true) + expect(isValidBrowserPreset('baileys')).toBe(true) + expect(isValidBrowserPreset('appropriate')).toBe(true) + }) + + it('should return false for invalid preset keys', () => { + expect(isValidBrowserPreset('invalid')).toBe(false) + expect(isValidBrowserPreset('UBUNTU')).toBe(false) + expect(isValidBrowserPreset('Ubuntu')).toBe(false) + }) + + it('should return false for non-string values', () => { + expect(isValidBrowserPreset(undefined)).toBe(false) + expect(isValidBrowserPreset(null)).toBe(false) + expect(isValidBrowserPreset(123)).toBe(false) + expect(isValidBrowserPreset({})).toBe(false) + expect(isValidBrowserPreset([])).toBe(false) + }) + + it('should work as type guard', () => { + const input: unknown = 'ubuntu' + if (isValidBrowserPreset(input)) { + // TypeScript should allow this after the type guard + const config = Browsers[input]('Test') + expect(config).toHaveLength(3) + } + }) + }) + + describe('edge cases and robustness', () => { + it('should not throw on any Browsers method call', () => { + expect(() => Browsers.ubuntu('test')).not.toThrow() + expect(() => Browsers.macOS('test')).not.toThrow() + expect(() => Browsers.windows('test')).not.toThrow() + expect(() => Browsers.baileys('test')).not.toThrow() + expect(() => Browsers.appropriate('test')).not.toThrow() + }) + + it('should not throw on getPlatformId with any input', () => { + expect(() => getPlatformId('valid')).not.toThrow() + expect(() => getPlatformId('')).not.toThrow() + // @ts-expect-error - Testing runtime behavior + expect(() => getPlatformId(undefined)).not.toThrow() + // @ts-expect-error - Testing runtime behavior + expect(() => getPlatformId(null)).not.toThrow() + // @ts-expect-error - Testing runtime behavior + expect(() => getPlatformId({})).not.toThrow() + }) + + it('all Browsers methods should return readonly-compatible tuples', () => { + const presets = ['ubuntu', 'macOS', 'windows', 'baileys', 'appropriate'] as const + for (const preset of presets) { + const result = Browsers[preset]('Test') + expect(Array.isArray(result)).toBe(true) + expect(result.length).toBe(3) + expect(result.every(item => typeof item === 'string')).toBe(true) + } + }) + }) +})