'Unknown', 'os_version' => '', 'browser' => 'Unknown', 'browser_version' => '', ]; } [$os, $osVersion] = self::parseOs($ua); [$browser, $browserVersion] = self::parseBrowser($ua); // iOS 26+ Safari still reports "iPhone OS 18_7" for compatibility. // When Version/ is 26+ and the OS token is 18.7, use Version/ for both fields. $compatVersion = self::ios26CompatVersion($ua); if ($compatVersion !== null && self::isIos187CompatToken($os, $osVersion)) { $osVersion = $compatVersion; $browserVersion = $compatVersion; } return [ 'os' => $os, 'os_version' => $osVersion, 'browser' => $browser, 'browser_version' => $browserVersion, ]; } private static function isIos187CompatToken(string $os, string $osVersion): bool { if (! in_array($os, ['iOS', 'iPadOS'], true)) { return false; } return $osVersion === '18.7' || str_starts_with($osVersion, '18.7.'); } private static function ios26CompatVersion(string $ua): ?string { if (! preg_match('/Version\/(\d+(?:\.\d+){0,2})/i', $ua, $m)) { return null; } $major = (int) explode('.', $m[1])[0]; if ($major < 26) { return null; } return $m[1]; } /** * @return array{0: string, 1: string} */ private static function parseOs(string $ua): array { if (preg_match('/iPhone OS (\d+)[._](\d+)(?:[._](\d+))?/i', $ua, $m) || preg_match('/CPU OS (\d+)[._](\d+)(?:[._](\d+))?.*like Mac OS X/i', $ua, $m)) { $ver = $m[1].'.'.$m[2].(isset($m[3]) && $m[3] !== '' ? '.'.$m[3] : ''); return ['iOS', $ver]; } if (preg_match('/iPad.*OS (\d+)[._](\d+)/i', $ua, $m)) { return ['iPadOS', $m[1].'.'.$m[2]]; } if (preg_match('/Android (\d+(?:\.\d+){0,2})/i', $ua, $m)) { return ['Android', $m[1]]; } if (preg_match('/Windows NT (\d+\.\d+)/i', $ua, $m)) { $map = [ '10.0' => '10/11', '6.3' => '8.1', '6.2' => '8', '6.1' => '7', ]; return ['Windows', $map[$m[1]] ?? $m[1]]; } if (preg_match('/Mac OS X (\d+)[._](\d+)(?:[._](\d+))?/i', $ua, $m)) { $ver = $m[1].'.'.$m[2].(isset($m[3]) && $m[3] !== '' ? '.'.$m[3] : ''); return ['macOS', $ver]; } if (preg_match('/Linux/i', $ua)) { return ['Linux', '']; } return ['Unknown', '']; } /** * @return array{0: string, 1: string} */ private static function parseBrowser(string $ua): array { // Order matters: more specific tokens first (iOS Chrome/Firefox/Edge before Safari). $rules = [ ['CriOS', 'Chrome'], ['Chrome', 'Chrome'], ['EdgiOS', 'Edge'], ['Edg/', 'Edge'], ['FxiOS', 'Firefox'], ['Firefox', 'Firefox'], ['OPT/', 'Opera'], ['OPiOS', 'Opera'], ['Opera', 'Opera'], ['SamsungBrowser', 'Samsung'], ['UCBrowser', 'UC'], ['Version/', 'Safari'], // Safari reports Version/x before Safari/ ['Safari', 'Safari'], ]; foreach ($rules as [$token, $name]) { if ($token === 'Version/' && ! preg_match('/Safari/i', $ua)) { continue; } if ($token === 'Safari' && preg_match('/CriOS|FxiOS|EdgiOS|Chrome|Android/i', $ua)) { continue; } $pattern = $token === 'Version/' || str_ends_with($token, '/') ? '/'.preg_quote(rtrim($token, '/'), '/').'\/(\d+(?:\.\d+){0,2})/i' : '/'.preg_quote($token, '/').'[\/\s]?(\d+(?:\.\d+){0,2})?/i'; if (preg_match($pattern, $ua, $m)) { return [$name, $m[1] ?? '']; } } return ['Unknown', '']; } }