Skip to content

Commit dc05676

Browse files
authored
Merge pull request #21 from he4rt/chore/platform-enum
chore(emulation): add platform enum for cross-platform header generation
2 parents f8853c9 + 0b06083 commit dc05676

5 files changed

Lines changed: 223 additions & 0 deletions

File tree

src/ClientBuilder.php

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
use Reqxide\Contract\TransportInterface;
1111
use Reqxide\Cookie\CookieJar;
1212
use Reqxide\Emulation\Browser;
13+
use Reqxide\Emulation\Platform;
1314
use Reqxide\Emulation\Profile;
1415
use Reqxide\Middleware\CompressionMiddleware;
1516
use Reqxide\Middleware\CookieMiddleware;
@@ -27,6 +28,8 @@ final class ClientBuilder
2728

2829
private ?Profile $profile = null;
2930

31+
private ?Platform $platform = null;
32+
3033
private ?TransportInterface $transport = null;
3134

3235
private ?Proxy $proxy = null;
@@ -64,6 +67,13 @@ public function profile(Profile $profile): self
6467
return $this;
6568
}
6669

70+
public function platform(Platform $platform): self
71+
{
72+
$this->platform = $platform;
73+
74+
return $this;
75+
}
76+
6777
public function transport(TransportInterface $transport): self
6878
{
6979
$this->transport = $transport;
@@ -145,6 +155,11 @@ public function build(): Client
145155
// Resolve profile
146156
$profile = $this->profile ?? $this->browser?->profile() ?? new Profile;
147157

158+
// Apply platform overrides to profile headers
159+
if ($this->platform instanceof Platform) {
160+
$profile = $this->applyPlatform($profile, $this->platform);
161+
}
162+
148163
// Resolve transport
149164
$transport = $this->transport ?? TransportFactory::create();
150165

@@ -172,6 +187,62 @@ public function build(): Client
172187
);
173188
}
174189

190+
private function applyPlatform(Profile $profile, Platform $platform): Profile
191+
{
192+
$headers = $profile->defaultHeaders;
193+
194+
if (isset($headers['User-Agent'])) {
195+
$headers['User-Agent'] = $this->rewriteUserAgent($headers['User-Agent'], $platform);
196+
}
197+
198+
if (isset($headers['sec-ch-ua-platform'])) {
199+
$headers['sec-ch-ua-platform'] = $platform->secChUaPlatform();
200+
}
201+
202+
if (isset($headers['sec-ch-ua-mobile'])) {
203+
$headers['sec-ch-ua-mobile'] = $platform->isMobile() ? '?1' : '?0';
204+
}
205+
206+
return new Profile(
207+
tlsOptions: $profile->tlsOptions,
208+
http2Options: $profile->http2Options,
209+
http1Options: $profile->http1Options,
210+
defaultHeaders: $headers,
211+
originalHeaderMap: $profile->originalHeaderMap,
212+
connectionGroup: $profile->connectionGroup,
213+
impersonateTarget: $profile->impersonateTarget,
214+
);
215+
}
216+
217+
private function rewriteUserAgent(string $ua, Platform $platform): string
218+
{
219+
$platformString = $platform->userAgentPlatform();
220+
221+
// Chrome/Edge: Mozilla/5.0 ({platform}) AppleWebKit/...
222+
if (preg_match('#^(Mozilla/5\.0 \()([^)]+)(\) AppleWebKit/537\.36.+)$#', $ua, $m)) {
223+
if ($platform === Platform::IOS) {
224+
$suffix = preg_replace('#Chrome/[\d.]+#', 'CriOS/$0', $m[3]) ?? $m[3];
225+
$suffix = str_replace('CriOS/CriOS/', 'CriOS/', $suffix);
226+
227+
return $m[1].$platformString.$suffix;
228+
}
229+
230+
return $m[1].$platformString.$m[3];
231+
}
232+
233+
// Firefox: Mozilla/5.0 ({platform}; rv:{ver}) Gecko/...
234+
if (preg_match('#^(Mozilla/5\.0 \()([^;]+(?:;[^)]*)?)(; rv:[\d.]+\) Gecko/.+)$#', $ua, $m)) {
235+
return $m[1].$platformString.$m[3];
236+
}
237+
238+
// Safari: Mozilla/5.0 ({platform}) AppleWebKit/605...
239+
if (preg_match('#^(Mozilla/5\.0 \()([^)]+)(\) AppleWebKit/605.+)$#', $ua, $m)) {
240+
return $m[1].$platformString.$m[3];
241+
}
242+
243+
return $ua;
244+
}
245+
175246
/** @return list<MiddlewareInterface> */
176247
private function buildMiddlewares(): array
177248
{

src/Emulation/Browser.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,4 +115,11 @@ public function impersonateTarget(): ?string
115115
default => null,
116116
};
117117
}
118+
119+
public static function random(): self
120+
{
121+
$cases = self::cases();
122+
123+
return $cases[array_rand($cases)];
124+
}
118125
}

src/Emulation/Platform.php

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Reqxide\Emulation;
6+
7+
enum Platform: string
8+
{
9+
case MacOS = 'macos';
10+
case Windows = 'windows';
11+
case Linux = 'linux';
12+
case Android = 'android';
13+
case IOS = 'ios';
14+
15+
public function secChUaPlatform(): string
16+
{
17+
return match ($this) {
18+
self::MacOS => '"macOS"',
19+
self::Windows => '"Windows"',
20+
self::Linux => '"Linux"',
21+
self::Android => '"Android"',
22+
self::IOS => '"iOS"',
23+
};
24+
}
25+
26+
public function isMobile(): bool
27+
{
28+
return match ($this) {
29+
self::Android, self::IOS => true,
30+
default => false,
31+
};
32+
}
33+
34+
public function userAgentPlatform(): string
35+
{
36+
return match ($this) {
37+
self::MacOS => 'Macintosh; Intel Mac OS X 10_15_7',
38+
self::Windows => 'Windows NT 10.0; Win64; x64',
39+
self::Linux => 'X11; Linux x86_64',
40+
self::Android => 'Linux; Android 10; K',
41+
self::IOS => 'iPhone; CPU iPhone OS 18_0 like Mac OS X',
42+
};
43+
}
44+
45+
public static function random(): self
46+
{
47+
$cases = self::cases();
48+
49+
return $cases[array_rand($cases)];
50+
}
51+
}

tests/Unit/ClientBuilderTest.php

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
use Reqxide\Contract\TransportInterface;
1212
use Reqxide\Cookie\CookieJar;
1313
use Reqxide\Emulation\Browser;
14+
use Reqxide\Emulation\Platform;
1415
use Reqxide\Emulation\Profile;
1516
use Reqxide\Proxy\Proxy;
1617
use Reqxide\Redirect\RedirectPolicy;
@@ -264,6 +265,52 @@ public function supportsHttp2Configuration(): bool
264265
expect($builder)->toBeInstanceOf(ClientBuilder::class);
265266
});
266267

268+
it('platform overrides sec-ch-ua-platform and User-Agent', function (): void {
269+
$transport = createMockTransport();
270+
$client = Client::builder()
271+
->emulation(Browser::Chrome145)
272+
->platform(Platform::Windows)
273+
->transport($transport)
274+
->build();
275+
276+
$request = new Request('GET', 'https://example.com');
277+
$client->sendRequest($request);
278+
279+
expect($transport->lastProfile->defaultHeaders['sec-ch-ua-platform'])->toBe('"Windows"')
280+
->and($transport->lastProfile->defaultHeaders['sec-ch-ua-mobile'])->toBe('?0')
281+
->and($transport->lastProfile->defaultHeaders['User-Agent'])->toContain('Windows NT 10.0');
282+
});
283+
284+
it('platform Android sets mobile flag', function (): void {
285+
$transport = createMockTransport();
286+
$client = Client::builder()
287+
->emulation(Browser::Chrome145)
288+
->platform(Platform::Android)
289+
->transport($transport)
290+
->build();
291+
292+
$request = new Request('GET', 'https://example.com');
293+
$client->sendRequest($request);
294+
295+
expect($transport->lastProfile->defaultHeaders['sec-ch-ua-mobile'])->toBe('?1')
296+
->and($transport->lastProfile->defaultHeaders['sec-ch-ua-platform'])->toBe('"Android"')
297+
->and($transport->lastProfile->defaultHeaders['User-Agent'])->toContain('Android');
298+
});
299+
300+
it('platform does not affect profile without relevant headers', function (): void {
301+
$transport = createMockTransport();
302+
$client = Client::builder()
303+
->profile(new Profile(defaultHeaders: ['X-Custom' => 'value']))
304+
->platform(Platform::Linux)
305+
->transport($transport)
306+
->build();
307+
308+
$request = new Request('GET', 'https://example.com');
309+
$client->sendRequest($request);
310+
311+
expect($transport->lastProfile->defaultHeaders)->toBe(['X-Custom' => 'value']);
312+
});
313+
267314
it('profile takes precedence over emulation', function (): void {
268315
$transport = createMockTransport();
269316
$customProfile = new Profile(defaultHeaders: ['X-Source' => 'custom-profile']);
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
use Reqxide\Emulation\Platform;
6+
7+
it('has the correct number of cases', function (): void {
8+
expect(Platform::cases())->toHaveCount(5);
9+
});
10+
11+
it('has correct backed values', function (Platform $case, string $expected): void {
12+
expect($case->value)->toBe($expected);
13+
})->with([
14+
[Platform::MacOS, 'macos'],
15+
[Platform::Windows, 'windows'],
16+
[Platform::Linux, 'linux'],
17+
[Platform::Android, 'android'],
18+
[Platform::IOS, 'ios'],
19+
]);
20+
21+
it('returns correct sec-ch-ua-platform', function (Platform $case, string $expected): void {
22+
expect($case->secChUaPlatform())->toBe($expected);
23+
})->with([
24+
[Platform::MacOS, '"macOS"'],
25+
[Platform::Windows, '"Windows"'],
26+
[Platform::Linux, '"Linux"'],
27+
[Platform::Android, '"Android"'],
28+
[Platform::IOS, '"iOS"'],
29+
]);
30+
31+
it('identifies mobile platforms', function (): void {
32+
expect(Platform::Android->isMobile())->toBeTrue()
33+
->and(Platform::IOS->isMobile())->toBeTrue()
34+
->and(Platform::MacOS->isMobile())->toBeFalse()
35+
->and(Platform::Windows->isMobile())->toBeFalse()
36+
->and(Platform::Linux->isMobile())->toBeFalse();
37+
});
38+
39+
it('returns platform-specific UA string', function (Platform $case): void {
40+
expect($case->userAgentPlatform())->toBeString()->not->toBeEmpty();
41+
})->with(Platform::cases());
42+
43+
it('random returns a valid Platform', function (): void {
44+
$platform = Platform::random();
45+
46+
expect($platform)->toBeInstanceOf(Platform::class);
47+
});

0 commit comments

Comments
 (0)