Skip to content

Commit ad28d2b

Browse files
committed
Updated how client hints are parsed to enable brands to be prepended onto the UA string, allowing the existing parser to process them as it would any other brands. This will ensure that app names and browsers get processed as per the current rules.
Added Windows 11 detection to `hints::parse()`. Updated `versions::load()` to be able to use a stale cache if the network is not available. Updated tests.
1 parent 5a01f2b commit ad28d2b

6 files changed

Lines changed: 115 additions & 49 deletions

File tree

src/agentzero.php

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -195,14 +195,19 @@ protected static function getTokens(string $ua, array $single, array $ignore) :
195195
* @return agentzero|false An agentzero object containing the parsed values of the input UA, or false if it could not be parsed
196196
*/
197197
public static function parse(string $ua, array $hints = [], array $config = []) : agentzero|false {
198-
if (($ua = \preg_replace('/\s{2,}/', ' ', $ua)) === null) {
198+
$ua = \preg_replace('/\s{2,}/', ' ', $ua);
199199

200-
} elseif (($config = config::get($config)) === null) {
200+
// parse client hints
201+
$hinted = $ua;
202+
$browser = hints::parse($hinted, $hints);
201203

202-
} elseif (($tokens = self::getTokens(\trim($ua, ' "\''), $config['single'], $config['ignore'])) !== false) {
204+
// get config
205+
if (($config = config::get($config)) === null) {
206+
207+
// get tokens
208+
} elseif (($tokens = self::getTokens(\trim($hinted, ' "\''), $config['single'], $config['ignore'])) !== false) {
203209

204210
// extract UA info
205-
$browser = hints::parse($hints);
206211
$tokenslower = \array_map('\\mb_strtolower', $tokens);
207212
foreach ($config['match'] AS $key => $item) {
208213
$item->match($browser, $key, $tokens, $tokenslower, $config);

src/helpers/hints.php

Lines changed: 66 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,75 @@
44

55
class hints {
66

7-
public static function parse(array $hints) : \stdClass {
7+
public static function parse(string &$ua, array $hints) : \stdClass {
88
$map = [
99
'sec-ch-ua-mobile' => fn (\stdClass $obj, string $value) : string => $obj->category = $value === '?1' ? 'mobile' : 'desktop',
1010
'sec-ch-ua-platform' => fn (\stdClass $obj, string $value) : ?string => $obj->platform = \trim($value, '"') ?: null,
11-
'sec-ch-ua-platform-version' => fn (\stdClass $obj, string $value) : ?string => $obj->platformversion = \trim($value, '"') ?: null,
11+
'sec-ch-ua-platform-version' => function (\stdClass $obj, string $value) : void {
12+
$value = \trim($value, '"');
13+
if ($obj->platform === 'Windows') {
14+
$map = [
15+
'8',
16+
'10.1507',
17+
'10.1511',
18+
'10.1607',
19+
'10.1703',
20+
'10.1709',
21+
'10.1803',
22+
'10.1809',
23+
'10.1903',
24+
'10.1909',
25+
'10.2004',
26+
'10.20H2',
27+
'10.21H1',
28+
'10.21H2'
29+
];
30+
$major = \intval($value);
31+
if (isset($map[$major])) {
32+
$value = $map[$major] ?? '11';
33+
}
34+
}
35+
$obj->platformversion = $value ?: null;
36+
},
1237
'sec-ch-ua-model' => fn (\stdClass $obj, string $value) : ?string => $obj->model = \trim($value, '"') ?: null,
13-
'sec-ch-ua-full-version-list' => function (\stdClass $obj, string $value) : void {
14-
$brands = self::parseBrands($value);
15-
$item = \array_key_first($brands);
16-
$obj->browserversion = $brands[$item];
38+
'sec-ch-ua-full-version-list' => function (\stdClass $obj, string $value, string &$ua) : void {
39+
$brands = [];
40+
41+
// process brands string
42+
foreach (\explode(',', $value) AS $item) {
43+
$parts = \explode('";v="', \trim($item, ' "'));
44+
45+
// remove GREASE brands
46+
if (\strcspn($parts[0], '()-./:;=?_') === \strlen($parts[0])) {
47+
$brands[$parts[0]] = $parts[1];
48+
}
49+
}
50+
51+
// remove Chromium if Google Chrome present
52+
if (isset($brands['Chromium'], $brands['Google Chrome'])) {
53+
unset($brands['Chromium']);
54+
}
55+
56+
// sort the values in importance order
57+
$sort = ['Chromium', 'Google Chrome'];
58+
$count = \count($sort);
59+
\uksort($brands, function (string $a, string $b) use ($sort, $count) : int {
60+
$aval = $bval = $count;
61+
foreach ($sort AS $key => $item) {
62+
if ($a === $item) {
63+
$aval = $key;
64+
}
65+
if ($b === $item) {
66+
$bval = $key;
67+
}
68+
}
69+
return $aval - $bval;
70+
});
71+
72+
// add to UA string
73+
foreach ($brands AS $key => $item) {
74+
$ua = $key.'/'.$item.' '.$ua;
75+
}
1776
},
1877
'device-memory' => fn (\stdClass $obj, string $value) : int => $obj->ram = \intval(\floatval($value) * 1024),
1978
'width' => fn (\stdClass $obj, string $value) : int => $obj->width = \intval($value),
@@ -23,37 +82,9 @@ public static function parse(array $hints) : \stdClass {
2382
foreach ($hints AS $key => $item) {
2483
$key = \strtolower($key);
2584
if (isset($map[$key])) {
26-
$map[$key]($obj, $item);
85+
$map[$key]($obj, $item, $ua);
2786
}
2887
}
2988
return $obj;
3089
}
31-
32-
protected static function parseBrands(string $brand) : array {
33-
34-
// parse the brands in the string
35-
$items = [];
36-
foreach (\explode(',', $brand) AS $item) {
37-
if (\mb_stripos($item, 'brand') === false && ($pos = \mb_strrpos($item, ';')) !== false) {
38-
$items[\trim(\mb_substr($item, 0, $pos), '"; ')] = \trim(\mb_substr($item, $pos + 1), 'v="; ');
39-
}
40-
}
41-
42-
// sort the values in importance order
43-
$sort = ['Chromium', 'Chrome'];
44-
$count = \count($sort);
45-
\uksort($items, function (string $a, string $b) use ($sort, $count) : int {
46-
$aval = $bval = $count;
47-
foreach ($sort AS $key => $item) {
48-
if (\mb_stripos($a, $item)) {
49-
$aval = $key;
50-
}
51-
if (\mb_stripos($b, $item)) {
52-
$bval = $key;
53-
}
54-
}
55-
return $aval - $bval;
56-
});
57-
return $items;
58-
}
5990
}

src/helpers/versions.php

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,20 +4,25 @@
44

55
class versions {
66

7-
protected static ?array $versions = null;
7+
protected static array|false|null $versions = null;
88

9-
protected static function load(string $source, ?string $cache, int $life = 604800) : array|false {
9+
protected static function load(string $source, ?string $cache = null, ?int $life = 604800) : array|false {
1010

1111
// cache for this session
1212
$data = self::$versions;
1313
if ($data === null) {
1414

1515
// fetch from cache
16-
if (\file_exists($cache) && \filemtime($cache) < \time() - $life && ($json = \file_get_contents($cache)) !== false) {
16+
if (\file_exists($cache) && \filemtime($cache) > \time() - $life && ($json = \file_get_contents($cache)) !== false) {
1717

1818
// fetch from server
1919
} elseif (($json = \file_get_contents($source)) === false) {
20-
return false;
20+
21+
// get stale cache
22+
if ($cache !== null && ($json = \file_get_contents($cache)) !== false) {
23+
self::$versions = false;
24+
return false;
25+
}
2126

2227
// update cache
2328
} elseif ($cache !== null) {

src/mappings/browsers.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -236,7 +236,7 @@ public static function get() : array {
236236
]),
237237
'Cronet/' => new props('start', $fn['chromium']),
238238
'Chromium/' => new props('start', $fn['chromium']),
239-
'Chrome/' => new props('start', $fn['chromium']),
239+
'Chrome/' => new props('any', $fn['chromium']),
240240
'Safari/' => new props('start', $fn['safari']),
241241
'Mozilla/' => new props('start', $fn['browserslash']),
242242
'rv:' => new props('start', fn (string $value) : array => [

tests/browsersTest.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -446,7 +446,7 @@ public function testBrave() : void {
446446
'engineversion' => '601.1.46',
447447
'browser' => 'Brave',
448448
'browserversion' => '1.2.11',
449-
'browserreleased' => '2025-02-26'
449+
'browserreleased' => '2025-03-26'
450450
],
451451
'Mozilla/5.0 (Macintosh; Intel Mac OS X 13_4_1) AppleWebKit/605.1.15 (KHTML, like Gecko) Brave/115.0.0.0 Safari/605.1.15' => [
452452
'string' => 'Mozilla/5.0 (Macintosh; Intel Mac OS X 13_4_1) AppleWebKit/605.1.15 (KHTML, like Gecko) Brave/115.0.0.0 Safari/605.1.15',

tests/clientHintsTest.php

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,14 +22,39 @@ public function testClientHints() : void {
2222
'bits' => 64,
2323
'kernel' => 'Windows NT',
2424
'platform' => 'Windows',
25-
'platformversion' => '19.0.0',
25+
'platformversion' => '11.0',
2626
'engine' => 'Blink',
27-
'engineversion' => '133.0.0.0',
27+
'engineversion' => '133.0.6943.142',
2828
'browser' => 'Chrome',
2929
'browserversion' => '133.0.6943.142',
30-
'browserreleased' => '2025-02-26',
30+
'browserreleased' => '2025-02-25',
3131
'nettype' => '4g'
32-
]
32+
],
33+
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36' => [
34+
'hints' => [
35+
'sec-ch-ua-mobile' => '?0',
36+
'sec-ch-ua-platform' => '"Windows"',
37+
'sec-ch-ua-platform-version' => '"1.0.0"',
38+
'sec-ch-ua-full-version-list' => '"Not(A:Brand";v="99.0.0.0", "Google Chrome";v="133.0.6943.142", "Chromium";v="133.0.6943.142"',
39+
'device-memory' => '8',
40+
'ect' => '4g'
41+
],
42+
'string' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36',
43+
'type' => 'human',
44+
'category' => 'desktop',
45+
'ram' => 8192,
46+
'architecture' => 'x86',
47+
'bits' => 64,
48+
'kernel' => 'Windows NT',
49+
'platform' => 'Windows',
50+
'platformversion' => '10.1507',
51+
'engine' => 'Blink',
52+
'engineversion' => '133.0.6943.142',
53+
'browser' => 'Chrome',
54+
'browserversion' => '133.0.6943.142',
55+
'browserreleased' => '2025-02-25',
56+
'nettype' => '4g'
57+
],
3358
];
3459
foreach ($strings AS $ua => $item) {
3560
$this->assertEquals(\array_diff_key($item, ['hints' => '']), lib::parse($ua, $item['hints']), $ua);

0 commit comments

Comments
 (0)