diff --git a/src/Command/GenerateManifestCommand.php b/src/Command/GenerateManifestCommand.php index 48d506a..0b735ab 100644 --- a/src/Command/GenerateManifestCommand.php +++ b/src/Command/GenerateManifestCommand.php @@ -4,26 +4,16 @@ namespace SpomkyLabs\PwaBundle\Command; -use Facebook\WebDriver\WebDriverDimension; use JsonException; -use RuntimeException; -use SpomkyLabs\PwaBundle\ImageProcessor\ImageProcessor; +use SpomkyLabs\PwaBundle\Command\SectionProcessor\SectionProcessor; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; use Symfony\Component\DependencyInjection\Attribute\Autowire; -use Symfony\Component\Filesystem\Exception\IOExceptionInterface; +use Symfony\Component\DependencyInjection\Attribute\TaggedIterator; use Symfony\Component\Filesystem\Filesystem; -use Symfony\Component\Finder\Finder; -use Symfony\Component\HttpKernel\Config\FileLocator; -use Symfony\Component\Mime\MimeTypes; -use Symfony\Component\Panther\Client; -use Symfony\Component\Routing\RouterInterface; -use function count; -use function dirname; -use function is_array; use function is_int; use const JSON_PRETTY_PRINT; use const JSON_THROW_ON_ERROR; @@ -33,27 +23,18 @@ #[AsCommand(name: 'pwa:build', description: 'Generate the Progressive Web App Manifest')] final class GenerateManifestCommand extends Command { - private readonly MimeTypes $mime; - - private readonly null|Client $webClient; - + /** + * @param iterable $processors + */ public function __construct( - private readonly null|ImageProcessor $imageProcessor, - #[Autowire('@pwa.web_client')] - null|Client $webClient, + #[TaggedIterator('pwa.section-processor')] + private readonly iterable $processors, #[Autowire('%spomky_labs_pwa.config%')] private readonly array $config, #[Autowire('%spomky_labs_pwa.dest%')] private readonly array $dest, private readonly Filesystem $filesystem, - private readonly FileLocator $fileLocator, - private readonly ?RouterInterface $router = null, ) { - if ($webClient === null && class_exists(Client::class)) { - $webClient = Client::createChromeClient(); - } - $this->webClient = $webClient; - $this->mime = MimeTypes::getDefault(); parent::__construct(); } @@ -64,35 +45,17 @@ protected function execute(InputInterface $input, OutputInterface $output): int $manifest = $this->config; $manifest = array_filter($manifest, static fn ($value) => ($value !== null && $value !== [])); - $manifest = $this->processIcons($io, $manifest); - if (! is_array($manifest)) { - return self::FAILURE; - } - $manifest = $this->processScreenshots($io, $manifest); - if (! is_array($manifest)) { - return self::FAILURE; - } - $manifest = $this->processShortcutIcons($io, $manifest); - if (! is_array($manifest)) { - return self::FAILURE; - } - $manifest = $this->processActions($io, $manifest); - if (! is_array($manifest)) { - return self::FAILURE; - } - $manifest = $this->processWidgets($io, $manifest); - if (! is_array($manifest)) { - return self::FAILURE; - } - $manifest = $this->processServiceWorker($io, $manifest); - if (! is_array($manifest)) { - return self::FAILURE; + foreach ($this->processors as $processor) { + $result = $processor->process($io, $this->config, $manifest); + if (is_int($result)) { + return $result; + } + $manifest = $result; } try { - $this->createDirectoryIfNotExists(dirname((string) $this->dest['manifest_filepath'])); if (! $this->filesystem->exists($this->dest['manifest_filepath'])) { - $this->filesystem->remove($this->dest['manifest_filepath']); + $this->filesystem->mkdir($this->dest['manifest_filepath']); } file_put_contents( (string) $this->dest['manifest_filepath'], @@ -108,404 +71,4 @@ protected function execute(InputInterface $input, OutputInterface $output): int return self::SUCCESS; } - - /** - * @param array $components - * @return array{src: string, type: string} - */ - private function storeFile(string $data, string $prefixUrl, string $storageFolder, array $components): array - { - $tempFilename = $this->filesystem->tempnam($storageFolder, 'pwa-'); - $hash = mb_substr(hash('sha256', $data), 0, 8); - file_put_contents($tempFilename, $data); - $mime = $this->mime->guessMimeType($tempFilename); - $extension = $this->mime->getExtensions($mime); - - if (empty($extension)) { - throw new RuntimeException(sprintf('Unable to guess the extension for the mime type "%s"', $mime)); - } - - $components[] = $hash; - $filename = sprintf('%s.%s', implode('-', $components), $extension[0]); - $localFilename = sprintf('%s/%s', rtrim($storageFolder, '/'), $filename); - - file_put_contents($localFilename, $data); - $this->filesystem->remove($tempFilename); - - return [ - 'src' => sprintf('%s/%s', $prefixUrl, $filename), - 'type' => $mime, - ]; - } - - /** - * @return array{src: string, type: string, sizes: string, form_factor: ?string} - */ - private function storeScreenshot(string $data, ?string $format, ?string $formFactor): array - { - if ($format !== null) { - $data = $this->imageProcessor->process($data, null, null, $format); - } - - ['width' => $width, 'height' => $height] = $this->imageProcessor->getSizes($data); - $size = sprintf('%sx%s', $width, $height); - - $fileData = $this->storeFile( - $data, - $this->dest['screenshot_prefix_url'], - $this->dest['screenshot_folder'], - ['screenshot', $formFactor, $size] - ); - if ($formFactor !== null) { - $fileData += [ - 'form_factor' => $formFactor, - ]; - } - - return $fileData + [ - 'sizes' => $size, - ]; - } - - private function handleSizeAndPurpose(?string $purpose, int $size, array $fileData): array - { - $sizes = $size === 0 ? 'any' : $size . 'x' . $size; - $fileData += [ - 'sizes' => $sizes, - ]; - - if ($purpose !== null) { - $fileData += [ - 'purpose' => $purpose, - ]; - } - - return $fileData; - } - - /** - * @return array{src: string, sizes: string, type: string, purpose: ?string} - */ - private function storeIcon(string $data, int $size, ?string $purpose): array - { - $fileData = $this->storeFile( - $data, - $this->dest['icon_prefix_url'], - $this->dest['icon_folder'], - ['icon', $purpose, $size === 0 ? 'any' : $size . 'x' . $size] - ); - - return $this->handleSizeAndPurpose($purpose, $size, $fileData); - } - - /** - * @param array{src: string, sizes: array, format: ?string, purpose: ?string} $icons - */ - private function processIcon(SymfonyStyle $io, array $icons): array|int - { - $result = []; - foreach ($icons as $icon) { - foreach ($icon['sizes'] as $size) { - if (! is_int($size) || $size < 0) { - $io->error('The icon size must be a positive integer'); - return self::FAILURE; - } - $data = $this->loadFileAndConvert($icon['src'], $size, $icon['format'] ?? null); - if ($data === null) { - $io->error(sprintf('Unable to read the icon "%s"', $icon['src'])); - return self::FAILURE; - } - - $iconManifest = $this->storeIcon($data, $size, $icon['purpose'] ?? null); - $result[] = $iconManifest; - } - } - - return $result; - } - - private function processIcons(SymfonyStyle $io, array $manifest): array|int - { - if ($this->config['icons'] === []) { - return $manifest; - } - if (! $this->createDirectoryIfNotExists($this->dest['icon_folder']) || ! $this->checkImageProcessor($io)) { - return self::FAILURE; - } - $manifest['icons'] = $this->processIcon($io, $this->config['icons']); - $io->info('Icons are built'); - - return $manifest; - } - - private function processScreenshot(SymfonyStyle $io, array $screenshots): array|int - { - $config = []; - foreach ($screenshots as $screenshot) { - if (isset($screenshot['src'])) { - $src = $screenshot['src']; - if (! $this->filesystem->exists($src)) { - continue; - } - foreach ($this->findImages($src) as $image) { - $data = $screenshot; - $data['src'] = $image; - $config[] = $data; - } - } - if (isset($screenshot['path'])) { - if ($this->webClient === null) { - $io->error( - 'The web client is not available. Unable to take a screenshot. Please install "symfony/panther" and a web driver.' - ); - return self::FAILURE; - } - $path = $screenshot['path']; - $height = $screenshot['height']; - $width = $screenshot['width']; - unset($screenshot['path'], $screenshot['height'], $screenshot['width']); - - $client = clone $this->webClient; - $client->request('GET', $path); - $tmpName = $this->filesystem->tempnam('', 'pwa-'); - $client->manage() - ->window() - ->setSize(new WebDriverDimension($width, $height)); - $client->manage() - ->window() - ->fullscreen(); - $client->takeScreenshot($tmpName); - $data = $screenshot; - $data['src'] = $tmpName; - $data['delete'] = true; - $config[] = $data; - } - } - - $result = []; - foreach ($config as $screenshot) { - $data = $this->loadFileAndConvert($screenshot['src'], null, $screenshot['format'] ?? null); - if ($data === null) { - $io->error(sprintf('Unable to read the icon "%s"', $screenshot['src'])); - return self::FAILURE; - } - $delete = $screenshot['delete'] ?? false; - unset($screenshot['delete']); - $screenshotManifest = $this->storeScreenshot( - $data, - $screenshot['format'] ?? null, - $screenshot['form_factor'] ?? null - ); - if (isset($screenshot['label'])) { - $screenshotManifest['label'] = $screenshot['label']; - } - if (isset($screenshot['platform'])) { - $screenshotManifest['platform'] = $screenshot['platform']; - } - $result[] = $screenshotManifest; - if ($delete) { - $this->filesystem->remove($screenshot['src']); - } - } - - return $result; - } - - private function processScreenshots(SymfonyStyle $io, array $manifest): array|int - { - if ($this->config['screenshots'] === []) { - return $manifest; - } - if (! $this->createDirectoryIfNotExists($this->dest['screenshot_folder']) || ! $this->checkImageProcessor( - $io - )) { - return self::FAILURE; - } - $manifest['screenshots'] = $this->processScreenshot($io, $this->config['screenshots']); - - return $manifest; - } - - private function processShortcutIcons(SymfonyStyle $io, array|int $manifest): array|int - { - if ($this->config['shortcuts'] === []) { - return $manifest; - } - if (! $this->createDirectoryIfNotExists($this->dest['icon_folder']) || ! $this->checkImageProcessor($io)) { - return self::FAILURE; - } - $manifest['shortcuts'] = []; - foreach ($this->config['shortcuts'] as $shortcutConfig) { - $shortcut = $shortcutConfig; - if (isset($shortcut['icons'])) { - unset($shortcut['icons']); - } - if (! str_starts_with((string) $shortcut['url'], '/')) { - if ($this->router === null) { - $io->error('The router is not available'); - return self::FAILURE; - } - $shortcut['url'] = $this->router->generate($shortcut['url'], [], RouterInterface::RELATIVE_PATH); - } - - if (isset($shortcutConfig['icons'])) { - $shortcut['icons'] = $this->processIcon($io, $shortcutConfig['icons']); - } - $manifest['shortcuts'][] = $shortcut; - } - $manifest['shortcuts'] = array_values($manifest['shortcuts']); - - return $manifest; - } - - private function loadFileAndConvert(string $src, ?int $size, ?string $format): ?string - { - $data = file_get_contents($src); - if ($data === false) { - return null; - } - if ($size !== 0 && $size !== null) { - $data = $this->imageProcessor->process($data, $size, $size, $format); - } - - return $data; - } - - private function checkImageProcessor(SymfonyStyle $io): bool - { - if ($this->imageProcessor === null) { - $io->error('Image processor not found'); - return false; - } - - return true; - } - - private function createDirectoryIfNotExists(string $folder): bool - { - try { - if (! $this->filesystem->exists($folder)) { - $this->filesystem->mkdir($folder); - } - } catch (IOExceptionInterface) { - return false; - } - - return true; - } - - private function processActions(SymfonyStyle $io, array $manifest): array|int - { - if ($this->config['file_handlers'] === []) { - return $manifest; - } - foreach ($manifest['file_handlers'] as $id => $handler) { - if (str_starts_with((string) $handler['action'], '/')) { - continue; - } - if ($this->router === null) { - $io->error('The router is not available. Unable to generate the file handler action URL.'); - return self::FAILURE; - } - $manifest['file_handlers'][$id]['action'] = $this->router->generate( - $handler['action'], - [], - RouterInterface::RELATIVE_PATH - ); - } - - return $manifest; - } - - /** - * @return iterable - */ - private function findImages(string $src): iterable - { - $finder = new Finder(); - if (is_file($src)) { - yield $src; - return; - } - $files = $finder->in($src) - ->files() - ->name('/\.(png|jpg|jpeg|gif|webp|svg)$/i'); - foreach ($files as $file) { - if ($file->isFile()) { - yield $file->getRealPath(); - } else { - yield from $this->findImages($file->getRealPath()); - } - } - } - - private function processServiceWorker(SymfonyStyle $io, array $manifest): int|array - { - if (! isset($manifest['serviceworker'])) { - $io->error('Service worker generation is disabled. Skipping.'); - return $manifest; - } - $generate = $manifest['serviceworker']['generate']; - unset($manifest['serviceworker']['generate']); - - if ($generate !== true) { - $io->info('Service worker generation is disabled. Skipping.'); - return $manifest; - } - - $dest = $manifest['serviceworker']['filepath']; - $scope = $manifest['serviceworker']['scope']; - $src = $manifest['serviceworker']['src']; - unset($manifest['serviceworker']['filepath']); - - if (! $this->filesystem->exists(dirname((string) $dest))) { - $this->filesystem->mkdir(dirname((string) $dest)); - } - if ($this->filesystem->exists($dest)) { - $io->info('Service worker already exists. Skipping.'); - return $manifest; - } - - $resourcePath = $this->fileLocator->locate('@SpomkyLabsPwaBundle/Resources/workbox.js', null, false); - if (count($resourcePath) !== 1) { - $io->error('Unable to find the Workbox resource.'); - return self::FAILURE; - } - $resourcePath = $resourcePath[0]; - $this->filesystem->copy($resourcePath, $dest); - $io->info('Service worker generated.'); - $io->comment('You can now configure your web server to serve the service worker file.'); - $io->section('# assets/app.js (or any other entrypoint)'); - $jsTemplate = << { - navigator.serviceWorker.register("{$src}", {scope: '{$scope}'}); - }) - } - JS; - - $io->writeln($jsTemplate); - $io->section('# End of file'); - - return $manifest; - } - - private function processWidgets(SymfonyStyle $io, array $manifest): int|array - { - if ($this->config['widgets'] === []) { - return $manifest; - } - $manifest['widgets'] = []; - foreach ($this->config['widgets'] as $widget) { - if (isset($widget['icons'])) { - $widget['icons'] = $this->processIcon($io, $widget['icons']); - } - if (isset($widget['screenshots'])) { - $widget['screenshots'] = $this->processScreenshot($io, $widget['screenshots']); - } - $manifest['widgets'][] = $widget; - } - - return $manifest; - } } diff --git a/src/Command/SectionProcessor/ActionsSectionProcessor.php b/src/Command/SectionProcessor/ActionsSectionProcessor.php new file mode 100644 index 0000000..9019bb7 --- /dev/null +++ b/src/Command/SectionProcessor/ActionsSectionProcessor.php @@ -0,0 +1,40 @@ + $handler) { + if (str_starts_with((string) $handler['action'], '/')) { + continue; + } + if ($this->router === null) { + $io->error('The router is not available. Unable to generate the file handler action URL.'); + return Command::FAILURE; + } + $manifest['file_handlers'][$id]['action'] = $this->router->generate( + $handler['action'], + [], + RouterInterface::RELATIVE_PATH + ); + } + + return $manifest; + } +} diff --git a/src/Command/SectionProcessor/ApplicationIconsSectionProcessor.php b/src/Command/SectionProcessor/ApplicationIconsSectionProcessor.php new file mode 100644 index 0000000..887bab7 --- /dev/null +++ b/src/Command/SectionProcessor/ApplicationIconsSectionProcessor.php @@ -0,0 +1,59 @@ +processIcons($io, $config['icons']); + if (is_int($result)) { + return $result; + } + $manifest['icons'] = $result; + $io->info('Icons are built'); + + return $manifest; + } + + protected function getFilesystem(): Filesystem + { + return $this->filesystem; + } + + protected function getIconPrefixUrl(): string + { + return $this->dest['icon_prefix_url']; + } + + protected function getIconFolder(): string + { + return $this->dest['icon_folder']; + } + + protected function getImageProcessor(): ?ImageProcessor + { + return $this->imageProcessor; + } +} diff --git a/src/Command/SectionProcessor/ApplicationScreenshotsSectionProcessor.php b/src/Command/SectionProcessor/ApplicationScreenshotsSectionProcessor.php new file mode 100644 index 0000000..a43a6e9 --- /dev/null +++ b/src/Command/SectionProcessor/ApplicationScreenshotsSectionProcessor.php @@ -0,0 +1,72 @@ +webClient = $webClient; + } + + public function process(SymfonyStyle $io, array $config, array $manifest): array|int + { + if ($config['screenshots'] === []) { + return $manifest; + } + $result = $this->processScreenshots($io, $config['screenshots']); + if (is_int($result)) { + return $result; + } + $manifest['screenshots'] = $result; + + return $manifest; + } + + protected function getFilesystem(): Filesystem + { + return $this->filesystem; + } + + protected function getImageProcessor(): ?ImageProcessor + { + return $this->imageProcessor; + } + + protected function getWebClient(): ?Client + { + return $this->webClient; + } + + protected function getScreenshotPrefixUrl(): string + { + return $this->dest['screenshot_prefix_url']; + } + + protected function getScreenshotFolder(): string + { + return $this->dest['screenshot_folder']; + } +} diff --git a/src/Command/SectionProcessor/FileProcessorTrait.php b/src/Command/SectionProcessor/FileProcessorTrait.php new file mode 100644 index 0000000..9d97159 --- /dev/null +++ b/src/Command/SectionProcessor/FileProcessorTrait.php @@ -0,0 +1,72 @@ +mime)) { + $this->mime = MimeTypes::getDefault(); + } + return $this->mime; + } + + protected function createDirectoryIfNotExists(string $folder): bool + { + try { + if (! $this->getFilesystem()->exists($folder)) { + $this->getFilesystem() + ->mkdir($folder); + } + } catch (IOExceptionInterface) { + return false; + } + + return true; + } + + /** + * @param array $components + * @return array{src: string, type: string} + */ + protected function storeFile(string $data, string $prefixUrl, string $storageFolder, array $components): array + { + $tempFilename = $this->getFilesystem() + ->tempnam($storageFolder, 'pwa-'); + $hash = mb_substr(hash('sha256', $data), 0, 8); + file_put_contents($tempFilename, $data); + $mime = $this->getMime() + ->guessMimeType($tempFilename); + $extension = $this->getMime() + ->getExtensions($mime); + + if (empty($extension)) { + throw new RuntimeException(sprintf('Unable to guess the extension for the mime type "%s"', $mime)); + } + + $components[] = $hash; + $filename = sprintf('%s.%s', implode('-', $components), $extension[0]); + $localFilename = sprintf('%s/%s', rtrim($storageFolder, '/'), $filename); + + file_put_contents($localFilename, $data); + $this->getFilesystem() + ->remove($tempFilename); + + return [ + 'src' => sprintf('%s/%s', $prefixUrl, $filename), + 'type' => $mime, + ]; + } +} diff --git a/src/Command/SectionProcessor/IconsSectionProcessorTrait.php b/src/Command/SectionProcessor/IconsSectionProcessorTrait.php new file mode 100644 index 0000000..c330eff --- /dev/null +++ b/src/Command/SectionProcessor/IconsSectionProcessorTrait.php @@ -0,0 +1,62 @@ +, format: ?string, purpose: ?string} $icons + */ + protected function processIcons(SymfonyStyle $io, array $icons): array|int + { + if (! $this->createDirectoryIfNotExists($this->getIconFolder()) || ! $this->checkImageProcessor($io)) { + return Command::FAILURE; + } + $result = []; + foreach ($icons as $icon) { + foreach ($icon['sizes'] as $size) { + if (! is_int($size) || $size < 0) { + $io->error('The icon size must be a positive integer'); + return Command::FAILURE; + } + $data = $this->loadFileAndConvert($icon['src'], $size, $icon['format'] ?? null); + if ($data === null) { + $io->error(sprintf('Unable to read the icon "%s"', $icon['src'])); + return Command::FAILURE; + } + + $iconManifest = $this->storeIcon($data, $size, $icon['purpose'] ?? null); + $result[] = $iconManifest; + } + } + + return $result; + } + + /** + * @return array{src: string, sizes: string, type: string, purpose: ?string} + */ + private function storeIcon(string $data, int $size, ?string $purpose): array + { + $fileData = $this->storeFile( + $data, + $this->getIconPrefixUrl(), + $this->getIconFolder(), + ['icon', $purpose, $size === 0 ? 'any' : $size . 'x' . $size] + ); + + return $this->handleSizeAndPurpose($purpose, $size, $fileData); + } +} diff --git a/src/Command/SectionProcessor/ImageSectionProcessorTrait.php b/src/Command/SectionProcessor/ImageSectionProcessorTrait.php new file mode 100644 index 0000000..b35e470 --- /dev/null +++ b/src/Command/SectionProcessor/ImageSectionProcessorTrait.php @@ -0,0 +1,55 @@ + $sizes, + ]; + + if ($purpose !== null) { + $fileData += [ + 'purpose' => $purpose, + ]; + } + + return $fileData; + } + + protected function loadFileAndConvert(string $src, ?int $size, ?string $format): ?string + { + $data = file_get_contents($src); + if ($data === false) { + return null; + } + if ($size !== 0 && $size !== null) { + $data = $this->getImageProcessor() + ->process($data, $size, $size, $format); + } + + return $data; + } + + protected function checkImageProcessor(SymfonyStyle $io): bool + { + if ($this->getImageProcessor() === null) { + $io->error('Image processor not found'); + return false; + } + + return true; + } +} diff --git a/src/Command/SectionProcessor/ScreenshotsProcessorTrait.php b/src/Command/SectionProcessor/ScreenshotsProcessorTrait.php new file mode 100644 index 0000000..19a396b --- /dev/null +++ b/src/Command/SectionProcessor/ScreenshotsProcessorTrait.php @@ -0,0 +1,154 @@ +createDirectoryIfNotExists($this->getScreenshotFolder()) || ! $this->checkImageProcessor( + $io + )) { + return Command::FAILURE; + } + $config = []; + foreach ($screenshots as $screenshot) { + if (isset($screenshot['src'])) { + $src = $screenshot['src']; + if (! $this->getFilesystem()->exists($src)) { + continue; + } + foreach ($this->findImages($src) as $image) { + $data = $screenshot; + $data['src'] = $image; + $config[] = $data; + } + } + if (isset($screenshot['path'])) { + if ($this->getWebClient() === null) { + $io->error( + 'The web client is not available. Unable to take a screenshot. Please install "symfony/panther" and a web driver.' + ); + return Command::FAILURE; + } + $path = $screenshot['path']; + $height = $screenshot['height']; + $width = $screenshot['width']; + unset($screenshot['path'], $screenshot['height'], $screenshot['width']); + + $client = clone $this->getWebClient(); + $client->request('GET', $path); + $tmpName = $this->getFilesystem() + ->tempnam('', 'pwa-'); + $client->manage() + ->window() + ->setSize(new WebDriverDimension($width, $height)); + $client->manage() + ->window() + ->fullscreen(); + $client->takeScreenshot($tmpName); + $data = $screenshot; + $data['src'] = $tmpName; + $data['delete'] = true; + $config[] = $data; + } + } + + $result = []; + foreach ($config as $screenshot) { + $data = $this->loadFileAndConvert($screenshot['src'], null, $screenshot['format'] ?? null); + if ($data === null) { + $io->error(sprintf('Unable to read the icon "%s"', $screenshot['src'])); + return Command::FAILURE; + } + $delete = $screenshot['delete'] ?? false; + unset($screenshot['delete']); + $screenshotManifest = $this->storeScreenshot( + $data, + $screenshot['format'] ?? null, + $screenshot['form_factor'] ?? null + ); + if (isset($screenshot['label'])) { + $screenshotManifest['label'] = $screenshot['label']; + } + if (isset($screenshot['platform'])) { + $screenshotManifest['platform'] = $screenshot['platform']; + } + $result[] = $screenshotManifest; + if ($delete) { + $this->getFilesystem() + ->remove($screenshot['src']); + } + } + + return $result; + } + + /** + * @return array{src: string, type: string, sizes: string, form_factor: ?string} + */ + private function storeScreenshot(string $data, ?string $format, ?string $formFactor): array + { + if ($format !== null) { + $data = $this->getImageProcessor() + ->process($data, null, null, $format); + } + + ['width' => $width, 'height' => $height] = $this->getImageProcessor()->getSizes($data); + $size = sprintf('%sx%s', $width, $height); + + $fileData = $this->storeFile( + $data, + $this->getScreenshotPrefixUrl(), + $this->getScreenshotFolder(), + ['screenshot', $formFactor, $size] + ); + if ($formFactor !== null) { + $fileData += [ + 'form_factor' => $formFactor, + ]; + } + + return $fileData + [ + 'sizes' => $size, + ]; + } + + /** + * @return iterable + */ + private function findImages(string $src): iterable + { + $finder = new Finder(); + if (is_file($src)) { + yield $src; + return; + } + $files = $finder->in($src) + ->files() + ->name('/\.(png|jpg|jpeg|gif|webp|svg)$/i'); + foreach ($files as $file) { + if ($file->isFile()) { + yield $file->getRealPath(); + } else { + yield from $this->findImages($file->getRealPath()); + } + } + } +} diff --git a/src/Command/SectionProcessor/SectionProcessor.php b/src/Command/SectionProcessor/SectionProcessor.php new file mode 100644 index 0000000..8d00f3f --- /dev/null +++ b/src/Command/SectionProcessor/SectionProcessor.php @@ -0,0 +1,17 @@ +error('Service worker generation is disabled. Skipping.'); + return $manifest; + } + $generate = $manifest['serviceworker']['generate']; + unset($manifest['serviceworker']['generate']); + + if ($generate !== true) { + $io->info('Service worker generation is disabled. Skipping.'); + return $manifest; + } + + $dest = $manifest['serviceworker']['filepath']; + $scope = $manifest['serviceworker']['scope']; + $src = $manifest['serviceworker']['src']; + unset($manifest['serviceworker']['filepath']); + + if (! $this->filesystem->exists(dirname((string) $dest))) { + $this->filesystem->mkdir(dirname((string) $dest)); + } + if ($this->filesystem->exists($dest)) { + $io->info('Service worker already exists. Skipping.'); + return $manifest; + } + + $resourcePath = $this->fileLocator->locate('@SpomkyLabsPwaBundle/Resources/workbox.js', null, false); + if (count($resourcePath) !== 1) { + $io->error('Unable to find the Workbox resource.'); + return Command::FAILURE; + } + $resourcePath = $resourcePath[0]; + $this->filesystem->copy($resourcePath, $dest); + $io->info('Service worker generated.'); + $io->comment('You can now configure your web server to serve the service worker file.'); + $io->section('# assets/app.js (or any other entrypoint)'); + $jsTemplate = << { + navigator.serviceWorker.register("{$src}", {scope: '{$scope}'}); + }) + } + JS; + + $io->writeln($jsTemplate); + $io->section('# End of file'); + + return $manifest; + } +} diff --git a/src/Command/SectionProcessor/ShortcutsSectionProcessor.php b/src/Command/SectionProcessor/ShortcutsSectionProcessor.php new file mode 100644 index 0000000..389a989 --- /dev/null +++ b/src/Command/SectionProcessor/ShortcutsSectionProcessor.php @@ -0,0 +1,75 @@ +router === null) { + $io->error('The router is not available'); + return Command::FAILURE; + } + $shortcut['url'] = $this->router->generate($shortcut['url'], [], RouterInterface::RELATIVE_PATH); + } + + if (isset($shortcutConfig['icons'])) { + $shortcut['icons'] = $this->processIcons($io, $shortcutConfig['icons']); + } + $manifest['shortcuts'][] = $shortcut; + } + $manifest['shortcuts'] = array_values($manifest['shortcuts']); + + return $manifest; + } + + protected function getFilesystem(): Filesystem + { + return $this->filesystem; + } + + protected function getIconPrefixUrl(): string + { + return $this->dest['icon_prefix_url']; + } + + protected function getIconFolder(): string + { + return $this->dest['icon_folder']; + } + + protected function getImageProcessor(): ?ImageProcessor + { + return $this->imageProcessor; + } +} diff --git a/src/Command/SectionProcessor/Windows10WidgetsSectionProcessor.php b/src/Command/SectionProcessor/Windows10WidgetsSectionProcessor.php new file mode 100644 index 0000000..aa4b92b --- /dev/null +++ b/src/Command/SectionProcessor/Windows10WidgetsSectionProcessor.php @@ -0,0 +1,89 @@ +webClient = $webClient; + } + + public function process(SymfonyStyle $io, array $config, array $manifest): array|int + { + if ($config['widgets'] === []) { + return $manifest; + } + $manifest['widgets'] = []; + foreach ($config['widgets'] as $widget) { + if (isset($widget['icons'])) { + $widget['icons'] = $this->processIcons($io, $widget['icons']); + } + if (isset($widget['screenshots'])) { + $widget['screenshots'] = $this->processScreenshots($io, $widget['screenshots']); + } + $manifest['widgets'][] = $widget; + } + + return $manifest; + } + + protected function getFilesystem(): Filesystem + { + return $this->filesystem; + } + + protected function getIconPrefixUrl(): string + { + return $this->dest['icon_prefix_url']; + } + + protected function getIconFolder(): string + { + return $this->dest['icon_folder']; + } + + protected function getImageProcessor(): ?ImageProcessor + { + return $this->imageProcessor; + } + + protected function getWebClient(): ?Client + { + return $this->webClient; + } + + protected function getScreenshotPrefixUrl(): string + { + return $this->dest['screenshot_prefix_url']; + } + + protected function getScreenshotFolder(): string + { + return $this->dest['screenshot_folder']; + } +} diff --git a/src/Resources/config/services.php b/src/Resources/config/services.php index 4b2f8ef..c678bf5 100644 --- a/src/Resources/config/services.php +++ b/src/Resources/config/services.php @@ -3,6 +3,12 @@ declare(strict_types=1); use SpomkyLabs\PwaBundle\Command\GenerateManifestCommand; +use SpomkyLabs\PwaBundle\Command\SectionProcessor\ActionsSectionProcessor; +use SpomkyLabs\PwaBundle\Command\SectionProcessor\ApplicationIconsSectionProcessor; +use SpomkyLabs\PwaBundle\Command\SectionProcessor\ApplicationScreenshotsSectionProcessor; +use SpomkyLabs\PwaBundle\Command\SectionProcessor\ServiceWorkerSectionProcessor; +use SpomkyLabs\PwaBundle\Command\SectionProcessor\ShortcutsSectionProcessor; +use SpomkyLabs\PwaBundle\Command\SectionProcessor\Windows10WidgetsSectionProcessor; use SpomkyLabs\PwaBundle\ImageProcessor\GDImageProcessor; use SpomkyLabs\PwaBundle\ImageProcessor\ImagickImageProcessor; use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; @@ -15,6 +21,19 @@ ->autowire() ; + $container->set(ApplicationIconsSectionProcessor::class) + ->tag('pwa.section-processor'); + $container->set(ApplicationScreenshotsSectionProcessor::class) + ->tag('pwa.section-processor'); + $container->set(ShortcutsSectionProcessor::class) + ->tag('pwa.section-processor'); + $container->set(ActionsSectionProcessor::class) + ->tag('pwa.section-processor'); + $container->set(Windows10WidgetsSectionProcessor::class) + ->tag('pwa.section-processor'); + $container->set(ServiceWorkerSectionProcessor::class) + ->tag('pwa.section-processor'); + $container->set(GenerateManifestCommand::class); if (extension_loaded('imagick')) {