diff --git a/composer.json b/composer.json index 25535d4..6726da3 100644 --- a/composer.json +++ b/composer.json @@ -30,10 +30,8 @@ "symfony/asset-mapper": "^6.4|^7.0", "symfony/config": "^6.4|^7.0", "symfony/dependency-injection": "^6.4|^7.0", - "symfony/filesystem": "^6.4|^7.0", "symfony/finder": "^6.4|^7.0", "symfony/http-kernel": "^6.4|^7.0", - "symfony/mime": "^6.4|^7.0", "symfony/property-access": "^6.4|^7.0", "symfony/property-info": "^7.0", "symfony/routing": "^6.4|^7.0", @@ -51,7 +49,9 @@ "phpstan/phpstan-strict-rules": "^1.0", "phpunit/phpunit": "^10.0", "rector/rector": "^0.19", + "symfony/filesystem": "^6.4|^7.0", "symfony/framework-bundle": "^6.4|^7.0", + "symfony/mime": "^6.4|^7.0", "symfony/panther": "^2.1", "symfony/phpunit-bridge": "^6.4|^7.0", "symfony/yaml": "^6.4|^7.0", @@ -67,6 +67,8 @@ "suggest": { "ext-gd": "Required to generate icons (or Imagick).", "ext-imagick": "Required to generate icons (or GD).", + "symfony/mime": "For generating and manipulating icons or screenshots", + "symfony/filesystem": "For generating and manipulating icons or screenshots", "symfony/panther": "For generating screenshots directly from your application" } } diff --git a/src/Command/GenerateIconsCommand.php b/src/Command/GenerateIconsCommand.php index 3130c3e..11312da 100644 --- a/src/Command/GenerateIconsCommand.php +++ b/src/Command/GenerateIconsCommand.php @@ -30,6 +30,7 @@ protected function configure(): void { $this->addArgument('source', InputArgument::REQUIRED, 'The source image'); $this->addArgument('output', InputArgument::REQUIRED, 'The output directory'); + $this->addArgument('filename', InputArgument::OPTIONAL, 'The output directory', 'icon'); $this->addOption('format', null, InputOption::VALUE_OPTIONAL, 'The format of the icons'); $this->addArgument( 'sizes', @@ -46,6 +47,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $source = $input->getArgument('source'); $dest = $input->getArgument('output'); + $filename = $input->getArgument('filename'); $format = $input->getOption('format'); $sizes = $input->getArgument('sizes'); @@ -73,8 +75,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int foreach ($sizes as $size) { $io->info('Generating icon ' . $size . 'x' . $size . '...'); $tmp = $this->imageProcessor->process(file_get_contents($source), (int) $size, (int) $size, $format); - $filename = sprintf('%s/icon-%sx%s.%s', $dest, $size, $size, $format); - $this->filesystem->dumpFile($filename, $tmp); + $this->filesystem->dumpFile(sprintf('%s/%s-%sx%s.%s', $dest, $filename, $size, $size, $format), $tmp); $io->info('Icon ' . $size . 'x' . $size . ' generated.'); } $io->info('Done.'); diff --git a/src/Command/TakeScreenshotCommand.php b/src/Command/TakeScreenshotCommand.php index 38053ca..f7c76a0 100644 --- a/src/Command/TakeScreenshotCommand.php +++ b/src/Command/TakeScreenshotCommand.php @@ -14,7 +14,9 @@ use Symfony\Component\Console\Style\SymfonyStyle; use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\Filesystem\Filesystem; +use Symfony\Component\Mime\MimeTypes; use Symfony\Component\Panther\Client; +use function count; #[AsCommand( name: 'pwa:take-screenshot', @@ -44,7 +46,14 @@ public function isEnabled(): bool protected function configure(): void { $this->addArgument('url', InputArgument::REQUIRED, 'The URL to take a screenshot from'); - $this->addArgument('output', InputArgument::REQUIRED, 'The output file'); + $this->addArgument('output', InputArgument::REQUIRED, 'The output directory of the screenshot'); + $this->addArgument( + 'filename', + InputArgument::OPTIONAL, + 'The output name of the screenshot', + 'screenshot', + ['homeage-android', 'feature1'] + ); $this->addOption('width', null, InputOption::VALUE_OPTIONAL, 'The width of the screenshot'); $this->addOption('height', null, InputOption::VALUE_OPTIONAL, 'The height of the screenshot'); } @@ -72,7 +81,28 @@ protected function execute(InputInterface $input, OutputInterface $output): int ->fullscreen(); $client->takeScreenshot($tmpName); - $this->filesystem->copy($tmpName, $input->getArgument('output'), true); + $mime = MimeTypes::getDefault(); + $mimeType = $mime->guessMimeType($tmpName); + $extensions = $mime->getExtensions($mimeType); + if (count($extensions) === 0) { + $io->error(sprintf('Unable to guess the extension for the mime type "%s".', $mimeType)); + return self::FAILURE; + } + $sizes = ''; + if ($width !== null && $height !== null) { + $sizes = sprintf('-%dx%d', (int) $width, (int) $height); + } + + $format = current($extensions); + $filename = sprintf( + '%s/%s%s.%s', + $input->getArgument('output'), + $input->getArgument('filename'), + $sizes, + $format + ); + + $this->filesystem->copy($tmpName, $filename, true); $this->filesystem->remove($tmpName); $io->success('Screenshot saved'); diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index f38342e..3fdc6ce 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -313,6 +313,12 @@ private function setupSimpleOptions(ArrayNodeDefinition $node): void ->thenInvalid('Invalid path type reference "%s".') ->end() ->end() + ->scalarNode('manifest_public_url') + ->defaultValue('/site.manifest') + ->cannotBeEmpty() + ->info('The public URL of the manifest file.') + ->example('/site.manifest') + ->end() ->scalarNode('image_processor') ->defaultNull() ->info('The image processor to use to generate the icons of different sizes.') diff --git a/src/DependencyInjection/SpomkyLabsPwaExtension.php b/src/DependencyInjection/SpomkyLabsPwaExtension.php index c9a685a..403e267 100644 --- a/src/DependencyInjection/SpomkyLabsPwaExtension.php +++ b/src/DependencyInjection/SpomkyLabsPwaExtension.php @@ -5,12 +5,14 @@ namespace SpomkyLabs\PwaBundle\DependencyInjection; use SpomkyLabs\PwaBundle\ImageProcessor\ImageProcessor; +use SpomkyLabs\PwaBundle\Subscriber\PwaDevServerSubscriber; use Symfony\Component\Config\Definition\ConfigurationInterface; use Symfony\Component\Config\Definition\Processor; use Symfony\Component\Config\FileLocator; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Loader\PhpFileLoader; use Symfony\Component\HttpKernel\DependencyInjection\Extension; +use function in_array; final class SpomkyLabsPwaExtension extends Extension { @@ -35,9 +37,18 @@ public function load(array $configs, ContainerBuilder $container): void $container->setAlias('pwa.web_client', $config['web_client']); } $container->setParameter('spomky_labs_pwa.routes.reference_type', $config['path_type_reference']); - unset($config['image_processor'], $config['web_client'], $config['path_type_reference']); - + $container->setParameter('spomky_labs_pwa.manifest_public_url', $config['manifest_public_url']); + + unset( + $config['image_processor'], + $config['web_client'], + $config['path_type_reference'], + $config['manifest_public_url'], + ); $container->setParameter('spomky_labs_pwa.config', $config); + if (! in_array($container->getParameter('kernel.environment'), ['dev', 'test'], true)) { + $container->removeDefinition(PwaDevServerSubscriber::class); + } } public function getConfiguration(array $config, ContainerBuilder $container): ConfigurationInterface diff --git a/src/Normalizer/IconNormalizer.php b/src/Normalizer/IconNormalizer.php index 2c6066c..77bc4af 100644 --- a/src/Normalizer/IconNormalizer.php +++ b/src/Normalizer/IconNormalizer.php @@ -38,6 +38,7 @@ public function normalize(mixed $object, string $format = null, array $context = $data, static fn ($value) => ($value !== null && $value !== []) ); + return $cleanup($result); } diff --git a/src/Normalizer/ScreenshotNormalizer.php b/src/Normalizer/ScreenshotNormalizer.php index a58d44c..1ab2172 100644 --- a/src/Normalizer/ScreenshotNormalizer.php +++ b/src/Normalizer/ScreenshotNormalizer.php @@ -5,14 +5,18 @@ namespace SpomkyLabs\PwaBundle\Normalizer; use SpomkyLabs\PwaBundle\Dto\Screenshot; +use SpomkyLabs\PwaBundle\ImageProcessor\ImageProcessor; use Symfony\Component\AssetMapper\AssetMapperInterface; +use Symfony\Component\AssetMapper\MappedAsset; +use Symfony\Component\Mime\MimeTypes; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; use function assert; final readonly class ScreenshotNormalizer implements NormalizerInterface { public function __construct( - private AssetMapperInterface $assetMapper + private AssetMapperInterface $assetMapper, + private null|ImageProcessor $imageProcessor, ) { } @@ -20,25 +24,24 @@ public function normalize(mixed $object, string $format = null, array $context = { assert($object instanceof Screenshot); $url = null; + $asset = null; if (! str_starts_with($object->src, '/')) { - $url = $this->assetMapper->getAsset($object->src)?->publicPath; + $asset = $this->assetMapper->getAsset($object->src); + $url = $asset?->publicPath; } if ($url === null) { $url = $object->src; } - $sizes = null; - if ($object->width !== null && $object->height !== null) { - $sizes = sprintf('%dx%d', $object->width, $object->height); - } + ['sizes' => $sizes, 'formFactor' => $formFactor] = $this->getSizes($object, $asset); + $format = $this->getFormat($object, $asset); $result = [ 'src' => $url, 'sizes' => $sizes, - 'width' => $object->width, - 'form_factor' => $object->formFactor, + 'form_factor' => $formFactor, 'label' => $object->label, 'platform' => $object->platform, - 'format' => $object->format, + 'format' => $format, ]; $cleanup = static fn (array $data): array => array_filter( @@ -62,4 +65,56 @@ public function getSupportedTypes(?string $format): array Screenshot::class => true, ]; } + + /** + * @return array{sizes: string|null, formFactor: string|null} + */ + private function getSizes(Screenshot $object, null|MappedAsset $asset): array + { + if ($object->width !== null && $object->height !== null) { + return [ + 'sizes' => sprintf('%dx%d', $object->width, $object->height), + 'formFactor' => $object->formFactor ?? $this->getFormFactor($object->width, $object->height), + ]; + } + + if ($this->imageProcessor === null || $asset === null) { + return [ + 'sizes' => null, + 'formFactor' => $object->formFactor, + ]; + } + + ['width' => $width, 'height' => $height] = $this->imageProcessor->getSizes( + file_get_contents($asset->sourcePath) + ); + + return [ + 'sizes' => sprintf('%dx%d', $width, $height), + 'formFactor' => $object->formFactor ?? $this->getFormFactor($width, $height), + ]; + } + + private function getFormat(Screenshot $object, ?MappedAsset $asset): ?string + { + if ($object->format !== null) { + return $object->format; + } + + if ($this->imageProcessor === null || $asset === null) { + return null; + } + + $mime = MimeTypes::getDefault(); + return $mime->guessMimeType($asset->sourcePath); + } + + private function getFormFactor(?int $width, ?int $height): ?string + { + if ($width === null || $height === null) { + return null; + } + + return $width > $height ? 'wide' : 'narrow'; + } } diff --git a/src/Normalizer/ShortcutNormalizer.php b/src/Normalizer/ShortcutNormalizer.php index b5a36e8..89b6913 100644 --- a/src/Normalizer/ShortcutNormalizer.php +++ b/src/Normalizer/ShortcutNormalizer.php @@ -7,16 +7,20 @@ use SpomkyLabs\PwaBundle\Dto\Shortcut; use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\Routing\RouterInterface; +use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface; +use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; use function assert; use const FILTER_VALIDATE_URL; -final readonly class ShortcutNormalizer implements NormalizerInterface +final class ShortcutNormalizer implements NormalizerInterface, NormalizerAwareInterface { + use NormalizerAwareTrait; + public function __construct( - private RouterInterface $router, + private readonly RouterInterface $router, #[Autowire('%spomky_labs_pwa.routes.reference_type%')] - private int $referenceType, + private readonly int $referenceType, ) { } @@ -33,7 +37,7 @@ public function normalize(mixed $object, string $format = null, array $context = 'short_name' => $object->shortName, 'description' => $object->description, 'url' => $url, - 'icons' => $object->icons, + 'icons' => $this->normalizer->normalize($object->icons, $format, $context), ]; $cleanup = static fn (array $data): array => array_filter( diff --git a/src/Resources/config/services.php b/src/Resources/config/services.php index 51de71c..26a6c7b 100644 --- a/src/Resources/config/services.php +++ b/src/Resources/config/services.php @@ -6,6 +6,9 @@ use SpomkyLabs\PwaBundle\ImageProcessor\GDImageProcessor; use SpomkyLabs\PwaBundle\ImageProcessor\ImagickImageProcessor; use SpomkyLabs\PwaBundle\Service\Builder; +use SpomkyLabs\PwaBundle\Subscriber\PwaDevServerSubscriber; +use SpomkyLabs\PwaBundle\Twig\PwaExtension; +use SpomkyLabs\PwaBundle\Twig\PwaRuntime; use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; use function Symfony\Component\DependencyInjection\Loader\Configurator\service; @@ -46,4 +49,19 @@ ->alias('pwa.image_processor.gd', GDImageProcessor::class) ; } + + $container->set(PwaDevServerSubscriber::class) + ->args([ + '$profiler' => service('profiler') + ->nullOnInvalid(), + ]) + ->tag('kernel.event_subscriber') + ; + + $container->set(PwaExtension::class) + ->tag('twig.extension') + ; + $container->set(PwaRuntime::class) + ->tag('twig.runtime') + ; }; diff --git a/src/Subscriber/PwaDevServerSubscriber.php b/src/Subscriber/PwaDevServerSubscriber.php new file mode 100644 index 0000000..860bd6c --- /dev/null +++ b/src/Subscriber/PwaDevServerSubscriber.php @@ -0,0 +1,83 @@ +manifestPublicUrl = '/' . trim($manifestPublicUrl, '/'); + } + + public function onKernelRequest(RequestEvent $event): void + { + if (! $event->isMainRequest()) { + return; + } + + $pathInfo = $event->getRequest() + ->getPathInfo(); + if ($pathInfo !== $this->manifestPublicUrl) { + return; + } + + $body = $this->serializer->serialize($this->manifest, 'json', [ + AbstractObjectNormalizer::SKIP_UNINITIALIZED_VALUES => true, + AbstractObjectNormalizer::SKIP_NULL_VALUES => true, + 'json_encode_options' => JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR, + ]); + + $this->profiler?->disable(); + + $response = new Response($body, Response::HTTP_OK, [ + 'Cache-Control' => 'public, max-age=604800, immutable', + 'Content-Type' => 'application/manifest+json', + 'X-Manifest-Dev' => true, + 'Etag' => hash('xxh128', $body), + ]); + + $event->setResponse($response); + $event->stopPropagation(); + } + + public function onKernelResponse(ResponseEvent $event): void + { + if ($event->getResponse()->headers->get('X-Manifest-Dev')) { + $event->stopPropagation(); + } + } + + public static function getSubscribedEvents(): array + { + return [ + // priority higher than RouterListener + KernelEvents::REQUEST => [['onKernelRequest', 35]], + // Highest priority possible to bypass all other listeners + KernelEvents::RESPONSE => [['onKernelResponse', 2048]], + ]; + } +} diff --git a/src/Twig/PwaRuntime.php b/src/Twig/PwaRuntime.php index 514d89c..6d38a51 100644 --- a/src/Twig/PwaRuntime.php +++ b/src/Twig/PwaRuntime.php @@ -4,27 +4,32 @@ namespace SpomkyLabs\PwaBundle\Twig; -use RuntimeException; +use SpomkyLabs\PwaBundle\Dto\Manifest; use Symfony\Component\AssetMapper\AssetMapperInterface; +use Symfony\Component\DependencyInjection\Attribute\Autowire; +use const PHP_EOL; final readonly class PwaRuntime { + private string $manifestPublicUrl; + public function __construct( - private AssetMapperInterface $assetMapper + private AssetMapperInterface $assetMapper, + private Manifest $manifest, + #[Autowire('%spomky_labs_pwa.manifest_public_url%')] + string $manifestPublicUrl, ) { + $this->manifestPublicUrl = '/' . trim($manifestPublicUrl, '/'); } - public function load(string $filename = 'site.webmanifest'): string + public function load(): string { - $url = $this - ->assetMapper - ->getAsset($filename) - ?->publicPath - ; - if ($url === null) { - throw new RuntimeException(sprintf('The asset "%s" is missing.', $filename)); + $url = $this->assetMapper->getPublicPath($this->manifestPublicUrl) ?? $this->manifestPublicUrl; + $output = sprintf('', $url); + if ($this->manifest->themeColor !== null) { + $output .= sprintf('%s', PHP_EOL, $this->manifest->themeColor); } - return sprintf('', $url); + return $output; } } diff --git a/tests/Functional/ManifestFileDevServerTest.php b/tests/Functional/ManifestFileDevServerTest.php new file mode 100644 index 0000000..2479ebc --- /dev/null +++ b/tests/Functional/ManifestFileDevServerTest.php @@ -0,0 +1,28 @@ +request('GET', '/site.manifest'); + + // Then + static::assertResponseIsSuccessful(); + static::assertResponseHeaderSame('Content-Type', 'application/manifest+json'); + } +} diff --git a/tests/Functional/TakeScreenshotCommandTest.php b/tests/Functional/TakeScreenshotCommandTest.php index 9f02f53..7569418 100644 --- a/tests/Functional/TakeScreenshotCommandTest.php +++ b/tests/Functional/TakeScreenshotCommandTest.php @@ -18,12 +18,13 @@ public static function aScreenshotIsCorrectlyTake(): void // Given $command = self::$application->find('pwa:take-screenshot'); $commandTester = new CommandTester($command); - $output = sprintf('%s/samples/screenshots/screenshot-1024x1920.png', self::$kernel->getCacheDir()); + $output = sprintf('%s/samples/screenshots/', self::$kernel->getCacheDir()); // When $commandTester->execute([ - 'url' => 'https://localhost', + 'url' => 'https://symfony.com', 'output' => $output, + 'filename' => 'screenshot', '--width' => '1024', '--height' => '1920', ]);