feat(browser-utils): add automatic OS version detection

Implements automatic detection of OS versions at runtime instead of
hardcoded values. This ensures the library reports accurate platform
versions to WhatsApp without manual updates.

Version Detection:
- Linux: Reads /etc/os-release for distribution version (e.g., '24.04.1')
- macOS: Converts Darwin kernel version to macOS version (e.g., Darwin 24.x → macOS 15.x)
- Windows: Uses os.release() directly (already returns correct format)

Security Fixes (from PR #62 review):
- Fix isValidBrowserPreset() to use Object.prototype.hasOwnProperty.call()
  to prevent matching inherited properties like 'toString' or 'constructor'
- Fix getPlatformId() signature to accept 'unknown' type for runtime safety

Other Improvements:
- Add FALLBACK_VERSIONS constant with updated 2025 versions
- Add DARWIN_TO_MACOS mapping for accurate macOS version conversion
- Export detectedOSVersions for debugging and logging
- Improve JSDoc documentation with accurate examples
- Add 36 comprehensive unit tests (up from 25)

The version is detected once at module load and cached for consistent
behavior throughout the application lifecycle.

https://claude.ai/code/session_018XbFWEYwCfeeXioFDLvSXV
This commit is contained in:
Claude
2026-01-30 15:26:46 +00:00
parent 10aad67861
commit d2e599a617
2 changed files with 348 additions and 37 deletions
+243 -25
View File
@@ -1,4 +1,5 @@
import { platform, release } from 'os'
import { readFileSync, existsSync } from 'fs'
import { proto } from '../../WAProto/index.js'
import type { BrowsersMap } from '../Types'
@@ -17,6 +18,40 @@ const DEFAULT_PLATFORM_NAME = 'Ubuntu' as const
*/
const DEFAULT_PLATFORM_ID = '1' as const
/**
* Default OS version fallback when detection fails
*/
const DEFAULT_OS_VERSION = '1.0.0' as const
/**
* Fallback versions used when automatic detection fails.
* These should be updated periodically to reflect current OS versions.
* Last updated: 2025-01
*/
const FALLBACK_VERSIONS = {
ubuntu: '24.04.1',
macOS: '15.2',
windows: '10.0.26100',
baileys: '6.5.0',
} as const
/**
* Maps Darwin kernel versions to macOS marketing versions.
* Darwin 24.x = macOS 15.x (Sequoia)
* Darwin 23.x = macOS 14.x (Sonoma)
* Darwin 22.x = macOS 13.x (Ventura)
* Darwin 21.x = macOS 12.x (Monterey)
* Darwin 20.x = macOS 11.x (Big Sur)
*/
const DARWIN_TO_MACOS: Readonly<Record<number, number>> = {
24: 15, // Sequoia
23: 14, // Sonoma
22: 13, // Ventura
21: 12, // Monterey
20: 11, // Big Sur
19: 10, // Catalina (10.15)
} as const
/**
* Maps Node.js platform identifiers to WhatsApp-recognized platform names.
* Values of `undefined` will fall back to DEFAULT_PLATFORM_NAME.
@@ -42,6 +77,9 @@ const PLATFORM_MAP: Readonly<Record<NodeJS.Platform, string | undefined>> = {
/**
* Pre-computed map of browser names to platform IDs.
* Built once at module load to avoid repeated proto access.
*
* Note: Protobuf enums have bidirectional mappings (name→value and value→name).
* We filter to only include string keys with numeric values.
*/
const BROWSER_TO_PLATFORM_ID: ReadonlyMap<string, string> = (() => {
const platformType = proto.DeviceProps?.PlatformType
@@ -51,8 +89,6 @@ const BROWSER_TO_PLATFORM_ID: ReadonlyMap<string, string> = (() => {
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()])
}
@@ -60,6 +96,151 @@ const BROWSER_TO_PLATFORM_ID: ReadonlyMap<string, string> = (() => {
return new Map(entries)
})()
// ============================================================
// Version Detection Functions
// ============================================================
/**
* Detects the Linux distribution version by reading /etc/os-release.
* This file is standard on most modern Linux distributions.
*
* @returns The VERSION_ID from os-release, or fallback version
*
* @example
* // On Ubuntu 24.04:
* getLinuxVersion() // Returns '24.04' or '24.04.1'
*/
const getLinuxVersion = (): string => {
try {
const osReleasePaths = ['/etc/os-release', '/usr/lib/os-release']
for (const filePath of osReleasePaths) {
if (existsSync(filePath)) {
const content = readFileSync(filePath, 'utf-8')
// Try VERSION_ID first (e.g., "24.04")
const versionIdMatch = content.match(/^VERSION_ID\s*=\s*"?([^"\n]+)"?/m)
if (versionIdMatch?.[1]) {
return versionIdMatch[1]
}
// Fallback to VERSION (e.g., "24.04.1 LTS (Noble Numbat)")
const versionMatch = content.match(/^VERSION\s*=\s*"?([0-9][0-9.]*)/m)
if (versionMatch?.[1]) {
return versionMatch[1]
}
}
}
} catch {
// Silently fail and use fallback
}
return FALLBACK_VERSIONS.ubuntu
}
/**
* Converts Darwin kernel version to macOS marketing version.
* Darwin versions map to macOS versions with an offset.
*
* @returns macOS version string (e.g., '15.2')
*
* @example
* // On macOS Sequoia with Darwin 24.2.0:
* getMacOSVersion() // Returns '15.2'
*/
const getMacOSVersion = (): string => {
try {
const darwinVersion = release() // e.g., '24.2.0'
const parts = darwinVersion.split('.')
const majorVersionStr = parts[0]
const minorVersionStr = parts[1]
if (!majorVersionStr) {
return FALLBACK_VERSIONS.macOS
}
const majorVersion = parseInt(majorVersionStr, 10)
if (isNaN(majorVersion)) {
return FALLBACK_VERSIONS.macOS
}
const minorVersion = minorVersionStr || '0'
const macOSMajor = DARWIN_TO_MACOS[majorVersion]
if (macOSMajor === undefined) {
// For newer Darwin versions, estimate macOS version
// Darwin 24 = macOS 15, so offset is 9
const estimatedMajor = majorVersion >= 20 ? majorVersion - 9 : 10
return `${estimatedMajor}.${minorVersion}`
}
// Special case: macOS 10.x (Catalina and earlier)
if (macOSMajor === 10) {
return `10.15.${minorVersion}`
}
return `${macOSMajor}.${minorVersion}`
} catch {
return FALLBACK_VERSIONS.macOS
}
}
/**
* Gets Windows version directly from os.release().
* Windows correctly reports version (e.g., '10.0.22631').
*
* @returns Windows version string
*/
const getWindowsVersion = (): string => {
try {
const version = release()
// Validate it looks like a Windows version (X.X.XXXXX)
if (/^\d+\.\d+\.\d+$/.test(version)) {
return version
}
} catch {
// Silently fail
}
return FALLBACK_VERSIONS.windows
}
/**
* Detects the current OS version automatically based on the platform.
* Uses platform-specific methods for accurate version detection.
*
* @returns Object containing detected versions for all platforms
*/
const detectOSVersions = (): Readonly<{
ubuntu: string
macOS: string
windows: string
baileys: string
}> => {
const currentPlatform = (() => {
try {
return platform()
} catch {
return 'linux' as NodeJS.Platform
}
})()
// Detect versions based on current platform
// For non-matching platforms, use fallback values
return {
ubuntu: currentPlatform === 'linux' ? getLinuxVersion() : FALLBACK_VERSIONS.ubuntu,
macOS: currentPlatform === 'darwin' ? getMacOSVersion() : FALLBACK_VERSIONS.macOS,
windows: currentPlatform === 'win32' ? getWindowsVersion() : FALLBACK_VERSIONS.windows,
baileys: FALLBACK_VERSIONS.baileys,
}
}
/**
* Cached OS versions, detected once at module load.
* This ensures consistent versions throughout the application lifecycle.
*/
const OS_VERSIONS = detectOSVersions()
// ============================================================
// Helper Functions
// ============================================================
@@ -82,7 +263,7 @@ const getPlatformName = (): string => {
/**
* Normalizes a browser identifier for platform type lookup.
*
* @param browser - The browser identifier to normalize
* @param browser - The browser identifier to normalize (accepts any type for runtime safety)
* @returns Uppercase trimmed string, or null if input is invalid
*/
const normalizeBrowserKey = (browser: unknown): string | null => {
@@ -94,15 +275,29 @@ const normalizeBrowserKey = (browser: unknown): string | null => {
}
/**
* Safely gets the OS release version with fallback
* Gets the appropriate OS version for the current platform.
* Uses automatic detection with fallback to sensible defaults.
*
* @returns OS release string or empty string on failure
* @returns OS version string appropriate for the current platform
*/
const safeRelease = (): string => {
const getAppropriateVersion = (): string => {
try {
return release()
const currentPlatform = platform()
switch (currentPlatform) {
case 'darwin':
return OS_VERSIONS.macOS
case 'win32':
return OS_VERSIONS.windows
case 'linux':
return OS_VERSIONS.ubuntu
default:
// For other platforms, try os.release() directly
const version = release()
return version || DEFAULT_OS_VERSION
}
} catch {
return ''
return DEFAULT_OS_VERSION
}
}
@@ -114,24 +309,35 @@ const safeRelease = (): string => {
* 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']
* Versions are automatically detected at module load:
* - Linux: Reads /etc/os-release for distribution version
* - macOS: Converts Darwin kernel version to macOS version
* - Windows: Uses os.release() directly
*
* @example
* // Use automatic platform detection
* // Use Ubuntu preset with auto-detected version
* const config = Browsers.ubuntu('Chrome')
* // Returns: ['Ubuntu', 'Chrome', '24.04.1'] (version detected automatically)
*
* @example
* // Use automatic platform and version detection
* const config = Browsers.appropriate('MyApp')
* // Returns: ['Mac OS', 'MyApp', '23.1.0'] (on macOS)
* // Returns: ['Mac OS', 'MyApp', '15.2'] (on macOS Sequoia)
*/
export const Browsers: BrowsersMap = {
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()],
ubuntu: (browser: string): [string, string, string] => ['Ubuntu', browser, OS_VERSIONS.ubuntu],
macOS: (browser: string): [string, string, string] => ['Mac OS', browser, OS_VERSIONS.macOS],
windows: (browser: string): [string, string, string] => ['Windows', browser, OS_VERSIONS.windows],
baileys: (browser: string): [string, string, string] => ['Baileys', browser, OS_VERSIONS.baileys],
appropriate: (browser: string): [string, string, string] => [getPlatformName(), browser, getAppropriateVersion()],
} as const
/**
* Exposed OS versions for debugging and logging purposes.
* These are the versions that will be used by the Browsers presets.
*/
export const detectedOSVersions = OS_VERSIONS
// ============================================================
// Exported Functions
// ============================================================
@@ -140,7 +346,11 @@ export const Browsers: BrowsersMap = {
* 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')
* This function safely handles invalid inputs (null, undefined, non-strings)
* by returning the default Chrome platform ID.
*
* @param browser - Browser identifier (e.g., 'chrome', 'firefox', 'safari').
* Accepts unknown types for runtime safety.
* @returns Platform type ID as string (defaults to '1' for Chrome)
*
* @example
@@ -148,9 +358,8 @@ export const Browsers: BrowsersMap = {
* 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 => {
export const getPlatformId = (browser: unknown): string => {
const key = normalizeBrowserKey(browser)
if (key === null) {
return DEFAULT_PLATFORM_ID
@@ -160,11 +369,20 @@ export const getPlatformId = (browser: string): string => {
}
/**
* Type guard to check if a platform string is a valid browser preset key.
* Type guard to check if a value is a valid browser preset key.
* Useful for external validation before using Browsers presets.
*
* Uses Object.prototype.hasOwnProperty to avoid matching inherited
* properties like 'toString' or 'constructor'.
*
* @param value - Value to check
* @returns True if value is a valid browser preset key
* @returns True if value is a valid browser preset key ('ubuntu', 'macOS', 'windows', 'baileys', 'appropriate')
*
* @example
* isValidBrowserPreset('ubuntu') // true
* isValidBrowserPreset('macOS') // true
* isValidBrowserPreset('invalid') // false
* isValidBrowserPreset('toString') // false (inherited property)
*
* @example
* if (isValidBrowserPreset(userInput)) {
@@ -172,5 +390,5 @@ export const getPlatformId = (browser: string): string => {
* }
*/
export const isValidBrowserPreset = (value: unknown): value is keyof BrowsersMap => {
return typeof value === 'string' && value in Browsers
return typeof value === 'string' && Object.prototype.hasOwnProperty.call(Browsers, value)
}
+105 -12
View File
@@ -1,30 +1,44 @@
import { Browsers, getPlatformId, isValidBrowserPreset } from '../../Utils/browser-utils'
import { Browsers, getPlatformId, isValidBrowserPreset, detectedOSVersions } 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'])
expect(result[0]).toBe('Ubuntu')
expect(result[1]).toBe('Chrome')
expect(result[2]).toMatch(/^\d+\.\d+/) // Version format like '24.04' or '24.04.1'
})
it('should handle empty browser string', () => {
const result = Browsers.ubuntu('')
expect(result).toEqual(['Ubuntu', '', '22.04.4'])
expect(result[0]).toBe('Ubuntu')
expect(result[1]).toBe('')
})
it('should use auto-detected or fallback version', () => {
const result = Browsers.ubuntu('Test')
// Version should be a non-empty string
expect(typeof result[2]).toBe('string')
expect(result[2].length).toBeGreaterThan(0)
})
})
describe('macOS', () => {
it('should return correct tuple for macOS', () => {
const result = Browsers.macOS('Safari')
expect(result).toEqual(['Mac OS', 'Safari', '14.4.1'])
expect(result[0]).toBe('Mac OS')
expect(result[1]).toBe('Safari')
expect(result[2]).toMatch(/^\d+\.\d+/) // Version format like '15.2'
})
})
describe('windows', () => {
it('should return correct tuple for Windows', () => {
const result = Browsers.windows('Edge')
expect(result).toEqual(['Windows', 'Edge', '10.0.22631'])
expect(result[0]).toBe('Windows')
expect(result[1]).toBe('Edge')
expect(result[2]).toMatch(/^\d+\.\d+\.\d+/) // Version format like '10.0.26100'
})
})
@@ -51,6 +65,41 @@ describe('browser-utils', () => {
// Should be a valid platform name
expect(['Ubuntu', 'Mac OS', 'Windows', 'AIX', 'Android', 'FreeBSD', 'OpenBSD', 'Solaris']).toContain(result[0])
})
it('should return a valid version string', () => {
const result = Browsers.appropriate('Test')
expect(result[2]).toBeDefined()
expect(typeof result[2]).toBe('string')
expect(result[2].length).toBeGreaterThan(0)
})
})
})
describe('detectedOSVersions', () => {
it('should expose detected versions', () => {
expect(detectedOSVersions).toBeDefined()
expect(typeof detectedOSVersions.ubuntu).toBe('string')
expect(typeof detectedOSVersions.macOS).toBe('string')
expect(typeof detectedOSVersions.windows).toBe('string')
expect(typeof detectedOSVersions.baileys).toBe('string')
})
it('should have valid version formats', () => {
// Ubuntu: X.X or X.X.X
expect(detectedOSVersions.ubuntu).toMatch(/^\d+\.\d+(\.\d+)?$/)
// macOS: X.X or X.X.X
expect(detectedOSVersions.macOS).toMatch(/^\d+\.\d+(\.\d+)?$/)
// Windows: X.X.XXXXX
expect(detectedOSVersions.windows).toMatch(/^\d+\.\d+\.\d+$/)
// Baileys: semver
expect(detectedOSVersions.baileys).toMatch(/^\d+\.\d+\.\d+$/)
})
it('should match Browsers preset versions', () => {
expect(Browsers.ubuntu('Test')[2]).toBe(detectedOSVersions.ubuntu)
expect(Browsers.macOS('Test')[2]).toBe(detectedOSVersions.macOS)
expect(Browsers.windows('Test')[2]).toBe(detectedOSVersions.windows)
expect(Browsers.baileys('Test')[2]).toBe(detectedOSVersions.baileys)
})
})
@@ -89,34 +138,37 @@ describe('browser-utils', () => {
})
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 return default ID for array input', () => {
const result = getPlatformId(['chrome'])
expect(result).toBe('1')
})
it('should always return a string', () => {
const validResult = getPlatformId('chrome')
const invalidResult = getPlatformId('nonexistent')
const undefinedResult = getPlatformId(undefined)
expect(typeof validResult).toBe('string')
expect(typeof invalidResult).toBe('string')
expect(typeof undefinedResult).toBe('string')
})
})
@@ -133,6 +185,7 @@ describe('browser-utils', () => {
expect(isValidBrowserPreset('invalid')).toBe(false)
expect(isValidBrowserPreset('UBUNTU')).toBe(false)
expect(isValidBrowserPreset('Ubuntu')).toBe(false)
expect(isValidBrowserPreset('MacOS')).toBe(false)
})
it('should return false for non-string values', () => {
@@ -141,6 +194,17 @@ describe('browser-utils', () => {
expect(isValidBrowserPreset(123)).toBe(false)
expect(isValidBrowserPreset({})).toBe(false)
expect(isValidBrowserPreset([])).toBe(false)
expect(isValidBrowserPreset(true)).toBe(false)
})
// Critical security test: inherited properties should not match
it('should return false for inherited Object properties (security)', () => {
expect(isValidBrowserPreset('toString')).toBe(false)
expect(isValidBrowserPreset('constructor')).toBe(false)
expect(isValidBrowserPreset('valueOf')).toBe(false)
expect(isValidBrowserPreset('hasOwnProperty')).toBe(false)
expect(isValidBrowserPreset('__proto__')).toBe(false)
expect(isValidBrowserPreset('prototype')).toBe(false)
})
it('should work as type guard', () => {
@@ -165,12 +229,11 @@ describe('browser-utils', () => {
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()
expect(() => getPlatformId([])).not.toThrow()
expect(() => getPlatformId(Symbol('test'))).not.toThrow()
})
it('all Browsers methods should return readonly-compatible tuples', () => {
@@ -182,5 +245,35 @@ describe('browser-utils', () => {
expect(result.every(item => typeof item === 'string')).toBe(true)
}
})
it('version strings should never be empty', () => {
const presets = ['ubuntu', 'macOS', 'windows', 'baileys', 'appropriate'] as const
for (const preset of presets) {
const result = Browsers[preset]('Test')
expect(result[2].length).toBeGreaterThan(0)
}
})
})
describe('version detection', () => {
it('should detect a reasonable Ubuntu version', () => {
const version = detectedOSVersions.ubuntu
// Should be at least 18.04 (oldest supported LTS)
const majorVersion = parseFloat(version)
expect(majorVersion).toBeGreaterThanOrEqual(18)
})
it('should detect a reasonable macOS version', () => {
const version = detectedOSVersions.macOS
// Should be at least macOS 10.x or 11+
const majorVersion = parseFloat(version)
expect(majorVersion).toBeGreaterThanOrEqual(10)
})
it('should detect a reasonable Windows version', () => {
const version = detectedOSVersions.windows
// Should start with 10. (Windows 10/11)
expect(version).toMatch(/^10\./)
})
})
})