Files
InfiniteAPI/src/Utils/browser-utils.ts
T
Renato Alcara 31ac5aeb11 feat: add Android browser preset for companion device registration (#248)
* feat: add Android browser preset for companion device registration

Add Browsers.android(apiLevel) preset that identifies as an Android
companion device (SMB_ANDROID platform, ANDROID_PHONE device type).
Validated against Frida captures of real WhatsApp Android protocol.


* feat: support BAILEYS_BROWSER env var for browser selection

Allows selecting Android companion mode via environment variable
without code changes. Set BAILEYS_BROWSER=android (or android:15
for a specific API level) to register as an Android device.

* fix: auto-detect Android browser in pair code and fall back to Chrome

Pair code companion registration fails with Android browser because the
WA server rejects the ANDROID_PHONE companion_platform_id over the web
Noise protocol. When Android is detected, requestPairingCode() now
transparently uses Chrome/macOS platform values for the pair code stanza
while QR code pairing continues to work as Android (SMB_ANDROID).

* docs: add android to isValidBrowserPreset JSDoc
2026-03-02 12:02:08 -03:00

414 lines
12 KiB
TypeScript

import { existsSync, readFileSync } from 'fs'
import { platform, release } from 'os'
import { proto } from '../../WAProto/index.js'
import type { BrowsersMap } from '../Types'
// ============================================================
// 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
/**
* 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.
*/
const PLATFORM_MAP: Readonly<Record<NodeJS.Platform, string | undefined>> = {
aix: 'AIX',
android: 'Android',
cygwin: undefined,
darwin: 'Mac OS',
freebsd: 'FreeBSD',
haiku: undefined,
linux: undefined,
netbsd: undefined,
openbsd: 'OpenBSD',
sunos: 'Solaris',
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.
*
* 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
if (!platformType || typeof platformType !== 'object') {
return new Map<string, string>()
}
const entries: Array<[string, string]> = []
for (const [key, value] of Object.entries(platformType)) {
if (typeof value === 'number' && typeof key === 'string' && !/^\d+$/.test(key)) {
entries.push([key.toUpperCase(), value.toString()])
}
}
const map = new Map(entries)
// ANDROID → ANDROID_PHONE alias (DeviceProps.PlatformType has ANDROID_PHONE but not ANDROID)
if (!map.has('ANDROID') && map.has('ANDROID_PHONE')) {
map.set('ANDROID', map.get('ANDROID_PHONE')!)
}
return map
})()
// ============================================================
// 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
// ============================================================
/**
* 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 (accepts any type for runtime safety)
* @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
}
/**
* Gets the appropriate OS version for the current platform.
* Uses automatic detection with fallback to sensible defaults.
*
* @returns OS version string appropriate for the current platform
*/
const getAppropriateVersion = (): string => {
try {
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 DEFAULT_OS_VERSION
}
}
// ============================================================
// Exported Constants
// ============================================================
/**
* Browser configuration presets for WhatsApp device registration.
* Each factory returns a tuple of [platform, browser, version].
*
* 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 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', '15.2'] (on macOS Sequoia)
*/
export const Browsers: BrowsersMap = {
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()],
android: (apiLevel: string): [string, string, string] => [apiLevel, 'Android', '']
} 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
// ============================================================
/**
* Resolves the platform type ID for a given browser name.
* Uses the WhatsApp protocol buffer definitions for mapping.
*
* 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
* getPlatformId('chrome') // Returns '1'
* getPlatformId('CHROME') // Returns '1' (case-insensitive)
* getPlatformId('firefox') // Returns platform-specific ID
* getPlatformId('') // Returns '1' (default)
*/
export const getPlatformId = (browser: unknown): string => {
const key = normalizeBrowserKey(browser)
if (key === null) {
return DEFAULT_PLATFORM_ID
}
return BROWSER_TO_PLATFORM_ID.get(key) ?? DEFAULT_PLATFORM_ID
}
/**
* 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 ('ubuntu', 'macOS', 'windows', 'baileys', 'appropriate', 'android')
*
* @example
* isValidBrowserPreset('ubuntu') // true
* isValidBrowserPreset('macOS') // true
* isValidBrowserPreset('invalid') // false
* isValidBrowserPreset('toString') // false (inherited property)
*
* @example
* if (isValidBrowserPreset(userInput)) {
* const config = Browsers[userInput]('MyApp')
* }
*/
/**
* Checks if the browser tuple represents an Android companion device.
*
* @param browser - Browser tuple [os, platform, version]
* @returns True if platform is 'Android' (case-insensitive)
*/
export const isAndroidBrowser = (browser: [string, string, string]): boolean => {
return browser[1]?.toUpperCase() === 'ANDROID'
}
export const isValidBrowserPreset = (value: unknown): value is keyof BrowsersMap => {
return typeof value === 'string' && Object.prototype.hasOwnProperty.call(Browsers, value)
}