diff --git a/composer.json b/composer.json index bdab78c..03d67db 100644 --- a/composer.json +++ b/composer.json @@ -44,6 +44,7 @@ "twig/twig": "^3.8" }, "require-dev": { + "ext-sockets": "*", "dbrekelmans/bdi": "^1.1", "infection/infection": "^0.28", "phpstan/extension-installer": "^1.1", diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index f78b5f7..b640631 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -107,9 +107,24 @@ parameters: - message: "#^Cannot cast mixed to string\\.$#" + count: 2 + path: src/Command/CreateScreenshotCommand.php + + - + message: "#^Method SpomkyLabs\\\\PwaBundle\\\\Command\\\\CreateScreenshotCommand\\:\\:getDefaultArguments\\(\\) return type has no value type specified in iterable type array\\.$#" + count: 1 + path: src/Command/CreateScreenshotCommand.php + + - + message: "#^Only booleans are allowed in a negated boolean, mixed given\\.$#" count: 1 path: src/Command/CreateScreenshotCommand.php + - + message: "#^Only booleans are allowed in an if condition, mixed given\\.$#" + count: 3 + path: src/Command/CreateScreenshotCommand.php + - message: "#^Parameter \\#1 \\$image of method SpomkyLabs\\\\PwaBundle\\\\ImageProcessor\\\\ImageProcessorInterface\\:\\:getSizes\\(\\) expects string, string\\|false given\\.$#" count: 1 diff --git a/src/Command/CreateScreenshotCommand.php b/src/Command/CreateScreenshotCommand.php index 6a14df5..7d5bdab 100644 --- a/src/Command/CreateScreenshotCommand.php +++ b/src/Command/CreateScreenshotCommand.php @@ -20,6 +20,7 @@ use Symfony\Component\Panther\Client; use Symfony\Component\Yaml\Yaml; use Throwable; +use function assert; use function count; #[AsCommand( @@ -28,8 +29,6 @@ )] final class CreateScreenshotCommand extends Command { - private readonly Client $webClient; - public function __construct( private readonly AssetMapperInterface $assetMapper, private readonly Filesystem $filesystem, @@ -37,13 +36,11 @@ public function __construct( private readonly string $projectDir, private readonly null|ImageProcessorInterface $imageProcessor, #[Autowire('@pwa.web_client')] - null|Client $webClient = null, + private readonly null|Client $webClient = null, + #[Autowire(param: 'spomky_labs_pwa.screenshot_user_agent')] + private readonly null|string $userAgent = null, ) { parent::__construct(); - if ($webClient === null) { - $webClient = Client::createChromeClient(); - } - $this->webClient = $webClient; } public function isEnabled(): bool @@ -95,12 +92,16 @@ protected function execute(InputInterface $input, OutputInterface $output): int $width = $input->getOption('width'); $format = $input->getOption('format'); - $client = clone $this->webClient; + $client = $this->getClient(); $crawler = $client->request('GET', $url); $tmpName = $this->filesystem ->tempnam('', 'pwa-'); if ($width !== null && $height !== null) { + if ($width < 0 || $height < 0) { + $io->error('Width and height must be positive integers.'); + return self::FAILURE; + } $client->manage() ->window() ->setSize(new WebDriverDimension((int) $width, (int) $height)); @@ -168,4 +169,59 @@ protected function execute(InputInterface $input, OutputInterface $output): int return self::SUCCESS; } + + private function getAvailablePort(): int + { + $socket = socket_create_listen(0); + assert($socket !== false, 'Unable to create a socket.'); + socket_getsockname($socket, $address, $port); + socket_close($socket); + + return $port; + } + + private function getDefaultArguments(): array + { + $args = []; + + if (! ($_SERVER['PANTHER_NO_HEADLESS'] ?? false)) { + $args[] = '--headless'; + $args[] = '--window-size=1200,1100'; + $args[] = '--disable-gpu'; + } + + if ($_SERVER['PANTHER_DEVTOOLS'] ?? true) { + $args[] = '--auto-open-devtools-for-tabs'; + } + + if ($_SERVER['PANTHER_NO_SANDBOX'] ?? $_SERVER['HAS_JOSH_K_SEAL_OF_APPROVAL'] ?? false) { + $args[] = '--no-sandbox'; + } + + if ($_SERVER['PANTHER_CHROME_ARGUMENTS'] ?? false) { + $arguments = explode(' ', (string) $_SERVER['PANTHER_CHROME_ARGUMENTS']); + $args = array_merge($args, $arguments); + } + + return $args; + } + + private function getClient(): Client + { + if ($this->webClient !== null) { + return clone $this->webClient; + } + $options = [ + 'port' => $this->getAvailablePort(), + 'capabilities' => [ + 'acceptInsecureCerts' => true, + ], + ]; + $arguments = $this->getDefaultArguments(); + if ($this->userAgent !== null) { + $arguments[] = sprintf('--user-agent=%s', $this->userAgent); + } + + return Client::createChromeClient(arguments: $arguments, options: $options); + } } diff --git a/src/EventSubscriber/ScreenshotSubscriber.php b/src/EventSubscriber/ScreenshotSubscriber.php new file mode 100644 index 0000000..c164f08 --- /dev/null +++ b/src/EventSubscriber/ScreenshotSubscriber.php @@ -0,0 +1,47 @@ + 'onRequest', + ]; + } + + public function onRequest(RequestEvent $event): void + { + if (! $event->isMainRequest() || $this->profiler === null) { + return; + } + + $userAgent = $event->getRequest() + ->headers->get('user-agent'); + if ($userAgent === null) { + return; + } + $userAgentToFind = $this->userAgent ?? 'HeadlessChrome'; + if (! str_contains($userAgent, $userAgentToFind)) { + return; + } + + $this->profiler->disable(); + } +} diff --git a/src/Resources/config/definition/web_client.php b/src/Resources/config/definition/web_client.php index fae787b..f1673d1 100644 --- a/src/Resources/config/definition/web_client.php +++ b/src/Resources/config/definition/web_client.php @@ -11,5 +11,11 @@ ->defaultNull() ->info('The Panther Client for generating screenshots. If not set, the default client will be used.') ->end() + ->scalarNode('user_agent') + ->defaultNull() + ->info( + 'The user agent to use when generating screenshots. If not set, the default user agent will be used. When requesting the current application in an environment other than "prod", the profiler will be disabled.' + ) + ->end() ->end(); }; diff --git a/src/Resources/config/services.php b/src/Resources/config/services.php index fb2a613..f11b9ea 100644 --- a/src/Resources/config/services.php +++ b/src/Resources/config/services.php @@ -12,6 +12,7 @@ use SpomkyLabs\PwaBundle\DataCollector\PwaCollector; use SpomkyLabs\PwaBundle\Dto\Manifest; use SpomkyLabs\PwaBundle\Dto\ServiceWorker; +use SpomkyLabs\PwaBundle\EventSubscriber\ScreenshotSubscriber; use SpomkyLabs\PwaBundle\ImageProcessor\GDImageProcessor; use SpomkyLabs\PwaBundle\ImageProcessor\ImagickImageProcessor; use SpomkyLabs\PwaBundle\MatchCallbackHandler\MatchCallbackHandlerInterface; @@ -142,6 +143,7 @@ '$urls' => abstract_arg('urls'), ]) ; + $container->set(ScreenshotSubscriber::class); if ($configurator->env() !== 'prod') { $container->set(PwaCollector::class) diff --git a/src/SpomkyLabsPwaBundle.php b/src/SpomkyLabsPwaBundle.php index 77e87f4..6dd5843 100644 --- a/src/SpomkyLabsPwaBundle.php +++ b/src/SpomkyLabsPwaBundle.php @@ -37,6 +37,8 @@ public function loadExtension(array $config, ContainerConfigurator $container, C if ($config['web_client'] !== null) { $builder->setAlias('pwa.web_client', $config['web_client']); } + $builder->setParameter('spomky_labs_pwa.screenshot_user_agent', $config['user_agent']); + $serviceWorkerConfig = $config['serviceworker']; $manifestConfig = $config['manifest']; if ($serviceWorkerConfig['enabled'] === true && $manifestConfig['enabled'] === true) {